feat: add analytics backend
This commit is contained in:
@@ -43,6 +43,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
"/auth/sms-code", // 短信验证码接口(免登录)
|
"/auth/sms-code", // 短信验证码接口(免登录)
|
||||||
"/auth/refresh-token", // 刷新token接口
|
"/auth/refresh-token", // 刷新token接口
|
||||||
"/auth/resetPassword", // 重置密码接口(免登录)
|
"/auth/resetPassword", // 重置密码接口(免登录)
|
||||||
|
"/analytics/events/batch", // Analytics event batch endpoint
|
||||||
"/health", // 健康检查接口
|
"/health", // 健康检查接口
|
||||||
"/ws/**", // WebSocket接口
|
"/ws/**", // WebSocket接口
|
||||||
"/swagger-ui/**", // Swagger UI
|
"/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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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;
|
||||||
|
}
|
||||||
+34
@@ -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;
|
||||||
|
}
|
||||||
+25
@@ -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;
|
||||||
|
}
|
||||||
+15
@@ -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;
|
||||||
|
}
|
||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
+20
@@ -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;
|
||||||
|
}
|
||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
+19
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user