feat: build governance center workflow

This commit is contained in:
yun-zhi-ztl 2026-03-16 14:27:40 +08:00 • committed by Xudong Sun
parent 62979b2dd7
commit b30311a537
46 changed files with 2101 additions and 37 deletions

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

@ -0,0 +1,117 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
PASS=0
FAIL=0
COOKIE_FILE="$(mktemp)"
cleanup() {
rm -f "$COOKIE_FILE"
}
trap cleanup EXIT
pass() {
echo "PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo "FAIL: $1"
FAIL=$((FAIL + 1))
}
json_field() {
local json="$1"
local expr="$2"
JSON_INPUT="$json" python3 - "$expr" <<'PY'
import json
import os
import sys
expr = sys.argv[1]
value = json.loads(os.environ["JSON_INPUT"])
for part in expr.split('.'):
if not part:
continue
if part.isdigit():
value = value[int(part)]
else:
value = value[part]
if isinstance(value, (dict, list)):
print(json.dumps(value, ensure_ascii=False))
else:
print(value)
PY
}
assert_code() {
local description="$1"
local json="$2"
local expected="$3"
local actual
actual="$(json_field "$json" "code")"
if [[ "$actual" == "$expected" ]]; then
pass "$description"
else
fail "$description (expected code $expected, got $actual)"
fi
}
assert_json_expr() {
local description="$1"
local json="$2"
local script="$3"
if JSON_INPUT="$json" python3 - <<PY
import json
import os
data = json.loads(os.environ["JSON_INPUT"])
$script
PY
then
pass "$description"
else
fail "$description"
fi
}
echo "=== Governance Workflow Smoke Test ==="
echo "Target: $BASE_URL"
echo
curl -s -c "$COOKIE_FILE" -H "X-Mock-User-Id: local-admin" "$BASE_URL/api/v1/auth/providers" >/dev/null
ADMIN_HEADERS=(-H "X-Mock-User-Id: local-admin" -b "$COOKIE_FILE" -c "$COOKIE_FILE")
SUMMARY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/summary")"
assert_code "Governance summary endpoint is available" "$SUMMARY_RESPONSE" "0"
assert_json_expr "Governance summary exposes review/promotion/report counts" "$SUMMARY_RESPONSE" $'summary = data["data"]\nassert "pendingReviews" in summary\nassert "pendingPromotions" in summary\nassert "pendingReports" in summary'
INBOX_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/inbox")"
assert_code "Governance inbox endpoint is available" "$INBOX_RESPONSE" "0"
assert_json_expr "Governance inbox returns paged items" "$INBOX_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0'
ACTIVITY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/activity")"
assert_code "Governance activity endpoint is available" "$ACTIVITY_RESPONSE" "0"
assert_json_expr "Governance activity returns paged items" "$ACTIVITY_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0'
NOTIFICATIONS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/notifications")"
assert_code "Governance notifications endpoint is available" "$NOTIFICATIONS_RESPONSE" "0"
assert_json_expr "Governance notifications returns a list" "$NOTIFICATIONS_RESPONSE" $'assert isinstance(data["data"], list)'
REPORTS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/skill-reports?status=PENDING&page=0&size=5")"
assert_code "Admin report list endpoint is available" "$REPORTS_RESPONSE" "0"
assert_json_expr "Admin report list returns page metadata" "$REPORTS_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5'
AUDIT_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/audit-logs?action=REVIEW_APPROVE&page=0&size=5")"
assert_code "Audit log endpoint is available for governance filters" "$AUDIT_RESPONSE" "0"
assert_json_expr "Audit log endpoint returns page metadata" "$AUDIT_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5'
echo
echo "Results: $PASS passed, $FAIL failed"
if [[ "$FAIL" -ne 0 ]]; then
exit 1
fi

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.report.SkillReportDisposition;
import com.iflytek.skillhub.domain.report.SkillReportService;
import com.iflytek.skillhub.dto.AdminSkillReportActionRequest;
import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse;
@ -54,6 +55,9 @@ public class AdminSkillReportController extends BaseApiController {
var report = skillReportService.resolveReport(
reportId,
principal.userId(),
request != null && request.disposition() != null
? SkillReportDisposition.valueOf(request.disposition().trim().toUpperCase())
: SkillReportDisposition.RESOLVE_ONLY,
request != null ? request.comment() : null,
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent")

View file

@ -0,0 +1,115 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.governance.UserNotification;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.GovernanceActivityItemResponse;
import com.iflytek.skillhub.dto.GovernanceInboxItemResponse;
import com.iflytek.skillhub.dto.GovernanceNotificationResponse;
import com.iflytek.skillhub.dto.GovernanceSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.service.GovernanceWorkbenchAppService;
import java.util.Map;
import java.util.Set;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping({"/api/v1/governance", "/api/web/governance"})
public class GovernanceController extends BaseApiController {
private final GovernanceWorkbenchAppService governanceWorkbenchAppService;
private final RbacService rbacService;
private final GovernanceNotificationService governanceNotificationService;
public GovernanceController(GovernanceWorkbenchAppService governanceWorkbenchAppService,
RbacService rbacService,
GovernanceNotificationService governanceNotificationService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.governanceWorkbenchAppService = governanceWorkbenchAppService;
this.rbacService = rbacService;
this.governanceNotificationService = governanceNotificationService;
}
@GetMapping("/summary")
public ApiResponse<GovernanceSummaryResponse> summary(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok(
"response.success.read",
governanceWorkbenchAppService.getSummary(userId, userNsRoles != null ? userNsRoles : Map.of(), roles(userId))
);
}
@GetMapping("/inbox")
public ApiResponse<PageResponse<GovernanceInboxItemResponse>> inbox(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ok(
"response.success.read",
governanceWorkbenchAppService.listInbox(
userId,
userNsRoles != null ? userNsRoles : Map.of(),
roles(userId),
type,
page,
size
)
);
}
@GetMapping("/activity")
public ApiResponse<PageResponse<GovernanceActivityItemResponse>> activity(
@RequestAttribute("userId") String userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ok("response.success.read", governanceWorkbenchAppService.listActivity(roles(userId), page, size));
}
@GetMapping("/notifications")
public ApiResponse<java.util.List<GovernanceNotificationResponse>> notifications(
@RequestAttribute("userId") String userId) {
return ok(
"response.success.read",
governanceNotificationService.listNotifications(userId).stream().map(this::toNotificationResponse).toList()
);
}
@PostMapping("/notifications/{id}/read")
public ApiResponse<GovernanceNotificationResponse> markNotificationRead(
@PathVariable Long id,
@RequestAttribute("userId") String userId) {
return ok("response.success.updated", toNotificationResponse(governanceNotificationService.markRead(id, userId)));
}
private Set<String> roles(String userId) {
return rbacService.getUserRoleCodes(userId);
}
private GovernanceNotificationResponse toNotificationResponse(UserNotification notification) {
return new GovernanceNotificationResponse(
notification.getId(),
notification.getCategory(),
notification.getEntityType(),
notification.getEntityId(),
notification.getTitle(),
notification.getBodyJson(),
notification.getStatus().name(),
notification.getCreatedAt() != null ? notification.getCreatedAt().toString() : null,
notification.getReadAt() != null ? notification.getReadAt().toString() : null
);
}
}

View file

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

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;
public record GovernanceActivityItemResponse(
Long id,
String action,
String actorUserId,
String actorDisplayName,
String targetType,
String targetId,
String details,
String timestamp
) {
}

View file

@ -0,0 +1,12 @@
package com.iflytek.skillhub.dto;
public record GovernanceInboxItemResponse(
String type,
Long id,
String title,
String subtitle,
String timestamp,
String namespace,
String skillSlug
) {
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.dto;
public record GovernanceNotificationResponse(
Long id,
String category,
String entityType,
Long entityId,
String title,
String bodyJson,
String status,
String createdAt,
String readAt
) {
}

View file

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

View file

@ -10,6 +10,7 @@ import org.springframework.util.StringUtils;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
@Service
@ -32,6 +33,20 @@ public class AdminAuditLogAppService {
String resourceId,
Instant startTime,
Instant endTime) {
return listAuditLogsByActions(page, size, userId, action != null ? List.of(action) : null, requestId, ipAddress, resourceType, resourceId, startTime, endTime);
}
@Transactional(readOnly = true)
public PageResponse<AuditLogItemResponse> listAuditLogsByActions(int page,
int size,
String userId,
Collection<String> actions,
String requestId,
String ipAddress,
String resourceType,
String resourceId,
Instant startTime,
Instant endTime) {
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("limit", size)
.addValue("offset", Math.max(page, 0) * size);
@ -39,7 +54,7 @@ public class AdminAuditLogAppService {
String whereClause = buildWhereClause(
parameters,
userId,
action,
actions,
requestId,
ipAddress,
resourceType,
@ -92,7 +107,7 @@ public class AdminAuditLogAppService {
private String buildWhereClause(MapSqlParameterSource parameters,
String userId,
String action,
Collection<String> actions,
String requestId,
String ipAddress,
String resourceType,
@ -104,9 +119,9 @@ public class AdminAuditLogAppService {
clause.append(" AND al.actor_user_id = :userId");
parameters.addValue("userId", userId.trim());
}
if (StringUtils.hasText(action)) {
clause.append(" AND al.action = :action");
parameters.addValue("action", action.trim());
if (actions != null && !actions.isEmpty()) {
clause.append(" AND al.action IN (:actions)");
parameters.addValue("actions", actions.stream().filter(StringUtils::hasText).map(String::trim).toList());
}
if (StringUtils.hasText(requestId)) {
clause.append(" AND al.request_id = :requestId");

View file

@ -0,0 +1,236 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.report.SkillReport;
import com.iflytek.skillhub.domain.report.SkillReportRepository;
import com.iflytek.skillhub.domain.report.SkillReportStatus;
import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.dto.AuditLogItemResponse;
import com.iflytek.skillhub.dto.GovernanceActivityItemResponse;
import com.iflytek.skillhub.dto.GovernanceInboxItemResponse;
import com.iflytek.skillhub.dto.GovernanceSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
@Service
public class GovernanceWorkbenchAppService {
private static final int SUMMARY_PAGE_SIZE = 100;
private static final Set<String> ACTIVITY_ACTIONS = Set.of(
"REVIEW_SUBMIT",
"REVIEW_APPROVE",
"REVIEW_REJECT",
"REVIEW_WITHDRAW",
"PROMOTION_SUBMIT",
"PROMOTION_APPROVE",
"PROMOTION_REJECT",
"REPORT_SKILL",
"RESOLVE_SKILL_REPORT",
"DISMISS_SKILL_REPORT",
"HIDE_SKILL",
"ARCHIVE_SKILL",
"UNHIDE_SKILL",
"UNARCHIVE_SKILL"
);
private final ReviewTaskRepository reviewTaskRepository;
private final PromotionRequestRepository promotionRequestRepository;
private final SkillReportRepository skillReportRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final NamespaceRepository namespaceRepository;
private final AdminAuditLogAppService adminAuditLogAppService;
public GovernanceWorkbenchAppService(ReviewTaskRepository reviewTaskRepository,
PromotionRequestRepository promotionRequestRepository,
SkillReportRepository skillReportRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
NamespaceRepository namespaceRepository,
AdminAuditLogAppService adminAuditLogAppService) {
this.reviewTaskRepository = reviewTaskRepository;
this.promotionRequestRepository = promotionRequestRepository;
this.skillReportRepository = skillReportRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.namespaceRepository = namespaceRepository;
this.adminAuditLogAppService = adminAuditLogAppService;
}
public GovernanceSummaryResponse getSummary(String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
return new GovernanceSummaryResponse(
visiblePendingReviews(namespaceRoles, platformRoles, SUMMARY_PAGE_SIZE).getTotalElements(),
hasPlatformGovernanceRole(platformRoles)
? promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements()
: 0,
hasPlatformGovernanceRole(platformRoles)
? skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements()
: 0
);
}
public PageResponse<GovernanceInboxItemResponse> listInbox(String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
String type,
int page,
int size) {
List<GovernanceInboxItemResponse> items = new ArrayList<>();
boolean includeAll = type == null || type.isBlank();
if (includeAll || "REVIEW".equalsIgnoreCase(type)) {
visiblePendingReviews(namespaceRoles, platformRoles, size).getContent().stream()
.map(this::toReviewInboxItem)
.forEach(items::add);
}
if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "PROMOTION".equalsIgnoreCase(type))) {
promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(page, size)).getContent().stream()
.map(this::toPromotionInboxItem)
.forEach(items::add);
}
if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "REPORT".equalsIgnoreCase(type))) {
skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(page, size)).getContent().stream()
.map(this::toReportInboxItem)
.forEach(items::add);
}
items.sort(Comparator.comparing(
GovernanceInboxItemResponse::timestamp,
Comparator.nullsLast(String::compareTo)
).reversed());
int fromIndex = Math.min(page * size, items.size());
int toIndex = Math.min(fromIndex + size, items.size());
return new PageResponse<>(items.subList(fromIndex, toIndex), items.size(), page, size);
}
public PageResponse<GovernanceActivityItemResponse> listActivity(Set<String> platformRoles, int page, int size) {
if (!canReadActivity(platformRoles)) {
return new PageResponse<>(List.of(), 0, page, size);
}
PageResponse<AuditLogItemResponse> raw = adminAuditLogAppService.listAuditLogsByActions(
page,
size,
null,
ACTIVITY_ACTIONS,
null,
null,
null,
null,
null,
null
);
List<GovernanceActivityItemResponse> items = raw.items().stream()
.map(item -> new GovernanceActivityItemResponse(
item.id(),
item.action(),
item.userId(),
item.username(),
item.resourceType(),
item.resourceId(),
item.details(),
item.timestamp() != null ? item.timestamp().toString() : null
))
.toList();
return new PageResponse<>(items, items.size(), page, size);
}
private Page<ReviewTask> visiblePendingReviews(Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
int size) {
if (hasPlatformGovernanceRole(platformRoles)) {
return reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, size));
}
List<ReviewTask> tasks = namespaceRoles.entrySet().stream()
.filter(entry -> entry.getValue() == NamespaceRole.OWNER || entry.getValue() == NamespaceRole.ADMIN)
.map(entry -> reviewTaskRepository.findByNamespaceIdAndStatus(entry.getKey(), ReviewTaskStatus.PENDING, PageRequest.of(0, size)))
.flatMap(pageResult -> pageResult.getContent().stream())
.toList();
return new org.springframework.data.domain.PageImpl<>(tasks, PageRequest.of(0, size), tasks.size());
}
private GovernanceInboxItemResponse toReviewInboxItem(ReviewTask task) {
SkillVersion version = skillVersionRepository.findById(task.getSkillVersionId()).orElse(null);
Skill skill = version != null ? skillRepository.findById(version.getSkillId()).orElse(null) : null;
Namespace namespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null;
String namespaceSlug = namespace != null ? namespace.getSlug() : null;
String skillSlug = skill != null ? skill.getSlug() : null;
String versionName = version != null ? version.getVersion() : null;
return new GovernanceInboxItemResponse(
"REVIEW",
task.getId(),
join(namespaceSlug, skillSlug, versionName),
"Pending review",
task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null,
namespaceSlug,
skillSlug
);
}
private GovernanceInboxItemResponse toPromotionInboxItem(PromotionRequest request) {
Skill skill = skillRepository.findById(request.getSourceSkillId()).orElse(null);
SkillVersion version = skillVersionRepository.findById(request.getSourceVersionId()).orElse(null);
Namespace sourceNamespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null;
Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()).orElse(null);
String sourceSlug = sourceNamespace != null ? sourceNamespace.getSlug() : null;
String skillSlug = skill != null ? skill.getSlug() : null;
String targetSlug = targetNamespace != null ? targetNamespace.getSlug() : null;
String versionName = version != null ? version.getVersion() : null;
return new GovernanceInboxItemResponse(
"PROMOTION",
request.getId(),
join(sourceSlug, skillSlug, versionName),
targetSlug != null ? "Promote to @" + targetSlug : "Pending promotion",
request.getSubmittedAt() != null ? request.getSubmittedAt().toString() : null,
sourceSlug,
skillSlug
);
}
private GovernanceInboxItemResponse toReportInboxItem(SkillReport report) {
Skill skill = skillRepository.findById(report.getSkillId()).orElse(null);
Namespace namespace = namespaceRepository.findById(report.getNamespaceId()).orElse(null);
String namespaceSlug = namespace != null ? namespace.getSlug() : null;
String skillSlug = skill != null ? skill.getSlug() : null;
return new GovernanceInboxItemResponse(
"REPORT",
report.getId(),
join(namespaceSlug, skillSlug, null),
report.getReason(),
report.getCreatedAt() != null ? report.getCreatedAt().toString() : null,
namespaceSlug,
skillSlug
);
}
private String join(String namespaceSlug, String skillSlug, String version) {
String path = namespaceSlug != null && skillSlug != null ? namespaceSlug + "/" + skillSlug : "Unknown target";
return version != null ? path + "@" + version : path;
}
private boolean hasPlatformGovernanceRole(Set<String> platformRoles) {
return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN");
}
private boolean canReadActivity(Set<String> platformRoles) {
return hasPlatformGovernanceRole(platformRoles)
|| platformRoles.contains("AUDITOR");
}
}

View file

@ -0,0 +1,15 @@
CREATE TABLE user_notification (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL,
entity_type VARCHAR(64) NOT NULL,
entity_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
body_json TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'UNREAD',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
read_at TIMESTAMPTZ
);
CREATE INDEX idx_user_notification_user_created_at ON user_notification(user_id, created_at DESC);
CREATE INDEX idx_user_notification_user_status ON user_notification(user_id, status, created_at DESC);

View file

@ -0,0 +1,159 @@
package com.iflytek.skillhub.controller;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.governance.UserNotification;
import com.iflytek.skillhub.dto.GovernanceActivityItemResponse;
import com.iflytek.skillhub.dto.GovernanceInboxItemResponse;
import com.iflytek.skillhub.dto.GovernanceNotificationResponse;
import com.iflytek.skillhub.dto.GovernanceSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.service.GovernanceWorkbenchAppService;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class GovernanceControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private GovernanceWorkbenchAppService governanceWorkbenchAppService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private RbacService rbacService;
@MockBean
private GovernanceNotificationService governanceNotificationService;
@Test
void summary_returnsGovernanceSummary() throws Exception {
when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN"));
when(governanceWorkbenchAppService.getSummary("admin", Map.of(), Set.of("SKILL_ADMIN")))
.thenReturn(new GovernanceSummaryResponse(3, 2, 1));
mockMvc.perform(get("/api/v1/governance/summary").with(auth("admin", Set.of("SKILL_ADMIN"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.pendingReviews").value(3))
.andExpect(jsonPath("$.data.pendingPromotions").value(2))
.andExpect(jsonPath("$.data.pendingReports").value(1));
}
@Test
void inbox_returnsUnifiedItems() throws Exception {
when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN"));
when(governanceWorkbenchAppService.listInbox("admin", Map.of(), Set.of("SKILL_ADMIN"), null, 0, 20))
.thenReturn(new PageResponse<>(
List.of(new GovernanceInboxItemResponse(
"REVIEW",
1L,
"team-a/skill-a@1.0.0",
"Pending review",
"2026-03-16T02:00:00Z",
"team-a",
"skill-a"
)),
1,
0,
20
));
mockMvc.perform(get("/api/v1/governance/inbox").with(auth("admin", Set.of("SKILL_ADMIN"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].type").value("REVIEW"));
}
@Test
void activity_returnsGovernanceActivity() throws Exception {
when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN"));
when(governanceWorkbenchAppService.listActivity(Set.of("SKILL_ADMIN"), 0, 20))
.thenReturn(new PageResponse<>(
List.of(new GovernanceActivityItemResponse(
1L,
"REVIEW_APPROVE",
"admin",
"Admin",
"REVIEW_TASK",
"99",
"{\"comment\":\"LGTM\"}",
Instant.parse("2026-03-16T02:00:00Z").toString()
)),
1,
0,
20
));
mockMvc.perform(get("/api/v1/governance/activity").with(auth("admin", Set.of("SKILL_ADMIN"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].action").value("REVIEW_APPROVE"));
}
@Test
void notifications_returnsCurrentUserNotifications() throws Exception {
UserNotification notification = new UserNotification("admin", "REVIEW", "REVIEW_TASK", 99L, "Review approved", "{}");
when(governanceNotificationService.listNotifications("admin")).thenReturn(List.of(notification));
mockMvc.perform(get("/api/v1/governance/notifications").with(auth("admin", Set.of("SKILL_ADMIN"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].category").value("REVIEW"));
}
@Test
void markRead_returnsUpdatedNotification() throws Exception {
UserNotification notification = new UserNotification("admin", "REVIEW", "REVIEW_TASK", 99L, "Review approved", "{}");
when(governanceNotificationService.markRead(10L, "admin")).thenReturn(notification);
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/governance/notifications/10/read")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf())
.with(auth("admin", Set.of("SKILL_ADMIN"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.category").value("REVIEW"));
}
private RequestPostProcessor auth(String userId, Set<String> roles) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,
userId,
userId + "@example.com",
"",
"session",
roles
);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
principal,
null,
roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList()
);
return authentication(authenticationToken);
}
}

View file

@ -13,6 +13,7 @@ import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.report.SkillReportDisposition;
import com.iflytek.skillhub.domain.report.SkillReport;
import com.iflytek.skillhub.domain.report.SkillReportService;
import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse;
@ -92,14 +93,20 @@ class AdminSkillReportControllerTest {
SkillReport report = new SkillReport(10L, 1L, "user-1", "Spam", "details");
ReflectionTestUtils.setField(report, "id", 99L);
report.setStatus(com.iflytek.skillhub.domain.report.SkillReportStatus.RESOLVED);
when(skillReportService.resolveReport(org.mockito.ArgumentMatchers.eq(99L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.eq("handled"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
when(skillReportService.resolveReport(
org.mockito.ArgumentMatchers.eq(99L),
org.mockito.ArgumentMatchers.eq("admin"),
org.mockito.ArgumentMatchers.eq(SkillReportDisposition.RESOLVE_AND_HIDE),
org.mockito.ArgumentMatchers.eq("handled"),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any()))
.thenReturn(report);
mockMvc.perform(post("/api/v1/admin/skill-reports/99/resolve")
.with(authentication(adminAuth()))
.with(csrf())
.contentType(APPLICATION_JSON)
.content("{\"comment\":\"handled\"}"))
.content("{\"comment\":\"handled\",\"disposition\":\"RESOLVE_AND_HIDE\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.reportId").value(99))
.andExpect(jsonPath("$.data.status").value("RESOLVED"));

View file

@ -0,0 +1,253 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.report.SkillReport;
import com.iflytek.skillhub.domain.report.SkillReportRepository;
import com.iflytek.skillhub.domain.report.SkillReportStatus;
import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.dto.AuditLogItemResponse;
import com.iflytek.skillhub.dto.GovernanceSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
@ExtendWith(MockitoExtension.class)
class GovernanceWorkbenchAppServiceTest {
@Mock
private ReviewTaskRepository reviewTaskRepository;
@Mock
private PromotionRequestRepository promotionRequestRepository;
@Mock
private SkillReportRepository skillReportRepository;
@Mock
private SkillRepository skillRepository;
@Mock
private SkillVersionRepository skillVersionRepository;
@Mock
private NamespaceRepository namespaceRepository;
@Mock
private AdminAuditLogAppService adminAuditLogAppService;
private GovernanceWorkbenchAppService service;
@BeforeEach
void setUp() {
service = new GovernanceWorkbenchAppService(
reviewTaskRepository,
promotionRequestRepository,
skillReportRepository,
skillRepository,
skillVersionRepository,
namespaceRepository,
adminAuditLogAppService
);
}
@Test
void summary_returnsAllPendingCountsForPlatformGovernor() {
when(reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 100)))
.thenReturn(new PageImpl<>(List.of(createReviewTask(1L, 11L, 101L, "owner"))));
when(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 100)))
.thenReturn(new PageImpl<>(List.of(createPromotionRequest(2L, 101L, 12L, "owner"))));
when(skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, 100)))
.thenReturn(new PageImpl<>(List.of(createReport(3L, 101L, 11L, "reporter"))));
GovernanceSummaryResponse response = service.getSummary("admin", Map.of(), Set.of("SKILL_ADMIN"));
assertThat(response.pendingReviews()).isEqualTo(1);
assertThat(response.pendingPromotions()).isEqualTo(1);
assertThat(response.pendingReports()).isEqualTo(1);
}
@Test
void summary_limitsReviewsToManagedNamespacesForNamespaceAdmin() {
when(reviewTaskRepository.findByNamespaceIdAndStatus(11L, ReviewTaskStatus.PENDING, PageRequest.of(0, 100)))
.thenReturn(new PageImpl<>(List.of(createReviewTask(1L, 11L, 101L, "owner"))));
GovernanceSummaryResponse response = service.getSummary(
"ns-admin",
Map.of(11L, NamespaceRole.ADMIN, 12L, NamespaceRole.MEMBER),
Set.of()
);
assertThat(response.pendingReviews()).isEqualTo(1);
assertThat(response.pendingPromotions()).isZero();
assertThat(response.pendingReports()).isZero();
}
@Test
void listInbox_combinesReviewPromotionAndReportItems() {
ReviewTask reviewTask = createReviewTask(1L, 11L, 101L, "owner");
PromotionRequest promotionRequest = createPromotionRequest(2L, 101L, 12L, "owner");
SkillReport report = createReport(3L, 101L, 11L, "reporter");
stubReviewContext(reviewTask, "team-a", "skill-a");
stubPromotionContext(promotionRequest, "team-a", "skill-a", "global");
stubReportContext(report, "team-a", "skill-a");
when(reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 20)))
.thenReturn(new PageImpl<>(List.of(reviewTask)));
when(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 20)))
.thenReturn(new PageImpl<>(List.of(promotionRequest)));
when(skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, 20)))
.thenReturn(new PageImpl<>(List.of(report)));
PageResponse<?> response = service.listInbox("admin", Map.of(), Set.of("SKILL_ADMIN"), null, 0, 20);
assertThat(response.total()).isEqualTo(3);
assertThat(response.items()).hasSize(3);
}
@Test
void listActivity_projectsGovernanceAuditEntries() {
when(adminAuditLogAppService.listAuditLogsByActions(
eq(0),
eq(20),
isNull(),
eq(Set.of(
"REVIEW_SUBMIT",
"REVIEW_APPROVE",
"REVIEW_REJECT",
"REVIEW_WITHDRAW",
"PROMOTION_SUBMIT",
"PROMOTION_APPROVE",
"PROMOTION_REJECT",
"REPORT_SKILL",
"RESOLVE_SKILL_REPORT",
"DISMISS_SKILL_REPORT",
"HIDE_SKILL",
"ARCHIVE_SKILL",
"UNHIDE_SKILL",
"UNARCHIVE_SKILL"
)),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull()))
.thenReturn(new PageResponse<>(
List.of(
new AuditLogItemResponse(
1L,
"REVIEW_APPROVE",
"admin",
"Admin",
"{\"comment\":\"LGTM\"}",
"127.0.0.1",
"req-1",
"REVIEW_TASK",
"99",
Instant.parse("2026-03-16T02:00:00Z")
)
),
1,
0,
20
));
PageResponse<?> response = service.listActivity(Set.of("SKILL_ADMIN"), 0, 20);
assertThat(response.total()).isEqualTo(1);
assertThat(response.items()).hasSize(1);
}
private void stubReviewContext(ReviewTask task, String namespaceSlug, String skillSlug) {
SkillVersion version = new SkillVersion(task.getSkillVersionId(), "1.0.0", task.getSubmittedBy());
setField(version, "id", task.getSkillVersionId());
setField(version, "skillId", task.getSkillVersionId());
Skill skill = new Skill(task.getNamespaceId(), skillSlug, task.getSubmittedBy(), SkillVisibility.PUBLIC);
setField(skill, "id", task.getSkillVersionId());
Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, task.getSubmittedBy());
setField(namespace, "id", task.getNamespaceId());
when(skillVersionRepository.findById(task.getSkillVersionId())).thenReturn(Optional.of(version));
when(skillRepository.findById(task.getSkillVersionId())).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(task.getNamespaceId())).thenReturn(Optional.of(namespace));
}
private void stubPromotionContext(PromotionRequest request, String sourceNamespaceSlug, String skillSlug, String targetNamespaceSlug) {
Skill skill = new Skill(11L, skillSlug, request.getSubmittedBy(), SkillVisibility.PUBLIC);
setField(skill, "id", request.getSourceSkillId());
SkillVersion version = new SkillVersion(request.getSourceSkillId(), "1.0.0", request.getSubmittedBy());
setField(version, "id", request.getSourceVersionId());
Namespace sourceNamespace = new Namespace(sourceNamespaceSlug, sourceNamespaceSlug, request.getSubmittedBy());
setField(sourceNamespace, "id", 11L);
Namespace targetNamespace = new Namespace(targetNamespaceSlug, targetNamespaceSlug, request.getSubmittedBy());
setField(targetNamespace, "id", request.getTargetNamespaceId());
when(skillRepository.findById(request.getSourceSkillId())).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(request.getSourceVersionId())).thenReturn(Optional.of(version));
when(namespaceRepository.findById(11L)).thenReturn(Optional.of(sourceNamespace));
when(namespaceRepository.findById(request.getTargetNamespaceId())).thenReturn(Optional.of(targetNamespace));
}
private void stubReportContext(SkillReport report, String namespaceSlug, String skillSlug) {
Skill skill = new Skill(report.getNamespaceId(), skillSlug, report.getReporterId(), SkillVisibility.PUBLIC);
setField(skill, "id", report.getSkillId());
Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, report.getReporterId());
setField(namespace, "id", report.getNamespaceId());
when(skillRepository.findById(report.getSkillId())).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(report.getNamespaceId())).thenReturn(Optional.of(namespace));
}
private ReviewTask createReviewTask(Long id, Long namespaceId, Long skillVersionId, String submittedBy) {
ReviewTask task = new ReviewTask(skillVersionId, namespaceId, submittedBy);
setField(task, "id", id);
return task;
}
private PromotionRequest createPromotionRequest(Long id, Long sourceVersionId, Long targetNamespaceId, String submittedBy) {
PromotionRequest request = new PromotionRequest(sourceVersionId, sourceVersionId, targetNamespaceId, submittedBy);
setField(request, "id", id);
setField(request, "sourceSkillId", sourceVersionId);
return request;
}
private SkillReport createReport(Long id, Long skillId, Long namespaceId, String reporterId) {
SkillReport report = new SkillReport(skillId, namespaceId, reporterId, "Spam", "details");
setField(report, "id", id);
return report;
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
}
}

View file

@ -0,0 +1,50 @@
package com.iflytek.skillhub.domain.governance;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class GovernanceNotificationService {
private final UserNotificationRepository userNotificationRepository;
public GovernanceNotificationService(UserNotificationRepository userNotificationRepository) {
this.userNotificationRepository = userNotificationRepository;
}
@Transactional
public UserNotification notifyUser(String userId,
String category,
String entityType,
Long entityId,
String title,
String bodyJson) {
return userNotificationRepository.save(new UserNotification(
userId,
category,
entityType,
entityId,
title,
bodyJson
));
}
@Transactional(readOnly = true)
public List<UserNotification> listNotifications(String userId) {
return userNotificationRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
@Transactional
public UserNotification markRead(Long notificationId, String userId) {
UserNotification notification = userNotificationRepository.findById(notificationId)
.orElseThrow(() -> new DomainNotFoundException("error.notification.notFound", notificationId));
if (!notification.getUserId().equals(userId)) {
throw new DomainForbiddenException("error.notification.noPermission");
}
notification.markRead();
return userNotificationRepository.save(notification);
}
}

View file

@ -0,0 +1,116 @@
package com.iflytek.skillhub.domain.governance;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "user_notification")
public class UserNotification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, length = 128)
private String userId;
@Column(nullable = false, length = 64)
private String category;
@Column(name = "entity_type", nullable = false, length = 64)
private String entityType;
@Column(name = "entity_id", nullable = false)
private Long entityId;
@Column(nullable = false, length = 200)
private String title;
@Column(name = "body_json", columnDefinition = "TEXT")
private String bodyJson;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private UserNotificationStatus status = UserNotificationStatus.UNREAD;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "read_at")
private Instant readAt;
protected UserNotification() {
}
public UserNotification(String userId,
String category,
String entityType,
Long entityId,
String title,
String bodyJson) {
this.userId = userId;
this.category = category;
this.entityType = entityType;
this.entityId = entityId;
this.title = title;
this.bodyJson = bodyJson;
}
@PrePersist
void onCreate() {
createdAt = Instant.now();
}
public void markRead() {
this.status = UserNotificationStatus.READ;
this.readAt = Instant.now();
}
public Long getId() {
return id;
}
public String getUserId() {
return userId;
}
public String getCategory() {
return category;
}
public String getEntityType() {
return entityType;
}
public Long getEntityId() {
return entityId;
}
public String getTitle() {
return title;
}
public String getBodyJson() {
return bodyJson;
}
public UserNotificationStatus getStatus() {
return status;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getReadAt() {
return readAt;
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.domain.governance;
import java.util.List;
import java.util.Optional;
public interface UserNotificationRepository {
UserNotification save(UserNotification notification);
Optional<UserNotification> findById(Long id);
List<UserNotification> findByUserIdOrderByCreatedAtDesc(String userId);
}

View file

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

View file

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

View file

@ -1,11 +1,13 @@
package com.iflytek.skillhub.domain.report;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import java.time.LocalDateTime;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -16,13 +18,19 @@ public class SkillReportService {
private final SkillRepository skillRepository;
private final SkillReportRepository skillReportRepository;
private final AuditLogService auditLogService;
private final SkillGovernanceService skillGovernanceService;
private final GovernanceNotificationService governanceNotificationService;
public SkillReportService(SkillRepository skillRepository,
SkillReportRepository skillReportRepository,
AuditLogService auditLogService) {
AuditLogService auditLogService,
SkillGovernanceService skillGovernanceService,
GovernanceNotificationService governanceNotificationService) {
this.skillRepository = skillRepository;
this.skillReportRepository = skillReportRepository;
this.auditLogService = auditLogService;
this.skillGovernanceService = skillGovernanceService;
this.governanceNotificationService = governanceNotificationService;
}
@Transactional
@ -66,13 +74,36 @@ public class SkillReportService {
String comment,
String clientIp,
String userAgent) {
return resolveReport(reportId, actorUserId, SkillReportDisposition.RESOLVE_ONLY, comment, clientIp, userAgent);
}
@Transactional
public SkillReport resolveReport(Long reportId,
String actorUserId,
SkillReportDisposition disposition,
String comment,
String clientIp,
String userAgent) {
SkillReport report = requirePendingReport(reportId);
if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE) {
skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, clientIp, userAgent, comment);
} else if (disposition == SkillReportDisposition.RESOLVE_AND_ARCHIVE) {
skillGovernanceService.archiveSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment);
}
report.setStatus(SkillReportStatus.RESOLVED);
report.setHandledBy(actorUserId);
report.setHandleComment(normalize(comment));
report.setHandledAt(LocalDateTime.now());
SkillReport saved = skillReportRepository.save(report);
auditLogService.record(actorUserId, "RESOLVE_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null);
governanceNotificationService.notifyUser(
report.getReporterId(),
"REPORT",
"SKILL_REPORT",
reportId,
"Report handled",
"{\"status\":\"RESOLVED\"}"
);
return saved;
}
@ -89,6 +120,14 @@ public class SkillReportService {
report.setHandledAt(LocalDateTime.now());
SkillReport saved = skillReportRepository.save(report);
auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null);
governanceNotificationService.notifyUser(
report.getReporterId(),
"REPORT",
"SKILL_REPORT",
reportId,
"Report dismissed",
"{\"status\":\"DISMISSED\"}"
);
return saved;
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.domain.review;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
@ -30,6 +31,7 @@ public class PromotionService {
private final NamespaceRepository namespaceRepository;
private final ReviewPermissionChecker permissionChecker;
private final ApplicationEventPublisher eventPublisher;
private final GovernanceNotificationService governanceNotificationService;
public PromotionService(PromotionRequestRepository promotionRequestRepository,
SkillRepository skillRepository,
@ -37,7 +39,8 @@ public class PromotionService {
SkillFileRepository skillFileRepository,
NamespaceRepository namespaceRepository,
ReviewPermissionChecker permissionChecker,
ApplicationEventPublisher eventPublisher) {
ApplicationEventPublisher eventPublisher,
GovernanceNotificationService governanceNotificationService) {
this.promotionRequestRepository = promotionRequestRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
@ -45,6 +48,7 @@ public class PromotionService {
this.namespaceRepository = namespaceRepository;
this.permissionChecker = permissionChecker;
this.eventPublisher = eventPublisher;
this.governanceNotificationService = governanceNotificationService;
}
@Transactional
@ -199,6 +203,14 @@ public class PromotionService {
eventPublisher.publishEvent(new SkillPublishedEvent(
newSkill.getId(), newVersion.getId(), reviewerId));
governanceNotificationService.notifyUser(
request.getSubmittedBy(),
"PROMOTION",
"PROMOTION_REQUEST",
promotionId,
"Promotion approved",
"{\"status\":\"APPROVED\"}"
);
return request;
}
@ -222,6 +234,14 @@ public class PromotionService {
if (updated == 0) {
throw new ConcurrentModificationException("Promotion request was modified concurrently");
}
governanceNotificationService.notifyUser(
request.getSubmittedBy(),
"PROMOTION",
"PROMOTION_REQUEST",
promotionId,
"Promotion rejected",
"{\"status\":\"REJECTED\"}"
);
return promotionRequestRepository.findById(promotionId).orElse(request);
}

View file

@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
@ -37,6 +38,7 @@ public class ReviewService {
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper;
private final SkillGovernanceService skillGovernanceService;
private final GovernanceNotificationService governanceNotificationService;
public ReviewService(ReviewTaskRepository reviewTaskRepository,
SkillVersionRepository skillVersionRepository,
@ -45,7 +47,8 @@ public class ReviewService {
ReviewPermissionChecker permissionChecker,
ApplicationEventPublisher eventPublisher,
ObjectMapper objectMapper,
SkillGovernanceService skillGovernanceService) {
SkillGovernanceService skillGovernanceService,
GovernanceNotificationService governanceNotificationService) {
this.reviewTaskRepository = reviewTaskRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillRepository = skillRepository;
@ -54,6 +57,7 @@ public class ReviewService {
this.eventPublisher = eventPublisher;
this.objectMapper = objectMapper;
this.skillGovernanceService = skillGovernanceService;
this.governanceNotificationService = governanceNotificationService;
}
@Transactional
@ -162,6 +166,14 @@ public class ReviewService {
eventPublisher.publishEvent(new SkillPublishedEvent(
skill.getId(), skillVersion.getId(), reviewerId));
governanceNotificationService.notifyUser(
task.getSubmittedBy(),
"REVIEW",
"REVIEW_TASK",
reviewTaskId,
"Review approved",
"{\"status\":\"APPROVED\"}"
);
// Reload to return updated state
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
@ -197,6 +209,14 @@ public class ReviewService {
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
skillVersion.setStatus(SkillVersionStatus.REJECTED);
skillVersionRepository.save(skillVersion);
governanceNotificationService.notifyUser(
task.getSubmittedBy(),
"REVIEW",
"REVIEW_TASK",
reviewTaskId,
"Review rejected",
"{\"status\":\"REJECTED\"}"
);
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
}

View file

@ -69,13 +69,31 @@ public class SkillGovernanceService {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId));
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason);
}
@Transactional
public Skill archiveSkillAsAdmin(Long skillId,
String actorUserId,
String clientIp,
String userAgent,
String reason) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId));
return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason);
}
private Skill archiveSkillInternal(Skill skill,
String actorUserId,
String clientIp,
String userAgent,
String reason) {
SkillStatus previousStatus = skill.getStatus();
skill.setStatus(SkillStatus.ARCHIVED);
skill.setUpdatedBy(actorUserId);
Skill saved = skillRepository.save(skill);
auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason));
eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ARCHIVED));
auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, jsonReason(reason));
eventPublisher.publishEvent(new SkillStatusChangedEvent(skill.getId(), previousStatus, SkillStatus.ARCHIVED));
return saved;
}

View file

@ -0,0 +1,85 @@
package com.iflytek.skillhub.domain.governance;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class GovernanceNotificationServiceTest {
@Mock
private UserNotificationRepository userNotificationRepository;
private GovernanceNotificationService service;
@BeforeEach
void setUp() {
service = new GovernanceNotificationService(userNotificationRepository);
}
@Test
void notifyUser_createsUnreadNotification() {
when(userNotificationRepository.save(any(UserNotification.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
UserNotification notification = service.notifyUser(
"user-1",
"REVIEW",
"REVIEW_TASK",
99L,
"Review completed",
"{\"status\":\"APPROVED\"}"
);
assertThat(notification.getUserId()).isEqualTo("user-1");
assertThat(notification.getStatus()).isEqualTo(UserNotificationStatus.UNREAD);
assertThat(notification.getCategory()).isEqualTo("REVIEW");
}
@Test
void markRead_requiresOwner() {
UserNotification notification = new UserNotification(
"user-1",
"REVIEW",
"REVIEW_TASK",
99L,
"Review completed",
"{\"status\":\"APPROVED\"}"
);
setField(notification, "id", 10L);
when(userNotificationRepository.findById(10L)).thenReturn(Optional.of(notification));
assertThrows(DomainForbiddenException.class, () -> service.markRead(10L, "user-2"));
}
@Test
void listNotifications_returnsNewestFirst() {
UserNotification unread = new UserNotification("user-1", "REVIEW", "REVIEW_TASK", 99L, "A", "{}");
UserNotification read = new UserNotification("user-1", "REPORT", "SKILL_REPORT", 88L, "B", "{}");
when(userNotificationRepository.findByUserIdOrderByCreatedAtDesc("user-1")).thenReturn(List.of(unread, read));
List<UserNotification> result = service.listNotifications("user-1");
assertThat(result).hasSize(2);
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
}
}

View file

@ -3,14 +3,17 @@ package com.iflytek.skillhub.domain.report;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -30,11 +33,23 @@ class SkillReportServiceTest {
@Mock
private AuditLogService auditLogService;
@Mock
private SkillGovernanceService skillGovernanceService;
@Mock
private GovernanceNotificationService governanceNotificationService;
private SkillReportService service;
@BeforeEach
void setUp() {
service = new SkillReportService(skillRepository, skillReportRepository, auditLogService);
service = new SkillReportService(
skillRepository,
skillReportRepository,
auditLogService,
skillGovernanceService,
governanceNotificationService
);
}
@Test
@ -90,6 +105,53 @@ class SkillReportServiceTest {
assertThat(saved.getHandledBy()).isEqualTo("admin");
}
@Test
void resolveReport_withHideDisposition_hidesSkillAndNotifiesReporter() {
SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null);
setField(report, "id", 99L);
when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report));
when(skillReportRepository.save(report)).thenReturn(report);
SkillReport saved = service.resolveReport(
99L,
"admin",
SkillReportDisposition.RESOLVE_AND_HIDE,
"handled",
"127.0.0.1",
"JUnit"
);
assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED);
verify(skillGovernanceService).hideSkill(10L, "admin", "127.0.0.1", "JUnit", "handled");
verify(governanceNotificationService).notifyUser(
eq("user-1"),
eq("REPORT"),
eq("SKILL_REPORT"),
eq(99L),
eq("Report handled"),
any()
);
}
@Test
void resolveReport_withArchiveDisposition_archivesSkill() {
SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null);
setField(report, "id", 99L);
when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report));
when(skillReportRepository.save(report)).thenReturn(report);
service.resolveReport(
99L,
"admin",
SkillReportDisposition.RESOLVE_AND_ARCHIVE,
"handled",
"127.0.0.1",
"JUnit"
);
verify(skillGovernanceService).archiveSkillAsAdmin(10L, "admin", "127.0.0.1", "JUnit", "handled");
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.domain.review;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
@ -35,6 +36,7 @@ class PromotionServiceTest {
@Mock private NamespaceRepository namespaceRepository;
@Mock private ReviewPermissionChecker permissionChecker;
@Mock private ApplicationEventPublisher eventPublisher;
@Mock private GovernanceNotificationService governanceNotificationService;
private PromotionService promotionService;
@ -51,7 +53,7 @@ class PromotionServiceTest {
void setUp() {
promotionService = new PromotionService(
promotionRequestRepository, skillRepository, skillVersionRepository,
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher);
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService);
}
private static void setField(Object target, String fieldName, Object value) {
@ -308,6 +310,52 @@ class PromotionServiceTest {
}
}
@Nested
class ReviewPromotion {
@Test
void shouldNotifySubmitterWhenPromotionApproved() {
PromotionRequest request = createPendingPromotion();
Skill sourceSkill = createSourceSkill();
SkillVersion sourceVersion = createPublishedVersion();
Skill newSkill = new Skill(TARGET_NAMESPACE_ID, "my-skill", REVIEWER_ID, SkillVisibility.PUBLIC);
setField(newSkill, "id", NEW_SKILL_ID);
SkillVersion newVersion = new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), REVIEWER_ID);
setField(newVersion, "id", NEW_VERSION_ID);
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request));
when(permissionChecker.canReviewPromotion(request, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true);
when(promotionRequestRepository.updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", null, request.getVersion()))
.thenReturn(1);
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
when(skillRepository.save(any(Skill.class))).thenReturn(newSkill);
when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(newVersion);
when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of());
promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"));
verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any());
}
@Test
void shouldNotifySubmitterWhenPromotionRejected() {
PromotionRequest request = createPendingPromotion();
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request));
when(permissionChecker.canReviewPromotion(request, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true);
when(promotionRequestRepository.updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "no", null, request.getVersion()))
.thenReturn(1);
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request));
promotionService.rejectPromotion(PROMOTION_ID, REVIEWER_ID, "no", Set.of("SKILL_ADMIN"));
verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any());
}
}
@Nested
class ApprovePromotion {

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.review;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
@ -46,6 +47,7 @@ class ReviewServiceTest {
@Mock private ReviewPermissionChecker permissionChecker;
@Mock private ApplicationEventPublisher eventPublisher;
@Mock private SkillGovernanceService skillGovernanceService;
@Mock private GovernanceNotificationService governanceNotificationService;
private ReviewService reviewService;
@ -62,7 +64,7 @@ class ReviewServiceTest {
objectMapper = new ObjectMapper();
reviewService = new ReviewService(
reviewTaskRepository, skillVersionRepository, skillRepository,
namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService);
namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService);
}
private SkillVersion createDraftSkillVersion() {
@ -245,6 +247,7 @@ class ReviewServiceTest {
assertEquals("Approved Summary", skill.getSummary());
assertEquals(REVIEWER_ID, skill.getUpdatedBy());
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review approved"), any());
}
@Test
@ -273,6 +276,28 @@ class ReviewServiceTest {
assertEquals(REVIEWER_ID, event.publisherId());
}
@Test
void shouldNotifySubmitterWhenRejected() {
ReviewTask task = createPendingReviewTask();
Namespace ns = createTeamNamespace();
SkillVersion sv = createPendingReviewSkillVersion();
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
when(permissionChecker.canReview(eq(task), eq(REVIEWER_ID), eq(ns.getType()), anyMap(), anySet()))
.thenReturn(true);
when(reviewTaskRepository.updateStatusWithVersion(
REVIEW_TASK_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Needs work", task.getVersion()))
.thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review rejected"), any());
}
@Test
void shouldThrowWhenReviewTaskNotFound() {
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.empty());

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.governance.UserNotification;
import com.iflytek.skillhub.domain.governance.UserNotificationRepository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserNotificationJpaRepository extends JpaRepository<UserNotification, Long>, UserNotificationRepository {
List<UserNotification> findByUserIdOrderByCreatedAtDesc(String userId);
}

View file

@ -16,6 +16,11 @@ import type {
AuditLogItem,
SkillSummary,
SkillReport,
GovernanceSummary,
GovernanceInboxItem,
GovernanceActivityItem,
GovernanceNotification,
ReportDisposition,
AuthMethod,
OAuthProvider,
User,
@ -745,13 +750,13 @@ export const reportApi = {
)
},
async resolveSkillReport(id: number, comment?: string): Promise<void> {
async resolveSkillReport(id: number, comment?: string, disposition: ReportDisposition = 'RESOLVE_ONLY'): Promise<void> {
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/resolve`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ comment }),
body: JSON.stringify({ comment, disposition }),
})
},
@ -766,6 +771,42 @@ export const reportApi = {
},
}
export const governanceApi = {
async getSummary(): Promise<GovernanceSummary> {
return fetchJson<GovernanceSummary>(`${WEB_API_PREFIX}/governance/summary`)
},
async getInbox(params: { type?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
if (params.type) searchParams.set('type', params.type)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: GovernanceInboxItem[]; total: number; page: number; size: number }>(
`${WEB_API_PREFIX}/governance/inbox?${searchParams.toString()}`,
)
},
async getActivity(params: { page?: number; size?: number }) {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: GovernanceActivityItem[]; total: number; page: number; size: number }>(
`${WEB_API_PREFIX}/governance/activity?${searchParams.toString()}`,
)
},
async getNotifications(): Promise<GovernanceNotification[]> {
return fetchJson<GovernanceNotification[]>(`${WEB_API_PREFIX}/governance/notifications`)
},
async markNotificationRead(id: number): Promise<GovernanceNotification> {
return fetchJson<GovernanceNotification>(`${WEB_API_PREFIX}/governance/notifications/${id}/read`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
}
export const meApi = {
async getStars(): Promise<SkillSummary[]> {
return fetchJson<SkillSummary[]>(`${WEB_API_PREFIX}/me/stars`)

View file

@ -278,6 +278,47 @@ export interface SkillReport {
handledAt?: string
}
export type ReportDisposition = 'RESOLVE_ONLY' | 'RESOLVE_AND_HIDE' | 'RESOLVE_AND_ARCHIVE'
export interface GovernanceSummary {
pendingReviews: number
pendingPromotions: number
pendingReports: number
}
export interface GovernanceInboxItem {
type: 'REVIEW' | 'PROMOTION' | 'REPORT' | string
id: number
title: string
subtitle?: string
timestamp?: string
namespace?: string
skillSlug?: string
}
export interface GovernanceActivityItem {
id: number
action: string
actorUserId?: string
actorDisplayName?: string
targetType?: string
targetId?: string
details?: string
timestamp?: string
}
export interface GovernanceNotification {
id?: number
category: string
entityType: string
entityId: number
title: string
bodyJson?: string
status: 'UNREAD' | 'READ' | string
createdAt?: string
readAt?: string
}
export interface AdminUser {
userId: string
username: string

View file

@ -50,6 +50,7 @@ const NamespaceReviewsPage = createLazyRouteComponent(
() => import('@/pages/dashboard/namespace-reviews'),
'NamespaceReviewsPage',
)
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
const ReportsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reports'), 'ReportsPage')
const ReviewDetailPage = createLazyRouteComponent(
@ -206,6 +207,13 @@ const dashboardNamespaceReviewsRoute = createRoute({
component: NamespaceReviewsPage,
})
const dashboardGovernanceRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/governance',
beforeLoad: requireAuth,
component: GovernancePage,
})
const dashboardReviewsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/reviews',
@ -321,6 +329,7 @@ const routeTree = rootRoute.addChildren([
dashboardNamespacesRoute,
dashboardNamespaceMembersRoute,
dashboardNamespaceReviewsRoute,
dashboardGovernanceRoute,
dashboardReviewsRoute,
dashboardReportsRoute,
dashboardReviewDetailRoute,

View file

@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next'
import type { GovernanceActivityItem } from '@/api/types'
import { Card } from '@/shared/ui/card'
interface GovernanceActivityProps {
items?: GovernanceActivityItem[]
isLoading: boolean
}
export function GovernanceActivity({ items, isLoading }: GovernanceActivityProps) {
const { t, i18n } = useTranslation()
if (isLoading) {
return <div className="h-32 animate-shimmer rounded-xl" />
}
if (!items || items.length === 0) {
return <Card className="p-10 text-center text-muted-foreground">{t('governance.emptyActivity')}</Card>
}
return (
<div className="space-y-3">
{items.map((item) => (
<Card key={item.id} className="p-4 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{item.action}</div>
<div className="text-xs text-muted-foreground">
{item.timestamp ? new Date(item.timestamp).toLocaleString(i18n.language) : '-'}
</div>
</div>
<div className="text-sm text-muted-foreground">
{item.actorDisplayName || item.actorUserId || t('governance.unknownActor')}
</div>
{item.details ? <div className="text-sm text-foreground break-all">{item.details}</div> : null}
</Card>
))}
</div>
)
}

View file

@ -0,0 +1,69 @@
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import type { GovernanceInboxItem } from '@/api/types'
import { Card } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
interface GovernanceInboxProps {
items?: GovernanceInboxItem[]
isLoading: boolean
}
export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
if (isLoading) {
return <div className="h-32 animate-shimmer rounded-xl" />
}
if (!items || items.length === 0) {
return <Card className="p-10 text-center text-muted-foreground">{t('governance.emptyInbox')}</Card>
}
const openItem = (item: GovernanceInboxItem) => {
if (item.type === 'REVIEW') {
navigate({ to: `/dashboard/reviews/${item.id}` })
return
}
if (item.type === 'PROMOTION') {
navigate({ to: '/dashboard/promotions' })
return
}
if (item.type === 'REPORT') {
navigate({ to: '/dashboard/reports' })
return
}
if (item.namespace && item.skillSlug) {
navigate({ to: `/space/${item.namespace}/${item.skillSlug}` })
}
}
return (
<div className="space-y-4">
{items.map((item) => (
<Card key={`${item.type}-${item.id}`} className="p-5 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs font-semibold text-secondary-foreground">
{item.type}
</span>
<div className="font-semibold font-heading">{item.title}</div>
</div>
{item.subtitle ? <div className="text-sm text-muted-foreground">{item.subtitle}</div> : null}
</div>
<div className="text-xs text-muted-foreground">
{item.timestamp ? new Date(item.timestamp).toLocaleString(i18n.language) : '-'}
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => openItem(item)}>
{t('governance.openItem')}
</Button>
</div>
</Card>
))}
</div>
)
}

View file

@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next'
import type { GovernanceNotification } from '@/api/types'
import { Card } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
interface GovernanceNotificationsProps {
items?: GovernanceNotification[]
isLoading: boolean
onMarkRead: (id: number) => void
isMarkingRead: boolean
}
export function GovernanceNotifications({ items, isLoading, onMarkRead, isMarkingRead }: GovernanceNotificationsProps) {
const { t, i18n } = useTranslation()
if (isLoading) {
return <div className="h-32 animate-shimmer rounded-xl" />
}
if (!items || items.length === 0) {
return <Card className="p-10 text-center text-muted-foreground">{t('governance.emptyNotifications')}</Card>
}
return (
<div className="space-y-3">
{items.map((item) => (
<Card key={`${item.category}-${item.id ?? item.entityId}`} className="p-4 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{item.title}</div>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${item.status === 'UNREAD' ? 'bg-primary/10 text-primary' : 'bg-secondary text-secondary-foreground'}`}>
{item.status}
</span>
</div>
{item.createdAt ? (
<div className="text-xs text-muted-foreground">
{new Date(item.createdAt).toLocaleString(i18n.language)}
</div>
) : null}
{item.bodyJson ? <div className="text-sm text-muted-foreground break-all">{item.bodyJson}</div> : null}
{item.status === 'UNREAD' && item.id ? (
<div className="flex justify-end">
<Button size="sm" variant="outline" disabled={isMarkingRead} onClick={() => onMarkRead(item.id as number)}>
{t('governance.markRead')}
</Button>
</div>
) : null}
</Card>
))}
</div>
)
}

View file

@ -0,0 +1,46 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { governanceApi } from '@/api/client'
export function useGovernanceSummary() {
return useQuery({
queryKey: ['governance', 'summary'],
queryFn: () => governanceApi.getSummary(),
})
}
export function useGovernanceInbox(type?: string) {
return useQuery({
queryKey: ['governance', 'inbox', type ?? 'ALL'],
queryFn: async () => {
const page = await governanceApi.getInbox({ type })
return page.items
},
})
}
export function useGovernanceActivity() {
return useQuery({
queryKey: ['governance', 'activity'],
queryFn: async () => {
const page = await governanceApi.getActivity({})
return page.items
},
})
}
export function useGovernanceNotifications() {
return useQuery({
queryKey: ['governance', 'notifications'],
queryFn: () => governanceApi.getNotifications(),
})
}
export function useMarkGovernanceNotificationRead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => governanceApi.markNotificationRead(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['governance', 'notifications'] })
},
})
}

View file

@ -26,6 +26,7 @@ export function useApprovePromotion() {
mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.approve(id, comment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promotions'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
},
})
}
@ -36,6 +37,7 @@ export function useRejectPromotion() {
mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.reject(id, comment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promotions'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
},
})
}

View file

@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { reportApi } from '@/api/client'
import type { ReportDisposition } from '@/api/types'
export function useSkillReports(status: string) {
return useQuery({
@ -20,9 +21,11 @@ export function useSubmitSkillReport(namespace: string, slug: string) {
export function useResolveSkillReport() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.resolveSkillReport(id, comment),
mutationFn: ({ id, comment, disposition }: { id: number; comment?: string; disposition?: ReportDisposition }) =>
reportApi.resolveSkillReport(id, comment, disposition),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skill-reports'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
},
})
}
@ -33,6 +36,7 @@ export function useDismissSkillReport() {
mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.dismissSkillReport(id, comment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skill-reports'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
},
})
}

View file

@ -30,6 +30,7 @@ export function useApproveReview() {
approveReview(taskId, comment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviews'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
},
})
}
@ -42,6 +43,7 @@ export function useRejectReview() {
rejectReview(taskId, comment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviews'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
},
})
}

View file

@ -226,6 +226,7 @@
"credentials": "Credentials",
"openTokens": "Open Token Page",
"governanceTitle": "Review & Governance",
"viewGovernance": "Open governance center",
"viewPromotions": "View Promotions",
"reportsTitle": "Report Management",
"viewReports": "View skill reports"
@ -406,9 +407,19 @@
"filterAll": "All",
"filterCliPublish": "CLI Publish",
"filterCompatPublish": "Compat Publish",
"filterReviewSubmit": "Review Submitted",
"filterReviewApprove": "Review Approved",
"filterReviewReject": "Review Rejected",
"filterPromotionSubmit": "Promotion Submitted",
"filterPromotionApprove": "Promotion Approved",
"filterPromotionReject": "Promotion Rejected",
"filterReportSkill": "Skill Reported",
"filterResolveSkillReport": "Report Resolved",
"filterDismissSkillReport": "Report Dismissed",
"filterHideSkill": "Skill Hidden",
"filterArchiveSkill": "Skill Archived",
"filterUnhideSkill": "Skill Unhidden",
"filterUnarchiveSkill": "Skill Restored",
"filterYankVersion": "Version Yanked",
"userIdPlaceholder": "User ID...",
"requestIdPlaceholder": "Request ID...",
@ -578,18 +589,52 @@
"reporter": "Reporter",
"handledBy": "Handled by",
"resolve": "Resolve",
"resolveAndHide": "Resolve & hide",
"resolveAndArchive": "Resolve & archive",
"dismiss": "Dismiss",
"resolveConfirmTitle": "Resolve report",
"resolveConfirmDescription": "Mark the report for \"{{skill}}\" as handled?",
"resolveAndHideConfirmDescription": "Resolve the report for \"{{skill}}\" and hide the skill from public access?",
"resolveAndArchiveConfirmDescription": "Resolve the report for \"{{skill}}\" and archive the skill?",
"dismissConfirmTitle": "Dismiss report",
"dismissConfirmDescription": "Dismiss the report for \"{{skill}}\"?",
"resolveSuccessTitle": "Report resolved",
"resolveSuccessDescription": "The report for \"{{skill}}\" has been marked as handled.",
"resolveAndHideSuccessTitle": "Report resolved and skill hidden",
"resolveAndHideSuccessDescription": "\"{{skill}}\" has been hidden after the report was resolved.",
"resolveAndArchiveSuccessTitle": "Report resolved and skill archived",
"resolveAndArchiveSuccessDescription": "\"{{skill}}\" has been archived after the report was resolved.",
"dismissSuccessTitle": "Report dismissed",
"dismissSuccessDescription": "The report for \"{{skill}}\" has been dismissed.",
"resolveErrorTitle": "Failed to resolve report",
"resolveAndHideErrorTitle": "Failed to resolve report and hide skill",
"resolveAndArchiveErrorTitle": "Failed to resolve report and archive skill",
"dismissErrorTitle": "Failed to dismiss report"
},
"governance": {
"title": "Governance Center",
"subtitle": "Track reviews, promotions, reports, and audit activity in one place.",
"pendingReviews": "Pending reviews",
"pendingPromotions": "Pending promotions",
"pendingReports": "Pending reports",
"unreadNotifications": "Unread notifications",
"inboxTitle": "Governance inbox",
"inboxSubtitle": "Unified queue for operational work that still needs action.",
"tabAll": "All",
"tabReview": "Reviews",
"tabPromotion": "Promotions",
"tabReport": "Reports",
"emptyInbox": "No governance work items right now.",
"openItem": "Open",
"notificationsTitle": "Notifications",
"notificationsSubtitle": "Recent governance updates that need your attention.",
"emptyNotifications": "No notifications yet.",
"markRead": "Mark as read",
"activityTitle": "Governance activity",
"activitySubtitle": "Recent audit events for review, promotion, report, and lifecycle actions.",
"emptyActivity": "No recent governance activity.",
"unknownActor": "Unknown actor"
},
"members": {
"title": "Member Management",
"addMember": "Add Member",
@ -730,6 +775,7 @@
"dashboard": "Dashboard",
"mySkills": "My Skills",
"myNamespaces": "My Namespaces",
"governance": "Governance Center",
"stars": "Starred",
"reviews": "Review Management",
"promotions": "Promotion Management",

View file

@ -226,6 +226,7 @@
"credentials": "访问凭证",
"openTokens": "打开 Token 页面",
"governanceTitle": "审核与治理",
"viewGovernance": "打开治理中心",
"viewPromotions": "查看提升审核",
"reportsTitle": "举报管理",
"viewReports": "查看技能举报"
@ -406,9 +407,19 @@
"filterAll": "全部",
"filterCliPublish": "CLI 发布",
"filterCompatPublish": "Compat 发布",
"filterReviewSubmit": "提交审核",
"filterReviewApprove": "审核通过",
"filterReviewReject": "审核拒绝",
"filterPromotionSubmit": "提交提升",
"filterPromotionApprove": "提升通过",
"filterPromotionReject": "提升拒绝",
"filterReportSkill": "提交举报",
"filterResolveSkillReport": "处理举报",
"filterDismissSkillReport": "驳回举报",
"filterHideSkill": "隐藏技能",
"filterArchiveSkill": "归档技能",
"filterUnhideSkill": "恢复隐藏",
"filterUnarchiveSkill": "恢复归档",
"filterYankVersion": "版本撤回",
"userIdPlaceholder": "用户 ID...",
"requestIdPlaceholder": "请求 ID...",
@ -578,18 +589,52 @@
"reporter": "举报人",
"handledBy": "处理人",
"resolve": "标记处理",
"resolveAndHide": "处理并隐藏",
"resolveAndArchive": "处理并归档",
"dismiss": "驳回举报",
"resolveConfirmTitle": "确认处理举报",
"resolveConfirmDescription": "确认将“{{skill}}”的举报标记为已处理吗?",
"resolveAndHideConfirmDescription": "确认处理“{{skill}}”的举报,并立即隐藏该技能吗?",
"resolveAndArchiveConfirmDescription": "确认处理“{{skill}}”的举报,并立即归档该技能吗?",
"dismissConfirmTitle": "确认驳回举报",
"dismissConfirmDescription": "确认驳回“{{skill}}”的这条举报吗?",
"resolveSuccessTitle": "举报已处理",
"resolveSuccessDescription": "“{{skill}}”的举报已标记为已处理。",
"resolveAndHideSuccessTitle": "举报已处理并隐藏技能",
"resolveAndHideSuccessDescription": "“{{skill}}”已根据举报结果被隐藏。",
"resolveAndArchiveSuccessTitle": "举报已处理并归档技能",
"resolveAndArchiveSuccessDescription": "“{{skill}}”已根据举报结果被归档。",
"dismissSuccessTitle": "举报已驳回",
"dismissSuccessDescription": "“{{skill}}”的举报已驳回。",
"resolveErrorTitle": "处理举报失败",
"resolveAndHideErrorTitle": "处理举报并隐藏技能失败",
"resolveAndArchiveErrorTitle": "处理举报并归档技能失败",
"dismissErrorTitle": "驳回举报失败"
},
"governance": {
"title": "治理中心",
"subtitle": "在一个视图里跟踪审核、提升、举报和审计活动。",
"pendingReviews": "待审核",
"pendingPromotions": "待提升审核",
"pendingReports": "待处理举报",
"unreadNotifications": "未读通知",
"inboxTitle": "治理待办",
"inboxSubtitle": "集中查看仍需处理的审核、提升和举报任务。",
"tabAll": "全部",
"tabReview": "审核",
"tabPromotion": "提升",
"tabReport": "举报",
"emptyInbox": "当前没有待处理的治理任务。",
"openItem": "打开",
"notificationsTitle": "治理通知",
"notificationsSubtitle": "查看最近需要你关注的治理更新。",
"emptyNotifications": "暂无治理通知。",
"markRead": "标记已读",
"activityTitle": "治理活动",
"activitySubtitle": "最近的审核、提升、举报和生命周期治理审计记录。",
"emptyActivity": "暂无治理活动。",
"unknownActor": "未知操作者"
},
"members": {
"title": "成员管理",
"addMember": "添加成员",
@ -730,6 +775,7 @@
"dashboard": "控制台",
"mySkills": "我的技能",
"myNamespaces": "我的命名空间",
"governance": "治理中心",
"stars": "我的收藏",
"reviews": "审核管理",
"promotions": "推广管理",

View file

@ -14,6 +14,26 @@ import {
} from '@/shared/ui/table'
import { useAuditLog } from '@/features/admin/use-audit-log'
const ACTION_OPTIONS = [
{ value: '', labelKey: 'auditLog.filterAll' },
{ value: 'CLI_PUBLISH', labelKey: 'auditLog.filterCliPublish' },
{ value: 'COMPAT_PUBLISH', labelKey: 'auditLog.filterCompatPublish' },
{ value: 'REVIEW_SUBMIT', labelKey: 'auditLog.filterReviewSubmit' },
{ value: 'REVIEW_APPROVE', labelKey: 'auditLog.filterReviewApprove' },
{ value: 'REVIEW_REJECT', labelKey: 'auditLog.filterReviewReject' },
{ value: 'PROMOTION_SUBMIT', labelKey: 'auditLog.filterPromotionSubmit' },
{ value: 'PROMOTION_APPROVE', labelKey: 'auditLog.filterPromotionApprove' },
{ value: 'PROMOTION_REJECT', labelKey: 'auditLog.filterPromotionReject' },
{ value: 'REPORT_SKILL', labelKey: 'auditLog.filterReportSkill' },
{ value: 'RESOLVE_SKILL_REPORT', labelKey: 'auditLog.filterResolveSkillReport' },
{ value: 'DISMISS_SKILL_REPORT', labelKey: 'auditLog.filterDismissSkillReport' },
{ value: 'HIDE_SKILL', labelKey: 'auditLog.filterHideSkill' },
{ value: 'ARCHIVE_SKILL', labelKey: 'auditLog.filterArchiveSkill' },
{ value: 'UNHIDE_SKILL', labelKey: 'auditLog.filterUnhideSkill' },
{ value: 'UNARCHIVE_SKILL', labelKey: 'auditLog.filterUnarchiveSkill' },
{ value: 'YANK_SKILL_VERSION', labelKey: 'auditLog.filterYankVersion' },
] as const
export function AuditLogPage() {
const { t, i18n } = useTranslation()
const [actionFilter, setActionFilter] = useState<string>('')
@ -56,13 +76,11 @@ export function AuditLogPage() {
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>
<option value="REVIEW_APPROVE">{t('auditLog.filterReviewApprove')}</option>
<option value="REVIEW_REJECT">{t('auditLog.filterReviewReject')}</option>
<option value="PROMOTION_APPROVE">{t('auditLog.filterPromotionApprove')}</option>
<option value="YANK_SKILL_VERSION">{t('auditLog.filterYankVersion')}</option>
{ACTION_OPTIONS.map((option) => (
<option key={option.value || 'all'} value={option.value}>
{t(option.labelKey)}
</option>
))}
</Select>
<Input
placeholder={t('auditLog.userIdPlaceholder')}

View file

@ -74,8 +74,8 @@ export function DashboardPage() {
</Card>
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.governanceTitle')}</div>
<Link to="/dashboard/promotions" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewPromotions')}
<Link to="/dashboard/governance" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewGovernance')}
</Link>
</Card>
{governanceVisible ? (

View file

@ -0,0 +1,102 @@
import { useState } from 'react'
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 { GovernanceInbox } from '@/features/governance/governance-inbox'
import { GovernanceActivity } from '@/features/governance/governance-activity'
import { GovernanceNotifications } from '@/features/governance/governance-notifications'
import {
useGovernanceActivity,
useGovernanceInbox,
useGovernanceNotifications,
useGovernanceSummary,
useMarkGovernanceNotificationRead,
} from '@/features/governance/use-governance'
type GovernanceInboxTab = 'ALL' | 'REVIEW' | 'PROMOTION' | 'REPORT'
function SummaryCard({ label, value }: { label: string; value?: number }) {
return (
<Card className="p-5">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="mt-3 text-3xl font-bold font-heading">{value ?? 0}</div>
</Card>
)
}
export function GovernancePage() {
const { t } = useTranslation()
const [inboxType, setInboxType] = useState<GovernanceInboxTab>('ALL')
const { data: summary, isLoading: isSummaryLoading } = useGovernanceSummary()
const { data: inboxItems, isLoading: isInboxLoading } = useGovernanceInbox(inboxType === 'ALL' ? undefined : inboxType)
const { data: activityItems, isLoading: isActivityLoading } = useGovernanceActivity()
const { data: notifications, isLoading: isNotificationsLoading } = useGovernanceNotifications()
const markReadMutation = useMarkGovernanceNotificationRead()
const unreadCount = notifications?.filter((item) => item.status === 'UNREAD').length ?? 0
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('governance.title')} subtitle={t('governance.subtitle')} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<SummaryCard label={t('governance.pendingReviews')} value={isSummaryLoading ? undefined : summary?.pendingReviews} />
<SummaryCard label={t('governance.pendingPromotions')} value={isSummaryLoading ? undefined : summary?.pendingPromotions} />
<SummaryCard label={t('governance.pendingReports')} value={isSummaryLoading ? undefined : summary?.pendingReports} />
<SummaryCard label={t('governance.unreadNotifications')} value={unreadCount} />
</div>
<Card className="p-5 space-y-5">
<div>
<h2 className="text-xl font-semibold font-heading">{t('governance.inboxTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('governance.inboxSubtitle')}</p>
</div>
<Tabs defaultValue="ALL" onValueChange={(value) => setInboxType(value as GovernanceInboxTab)}>
<TabsList>
<TabsTrigger value="ALL">{t('governance.tabAll')}</TabsTrigger>
<TabsTrigger value="REVIEW">{t('governance.tabReview')}</TabsTrigger>
<TabsTrigger value="PROMOTION">{t('governance.tabPromotion')}</TabsTrigger>
<TabsTrigger value="REPORT">{t('governance.tabReport')}</TabsTrigger>
</TabsList>
<TabsContent value="ALL" className="mt-6">
<GovernanceInbox items={inboxItems} isLoading={isInboxLoading} />
</TabsContent>
<TabsContent value="REVIEW" className="mt-6">
<GovernanceInbox items={inboxItems} isLoading={isInboxLoading} />
</TabsContent>
<TabsContent value="PROMOTION" className="mt-6">
<GovernanceInbox items={inboxItems} isLoading={isInboxLoading} />
</TabsContent>
<TabsContent value="REPORT" className="mt-6">
<GovernanceInbox items={inboxItems} isLoading={isInboxLoading} />
</TabsContent>
</Tabs>
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="p-5 space-y-5">
<div>
<h2 className="text-xl font-semibold font-heading">{t('governance.notificationsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('governance.notificationsSubtitle')}</p>
</div>
<GovernanceNotifications
items={notifications}
isLoading={isNotificationsLoading}
onMarkRead={(id) => markReadMutation.mutate(id)}
isMarkingRead={markReadMutation.isPending}
/>
</Card>
<Card className="p-5 space-y-5">
<div>
<h2 className="text-xl font-semibold font-heading">{t('governance.activityTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('governance.activitySubtitle')}</p>
</div>
<GovernanceActivity items={activityItems} isLoading={isActivityLoading} />
</Card>
</div>
</div>
)
}

View file

@ -8,11 +8,17 @@ 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'
import type { ReportDisposition } from '@/api/types'
export function ReportsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const [pendingAction, setPendingAction] = useState<{ id: number; action: 'resolve' | 'dismiss'; skillLabel: string } | null>(null)
const [pendingAction, setPendingAction] = useState<{
id: number
action: 'resolve' | 'dismiss'
disposition?: ReportDisposition
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')
@ -34,8 +40,8 @@ export function ReportsPage() {
}
try {
if (pendingAction.action === 'resolve') {
await resolveMutation.mutateAsync({ id: pendingAction.id })
toast.success(t('reports.resolveSuccessTitle'), t('reports.resolveSuccessDescription', { skill: pendingAction.skillLabel }))
await resolveMutation.mutateAsync({ id: pendingAction.id, disposition: pendingAction.disposition })
toast.success(resolveSuccessTitle(pendingAction.disposition, t), resolveSuccessDescription(pendingAction.disposition, t, pendingAction.skillLabel))
} else {
await dismissMutation.mutateAsync({ id: pendingAction.id })
toast.success(t('reports.dismissSuccessTitle'), t('reports.dismissSuccessDescription', { skill: pendingAction.skillLabel }))
@ -43,7 +49,7 @@ export function ReportsPage() {
setPendingAction(null)
} catch (error) {
toast.error(
pendingAction.action === 'resolve' ? t('reports.resolveErrorTitle') : t('reports.dismissErrorTitle'),
pendingAction.action === 'resolve' ? resolveErrorTitle(pendingAction.disposition, t) : t('reports.dismissErrorTitle'),
error instanceof Error ? error.message : '',
)
}
@ -101,12 +107,28 @@ export function ReportsPage() {
{t('reports.dismiss')}
</Button>
<Button
variant="outline"
size="sm"
disabled={resolveMutation.isPending || dismissMutation.isPending}
onClick={() => setPendingAction({ id: report.id, action: 'resolve', skillLabel })}
onClick={() => setPendingAction({ id: report.id, action: 'resolve', disposition: 'RESOLVE_ONLY', skillLabel })}
>
{t('reports.resolve')}
</Button>
<Button
variant="outline"
size="sm"
disabled={resolveMutation.isPending || dismissMutation.isPending}
onClick={() => setPendingAction({ id: report.id, action: 'resolve', disposition: 'RESOLVE_AND_HIDE', skillLabel })}
>
{t('reports.resolveAndHide')}
</Button>
<Button
size="sm"
disabled={resolveMutation.isPending || dismissMutation.isPending}
onClick={() => setPendingAction({ id: report.id, action: 'resolve', disposition: 'RESOLVE_AND_ARCHIVE', skillLabel })}
>
{t('reports.resolveAndArchive')}
</Button>
</div>
) : (
<div className="text-sm text-muted-foreground">
@ -151,11 +173,41 @@ export function ReportsPage() {
}}
title={pendingAction?.action === 'resolve' ? t('reports.resolveConfirmTitle') : t('reports.dismissConfirmTitle')}
description={pendingAction?.action === 'resolve'
? t('reports.resolveConfirmDescription', { skill: pendingAction?.skillLabel ?? '' })
? resolveConfirmDescription(pendingAction?.disposition, t, pendingAction?.skillLabel ?? '')
: t('reports.dismissConfirmDescription', { skill: pendingAction?.skillLabel ?? '' })}
confirmText={pendingAction?.action === 'resolve' ? t('reports.resolve') : t('reports.dismiss')}
confirmText={pendingAction?.action === 'resolve' ? resolveConfirmText(pendingAction?.disposition, t) : t('reports.dismiss')}
onConfirm={handleConfirm}
/>
</div>
)
}
function resolveConfirmText(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHide')
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchive')
return t('reports.resolve')
}
function resolveConfirmDescription(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string, skillLabel: string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideConfirmDescription', { skill: skillLabel })
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveConfirmDescription', { skill: skillLabel })
return t('reports.resolveConfirmDescription', { skill: skillLabel })
}
function resolveSuccessTitle(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideSuccessTitle')
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveSuccessTitle')
return t('reports.resolveSuccessTitle')
}
function resolveSuccessDescription(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string, skillLabel: string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideSuccessDescription', { skill: skillLabel })
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveSuccessDescription', { skill: skillLabel })
return t('reports.resolveSuccessDescription', { skill: skillLabel })
}
function resolveErrorTitle(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) {
if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideErrorTitle')
if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveErrorTitle')
return t('reports.resolveErrorTitle')
}

View file

@ -77,6 +77,11 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
{t('user.menu.myNamespaces')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/governance" className="cursor-pointer">
{t('user.menu.governance')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/stars" className="cursor-pointer">
{t('user.menu.stars')}

View file

@ -5,6 +5,7 @@ interface TabsProps {
defaultValue: string
children: React.ReactNode
className?: string
onValueChange?: (value: string) => void
}
interface TabsContextValue {
@ -14,11 +15,16 @@ interface TabsContextValue {
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined)
export function Tabs({ defaultValue, children, className }: TabsProps) {
export function Tabs({ defaultValue, children, className, onValueChange }: TabsProps) {
const [value, setValue] = React.useState(defaultValue)
const handleValueChange = React.useCallback((nextValue: string) => {
setValue(nextValue)
onValueChange?.(nextValue)
}, [onValueChange])
return (
<TabsContext.Provider value={{ value, setValue }}>
<TabsContext.Provider value={{ value, setValue: handleValueChange }}>
<div className={className}>{children}</div>
</TabsContext.Provider>
)