mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-16 04:28:39 +00:00
fix(review): preserve moderation under concurrent edits
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
78c8b56190
commit
5e380e50da
23 changed files with 386 additions and 49 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|||
|
||||
> **评分规则**:每个用户对每个技能包只能评分一次,可以修改评分但不能删除。
|
||||
|
||||
> **评价规则**:只有已发布技能可以评价;公开列表只显示可见评价。并发修改发生冲突时,刷新详情后重试。
|
||||
|
||||
- **星标数量**:技能包的星标数会显示在搜索结果和详情页
|
||||
- **平均评分**:技能包的平均评分会影响搜索排序
|
||||
- **通知设置**:用户可以在设置中关闭某些类型的通知
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ApiResponse<Void>> 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<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
|
||||
String msg = ex.getBindingResult().getFieldErrors().stream()
|
||||
|
|
|
|||
|
|
@ -39,20 +39,28 @@ public class JpaSkillReviewQueryRepository implements SkillReviewQueryRepository
|
|||
.toList();
|
||||
Map<String, UserAccount> 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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> 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<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> 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<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> 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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=Отзывы доступны только для опубликованных навыков
|
||||
|
|
|
|||
|
|
@ -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=仅已发布的技能可以评价
|
||||
|
|
|
|||
|
|
@ -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<ApiResponse<Void>> 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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import org.springframework.data.domain.Pageable;
|
|||
*/
|
||||
public interface SkillRatingRepository {
|
||||
SkillRating save(SkillRating rating);
|
||||
void flush();
|
||||
Optional<SkillRating> findById(Long id);
|
||||
Optional<SkillRating> findBySkillIdAndUserId(Long skillId, String userId);
|
||||
Page<SkillRating> findVisibleReviewsBySkillId(Long skillId, Pageable pageable);
|
||||
|
|
|
|||
|
|
@ -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<Short> getUserRating(Long skillId, String userId) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
144
web/src/features/social/skill-reviews.test.tsx
Normal file
144
web/src/features/social/skill-reviews.test.tsx
Normal file
|
|
@ -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<number, { items: Array<Record<string, unknown>>; 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<typeof import('react-i18next')>('react-i18next')
|
||||
const translations: Record<string, string> = {
|
||||
'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<string, unknown>) => 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(<SkillReviews skillId={10} canInteract onRequireLogin={vi.fn()} />)
|
||||
|
||||
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(<SkillReviews skillId={10} canInteract onRequireLogin={vi.fn()} />)
|
||||
|
||||
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(<SkillReviews skillId={10} canInteract onRequireLogin={vi.fn()} />)
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-1" aria-label={t('skillReviews.ratingDisplay', { score: value })}>
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<Star
|
||||
key={score}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
score <= value ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground/40',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1" aria-label={`${value}/5`}>
|
||||
<div className="flex items-center gap-1" role="radiogroup" aria-label={t('skillReviews.scoreLabel')}>
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<button
|
||||
key={score}
|
||||
type="button"
|
||||
className={cn('rounded p-0.5', onChange && 'hover:scale-110 transition-transform')}
|
||||
onClick={() => onChange?.(score)}
|
||||
disabled={disabled || !onChange}
|
||||
aria-label={`${score}`}
|
||||
role="radio"
|
||||
aria-checked={score === value}
|
||||
onClick={() => onChange(score)}
|
||||
disabled={disabled}
|
||||
aria-label={t('skillReviews.ratingOption', { score })}
|
||||
>
|
||||
<Star className={cn(
|
||||
'h-4 w-4',
|
||||
|
|
@ -56,6 +77,7 @@ function ReviewEditor({ skillId, review, onDone }: {
|
|||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const reviewTextId = useId()
|
||||
const [score, setScore] = useState(review?.rated ? review.score : 5)
|
||||
const [reviewText, setReviewText] = useState(review?.reviewText ?? '')
|
||||
const save = useUpsertSkillReview(skillId)
|
||||
|
|
@ -93,7 +115,9 @@ function ReviewEditor({ skillId, review, onDone }: {
|
|||
<span className="text-sm font-medium">{t('skillReviews.scoreLabel')}</span>
|
||||
<ReviewStars value={score} onChange={setScore} disabled={pending} />
|
||||
</div>
|
||||
<label htmlFor={reviewTextId} className="sr-only">{t('skillReviews.reviewTextLabel')}</label>
|
||||
<Textarea
|
||||
id={reviewTextId}
|
||||
value={reviewText}
|
||||
onChange={(event) => setReviewText(event.target.value)}
|
||||
maxLength={2000}
|
||||
|
|
@ -193,6 +217,11 @@ export function SkillReviews({ skillId, canInteract, onRequireLogin }: SkillRevi
|
|||
const canModerate = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const totalPages = reviews.data ? Math.ceil(reviews.data.total / reviews.data.size) : 0
|
||||
|
||||
const finishEditing = () => {
|
||||
setEditing(false)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
if (!isAuthenticated) {
|
||||
onRequireLogin()
|
||||
|
|
@ -232,7 +261,7 @@ export function SkillReviews({ skillId, canInteract, onRequireLogin }: SkillRevi
|
|||
key={`${mine.data?.reviewId ?? 'new'}-${mine.data?.updatedAt ?? ''}`}
|
||||
skillId={skillId}
|
||||
review={mine.data}
|
||||
onDone={() => setEditing(false)}
|
||||
onDone={finishEditing}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -257,12 +286,12 @@ export function SkillReviews({ skillId, canInteract, onRequireLogin }: SkillRevi
|
|||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 ? (
|
||||
{page > 0 || totalPages > 1 ? (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border/50 pt-4">
|
||||
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((value) => value - 1)}>
|
||||
{t('skillReviews.previous')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{page + 1}/{totalPages}</span>
|
||||
<span className="text-xs text-muted-foreground">{page + 1}/{Math.max(totalPages, 1)}</span>
|
||||
<Button variant="outline" size="sm" disabled={page + 1 >= totalPages} onClick={() => setPage((value) => value + 1)}>
|
||||
{t('skillReviews.next')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1305,6 +1305,9 @@
|
|||
"write": "Write a review",
|
||||
"edit": "Edit my review",
|
||||
"scoreLabel": "Your score",
|
||||
"ratingDisplay": "Rating: {{score}} out of 5 stars",
|
||||
"ratingOption": "Rate {{score}} out of 5 stars",
|
||||
"reviewTextLabel": "Review text",
|
||||
"placeholder": "Share what worked well and what others should know.",
|
||||
"save": "Save review",
|
||||
"cancel": "Cancel",
|
||||
|
|
|
|||
|
|
@ -1517,6 +1517,9 @@
|
|||
"write": "Написать отзыв",
|
||||
"edit": "Изменить мой отзыв",
|
||||
"scoreLabel": "Ваша оценка",
|
||||
"ratingDisplay": "Оценка: {{score}} из 5 звёзд",
|
||||
"ratingOption": "Поставить {{score}} из 5 звёзд",
|
||||
"reviewTextLabel": "Текст отзыва",
|
||||
"placeholder": "Расскажите об опыте использования и важных деталях.",
|
||||
"save": "Сохранить отзыв",
|
||||
"cancel": "Отмена",
|
||||
|
|
|
|||
|
|
@ -1305,6 +1305,9 @@
|
|||
"write": "写评价",
|
||||
"edit": "编辑我的评价",
|
||||
"scoreLabel": "你的评分",
|
||||
"ratingDisplay": "评分:5 星中 {{score}} 星",
|
||||
"ratingOption": "评为 5 星中 {{score}} 星",
|
||||
"reviewTextLabel": "评价内容",
|
||||
"placeholder": "说说使用体验,以及其他用户需要了解的信息。",
|
||||
"save": "保存评价",
|
||||
"cancel": "取消",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue