Merge pull request #38 from iflytek/feature/project-local

feat: strengthen security and evolve skill governance
This commit is contained in:
yun-zhi-ztl 2026-03-15 03:18:13 -07:00 committed by GitHub
commit de87446dd9
102 changed files with 3897 additions and 123 deletions

View file

@ -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` 字段)

View file

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

View file

@ -6,7 +6,7 @@
> **设计决策**一期暂不考虑异步发布uploadId、publishId、状态轮询、异步转正等。一期技能包为文本资源包体积有限上限 10MB同步处理足以满足需求。如后续引入大文件或复杂校验流程再考虑异步模型。
### 1.1 Phase 2 发布流程基线
### 1.1 当前发布流程基线
```
用户提交发布
@ -29,11 +29,11 @@
④ 持久化数据
- 创建或关联 skill 记录(首次发布时创建 skill
- 创建 skill_versionstatus=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` 实现,后续可替换为消息队列。

View file

@ -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}` | 本地哈希与远端比对 |

View file

@ -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 等)
- 向量搜索第二阶段增强(当前第一阶段仅做搜索增强,不做推荐)
## 主要风险与应对

View file

@ -8,12 +8,15 @@ import com.iflytek.skillhub.dto.AuthMethodResponse;
import com.iflytek.skillhub.dto.AuthProviderResponse;
import com.iflytek.skillhub.dto.DirectLoginRequest;
import com.iflytek.skillhub.dto.SessionBootstrapRequest;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.service.AuthMethodCatalog;
import com.iflytek.skillhub.service.DirectAuthService;
import com.iflytek.skillhub.service.SessionBootstrapService;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.iflytek.skillhub.exception.UnauthorizedException;
@ -32,15 +35,18 @@ public class AuthController extends BaseApiController {
private final AuthMethodCatalog authMethodCatalog;
private final SessionBootstrapService sessionBootstrapService;
private final DirectAuthService directAuthService;
private final AuthFailureThrottleService authFailureThrottleService;
public AuthController(ApiResponseFactory responseFactory,
AuthMethodCatalog authMethodCatalog,
SessionBootstrapService sessionBootstrapService,
DirectAuthService directAuthService) {
DirectAuthService directAuthService,
AuthFailureThrottleService authFailureThrottleService) {
super(responseFactory);
this.authMethodCatalog = authMethodCatalog;
this.sessionBootstrapService = sessionBootstrapService;
this.directAuthService = directAuthService;
this.authFailureThrottleService = authFailureThrottleService;
}
@GetMapping("/me")
@ -78,17 +84,42 @@ public class AuthController extends BaseApiController {
@RateLimit(category = "auth-direct-login", authenticated = 20, anonymous = 10, windowSeconds = 60)
public ApiResponse<AuthMeResponse> directLogin(@Valid @RequestBody DirectLoginRequest request,
HttpServletRequest httpRequest) {
return ok(
"response.success.read",
AuthMeResponse.from(
directAuthService.authenticate(
String category = "direct:" + request.provider();
String clientIp = resolveClientIp(httpRequest);
authFailureThrottleService.assertAllowed(category, request.username(), clientIp);
PlatformPrincipal principal;
try {
principal = directAuthService.authenticate(
request.provider(),
request.username(),
request.password(),
httpRequest
)
)
);
} catch (AuthFlowException ex) {
if (HttpStatus.UNAUTHORIZED.equals(ex.getStatus())) {
authFailureThrottleService.recordFailure(category, request.username(), clientIp);
}
throw ex;
}
authFailureThrottleService.resetIdentifier(category, request.username());
return ok(
"response.success.read",
AuthMeResponse.from(principal)
);
}
private String resolveClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.dto.ApiResponse;
@ -12,8 +13,10 @@ import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@ -27,15 +30,18 @@ public class LocalAuthController extends BaseApiController {
private final LocalAuthService localAuthService;
private final SkillHubMetrics skillHubMetrics;
private final PlatformSessionService platformSessionService;
private final AuthFailureThrottleService authFailureThrottleService;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics,
PlatformSessionService platformSessionService) {
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
this.platformSessionService = platformSessionService;
this.authFailureThrottleService = authFailureThrottleService;
}
@PostMapping("/register")
@ -52,13 +58,21 @@ public class LocalAuthController extends BaseApiController {
@RateLimit(category = "auth-local-login", authenticated = 20, anonymous = 10, windowSeconds = 60)
public ApiResponse<AuthMeResponse> login(@Valid @RequestBody LocalLoginRequest request,
HttpServletRequest httpRequest) {
authFailureThrottleService.assertAllowed("local", request.username(), resolveClientIp(httpRequest));
PlatformPrincipal principal;
try {
principal = localAuthService.login(request.username(), request.password());
} catch (AuthFlowException ex) {
if (HttpStatus.UNAUTHORIZED.equals(ex.getStatus())) {
authFailureThrottleService.recordFailure("local", request.username(), resolveClientIp(httpRequest));
}
skillHubMetrics.recordLocalLogin(false);
throw ex;
} catch (RuntimeException ex) {
skillHubMetrics.recordLocalLogin(false);
throw ex;
}
authFailureThrottleService.resetIdentifier("local", request.username());
skillHubMetrics.recordLocalLogin(true);
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.read", AuthMeResponse.from(principal));
@ -74,4 +88,18 @@ public class LocalAuthController extends BaseApiController {
localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword());
return ok("response.success.updated", null);
}
private String resolveClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View file

@ -0,0 +1,79 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.report.SkillReportService;
import com.iflytek.skillhub.dto.AdminSkillReportActionRequest;
import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillReportMutationResponse;
import com.iflytek.skillhub.service.AdminSkillReportAppService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/admin/skill-reports")
public class AdminSkillReportController extends BaseApiController {
private final AdminSkillReportAppService adminSkillReportAppService;
private final SkillReportService skillReportService;
public AdminSkillReportController(AdminSkillReportAppService adminSkillReportAppService,
SkillReportService skillReportService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.adminSkillReportAppService = adminSkillReportAppService;
this.skillReportService = skillReportService;
}
@GetMapping
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<PageResponse<AdminSkillReportSummaryResponse>> listReports(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ok("response.success", adminSkillReportAppService.listReports(status, page, size));
}
@PostMapping("/{reportId}/resolve")
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<SkillReportMutationResponse> resolveReport(@PathVariable Long reportId,
@RequestBody(required = false) AdminSkillReportActionRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
var report = skillReportService.resolveReport(
reportId,
principal.userId(),
request != null ? request.comment() : null,
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent")
);
return ok("response.success.updated", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
}
@PostMapping("/{reportId}/dismiss")
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<SkillReportMutationResponse> dismissReport(@PathVariable Long reportId,
@RequestBody(required = false) AdminSkillReportActionRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
var report = skillReportService.dismissReport(
reportId,
principal.userId(),
request != null ? request.comment() : null,
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent")
);
return ok("response.success.updated", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
}
}

View file

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

View file

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

View file

@ -0,0 +1,118 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillLifecycleController extends BaseApiController {
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillGovernanceService skillGovernanceService;
public SkillLifecycleController(NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillGovernanceService skillGovernanceService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillGovernanceService = skillGovernanceService;
}
@PostMapping("/{namespace}/{slug}/archive")
public ApiResponse<SkillLifecycleMutationResponse> archiveSkill(@PathVariable String namespace,
@PathVariable String slug,
@RequestBody(required = false) AdminSkillActionRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill archived = skillGovernanceService.archiveSkill(
skill.getId(),
userId,
userNsRoles != null ? userNsRoles : Map.of(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
request != null ? request.reason() : null
);
return ok("response.success.updated",
new SkillLifecycleMutationResponse(archived.getId(), null, "ARCHIVE", archived.getStatus().name()));
}
@PostMapping("/{namespace}/{slug}/unarchive")
public ApiResponse<SkillLifecycleMutationResponse> unarchiveSkill(@PathVariable String namespace,
@PathVariable String slug,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
Skill restored = skillGovernanceService.unarchiveSkill(
skill.getId(),
userId,
userNsRoles != null ? userNsRoles : Map.of(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent")
);
return ok("response.success.updated",
new SkillLifecycleMutationResponse(restored.getId(), null, "UNARCHIVE", restored.getStatus().name()));
}
@DeleteMapping("/{namespace}/{slug}/versions/{version}")
public ApiResponse<SkillLifecycleMutationResponse> deleteVersion(@PathVariable String namespace,
@PathVariable String slug,
@PathVariable String version,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
skillGovernanceService.deleteVersion(
skill,
skillVersion,
userId,
userNsRoles != null ? userNsRoles : Map.of(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent")
);
return ok("response.success.deleted",
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
}
}

View file

@ -0,0 +1,65 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.report.SkillReportService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillReportMutationResponse;
import com.iflytek.skillhub.dto.SkillReportSubmitRequest;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillReportController extends BaseApiController {
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillReportService skillReportService;
public SkillReportController(NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillReportService skillReportService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillReportService = skillReportService;
}
@PostMapping("/{namespace}/{slug}/reports")
public ApiResponse<SkillReportMutationResponse> submitReport(@PathVariable String namespace,
@PathVariable String slug,
@RequestBody SkillReportSubmitRequest request,
@RequestAttribute("userId") String userId,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
var report = skillReportService.submitReport(
skill.getId(),
userId,
request.reason(),
request.details(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent")
);
return ok("response.success.created", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
}
}

View file

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

View file

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

View file

@ -9,6 +9,9 @@ public record AuditLogItemResponse(
String username,
String details,
String ipAddress,
String requestId,
String resourceType,
String resourceId,
Instant timestamp
) {
}

View file

@ -15,5 +15,6 @@ public record SkillDetailResponse(
Integer ratingCount,
boolean hidden,
String latestVersion,
String namespace
String namespace,
boolean canManageLifecycle
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record SkillLifecycleMutationResponse(
Long skillId,
Long versionId,
String action,
String status
) {}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
public record SkillReportMutationResponse(
Long reportId,
String status
) {}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
public record SkillReportSubmitRequest(
String reason,
String details
) {}

View file

@ -8,6 +8,7 @@ public record SkillSummaryResponse(
String slug,
String displayName,
String summary,
String status,
Long downloadCount,
Integer starCount,
BigDecimal ratingAvg,

View file

@ -7,12 +7,14 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
@ -24,9 +26,12 @@ public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory) {
public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
}
@ExceptionHandler(LocalizedException.class)
@ -96,13 +101,20 @@ public class GlobalExceptionHandler {
apiResponseFactory.error(403, "error.forbidden"));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex, HttpServletRequest request) {
logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
apiResponseFactory.error(403, "error.forbidden"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex, HttpServletRequest request) {
logger.error(
"Unhandled API exception [requestId={}, method={}, path={}, userId={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
resolveUserId(request),
ex
);
@ -116,7 +128,7 @@ public class GlobalExceptionHandler {
MDC.get("requestId"),
status.value(),
request.getMethod(),
request.getRequestURI(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
resolveUserId(request),
messageCode
);

View file

@ -21,10 +21,14 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
private static final Logger logger = LoggerFactory.getLogger(ApiAccessDeniedHandler.class);
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
public ApiAccessDeniedHandler(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) {
public ApiAccessDeniedHandler(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
}
@Override
@ -35,7 +39,7 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
"Forbidden API request [requestId={}, method={}, path={}, reason={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
accessDeniedException.getClass().getSimpleName()
);
ApiResponse<Void> body = apiResponseFactory.error(403, "error.forbidden");

View file

@ -21,10 +21,14 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(ApiAuthenticationEntryPoint.class);
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) {
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
}
@Override
@ -35,7 +39,7 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
"Unauthorized API request [requestId={}, method={}, path={}, reason={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
authException.getClass().getSimpleName()
);
ApiResponse<Void> body = apiResponseFactory.error(401, "error.auth.required");

View file

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

View file

@ -0,0 +1,45 @@
package com.iflytek.skillhub.security;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Component
public class SensitiveLogSanitizer {
private static final Set<String> SENSITIVE_KEYS = Set.of(
"password", "passwd", "pwd", "token", "authorization", "cookie",
"secret", "api_key", "apikey", "access_key", "refresh_token", "code");
public String sanitizeRequestTarget(HttpServletRequest request) {
String uri = request.getRequestURI();
String query = request.getQueryString();
if (!StringUtils.hasText(query)) {
return uri;
}
return uri + "?" + sanitizeQuery(query);
}
String sanitizeQuery(String query) {
return Arrays.stream(query.split("&"))
.map(this::sanitizeQueryPart)
.collect(Collectors.joining("&"));
}
private String sanitizeQueryPart(String queryPart) {
int idx = queryPart.indexOf('=');
if (idx < 0) {
return queryPart;
}
String key = queryPart.substring(0, idx);
String normalizedKey = key.trim().toLowerCase(Locale.ROOT);
if (SENSITIVE_KEYS.contains(normalizedKey)) {
return key + "=[REDACTED]";
}
return queryPart;
}
}

View file

@ -22,12 +22,30 @@ public class AdminAuditLogAppService {
}
@Transactional(readOnly = true)
public PageResponse<AuditLogItemResponse> listAuditLogs(int page, int size, String userId, String action) {
public PageResponse<AuditLogItemResponse> listAuditLogs(int page,
int size,
String userId,
String action,
String requestId,
String ipAddress,
String resourceType,
String resourceId,
Instant startTime,
Instant endTime) {
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("limit", size)
.addValue("offset", Math.max(page, 0) * size);
String whereClause = buildWhereClause(parameters, userId, action);
String whereClause = buildWhereClause(
parameters,
userId,
action,
requestId,
ipAddress,
resourceType,
resourceId,
startTime,
endTime);
Long total = namedParameterJdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log al" + whereClause,
parameters,
@ -43,6 +61,7 @@ public class AdminAuditLogAppService {
al.detail_json,
al.target_type,
al.target_id,
al.request_id,
al.client_ip,
al.created_at
FROM audit_log al
@ -62,13 +81,24 @@ public class AdminAuditLogAppService {
rs.getString("target_type"),
rs.getObject("target_id")),
rs.getString("client_ip"),
rs.getString("request_id"),
rs.getString("target_type"),
toResourceId(rs.getObject("target_id")),
toInstant(rs.getTimestamp("created_at")))
);
return new PageResponse<>(items, total == null ? 0 : total, page, size);
}
private String buildWhereClause(MapSqlParameterSource parameters, String userId, String action) {
private String buildWhereClause(MapSqlParameterSource parameters,
String userId,
String action,
String requestId,
String ipAddress,
String resourceType,
String resourceId,
Instant startTime,
Instant endTime) {
StringBuilder clause = new StringBuilder(" WHERE 1 = 1");
if (StringUtils.hasText(userId)) {
clause.append(" AND al.actor_user_id = :userId");
@ -78,6 +108,30 @@ public class AdminAuditLogAppService {
clause.append(" AND al.action = :action");
parameters.addValue("action", action.trim());
}
if (StringUtils.hasText(requestId)) {
clause.append(" AND al.request_id = :requestId");
parameters.addValue("requestId", requestId.trim());
}
if (StringUtils.hasText(ipAddress)) {
clause.append(" AND al.client_ip = :ipAddress");
parameters.addValue("ipAddress", ipAddress.trim());
}
if (StringUtils.hasText(resourceType)) {
clause.append(" AND al.target_type = :resourceType");
parameters.addValue("resourceType", resourceType.trim());
}
if (StringUtils.hasText(resourceId)) {
clause.append(" AND CAST(al.target_id AS TEXT) = :resourceId");
parameters.addValue("resourceId", resourceId.trim());
}
if (startTime != null) {
clause.append(" AND al.created_at >= :startTime");
parameters.addValue("startTime", Timestamp.from(startTime));
}
if (endTime != null) {
clause.append(" AND al.created_at <= :endTime");
parameters.addValue("endTime", Timestamp.from(endTime));
}
return clause.toString();
}
@ -94,4 +148,8 @@ public class AdminAuditLogAppService {
private Instant toInstant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
private String toResourceId(Object targetId) {
return targetId == null ? null : String.valueOf(targetId);
}
}

View file

@ -0,0 +1,86 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.report.SkillReport;
import com.iflytek.skillhub.domain.report.SkillReportRepository;
import com.iflytek.skillhub.domain.report.SkillReportStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
@Service
public class AdminSkillReportAppService {
private final SkillReportRepository skillReportRepository;
private final SkillRepository skillRepository;
private final NamespaceRepository namespaceRepository;
public AdminSkillReportAppService(SkillReportRepository skillReportRepository,
SkillRepository skillRepository,
NamespaceRepository namespaceRepository) {
this.skillReportRepository = skillReportRepository;
this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository;
}
public PageResponse<AdminSkillReportSummaryResponse> listReports(String status, int page, int size) {
SkillReportStatus resolvedStatus = parseStatus(status);
var reportPage = skillReportRepository.findByStatus(resolvedStatus, PageRequest.of(page, size));
List<Long> skillIds = reportPage.getContent().stream().map(SkillReport::getSkillId).distinct().toList();
Map<Long, Skill> skillsById = skillIds.isEmpty()
? Map.of()
: skillRepository.findByIdIn(skillIds).stream().collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> namespaceIds = skillsById.values().stream().map(Skill::getNamespaceId).distinct().toList();
Map<Long, String> namespaceSlugs = namespaceIds.isEmpty()
? Map.of()
: namespaceRepository.findByIdIn(namespaceIds).stream().collect(Collectors.toMap(Namespace::getId, Namespace::getSlug));
List<AdminSkillReportSummaryResponse> items = reportPage.getContent().stream()
.map(report -> toResponse(report, skillsById.get(report.getSkillId()), namespaceSlugs))
.toList();
return new PageResponse<>(items, reportPage.getTotalElements(), reportPage.getNumber(), reportPage.getSize());
}
private AdminSkillReportSummaryResponse toResponse(SkillReport report,
Skill skill,
Map<Long, String> namespaceSlugs) {
return new AdminSkillReportSummaryResponse(
report.getId(),
report.getSkillId(),
skill != null ? namespaceSlugs.get(skill.getNamespaceId()) : null,
skill != null ? skill.getSlug() : null,
skill != null ? skill.getDisplayName() : null,
report.getReporterId(),
report.getReason(),
report.getDetails(),
report.getStatus().name(),
report.getHandledBy(),
report.getHandleComment(),
report.getCreatedAt(),
report.getHandledAt()
);
}
private SkillReportStatus parseStatus(String status) {
if (status == null || status.isBlank()) {
return SkillReportStatus.PENDING;
}
try {
return SkillReportStatus.valueOf(status.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
throw new DomainBadRequestException("error.skill.report.status.invalid", status);
}
}
}

View file

@ -124,6 +124,7 @@ public class MySkillAppService {
skill.getSlug(),
skill.getDisplayName(),
skill.getSummary(),
skill.getStatus().name(),
skill.getDownloadCount(),
skill.getStarCount(),
skill.getRatingAvg(),

View file

@ -147,6 +147,7 @@ public class SkillSearchAppService {
skill.getSlug(),
skill.getDisplayName(),
skill.getSummary(),
skill.getStatus().name(),
skill.getDownloadCount(),
skill.getStarCount(),
skill.getRatingAvg(),

View file

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

View file

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

View file

@ -0,0 +1,2 @@
ALTER TABLE skill_search_document
ADD COLUMN semantic_vector TEXT;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.domain.report;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface SkillReportRepository {
SkillReport save(SkillReport report);
Optional<SkillReport> findById(Long id);
boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status);
Page<SkillReport> findByStatus(SkillReportStatus status, Pageable pageable);
List<SkillReport> findBySkillIdIn(Collection<Long> skillIds);
}

View file

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

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.report;
public enum SkillReportStatus {
PENDING,
RESOLVED,
DISMISSED
}

View file

@ -7,7 +7,9 @@ public interface SkillVersionRepository {
Optional<SkillVersion> findById(Long id);
List<SkillVersion> findByIdIn(List<Long> ids);
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
List<SkillVersion> findBySkillId(Long skillId);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status);
SkillVersion save(SkillVersion version);
void delete(SkillVersion version);
}

View file

@ -1,13 +1,24 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -16,14 +27,23 @@ public class SkillGovernanceService {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillFileRepository skillFileRepository;
private final ObjectStorageService objectStorageService;
private final AuditLogService auditLogService;
private final ApplicationEventPublisher eventPublisher;
public SkillGovernanceService(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
AuditLogService auditLogService) {
SkillFileRepository skillFileRepository,
ObjectStorageService objectStorageService,
AuditLogService auditLogService,
ApplicationEventPublisher eventPublisher) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillFileRepository = skillFileRepository;
this.objectStorageService = objectStorageService;
this.auditLogService = auditLogService;
this.eventPublisher = eventPublisher;
}
@Transactional
@ -39,6 +59,26 @@ public class SkillGovernanceService {
return saved;
}
@Transactional
public Skill archiveSkill(Long skillId,
String actorUserId,
Map<Long, NamespaceRole> userNamespaceRoles,
String clientIp,
String userAgent,
String reason) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId));
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
SkillStatus previousStatus = skill.getStatus();
skill.setStatus(SkillStatus.ARCHIVED);
skill.setUpdatedBy(actorUserId);
Skill saved = skillRepository.save(skill);
auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason));
eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ARCHIVED));
return saved;
}
@Transactional
public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, String userAgent) {
Skill skill = skillRepository.findById(skillId)
@ -52,6 +92,56 @@ public class SkillGovernanceService {
return saved;
}
@Transactional
public Skill unarchiveSkill(Long skillId,
String actorUserId,
Map<Long, NamespaceRole> userNamespaceRoles,
String clientIp,
String userAgent) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId));
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
SkillStatus previousStatus = skill.getStatus();
skill.setStatus(SkillStatus.ACTIVE);
skill.setUpdatedBy(actorUserId);
Skill saved = skillRepository.save(skill);
auditLogService.record(actorUserId, "UNARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null);
eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ACTIVE));
return saved;
}
@Transactional
public void deleteVersion(Skill skill,
SkillVersion version,
String actorUserId,
Map<Long, NamespaceRole> userNamespaceRoles,
String clientIp,
String userAgent) {
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED) {
throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion());
}
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
if (!files.isEmpty()) {
objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList());
}
objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()));
skillFileRepository.deleteByVersionId(version.getId());
skillVersionRepository.delete(version);
auditLogService.record(
actorUserId,
"DELETE_SKILL_VERSION",
"SKILL_VERSION",
version.getId(),
null,
clientIp,
userAgent,
"{\"version\":\"" + version.getVersion().replace("\"", "\\\"") + "\"}"
);
}
@Transactional
public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) {
SkillVersion version = skillVersionRepository.findById(versionId)
@ -65,6 +155,18 @@ public class SkillGovernanceService {
return saved;
}
private void assertCanManageLifecycle(Skill skill,
String actorUserId,
Map<Long, NamespaceRole> userNamespaceRoles) {
NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId());
boolean canManage = skill.getOwnerId().equals(actorUserId)
|| namespaceRole == NamespaceRole.ADMIN
|| namespaceRole == NamespaceRole.OWNER;
if (!canManage) {
throw new DomainForbiddenException("error.skill.lifecycle.noPermission");
}
}
private String jsonReason(String reason) {
if (reason == null || reason.isBlank()) {
return null;

View file

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

View file

@ -68,7 +68,8 @@ public class SkillQueryService {
Long namespaceId,
java.time.LocalDateTime createdAt,
java.time.LocalDateTime updatedAt,
SkillVersion latestVersionEntity
SkillVersion latestVersionEntity,
boolean canManageLifecycle
) {}
public record SkillVersionDetailDTO(
@ -134,7 +135,8 @@ public class SkillQueryService {
skill.getNamespaceId(),
skill.getCreatedAt(),
skill.getUpdatedAt(),
latestVersionEntity
latestVersionEntity,
canManageRestrictedSkill(skill, currentUserId, userNsRoles)
);
}
@ -250,16 +252,31 @@ public class SkillQueryService {
Pageable pageable) {
Skill skill = findSkill(namespaceSlug, skillSlug);
assertPublishedAccessible(skill, currentUserId, userNsRoles);
List<SkillVersion> publishedVersions = skillVersionRepository.findBySkillIdAndStatus(
skill.getId(), SkillVersionStatus.PUBLISHED);
List<SkillVersion> visibleVersions;
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED
|| version.getStatus() == SkillVersionStatus.DRAFT
|| version.getStatus() == SkillVersionStatus.REJECTED)
.sorted(Comparator
.comparingInt((SkillVersion version) -> lifecycleListPriority(version.getStatus()))
.thenComparing(SkillVersion::getPublishedAt,
Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(SkillVersion::getCreatedAt,
Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(SkillVersion::getId, Comparator.reverseOrder()))
.toList();
} else {
visibleVersions = skillVersionRepository.findBySkillIdAndStatus(
skill.getId(), SkillVersionStatus.PUBLISHED);
}
// Manual pagination
int start = (int) pageable.getOffset();
int end = Math.min(start + pageable.getPageSize(), publishedVersions.size());
List<SkillVersion> pageContent = publishedVersions.subList(start, end);
int start = Math.min((int) pageable.getOffset(), visibleVersions.size());
int end = Math.min(start + pageable.getPageSize(), visibleVersions.size());
List<SkillVersion> pageContent = visibleVersions.subList(start, end);
return new PageImpl<>(pageContent, pageable, publishedVersions.size());
return new PageImpl<>(pageContent, pageable, visibleVersions.size());
}
public ResolvedVersionDTO resolveVersion(
@ -390,14 +407,37 @@ public class SkillQueryService {
}
private void assertPublishedAccessible(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (skill.getStatus() != SkillStatus.ACTIVE) {
throw new DomainBadRequestException("error.skill.status.notActive");
if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
}
if (skill.isHidden() && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
}
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
}
}
private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (currentUserId == null) {
return false;
}
NamespaceRole role = userNsRoles.get(skill.getNamespaceId());
return skill.getOwnerId().equals(currentUserId)
|| role == NamespaceRole.ADMIN
|| role == NamespaceRole.OWNER;
}
private int lifecycleListPriority(SkillVersionStatus status) {
if (status == SkillVersionStatus.PUBLISHED) {
return 0;
}
if (status == SkillVersionStatus.REJECTED) {
return 1;
}
return 2;
}
private void assertPublishedVersion(SkillVersion version, String versionStr) {
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
throw new DomainBadRequestException("error.skill.version.notPublished", versionStr);

View file

@ -0,0 +1,54 @@
package com.iflytek.skillhub.domain.skill.validation;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
@Component
public class BasicPrePublishValidator implements PrePublishValidator {
private static final List<Pattern> SECRET_PATTERNS = List.of(
Pattern.compile("AKIA[0-9A-Z]{16}"),
Pattern.compile("ghp_[A-Za-z0-9]{20,}"),
Pattern.compile("sk-[A-Za-z0-9]{20,}"),
Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?[A-Za-z0-9_\\-]{12,}")
);
@Override
public ValidationResult validate(SkillPackageContext context) {
List<String> errors = new ArrayList<>();
for (PackageEntry entry : context.entries()) {
if (!isTextLike(entry.path())) {
continue;
}
String content = new String(entry.content(), StandardCharsets.UTF_8);
for (Pattern secretPattern : SECRET_PATTERNS) {
if (secretPattern.matcher(content).find()) {
errors.add("Potential secret detected in " + entry.path());
break;
}
}
}
return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
}
private boolean isTextLike(String path) {
String lowerPath = path.toLowerCase(Locale.ROOT);
return lowerPath.endsWith(".md")
|| lowerPath.endsWith(".txt")
|| lowerPath.endsWith(".json")
|| lowerPath.endsWith(".yaml")
|| lowerPath.endsWith(".yml")
|| lowerPath.endsWith(".js")
|| lowerPath.endsWith(".ts")
|| lowerPath.endsWith(".py")
|| lowerPath.endsWith(".sh")
|| lowerPath.endsWith(".svg");
}
}

View file

@ -1,8 +1,5 @@
package com.iflytek.skillhub.domain.skill.validation;
import org.springframework.stereotype.Component;
@Component
public class NoOpPrePublishValidator implements PrePublishValidator {
@Override

View file

@ -2,6 +2,11 @@ package com.iflytek.skillhub.domain.skill.validation;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Set;
public final class SkillPackagePolicy {
@ -53,4 +58,70 @@ public final class SkillPackagePolicy {
public static boolean hasAllowedExtension(String path) {
return ALLOWED_EXTENSIONS.stream().anyMatch(path::endsWith);
}
public static String validateContentMatchesExtension(String path, byte[] content) {
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".png")) {
return hasPrefix(content, (byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
? null
: "File content does not match extension: " + path;
}
if (lowerPath.endsWith(".jpg")) {
return hasPrefix(content, (byte) 0xff, (byte) 0xd8, (byte) 0xff)
? null
: "File content does not match extension: " + path;
}
if (lowerPath.endsWith(".svg")) {
if (!isUtf8Text(content)) {
return "File content does not match extension: " + path;
}
String text = new String(content, StandardCharsets.UTF_8).trim().toLowerCase();
return text.contains("<svg") ? null : "File content does not match extension: " + path;
}
if (isTextExtension(lowerPath)) {
return isUtf8Text(content) ? null : "File content does not match extension: " + path;
}
return null;
}
private static boolean isTextExtension(String path) {
return path.endsWith(".md")
|| path.endsWith(".txt")
|| path.endsWith(".json")
|| path.endsWith(".yaml")
|| path.endsWith(".yml")
|| path.endsWith(".js")
|| path.endsWith(".ts")
|| path.endsWith(".py")
|| path.endsWith(".sh");
}
private static boolean isUtf8Text(byte[] content) {
for (byte value : content) {
if (value == 0) {
return false;
}
}
try {
CharBuffer ignored = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(content));
return true;
} catch (CharacterCodingException ex) {
return false;
}
}
private static boolean hasPrefix(byte[] content, int... prefix) {
if (content.length < prefix.length) {
return false;
}
for (int index = 0; index < prefix.length; index++) {
if ((content[index] & 0xff) != (prefix[index] & 0xff)) {
return false;
}
}
return true;
}
}

View file

@ -62,6 +62,11 @@ public class SkillPackageValidator {
errors.add("Disallowed file extension: " + normalizedPath);
}
String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension(normalizedPath, entry.content());
if (contentMismatch != null) {
errors.add(contentMismatch);
}
if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) {
skillMd = entry;
}

View file

@ -0,0 +1,102 @@
package com.iflytek.skillhub.domain.report;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class SkillReportServiceTest {
@Mock
private SkillRepository skillRepository;
@Mock
private SkillReportRepository skillReportRepository;
@Mock
private AuditLogService auditLogService;
private SkillReportService service;
@BeforeEach
void setUp() {
service = new SkillReportService(skillRepository, skillReportRepository, auditLogService);
}
@Test
void submitReport_createsPendingReport() {
Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC);
setField(skill, "id", 10L);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
when(skillReportRepository.existsBySkillIdAndReporterIdAndStatus(10L, "user-1", SkillReportStatus.PENDING)).thenReturn(false);
when(skillReportRepository.save(any(SkillReport.class))).thenAnswer(invocation -> {
SkillReport report = invocation.getArgument(0);
setField(report, "id", 99L);
return report;
});
SkillReport report = service.submitReport(10L, "user-1", "Inappropriate content", "details", "127.0.0.1", "JUnit");
assertThat(report.getStatus()).isEqualTo(SkillReportStatus.PENDING);
assertThat(report.getReason()).isEqualTo("Inappropriate content");
verify(auditLogService).record("user-1", "REPORT_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reportId\":99}");
}
@Test
void submitReport_rejectsDuplicatePendingReport() {
Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC);
setField(skill, "id", 10L);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
when(skillReportRepository.existsBySkillIdAndReporterIdAndStatus(10L, "user-1", SkillReportStatus.PENDING)).thenReturn(true);
assertThrows(DomainBadRequestException.class,
() -> service.submitReport(10L, "user-1", "Inappropriate content", null, "127.0.0.1", "JUnit"));
}
@Test
void submitReport_rejectsSelfReport() {
Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC);
setField(skill, "id", 10L);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
assertThrows(DomainBadRequestException.class,
() -> service.submitReport(10L, "owner", "Inappropriate content", null, "127.0.0.1", "JUnit"));
}
@Test
void resolveReport_marksReportResolved() {
SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null);
setField(report, "id", 99L);
when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report));
when(skillReportRepository.save(report)).thenReturn(report);
SkillReport saved = service.resolveReport(99L, "admin", "handled", "127.0.0.1", "JUnit");
assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED);
assertThat(saved.getHandledBy()).isEqualTo("admin");
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
}
}

View file

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

View file

@ -229,6 +229,38 @@ class SkillPublishServiceTest {
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
}
@Test
void testPublishFromEntries_ShouldRejectArchivedSkill() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
Skill archivedSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
archivedSkill.setStatus(SkillStatus.ARCHIVED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(archivedSkill));
assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug,
entries,
publisherId,
SkillVisibility.PUBLIC,
Set.of()
));
}
@Test
void testPublishFromEntries_ShouldAutoGenerateVersionWhenMissing() throws Exception {
String namespaceSlug = "test-ns";

View file

@ -351,6 +351,124 @@ class SkillQueryServiceTest {
assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl());
}
@Test
void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String userId = "owner-1";
Map<Long, NamespaceRole> userNsRoles = Map.of();
Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId);
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
assertTrue(result.canManageLifecycle());
}
@Test
void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String userId = "viewer-1";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
assertFalse(result.canManageLifecycle());
}
@Test
void testListVersions_ShouldIncludeDraftAndRejectedForLifecycleManagers() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String userId = "owner-1";
Map<Long, NamespaceRole> userNsRoles = Map.of();
Namespace namespace = new Namespace(namespaceSlug, "Test NS", userId);
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
SkillVersion published = new SkillVersion(1L, "1.0.0", userId);
setId(published, 11L);
published.setStatus(SkillVersionStatus.PUBLISHED);
SkillVersion draft = new SkillVersion(1L, "1.1.0", userId);
setId(draft, 12L);
draft.setStatus(SkillVersionStatus.DRAFT);
SkillVersion rejected = new SkillVersion(1L, "1.2.0", userId);
setId(rejected, 13L);
rejected.setStatus(SkillVersionStatus.REJECTED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
Page<SkillVersion> result = service.listVersions(
namespaceSlug,
skillSlug,
userId,
userNsRoles,
PageRequest.of(0, 10)
);
assertEquals(List.of("1.0.0", "1.2.0", "1.1.0"),
result.getContent().stream().map(SkillVersion::getVersion).toList());
}
@Test
void testListVersions_ShouldOnlyReturnPublishedForRegularViewers() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String userId = "viewer-1";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
setId(published, 11L);
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));
Page<SkillVersion> result = service.listVersions(
namespaceSlug,
skillSlug,
userId,
userNsRoles,
PageRequest.of(0, 10)
);
assertEquals(List.of("1.0.0"),
result.getContent().stream().map(SkillVersion::getVersion).toList());
}
private void setId(Object entity, Long id) throws Exception {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);

View file

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

View file

@ -221,4 +221,50 @@ class SkillPackageValidatorTest {
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("Duplicate package entry path: docs/guide.md")));
}
@Test
void testSpoofedBinaryTextFileRejected() {
String skillMdContent = """
---
name: test-skill
description: A test skill
version: 1.0.0
---
Body
""";
byte[] binaryPayload = new byte[] {0x4d, 0x5a, 0x00, 0x02};
List<PackageEntry> entries = List.of(
new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"),
new PackageEntry("notes.md", binaryPayload, binaryPayload.length, "text/markdown")
);
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
}
@Test
void testInvalidSvgPayloadRejected() {
String skillMdContent = """
---
name: test-skill
description: A test skill
version: 1.0.0
---
Body
""";
List<PackageEntry> entries = List.of(
new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"),
new PackageEntry("icon.svg", "not actually svg".getBytes(), 16, "image/svg+xml")
);
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
}
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.report.SkillReport;
import com.iflytek.skillhub.domain.report.SkillReportRepository;
import com.iflytek.skillhub.domain.report.SkillReportStatus;
import java.util.Collection;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SkillReportJpaRepository extends JpaRepository<SkillReport, Long>, SkillReportRepository {
boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status);
Page<SkillReport> findByStatusOrderByCreatedAtDesc(SkillReportStatus status, Pageable pageable);
List<SkillReport> findBySkillIdIn(Collection<Long> skillIds);
@Override
default Page<SkillReport> findByStatus(SkillReportStatus status, Pageable pageable) {
return findByStatusOrderByCreatedAtDesc(status, pageable);
}
}

View file

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

View file

@ -3,10 +3,13 @@ package com.iflytek.skillhub.infra.jpa;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@Repository
public interface SkillSearchDocumentJpaRepository extends JpaRepository<SkillSearchDocumentEntity, Long> {
Optional<SkillSearchDocumentEntity> findBySkillId(Long skillId);
List<SkillSearchDocumentEntity> findBySkillIdIn(Collection<Long> skillIds);
void deleteBySkillId(Long skillId);
}

View file

@ -14,6 +14,7 @@ import java.util.Optional;
@Repository
public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, Long>, SkillVersionRepository {
List<SkillVersion> findByIdIn(List<Long> ids);
List<SkillVersion> findBySkillId(Long skillId);
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);

View file

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

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.search;
public interface SearchEmbeddingService {
String embed(String text);
double similarity(String text, String serializedVector);
}

View file

@ -9,6 +9,7 @@ public record SkillSearchDocument(
String summary,
String keywords,
String searchText,
String semanticVector,
String visibility,
String status
) {}

View file

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

View file

@ -1,13 +1,21 @@
package com.iflytek.skillhub.search.postgres;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
import com.iflytek.skillhub.search.SearchEmbeddingService;
import com.iflytek.skillhub.search.SearchQuery;
import com.iflytek.skillhub.search.SearchQueryService;
import com.iflytek.skillhub.search.SearchResult;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.util.Comparator;
import java.util.HashMap;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
@ -20,9 +28,32 @@ public class PostgresFullTextQueryService implements SearchQueryService {
private static final String TITLE_SQL = "LOWER(title)";
private final EntityManager entityManager;
private final SkillSearchDocumentJpaRepository searchDocumentRepository;
private final SearchEmbeddingService searchEmbeddingService;
private final boolean semanticEnabled;
private final double semanticWeight;
private final int candidateMultiplier;
private final int maxCandidates;
public PostgresFullTextQueryService(EntityManager entityManager) {
this(entityManager, null, null, false, 0.35D, 8, 120);
}
@Autowired
public PostgresFullTextQueryService(EntityManager entityManager,
SkillSearchDocumentJpaRepository searchDocumentRepository,
SearchEmbeddingService searchEmbeddingService,
@Value("${skillhub.search.semantic.enabled:true}") boolean semanticEnabled,
@Value("${skillhub.search.semantic.weight:0.35}") double semanticWeight,
@Value("${skillhub.search.semantic.candidate-multiplier:8}") int candidateMultiplier,
@Value("${skillhub.search.semantic.max-candidates:120}") int maxCandidates) {
this.entityManager = entityManager;
this.searchDocumentRepository = searchDocumentRepository;
this.searchEmbeddingService = searchEmbeddingService;
this.semanticEnabled = semanticEnabled;
this.semanticWeight = semanticWeight;
this.candidateMultiplier = candidateMultiplier;
this.maxCandidates = maxCandidates;
}
@Override
@ -31,6 +62,21 @@ public class PostgresFullTextQueryService implements SearchQueryService {
String tsQuery = buildPrefixTsQuery(normalizedKeyword);
boolean hasKeyword = tsQuery != null;
boolean useShortPrefixTitleSearch = hasKeyword && normalizedKeyword.length() <= SHORT_PREFIX_LENGTH;
boolean useSemanticRerank = semanticEnabled
&& hasKeyword
&& "relevance".equals(query.sortBy())
&& searchDocumentRepository != null
&& searchEmbeddingService != null;
int requestedOffset = query.page() * query.size();
if (useSemanticRerank && requestedOffset + query.size() > maxCandidates) {
useSemanticRerank = false;
}
int sqlLimit = query.size();
int sqlOffset = requestedOffset;
if (useSemanticRerank) {
sqlLimit = Math.min(Math.max((query.page() + 1) * query.size() * candidateMultiplier, query.size() * candidateMultiplier), maxCandidates);
sqlOffset = 0;
}
Set<Long> memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty()
? Set.of(-1L)
: query.visibilityScope().memberNamespaceIds();
@ -114,8 +160,8 @@ public class PostgresFullTextQueryService implements SearchQueryService {
nativeQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%");
}
nativeQuery.setParameter("limit", query.size());
nativeQuery.setParameter("offset", query.page() * query.size());
nativeQuery.setParameter("limit", sqlLimit);
nativeQuery.setParameter("offset", sqlOffset);
@SuppressWarnings("unchecked")
List<Long> skillIds = (List<Long>) nativeQuery.getResultList().stream()
@ -152,9 +198,60 @@ public class PostgresFullTextQueryService implements SearchQueryService {
long total = ((Number) countQuery.getSingleResult()).longValue();
if (useSemanticRerank && !skillIds.isEmpty()) {
skillIds = rerankBySemanticSimilarity(skillIds, normalizedKeyword, requestedOffset, query.size());
}
return new SearchResult(skillIds, total, query.page(), query.size());
}
private List<Long> rerankBySemanticSimilarity(List<Long> candidateSkillIds,
String normalizedKeyword,
int requestedOffset,
int pageSize) {
Map<Long, SkillSearchDocumentEntity> documentsBySkillId = new HashMap<>();
for (SkillSearchDocumentEntity entity : searchDocumentRepository.findBySkillIdIn(candidateSkillIds)) {
documentsBySkillId.put(entity.getSkillId(), entity);
}
int totalCandidates = Math.max(candidateSkillIds.size(), 1);
List<RankedSkill> rankedSkills = new java.util.ArrayList<>(candidateSkillIds.size());
for (int index = 0; index < candidateSkillIds.size(); index++) {
Long skillId = candidateSkillIds.get(index);
SkillSearchDocumentEntity entity = documentsBySkillId.get(skillId);
double baseScore = 1D - (index / (double) totalCandidates);
double semanticScore = computeSemanticScore(normalizedKeyword, entity);
double combinedScore = (baseScore * (1D - semanticWeight)) + (semanticScore * semanticWeight);
rankedSkills.add(new RankedSkill(skillId, combinedScore));
}
return rankedSkills.stream()
.sorted(Comparator.comparingDouble(RankedSkill::score).reversed())
.skip(requestedOffset)
.limit(pageSize)
.map(RankedSkill::skillId)
.toList();
}
private double computeSemanticScore(String normalizedKeyword, SkillSearchDocumentEntity entity) {
if (entity == null) {
return 0D;
}
String serializedVector = entity.getSemanticVector();
if (serializedVector == null || serializedVector.isBlank()) {
serializedVector = searchEmbeddingService.embed(String.join("\n",
safe(entity.getTitle()),
safe(entity.getSummary()),
safe(entity.getKeywords()),
safe(entity.getSearchText())));
}
return searchEmbeddingService.similarity(normalizedKeyword, serializedVector);
}
private String safe(String value) {
return value == null ? "" : value;
}
private String normalizeKeyword(String keyword) {
if (keyword == null || keyword.isBlank()) {
return null;
@ -183,4 +280,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
.reduce((left, right) -> left + " & " + right)
.orElse(null);
}
private record RankedSkill(Long skillId, double score) {
}
}

View file

@ -88,6 +88,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
skill.getSummary(),
"",
searchText,
null,
skill.getVisibility().name(),
skill.getStatus().name()
));

View file

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

View file

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

View file

@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' ws: wss: http://localhost:* https://localhost:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
/>
<title>SkillHub</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />

View file

@ -15,6 +15,7 @@ import type {
PromotionTask,
AuditLogItem,
SkillSummary,
SkillReport,
AuthMethod,
OAuthProvider,
User,
@ -411,6 +412,35 @@ export const accountApi = {
},
}
export const skillLifecycleApi = {
async archiveSkill(namespace: string, slug: string, reason?: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/archive`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}),
})
},
async unarchiveSkill(namespace: string, slug: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/unarchive`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async deleteVersion(namespace: string, slug: string, version: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
}
export const tokenApi = {
async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> {
const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', {
@ -562,6 +592,49 @@ export const promotionApi = {
},
}
export const reportApi = {
async submitSkillReport(namespace: string, slug: string, request: { reason: string; details?: string }): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/reports`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async listSkillReports(params: { status?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
searchParams.set('status', params.status ?? 'PENDING')
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: SkillReport[]; total: number; page: number; size: number }>(
`/api/v1/admin/skill-reports?${searchParams.toString()}`,
)
},
async resolveSkillReport(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/resolve`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment }),
})
},
async dismissSkillReport(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/dismiss`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment }),
})
},
}
export const meApi = {
async getStars(): Promise<SkillSummary[]> {
return fetchJson<SkillSummary[]>(`${WEB_API_PREFIX}/me/stars`)
@ -642,10 +715,27 @@ export const adminApi = {
})
},
async getAuditLogs(params: { action?: string; userId?: string; page?: number; size?: number }) {
async getAuditLogs(params: {
action?: string
userId?: string
requestId?: string
ipAddress?: string
resourceType?: string
resourceId?: string
startTime?: string
endTime?: string
page?: number
size?: number
}) {
const searchParams = new URLSearchParams()
if (params.action) searchParams.set('action', params.action)
if (params.userId) searchParams.set('userId', params.userId)
if (params.requestId) searchParams.set('requestId', params.requestId)
if (params.ipAddress) searchParams.set('ipAddress', params.ipAddress)
if (params.resourceType) searchParams.set('resourceType', params.resourceType)
if (params.resourceId) searchParams.set('resourceId', params.resourceId)
if (params.startTime) searchParams.set('startTime', params.startTime)
if (params.endTime) searchParams.set('endTime', params.endTime)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: AuditLogItem[]; total: number; page: number; size: number }>(

View file

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

View file

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

View file

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

View file

@ -0,0 +1,38 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { reportApi } from '@/api/client'
export function useSkillReports(status: string) {
return useQuery({
queryKey: ['skill-reports', status],
queryFn: async () => {
const page = await reportApi.listSkillReports({ status })
return page.items
},
})
}
export function useSubmitSkillReport(namespace: string, slug: string) {
return useMutation({
mutationFn: (request: { reason: string; details?: string }) => reportApi.submitSkillReport(namespace, slug, request),
})
}
export function useResolveSkillReport() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.resolveSkillReport(id, comment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skill-reports'] })
},
})
}
export function useDismissSkillReport() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.dismissSkillReport(id, comment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skill-reports'] })
},
})
}

View file

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

View file

@ -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": "安全设置",

View file

@ -18,11 +18,23 @@ export function AuditLogPage() {
const { t, i18n } = useTranslation()
const [actionFilter, setActionFilter] = useState<string>('')
const [userIdFilter, setUserIdFilter] = useState('')
const [requestIdFilter, setRequestIdFilter] = useState('')
const [ipFilter, setIpFilter] = useState('')
const [resourceTypeFilter, setResourceTypeFilter] = useState('')
const [resourceIdFilter, setResourceIdFilter] = useState('')
const [startTimeFilter, setStartTimeFilter] = useState('')
const [endTimeFilter, setEndTimeFilter] = useState('')
const [page, setPage] = useState(0)
const { data, isLoading } = useAuditLog({
action: actionFilter || undefined,
userId: userIdFilter || undefined,
requestId: requestIdFilter || undefined,
ipAddress: ipFilter || undefined,
resourceType: resourceTypeFilter || undefined,
resourceId: resourceIdFilter || undefined,
startTime: startTimeFilter ? new Date(startTimeFilter).toISOString() : undefined,
endTime: endTimeFilter ? new Date(endTimeFilter).toISOString() : undefined,
page,
size: 20,
})
@ -39,8 +51,11 @@ export function AuditLogPage() {
</div>
<Card className="p-5">
<div className="flex gap-4">
<Select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="w-[200px]">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Select value={actionFilter} onChange={(e) => {
setActionFilter(e.target.value)
setPage(0)
}} className="w-[200px]">
<option value="">{t('auditLog.filterAll')}</option>
<option value="CLI_PUBLISH">{t('auditLog.filterCliPublish')}</option>
<option value="COMPAT_PUBLISH">{t('auditLog.filterCompatPublish')}</option>
@ -52,8 +67,58 @@ export function AuditLogPage() {
<Input
placeholder={t('auditLog.userIdPlaceholder')}
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value)}
className="w-[200px]"
onChange={(e) => {
setUserIdFilter(e.target.value)
setPage(0)
}}
/>
<Input
placeholder={t('auditLog.requestIdPlaceholder')}
value={requestIdFilter}
onChange={(e) => {
setRequestIdFilter(e.target.value)
setPage(0)
}}
/>
<Input
placeholder={t('auditLog.ipPlaceholder')}
value={ipFilter}
onChange={(e) => {
setIpFilter(e.target.value)
setPage(0)
}}
/>
<Input
placeholder={t('auditLog.resourceTypePlaceholder')}
value={resourceTypeFilter}
onChange={(e) => {
setResourceTypeFilter(e.target.value)
setPage(0)
}}
/>
<Input
placeholder={t('auditLog.resourceIdPlaceholder')}
value={resourceIdFilter}
onChange={(e) => {
setResourceIdFilter(e.target.value)
setPage(0)
}}
/>
<Input
type="datetime-local"
value={startTimeFilter}
onChange={(e) => {
setStartTimeFilter(e.target.value)
setPage(0)
}}
/>
<Input
type="datetime-local"
value={endTimeFilter}
onChange={(e) => {
setEndTimeFilter(e.target.value)
setPage(0)
}}
/>
</div>
</Card>
@ -88,10 +153,10 @@ export function AuditLogPage() {
<TableCell>{formatDate(log.timestamp)}</TableCell>
<TableCell className="font-medium">{log.action}</TableCell>
<TableCell>{log.userId || '-'}</TableCell>
<TableCell>{log.resourceType || '-'}</TableCell>
<TableCell>{log.username || '-'}</TableCell>
<TableCell>{log.ipAddress || '-'}</TableCell>
<TableCell className="max-w-md truncate">
{log.resourceId || '-'}
{log.details || `${log.resourceType || '-'}:${log.resourceId || '-'}`}
</TableCell>
</TableRow>
))}

View file

@ -6,7 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/sha
export function DashboardPage() {
const { t } = useTranslation()
const { user } = useAuth()
const { user, hasRole } = useAuth()
const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
return (
<div className="space-y-8 animate-fade-up">
@ -58,7 +59,7 @@ export function DashboardPage() {
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className={`grid grid-cols-1 gap-4 ${governanceVisible ? 'md:grid-cols-4' : 'md:grid-cols-3'}`}>
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.starsAndRatings')}</div>
<Link to="/dashboard/stars" className="mt-2 inline-block font-semibold text-primary hover:underline">
@ -77,6 +78,14 @@ export function DashboardPage() {
{t('dashboard.viewPromotions')}
</Link>
</Card>
{governanceVisible ? (
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.reportsTitle')}</div>
<Link to="/dashboard/reports" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewReports')}
</Link>
</Card>
) : null}
</div>
<TokenList />

View file

@ -1,22 +1,32 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { EmptyState } from '@/shared/components/empty-state'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { useMySkills } from '@/shared/hooks/use-skill-queries'
import { useArchiveSkill, useMySkills, useUnarchiveSkill } from '@/shared/hooks/use-skill-queries'
import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
export function MySkillsPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
const { data: skills, isLoading } = useMySkills()
const archiveMutation = useArchiveSkill()
const unarchiveMutation = useUnarchiveSkill()
const handleSkillClick = (namespace: string, slug: string) => {
navigate({ to: `/space/${namespace}/${slug}` })
}
const resolveStatusLabel = (status?: string) => {
if (status === 'ARCHIVED') {
return t('mySkills.statusArchived')
}
if (status === 'PENDING_REVIEW') {
return t('mySkills.statusPendingReview')
}
@ -27,6 +37,9 @@ export function MySkillsPage() {
}
const resolveStatusClassName = (status?: string) => {
if (status === 'ARCHIVED') {
return 'bg-slate-500/10 text-slate-500 border-slate-500/20'
}
if (status === 'PENDING_REVIEW') {
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}
@ -36,6 +49,46 @@ export function MySkillsPage() {
return 'bg-secondary/60 text-muted-foreground border-border/40'
}
const handleArchiveSkill = async () => {
if (!archiveTarget) {
return
}
try {
await archiveMutation.mutateAsync({
namespace: archiveTarget.namespace,
slug: archiveTarget.slug,
})
toast.success(
t('mySkills.archiveSuccessTitle'),
t('mySkills.archiveSuccessDescription', { skill: archiveTarget.name }),
)
setArchiveTarget(null)
} catch (error) {
toast.error(t('mySkills.archiveErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
const handleUnarchiveSkill = async () => {
if (!unarchiveTarget) {
return
}
try {
await unarchiveMutation.mutateAsync({
namespace: unarchiveTarget.namespace,
slug: unarchiveTarget.slug,
})
toast.success(
t('mySkills.unarchiveSuccessTitle'),
t('mySkills.unarchiveSuccessDescription', { skill: unarchiveTarget.name }),
)
setUnarchiveTarget(null)
} catch (error) {
toast.error(t('mySkills.unarchiveErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
if (isLoading) {
return (
<div className="space-y-4 animate-fade-up">
@ -79,6 +132,11 @@ export function MySkillsPage() {
{skill.latestVersion && (
<span className="font-mono text-xs">v{skill.latestVersion}</span>
)}
{skill.status ? (
<span className={`rounded-full border px-2.5 py-0.5 text-xs ${resolveStatusClassName(skill.status)}`}>
{resolveStatusLabel(skill.status)}
</span>
) : null}
{skill.latestVersionStatus ? (
<span className={`rounded-full border px-2.5 py-0.5 text-xs ${resolveStatusClassName(skill.latestVersionStatus)}`}>
{resolveStatusLabel(skill.latestVersionStatus)}
@ -92,9 +150,42 @@ export function MySkillsPage() {
</span>
</div>
</div>
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="flex items-center gap-2 pl-4">
{skill.status === 'ARCHIVED' ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setUnarchiveTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
})
}}
>
{t('mySkills.unarchive')}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setArchiveTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
})
}}
>
{t('mySkills.archive')}
</Button>
)}
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</Card>
))}
@ -110,6 +201,32 @@ export function MySkillsPage() {
}
/>
)}
<ConfirmDialog
open={!!archiveTarget}
onOpenChange={(open) => {
if (!open) {
setArchiveTarget(null)
}
}}
title={t('mySkills.archiveConfirmTitle')}
description={archiveTarget ? t('mySkills.archiveConfirmDescription', { skill: archiveTarget.name }) : ''}
confirmText={t('mySkills.archive')}
onConfirm={handleArchiveSkill}
/>
<ConfirmDialog
open={!!unarchiveTarget}
onOpenChange={(open) => {
if (!open) {
setUnarchiveTarget(null)
}
}}
title={t('mySkills.unarchiveConfirmTitle')}
description={unarchiveTarget ? t('mySkills.unarchiveConfirmDescription', { skill: unarchiveTarget.name }) : ''}
confirmText={t('mySkills.unarchive')}
onConfirm={handleUnarchiveSkill}
/>
</div>
)
}

View file

@ -0,0 +1,161 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Card } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { Button } from '@/shared/ui/button'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { useDismissSkillReport, useResolveSkillReport, useSkillReports } from '@/features/report/use-skill-reports'
import { toast } from '@/shared/lib/toast'
export function ReportsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const [pendingAction, setPendingAction] = useState<{ id: number; action: 'resolve' | 'dismiss'; skillLabel: string } | null>(null)
const { data: pendingReports, isLoading: isPendingLoading } = useSkillReports('PENDING')
const { data: resolvedReports, isLoading: isResolvedLoading } = useSkillReports('RESOLVED')
const { data: dismissedReports, isLoading: isDismissedLoading } = useSkillReports('DISMISSED')
const resolveMutation = useResolveSkillReport()
const dismissMutation = useDismissSkillReport()
const formatDate = (dateString: string) => new Date(dateString).toLocaleString(i18n.language)
const handleOpenSkill = (namespace?: string, skillSlug?: string) => {
if (!namespace || !skillSlug) {
return
}
navigate({ to: `/space/${namespace}/${skillSlug}` })
}
const handleConfirm = async () => {
if (!pendingAction) {
return
}
try {
if (pendingAction.action === 'resolve') {
await resolveMutation.mutateAsync({ id: pendingAction.id })
toast.success(t('reports.resolveSuccessTitle'), t('reports.resolveSuccessDescription', { skill: pendingAction.skillLabel }))
} else {
await dismissMutation.mutateAsync({ id: pendingAction.id })
toast.success(t('reports.dismissSuccessTitle'), t('reports.dismissSuccessDescription', { skill: pendingAction.skillLabel }))
}
setPendingAction(null)
} catch (error) {
toast.error(
pendingAction.action === 'resolve' ? t('reports.resolveErrorTitle') : t('reports.dismissErrorTitle'),
error instanceof Error ? error.message : '',
)
}
}
const renderList = (reports: typeof pendingReports, isLoading: boolean, status: 'PENDING' | 'RESOLVED' | 'DISMISSED') => {
if (isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-24 animate-shimmer rounded-lg" />
))}
</div>
)
}
if (!reports || reports.length === 0) {
return <Card className="p-12 text-center text-muted-foreground">{t('reports.empty')}</Card>
}
return (
<div className="space-y-4">
{reports.map((report) => {
const skillLabel = report.skillDisplayName || report.skillSlug || `#${report.skillId}`
return (
<Card key={report.id} className="p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 min-w-0">
<button
type="button"
className="text-left font-semibold font-heading text-foreground hover:text-primary transition-colors"
onClick={() => handleOpenSkill(report.namespace, report.skillSlug)}
>
{report.namespace && report.skillSlug ? `${report.namespace}/${report.skillSlug}` : skillLabel}
</button>
<div className="text-sm text-muted-foreground">{skillLabel}</div>
<div className="text-sm text-foreground">{report.reason}</div>
{report.details ? <div className="text-sm text-muted-foreground whitespace-pre-wrap">{report.details}</div> : null}
</div>
<div className="text-right text-xs text-muted-foreground space-y-1 shrink-0">
<div>{t('reports.reporter')}: {report.reporterId}</div>
<div>{formatDate(report.createdAt)}</div>
{report.handledAt ? <div>{formatDate(report.handledAt)}</div> : null}
</div>
</div>
{status === 'PENDING' ? (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={resolveMutation.isPending || dismissMutation.isPending}
onClick={() => setPendingAction({ id: report.id, action: 'dismiss', skillLabel })}
>
{t('reports.dismiss')}
</Button>
<Button
size="sm"
disabled={resolveMutation.isPending || dismissMutation.isPending}
onClick={() => setPendingAction({ id: report.id, action: 'resolve', skillLabel })}
>
{t('reports.resolve')}
</Button>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t('reports.handledBy')}: {report.handledBy || '—'}
</div>
)}
</Card>
)
})}
</div>
)
}
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('reports.title')} subtitle={t('reports.subtitle')} />
<Tabs defaultValue="PENDING">
<TabsList>
<TabsTrigger value="PENDING">{t('reports.tabPending')}</TabsTrigger>
<TabsTrigger value="RESOLVED">{t('reports.tabResolved')}</TabsTrigger>
<TabsTrigger value="DISMISSED">{t('reports.tabDismissed')}</TabsTrigger>
</TabsList>
<TabsContent value="PENDING" className="mt-6">
{renderList(pendingReports, isPendingLoading, 'PENDING')}
</TabsContent>
<TabsContent value="RESOLVED" className="mt-6">
{renderList(resolvedReports, isResolvedLoading, 'RESOLVED')}
</TabsContent>
<TabsContent value="DISMISSED" className="mt-6">
{renderList(dismissedReports, isDismissedLoading, 'DISMISSED')}
</TabsContent>
</Tabs>
<ConfirmDialog
open={pendingAction !== null}
onOpenChange={(open) => {
if (!open) {
setPendingAction(null)
}
}}
title={pendingAction?.action === 'resolve' ? t('reports.resolveConfirmTitle') : t('reports.dismissConfirmTitle')}
description={pendingAction?.action === 'resolve'
? t('reports.resolveConfirmDescription', { skill: pendingAction?.skillLabel ?? '' })
: t('reports.dismissConfirmDescription', { skill: pendingAction?.skillLabel ?? '' })}
confirmText={pendingAction?.action === 'resolve' ? t('reports.resolve') : t('reports.dismiss')}
onConfirm={handleConfirm}
/>
</div>
)
}

View file

@ -1,3 +1,4 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate, useRouterState } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@ -9,17 +10,26 @@ import { RatingInput } from '@/features/social/rating-input'
import { StarButton } from '@/features/social/star-button'
import { useAuth } from '@/features/auth/use-auth'
import { adminApi, WEB_API_PREFIX } from '@/api/client'
import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { formatCompactCount } from '@/shared/lib/number-format'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Textarea } from '@/shared/ui/textarea'
import { toast } from '@/shared/lib/toast'
import {
useSkillDetail,
useSkillVersions,
useSkillFiles,
useSkillReadme,
useArchiveSkill,
useDeleteSkillVersion,
useUnarchiveSkill,
} from '@/shared/hooks/use-skill-queries'
export function SkillDetailPage() {
@ -27,6 +37,12 @@ export function SkillDetailPage() {
const navigate = useNavigate()
const location = useRouterState({ select: (s) => s.location })
const queryClient = useQueryClient()
const [reportDialogOpen, setReportDialogOpen] = useState(false)
const [reportReason, setReportReason] = useState('')
const [reportDetails, setReportDetails] = useState('')
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false)
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
const [deleteVersionTarget, setDeleteVersionTarget] = useState<string | null>(null)
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
const { user, hasRole } = useAuth()
@ -57,6 +73,10 @@ export function SkillDetailPage() {
mutationFn: () => adminApi.yankVersion(latestVersion!.id),
onSuccess: refreshSkill,
})
const archiveMutation = useArchiveSkill()
const unarchiveMutation = useUnarchiveSkill()
const deleteVersionMutation = useDeleteSkillVersion()
const reportMutation = useSubmitSkillReport(namespace, slug)
const handleDownload = () => {
if (!user) {
@ -80,6 +100,34 @@ export function SkillDetailPage() {
})
}
const handleOpenReport = () => {
if (!user) {
requireLogin()
return
}
setReportDialogOpen(true)
}
const handleSubmitReport = async () => {
if (!reportReason.trim()) {
toast.error(t('skillDetail.reportReasonRequired'))
return
}
try {
await reportMutation.mutateAsync({
reason: reportReason.trim(),
details: reportDetails.trim() || undefined,
})
setReportDialogOpen(false)
setReportReason('')
setReportDetails('')
toast.success(t('skillDetail.reportSuccessTitle'), t('skillDetail.reportSuccessDescription'))
} catch (error) {
toast.error(t('skillDetail.reportErrorTitle'), error instanceof Error ? error.message : '')
}
}
const handleBack = () => {
if (window.history.length > 1) {
window.history.back()
@ -88,6 +136,66 @@ export function SkillDetailPage() {
navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })
}
const resolveSkillStatusLabel = (status?: string) => {
if (status === 'ARCHIVED') {
return t('skillDetail.statusArchived')
}
if (status === 'ACTIVE') {
return t('skillDetail.statusActive')
}
if (status === 'HIDDEN') {
return t('skillDetail.statusHidden')
}
return status ?? ''
}
const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED'
const handleArchive = async () => {
try {
await archiveMutation.mutateAsync({ namespace, slug })
toast.success(
t('skillDetail.archiveSuccessTitle'),
t('skillDetail.archiveSuccessDescription', { skill: skill?.displayName ?? slug }),
)
setArchiveConfirmOpen(false)
} catch (error) {
toast.error(t('skillDetail.archiveErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
const handleUnarchive = async () => {
try {
await unarchiveMutation.mutateAsync({ namespace, slug })
toast.success(
t('skillDetail.unarchiveSuccessTitle'),
t('skillDetail.unarchiveSuccessDescription', { skill: skill?.displayName ?? slug }),
)
setUnarchiveConfirmOpen(false)
} catch (error) {
toast.error(t('skillDetail.unarchiveErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
const handleDeleteVersion = async () => {
if (!deleteVersionTarget) {
return
}
try {
await deleteVersionMutation.mutateAsync({ namespace, slug, version: deleteVersionTarget })
toast.success(
t('skillDetail.deleteVersionSuccessTitle'),
t('skillDetail.deleteVersionSuccessDescription', { version: deleteVersionTarget }),
)
setDeleteVersionTarget(null)
} catch (error) {
toast.error(t('skillDetail.deleteVersionErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
if (isLoadingSkill) {
return (
<div className="space-y-6 animate-fade-up">
@ -144,6 +252,11 @@ export function SkillDetailPage() {
</Button>
<div className="flex items-center gap-3 mb-1">
<NamespaceBadge type="GLOBAL" name={namespace} />
{skill.status && (
<span className="rounded-full border border-border/60 bg-secondary/40 px-2.5 py-0.5 text-xs text-muted-foreground">
{resolveSkillStatusLabel(skill.status)}
</span>
)}
</div>
<h1 className="text-4xl font-bold font-heading text-foreground">{skill.displayName}</h1>
{skill.summary && (
@ -191,10 +304,26 @@ export function SkillDetailPage() {
<span className="px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-sm font-mono">
v{version.version}
</span>
{version.status && (
<span className="rounded-full border border-border/60 bg-secondary/40 px-2.5 py-0.5 text-xs text-muted-foreground">
{version.status}
</span>
)}
</span>
<span className="text-sm text-muted-foreground">
{formatLocalDateTime(version.publishedAt, i18n.language)}
</span>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{formatLocalDateTime(version.publishedAt, i18n.language)}
</span>
{skill.canManageLifecycle && canDeleteVersion(version.status) && (
<Button
size="sm"
variant="outline"
onClick={() => setDeleteVersionTarget(version.version)}
>
{t('skillDetail.deleteVersion')}
</Button>
)}
</div>
</div>
{version.changelog && (
<p className="text-sm text-muted-foreground leading-relaxed">{version.changelog}</p>
@ -253,6 +382,9 @@ export function SkillDetailPage() {
<div className="space-y-3">
<StarButton skillId={skill.id} starCount={skill.starCount} onRequireLogin={requireLogin} />
<RatingInput skillId={skill.id} onRequireLogin={requireLogin} />
<Button variant="outline" className="w-full" onClick={handleOpenReport} disabled={reportMutation.isPending}>
{reportMutation.isPending ? t('skillDetail.processing') : t('skillDetail.reportSkill')}
</Button>
{!user && (
<p className="text-xs text-muted-foreground">{t('skillDetail.loginToRate')}</p>
)}
@ -262,6 +394,9 @@ export function SkillDetailPage() {
{skill.latestVersion && (
<Card className="p-5 space-y-4">
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.install')}</div>
{skill.status === 'ARCHIVED' && (
<p className="text-sm text-muted-foreground">{t('skillDetail.archivedInstallHint')}</p>
)}
<InstallCommand
namespace={namespace}
slug={slug}
@ -275,7 +410,7 @@ export function SkillDetailPage() {
variant="outline"
size="lg"
onClick={handleDownload}
disabled={!latestVersion}
disabled={!latestVersion || skill.status === 'ARCHIVED'}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
@ -283,6 +418,26 @@ export function SkillDetailPage() {
{t('skillDetail.download')}
</Button>
{skill.canManageLifecycle && (
<Card className="p-5 space-y-3">
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.lifecycle')}</div>
<p className="text-sm text-muted-foreground">
{skill.status === 'ARCHIVED'
? t('skillDetail.archivedPublishHint')
: t('skillDetail.lifecycleHint')}
</p>
{skill.status === 'ARCHIVED' ? (
<Button variant="outline" onClick={() => setUnarchiveConfirmOpen(true)} disabled={unarchiveMutation.isPending}>
{unarchiveMutation.isPending ? t('skillDetail.processing') : t('skillDetail.unarchiveSkill')}
</Button>
) : (
<Button variant="outline" onClick={() => setArchiveConfirmOpen(true)} disabled={archiveMutation.isPending}>
{archiveMutation.isPending ? t('skillDetail.processing') : t('skillDetail.archiveSkill')}
</Button>
)}
</Card>
)}
{governanceVisible && (
<Card className="p-5 space-y-3">
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.governance')}</div>
@ -305,6 +460,69 @@ export function SkillDetailPage() {
</Card>
)}
</div>
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('skillDetail.reportDialogTitle')}</DialogTitle>
<DialogDescription>{t('skillDetail.reportDialogDescription')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
value={reportReason}
onChange={(event) => setReportReason(event.target.value)}
placeholder={t('skillDetail.reportReasonPlaceholder')}
maxLength={200}
/>
<Textarea
value={reportDetails}
onChange={(event) => setReportDetails(event.target.value)}
placeholder={t('skillDetail.reportDetailsPlaceholder')}
rows={5}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReportDialogOpen(false)}>
{t('dialog.cancel')}
</Button>
<Button onClick={handleSubmitReport} disabled={reportMutation.isPending}>
{reportMutation.isPending ? t('skillDetail.processing') : t('skillDetail.submitReport')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={archiveConfirmOpen}
onOpenChange={setArchiveConfirmOpen}
title={t('skillDetail.archiveConfirmTitle')}
description={t('skillDetail.archiveConfirmDescription', { skill: skill.displayName })}
confirmText={t('skillDetail.archiveSkill')}
onConfirm={handleArchive}
/>
<ConfirmDialog
open={unarchiveConfirmOpen}
onOpenChange={setUnarchiveConfirmOpen}
title={t('skillDetail.unarchiveConfirmTitle')}
description={t('skillDetail.unarchiveConfirmDescription', { skill: skill.displayName })}
confirmText={t('skillDetail.unarchiveSkill')}
onConfirm={handleUnarchive}
/>
<ConfirmDialog
open={!!deleteVersionTarget}
onOpenChange={(open) => {
if (!open) {
setDeleteVersionTarget(null)
}
}}
title={t('skillDetail.deleteVersionConfirmTitle')}
description={deleteVersionTarget ? t('skillDetail.deleteVersionConfirmDescription', { version: deleteVersionTarget }) : ''}
confirmText={t('skillDetail.deleteVersion')}
variant="destructive"
onConfirm={handleDeleteVersion}
/>
</div>
)
}

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