From 09da0762829509852daf262d118b736903c1ee34 Mon Sep 17 00:00:00 2001 From: ForgeTerra Date: Tue, 17 Mar 2026 22:29:31 +0800 Subject: [PATCH 1/2] feat: add intelligent configuration detection feature --- .../src/main/resources/application.yml | 17 ++ apollo-portal/pom.xml | 9 + .../controller/ItemDetectionController.java | 105 ++++++++++ .../controller/PageSettingController.java | 7 +- .../apollo/portal/entity/vo/PageSetting.java | 10 + .../detection/ConfigDetectionService.java | 83 ++++++++ .../config/detection/PromptBuilder.java | 148 ++++++++++++++ .../adapter/DetectionModelAdapter.java | 35 ++++ .../adapter/DetectionModelAdapterFactory.java | 69 +++++++ .../adapter/DetectionModelProvider.java | 51 +++++ .../adapter/OpenAICompatibleAdapter.java | 169 ++++++++++++++++ .../detection/config/DetectionProperties.java | 98 ++++++++++ .../config/detection/model/ChatMessage.java | 50 +++++ .../config/detection/model/ChatRequest.java | 75 +++++++ .../detection/model/DetectionRequest.java | 185 ++++++++++++++++++ .../src/main/resources/application.yml | 21 ++ .../src/main/resources/static/config.html | 3 + .../src/main/resources/static/i18n/en.json | 7 + .../src/main/resources/static/i18n/zh-CN.json | 7 + .../scripts/directive/item-modal-directive.js | 143 +++++++++++++- .../resources/static/styles/common-style.css | 180 +++++++++++++++++ .../static/views/component/item-modal.html | 50 +++++ 22 files changed, 1520 insertions(+), 2 deletions(-) create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/ItemDetectionController.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/ConfigDetectionService.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/PromptBuilder.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelAdapter.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelAdapterFactory.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelProvider.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/OpenAICompatibleAdapter.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/config/DetectionProperties.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/ChatMessage.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/ChatRequest.java create mode 100644 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/DetectionRequest.java diff --git a/apollo-assembly/src/main/resources/application.yml b/apollo-assembly/src/main/resources/application.yml index e57c9be0d35..27ff911766a 100644 --- a/apollo-assembly/src/main/resources/application.yml +++ b/apollo-assembly/src/main/resources/application.yml @@ -75,3 +75,20 @@ server: cookie: # prevent csrf same-site: Lax + + +# Intelligent Detection Configuration +apollo: + detection: + enabled: false + active-provider: qwen + timeout: 30000 + providers: + openai: + api-key: ${OPENAI_API_KEY:} + base-url: https://api.openai.com/v1 + model: gpt-4o + qwen: + api-key: ${QWEN_API_KEY:} + base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 + model: qwen-plus diff --git a/apollo-portal/pom.xml b/apollo-portal/pom.xml index a6138836921..64e0accc3c1 100644 --- a/apollo-portal/pom.xml +++ b/apollo-portal/pom.xml @@ -137,6 +137,15 @@ + + org.springframework.boot + spring-boot-starter-webflux + + + com.fasterxml.jackson.core + jackson-databind + + diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/ItemDetectionController.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/ItemDetectionController.java new file mode 100644 index 00000000000..d64b111b95e --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/ItemDetectionController.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.controller; + + +import com.ctrip.framework.apollo.portal.service.config.detection.ConfigDetectionService; +import com.ctrip.framework.apollo.portal.service.config.detection.model.DetectionRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.io.PrintWriter; + +/** + * Item detection controller for intelligent configuration detection + */ +@RestController +@RequestMapping("/apps/{appId}/envs/{env}/clusters/{clusterName}/namespaces/{namespaceName}") +public class ItemDetectionController { + + private final ConfigDetectionService configDetectionService; + + public ItemDetectionController(ConfigDetectionService configDetectionService) { + this.configDetectionService = configDetectionService; + } + + @GetMapping(value = "/items/intelligent-detection") + public void detectConfigStream( + @PathVariable String appId, + @PathVariable String env, + @PathVariable String clusterName, + @PathVariable String namespaceName, + @RequestParam String key, + @RequestParam String value, + @RequestParam(required = false) String comment, + HttpServletResponse response) throws IOException { + + response.setContentType("text/event-stream;charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Connection", "keep-alive"); + + PrintWriter writer = response.getWriter(); + + DetectionRequest request = DetectionRequest.builder() + .appId(appId) + .env(env) + .clusterName(clusterName) + .namespaceName(namespaceName) + .key(key) + .value(value) + .comment(comment) + .build(); + + configDetectionService.detectStream(request) + .doOnNext(chunk -> { + try { + String[] lines = chunk.split("\n", -1); + writer.write("event: message\n"); + for (String line : lines) { + writer.write("data: " + line + "\n"); + } + writer.write("\n"); + writer.flush(); + } catch (Exception e) { + e.printStackTrace(); + } + }) + .doOnComplete(() -> { + try { + writer.write("event: done\n"); + writer.write("data: [DONE]\n\n"); + writer.flush(); + writer.close(); + } catch (Exception e) { + e.printStackTrace(); + } + }) + .doOnError(e -> { + try { + writer.write("event: error\n"); + writer.write("data: check error:" + e.getMessage() + "\n\n"); + writer.flush(); + writer.close(); + } catch (Exception ex) { + ex.printStackTrace(); + } + }) + .blockLast(); + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/PageSettingController.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/PageSettingController.java index 3ab7383dbe7..2fdf7ac2f59 100644 --- a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/PageSettingController.java +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/PageSettingController.java @@ -18,6 +18,7 @@ import com.ctrip.framework.apollo.portal.component.config.PortalConfig; import com.ctrip.framework.apollo.portal.entity.vo.PageSetting; +import com.ctrip.framework.apollo.portal.service.config.detection.config.DetectionProperties; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -25,9 +26,12 @@ public class PageSettingController { private final PortalConfig portalConfig; + private final DetectionProperties detectionProperties; - public PageSettingController(final PortalConfig portalConfig) { + public PageSettingController(final PortalConfig portalConfig, + final DetectionProperties detectionProperties) { this.portalConfig = portalConfig; + this.detectionProperties = detectionProperties; } @GetMapping("/page-settings") @@ -36,6 +40,7 @@ public PageSetting getPageSetting() { setting.setWikiAddress(portalConfig.wikiAddress()); setting.setCanAppAdminCreatePrivateNamespace(portalConfig.canAppAdminCreatePrivateNamespace()); + setting.setDetectionEnabled(detectionProperties.isEnabled()); return setting; } diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/entity/vo/PageSetting.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/entity/vo/PageSetting.java index 62f1c597663..7d569340bbe 100644 --- a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/entity/vo/PageSetting.java +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/entity/vo/PageSetting.java @@ -22,6 +22,8 @@ public class PageSetting { private boolean canAppAdminCreatePrivateNamespace; + private boolean detectionEnabled; + public String getWikiAddress() { return wikiAddress; } @@ -37,4 +39,12 @@ public boolean isCanAppAdminCreatePrivateNamespace() { public void setCanAppAdminCreatePrivateNamespace(boolean canAppAdminCreatePrivateNamespace) { this.canAppAdminCreatePrivateNamespace = canAppAdminCreatePrivateNamespace; } + + public boolean isDetectionEnabled() { + return detectionEnabled; + } + + public void setDetectionEnabled(boolean detectionEnabled) { + this.detectionEnabled = detectionEnabled; + } } diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/ConfigDetectionService.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/ConfigDetectionService.java new file mode 100644 index 00000000000..a5d35a26a42 --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/ConfigDetectionService.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection; + + +import com.ctrip.framework.apollo.portal.service.config.detection.adapter.DetectionModelAdapter; +import com.ctrip.framework.apollo.portal.service.config.detection.adapter.DetectionModelAdapterFactory; +import com.ctrip.framework.apollo.portal.service.config.detection.config.DetectionProperties; +import com.ctrip.framework.apollo.portal.service.config.detection.model.ChatMessage; +import com.ctrip.framework.apollo.portal.service.config.detection.model.ChatRequest; +import com.ctrip.framework.apollo.portal.service.config.detection.model.DetectionRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; + +import java.util.Arrays; +import java.util.Map; + +/** + * Configuration detection service + */ +@Service +public class ConfigDetectionService { + + private static final Logger logger = LoggerFactory.getLogger(ConfigDetectionService.class); + + private final DetectionProperties properties; + private final DetectionModelAdapterFactory adapterFactory; + private final PromptBuilder promptBuilder; + + public ConfigDetectionService( + DetectionProperties properties, + DetectionModelAdapterFactory adapterFactory, + PromptBuilder promptBuilder) { + this.properties = properties; + this.adapterFactory = adapterFactory; + this.promptBuilder = promptBuilder; + } + + public Flux detectStream(DetectionRequest request) { + if (!properties.isEnabled()) { + return Flux.error(new IllegalStateException("Detection is not enabled")); + } + try { + String provider = properties.getActiveProvider(); + logger.info("Starting detection for key: {}, provider: {}", request.getKey(), provider); + DetectionModelAdapter adapter = adapterFactory.getAdapter(provider); + String systemPrompt = promptBuilder.getSystemPrompt(); + String userPrompt = promptBuilder.buildDetectionPrompt(request); + + ChatRequest chatRequest = ChatRequest.builder() + .messages(Arrays.asList( + new ChatMessage("system", systemPrompt), + new ChatMessage("user", userPrompt) + )) + .stream(true) + .build(); + + return adapter.streamChat(chatRequest) + .doOnComplete(() -> logger.info("Detection completed for key: {}", request.getKey())) + .doOnError(error -> logger.error("Detection failed for key: {}", request.getKey(), error)); + + } catch (Exception e) { + logger.error("Failed to start detection", e); + return Flux.error(e); + } + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/PromptBuilder.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/PromptBuilder.java new file mode 100644 index 00000000000..9020f5bc54f --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/PromptBuilder.java @@ -0,0 +1,148 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection; + + +import com.ctrip.framework.apollo.portal.service.config.detection.model.DetectionRequest; +import org.springframework.stereotype.Component; + +/** + * Prompt builder for intelligent detection + */ +@Component +public class PromptBuilder { + + private static final String SYSTEM_PROMPT = """ + 你是【Apollo配置中心配置检测专家】,请对配置项进行专业、严格、标准化检测,输出规范 Markdown 报告。 + + ⚙️ 输出语气和风格要求: + - 专业客观:使用技术术语,避免主观情绪化表达 + - 具体可执行:避免空话套话,给出可直接操作的建议 + - 精准简洁:描述清晰,不冗长啰嗦 + - 风险导向:重点说明潜在影响和整改价值 + + 📋 输出结构要求: + 1. 必须按顺序输出 3 个检测维度表格:安全性检测 → 命名规范检测 → 最佳实践检测 + 2. 每个检测维度表格内的数据行按风险等级从高到低排序(🔴严重 → 🟠高危 → 🟡中危 → 🔵低危 → 💡建议) + 3. 最后必须输出:整体风险总结(表格)、整改优先级建议 + 4. 每个检测维度至少有 1 条检测结果 + + 📊 风险等级定义(只能使用以下 5 个等级): + 🔴严重:导致安全漏洞、数据泄露、系统崩溃等严重后果 + 🟠高危:引发功能异常、性能劣化、运维困难等明显问题 + 🟡中危:存在隐患,可能在特定场景下引发问题 + 🔵低危:不符合规范,但短期内影响较小 + 💡建议:优化建议,可提升质量但非必需 + + 📝 内容质量要求: + - 问题描述(严格不超过60字):客观陈述配置存在的具体问题,引用配置值时使用反引号`xxx` + - 影响分析(严格不超过60字):说明问题可能导致的后果、影响范围、触发条件 + - 整改建议(严格不超过80字):给出具体可执行的操作步骤、代码示例、配置规范,说明整改后的预期效果 + + ⚠️⚠️⚠️ 表格格式规范(违反将导致报告无效): + 1. 表格结构:Markdown 表格由三部分组成 + - 表头行:| 检测项 | 风险等级 | 问题描述 | 影响分析 | 整改建议 | + - 分隔符行:|--------|----------|----------|----------|----------| + - 数据行:每行一条检测结果,格式 | 内容1 | 内容2 | 内容3 | 内容4 | 内容5 | + 2. 列数固定:必须是 5 列,不能多也不能少,每行必须有 5 个 | 分隔符 + 3. 表头统一:所有检测维度表格的表头必须完全一致,5 个列名逐字复制 + 4. 竖线位置:每行开头和结尾必须有竖线 |,列之间用 | 分隔 + 5. 完整性:表格必须一次性输出完整(表头→分隔符→所有数据行),中间不能插入说明文字或空行 + 6. 内容长度:严格控制每个单元格内容长度,避免内容过长导致表格换行或格式错乱 + + 你必须从以下 3 个维度检测。请直接复制下面的表格模板,只修改数据行内容: + + ### 1. 安全性检测 + | 检测项 | 风险等级 | 问题描述 | 影响分析 | 整改建议 | + |--------|----------|----------|----------|----------| + | 敏感信息 | 🔴严重 | 具体描述(严格不超过60字) | 具体影响(严格不超过60字) | 具体建议(严格不超过80字) | + | 注入攻击 | 🔵低危 | 具体描述(严格不超过60字) | 具体影响(严格不超过60字) | 具体建议(严格不超过80字) | + + ### 2. 命名规范检测 + | 检测项 | 风险等级 | 问题描述 | 影响分析 | 整改建议 | + |--------|----------|----------|----------|----------| + | 语义清晰 | 🔴严重 | 具体描述(严格不超过60字) | 具体影响(严格不超过60字) | 具体建议(严格不超过80字) | + | 格式统一 | 💡建议 | 具体描述(严格不超过60字) | 具体影响(严格不超过60字) | 具体建议(严格不超过80字) | + + ### 3. 最佳实践检测 + | 检测项 | 风险等级 | 问题描述 | 影响分析 | 整改建议 | + |--------|----------|----------|----------|----------| + | 可维护性 | 🟡中危 | 具体描述(严格不超过60字) | 具体影响(严格不超过60字) | 具体建议(严格不超过80字) | + | 性能优化 | 💡建议 | 具体描述(严格不超过60字) | 具体影响(严格不超过60字) | 具体建议(严格不超过80字) | + + --- + + 【整体风险总结格式】汇总本次检测发现的主要风险,按风险等级从高到低排序 + + ### 整体风险总结 + | 风险维度 | 关键问题 | 综合等级 | + |:--------:|----------|:--------:| + | 命名规范 | Key名称无业务语义,违反规范 | 🔴严重 | + | 安全治理 | 弱命名体系可能导致敏感信息泄露 | 🟡中危 | + | 配置溯源 | 缺失备注和责任人信息 | 💡建议 | + + 表格要求: + - 第一列"风险维度"和第三列"综合等级"居中对齐(分隔符行用 |:--------:|) + - 第二列"关键问题"左对齐(分隔符行用 |----------|) + - 仅列出有实际风险的维度,不要输出没有问题的维度 + - 按风险等级从高到低排序(🔴严重 → 🟠高危 → 🟡中危 → 🔵低危 → 💡建议) + - "关键问题"列控制在不超过 30 字,简明扼要 + + 【整改优先级建议格式】按风险等级分组,每条建议包含:风险等级 + 时限 + 具体操作 + 预期效果 + + ### 整改优先级建议 + 1. **🔴立即整改(24小时内)**:重命名Key为`order_timeout_ms`,补全备注说明业务含义、单位、修改影响,防止后续维护困难 + 2. **🟡中危跟进(1周内)**:建立团队级敏感配置识别规范,对含`password`/`secret`等关键词的Key启用加密存储 + 3. **💡持续优化(本迭代内)**:在Apollo配置提交时启用备注校验,对空备注提交自动拦截,提升配置可追溯性 + + 格式要求: + - 仅输出有对应风险等级的整改建议,没有该等级风险则跳过该条 + - 按风险等级从高到低排序 + - 每条建议不超过 80 字,包含具体可执行的操作和预期效果 + - 时限标准:🔴严重(24小时内)、🟠高危(3个工作日内)、🟡中危(1周内)、🔵低危(本迭代内)、💡建议(下迭代计划) + """; + + public String getSystemPrompt() { + return SYSTEM_PROMPT; + } + + public String buildDetectionPrompt(DetectionRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("请对以下 Apollo 配置项进行全面检测:\n\n"); + prompt.append("## 配置信息\n"); + prompt.append("- **AppId**: ").append(request.getAppId()).append("\n"); + prompt.append("- **环境**: ").append(request.getEnv()).append("\n"); + prompt.append("- **集群**: ").append(request.getClusterName()).append("\n"); + prompt.append("- **Namespace**: ").append(request.getNamespaceName()).append("\n"); + prompt.append("- **Key**: `").append(request.getKey()).append("`\n"); + prompt.append("- **Value**: `").append(request.getValue()).append("`\n"); + + if (request.getComment() != null && !request.getComment().isEmpty()) { + prompt.append("- **备注**: ").append(request.getComment()).append("\n"); + } else { + prompt.append("- **备注**: 无\n"); + } + + prompt.append("\n---\n\n"); + prompt.append("请严格按照系统提示要求输出检测报告:\n"); + prompt.append("1. 必须输出 3 个检测维度表格(安全性、命名规范、最佳实践)\n"); + prompt.append("2. 每个表格内按风险等级从高到低排序\n"); + prompt.append("3. 最后输出整体风险总结和整改优先级建议\n"); + + return prompt.toString(); + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelAdapter.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelAdapter.java new file mode 100644 index 00000000000..c9c040b7a50 --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelAdapter.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection.adapter; + + +import com.ctrip.framework.apollo.portal.service.config.detection.model.ChatRequest; +import reactor.core.publisher.Flux; + +/** + * Detection model adapter interface + */ +public interface DetectionModelAdapter { + + /** + * Stream chat with detection model + * + * @param request chat request + * @return stream of response chunks + */ + Flux streamChat(ChatRequest request); +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelAdapterFactory.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelAdapterFactory.java new file mode 100644 index 00000000000..eeca2724e60 --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelAdapterFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection.adapter; + + +import com.ctrip.framework.apollo.portal.service.config.detection.config.DetectionProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Detection model adapter factory + */ +@Component +public class DetectionModelAdapterFactory { + + private final DetectionProperties properties; + private final Map adapterCache = new ConcurrentHashMap<>(); + + public DetectionModelAdapterFactory(DetectionProperties properties) { + this.properties = properties; + } + + public DetectionModelAdapter getAdapter(String providerCode) { + if (providerCode == null || providerCode.isEmpty()) { + providerCode = properties.getActiveProvider(); + } + + return adapterCache.computeIfAbsent(providerCode, this::createAdapter); + } + + private DetectionModelAdapter createAdapter(String providerCode) { + DetectionProperties.ProviderConfig config = properties.getProviders().get(providerCode); + + if (config == null) { + throw new IllegalArgumentException("Detection model provider not configured: " + providerCode); + } + + if (config.getApiKey() == null || config.getApiKey().isEmpty()) { + throw new IllegalStateException("API key not configured for provider: " + providerCode); + } + + String baseUrl = config.getBaseUrl(); + if (baseUrl == null || baseUrl.isEmpty()) { + DetectionModelProvider provider = DetectionModelProvider.fromCode(providerCode); + baseUrl = provider.getDefaultBaseUrl(); + } + + Duration timeout = Duration.ofMillis(properties.getTimeout()); + + return new OpenAICompatibleAdapter(baseUrl, config.getApiKey(), config.getModel(), timeout); + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelProvider.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelProvider.java new file mode 100644 index 00000000000..db939dc919e --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/DetectionModelProvider.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection.adapter; + +/** + * Detection model provider enum + */ +public enum DetectionModelProvider { + + OPENAI("openai", "https://api.openai.com/v1"), + QWEN("qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1"); + + private final String code; + private final String defaultBaseUrl; + + DetectionModelProvider(String code, String defaultBaseUrl) { + this.code = code; + this.defaultBaseUrl = defaultBaseUrl; + } + + public String getCode() { + return code; + } + + public String getDefaultBaseUrl() { + return defaultBaseUrl; + } + + public static DetectionModelProvider fromCode(String code) { + for (DetectionModelProvider provider : values()) { + if (provider.code.equalsIgnoreCase(code)) { + return provider; + } + } + throw new IllegalArgumentException("Unknown detection model provider: " + code); + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/OpenAICompatibleAdapter.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/OpenAICompatibleAdapter.java new file mode 100644 index 00000000000..9f6c33e6c37 --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/adapter/OpenAICompatibleAdapter.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection.adapter; + + +import com.ctrip.framework.apollo.portal.service.config.detection.model.ChatMessage; +import com.ctrip.framework.apollo.portal.service.config.detection.model.ChatRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * OpenAI compatible adapter implementation + * Supports OpenAI, Qwen and other OpenAI-compatible APIs + */ +public class OpenAICompatibleAdapter implements DetectionModelAdapter { + + private static final Logger logger = LoggerFactory.getLogger(OpenAICompatibleAdapter.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final WebClient webClient; + private final String model; + + public OpenAICompatibleAdapter(String baseUrl, String apiKey, String model, Duration timeout) { + this.model = model; + this.webClient = WebClient.builder() + .baseUrl(baseUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("Content-Type", "application/json; charset=UTF-8") + .defaultHeader("Accept-Charset", "UTF-8") + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(16 * 1024 * 1024)) + .build(); + } + + @Override + public Flux streamChat(ChatRequest request) { + Map requestBody = new HashMap<>(); + requestBody.put("model", model); + requestBody.put("messages", convertMessages(request.getMessages())); + requestBody.put("stream", request.isStream()); + + logger.info("Sending request to detection model: {}, stream: {}", model, request.isStream()); + if (!request.isStream()) { + return webClient.post() + .uri("/chat/completions") + .contentType(MediaType.APPLICATION_JSON) + .acceptCharset(StandardCharsets.UTF_8) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(String.class) + .map(this::parseNonStreamResponse) + .flatMapMany(content -> { + String[] lines = content.split("\n"); + return Flux.fromArray(lines) + .map(line -> line + "\n"); + }) + .doOnNext(data -> logger.debug("Non-stream chunk: {}", data)) + .doOnError(error -> logger.error("Error in non-stream chat", error)); + } + StringBuilder lineBuffer = new StringBuilder(); + + return webClient.post() + .uri("/chat/completions") + .contentType(MediaType.APPLICATION_JSON) + .acceptCharset(StandardCharsets.UTF_8) + .bodyValue(requestBody) + .retrieve() + .bodyToFlux(DataBuffer.class) + .doOnSubscribe(sub -> logger.info("Subscribed to AI stream")) + .concatMap(dataBuffer -> { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + String chunk = new String(bytes, StandardCharsets.UTF_8); + lineBuffer.append(chunk); + String buffer = lineBuffer.toString(); + int lastNewlineIndex = buffer.lastIndexOf('\n'); + if (lastNewlineIndex == -1) { + return Flux.empty(); + } + String completeLines = buffer.substring(0, lastNewlineIndex); + lineBuffer.setLength(0); + lineBuffer.append(buffer.substring(lastNewlineIndex + 1)); + String[] lines = completeLines.split("\n"); + return Flux.fromArray(lines); + }) + .filter(line -> !line.trim().isEmpty()) + .doOnNext(line -> logger.debug("Received line: {}", line)) + .filter(line -> line.startsWith("data: ")) + .map(line -> line.substring(6).trim()) + .filter(data -> !data.isEmpty() && !"[DONE]".equals(data)) + .mapNotNull(this::parseStreamChunk) + .doOnNext(data -> logger.debug("Parsed content: [{}]", data)) + .doOnComplete(() -> logger.info("AI stream completed")) + .doOnError(error -> logger.error("Error in AI stream chat", error)); + } + + private List> convertMessages(List messages) { + return messages.stream() + .map(msg -> { + Map map = new HashMap<>(); + map.put("role", msg.getRole()); + map.put("content", msg.getContent()); + return map; + }) + .collect(Collectors.toList()); + } + + private String parseStreamChunk(String data) { + try { + JsonNode root = objectMapper.readTree(data); + JsonNode choices = root.get("choices"); + if (choices != null && choices.isArray() && !choices.isEmpty()) { + JsonNode delta = choices.get(0).get("delta"); + if (delta != null && delta.has("content")) { + return delta.get("content").asText(); + } + } + } catch (Exception e) { + logger.warn("Failed to parse stream chunk: {}", data, e); + } + return null; + } + + private String parseNonStreamResponse(String response) { + try { + JsonNode root = objectMapper.readTree(response); + JsonNode choices = root.get("choices"); + if (choices != null && choices.isArray() && !choices.isEmpty()) { + JsonNode message = choices.get(0).get("message"); + if (message != null && message.has("content")) { + return message.get("content").asText(); + } + } + } catch (Exception e) { + logger.error("Failed to parse non-stream response: {}", response, e); + } + return ""; + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/config/DetectionProperties.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/config/DetectionProperties.java new file mode 100644 index 00000000000..dc2d65848e9 --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/config/DetectionProperties.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration detection properties + */ +@Component +@ConfigurationProperties(prefix = "apollo.detection") +public class DetectionProperties { + + private boolean enabled; + private String activeProvider; + private long timeout = 30000; + private Map providers = new HashMap<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getActiveProvider() { + return activeProvider; + } + + public void setActiveProvider(String activeProvider) { + this.activeProvider = activeProvider; + } + + public long getTimeout() { + return timeout; + } + + public void setTimeout(long timeout) { + this.timeout = timeout; + } + + public Map getProviders() { + return providers; + } + + public void setProviders(Map providers) { + this.providers = providers; + } + + public static class ProviderConfig { + private String apiKey; + private String baseUrl; + private String model; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/ChatMessage.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/ChatMessage.java new file mode 100644 index 00000000000..9657fb16edf --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/ChatMessage.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection.model; + +/** + * Chat message for detection model + */ +public class ChatMessage { + + private String role; + private String content; + + public ChatMessage() { + } + + public ChatMessage(String role, String content) { + this.role = role; + this.content = content; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/ChatRequest.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/ChatRequest.java new file mode 100644 index 00000000000..82bc2a2d797 --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/ChatRequest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection.model; + +import java.util.List; + +/** + * Chat request for detection model + */ +public class ChatRequest { + + private List messages; + private boolean stream; + + public ChatRequest() { + } + + private ChatRequest(Builder builder) { + this.messages = builder.messages; + this.stream = builder.stream; + } + + public static Builder builder() { + return new Builder(); + } + + public List getMessages() { + return messages; + } + + public void setMessages(List messages) { + this.messages = messages; + } + + public boolean isStream() { + return stream; + } + + public void setStream(boolean stream) { + this.stream = stream; + } + + public static class Builder { + private List messages; + private boolean stream = true; + + public Builder messages(List messages) { + this.messages = messages; + return this; + } + + public Builder stream(boolean stream) { + this.stream = stream; + return this; + } + + public ChatRequest build() { + return new ChatRequest(this); + } + } +} diff --git a/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/DetectionRequest.java b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/DetectionRequest.java new file mode 100644 index 00000000000..fdf49ea7dab --- /dev/null +++ b/apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/config/detection/model/DetectionRequest.java @@ -0,0 +1,185 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.ctrip.framework.apollo.portal.service.config.detection.model; + +/** + * Configuration detection request + */ +public class DetectionRequest { + + private String appId; + private String env; + private String clusterName; + private String namespaceName; + private String key; + private String value; + private String comment; + private String provider; + private String dimension; + + public DetectionRequest() { + } + + private DetectionRequest(Builder builder) { + this.appId = builder.appId; + this.env = builder.env; + this.clusterName = builder.clusterName; + this.namespaceName = builder.namespaceName; + this.key = builder.key; + this.value = builder.value; + this.comment = builder.comment; + this.provider = builder.provider; + this.dimension = builder.dimension; + } + + public static Builder builder() { + return new Builder(); + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getEnv() { + return env; + } + + public void setEnv(String env) { + this.env = env; + } + + public String getClusterName() { + return clusterName; + } + + public void setClusterName(String clusterName) { + this.clusterName = clusterName; + } + + public String getNamespaceName() { + return namespaceName; + } + + public void setNamespaceName(String namespaceName) { + this.namespaceName = namespaceName; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getDimension() { + return dimension; + } + + public void setDimension(String dimension) { + this.dimension = dimension; + } + + public static class Builder { + private String appId; + private String env; + private String clusterName; + private String namespaceName; + private String key; + private String value; + private String comment; + private String provider; + private String dimension; + + public Builder appId(String appId) { + this.appId = appId; + return this; + } + + public Builder env(String env) { + this.env = env; + return this; + } + + public Builder clusterName(String clusterName) { + this.clusterName = clusterName; + return this; + } + + public Builder namespaceName(String namespaceName) { + this.namespaceName = namespaceName; + return this; + } + + public Builder key(String key) { + this.key = key; + return this; + } + + public Builder value(String value) { + this.value = value; + return this; + } + + public Builder comment(String comment) { + this.comment = comment; + return this; + } + + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + public Builder dimension(String dimension) { + this.dimension = dimension; + return this; + } + + public DetectionRequest build() { + return new DetectionRequest(this); + } + } +} diff --git a/apollo-portal/src/main/resources/application.yml b/apollo-portal/src/main/resources/application.yml index 7f8ec7fdf30..50388a4ac97 100644 --- a/apollo-portal/src/main/resources/application.yml +++ b/apollo-portal/src/main/resources/application.yml @@ -30,6 +30,11 @@ spring: multipart: max-file-size: 200MB # import data configs max-request-size: 200MB + http: + encoding: + charset: UTF-8 + enabled: true + force: true server: compression: enabled: true @@ -48,3 +53,19 @@ management: order: DOWN, OUT_OF_SERVICE, UNKNOWN, UP ldap: enabled: false # disable ldap health check by default + +# Intelligent Detection Configuration +apollo: + detection: + enabled: false + active-provider: qwen + timeout: 30000 + providers: + openai: + api-key: ${OPENAI_API_KEY:} + base-url: https://api.openai.com/v1 + model: gpt-4o + qwen: + api-key: ${QWEN_API_KEY:} + base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 + model: qwen-plus diff --git a/apollo-portal/src/main/resources/static/config.html b/apollo-portal/src/main/resources/static/config.html index 6675266ace3..bcc17620e74 100644 --- a/apollo-portal/src/main/resources/static/config.html +++ b/apollo-portal/src/main/resources/static/config.html @@ -461,6 +461,9 @@