liuchengsen 4 недель назад
Родитель
Сommit
c9cd630e87

+ 32 - 20
backend-java/src/main/java/com/pharmacopoeia/controller/AnalyticsController.java

@@ -21,6 +21,8 @@ public class AnalyticsController {
         this.request = request;
     }
 
+    // ==================== 事件 ====================
+
     @GetMapping("/events")
     public ResponseEntity<ApiResponse<Map<String, Object>>> queryEvents(
             @RequestParam(required = false) String eventType,
@@ -41,22 +43,6 @@ public class AnalyticsController {
         return ResponseEntity.ok(ApiResponse.success(detail));
     }
 
-    @GetMapping("/stats")
-    public ResponseEntity<ApiResponse<Map<String, Object>>> queryStats(
-            @RequestParam(required = false) String startDate,
-            @RequestParam(required = false) String endDate,
-            @RequestParam(required = false) String dimension,
-            @RequestParam(required = false) String range) {
-        // 没传时间范围默认统计今天
-        if ((startDate == null || startDate.isBlank()) && (endDate == null || endDate.isBlank())) {
-            String today = java.time.LocalDate.now().toString();
-            return ResponseEntity.ok(ApiResponse.success(
-                    analyticsService.queryStats(today, today, dimension, range)));
-        }
-        return ResponseEntity.ok(ApiResponse.success(
-                analyticsService.queryStats(startDate, endDate, dimension, range)));
-    }
-
     @PostMapping("/events")
     public ResponseEntity<ApiResponse<Map<String, String>>> saveEvent(@RequestBody Map<String, Object> body) {
         String eventType = (String) body.getOrDefault("event_type", "unknown");
@@ -70,19 +56,45 @@ public class AnalyticsController {
         String ip = IpUtils.getClientIp(request);
         String ua = request.getHeader("User-Agent");
 
-        // fire-and-forget:异步写入,不等待
         analyticsService.saveEvent(userKey, eventType, eventData, pageUrl, referrer,
                 ip, ua != null ? ua : "");
 
-        // 始终返回 202,前端不关心结果
         return ResponseEntity.accepted()
-                .body(ApiResponse.<Map<String, String>>builder().code(202).message("accepted").data(Map.of("status", "accepted")).build());
+                .body(ApiResponse.<Map<String, String>>builder().code(202).message("accepted")
+                        .data(Map.of("status", "accepted")).build());
+    }
+
+    // ==================== 概览 ====================
+
+    /** 概览数字:total_events / active_users / page_views / searches / ai_qa */
+    @GetMapping("/stats/overview")
+    public ResponseEntity<ApiResponse<Map<String, Object>>> queryOverview(
+            @RequestParam(required = false) String range) {
+        if (range == null || range.isBlank()) {
+            range = "total";
+        }
+        return ResponseEntity.ok(ApiResponse.success(analyticsService.queryOverview(range)));
+    }
+
+    // ==================== 维度 ====================
+
+    /** 分页维度统计:dimension=ip/url/type/user/user_ip */
+    @GetMapping("/stats/dimension")
+    public ResponseEntity<ApiResponse<Map<String, Object>>> queryDimension(
+            @RequestParam String dimension,
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(ApiResponse.success(
+                analyticsService.queryDimension(dimension, startDate, endDate, page, pageSize)));
     }
 
     private String getCurrentUserKey() {
         var auth = org.springframework.security.core.context.SecurityContextHolder
                 .getContext().getAuthentication();
-        if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
+        if (auth != null && auth.isAuthenticated()
+                && !"anonymousUser".equals(auth.getPrincipal())) {
             return auth.getName();
         }
         return "anonymous";

+ 49 - 6
backend-java/src/main/java/com/pharmacopoeia/repository/UserEventRepository.java

@@ -1,6 +1,8 @@
 package com.pharmacopoeia.repository;
 
 import com.pharmacopoeia.entity.UserEvent;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
 import org.springframework.data.jpa.repository.Query;
@@ -24,40 +26,81 @@ public interface UserEventRepository extends JpaRepository<UserEvent, Long>,
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime")
     long countDistinctUsers(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
 
-    /** 按类型统计数量 */
+    /** 按类型 count */
     @Query("SELECT COUNT(e) FROM UserEvent e " +
            "WHERE e.eventType = :eventType " +
            "AND e.createdAt >= :startTime AND e.createdAt < :endTime")
     long countByType(@Param("eventType") String eventType,
                      @Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
 
-    /** 按 IP 分组统计 */
+    // ==================== 分组统计(全量,供其他场景用) ====================
+
     @Query("SELECT e.ip as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
            "GROUP BY e.ip ORDER BY cnt DESC")
     List<Object[]> countByIp(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
 
-    /** 按 page_url 分组统计 */
     @Query("SELECT e.pageUrl as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
            "GROUP BY e.pageUrl ORDER BY cnt DESC")
     List<Object[]> countByPageUrl(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
 
-    /** 按 event_type 分组统计 */
     @Query("SELECT e.eventType as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
            "GROUP BY e.eventType ORDER BY cnt DESC")
     List<Object[]> countByEventType(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
 
-    /** 按 user_key 分组统计(去重用户) */
     @Query("SELECT e.userKey as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
            "GROUP BY e.userKey ORDER BY cnt DESC")
     List<Object[]> countByUserKey(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
 
-    /** 用户+IP 混合维度:每个用户关联的 IP 列表 */
     @Query("SELECT e.userKey, e.ip, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
            "GROUP BY e.userKey, e.ip ORDER BY e.userKey, cnt DESC")
     List<Object[]> countByUserIp(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+
+    // ==================== 分组统计(分页) ====================
+
+    @Query(value = "SELECT e.ip as key, COUNT(e) as cnt FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "GROUP BY e.ip ORDER BY cnt DESC",
+           countQuery = "SELECT COUNT(DISTINCT e.ip) FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime")
+    Page<Object[]> countByIpPaged(@Param("startTime") Instant startTime,
+                                   @Param("endTime") Instant endTime, Pageable pageable);
+
+    @Query(value = "SELECT e.pageUrl as key, COUNT(e) as cnt FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "GROUP BY e.pageUrl ORDER BY cnt DESC",
+           countQuery = "SELECT COUNT(DISTINCT e.pageUrl) FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime")
+    Page<Object[]> countByPageUrlPaged(@Param("startTime") Instant startTime,
+                                        @Param("endTime") Instant endTime, Pageable pageable);
+
+    @Query(value = "SELECT e.eventType as key, COUNT(e) as cnt FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "GROUP BY e.eventType ORDER BY cnt DESC",
+           countQuery = "SELECT COUNT(DISTINCT e.eventType) FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime")
+    Page<Object[]> countByEventTypePaged(@Param("startTime") Instant startTime,
+                                          @Param("endTime") Instant endTime, Pageable pageable);
+
+    @Query(value = "SELECT e.userKey as key, COUNT(e) as cnt FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "GROUP BY e.userKey ORDER BY cnt DESC",
+           countQuery = "SELECT COUNT(DISTINCT e.userKey) FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime")
+    Page<Object[]> countByUserKeyPaged(@Param("startTime") Instant startTime,
+                                        @Param("endTime") Instant endTime, Pageable pageable);
+
+    @Query(value = "SELECT e.userKey, e.ip, COUNT(e) as cnt FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "GROUP BY e.userKey, e.ip ORDER BY e.userKey, cnt DESC",
+           countQuery = "SELECT COUNT(*) FROM (" +
+               "SELECT e2.userKey, e2.ip FROM UserEvent e2 " +
+               "WHERE e2.createdAt >= :startTime AND e2.createdAt < :endTime " +
+               "GROUP BY e2.userKey, e2.ip) sub")
+    Page<Object[]> countByUserIpPaged(@Param("startTime") Instant startTime,
+                                       @Param("endTime") Instant endTime, Pageable pageable);
 }

+ 74 - 66
backend-java/src/main/java/com/pharmacopoeia/service/AnalyticsService.java

@@ -5,6 +5,7 @@ import com.pharmacopoeia.repository.UserEventRepository;
 import jakarta.persistence.criteria.Predicate;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jpa.domain.Specification;
@@ -24,6 +25,8 @@ import java.util.stream.Collectors;
 public class AnalyticsService {
 
     private static final Logger log = LoggerFactory.getLogger(AnalyticsService.class);
+    private static final Instant FAR_FUTURE = Instant.parse("2099-12-31T23:59:59Z");
+
     private final UserEventRepository repo;
 
     public AnalyticsService(UserEventRepository repo) {
@@ -49,30 +52,16 @@ public class AnalyticsService {
         }
     }
 
-    // ==================== 埋点查询 ====================
+    // ==================== 埋点事件列表 ====================
 
-    /** 列表(精简字段,不含 event_data / page_url / referrer / user_agent) */
     public Map<String, Object> queryEvents(String eventType, String startDate, String endDate,
                                            int page, int pageSize) {
         Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
         Instant endTime = endDate != null && !endDate.isBlank()
                 ? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
-                : Instant.parse("2099-12-31T23:59:59Z");
-
-        Specification<UserEvent> spec = (root, query, cb) -> {
-            List<Predicate> predicates = new ArrayList<>();
-            if (eventType != null && !eventType.isBlank()) {
-                predicates.add(cb.equal(root.get("eventType"), eventType));
-            }
-            if (startTime != null) {
-                predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startTime));
-            }
-            if (endTime != null) {
-                predicates.add(cb.lessThan(root.get("createdAt"), endTime));
-            }
-            return cb.and(predicates.toArray(new Predicate[0]));
-        };
+                : FAR_FUTURE;
 
+        Specification<UserEvent> spec = buildTimeSpec(eventType, startTime, endTime);
         var pageable = PageRequest.of(page - 1, pageSize, Sort.by(Sort.Direction.DESC, "createdAt"));
         var result = repo.findAll(spec, pageable);
 
@@ -97,7 +86,6 @@ public class AnalyticsService {
         return response;
     }
 
-    /** 详情(返回全部字段) */
     public Map<String, Object> getEventDetail(long id) {
         var opt = repo.findById(id);
         if (opt.isEmpty()) return Map.of();
@@ -115,14 +103,8 @@ public class AnalyticsService {
         return m;
     }
 
-    private Instant parseDate(String date) {
-        if (date == null || date.isBlank()) return null;
-        return LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant();
-    }
-
-    // ==================== 统计概览 ====================
+    // ==================== 概览数字 ====================
 
-    /** 概览数字:支持三种场景——累计、今天、最近7天 */
     public Map<String, Object> queryOverview(String range) {
         String today = LocalDate.now().toString();
         Instant todayStart = parseDate(today);
@@ -141,9 +123,8 @@ public class AnalyticsService {
             endTime = todayEnd;
             label = "week";
         } else {
-            // 累计:用极值替代 null,避免 PG 无法推断 NULL::timestamptz 类型
             startTime = Instant.EPOCH;
-            endTime = Instant.parse("2099-12-31T23:59:59Z");
+            endTime = FAR_FUTURE;
             label = "total";
         }
 
@@ -157,58 +138,85 @@ public class AnalyticsService {
         return overview;
     }
 
-    // ==================== 统计维度 ====================
+    // ==================== 维度分页统计 ====================
 
-    /** 五个维度统计,支持按维度切换,概览支持 range=today/week/total;
-     *  如果日期都不传,调用方默认填了当天 */
-    public Map<String, Object> queryStats(String startDate, String endDate, String dimension,
-                                          String range) {
+    /** 分页维度统计:ip / url / type / user / user_ip */
+    public Map<String, Object> queryDimension(String dimension, String startDate, String endDate,
+                                              int page, int pageSize) {
         Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
         Instant endTime = endDate != null && !endDate.isBlank()
                 ? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
-                : Instant.parse("2099-12-31T23:59:59Z");
-
-        Map<String, Object> result = new LinkedHashMap<>();
-        // 概览数字(range 控制累计/今天/7天)
-        result.put("overview", queryOverview(range));
-
-        // 指定维度只查一个,不查全部
-        boolean all = (dimension == null || dimension.isBlank());
-        if (all || "ip".equals(dimension)) {
-            result.put("by_ip", mapGroupResult(repo.countByIp(startTime, endTime)));
+                : FAR_FUTURE;
+
+        Page<Object[]> result;
+        switch (dimension != null ? dimension : "") {
+            case "ip":
+                result = repo.countByIpPaged(startTime, endTime,
+                        PageRequest.of(page - 1, pageSize));
+                break;
+            case "url":
+                result = repo.countByPageUrlPaged(startTime, endTime,
+                        PageRequest.of(page - 1, pageSize));
+                break;
+            case "type":
+                result = repo.countByEventTypePaged(startTime, endTime,
+                        PageRequest.of(page - 1, pageSize));
+                break;
+            case "user":
+                result = repo.countByUserKeyPaged(startTime, endTime,
+                        PageRequest.of(page - 1, pageSize));
+                break;
+            case "user_ip":
+                result = repo.countByUserIpPaged(startTime, endTime,
+                        PageRequest.of(page - 1, pageSize));
+                break;
+            default:
+                return Map.of();
         }
-        if (all || "url".equals(dimension)) {
-            result.put("by_url", mapGroupResult(repo.countByPageUrl(startTime, endTime)));
-        }
-        if (all || "type".equals(dimension)) {
-            result.put("by_type", mapGroupResult(repo.countByEventType(startTime, endTime)));
-        }
-        if (all || "user".equals(dimension)) {
-            List<Map<String, Object>> byUser = mapGroupResult(repo.countByUserKey(startTime, endTime));
-            result.put("by_user", byUser);
-            result.put("total_users", byUser.size());
-        }
-        if (all || "user_ip".equals(dimension)) {
-            result.put("by_user_ip", mapUserIpResult(repo.countByUserIp(startTime, endTime)));
-        }
-        return result;
-    }
 
-    private List<Map<String, Object>> mapGroupResult(List<Object[]> rows) {
-        return rows.stream()
-                .map(row -> Map.of("key", row[0] != null ? row[0] : "", "count", (Long) row[1]))
-                .collect(Collectors.toList());
+        List<Map<String, Object>> items = mapResult(result.getContent(), dimension);
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("dimension", dimension);
+        response.put("items", items);
+        response.put("total", result.getTotalElements());
+        response.put("page", page);
+        response.put("page_size", pageSize);
+        return response;
     }
 
-    private List<Map<String, Object>> mapUserIpResult(List<Object[]> rows) {
+    private List<Map<String, Object>> mapResult(List<Object[]> rows, String dimension) {
         return rows.stream()
                 .map(row -> {
                     Map<String, Object> m = new LinkedHashMap<>();
-                    m.put("user_key", row[0] != null ? row[0] : "");
-                    m.put("ip", row[1] != null ? row[1] : "");
-                    m.put("count", (Long) row[2]);
+                    if ("user_ip".equals(dimension)) {
+                        m.put("user_key", row[0] != null ? row[0] : "");
+                        m.put("ip", row[1] != null ? row[1] : "");
+                        m.put("count", (Long) row[2]);
+                    } else {
+                        m.put("key", row[0] != null ? row[0] : "");
+                        m.put("count", (Long) row[1]);
+                    }
                     return m;
                 })
                 .collect(Collectors.toList());
     }
+
+    // ==================== Helpers ====================
+
+    private Specification<UserEvent> buildTimeSpec(String eventType, Instant startTime, Instant endTime) {
+        return (root, query, cb) -> {
+            List<Predicate> predicates = new ArrayList<>();
+            if (eventType != null && !eventType.isBlank()) {
+                predicates.add(cb.equal(root.get("eventType"), eventType));
+            }
+            predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startTime));
+            predicates.add(cb.lessThan(root.get("createdAt"), endTime));
+            return cb.and(predicates.toArray(new Predicate[0]));
+        };
+    }
+
+    private Instant parseDate(String date) {
+        if (date == null || date.isBlank()) return null;
+        return LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant();
+    }
 }