feat: add analytics backend

This commit is contained in:
2026-05-17 10:14:08 +08:00
parent 6542912d93
commit 3decff526a
19 changed files with 809 additions and 0 deletions
@@ -43,6 +43,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/auth/sms-code", // 短信验证码接口(免登录)
"/auth/refresh-token", // 刷新token接口
"/auth/resetPassword", // 重置密码接口(免登录)
"/analytics/events/batch", // Analytics event batch endpoint
"/health", // 健康检查接口
"/ws/**", // WebSocket接口
"/swagger-ui/**", // Swagger UI
@@ -0,0 +1,56 @@
package com.emotion.controller;
import com.emotion.common.Result;
import com.emotion.dto.request.analytics.AnalyticsQueryRequest;
import com.emotion.dto.response.analytics.AnalyticsFunnelItem;
import com.emotion.dto.response.analytics.AnalyticsOverviewResponse;
import com.emotion.dto.response.analytics.AnalyticsPreferenceItem;
import com.emotion.dto.response.analytics.AnalyticsTopEventItem;
import com.emotion.dto.response.analytics.AnalyticsTrendItem;
import com.emotion.dto.response.analytics.AnalyticsUserItem;
import com.emotion.service.AnalyticsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin/analytics")
public class AdminAnalyticsController {
@Autowired
private AnalyticsService analyticsService;
@GetMapping("/overview")
public Result<AnalyticsOverviewResponse> overview(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getOverview(request));
}
@GetMapping("/trend")
public Result<List<AnalyticsTrendItem>> trend(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getTrend(request));
}
@GetMapping("/funnel")
public Result<List<AnalyticsFunnelItem>> funnel(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getFunnel(request));
}
@GetMapping("/preferences")
public Result<List<AnalyticsPreferenceItem>> preferences(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getPreferences(request));
}
@GetMapping("/top-events")
public Result<List<AnalyticsTopEventItem>> topEvents(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getTopEvents(request));
}
@GetMapping("/users")
public Result<List<AnalyticsUserItem>> users(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getUsers(request));
}
}
@@ -0,0 +1,26 @@
package com.emotion.controller;
import com.emotion.common.Result;
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
import com.emotion.dto.response.analytics.AnalyticsBatchResponse;
import com.emotion.service.AnalyticsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@RestController
@RequestMapping("/analytics")
public class AnalyticsController {
@Autowired
private AnalyticsService analyticsService;
@PostMapping("/events/batch")
public Result<AnalyticsBatchResponse> batch(@Valid @RequestBody AnalyticsEventBatchRequest request) {
return Result.success(analyticsService.ingestBatch(request));
}
}
@@ -0,0 +1,28 @@
package com.emotion.dto.request.analytics;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.util.List;
import java.util.Map;
@Data
public class AnalyticsEventBatchRequest {
@Size(max = 128)
private String anonymousId;
@NotBlank
@Size(max = 128)
private String sessionId;
private Map<String, Object> deviceInfo;
@Valid
@NotEmpty
@Size(max = 50)
private List<AnalyticsEventRequest> events;
}
@@ -0,0 +1,34 @@
package com.emotion.dto.request.analytics;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.Map;
@Data
public class AnalyticsEventRequest {
@NotBlank
@Size(max = 100)
private String eventName;
@NotBlank
@Size(max = 50)
private String eventType;
@Size(max = 255)
private String pagePath;
@Size(max = 255)
private String referrerPath;
private Map<String, Object> properties;
private Long durationMs;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime occurredAt;
}
@@ -0,0 +1,25 @@
package com.emotion.dto.request.analytics;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.util.List;
@Data
public class AnalyticsQueryRequest {
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
private String granularity;
private List<String> eventNames;
private String eventType;
private Integer limit;
}
@@ -0,0 +1,15 @@
package com.emotion.dto.response.analytics;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsBatchResponse {
private int accepted;
private int rejected;
}
@@ -0,0 +1,17 @@
package com.emotion.dto.response.analytics;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsFunnelItem {
private String eventName;
private String label;
private long users;
private double conversionRate;
}
@@ -0,0 +1,20 @@
package com.emotion.dto.response.analytics;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsOverviewResponse {
private long pv;
private long uv;
private long eventCount;
private long activeUsers;
private long ttsRequests;
private long ttsPlays;
private double avgStayMs;
}
@@ -0,0 +1,17 @@
package com.emotion.dto.response.analytics;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsPreferenceItem {
private String dimension;
private String value;
private long count;
private long users;
}
@@ -0,0 +1,17 @@
package com.emotion.dto.response.analytics;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsTopEventItem {
private String eventName;
private String eventType;
private long count;
private long users;
}
@@ -0,0 +1,17 @@
package com.emotion.dto.response.analytics;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsTrendItem {
private String bucket;
private String eventName;
private long count;
private long users;
}
@@ -0,0 +1,19 @@
package com.emotion.dto.response.analytics;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsUserItem {
private String userId;
private String anonymousId;
private long eventCount;
private LocalDateTime lastActiveTime;
}
@@ -0,0 +1,59 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName(value = "t_analytics_event", autoResultMap = true)
public class AnalyticsEvent extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("anonymous_id")
private String anonymousId;
@TableField("session_id")
private String sessionId;
@TableField("event_name")
private String eventName;
@TableField("event_type")
private String eventType;
@TableField("page_path")
private String pagePath;
@TableField("referrer_path")
private String referrerPath;
@TableField(value = "properties", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> properties;
@TableField(value = "device_info", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> deviceInfo;
@TableField("duration_ms")
private Long durationMs;
@TableField("occurred_at")
private LocalDateTime occurredAt;
@TableField("server_time")
private LocalDateTime serverTime;
}
@@ -0,0 +1,72 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.dto.response.analytics.AnalyticsPreferenceItem;
import com.emotion.dto.response.analytics.AnalyticsTopEventItem;
import com.emotion.dto.response.analytics.AnalyticsTrendItem;
import com.emotion.dto.response.analytics.AnalyticsUserItem;
import com.emotion.entity.AnalyticsEvent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AnalyticsEventMapper extends BaseMapper<AnalyticsEvent> {
@Select("SELECT COUNT(*) FROM t_analytics_event WHERE is_deleted = 0 AND occurred_at >= #{start} AND occurred_at < #{end}")
Long countAll(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
@Select("SELECT COUNT(*) FROM t_analytics_event WHERE is_deleted = 0 AND event_name = #{eventName} AND occurred_at >= #{start} AND occurred_at < #{end}")
Long countEvent(@Param("eventName") String eventName,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
@Select("SELECT COUNT(DISTINCT COALESCE(user_id, anonymous_id)) FROM t_analytics_event WHERE is_deleted = 0 AND occurred_at >= #{start} AND occurred_at < #{end} AND COALESCE(user_id, anonymous_id) IS NOT NULL")
Long countUniqueVisitors(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
@Select("SELECT COUNT(DISTINCT COALESCE(user_id, anonymous_id)) FROM t_analytics_event WHERE is_deleted = 0 AND user_id IS NOT NULL AND occurred_at >= #{start} AND occurred_at < #{end}")
Long countActiveUsers(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
@Select("SELECT COALESCE(AVG(duration_ms), 0) FROM t_analytics_event WHERE is_deleted = 0 AND event_name = 'page_leave' AND duration_ms IS NOT NULL AND duration_ms >= 0 AND occurred_at >= #{start} AND occurred_at < #{end}")
Double avgStayMs(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
@Select("SELECT event_name AS eventName, event_type AS eventType, COUNT(*) AS count, COUNT(DISTINCT COALESCE(user_id, anonymous_id)) AS users " +
"FROM t_analytics_event WHERE is_deleted = 0 AND occurred_at >= #{start} AND occurred_at < #{end} " +
"GROUP BY event_name, event_type ORDER BY count DESC LIMIT #{limit}")
List<AnalyticsTopEventItem> selectTopEvents(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("limit") int limit);
@Select("SELECT DATE_FORMAT(occurred_at, #{dateFormat}) AS bucket, event_name AS eventName, COUNT(*) AS count, COUNT(DISTINCT COALESCE(user_id, anonymous_id)) AS users " +
"FROM t_analytics_event WHERE is_deleted = 0 AND occurred_at >= #{start} AND occurred_at < #{end} " +
"GROUP BY DATE_FORMAT(occurred_at, #{dateFormat}), event_name ORDER BY bucket ASC, count DESC")
List<AnalyticsTrendItem> selectTrend(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("dateFormat") String dateFormat);
@Select("SELECT event_name AS eventName, COUNT(DISTINCT COALESCE(user_id, anonymous_id)) AS users " +
"FROM t_analytics_event WHERE is_deleted = 0 AND event_name IN ('app_launch', 'page_view', 'script_inspiration_click', 'script_generate_start', 'script_generate_success', 'script_detail_view', 'path_select', 'script_tts_play') " +
"AND occurred_at >= #{start} AND occurred_at < #{end} AND COALESCE(user_id, anonymous_id) IS NOT NULL " +
"GROUP BY event_name")
List<AnalyticsTopEventItem> selectFunnelUsers(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
@Select("SELECT #{dimension} AS dimension, JSON_UNQUOTE(JSON_EXTRACT(properties, CONCAT('$.', #{dimension}))) AS value, COUNT(*) AS count, COUNT(DISTINCT COALESCE(user_id, anonymous_id)) AS users " +
"FROM t_analytics_event WHERE is_deleted = 0 AND properties IS NOT NULL AND occurred_at >= #{start} AND occurred_at < #{end} " +
"GROUP BY JSON_UNQUOTE(JSON_EXTRACT(properties, CONCAT('$.', #{dimension}))) " +
"HAVING value IS NOT NULL AND value <> '' ORDER BY count DESC LIMIT #{limit}")
List<AnalyticsPreferenceItem> selectPreference(@Param("dimension") String dimension,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("limit") int limit);
@Select("SELECT user_id AS userId, anonymous_id AS anonymousId, COUNT(*) AS eventCount, MAX(occurred_at) AS lastActiveTime " +
"FROM t_analytics_event WHERE is_deleted = 0 AND occurred_at >= #{start} AND occurred_at < #{end} " +
"GROUP BY user_id, anonymous_id ORDER BY eventCount DESC, lastActiveTime DESC LIMIT #{limit}")
List<AnalyticsUserItem> selectUsers(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("limit") int limit);
}
@@ -0,0 +1,31 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
import com.emotion.dto.request.analytics.AnalyticsQueryRequest;
import com.emotion.dto.response.analytics.AnalyticsBatchResponse;
import com.emotion.dto.response.analytics.AnalyticsFunnelItem;
import com.emotion.dto.response.analytics.AnalyticsOverviewResponse;
import com.emotion.dto.response.analytics.AnalyticsPreferenceItem;
import com.emotion.dto.response.analytics.AnalyticsTopEventItem;
import com.emotion.dto.response.analytics.AnalyticsTrendItem;
import com.emotion.dto.response.analytics.AnalyticsUserItem;
import com.emotion.entity.AnalyticsEvent;
import java.util.List;
public interface AnalyticsService extends IService<AnalyticsEvent> {
AnalyticsBatchResponse ingestBatch(AnalyticsEventBatchRequest request);
AnalyticsOverviewResponse getOverview(AnalyticsQueryRequest request);
List<AnalyticsTrendItem> getTrend(AnalyticsQueryRequest request);
List<AnalyticsFunnelItem> getFunnel(AnalyticsQueryRequest request);
List<AnalyticsPreferenceItem> getPreferences(AnalyticsQueryRequest request);
List<AnalyticsTopEventItem> getTopEvents(AnalyticsQueryRequest request);
List<AnalyticsUserItem> getUsers(AnalyticsQueryRequest request);
}
@@ -0,0 +1,280 @@
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
import com.emotion.dto.request.analytics.AnalyticsEventRequest;
import com.emotion.dto.request.analytics.AnalyticsQueryRequest;
import com.emotion.dto.response.analytics.AnalyticsBatchResponse;
import com.emotion.dto.response.analytics.AnalyticsFunnelItem;
import com.emotion.dto.response.analytics.AnalyticsOverviewResponse;
import com.emotion.dto.response.analytics.AnalyticsPreferenceItem;
import com.emotion.dto.response.analytics.AnalyticsTopEventItem;
import com.emotion.dto.response.analytics.AnalyticsTrendItem;
import com.emotion.dto.response.analytics.AnalyticsUserItem;
import com.emotion.entity.AnalyticsEvent;
import com.emotion.mapper.AnalyticsEventMapper;
import com.emotion.service.AnalyticsService;
import com.emotion.util.UserContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@Service
public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, AnalyticsEvent>
implements AnalyticsService {
private static final Pattern SAFE_NAME = Pattern.compile("^[a-z][a-z0-9_]{1,99}$");
private static final int JSON_LIMIT_BYTES = 4096;
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private static final List<String> BLOCKED_PROPERTY_KEYS = List.of(
"token", "access_token", "refresh_token", "password", "phone", "smsCode", "content", "fullContent"
);
private static final List<String> FUNNEL_EVENTS = List.of(
"app_launch", "page_view", "script_inspiration_click", "script_generate_start",
"script_generate_success", "script_detail_view", "path_select", "script_tts_play"
);
private static final Map<String, String> FUNNEL_LABELS = Map.of(
"app_launch", "App launch",
"page_view", "Page view",
"script_inspiration_click", "Inspiration click",
"script_generate_start", "Generate start",
"script_generate_success", "Generate success",
"script_detail_view", "Script detail",
"path_select", "Path select",
"script_tts_play", "TTS play"
);
private static final List<String> PREFERENCE_DIMENSIONS = List.of("style", "length", "source", "tab");
public static boolean isSafeEventName(String eventName) {
return StringUtils.hasText(eventName) && SAFE_NAME.matcher(eventName).matches();
}
@Override
public AnalyticsBatchResponse ingestBatch(AnalyticsEventBatchRequest request) {
if (request == null || CollectionUtils.isEmpty(request.getEvents())) {
return AnalyticsBatchResponse.builder().accepted(0).rejected(0).build();
}
List<AnalyticsEvent> events = new ArrayList<>();
int rejected = 0;
LocalDateTime serverTime = LocalDateTime.now();
String currentUserId = UserContextHolder.getCurrentUserId();
Map<String, Object> deviceInfo = sanitizeJsonMap(request.getDeviceInfo());
for (AnalyticsEventRequest eventRequest : request.getEvents()) {
if (!isValidEvent(eventRequest)) {
rejected++;
continue;
}
AnalyticsEvent event = new AnalyticsEvent();
event.setUserId(currentUserId);
event.setAnonymousId(trimToNull(request.getAnonymousId()));
event.setSessionId(request.getSessionId());
event.setEventName(eventRequest.getEventName());
event.setEventType(eventRequest.getEventType());
event.setPagePath(trimToNull(eventRequest.getPagePath()));
event.setReferrerPath(trimToNull(eventRequest.getReferrerPath()));
event.setProperties(sanitizeJsonMap(eventRequest.getProperties()));
event.setDeviceInfo(deviceInfo);
event.setDurationMs(sanitizeDuration(eventRequest.getDurationMs()));
event.setOccurredAt(sanitizeOccurredAt(eventRequest.getOccurredAt(), serverTime));
event.setServerTime(serverTime);
events.add(event);
}
if (!events.isEmpty()) {
saveBatch(events);
}
return AnalyticsBatchResponse.builder()
.accepted(events.size())
.rejected(rejected)
.build();
}
@Override
public AnalyticsOverviewResponse getOverview(AnalyticsQueryRequest request) {
DateRange range = resolveRange(request);
return AnalyticsOverviewResponse.builder()
.pv(defaultLong(baseMapper.countEvent("page_view", range.start, range.end)))
.uv(defaultLong(baseMapper.countUniqueVisitors(range.start, range.end)))
.eventCount(defaultLong(baseMapper.countAll(range.start, range.end)))
.activeUsers(defaultLong(baseMapper.countActiveUsers(range.start, range.end)))
.ttsRequests(defaultLong(baseMapper.countEvent("script_tts_request", range.start, range.end)))
.ttsPlays(defaultLong(baseMapper.countEvent("script_tts_play", range.start, range.end)))
.avgStayMs(defaultDouble(baseMapper.avgStayMs(range.start, range.end)))
.build();
}
@Override
public List<AnalyticsTrendItem> getTrend(AnalyticsQueryRequest request) {
DateRange range = resolveRange(request);
return baseMapper.selectTrend(range.start, range.end, resolveDateFormat(request));
}
@Override
public List<AnalyticsFunnelItem> getFunnel(AnalyticsQueryRequest request) {
DateRange range = resolveRange(request);
Map<String, Long> usersByEvent = new HashMap<>();
for (AnalyticsTopEventItem item : baseMapper.selectFunnelUsers(range.start, range.end)) {
usersByEvent.put(item.getEventName(), item.getUsers());
}
List<AnalyticsFunnelItem> result = new ArrayList<>();
long firstUsers = 0L;
for (String eventName : FUNNEL_EVENTS) {
long users = usersByEvent.getOrDefault(eventName, 0L);
if (firstUsers == 0L && users > 0L) {
firstUsers = users;
}
double conversionRate = firstUsers == 0L ? 0D : (double) users / firstUsers;
result.add(AnalyticsFunnelItem.builder()
.eventName(eventName)
.label(FUNNEL_LABELS.getOrDefault(eventName, eventName))
.users(users)
.conversionRate(conversionRate)
.build());
}
return result;
}
@Override
public List<AnalyticsPreferenceItem> getPreferences(AnalyticsQueryRequest request) {
DateRange range = resolveRange(request);
int perDimensionLimit = Math.max(3, Math.min(10, resolveLimit(request)));
List<AnalyticsPreferenceItem> result = new ArrayList<>();
for (String dimension : PREFERENCE_DIMENSIONS) {
result.addAll(baseMapper.selectPreference(dimension, range.start, range.end, perDimensionLimit));
}
return result;
}
@Override
public List<AnalyticsTopEventItem> getTopEvents(AnalyticsQueryRequest request) {
DateRange range = resolveRange(request);
return baseMapper.selectTopEvents(range.start, range.end, resolveLimit(request));
}
@Override
public List<AnalyticsUserItem> getUsers(AnalyticsQueryRequest request) {
DateRange range = resolveRange(request);
return baseMapper.selectUsers(range.start, range.end, resolveLimit(request));
}
private boolean isValidEvent(AnalyticsEventRequest request) {
return request != null
&& isSafeEventName(request.getEventName())
&& isSafeEventName(request.getEventType())
&& fitsJsonLimit(request.getProperties());
}
private Map<String, Object> sanitizeJsonMap(Map<String, Object> source) {
if (source == null || source.isEmpty()) {
return null;
}
Map<String, Object> sanitized = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
String key = entry.getKey();
if (!StringUtils.hasText(key) || BLOCKED_PROPERTY_KEYS.contains(key)) {
continue;
}
sanitized.put(key, entry.getValue());
}
if (sanitized.isEmpty() || !fitsJsonLimit(sanitized)) {
return null;
}
return sanitized;
}
private boolean fitsJsonLimit(Map<String, Object> value) {
if (value == null || value.isEmpty()) {
return true;
}
return JSON.toJSONString(value).getBytes(StandardCharsets.UTF_8).length <= JSON_LIMIT_BYTES;
}
private Long sanitizeDuration(Long durationMs) {
if (durationMs == null || durationMs < 0) {
return null;
}
return Math.min(durationMs, 24L * 60L * 60L * 1000L);
}
private LocalDateTime sanitizeOccurredAt(LocalDateTime occurredAt, LocalDateTime fallback) {
if (occurredAt == null) {
return fallback;
}
LocalDateTime earliest = fallback.minusDays(30);
LocalDateTime latest = fallback.plusMinutes(10);
if (occurredAt.isBefore(earliest) || occurredAt.isAfter(latest)) {
return fallback;
}
return occurredAt;
}
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
private DateRange resolveRange(AnalyticsQueryRequest request) {
LocalDate today = LocalDate.now();
LocalDate endDate = request != null && request.getEndDate() != null ? request.getEndDate() : today;
LocalDate startDate = request != null && request.getStartDate() != null ? request.getStartDate() : endDate.minusDays(6);
if (startDate.isAfter(endDate)) {
startDate = endDate;
}
if (startDate.isBefore(endDate.minusDays(89))) {
startDate = endDate.minusDays(89);
}
return new DateRange(startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay());
}
private String resolveDateFormat(AnalyticsQueryRequest request) {
if (request != null && "hour".equalsIgnoreCase(request.getGranularity())) {
return "%Y-%m-%d %H:00";
}
if (request != null && "month".equalsIgnoreCase(request.getGranularity())) {
return "%Y-%m";
}
return "%Y-%m-%d";
}
private int resolveLimit(AnalyticsQueryRequest request) {
int limit = request != null && request.getLimit() != null ? request.getLimit() : DEFAULT_LIMIT;
return Math.max(1, Math.min(MAX_LIMIT, limit));
}
private long defaultLong(Long value) {
return value == null ? 0L : value;
}
private double defaultDouble(Double value) {
return value == null ? 0D : value;
}
private static class DateRange {
private final LocalDateTime start;
private final LocalDateTime end;
private DateRange(LocalDateTime start, LocalDateTime end) {
this.start = start;
this.end = end;
}
}
}
@@ -0,0 +1,46 @@
package com.emotion.service;
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
import com.emotion.dto.request.analytics.AnalyticsEventRequest;
import com.emotion.service.impl.AnalyticsServiceImpl;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class AnalyticsServiceTest {
@Test
void safeEventNameAllowsSnakeCase() {
assertTrue(AnalyticsServiceImpl.isSafeEventName("script_generate_success"));
}
@Test
void safeEventNameRejectsUnsafeCharacters() {
assertFalse(AnalyticsServiceImpl.isSafeEventName("script.generate"));
assertFalse(AnalyticsServiceImpl.isSafeEventName("<script>"));
}
@Test
void batchRequestShapeCanHoldValidEvent() {
AnalyticsEventRequest event = new AnalyticsEventRequest();
event.setEventName("page_view");
event.setEventType("page");
event.setPagePath("/pages/main/index");
event.setOccurredAt(LocalDateTime.now());
event.setProperties(Map.of("tab", "script"));
AnalyticsEventBatchRequest request = new AnalyticsEventBatchRequest();
request.setAnonymousId("anon_test");
request.setSessionId("session_test");
request.setEvents(List.of(event));
assertEquals("session_test", request.getSessionId());
assertEquals(1, request.getEvents().size());
}
}
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS t_analytics_event (
id VARCHAR(64) PRIMARY KEY COMMENT 'Primary key',
user_id VARCHAR(64) NULL COMMENT 'Logged-in user id',
anonymous_id VARCHAR(128) NULL COMMENT 'Anonymous client id',
session_id VARCHAR(128) NOT NULL COMMENT 'Client session id',
event_name VARCHAR(100) NOT NULL COMMENT 'Event name',
event_type VARCHAR(50) NOT NULL COMMENT 'Event category',
page_path VARCHAR(255) NULL COMMENT 'Current page path',
referrer_path VARCHAR(255) NULL COMMENT 'Referrer page path',
properties JSON NULL COMMENT 'Business metadata',
device_info JSON NULL COMMENT 'Device metadata',
duration_ms BIGINT NULL COMMENT 'Duration in milliseconds',
occurred_at DATETIME NOT NULL COMMENT 'Client occurrence time',
server_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Server receive time',
create_by VARCHAR(64) NULL COMMENT 'Creator',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time',
update_by VARCHAR(64) NULL COMMENT 'Updater',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Update time',
is_deleted TINYINT DEFAULT 0 COMMENT 'Logic delete flag',
remarks VARCHAR(500) NULL COMMENT 'Remarks'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Analytics event table';
CREATE INDEX idx_analytics_event_name ON t_analytics_event (event_name);
CREATE INDEX idx_analytics_event_type ON t_analytics_event (event_type);
CREATE INDEX idx_analytics_event_user_id ON t_analytics_event (user_id);
CREATE INDEX idx_analytics_event_anonymous_id ON t_analytics_event (anonymous_id);
CREATE INDEX idx_analytics_event_occurred_at ON t_analytics_event (occurred_at);
CREATE INDEX idx_analytics_event_name_time ON t_analytics_event (event_name, occurred_at);
CREATE INDEX idx_analytics_event_user_time ON t_analytics_event (user_id, occurred_at);