Kaynağa Gözat

关键词推荐更新

liuchengsen 4 hafta önce
ebeveyn
işleme
0dec6ee47d

+ 102 - 1
backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java

@@ -5,6 +5,7 @@ import com.pharmacopoeia.config.UnifiedResponse;
 import com.pharmacopoeia.dto.*;
 import com.pharmacopoeia.entity.QuickAsk;
 import com.pharmacopoeia.service.AdminKnowledgeService;
+import com.pharmacopoeia.service.BrandRecommendService;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
@@ -16,10 +17,13 @@ import java.util.*;
 public class AdminKnowledgeController {
 
     private final AdminKnowledgeService service;
+    private final BrandRecommendService brandRecommendService;
     private final ObjectMapper mapper = new ObjectMapper();
 
-    public AdminKnowledgeController(AdminKnowledgeService service) {
+    public AdminKnowledgeController(AdminKnowledgeService service,
+                                     BrandRecommendService brandRecommendService) {
         this.service = service;
+        this.brandRecommendService = brandRecommendService;
     }
 
     // ==================== 药品管理 ====================
@@ -245,6 +249,103 @@ public class AdminKnowledgeController {
         return ResponseEntity.ok(service.listQuickAskAuditLog(page, pageSize));
     }
 
+   // ==================== 品牌管理 ====================
+
+    @GetMapping("/brands")
+    public ResponseEntity<Map<String, Object>> listBrands(
+            @RequestParam(required = false) String keyword,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(brandRecommendService.listBrands(keyword, page, pageSize));
+    }
+
+    @PostMapping("/brands")
+    public ResponseEntity<Map<String, Object>> createBrand(@RequestBody Map<String, Object> body) {
+        return ResponseEntity.ok(brandRecommendService.createBrand(body));
+    }
+
+    @PutMapping("/brands/{id}")
+    public ResponseEntity<Map<String, Object>> updateBrand(
+            @PathVariable int id, @RequestBody Map<String, Object> body) {
+        return ResponseEntity.ok(brandRecommendService.updateBrand(id, body));
+    }
+
+    @DeleteMapping("/brands/{id}")
+    public ResponseEntity<Map<String, Object>> deleteBrand(@PathVariable int id) {
+        return ResponseEntity.ok(brandRecommendService.deleteBrand(id));
+    }
+
+    @PostMapping("/brands/import")
+    public ResponseEntity<Map<String, Object>> importBrands(
+            @RequestBody Map<String, String> body) {
+        String csv = body.get("csv");
+        return ResponseEntity.ok(brandRecommendService.importBrandsCsv(csv));
+    }
+
+    @GetMapping("/brands/export")
+    public ResponseEntity<Map<String, Object>> exportBrands() {
+        String csv = brandRecommendService.exportBrandsCsv();
+        return ResponseEntity.ok(Map.of("csv", csv));
+    }
+
+    @GetMapping("/brands/template")
+    public ResponseEntity<String> brandTemplate() {
+        return ResponseEntity.ok("name,function_indication,usage_dosage,contraindication,ingredients,properties,specification,adverse_reactions,precautions,execution_standard,storage,jump_url,description,sort_order");
+    }
+
+   // ==================== 品牌推荐规则管理 ====================
+
+    @GetMapping("/brand-recommend-rules")
+    public ResponseEntity<Map<String, Object>> listBrandRecommendRules(
+            @RequestParam(required = false) String keyword,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(brandRecommendService.listRules(keyword, page, pageSize));
+    }
+
+    @PostMapping("/brand-recommend-rules")
+    public ResponseEntity<Map<String, Object>> createBrandRecommendRule(
+            @RequestBody Map<String, Object> body) {
+        String keyword = (String) body.get("keyword");
+        int brandId = body.get("brand_id") instanceof Number n ? n.intValue() : 0;
+        int tier = body.get("tier") instanceof Number n ? n.intValue() : 1;
+        return ResponseEntity.ok(brandRecommendService.createRule(keyword, brandId, tier));
+    }
+
+    @PutMapping("/brand-recommend-rules/{id}")
+    public ResponseEntity<Map<String, Object>> updateBrandRecommendRule(
+            @PathVariable int id,
+            @RequestBody Map<String, Object> body) {
+        String keyword = (String) body.get("keyword");
+        Integer brandId = body.get("brand_id") instanceof Number n ? n.intValue() : null;
+        Integer tier = body.get("tier") instanceof Number n ? n.intValue() : null;
+        Boolean isActive = body.get("is_active") instanceof Boolean b ? b : null;
+        return ResponseEntity.ok(brandRecommendService.updateRule(id, keyword, brandId, tier, isActive));
+    }
+
+    @DeleteMapping("/brand-recommend-rules/{id}")
+    public ResponseEntity<Map<String, Object>> deleteBrandRecommendRule(@PathVariable int id) {
+        return ResponseEntity.ok(brandRecommendService.deleteRule(id));
+    }
+
+    @PostMapping("/brand-recommend-rules/import")
+    public ResponseEntity<Map<String, Object>> importBrandRecommendRules(
+            @RequestBody Map<String, String> body) {
+        String csv = body.get("csv");
+        return ResponseEntity.ok(brandRecommendService.importRulesCsv(csv));
+    }
+
+    @GetMapping("/brand-recommend-rules/export")
+    public ResponseEntity<Map<String, Object>> exportBrandRecommendRules() {
+        String csv = brandRecommendService.exportRulesCsv();
+        return ResponseEntity.ok(Map.of("csv", csv));
+    }
+
+    @GetMapping("/brand-recommend-rules/template")
+    public ResponseEntity<String> brandRuleTemplate() {
+        return ResponseEntity.ok("keyword,brand_id,tier");
+    }
+
    // ==================== Helpers ====================
 
     private int parseCount(ImportRequest req) {

+ 44 - 11
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -36,6 +36,7 @@ public class ChatController {
     private final ChatPersistenceService persistenceService;
     private final RerankerService rerankerService;
     private final QACacheService qaCache;
+    private final BrandRecommendService brandRecommendService;
     private final JdbcTemplate jdbc;
     private final QwenProperties props;
     private final HttpServletRequest request;
@@ -43,6 +44,7 @@ public class ChatController {
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
                           ChatPersistenceService cps, RerankerService rrs,
                           QACacheService qaCache,
+                          BrandRecommendService brandRecommendService,
                           JdbcTemplate jdbc, QwenProperties props,
                           HttpServletRequest request) {
         this.retrieverService = rs;
@@ -51,6 +53,7 @@ public class ChatController {
         this.persistenceService = cps;
         this.rerankerService = rrs;
         this.qaCache = qaCache;
+        this.brandRecommendService = brandRecommendService;
         this.jdbc = jdbc;
         this.props = props;
         this.request = request;
@@ -72,11 +75,13 @@ public class ChatController {
             String intent = (String) cached.getOrDefault("intent", "");
             @SuppressWarnings("unchecked")
             List<Map<String, Object>> sources = (List<Map<String, Object>>) cached.getOrDefault("sources", List.of());
+            List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
             persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
-            persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources);
+            persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
             return ResponseEntity.ok(Map.of(
                     "answer", answer,
                     "sources", sources,
+                    "brand_recommendations", brandRecs,
                     "intent", intent,
                     "conversation_id", cid,
                     "cached", true
@@ -91,10 +96,13 @@ public class ChatController {
                 String intent = (String) waited.getOrDefault("intent", "");
                 @SuppressWarnings("unchecked")
                 List<Map<String, Object>> sources = (List<Map<String, Object>>) waited.getOrDefault("sources", List.of());
+                List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
                 persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
-                persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources);
+                persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
                 return ResponseEntity.ok(Map.of(
-                        "answer", answer, "sources", sources, "intent", intent,
+                        "answer", answer, "sources", sources,
+                        "brand_recommendations", brandRecs,
+                        "intent", intent,
                         "conversation_id", cid, "cached", true
                 ));
             }
@@ -118,8 +126,9 @@ public class ChatController {
             throw e;
         }
         String answer = llmAnswer;
+        List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
         persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
-        persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources);
+        persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
 
         // 写入全局缓存
         qaCache.put(normalized, answer, intent, sources);
@@ -127,6 +136,7 @@ public class ChatController {
         return ResponseEntity.ok(Map.of(
                 "answer", answer,
                 "sources", sources,
+                "brand_recommendations", brandRecs,
                 "intent", intent,
                 "conversation_id", cid
         ));
@@ -189,6 +199,7 @@ public class ChatController {
                     Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
                         String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
                         final List<Map<String, Object>> sources = buildSources(docs);
+                        final List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, finalAnswer);
                         String meta;
                         try {
                             meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
@@ -202,10 +213,11 @@ public class ChatController {
                         long t4 = System.currentTimeMillis();
                         log.info("[chatStream] chatStream done, llmElapsed={}ms, total={}ms", t4 - t3, t4 - t0);
                         persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
-                        persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
+                        persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources, brandRecs);
                         qaCache.put(normalized, finalAnswer, intent, sources);
 
                         return Flux.just(
+                                buildBrandRecommendEvent(brandRecs),
                                 ServerSentEvent.<String>builder().event("meta").data(meta).build()
                         );
                     });
@@ -234,7 +246,8 @@ public class ChatController {
         List<Map<String, Object>> sources = (List<Map<String, Object>>) cached.getOrDefault("sources", List.of());
 
         persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
-        persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources);
+        List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
+        persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
 
         return Flux.create(sink -> {
             sink.next(ServerSentEvent.<String>builder().event("intent").data(intent).build());
@@ -244,6 +257,7 @@ public class ChatController {
             for (String chunk : chunks) {
                 sink.next(ServerSentEvent.<String>builder().data(chunk).build());
             }
+            sink.next(buildBrandRecommendEvent(brandRecs));
             try {
                 String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
                         "intent", intent, "sources", sources, "conversation_id", cid, "cached", true
@@ -287,14 +301,16 @@ public class ChatController {
         String answer = cleanAnswer(llmService.chat(messages, true));
 
         List<Map<String, Object>> sources = buildSources(docs);
+        List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
         persistenceService.saveMessage(userKey, cid, "user",
                 request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
                 intent, null);
-        persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources);
+        persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
 
         return ResponseEntity.ok(Map.of(
                 "answer", answer,
                 "sources", sources,
+                "brand_recommendations", brandRecs,
                 "intent", intent,
                 "conversation_id", cid
         ));
@@ -350,6 +366,7 @@ public class ChatController {
                                     Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
                                         String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
                                         final List<Map<String, Object>> sources = buildSources(docs);
+                                        final List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, finalAnswer);
                                         String meta;
                                         try {
                                             meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
@@ -364,8 +381,9 @@ public class ChatController {
                                         persistenceService.saveMessage(userKey, cid, "user",
                                                 request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
                                                 intent, null);
-                                        persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
+                                        persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources, brandRecs);
                                         return Flux.just(
+                                                buildBrandRecommendEvent(brandRecs),
                                                 ServerSentEvent.<String>builder().event("meta").data(meta).build()
                                         );
                                     });
@@ -429,14 +447,16 @@ public class ChatController {
         String answer = cleanAnswer(llmService.chat(messages, enableSearch));
 
         List<Map<String, Object>> sources = buildSources(docs);
+        List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
         String userMsg = !request.getMessage().isBlank() ? request.getMessage()
                 : !ocrText.isEmpty() ? "[" + mediaLabel + "]" : request.getMessage();
         persistenceService.saveMessage(userKey, cid, "user", userMsg, intent, null);
-        persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources);
+        persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
 
         return ResponseEntity.ok(Map.of(
                 "answer", answer,
                 "sources", sources,
+                "brand_recommendations", brandRecs,
                 "intent", intent,
                 "conversation_id", cid
         ));
@@ -534,18 +554,20 @@ public class ChatController {
 
                             Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
                                 final List<Map<String, Object>> sources = buildSources(docs);
+                                String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
+                                final List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, finalAnswer);
                                 String meta;
                                 try {
                                     meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
                                             "intent", intent, "sources", sources, "conversation_id", cid,
                                             "ocr_text", ocrText != null ? ocrText : ""));
                                 } catch (Exception e) { meta = "{}"; }
-                                String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
                                 String userMsg = (rawMsg != null && !rawMsg.isBlank()) ? rawMsg
                                         : (ocrText != null && !ocrText.isEmpty()) ? "[" + mediaLabel + "]" : "";
                                 persistenceService.saveMessage(userKey, cid, "user", userMsg, intent, null);
-                                persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
+                                persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources, brandRecs);
                                 return Flux.just(
+                                        buildBrandRecommendEvent(brandRecs),
                                         ServerSentEvent.<String>builder().event("meta").data(meta).build()
                                 );
                             });
@@ -717,6 +739,17 @@ public class ChatController {
         ));
     }
 
+    private ServerSentEvent<String> buildBrandRecommendEvent(List<Map<String, Object>> recs) {
+        try {
+            String data = new com.fasterxml.jackson.databind.ObjectMapper()
+                    .writeValueAsString(Map.of("recommendations", recs != null ? recs : List.of()));
+            return ServerSentEvent.<String>builder().event("brand_recommend").data(data).build();
+        } catch (Exception e) {
+            return ServerSentEvent.<String>builder().event("brand_recommend")
+                    .data("{\"recommendations\":[]}").build();
+        }
+    }
+
     private List<Map<String, Object>> buildSources(List<Map<String, Object>> docs) {
         Set<String> rawSeen = new HashSet<>();
         Set<String> seen = new HashSet<>();

+ 83 - 0
backend-java/src/main/java/com/pharmacopoeia/entity/Brand.java

@@ -0,0 +1,83 @@
+package com.pharmacopoeia.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.Instant;
+
+@Entity
+@Table(name = "brands")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class Brand {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Integer id;
+
+    @Column(nullable = false, length = 200)
+    private String name;
+
+    @Column(name = "function_indication", columnDefinition = "text")
+    private String functionIndication;
+
+    @Column(name = "usage_dosage", columnDefinition = "text")
+    private String usageDosage;
+
+    @Column(columnDefinition = "text")
+    private String contraindication;
+
+    @Column(columnDefinition = "text")
+    private String ingredients;
+
+    @Column(columnDefinition = "text")
+    private String properties;
+
+    @Column(columnDefinition = "text")
+    private String specification;
+
+    @Column(name = "adverse_reactions", columnDefinition = "text")
+    private String adverseReactions;
+
+    @Column(columnDefinition = "text")
+    private String precautions;
+
+    @Column(name = "execution_standard", columnDefinition = "text")
+    private String executionStandard;
+
+    @Column(columnDefinition = "text")
+    private String storage;
+
+    @Column(name = "jump_url", length = 500)
+    private String jumpUrl;
+
+    @Column(length = 500)
+    private String description;
+
+    @Column(name = "sort_order", nullable = false)
+    @Builder.Default
+    private Integer sortOrder = 0;
+
+    @Column(name = "is_active", nullable = false)
+    @Builder.Default
+    private Boolean isActive = true;
+
+    @Column(name = "created_at", updatable = false)
+    private Instant createdAt;
+
+    @Column(name = "updated_at")
+    private Instant updatedAt;
+
+    @PrePersist
+    void onCreate() {
+        if (createdAt == null) createdAt = Instant.now();
+        if (updatedAt == null) updatedAt = Instant.now();
+    }
+
+    @PreUpdate
+    void onUpdate() {
+        updatedAt = Instant.now();
+    }
+}

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

@@ -0,0 +1,50 @@
+package com.pharmacopoeia.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.Instant;
+
+@Entity
+@Table(name = "brand_recommend_rules")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class BrandRecommendRule {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Integer id;
+
+    @Column(nullable = false, length = 100)
+    private String keyword;
+
+    @Column(name = "brand_id", nullable = false)
+    private Integer brandId;
+
+    @Column(nullable = false)
+    @Builder.Default
+    private Integer tier = 1;
+
+    @Column(name = "is_active", nullable = false)
+    @Builder.Default
+    private Boolean isActive = true;
+
+    @Column(name = "created_at", updatable = false)
+    private Instant createdAt;
+
+    @Column(name = "updated_at")
+    private Instant updatedAt;
+
+    @PrePersist
+    void onCreate() {
+        if (createdAt == null) createdAt = Instant.now();
+        if (updatedAt == null) updatedAt = Instant.now();
+    }
+
+    @PreUpdate
+    void onUpdate() {
+        updatedAt = Instant.now();
+    }
+}

+ 21 - 0
backend-java/src/main/java/com/pharmacopoeia/entity/Message.java

@@ -43,6 +43,10 @@ public class Message {
     @Column(name = "sources", columnDefinition = "jsonb")
     private String sources;
 
+    @JdbcTypeCode(SqlTypes.JSON)
+    @Column(name = "brand_recommendations", columnDefinition = "jsonb")
+    private String brandRecommendations;
+
     @Column(length = 32)
     private String feedback;
 
@@ -73,4 +77,21 @@ public class Message {
             this.sources = null;
         }
     }
+
+    public List<Map<String, Object>> getBrandRecommendationsList() {
+        if (brandRecommendations == null || brandRecommendations.isBlank()) return List.of();
+        try {
+            return mapper.readValue(brandRecommendations, new TypeReference<List<Map<String, Object>>>() {});
+        } catch (JsonProcessingException e) {
+            return List.of();
+        }
+    }
+
+    public void setBrandRecommendationsList(List<Map<String, Object>> recs) {
+        try {
+            this.brandRecommendations = recs != null ? mapper.writeValueAsString(recs) : null;
+        } catch (JsonProcessingException e) {
+            this.brandRecommendations = null;
+        }
+    }
 }

+ 17 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/BrandRecommendRuleRepository.java

@@ -0,0 +1,17 @@
+package com.pharmacopoeia.repository;
+
+import com.pharmacopoeia.entity.BrandRecommendRule;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface BrandRecommendRuleRepository extends JpaRepository<BrandRecommendRule, Integer> {
+
+    List<BrandRecommendRule> findByIsActiveTrueOrderByTierAscKeywordAsc();
+
+    List<BrandRecommendRule> findAllByOrderByTierAscKeywordAsc();
+
+    boolean existsByKeywordAndIdNot(String keyword, Integer id);
+}

+ 17 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/BrandRepository.java

@@ -0,0 +1,17 @@
+package com.pharmacopoeia.repository;
+
+import com.pharmacopoeia.entity.Brand;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface BrandRepository extends JpaRepository<Brand, Integer> {
+
+    List<Brand> findByIsActiveTrueOrderBySortOrderAsc();
+
+    List<Brand> findAllByOrderBySortOrderAsc();
+
+    boolean existsByNameAndIdNot(String name, Integer id);
+}

+ 403 - 0
backend-java/src/main/java/com/pharmacopoeia/service/BrandRecommendService.java

@@ -0,0 +1,403 @@
+package com.pharmacopoeia.service;
+
+import com.pharmacopoeia.entity.Brand;
+import com.pharmacopoeia.entity.BrandRecommendRule;
+import com.pharmacopoeia.repository.BrandRecommendRuleRepository;
+import com.pharmacopoeia.repository.BrandRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+public class BrandRecommendService {
+
+    private final BrandRecommendRuleRepository ruleRepository;
+    private final BrandRepository brandRepository;
+
+    public BrandRecommendService(BrandRecommendRuleRepository ruleRepository,
+                                  BrandRepository brandRepository) {
+        this.ruleRepository = ruleRepository;
+        this.brandRepository = brandRepository;
+    }
+
+    // ==================== 层级匹配 ====================
+
+    public List<Map<String, Object>> match(List<Map<String, Object>> sources, String fullAnswer) {
+        List<BrandRecommendRule> rules = ruleRepository.findByIsActiveTrueOrderByTierAscKeywordAsc();
+        if (rules.isEmpty()) return List.of();
+
+        Map<Integer, List<BrandRecommendRule>> byTier = rules.stream()
+                .collect(Collectors.groupingBy(BrandRecommendRule::getTier, LinkedHashMap::new, Collectors.toList()));
+
+        Set<String> sourceNames = sources.stream()
+                .map(s -> (String) s.getOrDefault("name", ""))
+                .filter(n -> !n.isEmpty())
+                .collect(Collectors.toSet());
+
+        for (Map.Entry<Integer, List<BrandRecommendRule>> entry : byTier.entrySet()) {
+            List<Map<String, Object>> matches = findMatches(entry.getValue(), sourceNames);
+            if (!matches.isEmpty()) {
+                return matches;
+            }
+        }
+
+        // 全文回退也按 tier 逐层尝试
+        if (fullAnswer != null && !fullAnswer.isEmpty()) {
+            for (Map.Entry<Integer, List<BrandRecommendRule>> entry : byTier.entrySet()) {
+                List<Map<String, Object>> matches = findMatches(entry.getValue(), Set.of(fullAnswer));
+                if (!matches.isEmpty()) {
+                    return matches;
+                }
+            }
+        }
+        return List.of();
+    }
+
+    private List<Map<String, Object>> findMatches(List<BrandRecommendRule> rules, Set<String> targets) {
+        // 一次性加载所有用到的 Brand
+        Set<Integer> brandIds = rules.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
+        Map<Integer, Brand> brandMap = brandRepository.findAllById(brandIds).stream()
+                .collect(Collectors.toMap(Brand::getId, b -> b));
+
+        Set<Integer> seen = new HashSet<>();
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (BrandRecommendRule rule : rules) {
+            for (String target : targets) {
+                if (target.toLowerCase().contains(rule.getKeyword().toLowerCase())) {
+                    if (seen.add(rule.getId())) {
+                        Brand brand = brandMap.get(rule.getBrandId());
+                        result.add(buildBrandResult(rule, brand));
+                    }
+                    break;
+                }
+            }
+        }
+        return result;
+    }
+
+    private Map<String, Object> buildBrandResult(BrandRecommendRule rule, Brand brand) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("brand_id", rule.getBrandId());
+        m.put("matched_keyword", rule.getKeyword());
+        m.put("tier", rule.getTier());
+        if (brand != null) {
+            m.put("brand_name", brand.getName());
+            m.put("description", brand.getDescription() != null ? brand.getDescription() : "");
+            m.put("function", brand.getFunctionIndication() != null ? brand.getFunctionIndication() : "");
+            m.put("usage_dosage", brand.getUsageDosage() != null ? brand.getUsageDosage() : "");
+            m.put("contraindication", brand.getContraindication() != null ? brand.getContraindication() : "");
+            m.put("ingredients", brand.getIngredients() != null ? brand.getIngredients() : "");
+            m.put("properties", brand.getProperties() != null ? brand.getProperties() : "");
+            m.put("specification", brand.getSpecification() != null ? brand.getSpecification() : "");
+            m.put("adverse_reactions", brand.getAdverseReactions() != null ? brand.getAdverseReactions() : "");
+            m.put("precautions", brand.getPrecautions() != null ? brand.getPrecautions() : "");
+            m.put("execution_standard", brand.getExecutionStandard() != null ? brand.getExecutionStandard() : "");
+            m.put("storage", brand.getStorage() != null ? brand.getStorage() : "");
+            m.put("jump_url", brand.getJumpUrl() != null ? brand.getJumpUrl() : "");
+        }
+        return m;
+    }
+
+    // ==================== 品牌 CRUD ====================
+
+    public Map<String, Object> listBrands(String keyword, int page, int pageSize) {
+        List<Brand> all = brandRepository.findAllByOrderBySortOrderAsc();
+        if (keyword != null && !keyword.isBlank()) {
+            String kw = keyword.toLowerCase();
+            all = all.stream()
+                .filter(b -> b.getName().toLowerCase().contains(kw))
+                .collect(Collectors.toList());
+        }
+        int total = all.size();
+        int from = (page - 1) * pageSize;
+        int to = Math.min(from + pageSize, total);
+        List<Brand> pageItems = from < total ? all.subList(from, to) : List.of();
+        return Map.of(
+            "items", (Object) pageItems,
+            "page", page,
+            "page_size", pageSize,
+            "total", total,
+            "total_pages", (int) Math.ceil((double) total / pageSize)
+        );
+    }
+
+    @Transactional
+    public Map<String, Object> createBrand(Map<String, Object> body) {
+        String name = (String) body.get("name");
+        if (name == null || name.isBlank()) {
+            return Map.of("ok", false, "message", "品牌名称不能为空");
+        }
+        if (brandRepository.existsByNameAndIdNot(name.trim(), null)) {
+            return Map.of("ok", false, "message", "该品牌名称已存在");
+        }
+        Brand brand = Brand.builder()
+                .name(name.trim())
+                .functionIndication(str(body, "function_indication"))
+                .usageDosage(str(body, "usage_dosage"))
+                .contraindication(str(body, "contraindication"))
+                .ingredients(str(body, "ingredients"))
+                .properties(str(body, "properties"))
+                .specification(str(body, "specification"))
+                .adverseReactions(str(body, "adverse_reactions"))
+                .precautions(str(body, "precautions"))
+                .executionStandard(str(body, "execution_standard"))
+                .storage(str(body, "storage"))
+                .jumpUrl(str(body, "jump_url"))
+                .description(str(body, "description"))
+                .sortOrder(body.get("sort_order") instanceof Number n ? n.intValue() : 0)
+                .isActive(true)
+                .build();
+        brand = brandRepository.save(brand);
+        return Map.of("ok", true, "id", brand.getId(), "message", "已创建");
+    }
+
+    @Transactional
+    public Map<String, Object> updateBrand(int id, Map<String, Object> body) {
+        var opt = brandRepository.findById(id);
+        if (opt.isEmpty()) return Map.of("ok", false, "message", "未找到该品牌");
+        Brand b = opt.get();
+        String name = (String) body.get("name");
+        if (name != null && !name.isBlank()) {
+            if (brandRepository.existsByNameAndIdNot(name.trim(), id)) {
+                return Map.of("ok", false, "message", "该品牌名称已存在");
+            }
+            b.setName(name.trim());
+        }
+        if (body.containsKey("function_indication")) b.setFunctionIndication(str(body, "function_indication"));
+        if (body.containsKey("usage_dosage")) b.setUsageDosage(str(body, "usage_dosage"));
+        if (body.containsKey("contraindication")) b.setContraindication(str(body, "contraindication"));
+        if (body.containsKey("ingredients")) b.setIngredients(str(body, "ingredients"));
+        if (body.containsKey("properties")) b.setProperties(str(body, "properties"));
+        if (body.containsKey("specification")) b.setSpecification(str(body, "specification"));
+        if (body.containsKey("adverse_reactions")) b.setAdverseReactions(str(body, "adverse_reactions"));
+        if (body.containsKey("precautions")) b.setPrecautions(str(body, "precautions"));
+        if (body.containsKey("execution_standard")) b.setExecutionStandard(str(body, "execution_standard"));
+        if (body.containsKey("storage")) b.setStorage(str(body, "storage"));
+        if (body.containsKey("jump_url")) b.setJumpUrl(str(body, "jump_url"));
+        if (body.containsKey("description")) b.setDescription(str(body, "description"));
+        if (body.get("sort_order") instanceof Number n) b.setSortOrder(n.intValue());
+        if (body.get("is_active") instanceof Boolean a) b.setIsActive(a);
+        brandRepository.save(b);
+        return Map.of("ok", true, "id", id, "message", "已更新");
+    }
+
+    @Transactional
+    public Map<String, Object> deleteBrand(int id) {
+        if (!brandRepository.existsById(id)) return Map.of("ok", false, "message", "未找到该品牌");
+        brandRepository.deleteById(id);
+        return Map.of("ok", true, "message", "已删除");
+    }
+
+    // ==================== 匹配规则 CRUD ====================
+
+    public Map<String, Object> listRules(String keyword, int page, int pageSize) {
+        List<BrandRecommendRule> all = ruleRepository.findAllByOrderByTierAscKeywordAsc();
+        if (keyword != null && !keyword.isBlank()) {
+            all = all.stream()
+                .filter(r -> r.getKeyword().contains(keyword))
+                .collect(Collectors.toList());
+        }
+
+        // 加载关联品牌名
+        Set<Integer> brandIds = all.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
+        Map<Integer, String> brandNames = brandRepository.findAllById(brandIds).stream()
+                .collect(Collectors.toMap(Brand::getId, Brand::getName));
+
+        int total = all.size();
+        int from = (page - 1) * pageSize;
+        int to = Math.min(from + pageSize, total);
+        List<BrandRecommendRule> pageItems = from < total ? all.subList(from, to) : List.of();
+
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (BrandRecommendRule r : pageItems) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", r.getId());
+            m.put("keyword", r.getKeyword());
+            m.put("brand_id", r.getBrandId());
+            m.put("brand_name", brandNames.getOrDefault(r.getBrandId(), ""));
+            m.put("tier", r.getTier());
+            m.put("is_active", r.getIsActive());
+            m.put("created_at", r.getCreatedAt());
+            m.put("updated_at", r.getUpdatedAt());
+            items.add(m);
+        }
+        return Map.of(
+            "items", (Object) items,
+            "page", page,
+            "page_size", pageSize,
+            "total", total,
+            "total_pages", (int) Math.ceil((double) total / pageSize)
+        );
+    }
+
+    @Transactional
+    public Map<String, Object> createRule(String keyword, int brandId, int tier) {
+        if (keyword == null || keyword.isBlank()) {
+            return Map.of("ok", false, "message", "关键词不能为空");
+        }
+        if (!brandRepository.existsById(brandId)) {
+            return Map.of("ok", false, "message", "品牌不存在");
+        }
+        if (ruleRepository.existsByKeywordAndIdNot(keyword.trim(), null)) {
+            return Map.of("ok", false, "message", "该关键词已存在");
+        }
+        BrandRecommendRule rule = BrandRecommendRule.builder()
+                .keyword(keyword.trim())
+                .brandId(brandId)
+                .tier(tier)
+                .isActive(true)
+                .build();
+        rule = ruleRepository.save(rule);
+        return Map.of("ok", true, "id", rule.getId(), "message", "已创建");
+    }
+
+    @Transactional
+    public Map<String, Object> updateRule(int id, String keyword, Integer brandId, Integer tier, Boolean isActive) {
+        var opt = ruleRepository.findById(id);
+        if (opt.isEmpty()) return Map.of("ok", false, "message", "未找到该规则");
+        BrandRecommendRule rule = opt.get();
+        if (keyword != null && !keyword.isBlank()) {
+            if (ruleRepository.existsByKeywordAndIdNot(keyword.trim(), id)) {
+                return Map.of("ok", false, "message", "该关键词已存在");
+            }
+            rule.setKeyword(keyword.trim());
+        }
+        if (brandId != null) {
+            if (!brandRepository.existsById(brandId)) {
+                return Map.of("ok", false, "message", "品牌不存在");
+            }
+            rule.setBrandId(brandId);
+        }
+        if (tier != null) rule.setTier(tier);
+        if (isActive != null) rule.setIsActive(isActive);
+        ruleRepository.save(rule);
+        return Map.of("ok", true, "id", id, "message", "已更新");
+    }
+
+    @Transactional
+    public Map<String, Object> deleteRule(int id) {
+        if (!ruleRepository.existsById(id)) return Map.of("ok", false, "message", "未找到该规则");
+        ruleRepository.deleteById(id);
+        return Map.of("ok", true, "message", "已删除");
+    }
+
+    // ==================== CSV 导入导出 ====================
+
+    public Map<String, Object> importBrandsCsv(String csv) {
+        int success = 0, failed = 0;
+        for (String line : csv.split("\\n")) {
+            line = line.trim();
+            if (line.isEmpty() || line.startsWith("#")) continue;
+            String[] cols = line.split(",", 14);
+            if (cols.length < 1) { failed++; continue; }
+            try {
+                String name = cols[0].trim();
+                if (name.isEmpty() || brandRepository.existsByNameAndIdNot(name, null)) {
+                    failed++; continue;
+                }
+                Brand b = Brand.builder()
+                        .name(name)
+                        .functionIndication(col(cols, 1))
+                        .usageDosage(col(cols, 2))
+                        .contraindication(col(cols, 3))
+                        .ingredients(col(cols, 4))
+                        .properties(col(cols, 5))
+                        .specification(col(cols, 6))
+                        .adverseReactions(col(cols, 7))
+                        .precautions(col(cols, 8))
+                        .executionStandard(col(cols, 9))
+                        .storage(col(cols, 10))
+                        .jumpUrl(col(cols, 11))
+                        .description(col(cols, 12))
+                        .sortOrder(cols.length > 13 ? Integer.parseInt(cols[13].trim()) : 0)
+                        .isActive(true)
+                        .build();
+                brandRepository.save(b);
+                success++;
+            } catch (Exception e) { failed++; }
+        }
+        return Map.of("ok", true, "success", success, "failed", failed);
+    }
+
+    public String exportBrandsCsv() {
+        List<Brand> brands = brandRepository.findAllByOrderBySortOrderAsc();
+        StringBuilder sb = new StringBuilder(
+                "name,function_indication,usage_dosage,contraindication,ingredients,properties,specification,adverse_reactions,precautions,execution_standard,storage,jump_url,description,sort_order\\n");
+        for (Brand b : brands) {
+            sb.append(esc(b.getName())).append(",")
+              .append(esc(b.getFunctionIndication())).append(",")
+              .append(esc(b.getUsageDosage())).append(",")
+              .append(esc(b.getContraindication())).append(",")
+              .append(esc(b.getIngredients())).append(",")
+              .append(esc(b.getProperties())).append(",")
+              .append(esc(b.getSpecification())).append(",")
+              .append(esc(b.getAdverseReactions())).append(",")
+              .append(esc(b.getPrecautions())).append(",")
+              .append(esc(b.getExecutionStandard())).append(",")
+              .append(esc(b.getStorage())).append(",")
+              .append(esc(b.getJumpUrl())).append(",")
+              .append(esc(b.getDescription())).append(",")
+              .append(b.getSortOrder()).append("\\n");
+        }
+        return sb.toString();
+    }
+
+    public Map<String, Object> importRulesCsv(String csv) {
+        int success = 0, failed = 0;
+        for (String line : csv.split("\\n")) {
+            line = line.trim();
+            if (line.isEmpty() || line.startsWith("#")) continue;
+            String[] cols = line.split(",", 3);
+            if (cols.length < 2) { failed++; continue; }
+            try {
+                String kw = cols[0].trim();
+                int brandId = Integer.parseInt(cols[1].trim());
+                int tier = cols.length > 2 ? Integer.parseInt(cols[2].trim()) : 1;
+                if (!brandRepository.existsById(brandId)) { failed++; continue; }
+                if (ruleRepository.existsByKeywordAndIdNot(kw, null)) { failed++; continue; }
+                ruleRepository.save(BrandRecommendRule.builder()
+                        .keyword(kw).brandId(brandId).tier(tier).isActive(true).build());
+                success++;
+            } catch (Exception e) { failed++; }
+        }
+        return Map.of("ok", true, "success", success, "failed", failed);
+    }
+
+    public String exportRulesCsv() {
+        List<BrandRecommendRule> rules = ruleRepository.findAllByOrderByTierAscKeywordAsc();
+        Set<Integer> brandIds = rules.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
+        Map<Integer, String> brandNames = brandRepository.findAllById(brandIds).stream()
+                .collect(Collectors.toMap(Brand::getId, Brand::getName));
+        StringBuilder sb = new StringBuilder("keyword,brand_id,brand_name,tier,is_active\\n");
+        for (BrandRecommendRule r : rules) {
+            sb.append(r.getKeyword()).append(",")
+              .append(r.getBrandId()).append(",")
+              .append(brandNames.getOrDefault(r.getBrandId(), "")).append(",")
+              .append(r.getTier()).append(",")
+              .append(r.getIsActive()).append("\\n");
+        }
+        return sb.toString();
+    }
+
+    // ==================== Helpers ====================
+
+    private static String str(Map<String, Object> body, String key) {
+        Object v = body.get(key);
+        if (v == null) return null;
+        String s = v.toString().trim();
+        return s.isEmpty() ? null : s;
+    }
+
+    private static String col(String[] cols, int idx) {
+        if (idx >= cols.length) return null;
+        String s = cols[idx].trim();
+        return s.isEmpty() ? null : s;
+    }
+
+    private static String esc(String s) {
+        if (s == null || s.isEmpty()) return "";
+        return "\"" + s.replace("\"", "\"\"") + "\"";
+    }
+}

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

@@ -30,6 +30,13 @@ public class ChatPersistenceService {
     @Transactional
     public void saveMessage(String userKey, String conversationId, String role, String content,
                             String intent, List<Map<String, Object>> sources) {
+        saveMessage(userKey, conversationId, role, content, intent, sources, null);
+    }
+
+    @Transactional
+    public void saveMessage(String userKey, String conversationId, String role, String content,
+                            String intent, List<Map<String, Object>> sources,
+                            List<Map<String, Object>> brandRecommendations) {
         conversationRepository.findByConversationId(conversationId).orElseGet(() -> {
             var conv = Conversation.builder()
                     .conversationId(conversationId)
@@ -50,6 +57,9 @@ public class ChatPersistenceService {
                 .intent(intent)
                 .build();
         msg.setSourcesList(sources);
+        if (brandRecommendations != null) {
+            msg.setBrandRecommendationsList(brandRecommendations);
+        }
         messageRepository.save(msg);
     }
 
@@ -100,6 +110,7 @@ public class ChatPersistenceService {
                     "content", msg.getContent(),
                     "intent", msg.getIntent() != null ? msg.getIntent() : "",
                     "sources", msg.getSourcesList(),
+                    "brand_recommendations", msg.getBrandRecommendationsList(),
                     "created_at", msg.getCreatedAt() != null ? msg.getCreatedAt().toString() : ""
             ));
         }
@@ -129,7 +140,8 @@ public class ChatPersistenceService {
                     "role", msg.getRole(),
                     "content", msg.getContent(),
                     "intent", msg.getIntent() != null ? msg.getIntent() : "",
-                    "sources", msg.getSourcesList()
+                    "sources", msg.getSourcesList(),
+                    "brand_recommendations", msg.getBrandRecommendationsList()
             ));
         }
         return result;

+ 48 - 0
database/migrate_brand_recommend.sql

@@ -0,0 +1,48 @@
+-- ============================================================
+-- 品牌药品 + 品牌推荐规则 迁移
+-- 执行: psql -h <host> -U <user> -d pharmacopoeia -f database/migrate_brand_recommend.sql
+-- ============================================================
+BEGIN;
+
+-- 品牌药品表
+CREATE TABLE IF NOT EXISTS public.brands (
+    id SERIAL PRIMARY KEY,
+    name varchar(200) NOT NULL,
+    function_indication text,
+    usage_dosage text,
+    contraindication text,
+    ingredients text,
+    properties text,
+    specification text,
+    adverse_reactions text,
+    precautions text,
+    execution_standard text,
+    storage text,
+    jump_url varchar(500),
+    description varchar(500),
+    sort_order integer NOT NULL DEFAULT 0,
+    is_active boolean NOT NULL DEFAULT true,
+    created_at timestamptz DEFAULT now(),
+    updated_at timestamptz DEFAULT now()
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_brand_name ON public.brands(name);
+
+-- 品牌推荐规则表(关联品牌)
+CREATE TABLE IF NOT EXISTS public.brand_recommend_rules (
+    id SERIAL PRIMARY KEY,
+    keyword varchar(100) NOT NULL,
+    brand_id integer NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
+    tier integer NOT NULL DEFAULT 1,
+    is_active boolean NOT NULL DEFAULT true,
+    created_at timestamptz DEFAULT now(),
+    updated_at timestamptz DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_br_keyword ON public.brand_recommend_rules(keyword);
+CREATE INDEX IF NOT EXISTS idx_br_brand ON public.brand_recommend_rules(brand_id);
+CREATE INDEX IF NOT EXISTS idx_br_tier ON public.brand_recommend_rules(tier);
+CREATE INDEX IF NOT EXISTS idx_br_active ON public.brand_recommend_rules(is_active);
+
+-- messages 表加 brand_recommendations 快照字段(历史对话持久化)
+ALTER TABLE public.messages ADD COLUMN IF NOT EXISTS brand_recommendations jsonb;
+
+COMMIT;

+ 67 - 0
docs/ANALYTICS_API.md

@@ -102,6 +102,73 @@ curl -X POST https://pharmacopoeia.kailin.com.cn/api/v1/analytics/events \
 |-----------|---------|------------|
 | `drug_detail` | 查看药品详情 | `{drug_id: "H20000001", drug_name: "阿莫西林"}` |
 
+### 品牌推荐
+
+| event_type | 触发时机 | event_data |
+|-----------|---------|------------|
+| `brand_recommend` | 品牌推荐匹配命中 | `{recommendations: [{brand_name, matched_keyword, tier}]}` |
+
+> 品牌推荐匹配由后端在 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}` | 修改规则 |
+| 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 |

+ 11 - 0
docs/DEPLOY.md

@@ -53,8 +53,19 @@ tail -50 /var/log/pharmacopoeia/backend.log | grep -i "error\|exception\|access
 | 健康检查 | `curl -s http://localhost:9000/health` | 200 OK |
 | 药品库搜索 | `curl -s 'http://localhost:9000/api/v1/drug/search?keyword=布洛芬'` | 返回数据 |
 | 对话流式 | 用浏览器访问首页,发送一条问题 | SSE 正常走完,无网络异常 |
+| 品牌推荐 | 发送含"布洛芬"的问题 | SSE 中出现 `brand_recommend` 事件 |
 | 日志无异常 | `tail -100 /var/log/pharmacopoeia/backend.log \| grep -i "error\|exception"` | 无新增报错 |
 
+### 数据库迁移
+
+本次上线涉及新增表和新列,部署前必须执行迁移:
+
+```bash
+docker exec -i pharmacopoeia-pg psql -U postgres -d pharmacopoeia < database/migrate_brand_recommend.sql
+```
+
+该脚本创建 `brands` 和 `brand_recommend_rules` 两张表,以及在 `messages` 表加 `brand_recommendations jsonb` 列。使用 `IF NOT EXISTS`,可重复执行。
+
 ---
 
 ## 四、部署脚本说明

+ 109 - 6
docs/HANDOVER.md

@@ -1,6 +1,6 @@
 # AI 药典助手 — 项目交接文档
 
-> 最后更新: 2026-07-24
+> 最后更新: 2026-07-29
 
 ---
 
@@ -37,7 +37,7 @@
 │   │   ├── controller/      # ChatController, DrugController, AuthController
 │   │   ├── service/         # LLMService, RetrieverService, PromptService, QACacheService
 │   │   ├── repository/      # JPA Repository
-│   │   ├── entity/          # Drug, Message, Conversation, User
+│   │   ├── entity/          # Drug, Message, Conversation, User, Brand, BrandRecommendRule
 │   │   ├── security/        # JwtAuthFilter, RateLimitFilter, JwtUtil
 │   │   └── config/          # AuthProperties, DashScopeRateLimitProperties
 │   └── pom.xml
@@ -154,6 +154,29 @@ DASHSCOPE_RATE_LIMIT_PER_DAY=50000
 | GET | `/api/v1/drug/{drugId}` | 药品详情 |
 | GET | `/api/v1/drug/category/tree` | 分类树 |
 
+### 品牌推荐
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| - | 品牌详情字段 | brand_id, brand_name, matched_keyword, function, usage_dosage, contraindication, ingredients, properties, specification, adverse_reactions, precautions, execution_standard, storage, jump_url |
+
+### 管理端(需 JWT)
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/v1/admin/knowledge/brands` | 品牌列表(分页+搜索) |
+| POST | `/api/v1/admin/knowledge/brands` | 新增品牌 |
+| 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` | 下载导入模板 |
+| GET | `/api/v1/admin/knowledge/brand-recommend-rules` | 规则列表(分页+搜索) |
+| POST | `/api/v1/admin/knowledge/brand-recommend-rules` | 新增规则 |
+| PUT | `/api/v1/admin/knowledge/brand-recommend-rules/{id}` | 修改规则 |
+| 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` | 下载导入模板 |
+
 ---
 
 ## 八、鉴权机制
@@ -200,7 +223,9 @@ DASHSCOPE_RATE_LIMIT_PER_DAY=50000
 | `drugs` | 药品主表(含 sections JSONB) |
 | `drug_chunks` | 药品分块 + pgvector 向量 |
 | `conversations` | 对话会话 |
-| `messages` | 对话消息(含 user_key 隔离) |
+| `messages` | 对话消息(含 user_key 隔离、brand_recommendations JSONB 持久化) |
+| `brands` | 品牌药品详情(长文本信息 + 跳转链接) |
+| `brand_recommend_rules` | 品牌推荐匹配规则(关键词 → 品牌 ID 映射 + 层级) |
 | `users` | 用户表 |
 
 ### 用户隔离
@@ -209,6 +234,10 @@ DASHSCOPE_RATE_LIMIT_PER_DAY=50000
 
 ### 数据库迁移
 ```sql
+-- 品牌推荐功能(2026-07-29)
+-- 执行: psql -h <host> -U <user> -d pharmacopoeia -f database/migrate_brand_recommend.sql
+-- 创建 brands 表、brand_recommend_rules 表(含外键),messages 加 brand_recommendations jsonb
+
 -- 最近新增字段
 ALTER TABLE conversations ADD COLUMN IF NOT EXISTS user_key VARCHAR(128);
 ALTER TABLE messages ADD COLUMN IF NOT EXISTS user_key VARCHAR(128);
@@ -264,12 +293,86 @@ IndexedDB 可能被清理,前端会自动降级调 `/api/v1/chat/recent-messag
 - [ ] 百炼 API 限流值客户确认后启用
 - [ ] 定期清理过期对话数据(messages 表)
 - [ ] 监控 DashScope API 调用量
+- [ ] 运营管理端对接品牌管理和匹配规则 CRUD 接口
+
+---
+
+## 十六、品牌推荐功能(2026-07-29 新增)
+
+### 架构
+
+```
+用户提问 → RAG检索 → LLM生成回答(SSE流) → buildSources提取药名
+→ BrandRecommendService 分层关键词匹配 → SSE 注入 brand_recommend 事件
+→ 结果持久化到 messages.brand_recommendations JSONB
+```
+
+### 两张新表
+
+```
+brands (品牌表)
+├── id, name
+├── 长文本: function_indication, usage_dosage, contraindication,
+│           ingredients, properties, specification, adverse_reactions,
+│           precautions, execution_standard, storage
+├── jump_url          — 跳转链接
+├── description       — 简要描述
+├── sort_order        — 排序
+└── is_active         — 启用开关
+
+brand_recommend_rules (匹配规则表)
+├── id
+├── keyword           — 匹配关键词
+├── brand_id          — FK → brands.id
+├── tier              — 优先级(1最高)
+└── is_active         — 启用开关
+```
+
+### 匹配逻辑
+
+1. 从 `sources[].name` 提取药名集合
+2. 按 tier 升序(1→2→3…)逐层匹配:source name **包含** keyword
+3. 命中即停止,不再尝试更低 tier
+4. 所有 tier 的 source 匹配都无命中 → 回退到全文匹配(fullAnswer 包含 keyword),同样按 tier 逐层
+5. 同层级内去重,返回匹配到的规则对应的品牌详情(含所有长文本 + jump_url)
+
+### SSE 事件顺序
+
+```
+token → token → ... → brand_recommend → meta → 完成
+```
+
+`brand_recommend` 事件数据格式:
+```json
+{
+  "recommendations": [
+    {
+      "brand_id": 1,
+      "matched_keyword": "布洛芬",
+      "tier": 1,
+      "brand_name": "芬必得布洛芬缓释胶囊",
+      "function": "用于缓解轻至中度疼痛...",
+      "usage_dosage": "口服。成人一次1粒...",
+      "jump_url": "",
+      ...
+    }
+  ]
+}
+```
+
+### 历史持久化
+
+品牌推荐结果在 SSE 流结束后写入 `messages.brand_recommendations`(JSONB 列),用户翻看历史对话时直接从该字段读取,不受后续规则表变更影响。`getConversationDetail` 和 `getRecentMessages` 接口已包含该字段。
+
+### 部署前注意
+
+部署前必须执行 `database/migrate_brand_recommend.sql` 创建新表和新列。该脚本使用 `CREATE TABLE IF NOT EXISTS` 和 `ADD COLUMN IF NOT EXISTS`,可重复执行。
 
 ---
 
-## 十六、已知问题记录
+## 十、已知问题记录
 
-### 16.1 HttpServletRequest 代理对象线程安全问题(已修复 2026-07-24)
+### 17.1 HttpServletRequest 代理对象线程安全问题(已修复 2026-07-24)
 
 **现象:** SSE 流式回答写完后,日志出现:
 ```
@@ -281,7 +384,7 @@ IllegalStateException: No thread-bound request found
 
 **修复:** Controller 所有请求方法入口处提前调用 `getCurrentUserKey()` 获取 plain `String`,在 Reactor lambda 中只使用已捕获的 `final` 局部变量,绝不在回调线程中访问 `request` 代理对象。
 
-### 16.2 SSE 流式结束后 AccessDeniedException(已修复 2026-07-24)
+### 17.2 SSE 流式结束后 AccessDeniedException(已修复 2026-07-24)
 
 **现象:** SSE 流式 (`/api/v1/chat/stream`) 完成时日志出现:
 ```

+ 2 - 0
docs/MINIPROGRAM_INTEGRATION.md

@@ -139,6 +139,7 @@ export default {
 | 4 | API 地址 | `api/ai.js` 中 BASE_URL 是否正确 |
 | 5 | 文件路径 | pages.json 中 path 与文件实际路径一致 |
 | 6 | SSE 流式 | 发一个问题看是否流式输出 |
+| 7 | 品牌推荐 | 发送含"布洛芬"等关键词的问题 | SSE 中出现 `brand_recommend` 事件 |
 
 ---
 
@@ -151,3 +152,4 @@ export default {
 | Token 过期 | 在 `api/ai.js` 中增加 401 自动刷新 token 逻辑 |
 | 内容安全 | 接入微信内容安全 API 过滤违规内容 |
 | 药品数据 | 确保知识库覆盖常用药品(建议 500+ 条目) |
+| 品牌推荐 | 处理 SSE `brand_recommend` 事件,在回答末尾渲染推荐品牌卡片 |

+ 153 - 0
docs/brand-recommend-analysis.md

@@ -0,0 +1,153 @@
+# 品牌药品推荐 - 后端接口分析与方案(已完成实施)
+
+> **状态:已实施** (2026-07-29)
+> 详见 HANDOVER.md 第十六章节。
+
+## 一、需求理解
+
+### 核心流程
+```
+用户提问 → RAG检索 → LLM生成回答(SSE流式) → 从sources中提取药名 → 
+关键词匹配(异步) → 返回匹配的品牌推荐 → 前端展示在回答末尾
+```
+
+### 举例
+- AI回答中"参考原文"区域出现「布洛芬缓释胶囊」→ 匹配关键词"布洛芬" → 推荐"芬必得布洛芬缓释胶囊"
+- AI回答中「感冒灵颗粒」→ 匹配"感冒灵" → 推荐"999感冒灵颗粒"
+
+---
+
+## 二、匹配逻辑分析
+
+### 匹配对象:`buildSources` 返回的 sources 列表
+
+当前 `chatStream` 尾部 `tailFlux` 中的 sources 结构(来自 buildSources line 720+):
+
+```json
+{
+  "drug_id": "xxx",
+  "name": "布洛芬缓释胶囊",   // ← 匹配目标
+  "section": "用法与用量",
+  "category": "化学药品",
+  "source": "2025版药典二部",
+  "excerpt": "..."
+}
+```
+
+### 匹配规则(按之前的需求)
+
+| 规则 | 说明 |
+|---|---|
+| 匹配方式 | `sources[i].name` **包含**关键词(忽略大小写) |
+| 示例 | 标题"布洛芬缓释胶囊"包含关键词"布洛芬" → 命中 |
+| 同一标题多命中 | 去重展示所有匹配的品牌 |
+| 无命中 | 不展示推荐区域,不影响正常回答 |
+| 时机 | AI回答生成后,meta事件发送前(不增加用户可感知延迟) |
+
+### 在 SSE 流中的注入位置
+
+```
+现有流:  ...[token1] [token2] ... [tokenN] → [meta: {sources, intent, cid}] → 完成
+改造后: ...[token1] [token2] ... [tokenN] → [brand_recommend: {...}] → [meta: {...}] → 完成
+```
+
+新增 SSE 事件类型 `brand_recommend`,携带匹配结果。
+
+---
+
+## 三、需新增的后端内容
+
+### 3.1 数据库表:`brand_recommend_rules`
+
+```sql
+CREATE TABLE brand_recommend_rules (
+    id SERIAL PRIMARY KEY,
+    keyword VARCHAR(100) NOT NULL UNIQUE,      -- 匹配关键词(药名)
+    brand_name VARCHAR(200) NOT NULL,          -- 推荐品牌药品名称(必填)
+    description VARCHAR(500),                  -- 推荐品牌药品说明(可选)
+    is_active BOOLEAN NOT NULL DEFAULT TRUE,   -- 启用/停用开关
+    created_at TIMESTAMPTZ DEFAULT NOW(),
+    updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+```
+
+### 3.2 JPA 实体 + Repository
+
+- `BrandRecommendRule.java` — entity
+- `BrandRecommendRuleRepository.java` — 查询启用的规则
+
+### 3.3 品牌推荐 Service:`BrandRecommendService`
+
+```java
+@Service
+public class BrandRecommendService {
+    // 核心方法:根据 sources 列表返回匹配的品牌推荐
+    public List<BrandMatchResult> match(List<Map<String, Object>> sources) {
+        // 1. 从 sources 提取所有 drug name
+        // 2. 查询所有 is_active=true 的规则
+        // 3. 对每个 name 做包含匹配(忽略大小写)
+        // 4. 去重,返回匹配结果
+    }
+}
+```
+
+### 3.4 管理端 CRUD 接口(AdminKnowledgeController 新增)
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| `GET` | `/api/v1/admin/brand-recommend-rules` | 列表(分页+搜索) |
+| `POST` | `/api/v1/admin/brand-recommend-rules` | 新增 |
+| `PUT` | `/api/v1/admin/brand-recommend-rules/{id}` | 修改 |
+| `DELETE` | `/api/v1/admin/brand-recommend-rules/{id}` | 删除 |
+| `POST` | `/api/v1/admin/brand-recommend-rules/import` | Excel 导入 |
+| `GET` | `/api/v1/admin/brand-recommend-rules/export` | Excel 导出 |
+
+### 3.5 ChatController 改造
+
+在 `chatStream` 的 `tailFlux` 中,LLM 流结束后、meta 事件前,插入品牌推荐:
+
+```java
+Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
+    List<Map<String, Object>> sources = buildSources(docs);
+    // 新增:品牌推荐匹配
+    List<BrandMatchResult> brandRecs = brandRecommendService.match(sources);
+    // 先发 brand_recommend 事件,再发 meta
+    return Flux.concat(
+        buildBrandRecommendEvent(brandRecs),
+        buildMetaEvent(sources, intent, cid)
+    );
+});
+```
+
+---
+
+## 四、需要确认的问题
+
+### Q1:匹配范围
+需求原文说匹配对象为「参考原文标题」,即 `sources[].name`。但你说"回答了感冒灵颗粒"—这是匹配 AI 回答正文还是 sources?
+
+- **方案A(推荐)**:只匹配 `sources[].name`(参考原文标题,已结构化)
+- **方案B**:同时匹配 AI 回答正文全文(性能开销大,易误匹配)
+
+### Q2:品牌推荐 SSE 事件格式
+前端期望的 `brand_recommend` 事件数据格式是什么?建议:
+
+```json
+{
+  "event": "brand_recommend",
+  "data": {
+    "recommendations": [
+      {"brand_name": "芬必得布洛芬缓释胶囊", "matched_keyword": "布洛芬", "description": "缓解疼痛"},
+      {"brand_name": "999感冒灵颗粒", "matched_keyword": "感冒灵", "description": null}
+    ]
+  }
+}
+```
+
+### Q3:存量历史对话
+需求说"用户翻看历史对话时,历史回答中的品牌推荐正常展示,不因后台规则变更而丢失"。这意味着品牌推荐结果需要**持久化存储**到 messages 表中,而不是每次渲染时重新匹配。
+
+是否在 `messages` 表加一个 `brand_recommendations` JSONB 字段,保存当时匹配的品牌推荐结果?
+
+### Q4:品牌推荐表创建
+确认是否现在创建 `brand_recommend_rules` 表和相关 Entity/Repository/Service,还是先只做方案分析?

+ 2 - 3
static/index.html

@@ -196,10 +196,10 @@
     var h = {'Content-Type': 'application/json'};
     if (TOKEN) h['Authorization'] = 'Bearer ' + TOKEN;
     try {
-      console.log('[track]', type, merged);
       fetch(API_BASE + '/api/v1/analytics/events', {
         method: 'POST', headers: h, body: JSON.stringify(payload), keepalive: true
-      }).then(function(r){ console.log('[track] response', r.status); }).catch(function(e){ console.error('[track] error', e); });
+      }).then(function(r){
+      }).catch(function(e){ console.error('[track] error', e); });
     } catch(e) { console.error('[track] exception', e); }
   }
 
@@ -641,7 +641,6 @@
   }
   window.searchDrugs=function(){
     drugKeyword=(document.getElementById('drugSearchInput').value||'').trim();
-    console.log('[searchDrugs] keyword=', drugKeyword);
     if (drugKeyword) track('search', {keyword: drugKeyword});
     loadDrugList(1);
     return false;