feat(admin): 新增观看记录分页与评论分集信息

This commit is contained in:
Zhou Weidong committed 2026-10-21 16:35:26 +08:00
1 parent d52b805adf
commit 1d316791b1
14 files changed
+953 -7

No files matched your search

+5 -1
View File
@@ -590,6 +590,7 @@ GET /app/notices?pageIndex=1&pageSize=20
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
| GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 |
| GET | `/admin/app-users/{id}/watch-history` | `app-user:list` | 观看流水明细(带剧名/集号,客服核对用) |
| GET | `/admin/watch-history/page` | `watch-history:list` | 跨用户观看记录分页:keyword、userId、dramaTitle、dramaId、episodeId、source、finished、startTime、endTime |
| PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned |
状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。
@@ -607,7 +608,7 @@ GET /app/notices?pageIndex=1&pageSize=20
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/comments/page` | `comment:list` | 检索评论,`status=pending` 即审核队列 |
| 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` | 删除评论 |
@@ -616,6 +617,9 @@ GET /app/notices?pageIndex=1&pageSize=20
| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 |
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
后台评论行除 `dramaId/episodeId` 外,还返回 `dramaTitle/episodeNo/episodeTitle`;
剧或集已删除时对应展示字段留空。后台评论与举报的 `status` 空值均表示全部状态。
### 2.6 订单与权益(已实现)
| 方法 | 路径 | 权限点 | 说明 |
+114
View File
@@ -0,0 +1,114 @@
# K91 后台观看记录独立菜单、评论分集展示与全部状态筛选
## 目标
- FruitServer 新增跨用户观看记录分页接口,并给后台评论补充短剧名、集号与集标题。
- ReelShortWeb 新增「观看记录」独立页面,评论/举报状态筛选默认展示全部。
- 迁移脚本只提交文件,不执行数据库变更;不改 specs 仓库和 Flutter 客户端。
## 分支
- FruitServer:`feat/k91-watch-history`,从 `origin/develop` 创建。
- ReelShortWeb:`feat/k91-watch-history-page`,从 `origin/develop` 创建。
## A. FruitServer
### A1 观看记录分页
1. 新增 `WatchHistoryQueryReqVO`,字段:
`pageIndex/pageSize/keyword/userId/dramaTitle/dramaId/episodeId/source/finished/startTime/endTime`。
2. 新增 `AdminWatchHistoryVO`,返回用户、短剧、分集、进度、完成、来源、设备与观看时间。
3. 新增 `WatchHistoryAdminService`,直接注入:
- `WatchHistoryMapper`
- `AppUserMapper`
- `DramaMapper`
- `DramaEpisodeMapper`
4. 查询流程:
- `keyword` 先批量命中 `app_user.uid/nickname` 的 id;无命中直接空页。
- `dramaTitle` 先批量命中 `drama.title` 的 id;无命中直接空页。
- 精确 id 条件与模糊命中集合取交集。
- `finished` 在 SQL 层表达,允许区间两侧都落在 `duration_ms = 0` 等边界。
- 按 `watched_at DESC, id DESC` 排序。
- 对结果页的用户、剧、集各做一次批量查询后装配,禁止逐行查询。
5. 新增 `WatchHistoryAdminController`,权限点 `watch-history:list`,保留旧用户详情观看流水接口。
### A2 评论后台分集信息
1. `CommentRespVO` 增加 `dramaTitle/episodeNo/episodeTitle`。
2. 仅 `CommentService.adminPage` 对当前页批量补剧、集信息。
3. `toRespList` 的 C 端路径保持不增加查询。
### A3 SQL 与契约
1. 新增幂等迁移 `sql/migrate-k91-watch-history-menu.sql`:
- 权限点 `1105 watch-history:list`
- 菜单使用 id `34`,挂 parent `15`
- `component=views/userManagement/WatchHistoryView`
- 超管角色权限与菜单关联
2. 同步 `sql/data-phase1.sql`。
3. 更新 `docs/api-phase1.md` §2.3、§2.5。
### A4 测试
- `WatchHistoryAdminServiceTest` 覆盖:
- 默认分页与排序
- keyword 用户命中
- dramaTitle 剧名命中
- finished 真/假 SQL 条件
- 时间范围
- 命中集合为空直接空页
- pageSize 上限收敛为 100
- `CommentServiceTest` 覆盖后台页批量补齐剧名、集号、集标题。
- 门禁:`mvn test` 全模块通过。
## B. ReelShortWeb
### B1 类型/API
- 扩展 `WatchHistoryItem`:`userId/uid/nickname/episodeTitle`。
- 扩展 `CommentItem`:`dramaTitle/episodeNo/episodeTitle`。
- `appUser.ts` 新增 `pageWatchHistoryApi(params)`。
### B2 观看记录页面
- 新建 `src/views/userManagement/WatchHistoryView.vue`:
- 用户、短剧名称、来源、完成状态、时间范围筛选。
- 观看时间、用户、短剧、分集、进度、完成、来源、设备列。
- 10/20/50 分页。
- 复用 App 用户页的 `panel/console-toolbar/pagination/app-user-*` 视觉与格式化函数。
### B3 评论/举报全部状态
- 评论与举报状态首项增加 `{ value: '', label: '全部状态', tag: 'info' }`。
- 两个筛选默认值改为空串,请求时空串转 `undefined`。
- 评论表以「短剧 / 分集」替换短剧 ID 单列:
- 显示剧名与集号/集标题。
- 缺少剧名时回退短剧 id。
- 缺少集号时回退分集 id;历史 `episodeId=0` 显示未关联分集。
- 原始短剧 id / 分集 id 通过 tooltip 保留。
### B4 门禁
- `pnpm ts:check`
- `pnpm build:dev`
## 风险与待确认
1. `watch_history` 是月分区表,现有索引为 `(user_id, watched_at)` 与
`(drama_id, watched_at)`,没有跨用户的全局 `watched_at` 索引。本次按要求实现跨用户时间排序,
不擅自新增索引;数据量增长后由 PM 评估是否增加全局观看时间索引。
2. 用户/剧名模糊检索先取 id 集合,符合“命中为空不退化成全表”的要求;
超大关键词命中集合的批大小由后续实际数据量评估,本次不擅自加截断以免漏结果。
3. 历史评论的 `episode_id=0` 不回填;后台显示“未关联分集”,原始 id 仍保留在 tooltip。
4. 迁移脚本只写文件,不执行数据库;菜单落库由 PM 按发布流程执行。
5. 任务书建议菜单 id `26`,但当前 `data-phase1.sql` 已将 `26` 用作“创作者中心”目录,
`27-30` 为其子菜单。为避免覆盖线上已有菜单树,本次使用下一个空闲 id `34`;
父级、路由、组件与权限点均不变。
## 提交与合并
- FruitServer 提交:`feat(admin): 新增观看记录分页与评论分集信息`
- ReelShortWeb 提交:`feat(admin): 观看记录独立菜单与治理状态全部筛选`
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
- 作者与提交者:`Zhou Weidong <502333560@qq.com>`。
- 禁止 `git add -A`,所有文件逐项暂存。
+69
View File
@@ -23782,3 +23782,72 @@ $ flutter test --no-pub
```
说明:本任务仅修改 ReelShort 客户端,未改 FruitServer 功能代码、specs 仓库或后台接口;未安装真机,等待用户验收。
## 2026-10-21 K91 后台观看记录、评论分集与全部状态筛选
- 已完整读取 `docs/plans/INBOX.md`、`AGENTS.md`、`CODING_STANDARDS.md`、`GIT_WORKFLOW.md` 及相关契约/代码结构。
- 已创建 FruitServer 分支 `feat/k91-watch-history` 与 ReelShortWeb 分支 `feat/k91-watch-history-page`,均基于最新 `origin/develop`。
- 已先写计划文档 `docs/plans/2026-10-21-K91-watch-history.md`,其中记录 A/B 实施步骤、测试矩阵、迁移边界和风险。
- 本轮计划只提交 SQL 迁移文件,不执行数据库变更;不改 specs 与 Flutter 客户端。
- 勘察发现任务书建议的菜单 id `26` 已被“创作者中心”目录占用(`27-30` 为其子菜单);
为避免覆盖现有菜单树,采用下一个空闲 id `34`,仍挂 parent `15`,其余菜单字段按任务书执行。
- 已完成 A1/A2 主体代码:新增观看记录查询/返回 VO、admin-api 分页服务与控制器;
评论响应增加剧名/集号/集标题,只在后台 `adminPage` 当前页批量装配。
- 已补 `WatchHistoryAdminServiceTest` 与 `CommentServiceTest`,待执行定向 Maven 测试确认。
- 定向测试已通过:
```text
$ JAVA_HOME=<jdk21> mvn -pl fruitshorts-admin-api -am \
-Dtest=WatchHistoryAdminServiceTest,CommentServiceTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] fruitshorts-admin-api ... SUCCESS
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
解读:观看记录默认分页、模糊命中、完成条件、时间范围、空命中与 pageSize 收敛均通过;
评论后台页的剧名/集号/集标题批量装配通过。
- 已完成 A4/A5:新增 `sql/migrate-k91-watch-history-menu.sql`,同步 `sql/data-phase1.sql`
的权限 1105、菜单 34 与超管授权;更新 `docs/api-phase1.md` §2.3/§2.5。
- 迁移脚本只创建文件,未执行数据库变更。菜单 id 调整原因已写在迁移注释、计划和本节留痕中。
- 下一步执行 FruitServer 全模块 `mvn test` 门禁;通过后再做 ReelShortWeb。
- 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: 16.813 s
```
解读:本次运行包含全部 14 个 Reactor 模块,新增观看记录/评论分集测试与既有后端测试全过;
`mvn test` 不执行数据库迁移,K91 SQL 文件仍未落库。
- 已开始 B:Web `WatchHistoryItem`/`CommentItem` 扩展字段,`appUser.ts` 增加
`pageWatchHistoryApi`;新建 `WatchHistoryView.vue`;评论/举报状态默认改为全部,
评论表改为展示「短剧 / 分集」并通过 tooltip 保留原始 id。
- ReelShortWeb 门禁已通过:
```text
$ pnpm ts:check
> fruitshorts-admin@1.0.0 ts:check
> pnpm vue-tsc --noEmit --skipLibCheck
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
```
解读:新增观看记录页面与治理页改动通过类型检查和 dev 构建。构建保留既有
`element-plus` circular chunk 警告,不影响成功退出;未修改依赖或锁文件。
- 额外对 K91 修改的 Vue/TS 文件运行定向 ESLint,零错误:
```text
$ pnpm exec eslint src/api/admin/appUser.ts src/api/admin/types.ts \
src/views/governance/ContentGovernanceView.vue \
src/views/userManagement/WatchHistoryView.vue
exit 0
```
@@ -35,8 +35,8 @@ public class CommentAdminController {
private final CommentService commentService;
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */
@Operation(summary = "评论检索(默认按状态过滤,待审队列)")
/** 检索评论:status 为空时返回全部状态,传 pending 即审核队列。 */
@Operation(summary = "评论检索(status 为空返回全部状态)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('comment:list')")
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.WatchHistoryAdminService;
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台跨用户观看记录检索。 */
@Tag(name = "后台-观看记录")
@RestController
@RequestMapping("/admin/watch-history")
@RequiredArgsConstructor
public class WatchHistoryAdminController {
private final WatchHistoryAdminService watchHistoryAdminService;
@Operation(summary = "观看记录分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('watch-history:list')")
public Result<PageResult<AdminWatchHistoryVO>> page(WatchHistoryQueryReqVO req) {
return Result.ok(watchHistoryAdminService.page(req));
}
}
@@ -0,0 +1,198 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.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.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
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.content.enums.ContentStatus;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
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.List;
import java.util.Map;
/**
* 后台观看记录分页与跨域装配。
*
* <p>观看流水是播放域的原始事件,用户、短剧与分集资料分别位于用户和内容域。
* 该服务位于 admin-api 聚合层,直接注入只读 Mapper,避免反向扩展业务模块依赖。
*/
@Service
@RequiredArgsConstructor
public class WatchHistoryAdminService {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 100;
private static final long FINISHED_TOLERANCE_MS = 1000L;
private final WatchHistoryMapper watchHistoryMapper;
private final AppUserMapper appUserMapper;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
/** 按后台筛选条件分页查询观看记录。 */
public PageResult<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
int pageIndex = req.getPageIndex() == null || req.getPageIndex() < 1
? DEFAULT_PAGE_INDEX : req.getPageIndex();
int pageSize = req.getPageSize() == null || req.getPageSize() < 1
? DEFAULT_PAGE_SIZE : Math.min(req.getPageSize(), MAX_PAGE_SIZE);
List<Long> matchedUserIds = StringUtils.hasText(req.getKeyword())
? searchUserIds(req.getKeyword()) : null;
if (matchedUserIds != null && matchedUserIds.isEmpty()) {
return emptyPage();
}
List<Long> matchedDramaIds = StringUtils.hasText(req.getDramaTitle())
? searchDramaIds(req.getDramaTitle()) : null;
if (matchedDramaIds != null && matchedDramaIds.isEmpty()) {
return emptyPage();
}
IPage<WatchHistory> result = watchHistoryMapper.selectPage(new Page<>(pageIndex, pageSize),
buildQuery(req, matchedUserIds, matchedDramaIds));
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
}
private LambdaQueryWrapper<WatchHistory> buildQuery(WatchHistoryQueryReqVO req,
List<Long> matchedUserIds,
List<Long> matchedDramaIds) {
LambdaQueryWrapper<WatchHistory> wrapper = Wrappers.<WatchHistory>lambdaQuery()
.eq(req.getUserId() != null, WatchHistory::getUserId, req.getUserId())
.in(matchedUserIds != null, WatchHistory::getUserId, matchedUserIds)
.eq(req.getDramaId() != null, WatchHistory::getDramaId, req.getDramaId())
.in(matchedDramaIds != null, WatchHistory::getDramaId, matchedDramaIds)
.eq(req.getEpisodeId() != null, WatchHistory::getEpisodeId, req.getEpisodeId())
.eq(StringUtils.hasText(req.getSource()), WatchHistory::getSource, req.getSource())
.ge(req.getStartTime() != null, WatchHistory::getWatchedAt, req.getStartTime())
.le(req.getEndTime() != null, WatchHistory::getWatchedAt, req.getEndTime());
applyFinished(wrapper, req.getFinished());
return wrapper.orderByDesc(WatchHistory::getWatchedAt)
.orderByDesc(WatchHistory::getId);
}
private void applyFinished(LambdaQueryWrapper<WatchHistory> wrapper, Boolean finished) {
if (Boolean.TRUE.equals(finished)) {
wrapper.gt(WatchHistory::getDurationMs, 0L)
.apply("position_ms + {0} >= duration_ms", FINISHED_TOLERANCE_MS);
} else if (Boolean.FALSE.equals(finished)) {
wrapper.and(query -> query.eq(WatchHistory::getDurationMs, 0L)
.or()
.apply("position_ms + {0} < duration_ms", FINISHED_TOLERANCE_MS));
}
}
private List<Long> searchUserIds(String keyword) {
String value = keyword.trim();
return appUserMapper.selectList(Wrappers.<AppUser>lambdaQuery()
.select(AppUser::getId)
.and(query -> query.like(AppUser::getUid, value)
.or()
.like(AppUser::getNickname, value)))
.stream()
.map(AppUser::getId)
.toList();
}
private List<Long> searchDramaIds(String dramaTitle) {
String value = dramaTitle.trim();
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
.select(Drama::getId)
.like(Drama::getTitle, value))
.stream()
.map(Drama::getId)
.toList();
}
private List<AdminWatchHistoryVO> toVoList(List<WatchHistory> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, AppUser> users = loadUsers(rows);
Map<Long, Drama> dramas = loadDramas(rows);
Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
List<AdminWatchHistoryVO> list = new ArrayList<>(rows.size());
for (WatchHistory row : rows) {
list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()),
episodes.get(row.getEpisodeId())));
}
return list;
}
private Map<Long, AppUser> loadUsers(List<WatchHistory> rows) {
Map<Long, AppUser> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getUserId).distinct().toList();
for (AppUser user : appUserMapper.selectBatchIds(ids)) {
result.put(user.getId(), user);
}
return result;
}
private Map<Long, Drama> loadDramas(List<WatchHistory> rows) {
Map<Long, Drama> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getDramaId).distinct().toList();
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
result.put(drama.getId(), drama);
}
return result;
}
private Map<Long, DramaEpisode> loadEpisodes(List<WatchHistory> rows) {
Map<Long, DramaEpisode> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getEpisodeId).distinct().toList();
for (DramaEpisode episode : dramaEpisodeMapper.selectBatchIds(ids)) {
result.put(episode.getId(), episode);
}
return result;
}
private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama,
DramaEpisode episode) {
AdminWatchHistoryVO vo = new AdminWatchHistoryVO();
vo.setId(row.getId());
vo.setUserId(row.getUserId());
vo.setDramaId(row.getDramaId());
vo.setEpisodeId(row.getEpisodeId());
vo.setPositionMs(row.getPositionMs());
vo.setDurationMs(row.getDurationMs());
vo.setSource(row.getSource());
vo.setDeviceId(row.getDeviceId());
vo.setWatchedAt(row.getWatchedAt());
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration);
if (user != null) {
vo.setUid(user.getUid());
vo.setNickname(user.getNickname());
}
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus()));
} else {
vo.setDramaPublished(false);
}
if (episode != null) {
vo.setEpisodeNo(episode.getEpNo());
vo.setEpisodeTitle(episode.getTitle());
}
return vo;
}
private PageResult<AdminWatchHistoryVO> emptyPage() {
return new PageResult<>(0L, List.of());
}
}
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 后台观看记录行。
*
* <p>页面需要识别用户、短剧、分集与完播状态,因此由 admin-api 聚合流水与基础资料。
*/
@Data
public class AdminWatchHistoryVO {
private Long id;
private Long userId;
private String uid;
private String nickname;
private Long dramaId;
private String dramaTitle;
private Boolean dramaPublished;
private Long episodeId;
private Integer episodeNo;
private String episodeTitle;
private Long positionMs;
private Long durationMs;
private Boolean finished;
private String source;
private String deviceId;
private LocalDateTime watchedAt;
}
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/** 后台观看记录分页检索条件。 */
@Data
public class WatchHistoryQueryReqVO {
private Integer pageIndex = 1;
private Integer pageSize = 10;
/** uid / 昵称模糊检索 */
private String keyword;
private Long userId;
/** 短剧名称模糊检索 */
private String dramaTitle;
private Long dramaId;
private Long episodeId;
private String source;
private Boolean finished;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
}
@@ -0,0 +1,123 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.config.InteractionProperties;
import com.fruitshorts.interaction.dal.entity.ContentComment;
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.service.AuditRecorder;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.interaction.service.SensitiveWordService;
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 org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.verify;
/** 后台评论页批量补充短剧与分集展示字段。 */
class CommentServiceTest {
private ContentCommentMapper commentMapper;
private AppUserMapper appUserMapper;
private DramaService dramaService;
private EpisodeService episodeService;
private CommentService commentService;
@BeforeAll
static void initTableInfoCache() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
ContentComment.class);
}
@BeforeEach
void setUp() {
commentMapper = mock(ContentCommentMapper.class);
appUserMapper = mock(AppUserMapper.class);
dramaService = mock(DramaService.class);
episodeService = mock(EpisodeService.class);
commentService = new CommentService(
commentMapper,
mock(ContentLikeMapper.class),
mock(ContentAuditRecordMapper.class),
appUserMapper,
dramaService,
episodeService,
mock(SensitiveWordService.class),
mock(AuditRecorder.class),
mock(InteractionProperties.class));
}
@Test
void should_fill_drama_and_episode_metadata_when_admin_page_is_loaded() {
ContentComment withEpisode = comment(1L, 5L, 11L);
ContentComment legacy = comment(2L, 5L, 0L);
Page<ContentComment> page = new Page<>(1, 10);
page.setTotal(2L);
page.setRecords(List.of(withEpisode, legacy));
given(commentMapper.selectPage(any(), any())).willReturn(page);
AppUser user = new AppUser();
user.setId(7L);
user.setUid("uid-7");
user.setNickname("Viewer");
given(appUserMapper.selectBatchIds(any())).willReturn(List.of(user));
Drama drama = new Drama();
drama.setId(5L);
drama.setTitle("Signal");
given(dramaService.listByIds(any())).willReturn(List.of(drama));
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setEpNo(3);
episode.setTitle("The Call");
given(episodeService.listByIds(any())).willReturn(List.of(episode));
PageResult<CommentRespVO> result = commentService.adminPage(new CommentQueryReqVO());
assertThat(result.getList()).hasSize(2);
assertThat(result.getList().get(0).getDramaTitle()).isEqualTo("Signal");
assertThat(result.getList().get(0).getEpisodeNo()).isEqualTo(3);
assertThat(result.getList().get(0).getEpisodeTitle()).isEqualTo("The Call");
assertThat(result.getList().get(1).getDramaTitle()).isEqualTo("Signal");
assertThat(result.getList().get(1).getEpisodeNo()).isNull();
verify(dramaService).listByIds(List.of(5L));
verify(episodeService).listByIds(List.of(11L));
}
private ContentComment comment(Long id, Long dramaId, Long episodeId) {
ContentComment comment = new ContentComment();
comment.setId(id);
comment.setDramaId(dramaId);
comment.setEpisodeId(episodeId);
comment.setParentId(0L);
comment.setRootId(0L);
comment.setUserId(7L);
comment.setContent("comment");
comment.setStatus("published");
comment.setAuditReason("");
comment.setLikeCount(0);
comment.setReplyCount(0);
comment.setReportCount(0);
return comment;
}
}
@@ -0,0 +1,260 @@
// 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.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
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.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
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.verify;
/** 后台观看记录分页条件与批量装配。 */
class WatchHistoryAdminServiceTest {
private WatchHistoryMapper watchHistoryMapper;
private AppUserMapper appUserMapper;
private DramaMapper dramaMapper;
private DramaEpisodeMapper dramaEpisodeMapper;
private WatchHistoryAdminService service;
@BeforeAll
static void initTableInfoCache() {
MybatisConfiguration configuration = new MybatisConfiguration();
for (Class<?> type : List.of(AppUser.class, Drama.class, DramaEpisode.class,
WatchHistory.class)) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), type);
}
}
@BeforeEach
void setUp() {
watchHistoryMapper = mock(WatchHistoryMapper.class);
appUserMapper = mock(AppUserMapper.class);
dramaMapper = mock(DramaMapper.class);
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
service = new WatchHistoryAdminService(watchHistoryMapper, appUserMapper, dramaMapper,
dramaEpisodeMapper);
}
@Test
void should_use_default_page_and_newest_first_when_no_filters_are_given() {
WatchHistory row = history(100L, 7L, 5L, 11L);
stubPage(row);
stubBatchData();
PageResult<AdminWatchHistoryVO> result = service.page(new WatchHistoryQueryReqVO());
ArgumentCaptor<Page<WatchHistory>> pageCaptor = pageCaptor();
ArgumentCaptor<LambdaQueryWrapper<WatchHistory>> queryCaptor = queryCaptor();
verify(watchHistoryMapper).selectPage(pageCaptor.capture(), queryCaptor.capture());
assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(1);
assertThat(pageCaptor.getValue().getSize()).isEqualTo(10);
assertThat(queryCaptor.getValue().getSqlSegment())
.contains("watched_at DESC")
.contains("id DESC");
assertThat(result.getTotal()).isEqualTo(1L);
assertThat(result.getList().get(0).getUid()).isEqualTo("user-7");
assertThat(result.getList().get(0).getDramaTitle()).isEqualTo("Signal");
assertThat(result.getList().get(0).getEpisodeNo()).isEqualTo(1);
}
@Test
void should_filter_by_matched_user_ids_when_keyword_is_present() {
WatchHistory row = history(100L, 7L, 5L, 11L);
given(appUserMapper.selectList(any())).willReturn(List.of(user(7L, "Alice")));
stubPage(row);
stubBatchData();
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setKeyword("Ali");
service.page(req);
LambdaQueryWrapper<WatchHistory> query = capturedQuery();
assertThat(query.getSqlSegment()).contains("user_id IN");
assertThat(query.getParamNameValuePairs().containsValue(7L)).isTrue();
}
@Test
void should_filter_by_matched_drama_ids_when_title_is_present() {
WatchHistory row = history(100L, 7L, 5L, 11L);
given(dramaMapper.selectList(any())).willReturn(List.of(drama(5L, "Signal")));
stubPage(row);
stubBatchData();
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setDramaTitle("Sig");
service.page(req);
LambdaQueryWrapper<WatchHistory> query = capturedQuery();
assertThat(query.getSqlSegment()).contains("drama_id IN");
assertThat(query.getParamNameValuePairs().containsValue(5L)).isTrue();
}
@Test
void should_return_empty_page_without_querying_history_when_keyword_has_no_user() {
given(appUserMapper.selectList(any())).willReturn(List.of());
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setKeyword("missing");
PageResult<AdminWatchHistoryVO> result = service.page(req);
assertThat(result.getTotal()).isZero();
assertThat(result.getList()).isEmpty();
verify(watchHistoryMapper, never()).selectPage(any(), any());
}
@Test
void should_apply_true_finished_condition_in_sql() {
WatchHistory row = history(100L, 7L, 5L, 11L);
row.setPositionMs(59000L);
row.setDurationMs(60000L);
stubPage(row);
stubBatchData();
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setFinished(true);
PageResult<AdminWatchHistoryVO> result = service.page(req);
LambdaQueryWrapper<WatchHistory> query = capturedQuery();
assertThat(query.getSqlSegment())
.contains("duration_ms >")
.contains("position_ms +");
assertThat(query.getParamNameValuePairs().containsValue(1000L)).isTrue();
assertThat(result.getList().get(0).getFinished()).isTrue();
}
@Test
void should_apply_false_finished_condition_in_sql() {
WatchHistory row = history(100L, 7L, 5L, 11L);
row.setPositionMs(1000L);
row.setDurationMs(60000L);
stubPage(row);
stubBatchData();
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setFinished(false);
PageResult<AdminWatchHistoryVO> result = service.page(req);
LambdaQueryWrapper<WatchHistory> query = capturedQuery();
assertThat(query.getSqlSegment())
.contains("duration_ms =")
.contains("OR")
.contains("position_ms +");
assertThat(result.getList().get(0).getFinished()).isFalse();
}
@Test
void should_apply_closed_time_range_and_clamp_page_size() {
WatchHistory row = history(100L, 7L, 5L, 11L);
stubPage(row);
stubBatchData();
LocalDateTime start = LocalDateTime.of(2026, 10, 1, 0, 0);
LocalDateTime end = LocalDateTime.of(2026, 10, 21, 23, 59, 59);
WatchHistoryQueryReqVO req = new WatchHistoryQueryReqVO();
req.setPageSize(500);
req.setStartTime(start);
req.setEndTime(end);
service.page(req);
ArgumentCaptor<Page<WatchHistory>> pageCaptor = pageCaptor();
ArgumentCaptor<LambdaQueryWrapper<WatchHistory>> queryCaptor = queryCaptor();
verify(watchHistoryMapper).selectPage(pageCaptor.capture(), queryCaptor.capture());
assertThat(pageCaptor.getValue().getSize()).isEqualTo(100);
assertThat(queryCaptor.getValue().getSqlSegment())
.contains("watched_at >=")
.contains("watched_at <=");
}
private void stubPage(WatchHistory row) {
Page<WatchHistory> page = new Page<>(1, 10);
page.setTotal(1L);
page.setRecords(List.of(row));
given(watchHistoryMapper.selectPage(any(Page.class), any())).willReturn(page);
}
private void stubBatchData() {
given(appUserMapper.selectBatchIds(any())).willReturn(List.of(user(7L, "Alice")));
given(dramaMapper.selectBatchIds(any())).willReturn(List.of(drama(5L, "Signal")));
given(dramaEpisodeMapper.selectBatchIds(any())).willReturn(List.of(episode(11L)));
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<Page<WatchHistory>> pageCaptor() {
return ArgumentCaptor.forClass(Page.class);
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<LambdaQueryWrapper<WatchHistory>> queryCaptor() {
return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
}
private LambdaQueryWrapper<WatchHistory> capturedQuery() {
ArgumentCaptor<LambdaQueryWrapper<WatchHistory>> captor = queryCaptor();
verify(watchHistoryMapper).selectPage(any(), captor.capture());
return captor.getValue();
}
private WatchHistory history(Long id, Long userId, Long dramaId, Long episodeId) {
WatchHistory row = new WatchHistory();
row.setId(id);
row.setUserId(userId);
row.setDramaId(dramaId);
row.setEpisodeId(episodeId);
row.setPositionMs(1000L);
row.setDurationMs(60000L);
row.setSource("HOME");
row.setDeviceId("device-1");
row.setWatchedAt(LocalDateTime.of(2026, 10, 21, 12, 0));
return row;
}
private AppUser user(Long id, String nickname) {
AppUser user = new AppUser();
user.setId(id);
user.setUid("user-" + id);
user.setNickname(nickname);
return user;
}
private Drama drama(Long id, String title) {
Drama drama = new Drama();
drama.setId(id);
drama.setTitle(title);
drama.setStatus("published");
return drama;
}
private DramaEpisode episode(Long id) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setEpNo(1);
episode.setTitle("Episode 1");
return episode;
}
}
@@ -11,6 +11,8 @@ 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.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.config.InteractionProperties;
@@ -275,7 +277,10 @@ public class CommentService {
.like(StringUtils.hasText(req.getKeyword()), ContentComment::getContent,
req.getKeyword())
.orderByDesc(ContentComment::getId));
return new PageResult<>(result.getTotal(), toRespList(result.getRecords(), null));
List<ContentComment> rows = result.getRecords();
List<CommentRespVO> list = toRespList(rows, null);
fillAdminContentInfo(rows, list);
return new PageResult<>(result.getTotal(), list);
}
@Transactional(rollbackFor = Exception.class)
@@ -391,6 +396,39 @@ public class CommentService {
return list;
}
private void fillAdminContentInfo(List<ContentComment> rows, List<CommentRespVO> list) {
if (rows.isEmpty()) {
return;
}
Map<Long, Drama> dramas = new HashMap<>();
List<Long> dramaIds = rows.stream().map(ContentComment::getDramaId).distinct().toList();
for (Drama drama : dramaService.listByIds(dramaIds)) {
dramas.put(drama.getId(), drama);
}
Map<Long, DramaEpisode> episodes = new HashMap<>();
List<Long> episodeIds = rows.stream()
.map(ContentComment::getEpisodeId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
if (!episodeIds.isEmpty()) {
for (DramaEpisode episode : episodeService.listByIds(episodeIds)) {
episodes.put(episode.getId(), episode);
}
}
for (CommentRespVO vo : list) {
Drama drama = dramas.get(vo.getDramaId());
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
}
DramaEpisode episode = episodes.get(vo.getEpisodeId());
if (episode != null) {
vo.setEpisodeNo(episode.getEpNo());
vo.setEpisodeTitle(episode.getTitle());
}
}
}
private CommentRespVO toResp(ContentComment comment, AppUser author, boolean liked, Long meUserId) {
CommentRespVO vo = new CommentRespVO();
vo.setId(comment.getId());
@@ -12,7 +12,12 @@ public class CommentRespVO {
private Long id;
private Long dramaId;
/** 后台评论页展示用,C 端列表保持为空 */
private String dramaTitle;
private Long episodeId;
/** 后台评论页展示用,C 端列表保持为空 */
private Integer episodeNo;
private String episodeTitle;
private Long parentId;
private Long rootId;
private Long userId;
+7 -3
View File
@@ -39,7 +39,8 @@ ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
(1103, 'download:list', '查看下载授权', '用户管理', 'BUTTON', 103),
(1104, 'download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104)
(1104, 'download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104),
(1105, 'watch-history:list','查看观看记录', '用户管理', 'BUTTON', 105)
ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
@@ -80,6 +81,7 @@ 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',
'watch-history:list',
'comment:list', 'comment:audit', 'report:list', 'report:handle',
'sensitive-word:list', 'sensitive-word:update',
'order:list', 'order:update', 'entitlement:list', 'entitlement:update',
@@ -130,7 +132,9 @@ WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_1200');
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(15, 0, '用户管理', '/user', 'UserCenter', '#', '/user/app-users', 'material-symbols:group-rounded', 'app-user:list', 'CATALOG', 1, 0, 4),
(16, 15, 'App 用户', 'app-users', 'app-users', 'views/userManagement/AppUsersView', '', '', 'app-user:list', 'MENU', 0, 0, 1)
(16, 15, 'App 用户', 'app-users', 'app-users', 'views/userManagement/AppUsersView', '', '', 'app-user:list', 'MENU', 0, 0, 1),
(34, 15, '观看记录', 'watch-history', 'watch-history',
'views/userManagement/WatchHistoryView', '', '', 'watch-history:list', 'MENU', 0, 0, 2)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title,
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component,
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
@@ -141,7 +145,7 @@ SELECT r.id * 1000000 + m.id, r.id, m.id
FROM sys_role r
CROSS JOIN sys_menu m
WHERE r.code = 'super_admin'
AND m.id IN (15, 16)
AND m.id IN (15, 16, 34)
ON CONFLICT DO NOTHING;
-- ---------- 菜单:内容治理 / 订单与权益 / 数据看板 ----------
+34
View File
@@ -0,0 +1,34 @@
-- Author: fruit.com.cn
-- K91 后台观看记录独立菜单与权限点(幂等,仅提交文件,由发布流程执行)
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
(1105, 'watch-history:list', '查看观看记录', '用户管理', 'BUTTON', 105)
ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id * 1000000 + p.id, r.id, p.id
FROM sys_role r
CROSS JOIN sys_permission p
WHERE r.code = 'super_admin' AND p.code = 'watch-history:list'
ON CONFLICT DO NOTHING;
-- 任务书建议使用菜单 id 26,但现有“创作者中心”已使用 26,27-30 为其子菜单。
-- 为避免覆盖已有菜单树,这里使用下一个空闲 id 34。
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(34, 15, '观看记录', 'watch-history', 'watch-history',
'views/userManagement/WatchHistoryView', '', '',
'watch-history:list', 'MENU', 0, 0, 2)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title,
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name,
component = EXCLUDED.component, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon,
permission_code = EXCLUDED.permission_code, type = EXCLUDED.type,
always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id * 1000000 + m.id, r.id, m.id
FROM sys_role r
CROSS JOIN sys_menu m
WHERE r.code = 'super_admin' AND m.id = 34
ON CONFLICT DO NOTHING;