feat(interaction): 支持游客互动与登录合并

This commit is contained in:
Zhou Weidong committed 2026-10-26 22:34:14 +08:00
1 parent 2fe24b9db7
commit 4b0fbd73d3
41 files changed
+1207 -90

No files matched your search

@@ -18,6 +18,7 @@ public class ContentLike {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;
private String deviceId;
/** DRAMA/EPISODE/COMMENT */
private String targetType;
private Long targetId;
@@ -4,6 +4,7 @@ package com.fruitshorts.interaction.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.interaction.dal.entity.ContentComment;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -12,6 +13,9 @@ import java.util.List;
public interface ContentCommentMapper extends BaseMapper<ContentComment> {
@Delete("DELETE FROM content_comment WHERE id = #{id}")
int physicalDeleteById(@Param("id") Long id);
/**
* 后台举报工单按评论 id 批量读取,包含逻辑删除记录。
*
@@ -20,4 +20,13 @@ public interface ContentLikeMapper extends BaseMapper<ContentLike> {
+ "AND target_id = #{targetId}")
int physicalDelete(@Param("userId") Long userId, @Param("targetType") String targetType,
@Param("targetId") Long targetId);
@Delete("DELETE FROM content_like WHERE user_id IS NULL AND device_id = #{deviceId} "
+ "AND target_type = #{targetType} AND target_id = #{targetId}")
int physicalDeleteDevice(@Param("deviceId") String deviceId,
@Param("targetType") String targetType,
@Param("targetId") Long targetId);
@Delete("DELETE FROM content_like WHERE id = #{id}")
int physicalDeleteById(@Param("id") Long id);
}
@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
@@ -78,6 +79,11 @@ public class CommentService {
@Transactional(rollbackFor = Exception.class)
public CommentRespVO publish(Long userId, CommentPublishReqVO req, String ip, String deviceId) {
return publish(new InteractionIdentity(userId, deviceId), req, ip);
}
@Transactional(rollbackFor = Exception.class)
public CommentRespVO publish(InteractionIdentity identity, CommentPublishReqVO req, String ip) {
dramaService.getPublished(req.getDramaId());
if (req.getEpisodeId() != null) {
episodeService.getPublished(req.getEpisodeId());
@@ -90,7 +96,8 @@ public class CommentService {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"评论最多 " + properties.getCommentMaxLength() + " 字");
}
checkRate(userId);
checkRate(identity);
checkDuplicate(identity, req, text);
SensitiveWordCheck check = sensitiveWordService.check(text);
if (check.blocked()) {
@@ -110,7 +117,7 @@ public class CommentService {
ContentComment comment = new ContentComment();
comment.setDramaId(req.getDramaId());
comment.setEpisodeId(req.getEpisodeId() == null ? 0L : req.getEpisodeId());
comment.setUserId(userId);
comment.setUserId(identity.userId());
comment.setParentId(parent == null ? 0L : parent.getId());
comment.setRootId(resolveRootId(parent));
comment.setContent(check.sanitized());
@@ -121,7 +128,7 @@ public class CommentService {
comment.setReplyCount(0);
comment.setReportCount(0);
comment.setIp(ip == null ? "" : ip);
comment.setDeviceId(deviceId == null ? "" : deviceId);
comment.setDeviceId(identity.authenticated() ? "" : identity.deviceId());
comment.setCountryCode("");
commentMapper.insert(comment);
@@ -134,13 +141,22 @@ public class CommentService {
.eq(ContentComment::getId, parent.getId())
.setSql("reply_count = reply_count + 1"));
}
return toResp(comment, appUserMapper.selectById(userId), false, userId);
return toResp(
comment,
identity.authenticated() ? appUserMapper.selectById(identity.userId()) : null,
false,
identity);
}
/**
* 游标翻页。unsetRootId 表示只看顶层评论;传 rootId 则看该楼层的回复。
*/
public CursorResult<CommentRespVO> page(Long meUserId, CommentQueryReqVO req) {
return page(meUserId, "", req);
}
public CursorResult<CommentRespVO> page(
Long meUserId, String meDeviceId, CommentQueryReqVO req) {
if (req.getDramaId() == null) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "缺少 dramaId");
}
@@ -151,8 +167,13 @@ public class CommentService {
.eq(req.getEpisodeId() != null, ContentComment::getEpisodeId, req.getEpisodeId())
.eq(ContentComment::getRootId, rootFilter)
.lt(req.getCursor() != null, ContentComment::getId, req.getCursor());
if (meUserId == null) {
if (meUserId == null && (meDeviceId == null || meDeviceId.isBlank())) {
wrapper.eq(ContentComment::getStatus, STATUS_PUBLISHED);
} else if (meUserId == null) {
wrapper.and(w -> w.eq(ContentComment::getStatus, STATUS_PUBLISHED)
.or(x -> x.eq(ContentComment::getStatus, STATUS_PENDING)
.isNull(ContentComment::getUserId)
.eq(ContentComment::getDeviceId, meDeviceId)));
} else {
// 已发布 + 自己待审核的
wrapper.and(w -> w.eq(ContentComment::getStatus, STATUS_PUBLISHED)
@@ -166,13 +187,25 @@ public class CommentService {
rows = rows.subList(0, size);
}
Long nextCursor = rows.isEmpty() ? null : rows.get(rows.size() - 1).getId();
return new CursorResult<>(toRespList(rows, meUserId), hasMore ? nextCursor : null, hasMore);
return new CursorResult<>(
toRespList(rows, new InteractionIdentity(meUserId, meDeviceId)),
hasMore ? nextCursor : null,
hasMore);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long userId, Long commentId) {
delete(new InteractionIdentity(userId, ""), commentId);
}
@Transactional(rollbackFor = Exception.class)
public void delete(InteractionIdentity identity, Long commentId) {
ContentComment comment = getExisting(commentId);
if (!comment.getUserId().equals(userId)) {
boolean mine = identity.authenticated()
? identity.userId().equals(comment.getUserId())
: comment.getUserId() == null
&& identity.deviceId().equals(comment.getDeviceId());
if (!mine) {
throw new ServiceException(ErrorCode.FORBIDDEN, "只能删除自己的评论");
}
softDelete(comment);
@@ -278,7 +311,7 @@ public class CommentService {
req.getKeyword())
.orderByDesc(ContentComment::getId));
List<ContentComment> rows = result.getRecords();
List<CommentRespVO> list = toRespList(rows, null);
List<CommentRespVO> list = toRespList(rows, new InteractionIdentity(null, ""));
fillAdminContentInfo(rows, list);
return new PageResult<>(result.getTotal(), list);
}
@@ -349,15 +382,87 @@ public class CommentService {
commentMapper.updateById(comment);
}
private void checkRate(Long userId) {
Long recent = commentMapper.selectCount(Wrappers.<ContentComment>lambdaQuery()
.eq(ContentComment::getUserId, userId)
.ge(ContentComment::getCreatedAt, LocalDateTime.now().minusMinutes(1)));
private void checkRate(InteractionIdentity identity) {
var query = Wrappers.<ContentComment>lambdaQuery()
.ge(ContentComment::getCreatedAt, LocalDateTime.now().minusMinutes(1));
if (identity.authenticated()) {
query.eq(ContentComment::getUserId, identity.userId());
} else {
query.isNull(ContentComment::getUserId)
.eq(ContentComment::getDeviceId, identity.deviceId());
}
Long recent = commentMapper.selectCount(query);
if (recent != null && recent >= properties.getCommentPerMinute()) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "发言过于频繁,请稍后再试");
}
}
private void checkDuplicate(
InteractionIdentity identity, CommentPublishReqVO req, String text) {
var query = Wrappers.<ContentComment>lambdaQuery()
.eq(ContentComment::getDramaId, req.getDramaId())
.eq(ContentComment::getEpisodeId,
req.getEpisodeId() == null ? 0L : req.getEpisodeId())
.eq(ContentComment::getParentId,
req.getParentId() == null ? 0L : req.getParentId())
.eq(ContentComment::getContent, text)
.ne(ContentComment::getStatus, STATUS_DELETED)
.ge(ContentComment::getCreatedAt, LocalDateTime.now().minusMinutes(1));
if (identity.authenticated()) {
query.eq(ContentComment::getUserId, identity.userId());
} else {
query.isNull(ContentComment::getUserId)
.eq(ContentComment::getDeviceId, identity.deviceId());
}
Long count = commentMapper.selectCount(query);
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "请勿重复发送相同评论");
}
}
/** 登录时把设备评论归并到账号;重复内容删除设备侧记录。 */
@Transactional(rollbackFor = Exception.class)
public MergeResult mergeDeviceIntoUser(Long userId, String deviceId) {
if (userId == null || !StringUtils.hasText(deviceId)) {
return new MergeResult(0, 0);
}
List<ContentComment> deviceComments = commentMapper.selectList(
Wrappers.<ContentComment>lambdaQuery()
.isNull(ContentComment::getUserId)
.eq(ContentComment::getDeviceId, deviceId));
int migrated = 0;
int duplicates = 0;
for (ContentComment deviceComment : deviceComments) {
ContentComment existing = commentMapper.selectOne(
Wrappers.<ContentComment>lambdaQuery()
.eq(ContentComment::getUserId, userId)
.eq(ContentComment::getDramaId, deviceComment.getDramaId())
.eq(ContentComment::getEpisodeId, deviceComment.getEpisodeId())
.eq(ContentComment::getParentId, deviceComment.getParentId())
.eq(ContentComment::getContent, deviceComment.getContent())
.ne(ContentComment::getStatus, STATUS_DELETED)
.last("limit 1"));
if (existing != null) {
if (commentMapper.physicalDeleteById(deviceComment.getId()) > 0
&& deviceComment.getParentId() != null
&& deviceComment.getParentId() > 0) {
commentMapper.update(null, Wrappers.<ContentComment>lambdaUpdate()
.eq(ContentComment::getId, deviceComment.getParentId())
.setSql("reply_count = GREATEST(reply_count - 1, 0)"));
}
duplicates++;
continue;
}
deviceComment.setUserId(userId);
commentMapper.updateById(deviceComment);
migrated++;
}
return new MergeResult(migrated, duplicates);
}
public record MergeResult(int migrated, int duplicates) {
}
private Long resolveRootId(ContentComment parent) {
if (parent == null) {
return 0L;
@@ -371,27 +476,45 @@ public class CommentService {
return Math.min(value, properties.getPageMaxSize());
}
private List<CommentRespVO> toRespList(List<ContentComment> rows, Long meUserId) {
private List<CommentRespVO> toRespList(
List<ContentComment> rows, InteractionIdentity identity) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, AppUser> users = new HashMap<>();
List<Long> userIds = rows.stream().map(ContentComment::getUserId).distinct().toList();
for (AppUser user : appUserMapper.selectBatchIds(userIds)) {
users.put(user.getId(), user);
List<Long> userIds = rows.stream()
.map(ContentComment::getUserId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (!userIds.isEmpty()) {
for (AppUser user : appUserMapper.selectBatchIds(userIds)) {
users.put(user.getId(), user);
}
}
Set<Long> likedIds = new HashSet<>();
if (meUserId != null) {
for (ContentLike like : likeMapper.selectList(Wrappers.<ContentLike>lambdaQuery()
.eq(ContentLike::getUserId, meUserId)
if (identity.userId() != null || StringUtils.hasText(identity.deviceId())) {
var query = Wrappers.<ContentLike>lambdaQuery()
.eq(ContentLike::getTargetType, "COMMENT")
.in(ContentLike::getTargetId, rows.stream().map(ContentComment::getId).toList()))) {
.in(ContentLike::getTargetId,
rows.stream().map(ContentComment::getId).toList());
if (identity.authenticated()) {
query.eq(ContentLike::getUserId, identity.userId());
} else {
query.isNull(ContentLike::getUserId)
.eq(ContentLike::getDeviceId, identity.deviceId());
}
for (ContentLike like : likeMapper.selectList(query)) {
likedIds.add(like.getTargetId());
}
}
List<CommentRespVO> list = new ArrayList<>(rows.size());
for (ContentComment row : rows) {
list.add(toResp(row, users.get(row.getUserId()), likedIds.contains(row.getId()), meUserId));
list.add(toResp(
row,
users.get(row.getUserId()),
likedIds.contains(row.getId()),
identity));
}
return list;
}
@@ -429,7 +552,11 @@ public class CommentService {
}
}
private CommentRespVO toResp(ContentComment comment, AppUser author, boolean liked, Long meUserId) {
private CommentRespVO toResp(
ContentComment comment,
AppUser author,
boolean liked,
InteractionIdentity identity) {
CommentRespVO vo = new CommentRespVO();
vo.setId(comment.getId());
vo.setDramaId(comment.getDramaId());
@@ -444,12 +571,20 @@ public class CommentService {
vo.setReplyCount(comment.getReplyCount());
vo.setReportCount(comment.getReportCount());
vo.setCreatedAt(comment.getCreatedAt());
vo.setMine(meUserId != null && meUserId.equals(comment.getUserId()));
boolean mine = identity.authenticated()
? identity.userId().equals(comment.getUserId())
: comment.getUserId() == null
&& identity.deviceId().equals(comment.getDeviceId());
vo.setMine(mine);
vo.setLiked(liked);
if (author != null) {
vo.setUid(author.getUid());
vo.setNickname(author.getNickname());
vo.setAvatarUrl(author.getAvatarUrl());
} else {
vo.setUid("");
vo.setNickname("游客");
vo.setAvatarUrl("");
}
return vo;
}
@@ -5,6 +5,7 @@ package com.fruitshorts.interaction.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
@@ -20,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@@ -44,6 +46,11 @@ public class LikeService {
@Transactional(rollbackFor = Exception.class)
public LikeToggleRespVO toggle(Long userId, LikeToggleReqVO req) {
return toggle(new InteractionIdentity(userId, ""), req);
}
@Transactional(rollbackFor = Exception.class)
public LikeToggleRespVO toggle(InteractionIdentity identity, LikeToggleReqVO req) {
String type = req.getTargetType().trim().toUpperCase(Locale.ROOT);
if (!TYPE_DRAMA.equals(type) && !TYPE_EPISODE.equals(type) && !TYPE_COMMENT.equals(type)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧、剧集或评论点赞");
@@ -51,22 +58,26 @@ public class LikeService {
Long targetId = req.getTargetId();
validateTarget(type, targetId);
ContentLike existing = likeMapper.selectOne(Wrappers.<ContentLike>lambdaQuery()
.eq(ContentLike::getUserId, userId)
ContentLike existing = likeMapper.selectOne(identityQuery(identity)
.eq(ContentLike::getTargetType, type)
.eq(ContentLike::getTargetId, targetId)
.last("limit 1"));
boolean liked;
if (existing == null) {
ContentLike like = new ContentLike();
like.setUserId(userId);
like.setUserId(identity.userId());
like.setDeviceId(identity.authenticated() ? "" : identity.deviceId());
like.setTargetType(type);
like.setTargetId(targetId);
likeMapper.insert(like);
liked = true;
adjustCount(type, targetId, 1);
} else {
likeMapper.physicalDelete(userId, type, targetId);
if (identity.authenticated()) {
likeMapper.physicalDelete(identity.userId(), type, targetId);
} else {
likeMapper.physicalDeleteDevice(identity.deviceId(), type, targetId);
}
liked = false;
adjustCount(type, targetId, -1);
}
@@ -91,7 +102,13 @@ public class LikeService {
/** 批量判断用户是否已点赞,一次查询覆盖多个目标。 */
public Set<Long> likedTargetIds(Long userId, String type, Collection<Long> targetIds) {
if (userId == null || targetIds == null || targetIds.isEmpty()) {
return likedTargetIds(userId, "", type, targetIds);
}
/** 登录用户优先;匿名状态按设备号查询。 */
public Set<Long> likedTargetIds(Long userId, String deviceId, String type,
Collection<Long> targetIds) {
if (targetIds == null || targetIds.isEmpty()) {
return Set.of();
}
Set<Long> normalized = new HashSet<>();
@@ -103,8 +120,10 @@ public class LikeService {
if (normalized.isEmpty()) {
return Set.of();
}
return likeMapper.selectList(Wrappers.<ContentLike>lambdaQuery()
.eq(ContentLike::getUserId, userId)
if (userId == null && (deviceId == null || deviceId.isBlank())) {
return Set.of();
}
return likeMapper.selectList(identityQuery(new InteractionIdentity(userId, deviceId))
.eq(ContentLike::getTargetType, type)
.in(ContentLike::getTargetId, normalized))
.stream()
@@ -112,6 +131,52 @@ public class LikeService {
.collect(java.util.stream.Collectors.toSet());
}
/** 登录时把设备点赞归并到账号;重复关系删除并回退目标计数。 */
@Transactional(rollbackFor = Exception.class)
public MergeResult mergeDeviceIntoUser(Long userId, String deviceId) {
if (userId == null || deviceId == null || deviceId.isBlank()) {
return new MergeResult(0, 0);
}
List<ContentLike> deviceLikes = likeMapper.selectList(
Wrappers.<ContentLike>lambdaQuery()
.isNull(ContentLike::getUserId)
.eq(ContentLike::getDeviceId, deviceId));
int migrated = 0;
int duplicates = 0;
for (ContentLike deviceLike : deviceLikes) {
ContentLike existing = likeMapper.selectOne(Wrappers.<ContentLike>lambdaQuery()
.eq(ContentLike::getUserId, userId)
.eq(ContentLike::getTargetType, deviceLike.getTargetType())
.eq(ContentLike::getTargetId, deviceLike.getTargetId())
.last("limit 1"));
if (existing != null) {
if (likeMapper.physicalDeleteById(deviceLike.getId()) > 0) {
adjustCount(deviceLike.getTargetType(), deviceLike.getTargetId(), -1);
}
duplicates++;
continue;
}
deviceLike.setUserId(userId);
likeMapper.updateById(deviceLike);
migrated++;
}
return new MergeResult(migrated, duplicates);
}
private com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ContentLike>
identityQuery(InteractionIdentity identity) {
if (identity.authenticated()) {
return Wrappers.<ContentLike>lambdaQuery()
.eq(ContentLike::getUserId, identity.userId());
}
return Wrappers.<ContentLike>lambdaQuery()
.isNull(ContentLike::getUserId)
.eq(ContentLike::getDeviceId, identity.deviceId());
}
public record MergeResult(int migrated, int duplicates) {
}
private void adjustCount(String type, Long targetId, int delta) {
if (TYPE_DRAMA.equals(type)) {
dramaService.adjustLikeCount(targetId, delta);
@@ -20,4 +20,7 @@ public class CommentPublishReqVO {
/** 回复某条评论时传它的 id;顶层评论不传 */
private Long parentId;
/** 兼容旧调用;正常客户端通过 X-Device-Id 下发。 */
private String deviceId;
}
@@ -16,4 +16,7 @@ public class LikeToggleReqVO {
@NotNull(message = "缺少 targetId")
private Long targetId;
/** 兼容旧调用;正常客户端通过 X-Device-Id 下发。 */
private String deviceId;
}