merge(admin): 合并收藏与点赞只读查询

This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-27 12:32:23 +08:00
commit 226dc275bb
14 files changed
+686

No files matched your search

+15
View File
@@ -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 下载管理(已实现)
| 方法 | 路径 | 权限点 | 说明 |
@@ -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<PageResult<AdminFavoriteVO>> page(@Valid FavoriteQueryReqVO req) {
return Result.ok(service.page(req));
}
}
@@ -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<PageResult<AdminLikeVO>> page(@Valid LikeQueryReqVO req) {
return Result.ok(service.page(req));
}
}
@@ -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 <T> Page<T> 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<Long> userIds(String keyword) {
if (!StringUtils.hasText(keyword)) {
return null;
}
String value = keyword.trim();
return userMapper.selectList(Wrappers.<AppUser>lambdaQuery().select(AppUser::getId)
.and(q -> q.like(AppUser::getUid, value).or().like(AppUser::getNickname, value)))
.stream().map(AppUser::getId).toList();
}
/** 按剧名预检索,未传剧名时不限制短剧。 */
public List<Long> dramaIds(String title) {
if (!StringUtils.hasText(title)) {
return null;
}
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery().select(Drama::getId)
.like(Drama::getTitle, title.trim())).stream().map(Drama::getId).toList();
}
/** 区分无筛选和有筛选但无匹配。 */
public boolean noMatch(List<Long> ids) {
return ids != null && ids.isEmpty();
}
/** 按非空去重 ID 批量读取用户,避免游客触发空 IN。 */
public Map<Long, AppUser> users(Collection<Long> ids) {
List<Long> keys = keys(ids);
return keys.isEmpty() ? Map.of() : userMapper.selectBatchIds(keys).stream()
.collect(Collectors.toMap(AppUser::getId, Function.identity()));
}
/** 按非空去重 ID 批量读取短剧。 */
public Map<Long, Drama> dramas(Collection<Long> ids) {
List<Long> keys = keys(ids);
return keys.isEmpty() ? Map.of() : dramaMapper.selectBatchIds(keys).stream()
.collect(Collectors.toMap(Drama::getId, Function.identity()));
}
private List<Long> keys(Collection<Long> ids) {
return ids.stream().filter(Objects::nonNull).distinct().toList();
}
}
@@ -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<AdminFavoriteVO> page(FavoriteQueryReqVO req) {
List<Long> userIds = lookup.userIds(req.getKeyword());
if (lookup.noMatch(userIds)) {
return PageResult.empty();
}
List<Long> dramaIds = lookup.dramaIds(req.getDramaTitle());
if (lookup.noMatch(dramaIds)) {
return PageResult.empty();
}
var query = Wrappers.<UserFavorite>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<AdminFavoriteVO> assemble(List<UserFavorite> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, AppUser> users = lookup.users(rows.stream().map(UserFavorite::getUserId).toList());
Map<Long, Drama> 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;
}
}
@@ -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<AdminLikeVO> page(LikeQueryReqVO req) {
List<Long> users = lookup.userIds(req.getKeyword());
if (lookup.noMatch(users)) {
return PageResult.empty();
}
List<Long> 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<ContentLike> buildQuery(LikeQueryReqVO req, List<Long> users) {
return Wrappers.<ContentLike>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<AdminLikeVO> assemble(List<ContentLike> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, AppUser> users = lookup.users(rows.stream().map(ContentLike::getUserId).toList());
Map<Long, ContentComment> comments = comments(targetIds(rows, COMMENT));
List<Long> episodeIds = new java.util.ArrayList<>(targetIds(rows, EPISODE));
comments.values().stream().map(ContentComment::getEpisodeId).filter(Objects::nonNull)
.forEach(episodeIds::add);
Map<Long, DramaEpisode> episodes = episodes(episodeIds);
List<Long> 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<Long, Drama> dramas = lookup.dramas(dramaIds);
return rows.stream().map(row -> toVo(row, users, comments, episodes, dramas)).toList();
}
private List<Long> targetIds(List<ContentLike> rows, String type) {
return rows.stream().filter(row -> type.equals(row.getTargetType())).map(ContentLike::getTargetId)
.filter(Objects::nonNull).distinct().toList();
}
private Map<Long, ContentComment> comments(Collection<Long> ids) {
return ids.isEmpty() ? Map.of() : commentMapper.selectBatchIds(ids).stream()
.collect(Collectors.toMap(ContentComment::getId, Function.identity()));
}
private Map<Long, DramaEpisode> episodes(Collection<Long> ids) {
List<Long> 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<Long, AppUser> users, Map<Long, ContentComment> comments,
Map<Long, DramaEpisode> episodes, Map<Long, Drama> 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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,7 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
/** 后台收藏分页检索条件。 */
public class FavoriteQueryReqVO extends EngagementQueryReqVO {
}
@@ -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;
}
@@ -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());
}
}
@@ -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<UserFavorite> 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<ContentLike> 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();
}
}
@@ -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;