diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index 7997cc85..f076addd 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -235,11 +235,13 @@ | moderated_by | varchar(128) | 最近一次管理操作人,nullable | | moderated_at | datetime | 最近一次管理时间,nullable | | moderation_reason | varchar(500) | 隐藏原因,nullable | +| lock_version | bigint | 乐观锁版本;并发编辑或治理冲突返回 409 | | created_at | datetime | | | updated_at | datetime | | 唯一约束:`(skill_id, user_id)`,每人每技能一条,可修改。删除文字评价只清空 -`review_text`,保留评分;管理员隐藏评价时也保留评分,避免治理动作改变聚合分数。 +`review_text`,保留评分和既有治理状态;管理员隐藏评价时也保留评分,避免作者通过 +清空后重新提交绕过治理,或治理动作改变聚合分数。 ### user_account diff --git a/docs/skillhub/en/guide/social.md b/docs/skillhub/en/guide/social.md index a8c4128f..5470a893 100644 --- a/docs/skillhub/en/guide/social.md +++ b/docs/skillhub/en/guide/social.md @@ -60,6 +60,13 @@ View skill packages with the most stars and highest ratings to discover best pra 3. The rating takes effect immediately and impacts the skill package's average rating 4. You can update your rating at any time +**Writing a Review**: + +1. Select "Write a review" on a published skill's detail page +2. Choose 1-5 stars and enter up to 2,000 characters +3. You can edit or clear the text; clearing it keeps the star rating +4. A review hidden by an administrator stays hidden after author edits and becomes public only after an administrator restores it + **Viewing Notifications**: 1. Click the notification icon in the top navigation bar @@ -125,6 +132,24 @@ GET /api/v1/me/stars?page=0&size=20 GET /api/v1/skills/{skillId}/rating ``` +**Review APIs**: + +```bash +# Public review list +GET /api/v1/skills/{skillId}/reviews?page=0&size=20 + +# Read, create, or update the current user's review +GET /api/v1/skills/{skillId}/reviews/me +PUT /api/v1/skills/{skillId}/reviews/me + +# Clear review text while retaining the star rating +DELETE /api/v1/skills/{skillId}/reviews/me + +# SKILL_ADMIN or SUPER_ADMIN moderation +POST /api/v1/admin/skill-reviews/{reviewId}/hide +POST /api/v1/admin/skill-reviews/{reviewId}/restore +``` + **Response Example**: ```json { @@ -137,6 +162,8 @@ GET /api/v1/skills/{skillId}/rating > **Rating Rules**: Each user can rate each skill package only once. Ratings can be updated but not deleted. +> **Review Rules**: Only published skills can be reviewed, and the public list contains visible reviews only. Refresh and retry after a concurrent-update conflict. + - **Star Count**: A skill package's star count is displayed in search results and on the detail page - **Average Rating**: A skill package's average rating affects search ranking - **Notification Settings**: Users can disable certain notification types in their settings diff --git a/docs/skillhub/guide/social.md b/docs/skillhub/guide/social.md index 6fc733ba..78ccfc16 100644 --- a/docs/skillhub/guide/social.md +++ b/docs/skillhub/guide/social.md @@ -60,6 +60,13 @@ SkillHub 提供了丰富的社交功能,让团队成员可以互动、分享 3. 评分会立即生效,影响技能包的平均评分 4. 可以随时修改评分 +**撰写评价**: + +1. 在已发布技能的详情页点击「写评价」 +2. 选择 1-5 星并填写最多 2000 字的评价 +3. 可以修改或清空评价;清空评价不会删除星级评分 +4. 被管理员隐藏的评价在重新编辑后仍保持隐藏,只有技能管理员或超级管理员可以恢复公开 + **查看通知**: 1. 点击顶部导航栏的通知图标 @@ -125,6 +132,24 @@ GET /api/v1/me/stars?page=0&size=20 GET /api/v1/skills/{skillId}/rating ``` +**评价接口**: + +```bash +# 公开评价列表 +GET /api/v1/skills/{skillId}/reviews?page=0&size=20 + +# 查看、创建或修改自己的评价 +GET /api/v1/skills/{skillId}/reviews/me +PUT /api/v1/skills/{skillId}/reviews/me + +# 清空评价文字并保留星级评分 +DELETE /api/v1/skills/{skillId}/reviews/me + +# 技能管理员或超级管理员隐藏、恢复评价 +POST /api/v1/admin/skill-reviews/{reviewId}/hide +POST /api/v1/admin/skill-reviews/{reviewId}/restore +``` + **响应示例**: ```json { @@ -137,6 +162,8 @@ GET /api/v1/skills/{skillId}/rating > **评分规则**:每个用户对每个技能包只能评分一次,可以修改评分但不能删除。 +> **评价规则**:只有已发布技能可以评价;公开列表只显示可见评价。并发修改发生冲突时,刷新详情后重试。 + - **星标数量**:技能包的星标数会显示在搜索结果和详情页 - **平均评分**:技能包的平均评分会影响搜索排序 - **通知设置**:用户可以在设置中关闭某些类型的通知 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewResponse.java index 60d91aac..9c4156b1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewResponse.java @@ -1,7 +1,9 @@ package com.iflytek.skillhub.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import java.time.Instant; +@JsonInclude(JsonInclude.Include.NON_NULL) public record SkillReviewResponse( Long id, String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index e2b90929..e64abc56 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -13,10 +13,12 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.Authentication; import org.springframework.validation.FieldError; @@ -68,6 +70,18 @@ public class GlobalExceptionHandler { return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request); } + @ExceptionHandler({ + ObjectOptimisticLockingFailureException.class, + DataIntegrityViolationException.class + }) + public ResponseEntity> handlePersistenceConflict( + RuntimeException ex, + HttpServletRequest request) { + logHandledException(HttpStatus.CONFLICT, "error.request.conflict", request); + return ResponseEntity.status(HttpStatus.CONFLICT).body( + apiResponseFactory.error(409, "error.request.conflict")); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { String msg = ex.getBindingResult().getFieldErrors().stream() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepository.java index 9a1b6e2f..886483a5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepository.java @@ -39,20 +39,28 @@ public class JpaSkillReviewQueryRepository implements SkillReviewQueryRepository .toList(); Map authors = userAccountRepository.findByIdIn(authorIds).stream() .collect(Collectors.toMap(UserAccount::getId, Function.identity())); - return reviews.map(review -> toResponse(review, authors.get(review.getUserId()), viewerId)); + return reviews.map(review -> toResponse( + review, + authors.get(review.getUserId()), + viewerId, + includeHidden + )); } - private SkillReviewResponse toResponse(SkillRating review, UserAccount author, String viewerId) { + private SkillReviewResponse toResponse(SkillRating review, + UserAccount author, + String viewerId, + boolean includeModerationDetails) { return new SkillReviewResponse( review.getId(), - review.getUserId(), + includeModerationDetails ? review.getUserId() : null, author != null ? author.getDisplayName() : review.getUserId(), author != null ? author.getAvatarUrl() : null, review.getScore(), review.getReviewText(), review.getReviewStatus().name(), review.getUserId().equals(viewerId), - review.getModerationReason(), + includeModerationDetails ? review.getModerationReason() : null, review.getCreatedAt(), review.getUpdatedAt() ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillReviewAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillReviewAppService.java index 2faa5269..39d2d060 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillReviewAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillReviewAppService.java @@ -8,6 +8,8 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.social.SkillRating; import com.iflytek.skillhub.domain.social.SkillRatingService; @@ -28,6 +30,7 @@ public class SkillReviewAppService { private static final int MAX_PAGE_SIZE = 100; private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; private final VisibilityChecker visibilityChecker; private final SkillRatingService ratingService; private final SkillReviewQueryRepository queryRepository; @@ -35,12 +38,14 @@ public class SkillReviewAppService { private final RequestIdAccessor requestIdAccessor; public SkillReviewAppService(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, VisibilityChecker visibilityChecker, SkillRatingService ratingService, SkillReviewQueryRepository queryRepository, AuditLogService auditLogService, RequestIdAccessor requestIdAccessor) { this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; this.visibilityChecker = visibilityChecker; this.ratingService = ratingService; this.queryRepository = queryRepository; @@ -83,7 +88,7 @@ public class SkillReviewAppService { String reviewText, Map namespaceRoles, Set platformRoles) { - requireVisibleSkill(skillId, userId, namespaceRoles, platformRoles); + requireInteractableSkill(skillId, userId, namespaceRoles, platformRoles); return toMine(ratingService.upsertReview(skillId, userId, score, reviewText)); } @@ -91,7 +96,7 @@ public class SkillReviewAppService { String userId, Map namespaceRoles, Set platformRoles) { - requireVisibleSkill(skillId, userId, namespaceRoles, platformRoles); + requireInteractableSkill(skillId, userId, namespaceRoles, platformRoles); return toMine(ratingService.clearReview(skillId, userId)); } @@ -130,6 +135,21 @@ public class SkillReviewAppService { return skill; } + private Skill requireInteractableSkill(Long skillId, + String userId, + Map namespaceRoles, + Set platformRoles) { + Skill skill = requireVisibleSkill(skillId, userId, namespaceRoles, platformRoles); + boolean published = skill.getLatestVersionId() != null + && skillVersionRepository.findById(skill.getLatestVersionId()) + .map(version -> version.getStatus() == SkillVersionStatus.PUBLISHED) + .orElse(false); + if (!published) { + throw new DomainBadRequestException("error.skillReview.notInteractable"); + } + return skill; + } + private SkillReviewMeResponse toMine(SkillRating review) { return new SkillReviewMeResponse( true, diff --git a/server/skillhub-app/src/main/resources/db/migration/V46__skill_rating_reviews.sql b/server/skillhub-app/src/main/resources/db/migration/V47__skill_rating_reviews.sql similarity index 91% rename from server/skillhub-app/src/main/resources/db/migration/V46__skill_rating_reviews.sql rename to server/skillhub-app/src/main/resources/db/migration/V47__skill_rating_reviews.sql index 54d7754d..2fdaec7f 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V46__skill_rating_reviews.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V47__skill_rating_reviews.sql @@ -4,6 +4,7 @@ ALTER TABLE skill_rating ADD COLUMN moderated_by VARCHAR(128) REFERENCES user_account(id), ADD COLUMN moderated_at TIMESTAMPTZ, ADD COLUMN moderation_reason VARCHAR(500), + ADD COLUMN lock_version BIGINT NOT NULL DEFAULT 0, ADD CONSTRAINT chk_skill_rating_review_status CHECK (review_status IN ('VISIBLE', 'HIDDEN')); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 852325d6..c6d80a2a 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -192,3 +192,5 @@ error.skillReview.text.required=Review text is required error.skillReview.text.tooLong=Review text must not exceed {0} characters error.skillReview.reason.tooLong=Moderation reason must not exceed {0} characters error.pagination.invalid=Page must be non-negative and size must be between 1 and {0} +error.request.conflict=The data changed while this request was being processed. Refresh and try again. +error.skillReview.notInteractable=Reviews are available only for published skills diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 3a9eb00a..0302c2f5 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -182,3 +182,5 @@ error.skillReview.text.required=Текст отзыва обязателен error.skillReview.text.tooLong=Текст отзыва не должен превышать {0} символов error.skillReview.reason.tooLong=Причина модерации не должна превышать {0} символов error.pagination.invalid=Номер страницы не может быть отрицательным, а размер должен быть от 1 до {0} +error.request.conflict=Данные изменились во время обработки запроса. Обновите страницу и повторите попытку. +error.skillReview.notInteractable=Отзывы доступны только для опубликованных навыков diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index eb8d3a87..8b3e7a66 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -192,3 +192,5 @@ error.skillReview.text.required=评价内容不能为空 error.skillReview.text.tooLong=评价内容不能超过 {0} 个字符 error.skillReview.reason.tooLong=管理原因不能超过 {0} 个字符 error.pagination.invalid=页码不能为负数,每页数量必须在 1 到 {0} 之间 +error.request.conflict=数据在请求处理期间已发生变化,请刷新后重试 +error.skillReview.notInteractable=仅已发布的技能可以评价 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java index 1cfcee32..dd12fb51 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.security.SensitiveLogSanitizer; +import com.iflytek.skillhub.domain.social.SkillRating; import com.iflytek.skillhub.storage.StorageAccessException; import jakarta.servlet.http.HttpServletRequest; import java.time.Clock; @@ -29,11 +30,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.LoggerFactory; import org.springframework.context.support.StaticMessageSource; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.web.HttpMediaTypeNotAcceptableException; import org.springframework.web.HttpMediaTypeNotSupportedException; @@ -72,6 +75,8 @@ class GlobalExceptionHandlerTest { messageSource.addMessage("error.unsupportedMediaType", java.util.Locale.getDefault(), "Unsupported media type"); messageSource.addMessage("error.notAcceptable", java.util.Locale.getDefault(), "Requested response media type is not acceptable"); + messageSource.addMessage("error.request.conflict", java.util.Locale.getDefault(), + "Refresh and try again"); requestIdAccessor = new RequestIdAccessor(); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, @@ -144,6 +149,26 @@ class GlobalExceptionHandlerTest { .doesNotContain("userId=")); } + @Test + void persistenceConflictsReturn409WithoutLeakingDatabaseDetails() { + attachAppender(); + prepareClientErrorRequest("PUT", "/api/v1/skills/10/reviews/me"); + + for (RuntimeException exception : List.of( + new ObjectOptimisticLockingFailureException(SkillRating.class, 7L), + new DataIntegrityViolationException("duplicate key value violates constraint user-secret"))) { + ResponseEntity> response = handler.handlePersistenceConflict(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(409); + assertThat(response.getBody().msg()).isEqualTo("Refresh and try again"); + } + assertThat(loggedMessages()).allSatisfy(message -> assertThat(message) + .doesNotContain("user-secret") + .doesNotContain("duplicate key")); + } + @Test void handleStorageAccess_shouldLogAuthenticationWithoutStableUserId() { authenticateRequest(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillReviewAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillReviewAppServiceTest.java index 21a07e69..09cef6aa 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillReviewAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillReviewAppServiceTest.java @@ -11,6 +11,9 @@ import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.social.SkillRating; @@ -32,6 +35,7 @@ import org.springframework.data.domain.Pageable; class SkillReviewAppServiceTest { @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository skillVersionRepository; @Mock private SkillRatingService ratingService; @Mock private SkillReviewQueryRepository queryRepository; @Mock private AuditLogService auditLogService; @@ -43,6 +47,7 @@ class SkillReviewAppServiceTest { void setUp() { service = new SkillReviewAppService( skillRepository, + skillVersionRepository, new VisibilityChecker(), ratingService, queryRepository, @@ -87,6 +92,22 @@ class SkillReviewAppServiceTest { verify(ratingService, never()).upsertReview(any(), any(), any(Short.class), any()); } + @Test + void unpublishedSkillReviewMutationIsRejectedServerSide() { + Skill skill = publishedSkill(SkillVisibility.PUBLIC); + SkillVersion pending = new SkillVersion(10L, "1.0.0", "owner"); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(100L)).thenReturn(Optional.of(pending)); + + assertThatThrownBy(() -> service.upsert( + 10L, "owner", (short) 5, "not published", Map.of(), Set.of())) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class) + .hasMessage("error.skillReview.notInteractable"); + + verify(ratingService, never()).upsertReview(any(), any(), any(Short.class), any()); + } + @Test void hideWritesModerationAuditInSameWorkflow() { SkillRating review = new SkillRating(10L, "author", (short) 4); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java index 406cbb71..0448ea47 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java @@ -14,6 +14,10 @@ public class SkillRating { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Version + @Column(name = "lock_version", nullable = false) + private Long lockVersion; + @Column(name = "skill_id", nullable = false) private Long skillId; @@ -69,10 +73,6 @@ public class SkillRating { public void clearReview() { this.reviewText = null; - this.reviewStatus = SkillReviewStatus.VISIBLE; - this.moderatedBy = null; - this.moderatedAt = null; - this.moderationReason = null; this.updatedAt = Instant.now(Clock.systemUTC()); } @@ -143,6 +143,7 @@ public class SkillRating { // getters public Long getId() { return id; } + public Long getLockVersion() { return lockVersion; } public Long getSkillId() { return skillId; } public String getUserId() { return userId; } public Short getScore() { return score; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java index 40bae6a2..a608cfe6 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.domain.Pageable; */ public interface SkillRatingRepository { SkillRating save(SkillRating rating); + void flush(); Optional findById(Long id); Optional findBySkillIdAndUserId(Long skillId, String userId); Page findVisibleReviewsBySkillId(Long skillId, Pageable pageable); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java index 32d1f037..4cf954df 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java @@ -51,6 +51,7 @@ public class SkillRatingService { .orElseGet(() -> new SkillRating(skillId, userId, score)); rating.updateReview(score, reviewText); SkillRating saved = ratingRepository.save(rating); + ratingRepository.flush(); eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score)); return saved; } @@ -62,21 +63,27 @@ public class SkillRatingService { .filter(SkillRating::hasReview) .orElseThrow(() -> new DomainNotFoundException("error.skillReview.notFound")); rating.clearReview(); - return ratingRepository.save(rating); + SkillRating saved = ratingRepository.save(rating); + ratingRepository.flush(); + return saved; } @Transactional public SkillRating hideReview(Long reviewId, String moderatorId, String reason) { SkillRating rating = findReview(reviewId); rating.hideReview(moderatorId, reason); - return ratingRepository.save(rating); + SkillRating saved = ratingRepository.save(rating); + ratingRepository.flush(); + return saved; } @Transactional public SkillRating restoreReview(Long reviewId, String moderatorId) { SkillRating rating = findReview(reviewId); rating.restoreReview(moderatorId); - return ratingRepository.save(rating); + SkillRating saved = ratingRepository.save(rating); + ratingRepository.flush(); + return saved; } public Optional getUserRating(Long skillId, String userId) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java index 0e4c8ac5..07785ed4 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java @@ -133,6 +133,26 @@ class SkillRatingServiceTest { assertThat(result.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE); } + @Test + void clearAndResubmitReview_preservesHiddenModerationState() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); + SkillRating existing = new SkillRating(1L, "user-1", (short) 4); + existing.updateReview((short) 4, "Hidden review"); + existing.hideReview("moderator-1", "Policy violation"); + when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")) + .thenReturn(Optional.of(existing)); + when(ratingRepository.save(existing)).thenReturn(existing); + + service.clearReview(1L, "user-1"); + SkillRating resubmitted = service.upsertReview(1L, "user-1", (short) 5, "Rewritten review"); + + assertThat(resubmitted.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN); + assertThat(resubmitted.getModeratedBy()).isEqualTo("moderator-1"); + assertThat(resubmitted.getModerationReason()).isEqualTo("Policy violation"); + assertThat(resubmitted.getReviewText()).isEqualTo("Rewritten review"); + verify(ratingRepository, times(2)).flush(); + } + @Test void moderator_can_hide_and_restore_review() { SkillRating existing = new SkillRating(1L, "user-1", (short) 4); diff --git a/web/src/features/social/skill-reviews.test.ts b/web/src/features/social/skill-reviews.test.ts deleted file mode 100644 index 29944b59..00000000 --- a/web/src/features/social/skill-reviews.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import en from '@/i18n/locales/en.json' -import ru from '@/i18n/locales/ru.json' -import zh from '@/i18n/locales/zh.json' -import * as componentModule from './skill-reviews' -import * as hookModule from './use-skill-reviews' - -describe('skill reviews feature contract', () => { - it('exports the detail-page component and review hooks', () => { - expect(componentModule.SkillReviews).toBeTypeOf('function') - expect(hookModule.useSkillReviews).toBeTypeOf('function') - expect(hookModule.useMySkillReview).toBeTypeOf('function') - expect(hookModule.useUpsertSkillReview).toBeTypeOf('function') - expect(hookModule.useClearSkillReview).toBeTypeOf('function') - expect(hookModule.useModerateSkillReview).toBeTypeOf('function') - }) - - it('keeps the review interaction copy available in every supported locale', () => { - for (const locale of [en, zh, ru]) { - expect(locale.skillReviews.title).toBeTruthy() - expect(locale.skillReviews.write).toBeTruthy() - expect(locale.skillReviews.empty).toBeTruthy() - expect(locale.skillReviews.hide).toBeTruthy() - expect(locale.skillReviews.restore).toBeTruthy() - } - }) -}) diff --git a/web/src/features/social/skill-reviews.test.tsx b/web/src/features/social/skill-reviews.test.tsx new file mode 100644 index 00000000..cd3e3999 --- /dev/null +++ b/web/src/features/social/skill-reviews.test.tsx @@ -0,0 +1,144 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import en from '@/i18n/locales/en.json' +import ru from '@/i18n/locales/ru.json' +import zh from '@/i18n/locales/zh.json' + +const mocks = vi.hoisted(() => ({ + pages: new Map>; total: number; page: number; size: number }>(), + requestedPages: [] as number[], + saveMutate: vi.fn(), + clearMutate: vi.fn(), + moderateMutate: vi.fn(), +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + const translations: Record = { + 'skillReviews.title': 'User reviews', + 'skillReviews.count': '{{count}} reviews', + 'skillReviews.write': 'Write a review', + 'skillReviews.edit': 'Edit my review', + 'skillReviews.scoreLabel': 'Your score', + 'skillReviews.ratingDisplay': 'Rating: {{score}} out of 5 stars', + 'skillReviews.ratingOption': 'Rate {{score}} out of 5 stars', + 'skillReviews.reviewTextLabel': 'Review text', + 'skillReviews.placeholder': 'Share your experience', + 'skillReviews.save': 'Save review', + 'skillReviews.cancel': 'Cancel', + 'skillReviews.delete': 'Delete review', + 'skillReviews.empty': 'No reviews', + 'skillReviews.previous': 'Previous', + 'skillReviews.next': 'Next', + } + return { + ...actual, + useTranslation: () => ({ + i18n: { language: 'en' }, + t: (key: string, values?: Record) => Object.entries(values ?? {}).reduce( + (text, [name, value]) => text.replace(`{{${name}}}`, String(value)), + translations[key] ?? key, + ), + }), + } +}) + +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: () => ({ isAuthenticated: true, hasRole: () => false }), +})) + +vi.mock('@/shared/lib/date-time', () => ({ + formatLocalDateTime: (value: string) => value, +})) + +vi.mock('@/shared/lib/toast', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('./use-skill-reviews', () => ({ + useSkillReviews: (_skillId: number, page: number) => { + mocks.requestedPages.push(page) + return { data: mocks.pages.get(page), isLoading: false, isError: false } + }, + useMySkillReview: () => ({ + data: { + rated: true, + score: 4, + reviewed: true, + reviewId: 7, + reviewText: 'Useful review', + status: 'VISIBLE', + updatedAt: '2026-09-01T00:00:00Z', + }, + }), + useUpsertSkillReview: () => ({ mutate: mocks.saveMutate, isPending: false }), + useClearSkillReview: () => ({ mutate: mocks.clearMutate, isPending: false }), + useModerateSkillReview: () => ({ mutate: mocks.moderateMutate, isPending: false }), +})) + +import { SkillReviews } from './skill-reviews' + +describe('skill reviews', () => { + beforeEach(() => { + mocks.pages.clear() + mocks.pages.set(0, { items: [], total: 0, page: 0, size: 20 }) + mocks.requestedPages.length = 0 + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('exposes an accessible rating group and labelled review editor', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Edit my review' })) + + const rating = screen.getByRole('radiogroup', { name: 'Your score' }) + expect(rating).toBeTruthy() + expect(screen.getByRole('radio', { name: 'Rate 4 out of 5 stars' }).getAttribute('aria-checked')) + .toBe('true') + expect(screen.getByRole('textbox', { name: 'Review text' })).toHaveProperty('value', 'Useful review') + }) + + it('keeps a way back when a refetch empties the last page', () => { + mocks.pages.set(0, { items: [], total: 21, page: 0, size: 20 }) + mocks.pages.set(1, { + items: [{ + id: 8, + displayName: 'Alice', + score: 5, + reviewText: 'Great', + status: 'VISIBLE', + authoredByViewer: false, + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', + }], + total: 21, + page: 1, + size: 20, + }) + const view = render() + + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + expect(mocks.requestedPages).toContain(1) + + mocks.pages.set(1, { items: [], total: 20, page: 1, size: 20 }) + view.rerender() + fireEvent.click(screen.getByRole('button', { name: 'Previous' })) + expect(mocks.requestedPages.at(-1)).toBe(0) + }) + + it('keeps review interaction copy in every supported locale', () => { + for (const locale of [en, zh, ru]) { + expect(locale.skillReviews.ratingDisplay).toBeTruthy() + expect(locale.skillReviews.ratingOption).toBeTruthy() + expect(locale.skillReviews.reviewTextLabel).toBeTruthy() + expect(locale.skillReviews.hide).toBeTruthy() + expect(locale.skillReviews.restore).toBeTruthy() + } + }) +}) diff --git a/web/src/features/social/skill-reviews.tsx b/web/src/features/social/skill-reviews.tsx index ccfb40fe..e7a9bf57 100644 --- a/web/src/features/social/skill-reviews.tsx +++ b/web/src/features/social/skill-reviews.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useId, useState } from 'react' import { useTranslation } from 'react-i18next' import { Loader2, MessageSquare, ShieldAlert, Star } from 'lucide-react' import { useAuth } from '@/features/auth/use-auth' @@ -29,16 +29,37 @@ function ReviewStars({ value, onChange, disabled = false }: { onChange?: (value: number) => void disabled?: boolean }) { + const { t } = useTranslation() + + if (!onChange) { + return ( +
+ {[1, 2, 3, 4, 5].map((score) => ( +
+ ) + } + return ( -
+
{[1, 2, 3, 4, 5].map((score) => (
+