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

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

No files matched your search

@@ -35,8 +35,8 @@ public class CommentAdminController {
private final CommentService commentService;
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */
@Operation(summary = "评论检索(默认按状态过滤,待审队列)")
/** 检索评论:status 为空时返回全部状态,传 pending 即审核队列。 */
@Operation(summary = "评论检索(status 为空返回全部状态)")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('comment:list')")
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.WatchHistoryAdminService;
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台跨用户观看记录检索。 */
@Tag(name = "后台-观看记录")
@RestController
@RequestMapping("/admin/watch-history")
@RequiredArgsConstructor
public class WatchHistoryAdminController {
private final WatchHistoryAdminService watchHistoryAdminService;
@Operation(summary = "观看记录分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('watch-history:list')")
public Result<PageResult<AdminWatchHistoryVO>> page(WatchHistoryQueryReqVO req) {
return Result.ok(watchHistoryAdminService.page(req));
}
}
@@ -0,0 +1,198 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 后台观看记录分页与跨域装配。
*
* <p>观看流水是播放域的原始事件,用户、短剧与分集资料分别位于用户和内容域。
* 该服务位于 admin-api 聚合层,直接注入只读 Mapper,避免反向扩展业务模块依赖。
*/
@Service
@RequiredArgsConstructor
public class WatchHistoryAdminService {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 100;
private static final long FINISHED_TOLERANCE_MS = 1000L;
private final WatchHistoryMapper watchHistoryMapper;
private final AppUserMapper appUserMapper;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
/** 按后台筛选条件分页查询观看记录。 */
public PageResult<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
int pageIndex = req.getPageIndex() == null || req.getPageIndex() < 1
? DEFAULT_PAGE_INDEX : req.getPageIndex();
int pageSize = req.getPageSize() == null || req.getPageSize() < 1
? DEFAULT_PAGE_SIZE : Math.min(req.getPageSize(), MAX_PAGE_SIZE);
List<Long> matchedUserIds = StringUtils.hasText(req.getKeyword())
? searchUserIds(req.getKeyword()) : null;
if (matchedUserIds != null && matchedUserIds.isEmpty()) {
return emptyPage();
}
List<Long> matchedDramaIds = StringUtils.hasText(req.getDramaTitle())
? searchDramaIds(req.getDramaTitle()) : null;
if (matchedDramaIds != null && matchedDramaIds.isEmpty()) {
return emptyPage();
}
IPage<WatchHistory> result = watchHistoryMapper.selectPage(new Page<>(pageIndex, pageSize),
buildQuery(req, matchedUserIds, matchedDramaIds));
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
}
private LambdaQueryWrapper<WatchHistory> buildQuery(WatchHistoryQueryReqVO req,
List<Long> matchedUserIds,
List<Long> matchedDramaIds) {
LambdaQueryWrapper<WatchHistory> wrapper = Wrappers.<WatchHistory>lambdaQuery()
.eq(req.getUserId() != null, WatchHistory::getUserId, req.getUserId())
.in(matchedUserIds != null, WatchHistory::getUserId, matchedUserIds)
.eq(req.getDramaId() != null, WatchHistory::getDramaId, req.getDramaId())
.in(matchedDramaIds != null, WatchHistory::getDramaId, matchedDramaIds)
.eq(req.getEpisodeId() != null, WatchHistory::getEpisodeId, req.getEpisodeId())
.eq(StringUtils.hasText(req.getSource()), WatchHistory::getSource, req.getSource())
.ge(req.getStartTime() != null, WatchHistory::getWatchedAt, req.getStartTime())
.le(req.getEndTime() != null, WatchHistory::getWatchedAt, req.getEndTime());
applyFinished(wrapper, req.getFinished());
return wrapper.orderByDesc(WatchHistory::getWatchedAt)
.orderByDesc(WatchHistory::getId);
}
private void applyFinished(LambdaQueryWrapper<WatchHistory> wrapper, Boolean finished) {
if (Boolean.TRUE.equals(finished)) {
wrapper.gt(WatchHistory::getDurationMs, 0L)
.apply("position_ms + {0} >= duration_ms", FINISHED_TOLERANCE_MS);
} else if (Boolean.FALSE.equals(finished)) {
wrapper.and(query -> query.eq(WatchHistory::getDurationMs, 0L)
.or()
.apply("position_ms + {0} < duration_ms", FINISHED_TOLERANCE_MS));
}
}
private List<Long> searchUserIds(String keyword) {
String value = keyword.trim();
return appUserMapper.selectList(Wrappers.<AppUser>lambdaQuery()
.select(AppUser::getId)
.and(query -> query.like(AppUser::getUid, value)
.or()
.like(AppUser::getNickname, value)))
.stream()
.map(AppUser::getId)
.toList();
}
private List<Long> searchDramaIds(String dramaTitle) {
String value = dramaTitle.trim();
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
.select(Drama::getId)
.like(Drama::getTitle, value))
.stream()
.map(Drama::getId)
.toList();
}
private List<AdminWatchHistoryVO> toVoList(List<WatchHistory> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, AppUser> users = loadUsers(rows);
Map<Long, Drama> dramas = loadDramas(rows);
Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
List<AdminWatchHistoryVO> list = new ArrayList<>(rows.size());
for (WatchHistory row : rows) {
list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()),
episodes.get(row.getEpisodeId())));
}
return list;
}
private Map<Long, AppUser> loadUsers(List<WatchHistory> rows) {
Map<Long, AppUser> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getUserId).distinct().toList();
for (AppUser user : appUserMapper.selectBatchIds(ids)) {
result.put(user.getId(), user);
}
return result;
}
private Map<Long, Drama> loadDramas(List<WatchHistory> rows) {
Map<Long, Drama> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getDramaId).distinct().toList();
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
result.put(drama.getId(), drama);
}
return result;
}
private Map<Long, DramaEpisode> loadEpisodes(List<WatchHistory> rows) {
Map<Long, DramaEpisode> result = new HashMap<>();
List<Long> ids = rows.stream().map(WatchHistory::getEpisodeId).distinct().toList();
for (DramaEpisode episode : dramaEpisodeMapper.selectBatchIds(ids)) {
result.put(episode.getId(), episode);
}
return result;
}
private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama,
DramaEpisode episode) {
AdminWatchHistoryVO vo = new AdminWatchHistoryVO();
vo.setId(row.getId());
vo.setUserId(row.getUserId());
vo.setDramaId(row.getDramaId());
vo.setEpisodeId(row.getEpisodeId());
vo.setPositionMs(row.getPositionMs());
vo.setDurationMs(row.getDurationMs());
vo.setSource(row.getSource());
vo.setDeviceId(row.getDeviceId());
vo.setWatchedAt(row.getWatchedAt());
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration);
if (user != null) {
vo.setUid(user.getUid());
vo.setNickname(user.getNickname());
}
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus()));
} else {
vo.setDramaPublished(false);
}
if (episode != null) {
vo.setEpisodeNo(episode.getEpNo());
vo.setEpisodeTitle(episode.getTitle());
}
return vo;
}
private PageResult<AdminWatchHistoryVO> emptyPage() {
return new PageResult<>(0L, List.of());
}
}
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 后台观看记录行。
*
* <p>页面需要识别用户、短剧、分集与完播状态,因此由 admin-api 聚合流水与基础资料。
*/
@Data
public class AdminWatchHistoryVO {
private Long id;
private Long userId;
private String uid;
private String nickname;
private Long dramaId;
private String dramaTitle;
private Boolean dramaPublished;
private Long episodeId;
private Integer episodeNo;
private String episodeTitle;
private Long positionMs;
private Long durationMs;
private Boolean finished;
private String source;
private String deviceId;
private LocalDateTime watchedAt;
}
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/** 后台观看记录分页检索条件。 */
@Data
public class WatchHistoryQueryReqVO {
private Integer pageIndex = 1;
private Integer pageSize = 10;
/** uid / 昵称模糊检索 */
private String keyword;
private Long userId;
/** 短剧名称模糊检索 */
private String dramaTitle;
private Long dramaId;
private Long episodeId;
private String source;
private Boolean finished;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
}