From 1d316791b1c53ef94fa623a33bb891910b89c815 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 16:35:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E8=A7=82?= =?UTF-8?q?=E7=9C=8B=E8=AE=B0=E5=BD=95=E5=88=86=E9=A1=B5=E4=B8=8E=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E5=88=86=E9=9B=86=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-phase1.md | 6 +- docs/plans/2026-10-21-K91-watch-history.md | 114 ++++++++ docs/plans/PROGRESS.md | 69 +++++ .../controller/CommentAdminController.java | 4 +- .../WatchHistoryAdminController.java | 33 +++ .../service/WatchHistoryAdminService.java | 198 +++++++++++++ .../admin/vo/AdminWatchHistoryVO.java | 33 +++ .../admin/vo/WatchHistoryQueryReqVO.java | 31 +++ .../admin/service/CommentServiceTest.java | 123 +++++++++ .../service/WatchHistoryAdminServiceTest.java | 260 ++++++++++++++++++ .../interaction/service/CommentService.java | 40 ++- .../interaction/vo/CommentRespVO.java | 5 + sql/data-phase1.sql | 10 +- sql/migrate-k91-watch-history-menu.sql | 34 +++ 14 files changed, 953 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-10-21-K91-watch-history.md create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/WatchHistoryAdminController.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/WatchHistoryAdminService.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/WatchHistoryQueryReqVO.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/CommentServiceTest.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/WatchHistoryAdminServiceTest.java create mode 100644 sql/migrate-k91-watch-history-menu.sql diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 031a19d..a948b10 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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 订单与权益(已实现) | 方法 | 路径 | 权限点 | 说明 | diff --git a/docs/plans/2026-10-21-K91-watch-history.md b/docs/plans/2026-10-21-K91-watch-history.md new file mode 100644 index 0000000..a2498a2 --- /dev/null +++ b/docs/plans/2026-10-21-K91-watch-history.md @@ -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`,所有文件逐项暂存。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 91a6392..4e13747 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -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= 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= 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 +``` diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CommentAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CommentAdminController.java index f4665a5..35fe8eb 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CommentAdminController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CommentAdminController.java @@ -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> page(CommentQueryReqVO req) { diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/WatchHistoryAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/WatchHistoryAdminController.java new file mode 100644 index 0000000..4924593 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/WatchHistoryAdminController.java @@ -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> page(WatchHistoryQueryReqVO req) { + return Result.ok(watchHistoryAdminService.page(req)); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/WatchHistoryAdminService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/WatchHistoryAdminService.java new file mode 100644 index 0000000..15b2baa --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/WatchHistoryAdminService.java @@ -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; + +/** + * 后台观看记录分页与跨域装配。 + * + *

观看流水是播放域的原始事件,用户、短剧与分集资料分别位于用户和内容域。 + * 该服务位于 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 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 matchedUserIds = StringUtils.hasText(req.getKeyword()) + ? searchUserIds(req.getKeyword()) : null; + if (matchedUserIds != null && matchedUserIds.isEmpty()) { + return emptyPage(); + } + List matchedDramaIds = StringUtils.hasText(req.getDramaTitle()) + ? searchDramaIds(req.getDramaTitle()) : null; + if (matchedDramaIds != null && matchedDramaIds.isEmpty()) { + return emptyPage(); + } + IPage result = watchHistoryMapper.selectPage(new Page<>(pageIndex, pageSize), + buildQuery(req, matchedUserIds, matchedDramaIds)); + return new PageResult<>(result.getTotal(), toVoList(result.getRecords())); + } + + private LambdaQueryWrapper buildQuery(WatchHistoryQueryReqVO req, + List matchedUserIds, + List matchedDramaIds) { + LambdaQueryWrapper wrapper = Wrappers.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 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 searchUserIds(String keyword) { + String value = keyword.trim(); + return appUserMapper.selectList(Wrappers.lambdaQuery() + .select(AppUser::getId) + .and(query -> query.like(AppUser::getUid, value) + .or() + .like(AppUser::getNickname, value))) + .stream() + .map(AppUser::getId) + .toList(); + } + + private List searchDramaIds(String dramaTitle) { + String value = dramaTitle.trim(); + return dramaMapper.selectList(Wrappers.lambdaQuery() + .select(Drama::getId) + .like(Drama::getTitle, value)) + .stream() + .map(Drama::getId) + .toList(); + } + + private List toVoList(List rows) { + if (rows.isEmpty()) { + return List.of(); + } + Map users = loadUsers(rows); + Map dramas = loadDramas(rows); + Map episodes = loadEpisodes(rows); + List 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 loadUsers(List rows) { + Map result = new HashMap<>(); + List ids = rows.stream().map(WatchHistory::getUserId).distinct().toList(); + for (AppUser user : appUserMapper.selectBatchIds(ids)) { + result.put(user.getId(), user); + } + return result; + } + + private Map loadDramas(List rows) { + Map result = new HashMap<>(); + List ids = rows.stream().map(WatchHistory::getDramaId).distinct().toList(); + for (Drama drama : dramaMapper.selectBatchIds(ids)) { + result.put(drama.getId(), drama); + } + return result; + } + + private Map loadEpisodes(List rows) { + Map result = new HashMap<>(); + List 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 emptyPage() { + return new PageResult<>(0L, List.of()); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java new file mode 100644 index 0000000..e6140d2 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java @@ -0,0 +1,33 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 后台观看记录行。 + * + *

页面需要识别用户、短剧、分集与完播状态,因此由 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; +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/WatchHistoryQueryReqVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/WatchHistoryQueryReqVO.java new file mode 100644 index 0000000..ef26d7b --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/WatchHistoryQueryReqVO.java @@ -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; +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/CommentServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/CommentServiceTest.java new file mode 100644 index 0000000..1bbffff --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/CommentServiceTest.java @@ -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 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 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; + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/WatchHistoryAdminServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/WatchHistoryAdminServiceTest.java new file mode 100644 index 0000000..e5a2c5f --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/WatchHistoryAdminServiceTest.java @@ -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 result = service.page(new WatchHistoryQueryReqVO()); + + ArgumentCaptor> pageCaptor = pageCaptor(); + ArgumentCaptor> 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 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 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 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 result = service.page(req); + + LambdaQueryWrapper 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 result = service.page(req); + + LambdaQueryWrapper 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> pageCaptor = pageCaptor(); + ArgumentCaptor> 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 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> pageCaptor() { + return ArgumentCaptor.forClass(Page.class); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> queryCaptor() { + return ArgumentCaptor.forClass(LambdaQueryWrapper.class); + } + + private LambdaQueryWrapper capturedQuery() { + ArgumentCaptor> 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; + } +} diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java index 47c2afd..7a247d0 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java @@ -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 rows = result.getRecords(); + List 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 rows, List list) { + if (rows.isEmpty()) { + return; + } + Map dramas = new HashMap<>(); + List dramaIds = rows.stream().map(ContentComment::getDramaId).distinct().toList(); + for (Drama drama : dramaService.listByIds(dramaIds)) { + dramas.put(drama.getId(), drama); + } + Map episodes = new HashMap<>(); + List 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()); diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentRespVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentRespVO.java index 86d7eab..bc45e0c 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentRespVO.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentRespVO.java @@ -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; diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index f1fc430..3b0097e 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -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; -- ---------- 菜单:内容治理 / 订单与权益 / 数据看板 ---------- diff --git a/sql/migrate-k91-watch-history-menu.sql b/sql/migrate-k91-watch-history-menu.sql new file mode 100644 index 0000000..85b3dab --- /dev/null +++ b/sql/migrate-k91-watch-history-menu.sql @@ -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;