diff --git a/docs/api-phase1.md b/docs/api-phase1.md index bbcc9cd..1343391 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -868,10 +868,25 @@ GET /app/notices?pageIndex=1&pageSize=20 | 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 | +| GET | `/admin/favorites/page` | `favorite:list` | 收藏只读分页,含游客 | +| GET | `/admin/likes/page` | `like:list` | 点赞只读分页,含游客及多态目标 | | PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned | 状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。 +收藏和点赞分页参数:`pageIndex` 默认 1,`pageSize` 默认 10、最大 100; +`keyword` 模糊匹配 uid/昵称,`userId`、`deviceId`、`dramaId` 精确匹配, +`dramaTitle` 模糊匹配;`startTime/endTime` 按创建时间闭区间过滤, +格式 `yyyy-MM-dd HH:mm:ss`。点赞另支持 `targetType`(DRAMA/EPISODE/COMMENT)、 +`targetId` 和 `episodeId` 精确过滤,短剧和剧集过滤会解析评论及剧集所属关系。 +按 `createdAt DESC, id DESC` 排序,用户/短剧预检索无结果返回 total=0、list=[]。 + +收藏行字段:`id,userId,uid,nickname,deviceId,anonymous,dramaId,dramaTitle,dramaCover, +dramaStatus,dramaPublished,createdAt`。点赞行字段:`id,userId,uid,nickname,deviceId, +anonymous,targetType,targetId,dramaId,dramaTitle,dramaCover,episodeId,episodeNo,episodeTitle, +commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评论摘要最多 30 字。 +关联资料逻辑删除后展示字段为空,不排除原始互动记录。 + ### 2.4 下载管理(已实现) | 方法 | 路径 | 权限点 | 说明 | diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/FavoriteAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/FavoriteAdminController.java new file mode 100644 index 0000000..084ec38 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/FavoriteAdminController.java @@ -0,0 +1,34 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.service.FavoriteAdminService; +import com.fruitshorts.admin.vo.AdminFavoriteVO; +import com.fruitshorts.admin.vo.FavoriteQueryReqVO; +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 jakarta.validation.Valid; +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/favorites") +@RequiredArgsConstructor +public class FavoriteAdminController { + private final FavoriteAdminService service; + + /** 按用户、设备、内容和时间分页查询收藏。 */ + @Operation(summary = "收藏列表分页查询") + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('favorite:list')") + public Result> page(@Valid FavoriteQueryReqVO req) { + return Result.ok(service.page(req)); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LikeAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LikeAdminController.java new file mode 100644 index 0000000..671bf7e --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LikeAdminController.java @@ -0,0 +1,34 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.service.LikeAdminService; +import com.fruitshorts.admin.vo.AdminLikeVO; +import com.fruitshorts.admin.vo.LikeQueryReqVO; +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 jakarta.validation.Valid; +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/likes") +@RequiredArgsConstructor +public class LikeAdminController { + private final LikeAdminService service; + + /** 按用户、设备、内容和时间分页查询点赞。 */ + @Operation(summary = "点赞列表分页查询") + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('like:list')") + public Result> page(@Valid LikeQueryReqVO req) { + return Result.ok(service.page(req)); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EngagementAdminLookup.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EngagementAdminLookup.java new file mode 100644 index 0000000..411caa4 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EngagementAdminLookup.java @@ -0,0 +1,81 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.admin.vo.EngagementQueryReqVO; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.mapper.DramaMapper; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 后台互动列表的分页、预检索及批量资料读取。 */ +@Component +@RequiredArgsConstructor +public class EngagementAdminLookup { + private static final int DEFAULT_PAGE_SIZE = 10; + private static final int MAX_PAGE_SIZE = 100; + private final AppUserMapper userMapper; + private final DramaMapper dramaMapper; + + /** 规范化页码和页大小。 */ + public Page page(EngagementQueryReqVO req) { + int index = req.getPageIndex() == null ? 1 : Math.max(1, req.getPageIndex()); + int size = req.getPageSize() == null || req.getPageSize() < 1 + ? DEFAULT_PAGE_SIZE : Math.min(MAX_PAGE_SIZE, req.getPageSize()); + return new Page<>(index, size); + } + + /** 按 uid 或昵称预检索,未传关键词时不限制用户。 */ + public List userIds(String keyword) { + if (!StringUtils.hasText(keyword)) { + return null; + } + String value = keyword.trim(); + return userMapper.selectList(Wrappers.lambdaQuery().select(AppUser::getId) + .and(q -> q.like(AppUser::getUid, value).or().like(AppUser::getNickname, value))) + .stream().map(AppUser::getId).toList(); + } + + /** 按剧名预检索,未传剧名时不限制短剧。 */ + public List dramaIds(String title) { + if (!StringUtils.hasText(title)) { + return null; + } + return dramaMapper.selectList(Wrappers.lambdaQuery().select(Drama::getId) + .like(Drama::getTitle, title.trim())).stream().map(Drama::getId).toList(); + } + + /** 区分无筛选和有筛选但无匹配。 */ + public boolean noMatch(List ids) { + return ids != null && ids.isEmpty(); + } + + /** 按非空去重 ID 批量读取用户,避免游客触发空 IN。 */ + public Map users(Collection ids) { + List keys = keys(ids); + return keys.isEmpty() ? Map.of() : userMapper.selectBatchIds(keys).stream() + .collect(Collectors.toMap(AppUser::getId, Function.identity())); + } + + /** 按非空去重 ID 批量读取短剧。 */ + public Map dramas(Collection ids) { + List keys = keys(ids); + return keys.isEmpty() ? Map.of() : dramaMapper.selectBatchIds(keys).stream() + .collect(Collectors.toMap(Drama::getId, Function.identity())); + } + + private List keys(Collection ids) { + return ids.stream().filter(Objects::nonNull).distinct().toList(); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/FavoriteAdminService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/FavoriteAdminService.java new file mode 100644 index 0000000..3f018c2 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/FavoriteAdminService.java @@ -0,0 +1,82 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.admin.vo.AdminFavoriteVO; +import com.fruitshorts.admin.vo.FavoriteQueryReqVO; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.enums.ContentStatus; +import com.fruitshorts.playback.dal.entity.UserFavorite; +import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper; +import com.fruitshorts.user.dal.entity.AppUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import java.util.List; +import java.util.Map; + +/** 后台收藏只读分页及跨域资料装配。 */ +@Service +@RequiredArgsConstructor +public class FavoriteAdminService { + private final UserFavoriteMapper favoriteMapper; + private final EngagementAdminLookup lookup; + + /** 分页查询收藏,预检索无匹配时直接返回空页。 */ + public PageResult page(FavoriteQueryReqVO req) { + List userIds = lookup.userIds(req.getKeyword()); + if (lookup.noMatch(userIds)) { + return PageResult.empty(); + } + List dramaIds = lookup.dramaIds(req.getDramaTitle()); + if (lookup.noMatch(dramaIds)) { + return PageResult.empty(); + } + var query = Wrappers.lambdaQuery() + .eq(req.getUserId() != null, UserFavorite::getUserId, req.getUserId()) + .in(userIds != null, UserFavorite::getUserId, userIds) + .eq(StringUtils.hasText(req.getDeviceId()), UserFavorite::getDeviceId, req.getDeviceId()) + .eq(req.getDramaId() != null, UserFavorite::getDramaId, req.getDramaId()) + .in(dramaIds != null, UserFavorite::getDramaId, dramaIds) + .ge(req.getStartTime() != null, UserFavorite::getCreatedAt, req.getStartTime()) + .le(req.getEndTime() != null, UserFavorite::getCreatedAt, req.getEndTime()) + .orderByDesc(UserFavorite::getCreatedAt, UserFavorite::getId); + var result = favoriteMapper.selectPage(lookup.page(req), query); + return new PageResult<>(result.getTotal(), assemble(result.getRecords())); + } + + private List assemble(List rows) { + if (rows.isEmpty()) { + return List.of(); + } + Map users = lookup.users(rows.stream().map(UserFavorite::getUserId).toList()); + Map dramas = lookup.dramas(rows.stream().map(UserFavorite::getDramaId).toList()); + return rows.stream().map(row -> toVo(row, row.getUserId() == null ? null : users.get(row.getUserId()), + row.getDramaId() == null ? null : dramas.get(row.getDramaId()))) + .toList(); + } + + private AdminFavoriteVO toVo(UserFavorite row, AppUser user, Drama drama) { + AdminFavoriteVO vo = new AdminFavoriteVO(); + vo.setId(row.getId()); + vo.setUserId(row.getUserId()); + vo.setDeviceId(row.getDeviceId()); + vo.setAnonymous(row.getUserId() == null); + vo.setDramaId(row.getDramaId()); + vo.setCreatedAt(row.getCreatedAt()); + vo.setDramaPublished(false); + if (user != null) { + vo.setUid(user.getUid()); + vo.setNickname(user.getNickname()); + } + if (drama != null) { + vo.setDramaTitle(drama.getTitle()); + vo.setDramaCover(drama.getCoverUrl()); + vo.setDramaStatus(drama.getStatus()); + vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())); + } + return vo; + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/LikeAdminService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/LikeAdminService.java new file mode 100644 index 0000000..775be47 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/LikeAdminService.java @@ -0,0 +1,164 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.admin.vo.AdminLikeVO; +import com.fruitshorts.admin.vo.LikeQueryReqVO; +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.interaction.dal.entity.ContentComment; +import com.fruitshorts.interaction.dal.entity.ContentLike; +import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper; +import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper; +import com.fruitshorts.user.dal.entity.AppUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 后台点赞只读分页及多态目标批量装配。 */ +@Service +@RequiredArgsConstructor +public class LikeAdminService { + private static final String DRAMA = "DRAMA"; + private static final String EPISODE = "EPISODE"; + private static final String COMMENT = "COMMENT"; + private static final int SUMMARY_LENGTH = 30; + private static final String DRAMA_MATCH = + "(target_type = 'DRAMA' AND target_id = {0}) OR " + + "(target_type = 'EPISODE' AND EXISTS (SELECT 1 FROM drama_episode e " + + "WHERE e.id = target_id AND e.deleted = 0 AND e.drama_id = {0})) OR " + + "(target_type = 'COMMENT' AND EXISTS (SELECT 1 FROM content_comment c " + + "WHERE c.id = target_id AND c.deleted = 0 AND c.drama_id = {0}))"; + private static final String EPISODE_MATCH = + "(target_type = 'EPISODE' AND target_id = {0}) OR " + + "(target_type = 'COMMENT' AND EXISTS (SELECT 1 FROM content_comment c " + + "WHERE c.id = target_id AND c.deleted = 0 AND c.episode_id = {0}))"; + + private final ContentLikeMapper likeMapper; + private final ContentCommentMapper commentMapper; + private final DramaEpisodeMapper episodeMapper; + private final EngagementAdminLookup lookup; + + /** 分页查询点赞;短剧及剧集条件在分页之前作用于各类目标。 */ + public PageResult page(LikeQueryReqVO req) { + List users = lookup.userIds(req.getKeyword()); + if (lookup.noMatch(users)) { + return PageResult.empty(); + } + List dramas = lookup.dramaIds(req.getDramaTitle()); + if (lookup.noMatch(dramas)) { + return PageResult.empty(); + } + var query = buildQuery(req, users); + if (req.getDramaId() != null) { + query.and(q -> q.apply(DRAMA_MATCH, req.getDramaId())); + } + if (dramas != null) { + query.and(q -> { + for (Long id : dramas) { + q.or().apply(DRAMA_MATCH, id); + } + }); + } + if (req.getEpisodeId() != null) { + query.and(q -> q.apply(EPISODE_MATCH, req.getEpisodeId())); + } + var result = likeMapper.selectPage(lookup.page(req), query); + return new PageResult<>(result.getTotal(), assemble(result.getRecords())); + } + + private LambdaQueryWrapper buildQuery(LikeQueryReqVO req, List users) { + return Wrappers.lambdaQuery() + .eq(req.getUserId() != null, ContentLike::getUserId, req.getUserId()) + .in(users != null, ContentLike::getUserId, users) + .eq(StringUtils.hasText(req.getDeviceId()), ContentLike::getDeviceId, req.getDeviceId()) + .eq(StringUtils.hasText(req.getTargetType()), ContentLike::getTargetType, req.getTargetType()) + .eq(req.getTargetId() != null, ContentLike::getTargetId, req.getTargetId()) + .ge(req.getStartTime() != null, ContentLike::getCreatedAt, req.getStartTime()) + .le(req.getEndTime() != null, ContentLike::getCreatedAt, req.getEndTime()) + .orderByDesc(ContentLike::getCreatedAt, ContentLike::getId); + } + + private List assemble(List rows) { + if (rows.isEmpty()) { + return List.of(); + } + Map users = lookup.users(rows.stream().map(ContentLike::getUserId).toList()); + Map comments = comments(targetIds(rows, COMMENT)); + List episodeIds = new java.util.ArrayList<>(targetIds(rows, EPISODE)); + comments.values().stream().map(ContentComment::getEpisodeId).filter(Objects::nonNull) + .forEach(episodeIds::add); + Map episodes = episodes(episodeIds); + List dramaIds = new java.util.ArrayList<>(targetIds(rows, DRAMA)); + comments.values().stream().map(ContentComment::getDramaId).filter(Objects::nonNull).forEach(dramaIds::add); + episodes.values().stream().map(DramaEpisode::getDramaId).filter(Objects::nonNull).forEach(dramaIds::add); + Map dramas = lookup.dramas(dramaIds); + return rows.stream().map(row -> toVo(row, users, comments, episodes, dramas)).toList(); + } + + private List targetIds(List rows, String type) { + return rows.stream().filter(row -> type.equals(row.getTargetType())).map(ContentLike::getTargetId) + .filter(Objects::nonNull).distinct().toList(); + } + + private Map comments(Collection ids) { + return ids.isEmpty() ? Map.of() : commentMapper.selectBatchIds(ids).stream() + .collect(Collectors.toMap(ContentComment::getId, Function.identity())); + } + + private Map episodes(Collection ids) { + List keys = ids.stream().filter(Objects::nonNull).distinct().toList(); + return keys.isEmpty() ? Map.of() : episodeMapper.selectBatchIds(keys).stream() + .collect(Collectors.toMap(DramaEpisode::getId, Function.identity())); + } + + private AdminLikeVO toVo(ContentLike row, Map users, Map comments, + Map episodes, Map dramas) { + AdminLikeVO vo = new AdminLikeVO(); + vo.setId(row.getId()); + vo.setUserId(row.getUserId()); + vo.setDeviceId(row.getDeviceId()); + vo.setAnonymous(row.getUserId() == null); + vo.setTargetType(row.getTargetType()); + vo.setTargetId(row.getTargetId()); + vo.setCreatedAt(row.getCreatedAt()); + AppUser user = row.getUserId() == null ? null : users.get(row.getUserId()); + if (user != null) { + vo.setUid(user.getUid()); + vo.setNickname(user.getNickname()); + } + ContentComment comment = COMMENT.equals(row.getTargetType()) ? comments.get(row.getTargetId()) : null; + Long episodeId = EPISODE.equals(row.getTargetType()) ? row.getTargetId() + : comment == null ? null : comment.getEpisodeId(); + DramaEpisode episode = episodeId == null ? null : episodes.get(episodeId); + Long dramaId = DRAMA.equals(row.getTargetType()) ? row.getTargetId() + : comment != null ? comment.getDramaId() : episode == null ? null : episode.getDramaId(); + vo.setEpisodeId(episodeId); + vo.setDramaId(dramaId); + if (comment != null && comment.getContent() != null) { + String content = comment.getContent(); + int count = content.codePointCount(0, content.length()); + vo.setCommentSummary(content.substring(0, content.offsetByCodePoints(0, Math.min(count, SUMMARY_LENGTH)))); + } + if (episode != null) { + vo.setEpisodeNo(episode.getEpNo()); + vo.setEpisodeTitle(episode.getTitle()); + } + Drama drama = dramaId == null ? null : dramas.get(dramaId); + if (drama != null) { + vo.setDramaTitle(drama.getTitle()); + vo.setDramaCover(drama.getCoverUrl()); + } + return vo; + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminFavoriteVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminFavoriteVO.java new file mode 100644 index 0000000..26912e2 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminFavoriteVO.java @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 后台收藏只读列表行,保留游客设备信息。 */ +@Data +public class AdminFavoriteVO { + private Long id; + private Long userId; + private String uid; + private String nickname; + private String deviceId; + private Boolean anonymous; + private Long dramaId; + private String dramaTitle; + private String dramaCover; + private String dramaStatus; + private Boolean dramaPublished; + private LocalDateTime createdAt; +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminLikeVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminLikeVO.java new file mode 100644 index 0000000..ec93589 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminLikeVO.java @@ -0,0 +1,27 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 后台点赞只读列表行,保留游客设备信息。 */ +@Data +public class AdminLikeVO { + private Long id; + private Long userId; + private String uid; + private String nickname; + private String deviceId; + private Boolean anonymous; + private Long dramaId; + private String dramaTitle; + private String dramaCover; + private String targetType; + private Long targetId; + private Long episodeId; + private Integer episodeNo; + private String episodeTitle; + private String commentSummary; + private LocalDateTime createdAt; +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EngagementQueryReqVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EngagementQueryReqVO.java new file mode 100644 index 0000000..4391bba --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EngagementQueryReqVO.java @@ -0,0 +1,25 @@ +// 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 EngagementQueryReqVO { + private static final int DEFAULT_PAGE_SIZE = 10; + private static final String TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + private Integer pageIndex = 1; + private Integer pageSize = DEFAULT_PAGE_SIZE; + private String keyword; + private Long userId; + private String deviceId; + private String dramaTitle; + private Long dramaId; + @DateTimeFormat(pattern = TIME_PATTERN) + private LocalDateTime startTime; + @DateTimeFormat(pattern = TIME_PATTERN) + private LocalDateTime endTime; +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/FavoriteQueryReqVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/FavoriteQueryReqVO.java new file mode 100644 index 0000000..41fa0ae --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/FavoriteQueryReqVO.java @@ -0,0 +1,7 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.vo; + +/** 后台收藏分页检索条件。 */ +public class FavoriteQueryReqVO extends EngagementQueryReqVO { +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/LikeQueryReqVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/LikeQueryReqVO.java new file mode 100644 index 0000000..20e7719 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/LikeQueryReqVO.java @@ -0,0 +1,17 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.vo; + +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 后台点赞分页检索条件。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LikeQueryReqVO extends EngagementQueryReqVO { + @Pattern(regexp = "DRAMA|EPISODE|COMMENT", message = "目标类型必须为 DRAMA、EPISODE 或 COMMENT") + private String targetType; + private Long targetId; + private Long episodeId; +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EngagementControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EngagementControllerContractTest.java new file mode 100644 index 0000000..87dd30d --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EngagementControllerContractTest.java @@ -0,0 +1,45 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.service.FavoriteAdminService; +import com.fruitshorts.admin.service.LikeAdminService; +import com.fruitshorts.admin.support.ControllerContractSupport; +import com.fruitshorts.admin.vo.AdminFavoriteVO; +import com.fruitshorts.common.pojo.PageResult; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** 收藏和点赞分页路由及响应契约。 */ +class EngagementControllerContractTest { + @Test + void should_return_guest_fields_for_favorites() throws Exception { + FavoriteAdminService service = mock(FavoriteAdminService.class); + AdminFavoriteVO guest = new AdminFavoriteVO(); + guest.setId(1L); + guest.setDeviceId("guest"); + guest.setAnonymous(true); + when(service.page(any())).thenReturn(new PageResult<>(1L, List.of(guest))); + ControllerContractSupport.mockMvcOf(new FavoriteAdminController(service)) + .perform(get("/admin/favorites/page")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.list[0].deviceId").value("guest")) + .andExpect(jsonPath("$.data.list[0].anonymous").value(true)); + } + + @Test + void should_return_empty_like_page() throws Exception { + LikeAdminService service = mock(LikeAdminService.class); + when(service.page(any())).thenReturn(PageResult.empty()); + ControllerContractSupport.mockMvcOf(new LikeAdminController(service)) + .perform(get("/admin/likes/page").param("targetType", "COMMENT")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)) + .andExpect(jsonPath("$.data.list").isEmpty()); + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EngagementAdminServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EngagementAdminServiceTest.java new file mode 100644 index 0000000..e6f61f7 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EngagementAdminServiceTest.java @@ -0,0 +1,104 @@ +// 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.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.admin.vo.FavoriteQueryReqVO; +import com.fruitshorts.admin.vo.LikeQueryReqVO; +import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; +import com.fruitshorts.interaction.dal.entity.ContentLike; +import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper; +import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper; +import com.fruitshorts.playback.dal.entity.UserFavorite; +import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** 收藏及点赞分页的游客、空结果和多态目标检验。 */ +class EngagementAdminServiceTest { + @BeforeAll + static void initMetadata() { + MybatisConfiguration config = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(config, ""); + TableInfoHelper.initTableInfo(assistant, UserFavorite.class); + TableInfoHelper.initTableInfo(assistant, ContentLike.class); + } + + @Test + void should_enable_logic_delete_for_both_interaction_entities() { + assertThat(TableInfoHelper.getTableInfo(UserFavorite.class).isWithLogicDelete()).isTrue(); + assertThat(TableInfoHelper.getTableInfo(ContentLike.class).isWithLogicDelete()).isTrue(); + assertThat(TableInfoHelper.getTableInfo(UserFavorite.class).getLogicDeleteSql(true, true)) + .contains("deleted").contains("0"); + assertThat(TableInfoHelper.getTableInfo(ContentLike.class).getLogicDeleteSql(true, true)) + .contains("deleted").contains("0"); + } + + @Test + void should_return_empty_without_query_when_keyword_has_no_match() { + EngagementAdminLookup lookup = mock(EngagementAdminLookup.class); + UserFavoriteMapper mapper = mock(UserFavoriteMapper.class); + when(lookup.userIds("missing")).thenReturn(List.of()); + when(lookup.noMatch(List.of())).thenReturn(true); + FavoriteQueryReqVO req = new FavoriteQueryReqVO(); + req.setKeyword("missing"); + var result = new FavoriteAdminService(mapper, lookup).page(req); + assertThat(result.getTotal()).isZero(); + assertThat(result.getList()).isEmpty(); + verifyNoInteractions(mapper); + } + + @Test + void should_preserve_guest_and_unpublished_drama_when_favorite_has_no_user() { + EngagementAdminLookup lookup = mock(EngagementAdminLookup.class); + UserFavoriteMapper mapper = mock(UserFavoriteMapper.class); + when(lookup.page(any())).thenReturn(new Page<>(1, 10)); + UserFavorite row = new UserFavorite(); + row.setId(1L); + row.setDeviceId("guest-device"); + row.setDramaId(2L); + Page page = new Page<>(1, 10); + page.setTotal(1); + page.setRecords(List.of(row)); + when(mapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); + when(lookup.users(any())).thenReturn(Map.of()); + when(lookup.dramas(any())).thenReturn(Map.of()); + var result = new FavoriteAdminService(mapper, lookup).page(new FavoriteQueryReqVO()); + assertThat(result.getList().get(0).getAnonymous()).isTrue(); + assertThat(result.getList().get(0).getDeviceId()).isEqualTo("guest-device"); + assertThat(result.getList().get(0).getDramaPublished()).isFalse(); + } + + @Test + void should_preserve_guest_drama_like_without_episode() { + EngagementAdminLookup lookup = mock(EngagementAdminLookup.class); + ContentLikeMapper mapper = mock(ContentLikeMapper.class); + when(lookup.page(any())).thenReturn(new Page<>(1, 10)); + ContentLike row = new ContentLike(); + row.setId(1L); + row.setTargetType("DRAMA"); + row.setTargetId(2L); + row.setDeviceId("guest-device"); + Page page = new Page<>(1, 10); + page.setTotal(1); + page.setRecords(List.of(row)); + when(mapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); + when(lookup.users(any())).thenReturn(Map.of()); + when(lookup.dramas(any())).thenReturn(Map.of()); + var service = new LikeAdminService(mapper, mock(ContentCommentMapper.class), + mock(DramaEpisodeMapper.class), lookup); + var result = service.page(new LikeQueryReqVO()); + assertThat(result.getList().get(0).getAnonymous()).isTrue(); + assertThat(result.getList().get(0).getDramaId()).isEqualTo(2L); + assertThat(result.getList().get(0).getEpisodeId()).isNull(); + } +} diff --git a/sql/migrate-k107-admin-engagement-menu.sql b/sql/migrate-k107-admin-engagement-menu.sql new file mode 100644 index 0000000..22e46ae --- /dev/null +++ b/sql/migrate-k107-admin-engagement-menu.sql @@ -0,0 +1,28 @@ +-- Author: fruit.com.cn +-- K107 后台收藏和点赞只读权限及菜单。 +BEGIN; +INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES + (1106, 'favorite:list', '查看收藏', '用户管理', 'BUTTON', 106), + (1107, 'like:list', '查看点赞', '用户管理', 'BUTTON', 107) +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 IN ('favorite:list', 'like:list') +ON CONFLICT DO NOTHING; + +INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, + permission_code, type, always_show, affix, sort) VALUES + (40, 15, '收藏列表', 'favorites', 'user-favorites', 'views/userManagement/FavoritesView', + '', '', 'favorite:list', 'MENU', 0, 0, 3), + (41, 15, '点赞列表', 'likes', 'user-likes', 'views/userManagement/LikesView', + '', '', 'like:list', 'MENU', 0, 0, 4) +ON CONFLICT (id) DO NOTHING; + +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 IN (40, 41) +ON CONFLICT DO NOTHING; +COMMIT;