diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchClient.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchClient.java index 1ba0adb4..54eaf002 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchClient.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchClient.java @@ -9,6 +9,8 @@ import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider; import org.dromara.common.core.exception.ServiceException; import org.springframework.stereotype.Service; +import java.io.BufferedReader; +import java.io.StringReader; import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; @@ -18,8 +20,12 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @RequiredArgsConstructor @@ -27,6 +33,8 @@ public class AihrWebSearchClient { private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(12); + private static final Duration QWEN_REQUEST_TIMEOUT = Duration.ofSeconds(40); + private static final Pattern PUBLIC_URL = Pattern.compile("https?://[^\\s\\]\\)\\\"'<>]+"); private final AihrWebAiProperties properties; private final AihrWebSearchProviderService providerService; @@ -41,9 +49,6 @@ public class AihrWebSearchClient { } SearchResult search(RuntimeProvider runtime, String question) { - if (!"tavily".equalsIgnoreCase(clean(runtime.code()))) { - throw new ServiceException("暂不支持当前全网检索提供方"); - } String endpoint = clean(runtime.endpoint()); String apiKey = clean(runtime.apiKey()); if (!isPublicHttpsUrl(endpoint)) { @@ -52,6 +57,17 @@ public class AihrWebSearchClient { if (apiKey.isBlank()) { throw new ServiceException("全网检索密钥未配置"); } + String code = clean(runtime.code()).toLowerCase(Locale.ROOT); + if ("qwen".equals(code)) { + return searchQwen(runtime, question); + } + if (!"tavily".equals(code)) { + throw new ServiceException("暂不支持当前全网检索提供方"); + } + return searchTavily(runtime, question); + } + + private SearchResult searchTavily(RuntimeProvider runtime, String question) { try { ObjectNode body = objectMapper.createObjectNode(); body.put("query", question); @@ -59,9 +75,9 @@ public class AihrWebSearchClient { body.put("include_answer", "basic"); body.put("include_raw_content", false); body.put("max_results", properties.boundedMaxResults()); - HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint)) + HttpRequest request = HttpRequest.newBuilder(URI.create(runtime.endpoint())) .timeout(REQUEST_TIMEOUT) - .header("Authorization", "Bearer " + apiKey) + .header("Authorization", "Bearer " + runtime.apiKey()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) .build(); @@ -84,6 +100,53 @@ public class AihrWebSearchClient { } } + private SearchResult searchQwen(RuntimeProvider runtime, String question) { + String agentId = clean(runtime.agentId()); + String agentVersion = clean(runtime.agentVersion()); + if (agentId.isBlank() || agentVersion.isBlank()) { + throw new ServiceException("千问 Agent 配置不完整"); + } + try { + ObjectNode body = objectMapper.createObjectNode(); + ObjectNode input = body.putObject("input"); + ObjectNode message = input.putArray("messages").addObject(); + message.put("role", "user"); + message.put("content", question); + ObjectNode options = body.putObject("parameters").putObject("agent_options"); + options.put("agent_id", agentId); + options.put("agent_version", agentVersion); + options.put("agent_policy", "standard"); + options.put("forced_search", true); + options.put("enable_citation", true); + options.put("enable_text_image_mixed", false); + options.put("related_video", false); + options.put("enable_rec_question", false); + body.put("stream", true); + HttpRequest request = HttpRequest.newBuilder(URI.create(runtime.endpoint())) + .timeout(QWEN_REQUEST_TIMEOUT) + .header("Authorization", "Bearer " + runtime.apiKey()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) + .build(); + HttpResponse response = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + .send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new ServiceException("全网检索服务暂时不可用"); + } + return parseQwenSseResponse(objectMapper, response.body(), properties.boundedMaxResults()); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new ServiceException("全网检索已中断"); + } catch (ServiceException error) { + throw error; + } catch (Exception error) { + throw new ServiceException("全网检索服务暂时不可用"); + } + } + RuntimeProvider runtime(AihrKnowledgePrincipal principal) { RuntimeProvider configured = providerService.active(principal); if (configured != null) { @@ -96,7 +159,7 @@ public class AihrWebSearchClient { return null; } return new RuntimeProvider(-1L, clean(properties.getProvider()), clean(properties.getProvider()), - clean(properties.getEndpoint()), clean(properties.getApiKey()), true, true, null); + clean(properties.getEndpoint()), clean(properties.getApiKey()), "", "", true, true, null); } static SearchResult parseTavilyResponse(ObjectMapper mapper, String body, int limit) throws Exception { @@ -124,6 +187,125 @@ public class AihrWebSearchClient { return new SearchResult(answer, List.copyOf(sources)); } + static SearchResult parseQwenSseResponse(ObjectMapper mapper, String body, int limit) throws Exception { + StringBuilder answer = new StringBuilder(); + Map sources = new LinkedHashMap<>(); + try (BufferedReader reader = new BufferedReader(new StringReader(body == null ? "" : body))) { + String line; + while ((line = reader.readLine()) != null) { + String chunk = line.trim(); + if (!chunk.startsWith("data:")) { + continue; + } + String json = chunk.substring(5).trim(); + if (json.isBlank() || "[DONE]".equals(json)) { + continue; + } + JsonNode root = mapper.readTree(json); + int statusCode = root.path("status_code").asInt(200); + String code = clean(root.path("code").asText("")); + if (statusCode >= 400 || (!code.isBlank() && !"200".equals(code) && !"0".equals(code))) { + throw new ServiceException("全网检索服务暂时不可用"); + } + JsonNode choices = root.path("output").path("choices"); + if (!choices.isArray() || choices.size() == 0) { + continue; + } + JsonNode message = choices.get(0).path("message"); + String role = clean(message.path("role").asText("")); + JsonNode content = message.path("content"); + if ("assistant".equals(role) && content.isTextual()) { + appendStreamChunk(answer, content.asText("")); + collectTextUrls(content.asText(""), sources, limit); + } + if ("tool".equals(role)) { + collectSources(mapper, content, sources, limit); + } + collectSources(mapper, message.path("additional_kwargs"), sources, limit); + collectSources(mapper, message.path("tool_calls"), sources, limit); + } + } + List safeSources = List.copyOf(sources.values()); + return new SearchResult(safeSources.isEmpty() ? "" : truncate(clean(answer.toString()), 4000), safeSources); + } + + private static void appendStreamChunk(StringBuilder answer, String chunk) { + if (chunk == null || chunk.isEmpty()) { + return; + } + String current = answer.toString(); + if (!current.isEmpty() && chunk.startsWith(current)) { + answer.setLength(0); + } + answer.append(chunk); + } + + private static void collectSources(ObjectMapper mapper, JsonNode node, Map sources, int limit) { + if (node == null || node.isMissingNode() || node.isNull() || sources.size() >= safeLimit(limit)) { + return; + } + if (node.isTextual()) { + String value = node.asText("").trim(); + if ((value.startsWith("{") || value.startsWith("[")) && !value.isBlank()) { + try { + collectSources(mapper, mapper.readTree(value), sources, limit); + } catch (Exception ignored) { + collectTextUrls(value, sources, limit); + } + } else { + collectTextUrls(value, sources, limit); + } + return; + } + if (node.isArray()) { + for (JsonNode item : node) { + collectSources(mapper, item, sources, limit); + if (sources.size() >= safeLimit(limit)) { + break; + } + } + return; + } + if (!node.isObject()) { + return; + } + String url = firstText(node, "url", "link", "source_url", "sourceUrl"); + if (isPublicHttpUrl(url)) { + String title = firstText(node, "title", "name", "site_name", "siteName"); + sources.putIfAbsent(url, new WebSource( + title.isBlank() ? "公开来源" : title, + url, + truncate(firstText(node, "snippet", "summary", "description", "content"), 600), + node.path("score").asDouble(0D) + )); + } + node.fields().forEachRemaining(entry -> collectSources(mapper, entry.getValue(), sources, limit)); + } + + private static void collectTextUrls(String text, Map sources, int limit) { + Matcher matcher = PUBLIC_URL.matcher(text == null ? "" : text); + while (matcher.find() && sources.size() < safeLimit(limit)) { + String url = matcher.group().replaceAll("[.,;:!?,。;:!?]+$", ""); + if (isPublicHttpUrl(url)) { + sources.putIfAbsent(url, new WebSource("公开来源", url, "", 0D)); + } + } + } + + private static String firstText(JsonNode node, String... fields) { + for (String field : fields) { + JsonNode value = node.path(field); + if (value.isTextual() && !value.asText().isBlank()) { + return clean(value.asText()); + } + } + return ""; + } + + private static int safeLimit(int limit) { + return Math.max(1, Math.min(limit, 8)); + } + static boolean isPublicHttpUrl(String value) { try { URI uri = URI.create(clean(value)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderService.java index 6e30c4f1..b62d6e7f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderService.java @@ -1,5 +1,6 @@ package org.dromara.aihr.webai; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.RequiredArgsConstructor; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.common.core.exception.ServiceException; @@ -14,6 +15,7 @@ import java.sql.Statement; import java.time.LocalDateTime; import java.util.List; import java.util.Locale; +import java.util.Set; @Service @RequiredArgsConstructor @@ -26,11 +28,13 @@ public class AihrWebSearchProviderService { public List list(AihrKnowledgePrincipal principal) { ensureTable(); return jdbcTemplate.query(""" - select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time + select id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version, + enabled, last_test_ok, last_test_time from aihr_web_search_provider where tenant_id = ? order by id """, (rs, rowNum) -> new ProviderResponse( rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), - rs.getString("endpoint"), hasText(rs.getString("api_key")), rs.getBoolean("enabled"), + rs.getString("endpoint"), rs.getString("agent_id"), rs.getString("agent_version"), + hasText(rs.getString("api_key")), rs.getBoolean("enabled"), rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class) ), principal.tenantId()); } @@ -42,14 +46,17 @@ public class AihrWebSearchProviderService { jdbcTemplate.update(connection -> { PreparedStatement statement = connection.prepareStatement(""" insert into aihr_web_search_provider - (tenant_id, provider_name, provider_code, endpoint, api_key, enabled, create_time, update_time) - values (?, ?, ?, ?, ?, 0, now(), now()) + (tenant_id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version, + enabled, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, 0, now(), now()) """, Statement.RETURN_GENERATED_KEYS); statement.setString(1, principal.tenantId()); statement.setString(2, data.name()); statement.setString(3, data.code()); statement.setString(4, data.endpoint()); statement.setString(5, secretCodec.encrypt(data.apiKey())); + statement.setString(6, data.agentId()); + statement.setString(7, data.agentVersion()); return statement; }, keyHolder); Number key = keyHolder.getKey(); @@ -63,11 +70,13 @@ public class AihrWebSearchProviderService { int updated = jdbcTemplate.update(""" update aihr_web_search_provider set provider_name = ?, provider_code = ?, endpoint = ?, + agent_id = ?, agent_version = ?, api_key = case when ? is null then api_key else ? end, enabled = 0, last_test_ok = 0, last_test_time = null, update_time = now() where tenant_id = ? and id = ? - """, data.name(), data.code(), data.endpoint(), apiKey, apiKey, principal.tenantId(), request.id()); + """, data.name(), data.code(), data.endpoint(), data.agentId(), data.agentVersion(), + apiKey, apiKey, principal.tenantId(), request.id()); if (updated == 0) { throw new IllegalArgumentException("全网检索提供方不存在"); } @@ -79,7 +88,9 @@ public class AihrWebSearchProviderService { ensureTable(); if (enabled) { RuntimeProvider selected = provider(principal, id); - if (!AihrWebSearchClient.isPublicHttpsUrl(selected.endpoint()) || !hasText(selected.apiKey())) { + if (!AihrWebSearchClient.isPublicHttpsUrl(selected.endpoint()) || !hasText(selected.apiKey()) + || ("qwen".equals(selected.code()) + && (!hasText(selected.agentId()) || !hasText(selected.agentVersion())))) { throw new IllegalArgumentException("请先配置安全的公开接口和访问密钥"); } if (!selected.lastTestOk() || selected.lastTestTime() == null) { @@ -112,7 +123,8 @@ public class AihrWebSearchProviderService { ensureTable(); try { List rows = jdbcTemplate.query(""" - select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time + select id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version, + enabled, last_test_ok, last_test_time from aihr_web_search_provider where tenant_id = ? and enabled = 1 order by id desc limit 1 @@ -126,7 +138,8 @@ public class AihrWebSearchProviderService { RuntimeProvider provider(AihrKnowledgePrincipal principal, Long id) { ensureTable(); List rows = jdbcTemplate.query(""" - select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time + select id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version, + enabled, last_test_ok, last_test_time from aihr_web_search_provider where tenant_id = ? and id = ? """, (rs, rowNum) -> providerRow(rs), principal.tenantId(), id); if (rows.isEmpty()) { @@ -137,17 +150,20 @@ public class AihrWebSearchProviderService { private RuntimeProvider providerRow(java.sql.ResultSet rs) throws java.sql.SQLException { return new RuntimeProvider(rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), - rs.getString("endpoint"), secretCodec.decrypt(rs.getString("api_key")), rs.getBoolean("enabled"), + rs.getString("endpoint"), secretCodec.decrypt(rs.getString("api_key")), + rs.getString("agent_id"), rs.getString("agent_version"), rs.getBoolean("enabled"), rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class)); } private ProviderResponse responseById(AihrKnowledgePrincipal principal, Long id) { List rows = jdbcTemplate.query(""" - select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time + select id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version, + enabled, last_test_ok, last_test_time from aihr_web_search_provider where tenant_id = ? and id = ? """, (rs, rowNum) -> new ProviderResponse( rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), - rs.getString("endpoint"), hasText(rs.getString("api_key")), rs.getBoolean("enabled"), + rs.getString("endpoint"), rs.getString("agent_id"), rs.getString("agent_version"), + hasText(rs.getString("api_key")), rs.getBoolean("enabled"), rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class) ), principal.tenantId(), id); if (rows.isEmpty()) { @@ -166,8 +182,8 @@ public class AihrWebSearchProviderService { if (name.isBlank() || name.length() > 100) { throw new IllegalArgumentException("提供方名称不能为空且不能超过100字"); } - if (!"tavily".equals(code)) { - throw new IllegalArgumentException("当前仅支持 tavily"); + if (!Set.of("tavily", "qwen").contains(code)) { + throw new IllegalArgumentException("当前仅支持 Tavily 或千问联网检索"); } if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) { throw new IllegalArgumentException("检索地址必须是可公开访问的 HTTPS 地址"); @@ -179,7 +195,20 @@ public class AihrWebSearchProviderService { if (apiKey.length() > 1000) { throw new IllegalArgumentException("访问密钥长度超限"); } - return new ProviderData(name, code, endpoint, apiKey); + String agentId = clean(request.agentId()); + String agentVersion = clean(request.agentVersion()).toLowerCase(Locale.ROOT); + if ("qwen".equals(code)) { + if (!agentId.startsWith("aid-") || agentId.length() > 100) { + throw new IllegalArgumentException("千问 Agent ID 格式不正确"); + } + if (!Set.of("beta", "release").contains(agentVersion)) { + throw new IllegalArgumentException("千问 Agent Version 仅支持 beta 或 release"); + } + } else { + agentId = ""; + agentVersion = ""; + } + return new ProviderData(name, code, endpoint, apiKey, agentId, agentVersion); } private void ensureTable() { @@ -195,6 +224,7 @@ public class AihrWebSearchProviderService { `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL, `provider_name` varchar(100) NOT NULL, `provider_code` varchar(40) NOT NULL DEFAULT 'tavily', `endpoint` varchar(500) NOT NULL, `api_key` varchar(1000) DEFAULT NULL, + `agent_id` varchar(100) DEFAULT NULL, `agent_version` varchar(20) DEFAULT NULL, `enabled` tinyint NOT NULL DEFAULT 0, `last_test_ok` tinyint NOT NULL DEFAULT 0, `last_test_time` datetime DEFAULT NULL, @@ -216,18 +246,23 @@ public class AihrWebSearchProviderService { return value == null ? "" : value.trim(); } - public record ProviderRequest(Long id, String name, String providerCode, String endpoint, String apiKey) { + public record ProviderRequest(Long id, String name, String providerCode, String endpoint, + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String apiKey, + String agentId, String agentVersion) { } public record ProviderResponse(Long id, String name, String providerCode, String endpoint, + String agentId, String agentVersion, boolean configured, boolean enabled, boolean lastTestOk, LocalDateTime lastTestTime) { } - record RuntimeProvider(Long id, String name, String code, String endpoint, String apiKey, boolean enabled, - boolean lastTestOk, LocalDateTime lastTestTime) { + record RuntimeProvider(Long id, String name, String code, String endpoint, String apiKey, + String agentId, String agentVersion, boolean enabled, boolean lastTestOk, + LocalDateTime lastTestTime) { } - private record ProviderData(String name, String code, String endpoint, String apiKey) { + private record ProviderData(String name, String code, String endpoint, String apiKey, + String agentId, String agentVersion) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java index c68d8dfe..a7aab0f3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java @@ -6,6 +6,7 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest; import org.dromara.aihr.webai.AihrWebSearchClient.SearchResult; import org.dromara.aihr.webai.AihrWebSearchClient.WebSource; +import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderRequest; import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider; import org.dromara.common.core.exception.ServiceException; import org.junit.jupiter.api.Tag; @@ -38,6 +39,21 @@ class AihrWebAiSecurityTest { assertEquals("tavily", properties.getProvider()); } + @Test + @Tag("dev") + void providerRequestSecretIsAcceptedButNeverSerialized() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String json = """ + {"name":"千问","providerCode":"qwen","endpoint":"https://8.8.8.8/search", + "apiKey":"must-not-reach-logs","agentId":"aid-test","agentVersion":"release"} + """; + + ProviderRequest request = mapper.readValue(json, ProviderRequest.class); + + assertEquals("must-not-reach-logs", request.apiKey()); + assertFalse(mapper.writeValueAsString(request).contains("must-not-reach-logs")); + } + @Test @Tag("dev") void searchEndpointRejectsLocalAndPrivateNetworks() { @@ -96,6 +112,25 @@ class AihrWebAiSecurityTest { assertEquals("https://8.8.8.8/article", result.sources().get(0).url()); } + @Test + @Tag("dev") + void qwenSseKeepsGeneratedAnswerAndPublicToolSources() throws Exception { + String body = """ + data: {"status_code":200,"code":"","output":{"choices":[{"message":{"role":"tool","content":"{\\"results\\":[{\\"title\\":\\"公开资料\\",\\"url\\":\\"https://8.8.8.8/qwen\\",\\"snippet\\":\\"公开摘要\\"},{\\"title\\":\\"内网\\",\\"url\\":\\"http://127.0.0.1/secret\\"}]}"}}]}} + + data: {"status_code":200,"code":"","output":{"choices":[{"message":{"role":"assistant","content":"根据","additional_kwargs":{}}}]}} + + data: {"status_code":200,"code":"","output":{"choices":[{"message":{"role":"assistant","content":"公开资料生成答案。","additional_kwargs":{}}}]}} + """; + + SearchResult result = AihrWebSearchClient.parseQwenSseResponse(new ObjectMapper(), body, 5); + + assertEquals("根据公开资料生成答案。", result.answer()); + assertEquals(1, result.sources().size()); + assertEquals("公开资料", result.sources().get(0).title()); + assertEquals("https://8.8.8.8/qwen", result.sources().get(0).url()); + } + @Test @Tag("dev") void providerRequestsRequireHttpsEvenForPublicHosts() { @@ -103,7 +138,7 @@ class AihrWebAiSecurityTest { new AihrWebAiProperties(), mock(AihrWebSearchProviderService.class), new ObjectMapper() ); RuntimeProvider provider = new RuntimeProvider(1L, "test", "tavily", - "http://8.8.8.8/search", "secret", true, true, LocalDateTime.now()); + "http://8.8.8.8/search", "secret", "", "", true, true, LocalDateTime.now()); assertThrows(ServiceException.class, () -> client.search(provider, "test question")); } @@ -119,7 +154,7 @@ class AihrWebAiSecurityTest { "000000", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile" ); RuntimeProvider provider = new RuntimeProvider(1L, "Tavily", "tavily", - "https://8.8.8.8/search", "secret", true, true, LocalDateTime.now()); + "https://8.8.8.8/search", "secret", "", "", true, true, LocalDateTime.now()); when(resolver.current()).thenReturn(principal); when(searchClient.runtime(principal)).thenReturn(provider); when(searchClient.search(provider, "物业行业新规")) diff --git a/backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql b/backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql new file mode 100644 index 00000000..aa982b33 --- /dev/null +++ b/backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql @@ -0,0 +1,24 @@ +-- 千问联网检索 Agent 配置;API Key 继续使用 aihr_web_search_provider.api_key 加密保存。 +SET @aihr_schema = DATABASE(); + +SET @web_agent_id_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_web_search_provider' AND column_name = 'agent_id' +); +SET @web_agent_id_ddl = IF(@web_agent_id_exists = 0, + 'ALTER TABLE aihr_web_search_provider ADD COLUMN agent_id varchar(100) DEFAULT NULL COMMENT ''千问联网检索 Agent ID'' AFTER api_key', + 'SELECT 1'); +PREPARE aihr_web_agent_id_stmt FROM @web_agent_id_ddl; +EXECUTE aihr_web_agent_id_stmt; +DEALLOCATE PREPARE aihr_web_agent_id_stmt; + +SET @web_agent_version_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_web_search_provider' AND column_name = 'agent_version' +); +SET @web_agent_version_ddl = IF(@web_agent_version_exists = 0, + 'ALTER TABLE aihr_web_search_provider ADD COLUMN agent_version varchar(20) DEFAULT NULL COMMENT ''千问 Agent 版本:beta/release'' AFTER agent_id', + 'SELECT 1'); +PREPARE aihr_web_agent_version_stmt FROM @web_agent_version_ddl; +EXECUTE aihr_web_agent_version_stmt; +DEALLOCATE PREPARE aihr_web_agent_version_stmt; diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index d926afed..2ae8c840 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -54,6 +54,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql ``` 顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、正式记录和版本留痕三张表;工作上报幂等迁移随后只补原表列与唯一索引。两者同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 @@ -105,6 +106,7 @@ WHERE table_schema = DATABASE() OR (table_name = 'aihr_onboard_task' AND column_name IN ('ext_party_id', 'position_code', 'task_type', 'status', 'assign_by')) OR (table_name = 'aihr_qualification_gate' AND column_name IN ('ext_party_id', 'position_code', 'cert_id', 'passed', 'valid_thru')) OR (table_name = 'aihr_work_report' AND column_name IN ('request_key', 'request_hash')) + OR (table_name = 'aihr_web_search_provider' AND column_name IN ('agent_id', 'agent_version')) ) ORDER BY table_name, column_name; diff --git a/frontend/src/api/aihr/web-search.ts b/frontend/src/api/aihr/web-search.ts index 482fb258..e6bb2a6a 100644 --- a/frontend/src/api/aihr/web-search.ts +++ b/frontend/src/api/aihr/web-search.ts @@ -5,8 +5,10 @@ type ApiResult = { code: number; msg: string; data: T }; export interface WebSearchProvider { id: number; name: string; - providerCode: 'tavily' | string; + providerCode: 'tavily' | 'qwen'; endpoint: string; + agentId?: string; + agentVersion?: string; configured: boolean; enabled: boolean; lastTestOk: boolean; @@ -16,9 +18,11 @@ export interface WebSearchProvider { export interface WebSearchProviderForm { id?: number; name: string; - providerCode: 'tavily'; + providerCode: 'tavily' | 'qwen'; endpoint: string; apiKey: string; + agentId: string; + agentVersion: 'beta' | 'release'; } export const listWebSearchProviders = (): Promise> => request({ @@ -43,5 +47,6 @@ export const toggleWebSearchProvider = (id: number, enabled: boolean): Promise> => request({ url: `/api/aihr/web-search/providers/${id}/test`, method: 'post', - headers: { repeatSubmit: false } + headers: { repeatSubmit: false }, + timeout: 45000 }); diff --git a/frontend/src/views/system/web-search.vue b/frontend/src/views/system/web-search.vue index abff7148..0559340a 100644 --- a/frontend/src/views/system/web-search.vue +++ b/frontend/src/views/system/web-search.vue @@ -21,12 +21,13 @@

{{ item.name }}

-

{{ item.providerCode }}

+

{{ providerLabel(item.providerCode) }}

{{ item.enabled ? '已启用' : '未启用' }}
接口地址
{{ item.endpoint }}
+
Agent
{{ item.agentId }}({{ item.agentVersion }})
访问密钥
{{ item.configured ? '已安全保存' : '未配置' }}
连接测试
@@ -52,11 +53,25 @@ - + - - + + + + + + + + @@ -87,11 +102,19 @@ const saving = ref(false); const testingId = ref(0); const togglingId = ref(0); const editorVisible = ref(false); +const providerDefaults = { + qwen: { + name: '千问联网检索', + endpoint: 'https://dashscope.aliyuncs.com/api/v2/apps/web-search-agent/chat/completions' + }, + tavily: { name: 'Tavily 全网检索', endpoint: 'https://api.tavily.com/search' } +} as const; const emptyForm = (): WebSearchProviderForm => ({ - name: 'Tavily 全网检索', - providerCode: 'tavily', - endpoint: 'https://api.tavily.com/search', - apiKey: '' + ...providerDefaults.qwen, + providerCode: 'qwen', + apiKey: '', + agentId: '', + agentVersion: 'release' }); const form = reactive(emptyForm()); @@ -111,21 +134,29 @@ const openEditor = (item?: WebSearchProvider) => { Object.assign(form, emptyForm(), item ? { id: item.id, name: item.name, - providerCode: 'tavily', + providerCode: item.providerCode === 'qwen' ? 'qwen' : 'tavily', endpoint: item.endpoint, - apiKey: '' + apiKey: '', + agentId: item.agentId || '', + agentVersion: item.agentVersion === 'beta' ? 'beta' : 'release' } : {}); editorVisible.value = true; }; const submit = async () => { - if (!form.name.trim() || !form.endpoint.trim() || (!form.id && !form.apiKey.trim())) { - ElMessage.warning('请完整填写名称、接口地址和访问密钥'); + const qwenIncomplete = form.providerCode === 'qwen' && (!form.agentId.trim() || !form.agentVersion); + if (!form.name.trim() || !form.endpoint.trim() || (!form.id && !form.apiKey.trim()) || qwenIncomplete) { + ElMessage.warning('请完整填写名称、接口地址、访问密钥和 Agent 信息'); return; } saving.value = true; try { - await saveWebSearchProvider({ ...form, name: form.name.trim(), endpoint: form.endpoint.trim() }); + await saveWebSearchProvider({ + ...form, + name: form.name.trim(), + endpoint: form.endpoint.trim(), + agentId: form.agentId.trim() + }); editorVisible.value = false; ElMessage.success('配置已保存,启用前请先执行连接测试'); await load(); @@ -134,6 +165,15 @@ const submit = async () => { } }; +const changeProvider = (code: 'tavily' | 'qwen') => { + Object.assign(form, providerDefaults[code], { + agentId: code === 'qwen' ? form.agentId : '', + agentVersion: code === 'qwen' ? form.agentVersion : 'release' + }); +}; + +const providerLabel = (code: string) => code === 'qwen' ? '千问联网检索' : 'Tavily'; + const testProvider = async (item: WebSearchProvider) => { testingId.value = item.id; try { @@ -162,5 +202,5 @@ onMounted(load);