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 4389a39..ec7b7dd 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 @@ -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> groupByUser(Collection userIds) { @@ -40,6 +44,24 @@ public class AppSessionSystemService { return grouped; } + /** 按 device_id 批量取设备画像,未建档的设备不出现在结果里。 */ + public Map deviceProfiles(Collection deviceIds) { + Map result = new HashMap<>(); + if (deviceIds == null || deviceIds.isEmpty()) { + return result; + } + List keys = deviceIds.stream() + .filter(StringUtils::hasText).map(String::trim).distinct().toList(); + if (keys.isEmpty()) { + return result; + } + for (AppDeviceProfile profile : profileMapper.selectList( + Wrappers.lambdaQuery().in(AppDeviceProfile::getDeviceId, keys))) { + result.put(profile.getDeviceId(), profile); + } + return result; + } + /** 观看记录优先匹配同一设备,其次取未吊销会话。 */ 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 3e4e97a..d4421dc 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 @@ -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 Page page(EngagementQueryReqVO req) { @@ -96,6 +103,35 @@ public class EngagementAdminLookup { .collect(Collectors.toMap(Drama::getId, Function.identity())); } + /** 按 device_id 批量读取设备画像;游客也有记录,没建档的不返回。 */ + public Map deviceProfiles(Collection deviceIds) { + List keys = deviceIds.stream().filter(StringUtils::hasText) + .map(String::trim).distinct().toList(); + return keys.isEmpty() ? Map.of() : deviceProfileMapper.selectList( + Wrappers.lambdaQuery().in(AppDeviceProfile::getDeviceId, keys)) + .stream().collect(Collectors.toMap(AppDeviceProfile::getDeviceId, + Function.identity(), (first, second) -> first)); + } + + /** + * 每个用户取最近一次登录会话。 + * 已登录用户的互动行 device_id 为空(写入时会清空),只能退回会话取系统。 + */ + public Map latestSessions(Collection userIds) { + List keys = keys(userIds); + if (keys.isEmpty()) { + return Map.of(); + } + Map result = new HashMap<>(); + for (AppUserSession session : sessionMapper.selectList(Wrappers.lambdaQuery() + .in(AppUserSession::getUserId, keys) + .orderByDesc(AppUserSession::getLastActiveAt) + .orderByDesc(AppUserSession::getId))) { + result.putIfAbsent(session.getUserId(), session); + } + return result; + } + 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 484b35c..0116f5d 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 @@ -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 users = lookup.users(rows.stream().map(UserFavorite::getUserId).toList()); Map dramas = lookup.dramas(rows.stream().map(UserFavorite::getDramaId).toList()); + Map profiles = + lookup.deviceProfiles(rows.stream().map(UserFavorite::getDeviceId).toList()); + Map 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) { 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 dc2291d..56281d9 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 @@ -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 dramas = lookup.dramas(dramaIds); - return rows.stream().map(row -> toVo(row, users, comments, episodes, dramas)).toList(); + Map profiles = + lookup.deviceProfiles(rows.stream().map(ContentLike::getDeviceId).toList()); + Map 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 targetIds(List rows, String type) { @@ -130,12 +138,29 @@ public class LikeAdminService { } private AdminLikeVO toVo(ContentLike row, Map users, Map comments, - Map episodes, Map dramas) { + Map episodes, Map dramas, + Map profiles, + Map 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()); 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 ca5fe75..288c917 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 @@ -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 episodes = loadEpisodes(rows); Map> sessions = sessionSystem.groupByUser( rows.stream().map(WatchHistory::getUserId).distinct().toList()); + Map profiles = sessionSystem.deviceProfiles( + rows.stream().map(WatchHistory::getDeviceId).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()), - 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()); } diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminFavoriteVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminFavoriteVO.java index e0a72d1..d2fcbff 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminFavoriteVO.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminFavoriteVO.java @@ -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; diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminLikeVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminLikeVO.java index ec93589..ba55b72 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminLikeVO.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminLikeVO.java @@ -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; 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 index c913f55..ab82c50 100644 --- 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 @@ -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() { diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/web/DeviceProfileFilter.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/web/DeviceProfileFilter.java new file mode 100644 index 0000000..e1adfca --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/web/DeviceProfileFilter.java @@ -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; + +/** + * 收集客户端上报的设备画像(系统 / 机型 / 版本)。 + * + *

客户端在请求头带 `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 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")); + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppDeviceProfile.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppDeviceProfile.java new file mode 100644 index 0000000..3db5861 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppDeviceProfile.java @@ -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; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppDeviceProfileMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppDeviceProfileMapper.java new file mode 100644 index 0000000..7490d0d --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppDeviceProfileMapper.java @@ -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 { + + /** + * 按 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); +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/DeviceProfileService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/DeviceProfileService.java new file mode 100644 index 0000000..40ab409 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/DeviceProfileService.java @@ -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); + } +} diff --git a/sql/migrate-k111-device-profile.sql b/sql/migrate-k111-device-profile.sql new file mode 100644 index 0000000..6fca24c --- /dev/null +++ b/sql/migrate-k111-device-profile.sql @@ -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;