diff --git a/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java b/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java index 2dc9c24..e7d42f6 100644 --- a/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java +++ b/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java @@ -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 diff --git a/backend-single/src/main/java/com/emotion/controller/AdminAnalyticsController.java b/backend-single/src/main/java/com/emotion/controller/AdminAnalyticsController.java new file mode 100644 index 0000000..257b3bd --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/AdminAnalyticsController.java @@ -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 overview(@Validated AnalyticsQueryRequest request) { + return Result.success(analyticsService.getOverview(request)); + } + + @GetMapping("/trend") + public Result> trend(@Validated AnalyticsQueryRequest request) { + return Result.success(analyticsService.getTrend(request)); + } + + @GetMapping("/funnel") + public Result> funnel(@Validated AnalyticsQueryRequest request) { + return Result.success(analyticsService.getFunnel(request)); + } + + @GetMapping("/preferences") + public Result> preferences(@Validated AnalyticsQueryRequest request) { + return Result.success(analyticsService.getPreferences(request)); + } + + @GetMapping("/top-events") + public Result> topEvents(@Validated AnalyticsQueryRequest request) { + return Result.success(analyticsService.getTopEvents(request)); + } + + @GetMapping("/users") + public Result> users(@Validated AnalyticsQueryRequest request) { + return Result.success(analyticsService.getUsers(request)); + } +} diff --git a/backend-single/src/main/java/com/emotion/controller/AnalyticsController.java b/backend-single/src/main/java/com/emotion/controller/AnalyticsController.java new file mode 100644 index 0000000..8dcc377 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/AnalyticsController.java @@ -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 batch(@Valid @RequestBody AnalyticsEventBatchRequest request) { + return Result.success(analyticsService.ingestBatch(request)); + } +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventBatchRequest.java b/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventBatchRequest.java new file mode 100644 index 0000000..882e72a --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventBatchRequest.java @@ -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 deviceInfo; + + @Valid + @NotEmpty + @Size(max = 50) + private List events; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventRequest.java b/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventRequest.java new file mode 100644 index 0000000..acf8284 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventRequest.java @@ -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 properties; + + private Long durationMs; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime occurredAt; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsQueryRequest.java b/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsQueryRequest.java new file mode 100644 index 0000000..cd7bdf0 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsQueryRequest.java @@ -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 eventNames; + + private String eventType; + + private Integer limit; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsBatchResponse.java b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsBatchResponse.java new file mode 100644 index 0000000..d97d11f --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsBatchResponse.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsFunnelItem.java b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsFunnelItem.java new file mode 100644 index 0000000..825e8e3 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsFunnelItem.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsOverviewResponse.java b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsOverviewResponse.java new file mode 100644 index 0000000..0e7ea97 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsOverviewResponse.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsPreferenceItem.java b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsPreferenceItem.java new file mode 100644 index 0000000..edf78a9 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsPreferenceItem.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTopEventItem.java b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTopEventItem.java new file mode 100644 index 0000000..c943d96 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTopEventItem.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTrendItem.java b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTrendItem.java new file mode 100644 index 0000000..4ac24b3 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTrendItem.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsUserItem.java b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsUserItem.java new file mode 100644 index 0000000..272cb1b --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsUserItem.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/entity/AnalyticsEvent.java b/backend-single/src/main/java/com/emotion/entity/AnalyticsEvent.java new file mode 100644 index 0000000..1636621 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/AnalyticsEvent.java @@ -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 properties; + + @TableField(value = "device_info", typeHandler = JacksonTypeHandler.class) + private Map deviceInfo; + + @TableField("duration_ms") + private Long durationMs; + + @TableField("occurred_at") + private LocalDateTime occurredAt; + + @TableField("server_time") + private LocalDateTime serverTime; +} diff --git a/backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java b/backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java new file mode 100644 index 0000000..ddbea55 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java @@ -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 { + + @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 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 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 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 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 selectUsers(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("limit") int limit); +} diff --git a/backend-single/src/main/java/com/emotion/service/AnalyticsService.java b/backend-single/src/main/java/com/emotion/service/AnalyticsService.java new file mode 100644 index 0000000..63a2556 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/AnalyticsService.java @@ -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 { + AnalyticsBatchResponse ingestBatch(AnalyticsEventBatchRequest request); + + AnalyticsOverviewResponse getOverview(AnalyticsQueryRequest request); + + List getTrend(AnalyticsQueryRequest request); + + List getFunnel(AnalyticsQueryRequest request); + + List getPreferences(AnalyticsQueryRequest request); + + List getTopEvents(AnalyticsQueryRequest request); + + List getUsers(AnalyticsQueryRequest request); +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java new file mode 100644 index 0000000..aa4b923 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java @@ -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 + 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 BLOCKED_PROPERTY_KEYS = List.of( + "token", "access_token", "refresh_token", "password", "phone", "smsCode", "content", "fullContent" + ); + private static final List 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 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 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 events = new ArrayList<>(); + int rejected = 0; + LocalDateTime serverTime = LocalDateTime.now(); + String currentUserId = UserContextHolder.getCurrentUserId(); + Map 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 getTrend(AnalyticsQueryRequest request) { + DateRange range = resolveRange(request); + return baseMapper.selectTrend(range.start, range.end, resolveDateFormat(request)); + } + + @Override + public List getFunnel(AnalyticsQueryRequest request) { + DateRange range = resolveRange(request); + Map usersByEvent = new HashMap<>(); + for (AnalyticsTopEventItem item : baseMapper.selectFunnelUsers(range.start, range.end)) { + usersByEvent.put(item.getEventName(), item.getUsers()); + } + + List 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 getPreferences(AnalyticsQueryRequest request) { + DateRange range = resolveRange(request); + int perDimensionLimit = Math.max(3, Math.min(10, resolveLimit(request))); + List result = new ArrayList<>(); + for (String dimension : PREFERENCE_DIMENSIONS) { + result.addAll(baseMapper.selectPreference(dimension, range.start, range.end, perDimensionLimit)); + } + return result; + } + + @Override + public List getTopEvents(AnalyticsQueryRequest request) { + DateRange range = resolveRange(request); + return baseMapper.selectTopEvents(range.start, range.end, resolveLimit(request)); + } + + @Override + public List 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 sanitizeJsonMap(Map source) { + if (source == null || source.isEmpty()) { + return null; + } + Map sanitized = new LinkedHashMap<>(); + for (Map.Entry 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 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; + } + } +} diff --git a/backend-single/src/test/java/com/emotion/service/AnalyticsServiceTest.java b/backend-single/src/test/java/com/emotion/service/AnalyticsServiceTest.java new file mode 100644 index 0000000..e35d919 --- /dev/null +++ b/backend-single/src/test/java/com/emotion/service/AnalyticsServiceTest.java @@ -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("