chore(merge): 合并设备画像与后台系统展示

This commit is contained in:
jianghaifeng authored and trae-solo-agent committed 2026-10-27 13:53:49 +08:00
commit 2dadca6288
13 files changed
+362 -9

No files matched your search

@@ -3,7 +3,9 @@
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.user.dal.entity.AppDeviceProfile;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppDeviceProfileMapper;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -16,13 +18,15 @@ import java.util.List;
import java.util.Map;
/**
* 按用户最近会话解析系统。同一设备优先,否则用仍有效的最近一次登录。
* 按设备画像解析系统,缺画像时回退到用户最近会话。
* 设备画像不依赖登录态,游客行为也能显示系统。
*/
@Service
@RequiredArgsConstructor
public class AppSessionSystemService {
private final AppUserSessionMapper sessionMapper;
private final AppDeviceProfileMapper profileMapper;
/** 按用户分组,组内按最近活跃倒序。 */
public Map<Long, List<AppUserSession>> groupByUser(Collection<Long> userIds) {
@@ -40,6 +44,24 @@ public class AppSessionSystemService {
return grouped;
}
/** 按 device_id 批量取设备画像,未建档的设备不出现在结果里。 */
public Map<String, AppDeviceProfile> deviceProfiles(Collection<String> deviceIds) {
Map<String, AppDeviceProfile> result = new HashMap<>();
if (deviceIds == null || deviceIds.isEmpty()) {
return result;
}
List<String> keys = deviceIds.stream()
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
if (keys.isEmpty()) {
return result;
}
for (AppDeviceProfile profile : profileMapper.selectList(
Wrappers.<AppDeviceProfile>lambdaQuery().in(AppDeviceProfile::getDeviceId, keys))) {
result.put(profile.getDeviceId(), profile);
}
return result;
}
/** 观看记录优先匹配同一设备,其次取未吊销会话。 */
public AppUserSession match(List<AppUserSession> sessions, String deviceId) {
if (sessions == null || sessions.isEmpty()) {
@@ -9,12 +9,17 @@ import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.user.dal.entity.AppDeviceProfile;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppDeviceProfileMapper;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -35,6 +40,8 @@ public class EngagementAdminLookup {
private static final int LOOKUP_PROBE = MAX_LOOKUP_SIZE + 1;
private final AppUserMapper userMapper;
private final DramaMapper dramaMapper;
private final AppDeviceProfileMapper deviceProfileMapper;
private final AppUserSessionMapper sessionMapper;
/** 规范化页码和页大小。 */
public <T> Page<T> page(EngagementQueryReqVO req) {
@@ -96,6 +103,35 @@ public class EngagementAdminLookup {
.collect(Collectors.toMap(Drama::getId, Function.identity()));
}
/** 按 device_id 批量读取设备画像;游客也有记录,没建档的不返回。 */
public Map<String, AppDeviceProfile> deviceProfiles(Collection<String> deviceIds) {
List<String> keys = deviceIds.stream().filter(StringUtils::hasText)
.map(String::trim).distinct().toList();
return keys.isEmpty() ? Map.of() : deviceProfileMapper.selectList(
Wrappers.<AppDeviceProfile>lambdaQuery().in(AppDeviceProfile::getDeviceId, keys))
.stream().collect(Collectors.toMap(AppDeviceProfile::getDeviceId,
Function.identity(), (first, second) -> first));
}
/**
* 每个用户取最近一次登录会话。
* 已登录用户的互动行 device_id 为空(写入时会清空),只能退回会话取系统。
*/
public Map<Long, AppUserSession> latestSessions(Collection<Long> userIds) {
List<Long> keys = keys(userIds);
if (keys.isEmpty()) {
return Map.of();
}
Map<Long, AppUserSession> result = new HashMap<>();
for (AppUserSession session : sessionMapper.selectList(Wrappers.<AppUserSession>lambdaQuery()
.in(AppUserSession::getUserId, keys)
.orderByDesc(AppUserSession::getLastActiveAt)
.orderByDesc(AppUserSession::getId))) {
result.putIfAbsent(session.getUserId(), session);
}
return result;
}
private List<Long> keys(Collection<Long> ids) {
return ids.stream().filter(Objects::nonNull).distinct().toList();
}
@@ -10,7 +10,9 @@ import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.playback.dal.entity.UserFavorite;
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
import com.fruitshorts.user.dal.entity.AppDeviceProfile;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -53,17 +55,35 @@ public class FavoriteAdminService {
}
Map<Long, AppUser> users = lookup.users(rows.stream().map(UserFavorite::getUserId).toList());
Map<Long, Drama> dramas = lookup.dramas(rows.stream().map(UserFavorite::getDramaId).toList());
Map<String, AppDeviceProfile> profiles =
lookup.deviceProfiles(rows.stream().map(UserFavorite::getDeviceId).toList());
Map<Long, AppUserSession> sessions =
lookup.latestSessions(rows.stream().map(UserFavorite::getUserId).toList());
return rows.stream().map(row -> toVo(row, row.getUserId() == null ? null : users.get(row.getUserId()),
row.getDramaId() == null ? null : dramas.get(row.getDramaId())))
row.getDramaId() == null ? null : dramas.get(row.getDramaId()),
row.getDeviceId() == null ? null : profiles.get(row.getDeviceId()),
row.getUserId() == null ? null : sessions.get(row.getUserId())))
.toList();
}
private AdminFavoriteVO toVo(UserFavorite row, AppUser user, Drama drama) {
private AdminFavoriteVO toVo(UserFavorite row, AppUser user, Drama drama,
AppDeviceProfile profile, AppUserSession session) {
AdminFavoriteVO vo = new AdminFavoriteVO();
vo.setId(row.getId());
vo.setUserId(row.getUserId());
vo.setDeviceId(row.getDeviceId());
vo.setAnonymous(row.getUserId() == null);
if (profile != null) {
vo.setPlatform(profile.getPlatform());
vo.setOsVersion(profile.getOsVersion());
vo.setDeviceModel(profile.getDeviceModel());
}
// 已登录用户的互动行不带 device_id,设备画像查不到时退回最近一次登录会话
if (!StringUtils.hasText(vo.getPlatform()) && session != null) {
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
vo.setDeviceModel(session.getDeviceModel());
}
vo.setDramaId(row.getDramaId());
vo.setCreatedAt(row.getCreatedAt());
if (user != null) {
@@ -14,7 +14,9 @@ import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.entity.ContentLike;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
import com.fruitshorts.user.dal.entity.AppDeviceProfile;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -110,7 +112,13 @@ public class LikeAdminService {
comments.values().stream().map(ContentComment::getDramaId).filter(Objects::nonNull).forEach(dramaIds::add);
episodes.values().stream().map(DramaEpisode::getDramaId).filter(Objects::nonNull).forEach(dramaIds::add);
Map<Long, Drama> dramas = lookup.dramas(dramaIds);
return rows.stream().map(row -> toVo(row, users, comments, episodes, dramas)).toList();
Map<String, AppDeviceProfile> profiles =
lookup.deviceProfiles(rows.stream().map(ContentLike::getDeviceId).toList());
Map<Long, AppUserSession> sessions =
lookup.latestSessions(rows.stream().map(ContentLike::getUserId).toList());
return rows.stream()
.map(row -> toVo(row, users, comments, episodes, dramas, profiles, sessions))
.toList();
}
private List<Long> targetIds(List<ContentLike> rows, String type) {
@@ -130,12 +138,29 @@ public class LikeAdminService {
}
private AdminLikeVO toVo(ContentLike row, Map<Long, AppUser> users, Map<Long, ContentComment> comments,
Map<Long, DramaEpisode> episodes, Map<Long, Drama> dramas) {
Map<Long, DramaEpisode> episodes, Map<Long, Drama> dramas,
Map<String, AppDeviceProfile> profiles,
Map<Long, AppUserSession> sessions) {
AdminLikeVO vo = new AdminLikeVO();
vo.setId(row.getId());
vo.setUserId(row.getUserId());
vo.setDeviceId(row.getDeviceId());
vo.setAnonymous(row.getUserId() == null);
AppDeviceProfile profile = row.getDeviceId() == null ? null : profiles.get(row.getDeviceId());
if (profile != null) {
vo.setPlatform(profile.getPlatform());
vo.setOsVersion(profile.getOsVersion());
vo.setDeviceModel(profile.getDeviceModel());
}
// 已登录用户的互动行不带 device_id,设备画像查不到时退回最近一次登录会话
if (!StringUtils.hasText(vo.getPlatform()) && row.getUserId() != null) {
AppUserSession session = sessions.get(row.getUserId());
if (session != null) {
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
vo.setDeviceModel(session.getDeviceModel());
}
}
vo.setTargetType(row.getTargetType());
vo.setTargetId(row.getTargetId());
vo.setCreatedAt(row.getCreatedAt());
@@ -16,6 +16,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
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.AppDeviceProfile;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
@@ -129,11 +130,14 @@ public class WatchHistoryAdminService {
Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
Map<Long, List<AppUserSession>> sessions = sessionSystem.groupByUser(
rows.stream().map(WatchHistory::getUserId).distinct().toList());
Map<String, AppDeviceProfile> profiles = sessionSystem.deviceProfiles(
rows.stream().map(WatchHistory::getDeviceId).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()),
sessionSystem.match(sessions.get(row.getUserId()), row.getDeviceId())));
sessionSystem.match(sessions.get(row.getUserId()), row.getDeviceId()),
profiles.get(row.getDeviceId())));
}
return list;
}
@@ -166,7 +170,8 @@ public class WatchHistoryAdminService {
}
private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama,
DramaEpisode episode, AppUserSession session) {
DramaEpisode episode, AppUserSession session,
AppDeviceProfile profile) {
AdminWatchHistoryVO vo = new AdminWatchHistoryVO();
vo.setId(row.getId());
vo.setUserId(row.getUserId());
@@ -177,7 +182,12 @@ public class WatchHistoryAdminService {
vo.setSource(row.getSource());
vo.setDeviceId(row.getDeviceId());
vo.setWatchedAt(row.getWatchedAt());
if (session != null) {
// 设备画像不依赖登录态,优先;没有画像(老数据)再回退到登录会话
if (profile != null && (StringUtils.hasText(profile.getPlatform())
|| StringUtils.hasText(profile.getOsVersion()))) {
vo.setPlatform(profile.getPlatform());
vo.setOsVersion(profile.getOsVersion());
} else if (session != null) {
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
}
@@ -13,6 +13,10 @@ public class AdminFavoriteVO {
private String uid;
private String nickname;
private String deviceId;
/** 客户端系统:ANDROID / IOS / WEB,来自设备画像,游客同样有值。 */
private String platform;
private String osVersion;
private String deviceModel;
private Boolean anonymous;
private Long dramaId;
private String dramaTitle;
@@ -13,6 +13,10 @@ public class AdminLikeVO {
private String uid;
private String nickname;
private String deviceId;
/** 客户端系统:ANDROID / IOS / WEB,来自设备画像,游客同样有值。 */
private String platform;
private String osVersion;
private String deviceModel;
private Boolean anonymous;
private Long dramaId;
private String dramaTitle;
@@ -13,7 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat;
/** 观看记录匹配登录系统。 */
class AppSessionSystemServiceTest {
private final AppSessionSystemService service = new AppSessionSystemService(null);
private final AppSessionSystemService service = new AppSessionSystemService(null, null);
@Test
void should_use_same_device_session_when_watch_history_has_device() {
@@ -0,0 +1,70 @@
// Author: fruit.com.cn
package com.fruitshorts.app.web;
import com.fruitshorts.user.service.DeviceProfileService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 收集客户端上报的设备画像(系统 / 机型 / 版本)。
*
* <p>客户端在请求头带 `X-Client-Platform`、`X-Client-Os`、`X-Client-Model`、`X-App-Version`,
* 这里按 device_id 建档,后台列表据此展示系统;游客请求同样入库,这是与旧会话方案的关键差异。
* 同一设备 60 秒内只写一次,避免每个请求都打库。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceProfileFilter extends OncePerRequestFilter {
private static final long THROTTLE_MS = 60_000L;
private static final int MAX_TRACKED_DEVICES = 20_000;
private final DeviceProfileService deviceProfileService;
private final Map<String, Long> lastWriteAt = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
collect(request);
} catch (RuntimeException ex) {
// 画像只服务于后台展示,任何异常都不能影响业务请求
log.warn("device-profile collect failed reason={}", ex.getMessage());
}
filterChain.doFilter(request, response);
}
private void collect(HttpServletRequest request) {
String deviceId = request.getHeader("X-Device-Id");
if (!StringUtils.hasText(deviceId)) {
return;
}
long now = System.currentTimeMillis();
Long last = lastWriteAt.get(deviceId);
if (last != null && now - last < THROTTLE_MS) {
return;
}
if (lastWriteAt.size() > MAX_TRACKED_DEVICES) {
lastWriteAt.clear();
}
lastWriteAt.put(deviceId, now);
deviceProfileService.touch(deviceId,
request.getHeader("X-Client-Platform"),
request.getHeader("X-Client-Os"),
request.getHeader("X-Client-Model"),
request.getHeader("X-App-Version"));
}
}
@@ -0,0 +1,35 @@
// Author: fruit.com.cn
package com.fruitshorts.user.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 设备画像:按 device_id 记录客户端系统与机型。
* 与会话不同,它不依赖用户身份,游客请求也会建档。
*/
@Data
@TableName("app_device_profile")
public class AppDeviceProfile {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String deviceId;
private String platform;
private String osVersion;
private String deviceModel;
private String appVersion;
private LocalDateTime firstSeenAt;
private LocalDateTime lastSeenAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,34 @@
// Author: fruit.com.cn
package com.fruitshorts.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.user.dal.entity.AppDeviceProfile;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
public interface AppDeviceProfileMapper extends BaseMapper<AppDeviceProfile> {
/**
* 按 device_id 建档或刷新。
* 空字段不覆盖已有值:老版本客户端只带 deviceId,不能把已记录的系统抹掉。
*/
@Insert("""
INSERT INTO app_device_profile (id, device_id, platform, os_version, device_model, app_version,
first_seen_at, last_seen_at, created_at, updated_at, deleted)
VALUES (#{p.id}, #{p.deviceId}, #{p.platform}, #{p.osVersion}, #{p.deviceModel}, #{p.appVersion},
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)
ON CONFLICT (device_id) DO UPDATE SET
platform = CASE WHEN EXCLUDED.platform <> '' THEN EXCLUDED.platform
ELSE app_device_profile.platform END,
os_version = CASE WHEN EXCLUDED.os_version <> '' THEN EXCLUDED.os_version
ELSE app_device_profile.os_version END,
device_model = CASE WHEN EXCLUDED.device_model <> '' THEN EXCLUDED.device_model
ELSE app_device_profile.device_model END,
app_version = CASE WHEN EXCLUDED.app_version <> '' THEN EXCLUDED.app_version
ELSE app_device_profile.app_version END,
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
""")
int upsert(@Param("p") AppDeviceProfile profile);
}
@@ -0,0 +1,64 @@
// Author: fruit.com.cn
package com.fruitshorts.user.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fruitshorts.user.dal.entity.AppDeviceProfile;
import com.fruitshorts.user.dal.mapper.AppDeviceProfileMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/** 设备画像写入:按 device_id 建档,匿名请求也记录。 */
@Slf4j
@Service
@RequiredArgsConstructor
public class DeviceProfileService {
private static final int DEVICE_ID_MAX = 128;
private static final int PLATFORM_MAX = 16;
private static final int OS_VERSION_MAX = 64;
private static final int DEVICE_MODEL_MAX = 128;
private static final int APP_VERSION_MAX = 32;
private final AppDeviceProfileMapper mapper;
/**
* 记录或刷新设备画像。header 全部为空时直接跳过,
* 避免老版本客户端只带 deviceId 时产生无意义的空记录。
*/
public void touch(String deviceId, String platform, String osVersion, String deviceModel, String appVersion) {
if (!StringUtils.hasText(deviceId)) {
return;
}
String cleanPlatform = sanitize(platform, PLATFORM_MAX).toUpperCase();
String cleanOs = sanitize(osVersion, OS_VERSION_MAX);
String cleanModel = sanitize(deviceModel, DEVICE_MODEL_MAX);
String cleanApp = sanitize(appVersion, APP_VERSION_MAX);
if (cleanPlatform.isEmpty() && cleanOs.isEmpty() && cleanModel.isEmpty() && cleanApp.isEmpty()) {
return;
}
AppDeviceProfile profile = new AppDeviceProfile();
profile.setId(IdWorker.getId());
profile.setDeviceId(sanitize(deviceId, DEVICE_ID_MAX));
profile.setPlatform(cleanPlatform);
profile.setOsVersion(cleanOs);
profile.setDeviceModel(cleanModel);
profile.setAppVersion(cleanApp);
try {
mapper.upsert(profile);
} catch (RuntimeException ex) {
// 画像只用于后台展示,写失败不能影响正常业务请求
log.warn("device-profile upsert failed deviceId={} reason={}", profile.getDeviceId(), ex.getMessage());
}
}
private String sanitize(String value, int maxLength) {
if (!StringUtils.hasText(value)) {
return "";
}
String trimmed = value.trim().replaceAll("[\\p{Cntrl}]", "");
return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength);
}
}
+29
View File
@@ -0,0 +1,29 @@
-- Author: fruit.com.cn
-- K111 设备画像:记录每个 device_id 的客户端系统与机型。
-- 之前的系统信息只存在于 app_user_session / app_user_device,两者都依赖已登录账号,
-- 游客行为完全拿不到系统;改成按 device_id 独立建档,匿名请求也能落库。
BEGIN;
CREATE TABLE IF NOT EXISTS app_device_profile (
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
platform VARCHAR(16) NOT NULL DEFAULT '',
os_version VARCHAR(64) NOT NULL DEFAULT '',
device_model VARCHAR(128) NOT NULL DEFAULT '',
app_version VARCHAR(32) NOT NULL DEFAULT '',
first_seen_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
-- 不带 deleted 谓词:写入用的 ON CONFLICT (device_id) 需要能推断到唯一索引;
-- 设备画像不做软删,重复建档走更新。
CREATE UNIQUE INDEX IF NOT EXISTS uk_app_device_profile_device
ON app_device_profile (device_id);
COMMENT ON TABLE app_device_profile IS '设备画像:device_id 维度的客户端系统与机型,匿名请求同样记录';
COMMIT;