diff --git a/docs/api-phase1.md b/docs/api-phase1.md index a948b10..45512db 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -612,7 +612,7 @@ GET /app/notices?pageIndex=1&pageSize=20 | 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` | 举报工单检索(状态 / 类型 / 原因) | +| 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` | 维护词库,改动即时生效(服务端缓存失效重建) | diff --git a/docs/plans/2026-10-21-K92-report-target.md b/docs/plans/2026-10-21-K92-report-target.md new file mode 100644 index 0000000..ee31f62 --- /dev/null +++ b/docs/plans/2026-10-21-K92-report-target.md @@ -0,0 +1,108 @@ +# K92 举报工单展示短剧名称与分集 + +## 目标 + +- `/admin/reports/page` 不再只返回 `targetType / targetId`,而是返回举报人、目标短剧与分集的展示信息。 +- Web 举报工单列表与处理弹窗使用同一份目标摘要,不再展示裸对象 ID。 +- 不改既有举报处理逻辑、评论列表接口、观看记录接口或数据库结构。 + +## 分支 + +- FruitServer:`feat/k92-report-target`,从 `origin/develop` 创建。 +- ReelShortWeb:`feat/k92-report-target-ui`,从 `origin/develop` 创建。 + +## A. FruitServer + +### A1 返回模型 + +新增 `com.fruitshorts.admin.vo.AdminReportRespVO`,包含: + +- 工单:`id/reasonCode/description/status/handleBy/handleAt/handleNote/createdAt` +- 举报人:`reporterId/reporterNickname/reporterUid` +- 目标:`targetType/targetId/targetSummary` +- 剧集:`dramaId/dramaTitle/episodeId/episodeNo/episodeTitle` +- 评论/用户:`commentContent/targetNickname/targetUid` + +### A2 查询和解析 + +新增 `com.fruitshorts.admin.service.ReportAdminService`: + +1. 分页查询 `content_report`,条件与 `ReportService.page` 保持一致: + `status/targetType/reasonCode/reporterId` 均仅在有文本或非空时加入,空值返回全部; + 排序保持 `id DESC`。 +2. 当前页批量查询: + - COMMENT 目标评论:一次查询,且必须包含逻辑删除记录,用于判断“评论已删除”。 + - 评论与 DRAMA 目标剧:一次批量查询 `drama`。 + - 评论目标分集:一次批量查询 `drama_episode`。 + - USER 目标用户与全部 reporter:合并 id 后一次批量查询 `app_user`。 +3. 禁止逐行查询。 + +`ContentCommentMapper` 增加显式批量查询方法,绕过 `@TableLogic` 的自动过滤; +普通 `selectBatchIds` 仍保持原行为。 + +### A3 摘要规则 + +- COMMENT: + - 正常评论且剧存在:`剧名 · 第 N 集`,有集标题时追加 ` · 集标题`。 + - 评论删除、逻辑删除、评论不存在或剧不存在:`评论 #targetId`。 +- DRAMA: + - 剧存在:剧名。 + - 不存在:`短剧 #targetId`。 +- USER: + - 用户存在:`昵称(uid)`。 + - 不存在:`用户 #targetId`。 + +### A4 Controller 与文档 + +- `ReportAdminController.page` 改为返回 `Result>`。 +- `handle` 继续调用原 `ReportService.handle`,不变更业务行为。 +- 更新 `docs/api-phase1.md` §2.5。 + +### A5 测试 + +新增 `ReportAdminServiceTest`,覆盖: + +- COMMENT / DRAMA / USER 三类摘要。 +- 评论集标题、举报人昵称/uid、目标用户昵称/uid。 +- 空筛选条件表示全部。 +- 评论逻辑删除和目标剧不存在时退化文案。 +- 验证按页批量查询,不产生 N+1。 + +门禁:`mvn test` 全模块通过。 + +## B. ReelShortWeb + +### B1 类型 + +`ReportItem` 补齐后台新增字段,保留原 `targetId` 用于 tooltip 排查。 + +### B2 治理页 + +1. 「对象」列: + - 中文类型标签:评论 / 用户 / 短剧。 + - 显示 `targetSummary`。 + - tooltip 显示原始 `#targetId`,评论类型有内容时追加评论内容。 +2. 删除独立「对象ID」列。 +3. 「举报人」列显示昵称;无昵称回退 uid;再无回退 `#reporterId`。 +4. 处理弹窗「工单对象」显示类型标签 + `targetSummary`。 + +### B3 门禁 + +- `pnpm ts:check` +- `pnpm build:dev` +- 对改动文件执行定向 ESLint + +## 风险与说明 + +1. 评论逻辑删除记录默认被 MyBatis-Plus 过滤;本次使用显式 mapper 查询识别, + 不改变评论正常查询和删除行为。 +2. 已删除评论不展示正文,避免后台误读已删除内容;仍保留 `targetId` tooltip 便于排查。 +3. 目标关联数据已物理删除时按任务书使用 `评论 #id / 短剧 #id / 用户 #id` 退化。 +4. 本任务无数据库迁移,不改 specs 仓库和 Flutter 客户端。 + +## Git + +- FruitServer:`feat(admin): 举报工单返回短剧与分集信息` +- ReelShortWeb:`feat(admin): 举报列表展示短剧与分集` +- 两仓库均 `--no-ff` 合回 `develop` 并 push。 +- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 927eaeb..08f97e8 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -23868,3 +23868,64 @@ exit 0 - ReelShortWeb 的 Husky pre-commit 依赖不可用的 `npm`,commit-msg 又受当前 pnpm 版本与仓库 8.1 声明不兼容影响;功能与合并提交使用 `--no-verify`,门禁已独立完成。 - 未安装或操作真机,未修改 specs 仓库与 Flutter 客户端,等待用户验收。 + +## 2026-10-21 K92 举报工单短剧与分集展示 + +- 已完整读取 K92 `docs/plans/INBOX.md` 及项目协作、编码、Git 规范。 +- 已创建 FruitServer `feat/k92-report-target` 与 ReelShortWeb `feat/k92-report-target-ui`, + 均基于最新 `origin/develop`。 +- 已先写计划 `docs/plans/2026-10-21-K92-report-target.md`,明确三类目标摘要、 + 批量查询边界、逻辑删除评论识别和退化规则。 +- 本任务无数据库迁移,不改 specs、Flutter 客户端、评论列表或观看记录接口。 +- 已完成 A 主体代码:新增 `AdminReportRespVO`、`ReportAdminService`,分页按当前页批量装配 + 评论、剧、集与用户;`ContentCommentMapper` 增加包含逻辑删除记录的批量查询。 +- `ReportAdminController.page` 已切换为 `AdminReportRespVO`,`handle` 继续走原 `ReportService`。 +- `docs/api-phase1.md` §2.5 已补充举报人、剧名、集号、集标题与目标摘要说明。 +- 已新增 `ReportAdminServiceTest`:覆盖三类目标摘要、举报人信息、空筛选、逻辑删除/目标缺失退化, + 并通过 `times(1)` 验证按页每类关联只批量查询一次。 +- K92 定向后端测试已通过: + +```text +$ JAVA_HOME= mvn -pl fruitshorts-admin-api -am \ + -Dtest=ReportAdminServiceTest -Dsurefire.failIfNoSpecifiedTests=false test +[INFO] fruitshorts-admin-api ... SUCCESS +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +解读:COMMENT/DRAMA/USER 三类摘要、批量装配、空筛选以及删除/缺失退化均通过。 +- 已完成 B 主体代码:`ReportItem` 补齐展示字段;举报工单表格改为中文类型标签 + + `targetSummary`,删除对象 ID 列,tooltip 保留原始 `#targetId` 与评论内容; + 举报人按昵称/uid/id 回退,处理弹窗同步展示类型标签与目标摘要。 +- FruitServer 全模块门禁已通过: + +```text +$ JAVA_HOME= mvn test +[INFO] fruitshorts-admin-api .............................. SUCCESS +[INFO] fruitshorts-app-api ................................ SUCCESS +[INFO] fruitshorts-transcode-worker ....................... SUCCESS +[INFO] BUILD SUCCESS +[INFO] Total time: 20.438 s +``` + +解读:全部 14 个 Reactor 模块通过;新增举报目标装配测试与既有后端测试均全过。 +- ReelShortWeb 门禁已通过: + +```text +$ pnpm ts:check +> fruitshorts-admin@1.0.0 ts:check +> pnpm vue-tsc --noEmit --skipLibCheck +exit 0 + +$ pnpm exec eslint src/api/admin/types.ts \ + src/views/governance/ContentGovernanceView.vue +exit 0 + +$ pnpm build:dev +> fruitshorts-admin@1.0.0 build:dev +> VITE_CJS_IGNORE_WARNING=1 vite build --mode dev +Build successful. Please see dist-dev directory +``` + +解读:首次定向 ESLint 发现两处 Prettier 换行问题,按既有格式规则修正后零错误; +dev 构建保留既有 `element-plus` circular chunk 警告,不影响成功退出。 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 index ae670a1..4bc458b 100644 --- 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 @@ -2,11 +2,12 @@ package com.fruitshorts.admin.controller; +import com.fruitshorts.admin.service.ReportAdminService; +import com.fruitshorts.admin.vo.AdminReportRespVO; 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; @@ -28,13 +29,14 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor public class ReportAdminController { + private final ReportAdminService reportAdminService; private final ReportService reportService; @Operation(summary = "举报工单分页查询") @GetMapping("/page") @PreAuthorize("@ss.hasPermission('report:list')") - public Result> page(ReportQueryReqVO req) { - return Result.ok(reportService.page(req)); + public Result> page(ReportQueryReqVO req) { + return Result.ok(reportAdminService.page(req)); } /** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */ diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/ReportAdminService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/ReportAdminService.java new file mode 100644 index 0000000..a0a3b0b --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/ReportAdminService.java @@ -0,0 +1,283 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.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.admin.vo.AdminReportRespVO; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; +import com.fruitshorts.content.dal.mapper.DramaMapper; +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.service.CommentService; +import com.fruitshorts.interaction.service.ReportService; +import com.fruitshorts.interaction.vo.ReportQueryReqVO; +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.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 后台举报工单分页与目标展示信息装配。 + * + *

举报只保存目标类型和 id;该服务在当前页一次性加载评论、剧、集和用户, + * 避免列表逐行查询,同时保持原举报处理流程不变。 + */ +@Service +@RequiredArgsConstructor +public class ReportAdminService { + + private static final int DEFAULT_PAGE_INDEX = 1; + private static final int DEFAULT_PAGE_SIZE = 10; + private static final int DELETED_FLAG = 1; + + private final ContentReportMapper reportMapper; + private final ContentCommentMapper commentMapper; + private final DramaMapper dramaMapper; + private final DramaEpisodeMapper episodeMapper; + private final AppUserMapper appUserMapper; + + /** 按举报工单筛选条件分页查询,并补齐展示字段。 */ + public PageResult page(ReportQueryReqVO req) { + Page page = new Page<>( + req.getPageIndex() == null || req.getPageIndex() < 1 + ? DEFAULT_PAGE_INDEX : req.getPageIndex(), + req.getPageSize() == null || req.getPageSize() < 1 + ? DEFAULT_PAGE_SIZE : 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(), toVoList(result.getRecords())); + } + + private List toVoList(List rows) { + if (rows.isEmpty()) { + return List.of(); + } + Map comments = loadComments(rows); + Map dramas = loadDramas(rows, comments); + Map episodes = loadEpisodes(comments); + Map users = loadUsers(rows); + List list = new ArrayList<>(rows.size()); + for (ContentReport row : rows) { + list.add(toVo(row, comments, dramas, episodes, users)); + } + return list; + } + + private Map loadComments(List rows) { + List ids = rows.stream() + .filter(row -> ReportService.TYPE_COMMENT.equals(row.getTargetType())) + .map(ContentReport::getTargetId) + .filter(id -> id != null && id > 0L) + .distinct() + .toList(); + Map result = new HashMap<>(); + if (ids.isEmpty()) { + return result; + } + for (ContentComment comment : commentMapper.selectByIdsIncludingDeleted(ids)) { + result.put(comment.getId(), comment); + } + return result; + } + + private Map loadDramas(List rows, + Map comments) { + Set ids = new LinkedHashSet<>(); + for (ContentReport row : rows) { + if (ReportService.TYPE_DRAMA.equals(row.getTargetType())) { + addPositive(ids, row.getTargetId()); + continue; + } + if (ReportService.TYPE_COMMENT.equals(row.getTargetType())) { + ContentComment comment = comments.get(row.getTargetId()); + if (!isDeleted(comment)) { + addPositive(ids, comment.getDramaId()); + } + } + } + Map result = new HashMap<>(); + if (ids.isEmpty()) { + return result; + } + for (Drama drama : dramaMapper.selectBatchIds(ids)) { + result.put(drama.getId(), drama); + } + return result; + } + + private Map loadEpisodes(Map comments) { + Set ids = new LinkedHashSet<>(); + for (ContentComment comment : comments.values()) { + if (!isDeleted(comment)) { + addPositive(ids, comment.getEpisodeId()); + } + } + Map result = new HashMap<>(); + if (ids.isEmpty()) { + return result; + } + for (DramaEpisode episode : episodeMapper.selectBatchIds(ids)) { + result.put(episode.getId(), episode); + } + return result; + } + + private Map loadUsers(List rows) { + Set ids = new LinkedHashSet<>(); + for (ContentReport row : rows) { + addPositive(ids, row.getReporterId()); + if (ReportService.TYPE_USER.equals(row.getTargetType())) { + addPositive(ids, row.getTargetId()); + } + } + Map result = new HashMap<>(); + if (ids.isEmpty()) { + return result; + } + for (AppUser user : appUserMapper.selectBatchIds(ids)) { + result.put(user.getId(), user); + } + return result; + } + + private AdminReportRespVO toVo(ContentReport report, + Map comments, + Map dramas, + Map episodes, + Map users) { + AdminReportRespVO vo = new AdminReportRespVO(); + copyReport(report, vo); + fillReporter(report, users.get(report.getReporterId()), vo); + if (ReportService.TYPE_COMMENT.equals(report.getTargetType())) { + fillCommentTarget(report, comments.get(report.getTargetId()), dramas, episodes, vo); + } else if (ReportService.TYPE_DRAMA.equals(report.getTargetType())) { + fillDramaTarget(report, dramas.get(report.getTargetId()), vo); + } else if (ReportService.TYPE_USER.equals(report.getTargetType())) { + fillUserTarget(report, users.get(report.getTargetId()), vo); + } + return vo; + } + + private void copyReport(ContentReport report, AdminReportRespVO vo) { + vo.setId(report.getId()); + vo.setReporterId(report.getReporterId()); + vo.setTargetType(report.getTargetType()); + vo.setTargetId(report.getTargetId()); + vo.setReasonCode(report.getReasonCode()); + vo.setDescription(report.getDescription()); + vo.setStatus(report.getStatus()); + vo.setHandleBy(report.getHandleBy()); + vo.setHandleAt(report.getHandleAt()); + vo.setHandleNote(report.getHandleNote()); + vo.setCreatedAt(report.getCreatedAt()); + } + + private void fillReporter(ContentReport report, AppUser reporter, AdminReportRespVO vo) { + if (reporter != null) { + vo.setReporterNickname(reporter.getNickname()); + vo.setReporterUid(reporter.getUid()); + } + } + + private void fillCommentTarget(ContentReport report, ContentComment comment, + Map dramas, + Map episodes, + AdminReportRespVO vo) { + if (isDeleted(comment)) { + vo.setTargetSummary("评论 #" + report.getTargetId()); + return; + } + Drama drama = dramas.get(comment.getDramaId()); + if (drama == null) { + vo.setTargetSummary("评论 #" + report.getTargetId()); + return; + } + DramaEpisode episode = episodes.get(comment.getEpisodeId()); + vo.setDramaId(comment.getDramaId()); + vo.setDramaTitle(drama.getTitle()); + vo.setEpisodeId(comment.getEpisodeId()); + vo.setCommentContent(comment.getContent()); + if (episode == null) { + vo.setTargetSummary(drama.getTitle()); + return; + } + vo.setEpisodeNo(episode.getEpNo()); + vo.setEpisodeTitle(episode.getTitle()); + String summary = drama.getTitle() + " · 第 " + episode.getEpNo() + " 集"; + if (StringUtils.hasText(episode.getTitle())) { + summary += " · " + episode.getTitle(); + } + vo.setTargetSummary(summary); + } + + private void fillDramaTarget(ContentReport report, Drama drama, AdminReportRespVO vo) { + vo.setDramaId(report.getTargetId()); + if (drama == null) { + vo.setTargetSummary("短剧 #" + report.getTargetId()); + return; + } + vo.setDramaTitle(drama.getTitle()); + vo.setTargetSummary(drama.getTitle()); + } + + private void fillUserTarget(ContentReport report, AppUser user, AdminReportRespVO vo) { + if (user == null) { + vo.setTargetSummary("用户 #" + report.getTargetId()); + return; + } + vo.setTargetNickname(user.getNickname()); + vo.setTargetUid(user.getUid()); + vo.setTargetSummary(formatUser(user, report.getTargetId())); + } + + private String formatUser(AppUser user, Long fallbackId) { + boolean hasNickname = StringUtils.hasText(user.getNickname()); + boolean hasUid = StringUtils.hasText(user.getUid()); + if (hasNickname && hasUid) { + return user.getNickname() + "(" + user.getUid() + ")"; + } + if (hasNickname) { + return user.getNickname(); + } + if (hasUid) { + return user.getUid(); + } + return "用户 #" + fallbackId; + } + + private boolean isDeleted(ContentComment comment) { + return comment == null + || CommentService.STATUS_DELETED.equals(comment.getStatus()) + || Integer.valueOf(DELETED_FLAG).equals(comment.getDeleted()); + } + + private void addPositive(Set ids, Long id) { + if (id != null && id > 0L) { + ids.add(id); + } + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminReportRespVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminReportRespVO.java new file mode 100644 index 0000000..2327a9c --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminReportRespVO.java @@ -0,0 +1,35 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 后台举报工单视图,附带举报人与被举报目标的展示信息。 */ +@Data +public class AdminReportRespVO { + + private Long id; + private Long reporterId; + private String reporterNickname; + private String reporterUid; + private String targetType; + private Long targetId; + private String targetSummary; + private Long dramaId; + private String dramaTitle; + private Long episodeId; + private Integer episodeNo; + private String episodeTitle; + private String commentContent; + private String targetNickname; + private String targetUid; + private String reasonCode; + private String description; + private String status; + private Long handleBy; + private LocalDateTime handleAt; + private String handleNote; + private LocalDateTime createdAt; +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/ReportAdminServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/ReportAdminServiceTest.java new file mode 100644 index 0000000..3b432ac --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/ReportAdminServiceTest.java @@ -0,0 +1,209 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.admin.vo.AdminReportRespVO; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; +import com.fruitshorts.content.dal.mapper.DramaMapper; +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.service.ReportService; +import com.fruitshorts.interaction.vo.ReportQueryReqVO; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** 后台举报工单目标摘要与批量装配。 */ +class ReportAdminServiceTest { + + private ContentReportMapper reportMapper; + private ContentCommentMapper commentMapper; + private DramaMapper dramaMapper; + private DramaEpisodeMapper episodeMapper; + private AppUserMapper appUserMapper; + private ReportAdminService service; + + @BeforeAll + static void initTableInfoCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ContentReport.class); + } + + @BeforeEach + void setUp() { + reportMapper = mock(ContentReportMapper.class); + commentMapper = mock(ContentCommentMapper.class); + dramaMapper = mock(DramaMapper.class); + episodeMapper = mock(DramaEpisodeMapper.class); + appUserMapper = mock(AppUserMapper.class); + service = new ReportAdminService(reportMapper, commentMapper, dramaMapper, + episodeMapper, appUserMapper); + } + + @Test + void should_build_summary_and_batch_relations_for_all_report_types() { + List rows = List.of( + report(1L, 7L, ReportService.TYPE_COMMENT, 101L), + report(2L, 8L, ReportService.TYPE_DRAMA, 5L), + report(3L, 7L, ReportService.TYPE_USER, 9L)); + stubPage(rows); + given(commentMapper.selectByIdsIncludingDeleted(any())) + .willReturn(List.of(comment(101L, 5L, 11L, "bad comment", 0))); + given(dramaMapper.selectBatchIds(any())).willReturn(List.of(drama(5L, "Signal"))); + given(episodeMapper.selectBatchIds(any())).willReturn(List.of(episode(11L, 3, "The Call"))); + given(appUserMapper.selectBatchIds(any())).willReturn(List.of( + user(7L, "Alice", "u-7"), + user(8L, "Bob", "u-8"), + user(9L, "Target", "u-9"))); + + PageResult result = service.page(new ReportQueryReqVO()); + + AdminReportRespVO commentReport = result.getList().get(0); + assertThat(commentReport.getTargetSummary()) + .isEqualTo("Signal · 第 3 集 · The Call"); + assertThat(commentReport.getDramaTitle()).isEqualTo("Signal"); + assertThat(commentReport.getEpisodeNo()).isEqualTo(3); + assertThat(commentReport.getCommentContent()).isEqualTo("bad comment"); + assertThat(commentReport.getReporterNickname()).isEqualTo("Alice"); + assertThat(commentReport.getReporterUid()).isEqualTo("u-7"); + + AdminReportRespVO dramaReport = result.getList().get(1); + assertThat(dramaReport.getTargetSummary()).isEqualTo("Signal"); + assertThat(dramaReport.getDramaId()).isEqualTo(5L); + + AdminReportRespVO userReport = result.getList().get(2); + assertThat(userReport.getTargetSummary()).isEqualTo("Target(u-9)"); + assertThat(userReport.getTargetNickname()).isEqualTo("Target"); + assertThat(userReport.getTargetUid()).isEqualTo("u-9"); + verify(commentMapper, times(1)).selectByIdsIncludingDeleted(List.of(101L)); + verify(dramaMapper, times(1)).selectBatchIds(any()); + verify(episodeMapper, times(1)).selectBatchIds(any()); + verify(appUserMapper, times(1)).selectBatchIds(any()); + } + + @Test + void should_leave_all_filters_unset_when_status_and_filters_are_empty() { + stubPage(List.of()); + + PageResult result = service.page(new ReportQueryReqVO()); + + assertThat(result.getTotal()).isZero(); + assertThat(result.getList()).isEmpty(); + ArgumentCaptor> captor = queryCaptor(); + verify(reportMapper).selectPage(any(), captor.capture()); + assertThat(captor.getValue().getSqlSegment()) + .doesNotContain("status") + .doesNotContain("target_type") + .doesNotContain("reason_code") + .doesNotContain("reporter_id"); + } + + @Test + void should_fallback_to_ids_when_targets_are_deleted_or_missing() { + List rows = List.of( + report(1L, 7L, ReportService.TYPE_COMMENT, 101L), + report(2L, 7L, ReportService.TYPE_DRAMA, 5L), + report(3L, 7L, ReportService.TYPE_USER, 9L)); + stubPage(rows); + given(commentMapper.selectByIdsIncludingDeleted(any())) + .willReturn(List.of(comment(101L, 5L, 11L, "removed", 1))); + given(dramaMapper.selectBatchIds(any())).willReturn(List.of()); + given(appUserMapper.selectBatchIds(any())) + .willReturn(List.of(user(7L, "Alice", "u-7"))); + + PageResult result = service.page(new ReportQueryReqVO()); + + assertThat(result.getList()).extracting(AdminReportRespVO::getTargetSummary) + .containsExactly("评论 #101", "短剧 #5", "用户 #9"); + assertThat(result.getList().get(0).getCommentContent()).isNull(); + verify(episodeMapper, never()).selectBatchIds(any()); + } + + private void stubPage(List rows) { + Page page = new Page<>(1, 10); + page.setTotal(rows.size()); + page.setRecords(rows); + given(reportMapper.selectPage(any(Page.class), any())).willReturn(page); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> queryCaptor() { + return ArgumentCaptor.forClass(LambdaQueryWrapper.class); + } + + private ContentReport report(Long id, Long reporterId, String type, Long targetId) { + ContentReport report = new ContentReport(); + report.setId(id); + report.setReporterId(reporterId); + report.setTargetType(type); + report.setTargetId(targetId); + report.setReasonCode("VIOLATION"); + report.setDescription("description"); + report.setStatus("pending"); + report.setHandleBy(0L); + report.setHandleNote(""); + report.setCreatedAt(LocalDateTime.of(2026, 10, 21, 12, 0)); + return report; + } + + private ContentComment comment(Long id, Long dramaId, Long episodeId, String content, + int deleted) { + ContentComment comment = new ContentComment(); + comment.setId(id); + comment.setDramaId(dramaId); + comment.setEpisodeId(episodeId); + comment.setUserId(9L); + comment.setContent(content); + comment.setStatus(deleted == 1 ? "deleted" : "published"); + comment.setDeleted(deleted); + return comment; + } + + private Drama drama(Long id, String title) { + Drama drama = new Drama(); + drama.setId(id); + drama.setTitle(title); + return drama; + } + + private DramaEpisode episode(Long id, int no, String title) { + DramaEpisode episode = new DramaEpisode(); + episode.setId(id); + episode.setEpNo(no); + episode.setTitle(title); + return episode; + } + + private AppUser user(Long id, String nickname, String uid) { + AppUser user = new AppUser(); + user.setId(id); + user.setNickname(nickname); + user.setUid(uid); + return user; + } +} 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 index 4e44525..730194b 100644 --- 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 @@ -4,6 +4,31 @@ package com.fruitshorts.interaction.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.fruitshorts.interaction.dal.entity.ContentComment; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.Collection; +import java.util.List; public interface ContentCommentMapper extends BaseMapper { + + /** + * 后台举报工单按评论 id 批量读取,包含逻辑删除记录。 + * + *

普通 BaseMapper 查询会自动追加 deleted = 0,无法判断被举报评论是否已删除; + * 这里显式列出字段并绕过逻辑删除过滤。 + */ + @Select(""" + + """) + List selectByIdsIncludingDeleted(@Param("ids") Collection ids); }