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;