diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java
new file mode 100644
index 0000000..4fc40b1
--- /dev/null
+++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java
@@ -0,0 +1,64 @@
+package com.fruitshorts.app.controller;
+
+import com.fruitshorts.common.pojo.CursorResult;
+import com.fruitshorts.common.pojo.Result;
+import com.fruitshorts.framework.security.SecurityUtils;
+import com.fruitshorts.framework.web.WebUtils;
+import com.fruitshorts.interaction.service.CommentService;
+import com.fruitshorts.interaction.vo.CommentPublishReqVO;
+import com.fruitshorts.interaction.vo.CommentQueryReqVO;
+import com.fruitshorts.interaction.vo.CommentRespVO;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 评论。
+ *
+ * 列表走游标翻页:`cursor` 传上一页最后一条的 id,返回 `nextCursor` 与 `hasMore`。
+ * 不传 `rootId` 时返回顶层评论,传了则返回该楼层的回复。
+ * 列表允许匿名浏览;发布与删除需要登录。
+ */
+@RestController
+@RequestMapping("/app/comments")
+@RequiredArgsConstructor
+public class AppCommentController {
+
+ private final CommentService commentService;
+
+ @PostMapping
+ public Result publish(@Valid @RequestBody CommentPublishReqVO req,
+ HttpServletRequest request) {
+ return Result.ok(commentService.publish(SecurityUtils.getAppUserId(), req,
+ WebUtils.getClientIp(request), request.getHeader("X-Device-Id")));
+ }
+
+ @GetMapping
+ public Result> page(@RequestParam Long dramaId,
+ @RequestParam(required = false) Long episodeId,
+ @RequestParam(required = false) Long rootId,
+ @RequestParam(required = false) Long cursor,
+ @RequestParam(required = false) Integer size) {
+ CommentQueryReqVO query = new CommentQueryReqVO();
+ query.setDramaId(dramaId);
+ query.setEpisodeId(episodeId);
+ query.setRootId(rootId);
+ query.setCursor(cursor);
+ query.setSize(size);
+ return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query));
+ }
+
+ @DeleteMapping("/{id}")
+ public Result delete(@PathVariable Long id) {
+ commentService.delete(SecurityUtils.getAppUserId(), id);
+ return Result.ok();
+ }
+}
diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppInteractionController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppInteractionController.java
new file mode 100644
index 0000000..3593afe
--- /dev/null
+++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppInteractionController.java
@@ -0,0 +1,37 @@
+package com.fruitshorts.app.controller;
+
+import com.fruitshorts.common.pojo.Result;
+import com.fruitshorts.framework.security.SecurityUtils;
+import com.fruitshorts.interaction.service.LikeService;
+import com.fruitshorts.interaction.service.ReportService;
+import com.fruitshorts.interaction.vo.LikeToggleReqVO;
+import com.fruitshorts.interaction.vo.LikeToggleRespVO;
+import com.fruitshorts.interaction.vo.ReportCreateReqVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 点赞与举报。 */
+@RestController
+@RequestMapping("/app")
+@RequiredArgsConstructor
+public class AppInteractionController {
+
+ private final LikeService likeService;
+ private final ReportService reportService;
+
+ /** 点赞/取消点赞,返回切换后的状态与最新计数。 */
+ @PostMapping("/likes/toggle")
+ public Result toggleLike(@Valid @RequestBody LikeToggleReqVO req) {
+ return Result.ok(likeService.toggle(SecurityUtils.getAppUserId(), req));
+ }
+
+ /** 提交举报:同一用户对同一目标只保留一条待处理工单。 */
+ @PostMapping("/reports")
+ public Result report(@Valid @RequestBody ReportCreateReqVO req) {
+ return Result.ok(reportService.create(SecurityUtils.getAppUserId(), req));
+ }
+}
diff --git a/fruitshorts-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml
index 61488f9..acad9c8 100644
--- a/fruitshorts-app-api/src/main/resources/application.yml
+++ b/fruitshorts-app-api/src/main/resources/application.yml
@@ -27,6 +27,12 @@ mybatis-plus:
logic-not-delete-value: 0
fruitshorts:
+ interaction:
+ # MACHINE_FIRST:机审通过即发布,命中 REVIEW 级敏感词才转人工
+ # PRE_REVIEW :全部先审后发(生产建议)
+ comment-audit-mode: MACHINE_FIRST
+ comment-max-length: 500
+ comment-per-minute: 5
download:
# 这些值会通过 /app/downloads/policy 下发给客户端,改这里不用发版
max-per-device: 20
diff --git a/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/CursorResult.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/CursorResult.java
new file mode 100644
index 0000000..2f3f464
--- /dev/null
+++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/CursorResult.java
@@ -0,0 +1,29 @@
+package com.fruitshorts.common.pojo;
+
+import lombok.Getter;
+
+import java.util.List;
+
+/**
+ * 游标分页结果。
+ * 评论、流水这类会持续增长且允许插入新数据的列表用游标而不是 offset:
+ * 深翻页不会因为新数据插入而错位,也不会产生大偏移查询。
+ */
+@Getter
+public class CursorResult {
+
+ private final List list;
+ /** 下一页要带上的游标;为 null 表示没有下一页 */
+ private final Long nextCursor;
+ private final boolean hasMore;
+
+ public CursorResult(List list, Long nextCursor, boolean hasMore) {
+ this.list = list;
+ this.nextCursor = nextCursor;
+ this.hasMore = hasMore;
+ }
+
+ public static CursorResult empty() {
+ return new CursorResult<>(List.of(), null, false);
+ }
+}
diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java
index 2c2db23..a7cf729 100644
--- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java
+++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java
@@ -62,6 +62,8 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(registry -> registry
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
+ /* 评论列表允许匿名浏览;发布/删除仍需登录(未列入白名单即需鉴权) */
+ .requestMatchers(HttpMethod.GET, "/app/comments").permitAll()
.requestMatchers(PUBLIC_PATHS).permitAll()
.anyRequest().authenticated())
.exceptionHandling(handling -> handling
diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java
index 3d35ea3..ef8489c 100644
--- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java
+++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java
@@ -208,6 +208,18 @@ public class DramaService {
return dramaMapper.selectList(Wrappers.lambdaQuery().in(Drama::getId, ids));
}
+ /** 点赞数原子增减:并发下用 SQL 自增,不做读改写,避免丢更新。 */
+ public void adjustLikeCount(Long dramaId, int delta) {
+ if (dramaId == null || delta == 0) {
+ return;
+ }
+ dramaMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(Drama::getId, dramaId)
+ .setSql(delta > 0
+ ? "like_count = like_count + 1"
+ : "like_count = GREATEST(like_count - 1, 0)"));
+ }
+
/** 剧集数量变化后同步冗余字段。 */
public void syncTotalEpisodes(Long dramaId) {
Long count = dramaEpisodeMapper.selectCount(
diff --git a/fruitshorts-module-interaction/pom.xml b/fruitshorts-module-interaction/pom.xml
new file mode 100644
index 0000000..4629a33
--- /dev/null
+++ b/fruitshorts-module-interaction/pom.xml
@@ -0,0 +1,31 @@
+
+
+ 4.0.0
+
+
+ com.fruitshorts
+ fruitshorts
+ 1.0.0-SNAPSHOT
+
+
+ fruitshorts-module-interaction
+ fruitshorts-module-interaction
+ 互动域:评论、点赞、举报、敏感词与审核留证
+
+
+
+ com.fruitshorts
+ fruitshorts-framework
+
+
+ com.fruitshorts
+ fruitshorts-module-content
+
+
+ com.fruitshorts
+ fruitshorts-module-user
+
+
+
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/config/InteractionProperties.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/config/InteractionProperties.java
new file mode 100644
index 0000000..b997337
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/config/InteractionProperties.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentAuditRecord.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentAuditRecord.java
new file mode 100644
index 0000000..0b40d3d
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentAuditRecord.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentComment.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentComment.java
new file mode 100644
index 0000000..c9c5353
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentComment.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentLike.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentLike.java
new file mode 100644
index 0000000..8074bde
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentLike.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentReport.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentReport.java
new file mode 100644
index 0000000..a877f92
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentReport.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentShare.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentShare.java
new file mode 100644
index 0000000..ae536b2
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentShare.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/SensitiveWord.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/SensitiveWord.java
new file mode 100644
index 0000000..91ba55a
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/SensitiveWord.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/UserBlocklist.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/UserBlocklist.java
new file mode 100644
index 0000000..7b54e49
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/UserBlocklist.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentAuditRecordMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentAuditRecordMapper.java
new file mode 100644
index 0000000..7702d3c
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentAuditRecordMapper.java
@@ -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 {
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentCommentMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentCommentMapper.java
new file mode 100644
index 0000000..a5bff1f
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentCommentMapper.java
@@ -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 {
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentLikeMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentLikeMapper.java
new file mode 100644
index 0000000..5af3064
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentLikeMapper.java
@@ -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 {
+
+ /**
+ * 物理删除点赞关系。
+ *
+ * 点赞是可反复切换的动作,而 (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);
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentReportMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentReportMapper.java
new file mode 100644
index 0000000..24d5b10
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentReportMapper.java
@@ -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 {
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentShareMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentShareMapper.java
new file mode 100644
index 0000000..e02aa9d
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentShareMapper.java
@@ -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 {
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/SensitiveWordMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/SensitiveWordMapper.java
new file mode 100644
index 0000000..7923dec
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/SensitiveWordMapper.java
@@ -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 {
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/UserBlocklistMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/UserBlocklistMapper.java
new file mode 100644
index 0000000..687adc9
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/UserBlocklistMapper.java
@@ -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 {
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/AuditRecorder.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/AuditRecorder.java
new file mode 100644
index 0000000..2cfc85b
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/AuditRecorder.java
@@ -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.lambdaQuery()
+ .eq(ContentAuditRecord::getBizType, bizType)
+ .eq(ContentAuditRecord::getBizId, bizId));
+ return (count == null ? 0 : count.intValue()) + 1;
+ }
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java
new file mode 100644
index 0000000..bfa930e
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java
@@ -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;
+
+/**
+ * 评论:发布(含敏感词与审核策略)、游标翻页、删除、后台审核。
+ *
+ * 可见性规则:客户端只看得到已发布评论;自己待审核的评论也返回,便于展示"审核中"。
+ * 翻页用游标(上一页最后一条的 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.lambdaUpdate()
+ .eq(ContentComment::getId, parent.getId())
+ .setSql("reply_count = reply_count + 1"));
+ }
+ return toResp(comment, appUserMapper.selectById(userId), false, userId);
+ }
+
+ /**
+ * 游标翻页。unsetRootId 表示只看顶层评论;传 rootId 则看该楼层的回复。
+ */
+ public CursorResult 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 wrapper = Wrappers.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 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 adminPage(CommentQueryReqVO req) {
+ Page page = new Page<>(
+ req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(),
+ req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize());
+ IPage result = commentMapper.selectPage(page,
+ Wrappers.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 auditRecords(Long commentId) {
+ return auditRecordMapper.selectList(Wrappers.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.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.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 toRespList(List rows, Long meUserId) {
+ if (rows.isEmpty()) {
+ return List.of();
+ }
+ Map users = new HashMap<>();
+ List userIds = rows.stream().map(ContentComment::getUserId).distinct().toList();
+ for (AppUser user : appUserMapper.selectBatchIds(userIds)) {
+ users.put(user.getId(), user);
+ }
+ Set likedIds = new HashSet<>();
+ if (meUserId != null) {
+ for (ContentLike like : likeMapper.selectList(Wrappers.lambdaQuery()
+ .eq(ContentLike::getUserId, meUserId)
+ .eq(ContentLike::getTargetType, "COMMENT")
+ .in(ContentLike::getTargetId, rows.stream().map(ContentComment::getId).toList()))) {
+ likedIds.add(like.getTargetId());
+ }
+ }
+ List 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;
+ }
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java
new file mode 100644
index 0000000..ab52e9b
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java
@@ -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;
+
+/**
+ * 点赞。关系表是事实来源,计数同步更新在目标行上。
+ *
+ * 规模化后计数应改为消息队列聚合(写关系 → 异步累加),接口契约不变;
+ * 当前阶段同步更新即可,而且用 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.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.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, "评论不存在或未通过审核");
+ }
+ }
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/ReportService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/ReportService.java
new file mode 100644
index 0000000..268b825
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/ReportService.java
@@ -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;
+
+/**
+ * 举报工单。
+ *
+ * 处理结论与处置打通:举报成立会把被举报评论置为驳回,可选同时封禁被举报用户 ——
+ * 避免"举报处理完了,但违规内容还在"这类运营事故。
+ */
+@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.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 page(ReportQueryReqVO req) {
+ Page page = new Page<>(
+ req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(),
+ req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize());
+ IPage result = reportMapper.selectPage(page,
+ Wrappers.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, "只支持举报评论 / 用户 / 短剧");
+ }
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/SensitiveWordCheck.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/SensitiveWordCheck.java
new file mode 100644
index 0000000..f8e35ad
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/SensitiveWordCheck.java
@@ -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);
+ }
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/SensitiveWordService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/SensitiveWordService.java
new file mode 100644
index 0000000..f3b89d0
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/SensitiveWordService.java
@@ -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;
+
+/**
+ * 敏感词库与匹配。
+ *
+ * 词库全量常驻内存(量级在万级以内),每次改动失效重建:
+ * 评论是高频写路径,不能每发一条都查库。
+ */
+@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 cache;
+
+ public SensitiveWordCheck check(String text) {
+ if (!StringUtils.hasText(text)) {
+ return SensitiveWordCheck.pass(text);
+ }
+ String sanitized = text;
+ boolean needReview = false;
+ List blockedWords = new ArrayList<>();
+ List 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 activeWords() {
+ List local = cache;
+ if (local == null) {
+ synchronized (this) {
+ local = cache;
+ if (local == null) {
+ local = sensitiveWordMapper.selectList(Wrappers.lambdaQuery()
+ .eq(SensitiveWord::getStatus, 1));
+ cache = local;
+ log.info("敏感词库已加载:{} 条", local.size());
+ }
+ }
+ }
+ return local;
+ }
+
+ // ---------------- 后台 ----------------
+
+ public PageResult page(String keyword, String category, String level,
+ int pageIndex, int pageSize) {
+ Page page = new Page<>(pageIndex < 1 ? 1 : pageIndex, pageSize < 1 ? 10 : pageSize);
+ IPage result = sensitiveWordMapper.selectPage(page,
+ Wrappers.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.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());
+ }
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentAuditReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentAuditReqVO.java
new file mode 100644
index 0000000..70edc59
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentAuditReqVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentPublishReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentPublishReqVO.java
new file mode 100644
index 0000000..1c8ba02
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentPublishReqVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentQueryReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentQueryReqVO.java
new file mode 100644
index 0000000..266e62a
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentQueryReqVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentRespVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentRespVO.java
new file mode 100644
index 0000000..9b3ec8c
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentRespVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java
new file mode 100644
index 0000000..26d1c88
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleRespVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleRespVO.java
new file mode 100644
index 0000000..8650119
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleRespVO.java
@@ -0,0 +1,11 @@
+package com.fruitshorts.interaction.vo;
+
+import lombok.Data;
+
+/** 点赞结果:切换后的状态与最新计数。 */
+@Data
+public class LikeToggleRespVO {
+
+ private Boolean liked;
+ private Long count;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportCreateReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportCreateReqVO.java
new file mode 100644
index 0000000..1467f8b
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportCreateReqVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportHandleReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportHandleReqVO.java
new file mode 100644
index 0000000..c03a0af
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportHandleReqVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportQueryReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportQueryReqVO.java
new file mode 100644
index 0000000..acbc8d5
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/ReportQueryReqVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/SensitiveWordReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/SensitiveWordReqVO.java
new file mode 100644
index 0000000..afc6560
--- /dev/null
+++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/SensitiveWordReqVO.java
@@ -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;
+}
diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java
index a2609c6..2897770 100644
--- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java
+++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java
@@ -2,6 +2,17 @@ package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.playback.dal.entity.UserFavorite;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Param;
public interface UserFavoriteMapper extends BaseMapper {
+
+ /**
+ * 物理删除收藏关系。
+ *
+ * 与点赞同理:收藏可反复切换,唯一索引 (user_id, drama_id) 会挡住逻辑删除后的再次收藏。
+ * 之前用 deleteById(全局配置会改成逻辑删除),取消收藏后再收藏会报唯一键冲突。
+ */
+ @Delete("DELETE FROM user_favorite WHERE user_id = #{userId} AND drama_id = #{dramaId}")
+ int physicalDelete(@Param("userId") Long userId, @Param("dramaId") Long dramaId);
}
diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java
index 8bb0546..e6a54e0 100644
--- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java
+++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java
@@ -30,7 +30,8 @@ public class FavoriteService {
.eq(UserFavorite::getDramaId, dramaId)
.last("limit 1"));
if (existing != null) {
- favoriteMapper.deleteById(existing.getId());
+ // 必须物理删除:唯一索引 (user_id, drama_id) 会挡住"取消后再收藏"
+ favoriteMapper.physicalDelete(userId, dramaId);
return false;
}
UserFavorite favorite = new UserFavorite();
@@ -76,9 +77,7 @@ public class FavoriteService {
@Transactional(rollbackFor = Exception.class)
public void remove(Long userId, Long dramaId) {
- favoriteMapper.delete(Wrappers.lambdaQuery()
- .eq(UserFavorite::getUserId, userId)
- .eq(UserFavorite::getDramaId, dramaId));
+ favoriteMapper.physicalDelete(userId, dramaId);
}
@Transactional(rollbackFor = Exception.class)
diff --git a/pom.xml b/pom.xml
index 6bf41d1..6688ab0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,6 +26,7 @@
fruitshorts-module-media
fruitshorts-module-user
fruitshorts-module-playback
+ fruitshorts-module-interaction
fruitshorts-admin-api
fruitshorts-app-api
@@ -78,6 +79,11 @@
fruitshorts-module-playback
${project.version}
+
+ com.fruitshorts
+ fruitshorts-module-interaction
+ ${project.version}
+
com.baomidou
diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql
index 9b96f07..3b6fe84 100644
--- a/sql/data-phase1.sql
+++ b/sql/data-phase1.sql
@@ -37,13 +37,24 @@ INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
('download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104)
ON CONFLICT (code) DO NOTHING;
+INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
+ ('comment:list', '查看评论', '内容治理', 'BUTTON', 111),
+ ('comment:audit', '审核评论', '内容治理', 'BUTTON', 112),
+ ('report:list', '查看举报', '内容治理', 'BUTTON', 113),
+ ('report:handle', '处理举报', '内容治理', 'BUTTON', 114),
+ ('sensitive-word:list', '查看敏感词', '内容治理', 'BUTTON', 115),
+ ('sensitive-word:update','维护敏感词', '内容治理', 'BUTTON', 116)
+ON CONFLICT (code) DO NOTHING;
+
-- 超管默认拥有全部权限点
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM sys_role r
CROSS JOIN sys_permission p
WHERE r.code = 'super_admin'
- AND p.code IN ('app-user:list', 'app-user:update', 'download:list', 'download:revoke')
+ AND p.code IN ('app-user:list', 'app-user:update', 'download:list', 'download:revoke',
+ 'comment:list', 'comment:audit', 'report:list', 'report:handle',
+ 'sensitive-word:list', 'sensitive-word:update')
ON CONFLICT DO NOTHING;
-- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好)