Kaynağa Gözat

增加与ai大模型对话

wwh 3 hafta önce
ebeveyn
işleme
d0d4d090f5

+ 7 - 0
huimv-employment/fe-api/src/main/resources/application.yml

@@ -48,6 +48,13 @@ fe:
48 48
     port: 9107
49 49
     api-key: ${FE_KB_API_KEY:}
50 50
     default-model: default
51
+    default-knowledge-base: default
52
+    file-url-base: ""
53
+    upload-path: ${FE_KB_UPLOAD_PATH:}
54
+    url-rewrite-from-host: ""
55
+    url-rewrite-to-host: ""
51 56
     connect-timeout-ms: 15000
52 57
     read-timeout-ms: 120000
53 58
     stream-read-timeout-ms: 300000
59
+    max-retries: 2
60
+    retry-interval-ms: 500

+ 6 - 0
huimv-employment/fe-common/src/main/java/com/huimv/employment/common/exception/ErrorCode.java

@@ -57,6 +57,12 @@ public enum ErrorCode {
57 57
     /** AI:调用上游大模型失败 */
58 58
     AI_SERVICE_FAILED(50003, "AI 服务调用失败,请稍后再试"),
59 59
 
60
+    /** 知识库:远程服务已关闭 */
61
+    KB_SERVICE_DISABLED(50302, "知识库服务未启用"),
62
+
63
+    /** 知识库:调用上游失败 */
64
+    KB_SERVICE_FAILED(50004, "知识库服务调用失败,请稍后再试"),
65
+
60 66
     /** 通用:未预期的系统异常 */
61 67
     INTERNAL_ERROR(500, "系统繁忙,请稍后再试");
62 68
 

+ 85 - 1
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbApiProperties.java

@@ -3,7 +3,7 @@ package com.huimv.employment.integration.kb;
3 3
 import org.springframework.boot.context.properties.ConfigurationProperties;
4 4
 
5 5
 /**
6
- * 大模型网关 HTTP 对接(OpenAI 兼容 {@code /v1/models}、{@code /v1/chat/completions}),绑定 {@code fe.kb.*}。
6
+ * 大模型网关 HTTP 对接(知识库 {@code /api/v1/kb/**}、对话 {@code /v1/models}、{@code /v1/chat/completions}),绑定 {@code fe.kb.*}。
7 7
  */
8 8
 @ConfigurationProperties(prefix = "fe.kb")
9 9
 public class KbApiProperties {
@@ -22,9 +22,31 @@ public class KbApiProperties {
22 22
 
23 23
     private String authHeaderName = "Authorization";
24 24
 
25
+    /**
26
+     * 当业务表 {@code content_file_url} 不是 http(s) 开头时,与其拼接成知识库可拉取的完整 URL。
27
+     */
28
+    private String fileUrlBase = "";
29
+
30
+    /** 方式一上传默认知识库标识 */
31
+    private String defaultKnowledgeBase = "default";
32
+
25 33
     /** agent/messages 等非透传场景默认模型 */
26 34
     private String defaultModel = "default";
27 35
 
36
+    /**
37
+     * Markdown 模板与生成文件根目录(对应若依 ruoyi.profile)。
38
+     * 为空时 {@link KbMarkdownGenerator} 不可用。
39
+     */
40
+    private String uploadPath = "";
41
+
42
+    /**
43
+     * 可选:正文 URL 含此外网域名时,改写为 {@link #urlRewriteToHost} 供知识库节点内网拉取。
44
+     */
45
+    private String urlRewriteFromHost = "";
46
+
47
+    /** 可选:URL 改写目标 host(含端口,如 192.168.1.1:443) */
48
+    private String urlRewriteToHost = "";
49
+
28 50
     private int connectTimeoutMs = 15000;
29 51
 
30 52
     private int readTimeoutMs = 120000;
@@ -32,6 +54,12 @@ public class KbApiProperties {
32 54
     /** 对话流式 SSE(stream=true)读超时 */
33 55
     private int streamReadTimeoutMs = 300000;
34 56
 
57
+    /** 知识库同步/移出失败或超时时的最大重试次数(不含首次请求) */
58
+    private int maxRetries = 2;
59
+
60
+    /** 重试间隔(毫秒) */
61
+    private int retryIntervalMs = 500;
62
+
35 63
     public boolean isEnabled() {
36 64
         return enabled;
37 65
     }
@@ -80,6 +108,22 @@ public class KbApiProperties {
80 108
         this.authHeaderName = authHeaderName;
81 109
     }
82 110
 
111
+    public String getFileUrlBase() {
112
+        return fileUrlBase;
113
+    }
114
+
115
+    public void setFileUrlBase(String fileUrlBase) {
116
+        this.fileUrlBase = fileUrlBase;
117
+    }
118
+
119
+    public String getDefaultKnowledgeBase() {
120
+        return defaultKnowledgeBase;
121
+    }
122
+
123
+    public void setDefaultKnowledgeBase(String defaultKnowledgeBase) {
124
+        this.defaultKnowledgeBase = defaultKnowledgeBase;
125
+    }
126
+
83 127
     public String getDefaultModel() {
84 128
         return defaultModel;
85 129
     }
@@ -88,6 +132,30 @@ public class KbApiProperties {
88 132
         this.defaultModel = defaultModel;
89 133
     }
90 134
 
135
+    public String getUploadPath() {
136
+        return uploadPath;
137
+    }
138
+
139
+    public void setUploadPath(String uploadPath) {
140
+        this.uploadPath = uploadPath;
141
+    }
142
+
143
+    public String getUrlRewriteFromHost() {
144
+        return urlRewriteFromHost;
145
+    }
146
+
147
+    public void setUrlRewriteFromHost(String urlRewriteFromHost) {
148
+        this.urlRewriteFromHost = urlRewriteFromHost;
149
+    }
150
+
151
+    public String getUrlRewriteToHost() {
152
+        return urlRewriteToHost;
153
+    }
154
+
155
+    public void setUrlRewriteToHost(String urlRewriteToHost) {
156
+        this.urlRewriteToHost = urlRewriteToHost;
157
+    }
158
+
91 159
     public int getConnectTimeoutMs() {
92 160
         return connectTimeoutMs;
93 161
     }
@@ -112,6 +180,22 @@ public class KbApiProperties {
112 180
         this.streamReadTimeoutMs = streamReadTimeoutMs;
113 181
     }
114 182
 
183
+    public int getMaxRetries() {
184
+        return maxRetries;
185
+    }
186
+
187
+    public void setMaxRetries(int maxRetries) {
188
+        this.maxRetries = maxRetries;
189
+    }
190
+
191
+    public int getRetryIntervalMs() {
192
+        return retryIntervalMs;
193
+    }
194
+
195
+    public void setRetryIntervalMs(int retryIntervalMs) {
196
+        this.retryIntervalMs = retryIntervalMs;
197
+    }
198
+
115 199
     /** 请求头鉴权值,默认 {@code Bearer {apiKey}}。 */
116 200
     public String authHeaderValue() {
117 201
         if (apiKey == null || apiKey.isEmpty()) {

+ 84 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbFileUrlResolver.java

@@ -0,0 +1,84 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import com.huimv.employment.common.exception.BizException;
4
+import com.huimv.employment.common.exception.ErrorCode;
5
+import org.springframework.util.StringUtils;
6
+
7
+import java.net.URI;
8
+import java.net.URISyntaxException;
9
+
10
+/**
11
+ * 将业务表中的 {@code content_file_url} 解析为知识库「按 URL 拉取」接口所需的完整地址。
12
+ */
13
+public final class KbFileUrlResolver {
14
+
15
+    private KbFileUrlResolver() {
16
+    }
17
+
18
+    /**
19
+     * @param contentFileUrl 表字段 content_file_url
20
+     * @param properties     {@code fe.kb} 配置(file-url-base、可选 URL 改写)
21
+     */
22
+    public static String resolveForKbUpload(String contentFileUrl, KbApiProperties properties) {
23
+        if (!StringUtils.hasText(contentFileUrl)) {
24
+            throw new BizException(ErrorCode.BAD_REQUEST, "正文访问地址为空,无法同步知识库");
25
+        }
26
+        String fileUrlBase = properties != null ? properties.getFileUrlBase() : "";
27
+        String p = contentFileUrl.trim();
28
+        if (startsWithHttp(p)) {
29
+            return rewriteHostIfConfigured(contentFileUrl, p, properties);
30
+        }
31
+        if (!StringUtils.hasText(fileUrlBase)) {
32
+            throw new BizException(ErrorCode.BAD_REQUEST,
33
+                    "正文访问地址不是完整 http(s) 地址时,请在配置中设置 fe.kb.file-url-base");
34
+        }
35
+        String base = fileUrlBase.trim();
36
+        while (base.endsWith("/")) {
37
+            base = base.substring(0, base.length() - 1);
38
+        }
39
+        String path = p.startsWith("/") ? p : "/" + p;
40
+        return rewriteHostIfConfigured(contentFileUrl, base + path, properties);
41
+    }
42
+
43
+    private static boolean startsWithHttp(String url) {
44
+        return url.regionMatches(true, 0, "http://", 0, 7)
45
+                || url.regionMatches(true, 0, "https://", 0, 8);
46
+    }
47
+
48
+    /**
49
+     * 正文 URL 含配置的外网域名时,将解析结果中的域名替换为内网地址,便于知识库节点拉取文件。
50
+     */
51
+    static String rewriteHostIfConfigured(String contentFileUrl, String resolvedUrl, KbApiProperties properties) {
52
+        if (!StringUtils.hasText(contentFileUrl) || !StringUtils.hasText(resolvedUrl) || properties == null) {
53
+            return resolvedUrl;
54
+        }
55
+        String fromHost = properties.getUrlRewriteFromHost();
56
+        String toHost = properties.getUrlRewriteToHost();
57
+        if (!StringUtils.hasText(fromHost) || !StringUtils.hasText(toHost)) {
58
+            return resolvedUrl;
59
+        }
60
+        if (!contentFileUrl.contains(fromHost)) {
61
+            return resolvedUrl;
62
+        }
63
+        try {
64
+            URI uri = new URI(resolvedUrl);
65
+            if (uri.getHost() == null || !fromHost.equalsIgnoreCase(uri.getHost())) {
66
+                return resolvedUrl;
67
+            }
68
+            StringBuilder sb = new StringBuilder();
69
+            sb.append(uri.getScheme()).append("://").append(toHost);
70
+            if (uri.getRawPath() != null) {
71
+                sb.append(uri.getRawPath());
72
+            }
73
+            if (uri.getRawQuery() != null) {
74
+                sb.append('?').append(uri.getRawQuery());
75
+            }
76
+            if (uri.getRawFragment() != null) {
77
+                sb.append('#').append(uri.getRawFragment());
78
+            }
79
+            return sb.toString();
80
+        } catch (URISyntaxException e) {
81
+            return resolvedUrl;
82
+        }
83
+    }
84
+}

+ 128 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbMarkdownGenerator.java

@@ -0,0 +1,128 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import com.huimv.employment.common.exception.BizException;
4
+import com.huimv.employment.common.exception.ErrorCode;
5
+import org.springframework.core.io.ClassPathResource;
6
+import org.springframework.stereotype.Component;
7
+import org.springframework.util.StringUtils;
8
+
9
+import java.io.IOException;
10
+import java.io.InputStream;
11
+import java.nio.charset.StandardCharsets;
12
+import java.nio.file.Files;
13
+import java.nio.file.Path;
14
+import java.nio.file.Paths;
15
+import java.nio.file.StandardCopyOption;
16
+import java.util.Map;
17
+
18
+/**
19
+ * 基于模板占位符生成 Markdown 文件(输出至 {@code fe.kb.upload-path/template} 目录)。
20
+ */
21
+@Component
22
+public class KbMarkdownGenerator {
23
+
24
+    /** 相对 upload-path 的模板目录名 */
25
+    public static final String TEMPLATE_DIR = "template";
26
+
27
+    /** classpath 内置模板目录,部署时可复制到上传目录 */
28
+    public static final String CLASSPATH_TEMPLATE_PREFIX = "kb-template/";
29
+
30
+    private final KbApiProperties properties;
31
+
32
+    public KbMarkdownGenerator(KbApiProperties properties) {
33
+        this.properties = properties;
34
+    }
35
+
36
+    /**
37
+     * 从模板生成 Markdown 文件。
38
+     *
39
+     * @param templateFileName 模板文件名
40
+     * @param outputFileName   输出文件名
41
+     * @param placeholders     占位符键名不含花括号,如 {@code drugName} → {@code {drugName}}
42
+     * @return 生成文件的绝对路径
43
+     */
44
+    public Path generateFromTemplate(String templateFileName, String outputFileName, Map<String, String> placeholders) {
45
+        if (!StringUtils.hasText(templateFileName)) {
46
+            throw new BizException(ErrorCode.BAD_REQUEST, "Markdown 模板文件名为空");
47
+        }
48
+        if (!StringUtils.hasText(outputFileName)) {
49
+            throw new BizException(ErrorCode.BAD_REQUEST, "Markdown 输出文件名为空");
50
+        }
51
+        Path templatePath;
52
+        try {
53
+            templatePath = resolveTemplatePath(templateFileName);
54
+        } catch (IOException e) {
55
+            throw new BizException(ErrorCode.KB_SERVICE_FAILED, "准备 Markdown 模板失败:" + e.getMessage());
56
+        }
57
+        String content;
58
+        try {
59
+            content = new String(Files.readAllBytes(templatePath), StandardCharsets.UTF_8);
60
+        } catch (IOException e) {
61
+            throw new BizException(ErrorCode.KB_SERVICE_FAILED,
62
+                    "读取 Markdown 模板失败:" + templatePath + "," + e.getMessage());
63
+        }
64
+        content = applyPlaceholders(content, placeholders);
65
+        Path outputDir = templateDirectory();
66
+        try {
67
+            Files.createDirectories(outputDir);
68
+            Path output = outputDir.resolve(outputFileName);
69
+            Files.write(output, content.getBytes(StandardCharsets.UTF_8));
70
+            return output;
71
+        } catch (IOException e) {
72
+            throw new BizException(ErrorCode.KB_SERVICE_FAILED, "写入 Markdown 文件失败:" + e.getMessage());
73
+        }
74
+    }
75
+
76
+    public Path templateDirectory() {
77
+        String profile = properties.getUploadPath();
78
+        if (!StringUtils.hasText(profile)) {
79
+            throw new BizException(ErrorCode.BAD_REQUEST, "未配置 fe.kb.upload-path,无法生成知识库 Markdown 文件");
80
+        }
81
+        return Paths.get(profile, TEMPLATE_DIR);
82
+    }
83
+
84
+    /** 删除临时 Markdown(忽略不存在)。 */
85
+    public void deleteQuietly(Path path) {
86
+        if (path == null) {
87
+            return;
88
+        }
89
+        try {
90
+            Files.deleteIfExists(path);
91
+        } catch (IOException ignored) {
92
+            // 清理失败不阻断主流程
93
+        }
94
+    }
95
+
96
+    private Path resolveTemplatePath(String templateFileName) throws IOException {
97
+        Path inProfile = templateDirectory().resolve(templateFileName);
98
+        if (Files.isRegularFile(inProfile)) {
99
+            return inProfile;
100
+        }
101
+        ClassPathResource resource = new ClassPathResource(CLASSPATH_TEMPLATE_PREFIX + templateFileName);
102
+        if (!resource.exists()) {
103
+            throw new BizException(ErrorCode.BAD_REQUEST, "未找到 Markdown 模板:" + templateFileName
104
+                    + "(请置于 " + inProfile + " 或 classpath:" + CLASSPATH_TEMPLATE_PREFIX + templateFileName + ")");
105
+        }
106
+        Files.createDirectories(templateDirectory());
107
+        try (InputStream in = resource.getInputStream()) {
108
+            Files.copy(in, inProfile, StandardCopyOption.REPLACE_EXISTING);
109
+        }
110
+        return inProfile;
111
+    }
112
+
113
+    private static String applyPlaceholders(String template, Map<String, String> placeholders) {
114
+        if (placeholders == null || placeholders.isEmpty()) {
115
+            return template;
116
+        }
117
+        String result = template;
118
+        for (Map.Entry<String, String> entry : placeholders.entrySet()) {
119
+            String key = entry.getKey();
120
+            if (!StringUtils.hasText(key)) {
121
+                continue;
122
+            }
123
+            String value = entry.getValue() != null ? entry.getValue() : "";
124
+            result = result.replace("{" + key + "}", value);
125
+        }
126
+        return result;
127
+    }
128
+}

+ 37 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KnowledgeBaseClient.java

@@ -0,0 +1,37 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import java.io.File;
4
+
5
+/**
6
+ * 与大模型知识库后台的 HTTP 交互(上传文档、删除文档等),供各业务模块复用。
7
+ */
8
+public interface KnowledgeBaseClient {
9
+
10
+    /**
11
+     * 方式一:multipart/form-data 上传本地文件入库。
12
+     *
13
+     * @param file           本地文件
14
+     * @param uploadFilename multipart 中 file 部分的文件名
15
+     * @param knowledgeBase  知识库标识,默认 {@code default}
16
+     * @param category       分类
17
+     * @return 知识库返回的 file_id
18
+     */
19
+    String uploadFile(File file, String uploadFilename, String knowledgeBase, String category);
20
+
21
+    /**
22
+     * 方式二:multipart/form-data 提交 url、category、description 上传文件入库。
23
+     *
24
+     * @param fileUrl     知识库可 HTTP 拉取的正文地址
25
+     * @param category    分类
26
+     * @param description 描述
27
+     * @return 知识库返回的 file_id
28
+     */
29
+    String uploadFileByUrl(String fileUrl, String category, String description);
30
+
31
+    /**
32
+     * 按知识库文档 id 移出条目。
33
+     *
34
+     * @param kbDocId {@code /api/v1/kb/files/{id}} 中的 id
35
+     */
36
+    void deleteFile(String kbDocId);
37
+}

+ 331 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KnowledgeBaseClientImpl.java

@@ -0,0 +1,331 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.huimv.employment.common.exception.BizException;
6
+import com.huimv.employment.common.exception.ErrorCode;
7
+import org.slf4j.Logger;
8
+import org.slf4j.LoggerFactory;
9
+import org.springframework.beans.factory.annotation.Qualifier;
10
+import org.springframework.core.io.FileSystemResource;
11
+import org.springframework.http.HttpEntity;
12
+import org.springframework.http.HttpHeaders;
13
+import org.springframework.http.HttpMethod;
14
+import org.springframework.http.ResponseEntity;
15
+import org.springframework.stereotype.Service;
16
+import org.springframework.util.LinkedMultiValueMap;
17
+import org.springframework.util.MultiValueMap;
18
+import org.springframework.util.StringUtils;
19
+import org.springframework.web.client.HttpStatusCodeException;
20
+import org.springframework.web.client.ResourceAccessException;
21
+import org.springframework.web.client.RestTemplate;
22
+
23
+import java.io.File;
24
+import java.io.UnsupportedEncodingException;
25
+import java.net.URI;
26
+import java.net.URLEncoder;
27
+import java.nio.charset.StandardCharsets;
28
+import java.util.function.Supplier;
29
+
30
+/**
31
+ * 调用大模型知识库开放 API(multipart 上传 / 删除文件等)。
32
+ */
33
+@Service
34
+public class KnowledgeBaseClientImpl implements KnowledgeBaseClient {
35
+
36
+    private static final Logger log = LoggerFactory.getLogger(KnowledgeBaseClientImpl.class);
37
+
38
+    private static final String[] FILE_ID_KEYS = { "file_id", "fileId", "kbDocId", "kb_doc_id", "docId", "id" };
39
+
40
+    private final KbApiProperties properties;
41
+    private final RestTemplate restTemplate;
42
+    private final ObjectMapper objectMapper;
43
+
44
+    public KnowledgeBaseClientImpl(KbApiProperties properties,
45
+                                   @Qualifier("kbRestTemplate") RestTemplate restTemplate,
46
+                                   ObjectMapper objectMapper) {
47
+        this.properties = properties;
48
+        this.restTemplate = restTemplate;
49
+        this.objectMapper = objectMapper;
50
+    }
51
+
52
+    @Override
53
+    public String uploadFile(File file, String uploadFilename, String knowledgeBase, String category) {
54
+        ensureReady();
55
+        if (file == null || !file.isFile()) {
56
+            throw new BizException(ErrorCode.BAD_REQUEST, "上传文件无效或不存在");
57
+        }
58
+        String kb = StringUtils.hasText(knowledgeBase) ? knowledgeBase.trim() : properties.getDefaultKnowledgeBase();
59
+        if (!StringUtils.hasText(kb)) {
60
+            kb = "default";
61
+        }
62
+        String name = StringUtils.hasText(uploadFilename) ? uploadFilename.trim() : file.getName();
63
+
64
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
65
+        body.add("knowledge_base", kb);
66
+        body.add("category", category);
67
+        body.add("file", multipartFileResource(file, name));
68
+
69
+        return postMultipart(body);
70
+    }
71
+
72
+    @Override
73
+    public String uploadFileByUrl(String fileUrl, String category, String description) {
74
+        ensureReady();
75
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
76
+        body.add("url", fileUrl);
77
+        body.add("category", category);
78
+        body.add("description", description);
79
+        return postMultipart(body);
80
+    }
81
+
82
+    private String postMultipart(MultiValueMap<String, Object> body) {
83
+        String url = properties.baseUrl() + "/api/v1/kb/files";
84
+        HttpHeaders headers = new HttpHeaders();
85
+        headers.add(properties.getAuthHeaderName(), properties.authHeaderValue());
86
+        HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
87
+        String raw = executeWithRetry("知识库上传", () -> {
88
+            try {
89
+                ResponseEntity<String> resp = restTemplate.exchange(URI.create(url), HttpMethod.POST, entity, String.class);
90
+                return resp.getBody();
91
+            } catch (HttpStatusCodeException e) {
92
+                String b = e.getResponseBodyAsString(StandardCharsets.UTF_8);
93
+                throw new BizException(ErrorCode.KB_SERVICE_FAILED,
94
+                        "知识库上传失败 HTTP " + e.getRawStatusCode() + ":" + abbreviate(b));
95
+            }
96
+        });
97
+        return parseUploadResponse(raw);
98
+    }
99
+
100
+    private static FileSystemResource multipartFileResource(File file, String uploadName) {
101
+        return new FileSystemResource(file) {
102
+            @Override
103
+            public String getFilename() {
104
+                return uploadName;
105
+            }
106
+        };
107
+    }
108
+
109
+    @Override
110
+    public void deleteFile(String kbDocId) {
111
+        ensureReady();
112
+        if (!StringUtils.hasText(kbDocId)) {
113
+            throw new BizException(ErrorCode.BAD_REQUEST, "知识库文档 id 为空");
114
+        }
115
+        String url = properties.baseUrl() + "/api/v1/kb/files/" + encodePathSegment(kbDocId);
116
+        HttpHeaders headers = new HttpHeaders();
117
+        headers.add(properties.getAuthHeaderName(), properties.authHeaderValue());
118
+        HttpEntity<Void> entity = new HttpEntity<>(headers);
119
+        executeWithRetry("知识库删除", () -> {
120
+            try {
121
+                ResponseEntity<String> resp = restTemplate.exchange(URI.create(url), HttpMethod.DELETE, entity, String.class);
122
+                if (!resp.getStatusCode().is2xxSuccessful()) {
123
+                    throw new BizException(ErrorCode.KB_SERVICE_FAILED,
124
+                            "知识库删除失败 HTTP " + resp.getStatusCode().value());
125
+                }
126
+                return null;
127
+            } catch (HttpStatusCodeException e) {
128
+                if (e.getRawStatusCode() == 404) {
129
+                    return null;
130
+                }
131
+                String b = e.getResponseBodyAsString(StandardCharsets.UTF_8);
132
+                throw new BizException(ErrorCode.KB_SERVICE_FAILED,
133
+                        "知识库删除失败 HTTP " + e.getRawStatusCode() + ":" + abbreviate(b));
134
+            }
135
+        });
136
+    }
137
+
138
+    private <T> T executeWithRetry(String operation, Supplier<T> action) {
139
+        int maxRetries = Math.max(0, properties.getMaxRetries());
140
+        BizException last = null;
141
+        for (int attempt = 0; attempt <= maxRetries; attempt++) {
142
+            try {
143
+                return action.get();
144
+            } catch (BizException e) {
145
+                if (attempt >= maxRetries || !isRetryable(e)) {
146
+                    throw e;
147
+                }
148
+                last = e;
149
+                log.warn("{}失败,第 {}/{} 次重试:{}", operation, attempt + 1, maxRetries, e.getMessage());
150
+                sleepBeforeRetry();
151
+            } catch (Exception e) {
152
+                BizException wrapped = wrapUnexpected(operation, e);
153
+                if (attempt >= maxRetries || !isRetryable(wrapped, e)) {
154
+                    throw wrapped;
155
+                }
156
+                last = wrapped;
157
+                log.warn("{}异常,第 {}/{} 次重试:{}", operation, attempt + 1, maxRetries, e.getMessage());
158
+                sleepBeforeRetry();
159
+            }
160
+        }
161
+        if (last != null) {
162
+            throw last;
163
+        }
164
+        throw new BizException(ErrorCode.KB_SERVICE_FAILED, operation + "失败");
165
+    }
166
+
167
+    private static boolean isRetryable(BizException e) {
168
+        String msg = e.getMessage();
169
+        if (msg == null) {
170
+            return false;
171
+        }
172
+        if (msg.contains("HTTP 404")) {
173
+            return false;
174
+        }
175
+        if (msg.contains("HTTP 408") || msg.contains("HTTP 429")) {
176
+            return true;
177
+        }
178
+        if (msg.matches(".*HTTP [45]\\d\\d.*")) {
179
+            int code = extractHttpStatus(msg);
180
+            return code >= 500 || code == 408 || code == 429;
181
+        }
182
+        return false;
183
+    }
184
+
185
+    private static boolean isRetryable(BizException wrapped, Exception cause) {
186
+        if (cause instanceof ResourceAccessException) {
187
+            return true;
188
+        }
189
+        return isRetryable(wrapped);
190
+    }
191
+
192
+    private static int extractHttpStatus(String msg) {
193
+        int idx = msg.indexOf("HTTP ");
194
+        if (idx < 0) {
195
+            return 0;
196
+        }
197
+        int start = idx + 5;
198
+        int end = start;
199
+        while (end < msg.length() && Character.isDigit(msg.charAt(end))) {
200
+            end++;
201
+        }
202
+        if (end == start) {
203
+            return 0;
204
+        }
205
+        try {
206
+            return Integer.parseInt(msg.substring(start, end));
207
+        } catch (NumberFormatException e) {
208
+            return 0;
209
+        }
210
+    }
211
+
212
+    private void sleepBeforeRetry() {
213
+        int interval = Math.max(0, properties.getRetryIntervalMs());
214
+        if (interval <= 0) {
215
+            return;
216
+        }
217
+        try {
218
+            Thread.sleep(interval);
219
+        } catch (InterruptedException e) {
220
+            Thread.currentThread().interrupt();
221
+        }
222
+    }
223
+
224
+    private static BizException wrapUnexpected(String operation, Exception e) {
225
+        if (e instanceof BizException) {
226
+            return (BizException) e;
227
+        }
228
+        return new BizException(ErrorCode.KB_SERVICE_FAILED, operation + "异常:" + e.getMessage());
229
+    }
230
+
231
+    private static String encodePathSegment(String id) {
232
+        try {
233
+            return URLEncoder.encode(id, StandardCharsets.UTF_8.name()).replace("+", "%20");
234
+        } catch (UnsupportedEncodingException e) {
235
+            return id;
236
+        }
237
+    }
238
+
239
+    private String parseUploadResponse(String raw) {
240
+        if (!StringUtils.hasText(raw)) {
241
+            throw new BizException(ErrorCode.KB_SERVICE_FAILED, "知识库上传返回空响应");
242
+        }
243
+        JsonNode root;
244
+        try {
245
+            root = objectMapper.readTree(raw);
246
+        } catch (Exception e) {
247
+            throw new BizException(ErrorCode.KB_SERVICE_FAILED, "知识库上传响应非 JSON:" + abbreviate(raw));
248
+        }
249
+        assertBusinessOk(root);
250
+        String fileId = extractFileId(root);
251
+        if (!StringUtils.hasText(fileId)) {
252
+            throw new BizException(ErrorCode.KB_SERVICE_FAILED,
253
+                    "知识库上传成功但未解析到 file_id,响应:" + abbreviate(raw));
254
+        }
255
+        return fileId;
256
+    }
257
+
258
+    private void assertBusinessOk(JsonNode root) {
259
+        if (root == null || !root.has("code")) {
260
+            return;
261
+        }
262
+        JsonNode c = root.get("code");
263
+        boolean ok = false;
264
+        if (c.isNumber()) {
265
+            int v = c.intValue();
266
+            ok = (v == 0 || v == 200);
267
+        } else if (c.isTextual()) {
268
+            String s = c.asText();
269
+            ok = "0".equals(s) || "200".equals(s) || "success".equalsIgnoreCase(s);
270
+        }
271
+        if (!ok) {
272
+            String msg = root.has("message") ? root.get("message").asText(null) : null;
273
+            if (!StringUtils.hasText(msg) && root.has("msg")) {
274
+                msg = root.get("msg").asText(null);
275
+            }
276
+            if (!StringUtils.hasText(msg)) {
277
+                msg = "知识库接口返回失败";
278
+            }
279
+            throw new BizException(ErrorCode.KB_SERVICE_FAILED, msg);
280
+        }
281
+    }
282
+
283
+    private static String extractFileId(JsonNode root) {
284
+        JsonNode data = root.get("data");
285
+        if (data != null && data.isTextual()) {
286
+            String t = data.asText(null);
287
+            if (StringUtils.hasText(t)) {
288
+                return t;
289
+            }
290
+        }
291
+        if (data != null && data.isObject()) {
292
+            for (String key : FILE_ID_KEYS) {
293
+                if (data.has(key) && !data.get(key).isNull()) {
294
+                    String v = data.get(key).asText(null);
295
+                    if (StringUtils.hasText(v)) {
296
+                        return v;
297
+                    }
298
+                }
299
+            }
300
+        }
301
+        for (String key : FILE_ID_KEYS) {
302
+            if (root.has(key) && !root.get(key).isNull()) {
303
+                String v = root.get(key).asText(null);
304
+                if (StringUtils.hasText(v)) {
305
+                    return v;
306
+                }
307
+            }
308
+        }
309
+        return null;
310
+    }
311
+
312
+    private static String abbreviate(String s) {
313
+        if (s == null) {
314
+            return "";
315
+        }
316
+        String t = s.trim();
317
+        if (t.length() > 500) {
318
+            return t.substring(0, 500) + "...";
319
+        }
320
+        return t;
321
+    }
322
+
323
+    private void ensureReady() {
324
+        if (!properties.isEnabled()) {
325
+            throw new BizException(ErrorCode.KB_SERVICE_DISABLED);
326
+        }
327
+        if (!StringUtils.hasText(properties.getApiKey())) {
328
+            throw new BizException(ErrorCode.KB_SERVICE_FAILED, "未配置知识库访问密钥 fe.kb.api-key");
329
+        }
330
+    }
331
+}