feat(review): add author review progress and attempt history

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-01 12:20:37 +08:00
parent ebac94a043
commit 7995c00683
35 changed files with 1159 additions and 30 deletions

View file

@ -8,12 +8,14 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewActionRequest;
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
import com.iflytek.skillhub.dto.ReviewProgressResponse;
import com.iflytek.skillhub.dto.ReviewTaskRequest;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.List;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@ -136,6 +138,26 @@ public class ReviewController extends BaseApiController {
return ok("response.success.read", governanceWorkflowAppService.listMyReviewSubmissions(page, size, userId));
}
@GetMapping("/my-progress")
public ApiResponse<PageResponse<ReviewProgressResponse>> listMyProgress(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
return ok(
"response.success.read",
governanceWorkflowAppService.listMyReviewProgress(status, q, page, size, userId)
);
}
@GetMapping("/my-progress/{id}/attempts")
public ApiResponse<List<ReviewTaskResponse>> listMyAttempts(
@PathVariable Long id,
@RequestAttribute("userId") String userId) {
return ok("response.success.read", governanceWorkflowAppService.listMyReviewAttempts(id, userId));
}
@GetMapping("/{id}")
public ApiResponse<ReviewTaskResponse> getReviewDetail(@PathVariable Long id,
@RequestAttribute("userId") String userId,

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
/**
* Author-facing summary for one skill version's review attempts.
*/
public record ReviewProgressResponse(
Long latestReviewTaskId,
Long skillId,
String namespace,
String skillSlug,
String skillVersion,
String latestStatus,
String latestReviewComment,
Instant latestSubmittedAt,
Instant latestReviewedAt,
long attemptCount
) {}

View file

@ -104,15 +104,19 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
? Map.of()
: skillVersionRepository.findByIdIn(versionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> skillIds = distinct(versionsById.values().stream().map(SkillVersion::getSkillId).toList());
Set<Long> skillIds = new LinkedHashSet<>(distinct(
versionsById.values().stream().map(SkillVersion::getSkillId).toList()));
skillIds.addAll(distinct(tasks.stream().map(ReviewTask::getSkillId).toList()));
Map<Long, Skill> skillsById = skillIds.isEmpty()
? Map.of()
: skillRepository.findByIdIn(skillIds).stream()
: skillRepository.findByIdIn(List.copyOf(skillIds)).stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> namespaceIds = distinct(skillsById.values().stream().map(Skill::getNamespaceId).toList());
Set<Long> namespaceIds = new LinkedHashSet<>(distinct(
skillsById.values().stream().map(Skill::getNamespaceId).toList()));
namespaceIds.addAll(distinct(tasks.stream().map(ReviewTask::getNamespaceId).toList()));
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
? Map.of()
: namespaceRepository.findByIdIn(namespaceIds).stream()
: namespaceRepository.findByIdIn(List.copyOf(namespaceIds)).stream()
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
List<String> userIds = distinctStrings(tasks.stream()
.flatMap(task -> java.util.stream.Stream.of(task.getSubmittedBy(), task.getReviewedBy()))
@ -168,9 +172,14 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
}
private ReviewTaskResponse toReviewTaskResponse(ReviewTask task, ReviewReadBundle bundle) {
SkillVersion version = require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found");
Skill skill = require(bundle.skillsById(), version.getSkillId(), "skill.not_found");
Namespace namespace = require(bundle.namespacesById(), skill.getNamespaceId(), "namespace.not_found");
Long skillId = task.getSkillId() != null
? task.getSkillId()
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId();
Skill skill = require(bundle.skillsById(), skillId, "skill.not_found");
Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found");
String skillVersion = task.getSkillVersion() != null
? task.getSkillVersion()
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion();
UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy());
UserAccount reviewedBy = task.getReviewedBy() != null ? bundle.usersById().get(task.getReviewedBy()) : null;
return new ReviewTaskResponse(
@ -178,7 +187,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
task.getSkillVersionId(),
namespace.getSlug(),
skill.getSlug(),
version.getVersion(),
skillVersion,
task.getStatus().name(),
task.getSubmittedBy(),
submittedBy != null ? submittedBy.getDisplayName() : null,

View file

@ -0,0 +1,130 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewProgressResponse;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
/**
* PostgreSQL read-model query for review progress.
*
* <p>Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts,
* not individual review tasks. Window functions keep grouping, latest-attempt selection, counts,
* filtering, and pagination in the database instead of loading an author's full history.</p>
*/
@Repository
public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository {
private static final String MY_PROGRESS_SQL = """
WITH ranked AS (
SELECT task.id,
task.skill_id,
task.namespace_id,
task.skill_version,
task.status,
task.review_comment,
task.submitted_at,
task.reviewed_at,
ROW_NUMBER() OVER (
PARTITION BY task.skill_id, task.skill_version
ORDER BY task.submitted_at DESC, task.id DESC
) AS attempt_rank,
COUNT(*) OVER (
PARTITION BY task.skill_id, task.skill_version
) AS attempt_count
FROM review_task task
WHERE task.submitted_by = :userId
), latest AS (
SELECT *
FROM ranked
WHERE attempt_rank = 1
AND (:status = '' OR status = :status)
)
SELECT latest.id,
latest.skill_id,
namespace.slug,
skill.slug,
latest.skill_version,
latest.status,
latest.review_comment,
latest.submitted_at,
latest.reviewed_at,
latest.attempt_count,
COUNT(*) OVER () AS total_groups
FROM latest
JOIN skill ON skill.id = latest.skill_id
JOIN namespace ON namespace.id = latest.namespace_id
WHERE :query = ''
OR LOWER(skill.slug) LIKE :queryPattern
OR LOWER(namespace.slug) LIKE :queryPattern
ORDER BY latest.submitted_at DESC, latest.id DESC
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
""";
private final EntityManager entityManager;
public JpaReviewProgressQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
@Transactional(readOnly = true)
public PageResponse<ReviewProgressResponse> findMyProgress(
String userId,
ReviewTaskStatus status,
String query,
int page,
int size) {
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT);
Query nativeQuery = entityManager.createNativeQuery(MY_PROGRESS_SQL)
.setParameter("userId", userId)
.setParameter("status", status != null ? status.name() : "")
.setParameter("query", normalizedQuery)
.setParameter("queryPattern", "%" + normalizedQuery + "%")
.setParameter("offset", page * size)
.setParameter("size", size);
@SuppressWarnings("unchecked")
List<Object[]> rows = nativeQuery.getResultList();
List<ReviewProgressResponse> items = rows.stream().map(this::mapRow).toList();
long total = rows.isEmpty() ? 0 : number(rows.get(0)[10]).longValue();
return new PageResponse<>(items, total, page, size);
}
private ReviewProgressResponse mapRow(Object[] row) {
return new ReviewProgressResponse(
number(row[0]).longValue(),
number(row[1]).longValue(),
(String) row[2],
(String) row[3],
(String) row[4],
String.valueOf(row[5]),
(String) row[6],
instant(row[7]),
instant(row[8]),
number(row[9]).longValue()
);
}
private Number number(Object value) {
if (value instanceof Number number) {
return number;
}
throw new IllegalStateException("Expected numeric review progress value, got " + value);
}
private Instant instant(Object value) {
if (value == null) return null;
if (value instanceof Instant instant) return instant;
if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant();
if (value instanceof Timestamp timestamp) return timestamp.toInstant();
throw new IllegalStateException("Expected review progress timestamp, got " + value.getClass().getName());
}
}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewProgressResponse;
/**
* Query seam for author-facing review progress grouped by skill version.
*/
public interface ReviewProgressQueryRepository {
PageResponse<ReviewProgressResponse> findMyProgress(
String userId,
ReviewTaskStatus status,
String query,
int page,
int size
);
}

View file

@ -8,10 +8,12 @@ import com.iflytek.skillhub.dto.NamespaceResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
import com.iflytek.skillhub.dto.ReviewProgressResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
@ -93,6 +95,19 @@ public class GovernanceWorkflowAppService {
return reviewPortalAppService.listMySubmissions(page, size, userId);
}
public PageResponse<ReviewProgressResponse> listMyReviewProgress(
String status,
String query,
int page,
int size,
String userId) {
return reviewPortalAppService.listMyProgress(status, query, page, size, userId);
}
public List<ReviewTaskResponse> listMyReviewAttempts(Long reviewTaskId, String userId) {
return reviewPortalAppService.listMyAttempts(reviewTaskId, userId);
}
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {

View file

@ -13,9 +13,11 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewProgressResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -34,6 +36,7 @@ public class ReviewPortalAppService {
private final ReviewTaskRepository reviewTaskRepository;
private final NamespaceRepository namespaceRepository;
private final GovernanceQueryRepository governanceQueryRepository;
private final ReviewProgressQueryRepository reviewProgressQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
@ -42,6 +45,7 @@ public class ReviewPortalAppService {
ReviewTaskRepository reviewTaskRepository,
NamespaceRepository namespaceRepository,
GovernanceQueryRepository governanceQueryRepository,
ReviewProgressQueryRepository reviewProgressQueryRepository,
RbacService rbacService,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
@ -49,6 +53,7 @@ public class ReviewPortalAppService {
this.reviewTaskRepository = reviewTaskRepository;
this.namespaceRepository = namespaceRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.reviewProgressQueryRepository = reviewProgressQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
@ -212,6 +217,39 @@ public class ReviewPortalAppService {
));
}
public PageResponse<ReviewProgressResponse> listMyProgress(
String status,
String query,
int page,
int size,
String userId) {
ReviewTaskStatus reviewStatus = status == null || status.isBlank()
? null
: ReviewTaskStatus.valueOf(status.toUpperCase(java.util.Locale.ROOT));
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 100);
return reviewProgressQueryRepository.findMyProgress(
userId,
reviewStatus,
query != null ? query : "",
safePage,
safeSize
);
}
public List<ReviewTaskResponse> listMyAttempts(Long reviewTaskId, String userId) {
ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
if (!anchor.getSubmittedBy().equals(userId)) {
throw new DomainForbiddenException("review.no_permission");
}
List<ReviewTask> attempts = reviewTaskRepository
.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
userId, anchor.getSkillId(), anchor.getSkillVersion());
return governanceQueryRepository.getReviewTaskResponses(attempts);
}
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {

View file

@ -0,0 +1,27 @@
ALTER TABLE review_task
ADD COLUMN skill_id BIGINT,
ADD COLUMN skill_version VARCHAR(64);
UPDATE review_task task
SET skill_id = version.skill_id,
skill_version = version.version
FROM skill_version version
WHERE task.skill_version_id = version.id;
ALTER TABLE review_task
ALTER COLUMN skill_id SET NOT NULL,
ALTER COLUMN skill_version SET NOT NULL,
ALTER COLUMN skill_version_id DROP NOT NULL;
ALTER TABLE review_task
DROP CONSTRAINT review_task_skill_version_id_fkey,
ADD CONSTRAINT fk_review_task_skill_version
FOREIGN KEY (skill_version_id) REFERENCES skill_version(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_review_task_skill
FOREIGN KEY (skill_id) REFERENCES skill(id);
CREATE INDEX idx_review_task_submitter_submitted
ON review_task(submitted_by, submitted_at DESC, id DESC);
CREATE INDEX idx_review_task_skill_version_attempts
ON review_task(skill_id, skill_version, submitted_at DESC, id DESC);

View file

@ -16,11 +16,13 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
import com.iflytek.skillhub.dto.ReviewProgressResponse;
import com.iflytek.skillhub.dto.SkillDetailResponse;
import com.iflytek.skillhub.dto.SkillFileResponse;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillVersionResponse;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
import com.iflytek.skillhub.service.ReviewSkillDetailAppService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -38,6 +40,7 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.util.List;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@ -79,6 +82,9 @@ class ReviewPortalControllerTest {
@MockBean
private GovernanceQueryRepository governanceQueryRepository;
@MockBean
private ReviewProgressQueryRepository reviewProgressQueryRepository;
@MockBean
private RbacService rbacService;
@ -276,6 +282,54 @@ class ReviewPortalControllerTest {
verify(reviewTaskRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
void listMyReviewProgress_isScopedToAuthenticatedAuthor() throws Exception {
var item = new ReviewProgressResponse(
12L,
30L,
"team-a",
"skill-a",
"1.0.0",
"REJECTED",
"Please add tests",
Instant.parse("2026-08-31T10:00:00Z"),
Instant.parse("2026-08-31T11:00:00Z"),
2L
);
given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 0, 20))
.willReturn(new com.iflytek.skillhub.dto.PageResponse<>(List.of(item), 1, 0, 20));
mockMvc.perform(get("/api/v1/reviews/my-progress").with(auth("author-1")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].skillSlug").value("skill-a"))
.andExpect(jsonPath("$.data.items[0].attemptCount").value(2))
.andExpect(jsonPath("$.data.items[0].latestStatus").value("REJECTED"));
verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20);
}
@Test
void listMyReviewAttempts_returnsOnlyTheAuthorsVersionHistory() throws Exception {
ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.REJECTED);
setField(latest, "skillId", 30L);
setField(latest, "skillVersion", "1.0.0");
ReviewTask previous = createReviewTask(8L, 20L, "author-1", ReviewTaskStatus.REJECTED);
setField(previous, "skillId", 30L);
setField(previous, "skillVersion", "1.0.0");
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest));
given(reviewTaskRepository.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
"author-1", 30L, "1.0.0"))
.willReturn(List.of(latest, previous));
given(governanceQueryRepository.getReviewTaskResponses(List.of(latest, previous)))
.willReturn(List.of(toReviewResponse(latest), toReviewResponse(previous)));
mockMvc.perform(get("/api/v1/reviews/my-progress/12/attempts").with(auth("author-1")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[0].id").value(12))
.andExpect(jsonPath("$.data[1].id").value(8));
}
@Test
void downloadReviewVersion_streamsZipForAuthorizedReviewer() throws Exception {
stubNamespaceRoles("admin", List.of());

View file

@ -212,7 +212,8 @@ class SkillApprovalVisibilityFlowIntegrationTest {
version = skillVersionRepository.save(version);
skillVersionRepository.flush();
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId));
return new PendingSkillGraph(namespace, skill, version, reviewTask);
}
@ -237,7 +238,8 @@ class SkillApprovalVisibilityFlowIntegrationTest {
version = skillVersionRepository.save(version);
skillVersionRepository.flush();
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId));
return new PendingSkillGraph(namespace, skill, version, reviewTask);
}

View file

@ -91,15 +91,18 @@ class SkillVersionDeleteFlowIntegrationTest {
retainedVersion.setStatus(SkillVersionStatus.REJECTED);
retainedVersion = skillVersionRepository.save(retainedVersion);
ReviewTask rejectedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId);
ReviewTask rejectedTask = new ReviewTask(
rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId);
rejectedTask.setStatus(ReviewTaskStatus.REJECTED);
rejectedTask = reviewTaskRepository.save(rejectedTask);
ReviewTask approvedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId);
ReviewTask approvedTask = new ReviewTask(
rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId);
approvedTask.setStatus(ReviewTaskStatus.APPROVED);
approvedTask = reviewTaskRepository.save(approvedTask);
ReviewTask retainedTask = new ReviewTask(retainedVersion.getId(), namespace.getId(), ownerId);
ReviewTask retainedTask = new ReviewTask(
retainedVersion.getId(), skill.getId(), namespace.getId(), retainedVersion.getVersion(), ownerId);
retainedTask.setStatus(ReviewTaskStatus.REJECTED);
retainedTask = reviewTaskRepository.save(retainedTask);

View file

@ -86,6 +86,32 @@ class JpaGovernanceQueryRepositoryTest {
assertThat(responses.get(0).reviewedByName()).isEqualTo("Reviewer");
}
@Test
void getReviewTaskResponses_usesSnapshotAfterReviewedVersionIsReplaced() {
ReviewTask task = new ReviewTask(null, 201L, 11L, "1.2.0", "submitter");
setField(task, "id", 6L);
setField(task, "status", ReviewTaskStatus.REJECTED);
Skill skill = new Skill(11L, "skill-a", "submitter", SkillVisibility.PUBLIC);
setField(skill, "id", 201L);
Namespace namespace = new Namespace("team-a", "Team A", "submitter");
setField(namespace, "id", 11L);
UserAccount submitter = new UserAccount("submitter", "Submitter", "submitter@example.com", null);
given(skillRepository.findByIdIn(List.of(201L))).willReturn(List.of(skill));
given(namespaceRepository.findByIdIn(List.of(11L))).willReturn(List.of(namespace));
given(userAccountRepository.findByIdIn(List.of("submitter"))).willReturn(List.of(submitter));
var responses = repository.getReviewTaskResponses(List.of(task));
assertThat(responses).singleElement().satisfies(response -> {
assertThat(response.skillVersionId()).isNull();
assertThat(response.namespace()).isEqualTo("team-a");
assertThat(response.skillSlug()).isEqualTo("skill-a");
assertThat(response.version()).isEqualTo("1.2.0");
});
}
@Test
void getPromotionResponses_assemblesPromotionReadModel() {
PromotionRequest request = new PromotionRequest(201L, 101L, 12L, "submitter");

View file

@ -611,6 +611,12 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
String submittedBy, Long skillId, String skillVersion) {
throw unsupported();
}
@Override
public boolean existsByNamespaceId(Long namespaceId) {
return false;
@ -621,6 +627,11 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public void deleteBySkillId(Long skillId) {
throw unsupported();
}
@Override
public void delete(ReviewTask reviewTask) {
this.deletedTask = reviewTask;

View file

@ -111,7 +111,8 @@ public class ReviewService {
skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW);
skillVersionRepository.save(skillVersion);
ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId);
ReviewTask task = new ReviewTask(
skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId);
try {
ReviewTask saved = reviewTaskRepository.save(task);
eventPublisher.publishEvent(new ReviewSubmittedEvent(
@ -153,7 +154,8 @@ public class ReviewService {
skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW);
skillVersionRepository.save(skillVersion);
ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId);
ReviewTask task = new ReviewTask(
skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId);
try {
ReviewTask saved = reviewTaskRepository.save(task);
eventPublisher.publishEvent(new ReviewSubmittedEvent(

View file

@ -11,9 +11,15 @@ public class ReviewTask {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "skill_version_id", nullable = false)
@Column(name = "skill_version_id")
private Long skillVersionId;
@Column(name = "skill_id", nullable = false)
private Long skillId;
@Column(name = "skill_version", nullable = false, length = 64)
private String skillVersion;
@Column(name = "namespace_id", nullable = false)
private Long namespaceId;
@ -49,10 +55,23 @@ public class ReviewTask {
this.submittedBy = submittedBy;
}
public ReviewTask(Long skillVersionId, Long skillId, Long namespaceId,
String skillVersion, String submittedBy) {
this.skillVersionId = skillVersionId;
this.skillId = skillId;
this.namespaceId = namespaceId;
this.skillVersion = skillVersion;
this.submittedBy = submittedBy;
}
public Long getId() { return id; }
public Long getSkillVersionId() { return skillVersionId; }
public Long getSkillId() { return skillId; }
public String getSkillVersion() { return skillVersion; }
public Long getNamespaceId() { return namespaceId; }
public ReviewTaskStatus getStatus() { return status; }

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.review;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
@ -15,8 +16,11 @@ public interface ReviewTaskRepository {
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
String submittedBy, Long skillId, String skillVersion);
boolean existsByNamespaceId(Long namespaceId);
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
void deleteBySkillId(Long skillId);
void delete(ReviewTask reviewTask);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
String reviewComment, Integer expectedVersion);

View file

@ -107,9 +107,8 @@ public class SkillHardDeleteService {
skillRepository.save(skill);
skillRepository.flush();
if (!versionIds.isEmpty()) {
reviewTaskRepository.deleteBySkillVersionIdIn(versionIds);
}
// Also removes detached historical attempts whose replaced skill version no longer exists.
reviewTaskRepository.deleteBySkillId(skill.getId());
promotionRequestRepository.deleteBySourceSkillIdOrTargetSkillId(skill.getId(), skill.getId());
skillTagRepository.deleteBySkillId(skill.getId());
skillStarRepository.deleteBySkillId(skill.getId());

View file

@ -561,7 +561,8 @@ public class SkillPublishService {
// Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE)
if (!autoPublish && visibility != SkillVisibility.PRIVATE) {
ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId);
ReviewTask reviewTask = new ReviewTask(
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), publisherId);
ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask);
eventPublisher.publishEvent(new ReviewSubmittedEvent(
savedReviewTask.getId(),
@ -608,10 +609,11 @@ public class SkillPublishService {
skillRepository.flush();
}
// Every review task referencing this version has to go, not just a PENDING one:
// a rejected version still owns a REJECTED task whose foreign key blocks the
// skill_version delete below, which surfaces to the caller as an HTTP 500.
reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId()));
// A replaceable version may still have one obsolete pending task, but settled attempts are
// durable governance history. The database detaches those settled attempts from the
// replaced version while retaining their skill/version snapshot.
reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING)
.ifPresent(reviewTaskRepository::delete);
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
List<String> storageKeys = new ArrayList<>();

View file

@ -95,7 +95,8 @@ public class SkillReviewSubmitService {
skillVersionRepository.save(version);
// Create review task
ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId);
ReviewTask reviewTask = new ReviewTask(
versionId, skill.getId(), skill.getNamespaceId(), version.getVersion(), actorUserId);
reviewTaskRepository.save(reviewTask);
}

View file

@ -124,7 +124,7 @@ class SkillHardDeleteServiceTest {
inOrder.verify(skillRepository).save(skill);
inOrder.verify(skillRepository).flush();
inOrder.verify(skillVersionRepository).deleteBySkillId(7L);
verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(21L, 22L));
verify(reviewTaskRepository).deleteBySkillId(7L);
verify(promotionRequestRepository).deleteBySourceSkillIdOrTargetSkillId(7L, 7L);
verify(skillTagRepository).deleteBySkillId(7L);
verify(skillStarRepository).deleteBySkillId(7L);

View file

@ -424,7 +424,7 @@ class SkillPublishServiceTest {
}
@Test
void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception {
void testPublishFromEntries_ShouldPreserveSettledReviewHistoryWhenReplacingRejectedVersion() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
@ -473,7 +473,7 @@ class SkillPublishServiceTest {
assertEquals("1.0.0", result.version().getVersion());
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L));
verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(List.of(8L));
verify(skillFileRepository).deleteByVersionId(8L);
verify(skillVersionRepository).delete(rejectedVersion);
verify(skillVersionRepository, times(2)).flush();
@ -482,6 +482,8 @@ class SkillPublishServiceTest {
ArgumentCaptor<ReviewTask> reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class);
verify(reviewTaskRepository).save(reviewTaskCaptor.capture());
assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId());
assertEquals(skill.getId(), reviewTaskCaptor.getValue().getSkillId());
assertEquals("1.0.0", reviewTaskCaptor.getValue().getSkillVersion());
assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy());
}

View file

@ -11,6 +11,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
@ -28,10 +29,15 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
String submittedBy, Long skillId, String skillVersion);
boolean existsByNamespaceId(Long namespaceId);
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
void deleteBySkillId(Long skillId);
@Modifying
@Query("""
UPDATE ReviewTask t

View file

@ -24,6 +24,13 @@ test.describe('Rejected version replacement (Real API)', () => {
})
test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => {
const consoleErrors: string[] = []
const pageErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => pageErrors.push(error.message))
const publisherBuilder = new E2eTestDataBuilder(page, testInfo)
await publisherBuilder.init()
@ -74,8 +81,43 @@ test.describe('Rejected version replacement (Real API)', () => {
expect(replacement.version).toBe(firstPublish.version)
expect(replacementReviewId).not.toBe(rejectedReviewId)
const attemptsResponse = await page.request.get(
`/api/web/reviews/my-progress/${replacementReviewId}/attempts`,
)
expect(attemptsResponse.status()).toBe(200)
const attemptsBody = await attemptsResponse.json() as {
data: Array<{ id: number; status: string; skillVersionId: number | null }>
}
expect(attemptsBody.data.map((attempt) => attempt.id)).toEqual([
replacementReviewId,
rejectedReviewId,
])
expect(attemptsBody.data.map((attempt) => attempt.status)).toEqual(['PENDING', 'REJECTED'])
expect(attemptsBody.data[1]?.skillVersionId).toBeNull()
const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`)
expect(replacedReviewResponse.status()).toBe(404)
expect(replacedReviewResponse.status()).toBe(200)
await page.goto('/dashboard/review-progress')
await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible()
const progressCard = page.locator('article').filter({ hasText: replacement.slug })
await expect(progressCard).toContainText('In review')
await expect(progressCard).toContainText('2 submissions')
await progressCard.getByRole('button', { name: 'Submission history' }).click()
await expect(progressCard).toContainText('Attempt 2')
await expect(progressCard).toContainText('Attempt 1')
await expect(progressCard).toContainText('Rejected by Playwright E2E')
await page.screenshot({ path: testInfo.outputPath('author-review-progress-desktop.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
await expect(progressCard).toBeVisible()
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath('author-review-progress-mobile.png'), fullPage: true })
const unexpectedConsoleErrors = consoleErrors.filter((message) => (
!message.includes("frame-ancestors' is ignored when delivered via a <meta> element")
))
expect(unexpectedConsoleErrors).toEqual([])
expect(pageErrors).toEqual([])
} finally {
await adminBuilder.cleanup()
await adminContext.close()

View file

@ -15,6 +15,7 @@ import type {
MergeInitiateResponse,
MergeVerifyRequest,
ReviewSkillDetail,
ReviewProgress,
ReviewTask,
PromotionSortBy,
PromotionSortDirection,
@ -882,6 +883,21 @@ export const reviewApi = {
return fetchJson<ReviewTask>(`${WEB_API_PREFIX}/reviews/${id}`)
},
async listMyProgress(params: { status?: string; q?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
if (params.status) searchParams.set('status', params.status)
if (params.q) searchParams.set('q', params.q)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: ReviewProgress[]; total: number; page: number; size: number }>(
`${WEB_API_PREFIX}/reviews/my-progress?${searchParams.toString()}`,
)
},
async listMyAttempts(reviewTaskId: number): Promise<ReviewTask[]> {
return fetchJson<ReviewTask[]>(`${WEB_API_PREFIX}/reviews/my-progress/${reviewTaskId}/attempts`)
},
async getSkillDetail(id: number): Promise<ReviewSkillDetail> {
return fetchJson<ReviewSkillDetail>(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`)
},

View file

@ -2500,6 +2500,70 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/web/reviews/my-progress/{id}/attempts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyAttempts"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/reviews/my-progress/{id}/attempts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyAttempts_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/reviews/my-progress": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyProgress"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/reviews/my-progress": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyProgress_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/promotions/{id}": {
parameters: {
query?: never;
@ -4647,6 +4711,50 @@ export interface components {
/** Format: int32 */
size?: number;
};
ApiResponseListReviewTaskResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["ReviewTaskResponse"][];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
ApiResponsePageResponseReviewProgressResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["PageResponseReviewProgressResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
PageResponseReviewProgressResponse: {
items?: components["schemas"]["ReviewProgressResponse"][];
/** Format: int64 */
total?: number;
/** Format: int32 */
page?: number;
/** Format: int32 */
size?: number;
};
ReviewProgressResponse: {
/** Format: int64 */
latestReviewTaskId?: number;
/** Format: int64 */
skillId?: number;
namespace?: string;
skillSlug?: string;
skillVersion?: string;
latestStatus?: string;
latestReviewComment?: string;
/** Format: date-time */
latestSubmittedAt?: string;
/** Format: date-time */
latestReviewedAt?: string;
/** Format: int64 */
attemptCount?: number;
};
ApiResponsePageResponsePromotionResponseDto: {
/** Format: int32 */
code?: number;
@ -10167,6 +10275,100 @@ export interface operations {
};
};
};
listMyAttempts: {
parameters: {
query?: never;
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseListReviewTaskResponse"];
};
};
};
};
listMyAttempts_1: {
parameters: {
query?: never;
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseListReviewTaskResponse"];
};
};
};
};
listMyProgress: {
parameters: {
query?: {
status?: string;
q?: string;
page?: number;
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"];
};
};
};
};
listMyProgress_1: {
parameters: {
query?: {
status?: string;
q?: string;
page?: number;
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"];
};
};
};
};
getPromotionDetail: {
parameters: {
query?: never;

View file

@ -427,7 +427,7 @@ export interface SkillDeleteResult {
export interface ReviewTask {
id: number
skillVersionId: number
skillVersionId: number | null
namespace: string
skillSlug: string
version: string
@ -441,6 +441,19 @@ export interface ReviewTask {
reviewedAt?: string
}
export interface ReviewProgress {
latestReviewTaskId: number
skillId: number
namespace: string
skillSlug: string
skillVersion: string
latestStatus: 'PENDING' | 'APPROVED' | 'REJECTED'
latestReviewComment?: string
latestSubmittedAt: string
latestReviewedAt?: string
attemptCount: number
}
export interface ReviewSkillDetail {
skill: SkillDetail
versions: SkillVersion[]

View file

@ -103,6 +103,10 @@ const NamespaceReviewDetailPage = createLazyRouteComponent(
)
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
const ReviewProgressPage = createLazyRouteComponent(
() => import('@/pages/dashboard/review-progress'),
'ReviewProgressPage',
)
const ReportsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/reports'),
'ReportsPage',
@ -328,6 +332,24 @@ const dashboardReviewsRoute = createRoute({
component: ReviewsPage,
})
const dashboardReviewProgressRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/review-progress',
beforeLoad: requireAuth,
validateSearch: (search: Record<string, unknown>): {
status?: 'PENDING' | 'APPROVED' | 'REJECTED'
q?: string
page?: number
} => ({
status: search.status === 'PENDING' || search.status === 'APPROVED' || search.status === 'REJECTED'
? search.status
: undefined,
q: typeof search.q === 'string' && search.q.trim() ? search.q.trim() : undefined,
page: typeof search.page === 'number' && search.page > 0 ? search.page : undefined,
}),
component: ReviewProgressPage,
})
const dashboardReportsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/reports',
@ -478,6 +500,7 @@ const routeTree = rootRoute.addChildren([
dashboardNamespaceReviewDetailRoute,
dashboardGovernanceRoute,
dashboardReviewsRoute,
dashboardReviewProgressRoute,
dashboardReportsRoute,
dashboardReviewDetailRoute,
dashboardPromotionsRoute,

View file

@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query'
import { reviewApi } from '@/api/client'
interface MyReviewProgressParams {
status?: string
q?: string
page: number
size: number
}
export function useMyReviewProgress(params: MyReviewProgressParams) {
return useQuery({
queryKey: ['reviews', 'my-progress', params],
queryFn: () => reviewApi.listMyProgress(params),
})
}
export function useMyReviewAttempts(reviewTaskId: number | null) {
return useQuery({
queryKey: ['reviews', 'my-progress', reviewTaskId, 'attempts'],
queryFn: () => reviewApi.listMyAttempts(reviewTaskId!),
enabled: reviewTaskId !== null,
})
}

View file

@ -491,6 +491,28 @@
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
},
"reviewProgress": {
"title": "My Review Progress",
"subtitle": "Track the current decision, reviewer feedback, and every resubmission of your skills",
"searchLabel": "Search skills or namespaces",
"searchPlaceholder": "Search skill or namespace",
"searchAction": "Search",
"statusFilter": "Filter by review status",
"statusAll": "All statuses",
"statusPending": "In review",
"statusApproved": "Approved",
"statusRejected": "Rejected",
"latestSubmitted": "Latest submission: {{time}}",
"attemptCount": "{{count}} submissions",
"attemptNumber": "Attempt {{number}}",
"history": "Submission history",
"resubmit": "Edit and resubmit",
"loading": "Loading review progress",
"error": "Review progress could not be loaded. Try again later.",
"historyError": "Submission history could not be loaded. Try again later.",
"emptyTitle": "No review history yet",
"emptyDescription": "Reviews for public or team skills will appear here after submission."
},
"reviews": {
"title": "Review Center",
"subtitle": "Manage platform review tasks",
@ -1332,6 +1354,7 @@
"governance": "Governance Center",
"stars": "Starred",
"subscriptions": "My Subscriptions",
"reviewProgress": "My Review Progress",
"reviews": "Review Management",
"promotions": "Promotion Management",
"reports": "Report Management",

View file

@ -491,6 +491,28 @@
"switchToLight": "Переключить на светлую тему",
"switchToDark": "Переключить на тёмную тему"
},
"reviewProgress": {
"title": "Мои проверки",
"subtitle": "Текущий статус, комментарии проверяющего и история повторных отправок",
"searchLabel": "Поиск навыков или пространств имён",
"searchPlaceholder": "Навык или пространство имён",
"searchAction": "Найти",
"statusFilter": "Фильтр по статусу проверки",
"statusAll": "Все статусы",
"statusPending": "На проверке",
"statusApproved": "Одобрено",
"statusRejected": "Отклонено",
"latestSubmitted": "Последняя отправка: {{time}}",
"attemptCount": "Отправок: {{count}}",
"attemptNumber": "Попытка {{number}}",
"history": "История отправок",
"resubmit": "Изменить и отправить снова",
"loading": "Загрузка статуса проверки",
"error": "Не удалось загрузить статус проверки. Повторите попытку позже.",
"historyError": "Не удалось загрузить историю. Повторите попытку позже.",
"emptyTitle": "Истории проверок пока нет",
"emptyDescription": "После отправки публичного или командного навыка его статус появится здесь."
},
"reviews": {
"title": "Центр рецензирования",
"subtitle": "Задачи рецензирования на платформе",
@ -1633,6 +1655,7 @@
"governance": "Центр управления",
"stars": "Избранное",
"subscriptions": "Мои подписки",
"reviewProgress": "Мои проверки",
"reviews": "Управление ревью",
"promotions": "Управление продвижением",
"reports": "Управление жалобами",

View file

@ -491,6 +491,28 @@
"switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题"
},
"reviewProgress": {
"title": "我的审核进度",
"subtitle": "查看技能提交的当前状态、审核意见和每次重提记录",
"searchLabel": "搜索技能或命名空间",
"searchPlaceholder": "搜索 Skill 或命名空间",
"searchAction": "搜索",
"statusFilter": "按审核状态筛选",
"statusAll": "全部状态",
"statusPending": "审核中",
"statusApproved": "已通过",
"statusRejected": "已拒绝",
"latestSubmitted": "最近提交:{{time}}",
"attemptCount": "共 {{count}} 次提交",
"attemptNumber": "第 {{number}} 次",
"history": "提交历史",
"resubmit": "修改并重提",
"loading": "正在加载审核进度",
"error": "审核进度加载失败,请稍后重试。",
"historyError": "提交历史加载失败,请稍后重试。",
"emptyTitle": "还没有审核记录",
"emptyDescription": "提交公开或团队 Skill 后,审核进度会显示在这里。"
},
"reviews": {
"title": "审核中心",
"subtitle": "管理平台审核事务",
@ -1332,6 +1354,7 @@
"governance": "治理中心",
"stars": "我的收藏",
"subscriptions": "我的订阅",
"reviewProgress": "我的审核进度",
"reviews": "审核管理",
"promotions": "推广管理",
"reports": "举报管理",

View file

@ -0,0 +1,58 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { ReviewProgressPage } from './review-progress'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'zh' },
}),
}))
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
useSearch: () => ({}),
Link: ({ children, to, className }: { children: ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
}))
vi.mock('@/features/review/use-my-review-progress', () => ({
useMyReviewProgress: () => ({
data: {
items: [{
latestReviewTaskId: 42,
skillId: 7,
namespace: 'team-a',
skillSlug: 'demo-skill',
skillVersion: '1.2.0',
latestStatus: 'REJECTED',
latestReviewComment: 'Please clarify the permission requirements.',
latestSubmittedAt: '2026-09-01T01:00:00Z',
latestReviewedAt: '2026-09-01T02:00:00Z',
attemptCount: 2,
}],
total: 1,
page: 0,
size: 20,
},
isLoading: false,
isError: false,
}),
useMyReviewAttempts: () => ({ data: [], isLoading: false, isError: false }),
}))
describe('ReviewProgressPage', () => {
it('renders the author-facing latest result and keeps reviewer management out of the page', () => {
const html = renderToStaticMarkup(<ReviewProgressPage />)
expect(html).toContain('reviewProgress.title')
expect(html).toContain('@team-a/demo-skill')
expect(html).toContain('v1.2.0')
expect(html).toContain('reviewProgress.statusRejected')
expect(html).toContain('reviewProgress.resubmit')
expect(html).toContain('reviewProgress.history')
expect(html).not.toContain('reviews.typeSkill')
})
})

View file

@ -0,0 +1,255 @@
import { useState, type FormEvent } from 'react'
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { ChevronDown, ChevronUp, Clock3, RotateCcw, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ReviewProgress, ReviewTask } from '@/api/types'
import { useMyReviewAttempts, useMyReviewProgress } from '@/features/review/use-my-review-progress'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { cn } from '@/shared/lib/utils'
import { Button, buttonVariants } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
const PAGE_SIZE = 20
const statusClassNames: Record<ReviewStatus, string> = {
PENDING: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300',
APPROVED: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300',
REJECTED: 'border-red-500/25 bg-red-500/10 text-red-800 dark:text-red-300',
}
export function ReviewProgressPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const search = useSearch({ from: '/dashboard/review-progress' })
const [queryInput, setQueryInput] = useState(search.q ?? '')
const [expandedId, setExpandedId] = useState<number | null>(null)
const progressQuery = useMyReviewProgress({
status: search.status,
q: search.q,
page: search.page ?? 0,
size: PAGE_SIZE,
})
const page = search.page ?? 0
const totalPages = progressQuery.data
? Math.ceil(progressQuery.data.total / progressQuery.data.size)
: 0
function updateSearch(next: { status?: ReviewStatus | null; q?: string; page?: number }) {
void navigate({
to: '/dashboard/review-progress',
search: {
status: next.status === null ? undefined : next.status ?? search.status,
q: next.q ?? search.q,
page: next.page ?? 0,
},
replace: true,
})
}
function submitSearch(event: FormEvent) {
event.preventDefault()
updateSearch({ q: queryInput.trim(), page: 0 })
}
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader
title={t('reviewProgress.title')}
subtitle={t('reviewProgress.subtitle')}
/>
<Card className="border-border/70">
<CardContent className="space-y-6 p-5 md:p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<form className="flex min-w-0 flex-1 gap-2" onSubmit={submitSearch}>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={queryInput}
onChange={(event) => setQueryInput(event.target.value)}
placeholder={t('reviewProgress.searchPlaceholder')}
className="pl-9"
aria-label={t('reviewProgress.searchLabel')}
/>
</div>
<Button type="submit" variant="outline">{t('reviewProgress.searchAction')}</Button>
</form>
<div className="w-full md:w-48">
<Select
value={search.status ?? 'ALL'}
onValueChange={(value) => updateSearch({
status: value === 'ALL' ? null : value as ReviewStatus,
page: 0,
})}
>
<SelectTrigger aria-label={t('reviewProgress.statusFilter')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t('reviewProgress.statusAll')}</SelectItem>
<SelectItem value="PENDING">{t('reviewProgress.statusPending')}</SelectItem>
<SelectItem value="APPROVED">{t('reviewProgress.statusApproved')}</SelectItem>
<SelectItem value="REJECTED">{t('reviewProgress.statusRejected')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{progressQuery.isLoading ? (
<div className="space-y-3" aria-label={t('reviewProgress.loading')}>
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-28 animate-shimmer rounded-xl" />
))}
</div>
) : progressQuery.isError ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">
{t('reviewProgress.error')}
</div>
) : progressQuery.data?.items.length ? (
<div className="space-y-3">
{progressQuery.data.items.map((item) => (
<ProgressItem
key={`${item.skillId}:${item.skillVersion}`}
item={item}
expanded={expandedId === item.latestReviewTaskId}
onToggle={() => setExpandedId((current) => (
current === item.latestReviewTaskId ? null : item.latestReviewTaskId
))}
locale={i18n.language}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/70 px-6 py-14 text-center">
<Clock3 className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="mt-4 font-medium text-foreground">{t('reviewProgress.emptyTitle')}</p>
<p className="mt-1 text-sm text-muted-foreground">{t('reviewProgress.emptyDescription')}</p>
</div>
)}
{totalPages > 1 ? (
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
/>
) : null}
</CardContent>
</Card>
</div>
)
}
function ProgressItem({
item,
expanded,
onToggle,
locale,
}: {
item: ReviewProgress
expanded: boolean
onToggle: () => void
locale: string
}) {
const { t } = useTranslation()
const attemptsQuery = useMyReviewAttempts(expanded ? item.latestReviewTaskId : null)
const status = item.latestStatus
return (
<article className="overflow-hidden rounded-xl border border-border/70 bg-card text-card-foreground">
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:p-5">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to="/space/$namespace/$slug"
params={{ namespace: item.namespace, slug: item.skillSlug }}
className="truncate font-semibold text-foreground underline-offset-4 hover:underline"
>
@{item.namespace}/{item.skillSlug}
</Link>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs text-secondary-foreground">
v{item.skillVersion}
</span>
<span className={cn('rounded-full border px-2.5 py-1 text-xs font-medium', statusClassNames[status])}>
{t(`reviewProgress.status${status === 'PENDING' ? 'Pending' : status === 'APPROVED' ? 'Approved' : 'Rejected'}`)}
</span>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>{t('reviewProgress.latestSubmitted', { time: formatLocalDateTime(item.latestSubmittedAt, locale) })}</span>
<span>{t('reviewProgress.attemptCount', { count: item.attemptCount })}</span>
</div>
{item.latestReviewComment ? (
<p className="line-clamp-2 text-sm text-foreground/85">{item.latestReviewComment}</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{status === 'REJECTED' ? (
<Link
to="/space/$namespace/$slug"
params={{ namespace: item.namespace, slug: item.skillSlug }}
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'gap-2')}
>
<RotateCcw className="h-4 w-4" />
{t('reviewProgress.resubmit')}
</Link>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
aria-expanded={expanded}
className="gap-2"
>
{t('reviewProgress.history')}
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</div>
{expanded ? (
<div className="border-t border-border/70 bg-muted/30 px-4 py-4 md:px-5">
{attemptsQuery.isLoading ? (
<div className="h-16 animate-shimmer rounded-lg" />
) : attemptsQuery.isError ? (
<p className="text-sm text-destructive">{t('reviewProgress.historyError')}</p>
) : (
<AttemptTimeline attempts={attemptsQuery.data ?? []} locale={locale} />
)}
</div>
) : null}
</article>
)
}
function AttemptTimeline({ attempts, locale }: { attempts: ReviewTask[]; locale: string }) {
const { t } = useTranslation()
return (
<ol className="space-y-3">
{attempts.map((attempt, index) => (
<li key={attempt.id} className="grid gap-2 rounded-lg border border-border/60 bg-background/70 p-3 text-sm md:grid-cols-[auto_1fr_auto] md:items-start">
<span className="font-medium text-foreground">
{t('reviewProgress.attemptNumber', { number: attempts.length - index })}
</span>
<div className="min-w-0">
<span className={cn('inline-flex rounded-full border px-2 py-0.5 text-xs font-medium', statusClassNames[attempt.status])}>
{t(`reviewProgress.status${attempt.status === 'PENDING' ? 'Pending' : attempt.status === 'APPROVED' ? 'Approved' : 'Rejected'}`)}
</span>
{attempt.reviewComment ? <p className="mt-2 whitespace-pre-wrap text-foreground/85">{attempt.reviewComment}</p> : null}
</div>
<time className="text-xs text-muted-foreground" dateTime={attempt.submittedAt}>
{formatLocalDateTime(attempt.submittedAt, locale)}
</time>
</li>
))}
</ol>
)
}

View file

@ -77,6 +77,20 @@ describe('user-menu module exports', () => {
})
describe('UserMenu security settings visibility', () => {
it('keeps author review progress separate from reviewer management', () => {
const html = renderToStaticMarkup(
<UserMenu
user={{
displayName: 'Skill Author',
platformRoles: ['USER'],
}}
/>,
)
expect(html).toContain('user.menu.reviewProgress')
expect(html).not.toContain('user.menu.reviews')
})
it('shows security settings when password changes are allowed, independent of OAuth provider', () => {
const html = renderToStaticMarkup(
<UserMenu

View file

@ -164,6 +164,9 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
<Link to="/dashboard/subscriptions" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.subscriptions')}
</Link>
<Link to="/dashboard/review-progress" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reviewProgress')}
</Link>
{reviewCenterVisible ? (
<Link to={buildGlobalReviewsPath()} className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reviews')}