liuchengsen пре 1 месец
родитељ
комит
ce73d348fb

+ 10 - 0
backend-java/src/main/java/com/pharmacopoeia/config/GlobalExceptionHandler.java

@@ -2,6 +2,7 @@ package com.pharmacopoeia.config;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.pharmacopoeia.dto.ApiResponse;
+import com.pharmacopoeia.exception.DashScopeRateLimitExceededException;
 import jakarta.servlet.http.HttpServletResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -23,6 +24,15 @@ public class GlobalExceptionHandler {
         writeJson(response, HttpStatus.BAD_REQUEST, ApiResponse.error(400, e.getMessage()));
     }
 
+    /** 百炼 API 限流 → 503,告知前端服务暂时不可用 */
+    @ExceptionHandler(DashScopeRateLimitExceededException.class)
+    public void handleDashScopeRateLimit(DashScopeRateLimitExceededException e,
+                                          HttpServletResponse response) throws IOException {
+        log.warn("DashScope rate limit exceeded: {}", e.getMessage());
+        writeJson(response, HttpStatus.SERVICE_UNAVAILABLE,
+                ApiResponse.error(503, e.getMessage()));
+    }
+
     @ExceptionHandler(RuntimeException.class)
     public void handleRuntime(RuntimeException e, HttpServletResponse response) throws IOException {
         log.error("Runtime error: {}", e.getMessage(), e);

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

@@ -98,15 +98,23 @@ public class ChatController {
         }
 
         String intent = retrieverService.classifyIntent(query);
-        List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
-        docs = rerankerService.rerank(docs, query, 5);
+        List<Map<String, Object>> docs;
+        String llmAnswer;
+        List<Map<String, Object>> sources;
+        try {
+            docs = retrieverService.search(query, intent, 20);
+            docs = rerankerService.rerank(docs, query, 5);
 
-        // 统一:LLM 回答 + 原文对照
-        List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
-        String llmAnswer = cleanAnswer(llmService.chat(messages));
+            // 统一:LLM 回答 + 原文对照
+            List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+            llmAnswer = cleanAnswer(llmService.chat(messages));
+            sources = buildSources(docs);
+        } catch (Exception e) {
+            // 异常时清除 PENDING 标记,避免后续同问题请求被锁死
+            qaCache.removePending(normalized);
+            throw e;
+        }
         String answer = llmAnswer;
-
-        List<Map<String, Object>> sources = buildSources(docs);
         persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
         persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources);
 
@@ -178,12 +186,16 @@ public class ChatController {
                                 persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
                                 persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
 
-                                // 写入全局缓存
+                                // 写入全局缓存 + 清除 PENDING 标记
                                 qaCache.put(normalized, finalAnswer, intent, sources);
 
                                 sink.tryEmitComplete();
                             })
-                            .doOnError(sink::tryEmitError)
+                            .doOnError(e -> {
+                                // 异常时清除 PENDING 标记,避免后续同问题请求被锁死
+                                qaCache.removePending(normalized);
+                                sink.tryEmitError(e);
+                            })
                             .subscribe();
 
                     return sink.asFlux();

+ 12 - 0
backend-java/src/main/java/com/pharmacopoeia/exception/DashScopeRateLimitExceededException.java

@@ -0,0 +1,12 @@
+package com.pharmacopoeia.exception;
+
+/**
+ * 百炼 API 调用频率超限异常。
+ * 与用户侧限流 429 不同,这里表示服务端到 LLM 的调用达到上限,
+ * 应该返回 503 告知前端服务暂时不可用。
+ */
+public class DashScopeRateLimitExceededException extends RuntimeException {
+    public DashScopeRateLimitExceededException(String message) {
+        super(message);
+    }
+}

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

@@ -67,7 +67,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
             String token = header.substring(7);
             log.info("JWT 认证请求: {} {} | token={}...", method, uri, token.substring(0, Math.min(20, token.length())));
             if (jwtUtil.validateToken(token)) {
-                String subject = jwtUtil.getSubject(token);
+                String subject = jwtUtil.getSu2bject(token);
                 var auth = new UsernamePasswordAuthenticationToken(subject, null, Collections.emptyList());
                 SecurityContextHolder.getContext().setAuthentication(auth);
                 log.info("JWT 认证通过: subject={}, uri={} {}", subject, method, uri);

+ 11 - 6
backend-java/src/main/java/com/pharmacopoeia/security/RateLimitFilter.java

@@ -49,9 +49,7 @@ public class RateLimitFilter extends OncePerRequestFilter {
             // 每 IP 每分钟限制
             var ipCount = minuteCounter.computeIfAbsent(ip, k -> new AtomicInteger(0));
             if (ipCount.incrementAndGet() > props.getPerMinute()) {
-                response.setStatus(429);
-                response.setContentType("application/json;charset=UTF-8");
-                response.getWriter().write("{\"error\":\"请求过于频繁,请稍后再试\",\"code\":429}");
+                writeRateLimitError(response, "请求过于频繁,请稍后再试");
                 return;
             }
 
@@ -60,9 +58,7 @@ public class RateLimitFilter extends OncePerRequestFilter {
             if (userId == null) userId = "ip:" + ip;
             var dailyCount = dailyCounter.computeIfAbsent(userId, k -> new AtomicInteger(0));
             if (dailyCount.get() >= props.getPerDay()) {
-                response.setStatus(429);
-                response.setContentType("application/json;charset=UTF-8");
-                response.getWriter().write("{\"error\":\"今日提问次数已用完,请明天再提问\",\"code\":429}");
+                writeRateLimitError(response, "今日提问次数已用完,请明天再提问");
                 return;
             }
         }
@@ -87,6 +83,15 @@ public class RateLimitFilter extends OncePerRequestFilter {
         }
     }
 
+    private static void writeRateLimitError(HttpServletResponse response, String message) throws IOException {
+        response.setStatus(429);
+        response.setContentType("application/json;charset=UTF-8");
+        // 使用 detail 字段,前端 api/ai.js 的 request() 会优先读取 res.data.detail
+        response.getWriter().write(
+            "{\"error\":\"" + message + "\",\"detail\":\"" + message + "\",\"code\":429}"
+        );
+    }
+
     private String getUserId(HttpServletRequest request) {
         String header = request.getHeader("Authorization");
         if (header != null && header.startsWith("Bearer ")) {

+ 3 - 2
backend-java/src/main/java/com/pharmacopoeia/service/LLMService.java

@@ -3,6 +3,7 @@ package com.pharmacopoeia.service;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.pharmacopoeia.config.QwenProperties;
+import com.pharmacopoeia.exception.DashScopeRateLimitExceededException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.http.MediaType;
@@ -44,10 +45,10 @@ public class LLMService {
                 .build();
     }
 
-    /** 检查百炼限流,超限时抛异常 */
+    /** 检查百炼限流,超限时抛专用异常 */
     private void checkRateLimit() {
         if (!rateLimiter.tryAcquire()) {
-            throw new RuntimeException("百炼 API 调用频率超限,请稍后再试");
+            throw new DashScopeRateLimitExceededException("百炼 API 调用频率超限,请稍后再试");
         }
     }
 

+ 14 - 0
backend-java/src/main/java/com/pharmacopoeia/service/QACacheService.java

@@ -145,6 +145,20 @@ public class QACacheService {
         return null; // 超时,调用方自行处理
     }
 
+    /**
+     * 移除"处理中"标记(限流/异常时调用,避免后续请求被 PENDING 锁死)。
+     */
+    public void removePending(String normalizedQuery) {
+        if (!available || normalizedQuery == null || normalizedQuery.isBlank()) {
+            return;
+        }
+        try {
+            redis.delete(pendingKey(normalizedQuery));
+        } catch (Exception e) {
+            log.warn("移除 PENDING 标记失败: {}", e.getMessage());
+        }
+    }
+
     private static String sha256(String input) {
         try {
             MessageDigest md = MessageDigest.getInstance("SHA-256");

+ 9 - 6
backend-java/src/main/java/com/pharmacopoeia/service/RetrieverService.java

@@ -107,10 +107,13 @@ public class RetrieverService {
         }
 
         List<Float> vec = llmService.embed(query);
-        try {
-            redis.opsForValue().set(key, encodeVec(vec), EMBED_CACHE_TTL);
-        } catch (DataAccessException ignored) {
-            // 缓存写入失败不影响主流程
+        // 限流/异常降级返回空列表时,不写入缓存,避免下次无法获取正确数据
+        if (!vec.isEmpty()) {
+            try {
+                redis.opsForValue().set(key, encodeVec(vec), EMBED_CACHE_TTL);
+            } catch (DataAccessException ignored) {
+                // 缓存写入失败不影响主流程
+            }
         }
         return vec;
     }
@@ -417,7 +420,7 @@ public class RetrieverService {
             List<Map<String, Object>> drugChunks = jdbc.queryForList(
                 """
                 SELECT c.content, c.source, c.drug_id, c.section
-                FROM drug_chunks c WHERE c.drug_id = ? AND c.vec IS NOT NULL
+                FROM drug_chunks c WHERE c.drug_id = ?
                 ORDER BY CASE c.section
                   WHEN '功能主治' THEN 1 WHEN '适应证' THEN 1 WHEN '主治' THEN 1
                   WHEN '用法与用量' THEN 2 WHEN '用法用量' THEN 2 WHEN '类别' THEN 3
@@ -452,7 +455,7 @@ public class RetrieverService {
             "SELECT c.content, c.source, c.drug_id, c.section, "
             + "d.name, d.category, d.source_version, d.source_volume "
             + "FROM drug_chunks c JOIN drugs d ON d.drug_id = c.drug_id "
-            + "WHERE c.vec IS NOT NULL AND (" + contentOr + ")" + excludeClause
+            + "WHERE (" + contentOr + ")" + excludeClause
             + " ORDER BY d.name LIMIT ?",
             params.toArray());
     }

+ 27 - 6
miniprogram/api/ai.js

@@ -25,7 +25,11 @@ function request(url, options = {}) {
         else if (res.statusCode === 401) {
           uni.navigateTo({ url: '/pages/login/index' })
           reject(new Error('Unauthorized'))
-        } else reject(new Error(res.data?.detail || 'HTTP ' + res.statusCode))
+        } else if (res.statusCode === 429) {
+          // 限流:优先读取服务器返回的具体提示信息
+          const msg = res.data?.detail || res.data?.message || res.data?.error || '请求过于频繁,请稍后再试'
+          reject(new Error(msg))
+        } else reject(new Error(res.data?.detail || res.data?.message || res.data?.error || 'HTTP ' + res.statusCode))
       },
       fail: (err) => reject(err)
     })
@@ -54,6 +58,8 @@ export function chatStream(data, callbacks) {
     reqData.media_mime = data.media_mime || ''
   }
 
+  let aborted = false
+
   const task = uni.request({
     url: BASE_URL + endpoint,
     method: 'POST',
@@ -65,7 +71,26 @@ export function chatStream(data, callbacks) {
     enableChunked: true,
     responseType: 'text',
     success: () => {},
-    fail: (err) => { if (onError) onError(err) }
+    fail: (err) => {
+      if (aborted) return
+      if (onError) onError(err)
+    }
+  })
+
+  task.onHeadersReceived((res) => {
+    // 检测 HTTP 状态码:429 或非 2xx 时立即中断
+    if (res.statusCode === 429) {
+      aborted = true
+      task.abort()
+      if (onError) onError(new Error('请求过于频繁,请稍后再试'))
+      return
+    }
+    if (res.statusCode >= 400) {
+      aborted = true
+      task.abort()
+      if (onError) onError(new Error('服务异常,请稍后再试'))
+      return
+    }
   })
 
   task.onChunkReceived((res) => {
@@ -102,10 +127,6 @@ export function chatStream(data, callbacks) {
     }
   })
 
-  task.onHeadersReceived(() => {
-    // SSE 连接建立
-  })
-
   return task
 }
 

+ 1 - 1
miniprogram/components/ai-chat/index.vue

@@ -154,7 +154,7 @@ export default {
           onToken: (d) => { this.messages[idx].thinking = ''; this.messages[idx].content += d; this.scrollBottom() },
           onMeta: (meta) => { if (meta.cid) this.conversationId = meta.cid },
           onDone: () => { this.messages[idx].thinking = '' },
-          onError: () => { this.messages[idx].content = '服务暂不可用' }
+          onError: (err) => { this.messages[idx].content = err.message || '服务暂不可用' }
         }
       )
       this.streaming = false

+ 5 - 0
static/index.html

@@ -411,6 +411,11 @@
       var ctrl=streamAbort?streamAbort:new AbortController();
       var r=await fetch(endpoint,{method:'POST',headers:authHeaders(),body:body,signal:ctrl.signal});
       if(r.status===401){showLoginModal();aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();contentDiv.innerHTML=md2html('请先登录后再提问');return}
+      if(r.status===429){
+        var detail='请求过于频繁,请稍后再试';
+        try{var b=await r.json();detail=b.detail||b.message||b.error||detail}catch(e){}
+        contentDiv.innerHTML=md2html(detail);aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();return
+      }
       if(!r.ok){contentDiv.innerHTML=md2html('请求失败: HTTP '+r.status);aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();return}
       sourcesData=[];
       var reader=r.body.getReader(),decoder=new TextDecoder(),buffer='',fullText='',eventType='';