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 ec7b7dd..b866b7b 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 @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -62,6 +63,31 @@ public class AppSessionSystemService { return result; } + /** + * 平台筛选的命中范围:设备平台直接给 device_id;登录用户的行为行不带 device_id, + * 只能按该用户登录会话的平台判定,两者取并集。 + */ + public record PlatformScope(List deviceIds, List userIds) { + public boolean empty() { + return deviceIds.isEmpty() && userIds.isEmpty(); + } + } + + /** 按平台解析命中范围;未传平台返回 null 表示不限制。 */ + public PlatformScope platformScope(String platform) { + if (!StringUtils.hasText(platform)) { + return null; + } + String value = platform.trim().toUpperCase(Locale.ROOT); + List deviceIds = profileMapper.selectList(Wrappers.lambdaQuery() + .select(AppDeviceProfile::getDeviceId) + .eq(AppDeviceProfile::getPlatform, value)) + .stream().map(AppDeviceProfile::getDeviceId).toList(); + // 只认「最近一次登录」的平台,与列表里展示的系统保持一致 + List userIds = sessionMapper.selectUserIdsByLatestPlatform(value); + return new PlatformScope(deviceIds, userIds); + } + /** 观看记录优先匹配同一设备,其次取未吊销会话。 */ public AppUserSession match(List sessions, String deviceId) { if (sessions == null || sessions.isEmpty()) { 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 d4421dc..d6b150c 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 @@ -21,6 +21,7 @@ import org.springframework.util.StringUtils; import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.function.Function; @@ -42,6 +43,7 @@ public class EngagementAdminLookup { private final DramaMapper dramaMapper; private final AppDeviceProfileMapper deviceProfileMapper; private final AppUserSessionMapper sessionMapper; + private final AppSessionSystemService sessionSystem; /** 规范化页码和页大小。 */ public Page page(EngagementQueryReqVO req) { @@ -132,6 +134,11 @@ public class EngagementAdminLookup { return result; } + /** 按平台解析命中范围;未传平台返回 null 表示不限制。 */ + public AppSessionSystemService.PlatformScope platformScope(String platform) { + return sessionSystem.platformScope(platform); + } + 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 index 8193480..f9eb55b 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 @@ -36,6 +36,10 @@ public class FavoriteAdminService { if (lookup.noMatch(dramaIds)) { return PageResult.empty(); } + var scope = lookup.platformScope(req.getPlatform()); + if (scope != null && scope.empty()) { + return PageResult.empty(); + } var query = Wrappers.lambdaQuery() .eq(req.getUserId() != null, UserFavorite::getUserId, req.getUserId()) .in(userIds != null, UserFavorite::getUserId, userIds) @@ -43,8 +47,22 @@ public class FavoriteAdminService { .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); + .le(req.getEndTime() != null, UserFavorite::getCreatedAt, req.getEndTime()); + // orderBy 必须最后追加,否则条件会被拼到 ORDER BY 后面 + if (scope != null) { + query.and(inner -> { + if (!scope.deviceIds().isEmpty()) { + inner.in(UserFavorite::getDeviceId, scope.deviceIds()); + } + if (!scope.userIds().isEmpty()) { + if (!scope.deviceIds().isEmpty()) { + inner.or(); + } + inner.in(UserFavorite::getUserId, scope.userIds()); + } + }); + } + query.orderByDesc(UserFavorite::getCreatedAt, UserFavorite::getId); var result = favoriteMapper.selectPage(lookup.page(req), query); return new PageResult<>(result.getTotal(), assemble(result.getRecords())); } 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 4520750..4f87d8c 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 @@ -69,6 +69,23 @@ public class LikeAdminService { return PageResult.empty(); } var query = buildQuery(req, users); + var scope = lookup.platformScope(req.getPlatform()); + if (scope != null && scope.empty()) { + return PageResult.empty(); + } + if (scope != null) { + query.and(inner -> { + if (!scope.deviceIds().isEmpty()) { + inner.in(ContentLike::getDeviceId, scope.deviceIds()); + } + if (!scope.userIds().isEmpty()) { + if (!scope.deviceIds().isEmpty()) { + inner.or(); + } + inner.in(ContentLike::getUserId, scope.userIds()); + } + }); + } if (req.getDramaId() != null) { query.and(q -> q.apply(DRAMA_MATCH, req.getDramaId())); } @@ -82,6 +99,8 @@ public class LikeAdminService { if (req.getEpisodeId() != null) { query.and(q -> q.apply(EPISODE_MATCH, req.getEpisodeId())); } + // orderBy 必须最后追加,否则条件会被拼到 ORDER BY 后面 + query.orderByDesc(ContentLike::getCreatedAt, ContentLike::getId); var result = likeMapper.selectPage(lookup.page(req), query); return new PageResult<>(result.getTotal(), assemble(result.getRecords())); } @@ -94,8 +113,7 @@ public class LikeAdminService { .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); + .le(req.getEndTime() != null, ContentLike::getCreatedAt, req.getEndTime()); } private List assemble(List rows) { 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 8ec7f45..4afbcbd 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 @@ -66,14 +66,19 @@ public class WatchHistoryAdminService { if (matchedDramaIds != null && matchedDramaIds.isEmpty()) { return emptyPage(); } + var scope = sessionSystem.platformScope(req.getPlatform()); + if (scope != null && scope.empty()) { + return emptyPage(); + } IPage result = watchHistoryMapper.selectPage(new Page<>(pageIndex, pageSize), - buildQuery(req, matchedUserIds, matchedDramaIds)); + buildQuery(req, matchedUserIds, matchedDramaIds, scope)); return new PageResult<>(result.getTotal(), toVoList(result.getRecords())); } private LambdaQueryWrapper buildQuery(WatchHistoryQueryReqVO req, List matchedUserIds, - List matchedDramaIds) { + List matchedDramaIds, + AppSessionSystemService.PlatformScope scope) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(req.getUserId() != null, WatchHistory::getUserId, req.getUserId()) .in(matchedUserIds != null, WatchHistory::getUserId, matchedUserIds) @@ -83,6 +88,19 @@ public class WatchHistoryAdminService { .eq(StringUtils.hasText(req.getSource()), WatchHistory::getSource, req.getSource()) .ge(req.getStartTime() != null, WatchHistory::getWatchedAt, req.getStartTime()) .le(req.getEndTime() != null, WatchHistory::getWatchedAt, req.getEndTime()); + if (scope != null) { + wrapper.and(inner -> { + if (!scope.deviceIds().isEmpty()) { + inner.in(WatchHistory::getDeviceId, scope.deviceIds()); + } + if (!scope.userIds().isEmpty()) { + if (!scope.deviceIds().isEmpty()) { + inner.or(); + } + inner.in(WatchHistory::getUserId, scope.userIds()); + } + }); + } applyFinished(wrapper, req.getFinished()); return wrapper.orderByDesc(WatchHistory::getWatchedAt) .orderByDesc(WatchHistory::getId); 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 index 4391bba..849c726 100644 --- 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 @@ -16,6 +16,8 @@ public class EngagementQueryReqVO { private String keyword; private Long userId; private String deviceId; + /** 客户端平台筛选:ANDROID / IOS / WEB,留空不限制。 */ + private String platform; private String dramaTitle; private Long dramaId; @DateTimeFormat(pattern = TIME_PATTERN) diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/WatchHistoryQueryReqVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/WatchHistoryQueryReqVO.java index ef26d7b..bde569c 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/WatchHistoryQueryReqVO.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/WatchHistoryQueryReqVO.java @@ -20,6 +20,8 @@ public class WatchHistoryQueryReqVO { private String dramaTitle; private Long dramaId; private Long episodeId; + /** 客户端平台筛选:ANDROID / IOS / WEB,留空不限制。 */ + private String platform; private String source; private Boolean finished; diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserSessionMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserSessionMapper.java index e27d100..a5cf71a 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserSessionMapper.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserSessionMapper.java @@ -4,6 +4,27 @@ package com.fruitshorts.user.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.fruitshorts.user.dal.entity.AppUserSession; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; public interface AppUserSessionMapper extends BaseMapper { + + /** + * 最近一次登录会话平台等于指定值的用户。 + * 必须取「最近一次」而不是「存在过」:否则同一用户在 Android 登录过、 + * 后来又换 iOS,会被两端都命中,筛选结果与实际展示的系统不一致。 + */ + @Select(""" + SELECT t.user_id + FROM ( + SELECT DISTINCT ON (user_id) user_id, platform + FROM app_user_session + WHERE deleted = 0 + ORDER BY user_id, last_active_at DESC NULLS LAST, id DESC + ) t + WHERE t.platform = #{platform} + """) + List selectUserIdsByLatestPlatform(@Param("platform") String platform); }