feat: 06 互动与内容治理落地(评论 / 点赞 / 举报 / 敏感词 / 审核留证)
为什么: 1) 产品侧要先把内容治理闭环跑通才能谈上线:评论可发布、可翻页、可删除, 违规内容能在后台被审核与驳回,举报要有工单可追溯。本提交把这套补齐。 2) 审核不能只靠人肉:敏感词按 BLOCK/REPLACE/REVIEW 三档处置, 机审通过即发布(可配置为 PRE_REVIEW 全部先审后发),人审与机审的结论 都写进 content_audit_record,争议时可回答"当时凭什么放行/驳回"。 3) 举报处理必须与处置打通:结论成立会联动驳回被举报评论,并可选同时封禁被举报用户, 避免"工单处理完了但违规内容还挂着"。 4) 顺带修复一个真实缺陷:user_favorite / content_like 上有唯一索引,而全局配置会把 deleteById 改写成逻辑删除,导致取消收藏/点赞后旧的 deleted 行仍占位, 再次收藏或点赞直接撞唯一索引报错。关系类数据没有留证价值,改为显式物理删除, 并补了"取消后再来一次"的回归自测。
This commit is contained in:
1 parent
b3179fc836
commit
af1dc48494
48 files changed
+1752
-14
No files matched your search
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>fruitshorts-module-interaction</artifactId>
|
||||
<name>fruitshorts-module-interaction</name>
|
||||
<description>互动域:评论、点赞、举报、敏感词与审核留证</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-framework</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-content</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-user</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.fruitshorts.interaction.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 互动与内容治理策略。 */
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.interaction")
|
||||
public class InteractionProperties {
|
||||
|
||||
/**
|
||||
* 评论审核模式:
|
||||
* MACHINE_FIRST = 机审通过即发布,命中 REVIEW 级敏感词才转人工(开发/内容量小时用);
|
||||
* PRE_REVIEW = 全部先审后发(生产建议)。
|
||||
*/
|
||||
private String commentAuditMode = "MACHINE_FIRST";
|
||||
|
||||
/** 单条评论最大长度。 */
|
||||
private int commentMaxLength = 500;
|
||||
|
||||
/** 同一用户在同一剧集下每分钟最多发几条评论,防刷。 */
|
||||
private int commentPerMinute = 5;
|
||||
|
||||
/** 列表默认条数与上限。 */
|
||||
private int pageDefaultSize = 20;
|
||||
private int pageMaxSize = 50;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.fruitshorts.interaction.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 审核留证:机审与人审都记,争议时可回溯。 */
|
||||
@Data
|
||||
@TableName("content_audit_record")
|
||||
public class ContentAuditRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
/** COMMENT/DRAMA/AVATAR/CREATOR */
|
||||
private String bizType;
|
||||
private Long bizId;
|
||||
private Integer round;
|
||||
/** MACHINE/HUMAN */
|
||||
private String auditorType;
|
||||
private Long auditorId;
|
||||
/** PASS/REJECT/NEED_MORE */
|
||||
private String result;
|
||||
private String reasonCode;
|
||||
private String reasonText;
|
||||
private String snapshot;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.fruitshorts.interaction.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 评论与回复。先过敏感词,再按策略决定直接发布还是进人工审核。 */
|
||||
@Data
|
||||
@TableName("content_comment")
|
||||
public class ContentComment {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long dramaId;
|
||||
private Long episodeId;
|
||||
private Long userId;
|
||||
/** 直接回复的父评论;顶层评论为 0 */
|
||||
private Long parentId;
|
||||
/** 所属顶层评论,便于按楼层翻页;顶层评论为自己的 id */
|
||||
private Long rootId;
|
||||
private String content;
|
||||
/** pending/published/rejected/deleted */
|
||||
private String status;
|
||||
/** MACHINE/HUMAN */
|
||||
private String auditType;
|
||||
private String auditReason;
|
||||
private Integer likeCount;
|
||||
private Integer replyCount;
|
||||
private Integer reportCount;
|
||||
private String ip;
|
||||
private String deviceId;
|
||||
private String countryCode;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.fruitshorts.interaction.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 点赞关系。计数同步更新在目标行上,规模化后改为异步聚合。 */
|
||||
@Data
|
||||
@TableName("content_like")
|
||||
public class ContentLike {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** DRAMA/EPISODE/COMMENT */
|
||||
private String targetType;
|
||||
private Long targetId;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.fruitshorts.interaction.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 举报工单。 */
|
||||
@Data
|
||||
@TableName("content_report")
|
||||
public class ContentReport {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long reporterId;
|
||||
/** COMMENT/USER/DRAMA */
|
||||
private String targetType;
|
||||
private Long targetId;
|
||||
private String reasonCode;
|
||||
private String description;
|
||||
/** pending/handled/rejected */
|
||||
private String status;
|
||||
private Long handleBy;
|
||||
private LocalDateTime handleAt;
|
||||
private String handleNote;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.fruitshorts.interaction.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 分享事件,用于渠道效果与裂变分析。 */
|
||||
@Data
|
||||
@TableName("content_share")
|
||||
public class ContentShare {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long dramaId;
|
||||
private Long episodeId;
|
||||
/** WHATSAPP/IG/TIKTOK/COPYLINK */
|
||||
private String channel;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.fruitshorts.interaction.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 敏感词库。BLOCK 直接拒绝、REVIEW 进人工、REPLACE 替换后放行。 */
|
||||
@Data
|
||||
@TableName("sensitive_word")
|
||||
public class SensitiveWord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String word;
|
||||
/** POLITICS/PORN/ABUSE/AD/RELIGION */
|
||||
private String category;
|
||||
/** BLOCK/REVIEW/REPLACE */
|
||||
private String level;
|
||||
private String replaceText;
|
||||
private String language;
|
||||
private Integer status;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.fruitshorts.interaction.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 拉黑关系:被拉黑用户的评论对拉黑者不可见。 */
|
||||
@Data
|
||||
@TableName("user_blocklist")
|
||||
public class UserBlocklist {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long blockedUserId;
|
||||
/** MANUAL/COMMENT */
|
||||
private String source;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.interaction.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentAuditRecord;
|
||||
|
||||
public interface ContentAuditRecordMapper extends BaseMapper<ContentAuditRecord> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.interaction.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
||||
|
||||
public interface ContentCommentMapper extends BaseMapper<ContentComment> {
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.fruitshorts.interaction.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentLike;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
public interface ContentLikeMapper extends BaseMapper<ContentLike> {
|
||||
|
||||
/**
|
||||
* 物理删除点赞关系。
|
||||
*
|
||||
* <p>点赞是可反复切换的动作,而 (user_id, target_type, target_id) 上有唯一索引。
|
||||
* 走逻辑删除的话,取消点赞后旧行仍在,再点赞会直接撞唯一索引报错,
|
||||
* 所以这里显式物理删除 —— 点赞关系没有留证价值。
|
||||
*/
|
||||
@Delete("DELETE FROM content_like WHERE user_id = #{userId} AND target_type = #{targetType} "
|
||||
+ "AND target_id = #{targetId}")
|
||||
int physicalDelete(@Param("userId") Long userId, @Param("targetType") String targetType,
|
||||
@Param("targetId") Long targetId);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.interaction.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentReport;
|
||||
|
||||
public interface ContentReportMapper extends BaseMapper<ContentReport> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.interaction.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentShare;
|
||||
|
||||
public interface ContentShareMapper extends BaseMapper<ContentShare> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.interaction.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.interaction.dal.entity.SensitiveWord;
|
||||
|
||||
public interface SensitiveWordMapper extends BaseMapper<SensitiveWord> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.interaction.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.interaction.dal.entity.UserBlocklist;
|
||||
|
||||
public interface UserBlocklistMapper extends BaseMapper<UserBlocklist> {
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.fruitshorts.interaction.service;
|
||||
|
||||
import com.fruitshorts.interaction.dal.entity.ContentAuditRecord;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentAuditRecordMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 审核留证。机审与人审都记一条,争议时可以回答"当时凭什么放行/驳回"。
|
||||
* 后续创作者内容审核(第 3 步)也复用这里,避免各写一套。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuditRecorder {
|
||||
|
||||
public static final String TYPE_MACHINE = "MACHINE";
|
||||
public static final String TYPE_HUMAN = "HUMAN";
|
||||
|
||||
private final ContentAuditRecordMapper auditRecordMapper;
|
||||
|
||||
public void record(String bizType, Long bizId, String auditorType, Long auditorId,
|
||||
String result, String reasonCode, String reasonText, String snapshot) {
|
||||
ContentAuditRecord record = new ContentAuditRecord();
|
||||
record.setBizType(bizType);
|
||||
record.setBizId(bizId);
|
||||
record.setRound(nextRound(bizType, bizId));
|
||||
record.setAuditorType(auditorType);
|
||||
record.setAuditorId(auditorId == null ? 0L : auditorId);
|
||||
record.setResult(result);
|
||||
record.setReasonCode(reasonCode == null ? "" : reasonCode);
|
||||
record.setReasonText(reasonText == null ? "" : reasonText);
|
||||
record.setSnapshot(snapshot == null ? "" : snapshot);
|
||||
auditRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
private int nextRound(String bizType, Long bizId) {
|
||||
Long count = auditRecordMapper.selectCount(
|
||||
com.baomidou.mybatisplus.core.toolkit.Wrappers.<ContentAuditRecord>lambdaQuery()
|
||||
.eq(ContentAuditRecord::getBizType, bizType)
|
||||
.eq(ContentAuditRecord::getBizId, bizId));
|
||||
return (count == null ? 0 : count.intValue()) + 1;
|
||||
}
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
package com.fruitshorts.interaction.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
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.pojo.CursorResult;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
import com.fruitshorts.content.service.EpisodeService;
|
||||
import com.fruitshorts.interaction.config.InteractionProperties;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentAuditRecord;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentLike;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentAuditRecordMapper;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
|
||||
import com.fruitshorts.interaction.vo.CommentAuditReqVO;
|
||||
import com.fruitshorts.interaction.vo.CommentPublishReqVO;
|
||||
import com.fruitshorts.interaction.vo.CommentQueryReqVO;
|
||||
import com.fruitshorts.interaction.vo.CommentRespVO;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 评论:发布(含敏感词与审核策略)、游标翻页、删除、后台审核。
|
||||
*
|
||||
* <p>可见性规则:客户端只看得到已发布评论;自己待审核的评论也返回,便于展示"审核中"。
|
||||
* 翻页用游标(上一页最后一条的 id),新增评论不会导致翻页错位。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CommentService {
|
||||
|
||||
public static final String STATUS_PENDING = "pending";
|
||||
public static final String STATUS_PUBLISHED = "published";
|
||||
public static final String STATUS_REJECTED = "rejected";
|
||||
public static final String STATUS_DELETED = "deleted";
|
||||
|
||||
public static final String ROOT_TOP_LEVEL = "TOP";
|
||||
|
||||
private final ContentCommentMapper commentMapper;
|
||||
private final ContentLikeMapper likeMapper;
|
||||
private final ContentAuditRecordMapper auditRecordMapper;
|
||||
private final AppUserMapper appUserMapper;
|
||||
private final DramaService dramaService;
|
||||
private final EpisodeService episodeService;
|
||||
private final SensitiveWordService sensitiveWordService;
|
||||
private final AuditRecorder auditRecorder;
|
||||
private final InteractionProperties properties;
|
||||
|
||||
// ---------------- 客户端 ----------------
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CommentRespVO publish(Long userId, CommentPublishReqVO req, String ip, String deviceId) {
|
||||
dramaService.getPublished(req.getDramaId());
|
||||
if (req.getEpisodeId() != null) {
|
||||
episodeService.getPublished(req.getEpisodeId());
|
||||
}
|
||||
String text = req.getContent() == null ? "" : req.getContent().trim();
|
||||
if (text.isEmpty()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "评论内容不能为空");
|
||||
}
|
||||
if (text.length() > properties.getCommentMaxLength()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||
"评论最多 " + properties.getCommentMaxLength() + " 字");
|
||||
}
|
||||
checkRate(userId);
|
||||
|
||||
SensitiveWordCheck check = sensitiveWordService.check(text);
|
||||
if (check.blocked()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, check.reason());
|
||||
}
|
||||
|
||||
ContentComment parent = null;
|
||||
if (req.getParentId() != null && req.getParentId() > 0) {
|
||||
parent = commentMapper.selectById(req.getParentId());
|
||||
if (parent == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "要回复的评论不存在");
|
||||
}
|
||||
}
|
||||
boolean autoPublish = "MACHINE_FIRST".equalsIgnoreCase(properties.getCommentAuditMode())
|
||||
&& !check.needReview();
|
||||
|
||||
ContentComment comment = new ContentComment();
|
||||
comment.setDramaId(req.getDramaId());
|
||||
comment.setEpisodeId(req.getEpisodeId() == null ? 0L : req.getEpisodeId());
|
||||
comment.setUserId(userId);
|
||||
comment.setParentId(parent == null ? 0L : parent.getId());
|
||||
comment.setRootId(resolveRootId(parent));
|
||||
comment.setContent(check.sanitized());
|
||||
comment.setStatus(autoPublish ? STATUS_PUBLISHED : STATUS_PENDING);
|
||||
comment.setAuditType(AuditRecorder.TYPE_MACHINE);
|
||||
comment.setAuditReason(check.needReview() ? check.reason() : "");
|
||||
comment.setLikeCount(0);
|
||||
comment.setReplyCount(0);
|
||||
comment.setReportCount(0);
|
||||
comment.setIp(ip == null ? "" : ip);
|
||||
comment.setDeviceId(deviceId == null ? "" : deviceId);
|
||||
comment.setCountryCode("");
|
||||
commentMapper.insert(comment);
|
||||
|
||||
auditRecorder.record("COMMENT", comment.getId(), AuditRecorder.TYPE_MACHINE, 0L,
|
||||
autoPublish ? "PASS" : "NEED_MORE", check.needReview() ? "SENSITIVE_REVIEW" : "",
|
||||
check.reason(), comment.getContent());
|
||||
|
||||
if (parent != null) {
|
||||
commentMapper.update(null, Wrappers.<ContentComment>lambdaUpdate()
|
||||
.eq(ContentComment::getId, parent.getId())
|
||||
.setSql("reply_count = reply_count + 1"));
|
||||
}
|
||||
return toResp(comment, appUserMapper.selectById(userId), false, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 游标翻页。unsetRootId 表示只看顶层评论;传 rootId 则看该楼层的回复。
|
||||
*/
|
||||
public CursorResult<CommentRespVO> page(Long meUserId, CommentQueryReqVO req) {
|
||||
if (req.getDramaId() == null) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "缺少 dramaId");
|
||||
}
|
||||
int size = clampSize(req.getSize());
|
||||
Long rootFilter = req.getRootId() == null ? 0L : req.getRootId();
|
||||
LambdaQueryWrapper<ContentComment> wrapper = Wrappers.<ContentComment>lambdaQuery()
|
||||
.eq(ContentComment::getDramaId, req.getDramaId())
|
||||
.eq(req.getEpisodeId() != null, ContentComment::getEpisodeId, req.getEpisodeId())
|
||||
.eq(ContentComment::getRootId, rootFilter)
|
||||
.lt(req.getCursor() != null, ContentComment::getId, req.getCursor());
|
||||
if (meUserId == null) {
|
||||
wrapper.eq(ContentComment::getStatus, STATUS_PUBLISHED);
|
||||
} else {
|
||||
// 已发布 + 自己待审核的
|
||||
wrapper.and(w -> w.eq(ContentComment::getStatus, STATUS_PUBLISHED)
|
||||
.or(x -> x.eq(ContentComment::getStatus, STATUS_PENDING)
|
||||
.eq(ContentComment::getUserId, meUserId)));
|
||||
}
|
||||
List<ContentComment> rows = commentMapper.selectList(
|
||||
wrapper.orderByDesc(ContentComment::getId).last("limit " + (size + 1)));
|
||||
boolean hasMore = rows.size() > size;
|
||||
if (hasMore) {
|
||||
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);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long userId, Long commentId) {
|
||||
ContentComment comment = getExisting(commentId);
|
||||
if (!comment.getUserId().equals(userId)) {
|
||||
throw new ServiceException(ErrorCode.FORBIDDEN, "只能删除自己的评论");
|
||||
}
|
||||
softDelete(comment);
|
||||
}
|
||||
|
||||
// ---------------- 后台 ----------------
|
||||
|
||||
public PageResult<CommentRespVO> adminPage(CommentQueryReqVO req) {
|
||||
Page<ContentComment> page = new Page<>(
|
||||
req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(),
|
||||
req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize());
|
||||
IPage<ContentComment> result = commentMapper.selectPage(page,
|
||||
Wrappers.<ContentComment>lambdaQuery()
|
||||
.eq(req.getDramaId() != null, ContentComment::getDramaId, req.getDramaId())
|
||||
.eq(req.getUserId() != null, ContentComment::getUserId, req.getUserId())
|
||||
.eq(StringUtils.hasText(req.getStatus()), ContentComment::getStatus, req.getStatus())
|
||||
.like(StringUtils.hasText(req.getKeyword()), ContentComment::getContent,
|
||||
req.getKeyword())
|
||||
.orderByDesc(ContentComment::getId));
|
||||
return new PageResult<>(result.getTotal(), toRespList(result.getRecords(), null));
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void audit(Long commentId, CommentAuditReqVO req, Long operatorId) {
|
||||
ContentComment comment = getExisting(commentId);
|
||||
boolean pass = "PASS".equalsIgnoreCase(req.getResult());
|
||||
comment.setStatus(pass ? STATUS_PUBLISHED : STATUS_REJECTED);
|
||||
comment.setAuditType(AuditRecorder.TYPE_HUMAN);
|
||||
comment.setAuditReason(pass ? "" : safe(req.getReasonText()));
|
||||
commentMapper.updateById(comment);
|
||||
auditRecorder.record("COMMENT", commentId, AuditRecorder.TYPE_HUMAN, operatorId,
|
||||
pass ? "PASS" : "REJECT", req.getReasonCode(), req.getReasonText(), comment.getContent());
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void adminDelete(Long commentId) {
|
||||
softDelete(getExisting(commentId));
|
||||
}
|
||||
|
||||
public List<ContentAuditRecord> auditRecords(Long commentId) {
|
||||
return auditRecordMapper.selectList(Wrappers.<ContentAuditRecord>lambdaQuery()
|
||||
.eq(ContentAuditRecord::getBizType, "COMMENT")
|
||||
.eq(ContentAuditRecord::getBizId, commentId)
|
||||
.orderByDesc(ContentAuditRecord::getId));
|
||||
}
|
||||
|
||||
/** 举报成立时的联动处理:把被举报评论置为驳回,并留一条人审记录。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void rejectByReport(Long commentId, String reason, Long operatorId) {
|
||||
ContentComment comment = commentMapper.selectById(commentId);
|
||||
if (comment == null) {
|
||||
return;
|
||||
}
|
||||
comment.setStatus(STATUS_REJECTED);
|
||||
comment.setAuditType(AuditRecorder.TYPE_HUMAN);
|
||||
comment.setAuditReason(reason == null ? "举报成立" : reason);
|
||||
commentMapper.updateById(comment);
|
||||
auditRecorder.record("COMMENT", commentId, AuditRecorder.TYPE_HUMAN, operatorId,
|
||||
"REJECT", "REPORT", comment.getAuditReason(), comment.getContent());
|
||||
}
|
||||
|
||||
/** 举报处理联动:举报量过大时自动转入待审,减少人工巡检。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markReported(Long commentId) {
|
||||
ContentComment comment = commentMapper.selectById(commentId);
|
||||
if (comment == null) {
|
||||
return;
|
||||
}
|
||||
commentMapper.update(null, Wrappers.<ContentComment>lambdaUpdate()
|
||||
.eq(ContentComment::getId, commentId)
|
||||
.setSql("report_count = report_count + 1"));
|
||||
}
|
||||
|
||||
public ContentComment getExisting(Long id) {
|
||||
ContentComment comment = id == null ? null : commentMapper.selectById(id);
|
||||
if (comment == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "评论不存在");
|
||||
}
|
||||
return comment;
|
||||
}
|
||||
|
||||
// ---------------- 内部 ----------------
|
||||
|
||||
private void softDelete(ContentComment comment) {
|
||||
comment.setStatus(STATUS_DELETED);
|
||||
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)));
|
||||
if (recent != null && recent >= properties.getCommentPerMinute()) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "发言过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
private Long resolveRootId(ContentComment parent) {
|
||||
if (parent == null) {
|
||||
return 0L;
|
||||
}
|
||||
Long parentRoot = parent.getRootId();
|
||||
return parentRoot == null || parentRoot == 0L ? parent.getId() : parentRoot;
|
||||
}
|
||||
|
||||
private int clampSize(Integer size) {
|
||||
int value = size == null || size <= 0 ? properties.getPageDefaultSize() : size;
|
||||
return Math.min(value, properties.getPageMaxSize());
|
||||
}
|
||||
|
||||
private List<CommentRespVO> toRespList(List<ContentComment> rows, Long meUserId) {
|
||||
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);
|
||||
}
|
||||
Set<Long> likedIds = new HashSet<>();
|
||||
if (meUserId != null) {
|
||||
for (ContentLike like : likeMapper.selectList(Wrappers.<ContentLike>lambdaQuery()
|
||||
.eq(ContentLike::getUserId, meUserId)
|
||||
.eq(ContentLike::getTargetType, "COMMENT")
|
||||
.in(ContentLike::getTargetId, rows.stream().map(ContentComment::getId).toList()))) {
|
||||
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));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private CommentRespVO toResp(ContentComment comment, AppUser author, boolean liked, Long meUserId) {
|
||||
CommentRespVO vo = new CommentRespVO();
|
||||
vo.setId(comment.getId());
|
||||
vo.setDramaId(comment.getDramaId());
|
||||
vo.setEpisodeId(comment.getEpisodeId());
|
||||
vo.setParentId(comment.getParentId());
|
||||
vo.setRootId(comment.getRootId());
|
||||
vo.setUserId(comment.getUserId());
|
||||
vo.setContent(comment.getContent());
|
||||
vo.setStatus(comment.getStatus());
|
||||
vo.setAuditReason(comment.getAuditReason());
|
||||
vo.setLikeCount(comment.getLikeCount());
|
||||
vo.setReplyCount(comment.getReplyCount());
|
||||
vo.setReportCount(comment.getReportCount());
|
||||
vo.setCreatedAt(comment.getCreatedAt());
|
||||
vo.setMine(meUserId != null && meUserId.equals(comment.getUserId()));
|
||||
vo.setLiked(liked);
|
||||
if (author != null) {
|
||||
vo.setUid(author.getUid());
|
||||
vo.setNickname(author.getNickname());
|
||||
vo.setAvatarUrl(author.getAvatarUrl());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
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.content.service.DramaService;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentLike;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
|
||||
import com.fruitshorts.interaction.vo.LikeToggleReqVO;
|
||||
import com.fruitshorts.interaction.vo.LikeToggleRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 点赞。关系表是事实来源,计数同步更新在目标行上。
|
||||
*
|
||||
* <p>规模化后计数应改为消息队列聚合(写关系 → 异步累加),接口契约不变;
|
||||
* 当前阶段同步更新即可,而且用 SQL 自增而不是读改写,不会丢更新。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LikeService {
|
||||
|
||||
public static final String TYPE_DRAMA = "DRAMA";
|
||||
public static final String TYPE_COMMENT = "COMMENT";
|
||||
|
||||
private final ContentLikeMapper likeMapper;
|
||||
private final ContentCommentMapper commentMapper;
|
||||
private final DramaService dramaService;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public LikeToggleRespVO toggle(Long userId, LikeToggleReqVO req) {
|
||||
String type = req.getTargetType().trim().toUpperCase(Locale.ROOT);
|
||||
if (!TYPE_DRAMA.equals(type) && !TYPE_COMMENT.equals(type)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧或评论点赞");
|
||||
}
|
||||
Long targetId = req.getTargetId();
|
||||
validateTarget(type, targetId);
|
||||
|
||||
ContentLike existing = likeMapper.selectOne(Wrappers.<ContentLike>lambdaQuery()
|
||||
.eq(ContentLike::getUserId, userId)
|
||||
.eq(ContentLike::getTargetType, type)
|
||||
.eq(ContentLike::getTargetId, targetId)
|
||||
.last("limit 1"));
|
||||
boolean liked;
|
||||
if (existing == null) {
|
||||
ContentLike like = new ContentLike();
|
||||
like.setUserId(userId);
|
||||
like.setTargetType(type);
|
||||
like.setTargetId(targetId);
|
||||
likeMapper.insert(like);
|
||||
liked = true;
|
||||
adjustCount(type, targetId, 1);
|
||||
} else {
|
||||
likeMapper.physicalDelete(userId, type, targetId);
|
||||
liked = false;
|
||||
adjustCount(type, targetId, -1);
|
||||
}
|
||||
LikeToggleRespVO vo = new LikeToggleRespVO();
|
||||
vo.setLiked(liked);
|
||||
vo.setCount(count(type, targetId));
|
||||
return vo;
|
||||
}
|
||||
|
||||
public long count(String type, Long targetId) {
|
||||
if (TYPE_DRAMA.equals(type)) {
|
||||
return dramaService.getExisting(targetId).getLikeCount() == null
|
||||
? 0L : dramaService.getExisting(targetId).getLikeCount();
|
||||
}
|
||||
ContentComment comment = commentMapper.selectById(targetId);
|
||||
return comment == null || comment.getLikeCount() == null ? 0L : comment.getLikeCount();
|
||||
}
|
||||
|
||||
private void adjustCount(String type, Long targetId, int delta) {
|
||||
if (TYPE_DRAMA.equals(type)) {
|
||||
dramaService.adjustLikeCount(targetId, delta);
|
||||
return;
|
||||
}
|
||||
commentMapper.update(null, Wrappers.<ContentComment>lambdaUpdate()
|
||||
.eq(ContentComment::getId, targetId)
|
||||
.setSql(delta > 0
|
||||
? "like_count = like_count + 1"
|
||||
: "like_count = GREATEST(like_count - 1, 0)"));
|
||||
}
|
||||
|
||||
private void validateTarget(String type, Long targetId) {
|
||||
if (TYPE_DRAMA.equals(type)) {
|
||||
dramaService.getPublished(targetId);
|
||||
return;
|
||||
}
|
||||
ContentComment comment = commentMapper.selectById(targetId);
|
||||
if (comment == null || CommentService.STATUS_PUBLISHED.equals(comment.getStatus()) == false) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "评论不存在或未通过审核");
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package com.fruitshorts.interaction.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
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.pojo.PageResult;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentReport;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentReportMapper;
|
||||
import com.fruitshorts.interaction.vo.ReportCreateReqVO;
|
||||
import com.fruitshorts.interaction.vo.ReportHandleReqVO;
|
||||
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import com.fruitshorts.user.service.AppUserAdminService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 举报工单。
|
||||
*
|
||||
* <p>处理结论与处置打通:举报成立会把被举报评论置为驳回,可选同时封禁被举报用户 ——
|
||||
* 避免"举报处理完了,但违规内容还在"这类运营事故。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportService {
|
||||
|
||||
public static final String STATUS_PENDING = "pending";
|
||||
public static final String STATUS_HANDLED = "handled";
|
||||
public static final String STATUS_REJECTED = "rejected";
|
||||
|
||||
public static final String TYPE_COMMENT = "COMMENT";
|
||||
public static final String TYPE_USER = "USER";
|
||||
public static final String TYPE_DRAMA = "DRAMA";
|
||||
|
||||
private final ContentReportMapper reportMapper;
|
||||
private final ContentCommentMapper commentMapper;
|
||||
private final AppUserMapper appUserMapper;
|
||||
private final DramaService dramaService;
|
||||
private final CommentService commentService;
|
||||
private final AppUserAdminService appUserAdminService;
|
||||
|
||||
// ---------------- 客户端 ----------------
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(Long userId, ReportCreateReqVO req) {
|
||||
String type = req.getTargetType().trim().toUpperCase(Locale.ROOT);
|
||||
Long ownerId = resolveTargetOwner(type, req.getTargetId());
|
||||
if (ownerId != null && ownerId.equals(userId)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "不能举报自己发布的内容");
|
||||
}
|
||||
Long duplicated = reportMapper.selectCount(Wrappers.<ContentReport>lambdaQuery()
|
||||
.eq(ContentReport::getReporterId, userId)
|
||||
.eq(ContentReport::getTargetType, type)
|
||||
.eq(ContentReport::getTargetId, req.getTargetId())
|
||||
.eq(ContentReport::getStatus, STATUS_PENDING));
|
||||
if (duplicated != null && duplicated > 0) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "你已举报过该内容,我们正在处理");
|
||||
}
|
||||
|
||||
ContentReport report = new ContentReport();
|
||||
report.setReporterId(userId);
|
||||
report.setTargetType(type);
|
||||
report.setTargetId(req.getTargetId());
|
||||
report.setReasonCode(req.getReasonCode());
|
||||
report.setDescription(req.getDescription() == null ? "" : req.getDescription());
|
||||
report.setStatus(STATUS_PENDING);
|
||||
report.setHandleBy(0L);
|
||||
report.setHandleNote("");
|
||||
reportMapper.insert(report);
|
||||
|
||||
if (TYPE_COMMENT.equals(type)) {
|
||||
// 累计举报数,运营侧可据此优先处理
|
||||
commentService.markReported(req.getTargetId());
|
||||
}
|
||||
return report.getId();
|
||||
}
|
||||
|
||||
// ---------------- 后台 ----------------
|
||||
|
||||
public PageResult<ContentReport> page(ReportQueryReqVO req) {
|
||||
Page<ContentReport> page = new Page<>(
|
||||
req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(),
|
||||
req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize());
|
||||
IPage<ContentReport> result = reportMapper.selectPage(page,
|
||||
Wrappers.<ContentReport>lambdaQuery()
|
||||
.eq(StringUtils.hasText(req.getStatus()), ContentReport::getStatus, req.getStatus())
|
||||
.eq(StringUtils.hasText(req.getTargetType()), ContentReport::getTargetType,
|
||||
req.getTargetType())
|
||||
.eq(StringUtils.hasText(req.getReasonCode()), ContentReport::getReasonCode,
|
||||
req.getReasonCode())
|
||||
.eq(req.getReporterId() != null, ContentReport::getReporterId, req.getReporterId())
|
||||
.orderByDesc(ContentReport::getId));
|
||||
return new PageResult<>(result.getTotal(), result.getRecords());
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void handle(Long id, ReportHandleReqVO req, Long operatorId) {
|
||||
ContentReport report = id == null ? null : reportMapper.selectById(id);
|
||||
if (report == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "举报工单不存在");
|
||||
}
|
||||
if (!STATUS_PENDING.equals(report.getStatus())) {
|
||||
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该工单已处理过");
|
||||
}
|
||||
boolean handled = !STATUS_REJECTED.equalsIgnoreCase(req.getStatus());
|
||||
report.setStatus(handled ? STATUS_HANDLED : STATUS_REJECTED);
|
||||
report.setHandleBy(operatorId == null ? 0L : operatorId);
|
||||
report.setHandleAt(LocalDateTime.now());
|
||||
report.setHandleNote(req.getNote() == null ? "" : req.getNote());
|
||||
reportMapper.updateById(report);
|
||||
|
||||
if (handled && TYPE_COMMENT.equals(report.getTargetType())) {
|
||||
commentService.rejectByReport(report.getTargetId(), report.getHandleNote(), operatorId);
|
||||
}
|
||||
if (handled && Boolean.TRUE.equals(req.getBanTarget())) {
|
||||
Long targetUserId = resolveTargetOwner(report.getTargetType(), report.getTargetId());
|
||||
if (targetUserId != null) {
|
||||
String reason = StringUtils.hasText(req.getBanReason())
|
||||
? req.getBanReason()
|
||||
: "举报工单 #" + id + " 处理成立";
|
||||
appUserAdminService.updateStatus(targetUserId, "banned", reason, operatorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ContentReport getExisting(Long id) {
|
||||
ContentReport report = id == null ? null : reportMapper.selectById(id);
|
||||
if (report == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "举报工单不存在");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
/** 校验被举报对象存在,并返回其归属用户(用于"不能举报自己"与封禁联动)。 */
|
||||
private Long resolveTargetOwner(String type, Long targetId) {
|
||||
if (TYPE_COMMENT.equals(type)) {
|
||||
ContentComment comment = commentMapper.selectById(targetId);
|
||||
if (comment == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "被举报的评论不存在");
|
||||
}
|
||||
return comment.getUserId();
|
||||
}
|
||||
if (TYPE_USER.equals(type)) {
|
||||
AppUser user = appUserMapper.selectById(targetId);
|
||||
if (user == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "被举报的用户不存在");
|
||||
}
|
||||
return user.getId();
|
||||
}
|
||||
if (TYPE_DRAMA.equals(type)) {
|
||||
dramaService.getExisting(targetId);
|
||||
return null;
|
||||
}
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持举报评论 / 用户 / 短剧");
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.fruitshorts.interaction.service;
|
||||
|
||||
/** 敏感词检查结果。 */
|
||||
public record SensitiveWordCheck(boolean blocked, boolean needReview, String reason, String sanitized) {
|
||||
|
||||
public static SensitiveWordCheck pass(String text) {
|
||||
return new SensitiveWordCheck(false, false, "", text);
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package com.fruitshorts.interaction.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
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.pojo.PageResult;
|
||||
import com.fruitshorts.interaction.dal.entity.SensitiveWord;
|
||||
import com.fruitshorts.interaction.dal.mapper.SensitiveWordMapper;
|
||||
import com.fruitshorts.interaction.vo.SensitiveWordReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 敏感词库与匹配。
|
||||
*
|
||||
* <p>词库全量常驻内存(量级在万级以内),每次改动失效重建:
|
||||
* 评论是高频写路径,不能每发一条都查库。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SensitiveWordService {
|
||||
|
||||
public static final String LEVEL_BLOCK = "BLOCK";
|
||||
public static final String LEVEL_REVIEW = "REVIEW";
|
||||
public static final String LEVEL_REPLACE = "REPLACE";
|
||||
|
||||
private final SensitiveWordMapper sensitiveWordMapper;
|
||||
|
||||
private volatile List<SensitiveWord> cache;
|
||||
|
||||
public SensitiveWordCheck check(String text) {
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return SensitiveWordCheck.pass(text);
|
||||
}
|
||||
String sanitized = text;
|
||||
boolean needReview = false;
|
||||
List<String> blockedWords = new ArrayList<>();
|
||||
List<String> reviewWords = new ArrayList<>();
|
||||
for (SensitiveWord item : activeWords()) {
|
||||
String word = item.getWord();
|
||||
if (!StringUtils.hasText(word) || !sanitized.contains(word)) {
|
||||
continue;
|
||||
}
|
||||
if (LEVEL_BLOCK.equals(item.getLevel())) {
|
||||
blockedWords.add(word);
|
||||
} else if (LEVEL_REPLACE.equals(item.getLevel())) {
|
||||
sanitized = sanitized.replace(word, item.getReplaceText());
|
||||
} else {
|
||||
needReview = true;
|
||||
reviewWords.add(word);
|
||||
}
|
||||
}
|
||||
if (!blockedWords.isEmpty()) {
|
||||
return new SensitiveWordCheck(true, needReview,
|
||||
"包含禁止发布的词:" + String.join("、", blockedWords), sanitized);
|
||||
}
|
||||
if (needReview) {
|
||||
return new SensitiveWordCheck(false, true,
|
||||
"命中待审核词:" + String.join("、", reviewWords), sanitized);
|
||||
}
|
||||
return SensitiveWordCheck.pass(sanitized);
|
||||
}
|
||||
|
||||
public void invalidate() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
private List<SensitiveWord> activeWords() {
|
||||
List<SensitiveWord> local = cache;
|
||||
if (local == null) {
|
||||
synchronized (this) {
|
||||
local = cache;
|
||||
if (local == null) {
|
||||
local = sensitiveWordMapper.selectList(Wrappers.<SensitiveWord>lambdaQuery()
|
||||
.eq(SensitiveWord::getStatus, 1));
|
||||
cache = local;
|
||||
log.info("敏感词库已加载:{} 条", local.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
// ---------------- 后台 ----------------
|
||||
|
||||
public PageResult<SensitiveWord> page(String keyword, String category, String level,
|
||||
int pageIndex, int pageSize) {
|
||||
Page<SensitiveWord> page = new Page<>(pageIndex < 1 ? 1 : pageIndex, pageSize < 1 ? 10 : pageSize);
|
||||
IPage<SensitiveWord> result = sensitiveWordMapper.selectPage(page,
|
||||
Wrappers.<SensitiveWord>lambdaQuery()
|
||||
.like(StringUtils.hasText(keyword), SensitiveWord::getWord, keyword)
|
||||
.eq(StringUtils.hasText(category), SensitiveWord::getCategory, category)
|
||||
.eq(StringUtils.hasText(level), SensitiveWord::getLevel, level)
|
||||
.orderByDesc(SensitiveWord::getId));
|
||||
return new PageResult<>(result.getTotal(), result.getRecords());
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(SensitiveWordReqVO req) {
|
||||
ensureNotExists(req.getWord(), null);
|
||||
SensitiveWord word = new SensitiveWord();
|
||||
apply(word, req);
|
||||
sensitiveWordMapper.insert(word);
|
||||
invalidate();
|
||||
return word.getId();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void update(Long id, SensitiveWordReqVO req) {
|
||||
SensitiveWord word = getExisting(id);
|
||||
ensureNotExists(req.getWord(), id);
|
||||
apply(word, req);
|
||||
sensitiveWordMapper.updateById(word);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
getExisting(id);
|
||||
sensitiveWordMapper.deleteById(id);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private void ensureNotExists(String word, Long excludeId) {
|
||||
Long count = sensitiveWordMapper.selectCount(Wrappers.<SensitiveWord>lambdaQuery()
|
||||
.eq(SensitiveWord::getWord, word)
|
||||
.ne(excludeId != null, SensitiveWord::getId, excludeId));
|
||||
if (count != null && count > 0) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "该词已在词库中");
|
||||
}
|
||||
}
|
||||
|
||||
private SensitiveWord getExisting(Long id) {
|
||||
SensitiveWord word = id == null ? null : sensitiveWordMapper.selectById(id);
|
||||
if (word == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "敏感词不存在");
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
private void apply(SensitiveWord word, SensitiveWordReqVO req) {
|
||||
word.setWord(req.getWord().trim());
|
||||
word.setCategory(StringUtils.hasText(req.getCategory()) ? req.getCategory() : "");
|
||||
String level = StringUtils.hasText(req.getLevel()) ? req.getLevel().toUpperCase() : LEVEL_REVIEW;
|
||||
if (!LEVEL_BLOCK.equals(level) && !LEVEL_REVIEW.equals(level) && !LEVEL_REPLACE.equals(level)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "处置级别只支持 BLOCK/REVIEW/REPLACE");
|
||||
}
|
||||
word.setLevel(level);
|
||||
word.setReplaceText(StringUtils.hasText(req.getReplaceText()) ? req.getReplaceText() : "*");
|
||||
word.setLanguage(StringUtils.hasText(req.getLanguage()) ? req.getLanguage() : "id-ID");
|
||||
word.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/** 后台评论审核。 */
|
||||
@Data
|
||||
public class CommentAuditReqVO {
|
||||
|
||||
/** PASS/REJECT */
|
||||
@NotBlank(message = "请选择审核结果")
|
||||
private String result;
|
||||
|
||||
private String reasonCode;
|
||||
private String reasonText;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/** 发表评论或回复。 */
|
||||
@Data
|
||||
public class CommentPublishReqVO {
|
||||
|
||||
@NotNull(message = "缺少 dramaId")
|
||||
private Long dramaId;
|
||||
|
||||
private Long episodeId;
|
||||
|
||||
@NotBlank(message = "评论内容不能为空")
|
||||
private String content;
|
||||
|
||||
/** 回复某条评论时传它的 id;顶层评论不传 */
|
||||
private Long parentId;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 评论列表查询(客户端用游标,后台用分页)。 */
|
||||
@Data
|
||||
public class CommentQueryReqVO {
|
||||
|
||||
private Long dramaId;
|
||||
private Long episodeId;
|
||||
/** 只看某个楼层的回复 */
|
||||
private Long rootId;
|
||||
/** 客户端游标:上一页最后一条的 id */
|
||||
private Long cursor;
|
||||
private Integer size;
|
||||
|
||||
/** 后台检索条件 */
|
||||
private String status;
|
||||
private Long userId;
|
||||
private String keyword;
|
||||
private Integer pageIndex = 1;
|
||||
private Integer pageSize = 10;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 评论视图。 */
|
||||
@Data
|
||||
public class CommentRespVO {
|
||||
|
||||
private Long id;
|
||||
private Long dramaId;
|
||||
private Long episodeId;
|
||||
private Long parentId;
|
||||
private Long rootId;
|
||||
private Long userId;
|
||||
/** 对外用户标识与昵称头像,客户端直接渲染 */
|
||||
private String uid;
|
||||
private String nickname;
|
||||
private String avatarUrl;
|
||||
private String content;
|
||||
/** pending/published/rejected/deleted */
|
||||
private String status;
|
||||
private String auditReason;
|
||||
private Integer likeCount;
|
||||
private Integer replyCount;
|
||||
private Integer reportCount;
|
||||
private LocalDateTime createdAt;
|
||||
/** 是否本人发表(可删除) */
|
||||
private Boolean mine;
|
||||
/** 当前用户是否已点赞 */
|
||||
private Boolean liked;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/** 点赞/取消点赞。 */
|
||||
@Data
|
||||
public class LikeToggleReqVO {
|
||||
|
||||
/** DRAMA/COMMENT */
|
||||
@NotBlank(message = "缺少 targetType")
|
||||
private String targetType;
|
||||
|
||||
@NotNull(message = "缺少 targetId")
|
||||
private Long targetId;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 点赞结果:切换后的状态与最新计数。 */
|
||||
@Data
|
||||
public class LikeToggleRespVO {
|
||||
|
||||
private Boolean liked;
|
||||
private Long count;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/** 提交举报。 */
|
||||
@Data
|
||||
public class ReportCreateReqVO {
|
||||
|
||||
/** COMMENT/USER/DRAMA */
|
||||
@NotBlank(message = "缺少 targetType")
|
||||
private String targetType;
|
||||
|
||||
@NotNull(message = "缺少 targetId")
|
||||
private Long targetId;
|
||||
|
||||
@NotBlank(message = "请选择举报原因")
|
||||
private String reasonCode;
|
||||
|
||||
private String description;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 后台处理举报工单。 */
|
||||
@Data
|
||||
public class ReportHandleReqVO {
|
||||
|
||||
/** handled/rejected */
|
||||
private String status = "handled";
|
||||
private String note;
|
||||
/** 是否同时封禁被举报用户(仅 COMMENT/USER 类型有意义) */
|
||||
private Boolean banTarget;
|
||||
/** 封禁原因,留档用 */
|
||||
private String banReason;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 后台举报工单检索条件。 */
|
||||
@Data
|
||||
public class ReportQueryReqVO {
|
||||
|
||||
/** pending/handled/rejected */
|
||||
private String status;
|
||||
/** COMMENT/USER/DRAMA */
|
||||
private String targetType;
|
||||
private String reasonCode;
|
||||
private Long reporterId;
|
||||
private Integer pageIndex = 1;
|
||||
private Integer pageSize = 10;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.fruitshorts.interaction.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/** 敏感词新增/修改。 */
|
||||
@Data
|
||||
public class SensitiveWordReqVO {
|
||||
|
||||
@NotBlank(message = "请输入敏感词")
|
||||
private String word;
|
||||
|
||||
/** POLITICS/PORN/ABUSE/AD/RELIGION */
|
||||
private String category;
|
||||
/** BLOCK/REVIEW/REPLACE */
|
||||
private String level = "REVIEW";
|
||||
private String replaceText = "*";
|
||||
private String language = "id-ID";
|
||||
private Integer status = 1;
|
||||
}
|
||||
Reference in new issue
Block a user