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 index 411caa4..3e4e97a 100644 --- 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 @@ -5,6 +5,8 @@ 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.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.mapper.DramaMapper; import com.fruitshorts.user.dal.entity.AppUser; @@ -25,6 +27,12 @@ import java.util.stream.Collectors; public class EngagementAdminLookup { private static final int DEFAULT_PAGE_SIZE = 10; private static final int MAX_PAGE_SIZE = 100; + /** + * 关键词预检索的 ID 上限:命中过多时 IN 列表会超出数据库参数上限, + * 也说明关键词过于宽泛,直接提示使用者补充条件而不是截断结果。 + */ + private static final int MAX_LOOKUP_SIZE = 2000; + private static final int LOOKUP_PROBE = MAX_LOOKUP_SIZE + 1; private final AppUserMapper userMapper; private final DramaMapper dramaMapper; @@ -42,9 +50,11 @@ public class EngagementAdminLookup { 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))) + List ids = userMapper.selectList(Wrappers.lambdaQuery().select(AppUser::getId) + .and(q -> q.like(AppUser::getUid, value).or().like(AppUser::getNickname, value)) + .last("LIMIT " + LOOKUP_PROBE)) .stream().map(AppUser::getId).toList(); + return ensureLookupSize(ids, "uid / 昵称"); } /** 按剧名预检索,未传剧名时不限制短剧。 */ @@ -52,8 +62,19 @@ public class EngagementAdminLookup { if (!StringUtils.hasText(title)) { return null; } - return dramaMapper.selectList(Wrappers.lambdaQuery().select(Drama::getId) - .like(Drama::getTitle, title.trim())).stream().map(Drama::getId).toList(); + List ids = dramaMapper.selectList(Wrappers.lambdaQuery().select(Drama::getId) + .like(Drama::getTitle, title.trim()) + .last("LIMIT " + LOOKUP_PROBE)).stream().map(Drama::getId).toList(); + return ensureLookupSize(ids, "短剧名称"); + } + + /** 命中数量超限时给出可操作的提示,避免把整张表拉进 IN 条件。 */ + private List ensureLookupSize(List ids, String field) { + if (ids.size() > MAX_LOOKUP_SIZE) { + throw new ServiceException(ErrorCode.BAD_REQUEST, + field + "命中超过 " + MAX_LOOKUP_SIZE + " 条,请使用更精确的筛选条件"); + } + return ids; } /** 区分无筛选和有筛选但无匹配。 */ 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 index 3f018c2..484b35c 100644 --- 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 @@ -66,7 +66,6 @@ public class FavoriteAdminService { 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()); @@ -77,6 +76,8 @@ public class FavoriteAdminService { vo.setDramaStatus(drama.getStatus()); vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())); } + // 短剧读不到(已删除或数据异常)时保持 dramaPublished 为 null, + // 与「未发布」区分开,前端只在明确 false 时才展示未发布标记。 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 index 775be47..dc2291d 100644 --- 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 @@ -18,6 +18,7 @@ import com.fruitshorts.user.dal.entity.AppUser; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -33,6 +34,12 @@ public class LikeAdminService { private static final String EPISODE = "EPISODE"; private static final String COMMENT = "COMMENT"; private static final int SUMMARY_LENGTH = 30; + /** + * 点赞目标是多态列(target_type + target_id),按短剧/剧集筛选必须跨表回溯, + * 无法用 LambdaQueryWrapper 表达,只能写原生片段。 + * 维护约定:表名、逻辑删除列与 {@code drama_episode} / {@code content_comment} + * 实体上的 @TableName / @TableLogic 保持一致,改表结构时需要同步修改这里。 + */ 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 " @@ -95,11 +102,11 @@ public class LikeAdminService { } 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)); + List episodeIds = new 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)); + List dramaIds = new 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); 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 index 26912e2..e0a72d1 100644 --- 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 @@ -18,6 +18,7 @@ public class AdminFavoriteVO { private String dramaTitle; private String dramaCover; private String dramaStatus; + /** true=已发布,false=未发布,null=短剧已不存在(不代表未发布)。 */ private Boolean dramaPublished; private LocalDateTime createdAt; } 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 index 41fa0ae..013afe6 100644 --- 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 @@ -2,6 +2,11 @@ package com.fruitshorts.admin.vo; +import lombok.Data; +import lombok.EqualsAndHashCode; + /** 后台收藏分页检索条件。 */ +@Data +@EqualsAndHashCode(callSuper = true) public class FavoriteQueryReqVO extends EngagementQueryReqVO { } 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 index e6f61f7..eae9d8a 100644 --- 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 @@ -58,7 +58,7 @@ class EngagementAdminServiceTest { } @Test - void should_preserve_guest_and_unpublished_drama_when_favorite_has_no_user() { + void should_keep_drama_published_unknown_when_drama_missing() { EngagementAdminLookup lookup = mock(EngagementAdminLookup.class); UserFavoriteMapper mapper = mock(UserFavoriteMapper.class); when(lookup.page(any())).thenReturn(new Page<>(1, 10)); @@ -75,7 +75,8 @@ class EngagementAdminServiceTest { 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(); + // 短剧已不存在时不能误报成「未发布」 + assertThat(result.getList().get(0).getDramaPublished()).isNull(); } @Test diff --git a/sql/migrate-k108-engagement-list-index.sql b/sql/migrate-k108-engagement-list-index.sql new file mode 100644 index 0000000..8d5f962 --- /dev/null +++ b/sql/migrate-k108-engagement-list-index.sql @@ -0,0 +1,14 @@ +-- Author: fruit.com.cn +-- K108 后台收藏/点赞列表按时间倒序的分页索引。 +-- 列表默认 ORDER BY created_at DESC, id DESC,且实体走逻辑删除(deleted = 0), +-- 原有索引只覆盖 (user_id, created_at) / (device_id, created_at) 前缀, +-- 不加筛选条件时会退化成全表扫描 + 排序。 +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_content_like_created + ON content_like (created_at DESC, id DESC) WHERE deleted = 0; + +CREATE INDEX IF NOT EXISTS idx_user_favorite_created + ON user_favorite (created_at DESC, id DESC) WHERE deleted = 0; + +COMMIT;