浏览代码

埋点系统更新上线

liuchengsen 4 周之前
父节点
当前提交
bb628de17b

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

@@ -0,0 +1,55 @@
+package com.pharmacopoeia.controller;
+
+import com.pharmacopoeia.service.AnalyticsService;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/analytics")
+public class AnalyticsController {
+
+    private final AnalyticsService analyticsService;
+    private final HttpServletRequest request;
+
+    public AnalyticsController(AnalyticsService analyticsService, HttpServletRequest request) {
+        this.analyticsService = analyticsService;
+        this.request = request;
+    }
+
+    @PostMapping("/events")
+    public ResponseEntity<Map<String, String>> saveEvent(@RequestBody Map<String, Object> body) {
+        String eventType = (String) body.getOrDefault("event_type", "unknown");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> eventData = body.get("event_data") instanceof Map
+                ? (Map<String, Object>) body.get("event_data") : Map.of();
+        String pageUrl = (String) body.getOrDefault("page_url", "");
+        String referrer = (String) body.getOrDefault("referrer", "");
+
+        String userKey = getCurrentUserKey();
+        String ip = request.getRemoteAddr();
+        String forwarded = request.getHeader("X-Forwarded-For");
+        if (forwarded != null && !forwarded.isBlank()) {
+            ip = forwarded.split(",")[0].trim();
+        }
+        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(Map.of("status", "accepted"));
+    }
+
+    private String getCurrentUserKey() {
+        var auth = org.springframework.security.core.context.SecurityContextHolder
+                .getContext().getAuthentication();
+        if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
+            return auth.getName();
+        }
+        return "anonymous";
+    }
+}

+ 13 - 1
backend-java/src/main/java/com/pharmacopoeia/security/JwtAuthFilter.java

@@ -27,6 +27,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
             "/health",
             "/api/v1/auth/",
             "/api/v1/yaodian/",
+            "/api/v1/analytics/",
             "/static/",
             "/favicon.ico"
     );
@@ -56,8 +57,19 @@ public class JwtAuthFilter extends OncePerRequestFilter {
         String uri = request.getRequestURI();
         String method = request.getMethod();
 
-        // 公开路径始终放行
+        // 公开路径:不要求 token,但如果带来了有效 token 仍解析出用户身份
         if (isPublicPath(uri)) {
+            String header = request.getHeader("Authorization");
+            if (header != null && header.startsWith("Bearer ")) {
+                try {
+                    String token = header.substring(7);
+                    if (jwtUtil.validateToken(token)) {
+                        String subject = jwtUtil.getSubject(token);
+                        SecurityContextHolder.getContext().setAuthentication(
+                                new UsernamePasswordAuthenticationToken(subject, null, Collections.emptyList()));
+                    }
+                } catch (Exception ignored) {}
+            }
             filterChain.doFilter(request, response);
             return;
         }

+ 4 - 8
backend-java/src/main/java/com/pharmacopoeia/security/SecurityConfig.java

@@ -1,6 +1,5 @@
 package com.pharmacopoeia.security;
 
-import com.pharmacopoeia.config.AuthProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -20,26 +19,23 @@ public class SecurityConfig {
 
     private final JwtAuthFilter jwtAuthFilter;
     private final RateLimitFilter rateLimitFilter;
-    private final AuthProperties authProperties;
 
-    public SecurityConfig(JwtAuthFilter jwtAuthFilter, RateLimitFilter rateLimitFilter,
-                          AuthProperties authProperties) {
+    public SecurityConfig(JwtAuthFilter jwtAuthFilter, RateLimitFilter rateLimitFilter) {
         this.jwtAuthFilter = jwtAuthFilter;
         this.rateLimitFilter = rateLimitFilter;
-        this.authProperties = authProperties;
     }
 
     @Bean
     public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
-        http
+        HttpSecurity httpSecurity = http
                 .cors(cors -> cors.configurationSource(corsConfigSource()))
                 .csrf(csrf -> csrf.disable())
                 .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
 
 
-            // 生产模式:所有 /api/** 需要鉴权,公开路径仅限登录和静态资源
+        // 生产模式:所有 /api/** 需要鉴权,公开路径仅限登录和静态资源
         http.authorizeHttpRequests(auth -> auth
-                .requestMatchers("/health", "/api/v1/auth/**", "/api/v1/yaodian/**").permitAll()
+                .requestMatchers("/health", "/api/v1/auth/**", "/api/v1/yaodian/**", "/api/v1/analytics/**").permitAll()
                 .requestMatchers("/static/**", "/").permitAll()
                 .requestMatchers("/api/**").authenticated()
                 .anyRequest().permitAll());

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

@@ -0,0 +1,37 @@
+package com.pharmacopoeia.service;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Service
+public class AnalyticsService {
+
+    private static final Logger log = LoggerFactory.getLogger(AnalyticsService.class);
+    private final JdbcTemplate jdbc;
+
+    public AnalyticsService(JdbcTemplate jdbc) {
+        this.jdbc = jdbc;
+    }
+
+    @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 (?, ?, ?::jsonb, ?, ?, ?, ?)",
+                userKey, eventType, json, pageUrl, referrer, ip, userAgent
+            );
+        } catch (Exception e) {
+            log.warn("埋点写入失败: type={}, error={}", eventType, e.getMessage());
+        }
+    }
+}

+ 23 - 0
database/user_events.sql

@@ -0,0 +1,23 @@
+-- ============================================================
+-- 用户行为埋点表
+-- 执行方式:psql -h <host> -U <user> -d pharmacopoeia -f database/user_events.sql
+-- ============================================================
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS public.user_events (
+    id BIGSERIAL PRIMARY KEY,
+    user_key varchar(128),
+    event_type varchar(64),
+    event_data jsonb,
+    page_url varchar(512),
+    referrer varchar(512),
+    ip varchar(64),
+    user_agent varchar(512),
+    created_at timestamptz DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS ix_user_events_user_key ON user_events(user_key);
+CREATE INDEX IF NOT EXISTS ix_user_events_type ON user_events(event_type);
+CREATE INDEX IF NOT EXISTS ix_user_events_created_at ON user_events(created_at DESC);
+
+COMMIT;

+ 18 - 1
static/index.html

@@ -186,6 +186,20 @@
     window.visualViewport.addEventListener('resize',syncAppHeight);
   }
 
+  // ============================================================
+  // 用户行为埋点(fire-and-forget,不影响主流程)
+  // ============================================================
+  function track(type, data){
+    var payload = {event_type: type, event_data: data || {}, page_url: window.location.href};
+    var h = {'Content-Type': 'application/json'};
+    if (TOKEN) h['Authorization'] = 'Bearer ' + TOKEN;
+    try {
+      fetch(API_BASE + '/api/v1/analytics/events', {
+        method: 'POST', headers: h, body: JSON.stringify(payload), keepalive: true
+      }).catch(function(){});
+    } catch(e) {}
+  }
+
   function authHeaders(){
     var h = {'Content-Type':'application/json'};
     if (TOKEN) h['Authorization'] = 'Bearer ' + TOKEN;
@@ -448,7 +462,7 @@
     document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
   }
 
-  window.quickAsk=function(t){document.getElementById('userInput').value=t;send()};
+  window.quickAsk=function(t){document.getElementById('userInput').value=t;track('click', {label: t});send()};
   function resetSendBtn(){document.getElementById('sendBtn').disabled=false;document.getElementById('stopBtn').style.display='none'}
   window.stopCurrent=function(){STREAMING=false;if(streamAbort){streamAbort.abort();streamAbort=null}resetSendBtn()};
 
@@ -504,6 +518,7 @@
     if(!text&&!hasMedia)return;input.value='';if(STREAMING)return;
     // 强鉴权:无 token 直接弹登录框
     if(!TOKEN){ showLoginModal(); return; }
+    track('chat', {query: text});
     doSend(text,hasMedia?pendingMedia:null);
   };
   function doSend(text,media){
@@ -656,6 +671,7 @@
     renderDrugCards(ALL_DRUGS);
   };
   window.switchTab=function(tab){
+    track('switch_tab', {tab: tab});
     document.querySelectorAll('.nav-btn').forEach(function(b){b.classList.toggle('active',b.dataset.tab===tab)});
     document.getElementById('tabChat').classList.toggle('hidden',tab!=='chat');
     document.getElementById('tabDrugs').classList.toggle('hidden',tab!=='drugs');
@@ -669,6 +685,7 @@
   };
   // 初始化:打开本地数据库 → 恢复对话 → 预加载药品列表 → 加载快捷提问标签
       (async function init(){
+        track('page_view');
         await openChatDB();
         await restoreChat();
         loadDrugList();