feat(review): add skill comments and user feedback (#793)

* feat(review): add skill review domain model

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* feat(review): expose skill reviews in API and UI

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): preserve moderation under concurrent edits

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): scope concurrent write conflicts

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): restore web build compatibility

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): keep author cleanup available

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): preserve author cleanup access

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): require review score contract

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(review): strengthen failure and concurrency coverage

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(review): tighten persistence assertions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(review): disambiguate repository ports

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): enable request validation

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(review): align validation and postgres coverage

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(namespace): verify invalid batch has no side effects

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(web): align accessibility and plural assertions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(i18n): require complete plural references

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): wrap editor actions on mobile

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): wrap long mobile labels

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): disable edits for archived skills

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): enforce archived mutation guard

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-02 11:15:52 +08:00 committed by GitHub
parent 15dad68740
commit 45d341f144
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3102 additions and 35 deletions

View file

@ -230,10 +230,18 @@
| skill_id | bigint | |
| user_id | varchar(128) | |
| score | tinyint | 1-5 |
| review_text | varchar(2000) | 可选文字评价;空值表示仅评分 |
| review_status | enum | `VISIBLE` / `HIDDEN`,隐藏不影响评分聚合 |
| 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)`,每人每技能一条,可修改
唯一约束:`(skill_id, user_id)`,每人每技能一条,可修改。删除文字评价只清空
`review_text`,保留评分和既有治理状态;管理员隐藏评价时也保留评分,避免作者通过
清空后重新提交绕过治理,或治理动作改变聚合分数。
### user_account

View file

@ -252,6 +252,10 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确
→ 异步重算 skill.rating_avg 和 rating_countSELECT AVG + Redis 分布式锁防重复重算)
```
文字评价复用同一条 `skill_rating` 记录:用户提交 `score + review_text` 时同步更新评分并触发
`SkillRatedEvent`;删除评价只清空文字,保留星级评分。公开列表仅返回 `VISIBLE` 评价;
`SKILL_ADMIN` / `SUPER_ADMIN` 可隐藏或恢复评价,管理动作写入审计日志且不改变评分聚合。
## 7 异步事件汇总
| 事件 | 触发时机 | 消费方 |

View file

@ -82,6 +82,7 @@
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download` | 按标签下载(解析标签指向的版本后下载) |
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/files` | 按标签查看文件清单 |
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/file?path=...` | 按标签读取单个文件 |
| GET | `/api/v1/skills/{skillId}/reviews` | 公开评价分页列表;仅返回可见评价,管理员可见隐藏项 |
| GET | `/api/v1/namespaces` | 公开命名空间列表 |
| GET | `/api/v1/namespaces/{slug}` | 命名空间详情 |
@ -201,6 +202,9 @@ Public API 的可见性规则:
| POST | `/api/v1/skills/{namespace}/{slug}/star` | 收藏 |
| DELETE | `/api/v1/skills/{namespace}/{slug}/star` | 取消收藏 |
| POST | `/api/v1/skills/{namespace}/{slug}/rating` | 评分 |
| GET | `/api/v1/skills/{skillId}/reviews/me` | 当前用户的评分与文字评价 |
| PUT | `/api/v1/skills/{skillId}/reviews/me` | 新增或更新当前用户评价(`score` 1-5`reviewText` 最长 2000 |
| DELETE | `/api/v1/skills/{skillId}/reviews/me` | 删除文字评价并保留星级评分 |
| GET | `/api/v1/me/stars` | 我的收藏列表 |
| GET | `/api/v1/me/skills` | 我发布的技能列表 |
@ -312,6 +316,8 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN
| POST | `/api/v1/admin/skills/{id}/hide` | 隐藏技能(仅 `SUPER_ADMIN` |
| POST | `/api/v1/admin/skills/{id}/unhide` | 恢复技能(仅 `SUPER_ADMIN` |
| POST | `/api/v1/admin/skills/versions/{versionId}/yank` | 撤回已发布版本(`SKILL_ADMIN` / `SUPER_ADMIN` |
| POST | `/api/v1/admin/skill-reviews/{reviewId}/hide` | 隐藏用户评价(`SKILL_ADMIN` / `SUPER_ADMIN` |
| POST | `/api/v1/admin/skill-reviews/{reviewId}/restore` | 恢复用户评价(`SKILL_ADMIN` / `SUPER_ADMIN` |
### 用户治理(需 USER_ADMIN / SUPER_ADMIN

View file

@ -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 and remains available if the skill is later unpublished
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

View file

@ -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
> **评分规则**:每个用户对每个技能包只能评分一次,可以修改评分但不能删除。
> **评价规则**:只有已发布技能可以评价;公开列表只显示可见评价。并发修改发生冲突时,刷新详情后重试。
- **星标数量**:技能包的星标数会显示在搜索结果和详情页
- **平均评分**:技能包的平均评分会影响搜索排序
- **通知设置**:用户可以在设置中关闭某些类型的通知

View file

@ -22,6 +22,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>

View file

@ -0,0 +1,59 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillReviewModerationRequest;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SkillReviewAppService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/admin/skill-reviews")
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
public class AdminSkillReviewController extends BaseApiController {
private final SkillReviewAppService reviewAppService;
public AdminSkillReviewController(ApiResponseFactory responseFactory,
SkillReviewAppService reviewAppService) {
super(responseFactory);
this.reviewAppService = reviewAppService;
}
@PostMapping("/{reviewId}/hide")
public ApiResponse<SkillReviewResponse> hide(
@PathVariable Long reviewId,
@Valid @RequestBody(required = false) SkillReviewModerationRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated", reviewAppService.hide(
reviewId,
principal.userId(),
request != null ? request.reason() : null,
AuditRequestContext.from(httpRequest)
));
}
@PostMapping("/{reviewId}/restore")
public ApiResponse<SkillReviewResponse> restore(
@PathVariable Long reviewId,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated", reviewAppService.restore(
reviewId,
principal.userId(),
AuditRequestContext.from(httpRequest)
));
}
}

View file

@ -0,0 +1,95 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillReviewMeResponse;
import com.iflytek.skillhub.dto.SkillReviewRequest;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import com.iflytek.skillhub.service.SkillReviewAppService;
import jakarta.validation.Valid;
import java.util.Map;
import java.util.Set;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillReviewController extends BaseApiController {
private final SkillReviewAppService reviewAppService;
public SkillReviewController(ApiResponseFactory responseFactory,
SkillReviewAppService reviewAppService) {
super(responseFactory);
this.reviewAppService = reviewAppService;
}
@GetMapping("/{skillId}/reviews")
public ApiResponse<PageResponse<SkillReviewResponse>> list(
@PathVariable Long skillId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
return ok("response.success.read", reviewAppService.list(
skillId,
principal != null ? principal.userId() : null,
namespaceRoles,
roles(principal),
page,
size
));
}
@GetMapping("/{skillId}/reviews/me")
public ApiResponse<SkillReviewMeResponse> getMine(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
return ok("response.success.read", reviewAppService.getMine(
skillId, principal.userId(), namespaceRoles, roles(principal)));
}
@PutMapping("/{skillId}/reviews/me")
public ApiResponse<SkillReviewMeResponse> upsert(
@PathVariable Long skillId,
@Valid @RequestBody SkillReviewRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
return ok("response.success.updated", reviewAppService.upsert(
skillId,
principal.userId(),
request.score(),
request.reviewText(),
namespaceRoles,
roles(principal)
));
}
@DeleteMapping("/{skillId}/reviews/me")
public ApiResponse<SkillReviewMeResponse> clear(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
return ok("response.success.updated", reviewAppService.clear(
skillId, principal.userId(), namespaceRoles, roles(principal)));
}
private Set<String> roles(PlatformPrincipal principal) {
return principal != null && principal.platformRoles() != null
? principal.platformRoles()
: Set.of();
}
}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record SkillReviewMeResponse(
boolean rated,
short score,
boolean reviewed,
Long reviewId,
String reviewText,
String status,
String moderationReason,
Instant createdAt,
Instant updatedAt
) {
public static SkillReviewMeResponse empty() {
return new SkillReviewMeResponse(false, (short) 0, false, null, null, null, null, null, null);
}
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Size;
public record SkillReviewModerationRequest(
@Size(max = 500) String reason
) {}

View file

@ -0,0 +1,12 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record SkillReviewRequest(
@NotNull @Min(1) @Max(5) Short score,
@NotBlank @Size(max = 2000) String reviewText
) {}

View file

@ -0,0 +1,19 @@
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,
String displayName,
String avatarUrl,
short score,
String reviewText,
String status,
boolean authoredByViewer,
String moderationReason,
Instant createdAt,
Instant updatedAt
) {}

View file

@ -17,6 +17,7 @@ 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 +69,15 @@ public class GlobalExceptionHandler {
return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request);
}
@ExceptionHandler(ObjectOptimisticLockingFailureException.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()

View file

@ -0,0 +1,68 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillRatingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
@Repository
public class JpaSkillReviewQueryRepository implements SkillReviewQueryRepository {
private final SkillRatingRepository ratingRepository;
private final UserAccountRepository userAccountRepository;
public JpaSkillReviewQueryRepository(SkillRatingRepository ratingRepository,
UserAccountRepository userAccountRepository) {
this.ratingRepository = ratingRepository;
this.userAccountRepository = userAccountRepository;
}
@Override
public Page<SkillReviewResponse> list(Long skillId,
String viewerId,
boolean includeHidden,
Pageable pageable) {
Page<SkillRating> reviews = includeHidden
? ratingRepository.findReviewsBySkillId(skillId, pageable)
: ratingRepository.findVisibleReviewsBySkillId(skillId, pageable);
List<String> authorIds = reviews.getContent().stream()
.map(SkillRating::getUserId)
.distinct()
.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,
includeHidden
));
}
private SkillReviewResponse toResponse(SkillRating review,
UserAccount author,
String viewerId,
boolean includeModerationDetails) {
return new SkillReviewResponse(
review.getId(),
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),
includeModerationDetails ? review.getModerationReason() : null,
review.getCreatedAt(),
review.getUpdatedAt()
);
}
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface SkillReviewQueryRepository {
Page<SkillReviewResponse> list(Long skillId, String viewerId, boolean includeHidden, Pageable pageable);
}

View file

@ -0,0 +1,206 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
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.SkillStatus;
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;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillReviewMeResponse;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.SkillReviewQueryRepository;
import java.util.Map;
import java.util.Set;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
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;
private final AuditLogService auditLogService;
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;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
public PageResponse<SkillReviewResponse> list(Long skillId,
String viewerId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
int page,
int size) {
requireVisibleSkill(skillId, viewerId, namespaceRoles, platformRoles);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new DomainBadRequestException("error.pagination.invalid", MAX_PAGE_SIZE);
}
boolean includeHidden = isReviewModerator(platformRoles);
return PageResponse.from(queryRepository.list(
skillId,
viewerId,
includeHidden,
PageRequest.of(page, size)
));
}
public SkillReviewMeResponse getMine(Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
return ratingService.getUserFeedback(skillId, userId)
.map(this::toMine)
.orElseGet(SkillReviewMeResponse::empty);
}
public SkillReviewMeResponse upsert(Long skillId,
String userId,
short score,
String reviewText,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
requireInteractableSkill(skillId, userId, namespaceRoles, platformRoles);
return toMine(ratingService.upsertReview(skillId, userId, score, reviewText));
}
public SkillReviewMeResponse clear(Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
return toMine(ratingService.clearReview(skillId, userId));
}
@Transactional
public SkillReviewResponse hide(Long reviewId,
String moderatorId,
String reason,
AuditRequestContext auditContext) {
SkillRating review = ratingService.hideReview(reviewId, moderatorId, reason);
recordModerationAudit("SKILL_REVIEW_HIDE", review, moderatorId, reason, auditContext);
return toModerationResponse(review);
}
@Transactional
public SkillReviewResponse restore(Long reviewId,
String moderatorId,
AuditRequestContext auditContext) {
SkillRating review = ratingService.restoreReview(reviewId, moderatorId);
recordModerationAudit("SKILL_REVIEW_RESTORE", review, moderatorId, null, auditContext);
return toModerationResponse(review);
}
private Skill requireVisibleSkill(Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillId));
if (!visibilityChecker.canAccess(
skill,
userId,
namespaceRoles != null ? namespaceRoles : Map.of(),
platformRoles != null ? platformRoles : Set.of())) {
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
}
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 (skill.getStatus() != SkillStatus.ACTIVE || !published) {
throw new DomainBadRequestException("error.skillReview.notInteractable");
}
return skill;
}
private SkillReviewMeResponse toMine(SkillRating review) {
return new SkillReviewMeResponse(
true,
review.getScore(),
review.hasReview(),
review.hasReview() ? review.getId() : null,
review.getReviewText(),
review.hasReview() ? review.getReviewStatus().name() : null,
review.getModerationReason(),
review.getCreatedAt(),
review.getUpdatedAt()
);
}
private SkillReviewResponse toModerationResponse(SkillRating review) {
return new SkillReviewResponse(
review.getId(),
review.getUserId(),
review.getUserId(),
null,
review.getScore(),
review.getReviewText(),
review.getReviewStatus().name(),
false,
review.getModerationReason(),
review.getCreatedAt(),
review.getUpdatedAt()
);
}
private boolean isReviewModerator(Set<String> platformRoles) {
return platformRoles != null
&& (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"));
}
private void recordModerationAudit(String action,
SkillRating review,
String moderatorId,
String reason,
AuditRequestContext context) {
auditLogService.record(
moderatorId,
action,
"SKILL_REVIEW",
review.getId(),
requestIdAccessor.current(),
context != null ? context.clientIp() : null,
context != null ? context.userAgent() : null,
AuditDetail.builder()
.put("skillId", review.getSkillId())
.put("reason", reason)
.build()
);
}
}

View file

@ -0,0 +1,15 @@
ALTER TABLE skill_rating
ADD COLUMN review_text VARCHAR(2000),
ADD COLUMN review_status VARCHAR(16) NOT NULL DEFAULT 'VISIBLE',
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'));
CREATE INDEX idx_skill_rating_visible_reviews
ON skill_rating(skill_id, updated_at DESC, id DESC)
WHERE review_status = 'VISIBLE'
AND review_text IS NOT NULL
AND BTRIM(review_text) <> '';

View file

@ -187,3 +187,10 @@ promotion.sort.field.invalid=Unsupported promotion sort field: {0}
promotion.sort.direction.invalid=Unsupported promotion sort direction: {0}
promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting
error.skill.subscription.noPermission=You do not have permission to subscribe to this skill.
error.skillReview.notFound=Skill review not found
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

View file

@ -177,3 +177,10 @@ promotion.status.invalid=Неподдерживаемый статус прод
promotion.sort.field.invalid=Неподдерживаемое поле сортировки продвижения: {0}
promotion.sort.direction.invalid=Неподдерживаемое направление сортировки продвижения: {0}
promotion.sort.pending_unsupported=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью
error.skillReview.notFound=Отзыв о скилле не найден
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=Отзывы доступны только для опубликованных навыков

View file

@ -187,3 +187,10 @@ promotion.sort.field.invalid=不支持的提升审核排序字段:{0}
promotion.sort.direction.invalid=不支持的提升审核排序方向:{0}
promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序
error.skill.subscription.noPermission=您没有订阅此技能的权限。
error.skillReview.notFound=未找到技能评价
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=仅已发布的技能可以评价

View file

@ -1,7 +1,6 @@
package com.iflytek.skillhub.controller;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
@ -116,9 +115,6 @@ class LocalAuthControllerTest {
@Test
void register_rejectsInvalidEmailFormat() throws Exception {
given(localAuthService.register("bob", "Abcd123!", "not-an-email"))
.willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"));
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.header("Accept-Language", "zh-CN")
@ -129,14 +125,11 @@ class LocalAuthControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(localAuthService).register("bob", "Abcd123!", "not-an-email");
verify(localAuthService, never()).register("bob", "Abcd123!", "not-an-email");
}
@Test
void register_rejectsBlankEmail() throws Exception {
given(localAuthService.register("bob", "Abcd123!", " "))
.willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank"));
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@ -146,7 +139,7 @@ class LocalAuthControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(localAuthService).register("bob", "Abcd123!", " ");
verify(localAuthService, never()).register("bob", "Abcd123!", " ");
}
@Test
@ -277,9 +270,6 @@ class LocalAuthControllerTest {
@Test
void requestPasswordReset_rejectsInvalidEmailFormat() throws Exception {
willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"))
.given(passwordResetService).requestPasswordReset("alice");
mockMvc.perform(post("/api/v1/auth/local/password-reset/request")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@ -289,7 +279,7 @@ class LocalAuthControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(passwordResetService).requestPasswordReset("alice");
verify(passwordResetService, never()).requestPasswordReset("alice");
}
@Test
@ -308,9 +298,6 @@ class LocalAuthControllerTest {
@Test
void confirmPasswordReset_rejectsInvalidEmailFormat() throws Exception {
willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"))
.given(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!");
mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@ -320,7 +307,7 @@ class LocalAuthControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!");
verify(passwordResetService, never()).confirmPasswordReset("alice", "123456", "Abcd123!");
}
}

View file

@ -16,11 +16,13 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
import com.iflytek.skillhub.service.NamespacePortalCommandAppService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -35,6 +37,8 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -67,6 +71,9 @@ class NamespaceBatchMemberControllerTest {
@MockBean
private NamespaceMemberCandidateService namespaceMemberCandidateService;
@SpyBean
private NamespacePortalCommandAppService namespacePortalCommandAppService;
@MockBean
private DeviceAuthService deviceAuthService;
@ -187,9 +194,7 @@ class NamespaceBatchMemberControllerTest {
@Test
void batchAddMembers_emptyArray_returnsError() throws Exception {
// @NotEmpty on BatchMemberRequest.members triggers validation error
// Spring Boot 3.2+ raises HandlerMethodValidationException (500) rather than
// MethodArgumentNotValidException (400) for record-based @RequestBody validation
// @NotEmpty on BatchMemberRequest.members is enforced before the controller runs.
mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch")
.with(csrf())
.with(auth("owner-1"))
@ -198,7 +203,10 @@ class NamespaceBatchMemberControllerTest {
.content("""
{"members":[]}
"""))
.andExpect(status().isInternalServerError());
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(namespacePortalCommandAppService, never()).batchAddMembers(any(), any(), any());
}
@Test

View file

@ -0,0 +1,188 @@
package com.iflytek.skillhub.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillReviewMeResponse;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import com.iflytek.skillhub.service.SkillReviewAppService;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SkillReviewControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private SkillReviewAppService reviewAppService;
@MockBean private NamespaceMemberRepository namespaceMemberRepository;
@Test
void publicReviewListIsAnonymous() throws Exception {
when(reviewAppService.list(eq(10L), eq(null), any(), eq(Set.of()), eq(0), eq(20)))
.thenReturn(new PageResponse<>(List.of(), 0, 0, 20));
mockMvc.perform(get("/api/v1/skills/10/reviews"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(0));
}
@Test
void publicReviewListOmitsInternalUserAndModerationFields() throws Exception {
SkillReviewResponse review = new SkillReviewResponse(
8L, null, "Alice", null, (short) 5, "Useful", "VISIBLE", false,
null, null, null);
when(reviewAppService.list(eq(10L), eq(null), any(), eq(Set.of()), eq(0), eq(20)))
.thenReturn(new PageResponse<>(List.of(review), 1, 0, 20));
mockMvc.perform(get("/api/v1/skills/10/reviews"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].displayName").value("Alice"))
.andExpect(jsonPath("$.data.items[0].userId").doesNotExist())
.andExpect(jsonPath("$.data.items[0].moderationReason").doesNotExist());
}
@Test
void currentUserReviewRequiresAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/skills/10/reviews/me"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void anonymousUserCannotUpsertOrClearReview() throws Exception {
mockMvc.perform(put("/api/v1/skills/10/reviews/me")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"score\":4,\"reviewText\":\"Useful\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
mockMvc.perform(delete("/api/v1/skills/10/reviews/me")
.with(csrf()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void authenticatedUserCanUpsertReview() throws Exception {
var principal = principal("user-42", Set.of());
when(reviewAppService.upsert(eq(10L), eq("user-42"), eq((short) 4), eq("Useful"), any(), eq(Set.of())))
.thenReturn(new SkillReviewMeResponse(
true, (short) 4, true, 8L, "Useful", "VISIBLE", null, null, null));
mockMvc.perform(put("/api/v1/skills/10/reviews/me")
.with(authentication(authToken(principal, "ROLE_USER")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"score\":4,\"reviewText\":\"Useful\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.reviewed").value(true))
.andExpect(jsonPath("$.data.reviewText").value("Useful"));
verify(reviewAppService).upsert(eq(10L), eq("user-42"), eq((short) 4), eq("Useful"), any(), eq(Set.of()));
}
@Test
void reviewScoreIsRequiredByTheApiContract() throws Exception {
var principal = principal("user-42", Set.of());
mockMvc.perform(put("/api/v1/skills/10/reviews/me")
.with(authentication(authToken(principal, "ROLE_USER")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reviewText\":\"Useful\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void reviewTextIsRequiredByTheApiContract() throws Exception {
var principal = principal("user-42", Set.of());
mockMvc.perform(put("/api/v1/skills/10/reviews/me")
.with(authentication(authToken(principal, "ROLE_USER")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"score\":4}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void skillAdminCanHideReview() throws Exception {
var principal = principal("admin", Set.of("SKILL_ADMIN"));
mockMvc.perform(post("/api/v1/admin/skill-reviews/8/hide")
.with(authentication(authToken(principal, "ROLE_SKILL_ADMIN")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"spam\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(reviewAppService).hide(eq(8L), eq("admin"), eq("spam"), any());
}
@Test
void ordinaryUserCannotHideReview() throws Exception {
var principal = principal("user-42", Set.of());
mockMvc.perform(post("/api/v1/admin/skill-reviews/8/hide")
.with(authentication(authToken(principal, "ROLE_USER")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
void moderationReasonRejectsMoreThanFiveHundredCharacters() throws Exception {
var principal = principal("admin", Set.of("SKILL_ADMIN"));
mockMvc.perform(post("/api/v1/admin/skill-reviews/8/hide")
.with(authentication(authToken(principal, "ROLE_SKILL_ADMIN")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"" + "x".repeat(501) + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
private PlatformPrincipal principal(String userId, Set<String> roles) {
return new PlatformPrincipal(userId, userId, userId + "@example.test", null, "local", roles);
}
private UsernamePasswordAuthenticationToken authToken(PlatformPrincipal principal, String role) {
return new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority(role))
);
}
}

View file

@ -0,0 +1,165 @@
package com.iflytek.skillhub.controller.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.SkillhubApplication;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLog;
import com.iflytek.skillhub.domain.audit.AuditLogRepository;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillRatingRepository;
import com.iflytek.skillhub.domain.social.SkillReviewStatus;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.infra.jpa.AuditLogJpaRepository;
import com.iflytek.skillhub.infra.jpa.JpaSkillRatingRepository;
import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository;
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository;
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = SkillhubApplication.class)
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class SkillReviewModerationFlowIntegrationTest {
private static final String ADMIN_ID = "review-admin";
private static final String AUTHOR_ID = "review-author";
@Autowired private MockMvc mockMvc;
@Autowired private UserAccountJpaRepository userAccountRepository;
@Autowired private NamespaceJpaRepository namespaceRepository;
@Autowired private SkillJpaRepository skillRepository;
@Autowired private SkillRatingRepository ratingRepository;
@Autowired private JpaSkillRatingRepository ratingJpaRepository;
@Autowired private AuditLogRepository auditLogRepository;
@SpyBean private AuditLogJpaRepository auditLogJpaRepository;
@MockBean private DeviceAuthService deviceAuthService;
@MockBean private RbacService rbacService;
@MockBean private GovernanceNotificationService governanceNotificationService;
@MockBean private NotificationDispatcher notificationDispatcher;
@Test
void hideAndRestorePersistModerationStateAndAuditRows() throws Exception {
SkillRating review = createReview();
mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/hide")
.contentType("application/json")
.content("{\"reason\":\"off topic\"}")
.with(authentication(adminAuth()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("HIDDEN"));
SkillRating hidden = ratingRepository.findById(review.getId()).orElseThrow();
assertThat(hidden.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN);
assertThat(hidden.getModeratedBy()).isEqualTo(ADMIN_ID);
assertThat(hidden.getModerationReason()).isEqualTo("off topic");
mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/restore")
.with(authentication(adminAuth()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("VISIBLE"));
SkillRating saved = ratingRepository.findById(review.getId()).orElseThrow();
assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE);
assertThat(saved.getModeratedBy()).isEqualTo(ADMIN_ID);
List<String> actions = auditLogJpaRepository.findAll().stream()
.filter(log -> ADMIN_ID.equals(log.getActorUserId()))
.filter(log -> review.getId().equals(log.getTargetId()))
.filter(log -> "SKILL_REVIEW".equals(log.getTargetType()))
.map(AuditLog::getAction)
.toList();
assertThat(actions).contains("SKILL_REVIEW_HIDE", "SKILL_REVIEW_RESTORE");
}
@Test
void hideRollsBackModerationWhenAuditPersistenceFails() throws Exception {
SkillRating review = createReview();
doThrow(new DataIntegrityViolationException("forced audit failure"))
.when(auditLogRepository).save(any(AuditLog.class));
mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/hide")
.contentType("application/json")
.content("{\"reason\":\"off topic\"}")
.with(authentication(adminAuth()))
.with(csrf()))
.andExpect(status().isInternalServerError());
SkillRating saved = ratingRepository.findById(review.getId()).orElseThrow();
assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE);
assertThat(saved.getModeratedBy()).isNull();
assertThat(saved.getModerationReason()).isNull();
}
private SkillRating createReview() {
String suffix = UUID.randomUUID().toString().substring(0, 8);
saveUserIfAbsent(ADMIN_ID, "Review Admin", "review-admin@example.test");
saveUserIfAbsent(AUTHOR_ID, "Review Author", "review-author@example.test");
Namespace namespace = namespaceRepository.saveAndFlush(
new Namespace("review-team-" + suffix, "Review Team " + suffix, AUTHOR_ID));
Skill skill = new Skill(namespace.getId(), "review-skill-" + suffix, AUTHOR_ID, SkillVisibility.PUBLIC);
skill.setCreatedBy(AUTHOR_ID);
skill.setUpdatedBy(AUTHOR_ID);
skill = skillRepository.saveAndFlush(skill);
SkillRating review = new SkillRating(skill.getId(), AUTHOR_ID, (short) 4);
review.updateReview((short) 4, "Useful review");
return ratingJpaRepository.saveAndFlush(review);
}
private void saveUserIfAbsent(String userId, String displayName, String email) {
if (!userAccountRepository.existsById(userId)) {
userAccountRepository.saveAndFlush(new UserAccount(userId, displayName, email, null));
}
}
private UsernamePasswordAuthenticationToken adminAuth() {
PlatformPrincipal principal = new PlatformPrincipal(
ADMIN_ID,
"Review Admin",
"review-admin@example.test",
"",
"session",
Set.of("SKILL_ADMIN")
);
return new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))
);
}
}

View file

@ -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;
@ -34,6 +35,7 @@ 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 +74,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 +148,22 @@ class GlobalExceptionHandlerTest {
.doesNotContain("userId="));
}
@Test
void persistenceConflictsReturn409WithoutLeakingDatabaseDetails() {
attachAppender();
prepareClientErrorRequest("PUT", "/api/v1/skills/10/reviews/me");
ResponseEntity<ApiResponse<Void>> response = handler.handlePersistenceConflict(
new ObjectOptimisticLockingFailureException(SkillRating.class, 7L), 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"));
}
@Test
void handleStorageAccess_shouldLogAuthenticationWithoutStableUserId() {
authenticateRequest();

View file

@ -0,0 +1,69 @@
package com.iflytek.skillhub.repository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillRatingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
@ExtendWith(MockitoExtension.class)
class JpaSkillReviewQueryRepositoryTest {
@Mock private SkillRatingRepository ratingRepository;
@Mock private UserAccountRepository userAccountRepository;
@Test
void assemblesAuthorProfileWithoutPerRowUserQueries() {
SkillRating rating = new SkillRating(10L, "author-1", (short) 5);
rating.updateReview((short) 5, "excellent");
UserAccount author = new UserAccount("author-1", "Alice", null, "avatar.png");
PageRequest page = PageRequest.of(0, 20);
when(ratingRepository.findVisibleReviewsBySkillId(10L, page))
.thenReturn(new PageImpl<>(List.of(rating), page, 1));
when(userAccountRepository.findByIdIn(List.of("author-1"))).thenReturn(List.of(author));
var result = new JpaSkillReviewQueryRepository(ratingRepository, userAccountRepository)
.list(10L, "author-1", false, page);
assertThat(result.getContent()).singleElement().satisfies(review -> {
assertThat(review.displayName()).isEqualTo("Alice");
assertThat(review.avatarUrl()).isEqualTo("avatar.png");
assertThat(review.reviewText()).isEqualTo("excellent");
assertThat(review.authoredByViewer()).isTrue();
assertThat(review.status()).isEqualTo("VISIBLE");
assertThat(review.userId()).isNull();
assertThat(review.moderationReason()).isNull();
});
verify(userAccountRepository).findByIdIn(List.of("author-1"));
}
@Test
void administratorListingIncludesModerationDetails() {
SkillRating rating = new SkillRating(10L, "author-1", (short) 2);
rating.updateReview((short) 2, "off topic");
rating.hideReview("admin", "Policy violation");
PageRequest page = PageRequest.of(0, 20);
when(ratingRepository.findReviewsBySkillId(10L, page))
.thenReturn(new PageImpl<>(List.of(rating), page, 1));
when(userAccountRepository.findByIdIn(List.of("author-1"))).thenReturn(List.of());
var result = new JpaSkillReviewQueryRepository(ratingRepository, userAccountRepository)
.list(10L, "admin", true, page);
assertThat(result.getContent()).singleElement().satisfies(review -> {
assertThat(review.userId()).isEqualTo("author-1");
assertThat(review.moderationReason()).isEqualTo("Policy violation");
assertThat(review.status()).isEqualTo("HIDDEN");
});
}
}

View file

@ -0,0 +1,196 @@
package com.iflytek.skillhub.repository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillReviewStatus;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.OptimisticLockException;
import org.hibernate.exception.ConstraintViolationException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Testcontainers
class SkillRatingOptimisticLockingTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
}
@Autowired
private EntityManagerFactory entityManagerFactory;
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void concurrentReviewUpdatesRejectTheStaleWriter() {
Long ratingId = persistRating();
EntityManager firstManager = entityManagerFactory.createEntityManager();
EntityManager staleManager = entityManagerFactory.createEntityManager();
try {
firstManager.getTransaction().begin();
staleManager.getTransaction().begin();
SkillRating first = firstManager.find(SkillRating.class, ratingId);
SkillRating stale = staleManager.find(SkillRating.class, ratingId);
first.hideReview("moderator", "Policy violation");
firstManager.getTransaction().commit();
stale.updateReview((short) 2, "Stale update");
assertThatThrownBy(staleManager.getTransaction()::commit)
.satisfies(error -> assertThat(hasCause(error, OptimisticLockException.class)).isTrue());
EntityManager verifier = entityManagerFactory.createEntityManager();
try {
SkillRating saved = verifier.find(SkillRating.class, ratingId);
assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN);
assertThat(saved.getModerationReason()).isEqualTo("Policy violation");
} finally {
verifier.close();
}
} finally {
rollbackIfActive(firstManager);
rollbackIfActive(staleManager);
firstManager.close();
staleManager.close();
deleteRating(ratingId);
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void duplicateFirstInsertIsRejectedByTheDatabaseUniqueConstraint() {
EntityManager firstManager = entityManagerFactory.createEntityManager();
EntityManager secondManager = entityManagerFactory.createEntityManager();
try {
firstManager.getTransaction().begin();
secondManager.getTransaction().begin();
assertThat(countRatings(firstManager, 20L, "duplicate-author")).isZero();
assertThat(countRatings(secondManager, 20L, "duplicate-author")).isZero();
SkillRating first = new SkillRating(20L, "duplicate-author", (short) 4);
first.updateReview((short) 4, "First insert");
firstManager.persist(first);
firstManager.getTransaction().commit();
SkillRating duplicate = new SkillRating(20L, "duplicate-author", (short) 5);
duplicate.updateReview((short) 5, "Duplicate insert");
assertThatThrownBy(() -> {
secondManager.persist(duplicate);
secondManager.flush();
secondManager.getTransaction().commit();
}).satisfies(error -> assertThat(hasCause(error, ConstraintViolationException.class)).isTrue());
EntityManager verifier = entityManagerFactory.createEntityManager();
try {
assertThat(countRatings(verifier, 20L, "duplicate-author")).isEqualTo(1L);
} finally {
verifier.close();
}
} finally {
rollbackIfActive(firstManager);
rollbackIfActive(secondManager);
firstManager.close();
secondManager.close();
deleteRatings(20L, "duplicate-author");
}
}
private Long persistRating() {
EntityManager entityManager = entityManagerFactory.createEntityManager();
try {
entityManager.getTransaction().begin();
SkillRating rating = new SkillRating(10L, "author", (short) 4);
rating.updateReview((short) 4, "Original review");
entityManager.persist(rating);
entityManager.getTransaction().commit();
return rating.getId();
} finally {
rollbackIfActive(entityManager);
entityManager.close();
}
}
private void rollbackIfActive(EntityManager entityManager) {
if (entityManager.getTransaction().isActive()) {
entityManager.getTransaction().rollback();
}
}
private void deleteRating(Long ratingId) {
EntityManager entityManager = entityManagerFactory.createEntityManager();
try {
entityManager.getTransaction().begin();
SkillRating rating = entityManager.find(SkillRating.class, ratingId);
if (rating != null) {
entityManager.remove(rating);
}
entityManager.getTransaction().commit();
} finally {
rollbackIfActive(entityManager);
entityManager.close();
}
}
private long countRatings(EntityManager entityManager, Long skillId, String userId) {
return entityManager.createQuery("""
SELECT COUNT(r) FROM SkillRating r
WHERE r.skillId = :skillId AND r.userId = :userId
""", Long.class)
.setParameter("skillId", skillId)
.setParameter("userId", userId)
.getSingleResult();
}
private void deleteRatings(Long skillId, String userId) {
EntityManager entityManager = entityManagerFactory.createEntityManager();
try {
entityManager.getTransaction().begin();
entityManager.createQuery("""
DELETE FROM SkillRating r
WHERE r.skillId = :skillId AND r.userId = :userId
""")
.setParameter("skillId", skillId)
.setParameter("userId", userId)
.executeUpdate();
entityManager.getTransaction().commit();
} finally {
rollbackIfActive(entityManager);
entityManager.close();
}
}
private boolean hasCause(Throwable error, Class<? extends Throwable> expectedType) {
Throwable current = error;
while (current != null) {
if (expectedType.isInstance(current)) {
return true;
}
current = current.getCause();
}
return false;
}
}

View file

@ -0,0 +1,198 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
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.SkillStatus;
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;
import com.iflytek.skillhub.domain.social.SkillRatingService;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.SkillReviewQueryRepository;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@ExtendWith(MockitoExtension.class)
class SkillReviewAppServiceTest {
@Mock private SkillRepository skillRepository;
@Mock private SkillVersionRepository skillVersionRepository;
@Mock private SkillRatingService ratingService;
@Mock private SkillReviewQueryRepository queryRepository;
@Mock private AuditLogService auditLogService;
@Mock private RequestIdAccessor requestIdAccessor;
private SkillReviewAppService service;
@BeforeEach
void setUp() {
service = new SkillReviewAppService(
skillRepository,
skillVersionRepository,
new VisibilityChecker(),
ratingService,
queryRepository,
auditLogService,
requestIdAccessor
);
}
@Test
void publicListingExcludesHiddenReviewsForRegularViewer() {
Skill skill = publishedSkill(SkillVisibility.PUBLIC);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
when(queryRepository.list(eq(10L), eq(null), eq(false), any(Pageable.class)))
.thenReturn(Page.empty());
service.list(10L, null, Map.of(), Set.of(), 0, 20);
verify(queryRepository).list(eq(10L), eq(null), eq(false), any(Pageable.class));
}
@Test
void skillAdminListingIncludesHiddenReviews() {
Skill skill = publishedSkill(SkillVisibility.PUBLIC);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
when(queryRepository.list(eq(10L), eq("admin"), eq(true), any(Pageable.class)))
.thenReturn(Page.empty());
service.list(10L, "admin", Map.of(), Set.of("SKILL_ADMIN"), 0, 20);
verify(queryRepository).list(eq(10L), eq("admin"), eq(true), any(Pageable.class));
}
@Test
void reviewPaginationRejectsNegativePageAndSizesOutsideOneToOneHundred() {
Skill skill = publishedSkill(SkillVisibility.PUBLIC);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), -1, 20))
.isInstanceOf(DomainBadRequestException.class);
assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), 0, 0))
.isInstanceOf(DomainBadRequestException.class);
assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), 0, 101))
.isInstanceOf(DomainBadRequestException.class);
verify(queryRepository, never()).list(any(), any(), anyBoolean(), any(Pageable.class));
}
@Test
void privateSkillReviewMutationRequiresSkillAccess() {
Skill skill = publishedSkill(SkillVisibility.PRIVATE);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
assertThatThrownBy(() -> service.upsert(
10L, "other-user", (short) 5, "great", Map.of(), Set.of()))
.isInstanceOf(DomainForbiddenException.class);
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 archivedSkillReviewMutationIsRejectedServerSide() {
Skill skill = publishedSkill(SkillVisibility.PUBLIC);
skill.setStatus(SkillStatus.ARCHIVED);
SkillVersion published = new SkillVersion(10L, "1.0.0", "owner");
published.setStatus(SkillVersionStatus.PUBLISHED);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(100L)).thenReturn(Optional.of(published));
assertThatThrownBy(() -> service.upsert(
10L, "owner", (short) 5, "archived", Map.of(), Set.of()))
.isInstanceOf(DomainBadRequestException.class)
.hasMessage("error.skillReview.notInteractable");
verify(ratingService, never()).upsertReview(any(), any(), any(Short.class), any());
}
@Test
void authorCanClearReviewAfterSkillStopsBeingInteractable() {
SkillRating review = new SkillRating(10L, "author", (short) 4);
review.updateReview((short) 4, "Remove me");
review.clearReview();
when(ratingService.clearReview(10L, "author")).thenReturn(review);
service.clear(10L, "author", Map.of(), Set.of());
verify(ratingService).clearReview(10L, "author");
verify(skillRepository, never()).findById(any());
verify(skillVersionRepository, never()).findById(any());
}
@Test
void authorCanReadOwnReviewAfterSkillStopsBeingVisible() {
SkillRating review = new SkillRating(10L, "author", (short) 4);
review.updateReview((short) 4, "My review");
when(ratingService.getUserFeedback(10L, "author")).thenReturn(Optional.of(review));
service.getMine(10L, "author", Map.of(), Set.of());
verify(ratingService).getUserFeedback(10L, "author");
verify(skillRepository, never()).findById(any());
}
@Test
void hideWritesModerationAuditInSameWorkflow() {
SkillRating review = new SkillRating(10L, "author", (short) 4);
review.updateReview((short) 4, "helpful review");
when(ratingService.hideReview(null, "admin", "spam")).thenReturn(review);
when(requestIdAccessor.current()).thenReturn("request-1");
service.hide(null, "admin", "spam", new AuditRequestContext("127.0.0.1", "test"));
verify(auditLogService).record(
eq("admin"),
eq("SKILL_REVIEW_HIDE"),
eq("SKILL_REVIEW"),
eq(null),
eq("request-1"),
eq("127.0.0.1"),
eq("test"),
eq("{\"skillId\":10,\"reason\":\"spam\"}")
);
}
private Skill publishedSkill(SkillVisibility visibility) {
Skill skill = new Skill(1L, "demo", "owner", visibility);
skill.setLatestVersionId(100L);
return skill;
}
}

View file

@ -39,11 +39,19 @@ public class RouteSecurityPolicyRegistry {
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/star"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/rating"),
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/*/rating"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/reviews"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/reviews/me"),
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/*/reviews/me"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/reviews/me"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/star"),
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/*/star"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/star"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/rating"),
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/*/rating"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/reviews"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/reviews/me"),
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/*/reviews/me"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/reviews/me"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions"),
@ -112,9 +120,13 @@ public class RouteSecurityPolicyRegistry {
ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/star"),
ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/v1/skills/*/star"),
ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/rating"),
ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/reviews/me"),
ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/v1/skills/*/reviews/me"),
ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/star"),
ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/web/skills/*/star"),
ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/rating"),
ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/reviews/me"),
ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/web/skills/*/reviews/me"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"),

View file

@ -34,6 +34,26 @@ class RouteSecurityPolicyRegistryTest {
registry.accessLevel("GET", "/api/v1/resolve/team/demo"));
}
@Test
void reviewRoutesExposePublicListingButProtectCurrentUserMutations() {
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
registry.accessLevel("GET", "/api/v1/skills/10/reviews"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
registry.accessLevel("GET", "/api/v1/skills/10/reviews/me"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
registry.accessLevel("PUT", "/api/v1/skills/10/reviews/me"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
registry.accessLevel("DELETE", "/api/v1/skills/10/reviews/me"));
}
@Test
void apiTokenPolicyAllowsCurrentUserReviewMutations() {
assertTrue(registry.authorizeApiToken(
"PUT", "/api/v1/skills/10/reviews/me", Set.of()).allowed());
assertTrue(registry.authorizeApiToken(
"DELETE", "/api/v1/skills/10/reviews/me", Set.of()).allowed());
}
@Test
void authorizeApiToken_requiresPublishScopeForPublishEndpoints() {
var denied = registry.authorizeApiToken("POST", "/api/web/skills/global/publish", Set.of("skill:read"));

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.domain.shared.exception;
/**
* Domain exception used when a concurrent request prevents a safe state change.
*/
public class DomainConflictException extends LocalizedDomainException {
public DomainConflictException(String messageCode, Object... messageArgs) {
super(messageCode, messageArgs);
}
@Override
public int statusCode() {
return 409;
}
}

View file

@ -242,7 +242,8 @@ public class SkillQueryService {
skill.getUpdatedAt(),
canManageRestrictedSkill(skill, currentUserId, userNsRoles),
canSubmitPromotion(namespace, skill, publishedVersion, currentUserId, userNsRoles),
headlineVersion == null || "PUBLISHED".equals(headlineVersion.status()),
skill.getStatus() == SkillStatus.ACTIVE
&& (headlineVersion == null || "PUBLISHED".equals(headlineVersion.status())),
currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId),
headlineVersion,
publishedVersion,

View file

@ -9,9 +9,15 @@ import java.time.Instant;
@Table(name = "skill_rating",
uniqueConstraints = @UniqueConstraint(columnNames = {"skill_id", "user_id"}))
public class SkillRating {
private static final int MAX_REVIEW_LENGTH = 2000;
@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;
@ -21,6 +27,22 @@ public class SkillRating {
@Column(nullable = false)
private Short score;
@Column(name = "review_text", length = MAX_REVIEW_LENGTH)
private String reviewText;
@Enumerated(EnumType.STRING)
@Column(name = "review_status", nullable = false, length = 16)
private SkillReviewStatus reviewStatus = SkillReviewStatus.VISIBLE;
@Column(name = "moderated_by", length = 128)
private String moderatedBy;
@Column(name = "moderated_at")
private Instant moderatedAt;
@Column(name = "moderation_reason", length = 500)
private String moderationReason;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@ -37,11 +59,77 @@ public class SkillRating {
}
public void updateScore(short newScore) {
if (newScore < 1 || newScore > 5) throw new DomainBadRequestException("error.rating.score.invalid");
validateScore(newScore);
this.score = newScore;
this.updatedAt = Instant.now(Clock.systemUTC());
}
public void updateReview(short newScore, String newReviewText) {
validateScore(newScore);
this.score = newScore;
this.reviewText = normalizeReviewText(newReviewText);
this.updatedAt = Instant.now(Clock.systemUTC());
}
public void clearReview() {
this.reviewText = null;
this.updatedAt = Instant.now(Clock.systemUTC());
}
public void hideReview(String moderatorId, String reason) {
ensureReviewExists();
this.reviewStatus = SkillReviewStatus.HIDDEN;
this.moderatedBy = moderatorId;
this.moderatedAt = Instant.now(Clock.systemUTC());
this.moderationReason = normalizeReason(reason);
}
public void restoreReview(String moderatorId) {
ensureReviewExists();
this.reviewStatus = SkillReviewStatus.VISIBLE;
this.moderatedBy = moderatorId;
this.moderatedAt = Instant.now(Clock.systemUTC());
this.moderationReason = null;
}
public boolean hasReview() {
return reviewText != null && !reviewText.isBlank();
}
private static void validateScore(short value) {
if (value < 1 || value > 5) {
throw new DomainBadRequestException("error.rating.score.invalid");
}
}
private static String normalizeReviewText(String value) {
if (value == null || value.isBlank()) {
throw new DomainBadRequestException("error.skillReview.text.required");
}
String normalized = value.trim();
if (normalized.length() > MAX_REVIEW_LENGTH) {
throw new DomainBadRequestException("error.skillReview.text.tooLong", MAX_REVIEW_LENGTH);
}
return normalized;
}
private static String normalizeReason(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim();
if (normalized.length() > 500) {
throw new DomainBadRequestException("error.skillReview.reason.tooLong", 500);
}
return normalized;
}
private void ensureReviewExists() {
if (!hasReview()) {
throw new DomainBadRequestException("error.skillReview.notFound");
}
}
@PrePersist
void prePersist() {
this.createdAt = Instant.now(Clock.systemUTC());
@ -55,9 +143,15 @@ 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; }
public String getReviewText() { return reviewText; }
public SkillReviewStatus getReviewStatus() { return reviewStatus; }
public String getModeratedBy() { return moderatedBy; }
public Instant getModeratedAt() { return moderatedAt; }
public String getModerationReason() { return moderationReason; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View file

@ -1,13 +1,19 @@
package com.iflytek.skillhub.domain.social;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
/**
* Domain repository contract for per-user ratings and rating aggregates on one skill.
*/
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);
Page<SkillRating> findReviewsBySkillId(Long skillId, Pageable pageable);
double averageScoreBySkillId(Long skillId);
int countBySkillId(Long skillId);
void deleteBySkillId(Long skillId);

View file

@ -1,10 +1,12 @@
package com.iflytek.skillhub.domain.social;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.social.event.SkillRatedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -44,12 +46,76 @@ public class SkillRatingService {
eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score));
}
@Transactional
public SkillRating upsertReview(Long skillId, String userId, short score, String reviewText) {
ensureSkillExists(skillId);
SkillRating rating = ratingRepository.findBySkillIdAndUserId(skillId, userId)
.orElseGet(() -> new SkillRating(skillId, userId, score));
rating.updateReview(score, reviewText);
SkillRating saved;
try {
saved = ratingRepository.save(rating);
ratingRepository.flush();
} catch (DataIntegrityViolationException exception) {
throw new DomainConflictException("error.request.conflict");
}
eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score));
return saved;
}
@Transactional
public SkillRating clearReview(Long skillId, String userId) {
ensureSkillExists(skillId);
SkillRating rating = ratingRepository.findBySkillIdAndUserId(skillId, userId)
.filter(SkillRating::hasReview)
.orElseThrow(() -> new DomainNotFoundException("error.skillReview.notFound"));
rating.clearReview();
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);
SkillRating saved = ratingRepository.save(rating);
ratingRepository.flush();
return saved;
}
@Transactional
public SkillRating restoreReview(Long reviewId, String moderatorId) {
SkillRating rating = findReview(reviewId);
rating.restoreReview(moderatorId);
SkillRating saved = ratingRepository.save(rating);
ratingRepository.flush();
return saved;
}
public Optional<Short> getUserRating(Long skillId, String userId) {
ensureSkillExists(skillId);
return ratingRepository.findBySkillIdAndUserId(skillId, userId)
.map(SkillRating::getScore);
}
public Optional<SkillRating> getUserReview(Long skillId, String userId) {
ensureSkillExists(skillId);
return ratingRepository.findBySkillIdAndUserId(skillId, userId)
.filter(SkillRating::hasReview);
}
public Optional<SkillRating> getUserFeedback(Long skillId, String userId) {
ensureSkillExists(skillId);
return ratingRepository.findBySkillIdAndUserId(skillId, userId);
}
private SkillRating findReview(Long reviewId) {
return ratingRepository.findById(reviewId)
.filter(SkillRating::hasReview)
.orElseThrow(() -> new DomainNotFoundException("error.skillReview.notFound"));
}
private void ensureSkillExists(Long skillId) {
if (skillRepository.findById(skillId).isEmpty()) {
throw new DomainNotFoundException("skill.not_found", skillId);

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.social;
/** Visibility state for the optional review text attached to a skill rating. */
public enum SkillReviewStatus {
VISIBLE,
HIDDEN
}

View file

@ -1212,6 +1212,57 @@ class SkillQueryServiceTest {
assertTrue(result.canInteract());
}
@Test
void testGetSkillDetail_ShouldDisableInteractionForArchivedSkill() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String ownerId = "owner-1";
Map<Long, NamespaceRole> userNsRoles = Map.of();
Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId);
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ARCHIVED);
skill.setLatestVersionId(11L);
SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId);
setId(published, 11L);
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
assertNotNull(result.headlineVersion());
assertEquals("PUBLISHED", result.headlineVersion().status());
assertFalse(result.canInteract());
}
@Test
void testGetSkillDetail_ShouldKeepInteractionForActiveSkillWithoutHeadlineVersion() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String ownerId = "owner-1";
Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId);
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(
namespaceSlug, skillSlug, ownerId, Map.of());
assertNull(result.headlineVersion());
assertTrue(result.canInteract());
}
@Test
void testGetSkillDetail_ShouldIncludeRejectedOwnerPreviewComment() throws Exception {
String namespaceSlug = "test-ns";

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.domain.social;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
@ -11,6 +12,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.Optional;
@ -77,4 +79,141 @@ class SkillRatingServiceTest {
assertThatThrownBy(() -> service.getUserRating(99L, "10"))
.isInstanceOf(DomainNotFoundException.class);
}
@Test
void upsertReview_creates_review_and_rating() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
when(ratingRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
SkillRating review = service.upsertReview(1L, "user-1", (short) 5, " Useful skill. ");
assertThat(review.getScore()).isEqualTo((short) 5);
assertThat(review.getReviewText()).isEqualTo("Useful skill.");
assertThat(review.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE);
verify(eventPublisher).publishEvent(any(SkillRatedEvent.class));
}
@Test
void upsertReview_preserves_hidden_status_when_author_edits() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
SkillRating existing = new SkillRating(1L, "user-1", (short) 2);
existing.updateReview((short) 2, "Original review");
existing.hideReview("moderator-1", "Policy violation");
when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.of(existing));
when(ratingRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
SkillRating review = service.upsertReview(1L, "user-1", (short) 4, "Edited review");
assertThat(review.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN);
assertThat(review.getReviewText()).isEqualTo("Edited review");
}
@Test
void upsertReview_rejects_blank_or_too_long_text() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.upsertReview(1L, "user-1", (short) 4, " "))
.isInstanceOf(DomainBadRequestException.class);
assertThatThrownBy(() -> service.upsertReview(1L, "user-1", (short) 4, "x".repeat(2001)))
.isInstanceOf(DomainBadRequestException.class);
}
@Test
void upsertReview_mapsConcurrentFirstInsertToConflict() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
when(ratingRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
doThrow(new DataIntegrityViolationException("duplicate rating"))
.when(ratingRepository).flush();
assertThatThrownBy(() -> service.upsertReview(1L, "user-1", (short) 4, "Useful"))
.isInstanceOf(DomainConflictException.class)
.hasMessage("error.request.conflict");
verify(eventPublisher, never()).publishEvent(any(SkillRatedEvent.class));
}
@Test
void clearReview_keeps_rating_row_and_score() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
SkillRating existing = new SkillRating(1L, "user-1", (short) 4);
existing.updateReview((short) 4, "Useful skill");
when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.of(existing));
when(ratingRepository.save(existing)).thenReturn(existing);
SkillRating result = service.clearReview(1L, "user-1");
assertThat(result.hasReview()).isFalse();
assertThat(result.getScore()).isEqualTo((short) 4);
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);
existing.updateReview((short) 4, "Useful skill");
when(ratingRepository.findById(7L)).thenReturn(Optional.of(existing));
when(ratingRepository.save(existing)).thenReturn(existing);
SkillRating hidden = service.hideReview(7L, "moderator-1", "Off topic");
assertThat(hidden.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN);
assertThat(hidden.getModeratedBy()).isEqualTo("moderator-1");
assertThat(hidden.getModerationReason()).isEqualTo("Off topic");
SkillRating restored = service.restoreReview(7L, "moderator-2");
assertThat(restored.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE);
assertThat(restored.getModeratedBy()).isEqualTo("moderator-2");
assertThat(restored.getModerationReason()).isNull();
}
@Test
void moderationReason_acceptsFiveHundredCharactersAndRejectsFiveHundredOne() {
SkillRating valid = new SkillRating(1L, "user-1", (short) 4);
valid.updateReview((short) 4, "Useful skill");
SkillRating invalid = new SkillRating(1L, "user-2", (short) 4);
invalid.updateReview((short) 4, "Another useful skill");
when(ratingRepository.findById(7L)).thenReturn(Optional.of(valid));
when(ratingRepository.findById(8L)).thenReturn(Optional.of(invalid));
when(ratingRepository.save(valid)).thenReturn(valid);
SkillRating hidden = service.hideReview(7L, "moderator", "x".repeat(500));
assertThat(hidden.getModerationReason()).hasSize(500);
assertThatThrownBy(() -> service.hideReview(8L, "moderator", "x".repeat(501)))
.isInstanceOf(DomainBadRequestException.class)
.hasMessage("error.skillReview.reason.tooLong");
verify(ratingRepository, never()).save(invalid);
}
@Test
void clearReview_throws_when_user_has_only_rating() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
when(ratingRepository.findBySkillIdAndUserId(1L, "user-1"))
.thenReturn(Optional.of(new SkillRating(1L, "user-1", (short) 4)));
assertThatThrownBy(() -> service.clearReview(1L, "user-1"))
.isInstanceOf(DomainNotFoundException.class);
}
}

View file

@ -5,6 +5,8 @@ import com.iflytek.skillhub.domain.social.SkillRatingRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
/**
@ -14,6 +16,25 @@ import java.util.Optional;
public interface JpaSkillRatingRepository extends JpaRepository<SkillRating, Long>, SkillRatingRepository {
Optional<SkillRating> findBySkillIdAndUserId(Long skillId, String userId);
@Query("""
SELECT r FROM SkillRating r
WHERE r.skillId = :skillId
AND r.reviewStatus = com.iflytek.skillhub.domain.social.SkillReviewStatus.VISIBLE
AND r.reviewText IS NOT NULL
AND TRIM(r.reviewText) <> ''
ORDER BY r.updatedAt DESC, r.id DESC
""")
Page<SkillRating> findVisibleReviewsBySkillId(Long skillId, Pageable pageable);
@Query("""
SELECT r FROM SkillRating r
WHERE r.skillId = :skillId
AND r.reviewText IS NOT NULL
AND TRIM(r.reviewText) <> ''
ORDER BY r.updatedAt DESC, r.id DESC
""")
Page<SkillRating> findReviewsBySkillId(Long skillId, Pageable pageable);
@Query("SELECT COALESCE(AVG(r.score), 0) FROM SkillRating r WHERE r.skillId = :skillId")
double averageScoreBySkillId(Long skillId);

View file

@ -68,6 +68,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/web/skills/{skillId}/reviews/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getMine"];
put: operations["upsert"];
post?: never;
delete: operations["clear"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/skills/{skillId}/reviews/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getMine_1"];
put: operations["upsert_1"];
post?: never;
delete: operations["clear_1"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/skills/{skillId}/rating": {
parameters: {
query?: never;
@ -1195,7 +1227,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["list_2"];
get: operations["list_4"];
put?: never;
post: operations["create"];
delete?: never;
@ -1540,6 +1572,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/skill-reviews/{reviewId}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["restore"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/skill-reviews/{reviewId}/hide": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["hide"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/skill-reports/{reportId}/resolve": {
parameters: {
query?: never;
@ -1860,6 +1924,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/web/skills/{skillId}/reviews": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/skills/{skillId}/reviews": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/skills/{namespace}/{slug}/versions/{version}/files": {
parameters: {
query?: never;
@ -2731,7 +2827,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["list"];
get: operations["list_2"];
put?: never;
post?: never;
delete?: never;
@ -2747,7 +2843,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["list_1"];
get: operations["list_3"];
put?: never;
post?: never;
delete?: never;
@ -3307,7 +3403,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["list_3"];
get: operations["list_5"];
put?: never;
post?: never;
delete?: never;
@ -3649,6 +3745,35 @@ export interface components {
timestamp?: string;
requestId?: string;
};
SkillReviewRequest: {
/** Format: int32 */
score: number;
reviewText: string;
};
ApiResponseSkillReviewMeResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["SkillReviewMeResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
SkillReviewMeResponse: {
rated?: boolean;
/** Format: int32 */
score?: number;
reviewed?: boolean;
/** Format: int64 */
reviewId?: number;
reviewText?: string;
status?: string;
moderationReason?: string;
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
updatedAt?: string;
};
SkillRatingRequest: {
/** Format: int32 */
score: number;
@ -4241,6 +4366,35 @@ export interface components {
timestamp?: string;
requestId?: string;
};
ApiResponseSkillReviewResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["SkillReviewResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
SkillReviewResponse: {
/** Format: int64 */
id?: number;
userId?: string;
displayName?: string;
avatarUrl?: string;
/** Format: int32 */
score?: number;
reviewText?: string;
status?: string;
authoredByViewer?: boolean;
moderationReason?: string;
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
updatedAt?: string;
};
SkillReviewModerationRequest: {
reason?: string;
};
AdminSkillReportActionRequest: {
comment?: string;
disposition?: string;
@ -4478,6 +4632,24 @@ export interface components {
timestamp?: string;
requestId?: string;
};
ApiResponsePageResponseSkillReviewResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["PageResponseSkillReviewResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
PageResponseSkillReviewResponse: {
items?: components["schemas"]["SkillReviewResponse"][];
/** Format: int64 */
total?: number;
/** Format: int32 */
page?: number;
/** Format: int32 */
size?: number;
};
ApiResponseSkillRatingStatusResponse: {
/** Format: int32 */
code?: number;
@ -5846,6 +6018,146 @@ export interface operations {
};
};
};
getMine: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"];
};
};
};
};
upsert: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SkillReviewRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"];
};
};
};
};
clear: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"];
};
};
};
};
getMine_1: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"];
};
};
};
};
upsert_1: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SkillReviewRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"];
};
};
};
};
clear_1: {
parameters: {
query?: never;
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"];
};
};
};
};
getUserRating: {
parameters: {
query?: never;
@ -8099,7 +8411,7 @@ export interface operations {
};
};
};
list_2: {
list_4: {
parameters: {
query?: {
page?: number;
@ -8690,6 +9002,54 @@ export interface operations {
};
};
};
restore: {
parameters: {
query?: never;
header?: never;
path: {
reviewId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillReviewResponse"];
};
};
};
};
hide: {
parameters: {
query?: never;
header?: never;
path: {
reviewId: number;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["SkillReviewModerationRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseSkillReviewResponse"];
};
};
};
};
resolveReport: {
parameters: {
query?: never;
@ -9258,6 +9618,56 @@ export interface operations {
};
};
};
list: {
parameters: {
query?: {
page?: number;
size?: number;
};
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"];
};
};
};
};
list_1: {
parameters: {
query?: {
page?: number;
size?: number;
};
header?: never;
path: {
skillId: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"];
};
};
};
};
listFiles: {
parameters: {
query?: never;
@ -10624,7 +11034,7 @@ export interface operations {
};
};
};
list: {
list_2: {
parameters: {
query?: {
category?: string;
@ -10648,7 +11058,7 @@ export interface operations {
};
};
};
list_1: {
list_3: {
parameters: {
query?: {
category?: string;
@ -11463,7 +11873,7 @@ export interface operations {
};
};
};
list_3: {
list_5: {
parameters: {
query?: {
status?: string;

View file

@ -0,0 +1,187 @@
/** @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(),
mineEnabled: [] as boolean[],
}))
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: (_skillId: number, enabled: boolean) => {
mocks.mineEnabled.push(enabled)
return { 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
mocks.mineEnabled.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' }))
.toHaveProperty('checked', 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[mocks.requestedPages.length - 1]).toBe(0)
})
it('wraps long reviewer names and review text', () => {
const reviewer = 'review_author_1788284593_353294'
const reviewText = 'x'.repeat(200)
mocks.pages.set(0, {
items: [{
id: 8,
displayName: reviewer,
score: 5,
reviewText,
status: 'VISIBLE',
authoredByViewer: false,
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
}],
total: 1,
page: 0,
size: 20,
})
render(<SkillReviews skillId={10} canInteract onRequireLogin={vi.fn()} />)
expect(screen.getByText(reviewer).className).toContain('[overflow-wrap:anywhere]')
expect(screen.getByText(reviewText).className).toContain('[overflow-wrap:anywhere]')
})
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()
}
expect(en.skillReviews.count_one).toBe('{{count}} review')
expect(en.skillReviews.count_other).toBe('{{count}} reviews')
expect(ru.skillReviews.count_one).toBeTruthy()
expect(ru.skillReviews.count_few).toBeTruthy()
expect(ru.skillReviews.count_many).toBeTruthy()
expect(ru.skillReviews.count_other).toBeTruthy()
})
it('lets an authenticated author clear existing text when the skill is not interactable', () => {
render(<SkillReviews skillId={10} canInteract={false} onRequireLogin={vi.fn()} />)
expect(mocks.mineEnabled).toContain(true)
fireEvent.click(screen.getByRole('button', { name: 'Delete review' }))
expect(mocks.clearMutate).toHaveBeenCalledOnce()
expect(screen.queryByRole('button', { name: 'Edit my review' })).toBeNull()
})
})

View file

@ -0,0 +1,329 @@
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'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Textarea } from '@/shared/ui/textarea'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { toast } from '@/shared/lib/toast'
import { cn } from '@/shared/lib/utils'
import {
type MySkillReview,
type SkillReview,
useClearSkillReview,
useModerateSkillReview,
useMySkillReview,
useSkillReviews,
useUpsertSkillReview,
} from './use-skill-reviews'
interface SkillReviewsProps {
skillId: number
canInteract: boolean
onRequireLogin: () => void
}
function ReviewStars({ value, onChange, disabled = false }: {
value: number
onChange?: (value: number) => void
disabled?: boolean
}) {
const { t } = useTranslation()
const ratingName = useId()
if (!onChange) {
return (
<div
className="flex items-center gap-1"
role="img"
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" role="radiogroup" aria-label={t('skillReviews.scoreLabel')}>
{[1, 2, 3, 4, 5].map((score) => (
<span key={score}>
<input
id={`${ratingName}-${score}`}
className="peer sr-only"
type="radio"
name={ratingName}
value={score}
checked={score === value}
onChange={() => onChange(score)}
disabled={disabled}
aria-label={t('skillReviews.ratingOption', { score })}
/>
<label
htmlFor={`${ratingName}-${score}`}
className="block cursor-pointer rounded p-0.5 transition-transform hover:scale-110 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-primary"
>
<Star aria-hidden="true" className={cn(
'h-4 w-4',
score <= value ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground/40',
)} />
</label>
</span>
))}
</div>
)
}
function ReviewEditor({ skillId, review, onDone }: {
skillId: number
review?: MySkillReview
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)
const clear = useClearSkillReview(skillId)
const pending = save.isPending || clear.isPending
const handleSave = () => {
const normalized = reviewText.trim()
if (!normalized) {
toast.error(t('skillReviews.textRequired'))
return
}
save.mutate({ score, reviewText: normalized }, {
onSuccess: () => {
toast.success(t('skillReviews.saved'))
onDone()
},
onError: (error) => toast.error(t('skillReviews.saveFailed'), error.message),
})
}
const handleDelete = () => {
clear.mutate(undefined, {
onSuccess: () => {
toast.success(t('skillReviews.deleted'))
onDone()
},
onError: (error) => toast.error(t('skillReviews.deleteFailed'), error.message),
})
}
return (
<div className="space-y-4 rounded-xl border border-border/60 bg-secondary/20 p-4">
<div className="flex items-center justify-between gap-3">
<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}
placeholder={t('skillReviews.placeholder')}
disabled={pending}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{reviewText.length}/2000</span>
<div className="ml-auto flex flex-wrap justify-end gap-2">
{review?.reviewed ? (
<Button variant="ghost" size="sm" onClick={handleDelete} disabled={pending}>
{t('skillReviews.delete')}
</Button>
) : null}
<Button variant="outline" size="sm" onClick={onDone} disabled={pending}>
{t('skillReviews.cancel')}
</Button>
<Button size="sm" onClick={handleSave} disabled={pending || !reviewText.trim()}>
{pending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('skillReviews.save')}
</Button>
</div>
</div>
</div>
)
}
function ReviewRow({ skillId, review, canModerate }: {
skillId: number
review: SkillReview
canModerate: boolean
}) {
const { t, i18n } = useTranslation()
const moderation = useModerateSkillReview(skillId)
const hidden = review.status === 'HIDDEN'
const initials = review.displayName.trim().slice(0, 1).toUpperCase() || '?'
const moderate = () => {
moderation.mutate({ reviewId: review.id, action: hidden ? 'restore' : 'hide' }, {
onSuccess: () => toast.success(t(hidden ? 'skillReviews.restored' : 'skillReviews.hidden')),
onError: (error) => toast.error(t('skillReviews.moderationFailed'), error.message),
})
}
return (
<div className={cn('space-y-3 py-5 first:pt-0 last:pb-0', hidden && 'opacity-60')}>
<div className="flex items-start gap-3">
{review.avatarUrl ? (
<img src={review.avatarUrl} alt="" className="h-9 w-9 rounded-full object-cover" />
) : (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{initials}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 max-w-full flex flex-wrap items-center gap-2">
<span className="min-w-0 max-w-full break-words font-medium text-foreground [overflow-wrap:anywhere]">{review.displayName}</span>
<ReviewStars value={review.score} />
{hidden ? (
<span className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
{t('skillReviews.hiddenStatus')}
</span>
) : null}
</div>
<span className="text-xs text-muted-foreground">
{formatLocalDateTime(review.updatedAt, i18n.language)}
</span>
</div>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90 [overflow-wrap:anywhere]">{review.reviewText}</p>
{hidden && review.moderationReason ? (
<p className="mt-2 text-xs text-muted-foreground">
{t('skillReviews.moderationReason', { reason: review.moderationReason })}
</p>
) : null}
</div>
</div>
{canModerate ? (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={moderate} disabled={moderation.isPending}>
<ShieldAlert className="mr-2 h-4 w-4" />
{t(hidden ? 'skillReviews.restore' : 'skillReviews.hide')}
</Button>
</div>
) : null}
</div>
)
}
export function SkillReviews({ skillId, canInteract, onRequireLogin }: SkillReviewsProps) {
const { t } = useTranslation()
const { isAuthenticated, hasRole } = useAuth()
const [page, setPage] = useState(0)
const [editing, setEditing] = useState(false)
const reviews = useSkillReviews(skillId, page)
const mine = useMySkillReview(skillId, isAuthenticated)
const clearMine = useClearSkillReview(skillId)
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()
return
}
setEditing(true)
}
const deleteUnavailableReview = () => {
clearMine.mutate(undefined, {
onSuccess: () => {
toast.success(t('skillReviews.deleted'))
setPage(0)
},
onError: (error) => toast.error(t('skillReviews.deleteFailed'), error.message),
})
}
return (
<Card className="p-6 space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-primary" />
<h2 className="font-heading text-lg font-semibold">{t('skillReviews.title')}</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t('skillReviews.count', { count: reviews.data?.total ?? 0 })}
</p>
</div>
{canInteract && !editing ? (
<Button variant="outline" onClick={startEditing}>
{mine.data?.reviewed ? t('skillReviews.edit') : t('skillReviews.write')}
</Button>
) : !canInteract && isAuthenticated && mine.data?.reviewed ? (
<Button variant="outline" onClick={deleteUnavailableReview} disabled={clearMine.isPending}>
{t('skillReviews.delete')}
</Button>
) : null}
</div>
{mine.data?.status === 'HIDDEN' ? (
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-3 text-sm text-muted-foreground">
{t('skillReviews.yourReviewHidden')}
{mine.data.moderationReason ? ` ${t('skillReviews.moderationReason', { reason: mine.data.moderationReason })}` : ''}
</div>
) : null}
{editing ? (
<ReviewEditor
key={`${mine.data?.reviewId ?? 'new'}-${mine.data?.updatedAt ?? ''}`}
skillId={skillId}
review={mine.data}
onDone={finishEditing}
/>
) : null}
{reviews.isLoading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('skillReviews.loading')}
</div>
) : reviews.isError ? (
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-4 text-sm text-destructive">
{t('skillReviews.loadFailed')}
</div>
) : reviews.data?.items.length ? (
<div className="divide-y divide-border/50">
{reviews.data.items.map((review) => (
<ReviewRow key={review.id} skillId={skillId} review={review} canModerate={canModerate} />
))}
</div>
) : (
<div className="py-10 text-center text-sm text-muted-foreground">
{t('skillReviews.empty')}
</div>
)}
{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}/{Math.max(totalPages, 1)}</span>
<Button variant="outline" size="sm" disabled={page + 1 >= totalPages} onClick={() => setPage((value) => value + 1)}>
{t('skillReviews.next')}
</Button>
</div>
) : null}
</Card>
)
}

View file

@ -0,0 +1,131 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client'
import type { components } from '@/api/generated/schema'
type GeneratedSkillReview = components['schemas']['SkillReviewResponse']
type GeneratedMySkillReview = components['schemas']['SkillReviewMeResponse']
type GeneratedSkillReviewPage = components['schemas']['PageResponseSkillReviewResponse']
type GeneratedReviewInput = components['schemas']['SkillReviewRequest']
export interface SkillReview extends Omit<GeneratedSkillReview,
'id' | 'userId' | 'displayName' | 'avatarUrl' | 'score' | 'reviewText' | 'status' |
'authoredByViewer' | 'moderationReason' | 'createdAt' | 'updatedAt'> {
id: number
userId?: string | null
displayName: string
avatarUrl?: string | null
score: number
reviewText: string
status: 'VISIBLE' | 'HIDDEN'
authoredByViewer: boolean
moderationReason?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface MySkillReview extends Omit<GeneratedMySkillReview,
'rated' | 'score' | 'reviewed' | 'reviewId' | 'reviewText' | 'status' |
'moderationReason' | 'createdAt' | 'updatedAt'> {
rated: boolean
score: number
reviewed: boolean
reviewId?: number | null
reviewText?: string | null
status?: 'VISIBLE' | 'HIDDEN' | null
moderationReason?: string | null
createdAt?: string | null
updatedAt?: string | null
}
interface SkillReviewPage extends Omit<GeneratedSkillReviewPage, 'items' | 'total' | 'page' | 'size'> {
items: SkillReview[]
total: number
page: number
size: number
}
interface ReviewInput extends Omit<GeneratedReviewInput, 'score' | 'reviewText'> {
score: number
reviewText: string
}
async function listReviews(skillId: number, page: number): Promise<SkillReviewPage> {
return fetchJson<SkillReviewPage>(`${WEB_API_PREFIX}/skills/${skillId}/reviews?page=${page}&size=20`)
}
async function getMyReview(skillId: number): Promise<MySkillReview> {
return fetchJson<MySkillReview>(`${WEB_API_PREFIX}/skills/${skillId}/reviews/me`)
}
async function upsertReview(skillId: number, input: ReviewInput): Promise<MySkillReview> {
return fetchJson<MySkillReview>(`${WEB_API_PREFIX}/skills/${skillId}/reviews/me`, {
method: 'PUT',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(input),
})
}
async function clearReview(skillId: number): Promise<MySkillReview> {
return fetchJson<MySkillReview>(`${WEB_API_PREFIX}/skills/${skillId}/reviews/me`, {
method: 'DELETE',
headers: getCsrfHeaders(),
})
}
async function moderateReview(reviewId: number, action: 'hide' | 'restore'): Promise<SkillReview> {
return fetchJson<SkillReview>(`/api/v1/admin/skill-reviews/${reviewId}/${action}`, {
method: 'POST',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: action === 'hide' ? JSON.stringify({}) : undefined,
})
}
export function useSkillReviews(skillId: number, page: number) {
return useQuery({
queryKey: ['skills', skillId, 'reviews', page],
queryFn: () => listReviews(skillId, page),
enabled: skillId > 0,
})
}
export function useMySkillReview(skillId: number, enabled: boolean) {
return useQuery({
queryKey: ['skills', skillId, 'reviews', 'me'],
queryFn: () => getMyReview(skillId),
enabled: enabled && skillId > 0,
})
}
function useReviewMutationInvalidation(skillId: number) {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'reviews'] })
queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'rating'] })
queryClient.invalidateQueries({ queryKey: ['skills'] })
}
}
export function useUpsertSkillReview(skillId: number) {
const invalidate = useReviewMutationInvalidation(skillId)
return useMutation({
mutationFn: (input: ReviewInput) => upsertReview(skillId, input),
onSuccess: invalidate,
})
}
export function useClearSkillReview(skillId: number) {
const invalidate = useReviewMutationInvalidation(skillId)
return useMutation({
mutationFn: () => clearReview(skillId),
onSuccess: invalidate,
})
}
export function useModerateSkillReview(skillId: number) {
const invalidate = useReviewMutationInvalidation(skillId)
return useMutation({
mutationFn: ({ reviewId, action }: { reviewId: number; action: 'hide' | 'restore' }) =>
moderateReview(reviewId, action),
onSuccess: invalidate,
})
}

View file

@ -1299,6 +1299,40 @@
"ratingInput": {
"yourRating": "Your rating: {{score}} stars"
},
"skillReviews": {
"title": "User reviews",
"count": "{{count}} reviews",
"count_one": "{{count}} review",
"count_other": "{{count}} reviews",
"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",
"delete": "Delete review",
"saved": "Review saved",
"deleted": "Review deleted; your star rating was kept",
"textRequired": "Enter a review before saving",
"saveFailed": "Could not save review",
"deleteFailed": "Could not delete review",
"loadFailed": "Could not load reviews. Try again later.",
"empty": "No reviews yet. Be the first to share your experience.",
"loading": "Loading reviews...",
"hiddenStatus": "Hidden",
"yourReviewHidden": "Your review is hidden from the public list.",
"moderationReason": "Reason: {{reason}}",
"hide": "Hide review",
"restore": "Restore review",
"hidden": "Review hidden",
"restored": "Review restored",
"moderationFailed": "Could not update review visibility",
"previous": "Previous",
"next": "Next"
},
"createToken": {
"title": "Create API Token",
"description": "Create a new API Token for CLI or API access",

View file

@ -1511,6 +1511,42 @@
"ratingInput": {
"yourRating": "Ваша оценка: {{score}} зв."
},
"skillReviews": {
"title": "Отзывы пользователей",
"count": "Отзывов: {{count}}",
"count_one": "{{count}} отзыв",
"count_few": "{{count}} отзыва",
"count_many": "{{count}} отзывов",
"count_other": "{{count}} отзыва",
"write": "Написать отзыв",
"edit": "Изменить мой отзыв",
"scoreLabel": "Ваша оценка",
"ratingDisplay": "Оценка: {{score}} из 5 звёзд",
"ratingOption": "Поставить {{score}} из 5 звёзд",
"reviewTextLabel": "Текст отзыва",
"placeholder": "Расскажите об опыте использования и важных деталях.",
"save": "Сохранить отзыв",
"cancel": "Отмена",
"delete": "Удалить отзыв",
"saved": "Отзыв сохранён",
"deleted": "Отзыв удалён, оценка сохранена",
"textRequired": "Введите текст отзыва",
"saveFailed": "Не удалось сохранить отзыв",
"deleteFailed": "Не удалось удалить отзыв",
"loadFailed": "Не удалось загрузить отзывы. Повторите позже.",
"empty": "Отзывов пока нет. Поделитесь опытом первым.",
"loading": "Загрузка отзывов...",
"hiddenStatus": "Скрыт",
"yourReviewHidden": "Ваш отзыв скрыт из публичного списка.",
"moderationReason": "Причина: {{reason}}",
"hide": "Скрыть отзыв",
"restore": "Восстановить отзыв",
"hidden": "Отзыв скрыт",
"restored": "Отзыв восстановлен",
"moderationFailed": "Не удалось изменить видимость отзыва",
"previous": "Назад",
"next": "Далее"
},
"review": {
"detail": "Детали ревью",
"id": "ID ревью",

View file

@ -1299,6 +1299,39 @@
"ratingInput": {
"yourRating": "你的评分: {{score}} 星"
},
"skillReviews": {
"title": "用户评价",
"count": "共 {{count}} 条评价",
"count_other": "共 {{count}} 条评价",
"write": "写评价",
"edit": "编辑我的评价",
"scoreLabel": "你的评分",
"ratingDisplay": "评分5 星中 {{score}} 星",
"ratingOption": "评为 5 星中 {{score}} 星",
"reviewTextLabel": "评价内容",
"placeholder": "说说使用体验,以及其他用户需要了解的信息。",
"save": "保存评价",
"cancel": "取消",
"delete": "删除评价",
"saved": "评价已保存",
"deleted": "评价已删除,星级评分已保留",
"textRequired": "请先填写评价内容",
"saveFailed": "评价保存失败",
"deleteFailed": "评价删除失败",
"loadFailed": "评价加载失败,请稍后重试。",
"empty": "还没有评价,来分享第一条使用体验吧。",
"loading": "正在加载评价...",
"hiddenStatus": "已隐藏",
"yourReviewHidden": "你的评价已从公开列表隐藏。",
"moderationReason": "原因:{{reason}}",
"hide": "隐藏评价",
"restore": "恢复评价",
"hidden": "评价已隐藏",
"restored": "评价已恢复",
"moderationFailed": "评价可见性更新失败",
"previous": "上一页",
"next": "下一页"
},
"createToken": {
"title": "创建 API Token",
"description": "创建一个新的 API Token 用于 CLI 或 API 访问",

View file

@ -15,9 +15,15 @@ function placeholders(text: string): string[] {
return [...text.matchAll(/\{\{[^}]+\}\}/g)].map((match) => match[0]).sort()
}
const pluralSuffix = /_(zero|one|two|few|many|other)$/
function normalizedLeafKeys(value: unknown): string[] {
return [...new Set(leafKeys(value).map((key) => key.replace(pluralSuffix, '_plural')))]
}
describe('russian locale', () => {
it('mirrors the english key tree', () => {
expect(leafKeys(ru).sort()).toEqual(leafKeys(en).sort())
expect(normalizedLeafKeys(ru).sort()).toEqual(normalizedLeafKeys(en).sort())
})
it('preserves interpolation placeholders', () => {
@ -36,7 +42,11 @@ describe('russian locale', () => {
for (const part of parts) {
cursor = (cursor as Record<string, unknown>)[part]
}
if (placeholders(String(cursor)).join() !== placeholders(enMap[key] ?? '').join()) {
const englishReference = enMap[key] ?? enMap[key.replace(pluralSuffix, '_other')]
if (englishReference === undefined) {
throw new Error(`missing English reference for ${key}`)
}
if (placeholders(String(cursor)).join() !== placeholders(englishReference).join()) {
mismatches.push(key)
}
}

View file

@ -348,6 +348,20 @@ describe('SkillDetailPage', () => {
expect(html).not.toContain('skillDetail.deleteSkill')
})
it('wraps a long skill name instead of widening the mobile page', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({ displayName: 'review-runtime-1788284593-353294' }),
isLoading: false,
isFetching: false,
error: null,
})
const html = renderToStaticMarkup(<SkillDetailPage />)
expect(html).toContain('text-balance break-words text-4xl')
expect(html).toContain('[overflow-wrap:anywhere]')
})
it('shows the label management panel for a user who can manage the skill lifecycle', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({

View file

@ -26,6 +26,7 @@ import { isSkillDetailQueriesEnabled } from './skill-detail-query'
import { RatingInput } from '@/features/social/rating-input'
import { StarButton } from '@/features/social/star-button'
import { SubscribeButton } from '@/features/social/subscribe-button'
import { SkillReviews } from '@/features/social/skill-reviews'
import { useAuth } from '@/features/auth/use-auth'
import { adminApi, ApiError, buildApiUrl, WEB_API_PREFIX } from '@/api/client'
import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
@ -837,7 +838,7 @@ export function SkillDetailPage() {
</span>
)}
</div>
<h1 className="text-balance text-4xl font-bold font-heading text-foreground">{skill.displayName}</h1>
<h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">{skill.displayName}</h1>
{skill.ownerDisplayName && (
<div className="flex min-w-0">
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-sm text-muted-foreground shadow-sm backdrop-blur-sm">
@ -1077,6 +1078,8 @@ export function SkillDetailPage() {
</Card>
</TabsContent>
</Tabs>
<SkillReviews skillId={skill.id} canInteract={canInteract} onRequireLogin={requireLogin} />
</div>
{/* Sidebar */}