diff --git a/docs/00-product-direction.md b/docs/00-product-direction.md index ce6d4f44..fd9f4fda 100644 --- a/docs/00-product-direction.md +++ b/docs/00-product-direction.md @@ -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` 字段) diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 5dfe3b90..098f2e66 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -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 时: diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index e01549c4..73c8e794 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -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` 实现,后续可替换为消息队列。 diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 6b6b5efc..976ed7e2 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -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}` | 本地哈希与远端比对 | diff --git a/docs/10-delivery-roadmap.md b/docs/10-delivery-roadmap.md index 553f2949..5ef9eb3c 100644 --- a/docs/10-delivery-roadmap.md +++ b/docs/10-delivery-roadmap.md @@ -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 等) +- 向量搜索第二阶段增强(当前第一阶段仅做搜索增强,不做推荐) ## 主要风险与应对 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index 6bcba1a6..3fcaac46 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -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 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; + } + } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 602b6b5b..daea30e0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -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 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; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java new file mode 100644 index 00000000..0bdb76fa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java @@ -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> 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 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 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())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java index 21fa7405..2616ea7e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java @@ -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)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index bc294609..059da89b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -70,7 +70,8 @@ public class SkillController extends BaseApiController { detail.ratingCount(), detail.hidden(), detail.latestVersion(), - namespace + namespace, + detail.canManageLifecycle() ); return ok("response.success.read", response); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java new file mode 100644 index 00000000..604119ff --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -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 archiveSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestBody(required = false) AdminSkillActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 unarchiveSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 deleteVersion(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java new file mode 100644 index 00000000..ca9c5b3a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java @@ -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 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)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java new file mode 100644 index 00000000..92124df1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillReportActionRequest( + String comment +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java new file mode 100644 index 00000000..294784e8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java index 699c1b19..b3657f43 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuditLogItemResponse.java @@ -9,6 +9,9 @@ public record AuditLogItemResponse( String username, String details, String ipAddress, + String requestId, + String resourceType, + String resourceId, Instant timestamp ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 12d0019a..09a67b51 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -15,5 +15,6 @@ public record SkillDetailResponse( Integer ratingCount, boolean hidden, String latestVersion, - String namespace + String namespace, + boolean canManageLifecycle ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java new file mode 100644 index 00000000..5cdea644 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record SkillLifecycleMutationResponse( + Long skillId, + Long versionId, + String action, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java new file mode 100644 index 00000000..1445d957 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record SkillReportMutationResponse( + Long reportId, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java new file mode 100644 index 00000000..8754b460 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record SkillReportSubmitRequest( + String reason, + String details +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index e5f51c0d..4cc2e91e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -8,6 +8,7 @@ public record SkillSummaryResponse( String slug, String displayName, String summary, + String status, Long downloadCount, Integer starCount, BigDecimal ratingAvg, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index a2a4a028..a1694498 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -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> 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> 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 ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 4987bf9d..44a7c626 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -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 body = apiResponseFactory.error(403, "error.forbidden"); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java index bac1e010..2611d3bf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -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 body = apiResponseFactory.error(401, "error.auth.required"); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java new file mode 100644 index 00000000..19997a4b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java @@ -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(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java new file mode 100644 index 00000000..9b7efe73 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java @@ -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 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; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java index d1673617..b712ab83 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java @@ -22,12 +22,30 @@ public class AdminAuditLogAppService { } @Transactional(readOnly = true) - public PageResponse listAuditLogs(int page, int size, String userId, String action) { + public PageResponse 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); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java new file mode 100644 index 00000000..fe807e4f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java @@ -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 listReports(String status, int page, int size) { + SkillReportStatus resolvedStatus = parseStatus(status); + var reportPage = skillReportRepository.findByStatus(resolvedStatus, PageRequest.of(page, size)); + + List skillIds = reportPage.getContent().stream().map(SkillReport::getSkillId).distinct().toList(); + Map skillsById = skillIds.isEmpty() + ? Map.of() + : skillRepository.findByIdIn(skillIds).stream().collect(Collectors.toMap(Skill::getId, Function.identity())); + + List namespaceIds = skillsById.values().stream().map(Skill::getNamespaceId).distinct().toList(); + Map namespaceSlugs = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream().collect(Collectors.toMap(Namespace::getId, Namespace::getSlug)); + + List 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 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); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 5a30ad70..1b465f07 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -124,6 +124,7 @@ public class MySkillAppService { skill.getSlug(), skill.getDisplayName(), skill.getSummary(), + skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), skill.getRatingAvg(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 3746a9b8..7c1b4a88 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -147,6 +147,7 @@ public class SkillSearchAppService { skill.getSlug(), skill.getDisplayName(), skill.getSummary(), + skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), skill.getRatingAvg(), diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 9655837c..f999efe3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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 diff --git a/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql b/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql new file mode 100644 index 00000000..c93da0aa --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql @@ -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); diff --git a/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql b/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql new file mode 100644 index 00000000..ddce45d4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql @@ -0,0 +1,2 @@ +ALTER TABLE skill_search_document +ADD COLUMN semantic_vector TEXT; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index c870d587..70e73e4d 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 70fe706e..0353c3d2 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index b8cdeffe..691ac8ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -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")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index b56befd2..1e127f64 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -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")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java index f36a3a8d..9f26e22f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java @@ -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"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java index e6298171..8ef61470 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -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( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java index 695fa221..fb329bd5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java @@ -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'"))); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index d88cba1e..c83c66fc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -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); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index 2912fa7f..841fb77b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -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()); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java new file mode 100644 index 00000000..481d8ef0 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java @@ -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")) + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java index d255616d..b748adbf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java @@ -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()); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index 8379a594..a4916ad7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -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( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java new file mode 100644 index 00000000..3f196de4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -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); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java new file mode 100644 index 00000000..4f886b7a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java @@ -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")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java new file mode 100644 index 00000000..74ed9560 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java @@ -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()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java new file mode 100644 index 00000000..f68e819e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java @@ -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]"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java index 5f2d64cc..bfaf2d18 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java @@ -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)); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index b08917e1..a3cecd29 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -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) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index 9d210c00..d0a1d1b2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -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)); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java index 08a1b7f2..01332a6d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java @@ -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, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index bba596f5..75e34918 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -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/")); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java index c4ff48a5..c5a55832 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java @@ -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) { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index 6f2cd2a1..6b0956f7 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -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(); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index 0c76132e..f40e542c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -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); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java index 10e33853..735fddfc 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java @@ -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); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java new file mode 100644 index 00000000..7329e803 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java @@ -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; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java new file mode 100644 index 00000000..291e3503 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java @@ -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 findById(Long id); + boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status); + Page findByStatus(SkillReportStatus status, Pageable pageable); + List findBySkillIdIn(Collection skillIds); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java new file mode 100644 index 00000000..17b2722a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -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; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java new file mode 100644 index 00000000..527d8ac5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.report; + +public enum SkillReportStatus { + PENDING, + RESOLVED, + DISMISSED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index db7f457c..6a5739c7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -7,7 +7,9 @@ public interface SkillVersionRepository { Optional findById(Long id); List findByIdIn(List ids); List findBySkillIdIn(List skillIds); + List findBySkillId(Long skillId); Optional findBySkillIdAndVersion(Long skillId, String version); List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status); SkillVersion save(SkillVersion version); + void delete(SkillVersion version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 6f09cb32..3a17f5ef 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -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 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 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 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 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 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; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index a6cd6396..ca67907c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -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()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 4abdb8aa..938979cd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -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 publishedVersions = skillVersionRepository.findBySkillIdAndStatus( - skill.getId(), SkillVersionStatus.PUBLISHED); + List 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 pageContent = publishedVersions.subList(start, end); + int start = Math.min((int) pageable.getOffset(), visibleVersions.size()); + int end = Math.min(start + pageable.getPageSize(), visibleVersions.size()); + List 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 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 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); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java new file mode 100644 index 00000000..6f17fb95 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java @@ -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 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 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"); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java index 73f821d4..cd8e1ef8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java @@ -1,8 +1,5 @@ package com.iflytek.skillhub.domain.skill.validation; -import org.springframework.stereotype.Component; - -@Component public class NoOpPrePublishValidator implements PrePublishValidator { @Override diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index 3c0e5498..38ff4c23 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -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(" { + 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); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 9df6b643..469bd2fc 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -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); + } + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index a184de8e..02cc64e2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -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 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"; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 91b2cac9..00d4adef 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -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 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 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 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 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 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 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); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java new file mode 100644 index 00000000..46e9e127 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java @@ -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()); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index c36f4f42..9a140beb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -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 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 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"))); + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java new file mode 100644 index 00000000..1e352760 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java @@ -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, SkillReportRepository { + boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status); + Page findByStatusOrderByCreatedAtDesc(SkillReportStatus status, Pageable pageable); + List findBySkillIdIn(Collection skillIds); + + @Override + default Page findByStatus(SkillReportStatus status, Pageable pageable) { + return findByStatusOrderByCreatedAtDesc(status, pageable); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java index 4eeae924..85f58db8 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java @@ -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; } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java index 7dd6fd62..f9e65ec5 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java @@ -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 { Optional findBySkillId(Long skillId); + List findBySkillIdIn(Collection skillIds); void deleteBySkillId(Long skillId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index d5c95394..e6095270 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -14,6 +14,7 @@ import java.util.Optional; @Repository public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { List findByIdIn(List ids); + List findBySkillId(Long skillId); List findBySkillIdIn(List skillIds); Optional findBySkillIdAndVersion(Long skillId, String version); diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java new file mode 100644 index 00000000..b92efe24 --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java @@ -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; + } + } +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java new file mode 100644 index 00000000..4eec4795 --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.search; + +public interface SearchEmbeddingService { + String embed(String text); + + double similarity(String text, String serializedVector); +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java index d2eaec3c..d40fb09f 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java @@ -9,6 +9,7 @@ public record SkillSearchDocument( String summary, String keywords, String searchText, + String semanticVector, String visibility, String status ) {} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java index 9bfe64dc..b856b82a 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java @@ -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; + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 6114e35c..b06c6a0a 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -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 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 skillIds = (List) 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 rerankBySemanticSimilarity(List candidateSkillIds, + String normalizedKeyword, + int requestedOffset, + int pageSize) { + Map documentsBySkillId = new HashMap<>(); + for (SkillSearchDocumentEntity entity : searchDocumentRepository.findBySkillIdIn(candidateSkillIds)) { + documentsBySkillId.put(entity.getSkillId(), entity); + } + + int totalCandidates = Math.max(candidateSkillIds.size(), 1); + List 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) { + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index 7d1a0b2e..59bd51ef 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -88,6 +88,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService { skill.getSummary(), "", searchText, + null, skill.getVisibility().name(), skill.getStatus().name() )); diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java new file mode 100644 index 00000000..c0412a16 --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java @@ -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); + } +} diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index b3c909a6..cfdc44f6 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -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); + } } diff --git a/web/index.html b/web/index.html index ceca1d26..107e37f9 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,10 @@ + SkillHub diff --git a/web/src/api/client.ts b/web/src/api/client.ts index af43b166..f0faf193 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${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 { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/unarchive`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async deleteVersion(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${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 { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${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 { + await fetchJson(`/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 { + await fetchJson(`/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 { return fetchJson(`${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 }>( diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 5b5f35a3..13ffb46d 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d0167041..0d9af6f3 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -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, diff --git a/web/src/features/admin/use-audit-log.ts b/web/src/features/admin/use-audit-log.ts index e398aca4..c55ab5a7 100644 --- a/web/src/features/admin/use-audit-log.ts +++ b/web/src/features/admin/use-audit-log.ts @@ -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 } diff --git a/web/src/features/report/use-skill-reports.ts b/web/src/features/report/use-skill-reports.ts new file mode 100644 index 00000000..284cce4e --- /dev/null +++ b/web/src/features/report/use-skill-reports.ts @@ -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'] }) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 41f34664..838d927b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3e319ff5..606d60dd 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "安全设置", diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index 881c4909..713b8327 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -18,11 +18,23 @@ export function AuditLogPage() { const { t, i18n } = useTranslation() const [actionFilter, setActionFilter] = useState('') 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() { -
- { + setActionFilter(e.target.value) + setPage(0) + }} className="w-[200px]"> @@ -52,8 +67,58 @@ export function AuditLogPage() { setUserIdFilter(e.target.value)} - className="w-[200px]" + onChange={(e) => { + setUserIdFilter(e.target.value) + setPage(0) + }} + /> + { + setRequestIdFilter(e.target.value) + setPage(0) + }} + /> + { + setIpFilter(e.target.value) + setPage(0) + }} + /> + { + setResourceTypeFilter(e.target.value) + setPage(0) + }} + /> + { + setResourceIdFilter(e.target.value) + setPage(0) + }} + /> + { + setStartTimeFilter(e.target.value) + setPage(0) + }} + /> + { + setEndTimeFilter(e.target.value) + setPage(0) + }} />
@@ -88,10 +153,10 @@ export function AuditLogPage() { {formatDate(log.timestamp)} {log.action} {log.userId || '-'} - {log.resourceType || '-'} + {log.username || '-'} {log.ipAddress || '-'} - {log.resourceId || '-'} + {log.details || `${log.resourceType || '-'}:${log.resourceId || '-'}`} ))} diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 7fc83e9b..12d5d314 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -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 (
@@ -58,7 +59,7 @@ export function DashboardPage() { -
+
{t('dashboard.starsAndRatings')}
@@ -77,6 +78,14 @@ export function DashboardPage() { {t('dashboard.viewPromotions')}
+ {governanceVisible ? ( + +
{t('dashboard.reportsTitle')}
+ + {t('dashboard.viewReports')} + +
+ ) : null}
diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 5a94b3d4..c76261db 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -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 (
@@ -79,6 +132,11 @@ export function MySkillsPage() { {skill.latestVersion && ( v{skill.latestVersion} )} + {skill.status ? ( + + {resolveStatusLabel(skill.status)} + + ) : null} {skill.latestVersionStatus ? ( {resolveStatusLabel(skill.latestVersionStatus)} @@ -92,9 +150,42 @@ export function MySkillsPage() {
- - - +
+ {skill.status === 'ARCHIVED' ? ( + + ) : ( + + )} + + + +
))} @@ -110,6 +201,32 @@ export function MySkillsPage() { } /> )} + + { + if (!open) { + setArchiveTarget(null) + } + }} + title={t('mySkills.archiveConfirmTitle')} + description={archiveTarget ? t('mySkills.archiveConfirmDescription', { skill: archiveTarget.name }) : ''} + confirmText={t('mySkills.archive')} + onConfirm={handleArchiveSkill} + /> + + { + if (!open) { + setUnarchiveTarget(null) + } + }} + title={t('mySkills.unarchiveConfirmTitle')} + description={unarchiveTarget ? t('mySkills.unarchiveConfirmDescription', { skill: unarchiveTarget.name }) : ''} + confirmText={t('mySkills.unarchive')} + onConfirm={handleUnarchiveSkill} + /> ) } diff --git a/web/src/pages/dashboard/reports.tsx b/web/src/pages/dashboard/reports.tsx new file mode 100644 index 00000000..1b25e3e5 --- /dev/null +++ b/web/src/pages/dashboard/reports.tsx @@ -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 ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ) + } + + if (!reports || reports.length === 0) { + return {t('reports.empty')} + } + + return ( +
+ {reports.map((report) => { + const skillLabel = report.skillDisplayName || report.skillSlug || `#${report.skillId}` + return ( + +
+
+ +
{skillLabel}
+
{report.reason}
+ {report.details ?
{report.details}
: null} +
+
+
{t('reports.reporter')}: {report.reporterId}
+
{formatDate(report.createdAt)}
+ {report.handledAt ?
{formatDate(report.handledAt)}
: null} +
+
+ + {status === 'PENDING' ? ( +
+ + +
+ ) : ( +
+ {t('reports.handledBy')}: {report.handledBy || '—'} +
+ )} +
+ ) + })} +
+ ) + } + + return ( +
+ + + + + {t('reports.tabPending')} + {t('reports.tabResolved')} + {t('reports.tabDismissed')} + + + + {renderList(pendingReports, isPendingLoading, 'PENDING')} + + + {renderList(resolvedReports, isResolvedLoading, 'RESOLVED')} + + + {renderList(dismissedReports, isDismissedLoading, 'DISMISSED')} + + + + { + 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} + /> +
+ ) +} diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index e0b70dc8..5dea9ee1 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -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(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 (
@@ -144,6 +252,11 @@ export function SkillDetailPage() {
+ {skill.status && ( + + {resolveSkillStatusLabel(skill.status)} + + )}

{skill.displayName}

{skill.summary && ( @@ -191,10 +304,26 @@ export function SkillDetailPage() { v{version.version} + {version.status && ( + + {version.status} + + )} - - {formatLocalDateTime(version.publishedAt, i18n.language)} - +
+ + {formatLocalDateTime(version.publishedAt, i18n.language)} + + {skill.canManageLifecycle && canDeleteVersion(version.status) && ( + + )} +
{version.changelog && (

{version.changelog}

@@ -253,6 +382,9 @@ export function SkillDetailPage() {
+ {!user && (

{t('skillDetail.loginToRate')}

)} @@ -262,6 +394,9 @@ export function SkillDetailPage() { {skill.latestVersion && (
{t('skillDetail.install')}
+ {skill.status === 'ARCHIVED' && ( +

{t('skillDetail.archivedInstallHint')}

+ )} @@ -283,6 +418,26 @@ export function SkillDetailPage() { {t('skillDetail.download')} + {skill.canManageLifecycle && ( + +
{t('skillDetail.lifecycle')}
+

+ {skill.status === 'ARCHIVED' + ? t('skillDetail.archivedPublishHint') + : t('skillDetail.lifecycleHint')} +

+ {skill.status === 'ARCHIVED' ? ( + + ) : ( + + )} +
+ )} + {governanceVisible && (
{t('skillDetail.governance')}
@@ -305,6 +460,69 @@ export function SkillDetailPage() {
)}
+ + + + + {t('skillDetail.reportDialogTitle')} + {t('skillDetail.reportDialogDescription')} + +
+ setReportReason(event.target.value)} + placeholder={t('skillDetail.reportReasonPlaceholder')} + maxLength={200} + /> +