feat: add analytics backend
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
+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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user