feat(admin): 举报工单返回短剧与分集信息

This commit is contained in:
Zhou Weidong committed 2026-10-21 16:44:46 +08:00
1 parent 6ddddef5ec
commit 9a74876b3f
8 files changed
+727 -4

No files matched your search

+1 -1
View File
@@ -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` | 维护词库,改动即时生效(服务端缓存失效重建) |
+108
View File
@@ -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<PageResult<AdminReportRespVO>>`。
- `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`。
+61
View File
@@ -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=<jdk21> 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=<jdk21> 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 警告,不影响成功退出。
@@ -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<PageResult<ContentReport>> page(ReportQueryReqVO req) {
return Result.ok(reportService.page(req));
public Result<PageResult<AdminReportRespVO>> page(ReportQueryReqVO req) {
return Result.ok(reportAdminService.page(req));
}
/** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */
@@ -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;
/**
* 后台举报工单分页与目标展示信息装配。
*
* <p>举报只保存目标类型和 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<AdminReportRespVO> page(ReportQueryReqVO req) {
Page<ContentReport> 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<ContentReport> result = reportMapper.selectPage(page,
Wrappers.<ContentReport>lambdaQuery()
.eq(StringUtils.hasText(req.getStatus()),
ContentReport::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getTargetType()),
ContentReport::getTargetType, req.getTargetType())
.eq(StringUtils.hasText(req.getReasonCode()),
ContentReport::getReasonCode, req.getReasonCode())
.eq(req.getReporterId() != null,
ContentReport::getReporterId, req.getReporterId())
.orderByDesc(ContentReport::getId));
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
}
private List<AdminReportRespVO> toVoList(List<ContentReport> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, ContentComment> comments = loadComments(rows);
Map<Long, Drama> dramas = loadDramas(rows, comments);
Map<Long, DramaEpisode> episodes = loadEpisodes(comments);
Map<Long, AppUser> users = loadUsers(rows);
List<AdminReportRespVO> list = new ArrayList<>(rows.size());
for (ContentReport row : rows) {
list.add(toVo(row, comments, dramas, episodes, users));
}
return list;
}
private Map<Long, ContentComment> loadComments(List<ContentReport> rows) {
List<Long> ids = rows.stream()
.filter(row -> ReportService.TYPE_COMMENT.equals(row.getTargetType()))
.map(ContentReport::getTargetId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
Map<Long, ContentComment> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (ContentComment comment : commentMapper.selectByIdsIncludingDeleted(ids)) {
result.put(comment.getId(), comment);
}
return result;
}
private Map<Long, Drama> loadDramas(List<ContentReport> rows,
Map<Long, ContentComment> comments) {
Set<Long> 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<Long, Drama> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
result.put(drama.getId(), drama);
}
return result;
}
private Map<Long, DramaEpisode> loadEpisodes(Map<Long, ContentComment> comments) {
Set<Long> ids = new LinkedHashSet<>();
for (ContentComment comment : comments.values()) {
if (!isDeleted(comment)) {
addPositive(ids, comment.getEpisodeId());
}
}
Map<Long, DramaEpisode> result = new HashMap<>();
if (ids.isEmpty()) {
return result;
}
for (DramaEpisode episode : episodeMapper.selectBatchIds(ids)) {
result.put(episode.getId(), episode);
}
return result;
}
private Map<Long, AppUser> loadUsers(List<ContentReport> rows) {
Set<Long> ids = new LinkedHashSet<>();
for (ContentReport row : rows) {
addPositive(ids, row.getReporterId());
if (ReportService.TYPE_USER.equals(row.getTargetType())) {
addPositive(ids, row.getTargetId());
}
}
Map<Long, AppUser> 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<Long, ContentComment> comments,
Map<Long, Drama> dramas,
Map<Long, DramaEpisode> episodes,
Map<Long, AppUser> 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<Long, Drama> dramas,
Map<Long, DramaEpisode> 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<Long> ids, Long id) {
if (id != null && id > 0L) {
ids.add(id);
}
}
}
@@ -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;
}
@@ -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<ContentReport> 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<AdminReportRespVO> 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<AdminReportRespVO> result = service.page(new ReportQueryReqVO());
assertThat(result.getTotal()).isZero();
assertThat(result.getList()).isEmpty();
ArgumentCaptor<LambdaQueryWrapper<ContentReport>> 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<ContentReport> 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<AdminReportRespVO> 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<ContentReport> rows) {
Page<ContentReport> 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<LambdaQueryWrapper<ContentReport>> 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;
}
}
@@ -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<ContentComment> {
/**
* 后台举报工单按评论 id 批量读取,包含逻辑删除记录。
*
* <p>普通 BaseMapper 查询会自动追加 deleted = 0,无法判断被举报评论是否已删除;
* 这里显式列出字段并绕过逻辑删除过滤。
*/
@Select("""
<script>
SELECT id, drama_id, episode_id, user_id, parent_id, root_id, content, status,
audit_type, audit_reason, like_count, reply_count, report_count, ip,
device_id, country_code, created_at, updated_at, deleted
FROM content_comment
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</script>
""")
List<ContentComment> selectByIdsIncludingDeleted(@Param("ids") Collection<Long> ids);
}