Просмотр исходного кода

快速问答信息通过后端管理,还有缓存的清理和存储时间调整

liuchengsen 1 месяц назад
Родитель
Сommit
3c330970ac

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

@@ -10,4 +10,6 @@ import org.springframework.context.annotation.Configuration;
 public class AuthProperties {
     /** 是否启用鉴权,默认 false(开发模式),上线后设为 true */
     private boolean enabled = false;
+    /** 运营端管理 token,用于清缓存等管理操作 */
+    private String adminToken = "";
 }

+ 38 - 1
backend-java/src/main/java/com/pharmacopoeia/controller/AdminController.java

@@ -1,7 +1,10 @@
 package com.pharmacopoeia.controller;
 
 import com.pharmacopoeia.config.QwenProperties;
+import com.pharmacopoeia.config.AuthProperties;
+import com.pharmacopoeia.service.QACacheService;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
@@ -15,10 +18,15 @@ public class AdminController {
 
     private final QwenProperties qwenProperties;
     private final JdbcTemplate jdbc;
+    private final QACacheService qaCacheService;
+    private final AuthProperties authProperties;
 
-    public AdminController(QwenProperties qwenProperties, JdbcTemplate jdbc) {
+    public AdminController(QwenProperties qwenProperties, JdbcTemplate jdbc,
+                          QACacheService qaCacheService, AuthProperties authProperties) {
         this.qwenProperties = qwenProperties;
         this.jdbc = jdbc;
+        this.qaCacheService = qaCacheService;
+        this.authProperties = authProperties;
     }
 
     @GetMapping("/stats")
@@ -91,4 +99,33 @@ public class AdminController {
         result.put("embedding_model", qwenProperties.getEmbeddingModel());
         return ResponseEntity.ok(result);
     }
+
+    @DeleteMapping("/cache")
+    public ResponseEntity<Map<String, Object>> clearCache(
+            @RequestParam(required = false) String keyword,
+            @RequestHeader(value = "Authorization", required = false) String authHeader) {
+        // 运营端鉴权:必须提供 x-admin-token,或使用 adminToken Bearer
+        String adminToken = authProperties.getAdminToken();
+        if (adminToken == null || adminToken.isBlank()) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(Map.of("error", "admin token 未配置"));
+        }
+        boolean authorized = false;
+        // 方式1: Authorization: Bearer <admin-token>
+        if (authHeader != null && authHeader.startsWith("Bearer ")) {
+            String token = authHeader.substring(7);
+            if (adminToken.equals(token)) {
+                authorized = true;
+            }
+        }
+        if (!authorized) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(Map.of("error", "仅运营端可操作"));
+        }
+        int deleted = qaCacheService.clearAll(keyword);
+        return ResponseEntity.ok(Map.of(
+                "ok", true,
+                "deleted", deleted
+        ));
+    }
 }

+ 30 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java

@@ -208,6 +208,36 @@ public class AdminKnowledgeController {
         return ResponseEntity.ok(service.knowledgeStats());
     }
 
+    // ==================== 快捷提问标签管理 ====================
+
+    @GetMapping("/quick-asks")
+    public ResponseEntity<List<Map<String, Object>>> listQuickAsks() {
+        return ResponseEntity.ok(service.listAllQuickAsks());
+    }
+
+    @PostMapping("/quick-asks")
+    public ResponseEntity<Map<String, Object>> createQuickAsk(@RequestBody Map<String, Object> body) {
+        String label = (String) body.getOrDefault("label", "");
+        String question = (String) body.getOrDefault("question", label);
+        int sortOrder = body.get("sort_order") instanceof Integer i ? i : 0;
+        return ResponseEntity.ok(service.createQuickAsk(label, question, sortOrder));
+    }
+
+    @PutMapping("/quick-asks/{id}")
+    public ResponseEntity<Map<String, Object>> updateQuickAsk(
+            @PathVariable int id, @RequestBody Map<String, Object> body) {
+        String label = body.containsKey("label") ? (String) body.get("label") : null;
+        String question = body.containsKey("question") ? (String) body.get("question") : null;
+        Integer sortOrder = body.get("sort_order") instanceof Integer i ? i : null;
+        Boolean isActive = body.get("is_active") instanceof Boolean b ? b : null;
+        return ResponseEntity.ok(service.updateQuickAsk(id, label, question, sortOrder, isActive));
+    }
+
+    @DeleteMapping("/quick-asks/{id}")
+    public ResponseEntity<Map<String, Object>> deleteQuickAsk(@PathVariable int id) {
+        return ResponseEntity.ok(service.deleteQuickAsk(id));
+    }
+
     // ==================== Helpers ====================
 
     private int parseCount(ImportRequest req) {

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

@@ -84,7 +84,7 @@ public class ChatController {
         }
 
         // 未命中缓存:尝试抢占处理权,避免并发重复调 LLM
-        if (!qaCache.tryMarkPending(normalized)) {
+        if (qaCache.tryMarkPending(normalized)) {
             Map<String, Object> waited = qaCache.waitForCache(normalized);
             if (waited != null) {
                 String answer = (String) waited.get("answer");
@@ -148,7 +148,7 @@ public class ChatController {
         }
 
         // 未命中缓存:尝试抢占处理权,避免并发重复调 LLM
-        if (!qaCache.tryMarkPending(normalized)) {
+        if (qaCache.tryMarkPending(normalized)) {
             Map<String, Object> waited = qaCache.waitForCache(normalized);
             if (waited != null) {
                 return streamCached(waited, cid, query, userKey);

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

@@ -26,6 +26,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
     private static final Set<String> PUBLIC_PREFIXES = Set.of(
             "/health",
             "/api/v1/auth/",
+            "/api/v1/quick-asks",
             "/static/",
             "/favicon.ico"
     );

+ 48 - 0
backend-java/src/main/java/com/pharmacopoeia/service/AdminKnowledgeService.java

@@ -355,6 +355,54 @@ public class AdminKnowledgeService {
 
     // ==================== Helpers ====================
 
+    // ==================== 快捷提问标签管理 ====================
+
+    /** 获取所有激活的标签,按 sort_order 排序(公开接口,无需鉴权) */
+    public List<Map<String, Object>> listQuickAsks() {
+        return jdbc.queryForList(
+                "SELECT id, label, question, sort_order FROM quick_asks WHERE is_active = TRUE ORDER BY sort_order, id");
+    }
+
+    /** 管理端:获取所有标签(含禁用) */
+    public List<Map<String, Object>> listAllQuickAsks() {
+        return jdbc.queryForList(
+                "SELECT id, label, question, sort_order, is_active, created_at FROM quick_asks ORDER BY sort_order, id");
+    }
+
+    /** 新增标签 */
+    public Map<String, Object> createQuickAsk(String label, String question, int sortOrder) {
+        int rows = jdbc.update(
+                "INSERT INTO quick_asks (label, question, sort_order) VALUES (?, ?, ?)",
+                label, question, sortOrder);
+        return Map.of("ok", rows > 0, "message", rows > 0 ? "已创建" : "创建失败");
+    }
+
+    /** 更新标签 */
+    public Map<String, Object> updateQuickAsk(int id, String label, String question, Integer sortOrder, Boolean isActive) {
+        var sql = new StringBuilder("UPDATE quick_asks SET ");
+        var params = new ArrayList<>();
+        if (label != null) { sql.append("label = ?, "); params.add(label); }
+        if (question != null) { sql.append("question = ?, "); params.add(question); }
+        if (sortOrder != null) { sql.append("sort_order = ?, "); params.add(sortOrder); }
+        if (isActive != null) { sql.append("is_active = ?, "); params.add(isActive); }
+        if (params.isEmpty()) return Map.of("ok", false, "message", "无修改字段");
+
+        sql.setLength(sql.length() - 2); // 去掉末尾 ", "
+        sql.append(" WHERE id = ?");
+        params.add(id);
+
+        int rows = jdbc.update(sql.toString(), params.toArray());
+        return Map.of("ok", rows > 0, "message", rows > 0 ? "已更新" : "未找到");
+    }
+
+    /** 删除标签 */
+    public Map<String, Object> deleteQuickAsk(int id) {
+        int rows = jdbc.update("DELETE FROM quick_asks WHERE id = ?", id);
+        return Map.of("ok", rows > 0, "message", rows > 0 ? "已删除" : "未找到");
+    }
+
+    // ==================== Helpers ====================
+
     @SuppressWarnings("unchecked")
     private Map<String, Object> castToMap(Object obj) {
         if (obj instanceof Map) return (Map<String, Object>) obj;

+ 51 - 7
backend-java/src/main/java/com/pharmacopoeia/service/QACacheService.java

@@ -13,6 +13,7 @@ import java.security.NoSuchAlgorithmException;
 import java.time.Duration;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 /**
  * 全局 QA 缓存(不区分用户,24 小时过期)。
@@ -26,7 +27,7 @@ public class QACacheService {
 
     private static final String CACHE_PREFIX = "aiyaodian:qa:";
     private static final String PENDING_PREFIX = "aiyaodian:qa:pending:";
-    private static final Duration TTL = Duration.ofHours(24);
+    private static final Duration TTL = Duration.ofHours(168); // 7 天
     /** 处理中标记超时:防止死锁,30 秒后自动释放 */
     private static final Duration PENDING_TTL = Duration.ofSeconds(30);
     /** 等待缓存写入的最大重试次数 */
@@ -55,7 +56,9 @@ public class QACacheService {
 
     /** 规范化问题文本,用于生成缓存 key */
     public String normalize(String query) {
-        if (query == null) return "";
+        if (query == null) {
+            return "";
+        }
         return query.trim().replaceAll("\\s+", " ");
     }
 
@@ -77,8 +80,11 @@ public class QACacheService {
         }
         try {
             String json = redis.opsForValue().get(cacheKey(normalizedQuery));
-            if (json == null || json.isEmpty()) return null;
-            return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
+            if (json == null || json.isEmpty()) {
+                return null;
+            }
+            return objectMapper.readValue(json, new TypeReference<>() {
+            });
         } catch (Exception e) {
             return null;
         }
@@ -114,14 +120,14 @@ public class QACacheService {
      */
     public boolean tryMarkPending(String normalizedQuery) {
         if (!available || normalizedQuery == null || normalizedQuery.isBlank()) {
-            return true; // Redis 不可用时放行,避免阻塞
+            return false; // Redis 不可用时放行,避免阻塞
         }
         try {
             Boolean ok = redis.opsForValue()
                     .setIfAbsent(pendingKey(normalizedQuery), "1", PENDING_TTL);
-            return Boolean.TRUE.equals(ok);
+            return !Boolean.TRUE.equals(ok);
         } catch (Exception e) {
-            return true; // 异常时放行
+            return false; // 异常时放行
         }
     }
 
@@ -159,6 +165,44 @@ public class QACacheService {
         }
     }
 
+    /**
+     * 清除 QA 缓存。不传 keyword 则全清;传 keyword 则只清除 answer/content
+     * 中包含该关键字的条目。
+     * @param keyword 可选,不为空时按关键字过滤
+     * @return 删除的 key 数量
+     */
+    public int clearAll(String keyword) {
+        if (!available) {
+            log.warn("Redis 不可用,无法清除缓存");
+            return 0;
+        }
+        boolean filterByKeyword = keyword != null && !keyword.isBlank();
+        try {
+            var keys = redis.keys(CACHE_PREFIX + "*");
+            int count = 0;
+            if (!keys.isEmpty()) {
+                for (String key : keys) {
+                    if (filterByKeyword) {
+                        String json = redis.opsForValue().get(key);
+                        if (json == null || !json.contains(keyword)) {
+                            continue;
+                        }
+                    }
+                    count += Optional.of(redis.delete(key)).orElse(false) ? 1 : 0;
+                }
+            }
+            var pendingKeys = redis.keys(PENDING_PREFIX + "*");
+            if (!pendingKeys.isEmpty()) {
+                count += redis.delete(pendingKeys).intValue();
+            }
+            log.info("QA 缓存已清除: keyword={}, deleted={}", keyword, count);
+            return count;
+        } catch (Exception e) {
+            log.warn("清除 QA 缓存失败: {}", e.getMessage());
+            return 0;
+        }
+    }
+
     private static String sha256(String input) {
         try {
             MessageDigest md = MessageDigest.getInstance("SHA-256");

+ 2 - 1
backend-java/src/main/resources/application.yml

@@ -56,7 +56,8 @@ qwen:
 # 鉴权
 # ============================================
 auth:
-  enabled: true  # 上线后改为 true
+  enabled: true       # 上线后改为 true
+  admin-token: ""     # 运营端管理 token(与 JWT 无关,单独配置)
 
 # ============================================
 # JWT

+ 32 - 0
database/quick_asks.sql

@@ -0,0 +1,32 @@
+-- ============================================================
+-- 快捷提问标签表:前端首页的 quick-asks 标签由后端管理
+-- 执行方式:psql -h <host> -U <user> -d pharmacopoeia -f database/quick_asks.sql
+-- 通过 backend-java 的 JdbcTemplate 直接操作,不需要 JPA Entity
+-- ============================================================
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS public.quick_asks (
+    id SERIAL PRIMARY KEY,
+    label varchar(64) NOT NULL,
+    question varchar(256) NOT NULL,
+    sort_order int DEFAULT 0,
+    is_active boolean DEFAULT true,
+    created_at timestamptz DEFAULT now()
+);
+
+-- 初始数据(如已存在则跳过)
+INSERT INTO public.quick_asks (label, question, sort_order)
+SELECT v.label, v.question, v.sort_order
+FROM (VALUES
+    ('布洛芬', '布洛芬', 1),
+    ('甲硝唑', '甲硝唑', 2),
+    ('阿莫西林禁忌', '阿莫西林禁忌', 3),
+    ('布洛芬用法用量', '布洛芬用法用量', 4),
+    ('糠酸莫米松不良反应', '糠酸莫米松不良反应', 5),
+    ('青霉素过敏处理', '青霉素过敏处理', 6)
+) AS v(label, question, sort_order)
+WHERE NOT EXISTS (
+    SELECT 1 FROM public.quick_asks WHERE quick_asks.label = v.label
+);
+
+COMMIT;

+ 20 - 2
static/index.html

@@ -119,7 +119,7 @@
 </div>
 <div id="tabChat">
   <div class="chat" id="chat"><div class="welcome"><p>输入药品名称或问题,AI 从药典知识库中检索回答<br><small style="color:#aaa">支持图片上传识别</small></p></div></div>
-  <div class="tags"><span class="tag-btn" onclick="quickAsk('布洛芬')">布洛芬</span><span class="tag-btn" onclick="quickAsk('甲硝唑')">甲硝唑</span><span class="tag-btn" onclick="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</span><span class="tag-btn" onclick="quickAsk('布洛芬用法用量')">布洛芬用法用量</span><span class="tag-btn" onclick="quickAsk('糠酸莫米松不良反应')">糠酸莫米松不良反应</span><span class="tag-btn" onclick="quickAsk('青霉素过敏处理')">青霉素过敏处理</span></div>
+  <div class="tags" id="quickTags"></div>
   <div id="previewArea" style="display:none;padding:8px 14px;background:#fffef5;border-top:1px solid #f0e68c;align-items:center;gap:10px;flex-wrap:wrap">
     <span id="previewLabel" style="font-size:13px;color:#666;font-weight:500"></span>
     <img id="previewThumb" style="max-width:80px;max-height:60px;border-radius:6px;display:none;border:1px solid #ddd">
@@ -657,12 +657,30 @@
     loadDrugList(1);
   }
   };
-  // 初始化:打开本地数据库 → 恢复对话 → 预加载药品列表
+  // 初始化:打开本地数据库 → 恢复对话 → 预加载药品列表 → 加载快捷提问标签
       (async function init(){
         await openChatDB();
         await restoreChat();
         loadDrugList();
+        loadQuickAsks();
       })();
+
+  async function loadQuickAsks(){
+    try {
+      var r = await fetch(API_BASE + '/api/v1/quick-asks');
+      if (!r.ok) return;
+      var items = await r.json();
+      var container = document.getElementById('quickTags');
+      if (!items || !items.length) { container.style.display='none'; return; }
+      var html = '';
+      for (var i = 0; i < items.length; i++) {
+        var q = escAttr(items[i].question);
+        var lbl = esc(items[i].label);
+        html += '<span class="tag-btn" onclick="quickAsk(\'' + q + '\')">' + lbl + '</span>';
+      }
+      container.innerHTML = html;
+    } catch(e) {}
+  }
 </script>
 </body>
 </html>