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:
Vicky committed 2026-10-15 12:03:32 +08:00
1 parent b3179fc836
commit af1dc48494
48 files changed
+1752 -14

No files matched your search

@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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> {
}
@@ -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> {
}
@@ -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);
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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, "评论不存在或未通过审核");
}
}
}
@@ -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, "只支持举报评论 / 用户 / 短剧");
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,11 @@
package com.fruitshorts.interaction.vo;
import lombok.Data;
/** 点赞结果:切换后的状态与最新计数。 */
@Data
public class LikeToggleRespVO {
private Boolean liked;
private Long count;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}