chore(merge): 合并设备画像与后台系统展示
This commit is contained in:
commit
2dadca6288
13 files changed
+362
-9
No files matched your search
+23
-1
@@ -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()) {
|
||||
|
||||
+36
@@ -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();
|
||||
}
|
||||
|
||||
+22
-2
@@ -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) {
|
||||
|
||||
+27
-2
@@ -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());
|
||||
|
||||
+13
-3
@@ -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;
|
||||
|
||||
+1
-1
@@ -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"));
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user