merge: 合并 K92 举报目标短剧与分集信息
This commit is contained in:
commit
ae053b0268
8 files changed
+727
-4
No files matched your search
+1
-1
@@ -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` | 维护词库,改动即时生效(服务端缓存失效重建) |
|
||||
|
||||
@@ -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`。
|
||||
@@ -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 警告,不影响成功退出。
|
||||
+5
-3
@@ -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));
|
||||
}
|
||||
|
||||
/** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */
|
||||
|
||||
+283
@@ -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;
|
||||
}
|
||||
+209
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user