feat(user): 展示用户最近使用的系统
This commit is contained in:
1 parent
45e3d84f59
commit
ab58641bbe
12 files changed
+212
-8
No files matched your search
+13
-2
@@ -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<Long, DramaEpisode> 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<AppUserSession> 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<Long, Drama> dramas,
|
||||
Map<Long, DramaEpisode> episodes) {
|
||||
Map<Long, DramaEpisode> 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());
|
||||
|
||||
+62
@@ -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<Long, List<AppUserSession>> groupByUser(Collection<Long> userIds) {
|
||||
Map<Long, List<AppUserSession>> grouped = new HashMap<>();
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return grouped;
|
||||
}
|
||||
List<AppUserSession> sessions = sessionMapper.selectList(Wrappers.<AppUserSession>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<AppUserSession> 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);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
|
||||
@@ -125,10 +127,13 @@ public class WatchHistoryAdminService {
|
||||
Map<Long, AppUser> users = loadUsers(rows);
|
||||
Map<Long, Drama> dramas = loadDramas(rows);
|
||||
Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
|
||||
Map<Long, List<AppUserSession>> sessions = sessionSystem.groupByUser(
|
||||
rows.stream().map(WatchHistory::getUserId).distinct().toList());
|
||||
List<AdminWatchHistoryVO> 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+4
@@ -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;
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ public class AppAuthController {
|
||||
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
|
||||
@PostMapping("/refresh")
|
||||
public Result<TokenRespVO> 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 时吊销全部。 */
|
||||
|
||||
+12
@@ -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.<AppUserSession>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);
|
||||
|
||||
+44
-2
@@ -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<AppUserAdminRespVO> page(AppUserQueryReqVO req) {
|
||||
@@ -74,7 +79,9 @@ public class AppUserAdminService {
|
||||
req.getRegisterSource())
|
||||
.orderByDesc(AppUser::getId));
|
||||
}
|
||||
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
|
||||
List<AppUserAdminRespVO> 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<AppUserAdminRespVO> list) {
|
||||
if (list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> ids = new ArrayList<>(list.size());
|
||||
for (AppUserAdminRespVO vo : list) {
|
||||
if (vo.getId() != null) {
|
||||
ids.add(vo.getId());
|
||||
}
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<AppUserSession> sessions = sessionMapper.selectList(Wrappers.<AppUserSession>lambdaQuery()
|
||||
.in(AppUserSession::getUserId, ids)
|
||||
.isNull(AppUserSession::getRevokedAt)
|
||||
.orderByDesc(AppUserSession::getLastActiveAt)
|
||||
.orderByDesc(AppUserSession::getId));
|
||||
Map<Long, AppUserSession> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,10 @@ import lombok.EqualsAndHashCode;
|
||||
public class AppUserAdminRespVO extends AppUserRespVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
/** 最近一次登录的系统:ANDROID / IOS / WEB。 */
|
||||
private String platform;
|
||||
|
||||
/** 最近一次登录的系统版本。 */
|
||||
private String osVersion;
|
||||
}
|
||||
@@ -11,4 +11,10 @@ public class RefreshReqVO {
|
||||
|
||||
@NotBlank(message = "缺少 refreshToken")
|
||||
private String refreshToken;
|
||||
|
||||
/** 客户端系统:ANDROID / IOS / WEB。刷新时顺带更新,避免必须重新登录才有记录。 */
|
||||
private String platform;
|
||||
|
||||
/** 系统版本,例如 Android 16。 */
|
||||
private String osVersion;
|
||||
}
|
||||
Reference in new issue
Block a user