feat(device): 按 device_id 记录客户端系统与机型

- 新增 app_device_profile 表与 upsert,匿名请求同样建档
- app-api 设备画像过滤器,同设备 60 秒节流写一次
- 收藏/点赞/观看列表优先用设备画像,缺失回退最近会话
This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-27 13:53:47 +08:00
1 parent d9a1670a72
commit 2cf8f9a1c3
13 files changed
+362 -9

No files matched your search

@@ -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);
}
}