Selaa lähdekoodia

交易市场平台(供应商)

wwh 1 viikko sitten
vanhempi
commit
5719377e37

+ 157 - 28
baqing-admin/src/main/java/com/ruoyi/web/kb/service/impl/KnowledgeBaseClientImpl.java

@@ -3,9 +3,12 @@ package com.ruoyi.web.kb.service.impl;
3 3
 import java.io.File;
4 4
 import java.net.URI;
5 5
 import java.nio.charset.StandardCharsets;
6
+import java.util.function.Supplier;
6 7
 
7 8
 import com.ruoyi.web.kb.support.KbApiProperties;
8 9
 import com.ruoyi.web.kb.service.KnowledgeBaseClient;
10
+import org.slf4j.Logger;
11
+import org.slf4j.LoggerFactory;
9 12
 import org.springframework.beans.factory.annotation.Qualifier;
10 13
 import org.springframework.core.io.FileSystemResource;
11 14
 import org.springframework.http.HttpEntity;
@@ -16,6 +19,7 @@ import org.springframework.stereotype.Service;
16 19
 import org.springframework.util.LinkedMultiValueMap;
17 20
 import org.springframework.util.MultiValueMap;
18 21
 import org.springframework.web.client.HttpStatusCodeException;
22
+import org.springframework.web.client.ResourceAccessException;
19 23
 import org.springframework.web.client.RestTemplate;
20 24
 import com.fasterxml.jackson.databind.JsonNode;
21 25
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -28,6 +32,8 @@ import com.ruoyi.common.utils.StringUtils;
28 32
 @Service
29 33
 public class KnowledgeBaseClientImpl implements KnowledgeBaseClient
30 34
 {
35
+    private static final Logger log = LoggerFactory.getLogger(KnowledgeBaseClientImpl.class);
36
+
31 37
     private static final String[] FILE_ID_KEYS = { "file_id", "fileId", "kbDocId", "kb_doc_id", "docId", "id" };
32 38
 
33 39
     private final KbApiProperties properties;
@@ -100,21 +106,18 @@ public class KnowledgeBaseClientImpl implements KnowledgeBaseClient
100 106
         HttpHeaders headers = new HttpHeaders();
101 107
         headers.add(properties.getAuthHeaderName(), properties.authHeaderValue());
102 108
         HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
103
-        String raw;
104
-        try
105
-        {
106
-            ResponseEntity<String> resp = restTemplate.exchange(URI.create(url), HttpMethod.POST, entity, String.class);
107
-            raw = resp.getBody();
108
-        }
109
-        catch (HttpStatusCodeException e)
110
-        {
111
-            String b = e.getResponseBodyAsString(StandardCharsets.UTF_8);
112
-            throw new ServiceException("知识库上传失败 HTTP " + e.getRawStatusCode() + ":" + abbreviate(b));
113
-        }
114
-        catch (Exception e)
115
-        {
116
-            throw new ServiceException("知识库上传异常:" + e.getMessage());
117
-        }
109
+        String raw = executeWithRetry("知识库上传", () -> {
110
+            try
111
+            {
112
+                ResponseEntity<String> resp = restTemplate.exchange(URI.create(url), HttpMethod.POST, entity, String.class);
113
+                return resp.getBody();
114
+            }
115
+            catch (HttpStatusCodeException e)
116
+            {
117
+                String b = e.getResponseBodyAsString(StandardCharsets.UTF_8);
118
+                throw new ServiceException("知识库上传失败 HTTP " + e.getRawStatusCode() + ":" + abbreviate(b));
119
+            }
120
+        });
118 121
         return parseUploadResponse(raw);
119 122
     }
120 123
 
@@ -149,27 +152,153 @@ public class KnowledgeBaseClientImpl implements KnowledgeBaseClient
149 152
         HttpHeaders headers = new HttpHeaders();
150 153
         headers.add(properties.getAuthHeaderName(), properties.authHeaderValue());
151 154
         HttpEntity<Void> entity = new HttpEntity<>(headers);
152
-        try
153
-        {
154
-            ResponseEntity<String> resp = restTemplate.exchange(URI.create(url), HttpMethod.DELETE, entity, String.class);
155
-            if (!resp.getStatusCode().is2xxSuccessful())
155
+        executeWithRetry("知识库删除", () -> {
156
+            try
156 157
             {
157
-                throw new ServiceException("知识库删除失败 HTTP " + resp.getStatusCodeValue());
158
+                ResponseEntity<String> resp = restTemplate.exchange(URI.create(url), HttpMethod.DELETE, entity, String.class);
159
+                if (!resp.getStatusCode().is2xxSuccessful())
160
+                {
161
+                    throw new ServiceException("知识库删除失败 HTTP " + resp.getStatusCodeValue());
162
+                }
163
+                return null;
158 164
             }
159
-        }
160
-        catch (HttpStatusCodeException e)
165
+            catch (HttpStatusCodeException e)
166
+            {
167
+                if (e.getRawStatusCode() == 404)
168
+                {
169
+                    return null;
170
+                }
171
+                String b = e.getResponseBodyAsString(StandardCharsets.UTF_8);
172
+                throw new ServiceException("知识库删除失败 HTTP " + e.getRawStatusCode() + ":" + abbreviate(b));
173
+            }
174
+        });
175
+    }
176
+
177
+    /**
178
+     * 超时或 transient 失败时重试,最多 {@link KbApiProperties#getMaxRetries()} 次(不含首次请求)。
179
+     */
180
+    private <T> T executeWithRetry(String operation, Supplier<T> action)
181
+    {
182
+        int maxRetries = Math.max(0, properties.getMaxRetries());
183
+        Exception last = null;
184
+        for (int attempt = 0; attempt <= maxRetries; attempt++)
161 185
         {
162
-            if (e.getRawStatusCode() == 404)
186
+            try
187
+            {
188
+                return action.get();
189
+            }
190
+            catch (ServiceException e)
191
+            {
192
+                if (attempt >= maxRetries || !isRetryable(e))
193
+                {
194
+                    throw e;
195
+                }
196
+                last = e;
197
+                log.warn("{}失败,第 {}/{} 次重试:{}", operation, attempt + 1, maxRetries, e.getMessage());
198
+                sleepBeforeRetry();
199
+            }
200
+            catch (Exception e)
163 201
             {
164
-                return;
202
+                ServiceException wrapped = wrapUnexpected(operation, e);
203
+                if (attempt >= maxRetries || !isRetryable(wrapped, e))
204
+                {
205
+                    throw wrapped;
206
+                }
207
+                last = wrapped;
208
+                log.warn("{}异常,第 {}/{} 次重试:{}", operation, attempt + 1, maxRetries, e.getMessage());
209
+                sleepBeforeRetry();
165 210
             }
166
-            String b = e.getResponseBodyAsString(StandardCharsets.UTF_8);
167
-            throw new ServiceException("知识库删除失败 HTTP " + e.getRawStatusCode() + ":" + abbreviate(b));
168 211
         }
169
-        catch (Exception e)
212
+        if (last instanceof ServiceException)
213
+        {
214
+            throw (ServiceException) last;
215
+        }
216
+        throw wrapUnexpected(operation, last);
217
+    }
218
+
219
+    private static boolean isRetryable(ServiceException e)
220
+    {
221
+        String msg = e.getMessage();
222
+        if (msg == null)
223
+        {
224
+            return false;
225
+        }
226
+        if (msg.contains("HTTP 404"))
227
+        {
228
+            return false;
229
+        }
230
+        if (msg.contains("HTTP 408") || msg.contains("HTTP 429"))
231
+        {
232
+            return true;
233
+        }
234
+        if (msg.matches(".*HTTP [45]\\d\\d.*"))
235
+        {
236
+            int code = extractHttpStatus(msg);
237
+            return code >= 500 || code == 408 || code == 429;
238
+        }
239
+        return false;
240
+    }
241
+
242
+    private static boolean isRetryable(ServiceException wrapped, Exception cause)
243
+    {
244
+        if (cause instanceof ResourceAccessException)
245
+        {
246
+            return true;
247
+        }
248
+        return isRetryable(wrapped);
249
+    }
250
+
251
+    private static int extractHttpStatus(String msg)
252
+    {
253
+        int idx = msg.indexOf("HTTP ");
254
+        if (idx < 0)
255
+        {
256
+            return 0;
257
+        }
258
+        int start = idx + 5;
259
+        int end = start;
260
+        while (end < msg.length() && Character.isDigit(msg.charAt(end)))
261
+        {
262
+            end++;
263
+        }
264
+        if (end == start)
265
+        {
266
+            return 0;
267
+        }
268
+        try
269
+        {
270
+            return Integer.parseInt(msg.substring(start, end));
271
+        }
272
+        catch (NumberFormatException e)
273
+        {
274
+            return 0;
275
+        }
276
+    }
277
+
278
+    private void sleepBeforeRetry()
279
+    {
280
+        int interval = Math.max(0, properties.getRetryIntervalMs());
281
+        if (interval <= 0)
282
+        {
283
+            return;
284
+        }
285
+        try
286
+        {
287
+            Thread.sleep(interval);
288
+        }
289
+        catch (InterruptedException e)
290
+        {
291
+            Thread.currentThread().interrupt();
292
+        }
293
+    }
294
+
295
+    private static ServiceException wrapUnexpected(String operation, Exception e)
296
+    {
297
+        if (e instanceof ServiceException)
170 298
         {
171
-            throw new ServiceException("知识库删除异常:" + e.getMessage());
299
+            return (ServiceException) e;
172 300
         }
301
+        return new ServiceException(operation + "异常:" + e.getMessage());
173 302
     }
174 303
 
175 304
     private static String encodePathSegment(String id)

+ 26 - 0
baqing-admin/src/main/java/com/ruoyi/web/kb/support/KbApiProperties.java

@@ -40,6 +40,12 @@ public class KbApiProperties
40 40
     /** 对话流式 SSE(/v1/chat/completions stream=true)读超时 */
41 41
     private int streamReadTimeoutMs = 300000;
42 42
 
43
+    /** 知识库同步/移出失败或超时时的最大重试次数(不含首次请求) */
44
+    private int maxRetries = 2;
45
+
46
+    /** 重试间隔(毫秒) */
47
+    private int retryIntervalMs = 500;
48
+
43 49
     public boolean isEnabled()
44 50
     {
45 51
         return enabled;
@@ -167,6 +173,26 @@ public class KbApiProperties
167 173
         this.streamReadTimeoutMs = streamReadTimeoutMs;
168 174
     }
169 175
 
176
+    public int getMaxRetries()
177
+    {
178
+        return maxRetries;
179
+    }
180
+
181
+    public void setMaxRetries(int maxRetries)
182
+    {
183
+        this.maxRetries = maxRetries;
184
+    }
185
+
186
+    public int getRetryIntervalMs()
187
+    {
188
+        return retryIntervalMs;
189
+    }
190
+
191
+    public void setRetryIntervalMs(int retryIntervalMs)
192
+    {
193
+        this.retryIntervalMs = retryIntervalMs;
194
+    }
195
+
170 196
     public String baseUrl()
171 197
     {
172 198
         return scheme + "://" + host + ":" + port;

+ 3 - 0
baqing-admin/src/main/resources/application.yml

@@ -22,6 +22,9 @@ ruoyi:
22 22
     file-url-base: ""
23 23
     connect-timeout-ms: 15000
24 24
     read-timeout-ms: 120000
25
+    # 知识库同步/移出失败或超时重试次数(不含首次请求)
26
+    max-retries: 2
27
+    retry-interval-ms: 500
25 28
     # 对话流式 SSE(/v1/chat/completions stream=true)读超时
26 29
     stream-read-timeout-ms: 300000
27 30
   # 获取ip地址开关

+ 110 - 0
baqing-admin/src/test/java/com/ruoyi/web/kb/service/impl/KnowledgeBaseClientImplTest.java

@@ -0,0 +1,110 @@
1
+package com.ruoyi.web.kb.service.impl;
2
+
3
+import static org.junit.jupiter.api.Assertions.assertEquals;
4
+import static org.junit.jupiter.api.Assertions.assertThrows;
5
+import static org.mockito.ArgumentMatchers.any;
6
+import static org.mockito.ArgumentMatchers.eq;
7
+import static org.mockito.Mockito.times;
8
+import static org.mockito.Mockito.verify;
9
+import static org.mockito.Mockito.when;
10
+
11
+import java.net.URI;
12
+
13
+import org.junit.jupiter.api.BeforeEach;
14
+import org.junit.jupiter.api.DisplayName;
15
+import org.junit.jupiter.api.Test;
16
+import org.junit.jupiter.api.extension.ExtendWith;
17
+import org.mockito.Mock;
18
+import org.mockito.junit.jupiter.MockitoExtension;
19
+import org.springframework.http.HttpEntity;
20
+import org.springframework.http.HttpMethod;
21
+import org.springframework.http.HttpStatus;
22
+import org.springframework.http.ResponseEntity;
23
+import org.springframework.web.client.HttpClientErrorException;
24
+import org.springframework.web.client.HttpServerErrorException;
25
+import org.springframework.web.client.ResourceAccessException;
26
+import org.springframework.web.client.RestTemplate;
27
+
28
+import com.fasterxml.jackson.databind.ObjectMapper;
29
+import com.ruoyi.common.exception.ServiceException;
30
+import com.ruoyi.web.kb.support.KbApiProperties;
31
+
32
+@ExtendWith(MockitoExtension.class)
33
+@DisplayName("KnowledgeBaseClientImpl 重试")
34
+class KnowledgeBaseClientImplTest
35
+{
36
+    @Mock
37
+    private RestTemplate restTemplate;
38
+
39
+    private KbApiProperties properties;
40
+
41
+    private KnowledgeBaseClientImpl client;
42
+
43
+    @BeforeEach
44
+    void setUp()
45
+    {
46
+        properties = new KbApiProperties();
47
+        properties.setEnabled(true);
48
+        properties.setApiKey("test-key");
49
+        properties.setHost("localhost");
50
+        properties.setPort(9107);
51
+        properties.setMaxRetries(2);
52
+        properties.setRetryIntervalMs(0);
53
+        client = new KnowledgeBaseClientImpl(properties, restTemplate, new ObjectMapper());
54
+    }
55
+
56
+    @Test
57
+    @DisplayName("上传超时后重试2次,第3次成功")
58
+    void uploadFileByUrl_retriesOnTimeoutThenSucceeds()
59
+    {
60
+        when(restTemplate.exchange(any(URI.class), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)))
61
+                .thenThrow(new ResourceAccessException("Read timed out"))
62
+                .thenThrow(new ResourceAccessException("Read timed out"))
63
+                .thenReturn(ResponseEntity.ok("{\"code\":200,\"data\":{\"file_id\":\"DOC-1\"}}"));
64
+
65
+        String fileId = client.uploadFileByUrl("http://localhost/a.pdf", "分类", "描述");
66
+
67
+        assertEquals("DOC-1", fileId);
68
+        verify(restTemplate, times(3)).exchange(any(URI.class), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class));
69
+    }
70
+
71
+    @Test
72
+    @DisplayName("上传5xx后重试2次仍失败则抛出异常")
73
+    void uploadFileByUrl_retriesExhausted()
74
+    {
75
+        when(restTemplate.exchange(any(URI.class), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)))
76
+                .thenThrow(HttpServerErrorException.create(HttpStatus.BAD_GATEWAY, "bad gateway", null, null, null));
77
+
78
+        ServiceException ex = assertThrows(ServiceException.class,
79
+                () -> client.uploadFileByUrl("http://localhost/a.pdf", "分类", "描述"));
80
+
81
+        assertEquals(true, ex.getMessage().contains("502"));
82
+        verify(restTemplate, times(3)).exchange(any(URI.class), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class));
83
+    }
84
+
85
+    @Test
86
+    @DisplayName("删除超时后重试2次,第3次成功")
87
+    void deleteFile_retriesOnTimeoutThenSucceeds()
88
+    {
89
+        when(restTemplate.exchange(any(URI.class), eq(HttpMethod.DELETE), any(HttpEntity.class), eq(String.class)))
90
+                .thenThrow(new ResourceAccessException("connect timed out"))
91
+                .thenThrow(new ResourceAccessException("connect timed out"))
92
+                .thenReturn(ResponseEntity.ok("{\"code\":200}"));
93
+
94
+        client.deleteFile("DOC-9");
95
+
96
+        verify(restTemplate, times(3)).exchange(any(URI.class), eq(HttpMethod.DELETE), any(HttpEntity.class), eq(String.class));
97
+    }
98
+
99
+    @Test
100
+    @DisplayName("删除404不重试")
101
+    void deleteFile_notFoundNoRetry()
102
+    {
103
+        when(restTemplate.exchange(any(URI.class), eq(HttpMethod.DELETE), any(HttpEntity.class), eq(String.class)))
104
+                .thenThrow(HttpClientErrorException.create(HttpStatus.NOT_FOUND, "not found", null, null, null));
105
+
106
+        client.deleteFile("DOC-MISSING");
107
+
108
+        verify(restTemplate, times(1)).exchange(any(URI.class), eq(HttpMethod.DELETE), any(HttpEntity.class), eq(String.class));
109
+    }
110
+}