diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/config/AdminJacksonTimeConfig.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/config/AdminJacksonTimeConfig.java new file mode 100644 index 0000000..f3c881b --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/config/AdminJacksonTimeConfig.java @@ -0,0 +1,28 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.config; + +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.format.DateTimeFormatter; + +/** + * 后台接口的时间格式统一成 `yyyy-MM-dd HH:mm:ss`。 + * + *

application.yml 里的 `spring.jackson.date-format` 只作用于 java.util.Date, + * LocalDateTime 仍会输出 ISO 的 `2026-10-27T14:14:12.644`,后台表格里会露出一串带 T 的原文。 + * 该配置只放在 admin-api,C 端保持 ISO,避免影响客户端解析。 + */ +@Configuration +public class AdminJacksonTimeConfig { + + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Bean + public Jackson2ObjectMapperBuilderCustomizer adminLocalDateTimeFormat() { + return builder -> builder.serializers(new LocalDateTimeSerializer(DATE_TIME)); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java index b866b7b..0faa1eb 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java @@ -36,6 +36,9 @@ public class AppSessionSystemService { return grouped; } List sessions = sessionMapper.selectList(Wrappers.lambdaQuery() + .select(AppUserSession::getId, AppUserSession::getUserId, AppUserSession::getDeviceId, + AppUserSession::getPlatform, AppUserSession::getOsVersion, + AppUserSession::getRevokedAt, AppUserSession::getLastActiveAt) .in(AppUserSession::getUserId, userIds) .orderByDesc(AppUserSession::getLastActiveAt) .orderByDesc(AppUserSession::getId)); 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 d6b150c..96fdc7e 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 @@ -126,6 +126,8 @@ public class EngagementAdminLookup { } Map result = new HashMap<>(); for (AppUserSession session : sessionMapper.selectList(Wrappers.lambdaQuery() + .select(AppUserSession::getId, AppUserSession::getUserId, + AppUserSession::getPlatform) .in(AppUserSession::getUserId, keys) .orderByDesc(AppUserSession::getLastActiveAt) .orderByDesc(AppUserSession::getId))) { 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 index 4afbcbd..da731ef 100644 --- 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 @@ -8,6 +8,8 @@ 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.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; @@ -28,6 +30,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * 后台观看记录分页与跨域装配。 @@ -43,6 +46,8 @@ public class WatchHistoryAdminService { 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 static final int MAX_LOOKUP_SIZE = 2000; + private static final int LOOKUP_PROBE = MAX_LOOKUP_SIZE + 1; private final WatchHistoryMapper watchHistoryMapper; private final AppUserMapper appUserMapper; @@ -119,24 +124,37 @@ public class WatchHistoryAdminService { private List searchUserIds(String keyword) { String value = keyword.trim(); - return appUserMapper.selectList(Wrappers.lambdaQuery() + List ids = appUserMapper.selectList(Wrappers.lambdaQuery() .select(AppUser::getId) .and(query -> query.like(AppUser::getUid, value) .or() - .like(AppUser::getNickname, value))) + .like(AppUser::getNickname, value)) + .last("LIMIT " + LOOKUP_PROBE)) .stream() .map(AppUser::getId) .toList(); + return ensureLookupSize(ids, "uid / 昵称"); } private List searchDramaIds(String dramaTitle) { String value = dramaTitle.trim(); - return dramaMapper.selectList(Wrappers.lambdaQuery() + List ids = dramaMapper.selectList(Wrappers.lambdaQuery() .select(Drama::getId) - .like(Drama::getTitle, value)) + .like(Drama::getTitle, value) + .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; } private List toVoList(List rows) { @@ -162,8 +180,14 @@ public class WatchHistoryAdminService { private Map loadUsers(List rows) { Map result = new HashMap<>(); - List ids = rows.stream().map(WatchHistory::getUserId).distinct().toList(); - for (AppUser user : appUserMapper.selectBatchIds(ids)) { + List ids = rows.stream().map(WatchHistory::getUserId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return result; + } + // 列表只需要身份展示字段,避免把整行用户资料拉回来 + for (AppUser user : appUserMapper.selectList(Wrappers.lambdaQuery() + .select(AppUser::getId, AppUser::getUid, AppUser::getNickname) + .in(AppUser::getId, ids))) { result.put(user.getId(), user); } return result; @@ -171,8 +195,13 @@ public class WatchHistoryAdminService { private Map loadDramas(List rows) { Map result = new HashMap<>(); - List ids = rows.stream().map(WatchHistory::getDramaId).distinct().toList(); - for (Drama drama : dramaMapper.selectBatchIds(ids)) { + List ids = rows.stream().map(WatchHistory::getDramaId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return result; + } + for (Drama drama : dramaMapper.selectList(Wrappers.lambdaQuery() + .select(Drama::getId, Drama::getTitle, Drama::getStatus) + .in(Drama::getId, ids))) { result.put(drama.getId(), drama); } return result; @@ -180,8 +209,14 @@ public class WatchHistoryAdminService { private Map loadEpisodes(List rows) { Map result = new HashMap<>(); - List ids = rows.stream().map(WatchHistory::getEpisodeId).distinct().toList(); - for (DramaEpisode episode : dramaEpisodeMapper.selectBatchIds(ids)) { + List ids = rows.stream().map(WatchHistory::getEpisodeId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return result; + } + for (DramaEpisode episode : dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() + .select(DramaEpisode::getId, DramaEpisode::getDramaId, + DramaEpisode::getEpNo, DramaEpisode::getTitle) + .in(DramaEpisode::getId, ids))) { result.put(episode.getId(), episode); } return result; 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 index ace401d..ba30f12 100644 --- 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 @@ -199,9 +199,10 @@ class WatchHistoryAdminServiceTest { } 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))); + // 装配阶段按需列查询,而不是 selectBatchIds,桩法要跟着改 + given(appUserMapper.selectList(any())).willReturn(List.of(user(7L, "Alice"))); + given(dramaMapper.selectList(any())).willReturn(List.of(drama(5L, "Signal"))); + given(dramaEpisodeMapper.selectList(any())).willReturn(List.of(episode(11L))); } @SuppressWarnings("unchecked") diff --git a/sql/migrate-k112-watch-history-time-index.sql b/sql/migrate-k112-watch-history-time-index.sql new file mode 100644 index 0000000..5fc82f5 --- /dev/null +++ b/sql/migrate-k112-watch-history-time-index.sql @@ -0,0 +1,11 @@ +-- Author: fruit.com.cn +-- K112 观看记录默认列表的排序索引。 +-- 后台按 watched_at DESC, id DESC 倒序翻页,原有索引都以 user_id/drama_id/device_id 开头, +-- 不带筛选条件时只能全表扫描再排序;补一条时间索引让翻页直接走索引。 +-- watch_history 是分区表,在父表建索引会自动下发到各分区。 +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_watch_history_watched_at + ON watch_history (watched_at DESC, id DESC); + +COMMIT;