From ab58641bbee274abb08129a3a0928d7ed46002a6 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:34:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(user):=20=E5=B1=95=E7=A4=BA=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=9C=80=E8=BF=91=E4=BD=BF=E7=94=A8=E7=9A=84=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AppUserAdminController.java | 15 ++++- .../service/AppSessionSystemService.java | 62 +++++++++++++++++++ .../service/WatchHistoryAdminService.java | 13 +++- .../admin/vo/AdminWatchHistoryVO.java | 4 ++ .../admin/vo/AppUserWatchHistoryVO.java | 4 ++ .../service/AppSessionSystemServiceTest.java | 48 ++++++++++++++ .../service/WatchHistoryAdminServiceTest.java | 2 +- .../app/controller/AppAuthController.java | 2 +- .../user/service/AppAuthService.java | 12 ++++ .../user/service/AppUserAdminService.java | 46 +++++++++++++- .../user/vo/AppUserAdminRespVO.java | 6 ++ .../com/fruitshorts/user/vo/RefreshReqVO.java | 6 ++ 12 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/AppSessionSystemServiceTest.java diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppUserAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppUserAdminController.java index 3fa3400..495e4fa 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppUserAdminController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppUserAdminController.java @@ -4,6 +4,7 @@ package com.fruitshorts.admin.controller; import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.admin.service.AppSessionSystemService; import com.fruitshorts.admin.vo.AppUserWatchHistoryVO; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; @@ -13,6 +14,7 @@ import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.playback.dal.entity.WatchHistory; +import com.fruitshorts.user.dal.entity.AppUserSession; import com.fruitshorts.playback.service.WatchProgressService; import com.fruitshorts.user.dal.entity.AppUserBanRecord; import com.fruitshorts.user.service.AppUserAdminService; @@ -44,6 +46,7 @@ public class AppUserAdminController { private final WatchProgressService watchProgressService; private final DramaService dramaService; private final EpisodeService episodeService; + private final AppSessionSystemService sessionSystem; @Operation(summary = "App 用户分页查询") @GetMapping("/page") @@ -83,11 +86,15 @@ public class AppUserAdminController { Map episodes = episodeService .listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList()) .stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a)); - return Result.ok(rows.stream().map(row -> toVo(row, dramas, episodes)).toList()); + List sessions = sessionSystem.groupByUser(List.of(id)) + .getOrDefault(id, List.of()); + return Result.ok(rows.stream() + .map(row -> toVo(row, dramas, episodes, sessionSystem.match(sessions, row.getDeviceId()))) + .toList()); } private AppUserWatchHistoryVO toVo(WatchHistory row, Map dramas, - Map episodes) { + Map episodes, AppUserSession session) { AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO(); vo.setId(row.getId()); vo.setDramaId(row.getDramaId()); @@ -100,6 +107,10 @@ public class AppUserAdminController { long position = row.getPositionMs() == null ? 0L : row.getPositionMs(); long duration = row.getDurationMs() == null ? 0L : row.getDurationMs(); vo.setFinished(duration > 0 && position + 1000L >= duration); + if (session != null) { + vo.setPlatform(session.getPlatform()); + vo.setOsVersion(session.getOsVersion()); + } Drama drama = dramas.get(row.getDramaId()); if (drama != null) { vo.setDramaTitle(drama.getTitle()); 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 new file mode 100644 index 0000000..4389a39 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java @@ -0,0 +1,62 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.user.dal.entity.AppUserSession; +import com.fruitshorts.user.dal.mapper.AppUserSessionMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 按用户最近会话解析系统。同一设备优先,否则用仍有效的最近一次登录。 + */ +@Service +@RequiredArgsConstructor +public class AppSessionSystemService { + + private final AppUserSessionMapper sessionMapper; + + /** 按用户分组,组内按最近活跃倒序。 */ + public Map> groupByUser(Collection userIds) { + Map> grouped = new HashMap<>(); + if (userIds == null || userIds.isEmpty()) { + return grouped; + } + List sessions = sessionMapper.selectList(Wrappers.lambdaQuery() + .in(AppUserSession::getUserId, userIds) + .orderByDesc(AppUserSession::getLastActiveAt) + .orderByDesc(AppUserSession::getId)); + for (AppUserSession session : sessions) { + grouped.computeIfAbsent(session.getUserId(), key -> new ArrayList<>()).add(session); + } + return grouped; + } + + /** 观看记录优先匹配同一设备,其次取未吊销会话。 */ + public AppUserSession match(List sessions, String deviceId) { + if (sessions == null || sessions.isEmpty()) { + return null; + } + if (StringUtils.hasText(deviceId)) { + for (AppUserSession session : sessions) { + if (deviceId.equals(session.getDeviceId())) { + return session; + } + } + } + for (AppUserSession session : sessions) { + if (session.getRevokedAt() == null) { + return session; + } + } + return sessions.get(0); + } +} 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 15b2baa..ca5fe75 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 @@ -17,6 +17,7 @@ import com.fruitshorts.content.enums.ContentStatus; import com.fruitshorts.playback.dal.entity.WatchHistory; import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.entity.AppUserSession; import com.fruitshorts.user.dal.mapper.AppUserMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -46,6 +47,7 @@ public class WatchHistoryAdminService { private final AppUserMapper appUserMapper; private final DramaMapper dramaMapper; private final DramaEpisodeMapper dramaEpisodeMapper; + private final AppSessionSystemService sessionSystem; /** 按后台筛选条件分页查询观看记录。 */ public PageResult page(WatchHistoryQueryReqVO req) { @@ -125,10 +127,13 @@ public class WatchHistoryAdminService { Map users = loadUsers(rows); Map dramas = loadDramas(rows); Map episodes = loadEpisodes(rows); + Map> sessions = sessionSystem.groupByUser( + rows.stream().map(WatchHistory::getUserId).distinct().toList()); List list = new ArrayList<>(rows.size()); for (WatchHistory row : rows) { list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()), - episodes.get(row.getEpisodeId()))); + episodes.get(row.getEpisodeId()), + sessionSystem.match(sessions.get(row.getUserId()), row.getDeviceId()))); } return list; } @@ -161,7 +166,7 @@ public class WatchHistoryAdminService { } private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama, - DramaEpisode episode) { + DramaEpisode episode, AppUserSession session) { AdminWatchHistoryVO vo = new AdminWatchHistoryVO(); vo.setId(row.getId()); vo.setUserId(row.getUserId()); @@ -172,6 +177,10 @@ public class WatchHistoryAdminService { vo.setSource(row.getSource()); vo.setDeviceId(row.getDeviceId()); vo.setWatchedAt(row.getWatchedAt()); + if (session != null) { + vo.setPlatform(session.getPlatform()); + vo.setOsVersion(session.getOsVersion()); + } long position = row.getPositionMs() == null ? 0L : row.getPositionMs(); long duration = row.getDurationMs() == null ? 0L : row.getDurationMs(); vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration); diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java index e6140d2..079126c 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java @@ -29,5 +29,9 @@ public class AdminWatchHistoryVO { private Boolean finished; private String source; private String deviceId; + /** ANDROID / IOS / WEB,来自该次设备的登录会话。 */ + private String platform; + /** 系统版本,例如 Android 16。 */ + private String osVersion; private LocalDateTime watchedAt; } diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AppUserWatchHistoryVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AppUserWatchHistoryVO.java index 7bd5557..237db95 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AppUserWatchHistoryVO.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AppUserWatchHistoryVO.java @@ -28,5 +28,9 @@ public class AppUserWatchHistoryVO { private Boolean finished; private String source; private String deviceId; + /** ANDROID / IOS / WEB,来自该次设备的登录会话。 */ + private String platform; + /** 系统版本。 */ + private String osVersion; private LocalDateTime watchedAt; } diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/AppSessionSystemServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/AppSessionSystemServiceTest.java new file mode 100644 index 0000000..c913f55 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/AppSessionSystemServiceTest.java @@ -0,0 +1,48 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.service; + +import com.fruitshorts.user.dal.entity.AppUserSession; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 观看记录匹配登录系统。 */ +class AppSessionSystemServiceTest { + + private final AppSessionSystemService service = new AppSessionSystemService(null); + + @Test + void should_use_same_device_session_when_watch_history_has_device() { + AppUserSession older = session("device-1", "ANDROID", "14", null); + AppUserSession newer = session("device-2", "IOS", "18", null); + + AppUserSession matched = service.match(List.of(newer, older), "device-1"); + + assertThat(matched.getPlatform()).isEqualTo("ANDROID"); + assertThat(matched.getOsVersion()).isEqualTo("14"); + } + + @Test + void should_use_active_session_when_device_does_not_match() { + AppUserSession revoked = session("device-9", "WEB", "1", LocalDateTime.now()); + AppUserSession active = session("device-2", "IOS", "18", null); + + AppUserSession matched = service.match(List.of(revoked, active), "missing"); + + assertThat(matched.getPlatform()).isEqualTo("IOS"); + } + + private AppUserSession session(String deviceId, String platform, String osVersion, + LocalDateTime revokedAt) { + AppUserSession session = new AppUserSession(); + session.setDeviceId(deviceId); + session.setPlatform(platform); + session.setOsVersion(osVersion); + session.setRevokedAt(revokedAt); + return session; + } +} 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 e5a2c5f..ace401d 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 @@ -58,7 +58,7 @@ class WatchHistoryAdminServiceTest { dramaMapper = mock(DramaMapper.class); dramaEpisodeMapper = mock(DramaEpisodeMapper.class); service = new WatchHistoryAdminService(watchHistoryMapper, appUserMapper, dramaMapper, - dramaEpisodeMapper); + dramaEpisodeMapper, mock(AppSessionSystemService.class)); } @Test diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java index c514761..5a381fd 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java @@ -68,7 +68,7 @@ public class AppAuthController { @Operation(summary = "刷新令牌(旧 refresh token 立即失效)") @PostMapping("/refresh") public Result refresh(@Valid @RequestBody RefreshReqVO req) { - return Result.ok(authService.refresh(req.getRefreshToken())); + return Result.ok(authService.refresh(req.getRefreshToken(), req.getPlatform(), req.getOsVersion())); } /** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */ diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java index c6ccaf9..ac845dd 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java @@ -294,6 +294,12 @@ public class AppAuthService { /** 刷新令牌:轮换 refresh token,旧的立即失效。 */ @Transactional(rollbackFor = Exception.class) public TokenRespVO refresh(String refreshToken) { + return refresh(refreshToken, null, null); + } + + /** 刷新令牌,并在客户端带上系统信息时更新最近一次使用的系统。 */ + @Transactional(rollbackFor = Exception.class) + public TokenRespVO refresh(String refreshToken, String platform, String osVersion) { AppUserSession session = sessionMapper.selectOne(Wrappers.lambdaQuery() .eq(AppUserSession::getRefreshTokenHash, hasher.sha256(refreshToken)) .last("limit 1")); @@ -313,6 +319,12 @@ public class AppAuthService { String nextRefreshToken = hasher.newRefreshToken(); session.setRefreshTokenHash(hasher.sha256(nextRefreshToken)); + if (StringUtils.hasText(platform)) { + session.setPlatform(platform); + } + if (StringUtils.hasText(osVersion)) { + session.setOsVersion(osVersion); + } session.setLastActiveAt(now); session.setExpireAt(now.plus(tokenProvider.appRefreshTtl())); sessionMapper.updateById(session); diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserAdminService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserAdminService.java index 158f031..8ebd681 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserAdminService.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserAdminService.java @@ -11,9 +11,11 @@ import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.user.dal.entity.AppUser; import com.fruitshorts.user.dal.entity.AppUserBanRecord; import com.fruitshorts.user.dal.entity.AppUserCredential; +import com.fruitshorts.user.dal.entity.AppUserSession; import com.fruitshorts.user.dal.mapper.AppUserBanRecordMapper; import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper; import com.fruitshorts.user.dal.mapper.AppUserMapper; +import com.fruitshorts.user.dal.mapper.AppUserSessionMapper; import com.fruitshorts.user.enums.AppUserStatus; import com.fruitshorts.user.vo.AppUserQueryReqVO; import com.fruitshorts.user.vo.AppUserAdminRespVO; @@ -26,7 +28,9 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * 后台用户管理:检索、详情、状态处置。 @@ -40,6 +44,7 @@ public class AppUserAdminService { private final AppUserMapper appUserMapper; private final AppUserCredentialMapper credentialMapper; private final AppUserBanRecordMapper banRecordMapper; + private final AppUserSessionMapper sessionMapper; private final AppAuthService authService; public PageResult page(AppUserQueryReqVO req) { @@ -74,7 +79,9 @@ public class AppUserAdminService { req.getRegisterSource()) .orderByDesc(AppUser::getId)); } - return new PageResult<>(result.getTotal(), toRespList(result.getRecords())); + List list = toRespList(result.getRecords()); + fillLatestSystem(list); + return new PageResult<>(result.getTotal(), list); } public AppUserAdminRespVO detail(Long id) { @@ -82,7 +89,9 @@ public class AppUserAdminService { if (user == null) { throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在"); } - return toAdminResp(user); + AppUserAdminRespVO vo = toAdminResp(user); + fillLatestSystem(List.of(vo)); + return vo; } /** 修改账号状态。封禁/注销会立即踢下线。 */ @@ -154,4 +163,37 @@ public class AppUserAdminService { vo.setId(user.getId()); return vo; } + + /** 用最近一次未吊销会话上的系统信息,供后台用户列表展示。 */ + private void fillLatestSystem(List list) { + if (list.isEmpty()) { + return; + } + List ids = new ArrayList<>(list.size()); + for (AppUserAdminRespVO vo : list) { + if (vo.getId() != null) { + ids.add(vo.getId()); + } + } + if (ids.isEmpty()) { + return; + } + List sessions = sessionMapper.selectList(Wrappers.lambdaQuery() + .in(AppUserSession::getUserId, ids) + .isNull(AppUserSession::getRevokedAt) + .orderByDesc(AppUserSession::getLastActiveAt) + .orderByDesc(AppUserSession::getId)); + Map latest = new HashMap<>(); + for (AppUserSession session : sessions) { + latest.putIfAbsent(session.getUserId(), session); + } + for (AppUserAdminRespVO vo : list) { + AppUserSession session = latest.get(vo.getId()); + if (session == null) { + continue; + } + vo.setPlatform(session.getPlatform()); + vo.setOsVersion(session.getOsVersion()); + } + } } diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserAdminRespVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserAdminRespVO.java index d38f535..f4ebd9a 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserAdminRespVO.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserAdminRespVO.java @@ -16,4 +16,10 @@ import lombok.EqualsAndHashCode; public class AppUserAdminRespVO extends AppUserRespVO { private Long id; + + /** 最近一次登录的系统:ANDROID / IOS / WEB。 */ + private String platform; + + /** 最近一次登录的系统版本。 */ + private String osVersion; } diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RefreshReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RefreshReqVO.java index 90089b0..a5daa2d 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RefreshReqVO.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RefreshReqVO.java @@ -11,4 +11,10 @@ public class RefreshReqVO { @NotBlank(message = "缺少 refreshToken") private String refreshToken; + + /** 客户端系统:ANDROID / IOS / WEB。刷新时顺带更新,避免必须重新登录才有记录。 */ + private String platform; + + /** 系统版本,例如 Android 16。 */ + private String osVersion; }