liuchengsen 3 тижнів тому
батько
коміт
5c3b21bdde

+ 34 - 0
backend-java/src/main/java/com/pharmacopoeia/config/AnalyticsProperties.java

@@ -0,0 +1,34 @@
+package com.pharmacopoeia.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 埋点接入配置。
+ * <p>
+ * {@code systems} 同时承担两个职责:
+ * 1) 白名单——只有出现在此 map 中的系统名才允许上报;
+ * 2) 签名密钥——每个系统配独立 HMAC 密钥,便于按系统发放/轮换/回收。
+ * <p>
+ * 后期接入新系统或调整密钥时,仅需在此配置中增删条目,无需改动代码。
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "analytics")
+public class AnalyticsProperties {
+
+    /** 允许接入的埋点系统及其签名密钥:key=系统名(白名单),value=HMAC-SHA256 密钥 */
+    private Map<String, String> systems = new LinkedHashMap<>();
+
+    private Signature signature = new Signature();
+
+    @Data
+    public static class Signature {
+        /** 是否启用签名校验。上线外网保持 true;本地调试可临时设 false */
+        private boolean enabled = true;
+    }
+}

+ 18 - 4
backend-java/src/main/java/com/pharmacopoeia/controller/AnalyticsController.java

@@ -27,12 +27,13 @@ public class AnalyticsController {
     @GetMapping("/events")
     public ResponseEntity<ApiResponse<Map<String, Object>>> queryEvents(
             @RequestParam(required = false) String eventType,
+            @RequestParam(required = false) String system,
             @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.queryEvents(eventType, startDate, endDate, page, pageSize)));
+                analyticsService.queryEvents(eventType, system, startDate, endDate, page, pageSize)));
     }
 
     @GetMapping("/events/{id}")
@@ -47,6 +48,9 @@ public class AnalyticsController {
     @PostMapping("/events")
     public ResponseEntity<ApiResponse<Map<String, String>>> saveEvent(@RequestBody Map<String, Object> body) {
         String eventType = (String) body.getOrDefault("event_type", "unknown");
+        // 来源系统:用于区分不同系统的埋点,缺省归入 AI药典
+        String system = body.get("system") instanceof String s && !s.isBlank()
+                ? s : "AI药典";
         @SuppressWarnings("unchecked")
         Map<String, Object> eventData = body.get("event_data") instanceof Map
                 ? (Map<String, Object>) body.get("event_data") : Map.of();
@@ -58,7 +62,7 @@ public class AnalyticsController {
         String ua = request.getHeader("User-Agent");
 
         analyticsService.saveEvent(userKey, eventType, eventData, pageUrl, referrer,
-                ip, ua != null ? ua : "");
+                ip, ua != null ? ua : "", system);
 
         return ResponseEntity.accepted()
                 .body(ApiResponse.<Map<String, String>>builder().code(202).message("accepted")
@@ -73,6 +77,14 @@ public class AnalyticsController {
         return ResponseEntity.ok(ApiResponse.success(analyticsService.getEventTypes()));
     }
 
+    // ==================== 来源系统列表 ====================
+
+    /** 获取当前所有来源系统(AI药典恒在且首位),供前端系统切换下拉 */
+    @GetMapping("/systems")
+    public ResponseEntity<ApiResponse<List<String>>> getSystems() {
+        return ResponseEntity.ok(ApiResponse.success(analyticsService.getSystems()));
+    }
+
     // ==================== 概览 ====================
 
     /** 概览数字:total_events / active_users / page_views / searches / ai_qa
@@ -81,10 +93,11 @@ public class AnalyticsController {
     @GetMapping("/stats/overview")
     public ResponseEntity<ApiResponse<Map<String, Object>>> queryOverview(
             @RequestParam(required = false) String range,
+            @RequestParam(required = false) String system,
             @RequestParam(required = false) String startDate,
             @RequestParam(required = false) String endDate) {
         return ResponseEntity.ok(ApiResponse.success(
-                analyticsService.queryOverview(range != null ? range : "total", startDate, endDate)));
+                analyticsService.queryOverview(range != null ? range : "total", system, startDate, endDate)));
     }
 
     // ==================== 维度 ====================
@@ -93,12 +106,13 @@ public class AnalyticsController {
     @GetMapping("/stats/dimension")
     public ResponseEntity<ApiResponse<Map<String, Object>>> queryDimension(
             @RequestParam String dimension,
+            @RequestParam(required = false) String system,
             @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)));
+                analyticsService.queryDimension(dimension, system, startDate, endDate, page, pageSize)));
     }
 
     private String getCurrentUserKey() {

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

@@ -23,6 +23,10 @@ public class UserEvent {
     @Column(name = "user_key", length = 128)
     private String userKey;
 
+    /** 埋点来源系统,用于区分不同业务系统的埋点,默认 AI药典 */
+    @Column(name = "system_source", length = 64)
+    private String systemSource;
+
     @Column(name = "event_type", length = 64)
     private String eventType;
 

+ 62 - 22
backend-java/src/main/java/com/pharmacopoeia/repository/UserEventRepository.java

@@ -16,100 +16,140 @@ import java.util.List;
 public interface UserEventRepository extends JpaRepository<UserEvent, Long>,
         JpaSpecificationExecutor<UserEvent> {
 
+    // systemSource 为 null 时不过滤(用于 system=all 跨系统统计)
     /** 总数 */
     @Query("SELECT COUNT(e) FROM UserEvent e " +
-           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime")
-    long countTotal(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource)")
+    long countTotal(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                    @Param("systemSource") String systemSource);
 
     /** 去重活跃用户数 */
     @Query("SELECT COUNT(DISTINCT e.userKey) FROM UserEvent e " +
-           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime")
-    long countDistinctUsers(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource)")
+    long countDistinctUsers(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                            @Param("systemSource") String systemSource);
 
     /** 去重 IP 数 */
     @Query("SELECT COUNT(DISTINCT e.ip) FROM UserEvent e " +
-           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime")
-    long countDistinctIps(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource)")
+    long countDistinctIps(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                          @Param("systemSource") String systemSource);
 
     /** 按类型 count */
     @Query("SELECT COUNT(e) FROM UserEvent e " +
            "WHERE e.eventType = :eventType " +
-           "AND e.createdAt >= :startTime AND e.createdAt < :endTime")
+           "AND e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource)")
     long countByType(@Param("eventType") String eventType,
-                     @Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+                     @Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                     @Param("systemSource") String systemSource);
 
     // ==================== 分组统计(全量,供其他场景用) ====================
 
     @Query("SELECT e.ip as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "GROUP BY e.ip ORDER BY cnt DESC")
-    List<Object[]> countByIp(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+    List<Object[]> countByIp(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                             @Param("systemSource") String systemSource);
 
     @Query("SELECT e.pageUrl as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "GROUP BY e.pageUrl ORDER BY cnt DESC")
-    List<Object[]> countByPageUrl(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+    List<Object[]> countByPageUrl(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                                  @Param("systemSource") String systemSource);
 
     @Query("SELECT e.eventType as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "GROUP BY e.eventType ORDER BY cnt DESC")
-    List<Object[]> countByEventType(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+    List<Object[]> countByEventType(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                                    @Param("systemSource") String systemSource);
 
     @Query("SELECT e.userKey as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "GROUP BY e.userKey ORDER BY cnt DESC")
-    List<Object[]> countByUserKey(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+    List<Object[]> countByUserKey(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                                  @Param("systemSource") String systemSource);
 
     @Query("SELECT e.userKey, e.ip, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "GROUP BY e.userKey, e.ip ORDER BY e.userKey, cnt DESC")
-    List<Object[]> countByUserIp(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
+    List<Object[]> countByUserIp(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime,
+                                 @Param("systemSource") String systemSource);
 
     // ==================== 分组统计(分页) ====================
 
     @Query(value = "SELECT e.ip as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "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")
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource)")
     Page<Object[]> countByIpPaged(@Param("startTime") Instant startTime,
-                                   @Param("endTime") Instant endTime, Pageable pageable);
+                                   @Param("endTime") Instant endTime,
+                                   @Param("systemSource") String systemSource, Pageable pageable);
 
     @Query(value = "SELECT e.pageUrl as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "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")
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource)")
     Page<Object[]> countByPageUrlPaged(@Param("startTime") Instant startTime,
-                                        @Param("endTime") Instant endTime, Pageable pageable);
+                                        @Param("endTime") Instant endTime,
+                                        @Param("systemSource") String systemSource, Pageable pageable);
 
     @Query(value = "SELECT e.eventType as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "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")
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource)")
     Page<Object[]> countByEventTypePaged(@Param("startTime") Instant startTime,
-                                          @Param("endTime") Instant endTime, Pageable pageable);
+                                          @Param("endTime") Instant endTime,
+                                          @Param("systemSource") String systemSource, Pageable pageable);
 
     @Query(value = "SELECT e.userKey as key, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "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")
+           "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource)")
     Page<Object[]> countByUserKeyPaged(@Param("startTime") Instant startTime,
-                                        @Param("endTime") Instant endTime, Pageable pageable);
+                                        @Param("endTime") Instant endTime,
+                                        @Param("systemSource") String systemSource, Pageable pageable);
 
     @Query(value = "SELECT e.userKey, e.ip, COUNT(e) as cnt FROM UserEvent e " +
            "WHERE e.createdAt >= :startTime AND e.createdAt < :endTime " +
+           "AND (:systemSource IS NULL OR e.systemSource = :systemSource) " +
            "GROUP BY e.userKey, e.ip ORDER BY e.userKey, cnt DESC",
            countQuery = "SELECT COUNT(*) FROM (" +
                "SELECT e2.userKey AS userKey, e2.ip AS ip FROM UserEvent e2 " +
                "WHERE e2.createdAt >= :startTime AND e2.createdAt < :endTime " +
+               "AND (:systemSource IS NULL OR e2.systemSource = :systemSource) " +
                "GROUP BY e2.userKey, e2.ip) sub")
     Page<Object[]> countByUserIpPaged(@Param("startTime") Instant startTime,
-                                       @Param("endTime") Instant endTime, Pageable pageable);
+                                       @Param("endTime") Instant endTime,
+                                       @Param("systemSource") String systemSource, Pageable pageable);
 
     /** 获取所有去重事件类型,供前端筛选 */
     @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();
 }

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

@@ -33,6 +33,9 @@ public class AnalyticsService {
         this.repo = repo;
     }
 
+    /** 默认来源系统:当前业务系统 */
+    private static final String DEFAULT_SYSTEM = "AI药典";
+
     @Async("analyticsExecutor")
     public void saveEvent(
             final String userKey,
@@ -41,7 +44,8 @@ public class AnalyticsService {
             final String pageUrl,
             final String referrer,
             final String ip,
-            final String userAgent) {
+            final String userAgent,
+            final String systemSource) {
         try {
             // Truncate data to avoid sql errors when user-agent is longer than the column, etc.
             UserEvent event = UserEvent.builder()
@@ -52,6 +56,7 @@ public class AnalyticsService {
                     .referrer(trunc(referrer, 512))
                     .ip(trunc(ip, 64))
                     .userAgent(trunc(userAgent, 512))
+                    .systemSource(trunc(normalizeSystem(systemSource), 64))
                     .build();
             repo.save(event);
         } catch (Exception e) {
@@ -59,6 +64,34 @@ public class AnalyticsService {
         }
     }
 
+    /** 兼容旧调用方,默认归入 AI药典 系统 */
+    public void saveEvent(
+            final String userKey,
+            final String eventType,
+            @SuppressWarnings("rawtypes") final Map eventData,
+            final String pageUrl,
+            final String referrer,
+            final String ip,
+            final String userAgent) {
+        saveEvent(userKey, eventType, eventData, pageUrl, referrer, ip, userAgent, DEFAULT_SYSTEM);
+    }
+
+    private static String normalizeSystem(String systemSource) {
+        return (systemSource == null || systemSource.isBlank()) ? DEFAULT_SYSTEM : systemSource;
+    }
+
+    /**
+     * 查询用系统过滤归一化:
+     * null/空 → 默认 AI药典(只看本系统);
+     * "all"(大小写无关)→ null,表示跨系统全量统计;
+     * 其它值 → 原样返回,按该系统过滤(如 "中医")。
+     */
+    private static String normalizeSystemFilter(String systemSource) {
+        if (systemSource == null || systemSource.isBlank()) return DEFAULT_SYSTEM;
+        if ("all".equalsIgnoreCase(systemSource)) return null;
+        return systemSource;
+    }
+
     private static String trunc(String s, int max) {
         if (s == null) return null;
         return s.length() <= max ? s : s.substring(0, max);
@@ -66,14 +99,15 @@ public class AnalyticsService {
 
     // ==================== 埋点事件列表 ====================
 
-    public Map<String, Object> queryEvents(String eventType, String startDate, String endDate,
+    public Map<String, Object> queryEvents(String eventType, String systemSource,
+                                           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()
                 : FAR_FUTURE;
 
-        Specification<UserEvent> spec = buildTimeSpec(eventType, startTime, endTime);
+        Specification<UserEvent> spec = buildTimeSpec(eventType, normalizeSystemFilter(systemSource), startTime, endTime);
         var pageable = PageRequest.of(page - 1, pageSize, Sort.by(Sort.Direction.DESC, "createdAt"));
         var result = repo.findAll(spec, pageable);
 
@@ -81,6 +115,7 @@ public class AnalyticsService {
                 .map(e -> {
                     Map<String, Object> m = new LinkedHashMap<>();
                     m.put("id", e.getId());
+                    m.put("system", e.getSystemSource());
                     m.put("event_type", e.getEventType());
                     m.put("user_key", e.getUserKey());
                     m.put("page_url", e.getPageUrl());
@@ -104,6 +139,7 @@ public class AnalyticsService {
         var e = opt.get();
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("id", e.getId());
+        m.put("system", e.getSystemSource());
         m.put("user_key", e.getUserKey());
         m.put("event_type", e.getEventType());
         m.put("event_data", e.getEventData());
@@ -120,13 +156,16 @@ public class AnalyticsService {
     /**
      * 统计概览
      * @param range today | yesterday | week | total | custom
+     * @param systemSource 来源系统:null/空 → 默认 AI药典;"all" → 跨系统全量;其它值 → 按该系统过滤
      * @param startDate custom 的起始日期(yyyy-MM-dd),仅 range=custom 时生效
      * @param endDate   custom 的结束日期(yyyy-MM-dd),仅 range=custom 时生效
      */
-    public Map<String, Object> queryOverview(String range, String startDate, String endDate) {
+    public Map<String, Object> queryOverview(String range, String systemSource,
+                                            String startDate, String endDate) {
         if (range == null || range.isBlank()) {
             range = "total";
         }
+        String systemFilter = normalizeSystemFilter(systemSource);
         Instant todayStart = parseDate(LocalDate.now().toString());
         Instant todayEnd = LocalDate.now().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
 
@@ -167,45 +206,49 @@ public class AnalyticsService {
 
         Map<String, Object> overview = new LinkedHashMap<>();
         overview.put("range", label);
-        overview.put("total_events", repo.countTotal(startTime, endTime));
-        overview.put("active_users", repo.countDistinctUsers(startTime, endTime));
-        overview.put("unique_ips", repo.countDistinctIps(startTime, endTime));
-        overview.put("page_views", repo.countByType("page_view", startTime, endTime));
-        overview.put("searches", repo.countByType("search", startTime, endTime));
-        overview.put("ai_qa", repo.countByType("chat", startTime, endTime));
+        overview.put("system", systemFilter != null ? systemFilter : "all");
+        overview.put("total_events", repo.countTotal(startTime, endTime, systemFilter));
+        overview.put("active_users", repo.countDistinctUsers(startTime, endTime, systemFilter));
+        overview.put("unique_ips", repo.countDistinctIps(startTime, endTime, systemFilter));
+        overview.put("page_views", repo.countByType("page_view", startTime, endTime, systemFilter));
+        overview.put("searches", repo.countByType("search", startTime, endTime, systemFilter));
+        overview.put("ai_qa", repo.countByType("chat", startTime, endTime, systemFilter));
         return overview;
     }
 
     // ==================== 维度分页统计 ====================
 
-    /** 分页维度统计:ip / url / type / user / user_ip */
-    public Map<String, Object> queryDimension(String dimension, String startDate, String endDate,
+    /** 分页维度统计:ip / url / type / user / user_ip
+     *  systemSource 来源系统:null/空 → 默认 AI药典;"all" → 跨系统全量;其它值 → 按该系统过滤 */
+    public Map<String, Object> queryDimension(String dimension, String systemSource,
+                                              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()
                 : FAR_FUTURE;
+        String systemFilter = normalizeSystemFilter(systemSource);
 
         Page<Object[]> result;
         switch (dimension != null ? dimension : "") {
             case "ip":
-                result = repo.countByIpPaged(startTime, endTime,
+                result = repo.countByIpPaged(startTime, endTime, systemFilter,
                         PageRequest.of(page - 1, pageSize));
                 break;
             case "url":
-                result = repo.countByPageUrlPaged(startTime, endTime,
+                result = repo.countByPageUrlPaged(startTime, endTime, systemFilter,
                         PageRequest.of(page - 1, pageSize));
                 break;
             case "type":
-                result = repo.countByEventTypePaged(startTime, endTime,
+                result = repo.countByEventTypePaged(startTime, endTime, systemFilter,
                         PageRequest.of(page - 1, pageSize));
                 break;
             case "user":
-                result = repo.countByUserKeyPaged(startTime, endTime,
+                result = repo.countByUserKeyPaged(startTime, endTime, systemFilter,
                         PageRequest.of(page - 1, pageSize));
                 break;
             case "user_ip":
-                result = repo.countByUserIpPaged(startTime, endTime,
+                result = repo.countByUserIpPaged(startTime, endTime, systemFilter,
                         PageRequest.of(page - 1, pageSize));
                 break;
             default:
@@ -215,6 +258,7 @@ public class AnalyticsService {
         List<Map<String, Object>> items = mapResult(result.getContent(), dimension);
         Map<String, Object> response = new LinkedHashMap<>();
         response.put("dimension", dimension);
+        response.put("system", systemFilter != null ? systemFilter : "all");
         response.put("items", items);
         response.put("total", result.getTotalElements());
         response.put("page", page);
@@ -247,14 +291,34 @@ public class AnalyticsService {
         return repo.findDistinctEventTypes();
     }
 
+    /**
+     * 返回当前所有出现过的来源系统,供前端系统切换下拉。
+     * AI药典(本系统)恒定存在且排首位;其余按字母序追加。
+     */
+    public List<String> getSystems() {
+        List<String> distinct = repo.findDistinctSystemSources();
+        List<String> systems = new ArrayList<>();
+        systems.add(DEFAULT_SYSTEM);
+        for (String s : distinct) {
+            if (!DEFAULT_SYSTEM.equals(s)) {
+                systems.add(s);
+            }
+        }
+        return systems;
+    }
+
     // ==================== Helpers ====================
 
-    private Specification<UserEvent> buildTimeSpec(String eventType, Instant startTime, Instant endTime) {
+    private Specification<UserEvent> buildTimeSpec(String eventType, String systemSource,
+                                                   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));
             }
+            if (systemSource != null && !systemSource.isBlank()) {
+                predicates.add(cb.equal(root.get("systemSource"), systemSource));
+            }
             predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startTime));
             predicates.add(cb.lessThan(root.get("createdAt"), endTime));
             return cb.and(predicates.toArray(new Predicate[0]));

+ 17 - 0
database/migrate_user_events_system.sql

@@ -0,0 +1,17 @@
+-- ============================================================
+-- 增量迁移:user_events 增加 system_source 列,区分不同系统的埋点
+-- 执行方式:psql -h <host> -U <user> -d pharmacopoeia -f database/migrate_user_events_system.sql
+-- ============================================================
+BEGIN;
+
+ALTER TABLE public.user_events
+    ADD COLUMN IF NOT EXISTS system_source varchar(64) DEFAULT 'AI药典';
+
+-- 历史数据全部归入 AI药典(当前业务系统)
+UPDATE public.user_events
+   SET system_source = 'AI药典'
+ WHERE system_source IS NULL;
+
+CREATE INDEX IF NOT EXISTS ix_user_events_system ON public.user_events(system_source);
+
+COMMIT;

+ 2 - 0
database/user_events.sql

@@ -7,6 +7,7 @@ BEGIN;
 CREATE TABLE IF NOT EXISTS public.user_events (
     id BIGSERIAL PRIMARY KEY,
     user_key varchar(128),
+    system_source varchar(64) DEFAULT 'AI药典',
     event_type varchar(64),
     event_data jsonb,
     page_url varchar(512),
@@ -17,6 +18,7 @@ CREATE TABLE IF NOT EXISTS public.user_events (
 );
 
 CREATE INDEX IF NOT EXISTS ix_user_events_user_key ON user_events(user_key);
+CREATE INDEX IF NOT EXISTS ix_user_events_system ON user_events(system_source);
 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);
 CREATE INDEX IF NOT EXISTS ix_user_events_ip ON user_events(ip);

+ 26 - 62
docs/ANALYTICS_API.md

@@ -27,6 +27,7 @@ Authorization: Bearer <token>   (可选)
 | 字段 | 类型 | 必填 | 说明 |
 |------|------|------|------|
 | event_type | string | 否 | 事件类型,不传则默认为 `unknown` |
+| system | string | 否 | 来源系统,用于区分不同业务系统的埋点。不传默认 `AI药典`;接入方按自身系统传值即可,如 `中医` |
 | event_data | object | 否 | 事件附加数据,JSON 对象 |
 | page_url | string | 否 | 触发事件的页面 URL(建议传完整地址) |
 | referrer | string | 否 | 来源页面 URL |
@@ -40,6 +41,7 @@ Authorization: Bearer <token>   (可选)
 | user_key | JWT Token 中的用户标识,未登录为 `anonymous` |
 | ip | 请求来源 IP |
 | user_agent | `User-Agent` 请求头 |
+| system_source | 取自请求体 `system`,缺省记为 `AI药典`,超长自动截断至 64 字符 |
 
 ### 响应
 
@@ -62,6 +64,7 @@ curl -X POST https://pharmacopoeia.kailin.com.cn/api/v1/analytics/events \
   -H "Content-Type: application/json" \
   -d '{
     "event_type": "page_view",
+    "system": "AI药典",
     "event_data": {},
     "page_url": "https://pharmacopoeia.kailin.com.cn/?token=xxx"
   }'
@@ -110,66 +113,6 @@ curl -X POST https://pharmacopoeia.kailin.com.cn/api/v1/analytics/events \
 
 > 品牌推荐匹配由后端在 SSE 流中自动注入 `brand_recommend` 事件,前端收到后渲染推荐卡片。事件数据详见 HANDOVER.md 第十六章节。
 
-### 管理端——品牌管理 API
-
-| 方法 | 路径 | 说明 |
-|------|------|------|
-| GET | `/api/v1/admin/knowledge/brands` | 品牌列表(分页+搜索 keyword/name) |
-| POST | `/api/v1/admin/knowledge/brands` | 新增品牌(name + 10个长文本字段 + jump_url + sort_order) |
-| PUT | `/api/v1/admin/knowledge/brands/{id}` | 修改品牌 |
-| DELETE | `/api/v1/admin/knowledge/brands/{id}` | 删除品牌 |
-| POST | `/api/v1/admin/knowledge/brands/import` | 品牌 CSV 导入 |
-| GET | `/api/v1/admin/knowledge/brands/export` | 品牌 CSV 导出 |
-| GET | `/api/v1/admin/knowledge/brands/template` | 下载品牌导入模板 |
-
-### 管理端——品牌推荐规则 API
-
-| 方法 | 路径 | 说明 |
-|------|------|------|
-| GET | `/api/v1/admin/knowledge/brand-recommend-rules` | 规则列表(分页+搜索 keyword) |
-| POST | `/api/v1/admin/knowledge/brand-recommend-rules` | 新增规则(keyword + brand_id + tier) |
-| PUT | `/api/v1/admin/knowledge/brand-recommend-rules/{id}` | 修改规则 |
-| PATCH | `/api/v1/admin/knowledge/brand-recommend-rules/{id}/toggle` | 启用/停用规则 |
-| DELETE | `/api/v1/admin/knowledge/brand-recommend-rules/{id}` | 删除规则 |
-| POST | `/api/v1/admin/knowledge/brand-recommend-rules/import` | 规则 CSV 导入 |
-| GET | `/api/v1/admin/knowledge/brand-recommend-rules/export` | 规则 CSV 导出 |
-| GET | `/api/v1/admin/knowledge/brand-recommend-rules/template` | 下载规则导入模板 |
-
-### 品牌实体字段
-
-```json
-{
-  "id": 1,
-  "name": "芬必得布洛芬缓释胶囊",
-  "function_indication": "用于缓解轻至中度疼痛...",
-  "usage_dosage": "口服。成人一次1粒,一日2次...",
-  "contraindication": "对本品过敏者禁用...",
-  "ingredients": "每粒含布洛芬300mg",
-  "properties": "本品为胶囊剂,内容物为白色粉末",
-  "specification": "0.3g×20粒/盒",
-  "adverse_reactions": "偶见恶心、呕吐...",
-  "precautions": "孕妇及哺乳期妇女慎用...",
-  "execution_standard": "《中国药典》2025年版二部",
-  "storage": "密封,在阴凉干燥处保存",
-  "jump_url": "https://example.com/product/123",
-  "description": "知名止痛品牌",
-  "sort_order": 0,
-  "is_active": true
-}
-```
-
-### 匹配规则实体字段
-
-```json
-{
-  "id": 1,
-  "keyword": "布洛芬",
-  "brand_id": 1,
-  "tier": 1,
-  "is_active": true
-}
-```
-
 ### 接口调用(自动埋点)
 
 | event_type | 触发时机 | event_data |
@@ -179,14 +122,35 @@ curl -X POST https://pharmacopoeia.kailin.com.cn/api/v1/analytics/events \
 
 ---
 
-## 三、前端接入示例
+## 三、来源系统(system)
+
+埋点按 `system` 字段区分来源系统,便于多业务系统共用一套埋点时分别统计。
+
+### 取值约定
+
+| 传入值 | 含义 |
+|--------|------|
+| 不传 / 空 | 默认归入 `AI药典`(当前业务系统) |
+| `AI药典` | 本系统 |
+| `中医` | 中医系统(示例,按接入方实际系统名传值) |
+| 其它字符串 | 接入方自定义系统名,后端原样存储,自动截断至 64 字符 |
+
+> 服务端自动埋点(`api_call`)统一记为 `AI药典`,无需接入方处理。
+
+---
+
+## 四、前端接入示例
 
 ### 最小接入(推荐封装 track 函数)
 
 ```javascript
+// SYSTEM 为当前接入方系统名,不传后端默认记为 AI药典
+var SYSTEM = '中医';
+
 function track(eventType, eventData) {
   var payload = {
     event_type: eventType,
+    system: SYSTEM,
     event_data: eventData || {},
     page_url: window.location.href,
     referrer: document.referrer || ''
@@ -253,7 +217,7 @@ track('custom_event', { key1: 'value1', key2: 'value2' });
 
 ---
 
-## 、注意事项
+## 、注意事项
 
 1. **fire-and-forget** — 埋点接口返回 202,异步写入,前端不要 await,不要阻塞主流程
 2. **keepalive** — fetch 带上 `keepalive: true`,确保页面关闭/跳转时也能发出