BookReviewServiceImpl.java
package org.darkroomlibrary.service.impl;
import org.darkroomlibrary.context.CurrentUserContext;
import org.darkroomlibrary.mapper.BookMapper;
import org.darkroomlibrary.mapper.BookReviewLikeMapper;
import org.darkroomlibrary.mapper.BookReviewMapper;
import org.darkroomlibrary.mapper.BookReviewReplyMapper;
import org.darkroomlibrary.mapper.BookReviewReportMapper;
import org.darkroomlibrary.mapper.UserMapper;
import org.darkroomlibrary.web.response.ApiResponse;
import org.darkroomlibrary.web.response.PageResponse;
import org.darkroomlibrary.web.dto.query.BookReviewPageQuery;
import org.darkroomlibrary.web.dto.command.BookReviewCreateDto;
import org.darkroomlibrary.web.dto.command.BookReviewUpdateDto;
import org.darkroomlibrary.domain.model.Book;
import org.darkroomlibrary.domain.model.BookReview;
import org.darkroomlibrary.domain.model.BookReviewLike;
import org.darkroomlibrary.domain.model.BookReviewReply;
import org.darkroomlibrary.domain.model.BookReviewReport;
import org.darkroomlibrary.domain.model.User;
import org.darkroomlibrary.web.view.BookReviewReplyView;
import org.darkroomlibrary.web.view.BookReviewView;
import org.darkroomlibrary.web.view.InteractionSummary;
import org.darkroomlibrary.service.ContentPostingPolicy;
import org.darkroomlibrary.service.OperationAuditService;
import org.darkroomlibrary.service.BookReviewService;
import org.darkroomlibrary.service.support.RecommendationSourceVersionService;
import org.darkroomlibrary.utils.ContentSanitizer;
import org.darkroomlibrary.utils.IdListUtils;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 图书评价服务实现
*/
@Service
public class BookReviewServiceImpl implements BookReviewService {
@Resource
private BookMapper bookMapper;
@Resource
private BookReviewMapper bookReviewMapper;
@Resource
private BookReviewLikeMapper bookReviewLikeMapper;
@Resource
private BookReviewReplyMapper bookReviewReplyMapper;
@Resource
private BookReviewReportMapper bookReviewReportMapper;
@Resource
private OperationAuditService operationAuditService;
@Resource
private ContentPostingPolicy contentPostingPolicy;
@Resource
private UserMapper userMapper;
@Resource
private RecommendationSourceVersionService recommendationSourceVersionService;
@Override
@Transactional
public ApiResponse<Void> save(BookReviewCreateDto dto) {
if (dto == null) {
return ApiResponse.error("评价参数不能为空");
}
String postingError = contentPostingPolicy.currentUserRejectionReason();
if (postingError != null) {
return ApiResponse.error(postingError);
}
Book book = dto.getBookId() == null ? null : bookMapper.findByIdForUpdate(dto.getBookId());
if (book == null || Boolean.TRUE.equals(book.getIsDeleted())) {
return ApiResponse.error("图书不存在或已下架");
}
Integer userId = CurrentUserContext.userId();
if (dto.getRating() == null || dto.getRating() < 1 || dto.getRating() > 5) {
return ApiResponse.error("评分必须在1-5之间");
}
if (ContentSanitizer.exceedsLength(
dto.getContent(), ContentSanitizer.REVIEW_MAX_LENGTH)) {
return ApiResponse.error("评价内容不能超过1000个字符");
}
String cleanContent = ContentSanitizer.plainText(dto.getContent());
if (cleanContent == null || cleanContent.isEmpty()) {
return ApiResponse.error("评价内容不能为空");
}
BookReview bookReview = BookReview.builder()
.userId(userId)
.bookId(dto.getBookId())
.rating(dto.getRating())
.content(cleanContent)
.status(0)
.createTime(LocalDateTime.now())
.build();
if (bookReviewMapper.insert(bookReview) != 1) {
return ApiResponse.error("评价发布失败,请重试");
}
recommendationSourceVersionService.invalidateUserAndGlobalAfterCommit(userId);
return ApiResponse.success("评价成功");
}
@Override
@Transactional
public ApiResponse<Void> update(BookReviewUpdateDto dto) {
if (dto == null || dto.getId() == null) {
return ApiResponse.error("评价参数不能为空");
}
String postingError = contentPostingPolicy.currentUserRejectionReason();
if (postingError != null) {
return ApiResponse.error(postingError);
}
BookReview exist = bookReviewMapper.findByIdForUpdate(dto.getId());
if (exist == null) {
return ApiResponse.error("评价不存在");
}
Integer userId = CurrentUserContext.userId();
if (!exist.getUserId().equals(userId)) {
return ApiResponse.error("只能修改自己的评价");
}
if (dto.getRating() != null
&& (dto.getRating() < 1 || dto.getRating() > 5)) {
return ApiResponse.error("评分必须在1-5之间");
}
String cleanContent = null;
if (dto.getContent() != null) {
if (ContentSanitizer.exceedsLength(
dto.getContent(), ContentSanitizer.REVIEW_MAX_LENGTH)) {
return ApiResponse.error("评价内容不能超过1000个字符");
}
cleanContent = ContentSanitizer.plainText(dto.getContent());
if (cleanContent == null || cleanContent.isEmpty()) {
return ApiResponse.error("评价内容不能为空");
}
}
BookReview update = BookReview.builder()
.id(dto.getId())
.rating(dto.getRating())
.content(cleanContent)
.build();
if (bookReviewMapper.update(update) == 0) {
return ApiResponse.error("评价状态已变化,请刷新后重试");
}
recommendationSourceVersionService.invalidateUserAndGlobalAfterCommit(userId);
return ApiResponse.success("修改评价成功");
}
@Override
@Transactional
public ApiResponse<Void> batchDelete(List<Integer> ids) {
List<Integer> normalizedIds = IdListUtils.normalize(ids);
if (normalizedIds.isEmpty()) {
return ApiResponse.error("请选择要删除的评价");
}
if (IdListUtils.exceedsBatchLimit(normalizedIds)) {
return ApiResponse.error("单次最多删除" + IdListUtils.MAX_BATCH_SIZE + "条评价");
}
List<BookReview> reviews = bookReviewMapper.findByIdsForUpdate(normalizedIds);
if (reviews.size() != normalizedIds.size()) {
return ApiResponse.error("部分评价不存在");
}
// 非管理员只能删除自己的评价
if (!CurrentUserContext.isAdministrator()) {
Integer currentUserId = CurrentUserContext.userId();
for (BookReview review : reviews) {
if (!Objects.equals(review.getUserId(), currentUserId)) {
return ApiResponse.error("只能删除自己的评价");
}
}
}
boolean adminOperation = CurrentUserContext.isAdministrator();
bookReviewLikeMapper.deleteByReviewIds(normalizedIds);
int deletedReplyCount = bookReviewReplyMapper.deleteByReviewIds(normalizedIds);
bookReviewReportMapper.deleteByReviewIds(normalizedIds);
if (bookReviewMapper.batchDelete(normalizedIds) != normalizedIds.size()) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error("评价状态已变化,请刷新后重试");
}
recommendationSourceVersionService.invalidateGlobalAfterCommit();
reviews.stream().map(BookReview::getUserId).filter(Objects::nonNull).distinct()
.forEach(recommendationSourceVersionService::invalidateUserAfterCommit);
if (adminOperation) {
operationAuditService.record("删除", "书评及回复",
"书评ID=" + normalizedIds + ",删除书评数=" + normalizedIds.size()
+ ",删除关联回复数=" + deletedReplyCount
+ ",关联点赞和举报记录已清理");
}
return ApiResponse.success("删除评价成功");
}
@Override
public ApiResponse<List<BookReviewView>> query(BookReviewPageQuery dto) {
List<BookReviewView> list = bookReviewMapper.query(dto);
fillInteractionInfo(list);
Integer total = bookReviewMapper.queryCount(dto);
return PageResponse.success(list, total);
}
@Override
@Transactional
public ApiResponse<Boolean> toggleLike(Integer reviewId) {
String accountError = contentPostingPolicy.currentUserAccountRejectionReason();
if (accountError != null) {
return ApiResponse.error(accountError);
}
BookReview review = reviewId == null ? null : bookReviewMapper.findByIdForUpdate(reviewId);
if (review == null || !Objects.equals(review.getStatus(), 0)) {
return ApiResponse.error("评价不存在或已隐藏");
}
Integer userId = CurrentUserContext.userId();
Integer exists = bookReviewLikeMapper.countByReviewIdAndUserId(reviewId, userId);
if (exists != null && exists > 0) {
if (bookReviewLikeMapper.deleteByReviewIdAndUserId(reviewId, userId) != 1) {
return ApiResponse.error("点赞状态已变化,请刷新后重试");
}
return ApiResponse.success("已取消点赞", false);
}
BookReviewLike like = BookReviewLike.builder()
.reviewId(reviewId)
.userId(userId)
.createTime(LocalDateTime.now())
.build();
try {
if (bookReviewLikeMapper.insert(like) != 1) {
return ApiResponse.error("点赞失败,请重试");
}
} catch (DuplicateKeyException e) {
return ApiResponse.success("已点赞", true);
}
return ApiResponse.success("点赞成功", true);
}
@Override
@Transactional
public ApiResponse<Void> reply(Integer reviewId, String content, Integer replyToUserId) {
BookReview snapshot = reviewId == null ? null : bookReviewMapper.getById(reviewId);
if (snapshot == null || !Objects.equals(snapshot.getStatus(), 0)) {
return ApiResponse.error("评价不存在或已隐藏");
}
Map<Integer, User> lockedUsers =
lockUsers(CurrentUserContext.userId(), snapshot.getUserId());
User currentUser = lockedUsers.get(CurrentUserContext.userId());
String postingError = contentPostingPolicy.postingRejectionReason(currentUser);
if (postingError != null) {
return ApiResponse.error(postingError);
}
if (lockedUsers.get(snapshot.getUserId()) == null) {
return ApiResponse.error("书评作者不存在或账号已删除");
}
BookReview review = reviewId == null ? null : bookReviewMapper.findByIdForUpdate(reviewId);
if (review == null
|| !Objects.equals(review.getStatus(), 0)
|| !Objects.equals(review.getUserId(), snapshot.getUserId())) {
return ApiResponse.error("评价状态已变化,请刷新后重试");
}
if (ContentSanitizer.exceedsLength(content, ContentSanitizer.REVIEW_REPLY_MAX_LENGTH)) {
return ApiResponse.error("回复内容不能超过500个字符");
}
String cleanContent = ContentSanitizer.plainText(content);
if (cleanContent == null || cleanContent.isEmpty()) {
return ApiResponse.error("回复内容不能为空");
}
if (replyToUserId != null && !Objects.equals(replyToUserId, review.getUserId())) {
return ApiResponse.error("回复目标不属于当前书评");
}
BookReviewReply reply = BookReviewReply.builder()
.reviewId(reviewId)
.userId(CurrentUserContext.userId())
.replyToUserId(review.getUserId())
.content(cleanContent)
.createTime(LocalDateTime.now())
.build();
if (bookReviewReplyMapper.insert(reply) != 1) {
return ApiResponse.error("回复发布失败,请重试");
}
return ApiResponse.success("回复成功");
}
@Override
@Transactional
public ApiResponse<Void> report(Integer reviewId, String reason) {
String accountError = contentPostingPolicy.currentUserAccountRejectionReason();
if (accountError != null) {
return ApiResponse.error(accountError);
}
BookReview review = reviewId == null ? null : bookReviewMapper.findByIdForUpdate(reviewId);
if (review == null || !Objects.equals(review.getStatus(), 0)) {
return ApiResponse.error("评价不存在或已隐藏");
}
Integer userId = CurrentUserContext.userId();
if (Objects.equals(review.getUserId(), userId)) {
return ApiResponse.error("不能举报自己的评价");
}
String cleanReason = ContentSanitizer.plainText(reason);
cleanReason = cleanReason == null ? "" : cleanReason;
if (cleanReason.isEmpty()) {
return ApiResponse.error("举报原因不能为空");
}
if (cleanReason.length() > 200) {
return ApiResponse.error("举报原因不能超过200字");
}
Integer exists = bookReviewReportMapper.countByReviewIdAndUserId(reviewId, userId);
if (exists != null && exists > 0) {
return ApiResponse.error("你已举报过这条书评");
}
BookReviewReport report = BookReviewReport.builder()
.reviewId(reviewId)
.userId(userId)
.reason(cleanReason)
.status(0)
.createTime(LocalDateTime.now())
.build();
try {
if (bookReviewReportMapper.insert(report) != 1) {
return ApiResponse.error("举报提交失败,请重试");
}
} catch (DuplicateKeyException e) {
return ApiResponse.error("你已举报过这条书评");
}
return ApiResponse.success("举报已提交,等待管理员审核");
}
private Map<Integer, User> lockUsers(Integer... userIds) {
List<Integer> ids = java.util.Arrays.stream(userIds)
.filter(Objects::nonNull)
.distinct()
.sorted()
.collect(Collectors.toList());
List<User> users = ids.isEmpty() ? List.of() : userMapper.findByIdsForUpdate(ids);
Map<Integer, User> locked = new LinkedHashMap<>();
for (Integer id : ids) {
locked.put(id, null);
}
for (User user : users) {
locked.put(user.getId(), user);
}
return locked;
}
private void fillInteractionInfo(List<BookReviewView> list) {
if (list == null || list.isEmpty()) {
return;
}
Integer currentUserId = CurrentUserContext.userId();
List<Integer> reviewIds = list.stream()
.map(BookReviewView::getId)
.collect(Collectors.toList());
List<BookReviewReplyView> replies = bookReviewReplyMapper.queryByReviewIds(reviewIds);
Map<Integer, List<BookReviewReplyView>> replyMap = replies == null
? Collections.emptyMap()
: replies.stream().collect(Collectors.groupingBy(BookReviewReplyView::getReviewId));
Map<Integer, InteractionSummary> likeSummary = summariesByReviewId(
bookReviewLikeMapper.summarizeByReviewIds(reviewIds, currentUserId));
Map<Integer, InteractionSummary> reportSummary = summariesByReviewId(
bookReviewReportMapper.summarizeByReviewIds(reviewIds, currentUserId));
for (BookReviewView review : list) {
review.setContent(ContentSanitizer.plainText(review.getContent()));
InteractionSummary likes = likeSummary.get(review.getId());
InteractionSummary reports = reportSummary.get(review.getId());
review.setLikeCount(summaryCount(likes));
review.setReportCount(summaryCount(reports));
review.setLiked(currentUserId != null && viewerInteracted(likes));
review.setReported(currentUserId != null && viewerInteracted(reports));
List<BookReviewReplyView> reviewReplies =
replyMap.getOrDefault(review.getId(), Collections.emptyList());
for (BookReviewReplyView reply : reviewReplies) {
reply.setContent(ContentSanitizer.plainText(reply.getContent()));
}
review.setReplies(reviewReplies);
}
}
private Map<Integer, InteractionSummary> summariesByReviewId(List<InteractionSummary> summaries) {
if (summaries == null || summaries.isEmpty()) {
return Collections.emptyMap();
}
return summaries.stream().collect(Collectors.toMap(
InteractionSummary::getReviewId,
summary -> summary,
(left, right) -> left,
LinkedHashMap::new));
}
private int summaryCount(InteractionSummary summary) {
return summary == null || summary.getTotalCount() == null ? 0 : summary.getTotalCount();
}
private boolean viewerInteracted(InteractionSummary summary) {
return summary != null
&& summary.getViewerCount() != null
&& summary.getViewerCount() > 0;
}
}