Pārlūkot izejas kodu

埋点和推荐位管理

liuchengsen 4 nedēļas atpakaļ
vecāks
revīzija
6fd4bc8c02

+ 17 - 0
backend-java/src/main/java/com/pharmacopoeia/config/GlobalExceptionHandler.java

@@ -3,6 +3,8 @@ package com.pharmacopoeia.config;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.pharmacopoeia.dto.ApiResponse;
 import com.pharmacopoeia.exception.DashScopeRateLimitExceededException;
+import com.pharmacopoeia.util.IpUtils;
+import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -10,6 +12,7 @@ import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
 
 import java.io.IOException;
 
@@ -18,6 +21,20 @@ public class GlobalExceptionHandler {
 
     private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
     private static final ObjectMapper mapper = new ObjectMapper();
+    private final HttpServletRequest request;
+
+    public GlobalExceptionHandler(HttpServletRequest request) {
+        this.request = request;
+    }
+
+    /** 静态资源 404:只记录来源 IP,不打印堆栈 */
+    @ExceptionHandler(NoResourceFoundException.class)
+    public void handleNoResource(NoResourceFoundException e, HttpServletResponse response) throws IOException {
+        String ip = IpUtils.getClientIp(request);
+        log.info("NoResourceFound from ip={}: path={}",
+                ip, e.getResourcePath());
+        writeJson(response, HttpStatus.NOT_FOUND, ApiResponse.error(404, "Resource not found"));
+    }
 
     @ExceptionHandler(IllegalArgumentException.class)
     public void handleBadRequest(IllegalArgumentException e, HttpServletResponse response) throws IOException {

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

@@ -20,6 +20,17 @@ public class AnalyticsController {
         this.request = request;
     }
 
+    @GetMapping("/events")
+    public ResponseEntity<Map<String, Object>> queryEvents(
+            @RequestParam(required = false) String eventType,
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(
+                analyticsService.queryEvents(eventType, startDate, endDate, page, pageSize));
+    }
+
     @PostMapping("/events")
     public ResponseEntity<Map<String, String>> saveEvent(@RequestBody Map<String, Object> body) {
         String eventType = (String) body.getOrDefault("event_type", "unknown");

+ 50 - 0
backend-java/src/main/java/com/pharmacopoeia/entity/UserEvent.java

@@ -0,0 +1,50 @@
+package com.pharmacopoeia.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.Instant;
+import java.util.Map;
+
+@Entity
+@Table(name = "user_events")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class UserEvent {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "user_key", length = 128)
+    private String userKey;
+
+    @Column(name = "event_type", length = 64)
+    private String eventType;
+
+    @Column(name = "event_data", columnDefinition = "jsonb")
+    @Convert(converter = com.pharmacopoeia.config.JsonbConverter.class)
+    private Map<String, Object> eventData;
+
+    @Column(name = "page_url", length = 512)
+    private String pageUrl;
+
+    @Column(length = 512)
+    private String referrer;
+
+    @Column(length = 64)
+    private String ip;
+
+    @Column(name = "user_agent", length = 512)
+    private String userAgent;
+
+    @Column(name = "created_at", updatable = false)
+    private Instant createdAt;
+
+    @PrePersist
+    void onCreate() {
+        if (createdAt == null) createdAt = Instant.now();
+    }
+}

+ 25 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/UserEventRepository.java

@@ -0,0 +1,25 @@
+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.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.Instant;
+
+@Repository
+public interface UserEventRepository extends JpaRepository<UserEvent, Long> {
+
+    @Query("SELECT e FROM UserEvent e WHERE " +
+           "(:eventType IS NULL OR e.eventType = :eventType) AND " +
+           "(:startTime IS NULL OR e.createdAt >= :startTime) AND " +
+           "(:endTime IS NULL OR e.createdAt < :endTime) " +
+           "ORDER BY e.createdAt DESC")
+    Page<UserEvent> findWithFilters(@Param("eventType") String eventType,
+                                    @Param("startTime") Instant startTime,
+                                    @Param("endTime") Instant endTime,
+                                    Pageable pageable);
+}

+ 65 - 12
backend-java/src/main/java/com/pharmacopoeia/service/AnalyticsService.java

@@ -1,37 +1,90 @@
 package com.pharmacopoeia.service;
 
+import com.pharmacopoeia.entity.UserEvent;
+import com.pharmacopoeia.repository.UserEventRepository;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.data.domain.PageRequest;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 @Service
 public class AnalyticsService {
 
     private static final Logger log = LoggerFactory.getLogger(AnalyticsService.class);
-    private final JdbcTemplate jdbc;
+    private final UserEventRepository repo;
 
-    public AnalyticsService(JdbcTemplate jdbc) {
-        this.jdbc = jdbc;
+    public AnalyticsService(UserEventRepository repo) {
+        this.repo = repo;
     }
 
     @Async
     public void saveEvent(String userKey, String eventType, Map<String, Object> eventData,
                           String pageUrl, String referrer, String ip, String userAgent) {
         try {
-            String json = eventData != null && !eventData.isEmpty()
-                    ? new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(eventData)
-                    : "{}";
-            jdbc.update(
-               "INSERT INTO user_events (user_key, event_type, event_data, page_url, referrer, ip, user_agent) " +
-                "VALUES (?, ?, CAST(? AS jsonb), ?, ?, ?, ?)",
-               userKey, eventType, json, pageUrl, referrer, ip, userAgent
-            );
+            UserEvent event = UserEvent.builder()
+                    .userKey(userKey)
+                    .eventType(eventType)
+                    .eventData(eventData != null ? eventData : Map.of())
+                    .pageUrl(pageUrl)
+                    .referrer(referrer)
+                    .ip(ip)
+                    .userAgent(userAgent)
+                    .build();
+            repo.save(event);
         } catch (Exception e) {
             log.warn("埋点写入失败: type={}, error={}", eventType, e.getMessage());
         }
     }
+
+    // ==================== 埋点查询 ====================
+
+    public Map<String, Object> queryEvents(String eventType, String startDate, String endDate,
+                                           int page, int pageSize) {
+        Instant startTime = parseDate(startDate);
+        Instant endTime = endDate != null && !endDate.isBlank()
+                ? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
+                : null;
+
+        var pageable = PageRequest.of(page - 1, pageSize);
+        var result = repo.findWithFilters(
+                eventType != null && !eventType.isBlank() ? eventType : null,
+                startTime, endTime, pageable);
+
+        List<Map<String, Object>> items = result.getContent().stream()
+                .map(e -> {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("id", e.getId());
+                    m.put("user_key", e.getUserKey());
+                    m.put("event_type", e.getEventType());
+                    m.put("event_data", e.getEventData());
+                    m.put("page_url", e.getPageUrl());
+                    m.put("referrer", e.getReferrer());
+                    m.put("ip", e.getIp());
+                    m.put("user_agent", e.getUserAgent());
+                    m.put("created_at", e.getCreatedAt());
+                    return m;
+                })
+                .collect(Collectors.toList());
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("items", items);
+        response.put("total", result.getTotalElements());
+        response.put("page", page);
+        response.put("page_size", pageSize);
+        return response;
+    }
+
+    private Instant parseDate(String date) {
+        if (date == null || date.isBlank()) return null;
+        return LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant();
+    }
 }