Bladeren bron

埋点添加新的接口

liuchengsen 11 uur geleden
bovenliggende
commit
d617027255

+ 2 - 0
backend-java/src/main/java/com/pharmacopoeia/config/RateLimitProperties.java

@@ -11,6 +11,8 @@ public class RateLimitProperties {
     private int perMinute = 60;
     /** 埋点写入 /api/v1/analytics/events 的独立限流(多系统调用,阈值给大) */
     private int analyticsPerMinute = 600;
+    /** 埋点事件导出 GET /api/v1/analytics/events/export 的独立限流(重查询,阈值给小) */
+    private int exportPerMinute = 5;
     private int perHour = 999999;
     private int perDay = 999999;
 }

+ 31 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/AnalyticsController.java

@@ -4,9 +4,16 @@ import com.pharmacopoeia.dto.ApiResponse;
 import com.pharmacopoeia.service.AnalyticsService;
 import com.pharmacopoeia.util.IpUtils;
 import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Map;
 import java.util.List;
 
@@ -36,6 +43,30 @@ public class AnalyticsController {
                 analyticsService.queryEvents(eventType, system, startDate, endDate, page, pageSize)));
     }
 
+    /** 按当前筛选条件全量导出埋点事件为 CSV(UTF-8 + BOM)。
+     *  字段:ID / 事件类型 / 来源系统 / 用户标识 / IP地址 / 时间,按时间倒序。
+     *  无数据或超上限时返回 JSON(application/json)供前端提示;成功返回 text/csv 文件。 */
+    @GetMapping("/events/export")
+    public ResponseEntity<?> exportEvents(
+            @RequestParam(required = false) String eventType,
+            @RequestParam(required = false) String system,
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
+        var result = analyticsService.exportEvents(eventType, system, startDate, endDate);
+        if (result.status() != AnalyticsService.EventExportResult.Status.SUCCESS) {
+            return ResponseEntity.ok(ApiResponse.<Map<String, Object>>builder()
+                    .code(422).message(result.message()).build());
+        }
+        String stamp = LocalDateTime.now(ZoneId.of("Asia/Shanghai"))
+                .format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
+        String filename = "埋点事件记录_" + stamp + ".csv";
+        String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
+                .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
+                .body(result.bytes());
+    }
+
     @GetMapping("/events/{id}")
     public ResponseEntity<ApiResponse<Map<String, Object>>> getEventDetail(@PathVariable long id) {
         var detail = analyticsService.getEventDetail(id);

+ 20 - 1
backend-java/src/main/java/com/pharmacopoeia/repository/UserEventRepository.java

@@ -147,9 +147,28 @@ public interface UserEventRepository extends JpaRepository<UserEvent, Long>,
     @Query("SELECT DISTINCT e.eventType FROM UserEvent e ORDER BY e.eventType")
     List<String> findDistinctEventTypes();
 
-    /** 获取所有去重来源系统,供前端切换系统维度 */
+    /** 获取所有去重来源系统,供前端系统切换系统维度 */
     @Query("SELECT DISTINCT e.systemSource FROM UserEvent e " +
            "WHERE e.systemSource IS NOT NULL " +
            "ORDER BY e.systemSource")
     List<String> findDistinctSystemSources();
+
+    // ==================== 导出(只取 6 列投影,避免加载 jsonb/UA 等大字段) ====================
+
+    /** 导出前 count:与 findForExport 同条件,systemSource/eventType 为 null 时不过滤 */
+    @Query("SELECT COUNT(e) FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
+           "AND (:eventType IS NULL OR e.eventType = :eventType)")
+    long countForExport(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                       @Param("systemSource") String systemSource, @Param("eventType") String eventType);
+
+    /** 按筛选条件全量查询导出列,按时间倒序;id/system/eventType/userKey/ip/createdAt */
+    @Query("SELECT e.id, e.systemSource, e.eventType, e.userKey, e.ip, e.createdAt FROM UserEvent e " +
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
+           "AND (:eventType IS NULL OR e.eventType = :eventType) " +
+           "ORDER BY e.createdAt DESC")
+    List<Object[]> findForExport(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                                @Param("systemSource") String systemSource, @Param("eventType") String eventType);
 }

+ 6 - 0
backend-java/src/main/java/com/pharmacopoeia/security/RateLimitFilter.java

@@ -22,6 +22,7 @@ import java.util.Deque;
  * 用户/系统侧限流(按 IP 滑动窗口 60s):
  * - chat POST(/chat/*):perMinute(默认 60)—— 问答不能无限刷
  * - 埋点写入 POST(/api/v1/analytics/events):analyticsPerMinute(默认 600,多系统调用给大)
+ * - 埋点导出 GET(/api/v1/analytics/events/export):exportPerMinute(默认 5,重查询阈值给小)
  * 时间戳队列 + Caffeine expireAfterAccess(60s) 自动淘汰空闲桶,无需手动清理。
  * 超限返回 429,不进入 Controller 层。
  */
@@ -33,11 +34,13 @@ public class RateLimitFilter extends OncePerRequestFilter {
     private final RateLimitProperties props;
     private final Cache<String, Deque<Long>> chatBuckets;
     private final Cache<String, Deque<Long>> analyticsBuckets;
+    private final Cache<String, Deque<Long>> exportBuckets;
 
     public RateLimitFilter(RateLimitProperties props) {
         this.props = props;
         this.chatBuckets = Caffeine.newBuilder().expireAfterAccess(Duration.ofSeconds(60)).build();
         this.analyticsBuckets = Caffeine.newBuilder().expireAfterAccess(Duration.ofSeconds(60)).build();
+        this.exportBuckets = Caffeine.newBuilder().expireAfterAccess(Duration.ofSeconds(60)).build();
     }
 
     @Override
@@ -49,12 +52,15 @@ public class RateLimitFilter extends OncePerRequestFilter {
         try {
             String uri = request.getRequestURI();
             boolean post = "POST".equalsIgnoreCase(request.getMethod());
+            boolean get = "GET".equalsIgnoreCase(request.getMethod());
 
             boolean limited = false;
             if (post && uri.contains("/chat/")) {
                 limited = !allow(ip, chatBuckets, props.getPerMinute());
             } else if (post && uri.contains("/api/v1/analytics/events")) {
                 limited = !allow(ip, analyticsBuckets, props.getAnalyticsPerMinute());
+            } else if (get && uri.contains("/api/v1/analytics/events/export")) {
+                limited = !allow(ip, exportBuckets, props.getExportPerMinute());
             }
 
             if (limited) {

+ 84 - 0
backend-java/src/main/java/com/pharmacopoeia/service/AnalyticsService.java

@@ -13,9 +13,12 @@ import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.time.LocalDate;
 import java.time.ZoneOffset;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -136,6 +139,87 @@ public class AnalyticsService {
         return response;
     }
 
+    // ==================== 事件导出(CSV) ====================
+
+    /** 单次导出上限:超出时提示用户缩小时间范围分批导出 */
+    private static final int EXPORT_MAX_ROWS = 100_000;
+
+    /** UTF-8 BOM,便于 Excel 直接双击打开时正确识别中文 */
+    private static final byte[] CSV_BOM = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
+
+    /** 事件类型中文名映射,与前端 TrackingInfoView 的 EVENT_LABEL_MAP 对齐;未知类型保留原值 */
+    private static final Map<String, String> EVENT_LABEL_MAP = Map.of(
+            "page_view", "页面浏览",
+            "search", "搜索",
+            "click", "点击",
+            "ai_qa", "AI问答",
+            "chat", "AI问答"
+    );
+
+    private static final ZoneId BEIJING = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter EXPORT_TIME_FMT =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BEIJING);
+
+    /** 导出结果:SUCCESS 返回 bytes;EMPTY/TOO_LARGE 返回面向用户的 message */
+    public record EventExportResult(Status status, String message, byte[] bytes) {
+        public enum Status { SUCCESS, EMPTY, TOO_LARGE }
+    }
+
+    /**
+     * 按当前筛选条件全量导出埋点事件为 CSV(UTF-8 + BOM,便于 Excel 正确显示中文)。
+     * 字段顺序:ID / 事件类型 / 来源系统 / 用户标识 / IP地址 / 时间,按时间倒序。
+     * 事件类型取页面展示的中文名(与前端 getEventLabel 对齐);时间为北京时间 yyyy-MM-dd HH:mm:ss。
+     */
+    public EventExportResult exportEvents(String eventType, String systemSource,
+                                          String startDate, String endDate) {
+        Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
+        Instant endTime = endDate != null && !endDate.isBlank()
+                ? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
+                : FAR_FUTURE;
+        String systemFilter = normalizeSystemFilter(systemSource);
+        String typeFilter = (eventType == null || eventType.isBlank()) ? null : eventType;
+
+        long total = repo.countForExport(startTime, endTime, systemFilter, typeFilter);
+        if (total == 0) {
+            return new EventExportResult(EventExportResult.Status.EMPTY, "当前筛选条件下无数据,无法导出", null);
+        }
+        if (total > EXPORT_MAX_ROWS) {
+            return new EventExportResult(EventExportResult.Status.TOO_LARGE,
+                    "当前筛选条件下数据量为 " + total + " 条,超过单次导出上限 " + EXPORT_MAX_ROWS
+                            + " 条,请缩小时间范围后分批导出", null);
+        }
+
+        List<Object[]> rows = repo.findForExport(startTime, endTime, systemFilter, typeFilter);
+        StringBuilder sb = new StringBuilder();
+        sb.append("ID,事件类型,来源系统,用户标识,IP地址,时间\n");
+        for (Object[] r : rows) {
+            // r: [id, systemSource, eventType, userKey, ip, createdAt]
+            sb.append(((Number) r[0]).longValue()).append(',')
+                    .append(esc(mapEventLabel((String) r[2]))).append(',')
+                    .append(esc((String) r[1])).append(',')
+                    .append(esc((String) r[3])).append(',')
+                    .append(esc((String) r[4])).append(',')
+                    .append(esc(r[5] != null ? EXPORT_TIME_FMT.format((Instant) r[5]) : ""))
+                    .append('\n');
+        }
+        byte[] content = sb.toString().getBytes(StandardCharsets.UTF_8);
+        byte[] withBom = new byte[CSV_BOM.length + content.length];
+        System.arraycopy(CSV_BOM, 0, withBom, 0, CSV_BOM.length);
+        System.arraycopy(content, 0, withBom, CSV_BOM.length, content.length);
+        return new EventExportResult(EventExportResult.Status.SUCCESS, "ok", withBom);
+    }
+
+    /** CSV 单元格转义:空值返回空串;非空则用双引号包裹并把内部双引号翻倍。与 BrandRecommendService#esc 对齐 */
+    private static String esc(String s) {
+        return (s == null || s.isEmpty()) ? "" : "\"" + s.replace("\"", "\"\"") + "\"";
+    }
+
+    /** 事件类型 → 页面展示中文名;未知类型保留原值,与前端 getEventLabel 一致 */
+    private static String mapEventLabel(String type) {
+        if (type == null) return "";
+        return EVENT_LABEL_MAP.getOrDefault(type, type);
+    }
+
     public Map<String, Object> getEventDetail(long id) {
         var opt = repo.findById(id);
         if (opt.isEmpty()) return Map.of();