merge: 合并 K91 观看记录与评论分集信息
This commit is contained in:
commit
fe18a2c9ea
14 files changed
+953
-7
No files matched your search
+5
-1
@@ -590,6 +590,7 @@ GET /app/notices?pageIndex=1&pageSize=20
|
|||||||
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
|
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
|
||||||
| GET | `/admin/app-users/{id}/ban-records` | `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/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 |
|
| 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` | 审核留证:机审与人审的完整链路 |
|
| GET | `/admin/comments/{id}/audit-records` | `comment:list` | 审核留证:机审与人审的完整链路 |
|
||||||
| PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) |
|
| PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) |
|
||||||
| DELETE | `/admin/comments/{id}` | `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` | 敏感词检索 |
|
| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 |
|
||||||
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
|
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
|
||||||
|
|
||||||
|
后台评论行除 `dramaId/episodeId` 外,还返回 `dramaTitle/episodeNo/episodeTitle`;
|
||||||
|
剧或集已删除时对应展示字段留空。后台评论与举报的 `status` 空值均表示全部状态。
|
||||||
|
|
||||||
### 2.6 订单与权益(已实现)
|
### 2.6 订单与权益(已实现)
|
||||||
|
|
||||||
| 方法 | 路径 | 权限点 | 说明 |
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
|
|||||||
@@ -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`,所有文件逐项暂存。
|
||||||
@@ -23782,3 +23782,72 @@ $ flutter test --no-pub
|
|||||||
```
|
```
|
||||||
|
|
||||||
说明:本任务仅修改 ReelShort 客户端,未改 FruitServer 功能代码、specs 仓库或后台接口;未安装真机,等待用户验收。
|
说明:本任务仅修改 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
|
||||||
|
```
|
||||||
+2
-2
@@ -35,8 +35,8 @@ public class CommentAdminController {
|
|||||||
|
|
||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
|
|
||||||
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */
|
/** 检索评论:status 为空时返回全部状态,传 pending 即审核队列。 */
|
||||||
@Operation(summary = "评论检索(默认按状态过滤,待审队列)")
|
@Operation(summary = "评论检索(status 为空返回全部状态)")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('comment:list')")
|
@PreAuthorize("@ss.hasPermission('comment:list')")
|
||||||
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
|
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
|
||||||
|
|||||||
+33
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
@@ -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;
|
||||||
|
}
|
||||||
+31
@@ -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;
|
||||||
|
}
|
||||||
+123
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+260
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-1
@@ -11,6 +11,8 @@ import com.fruitshorts.common.exception.ErrorCode;
|
|||||||
import com.fruitshorts.common.exception.ServiceException;
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
import com.fruitshorts.common.pojo.CursorResult;
|
import com.fruitshorts.common.pojo.CursorResult;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
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.DramaService;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.interaction.config.InteractionProperties;
|
import com.fruitshorts.interaction.config.InteractionProperties;
|
||||||
@@ -275,7 +277,10 @@ public class CommentService {
|
|||||||
.like(StringUtils.hasText(req.getKeyword()), ContentComment::getContent,
|
.like(StringUtils.hasText(req.getKeyword()), ContentComment::getContent,
|
||||||
req.getKeyword())
|
req.getKeyword())
|
||||||
.orderByDesc(ContentComment::getId));
|
.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)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -391,6 +396,39 @@ public class CommentService {
|
|||||||
return list;
|
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) {
|
private CommentRespVO toResp(ContentComment comment, AppUser author, boolean liked, Long meUserId) {
|
||||||
CommentRespVO vo = new CommentRespVO();
|
CommentRespVO vo = new CommentRespVO();
|
||||||
vo.setId(comment.getId());
|
vo.setId(comment.getId());
|
||||||
|
|||||||
+5
@@ -12,7 +12,12 @@ public class CommentRespVO {
|
|||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private Long dramaId;
|
private Long dramaId;
|
||||||
|
/** 后台评论页展示用,C 端列表保持为空 */
|
||||||
|
private String dramaTitle;
|
||||||
private Long episodeId;
|
private Long episodeId;
|
||||||
|
/** 后台评论页展示用,C 端列表保持为空 */
|
||||||
|
private Integer episodeNo;
|
||||||
|
private String episodeTitle;
|
||||||
private Long parentId;
|
private Long parentId;
|
||||||
private Long rootId;
|
private Long rootId;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
|||||||
+7
-3
@@ -39,7 +39,8 @@ ON CONFLICT (code) DO NOTHING;
|
|||||||
|
|
||||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||||
(1103, 'download:list', '查看下载授权', '用户管理', 'BUTTON', 103),
|
(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;
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
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
|
CROSS JOIN sys_permission p
|
||||||
WHERE r.code = 'super_admin'
|
WHERE r.code = 'super_admin'
|
||||||
AND p.code IN ('app-user:list', 'app-user:update', 'download:list', 'download:revoke',
|
AND p.code IN ('app-user:list', 'app-user:update', 'download:list', 'download:revoke',
|
||||||
|
'watch-history:list',
|
||||||
'comment:list', 'comment:audit', 'report:list', 'report:handle',
|
'comment:list', 'comment:audit', 'report:list', 'report:handle',
|
||||||
'sensitive-word:list', 'sensitive-word:update',
|
'sensitive-word:list', 'sensitive-word:update',
|
||||||
'order:list', 'order:update', 'entitlement:list', 'entitlement: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,
|
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
|
||||||
permission_code, type, always_show, affix, sort) VALUES
|
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),
|
(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,
|
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,
|
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component,
|
||||||
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
|
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
|
FROM sys_role r
|
||||||
CROSS JOIN sys_menu m
|
CROSS JOIN sys_menu m
|
||||||
WHERE r.code = 'super_admin'
|
WHERE r.code = 'super_admin'
|
||||||
AND m.id IN (15, 16)
|
AND m.id IN (15, 16, 34)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ---------- 菜单:内容治理 / 订单与权益 / 数据看板 ----------
|
-- ---------- 菜单:内容治理 / 订单与权益 / 数据看板 ----------
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in new issue
Block a user