mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
feat: build governance center workflow
This commit is contained in:
parent
62979b2dd7
commit
b30311a537
46 changed files with 2101 additions and 37 deletions
117
scripts/governance-smoke-test.sh
Executable file
117
scripts/governance-smoke-test.sh
Executable file
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-http://localhost:8080}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
COOKIE_FILE="$(mktemp)"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$COOKIE_FILE"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
pass() {
|
||||
echo "PASS: $1"
|
||||
PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1"
|
||||
FAIL=$((FAIL + 1))
|
||||
}
|
||||
|
||||
json_field() {
|
||||
local json="$1"
|
||||
local expr="$2"
|
||||
JSON_INPUT="$json" python3 - "$expr" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
expr = sys.argv[1]
|
||||
value = json.loads(os.environ["JSON_INPUT"])
|
||||
for part in expr.split('.'):
|
||||
if not part:
|
||||
continue
|
||||
if part.isdigit():
|
||||
value = value[int(part)]
|
||||
else:
|
||||
value = value[part]
|
||||
if isinstance(value, (dict, list)):
|
||||
print(json.dumps(value, ensure_ascii=False))
|
||||
else:
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
assert_code() {
|
||||
local description="$1"
|
||||
local json="$2"
|
||||
local expected="$3"
|
||||
local actual
|
||||
actual="$(json_field "$json" "code")"
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
pass "$description"
|
||||
else
|
||||
fail "$description (expected code $expected, got $actual)"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_expr() {
|
||||
local description="$1"
|
||||
local json="$2"
|
||||
local script="$3"
|
||||
if JSON_INPUT="$json" python3 - <<PY
|
||||
import json
|
||||
import os
|
||||
|
||||
data = json.loads(os.environ["JSON_INPUT"])
|
||||
$script
|
||||
PY
|
||||
then
|
||||
pass "$description"
|
||||
else
|
||||
fail "$description"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Governance Workflow Smoke Test ==="
|
||||
echo "Target: $BASE_URL"
|
||||
echo
|
||||
|
||||
curl -s -c "$COOKIE_FILE" -H "X-Mock-User-Id: local-admin" "$BASE_URL/api/v1/auth/providers" >/dev/null
|
||||
|
||||
ADMIN_HEADERS=(-H "X-Mock-User-Id: local-admin" -b "$COOKIE_FILE" -c "$COOKIE_FILE")
|
||||
|
||||
SUMMARY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/summary")"
|
||||
assert_code "Governance summary endpoint is available" "$SUMMARY_RESPONSE" "0"
|
||||
assert_json_expr "Governance summary exposes review/promotion/report counts" "$SUMMARY_RESPONSE" $'summary = data["data"]\nassert "pendingReviews" in summary\nassert "pendingPromotions" in summary\nassert "pendingReports" in summary'
|
||||
|
||||
INBOX_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/inbox")"
|
||||
assert_code "Governance inbox endpoint is available" "$INBOX_RESPONSE" "0"
|
||||
assert_json_expr "Governance inbox returns paged items" "$INBOX_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0'
|
||||
|
||||
ACTIVITY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/activity")"
|
||||
assert_code "Governance activity endpoint is available" "$ACTIVITY_RESPONSE" "0"
|
||||
assert_json_expr "Governance activity returns paged items" "$ACTIVITY_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0'
|
||||
|
||||
NOTIFICATIONS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/notifications")"
|
||||
assert_code "Governance notifications endpoint is available" "$NOTIFICATIONS_RESPONSE" "0"
|
||||
assert_json_expr "Governance notifications returns a list" "$NOTIFICATIONS_RESPONSE" $'assert isinstance(data["data"], list)'
|
||||
|
||||
REPORTS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/skill-reports?status=PENDING&page=0&size=5")"
|
||||
assert_code "Admin report list endpoint is available" "$REPORTS_RESPONSE" "0"
|
||||
assert_json_expr "Admin report list returns page metadata" "$REPORTS_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5'
|
||||
|
||||
AUDIT_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/audit-logs?action=REVIEW_APPROVE&page=0&size=5")"
|
||||
assert_code "Audit log endpoint is available for governance filters" "$AUDIT_RESPONSE" "0"
|
||||
assert_json_expr "Audit log endpoint returns page metadata" "$AUDIT_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5'
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
if [[ "$FAIL" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.admin;
|
|||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportDisposition;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillReportActionRequest;
|
||||
import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse;
|
||||
|
|
@ -54,6 +55,9 @@ public class AdminSkillReportController extends BaseApiController {
|
|||
var report = skillReportService.resolveReport(
|
||||
reportId,
|
||||
principal.userId(),
|
||||
request != null && request.disposition() != null
|
||||
? SkillReportDisposition.valueOf(request.disposition().trim().toUpperCase())
|
||||
: SkillReportDisposition.RESOLVE_ONLY,
|
||||
request != null ? request.comment() : null,
|
||||
httpRequest.getRemoteAddr(),
|
||||
httpRequest.getHeader("User-Agent")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.RbacService;
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
|
||||
import com.iflytek.skillhub.domain.governance.UserNotification;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.GovernanceActivityItemResponse;
|
||||
import com.iflytek.skillhub.dto.GovernanceInboxItemResponse;
|
||||
import com.iflytek.skillhub.dto.GovernanceNotificationResponse;
|
||||
import com.iflytek.skillhub.dto.GovernanceSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.service.GovernanceWorkbenchAppService;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/governance", "/api/web/governance"})
|
||||
public class GovernanceController extends BaseApiController {
|
||||
|
||||
private final GovernanceWorkbenchAppService governanceWorkbenchAppService;
|
||||
private final RbacService rbacService;
|
||||
private final GovernanceNotificationService governanceNotificationService;
|
||||
|
||||
public GovernanceController(GovernanceWorkbenchAppService governanceWorkbenchAppService,
|
||||
RbacService rbacService,
|
||||
GovernanceNotificationService governanceNotificationService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.governanceWorkbenchAppService = governanceWorkbenchAppService;
|
||||
this.rbacService = rbacService;
|
||||
this.governanceNotificationService = governanceNotificationService;
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
public ApiResponse<GovernanceSummaryResponse> summary(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
governanceWorkbenchAppService.getSummary(userId, userNsRoles != null ? userNsRoles : Map.of(), roles(userId))
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/inbox")
|
||||
public ApiResponse<PageResponse<GovernanceInboxItemResponse>> inbox(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
governanceWorkbenchAppService.listInbox(
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
roles(userId),
|
||||
type,
|
||||
page,
|
||||
size
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/activity")
|
||||
public ApiResponse<PageResponse<GovernanceActivityItemResponse>> activity(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ok("response.success.read", governanceWorkbenchAppService.listActivity(roles(userId), page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/notifications")
|
||||
public ApiResponse<java.util.List<GovernanceNotificationResponse>> notifications(
|
||||
@RequestAttribute("userId") String userId) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
governanceNotificationService.listNotifications(userId).stream().map(this::toNotificationResponse).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/notifications/{id}/read")
|
||||
public ApiResponse<GovernanceNotificationResponse> markNotificationRead(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
return ok("response.success.updated", toNotificationResponse(governanceNotificationService.markRead(id, userId)));
|
||||
}
|
||||
|
||||
private Set<String> roles(String userId) {
|
||||
return rbacService.getUserRoleCodes(userId);
|
||||
}
|
||||
|
||||
private GovernanceNotificationResponse toNotificationResponse(UserNotification notification) {
|
||||
return new GovernanceNotificationResponse(
|
||||
notification.getId(),
|
||||
notification.getCategory(),
|
||||
notification.getEntityType(),
|
||||
notification.getEntityId(),
|
||||
notification.getTitle(),
|
||||
notification.getBodyJson(),
|
||||
notification.getStatus().name(),
|
||||
notification.getCreatedAt() != null ? notification.getCreatedAt().toString() : null,
|
||||
notification.getReadAt() != null ? notification.getReadAt().toString() : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminSkillReportActionRequest(
|
||||
String comment
|
||||
String comment,
|
||||
String disposition
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record GovernanceActivityItemResponse(
|
||||
Long id,
|
||||
String action,
|
||||
String actorUserId,
|
||||
String actorDisplayName,
|
||||
String targetType,
|
||||
String targetId,
|
||||
String details,
|
||||
String timestamp
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record GovernanceInboxItemResponse(
|
||||
String type,
|
||||
Long id,
|
||||
String title,
|
||||
String subtitle,
|
||||
String timestamp,
|
||||
String namespace,
|
||||
String skillSlug
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record GovernanceNotificationResponse(
|
||||
Long id,
|
||||
String category,
|
||||
String entityType,
|
||||
Long entityId,
|
||||
String title,
|
||||
String bodyJson,
|
||||
String status,
|
||||
String createdAt,
|
||||
String readAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record GovernanceSummaryResponse(
|
||||
long pendingReviews,
|
||||
long pendingPromotions,
|
||||
long pendingReports
|
||||
) {
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import org.springframework.util.StringUtils;
|
|||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
|
|
@ -32,6 +33,20 @@ public class AdminAuditLogAppService {
|
|||
String resourceId,
|
||||
Instant startTime,
|
||||
Instant endTime) {
|
||||
return listAuditLogsByActions(page, size, userId, action != null ? List.of(action) : null, requestId, ipAddress, resourceType, resourceId, startTime, endTime);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<AuditLogItemResponse> listAuditLogsByActions(int page,
|
||||
int size,
|
||||
String userId,
|
||||
Collection<String> actions,
|
||||
String requestId,
|
||||
String ipAddress,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
Instant startTime,
|
||||
Instant endTime) {
|
||||
MapSqlParameterSource parameters = new MapSqlParameterSource()
|
||||
.addValue("limit", size)
|
||||
.addValue("offset", Math.max(page, 0) * size);
|
||||
|
|
@ -39,7 +54,7 @@ public class AdminAuditLogAppService {
|
|||
String whereClause = buildWhereClause(
|
||||
parameters,
|
||||
userId,
|
||||
action,
|
||||
actions,
|
||||
requestId,
|
||||
ipAddress,
|
||||
resourceType,
|
||||
|
|
@ -92,7 +107,7 @@ public class AdminAuditLogAppService {
|
|||
|
||||
private String buildWhereClause(MapSqlParameterSource parameters,
|
||||
String userId,
|
||||
String action,
|
||||
Collection<String> actions,
|
||||
String requestId,
|
||||
String ipAddress,
|
||||
String resourceType,
|
||||
|
|
@ -104,9 +119,9 @@ public class AdminAuditLogAppService {
|
|||
clause.append(" AND al.actor_user_id = :userId");
|
||||
parameters.addValue("userId", userId.trim());
|
||||
}
|
||||
if (StringUtils.hasText(action)) {
|
||||
clause.append(" AND al.action = :action");
|
||||
parameters.addValue("action", action.trim());
|
||||
if (actions != null && !actions.isEmpty()) {
|
||||
clause.append(" AND al.action IN (:actions)");
|
||||
parameters.addValue("actions", actions.stream().filter(StringUtils::hasText).map(String::trim).toList());
|
||||
}
|
||||
if (StringUtils.hasText(requestId)) {
|
||||
clause.append(" AND al.request_id = :requestId");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.report.SkillReport;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportRepository;
|
||||
import com.iflytek.skillhub.domain.report.SkillReportStatus;
|
||||
import com.iflytek.skillhub.domain.review.PromotionRequest;
|
||||
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.dto.AuditLogItemResponse;
|
||||
import com.iflytek.skillhub.dto.GovernanceActivityItemResponse;
|
||||
import com.iflytek.skillhub.dto.GovernanceInboxItemResponse;
|
||||
import com.iflytek.skillhub.dto.GovernanceSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class GovernanceWorkbenchAppService {
|
||||
|
||||
private static final int SUMMARY_PAGE_SIZE = 100;
|
||||
private static final Set<String> ACTIVITY_ACTIONS = Set.of(
|
||||
"REVIEW_SUBMIT",
|
||||
"REVIEW_APPROVE",
|
||||
"REVIEW_REJECT",
|
||||
"REVIEW_WITHDRAW",
|
||||
"PROMOTION_SUBMIT",
|
||||
"PROMOTION_APPROVE",
|
||||
"PROMOTION_REJECT",
|
||||
"REPORT_SKILL",
|
||||
"RESOLVE_SKILL_REPORT",
|
||||
"DISMISS_SKILL_REPORT",
|
||||
"HIDE_SKILL",
|
||||
"ARCHIVE_SKILL",
|
||||
"UNHIDE_SKILL",
|
||||
"UNARCHIVE_SKILL"
|
||||
);
|
||||
|
||||
private final ReviewTaskRepository reviewTaskRepository;
|
||||
private final PromotionRequestRepository promotionRequestRepository;
|
||||
private final SkillReportRepository skillReportRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final AdminAuditLogAppService adminAuditLogAppService;
|
||||
|
||||
public GovernanceWorkbenchAppService(ReviewTaskRepository reviewTaskRepository,
|
||||
PromotionRequestRepository promotionRequestRepository,
|
||||
SkillReportRepository skillReportRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
AdminAuditLogAppService adminAuditLogAppService) {
|
||||
this.reviewTaskRepository = reviewTaskRepository;
|
||||
this.promotionRequestRepository = promotionRequestRepository;
|
||||
this.skillReportRepository = skillReportRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.adminAuditLogAppService = adminAuditLogAppService;
|
||||
}
|
||||
|
||||
public GovernanceSummaryResponse getSummary(String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
return new GovernanceSummaryResponse(
|
||||
visiblePendingReviews(namespaceRoles, platformRoles, SUMMARY_PAGE_SIZE).getTotalElements(),
|
||||
hasPlatformGovernanceRole(platformRoles)
|
||||
? promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements()
|
||||
: 0,
|
||||
hasPlatformGovernanceRole(platformRoles)
|
||||
? skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements()
|
||||
: 0
|
||||
);
|
||||
}
|
||||
|
||||
public PageResponse<GovernanceInboxItemResponse> listInbox(String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
String type,
|
||||
int page,
|
||||
int size) {
|
||||
List<GovernanceInboxItemResponse> items = new ArrayList<>();
|
||||
boolean includeAll = type == null || type.isBlank();
|
||||
if (includeAll || "REVIEW".equalsIgnoreCase(type)) {
|
||||
visiblePendingReviews(namespaceRoles, platformRoles, size).getContent().stream()
|
||||
.map(this::toReviewInboxItem)
|
||||
.forEach(items::add);
|
||||
}
|
||||
if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "PROMOTION".equalsIgnoreCase(type))) {
|
||||
promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(page, size)).getContent().stream()
|
||||
.map(this::toPromotionInboxItem)
|
||||
.forEach(items::add);
|
||||
}
|
||||
if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "REPORT".equalsIgnoreCase(type))) {
|
||||
skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(page, size)).getContent().stream()
|
||||
.map(this::toReportInboxItem)
|
||||
.forEach(items::add);
|
||||
}
|
||||
items.sort(Comparator.comparing(
|
||||
GovernanceInboxItemResponse::timestamp,
|
||||
Comparator.nullsLast(String::compareTo)
|
||||
).reversed());
|
||||
int fromIndex = Math.min(page * size, items.size());
|
||||
int toIndex = Math.min(fromIndex + size, items.size());
|
||||
return new PageResponse<>(items.subList(fromIndex, toIndex), items.size(), page, size);
|
||||
}
|
||||
|
||||
public PageResponse<GovernanceActivityItemResponse> listActivity(Set<String> platformRoles, int page, int size) {
|
||||
if (!canReadActivity(platformRoles)) {
|
||||
return new PageResponse<>(List.of(), 0, page, size);
|
||||
}
|
||||
PageResponse<AuditLogItemResponse> raw = adminAuditLogAppService.listAuditLogsByActions(
|
||||
page,
|
||||
size,
|
||||
null,
|
||||
ACTIVITY_ACTIONS,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
List<GovernanceActivityItemResponse> items = raw.items().stream()
|
||||
.map(item -> new GovernanceActivityItemResponse(
|
||||
item.id(),
|
||||
item.action(),
|
||||
item.userId(),
|
||||
item.username(),
|
||||
item.resourceType(),
|
||||
item.resourceId(),
|
||||
item.details(),
|
||||
item.timestamp() != null ? item.timestamp().toString() : null
|
||||
))
|
||||
.toList();
|
||||
return new PageResponse<>(items, items.size(), page, size);
|
||||
}
|
||||
|
||||
private Page<ReviewTask> visiblePendingReviews(Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
int size) {
|
||||
if (hasPlatformGovernanceRole(platformRoles)) {
|
||||
return reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, size));
|
||||
}
|
||||
List<ReviewTask> tasks = namespaceRoles.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() == NamespaceRole.OWNER || entry.getValue() == NamespaceRole.ADMIN)
|
||||
.map(entry -> reviewTaskRepository.findByNamespaceIdAndStatus(entry.getKey(), ReviewTaskStatus.PENDING, PageRequest.of(0, size)))
|
||||
.flatMap(pageResult -> pageResult.getContent().stream())
|
||||
.toList();
|
||||
return new org.springframework.data.domain.PageImpl<>(tasks, PageRequest.of(0, size), tasks.size());
|
||||
}
|
||||
|
||||
private GovernanceInboxItemResponse toReviewInboxItem(ReviewTask task) {
|
||||
SkillVersion version = skillVersionRepository.findById(task.getSkillVersionId()).orElse(null);
|
||||
Skill skill = version != null ? skillRepository.findById(version.getSkillId()).orElse(null) : null;
|
||||
Namespace namespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null;
|
||||
String namespaceSlug = namespace != null ? namespace.getSlug() : null;
|
||||
String skillSlug = skill != null ? skill.getSlug() : null;
|
||||
String versionName = version != null ? version.getVersion() : null;
|
||||
return new GovernanceInboxItemResponse(
|
||||
"REVIEW",
|
||||
task.getId(),
|
||||
join(namespaceSlug, skillSlug, versionName),
|
||||
"Pending review",
|
||||
task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null,
|
||||
namespaceSlug,
|
||||
skillSlug
|
||||
);
|
||||
}
|
||||
|
||||
private GovernanceInboxItemResponse toPromotionInboxItem(PromotionRequest request) {
|
||||
Skill skill = skillRepository.findById(request.getSourceSkillId()).orElse(null);
|
||||
SkillVersion version = skillVersionRepository.findById(request.getSourceVersionId()).orElse(null);
|
||||
Namespace sourceNamespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null;
|
||||
Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()).orElse(null);
|
||||
String sourceSlug = sourceNamespace != null ? sourceNamespace.getSlug() : null;
|
||||
String skillSlug = skill != null ? skill.getSlug() : null;
|
||||
String targetSlug = targetNamespace != null ? targetNamespace.getSlug() : null;
|
||||
String versionName = version != null ? version.getVersion() : null;
|
||||
return new GovernanceInboxItemResponse(
|
||||
"PROMOTION",
|
||||
request.getId(),
|
||||
join(sourceSlug, skillSlug, versionName),
|
||||
targetSlug != null ? "Promote to @" + targetSlug : "Pending promotion",
|
||||
request.getSubmittedAt() != null ? request.getSubmittedAt().toString() : null,
|
||||
sourceSlug,
|
||||
skillSlug
|
||||
);
|
||||
}
|
||||
|
||||
private GovernanceInboxItemResponse toReportInboxItem(SkillReport report) {
|
||||
Skill skill = skillRepository.findById(report.getSkillId()).orElse(null);
|
||||
Namespace namespace = namespaceRepository.findById(report.getNamespaceId()).orElse(null);
|
||||
String namespaceSlug = namespace != null ? namespace.getSlug() : null;
|
||||
String skillSlug = skill != null ? skill.getSlug() : null;
|
||||
return new GovernanceInboxItemResponse(
|
||||
"REPORT",
|
||||
report.getId(),
|
||||
join(namespaceSlug, skillSlug, null),
|
||||
report.getReason(),
|
||||
report.getCreatedAt() != null ? report.getCreatedAt().toString() : null,
|
||||
namespaceSlug,
|
||||
skillSlug
|
||||
);
|
||||
}
|
||||
|
||||
private String join(String namespaceSlug, String skillSlug, String version) {
|
||||
String path = namespaceSlug != null && skillSlug != null ? namespaceSlug + "/" + skillSlug : "Unknown target";
|
||||
return version != null ? path + "@" + version : path;
|
||||
}
|
||||
|
||||
private boolean hasPlatformGovernanceRole(Set<String> platformRoles) {
|
||||
return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN");
|
||||
}
|
||||
|
||||
private boolean canReadActivity(Set<String> platformRoles) {
|
||||
return hasPlatformGovernanceRole(platformRoles)
|
||||
|| platformRoles.contains("AUDITOR");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.iflytek.skillhub.domain.governance;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class GovernanceNotificationService {
|
||||
|
||||
private final UserNotificationRepository userNotificationRepository;
|
||||
|
||||
public GovernanceNotificationService(UserNotificationRepository userNotificationRepository) {
|
||||
this.userNotificationRepository = userNotificationRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserNotification notifyUser(String userId,
|
||||
String category,
|
||||
String entityType,
|
||||
Long entityId,
|
||||
String title,
|
||||
String bodyJson) {
|
||||
return userNotificationRepository.save(new UserNotification(
|
||||
userId,
|
||||
category,
|
||||
entityType,
|
||||
entityId,
|
||||
title,
|
||||
bodyJson
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserNotification> listNotifications(String userId) {
|
||||
return userNotificationRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserNotification markRead(Long notificationId, String userId) {
|
||||
UserNotification notification = userNotificationRepository.findById(notificationId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.notification.notFound", notificationId));
|
||||
if (!notification.getUserId().equals(userId)) {
|
||||
throw new DomainForbiddenException("error.notification.noPermission");
|
||||
}
|
||||
notification.markRead();
|
||||
return userNotificationRepository.save(notification);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package com.iflytek.skillhub.domain.governance;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_notification")
|
||||
public class UserNotification {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false, length = 128)
|
||||
private String userId;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String category;
|
||||
|
||||
@Column(name = "entity_type", nullable = false, length = 64)
|
||||
private String entityType;
|
||||
|
||||
@Column(name = "entity_id", nullable = false)
|
||||
private Long entityId;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(name = "body_json", columnDefinition = "TEXT")
|
||||
private String bodyJson;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private UserNotificationStatus status = UserNotificationStatus.UNREAD;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "read_at")
|
||||
private Instant readAt;
|
||||
|
||||
protected UserNotification() {
|
||||
}
|
||||
|
||||
public UserNotification(String userId,
|
||||
String category,
|
||||
String entityType,
|
||||
Long entityId,
|
||||
String title,
|
||||
String bodyJson) {
|
||||
this.userId = userId;
|
||||
this.category = category;
|
||||
this.entityType = entityType;
|
||||
this.entityId = entityId;
|
||||
this.title = title;
|
||||
this.bodyJson = bodyJson;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public void markRead() {
|
||||
this.status = UserNotificationStatus.READ;
|
||||
this.readAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
public String getEntityType() {
|
||||
return entityType;
|
||||
}
|
||||
|
||||
public Long getEntityId() {
|
||||
return entityId;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getBodyJson() {
|
||||
return bodyJson;
|
||||
}
|
||||
|
||||
public UserNotificationStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getReadAt() {
|
||||
return readAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.governance;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserNotificationRepository {
|
||||
UserNotification save(UserNotification notification);
|
||||
Optional<UserNotification> findById(Long id);
|
||||
List<UserNotification> findByUserIdOrderByCreatedAtDesc(String userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.domain.governance;
|
||||
|
||||
public enum UserNotificationStatus {
|
||||
UNREAD,
|
||||
READ
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.report;
|
||||
|
||||
public enum SkillReportDisposition {
|
||||
RESOLVE_ONLY,
|
||||
RESOLVE_AND_HIDE,
|
||||
RESOLVE_AND_ARCHIVE
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
package com.iflytek.skillhub.domain.report;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
|
||||
import java.time.LocalDateTime;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -16,13 +18,19 @@ public class SkillReportService {
|
|||
private final SkillRepository skillRepository;
|
||||
private final SkillReportRepository skillReportRepository;
|
||||
private final AuditLogService auditLogService;
|
||||
private final SkillGovernanceService skillGovernanceService;
|
||||
private final GovernanceNotificationService governanceNotificationService;
|
||||
|
||||
public SkillReportService(SkillRepository skillRepository,
|
||||
SkillReportRepository skillReportRepository,
|
||||
AuditLogService auditLogService) {
|
||||
AuditLogService auditLogService,
|
||||
SkillGovernanceService skillGovernanceService,
|
||||
GovernanceNotificationService governanceNotificationService) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillReportRepository = skillReportRepository;
|
||||
this.auditLogService = auditLogService;
|
||||
this.skillGovernanceService = skillGovernanceService;
|
||||
this.governanceNotificationService = governanceNotificationService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -66,13 +74,36 @@ public class SkillReportService {
|
|||
String comment,
|
||||
String clientIp,
|
||||
String userAgent) {
|
||||
return resolveReport(reportId, actorUserId, SkillReportDisposition.RESOLVE_ONLY, comment, clientIp, userAgent);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillReport resolveReport(Long reportId,
|
||||
String actorUserId,
|
||||
SkillReportDisposition disposition,
|
||||
String comment,
|
||||
String clientIp,
|
||||
String userAgent) {
|
||||
SkillReport report = requirePendingReport(reportId);
|
||||
if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE) {
|
||||
skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, clientIp, userAgent, comment);
|
||||
} else if (disposition == SkillReportDisposition.RESOLVE_AND_ARCHIVE) {
|
||||
skillGovernanceService.archiveSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment);
|
||||
}
|
||||
report.setStatus(SkillReportStatus.RESOLVED);
|
||||
report.setHandledBy(actorUserId);
|
||||
report.setHandleComment(normalize(comment));
|
||||
report.setHandledAt(LocalDateTime.now());
|
||||
SkillReport saved = skillReportRepository.save(report);
|
||||
auditLogService.record(actorUserId, "RESOLVE_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null);
|
||||
governanceNotificationService.notifyUser(
|
||||
report.getReporterId(),
|
||||
"REPORT",
|
||||
"SKILL_REPORT",
|
||||
reportId,
|
||||
"Report handled",
|
||||
"{\"status\":\"RESOLVED\"}"
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +120,14 @@ public class SkillReportService {
|
|||
report.setHandledAt(LocalDateTime.now());
|
||||
SkillReport saved = skillReportRepository.save(report);
|
||||
auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null);
|
||||
governanceNotificationService.notifyUser(
|
||||
report.getReporterId(),
|
||||
"REPORT",
|
||||
"SKILL_REPORT",
|
||||
reportId,
|
||||
"Report dismissed",
|
||||
"{\"status\":\"DISMISSED\"}"
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,17 @@ package com.iflytek.skillhub.domain.report;
|
|||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -30,11 +33,23 @@ class SkillReportServiceTest {
|
|||
@Mock
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@Mock
|
||||
private SkillGovernanceService skillGovernanceService;
|
||||
|
||||
@Mock
|
||||
private GovernanceNotificationService governanceNotificationService;
|
||||
|
||||
private SkillReportService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SkillReportService(skillRepository, skillReportRepository, auditLogService);
|
||||
service = new SkillReportService(
|
||||
skillRepository,
|
||||
skillReportRepository,
|
||||
auditLogService,
|
||||
skillGovernanceService,
|
||||
governanceNotificationService
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -90,6 +105,53 @@ class SkillReportServiceTest {
|
|||
assertThat(saved.getHandledBy()).isEqualTo("admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveReport_withHideDisposition_hidesSkillAndNotifiesReporter() {
|
||||
SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null);
|
||||
setField(report, "id", 99L);
|
||||
when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report));
|
||||
when(skillReportRepository.save(report)).thenReturn(report);
|
||||
|
||||
SkillReport saved = service.resolveReport(
|
||||
99L,
|
||||
"admin",
|
||||
SkillReportDisposition.RESOLVE_AND_HIDE,
|
||||
"handled",
|
||||
"127.0.0.1",
|
||||
"JUnit"
|
||||
);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED);
|
||||
verify(skillGovernanceService).hideSkill(10L, "admin", "127.0.0.1", "JUnit", "handled");
|
||||
verify(governanceNotificationService).notifyUser(
|
||||
eq("user-1"),
|
||||
eq("REPORT"),
|
||||
eq("SKILL_REPORT"),
|
||||
eq(99L),
|
||||
eq("Report handled"),
|
||||
any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveReport_withArchiveDisposition_archivesSkill() {
|
||||
SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null);
|
||||
setField(report, "id", 99L);
|
||||
when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report));
|
||||
when(skillReportRepository.save(report)).thenReturn(report);
|
||||
|
||||
service.resolveReport(
|
||||
99L,
|
||||
"admin",
|
||||
SkillReportDisposition.RESOLVE_AND_ARCHIVE,
|
||||
"handled",
|
||||
"127.0.0.1",
|
||||
"JUnit"
|
||||
);
|
||||
|
||||
verify(skillGovernanceService).archiveSkillAsAdmin(10L, "admin", "127.0.0.1", "JUnit", "handled");
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
|
||||
|
|
|
|||
|
|
@ -1,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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
39
web/src/features/governance/governance-activity.tsx
Normal file
39
web/src/features/governance/governance-activity.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
web/src/features/governance/governance-inbox.tsx
Normal file
69
web/src/features/governance/governance-inbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
web/src/features/governance/governance-notifications.tsx
Normal file
51
web/src/features/governance/governance-notifications.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
web/src/features/governance/use-governance.ts
Normal file
46
web/src/features/governance/use-governance.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "推广管理",
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
102
web/src/pages/dashboard/governance.tsx
Normal file
102
web/src/pages/dashboard/governance.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue