liuchengsen 3 недель назад
Родитель
Сommit
e549fb3da2

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

@@ -2,10 +2,8 @@ package com.pharmacopoeia;
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.scheduling.annotation.EnableAsync;
 
 @SpringBootApplication
-@EnableAsync
 public class PharmacopoeiaApplication {
 
     public static void main(String[] args) {

+ 28 - 0
backend-java/src/main/java/com/pharmacopoeia/config/AsyncConfig.java

@@ -0,0 +1,28 @@
+package com.pharmacopoeia.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+    @Bean("analyticsExecutor")
+    public Executor analyticsExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(4);
+        executor.setMaxPoolSize(4);
+        executor.setQueueCapacity(300);
+        executor.setThreadNamePrefix("analytics-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.setWaitForTasksToCompleteOnShutdown(true);
+        executor.setAwaitTerminationSeconds(60);
+        executor.initialize();
+        return executor;
+    }
+}

+ 82 - 0
backend-java/src/main/java/com/pharmacopoeia/security/ApiTrackingFilter.java

@@ -0,0 +1,82 @@
+package com.pharmacopoeia.security;
+
+import com.pharmacopoeia.service.AnalyticsService;
+import com.pharmacopoeia.util.IpUtils;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.Map;
+
+@Component
+public class ApiTrackingFilter extends OncePerRequestFilter {
+
+    private final AnalyticsService analyticsService;
+
+    public ApiTrackingFilter(AnalyticsService analyticsService) {
+        this.analyticsService = analyticsService;
+    }
+
+    @Override
+    protected void doFilterInternal(@NotNull HttpServletRequest request,
+                                    @NotNull HttpServletResponse response,
+                                    @NotNull FilterChain filterChain) throws ServletException, IOException {
+        String requestUri = request.getRequestURI();
+
+        if (!shouldTrack(requestUri)) {
+            filterChain.doFilter(request, response);
+            return;
+        }
+
+        long startTime = System.currentTimeMillis();
+        String ip = IpUtils.getClientIp(request);
+        String userAgent = request.getHeader("User-Agent");
+        String method = request.getMethod();
+        String referrer = request.getHeader("Referer");
+
+        try {
+            filterChain.doFilter(request, response);
+        } finally {
+            long elapsedMs = System.currentTimeMillis() - startTime;
+            int status = response.getStatus();
+            String userKey = getCurrentUserKey();
+
+            Map<String, Object> eventData = Map.of(
+                "url", requestUri,
+                "method", method,
+                "status", status,
+                "elapsed_ms", elapsedMs
+            );
+
+            analyticsService.saveEvent(
+                userKey,
+                "api_call",
+                eventData,
+                requestUri,
+                referrer != null ? referrer : "",
+                ip,
+                userAgent != null ? userAgent : ""
+            );
+        }
+    }
+
+    private boolean shouldTrack(String requestUri) {
+        // Don't track the analytics endpoint itself to avoid infinite loop
+        return !requestUri.startsWith("/api/v1/analytics/events");
+    }
+
+    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";
+    }
+}

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

@@ -21,12 +21,14 @@ public class SecurityConfig {
 
     private final JwtAuthFilter jwtAuthFilter;
     private final RateLimitFilter rateLimitFilter;
+    private final ApiTrackingFilter apiTrackingFilter;
     private final AuthProperties authProperties;
 
     public SecurityConfig(JwtAuthFilter jwtAuthFilter, RateLimitFilter rateLimitFilter,
-                          AuthProperties authProperties) {
+                          ApiTrackingFilter apiTrackingFilter, AuthProperties authProperties) {
         this.jwtAuthFilter = jwtAuthFilter;
         this.rateLimitFilter = rateLimitFilter;
+        this.apiTrackingFilter = apiTrackingFilter;
         this.authProperties = authProperties;
     }
 
@@ -53,8 +55,13 @@ public class SecurityConfig {
             http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
         }
 
+        // Filter order:
+        // 1. RateLimitFilter - first reject rate-limited requests (don't track them)
+        // 2. JwtAuthFilter - authenticate user so userKey is available
+        // 3. ApiTrackingFilter - now we can track with userKey
         http.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class);
         http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
+        http.addFilterBefore(apiTrackingFilter, UsernamePasswordAuthenticationFilter.class);
         return http.build();
     }
 

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

@@ -33,7 +33,7 @@ public class AnalyticsService {
         this.repo = repo;
     }
 
-    @Async
+    @Async("analyticsExecutor")
     public void saveEvent(
             final String userKey,
             final String eventType,

+ 36 - 0
backend-java/src/main/resources/application.yml

@@ -6,6 +6,14 @@ spring:
     username: postgres
     password: pharma2025
     driver-class-name: org.postgresql.Driver
+    hikari:
+      maximum-pool-size: 20
+      minimum-idle: 5
+      connection-timeout: 30000
+      max-lifetime: 1800000
+      idle-timeout: 600000
+      connection-test-query: SELECT 1
+      pool-name: PharmaHikariCP
 
   jpa:
     hibernate:
@@ -20,6 +28,13 @@ spring:
     redis:
       host: localhost
       port: 6379
+      lettuce:
+        pool:
+          max-active: 20
+          max-idle: 10
+          min-idle: 5
+          max-wait: 3000ms
+      timeout: 3000ms
 
   jackson:
     serialization:
@@ -30,8 +45,29 @@ spring:
       max-file-size: 50MB
       max-request-size: 55MB
 
+  task:
+    execution:
+      pool:
+        core-size: 2
+        max-size: 4
+        queue-capacity: 100
+        thread-name-prefix: task-
+      shutdown:
+        await-termination: true
+        await-termination-period: 60s
+
 server:
   port: 9000
+  tomcat:
+    threads:
+      max: 200
+      min-spare: 10
+    accept-count: 100
+    max-connections: 8192
+    max-http-form-post-size: 55MB
+    max-http-header-size: 64KB
+    connection-timeout: 20000ms
+    keep-alive-timeout: 60000ms
 
 # ============================================
 # Qwen API 配置

+ 0 - 33
static/index.html

@@ -224,39 +224,6 @@
     } catch(e) {}
   }
 
-  // 自动埋点所有 fetch 调用
-  (function(){
-    var _fetch = window.fetch;
-    window.fetch = function(url, opts){
-      var t0 = Date.now();
-      var isTracked = (typeof url === 'string') && url.indexOf(API_BASE + '/api/v1/') === 0
-                   && url.indexOf('/analytics/events') === -1;
-      var trackUrl = typeof url === 'string' ? url.replace(API_BASE, '') : '';
-      var method = (opts && opts.method) || 'GET';
-      return _fetch.apply(this, arguments).then(function(r){
-        if (isTracked) {
-          track('api_call', {
-            url: trackUrl,
-            method: method,
-            status: r.status,
-            elapsed_ms: Date.now() - t0
-          });
-        }
-        return r;
-      }).catch(function(e){
-        if (isTracked) {
-          track('api_call', {
-            url: trackUrl,
-            method: method,
-            status: 0,
-            elapsed_ms: Date.now() - t0,
-            error: e.message || 'network_error'
-          });
-        }
-        throw e;
-      });
-    };
-  })();
 
   function authHeaders(){
     var h = {'Content-Type':'application/json'};