mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
Merge pull request #38 from iflytek/feature/project-local
feat: strengthen security and evolve skill governance
This commit is contained in:
commit
de87446dd9
102 changed files with 3897 additions and 123 deletions
|
|
@ -95,7 +95,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]*
|
|||
## 4. 一期 MVP 功能
|
||||
|
||||
核心能力:
|
||||
- 技能发布(Phase 2 先直达 `PUBLISHED` 跑通主链路,Phase 3 切回“提交 → 审核 → 上线”)
|
||||
- 技能发布(当前版本采用“提交 → 审核 → 上线”;`SUPER_ADMIN` 保留直发能力)
|
||||
- 技能版本管理(semver + 标签)
|
||||
- 技能浏览、详情、下载(公共技能匿名可访问)
|
||||
- 标签管理(`latest` 系统保留只读 + 自定义标签人工维护)
|
||||
|
|
@ -109,12 +109,12 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]*
|
|||
- 创建技能时选择归属空间
|
||||
|
||||
审核流程:
|
||||
- Phase 2:跳过审核,发布链路直达 `PUBLISHED`
|
||||
- Phase 3 起恢复每版本审核策略
|
||||
- 当前版本:普通用户发布后进入审核,审核通过后上线
|
||||
- `SUPER_ADMIN` 发布可直达 `PUBLISHED`
|
||||
- 分级审核:团队空间由团队管理员审核,全局空间由平台管理员审核
|
||||
- 团队技能提升到全局需平台管理员二次审核
|
||||
- 平台管理员只负责全局空间审核与提升审核,不介入团队空间审核
|
||||
- 一期纯人工审核,架构预留自动预检扩展点(`PrePublishValidator`)
|
||||
- 当前不引入自动审核;`PrePublishValidator` 仅作为未来扩展点保留,默认实现为 `NoOp`
|
||||
|
||||
认证与权限:
|
||||
- OAuth2 标准登录(一期 GitHub OAuth)
|
||||
|
|
@ -136,7 +136,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]*
|
|||
- 评论 → Phase 5 上线,含举报机制
|
||||
- 自动安全扫描 → Phase 5 上线,接入 `PrePublishValidator` 扩展点
|
||||
- 举报/标记机制 → Phase 5 上线,配合评论和治理闭环
|
||||
- 向量搜索 → Phase 3(搜索演进路线)
|
||||
- 向量搜索 → 当前进入第一阶段规划,仅做搜索增强,不引入推荐系统
|
||||
- 在线编辑器 → 暂不规划
|
||||
- Webhook/事件通知 → Phase 5(预留扩展点)
|
||||
- 技能依赖/兼容性声明 → 暂不规划(预留 `parsed_metadata_json` 字段)
|
||||
|
|
|
|||
|
|
@ -107,10 +107,17 @@ PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列,
|
|||
| 阶段 | 实现 | 索引粒度 | 切换方式 |
|
||||
|------|------|---------|---------|
|
||||
| 一期 | PostgreSQL Full-Text (tsvector + GIN) | 每 skill 一条(latest_version_id) | 默认 |
|
||||
| 一点五期 | PostgreSQL Full-Text + 语义向量重排 | 每 skill 一条(latest_version_id) | 配置 `skillhub.search.semantic.enabled=true` |
|
||||
| 二期 | ES / OpenSearch | 每 skill_version 一条 + skill 聚合文档 | 配置 `search.provider=elasticsearch` |
|
||||
| 三期 | 向量检索 | 每 skill_version 多条(chunk 级) | 配置 `search.provider=vector` |
|
||||
| 四期 | 混合排序 | 关键词 + 向量混合 | 配置 `search.provider=hybrid` |
|
||||
|
||||
当前代码实现已落在“一点五期”:
|
||||
- 仍然使用 PostgreSQL 全文搜索作为主召回
|
||||
- 搜索文档表新增 `semantic_vector` 缓存字段
|
||||
- relevance 排序下,对全文候选集追加语义向量重排
|
||||
- 语义向量不可用时自动降级为现有全文相关度排序
|
||||
|
||||
### 5.3 SPI 演进策略
|
||||
|
||||
一期 SPI 接口(`SearchIndexService` / `SearchQueryService`)的入参是 `SkillSearchDocument`(skill 粒度)。二期切换到 ES 时:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
> **设计决策**:一期暂不考虑异步发布(uploadId、publishId、状态轮询、异步转正等)。一期技能包为文本资源包,体积有限(上限 10MB),同步处理足以满足需求。如后续引入大文件或复杂校验流程,再考虑异步模型。
|
||||
|
||||
### 1.1 Phase 2 发布流程基线
|
||||
### 1.1 当前发布流程基线
|
||||
|
||||
```
|
||||
用户提交发布
|
||||
|
|
@ -29,11 +29,11 @@
|
|||
▼
|
||||
④ 持久化数据
|
||||
- 创建或关联 skill 记录(首次发布时创建 skill)
|
||||
- 创建 skill_version(status=PUBLISHED)
|
||||
- 创建 skill_version(普通用户进入 `PENDING_REVIEW`,`SUPER_ADMIN` 直达 `PUBLISHED`)
|
||||
- 创建 skill_file 记录
|
||||
- 解析 SKILL.md frontmatter → parsed_metadata_json
|
||||
- 生成 manifest_json
|
||||
- 更新 skill.latest_version_id
|
||||
- 直发场景更新 skill.latest_version_id
|
||||
│
|
||||
▼
|
||||
⑤ 同步写入审计日志
|
||||
|
|
@ -42,16 +42,11 @@
|
|||
⑥ 异步触发搜索索引写入
|
||||
```
|
||||
|
||||
Phase 2 的目标是先跑通上传、存储、发布、查询、下载完整链路,因此不经过审核,发布结果直接进入 `PUBLISHED`。
|
||||
当前版本采用审核流,不再区分“Phase 2 直发”与“Phase 3 恢复审核”两套现实实现:
|
||||
|
||||
### 1.2 Phase 3 迁移后的发布流程
|
||||
|
||||
Phase 3 在不改变发布入口的前提下,把后半段切换为“创建 DRAFT → 提交审核 → 人工审核 → 发布”:
|
||||
|
||||
- 发布请求先创建 `skill_version(status=DRAFT)`
|
||||
- 提交审核后转为 `PENDING_REVIEW`
|
||||
- 创建 `review_task(status=PENDING)`
|
||||
- 审核通过后才转为 `PUBLISHED`
|
||||
- 普通用户发布请求创建 `skill_version(status=PENDING_REVIEW)`
|
||||
- 同步创建 `review_task(status=PENDING)`
|
||||
- 审核通过后转为 `PUBLISHED`
|
||||
- 审核拒绝后转为 `REJECTED`
|
||||
- 例外:提交人持有 `SUPER_ADMIN` 平台角色时,发布入口直接创建 `skill_version(status=PUBLISHED)`,跳过 `review_task` 创建,同时不再要求其必须是目标 namespace 成员
|
||||
- 上述例外必须对 Web、`/api/v1/publish`、`/api/compat/v1/publish` 保持一致
|
||||
|
|
@ -76,10 +71,9 @@ Parts:
|
|||
|
||||
一期同步响应:服务端同步完成上传、校验、存储、持久化,返回 `200 OK` + skill_version 信息。
|
||||
|
||||
Phase 2 CLI 默认行为:上传 → 直接发布为 `PUBLISHED`。
|
||||
Phase 3 CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核。
|
||||
当前 CLI 默认行为:上传 → 进入审核。
|
||||
如果调用方持有 `SUPER_ADMIN`,则直接发布为 `PUBLISHED`。
|
||||
Web 端可保留“发布后再提交审核”的两段式体验,但这属于 Phase 3 能力。
|
||||
Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确的审核提示。
|
||||
|
||||
`/api/v1/publish` 响应:
|
||||
|
||||
|
|
@ -199,7 +193,7 @@ Web 端可保留“发布后再提交审核”的两段式体验,但这属于
|
|||
| `SkillDownloadedEvent` | 下载完成 | 下载计数 |
|
||||
| `SkillStarredEvent` | 收藏/取消 | 收藏计数 |
|
||||
| `SkillRatedEvent` | 评分提交 | 评分重算 |
|
||||
| `ReviewCompletedEvent` | 审核完成 | 通知提交者(一期可选) |
|
||||
| `ReviewCompletedEvent` | 审核完成 | 预留给后续通知能力(当前可不消费) |
|
||||
| `SkillPromotedEvent` | 提升到全局 | 搜索索引写入(新 skill) |
|
||||
|
||||
一期用 Spring ApplicationEvent + `@Async` 实现,后续可替换为消息队列。
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ Public API 的可见性规则:
|
|||
|
||||
- 普通用户发布成功后,`status` 为 `PENDING_REVIEW`
|
||||
- 持有 `SUPER_ADMIN` 的用户通过 Web、`/api/v1/publish`、`/api/compat/v1/publish` 发布时,`status` 为 `PUBLISHED`,且不要求其必须是目标 namespace 成员
|
||||
- 当前版本保持该审核策略,不再提供“全员直发”的运行模式
|
||||
|
||||
## 7.4 Token API(需登录)
|
||||
|
||||
|
|
@ -254,7 +255,7 @@ Public API 的可见性规则:
|
|||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/whoami` | 当前 Bearer Token 对应的用户信息 |
|
||||
| POST | `/api/v1/publish` | 发布技能包(Phase 2 直接返回 `PUBLISHED`,Phase 3 恢复审核流;`SUPER_ADMIN` 始终直发) |
|
||||
| POST | `/api/v1/publish` | 发布技能包(普通用户进入审核;`SUPER_ADMIN` 始终直发) |
|
||||
| GET | `/api/v1/resolve/{namespace}/{slug}` | 解析版本 |
|
||||
| GET | `/api/v1/check/{namespace}/{slug}/{version}` | 本地哈希与远端比对 |
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
- 命名空间 CRUD + 成员管理
|
||||
- 对象存储集成(LocalFile + S3 双实现)
|
||||
- 技能发布(上传 → 校验 → 存储 → `PUBLISHED`,一期同步处理)
|
||||
- 技能发布(上传 → 校验 → 存储 → 审核 / 上线,一期同步处理)
|
||||
- 技能查询(详情、版本、文件)、下载(打包 + 可见性检查,PUBLIC 匿名可下载)
|
||||
- 标签管理、搜索(PostgreSQL Full-Text,匿名搜索限 PUBLIC)
|
||||
- 异步事件基础设施
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
|
||||
### 验收
|
||||
|
||||
完整发布 → 存储 → 查询 → 下载链路,搜索可用,命名空间隔离生效,匿名用户可浏览/下载公共技能,Phase 2 不经过审核即可完成发布
|
||||
完整发布 → 存储 → 审核 → 查询 → 下载链路可用,搜索可用,命名空间隔离生效,匿名用户可浏览/下载公共技能
|
||||
|
||||
## Phase 3:审核流程 + 评分收藏 + CLI API / ClawHub 兼容层
|
||||
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
|
||||
### 验收
|
||||
|
||||
发布恢复为必须经审核,团队空间自治审核与全局空间平台审核生效,skillhub CLI Device Flow 可用,ClawHub CLI 通过兼容层可完成核心 registry 操作,评分收藏可用
|
||||
团队空间自治审核与全局空间平台审核生效,skillhub CLI Device Flow 可用,ClawHub CLI 通过兼容层可完成核心 registry 操作,评分收藏可用
|
||||
|
||||
## Phase 4:运维增强 + 打磨 + 开源就绪
|
||||
|
||||
|
|
@ -131,9 +131,10 @@
|
|||
|
||||
- 评论功能
|
||||
- 举报/标记机制(用户举报 → 管理员处理 → 隐藏/撤回)
|
||||
- 自动安全预检(`PrePublishValidator` 实现:敏感信息扫描、恶意脚本检测)
|
||||
- 自动安全预检(`PrePublishValidator` 从当前 `NoOp` 扩展为真实校验链)
|
||||
- Webhook/事件通知(发布通知、审核结果通知)
|
||||
- 后续 OAuth Provider 扩展(GitLab、Google 等)
|
||||
- 向量搜索第二阶段增强(当前第一阶段仅做搜索增强,不做推荐)
|
||||
|
||||
## 主要风险与应对
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ import com.iflytek.skillhub.dto.AuthMethodResponse;
|
|||
import com.iflytek.skillhub.dto.AuthProviderResponse;
|
||||
import com.iflytek.skillhub.dto.DirectLoginRequest;
|
||||
import com.iflytek.skillhub.dto.SessionBootstrapRequest;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.service.AuthMethodCatalog;
|
||||
import com.iflytek.skillhub.service.DirectAuthService;
|
||||
import com.iflytek.skillhub.service.SessionBootstrapService;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import com.iflytek.skillhub.security.AuthFailureThrottleService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
|
|
@ -32,15 +35,18 @@ public class AuthController extends BaseApiController {
|
|||
private final AuthMethodCatalog authMethodCatalog;
|
||||
private final SessionBootstrapService sessionBootstrapService;
|
||||
private final DirectAuthService directAuthService;
|
||||
private final AuthFailureThrottleService authFailureThrottleService;
|
||||
|
||||
public AuthController(ApiResponseFactory responseFactory,
|
||||
AuthMethodCatalog authMethodCatalog,
|
||||
SessionBootstrapService sessionBootstrapService,
|
||||
DirectAuthService directAuthService) {
|
||||
DirectAuthService directAuthService,
|
||||
AuthFailureThrottleService authFailureThrottleService) {
|
||||
super(responseFactory);
|
||||
this.authMethodCatalog = authMethodCatalog;
|
||||
this.sessionBootstrapService = sessionBootstrapService;
|
||||
this.directAuthService = directAuthService;
|
||||
this.authFailureThrottleService = authFailureThrottleService;
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
|
|
@ -78,17 +84,42 @@ public class AuthController extends BaseApiController {
|
|||
@RateLimit(category = "auth-direct-login", authenticated = 20, anonymous = 10, windowSeconds = 60)
|
||||
public ApiResponse<AuthMeResponse> directLogin(@Valid @RequestBody DirectLoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
AuthMeResponse.from(
|
||||
directAuthService.authenticate(
|
||||
String category = "direct:" + request.provider();
|
||||
String clientIp = resolveClientIp(httpRequest);
|
||||
authFailureThrottleService.assertAllowed(category, request.username(), clientIp);
|
||||
PlatformPrincipal principal;
|
||||
try {
|
||||
principal = directAuthService.authenticate(
|
||||
request.provider(),
|
||||
request.username(),
|
||||
request.password(),
|
||||
httpRequest
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (AuthFlowException ex) {
|
||||
if (HttpStatus.UNAUTHORIZED.equals(ex.getStatus())) {
|
||||
authFailureThrottleService.recordFailure(category, request.username(), clientIp);
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
authFailureThrottleService.resetIdentifier(category, request.username());
|
||||
return ok(
|
||||
"response.success.read",
|
||||
AuthMeResponse.from(principal)
|
||||
);
|
||||
}
|
||||
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
|
|
@ -12,8 +13,10 @@ import com.iflytek.skillhub.dto.LocalRegisterRequest;
|
|||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import com.iflytek.skillhub.security.AuthFailureThrottleService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
|
@ -27,15 +30,18 @@ public class LocalAuthController extends BaseApiController {
|
|||
private final LocalAuthService localAuthService;
|
||||
private final SkillHubMetrics skillHubMetrics;
|
||||
private final PlatformSessionService platformSessionService;
|
||||
private final AuthFailureThrottleService authFailureThrottleService;
|
||||
|
||||
public LocalAuthController(ApiResponseFactory responseFactory,
|
||||
LocalAuthService localAuthService,
|
||||
SkillHubMetrics skillHubMetrics,
|
||||
PlatformSessionService platformSessionService) {
|
||||
PlatformSessionService platformSessionService,
|
||||
AuthFailureThrottleService authFailureThrottleService) {
|
||||
super(responseFactory);
|
||||
this.localAuthService = localAuthService;
|
||||
this.skillHubMetrics = skillHubMetrics;
|
||||
this.platformSessionService = platformSessionService;
|
||||
this.authFailureThrottleService = authFailureThrottleService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
|
|
@ -52,13 +58,21 @@ public class LocalAuthController extends BaseApiController {
|
|||
@RateLimit(category = "auth-local-login", authenticated = 20, anonymous = 10, windowSeconds = 60)
|
||||
public ApiResponse<AuthMeResponse> login(@Valid @RequestBody LocalLoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
authFailureThrottleService.assertAllowed("local", request.username(), resolveClientIp(httpRequest));
|
||||
PlatformPrincipal principal;
|
||||
try {
|
||||
principal = localAuthService.login(request.username(), request.password());
|
||||
} catch (AuthFlowException ex) {
|
||||
if (HttpStatus.UNAUTHORIZED.equals(ex.getStatus())) {
|
||||
authFailureThrottleService.recordFailure("local", request.username(), resolveClientIp(httpRequest));
|
||||
}
|
||||
skillHubMetrics.recordLocalLogin(false);
|
||||
throw ex;
|
||||
} catch (RuntimeException ex) {
|
||||
skillHubMetrics.recordLocalLogin(false);
|
||||
throw ex;
|
||||
}
|
||||
authFailureThrottleService.resetIdentifier("local", request.username());
|
||||
skillHubMetrics.recordLocalLogin(true);
|
||||
platformSessionService.establishSession(principal, httpRequest);
|
||||
return ok("response.success.read", AuthMeResponse.from(principal));
|
||||
|
|
@ -74,4 +88,18 @@ public class LocalAuthController extends BaseApiController {
|
|||
localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword());
|
||||
return ok("response.success.updated", null);
|
||||
}
|
||||
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
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.SkillReportService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillReportActionRequest;
|
||||
import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillReportMutationResponse;
|
||||
import com.iflytek.skillhub.service.AdminSkillReportAppService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
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.RequestBody;
|
||||
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/admin/skill-reports")
|
||||
public class AdminSkillReportController extends BaseApiController {
|
||||
|
||||
private final AdminSkillReportAppService adminSkillReportAppService;
|
||||
private final SkillReportService skillReportService;
|
||||
|
||||
public AdminSkillReportController(AdminSkillReportAppService adminSkillReportAppService,
|
||||
SkillReportService skillReportService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.adminSkillReportAppService = adminSkillReportAppService;
|
||||
this.skillReportService = skillReportService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
|
||||
public ApiResponse<PageResponse<AdminSkillReportSummaryResponse>> listReports(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ok("response.success", adminSkillReportAppService.listReports(status, page, size));
|
||||
}
|
||||
|
||||
@PostMapping("/{reportId}/resolve")
|
||||
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
|
||||
public ApiResponse<SkillReportMutationResponse> resolveReport(@PathVariable Long reportId,
|
||||
@RequestBody(required = false) AdminSkillReportActionRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest httpRequest) {
|
||||
var report = skillReportService.resolveReport(
|
||||
reportId,
|
||||
principal.userId(),
|
||||
request != null ? request.comment() : null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
|
||||
}
|
||||
|
||||
@PostMapping("/{reportId}/dismiss")
|
||||
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
|
||||
public ApiResponse<SkillReportMutationResponse> dismissReport(@PathVariable Long reportId,
|
||||
@RequestBody(required = false) AdminSkillReportActionRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest httpRequest) {
|
||||
var report = skillReportService.dismissReport(
|
||||
reportId,
|
||||
principal.userId(),
|
||||
request != null ? request.comment() : null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.updated", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import com.iflytek.skillhub.service.AdminAuditLogAppService;
|
|||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/audit-logs")
|
||||
public class AuditLogController extends BaseApiController {
|
||||
|
|
@ -27,7 +29,23 @@ public class AuditLogController extends BaseApiController {
|
|||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam(required = false) String action) {
|
||||
return ok("response.success.read", adminAuditLogAppService.listAuditLogs(page, size, userId, action));
|
||||
@RequestParam(required = false) String action,
|
||||
@RequestParam(required = false) String requestId,
|
||||
@RequestParam(required = false) String ipAddress,
|
||||
@RequestParam(required = false) String resourceType,
|
||||
@RequestParam(required = false) String resourceId,
|
||||
@RequestParam(required = false) Instant startTime,
|
||||
@RequestParam(required = false) Instant endTime) {
|
||||
return ok("response.success.read", adminAuditLogAppService.listAuditLogs(
|
||||
page,
|
||||
size,
|
||||
userId,
|
||||
action,
|
||||
requestId,
|
||||
ipAddress,
|
||||
resourceType,
|
||||
resourceId,
|
||||
startTime,
|
||||
endTime));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ public class SkillController extends BaseApiController {
|
|||
detail.ratingCount(),
|
||||
detail.hidden(),
|
||||
detail.latestVersion(),
|
||||
namespace
|
||||
namespace,
|
||||
detail.canManageLifecycle()
|
||||
);
|
||||
|
||||
return ok("response.success.read", response);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
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.shared.exception.DomainBadRequestException;
|
||||
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.service.SkillGovernanceService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillLifecycleController extends BaseApiController {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillGovernanceService skillGovernanceService;
|
||||
|
||||
public SkillLifecycleController(NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillGovernanceService skillGovernanceService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillGovernanceService = skillGovernanceService;
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/archive")
|
||||
public ApiResponse<SkillLifecycleMutationResponse> archiveSkill(@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@RequestBody(required = false) AdminSkillActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
Skill archived = skillGovernanceService.archiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent"),
|
||||
request != null ? request.reason() : null
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(archived.getId(), null, "ARCHIVE", archived.getStatus().name()));
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/unarchive")
|
||||
public ApiResponse<SkillLifecycleMutationResponse> unarchiveSkill(@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
Skill restored = skillGovernanceService.unarchiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
|
||||
return ok("response.success.updated",
|
||||
new SkillLifecycleMutationResponse(restored.getId(), null, "UNARCHIVE", restored.getStatus().name()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{namespace}/{slug}/versions/{version}")
|
||||
public ApiResponse<SkillLifecycleMutationResponse> deleteVersion(@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@PathVariable String version,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
skillGovernanceService.deleteVersion(
|
||||
skill,
|
||||
skillVersion,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
|
||||
return ok("response.success.deleted",
|
||||
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version));
|
||||
}
|
||||
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug) {
|
||||
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
|
||||
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
|
||||
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportService;
|
||||
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.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.SkillReportMutationResponse;
|
||||
import com.iflytek.skillhub.dto.SkillReportSubmitRequest;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillReportController extends BaseApiController {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillReportService skillReportService;
|
||||
|
||||
public SkillReportController(NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillReportService skillReportService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillReportService = skillReportService;
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/reports")
|
||||
public ApiResponse<SkillReportMutationResponse> submitReport(@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@RequestBody SkillReportSubmitRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
var report = skillReportService.submitReport(
|
||||
skill.getId(),
|
||||
userId,
|
||||
request.reason(),
|
||||
request.details(),
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
);
|
||||
return ok("response.success.created", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
|
||||
}
|
||||
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug) {
|
||||
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
|
||||
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
|
||||
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminSkillReportActionRequest(
|
||||
String comment
|
||||
) {}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record AdminSkillReportSummaryResponse(
|
||||
Long id,
|
||||
Long skillId,
|
||||
String namespace,
|
||||
String skillSlug,
|
||||
String skillDisplayName,
|
||||
String reporterId,
|
||||
String reason,
|
||||
String details,
|
||||
String status,
|
||||
String handledBy,
|
||||
String handleComment,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime handledAt
|
||||
) {}
|
||||
|
|
@ -9,6 +9,9 @@ public record AuditLogItemResponse(
|
|||
String username,
|
||||
String details,
|
||||
String ipAddress,
|
||||
String requestId,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
Instant timestamp
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ public record SkillDetailResponse(
|
|||
Integer ratingCount,
|
||||
boolean hidden,
|
||||
String latestVersion,
|
||||
String namespace
|
||||
String namespace,
|
||||
boolean canManageLifecycle
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillLifecycleMutationResponse(
|
||||
Long skillId,
|
||||
Long versionId,
|
||||
String action,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillReportMutationResponse(
|
||||
Long reportId,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillReportSubmitRequest(
|
||||
String reason,
|
||||
String details
|
||||
) {}
|
||||
|
|
@ -8,6 +8,7 @@ public record SkillSummaryResponse(
|
|||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
String status,
|
||||
Long downloadCount,
|
||||
Integer starCount,
|
||||
BigDecimal ratingAvg,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
|
|||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
|
|
@ -24,9 +26,12 @@ public class GlobalExceptionHandler {
|
|||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
private final ApiResponseFactory apiResponseFactory;
|
||||
private final SensitiveLogSanitizer sensitiveLogSanitizer;
|
||||
|
||||
public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory) {
|
||||
public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory,
|
||||
SensitiveLogSanitizer sensitiveLogSanitizer) {
|
||||
this.apiResponseFactory = apiResponseFactory;
|
||||
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
|
||||
}
|
||||
|
||||
@ExceptionHandler(LocalizedException.class)
|
||||
|
|
@ -96,13 +101,20 @@ public class GlobalExceptionHandler {
|
|||
apiResponseFactory.error(403, "error.forbidden"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
|
||||
apiResponseFactory.error(403, "error.forbidden"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex, HttpServletRequest request) {
|
||||
logger.error(
|
||||
"Unhandled API exception [requestId={}, method={}, path={}, userId={}]",
|
||||
MDC.get("requestId"),
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
sensitiveLogSanitizer.sanitizeRequestTarget(request),
|
||||
resolveUserId(request),
|
||||
ex
|
||||
);
|
||||
|
|
@ -116,7 +128,7 @@ public class GlobalExceptionHandler {
|
|||
MDC.get("requestId"),
|
||||
status.value(),
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
sensitiveLogSanitizer.sanitizeRequestTarget(request),
|
||||
resolveUserId(request),
|
||||
messageCode
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,10 +21,14 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
|
|||
private static final Logger logger = LoggerFactory.getLogger(ApiAccessDeniedHandler.class);
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiResponseFactory apiResponseFactory;
|
||||
private final SensitiveLogSanitizer sensitiveLogSanitizer;
|
||||
|
||||
public ApiAccessDeniedHandler(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) {
|
||||
public ApiAccessDeniedHandler(ObjectMapper objectMapper,
|
||||
ApiResponseFactory apiResponseFactory,
|
||||
SensitiveLogSanitizer sensitiveLogSanitizer) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.apiResponseFactory = apiResponseFactory;
|
||||
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -35,7 +39,7 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
|
|||
"Forbidden API request [requestId={}, method={}, path={}, reason={}]",
|
||||
MDC.get("requestId"),
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
sensitiveLogSanitizer.sanitizeRequestTarget(request),
|
||||
accessDeniedException.getClass().getSimpleName()
|
||||
);
|
||||
ApiResponse<Void> body = apiResponseFactory.error(403, "error.forbidden");
|
||||
|
|
|
|||
|
|
@ -21,10 +21,14 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
|||
private static final Logger logger = LoggerFactory.getLogger(ApiAuthenticationEntryPoint.class);
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiResponseFactory apiResponseFactory;
|
||||
private final SensitiveLogSanitizer sensitiveLogSanitizer;
|
||||
|
||||
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) {
|
||||
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper,
|
||||
ApiResponseFactory apiResponseFactory,
|
||||
SensitiveLogSanitizer sensitiveLogSanitizer) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.apiResponseFactory = apiResponseFactory;
|
||||
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -35,7 +39,7 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
|||
"Unauthorized API request [requestId={}, method={}, path={}, reason={}]",
|
||||
MDC.get("requestId"),
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
sensitiveLogSanitizer.sanitizeRequestTarget(request),
|
||||
authException.getClass().getSimpleName()
|
||||
);
|
||||
ApiResponse<Void> body = apiResponseFactory.error(401, "error.auth.required");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
package com.iflytek.skillhub.security;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class AuthFailureThrottleService {
|
||||
|
||||
private static final Duration WINDOW = Duration.ofMinutes(15);
|
||||
private static final int IDENTIFIER_LIMIT = 8;
|
||||
private static final int IP_LIMIT = 30;
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public AuthFailureThrottleService(StringRedisTemplate redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
public void assertAllowed(String category, String identifier, String clientIp) {
|
||||
if (isLimited(identifierKey(category, identifier), IDENTIFIER_LIMIT)
|
||||
|| isLimited(ipKey(category, clientIp), IP_LIMIT)) {
|
||||
throw new AuthFlowException(HttpStatus.TOO_MANY_REQUESTS, "error.auth.login.throttled", remainingMinutes(category, identifier, clientIp));
|
||||
}
|
||||
}
|
||||
|
||||
public void recordFailure(String category, String identifier, String clientIp) {
|
||||
increment(identifierKey(category, identifier));
|
||||
increment(ipKey(category, clientIp));
|
||||
}
|
||||
|
||||
public void resetIdentifier(String category, String identifier) {
|
||||
String key = identifierKey(category, identifier);
|
||||
if (key != null) {
|
||||
redisTemplate.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLimited(String key, int limit) {
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
String value = redisTemplate.opsForValue().get(key);
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(value) >= limit;
|
||||
} catch (NumberFormatException ignored) {
|
||||
redisTemplate.delete(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void increment(String key) {
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
Long count = redisTemplate.opsForValue().increment(key);
|
||||
if (count != null && count == 1L) {
|
||||
redisTemplate.expire(key, WINDOW);
|
||||
}
|
||||
}
|
||||
|
||||
private long remainingMinutes(String category, String identifier, String clientIp) {
|
||||
long identifierMinutes = remainingMinutes(identifierKey(category, identifier));
|
||||
long ipMinutes = remainingMinutes(ipKey(category, clientIp));
|
||||
return Math.max(1, Math.max(identifierMinutes, ipMinutes));
|
||||
}
|
||||
|
||||
private long remainingMinutes(String key) {
|
||||
if (key == null) {
|
||||
return 1;
|
||||
}
|
||||
Long seconds = redisTemplate.getExpire(key);
|
||||
if (seconds == null || seconds <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, (seconds + 59) / 60);
|
||||
}
|
||||
|
||||
private String identifierKey(String category, String identifier) {
|
||||
if (!StringUtils.hasText(identifier)) {
|
||||
return null;
|
||||
}
|
||||
return "auth-failure:" + category + ":id:" + identifier.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String ipKey(String category, String clientIp) {
|
||||
if (!StringUtils.hasText(clientIp)) {
|
||||
return null;
|
||||
}
|
||||
return "auth-failure:" + category + ":ip:" + clientIp.trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.iflytek.skillhub.security;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Component
|
||||
public class SensitiveLogSanitizer {
|
||||
|
||||
private static final Set<String> SENSITIVE_KEYS = Set.of(
|
||||
"password", "passwd", "pwd", "token", "authorization", "cookie",
|
||||
"secret", "api_key", "apikey", "access_key", "refresh_token", "code");
|
||||
|
||||
public String sanitizeRequestTarget(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
String query = request.getQueryString();
|
||||
if (!StringUtils.hasText(query)) {
|
||||
return uri;
|
||||
}
|
||||
return uri + "?" + sanitizeQuery(query);
|
||||
}
|
||||
|
||||
String sanitizeQuery(String query) {
|
||||
return Arrays.stream(query.split("&"))
|
||||
.map(this::sanitizeQueryPart)
|
||||
.collect(Collectors.joining("&"));
|
||||
}
|
||||
|
||||
private String sanitizeQueryPart(String queryPart) {
|
||||
int idx = queryPart.indexOf('=');
|
||||
if (idx < 0) {
|
||||
return queryPart;
|
||||
}
|
||||
String key = queryPart.substring(0, idx);
|
||||
String normalizedKey = key.trim().toLowerCase(Locale.ROOT);
|
||||
if (SENSITIVE_KEYS.contains(normalizedKey)) {
|
||||
return key + "=[REDACTED]";
|
||||
}
|
||||
return queryPart;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,12 +22,30 @@ public class AdminAuditLogAppService {
|
|||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<AuditLogItemResponse> listAuditLogs(int page, int size, String userId, String action) {
|
||||
public PageResponse<AuditLogItemResponse> listAuditLogs(int page,
|
||||
int size,
|
||||
String userId,
|
||||
String action,
|
||||
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);
|
||||
|
||||
String whereClause = buildWhereClause(parameters, userId, action);
|
||||
String whereClause = buildWhereClause(
|
||||
parameters,
|
||||
userId,
|
||||
action,
|
||||
requestId,
|
||||
ipAddress,
|
||||
resourceType,
|
||||
resourceId,
|
||||
startTime,
|
||||
endTime);
|
||||
Long total = namedParameterJdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM audit_log al" + whereClause,
|
||||
parameters,
|
||||
|
|
@ -43,6 +61,7 @@ public class AdminAuditLogAppService {
|
|||
al.detail_json,
|
||||
al.target_type,
|
||||
al.target_id,
|
||||
al.request_id,
|
||||
al.client_ip,
|
||||
al.created_at
|
||||
FROM audit_log al
|
||||
|
|
@ -62,13 +81,24 @@ public class AdminAuditLogAppService {
|
|||
rs.getString("target_type"),
|
||||
rs.getObject("target_id")),
|
||||
rs.getString("client_ip"),
|
||||
rs.getString("request_id"),
|
||||
rs.getString("target_type"),
|
||||
toResourceId(rs.getObject("target_id")),
|
||||
toInstant(rs.getTimestamp("created_at")))
|
||||
);
|
||||
|
||||
return new PageResponse<>(items, total == null ? 0 : total, page, size);
|
||||
}
|
||||
|
||||
private String buildWhereClause(MapSqlParameterSource parameters, String userId, String action) {
|
||||
private String buildWhereClause(MapSqlParameterSource parameters,
|
||||
String userId,
|
||||
String action,
|
||||
String requestId,
|
||||
String ipAddress,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
Instant startTime,
|
||||
Instant endTime) {
|
||||
StringBuilder clause = new StringBuilder(" WHERE 1 = 1");
|
||||
if (StringUtils.hasText(userId)) {
|
||||
clause.append(" AND al.actor_user_id = :userId");
|
||||
|
|
@ -78,6 +108,30 @@ public class AdminAuditLogAppService {
|
|||
clause.append(" AND al.action = :action");
|
||||
parameters.addValue("action", action.trim());
|
||||
}
|
||||
if (StringUtils.hasText(requestId)) {
|
||||
clause.append(" AND al.request_id = :requestId");
|
||||
parameters.addValue("requestId", requestId.trim());
|
||||
}
|
||||
if (StringUtils.hasText(ipAddress)) {
|
||||
clause.append(" AND al.client_ip = :ipAddress");
|
||||
parameters.addValue("ipAddress", ipAddress.trim());
|
||||
}
|
||||
if (StringUtils.hasText(resourceType)) {
|
||||
clause.append(" AND al.target_type = :resourceType");
|
||||
parameters.addValue("resourceType", resourceType.trim());
|
||||
}
|
||||
if (StringUtils.hasText(resourceId)) {
|
||||
clause.append(" AND CAST(al.target_id AS TEXT) = :resourceId");
|
||||
parameters.addValue("resourceId", resourceId.trim());
|
||||
}
|
||||
if (startTime != null) {
|
||||
clause.append(" AND al.created_at >= :startTime");
|
||||
parameters.addValue("startTime", Timestamp.from(startTime));
|
||||
}
|
||||
if (endTime != null) {
|
||||
clause.append(" AND al.created_at <= :endTime");
|
||||
parameters.addValue("endTime", Timestamp.from(endTime));
|
||||
}
|
||||
return clause.toString();
|
||||
}
|
||||
|
||||
|
|
@ -94,4 +148,8 @@ public class AdminAuditLogAppService {
|
|||
private Instant toInstant(Timestamp timestamp) {
|
||||
return timestamp == null ? null : timestamp.toInstant();
|
||||
}
|
||||
|
||||
private String toResourceId(Object targetId) {
|
||||
return targetId == null ? null : String.valueOf(targetId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
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.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AdminSkillReportAppService {
|
||||
|
||||
private final SkillReportRepository skillReportRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
|
||||
public AdminSkillReportAppService(SkillReportRepository skillReportRepository,
|
||||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository) {
|
||||
this.skillReportRepository = skillReportRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
}
|
||||
|
||||
public PageResponse<AdminSkillReportSummaryResponse> listReports(String status, int page, int size) {
|
||||
SkillReportStatus resolvedStatus = parseStatus(status);
|
||||
var reportPage = skillReportRepository.findByStatus(resolvedStatus, PageRequest.of(page, size));
|
||||
|
||||
List<Long> skillIds = reportPage.getContent().stream().map(SkillReport::getSkillId).distinct().toList();
|
||||
Map<Long, Skill> skillsById = skillIds.isEmpty()
|
||||
? Map.of()
|
||||
: skillRepository.findByIdIn(skillIds).stream().collect(Collectors.toMap(Skill::getId, Function.identity()));
|
||||
|
||||
List<Long> namespaceIds = skillsById.values().stream().map(Skill::getNamespaceId).distinct().toList();
|
||||
Map<Long, String> namespaceSlugs = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream().collect(Collectors.toMap(Namespace::getId, Namespace::getSlug));
|
||||
|
||||
List<AdminSkillReportSummaryResponse> items = reportPage.getContent().stream()
|
||||
.map(report -> toResponse(report, skillsById.get(report.getSkillId()), namespaceSlugs))
|
||||
.toList();
|
||||
|
||||
return new PageResponse<>(items, reportPage.getTotalElements(), reportPage.getNumber(), reportPage.getSize());
|
||||
}
|
||||
|
||||
private AdminSkillReportSummaryResponse toResponse(SkillReport report,
|
||||
Skill skill,
|
||||
Map<Long, String> namespaceSlugs) {
|
||||
return new AdminSkillReportSummaryResponse(
|
||||
report.getId(),
|
||||
report.getSkillId(),
|
||||
skill != null ? namespaceSlugs.get(skill.getNamespaceId()) : null,
|
||||
skill != null ? skill.getSlug() : null,
|
||||
skill != null ? skill.getDisplayName() : null,
|
||||
report.getReporterId(),
|
||||
report.getReason(),
|
||||
report.getDetails(),
|
||||
report.getStatus().name(),
|
||||
report.getHandledBy(),
|
||||
report.getHandleComment(),
|
||||
report.getCreatedAt(),
|
||||
report.getHandledAt()
|
||||
);
|
||||
}
|
||||
|
||||
private SkillReportStatus parseStatus(String status) {
|
||||
if (status == null || status.isBlank()) {
|
||||
return SkillReportStatus.PENDING;
|
||||
}
|
||||
try {
|
||||
return SkillReportStatus.valueOf(status.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new DomainBadRequestException("error.skill.report.status.invalid", status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +124,7 @@ public class MySkillAppService {
|
|||
skill.getSlug(),
|
||||
skill.getDisplayName(),
|
||||
skill.getSummary(),
|
||||
skill.getStatus().name(),
|
||||
skill.getDownloadCount(),
|
||||
skill.getStarCount(),
|
||||
skill.getRatingAvg(),
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ public class SkillSearchAppService {
|
|||
skill.getSlug(),
|
||||
skill.getDisplayName(),
|
||||
skill.getSummary(),
|
||||
skill.getStatus().name(),
|
||||
skill.getDownloadCount(),
|
||||
skill.getStarCount(),
|
||||
skill.getRatingAvg(),
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ skillhub:
|
|||
search:
|
||||
engine: postgres
|
||||
rebuild-on-startup: false
|
||||
semantic:
|
||||
enabled: true
|
||||
weight: 0.35
|
||||
candidate-multiplier: 8
|
||||
max-candidates: 120
|
||||
publish:
|
||||
max-file-count: 100
|
||||
max-single-file-size: 1048576 # 1MB
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
CREATE TABLE skill_report (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE,
|
||||
namespace_id BIGINT NOT NULL REFERENCES namespace(id) ON DELETE CASCADE,
|
||||
reporter_id VARCHAR(128) NOT NULL,
|
||||
reason VARCHAR(200) NOT NULL,
|
||||
details TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
handled_by VARCHAR(128),
|
||||
handle_comment TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
handled_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_skill_report_status_created_at ON skill_report(status, created_at DESC);
|
||||
CREATE INDEX idx_skill_report_skill_id ON skill_report(skill_id);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE skill_search_document
|
||||
ADD COLUMN semantic_vector TEXT;
|
||||
|
|
@ -39,6 +39,7 @@ error.auth.local.accountDisabled=This account has been disabled
|
|||
error.auth.local.accountPending=This account is pending activation
|
||||
error.auth.local.accountMerged=This account has been merged and can no longer be used to log in
|
||||
error.auth.local.locked=Too many failed attempts. Please try again in {0} minute(s)
|
||||
error.auth.login.throttled=Too many login attempts. Please try again in {0} minute(s)
|
||||
error.auth.direct.disabled=Direct authentication compatibility is disabled
|
||||
error.auth.direct.providerUnsupported=Unsupported direct authentication provider: {0}
|
||||
error.auth.sessionBootstrap.disabled=Session bootstrap is disabled
|
||||
|
|
@ -77,13 +78,23 @@ error.skill.publish.publisher.notMember=Publisher is not a member of namespace:
|
|||
error.skill.publish.package.invalid=Package validation failed: {0}
|
||||
error.skill.publish.skillMd.notFound=SKILL.md not found
|
||||
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
|
||||
error.skill.publish.archived=Archived skill must be restored before publishing: {0}
|
||||
error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters
|
||||
error.skill.notFound=Skill not found: {0}
|
||||
error.skill.access.denied=Access denied to skill: {0}
|
||||
error.skill.status.notActive=Skill is not active
|
||||
error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can manage this skill
|
||||
error.skill.version.exists=Version already exists: {0}
|
||||
error.skill.version.notFound=Version not found: {0}
|
||||
error.skill.version.notPublished=Version is not published: {0}
|
||||
error.skill.version.delete.unsupported=Only DRAFT or REJECTED versions can be deleted: {0}
|
||||
error.skill.report.reason.required=Please provide a report reason
|
||||
error.skill.report.unavailable=This skill cannot be reported right now: {0}
|
||||
error.skill.report.self=You cannot report your own skill
|
||||
error.skill.report.duplicate=You already have a pending report for this skill
|
||||
error.skill.report.notFound=Skill report not found: {0}
|
||||
error.skill.report.alreadyHandled=This skill report has already been handled
|
||||
error.skill.report.status.invalid=Unsupported skill report status: {0}
|
||||
error.skill.version.latest.unavailable=No published version available for skill: {0}
|
||||
error.skill.version.latest.notFound=Latest published version not found
|
||||
error.skill.file.notFound=File not found: {0}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ error.auth.local.accountDisabled=该账号已被禁用
|
|||
error.auth.local.accountPending=该账号尚未激活
|
||||
error.auth.local.accountMerged=该账号已合并,不能再用于登录
|
||||
error.auth.local.locked=连续失败次数过多,请在 {0} 分钟后重试
|
||||
error.auth.login.throttled=登录尝试过于频繁,请在 {0} 分钟后重试
|
||||
error.auth.direct.disabled=直连认证兼容层未启用
|
||||
error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0}
|
||||
error.auth.sessionBootstrap.disabled=会话引导能力未启用
|
||||
|
|
@ -77,13 +78,23 @@ error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0}
|
|||
error.skill.publish.package.invalid=技能包校验失败:{0}
|
||||
error.skill.publish.skillMd.notFound=未找到 SKILL.md
|
||||
error.skill.publish.precheck.failed=预发布校验失败:{0}
|
||||
error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0}
|
||||
error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符
|
||||
error.skill.notFound=未找到技能:{0}
|
||||
error.skill.access.denied=没有权限访问技能:{0}
|
||||
error.skill.status.notActive=技能未处于 ACTIVE 状态
|
||||
error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员可以管理该技能
|
||||
error.skill.version.exists=版本已存在:{0}
|
||||
error.skill.version.notFound=未找到版本:{0}
|
||||
error.skill.version.notPublished=版本未发布:{0}
|
||||
error.skill.version.delete.unsupported=只有 DRAFT 或 REJECTED 版本可以删除:{0}
|
||||
error.skill.report.reason.required=请填写举报原因
|
||||
error.skill.report.unavailable=当前无法举报该技能:{0}
|
||||
error.skill.report.self=不能举报自己发布的技能
|
||||
error.skill.report.duplicate=你已经提交过该技能的待处理举报
|
||||
error.skill.report.notFound=未找到技能举报:{0}
|
||||
error.skill.report.alreadyHandled=该技能举报已经处理过
|
||||
error.skill.report.status.invalid=不支持的技能举报状态:{0}
|
||||
error.skill.version.latest.unavailable=技能没有可下载的已发布版本:{0}
|
||||
error.skill.version.latest.notFound=未找到最新已发布版本
|
||||
error.skill.file.notFound=未找到文件:{0}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class ClawHubCompatControllerTest {
|
|||
"my-skill",
|
||||
"My Skill",
|
||||
"test summary",
|
||||
"ACTIVE",
|
||||
10L,
|
||||
5,
|
||||
BigDecimal.valueOf(4.5),
|
||||
|
|
@ -69,14 +70,13 @@ class ClawHubCompatControllerTest {
|
|||
20
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/compat/v1/search")
|
||||
mockMvc.perform(get("/api/v1/search")
|
||||
.param("q", "test"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items").isArray())
|
||||
.andExpect(jsonPath("$.items[0].canonicalSlug").value("my-skill"))
|
||||
.andExpect(jsonPath("$.items[0].description").value("test summary"))
|
||||
.andExpect(jsonPath("$.items[0].latestVersion").value("1.2.0"))
|
||||
.andExpect(jsonPath("$.items[0].starCount").value(5));
|
||||
.andExpect(jsonPath("$.results").isArray())
|
||||
.andExpect(jsonPath("$.results[0].slug").value("my-skill"))
|
||||
.andExpect(jsonPath("$.results[0].summary").value("test summary"))
|
||||
.andExpect(jsonPath("$.results[0].version").value("1.2.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -84,11 +84,10 @@ class ClawHubCompatControllerTest {
|
|||
when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
|
||||
mockMvc.perform(get("/api/compat/v1/resolve/my-skill"))
|
||||
mockMvc.perform(get("/api/v1/resolve/my-skill"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.canonicalSlug").value("my-skill"))
|
||||
.andExpect(jsonPath("$.version").value("latest"))
|
||||
.andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/global/my-skill/download"));
|
||||
.andExpect(jsonPath("$.match.version").value("latest"))
|
||||
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -96,11 +95,10 @@ class ClawHubCompatControllerTest {
|
|||
when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download"));
|
||||
mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill"))
|
||||
mockMvc.perform(get("/api/v1/resolve/team-ai--my-skill"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill"))
|
||||
.andExpect(jsonPath("$.version").value("latest"))
|
||||
.andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/team-ai/my-skill/download"));
|
||||
.andExpect(jsonPath("$.match.version").value("latest"))
|
||||
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -108,12 +106,11 @@ class ClawHubCompatControllerTest {
|
|||
when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "global", "my-skill", "1.0.0", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
|
||||
mockMvc.perform(get("/api/compat/v1/resolve/my-skill")
|
||||
mockMvc.perform(get("/api/v1/resolve/my-skill")
|
||||
.param("version", "1.0.0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.canonicalSlug").value("my-skill"))
|
||||
.andExpect(jsonPath("$.version").value("1.0.0"))
|
||||
.andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/global/my-skill/download"));
|
||||
.andExpect(jsonPath("$.match.version").value("1.0.0"))
|
||||
.andExpect(jsonPath("$.latestVersion.version").value("1.0.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -132,12 +129,12 @@ class ClawHubCompatControllerTest {
|
|||
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
|
||||
mockMvc.perform(get("/api/compat/v1/whoami")
|
||||
mockMvc.perform(get("/api/v1/whoami")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.userId").value("user-42"))
|
||||
.andExpect(jsonPath("$.displayName").value("tester"))
|
||||
.andExpect(jsonPath("$.email").value("tester@example.com"));
|
||||
.andExpect(jsonPath("$.user.handle").value("user-42"))
|
||||
.andExpect(jsonPath("$.user.displayName").value("tester"))
|
||||
.andExpect(jsonPath("$.user.image").value("https://example.com/avatar.png"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller;
|
|||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.security.AuthFailureThrottleService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
|
|
@ -51,6 +52,9 @@ class AuthControllerTest {
|
|||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private AuthFailureThrottleService authFailureThrottleService;
|
||||
|
||||
@Test
|
||||
void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/auth/me"))
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimiter;
|
||||
import com.iflytek.skillhub.security.AuthFailureThrottleService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
|
|
@ -43,6 +46,9 @@ class AuthRateLimitControllerTest {
|
|||
@MockBean
|
||||
private RateLimiter rateLimiter;
|
||||
|
||||
@MockBean
|
||||
private AuthFailureThrottleService authFailureThrottleService;
|
||||
|
||||
@Test
|
||||
void localLoginShouldReturnTooManyRequestsWhenRateLimitIsExceeded() throws Exception {
|
||||
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(false);
|
||||
|
|
@ -59,4 +65,45 @@ class AuthRateLimitControllerTest {
|
|||
|
||||
verify(localAuthService, never()).login(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void localLoginShouldRecordCredentialFailuresForBruteForceTracking() throws Exception {
|
||||
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true);
|
||||
given(localAuthService.login("alice", "wrong"))
|
||||
.willThrow(new AuthFlowException(org.springframework.http.HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/local/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"username":"alice","password":"wrong"}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
verify(authFailureThrottleService).assertAllowed("local", "alice", "127.0.0.1");
|
||||
verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void localLoginShouldResetIdentifierThrottleAfterSuccess() throws Exception {
|
||||
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true);
|
||||
given(localAuthService.login("alice", "correct")).willReturn(new PlatformPrincipal(
|
||||
"usr_1",
|
||||
"alice",
|
||||
"alice@example.com",
|
||||
"",
|
||||
"local",
|
||||
java.util.Set.of("USER")
|
||||
));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/local/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"username":"alice","password":"correct"}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(authFailureThrottleService).resetIdentifier("local", "alice");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.security.AuthFailureThrottleService;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -39,6 +40,9 @@ class DirectAuthControllerTest {
|
|||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private AuthFailureThrottleService authFailureThrottleService;
|
||||
|
||||
@Test
|
||||
void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import org.springframework.test.context.ActiveProfiles;
|
|||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
|
|
@ -27,6 +28,8 @@ class HealthControllerTest {
|
|||
.andExpect(jsonPath("$.msg").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.message").value("UP"))
|
||||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty())
|
||||
.andExpect(header().string("Content-Security-Policy", org.hamcrest.Matchers.containsString("default-src 'self'")))
|
||||
.andExpect(header().string("Content-Security-Policy", org.hamcrest.Matchers.containsString("object-src 'none'")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.iflytek.skillhub.auth.local.LocalAuthService;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import com.iflytek.skillhub.security.AuthFailureThrottleService;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -46,6 +47,9 @@ class LocalAuthControllerTest {
|
|||
@MockBean
|
||||
private SkillHubMetrics skillHubMetrics;
|
||||
|
||||
@MockBean
|
||||
private AuthFailureThrottleService authFailureThrottleService;
|
||||
|
||||
@Test
|
||||
void login_returnsCurrentUserEnvelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
|
|
@ -70,6 +74,7 @@ class LocalAuthControllerTest {
|
|||
.andExpect(jsonPath("$.data.oauthProvider").value("local"));
|
||||
verify(skillHubMetrics).recordLocalLogin(true);
|
||||
verify(skillHubMetrics, never()).recordLocalLogin(false);
|
||||
verify(authFailureThrottleService).resetIdentifier("local", "alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -125,6 +130,7 @@ class LocalAuthControllerTest {
|
|||
{"username":"alice","password":"wrong"}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized());
|
||||
verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1");
|
||||
verify(skillHubMetrics).recordLocalLogin(false);
|
||||
verify(skillHubMetrics, never()).recordLocalLogin(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,4 +132,25 @@ class SkillStarControllerTest {
|
|||
mockMvc.perform(get("/api/v1/skills/10/star"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiWebStarSkillWithoutCsrfShouldBeRejectedForSessionAuth() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"tester",
|
||||
"tester@example.com",
|
||||
"https://example.com/avatar.png",
|
||||
"github",
|
||||
Set.of("SUPER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
|
||||
mockMvc.perform(put("/api/web/skills/10/star")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
package com.iflytek.skillhub.controller.admin;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
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;
|
||||
|
||||
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.SkillReport;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.service.AdminSkillReportAppService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestRedisConfig.class)
|
||||
class AdminSkillReportControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private AdminSkillReportAppService adminSkillReportAppService;
|
||||
|
||||
@MockBean
|
||||
private SkillReportService skillReportService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void listReports_returnsPagedReports() throws Exception {
|
||||
when(adminSkillReportAppService.listReports("PENDING", 0, 20))
|
||||
.thenReturn(new PageResponse<>(
|
||||
List.of(new AdminSkillReportSummaryResponse(
|
||||
99L,
|
||||
10L,
|
||||
"global",
|
||||
"demo-skill",
|
||||
"Demo Skill",
|
||||
"user-1",
|
||||
"Spam",
|
||||
"details",
|
||||
"PENDING",
|
||||
null,
|
||||
null,
|
||||
LocalDateTime.of(2026, 3, 15, 12, 0),
|
||||
null
|
||||
)),
|
||||
1,
|
||||
0,
|
||||
20
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/v1/admin/skill-reports")
|
||||
.param("status", "PENDING")
|
||||
.with(authentication(adminAuth())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.items[0].id").value(99))
|
||||
.andExpect(jsonPath("$.data.items[0].skillSlug").value("demo-skill"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveReport_returnsUpdatedEnvelope() throws Exception {
|
||||
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()))
|
||||
.thenReturn(report);
|
||||
|
||||
mockMvc.perform(post("/api/v1/admin/skill-reports/99/resolve")
|
||||
.with(authentication(adminAuth()))
|
||||
.with(csrf())
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content("{\"comment\":\"handled\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.reportId").value(99))
|
||||
.andExpect(jsonPath("$.data.status").value("RESOLVED"));
|
||||
}
|
||||
|
||||
private UsernamePasswordAuthenticationToken adminAuth() {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"admin", "admin", "admin@example.com", "", "github", Set.of("SKILL_ADMIN")
|
||||
);
|
||||
return new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ class AuditLogControllerTest {
|
|||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR"))
|
||||
);
|
||||
|
||||
when(adminAuditLogAppService.listAuditLogs(0, 20, null, null))
|
||||
when(adminAuditLogAppService.listAuditLogs(0, 20, null, null, null, null, null, null, null, null))
|
||||
.thenReturn(new PageResponse<>(
|
||||
List.of(new AuditLogItemResponse(
|
||||
1L,
|
||||
|
|
@ -70,6 +70,9 @@ class AuditLogControllerTest {
|
|||
"alice",
|
||||
"{\"status\":\"DISABLED\"}",
|
||||
"127.0.0.1",
|
||||
"req-1",
|
||||
"USER",
|
||||
"42",
|
||||
Instant.parse("2026-03-13T01:00:00Z"))),
|
||||
1,
|
||||
0,
|
||||
|
|
@ -81,7 +84,10 @@ class AuditLogControllerTest {
|
|||
.andExpect(jsonPath("$.data.items").isArray())
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].username").value("alice"))
|
||||
.andExpect(jsonPath("$.data.items[0].details").value("{\"status\":\"DISABLED\"}"));
|
||||
.andExpect(jsonPath("$.data.items[0].details").value("{\"status\":\"DISABLED\"}"))
|
||||
.andExpect(jsonPath("$.data.items[0].requestId").value("req-1"))
|
||||
.andExpect(jsonPath("$.data.items[0].resourceType").value("USER"))
|
||||
.andExpect(jsonPath("$.data.items[0].resourceId").value("42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -93,7 +99,7 @@ class AuditLogControllerTest {
|
|||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
|
||||
when(adminAuditLogAppService.listAuditLogs(0, 20, null, null))
|
||||
when(adminAuditLogAppService.listAuditLogs(0, 20, null, null, null, null, null, null, null, null))
|
||||
.thenReturn(new PageResponse<>(List.of(), 0, 0, 20));
|
||||
|
||||
mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth)))
|
||||
|
|
@ -110,14 +116,43 @@ class AuditLogControllerTest {
|
|||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR"))
|
||||
);
|
||||
|
||||
when(adminAuditLogAppService.listAuditLogs(0, 20, "user-1", "CREATE_SKILL"))
|
||||
when(adminAuditLogAppService.listAuditLogs(
|
||||
0,
|
||||
20,
|
||||
"user-1",
|
||||
"CREATE_SKILL",
|
||||
"req-2",
|
||||
"127.0.0.1",
|
||||
"SKILL",
|
||||
"99",
|
||||
Instant.parse("2026-03-13T00:00:00Z"),
|
||||
Instant.parse("2026-03-14T00:00:00Z")))
|
||||
.thenReturn(new PageResponse<>(List.of(), 0, 0, 20));
|
||||
|
||||
mockMvc.perform(get("/api/v1/admin/audit-logs")
|
||||
.param("userId", "user-1")
|
||||
.param("action", "CREATE_SKILL")
|
||||
.param("requestId", "req-2")
|
||||
.param("ipAddress", "127.0.0.1")
|
||||
.param("resourceType", "SKILL")
|
||||
.param("resourceId", "99")
|
||||
.param("startTime", "2026-03-13T00:00:00Z")
|
||||
.param("endTime", "2026-03-14T00:00:00Z")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAuditLogs_withUserAdminRole_returns403() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-88", "useradmin", "useradmin@example.com", "", "github", Set.of("USER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))
|
||||
);
|
||||
|
||||
mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth)))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,19 @@ class UserManagementControllerTest {
|
|||
.andExpect(jsonPath("$.data.items").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsers_withSkillAdminRole_returns403() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-77", "skilladmin", "skilladmin@example.com", "", "github", Set.of("SKILL_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))
|
||||
);
|
||||
|
||||
mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth)))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserRole_withUserAdminRole_returns200() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
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;
|
||||
|
||||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
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.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.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestRedisConfig.class)
|
||||
class SkillLifecycleControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private NamespaceRepository namespaceRepository;
|
||||
|
||||
@MockBean
|
||||
private SkillRepository skillRepository;
|
||||
|
||||
@MockBean
|
||||
private SkillVersionRepository skillVersionRepository;
|
||||
|
||||
@MockBean
|
||||
private SkillGovernanceService skillGovernanceService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void archiveSkill_returnsUnifiedEnvelope() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setNamespaceId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
|
||||
setSkillId(skill, 1L);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup")))
|
||||
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED));
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/global/demo-skill/archive")
|
||||
.requestAttr("userId", "usr_1")
|
||||
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"cleanup\"}")
|
||||
.with(user("usr_1"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.skillId").value(1))
|
||||
.andExpect(jsonPath("$.data.action").value("ARCHIVE"))
|
||||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unarchiveSkill_returnsUnifiedEnvelope() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setNamespaceId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
|
||||
setSkillId(skill, 1L);
|
||||
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class)))
|
||||
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE));
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/global/demo-skill/unarchive")
|
||||
.requestAttr("userId", "usr_1")
|
||||
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
|
||||
.with(user("usr_1"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.skillId").value(1))
|
||||
.andExpect(jsonPath("$.data.action").value("UNARCHIVE"))
|
||||
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteVersion_returnsUnifiedEnvelope() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setNamespaceId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
|
||||
setSkillId(skill, 1L);
|
||||
SkillVersion version = new SkillVersion(2L, "1.0.0", "owner");
|
||||
setSkillVersionId(version, 2L);
|
||||
version.setStatus(SkillVersionStatus.DRAFT);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
|
||||
|
||||
mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0")
|
||||
.requestAttr("userId", "usr_1")
|
||||
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
|
||||
.with(user("usr_1"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.skillId").value(1))
|
||||
.andExpect(jsonPath("$.data.versionId").value(2))
|
||||
.andExpect(jsonPath("$.data.action").value("DELETE_VERSION"))
|
||||
.andExpect(jsonPath("$.data.status").value("1.0.0"));
|
||||
}
|
||||
|
||||
private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) {
|
||||
skill.setStatus(status);
|
||||
return skill;
|
||||
}
|
||||
|
||||
private void setNamespaceId(Namespace namespace, Long id) {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(namespace, "id", id);
|
||||
}
|
||||
|
||||
private void setSkillId(Skill skill, Long id) {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(skill, "id", id);
|
||||
}
|
||||
|
||||
private void setSkillVersionId(SkillVersion version, Long id) {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(version, "id", id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
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;
|
||||
|
||||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.report.SkillReport;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportService;
|
||||
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.namespace.NamespaceRepository;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestRedisConfig.class)
|
||||
class SkillReportControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private NamespaceRepository namespaceRepository;
|
||||
|
||||
@MockBean
|
||||
private SkillRepository skillRepository;
|
||||
|
||||
@MockBean
|
||||
private SkillReportService skillReportService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void submitReport_returnsCreatedEnvelope() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
ReflectionTestUtils.setField(namespace, "id", 1L);
|
||||
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
|
||||
ReflectionTestUtils.setField(skill, "id", 10L);
|
||||
SkillReport report = new SkillReport(10L, 1L, "user-1", "Spam", "details");
|
||||
ReflectionTestUtils.setField(report, "id", 99L);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillReportService.submitReport(eq(10L), eq("user-1"), eq("Spam"), eq("details"), nullable(String.class), nullable(String.class)))
|
||||
.willReturn(report);
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/global/demo-skill/reports")
|
||||
.requestAttr("userId", "user-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"Spam\",\"details\":\"details\"}")
|
||||
.with(user("user-1"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.reportId").value(99))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.iflytek.skillhub.metrics;
|
||||
|
||||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestRedisConfig.class)
|
||||
class PrometheusSecurityTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void prometheusEndpointShouldNotBeAnonymous() throws Exception {
|
||||
mockMvc.perform(get("/actuator/prometheus"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.iflytek.skillhub.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SensitiveLogSanitizerTest {
|
||||
|
||||
private final SensitiveLogSanitizer sanitizer = new SensitiveLogSanitizer();
|
||||
|
||||
@Test
|
||||
void shouldRedactSensitiveQueryParameters() {
|
||||
String sanitized = sanitizer.sanitizeQuery("returnTo=%2Fdashboard&token=abc123&password=secret&code=xyz");
|
||||
|
||||
assertThat(sanitized).contains("returnTo=%2Fdashboard");
|
||||
assertThat(sanitized).contains("token=[REDACTED]");
|
||||
assertThat(sanitized).contains("password=[REDACTED]");
|
||||
assertThat(sanitized).contains("code=[REDACTED]");
|
||||
}
|
||||
}
|
||||
|
|
@ -31,14 +31,35 @@ class AdminAuditLogAppServiceTest {
|
|||
"alice",
|
||||
"{\"status\":\"DISABLED\"}",
|
||||
"127.0.0.1",
|
||||
"req-1",
|
||||
"USER",
|
||||
"42",
|
||||
Instant.parse("2026-03-13T01:00:00Z")
|
||||
)));
|
||||
|
||||
PageResponse<?> response = service.listAuditLogs(0, 20, "user-1", "USER_STATUS_CHANGE");
|
||||
PageResponse<?> response = service.listAuditLogs(
|
||||
0,
|
||||
20,
|
||||
"user-1",
|
||||
"USER_STATUS_CHANGE",
|
||||
"req-1",
|
||||
"127.0.0.1",
|
||||
"USER",
|
||||
"42",
|
||||
Instant.parse("2026-03-13T00:00:00Z"),
|
||||
Instant.parse("2026-03-14T00:00:00Z"));
|
||||
|
||||
assertThat(response.total()).isEqualTo(1);
|
||||
assertThat(response.items()).hasSize(1);
|
||||
verify(jdbcTemplate).queryForObject(contains("al.actor_user_id = :userId"), any(MapSqlParameterSource.class), eq(Long.class));
|
||||
verify(jdbcTemplate).query(contains("al.action = :action"), any(MapSqlParameterSource.class), any(RowMapper.class));
|
||||
verify(jdbcTemplate).query(
|
||||
contains("al.request_id = :requestId"),
|
||||
any(MapSqlParameterSource.class),
|
||||
any(RowMapper.class));
|
||||
verify(jdbcTemplate).query(
|
||||
contains("CAST(al.target_id AS TEXT) = :resourceId"),
|
||||
any(MapSqlParameterSource.class),
|
||||
any(RowMapper.class));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
|
@ -26,10 +27,23 @@ import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
|||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
private static final String CONTENT_SECURITY_POLICY = String.join("; ",
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"connect-src 'self' ws: wss: http://localhost:* https://localhost:*",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'");
|
||||
|
||||
private final CustomOAuth2UserService customOAuth2UserService;
|
||||
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
|
||||
|
|
@ -65,16 +79,32 @@ public class SecurityConfig {
|
|||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
var csrfHandler = new CsrfTokenRequestAttributeHandler();
|
||||
csrfHandler.setCsrfRequestAttributeName(null);
|
||||
RequestMatcher csrfIgnoreMatcher = request -> {
|
||||
String path = request.getRequestURI();
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (authorization != null && authorization.startsWith("Bearer ")) {
|
||||
return true;
|
||||
}
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
return path.startsWith("/api/compat/")
|
||||
|| path.equals("/api/v1/publish")
|
||||
|| path.startsWith("/api/v1/auth/device/");
|
||||
};
|
||||
|
||||
http
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(csrfHandler)
|
||||
.ignoringRequestMatchers("/api/v1/**", "/api/web/**", "/api/compat/**")
|
||||
.ignoringRequestMatchers(csrfIgnoreMatcher)
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/api/v1/health",
|
||||
"/api/v1/search",
|
||||
"/api/v1/resolve/**",
|
||||
"/api/v1/download/**",
|
||||
"/api/v1/auth/providers",
|
||||
"/api/v1/auth/methods",
|
||||
"/api/v1/auth/me",
|
||||
|
|
@ -84,7 +114,6 @@ public class SecurityConfig {
|
|||
"/api/v1/auth/device/**",
|
||||
"/api/v1/check",
|
||||
"/actuator/health",
|
||||
"/actuator/prometheus",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/.well-known/**",
|
||||
|
|
@ -92,6 +121,7 @@ public class SecurityConfig {
|
|||
"/api/compat/v1/resolve/**",
|
||||
"/api/compat/v1/download/**"
|
||||
).permitAll()
|
||||
.requestMatchers("/actuator/prometheus").hasAnyRole("SUPER_ADMIN", "AUDITOR")
|
||||
.requestMatchers(
|
||||
HttpMethod.GET,
|
||||
"/api/v1/skills/*/star",
|
||||
|
|
@ -129,7 +159,7 @@ public class SecurityConfig {
|
|||
"/api/web/namespaces",
|
||||
"/api/web/namespaces/*"
|
||||
).permitAll()
|
||||
.requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR")
|
||||
.requestMatchers("/api/v1/admin/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2Login(oauth2 -> oauth2
|
||||
|
|
@ -140,6 +170,7 @@ public class SecurityConfig {
|
|||
)
|
||||
.headers(headers -> headers
|
||||
.contentTypeOptions(contentTypeOptions -> {})
|
||||
.contentSecurityPolicy(csp -> csp.policyDirectives(CONTENT_SECURITY_POLICY))
|
||||
.frameOptions(frameOptions -> frameOptions.deny())
|
||||
.httpStrictTransportSecurity(hsts -> hsts
|
||||
.includeSubDomains(true)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSu
|
|||
if (authentication.getPrincipal() instanceof OAuth2User oAuth2User) {
|
||||
PlatformPrincipal principal = (PlatformPrincipal) oAuth2User.getAttributes().get("platformPrincipal");
|
||||
if (principal != null) {
|
||||
platformSessionService.attachToAuthenticatedSession(principal, authentication, request);
|
||||
platformSessionService.attachToAuthenticatedSession(principal, authentication, request, true);
|
||||
}
|
||||
}
|
||||
String returnTo = consumeReturnTo(request.getSession(false));
|
||||
|
|
|
|||
|
|
@ -30,7 +30,14 @@ public class PlatformSessionService {
|
|||
public void attachToAuthenticatedSession(PlatformPrincipal principal,
|
||||
Authentication authentication,
|
||||
HttpServletRequest request) {
|
||||
persist(principal, authentication, request, false);
|
||||
attachToAuthenticatedSession(principal, authentication, request, false);
|
||||
}
|
||||
|
||||
public void attachToAuthenticatedSession(PlatformPrincipal principal,
|
||||
Authentication authentication,
|
||||
HttpServletRequest request,
|
||||
boolean rotateSessionId) {
|
||||
persist(principal, authentication, request, rotateSessionId);
|
||||
}
|
||||
|
||||
private void persist(PlatformPrincipal principal,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
|
|||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return !(path.startsWith("/api/v1/")
|
||||
|| path.startsWith("/api/web/")
|
||||
|| path.startsWith("/api/compat/"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter {
|
|||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path == null || (!path.startsWith("/api/v1/") && !path.startsWith("/api/compat/"));
|
||||
return path == null || (!path.startsWith("/api/v1/")
|
||||
&& !path.startsWith("/api/web/")
|
||||
&& !path.startsWith("/api/compat/"));
|
||||
}
|
||||
|
||||
private boolean isApiTokenAuthentication(Authentication authentication) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class OAuth2LoginHandlersTest {
|
|||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
HttpSession session = request.getSession(true);
|
||||
String originalSessionId = session.getId();
|
||||
session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, "/dashboard/publish");
|
||||
|
||||
var principal = new com.iflytek.skillhub.auth.rbac.PlatformPrincipal(
|
||||
|
|
@ -39,6 +40,7 @@ class OAuth2LoginHandlersTest {
|
|||
handler.onAuthenticationSuccess(request, response, authentication);
|
||||
|
||||
assertThat(response.getRedirectedUrl()).isEqualTo("/dashboard/publish");
|
||||
assertThat(request.getSession(false).getId()).isNotEqualTo(originalSessionId);
|
||||
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
|
||||
assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal);
|
||||
assertThat(session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNotNull();
|
||||
|
|
|
|||
|
|
@ -92,4 +92,23 @@ class ApiTokenAuthenticationFilterTest {
|
|||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(apiTokenService, never()).touchLastUsed(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAuthenticateBearerTokensForApiWebRequests() throws Exception {
|
||||
ApiToken token = new ApiToken("user-3", "cli", "sk_test", "hash", "[\"skill:publish\"]");
|
||||
UserAccount user = new UserAccount("user-3", "Carol", "carol@example.com", "");
|
||||
|
||||
when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token));
|
||||
when(userAccountRepository.findById("user-3")).thenReturn(Optional.of(user));
|
||||
when(roleBindingRepository.findByUserId("user-3")).thenReturn(List.of());
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRequestURI("/api/web/skills/global/publish");
|
||||
request.addHeader("Authorization", "Bearer raw-token");
|
||||
|
||||
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
|
||||
|
||||
assertNotNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(apiTokenService).touchLastUsed(token);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,4 +99,37 @@ class ApiTokenScopeFilterTest {
|
|||
verify(chain).doFilter(request, response);
|
||||
verify(handler, never()).handle(eq(request), eq(response), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDenyApiWebRequestsWithoutRequiredScope() throws Exception {
|
||||
AccessDeniedHandler handler = (request, response, accessDeniedException) -> {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
|
||||
};
|
||||
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
|
||||
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-3",
|
||||
"Bob",
|
||||
"bob@example.com",
|
||||
"",
|
||||
"api_token",
|
||||
Set.of("USER")
|
||||
);
|
||||
var authentication = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/web/skills/global/publish");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus());
|
||||
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
package com.iflytek.skillhub.domain.report;
|
||||
|
||||
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.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "skill_report")
|
||||
public class SkillReport {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "skill_id", nullable = false)
|
||||
private Long skillId;
|
||||
|
||||
@Column(name = "namespace_id", nullable = false)
|
||||
private Long namespaceId;
|
||||
|
||||
@Column(name = "reporter_id", nullable = false, length = 128)
|
||||
private String reporterId;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String reason;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String details;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private SkillReportStatus status = SkillReportStatus.PENDING;
|
||||
|
||||
@Column(name = "handled_by", length = 128)
|
||||
private String handledBy;
|
||||
|
||||
@Column(name = "handle_comment", columnDefinition = "TEXT")
|
||||
private String handleComment;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "handled_at")
|
||||
private LocalDateTime handledAt;
|
||||
|
||||
protected SkillReport() {
|
||||
}
|
||||
|
||||
public SkillReport(Long skillId, Long namespaceId, String reporterId, String reason, String details) {
|
||||
this.skillId = skillId;
|
||||
this.namespaceId = namespaceId;
|
||||
this.reporterId = reporterId;
|
||||
this.reason = reason;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getSkillId() {
|
||||
return skillId;
|
||||
}
|
||||
|
||||
public Long getNamespaceId() {
|
||||
return namespaceId;
|
||||
}
|
||||
|
||||
public String getReporterId() {
|
||||
return reporterId;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public String getDetails() {
|
||||
return details;
|
||||
}
|
||||
|
||||
public SkillReportStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(SkillReportStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getHandledBy() {
|
||||
return handledBy;
|
||||
}
|
||||
|
||||
public void setHandledBy(String handledBy) {
|
||||
this.handledBy = handledBy;
|
||||
}
|
||||
|
||||
public String getHandleComment() {
|
||||
return handleComment;
|
||||
}
|
||||
|
||||
public void setHandleComment(String handleComment) {
|
||||
this.handleComment = handleComment;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getHandledAt() {
|
||||
return handledAt;
|
||||
}
|
||||
|
||||
public void setHandledAt(LocalDateTime handledAt) {
|
||||
this.handledAt = handledAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.iflytek.skillhub.domain.report;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface SkillReportRepository {
|
||||
SkillReport save(SkillReport report);
|
||||
Optional<SkillReport> findById(Long id);
|
||||
boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status);
|
||||
Page<SkillReport> findByStatus(SkillReportStatus status, Pageable pageable);
|
||||
List<SkillReport> findBySkillIdIn(Collection<Long> skillIds);
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.iflytek.skillhub.domain.report;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
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 java.time.LocalDateTime;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class SkillReportService {
|
||||
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillReportRepository skillReportRepository;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
public SkillReportService(SkillRepository skillRepository,
|
||||
SkillReportRepository skillReportRepository,
|
||||
AuditLogService auditLogService) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillReportRepository = skillReportRepository;
|
||||
this.auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillReport submitReport(Long skillId,
|
||||
String reporterId,
|
||||
String reason,
|
||||
String details,
|
||||
String clientIp,
|
||||
String userAgent) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new DomainBadRequestException("error.skill.report.reason.required");
|
||||
}
|
||||
|
||||
Skill skill = skillRepository.findById(skillId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId));
|
||||
if (skill.getStatus() != SkillStatus.ACTIVE || skill.isHidden()) {
|
||||
throw new DomainBadRequestException("error.skill.report.unavailable", skill.getSlug());
|
||||
}
|
||||
if (skill.getOwnerId().equals(reporterId)) {
|
||||
throw new DomainBadRequestException("error.skill.report.self");
|
||||
}
|
||||
if (skillReportRepository.existsBySkillIdAndReporterIdAndStatus(skillId, reporterId, SkillReportStatus.PENDING)) {
|
||||
throw new DomainBadRequestException("error.skill.report.duplicate");
|
||||
}
|
||||
|
||||
SkillReport saved = skillReportRepository.save(new SkillReport(
|
||||
skillId,
|
||||
skill.getNamespaceId(),
|
||||
reporterId,
|
||||
reason.trim(),
|
||||
normalize(details)
|
||||
));
|
||||
auditLogService.record(reporterId, "REPORT_SKILL", "SKILL", skillId, null, clientIp, userAgent,
|
||||
"{\"reportId\":" + saved.getId() + "}");
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillReport resolveReport(Long reportId,
|
||||
String actorUserId,
|
||||
String comment,
|
||||
String clientIp,
|
||||
String userAgent) {
|
||||
SkillReport report = requirePendingReport(reportId);
|
||||
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);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillReport dismissReport(Long reportId,
|
||||
String actorUserId,
|
||||
String comment,
|
||||
String clientIp,
|
||||
String userAgent) {
|
||||
SkillReport report = requirePendingReport(reportId);
|
||||
report.setStatus(SkillReportStatus.DISMISSED);
|
||||
report.setHandledBy(actorUserId);
|
||||
report.setHandleComment(normalize(comment));
|
||||
report.setHandledAt(LocalDateTime.now());
|
||||
SkillReport saved = skillReportRepository.save(report);
|
||||
auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private SkillReport requirePendingReport(Long reportId) {
|
||||
SkillReport report = skillReportRepository.findById(reportId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.report.notFound", reportId));
|
||||
if (report.getStatus() != SkillReportStatus.PENDING) {
|
||||
throw new DomainBadRequestException("error.skill.report.alreadyHandled");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.report;
|
||||
|
||||
public enum SkillReportStatus {
|
||||
PENDING,
|
||||
RESOLVED,
|
||||
DISMISSED
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@ public interface SkillVersionRepository {
|
|||
Optional<SkillVersion> findById(Long id);
|
||||
List<SkillVersion> findByIdIn(List<Long> ids);
|
||||
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
|
||||
List<SkillVersion> findBySkillId(Long skillId);
|
||||
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
|
||||
List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status);
|
||||
SkillVersion save(SkillVersion version);
|
||||
void delete(SkillVersion version);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,24 @@
|
|||
package com.iflytek.skillhub.domain.skill.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFile;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -16,14 +27,23 @@ public class SkillGovernanceService {
|
|||
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillFileRepository skillFileRepository;
|
||||
private final ObjectStorageService objectStorageService;
|
||||
private final AuditLogService auditLogService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public SkillGovernanceService(SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
AuditLogService auditLogService) {
|
||||
SkillFileRepository skillFileRepository,
|
||||
ObjectStorageService objectStorageService,
|
||||
AuditLogService auditLogService,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillFileRepository = skillFileRepository;
|
||||
this.objectStorageService = objectStorageService;
|
||||
this.auditLogService = auditLogService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -39,6 +59,26 @@ public class SkillGovernanceService {
|
|||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Skill archiveSkill(Long skillId,
|
||||
String actorUserId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
String clientIp,
|
||||
String userAgent,
|
||||
String reason) {
|
||||
Skill skill = skillRepository.findById(skillId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId));
|
||||
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
|
||||
|
||||
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));
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, String userAgent) {
|
||||
Skill skill = skillRepository.findById(skillId)
|
||||
|
|
@ -52,6 +92,56 @@ public class SkillGovernanceService {
|
|||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Skill unarchiveSkill(Long skillId,
|
||||
String actorUserId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
String clientIp,
|
||||
String userAgent) {
|
||||
Skill skill = skillRepository.findById(skillId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId));
|
||||
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
|
||||
|
||||
SkillStatus previousStatus = skill.getStatus();
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
skill.setUpdatedBy(actorUserId);
|
||||
Skill saved = skillRepository.save(skill);
|
||||
auditLogService.record(actorUserId, "UNARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null);
|
||||
eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ACTIVE));
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteVersion(Skill skill,
|
||||
SkillVersion version,
|
||||
String actorUserId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
String clientIp,
|
||||
String userAgent) {
|
||||
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
|
||||
if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED) {
|
||||
throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion());
|
||||
}
|
||||
|
||||
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
|
||||
if (!files.isEmpty()) {
|
||||
objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList());
|
||||
}
|
||||
objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()));
|
||||
skillFileRepository.deleteByVersionId(version.getId());
|
||||
skillVersionRepository.delete(version);
|
||||
auditLogService.record(
|
||||
actorUserId,
|
||||
"DELETE_SKILL_VERSION",
|
||||
"SKILL_VERSION",
|
||||
version.getId(),
|
||||
null,
|
||||
clientIp,
|
||||
userAgent,
|
||||
"{\"version\":\"" + version.getVersion().replace("\"", "\\\"") + "\"}"
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) {
|
||||
SkillVersion version = skillVersionRepository.findById(versionId)
|
||||
|
|
@ -65,6 +155,18 @@ public class SkillGovernanceService {
|
|||
return saved;
|
||||
}
|
||||
|
||||
private void assertCanManageLifecycle(Skill skill,
|
||||
String actorUserId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId());
|
||||
boolean canManage = skill.getOwnerId().equals(actorUserId)
|
||||
|| namespaceRole == NamespaceRole.ADMIN
|
||||
|| namespaceRole == NamespaceRole.OWNER;
|
||||
if (!canManage) {
|
||||
throw new DomainForbiddenException("error.skill.lifecycle.noPermission");
|
||||
}
|
||||
}
|
||||
|
||||
private String jsonReason(String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -142,6 +142,10 @@ public class SkillPublishService {
|
|||
return skillRepository.save(newSkill);
|
||||
});
|
||||
|
||||
if (skill.getStatus() == SkillStatus.ARCHIVED) {
|
||||
throw new DomainBadRequestException("error.skill.publish.archived", skillSlug);
|
||||
}
|
||||
|
||||
// 7. Check version doesn't already exist
|
||||
if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) {
|
||||
throw new DomainBadRequestException("error.skill.version.exists", metadata.version());
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ public class SkillQueryService {
|
|||
Long namespaceId,
|
||||
java.time.LocalDateTime createdAt,
|
||||
java.time.LocalDateTime updatedAt,
|
||||
SkillVersion latestVersionEntity
|
||||
SkillVersion latestVersionEntity,
|
||||
boolean canManageLifecycle
|
||||
) {}
|
||||
|
||||
public record SkillVersionDetailDTO(
|
||||
|
|
@ -134,7 +135,8 @@ public class SkillQueryService {
|
|||
skill.getNamespaceId(),
|
||||
skill.getCreatedAt(),
|
||||
skill.getUpdatedAt(),
|
||||
latestVersionEntity
|
||||
latestVersionEntity,
|
||||
canManageRestrictedSkill(skill, currentUserId, userNsRoles)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -250,16 +252,31 @@ public class SkillQueryService {
|
|||
Pageable pageable) {
|
||||
Skill skill = findSkill(namespaceSlug, skillSlug);
|
||||
assertPublishedAccessible(skill, currentUserId, userNsRoles);
|
||||
|
||||
List<SkillVersion> publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
|
||||
skill.getId(), SkillVersionStatus.PUBLISHED);
|
||||
List<SkillVersion> visibleVersions;
|
||||
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
|
||||
visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
|
||||
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED
|
||||
|| version.getStatus() == SkillVersionStatus.DRAFT
|
||||
|| version.getStatus() == SkillVersionStatus.REJECTED)
|
||||
.sorted(Comparator
|
||||
.comparingInt((SkillVersion version) -> lifecycleListPriority(version.getStatus()))
|
||||
.thenComparing(SkillVersion::getPublishedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(SkillVersion::getCreatedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(SkillVersion::getId, Comparator.reverseOrder()))
|
||||
.toList();
|
||||
} else {
|
||||
visibleVersions = skillVersionRepository.findBySkillIdAndStatus(
|
||||
skill.getId(), SkillVersionStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
// Manual pagination
|
||||
int start = (int) pageable.getOffset();
|
||||
int end = Math.min(start + pageable.getPageSize(), publishedVersions.size());
|
||||
List<SkillVersion> pageContent = publishedVersions.subList(start, end);
|
||||
int start = Math.min((int) pageable.getOffset(), visibleVersions.size());
|
||||
int end = Math.min(start + pageable.getPageSize(), visibleVersions.size());
|
||||
List<SkillVersion> pageContent = visibleVersions.subList(start, end);
|
||||
|
||||
return new PageImpl<>(pageContent, pageable, publishedVersions.size());
|
||||
return new PageImpl<>(pageContent, pageable, visibleVersions.size());
|
||||
}
|
||||
|
||||
public ResolvedVersionDTO resolveVersion(
|
||||
|
|
@ -390,14 +407,37 @@ public class SkillQueryService {
|
|||
}
|
||||
|
||||
private void assertPublishedAccessible(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (skill.getStatus() != SkillStatus.ACTIVE) {
|
||||
throw new DomainBadRequestException("error.skill.status.notActive");
|
||||
if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
|
||||
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
|
||||
}
|
||||
if (skill.isHidden() && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
|
||||
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
|
||||
}
|
||||
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
|
||||
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (currentUserId == null) {
|
||||
return false;
|
||||
}
|
||||
NamespaceRole role = userNsRoles.get(skill.getNamespaceId());
|
||||
return skill.getOwnerId().equals(currentUserId)
|
||||
|| role == NamespaceRole.ADMIN
|
||||
|| role == NamespaceRole.OWNER;
|
||||
}
|
||||
|
||||
private int lifecycleListPriority(SkillVersionStatus status) {
|
||||
if (status == SkillVersionStatus.PUBLISHED) {
|
||||
return 0;
|
||||
}
|
||||
if (status == SkillVersionStatus.REJECTED) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
private void assertPublishedVersion(SkillVersion version, String versionStr) {
|
||||
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
|
||||
throw new DomainBadRequestException("error.skill.version.notPublished", versionStr);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
package com.iflytek.skillhub.domain.skill.validation;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class BasicPrePublishValidator implements PrePublishValidator {
|
||||
|
||||
private static final List<Pattern> SECRET_PATTERNS = List.of(
|
||||
Pattern.compile("AKIA[0-9A-Z]{16}"),
|
||||
Pattern.compile("ghp_[A-Za-z0-9]{20,}"),
|
||||
Pattern.compile("sk-[A-Za-z0-9]{20,}"),
|
||||
Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?[A-Za-z0-9_\\-]{12,}")
|
||||
);
|
||||
|
||||
@Override
|
||||
public ValidationResult validate(SkillPackageContext context) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
for (PackageEntry entry : context.entries()) {
|
||||
if (!isTextLike(entry.path())) {
|
||||
continue;
|
||||
}
|
||||
String content = new String(entry.content(), StandardCharsets.UTF_8);
|
||||
for (Pattern secretPattern : SECRET_PATTERNS) {
|
||||
if (secretPattern.matcher(content).find()) {
|
||||
errors.add("Potential secret detected in " + entry.path());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
|
||||
}
|
||||
|
||||
private boolean isTextLike(String path) {
|
||||
String lowerPath = path.toLowerCase(Locale.ROOT);
|
||||
return lowerPath.endsWith(".md")
|
||||
|| lowerPath.endsWith(".txt")
|
||||
|| lowerPath.endsWith(".json")
|
||||
|| lowerPath.endsWith(".yaml")
|
||||
|| lowerPath.endsWith(".yml")
|
||||
|| lowerPath.endsWith(".js")
|
||||
|| lowerPath.endsWith(".ts")
|
||||
|| lowerPath.endsWith(".py")
|
||||
|| lowerPath.endsWith(".sh")
|
||||
|| lowerPath.endsWith(".svg");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
package com.iflytek.skillhub.domain.skill.validation;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class NoOpPrePublishValidator implements PrePublishValidator {
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ package com.iflytek.skillhub.domain.skill.validation;
|
|||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
|
||||
public final class SkillPackagePolicy {
|
||||
|
|
@ -53,4 +58,70 @@ public final class SkillPackagePolicy {
|
|||
public static boolean hasAllowedExtension(String path) {
|
||||
return ALLOWED_EXTENSIONS.stream().anyMatch(path::endsWith);
|
||||
}
|
||||
|
||||
public static String validateContentMatchesExtension(String path, byte[] content) {
|
||||
String lowerPath = path.toLowerCase();
|
||||
if (lowerPath.endsWith(".png")) {
|
||||
return hasPrefix(content, (byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
? null
|
||||
: "File content does not match extension: " + path;
|
||||
}
|
||||
if (lowerPath.endsWith(".jpg")) {
|
||||
return hasPrefix(content, (byte) 0xff, (byte) 0xd8, (byte) 0xff)
|
||||
? null
|
||||
: "File content does not match extension: " + path;
|
||||
}
|
||||
if (lowerPath.endsWith(".svg")) {
|
||||
if (!isUtf8Text(content)) {
|
||||
return "File content does not match extension: " + path;
|
||||
}
|
||||
String text = new String(content, StandardCharsets.UTF_8).trim().toLowerCase();
|
||||
return text.contains("<svg") ? null : "File content does not match extension: " + path;
|
||||
}
|
||||
if (isTextExtension(lowerPath)) {
|
||||
return isUtf8Text(content) ? null : "File content does not match extension: " + path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isTextExtension(String path) {
|
||||
return path.endsWith(".md")
|
||||
|| path.endsWith(".txt")
|
||||
|| path.endsWith(".json")
|
||||
|| path.endsWith(".yaml")
|
||||
|| path.endsWith(".yml")
|
||||
|| path.endsWith(".js")
|
||||
|| path.endsWith(".ts")
|
||||
|| path.endsWith(".py")
|
||||
|| path.endsWith(".sh");
|
||||
}
|
||||
|
||||
private static boolean isUtf8Text(byte[] content) {
|
||||
for (byte value : content) {
|
||||
if (value == 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
CharBuffer ignored = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(content));
|
||||
return true;
|
||||
} catch (CharacterCodingException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasPrefix(byte[] content, int... prefix) {
|
||||
if (content.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < prefix.length; index++) {
|
||||
if ((content[index] & 0xff) != (prefix[index] & 0xff)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ public class SkillPackageValidator {
|
|||
errors.add("Disallowed file extension: " + normalizedPath);
|
||||
}
|
||||
|
||||
String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension(normalizedPath, entry.content());
|
||||
if (contentMismatch != null) {
|
||||
errors.add(contentMismatch);
|
||||
}
|
||||
|
||||
if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) {
|
||||
skillMd = entry;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
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.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
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 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 SkillReportServiceTest {
|
||||
|
||||
@Mock
|
||||
private SkillRepository skillRepository;
|
||||
|
||||
@Mock
|
||||
private SkillReportRepository skillReportRepository;
|
||||
|
||||
@Mock
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
private SkillReportService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SkillReportService(skillRepository, skillReportRepository, auditLogService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitReport_createsPendingReport() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 10L);
|
||||
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
|
||||
when(skillReportRepository.existsBySkillIdAndReporterIdAndStatus(10L, "user-1", SkillReportStatus.PENDING)).thenReturn(false);
|
||||
when(skillReportRepository.save(any(SkillReport.class))).thenAnswer(invocation -> {
|
||||
SkillReport report = invocation.getArgument(0);
|
||||
setField(report, "id", 99L);
|
||||
return report;
|
||||
});
|
||||
|
||||
SkillReport report = service.submitReport(10L, "user-1", "Inappropriate content", "details", "127.0.0.1", "JUnit");
|
||||
|
||||
assertThat(report.getStatus()).isEqualTo(SkillReportStatus.PENDING);
|
||||
assertThat(report.getReason()).isEqualTo("Inappropriate content");
|
||||
verify(auditLogService).record("user-1", "REPORT_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reportId\":99}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitReport_rejectsDuplicatePendingReport() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 10L);
|
||||
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
|
||||
when(skillReportRepository.existsBySkillIdAndReporterIdAndStatus(10L, "user-1", SkillReportStatus.PENDING)).thenReturn(true);
|
||||
|
||||
assertThrows(DomainBadRequestException.class,
|
||||
() -> service.submitReport(10L, "user-1", "Inappropriate content", null, "127.0.0.1", "JUnit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitReport_rejectsSelfReport() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 10L);
|
||||
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
|
||||
|
||||
assertThrows(DomainBadRequestException.class,
|
||||
() -> service.submitReport(10L, "owner", "Inappropriate content", null, "127.0.0.1", "JUnit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveReport_marksReportResolved() {
|
||||
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", "handled", "127.0.0.1", "JUnit");
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED);
|
||||
assertThat(saved.getHandledBy()).isEqualTo("admin");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,30 @@
|
|||
package com.iflytek.skillhub.domain.skill.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFile;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
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.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import java.util.Optional;
|
||||
import java.util.Map;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
|
@ -26,13 +39,26 @@ class SkillGovernanceServiceTest {
|
|||
@Mock
|
||||
private SkillVersionRepository skillVersionRepository;
|
||||
@Mock
|
||||
private SkillFileRepository skillFileRepository;
|
||||
@Mock
|
||||
private ObjectStorageService objectStorageService;
|
||||
@Mock
|
||||
private AuditLogService auditLogService;
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private SkillGovernanceService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SkillGovernanceService(skillRepository, skillVersionRepository, auditLogService);
|
||||
service = new SkillGovernanceService(
|
||||
skillRepository,
|
||||
skillVersionRepository,
|
||||
skillFileRepository,
|
||||
objectStorageService,
|
||||
auditLogService,
|
||||
eventPublisher
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -48,6 +74,45 @@ class SkillGovernanceServiceTest {
|
|||
verify(auditLogService).record("admin", "HIDE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"policy\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveSkill_marksSkillArchived() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 10L);
|
||||
given(skillRepository.findById(10L)).willReturn(Optional.of(skill));
|
||||
given(skillRepository.save(skill)).willReturn(skill);
|
||||
|
||||
Skill result = service.archiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit", "cleanup");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(SkillStatus.ARCHIVED);
|
||||
verify(auditLogService).record("owner", "ARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"cleanup\"}");
|
||||
verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unarchiveSkill_restoresActiveStatus() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 10L);
|
||||
skill.setStatus(SkillStatus.ARCHIVED);
|
||||
given(skillRepository.findById(10L)).willReturn(Optional.of(skill));
|
||||
given(skillRepository.save(skill)).willReturn(skill);
|
||||
|
||||
Skill result = service.unarchiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(SkillStatus.ACTIVE);
|
||||
verify(auditLogService).record("owner", "UNARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", null);
|
||||
verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveSkill_requiresOwnerOrNamespaceAdmin() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 10L);
|
||||
given(skillRepository.findById(10L)).willReturn(Optional.of(skill));
|
||||
|
||||
assertThrows(DomainForbiddenException.class,
|
||||
() -> service.archiveSkill(10L, "other", Map.of(1L, NamespaceRole.MEMBER), "127.0.0.1", "JUnit", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankVersion_setsYankedStatus() {
|
||||
SkillVersion version = new SkillVersion(2L, "1.0.0", "owner");
|
||||
|
|
@ -61,4 +126,52 @@ class SkillGovernanceServiceTest {
|
|||
assertThat(result.getYankedBy()).isEqualTo("admin");
|
||||
verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteVersion_removesDraftFilesAndBundle() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 1L);
|
||||
SkillVersion version = new SkillVersion(2L, "1.0.0", "owner");
|
||||
setField(version, "id", 2L);
|
||||
version.setStatus(SkillVersionStatus.DRAFT);
|
||||
SkillFile readme = new SkillFile(version.getId(), "README.md", 10L, "text/markdown", "sha1", "skills/demo/readme");
|
||||
SkillFile icon = new SkillFile(version.getId(), "icon.png", 20L, "image/png", "sha2", "skills/demo/icon");
|
||||
given(skillFileRepository.findByVersionId(version.getId())).willReturn(java.util.List.of(readme, icon));
|
||||
|
||||
service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit");
|
||||
|
||||
verify(objectStorageService).deleteObjects(argThat(keys ->
|
||||
keys.size() == 2
|
||||
&& keys.contains("skills/demo/readme")
|
||||
&& keys.contains("skills/demo/icon")));
|
||||
verify(objectStorageService).deleteObject("packages/1/2/bundle.zip");
|
||||
verify(skillFileRepository).deleteByVersionId(2L);
|
||||
verify(skillVersionRepository).delete(version);
|
||||
verify(auditLogService).record("owner", "DELETE_SKILL_VERSION", "SKILL_VERSION", 2L, null, "127.0.0.1", "JUnit", "{\"version\":\"1.0.0\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteVersion_rejectsPublishedVersion() {
|
||||
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 1L);
|
||||
SkillVersion version = new SkillVersion(2L, "1.0.0", "owner");
|
||||
setField(version, "id", 2L);
|
||||
version.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
assertThrows(DomainBadRequestException.class,
|
||||
() -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit"));
|
||||
|
||||
verify(skillVersionRepository, never()).delete(any());
|
||||
verify(objectStorageService, never()).deleteObject(any());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,38 @@ class SkillPublishServiceTest {
|
|||
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldRejectArchivedSkill() 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");
|
||||
setId(namespace, 1L);
|
||||
NamespaceMember member = mock(NamespaceMember.class);
|
||||
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
|
||||
Skill archivedSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
|
||||
archivedSkill.setStatus(SkillStatus.ARCHIVED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
|
||||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(archivedSkill));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
|
||||
namespaceSlug,
|
||||
entries,
|
||||
publisherId,
|
||||
SkillVisibility.PUBLIC,
|
||||
Set.of()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldAutoGenerateVersionWhenMissing() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
|
|
|
|||
|
|
@ -351,6 +351,124 @@ class SkillQueryServiceTest {
|
|||
assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
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);
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
assertTrue(result.canManageLifecycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "viewer-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
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);
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
assertFalse(result.canManageLifecycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListVersions_ShouldIncludeDraftAndRejectedForLifecycleManagers() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
SkillVersion published = new SkillVersion(1L, "1.0.0", userId);
|
||||
setId(published, 11L);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
SkillVersion draft = new SkillVersion(1L, "1.1.0", userId);
|
||||
setId(draft, 12L);
|
||||
draft.setStatus(SkillVersionStatus.DRAFT);
|
||||
SkillVersion rejected = new SkillVersion(1L, "1.2.0", userId);
|
||||
setId(rejected, 13L);
|
||||
rejected.setStatus(SkillVersionStatus.REJECTED);
|
||||
|
||||
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.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
|
||||
|
||||
Page<SkillVersion> result = service.listVersions(
|
||||
namespaceSlug,
|
||||
skillSlug,
|
||||
userId,
|
||||
userNsRoles,
|
||||
PageRequest.of(0, 10)
|
||||
);
|
||||
|
||||
assertEquals(List.of("1.0.0", "1.2.0", "1.1.0"),
|
||||
result.getContent().stream().map(SkillVersion::getVersion).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListVersions_ShouldOnlyReturnPublishedForRegularViewers() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "viewer-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
|
||||
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.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));
|
||||
|
||||
Page<SkillVersion> result = service.listVersions(
|
||||
namespaceSlug,
|
||||
skillSlug,
|
||||
userId,
|
||||
userNsRoles,
|
||||
PageRequest.of(0, 10)
|
||||
);
|
||||
|
||||
assertEquals(List.of("1.0.0"),
|
||||
result.getContent().stream().map(SkillVersion::getVersion).toList());
|
||||
}
|
||||
|
||||
private void setId(Object entity, Long id) throws Exception {
|
||||
Field idField = entity.getClass().getDeclaredField("id");
|
||||
idField.setAccessible(true);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package com.iflytek.skillhub.domain.skill.validation;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class BasicPrePublishValidatorTest {
|
||||
|
||||
private final BasicPrePublishValidator validator = new BasicPrePublishValidator();
|
||||
|
||||
@Test
|
||||
void shouldRejectObviousCredentialLeak() {
|
||||
PackageEntry skillMd = new PackageEntry(
|
||||
"SKILL.md",
|
||||
"""
|
||||
---
|
||||
name: Secret Skill
|
||||
version: 1.0.0
|
||||
---
|
||||
""".getBytes(StandardCharsets.UTF_8),
|
||||
47,
|
||||
"text/markdown"
|
||||
);
|
||||
PackageEntry config = new PackageEntry(
|
||||
"config.txt",
|
||||
"OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456".getBytes(StandardCharsets.UTF_8),
|
||||
50,
|
||||
"text/plain"
|
||||
);
|
||||
|
||||
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
|
||||
List.of(skillMd, config),
|
||||
new SkillMetadata("Secret Skill", "desc", "1.0.0", "body", Map.of()),
|
||||
"user-1",
|
||||
1L
|
||||
));
|
||||
|
||||
assertFalse(result.passed());
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("Potential secret detected")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowOrdinaryTextFiles() {
|
||||
PackageEntry skillMd = new PackageEntry(
|
||||
"SKILL.md",
|
||||
"""
|
||||
---
|
||||
name: Safe Skill
|
||||
version: 1.0.0
|
||||
---
|
||||
""".getBytes(StandardCharsets.UTF_8),
|
||||
45,
|
||||
"text/markdown"
|
||||
);
|
||||
PackageEntry readme = new PackageEntry(
|
||||
"README.md",
|
||||
"This skill documents safe usage.".getBytes(StandardCharsets.UTF_8),
|
||||
31,
|
||||
"text/markdown"
|
||||
);
|
||||
|
||||
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
|
||||
List.of(skillMd, readme),
|
||||
new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
|
||||
"user-1",
|
||||
1L
|
||||
));
|
||||
|
||||
assertTrue(result.passed());
|
||||
}
|
||||
}
|
||||
|
|
@ -221,4 +221,50 @@ class SkillPackageValidatorTest {
|
|||
assertFalse(result.passed());
|
||||
assertTrue(result.errors().stream().anyMatch(e -> e.contains("Duplicate package entry path: docs/guide.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSpoofedBinaryTextFileRejected() {
|
||||
String skillMdContent = """
|
||||
---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
version: 1.0.0
|
||||
---
|
||||
Body
|
||||
""";
|
||||
|
||||
byte[] binaryPayload = new byte[] {0x4d, 0x5a, 0x00, 0x02};
|
||||
|
||||
List<PackageEntry> entries = List.of(
|
||||
new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"),
|
||||
new PackageEntry("notes.md", binaryPayload, binaryPayload.length, "text/markdown")
|
||||
);
|
||||
|
||||
ValidationResult result = validator.validate(entries);
|
||||
|
||||
assertFalse(result.passed());
|
||||
assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidSvgPayloadRejected() {
|
||||
String skillMdContent = """
|
||||
---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
version: 1.0.0
|
||||
---
|
||||
Body
|
||||
""";
|
||||
|
||||
List<PackageEntry> entries = List.of(
|
||||
new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"),
|
||||
new PackageEntry("icon.svg", "not actually svg".getBytes(), 16, "image/svg+xml")
|
||||
);
|
||||
|
||||
ValidationResult result = validator.validate(entries);
|
||||
|
||||
assertFalse(result.passed());
|
||||
assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package com.iflytek.skillhub.infra.jpa;
|
||||
|
||||
import com.iflytek.skillhub.domain.report.SkillReport;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportRepository;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportStatus;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface SkillReportJpaRepository extends JpaRepository<SkillReport, Long>, SkillReportRepository {
|
||||
boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status);
|
||||
Page<SkillReport> findByStatusOrderByCreatedAtDesc(SkillReportStatus status, Pageable pageable);
|
||||
List<SkillReport> findBySkillIdIn(Collection<Long> skillIds);
|
||||
|
||||
@Override
|
||||
default Page<SkillReport> findByStatus(SkillReportStatus status, Pageable pageable) {
|
||||
return findByStatusOrderByCreatedAtDesc(status, pageable);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,9 @@ public class SkillSearchDocumentEntity {
|
|||
@Column(name = "search_text", columnDefinition = "TEXT")
|
||||
private String searchText;
|
||||
|
||||
@Column(name = "semantic_vector", columnDefinition = "TEXT")
|
||||
private String semanticVector;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String visibility;
|
||||
|
||||
|
|
@ -56,6 +59,7 @@ public class SkillSearchDocumentEntity {
|
|||
String summary,
|
||||
String keywords,
|
||||
String searchText,
|
||||
String semanticVector,
|
||||
String visibility,
|
||||
String status) {
|
||||
this.skillId = skillId;
|
||||
|
|
@ -66,6 +70,7 @@ public class SkillSearchDocumentEntity {
|
|||
this.summary = summary;
|
||||
this.keywords = keywords;
|
||||
this.searchText = searchText;
|
||||
this.semanticVector = semanticVector;
|
||||
this.visibility = visibility;
|
||||
this.status = status;
|
||||
}
|
||||
|
|
@ -117,6 +122,10 @@ public class SkillSearchDocumentEntity {
|
|||
return visibility;
|
||||
}
|
||||
|
||||
public String getSemanticVector() {
|
||||
return semanticVector;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
|
@ -154,6 +163,10 @@ public class SkillSearchDocumentEntity {
|
|||
this.searchText = searchText;
|
||||
}
|
||||
|
||||
public void setSemanticVector(String semanticVector) {
|
||||
this.semanticVector = semanticVector;
|
||||
}
|
||||
|
||||
public void setVisibility(String visibility) {
|
||||
this.visibility = visibility;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package com.iflytek.skillhub.infra.jpa;
|
|||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface SkillSearchDocumentJpaRepository extends JpaRepository<SkillSearchDocumentEntity, Long> {
|
||||
Optional<SkillSearchDocumentEntity> findBySkillId(Long skillId);
|
||||
List<SkillSearchDocumentEntity> findBySkillIdIn(Collection<Long> skillIds);
|
||||
void deleteBySkillId(Long skillId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import java.util.Optional;
|
|||
@Repository
|
||||
public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, Long>, SkillVersionRepository {
|
||||
List<SkillVersion> findByIdIn(List<Long> ids);
|
||||
List<SkillVersion> findBySkillId(Long skillId);
|
||||
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
|
||||
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.iflytek.skillhub.search;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class HashingSearchEmbeddingService implements SearchEmbeddingService {
|
||||
private static final Pattern TOKEN_SPLITTER = Pattern.compile("[^\\p{L}\\p{N}_]+");
|
||||
private static final int DIMENSIONS = 64;
|
||||
private static final double NGRAM_WEIGHT = 0.35D;
|
||||
|
||||
@Override
|
||||
public String embed(String text) {
|
||||
double[] vector = buildVector(text);
|
||||
return Arrays.stream(vector)
|
||||
.mapToObj(value -> String.format(Locale.ROOT, "%.6f", value))
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
@Override
|
||||
public double similarity(String text, String serializedVector) {
|
||||
if (serializedVector == null || serializedVector.isBlank()) {
|
||||
return 0D;
|
||||
}
|
||||
double[] left = buildVector(text);
|
||||
double[] right = parseVector(serializedVector);
|
||||
if (left.length != right.length || left.length == 0) {
|
||||
return 0D;
|
||||
}
|
||||
|
||||
double dot = 0D;
|
||||
for (int i = 0; i < left.length; i++) {
|
||||
dot += left[i] * right[i];
|
||||
}
|
||||
return dot;
|
||||
}
|
||||
|
||||
private double[] buildVector(String text) {
|
||||
double[] vector = new double[DIMENSIONS];
|
||||
if (text == null || text.isBlank()) {
|
||||
return vector;
|
||||
}
|
||||
|
||||
TOKEN_SPLITTER.splitAsStream(text.toLowerCase(Locale.ROOT))
|
||||
.map(String::trim)
|
||||
.filter(token -> !token.isBlank())
|
||||
.forEach(token -> {
|
||||
addTokenWeight(vector, token, 1D + Math.min(token.length(), 12) / 12D);
|
||||
addCharacterNgrams(vector, token);
|
||||
});
|
||||
|
||||
normalize(vector);
|
||||
return vector;
|
||||
}
|
||||
|
||||
private void addTokenWeight(double[] vector, String token, double weight) {
|
||||
int hash = token.hashCode();
|
||||
int index = Math.floorMod(hash, DIMENSIONS);
|
||||
vector[index] += weight;
|
||||
}
|
||||
|
||||
private void addCharacterNgrams(double[] vector, String token) {
|
||||
if (token.length() < 3) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i <= token.length() - 3; i++) {
|
||||
String trigram = token.substring(i, i + 3);
|
||||
addTokenWeight(vector, trigram, NGRAM_WEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
private double[] parseVector(String serializedVector) {
|
||||
String[] parts = serializedVector.split(",");
|
||||
double[] vector = new double[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
vector[i] = Double.parseDouble(parts[i]);
|
||||
}
|
||||
normalize(vector);
|
||||
return vector;
|
||||
}
|
||||
|
||||
private void normalize(double[] vector) {
|
||||
double magnitude = 0D;
|
||||
for (double value : vector) {
|
||||
magnitude += value * value;
|
||||
}
|
||||
if (magnitude == 0D) {
|
||||
return;
|
||||
}
|
||||
double norm = Math.sqrt(magnitude);
|
||||
for (int i = 0; i < vector.length; i++) {
|
||||
vector[i] = vector[i] / norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.search;
|
||||
|
||||
public interface SearchEmbeddingService {
|
||||
String embed(String text);
|
||||
|
||||
double similarity(String text, String serializedVector);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ public record SkillSearchDocument(
|
|||
String summary,
|
||||
String keywords,
|
||||
String searchText,
|
||||
String semanticVector,
|
||||
String visibility,
|
||||
String status
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.postgres;
|
|||
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
|
||||
import com.iflytek.skillhub.search.SearchEmbeddingService;
|
||||
import com.iflytek.skillhub.search.SearchIndexService;
|
||||
import com.iflytek.skillhub.search.SkillSearchDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -14,9 +15,12 @@ import java.util.Optional;
|
|||
public class PostgresFullTextIndexService implements SearchIndexService {
|
||||
|
||||
private final SkillSearchDocumentJpaRepository repository;
|
||||
private final SearchEmbeddingService searchEmbeddingService;
|
||||
|
||||
public PostgresFullTextIndexService(SkillSearchDocumentJpaRepository repository) {
|
||||
public PostgresFullTextIndexService(SkillSearchDocumentJpaRepository repository,
|
||||
SearchEmbeddingService searchEmbeddingService) {
|
||||
this.repository = repository;
|
||||
this.searchEmbeddingService = searchEmbeddingService;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -33,6 +37,7 @@ public class PostgresFullTextIndexService implements SearchIndexService {
|
|||
entity.setSummary(document.summary());
|
||||
entity.setKeywords(document.keywords());
|
||||
entity.setSearchText(document.searchText());
|
||||
entity.setSemanticVector(buildSemanticVector(document));
|
||||
entity.setVisibility(document.visibility());
|
||||
entity.setStatus(document.status());
|
||||
repository.save(entity);
|
||||
|
|
@ -46,6 +51,7 @@ public class PostgresFullTextIndexService implements SearchIndexService {
|
|||
document.summary(),
|
||||
document.keywords(),
|
||||
document.searchText(),
|
||||
buildSemanticVector(document),
|
||||
document.visibility(),
|
||||
document.status()
|
||||
);
|
||||
|
|
@ -66,4 +72,17 @@ public class PostgresFullTextIndexService implements SearchIndexService {
|
|||
public void remove(Long skillId) {
|
||||
repository.deleteBySkillId(skillId);
|
||||
}
|
||||
|
||||
private String buildSemanticVector(SkillSearchDocument document) {
|
||||
return searchEmbeddingService.embed(String.join("\n",
|
||||
safe(document.title()),
|
||||
safe(document.title()),
|
||||
safe(document.summary()),
|
||||
safe(document.keywords()),
|
||||
safe(document.searchText())));
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
package com.iflytek.skillhub.search.postgres;
|
||||
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
|
||||
import com.iflytek.skillhub.search.SearchEmbeddingService;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchQueryService;
|
||||
import com.iflytek.skillhub.search.SearchResult;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
|
@ -20,9 +28,32 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
private static final String TITLE_SQL = "LOWER(title)";
|
||||
|
||||
private final EntityManager entityManager;
|
||||
private final SkillSearchDocumentJpaRepository searchDocumentRepository;
|
||||
private final SearchEmbeddingService searchEmbeddingService;
|
||||
private final boolean semanticEnabled;
|
||||
private final double semanticWeight;
|
||||
private final int candidateMultiplier;
|
||||
private final int maxCandidates;
|
||||
|
||||
public PostgresFullTextQueryService(EntityManager entityManager) {
|
||||
this(entityManager, null, null, false, 0.35D, 8, 120);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public PostgresFullTextQueryService(EntityManager entityManager,
|
||||
SkillSearchDocumentJpaRepository searchDocumentRepository,
|
||||
SearchEmbeddingService searchEmbeddingService,
|
||||
@Value("${skillhub.search.semantic.enabled:true}") boolean semanticEnabled,
|
||||
@Value("${skillhub.search.semantic.weight:0.35}") double semanticWeight,
|
||||
@Value("${skillhub.search.semantic.candidate-multiplier:8}") int candidateMultiplier,
|
||||
@Value("${skillhub.search.semantic.max-candidates:120}") int maxCandidates) {
|
||||
this.entityManager = entityManager;
|
||||
this.searchDocumentRepository = searchDocumentRepository;
|
||||
this.searchEmbeddingService = searchEmbeddingService;
|
||||
this.semanticEnabled = semanticEnabled;
|
||||
this.semanticWeight = semanticWeight;
|
||||
this.candidateMultiplier = candidateMultiplier;
|
||||
this.maxCandidates = maxCandidates;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -31,6 +62,21 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
String tsQuery = buildPrefixTsQuery(normalizedKeyword);
|
||||
boolean hasKeyword = tsQuery != null;
|
||||
boolean useShortPrefixTitleSearch = hasKeyword && normalizedKeyword.length() <= SHORT_PREFIX_LENGTH;
|
||||
boolean useSemanticRerank = semanticEnabled
|
||||
&& hasKeyword
|
||||
&& "relevance".equals(query.sortBy())
|
||||
&& searchDocumentRepository != null
|
||||
&& searchEmbeddingService != null;
|
||||
int requestedOffset = query.page() * query.size();
|
||||
if (useSemanticRerank && requestedOffset + query.size() > maxCandidates) {
|
||||
useSemanticRerank = false;
|
||||
}
|
||||
int sqlLimit = query.size();
|
||||
int sqlOffset = requestedOffset;
|
||||
if (useSemanticRerank) {
|
||||
sqlLimit = Math.min(Math.max((query.page() + 1) * query.size() * candidateMultiplier, query.size() * candidateMultiplier), maxCandidates);
|
||||
sqlOffset = 0;
|
||||
}
|
||||
Set<Long> memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty()
|
||||
? Set.of(-1L)
|
||||
: query.visibilityScope().memberNamespaceIds();
|
||||
|
|
@ -114,8 +160,8 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
nativeQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%");
|
||||
}
|
||||
|
||||
nativeQuery.setParameter("limit", query.size());
|
||||
nativeQuery.setParameter("offset", query.page() * query.size());
|
||||
nativeQuery.setParameter("limit", sqlLimit);
|
||||
nativeQuery.setParameter("offset", sqlOffset);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Long> skillIds = (List<Long>) nativeQuery.getResultList().stream()
|
||||
|
|
@ -152,9 +198,60 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
|
||||
long total = ((Number) countQuery.getSingleResult()).longValue();
|
||||
|
||||
if (useSemanticRerank && !skillIds.isEmpty()) {
|
||||
skillIds = rerankBySemanticSimilarity(skillIds, normalizedKeyword, requestedOffset, query.size());
|
||||
}
|
||||
|
||||
return new SearchResult(skillIds, total, query.page(), query.size());
|
||||
}
|
||||
|
||||
private List<Long> rerankBySemanticSimilarity(List<Long> candidateSkillIds,
|
||||
String normalizedKeyword,
|
||||
int requestedOffset,
|
||||
int pageSize) {
|
||||
Map<Long, SkillSearchDocumentEntity> documentsBySkillId = new HashMap<>();
|
||||
for (SkillSearchDocumentEntity entity : searchDocumentRepository.findBySkillIdIn(candidateSkillIds)) {
|
||||
documentsBySkillId.put(entity.getSkillId(), entity);
|
||||
}
|
||||
|
||||
int totalCandidates = Math.max(candidateSkillIds.size(), 1);
|
||||
List<RankedSkill> rankedSkills = new java.util.ArrayList<>(candidateSkillIds.size());
|
||||
for (int index = 0; index < candidateSkillIds.size(); index++) {
|
||||
Long skillId = candidateSkillIds.get(index);
|
||||
SkillSearchDocumentEntity entity = documentsBySkillId.get(skillId);
|
||||
double baseScore = 1D - (index / (double) totalCandidates);
|
||||
double semanticScore = computeSemanticScore(normalizedKeyword, entity);
|
||||
double combinedScore = (baseScore * (1D - semanticWeight)) + (semanticScore * semanticWeight);
|
||||
rankedSkills.add(new RankedSkill(skillId, combinedScore));
|
||||
}
|
||||
|
||||
return rankedSkills.stream()
|
||||
.sorted(Comparator.comparingDouble(RankedSkill::score).reversed())
|
||||
.skip(requestedOffset)
|
||||
.limit(pageSize)
|
||||
.map(RankedSkill::skillId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private double computeSemanticScore(String normalizedKeyword, SkillSearchDocumentEntity entity) {
|
||||
if (entity == null) {
|
||||
return 0D;
|
||||
}
|
||||
String serializedVector = entity.getSemanticVector();
|
||||
if (serializedVector == null || serializedVector.isBlank()) {
|
||||
serializedVector = searchEmbeddingService.embed(String.join("\n",
|
||||
safe(entity.getTitle()),
|
||||
safe(entity.getSummary()),
|
||||
safe(entity.getKeywords()),
|
||||
safe(entity.getSearchText())));
|
||||
}
|
||||
return searchEmbeddingService.similarity(normalizedKeyword, serializedVector);
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private String normalizeKeyword(String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return null;
|
||||
|
|
@ -183,4 +280,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
.reduce((left, right) -> left + " & " + right)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private record RankedSkill(Long skillId, double score) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
|
|||
skill.getSummary(),
|
||||
"",
|
||||
searchText,
|
||||
null,
|
||||
skill.getVisibility().name(),
|
||||
skill.getStatus().name()
|
||||
));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package com.iflytek.skillhub.search;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HashingSearchEmbeddingServiceTest {
|
||||
|
||||
private final HashingSearchEmbeddingService service = new HashingSearchEmbeddingService();
|
||||
|
||||
@Test
|
||||
void embedShouldBeDeterministic() {
|
||||
String first = service.embed("self improving skill");
|
||||
String second = service.embed("self improving skill");
|
||||
|
||||
assertThat(first).isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void similarityShouldFavorCloserText() {
|
||||
String relevantVector = service.embed("self improvement productivity habit tracker");
|
||||
String noisyVector = service.embed("web search keywords company research");
|
||||
|
||||
double relevant = service.similarity("self improvement", relevantVector);
|
||||
double noisy = service.similarity("self improvement", noisyVector);
|
||||
|
||||
assertThat(relevant).isGreaterThan(noisy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void similarityShouldHandleSingularAndPluralForms() {
|
||||
String pluralVector = service.embed("build strong habits with daily practice");
|
||||
String unrelatedVector = service.embed("research company profiles on the web");
|
||||
|
||||
double pluralMatch = service.similarity("habit", pluralVector);
|
||||
double unrelatedMatch = service.similarity("habit", unrelatedVector);
|
||||
|
||||
assertThat(pluralMatch).isGreaterThan(unrelatedMatch);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
package com.iflytek.skillhub.search.postgres;
|
||||
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
|
||||
import com.iflytek.skillhub.search.HashingSearchEmbeddingService;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchVisibilityScope;
|
||||
import jakarta.persistence.EntityManager;
|
||||
|
|
@ -165,4 +168,92 @@ class PostgresFullTextQueryServiceTest {
|
|||
verify(nativeQuery).setParameter("tsQuery", "self:* & improving:*");
|
||||
verify(countQuery).setParameter("tsQuery", "self:* & improving:*");
|
||||
}
|
||||
|
||||
@Test
|
||||
void semanticRerankShouldPromoteSemanticallyRelevantCandidate() {
|
||||
EntityManager entityManager = mock(EntityManager.class);
|
||||
Query nativeQuery = mock(Query.class);
|
||||
Query countQuery = mock(Query.class);
|
||||
SkillSearchDocumentJpaRepository repository = mock(SkillSearchDocumentJpaRepository.class);
|
||||
HashingSearchEmbeddingService embeddingService = new HashingSearchEmbeddingService();
|
||||
when(entityManager.createNativeQuery(anyString()))
|
||||
.thenReturn(nativeQuery)
|
||||
.thenReturn(countQuery);
|
||||
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
|
||||
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
|
||||
when(nativeQuery.getResultList()).thenReturn(List.of(2L, 1L));
|
||||
when(countQuery.getSingleResult()).thenReturn(2L);
|
||||
when(repository.findBySkillIdIn(List.of(2L, 1L))).thenReturn(List.of(
|
||||
new SkillSearchDocumentEntity(1L, 1L, "global", "user-1", "Self Improvement Coach",
|
||||
"Build better habits", "habits,self improvement", "habit tracker and self improvement guide",
|
||||
embeddingService.embed("habit tracker and self improvement guide"), "PUBLIC", "ACTIVE"),
|
||||
new SkillSearchDocumentEntity(2L, 1L, "global", "user-2", "Web Search Exa",
|
||||
"Research assistant", "keywords,search", "web search keywords company research",
|
||||
embeddingService.embed("web search keywords company research"), "PUBLIC", "ACTIVE")
|
||||
));
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(
|
||||
entityManager,
|
||||
repository,
|
||||
embeddingService,
|
||||
true,
|
||||
0.6D,
|
||||
8,
|
||||
120
|
||||
);
|
||||
|
||||
var result = service.search(new SearchQuery(
|
||||
"self improvement",
|
||||
null,
|
||||
new SearchVisibilityScope(null, Set.of(), Set.of()),
|
||||
"relevance",
|
||||
0,
|
||||
2
|
||||
));
|
||||
|
||||
verify(nativeQuery).setParameter("limit", 16);
|
||||
verify(nativeQuery).setParameter("offset", 0);
|
||||
assertThat(result.skillIds()).containsExactly(1L, 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deepSemanticPagesShouldFallBackToDatabasePagination() {
|
||||
EntityManager entityManager = mock(EntityManager.class);
|
||||
Query nativeQuery = mock(Query.class);
|
||||
Query countQuery = mock(Query.class);
|
||||
SkillSearchDocumentJpaRepository repository = mock(SkillSearchDocumentJpaRepository.class);
|
||||
HashingSearchEmbeddingService embeddingService = new HashingSearchEmbeddingService();
|
||||
when(entityManager.createNativeQuery(anyString()))
|
||||
.thenReturn(nativeQuery)
|
||||
.thenReturn(countQuery);
|
||||
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
|
||||
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
|
||||
when(nativeQuery.getResultList()).thenReturn(List.of(201L, 202L));
|
||||
when(countQuery.getSingleResult()).thenReturn(1000L);
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(
|
||||
entityManager,
|
||||
repository,
|
||||
embeddingService,
|
||||
true,
|
||||
0.6D,
|
||||
8,
|
||||
120
|
||||
);
|
||||
|
||||
var result = service.search(new SearchQuery(
|
||||
"self improvement",
|
||||
null,
|
||||
new SearchVisibilityScope(null, Set.of(), Set.of()),
|
||||
"relevance",
|
||||
20,
|
||||
10
|
||||
));
|
||||
|
||||
verify(nativeQuery).setParameter("limit", 10);
|
||||
verify(nativeQuery).setParameter("offset", 200);
|
||||
verify(repository, never()).findBySkillIdIn(org.mockito.ArgumentMatchers.anyList());
|
||||
assertThat(result.skillIds()).containsExactly(201L, 202L);
|
||||
assertThat(result.total()).isEqualTo(1000L);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' ws: wss: http://localhost:* https://localhost:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||
/>
|
||||
<title>SkillHub</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
PromotionTask,
|
||||
AuditLogItem,
|
||||
SkillSummary,
|
||||
SkillReport,
|
||||
AuthMethod,
|
||||
OAuthProvider,
|
||||
User,
|
||||
|
|
@ -411,6 +412,35 @@ export const accountApi = {
|
|||
},
|
||||
}
|
||||
|
||||
export const skillLifecycleApi = {
|
||||
async archiveSkill(namespace: string, slug: string, reason?: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/archive`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}),
|
||||
})
|
||||
},
|
||||
|
||||
async unarchiveSkill(namespace: string, slug: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/unarchive`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async deleteVersion(namespace: string, slug: string, version: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}`, {
|
||||
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', {
|
||||
|
|
@ -562,6 +592,49 @@ export const promotionApi = {
|
|||
},
|
||||
}
|
||||
|
||||
export const reportApi = {
|
||||
async submitSkillReport(namespace: string, slug: string, request: { reason: string; details?: string }): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/reports`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
|
||||
async listSkillReports(params: { status?: string; page?: number; size?: number }) {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('status', params.status ?? 'PENDING')
|
||||
searchParams.set('page', String(params.page ?? 0))
|
||||
searchParams.set('size', String(params.size ?? 20))
|
||||
return fetchJson<{ items: SkillReport[]; total: number; page: number; size: number }>(
|
||||
`/api/v1/admin/skill-reports?${searchParams.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
async resolveSkillReport(id: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
},
|
||||
|
||||
async dismissSkillReport(id: number, comment?: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const meApi = {
|
||||
async getStars(): Promise<SkillSummary[]> {
|
||||
return fetchJson<SkillSummary[]>(`${WEB_API_PREFIX}/me/stars`)
|
||||
|
|
@ -642,10 +715,27 @@ export const adminApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async getAuditLogs(params: { action?: string; userId?: string; page?: number; size?: number }) {
|
||||
async getAuditLogs(params: {
|
||||
action?: string
|
||||
userId?: string
|
||||
requestId?: string
|
||||
ipAddress?: string
|
||||
resourceType?: string
|
||||
resourceId?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.action) searchParams.set('action', params.action)
|
||||
if (params.userId) searchParams.set('userId', params.userId)
|
||||
if (params.requestId) searchParams.set('requestId', params.requestId)
|
||||
if (params.ipAddress) searchParams.set('ipAddress', params.ipAddress)
|
||||
if (params.resourceType) searchParams.set('resourceType', params.resourceType)
|
||||
if (params.resourceId) searchParams.set('resourceId', params.resourceId)
|
||||
if (params.startTime) searchParams.set('startTime', params.startTime)
|
||||
if (params.endTime) searchParams.set('endTime', params.endTime)
|
||||
searchParams.set('page', String(params.page ?? 0))
|
||||
searchParams.set('size', String(params.size ?? 20))
|
||||
return fetchJson<{ items: AuditLogItem[]; total: number; page: number; size: number }>(
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export interface SkillSummary {
|
|||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
status?: string
|
||||
downloadCount: number
|
||||
starCount: number
|
||||
ratingAvg?: number
|
||||
|
|
@ -131,6 +132,7 @@ export interface SkillDetail {
|
|||
hidden: boolean
|
||||
latestVersion?: string
|
||||
namespace: string
|
||||
canManageLifecycle: boolean
|
||||
}
|
||||
|
||||
export interface SkillVersion {
|
||||
|
|
@ -220,6 +222,22 @@ export interface PromotionTask {
|
|||
reviewedAt?: string
|
||||
}
|
||||
|
||||
export interface SkillReport {
|
||||
id: number
|
||||
skillId: number
|
||||
namespace?: string
|
||||
skillSlug?: string
|
||||
skillDisplayName?: string
|
||||
reporterId: string
|
||||
reason: string
|
||||
details?: string
|
||||
status: 'PENDING' | 'RESOLVED' | 'DISMISSED' | string
|
||||
handledBy?: string
|
||||
handleComment?: string
|
||||
createdAt: string
|
||||
handledAt?: string
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
userId: string
|
||||
username: string
|
||||
|
|
@ -232,7 +250,10 @@ export interface AdminUser {
|
|||
export interface AuditLogItem {
|
||||
id: string
|
||||
userId?: string
|
||||
username?: string
|
||||
action: string
|
||||
details?: string
|
||||
requestId?: string
|
||||
resourceType?: string
|
||||
resourceId?: string
|
||||
timestamp: string
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const NamespaceReviewsPage = createLazyRouteComponent(
|
|||
'NamespaceReviewsPage',
|
||||
)
|
||||
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
|
||||
const ReportsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reports'), 'ReportsPage')
|
||||
const ReviewDetailPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/review-detail'),
|
||||
'ReviewDetailPage',
|
||||
|
|
@ -212,6 +213,19 @@ const dashboardReviewsRoute = createRoute({
|
|||
component: ReviewsPage,
|
||||
})
|
||||
|
||||
const dashboardReportsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/reports',
|
||||
beforeLoad: async (ctx) => {
|
||||
const { user } = await requireAuth(ctx)
|
||||
if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) {
|
||||
throw redirect({ to: '/dashboard' })
|
||||
}
|
||||
return { user }
|
||||
},
|
||||
component: ReportsPage,
|
||||
})
|
||||
|
||||
const dashboardReviewDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/reviews/$id',
|
||||
|
|
@ -308,6 +322,7 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardNamespaceMembersRoute,
|
||||
dashboardNamespaceReviewsRoute,
|
||||
dashboardReviewsRoute,
|
||||
dashboardReportsRoute,
|
||||
dashboardReviewDetailRoute,
|
||||
dashboardPromotionsRoute,
|
||||
dashboardStarsRoute,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import type { AuditLogItem } from '@/api/types'
|
|||
export interface AuditLogParams {
|
||||
action?: string
|
||||
userId?: string
|
||||
requestId?: string
|
||||
ipAddress?: string
|
||||
resourceType?: string
|
||||
resourceId?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
|
|
|||
38
web/src/features/report/use-skill-reports.ts
Normal file
38
web/src/features/report/use-skill-reports.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { reportApi } from '@/api/client'
|
||||
|
||||
export function useSkillReports(status: string) {
|
||||
return useQuery({
|
||||
queryKey: ['skill-reports', status],
|
||||
queryFn: async () => {
|
||||
const page = await reportApi.listSkillReports({ status })
|
||||
return page.items
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubmitSkillReport(namespace: string, slug: string) {
|
||||
return useMutation({
|
||||
mutationFn: (request: { reason: string; details?: string }) => reportApi.submitSkillReport(namespace, slug, request),
|
||||
})
|
||||
}
|
||||
|
||||
export function useResolveSkillReport() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.resolveSkillReport(id, comment),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skill-reports'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDismissSkillReport() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.dismissSkillReport(id, comment),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skill-reports'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -182,14 +182,29 @@
|
|||
"credentials": "Credentials",
|
||||
"openTokens": "Open Token Page",
|
||||
"governanceTitle": "Review & Governance",
|
||||
"viewPromotions": "View Promotions"
|
||||
"viewPromotions": "View Promotions",
|
||||
"reportsTitle": "Report Management",
|
||||
"viewReports": "View skill reports"
|
||||
},
|
||||
"mySkills": {
|
||||
"title": "My Skills",
|
||||
"subtitle": "Manage your published skills",
|
||||
"publishNew": "Publish New Skill",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Restore",
|
||||
"statusArchived": "Archived",
|
||||
"statusPendingReview": "Pending Review",
|
||||
"statusPublished": "Published",
|
||||
"archiveConfirmTitle": "Archive skill",
|
||||
"archiveConfirmDescription": "After archiving, regular users will no longer be able to view or download \"{{skill}}\". Continue?",
|
||||
"unarchiveConfirmTitle": "Restore skill",
|
||||
"unarchiveConfirmDescription": "\"{{skill}}\" will become visible again and can publish new versions after being restored.",
|
||||
"archiveSuccessTitle": "Skill archived",
|
||||
"archiveSuccessDescription": "\"{{skill}}\" has been archived.",
|
||||
"archiveErrorTitle": "Failed to archive skill",
|
||||
"unarchiveSuccessTitle": "Skill restored",
|
||||
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored and can publish new versions again.",
|
||||
"unarchiveErrorTitle": "Failed to restore skill",
|
||||
"emptyTitle": "No skills yet",
|
||||
"emptyDescription": "Start publishing your first skill",
|
||||
"publishSkill": "Publish Skill"
|
||||
|
|
@ -293,6 +308,10 @@
|
|||
"filterPromotionApprove": "Promotion Approved",
|
||||
"filterYankVersion": "Version Yanked",
|
||||
"userIdPlaceholder": "User ID...",
|
||||
"requestIdPlaceholder": "Request ID...",
|
||||
"ipPlaceholder": "IP address...",
|
||||
"resourceTypePlaceholder": "Resource type...",
|
||||
"resourceIdPlaceholder": "Resource ID...",
|
||||
"empty": "No audit logs",
|
||||
"colTime": "Time",
|
||||
"colAction": "Action",
|
||||
|
|
@ -370,11 +389,67 @@
|
|||
"loginToRate": "Login to star and rate",
|
||||
"install": "Install",
|
||||
"download": "Download",
|
||||
"lifecycle": "Lifecycle",
|
||||
"lifecycleHint": "You can archive this skill. Archived skills are hidden from regular users and cannot be downloaded.",
|
||||
"archivedPublishHint": "This skill is archived. Restore it before publishing a new version.",
|
||||
"archivedInstallHint": "This skill is archived and not available for public download.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"statusHidden": "Hidden",
|
||||
"governance": "Governance",
|
||||
"processing": "Processing...",
|
||||
"archiveSkill": "Archive Skill",
|
||||
"hideSkill": "Hide Skill",
|
||||
"unhideSkill": "Unhide Skill",
|
||||
"yankVersion": "Yank Current Version"
|
||||
"archiveConfirmTitle": "Archive skill",
|
||||
"archiveConfirmDescription": "After archiving, regular users will no longer be able to view or download \"{{skill}}\". Continue?",
|
||||
"unarchiveConfirmTitle": "Restore skill",
|
||||
"unarchiveConfirmDescription": "\"{{skill}}\" will become visible again and can publish new versions after being restored.",
|
||||
"archiveSuccessTitle": "Skill archived",
|
||||
"archiveSuccessDescription": "\"{{skill}}\" has been archived.",
|
||||
"archiveErrorTitle": "Failed to archive skill",
|
||||
"unarchiveSuccessTitle": "Skill restored",
|
||||
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored.",
|
||||
"unarchiveErrorTitle": "Failed to restore skill",
|
||||
"deleteVersion": "Delete Version",
|
||||
"deleteVersionConfirmTitle": "Delete version",
|
||||
"deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?",
|
||||
"deleteVersionSuccessTitle": "Version deleted",
|
||||
"deleteVersionSuccessDescription": "Version {{version}} has been deleted.",
|
||||
"deleteVersionErrorTitle": "Failed to delete version",
|
||||
"yankVersion": "Yank Current Version",
|
||||
"reportSkill": "Report Skill",
|
||||
"reportDialogTitle": "Report skill",
|
||||
"reportDialogDescription": "Provide a reason so administrators can review and act on it quickly.",
|
||||
"reportReasonPlaceholder": "Reason, for example policy violation, misleading content, or infringement",
|
||||
"reportDetailsPlaceholder": "Additional details (optional)",
|
||||
"submitReport": "Submit Report",
|
||||
"reportReasonRequired": "Please provide a report reason",
|
||||
"reportSuccessTitle": "Report submitted",
|
||||
"reportSuccessDescription": "Administrators will review this report soon.",
|
||||
"reportErrorTitle": "Report failed"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Skill Reports",
|
||||
"subtitle": "Handle user-submitted skill reports",
|
||||
"tabPending": "Pending",
|
||||
"tabResolved": "Resolved",
|
||||
"tabDismissed": "Dismissed",
|
||||
"empty": "No reports",
|
||||
"reporter": "Reporter",
|
||||
"handledBy": "Handled by",
|
||||
"resolve": "Resolve",
|
||||
"dismiss": "Dismiss",
|
||||
"resolveConfirmTitle": "Resolve report",
|
||||
"resolveConfirmDescription": "Mark the report for \"{{skill}}\" as handled?",
|
||||
"dismissConfirmTitle": "Dismiss report",
|
||||
"dismissConfirmDescription": "Dismiss the report for \"{{skill}}\"?",
|
||||
"resolveSuccessTitle": "Report resolved",
|
||||
"resolveSuccessDescription": "The report for \"{{skill}}\" has been marked as handled.",
|
||||
"dismissSuccessTitle": "Report dismissed",
|
||||
"dismissSuccessDescription": "The report for \"{{skill}}\" has been dismissed.",
|
||||
"resolveErrorTitle": "Failed to resolve report",
|
||||
"dismissErrorTitle": "Failed to dismiss report"
|
||||
},
|
||||
"members": {
|
||||
"title": "Member Management",
|
||||
|
|
@ -471,6 +546,7 @@
|
|||
"stars": "Starred",
|
||||
"reviews": "Review Management",
|
||||
"promotions": "Promotion Management",
|
||||
"reports": "Report Management",
|
||||
"users": "User Management",
|
||||
"auditLog": "Audit Log",
|
||||
"security": "Security Settings",
|
||||
|
|
|
|||
|
|
@ -182,14 +182,29 @@
|
|||
"credentials": "访问凭证",
|
||||
"openTokens": "打开 Token 页面",
|
||||
"governanceTitle": "审核与治理",
|
||||
"viewPromotions": "查看提升审核"
|
||||
"viewPromotions": "查看提升审核",
|
||||
"reportsTitle": "举报管理",
|
||||
"viewReports": "查看技能举报"
|
||||
},
|
||||
"mySkills": {
|
||||
"title": "我的技能",
|
||||
"subtitle": "管理你发布的技能",
|
||||
"publishNew": "发布新技能",
|
||||
"archive": "归档",
|
||||
"unarchive": "恢复",
|
||||
"statusArchived": "已归档",
|
||||
"statusPendingReview": "审核中",
|
||||
"statusPublished": "已发布",
|
||||
"archiveConfirmTitle": "确认归档技能",
|
||||
"archiveConfirmDescription": "归档后普通用户将无法看到或下载“{{skill}}”,确定继续吗?",
|
||||
"unarchiveConfirmTitle": "确认恢复技能",
|
||||
"unarchiveConfirmDescription": "恢复后“{{skill}}”会重新对外可见,并允许继续发布新版本。",
|
||||
"archiveSuccessTitle": "技能已归档",
|
||||
"archiveSuccessDescription": "“{{skill}}”已归档。",
|
||||
"archiveErrorTitle": "归档技能失败",
|
||||
"unarchiveSuccessTitle": "技能已恢复",
|
||||
"unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。",
|
||||
"unarchiveErrorTitle": "恢复技能失败",
|
||||
"emptyTitle": "还没有技能",
|
||||
"emptyDescription": "开始发布你的第一个技能吧",
|
||||
"publishSkill": "发布技能"
|
||||
|
|
@ -293,6 +308,10 @@
|
|||
"filterPromotionApprove": "提升通过",
|
||||
"filterYankVersion": "版本撤回",
|
||||
"userIdPlaceholder": "用户 ID...",
|
||||
"requestIdPlaceholder": "请求 ID...",
|
||||
"ipPlaceholder": "IP 地址...",
|
||||
"resourceTypePlaceholder": "资源类型...",
|
||||
"resourceIdPlaceholder": "资源 ID...",
|
||||
"empty": "暂无审计日志",
|
||||
"colTime": "时间",
|
||||
"colAction": "操作",
|
||||
|
|
@ -370,11 +389,67 @@
|
|||
"loginToRate": "登录后可以收藏和评分",
|
||||
"install": "安装",
|
||||
"download": "下载",
|
||||
"lifecycle": "生命周期管理",
|
||||
"lifecycleHint": "你可以归档这个技能,归档后普通用户将无法查看或下载。",
|
||||
"archivedPublishHint": "该技能已归档,请先恢复后再继续发布新版本。",
|
||||
"archivedInstallHint": "该技能已归档,普通用户不可下载。",
|
||||
"statusActive": "正常",
|
||||
"statusArchived": "已归档",
|
||||
"statusHidden": "已隐藏",
|
||||
"governance": "治理操作",
|
||||
"processing": "处理中...",
|
||||
"archiveSkill": "归档技能",
|
||||
"hideSkill": "隐藏技能",
|
||||
"unhideSkill": "恢复技能",
|
||||
"yankVersion": "撤回当前版本"
|
||||
"archiveConfirmTitle": "确认归档技能",
|
||||
"archiveConfirmDescription": "归档后普通用户将无法看到或下载“{{skill}}”,确定继续吗?",
|
||||
"unarchiveConfirmTitle": "确认恢复技能",
|
||||
"unarchiveConfirmDescription": "恢复后“{{skill}}”会重新对外可见,并允许继续发布新版本。",
|
||||
"archiveSuccessTitle": "技能已归档",
|
||||
"archiveSuccessDescription": "“{{skill}}”已归档。",
|
||||
"archiveErrorTitle": "归档技能失败",
|
||||
"unarchiveSuccessTitle": "技能已恢复",
|
||||
"unarchiveSuccessDescription": "“{{skill}}”已恢复。",
|
||||
"unarchiveErrorTitle": "恢复技能失败",
|
||||
"deleteVersion": "删除版本",
|
||||
"deleteVersionConfirmTitle": "确认删除版本",
|
||||
"deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?",
|
||||
"deleteVersionSuccessTitle": "版本已删除",
|
||||
"deleteVersionSuccessDescription": "版本 {{version}} 已删除。",
|
||||
"deleteVersionErrorTitle": "删除版本失败",
|
||||
"yankVersion": "撤回当前版本",
|
||||
"reportSkill": "举报技能",
|
||||
"reportDialogTitle": "举报技能",
|
||||
"reportDialogDescription": "请填写举报原因,帮助管理员快速判断和处理。",
|
||||
"reportReasonPlaceholder": "举报原因,例如包含违规内容、恶意误导、侵权等",
|
||||
"reportDetailsPlaceholder": "补充说明(可选)",
|
||||
"submitReport": "提交举报",
|
||||
"reportReasonRequired": "请先填写举报原因",
|
||||
"reportSuccessTitle": "举报已提交",
|
||||
"reportSuccessDescription": "管理员将尽快处理这条举报。",
|
||||
"reportErrorTitle": "举报失败"
|
||||
},
|
||||
"reports": {
|
||||
"title": "技能举报",
|
||||
"subtitle": "处理用户提交的技能举报",
|
||||
"tabPending": "待处理",
|
||||
"tabResolved": "已处理",
|
||||
"tabDismissed": "已驳回",
|
||||
"empty": "暂无举报记录",
|
||||
"reporter": "举报人",
|
||||
"handledBy": "处理人",
|
||||
"resolve": "标记处理",
|
||||
"dismiss": "驳回举报",
|
||||
"resolveConfirmTitle": "确认处理举报",
|
||||
"resolveConfirmDescription": "确认将“{{skill}}”的举报标记为已处理吗?",
|
||||
"dismissConfirmTitle": "确认驳回举报",
|
||||
"dismissConfirmDescription": "确认驳回“{{skill}}”的这条举报吗?",
|
||||
"resolveSuccessTitle": "举报已处理",
|
||||
"resolveSuccessDescription": "“{{skill}}”的举报已标记为已处理。",
|
||||
"dismissSuccessTitle": "举报已驳回",
|
||||
"dismissSuccessDescription": "“{{skill}}”的举报已驳回。",
|
||||
"resolveErrorTitle": "处理举报失败",
|
||||
"dismissErrorTitle": "驳回举报失败"
|
||||
},
|
||||
"members": {
|
||||
"title": "成员管理",
|
||||
|
|
@ -471,6 +546,7 @@
|
|||
"stars": "我的收藏",
|
||||
"reviews": "审核管理",
|
||||
"promotions": "推广管理",
|
||||
"reports": "举报管理",
|
||||
"users": "用户管理",
|
||||
"auditLog": "审计日志",
|
||||
"security": "安全设置",
|
||||
|
|
|
|||
|
|
@ -18,11 +18,23 @@ export function AuditLogPage() {
|
|||
const { t, i18n } = useTranslation()
|
||||
const [actionFilter, setActionFilter] = useState<string>('')
|
||||
const [userIdFilter, setUserIdFilter] = useState('')
|
||||
const [requestIdFilter, setRequestIdFilter] = useState('')
|
||||
const [ipFilter, setIpFilter] = useState('')
|
||||
const [resourceTypeFilter, setResourceTypeFilter] = useState('')
|
||||
const [resourceIdFilter, setResourceIdFilter] = useState('')
|
||||
const [startTimeFilter, setStartTimeFilter] = useState('')
|
||||
const [endTimeFilter, setEndTimeFilter] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const { data, isLoading } = useAuditLog({
|
||||
action: actionFilter || undefined,
|
||||
userId: userIdFilter || undefined,
|
||||
requestId: requestIdFilter || undefined,
|
||||
ipAddress: ipFilter || undefined,
|
||||
resourceType: resourceTypeFilter || undefined,
|
||||
resourceId: resourceIdFilter || undefined,
|
||||
startTime: startTimeFilter ? new Date(startTimeFilter).toISOString() : undefined,
|
||||
endTime: endTimeFilter ? new Date(endTimeFilter).toISOString() : undefined,
|
||||
page,
|
||||
size: 20,
|
||||
})
|
||||
|
|
@ -39,8 +51,11 @@ export function AuditLogPage() {
|
|||
</div>
|
||||
|
||||
<Card className="p-5">
|
||||
<div className="flex gap-4">
|
||||
<Select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="w-[200px]">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Select value={actionFilter} onChange={(e) => {
|
||||
setActionFilter(e.target.value)
|
||||
setPage(0)
|
||||
}} className="w-[200px]">
|
||||
<option value="">{t('auditLog.filterAll')}</option>
|
||||
<option value="CLI_PUBLISH">{t('auditLog.filterCliPublish')}</option>
|
||||
<option value="COMPAT_PUBLISH">{t('auditLog.filterCompatPublish')}</option>
|
||||
|
|
@ -52,8 +67,58 @@ export function AuditLogPage() {
|
|||
<Input
|
||||
placeholder={t('auditLog.userIdPlaceholder')}
|
||||
value={userIdFilter}
|
||||
onChange={(e) => setUserIdFilter(e.target.value)}
|
||||
className="w-[200px]"
|
||||
onChange={(e) => {
|
||||
setUserIdFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('auditLog.requestIdPlaceholder')}
|
||||
value={requestIdFilter}
|
||||
onChange={(e) => {
|
||||
setRequestIdFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('auditLog.ipPlaceholder')}
|
||||
value={ipFilter}
|
||||
onChange={(e) => {
|
||||
setIpFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('auditLog.resourceTypePlaceholder')}
|
||||
value={resourceTypeFilter}
|
||||
onChange={(e) => {
|
||||
setResourceTypeFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('auditLog.resourceIdPlaceholder')}
|
||||
value={resourceIdFilter}
|
||||
onChange={(e) => {
|
||||
setResourceIdFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startTimeFilter}
|
||||
onChange={(e) => {
|
||||
setStartTimeFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={endTimeFilter}
|
||||
onChange={(e) => {
|
||||
setEndTimeFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -88,10 +153,10 @@ export function AuditLogPage() {
|
|||
<TableCell>{formatDate(log.timestamp)}</TableCell>
|
||||
<TableCell className="font-medium">{log.action}</TableCell>
|
||||
<TableCell>{log.userId || '-'}</TableCell>
|
||||
<TableCell>{log.resourceType || '-'}</TableCell>
|
||||
<TableCell>{log.username || '-'}</TableCell>
|
||||
<TableCell>{log.ipAddress || '-'}</TableCell>
|
||||
<TableCell className="max-w-md truncate">
|
||||
{log.resourceId || '-'}
|
||||
{log.details || `${log.resourceType || '-'}:${log.resourceId || '-'}`}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/sha
|
|||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
const { user, hasRole } = useAuth()
|
||||
const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
|
|
@ -58,7 +59,7 @@ export function DashboardPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className={`grid grid-cols-1 gap-4 ${governanceVisible ? 'md:grid-cols-4' : 'md:grid-cols-3'}`}>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.starsAndRatings')}</div>
|
||||
<Link to="/dashboard/stars" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
|
|
@ -77,6 +78,14 @@ export function DashboardPage() {
|
|||
{t('dashboard.viewPromotions')}
|
||||
</Link>
|
||||
</Card>
|
||||
{governanceVisible ? (
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.reportsTitle')}</div>
|
||||
<Link to="/dashboard/reports" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
{t('dashboard.viewReports')}
|
||||
</Link>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TokenList />
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { useMySkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { useArchiveSkill, useMySkills, useUnarchiveSkill } from '@/shared/hooks/use-skill-queries'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
||||
export function MySkillsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const { data: skills, isLoading } = useMySkills()
|
||||
const archiveMutation = useArchiveSkill()
|
||||
const unarchiveMutation = useUnarchiveSkill()
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
navigate({ to: `/space/${namespace}/${slug}` })
|
||||
}
|
||||
|
||||
const resolveStatusLabel = (status?: string) => {
|
||||
if (status === 'ARCHIVED') {
|
||||
return t('mySkills.statusArchived')
|
||||
}
|
||||
if (status === 'PENDING_REVIEW') {
|
||||
return t('mySkills.statusPendingReview')
|
||||
}
|
||||
|
|
@ -27,6 +37,9 @@ export function MySkillsPage() {
|
|||
}
|
||||
|
||||
const resolveStatusClassName = (status?: string) => {
|
||||
if (status === 'ARCHIVED') {
|
||||
return 'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}
|
||||
if (status === 'PENDING_REVIEW') {
|
||||
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}
|
||||
|
|
@ -36,6 +49,46 @@ export function MySkillsPage() {
|
|||
return 'bg-secondary/60 text-muted-foreground border-border/40'
|
||||
}
|
||||
|
||||
const handleArchiveSkill = async () => {
|
||||
if (!archiveTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await archiveMutation.mutateAsync({
|
||||
namespace: archiveTarget.namespace,
|
||||
slug: archiveTarget.slug,
|
||||
})
|
||||
toast.success(
|
||||
t('mySkills.archiveSuccessTitle'),
|
||||
t('mySkills.archiveSuccessDescription', { skill: archiveTarget.name }),
|
||||
)
|
||||
setArchiveTarget(null)
|
||||
} catch (error) {
|
||||
toast.error(t('mySkills.archiveErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchiveSkill = async () => {
|
||||
if (!unarchiveTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await unarchiveMutation.mutateAsync({
|
||||
namespace: unarchiveTarget.namespace,
|
||||
slug: unarchiveTarget.slug,
|
||||
})
|
||||
toast.success(
|
||||
t('mySkills.unarchiveSuccessTitle'),
|
||||
t('mySkills.unarchiveSuccessDescription', { skill: unarchiveTarget.name }),
|
||||
)
|
||||
setUnarchiveTarget(null)
|
||||
} catch (error) {
|
||||
toast.error(t('mySkills.unarchiveErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
|
|
@ -79,6 +132,11 @@ export function MySkillsPage() {
|
|||
{skill.latestVersion && (
|
||||
<span className="font-mono text-xs">v{skill.latestVersion}</span>
|
||||
)}
|
||||
{skill.status ? (
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs ${resolveStatusClassName(skill.status)}`}>
|
||||
{resolveStatusLabel(skill.status)}
|
||||
</span>
|
||||
) : null}
|
||||
{skill.latestVersionStatus ? (
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs ${resolveStatusClassName(skill.latestVersionStatus)}`}>
|
||||
{resolveStatusLabel(skill.latestVersionStatus)}
|
||||
|
|
@ -92,9 +150,42 @@ export function MySkillsPage() {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
{skill.status === 'ARCHIVED' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setUnarchiveTarget({
|
||||
namespace: skill.namespace,
|
||||
slug: skill.slug,
|
||||
name: skill.displayName,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('mySkills.unarchive')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setArchiveTarget({
|
||||
namespace: skill.namespace,
|
||||
slug: skill.slug,
|
||||
name: skill.displayName,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('mySkills.archive')}
|
||||
</Button>
|
||||
)}
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -110,6 +201,32 @@ export function MySkillsPage() {
|
|||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setArchiveTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('mySkills.archiveConfirmTitle')}
|
||||
description={archiveTarget ? t('mySkills.archiveConfirmDescription', { skill: archiveTarget.name }) : ''}
|
||||
confirmText={t('mySkills.archive')}
|
||||
onConfirm={handleArchiveSkill}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!unarchiveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setUnarchiveTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('mySkills.unarchiveConfirmTitle')}
|
||||
description={unarchiveTarget ? t('mySkills.unarchiveConfirmDescription', { skill: unarchiveTarget.name }) : ''}
|
||||
confirmText={t('mySkills.unarchive')}
|
||||
onConfirm={handleUnarchiveSkill}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
161
web/src/pages/dashboard/reports.tsx
Normal file
161
web/src/pages/dashboard/reports.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { useDismissSkillReport, useResolveSkillReport, useSkillReports } from '@/features/report/use-skill-reports'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
||||
export function ReportsPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [pendingAction, setPendingAction] = useState<{ id: number; action: 'resolve' | 'dismiss'; skillLabel: string } | null>(null)
|
||||
const { data: pendingReports, isLoading: isPendingLoading } = useSkillReports('PENDING')
|
||||
const { data: resolvedReports, isLoading: isResolvedLoading } = useSkillReports('RESOLVED')
|
||||
const { data: dismissedReports, isLoading: isDismissedLoading } = useSkillReports('DISMISSED')
|
||||
const resolveMutation = useResolveSkillReport()
|
||||
const dismissMutation = useDismissSkillReport()
|
||||
|
||||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString(i18n.language)
|
||||
|
||||
const handleOpenSkill = (namespace?: string, skillSlug?: string) => {
|
||||
if (!namespace || !skillSlug) {
|
||||
return
|
||||
}
|
||||
navigate({ to: `/space/${namespace}/${skillSlug}` })
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!pendingAction) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (pendingAction.action === 'resolve') {
|
||||
await resolveMutation.mutateAsync({ id: pendingAction.id })
|
||||
toast.success(t('reports.resolveSuccessTitle'), t('reports.resolveSuccessDescription', { skill: pendingAction.skillLabel }))
|
||||
} else {
|
||||
await dismissMutation.mutateAsync({ id: pendingAction.id })
|
||||
toast.success(t('reports.dismissSuccessTitle'), t('reports.dismissSuccessDescription', { skill: pendingAction.skillLabel }))
|
||||
}
|
||||
setPendingAction(null)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
pendingAction.action === 'resolve' ? t('reports.resolveErrorTitle') : t('reports.dismissErrorTitle'),
|
||||
error instanceof Error ? error.message : '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const renderList = (reports: typeof pendingReports, isLoading: boolean, status: 'PENDING' | 'RESOLVED' | 'DISMISSED') => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-24 animate-shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!reports || reports.length === 0) {
|
||||
return <Card className="p-12 text-center text-muted-foreground">{t('reports.empty')}</Card>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{reports.map((report) => {
|
||||
const skillLabel = report.skillDisplayName || report.skillSlug || `#${report.skillId}`
|
||||
return (
|
||||
<Card key={report.id} className="p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left font-semibold font-heading text-foreground hover:text-primary transition-colors"
|
||||
onClick={() => handleOpenSkill(report.namespace, report.skillSlug)}
|
||||
>
|
||||
{report.namespace && report.skillSlug ? `${report.namespace}/${report.skillSlug}` : skillLabel}
|
||||
</button>
|
||||
<div className="text-sm text-muted-foreground">{skillLabel}</div>
|
||||
<div className="text-sm text-foreground">{report.reason}</div>
|
||||
{report.details ? <div className="text-sm text-muted-foreground whitespace-pre-wrap">{report.details}</div> : null}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground space-y-1 shrink-0">
|
||||
<div>{t('reports.reporter')}: {report.reporterId}</div>
|
||||
<div>{formatDate(report.createdAt)}</div>
|
||||
{report.handledAt ? <div>{formatDate(report.handledAt)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === 'PENDING' ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={resolveMutation.isPending || dismissMutation.isPending}
|
||||
onClick={() => setPendingAction({ id: report.id, action: 'dismiss', skillLabel })}
|
||||
>
|
||||
{t('reports.dismiss')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={resolveMutation.isPending || dismissMutation.isPending}
|
||||
onClick={() => setPendingAction({ id: report.id, action: 'resolve', skillLabel })}
|
||||
>
|
||||
{t('reports.resolve')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('reports.handledBy')}: {report.handledBy || '—'}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('reports.title')} subtitle={t('reports.subtitle')} />
|
||||
|
||||
<Tabs defaultValue="PENDING">
|
||||
<TabsList>
|
||||
<TabsTrigger value="PENDING">{t('reports.tabPending')}</TabsTrigger>
|
||||
<TabsTrigger value="RESOLVED">{t('reports.tabResolved')}</TabsTrigger>
|
||||
<TabsTrigger value="DISMISSED">{t('reports.tabDismissed')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="PENDING" className="mt-6">
|
||||
{renderList(pendingReports, isPendingLoading, 'PENDING')}
|
||||
</TabsContent>
|
||||
<TabsContent value="RESOLVED" className="mt-6">
|
||||
{renderList(resolvedReports, isResolvedLoading, 'RESOLVED')}
|
||||
</TabsContent>
|
||||
<TabsContent value="DISMISSED" className="mt-6">
|
||||
{renderList(dismissedReports, isDismissedLoading, 'DISMISSED')}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingAction !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingAction(null)
|
||||
}
|
||||
}}
|
||||
title={pendingAction?.action === 'resolve' ? t('reports.resolveConfirmTitle') : t('reports.dismissConfirmTitle')}
|
||||
description={pendingAction?.action === 'resolve'
|
||||
? t('reports.resolveConfirmDescription', { skill: pendingAction?.skillLabel ?? '' })
|
||||
: t('reports.dismissConfirmDescription', { skill: pendingAction?.skillLabel ?? '' })}
|
||||
confirmText={pendingAction?.action === 'resolve' ? t('reports.resolve') : t('reports.dismiss')}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
|
@ -9,17 +10,26 @@ import { RatingInput } from '@/features/social/rating-input'
|
|||
import { StarButton } from '@/features/social/star-button'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { adminApi, WEB_API_PREFIX } from '@/api/client'
|
||||
import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import {
|
||||
useSkillDetail,
|
||||
useSkillVersions,
|
||||
useSkillFiles,
|
||||
useSkillReadme,
|
||||
useArchiveSkill,
|
||||
useDeleteSkillVersion,
|
||||
useUnarchiveSkill,
|
||||
} from '@/shared/hooks/use-skill-queries'
|
||||
|
||||
export function SkillDetailPage() {
|
||||
|
|
@ -27,6 +37,12 @@ export function SkillDetailPage() {
|
|||
const navigate = useNavigate()
|
||||
const location = useRouterState({ select: (s) => s.location })
|
||||
const queryClient = useQueryClient()
|
||||
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
||||
const [reportReason, setReportReason] = useState('')
|
||||
const [reportDetails, setReportDetails] = useState('')
|
||||
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false)
|
||||
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
|
||||
const [deleteVersionTarget, setDeleteVersionTarget] = useState<string | null>(null)
|
||||
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
|
||||
const { user, hasRole } = useAuth()
|
||||
|
||||
|
|
@ -57,6 +73,10 @@ export function SkillDetailPage() {
|
|||
mutationFn: () => adminApi.yankVersion(latestVersion!.id),
|
||||
onSuccess: refreshSkill,
|
||||
})
|
||||
const archiveMutation = useArchiveSkill()
|
||||
const unarchiveMutation = useUnarchiveSkill()
|
||||
const deleteVersionMutation = useDeleteSkillVersion()
|
||||
const reportMutation = useSubmitSkillReport(namespace, slug)
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!user) {
|
||||
|
|
@ -80,6 +100,34 @@ export function SkillDetailPage() {
|
|||
})
|
||||
}
|
||||
|
||||
const handleOpenReport = () => {
|
||||
if (!user) {
|
||||
requireLogin()
|
||||
return
|
||||
}
|
||||
setReportDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmitReport = async () => {
|
||||
if (!reportReason.trim()) {
|
||||
toast.error(t('skillDetail.reportReasonRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await reportMutation.mutateAsync({
|
||||
reason: reportReason.trim(),
|
||||
details: reportDetails.trim() || undefined,
|
||||
})
|
||||
setReportDialogOpen(false)
|
||||
setReportReason('')
|
||||
setReportDetails('')
|
||||
toast.success(t('skillDetail.reportSuccessTitle'), t('skillDetail.reportSuccessDescription'))
|
||||
} catch (error) {
|
||||
toast.error(t('skillDetail.reportErrorTitle'), error instanceof Error ? error.message : '')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back()
|
||||
|
|
@ -88,6 +136,66 @@ export function SkillDetailPage() {
|
|||
navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })
|
||||
}
|
||||
|
||||
const resolveSkillStatusLabel = (status?: string) => {
|
||||
if (status === 'ARCHIVED') {
|
||||
return t('skillDetail.statusArchived')
|
||||
}
|
||||
if (status === 'ACTIVE') {
|
||||
return t('skillDetail.statusActive')
|
||||
}
|
||||
if (status === 'HIDDEN') {
|
||||
return t('skillDetail.statusHidden')
|
||||
}
|
||||
return status ?? ''
|
||||
}
|
||||
|
||||
const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED'
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await archiveMutation.mutateAsync({ namespace, slug })
|
||||
toast.success(
|
||||
t('skillDetail.archiveSuccessTitle'),
|
||||
t('skillDetail.archiveSuccessDescription', { skill: skill?.displayName ?? slug }),
|
||||
)
|
||||
setArchiveConfirmOpen(false)
|
||||
} catch (error) {
|
||||
toast.error(t('skillDetail.archiveErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchive = async () => {
|
||||
try {
|
||||
await unarchiveMutation.mutateAsync({ namespace, slug })
|
||||
toast.success(
|
||||
t('skillDetail.unarchiveSuccessTitle'),
|
||||
t('skillDetail.unarchiveSuccessDescription', { skill: skill?.displayName ?? slug }),
|
||||
)
|
||||
setUnarchiveConfirmOpen(false)
|
||||
} catch (error) {
|
||||
toast.error(t('skillDetail.unarchiveErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteVersion = async () => {
|
||||
if (!deleteVersionTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteVersionMutation.mutateAsync({ namespace, slug, version: deleteVersionTarget })
|
||||
toast.success(
|
||||
t('skillDetail.deleteVersionSuccessTitle'),
|
||||
t('skillDetail.deleteVersionSuccessDescription', { version: deleteVersionTarget }),
|
||||
)
|
||||
setDeleteVersionTarget(null)
|
||||
} catch (error) {
|
||||
toast.error(t('skillDetail.deleteVersionErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingSkill) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
|
|
@ -144,6 +252,11 @@ export function SkillDetailPage() {
|
|||
</Button>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<NamespaceBadge type="GLOBAL" name={namespace} />
|
||||
{skill.status && (
|
||||
<span className="rounded-full border border-border/60 bg-secondary/40 px-2.5 py-0.5 text-xs text-muted-foreground">
|
||||
{resolveSkillStatusLabel(skill.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-heading text-foreground">{skill.displayName}</h1>
|
||||
{skill.summary && (
|
||||
|
|
@ -191,10 +304,26 @@ export function SkillDetailPage() {
|
|||
<span className="px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-sm font-mono">
|
||||
v{version.version}
|
||||
</span>
|
||||
{version.status && (
|
||||
<span className="rounded-full border border-border/60 bg-secondary/40 px-2.5 py-0.5 text-xs text-muted-foreground">
|
||||
{version.status}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatLocalDateTime(version.publishedAt, i18n.language)}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatLocalDateTime(version.publishedAt, i18n.language)}
|
||||
</span>
|
||||
{skill.canManageLifecycle && canDeleteVersion(version.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteVersionTarget(version.version)}
|
||||
>
|
||||
{t('skillDetail.deleteVersion')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{version.changelog && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{version.changelog}</p>
|
||||
|
|
@ -253,6 +382,9 @@ export function SkillDetailPage() {
|
|||
<div className="space-y-3">
|
||||
<StarButton skillId={skill.id} starCount={skill.starCount} onRequireLogin={requireLogin} />
|
||||
<RatingInput skillId={skill.id} onRequireLogin={requireLogin} />
|
||||
<Button variant="outline" className="w-full" onClick={handleOpenReport} disabled={reportMutation.isPending}>
|
||||
{reportMutation.isPending ? t('skillDetail.processing') : t('skillDetail.reportSkill')}
|
||||
</Button>
|
||||
{!user && (
|
||||
<p className="text-xs text-muted-foreground">{t('skillDetail.loginToRate')}</p>
|
||||
)}
|
||||
|
|
@ -262,6 +394,9 @@ export function SkillDetailPage() {
|
|||
{skill.latestVersion && (
|
||||
<Card className="p-5 space-y-4">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.install')}</div>
|
||||
{skill.status === 'ARCHIVED' && (
|
||||
<p className="text-sm text-muted-foreground">{t('skillDetail.archivedInstallHint')}</p>
|
||||
)}
|
||||
<InstallCommand
|
||||
namespace={namespace}
|
||||
slug={slug}
|
||||
|
|
@ -275,7 +410,7 @@ export function SkillDetailPage() {
|
|||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleDownload}
|
||||
disabled={!latestVersion}
|
||||
disabled={!latestVersion || skill.status === 'ARCHIVED'}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
|
|
@ -283,6 +418,26 @@ export function SkillDetailPage() {
|
|||
{t('skillDetail.download')}
|
||||
</Button>
|
||||
|
||||
{skill.canManageLifecycle && (
|
||||
<Card className="p-5 space-y-3">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.lifecycle')}</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.status === 'ARCHIVED'
|
||||
? t('skillDetail.archivedPublishHint')
|
||||
: t('skillDetail.lifecycleHint')}
|
||||
</p>
|
||||
{skill.status === 'ARCHIVED' ? (
|
||||
<Button variant="outline" onClick={() => setUnarchiveConfirmOpen(true)} disabled={unarchiveMutation.isPending}>
|
||||
{unarchiveMutation.isPending ? t('skillDetail.processing') : t('skillDetail.unarchiveSkill')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setArchiveConfirmOpen(true)} disabled={archiveMutation.isPending}>
|
||||
{archiveMutation.isPending ? t('skillDetail.processing') : t('skillDetail.archiveSkill')}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{governanceVisible && (
|
||||
<Card className="p-5 space-y-3">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.governance')}</div>
|
||||
|
|
@ -305,6 +460,69 @@ export function SkillDetailPage() {
|
|||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('skillDetail.reportDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('skillDetail.reportDialogDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
value={reportReason}
|
||||
onChange={(event) => setReportReason(event.target.value)}
|
||||
placeholder={t('skillDetail.reportReasonPlaceholder')}
|
||||
maxLength={200}
|
||||
/>
|
||||
<Textarea
|
||||
value={reportDetails}
|
||||
onChange={(event) => setReportDetails(event.target.value)}
|
||||
placeholder={t('skillDetail.reportDetailsPlaceholder')}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReportDialogOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmitReport} disabled={reportMutation.isPending}>
|
||||
{reportMutation.isPending ? t('skillDetail.processing') : t('skillDetail.submitReport')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={archiveConfirmOpen}
|
||||
onOpenChange={setArchiveConfirmOpen}
|
||||
title={t('skillDetail.archiveConfirmTitle')}
|
||||
description={t('skillDetail.archiveConfirmDescription', { skill: skill.displayName })}
|
||||
confirmText={t('skillDetail.archiveSkill')}
|
||||
onConfirm={handleArchive}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={unarchiveConfirmOpen}
|
||||
onOpenChange={setUnarchiveConfirmOpen}
|
||||
title={t('skillDetail.unarchiveConfirmTitle')}
|
||||
description={t('skillDetail.unarchiveConfirmDescription', { skill: skill.displayName })}
|
||||
confirmText={t('skillDetail.unarchiveSkill')}
|
||||
onConfirm={handleUnarchive}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteVersionTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteVersionTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('skillDetail.deleteVersionConfirmTitle')}
|
||||
description={deleteVersionTarget ? t('skillDetail.deleteVersionConfirmDescription', { version: deleteVersionTarget }) : ''}
|
||||
confirmText={t('skillDetail.deleteVersion')}
|
||||
variant="destructive"
|
||||
onConfirm={handleDeleteVersion}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue