diff --git a/docs/api-phase1.md b/docs/api-phase1.md index c9aa578..a2af271 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -173,16 +173,28 @@ POST /app/auth/register 需要解锁的剧集走权益判定,未解锁不允许下载(当前内容均为免费集,付费链路见 09)。 配额与有效期由 `fruitshorts.download.*` 配置,通过 `policy` 下发,客户端不要写死。 -### 1.7 互动、交易、创作者(下一批) +### 1.7 互动与内容治理(已实现) + +| 方法 | 路径 | 鉴权 | 说明 | +| --- | --- | --- | --- | +| POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 | +| GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 | +| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) | +| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `COMMENT` | +| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 | + +**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。 +审核策略由 `fruitshorts.interaction.comment-audit-mode` 控制: +`MACHINE_FIRST`(机审通过即发布,当前默认)或 `PRE_REVIEW`(全部先审后发,生产建议)。 + +**可见性**:匿名与其他人只看得到 `published`;自己待审的评论也会返回,便于展示"审核中"。 +评论列表禁止重复提交(同一剧集每分钟 5 条,可配)。 + +### 1.8 交易、创作者(下一批) | 方法 | 路径 | 说明 | | --- | --- | --- | -| POST | `/app/comments` | 发表评论(先审后发) | -| GET | `/app/comments?dramaId=&cursor=` | 评论列表(游标分页) | -| DELETE | `/app/comments/{id}` | 删除自己的评论 | -| POST | `/app/likes/toggle` | 点赞(DRAMA/EPISODE/COMMENT) | -| POST | `/app/reports` | 举报 | -| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据,服务端二次校验后发权益 | +| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据,服务端二次校验后发权益(本轮只留接口,不接真实商店) | | GET | `/app/orders` | 我的订单 | | GET | `/app/wallet` | 金币余额与流水 | | POST | `/app/episodes/{id}/unlock` | 金币解锁分集 | @@ -226,7 +238,20 @@ POST /app/auth/register | PUT | `/admin/downloads/{id}/revoke` | `download:revoke` | 吊销授权,用户侧下次联网即失效 | | POST | `/admin/downloads/cleanup` | `download:revoke` | 手动清理过期授权(默认每天凌晨自动执行) | -### 2.5 内容治理、订单、创作者审核、数据看板(下一批) +### 2.5 内容治理(已实现) + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| GET | `/admin/comments/page` | `comment:list` | 检索评论,`status=pending` 即审核队列 | +| GET | `/admin/comments/{id}/audit-records` | `comment:list` | 审核留证:机审与人审的完整链路 | +| PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) | +| DELETE | `/admin/comments/{id}` | `comment:audit` | 删除评论 | +| GET | `/admin/reports/page` | `report:list` | 举报工单检索(状态 / 类型 / 原因) | +| PUT | `/admin/reports/{id}/handle` | `report:handle` | 处理工单;结论成立会联动驳回被举报评论,可选择同时封禁被举报用户 | +| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 | +| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) | + +### 2.6 订单、创作者审核、数据看板(下一批) | 方法 | 路径 | 说明 | | --- | --- | --- | @@ -257,7 +282,7 @@ POST /app/auth/register | 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) | | 04 播放器与播放控制 | 已建 | 已冻结 | 票据 + 签名 + 进度 已实现;多码率依赖转码 worker | | 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 | -| 06 评论、点赞与分享 | 已建 | 已冻结 | 下一批 | +| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;分享埋点在下一批 | | 07 创作者中心 | 已建 | 已冻结 | 下一批(转码任务与产物记账已实现) | | 08 平台运营后台 | 已建 | 用户管理已实现 | 治理/订单/看板 下一批 | | 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) | diff --git a/fruitshorts-admin-api/pom.xml b/fruitshorts-admin-api/pom.xml index 4f2b405..2ba4278 100644 --- a/fruitshorts-admin-api/pom.xml +++ b/fruitshorts-admin-api/pom.xml @@ -35,6 +35,10 @@ com.fruitshorts fruitshorts-module-playback + + com.fruitshorts + fruitshorts-module-interaction + org.springframework.boot spring-boot-starter-test diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CommentAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CommentAdminController.java new file mode 100644 index 0000000..4ae7780 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CommentAdminController.java @@ -0,0 +1,62 @@ +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.interaction.dal.entity.ContentAuditRecord; +import com.fruitshorts.interaction.service.CommentService; +import com.fruitshorts.interaction.vo.CommentAuditReqVO; +import com.fruitshorts.interaction.vo.CommentQueryReqVO; +import com.fruitshorts.interaction.vo.CommentRespVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** 后台评论审核。 */ +@RestController +@RequestMapping("/admin/comments") +@RequiredArgsConstructor +public class CommentAdminController { + + private final CommentService commentService; + + /** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */ + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('comment:list')") + public Result> page(CommentQueryReqVO req) { + return Result.ok(commentService.adminPage(req)); + } + + /** 审核留证:机审与人审的完整链路。 */ + @GetMapping("/{id}/audit-records") + @PreAuthorize("@ss.hasPermission('comment:list')") + public Result> auditRecords(@PathVariable Long id) { + return Result.ok(commentService.auditRecords(id)); + } + + @PutMapping("/{id}/audit") + @PreAuthorize("@ss.hasPermission('comment:audit')") + @OperationLog(module = "内容治理", action = "审核评论") + public Result audit(@PathVariable Long id, @Valid @RequestBody CommentAuditReqVO req) { + commentService.audit(id, req, SecurityUtils.getUserId()); + return Result.ok(); + } + + @DeleteMapping("/{id}") + @PreAuthorize("@ss.hasPermission('comment:audit')") + @OperationLog(module = "内容治理", action = "删除评论") + public Result delete(@PathVariable Long id) { + commentService.adminDelete(id); + return Result.ok(); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ReportAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ReportAdminController.java new file mode 100644 index 0000000..9c2d3ae --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ReportAdminController.java @@ -0,0 +1,42 @@ +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.interaction.dal.entity.ContentReport; +import com.fruitshorts.interaction.service.ReportService; +import com.fruitshorts.interaction.vo.ReportHandleReqVO; +import com.fruitshorts.interaction.vo.ReportQueryReqVO; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 后台举报工单。 */ +@RestController +@RequestMapping("/admin/reports") +@RequiredArgsConstructor +public class ReportAdminController { + + private final ReportService reportService; + + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('report:list')") + public Result> page(ReportQueryReqVO req) { + return Result.ok(reportService.page(req)); + } + + /** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */ + @PutMapping("/{id}/handle") + @PreAuthorize("@ss.hasPermission('report:handle')") + @OperationLog(module = "内容治理", action = "处理举报") + public Result handle(@PathVariable Long id, @RequestBody ReportHandleReqVO req) { + reportService.handle(id, req, SecurityUtils.getUserId()); + return Result.ok(); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/SensitiveWordAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/SensitiveWordAdminController.java new file mode 100644 index 0000000..009857e --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/SensitiveWordAdminController.java @@ -0,0 +1,62 @@ +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.interaction.dal.entity.SensitiveWord; +import com.fruitshorts.interaction.service.SensitiveWordService; +import com.fruitshorts.interaction.vo.SensitiveWordReqVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +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.PutMapping; +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; + +/** 后台敏感词库。改动即时生效(服务端词库缓存会失效重建)。 */ +@RestController +@RequestMapping("/admin/sensitive-words") +@RequiredArgsConstructor +public class SensitiveWordAdminController { + + private final SensitiveWordService sensitiveWordService; + + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('sensitive-word:list')") + public Result> page(@RequestParam(required = false) String keyword, + @RequestParam(required = false) String category, + @RequestParam(required = false) String level, + @RequestParam(defaultValue = "1") Integer pageIndex, + @RequestParam(defaultValue = "10") Integer pageSize) { + return Result.ok(sensitiveWordService.page(keyword, category, level, pageIndex, pageSize)); + } + + @PostMapping + @PreAuthorize("@ss.hasPermission('sensitive-word:update')") + @OperationLog(module = "内容治理", action = "新增敏感词") + public Result create(@Valid @RequestBody SensitiveWordReqVO req) { + return Result.ok(sensitiveWordService.create(req)); + } + + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPermission('sensitive-word:update')") + @OperationLog(module = "内容治理", action = "修改敏感词") + public Result update(@PathVariable Long id, @Valid @RequestBody SensitiveWordReqVO req) { + sensitiveWordService.update(id, req); + return Result.ok(); + } + + @DeleteMapping("/{id}") + @PreAuthorize("@ss.hasPermission('sensitive-word:update')") + @OperationLog(module = "内容治理", action = "删除敏感词") + public Result delete(@PathVariable Long id) { + sensitiveWordService.delete(id); + return Result.ok(); + } +} diff --git a/fruitshorts-app-api/pom.xml b/fruitshorts-app-api/pom.xml index 4ef4f00..24471f2 100644 --- a/fruitshorts-app-api/pom.xml +++ b/fruitshorts-app-api/pom.xml @@ -31,6 +31,10 @@ com.fruitshorts fruitshorts-module-playback + + com.fruitshorts + fruitshorts-module-interaction + 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; -- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好)