From 7c397bbfae3e768b26e3ee962c5aa7064e8d7c96 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 9 Oct 2026 12:33:14 +0800 Subject: [PATCH] feat(ai1): 同步 XXL-AI Cloud 增量,优化请求 Header、技能注册和首页图表 --- yudao-module-ai1-springai/pom.xml | 2 +- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java | 6 ++++-- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java | 12 ++++++------ yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java | 13 +++++++++---- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java | 1 + yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java | 2 +- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java | 24 +++++++++++------------- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java | 30 ++++++++++++++++-------------- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java | 2 +- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java | 10 ++++++---- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java | 5 ++--- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java | 6 ++++-- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java | 5 ++--- yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java | 57 +++++++++++++++++++++++++++------------------------------ yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java | 6 +++++- yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java | 41 +++++++++++++++++++++++++---------------- yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java | 31 +++++++++++++++++++++++++++++++ yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java | 5 +++-- yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java | 50 ++++++++++++++++++++++++++++++++++++++++++++------ yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 23 files changed, 546 insertions(+), 120 deletions(-) create mode 100644 yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java diff --git a/yudao-module-ai1-springai/pom.xml b/yudao-module-ai1-springai/pom.xml index d0a3916..f29374d 100644 --- a/yudao-module-ai1-springai/pom.xml +++ b/yudao-module-ai1-springai/pom.xml @@ -22,7 +22,7 @@ 2.0.1 - 0.12.0 + 0.13.0 3.0.10 diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java index 42aa913..cd14196 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.Map; + import java.time.LocalDateTime; @Schema(description = "管理后台 - AI1 供应商 Response VO") @@ -21,8 +23,8 @@ public class Ai1ProviderRespVO { @Schema(description = "API 密钥,脱敏展示", example = "sk-****abcd") private String apiKey; - @Schema(description = "请求附属 Header,JSON 数组", example = "[{\"key\":\"X-Session\",\"value\":\"{session}\"}]") - private String headers; + @Schema(description = "请求 Header,JSON 对象", example = "{\"X-Session\":\"{session}\"}") + private Map headers; @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") private Integer status; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java index b07bdbc..cb41dca 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java @@ -1,6 +1,5 @@ package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; -import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.validation.InEnum; import cn.iocoder.yudao.module.ai1.util.Ai1Utils; @@ -12,6 +11,8 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; +import java.util.Map; + @Schema(description = "管理后台 - AI1 供应商新增/修改 Request VO") @Data public class Ai1ProviderSaveReqVO { @@ -33,9 +34,8 @@ public class Ai1ProviderSaveReqVO { @Size(max = 200, message = "API 密钥长度不能超过 200 个字符") private String apiKey; - @Schema(description = "请求附属 Header,JSON 数组", example = "[{\"key\":\"X-Session\",\"value\":\"{session}\"}]") - @Size(max = 2000, message = "请求附属 Header 长度不能超过 2000 个字符") - private String headers; + @Schema(description = "请求 Header,JSON 对象", example = "{\"X-Session\":\"{session}\"}") + private Map headers; @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") @NotNull(message = "状态不能为空") @@ -46,10 +46,10 @@ public class Ai1ProviderSaveReqVO { @Size(max = 255, message = "备注长度不能超过 255 个字符") private String remark; - @AssertTrue(message = "请求附属 Header 格式不正确,应为 JSON 数组:[{\"key\":\"...\",\"value\":\"...\"}]") + @AssertTrue(message = "请求 Header 名称不能为空或重复,值必须为字符串,JSON 长度不能超过 2000 个字符") @JsonIgnore public boolean isHeadersValid() { - return StrUtil.isBlank(headers) || Ai1Utils.parseHeaders(headers) != null; + return Ai1Utils.isHeadersValid(headers); } } diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java index fa8bec1..a08da32 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java @@ -4,9 +4,13 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.baomidou.mybatisplus.annotation.TableName; import lombok.*; +import java.util.Map; + /** * AI1 供应商 DO * @@ -14,7 +18,7 @@ import lombok.*; * * @author 芋道源码 */ -@TableName("ai1_provider") +@TableName(value = "ai1_provider", autoResultMap = true) @KeySequence("ai1_provider_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 @Data @EqualsAndHashCode(callSuper = true) @@ -42,11 +46,12 @@ public class Ai1ProviderDO extends TenantBaseDO { */ private String apiKey; /** - * 请求附属 Header + * 请求 Header * - * JSON 数组,格式为 [{"key":"...","value":"..."}];value 可包含 {session} 占位符,请求时替换为会话编号 + * JSON 对象,格式为 {"X-Session":"{session}"};value 可包含 {session} 占位符,请求时替换为会话编号 */ - private String headers; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map headers; /** * 状态 * diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java index f73328f..1bb1b99 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java @@ -16,6 +16,7 @@ public interface Ai1ErrorCodeConstants { ErrorCode PROVIDER_REMOTE_MODEL_LOAD_FAIL = new ErrorCode(1_041_000_004, "模型拉取失败:请检查供应商地址与 API 密钥"); ErrorCode PROVIDER_REMOTE_MODEL_EMPTY = new ErrorCode(1_041_000_005, "远程未返回可用模型"); ErrorCode CONFIG_PLACEHOLDER_NOT_RESOLVED = new ErrorCode(1_041_000_006, "配置({})无法解析,请检查环境变量或配置项"); + ErrorCode PROVIDER_HEADERS_INVALID = new ErrorCode(1_041_000_007, "请求 Header 格式不正确或名称重复,请检查供应商配置"); // ========== 模型 1-041-001-000 ========== ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_041_001_000, "模型不存在"); diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java index bea83f5..ef1dace 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java @@ -51,7 +51,7 @@ public class Ai1LlmChatTool { * @param content 当前用户消息 * @param tools 工具(ToolCallback 或 @Tool 对象),可为空 * @param advisors 会话 Advisor,可为空 - * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符 + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符 * @param onThinking 思考过程增量回调,可为空 * @param onContent 回复内容增量回调,可为空 * @return 完整回复(内容 + 思考过程) diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java index 674dfc3..6e0645c 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java @@ -1,6 +1,5 @@ package cn.iocoder.yudao.module.ai1.harness.llm; -import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; import com.google.common.cache.Cache; @@ -21,7 +20,6 @@ import org.springframework.util.MultiValueMap; import java.net.URI; import java.time.Duration; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Predicate; @@ -43,7 +41,7 @@ import java.util.function.Predicate; public class Ai1LlmModelFactory { /** - * 附属 Header 会话占位符:构建模型时替换为当前会话编号 + * 请求 Header 会话占位符:构建模型时替换为当前会话编号 */ public static final String SESSION_PLACEHOLDER = "{session}"; /** @@ -76,7 +74,7 @@ public class Ai1LlmModelFactory { * 获取(或构建)对话模型,并包装为 ChatClient * * @param model 模型运行时快照 - * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符,可为空 + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符,可为空 * @return ChatClient */ public ChatClient buildChatClient(Ai1ModelRespBO model, Long sessionId) { @@ -132,7 +130,7 @@ public class Ai1LlmModelFactory { /** * 获取(或构建)对话模型 * - * 仅当附属 Header 使用 {session} 占位符(需按会话隔离)时,才把会话编号纳入缓存 key; + * 仅当请求 Header 使用 {session} 占位符(需按会话隔离)时,才把会话编号纳入缓存 key; * 否则同一模型跨会话复用,避免每个会话都构建一个模型实例 */ private OpenAiChatModel getOrCreateChatModel(Ai1ModelRespBO model, Long sessionId) { @@ -205,23 +203,23 @@ public class Ai1LlmModelFactory { } /** - * 附属 Header 是否使用 {session} 占位符 + * 请求 Header 是否使用 {session} 占位符 */ - private static boolean usesSessionHeader(List> headers) { - return CollUtil.findOne(headers, header -> StrUtil.contains(header.get("value"), SESSION_PLACEHOLDER)) != null; + private static boolean usesSessionHeader(Map headers) { + return headers != null && headers.values().stream().anyMatch(value -> StrUtil.contains(value, SESSION_PLACEHOLDER)); } /** * 构建请求 Header:{session} 占位符替换为会话编号;会话编号为空时,跳过带占位符的 Header */ - private static Map buildHeaders(List> headers, Long sessionId) { + private static Map buildHeaders(Map headers, Long sessionId) { Map result = new LinkedHashMap<>(); - if (CollUtil.isEmpty(headers)) { + if (headers == null || headers.isEmpty()) { return result; } - for (Map header : headers) { - String key = header.get("key"); - String value = header.get("value"); + for (Map.Entry header : headers.entrySet()) { + String key = header.getKey(); + String value = header.getValue(); if (StrUtil.isBlank(key)) { continue; } diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java index f6f3322..24f7f2d 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java @@ -58,10 +58,10 @@ public class Ai1ProviderTool { * * @param baseUrl 接口地址 * @param apiKey API 密钥 - * @param headers 请求附属 Header + * @param headers 请求 Header * @return 测试结果 */ - public Ai1ProviderConnectRespVO testConnect(String baseUrl, String apiKey, List> headers) { + public Ai1ProviderConnectRespVO testConnect(String baseUrl, String apiKey, Map headers) { long startTime = System.currentTimeMillis(); String url = normalizeBaseUrl(baseUrl); @@ -102,10 +102,10 @@ public class Ai1ProviderTool { * * @param baseUrl 接口地址 * @param apiKey API 密钥 - * @param headers 请求附属 Header + * @param headers 请求 Header * @return 模型标识列表 */ - public List listModels(String baseUrl, String apiKey, List> headers) { + public List listModels(String baseUrl, String apiKey, Map headers) { // 1. 请求 GET /models:非 HTTP 200 时,提示检查配置 String url = normalizeBaseUrl(baseUrl) + "/models"; HttpOutcome outcome = executeRequest(Method.GET, url, apiKey, headers, null); @@ -153,12 +153,12 @@ public class Ai1ProviderTool { * @param method 请求方法 * @param url 请求地址 * @param apiKey API 密钥 - * @param headers 请求附属 Header + * @param headers 请求 Header * @param body JSON 请求体,可为空 * @return 请求结果 */ private static HttpOutcome executeRequest(Method method, String url, String apiKey, - List> headers, String body) { + Map headers, String body) { try { HttpRequest request = buildOpenAiRequest(method, url, apiKey, headers); if (body != null) { @@ -168,30 +168,32 @@ public class Ai1ProviderTool { return new HttpOutcome(response.getStatus(), null, response.body()); } } catch (Exception e) { - return new HttpOutcome(0, e.getMessage(), null); + // HTTP 客户端异常可能包含密钥或 Header 原文,不能直接回传。 + return new HttpOutcome(0, "请求发送失败,请检查服务地址、密钥及 Header(" + + e.getClass().getSimpleName() + ")", null); } } /** - * 构建 OpenAI 兼容接口的 HTTP 请求:携带 API 密钥与静态附属 Header,包含 {session} 占位符的 Header 跳过 + * 构建 OpenAI 兼容接口的 HTTP 请求:携带 API 密钥与静态 Header,包含 {session} 占位符的 Header 跳过 * * @param method 请求方法 * @param url 请求地址 * @param apiKey API 密钥 - * @param headers 请求附属 Header + * @param headers 请求 Header * @return HTTP 请求 */ - private static HttpRequest buildOpenAiRequest(Method method, String url, String apiKey, List> headers) { + private static HttpRequest buildOpenAiRequest(Method method, String url, String apiKey, Map headers) { HttpRequest request = HttpRequest.of(url).method(method) .setConnectionTimeout(CONNECT_TIMEOUT).setReadTimeout(READ_TIMEOUT) .header(Header.ACCEPT, ContentType.JSON.getValue()); if (StrUtil.isNotBlank(apiKey)) { request.bearerAuth(apiKey); } - if (CollUtil.isNotEmpty(headers)) { - for (Map header : headers) { - String key = header.get("key"); - String value = header.get("value"); + if (MapUtil.isNotEmpty(headers)) { + for (Map.Entry header : headers.entrySet()) { + String key = header.getKey(); + String value = header.getValue(); if (StrUtil.isBlank(key) || StrUtil.contains(value, Ai1LlmModelFactory.SESSION_PLACEHOLDER)) { continue; } diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java index 31801ff..e190cf5 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java @@ -304,7 +304,7 @@ public class Ai1SessionStreamTool implements SmartLifecycle { // 1.2 生成所需的业务字段 fields.put(FIELD_MESSAGE_ID, String.valueOf(messageId)); fields.put(FIELD_AGENT_ID, String.valueOf(agentId)); - // 生成时,附属 Header 中的 {session} 占位符替换为此处的会话编号 + // 生成时,请求 Header 中的 {session} 占位符替换为此处的会话编号 fields.put(FIELD_SESSION_ID, String.valueOf(sessionId)); fields.put(FIELD_CONTENT, content); diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java index 8dffdf6..7f08873 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java @@ -157,16 +157,18 @@ public class Ai1SkillToolFactory { // 2.1 清空目录后,逐个物化开启的 SKILL FileUtil.del(root); FileUtil.mkdir(root.toFile()); - boolean hasSkill = false; - for (Ai1SkillDO skill : skills) { + List skillDirectories = new ArrayList<>(); + // 固定注册顺序;同名 frontmatter 按 agent-utils 的首次注册规则处理。 + for (Ai1SkillDO skill : skills.stream().sorted(Comparator.comparing(Ai1SkillDO::getId)).toList()) { if (CommonStatusEnum.isEnable(skill.getStatus()) && materializeSkill(root.resolve(sanitize(skill.getName())), skill.getId())) { - hasSkill = true; + skillDirectories.add(root.resolve(sanitize(skill.getName())).toString()); } } // 2.2 写入指纹标记,构建技能工具并缓存快照 FileUtil.writeUtf8String(fingerprint, root.resolve(FILE_NAME_FINGERPRINT).toFile()); - ToolCallback skillsTool = hasSkill ? SkillsTool.builder().addSkillsDirectory(root.toString()).build() : null; + ToolCallback skillsTool = skillDirectories.isEmpty() ? null + : SkillsTool.builder().addSkillsDirectories(skillDirectories).build(); SkillSnapshot snapshot = new SkillSnapshot(fingerprint, root, skillsTool); snapshotCache.put(agent.getId(), snapshot); log.info("[sync][Agent({}) SKILL 物化完成,目录({})]", agent.getId(), root); diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java index d2389ff..ba3836c 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java @@ -29,7 +29,6 @@ import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; -import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders; import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; /** @@ -176,7 +175,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { // 2. 拉取远程模型:调用前解析 ${ENV} 占位符,库中的原文不改 return providerTool.listModels(resolveSpringPlaceholders(provider.getBaseUrl()), - resolveSpringPlaceholders(provider.getApiKey()), parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); + resolveSpringPlaceholders(provider.getApiKey()), resolveSpringPlaceholders(provider.getHeaders())); } @Override @@ -200,7 +199,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { .setModelId(model.getId()).setModel(model.getModel()).setModelType(model.getType()) .setBaseUrl(resolveSpringPlaceholders(provider.getBaseUrl())) .setApiKey(resolveSpringPlaceholders(provider.getApiKey())) - .setHeaders(parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); + .setHeaders(resolveSpringPlaceholders(provider.getHeaders())); } @Override diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java index f2cb53c..51dc685 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java @@ -23,7 +23,7 @@ import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; -import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.normalizeHeaders; import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; /** @@ -51,6 +51,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { @Override public Long createProvider(Ai1ProviderSaveReqVO createReqVO) { Ai1ProviderDO provider = BeanUtils.toBean(createReqVO, Ai1ProviderDO.class); + provider.setHeaders(normalizeHeaders(createReqVO.getHeaders())); providerMapper.insert(provider); return provider.getId(); } @@ -62,6 +63,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { // 2. 更新:API 密钥为空时置为 null,由 updateById 跳过该字段,保持原密钥 Ai1ProviderDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ProviderDO.class); + updateObj.setHeaders(normalizeHeaders(updateReqVO.getHeaders())); if (StrUtil.isBlank(updateObj.getApiKey())) { updateObj.setApiKey(null); } @@ -132,7 +134,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { // 2. 连通测试:调用前解析 ${ENV} 占位符,库中的原文不改 return providerTool.testConnect(resolveSpringPlaceholders(provider.getBaseUrl()), - resolveSpringPlaceholders(provider.getApiKey()), parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); + resolveSpringPlaceholders(provider.getApiKey()), resolveSpringPlaceholders(provider.getHeaders())); } } diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java index e1b3b54..8ae40ee 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java @@ -4,7 +4,6 @@ import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; import lombok.Data; import lombok.experimental.Accessors; -import java.util.List; import java.util.Map; /** @@ -46,8 +45,8 @@ public class Ai1ModelRespBO { */ private String apiKey; /** - * 请求附属 Header,每项包含 key、value;value 可包含 {session} 占位符 + * 请求 Header,value 可包含 {session} 占位符 */ - private List> headers; + private Map headers; } diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java index f4b7b0f..a9d239e 100644 --- a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java @@ -1,20 +1,16 @@ package cn.iocoder.yudao.module.ai1.util; -import cn.hutool.core.map.MapUtil; -import cn.hutool.core.util.ClassUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; -import com.fasterxml.jackson.core.type.TypeReference; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import org.springframework.core.env.Environment; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.CONFIG_PLACEHOLDER_NOT_RESOLVED; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_HEADERS_INVALID; /** * AI1 工具类 @@ -38,7 +34,7 @@ public class Ai1Utils { try { return SpringUtil.getBean(Environment.class).resolveRequiredPlaceholders(value); } catch (IllegalArgumentException ex) { - throw exception(CONFIG_PLACEHOLDER_NOT_RESOLVED, value); + throw exception(CONFIG_PLACEHOLDER_NOT_RESOLVED, "动态配置项"); } } @@ -58,36 +54,37 @@ public class Ai1Utils { } /** - * 解析供应商的请求附属 Header:JSON 数组,格式为 [{"key":"...","value":"..."}] - * - * @param headersJson JSON 字符串 - * @return Header 列表,每项包含 key、value;为空或格式错误时返回 null + * 规范化请求 Header,保留输入顺序;不解析环境占位符 */ - public static List> parseHeaders(String headersJson) { - if (StrUtil.isBlank(headersJson)) { - return null; - } - List> items = JsonUtils.parseObjectQuietly(headersJson, new TypeReference>>() {}); - if (items == null) { + public static Map normalizeHeaders(Map values) { + if (values == null) { return null; } - List> headers = new ArrayList<>(items.size()); - for (Map item : items) { - // 每项必须是对象,且 key 为标量 - if (item == null || !isSimpleValue(item.get("key"))) { - return null; + Map headers = new LinkedHashMap<>(); + Set names = new HashSet<>(); + values.forEach((key, value) -> { + if (StrUtil.isBlank(key) || value == null) { + throw exception(PROVIDER_HEADERS_INVALID); + } + String name = key.trim(); + if (!names.add(name.toLowerCase(Locale.ROOT))) { + throw exception(PROVIDER_HEADERS_INVALID); } - headers.add(MapUtil.builder("key", MapUtil.getStr(item, "key")) - .put("value", isSimpleValue(item.get("value")) ? MapUtil.getStr(item, "value") : "").build()); + headers.put(name, value); + }); + if (JsonUtils.toJsonString(headers).length() > 2000) { + throw exception(PROVIDER_HEADERS_INVALID); } return headers; } - /** - * 是否为 JSON 标量值:字符串、数字、布尔 - */ - private static boolean isSimpleValue(Object value) { - return value != null && ClassUtil.isSimpleValueType(value.getClass()); + public static boolean isHeadersValid(Map headers) { + try { + normalizeHeaders(headers); + return true; + } catch (ServiceException ex) { + return false; + } } } diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java index ee5753e..bed9e23 100644 --- a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java @@ -7,13 +7,18 @@ import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springaicommunity.agent.tools.SkillsTool; import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.tool.ToolCallback; import org.springframework.test.util.ReflectionTestUtils; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -22,7 +27,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import static org.junit.jupiter.api.Assertions.*; /** - * 模型调用回归:通过本地 HTTP 端点验证真实请求、流式分片和模型缓存。 + * {@link Ai1LlmModelFactory} 的单元测试 */ public class Ai1LlmModelFactoryTest { @@ -30,9 +35,11 @@ public class Ai1LlmModelFactoryTest { private final List requests = new CopyOnWriteArrayList<>(); private HttpServer server; private String baseUrl; + private String requestedTool; @BeforeEach public void before() throws Exception { + // mock HTTP 服务 server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", exchange -> { requests.add(new Request(exchange.getRequestURI().getPath(), @@ -41,7 +48,8 @@ public class Ai1LlmModelFactoryTest { exchange.getRequestHeaders().getFirst("X-Provider"), JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)))); boolean embedding = exchange.getRequestURI().getPath().endsWith("/embeddings"); - String response = embedding + boolean callTool = requestedTool != null && requests.size() == 1; + String response = callTool ? toolCallSse(requestedTool) : embedding ? "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"index\":0,\"embedding\":[0.1,0.2]}],\"model\":\"test-model\",\"usage\":{\"prompt_tokens\":1,\"total_tokens\":1}}" : sse("{\"role\":\"assistant\",\"reasoning_content\":\"想\"}") + sse("{\"reasoning_content\":\"想\"}") @@ -67,15 +75,18 @@ public class Ai1LlmModelFactoryTest { @ParameterizedTest @ValueSource(strings = {"", "/v1/", "/proxy/v1"}) public void testChat_endpointHeadersAndReasoningDeltas(String prefix) { + // 准备参数 Ai1ModelRespBO model = model(baseUrl + prefix); Ai1LlmChatTool chatTool = new Ai1LlmChatTool(); ReflectionTestUtils.setField(chatTool, "llmModelFactory", factory); List thinking = new ArrayList<>(); List content = new ArrayList<>(); + // 调用 Ai1LlmChatTool.ChatText result = chatTool.chat(model, "系统指令", List.of(new String[]{"user", "历史"}), "当前消息", List.of(), List.of(), 42L, thinking::add, content::add); + // 断言 assertEquals("你好", result.getContent()); assertEquals("想想想到", result.getThinking()); assertEquals(List.of("想", "想", "想到"), thinking); @@ -94,10 +105,14 @@ public class Ai1LlmModelFactoryTest { @Test public void testEmbedding_endpointAndHeaders() { + // 准备参数 Ai1ModelRespBO model = model(baseUrl + "/proxy/v1/"); EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); - assertArrayEquals(new float[]{0.1F, 0.2F}, embedding.embed("文档内容")); + // 调用 + float[] result = embedding.embed("文档内容"); + // 断言 + assertArrayEquals(new float[]{0.1F, 0.2F}, result); Request request = requests.get(0); assertEquals("/proxy/v1/embeddings", request.path()); assertEquals("Bearer test-key", request.authorization()); @@ -108,35 +123,79 @@ public class Ai1LlmModelFactoryTest { } @Test + public void testChat_skillCallback(@TempDir Path root) throws Exception { + // 准备参数 + Files.writeString(root.resolve("SKILL.md"), "---\nname: probe\ndescription: test\n---\nunique-skill-content\n"); + ToolCallback skill = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); + requestedTool = skill.getToolDefinition().name(); + Ai1LlmChatTool chat = new Ai1LlmChatTool(); + ReflectionTestUtils.setField(chat, "llmModelFactory", factory); + + // 调用 + Ai1LlmChatTool.ChatText result = chat.chat(model(baseUrl), "system", List.of(), "use skill", + List.of(skill), List.of(), 42L, null, null); + // 断言 + assertEquals("你好", result.getContent()); + assertEquals(2, requests.size()); + assertEquals(requestedTool, requests.get(0).body().get("tools").get(0).get("function").get("name").asText()); + boolean hasToolResult = false; + for (JsonNode message : requests.get(1).body().get("messages")) { + if ("tool".equals(message.get("role").asText())) { + hasToolResult = true; + assertTrue(message.get("content").asText().contains("unique-skill-content")); + } + } + assertTrue(hasToolResult); + assertEquals("42", requests.get(1).session()); + } + + @Test public void testCache_sessionIsolationAndInvalidation() { + // 准备参数 Ai1ModelRespBO model = model(baseUrl); + + // 调用 Object chat = chatModel(model, 1L); + // 断言 assertSame(chat, chatModel(model, 1L)); assertNotSame(chat, chatModel(model, 2L)); EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); assertSame(embedding, factory.getOrCreateEmbeddingModel(model)); + // 调用 factory.evictByModelId(model.getModelId()); + // 断言 assertNotSame(chat, chatModel(model, 1L)); assertNotSame(embedding, factory.getOrCreateEmbeddingModel(model)); - model.setHeaders(List.of(Map.of("key", "X-Provider", "value", "provider"))); + + // 准备参数 + model.setHeaders(Map.of("X-Provider", "provider")); + // 调用 chat = chatModel(model, 1L); + // 断言 assertSame(chat, chatModel(model, 2L)); + + // 调用 model.setApiKey("changed-key"); + // 断言 assertNotSame(chat, chatModel(model, 1L)); + + // 调用 chat = chatModel(model, 1L); factory.evictByProviderId(model.getProviderId()); + // 断言 assertNotSame(chat, chatModel(model, 1L)); } + // ========== 工具方法 ========== + private Object chatModel(Ai1ModelRespBO model, Long sessionId) { return ReflectionTestUtils.invokeMethod(factory, "getOrCreateChatModel", model, sessionId); } private static Ai1ModelRespBO model(String url) { return new Ai1ModelRespBO().setProviderId(1L).setModelId(2L).setBaseUrl(url).setApiKey("test-key") - .setModel("test-model").setHeaders(List.of(Map.of("key", "X-Session", "value", "{session}"), - Map.of("key", "X-Provider", "value", "provider"))); + .setModel("test-model").setHeaders(Map.of("X-Session", "{session}", "X-Provider", "provider")); } private static String sse(String delta) { @@ -144,6 +203,16 @@ public class Ai1LlmModelFactoryTest { + "\"choices\":[{\"index\":0,\"delta\":" + delta + ",\"finish_reason\":null}]}\n\n"; } + private static String toolCallSse(String name) { + String delta = JsonUtils.toJsonString(Map.of("role", "assistant", "tool_calls", List.of(Map.of( + "index", 0, "id", "call-probe", "type", "function", "function", Map.of( + "name", name, "arguments", "{\"command\":\"probe\"}"))))); + return sse(delta) + + "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test-model\"," + + "\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" + + "data: [DONE]\n\n"; + } + private record Request(String path, String authorization, String session, String provider, JsonNode body) { } } diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java index b890b02..717116a 100644 --- a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java @@ -16,13 +16,14 @@ import java.util.concurrent.CopyOnWriteArrayList; import static org.junit.jupiter.api.Assertions.*; /** - * 验证 MCP SDK 的真实 HTTP 握手及工具调用。 + * {@link Ai1McpClientTool} 的运行时测试 */ public class Ai1McpRuntimeCompatibilityTest { @Test @Timeout(15) public void testStreamableHttp_initializeListAndCall() throws Exception { + // mock HTTP 服务 List methods = new CopyOnWriteArrayList<>(); List paths = new CopyOnWriteArrayList<>(); List headers = new CopyOnWriteArrayList<>(); @@ -63,13 +64,16 @@ public class Ai1McpRuntimeCompatibilityTest { server.start(); Ai1McpClientTool client = new Ai1McpClientTool(); try { + // 准备参数 Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("probe").setTransport("http") .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/mcp?key=test") .setHeaders(Map.of("X-Probe", "compatibility")); + // 调用,并断言工具发现和调用 assertEquals(1, client.listTools(mcp).size()); assertEquals("echo", client.listTools(mcp).get(0).name()); assertEquals("hello", client.callTool(mcp, "echo", Map.of("value", "hello"))); + // 断言 HTTP 请求 assertTrue(methods.containsAll(List.of("initialize", "notifications/initialized", "tools/list", "tools/call"))); assertTrue(paths.stream().allMatch("/proxy/mcp?key=test"::equals)); assertTrue(headers.stream().allMatch("compatibility"::equals)); diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java index 6dcf872..8d161b7 100644 --- a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java @@ -1,13 +1,24 @@ package cn.iocoder.yudao.module.ai1.harness.model; +import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.util.Ai1Utils; +import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockEnvironment; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; /** * {@link Ai1ProviderTool} 的单元测试 @@ -39,6 +50,68 @@ public class Ai1ProviderToolTest { } @Test + public void testTestConnectAndListModels_staticHeaders() throws Exception { + // mock HTTP 服务 + List> received = new CopyOnWriteArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/proxy/v1/models", exchange -> { + received.add(Map.of("auth", exchange.getRequestHeaders().getFirst("Authorization"), + "test", exchange.getRequestHeaders().getFirst("X-Test"), + "session", String.valueOf(exchange.getRequestHeaders().getFirst("X-Session")))); + byte[] body = "{\"data\":[{\"id\":\"test-model\"}]}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + }); + server.start(); + try { + // 准备参数 + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/v1/"; + Map headers = Map.of("X-Test", "a\"b\\c", "X-Session", "{session}"); + // 调用 + Ai1ProviderConnectRespVO result = providerTool.testConnect(url, "synthetic-key", headers); + List models = providerTool.listModels(url, "synthetic-key", headers); + // 断言 + assertTrue(result.getConnectable()); + assertEquals(List.of("test-model"), models); + assertEquals(2, received.size()); + received.forEach(request -> assertEquals(Map.of("auth", "Bearer synthetic-key", "test", "a\"b\\c", "session", "null"), request)); + } finally { + server.stop(0); + } + } + + @Test + public void testTestConnect_invalidConfiguration() throws Exception { + // 准备参数 + String secret = "synthetic-private-token\nprivate-suffix"; + // mock HTTP 服务 + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.start(); + try (MockedStatic spring = mockStatic(SpringUtil.class)) { + // mock 环境变量 + spring.when(() -> SpringUtil.getBean(Environment.class)) + .thenReturn(new MockEnvironment().withProperty("HTTP_HEADER_TOKEN", secret)); + // 准备参数 + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; + Map headers = Ai1Utils.resolveSpringPlaceholders(Map.of("X-Token", "${HTTP_HEADER_TOKEN}")); + // 调用 + List results = List.of( + providerTool.testConnect(url, null, headers), providerTool.testConnect(url, secret, null)); + // 断言 + for (Ai1ProviderConnectRespVO result : results) { + assertFalse(result.getConnectable()); + assertEquals(0, result.getHttpCode()); + assertFalse(result.getMessage().contains("synthetic-private-token")); + assertFalse(result.getMessage().contains("private-suffix")); + } + } finally { + server.stop(0); + } + } + + @Test public void testParseModels() { // 调用,并断言过滤无效模型 assertEquals(List.of("gpt-4o", "qwen3"), Ai1ProviderTool.parseModels( diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java index 7833e90..2f57288 100644 --- a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java @@ -4,20 +4,20 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; -import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; -import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; -import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; -import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; -import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; -import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; -import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -26,23 +26,23 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; -import org.redisson.api.RStream; import org.redisson.api.RLock; +import org.redisson.api.RStream; import org.redisson.api.RedissonClient; import org.redisson.api.stream.StreamAddArgs; import org.redisson.api.stream.StreamMessageId; import org.redisson.api.stream.StreamRangeArgs; import org.redisson.api.stream.StreamReadArgs; +import org.redisson.client.codec.Codec; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import org.redisson.client.codec.Codec; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.LinkedHashMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -111,14 +111,17 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @Test public void testStreamResult_disconnectedMustNotComplete() throws Exception { - SseEmitter emitter = mock( - SseEmitter.class); + // 准备参数 + SseEmitter emitter = mock(SseEmitter.class); + // mock 连接断开 doThrow(new IOException("client disconnected")).when(emitter).send( any(SseEmitter.SseEventBuilder.class)); + // 调用 Boolean shouldComplete = ReflectionTestUtils.invokeMethod( sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); + // 断言 assertEquals(Boolean.FALSE, shouldComplete); verifyNoInteractions(resultStream); verify(emitter, never()).complete(); @@ -126,27 +129,33 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @Test public void testStreamResult_terminalMustComplete() { - SseEmitter emitter = mock( - SseEmitter.class); + // 准备参数 + SseEmitter emitter = mock(SseEmitter.class); + // mock 结果流 when(resultStream.read(any(StreamReadArgs.class))) .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); + // 调用 Boolean shouldComplete = ReflectionTestUtils.invokeMethod( sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); + // 断言 assertEquals(Boolean.TRUE, shouldComplete); } @Test public void testSendErrorAndComplete_disconnectedMustNotComplete() throws Exception { - SseEmitter emitter = mock( - SseEmitter.class); + // 准备参数 + SseEmitter emitter = mock(SseEmitter.class); + // mock 连接断开 doThrow(new IOException("client disconnected")).when(emitter).send( any(SseEmitter.SseEventBuilder.class)); + // 调用 ReflectionTestUtils.invokeMethod( Ai1SessionStreamTool.class, "sendErrorAndComplete", emitter, "error"); + // 断言 verify(emitter, never()).complete(); } diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java index 1b13b9a..a2da13e 100644 --- a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java @@ -10,29 +10,87 @@ import org.springframework.ai.tool.ToolCallback; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; -/** - * 验证 AI1 使用的 agent-utils 工具的注册、JSON 入参和实际执行。 - */ public class Ai1SkillRuntimeCompatibilityTest { @TempDir private Path root; @Test - public void testSkillAndExecutionTools() throws Exception { + public void testSkillsTool_duplicateName() throws Exception { + // 准备参数 + Path first = Files.createDirectory(root.resolve("first")); + Path second = Files.createDirectory(root.resolve("second")); + Files.writeString(first.resolve("SKILL.md"), "---\nname: same\ndescription: first\n---\nfirst-content\n"); + Files.writeString(second.resolve("SKILL.md"), "---\nname: same\ndescription: second\n---\nsecond-content\n"); + ToolCallback callback = SkillsTool.builder().addSkillsDirectories(List.of(first.toString(), second.toString())).build(); + + // 调用 + String result = callback.call("{\"command\":\"same\"}"); + // 断言 + assertTrue(result.contains("first-content")); + assertTrue(result.contains(first.toString())); + assertFalse(result.contains("second-content")); + assertFalse(callback.getToolDefinition().description().contains("second")); + } + + @Test + public void testFileTools_pathConfinement() throws Exception { + // 准备参数 + Path nested = Files.createDirectory(root.resolve("nested")); + Files.writeString(nested.resolve("marker.txt"), "inside-marker"); + Path outside = Files.createTempDirectory(root.getParent(), "ai1-outside-"); + try { + Files.writeString(outside.resolve("private.txt"), "outside-marker"); + Files.createSymbolicLink(root.resolve("escape"), outside); + Map callbacks = callbacks(); + + // 调用,并断言目录内搜索 + for (String path : List.of("nested", nested.toString())) { + assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", path)).contains("marker.txt")); + assertTrue(call(callbacks, "Grep", Map.of("pattern", "inside-marker", "path", path)).contains("marker.txt")); + assertTrue(call(callbacks, "ListDirectory", Map.of("path", path)).contains("marker.txt")); + } + // 调用,并断言目录外搜索 + for (String path : List.of("../" + outside.getFileName(), outside.toString(), "escape")) { + assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", path)).contains("private.txt")); + assertFalse(call(callbacks, "Grep", Map.of("pattern", "outside-marker", "path", path)).contains("private.txt")); + assertFalse(call(callbacks, "ListDirectory", Map.of("path", path)).contains("private.txt")); + } + // 调用,并断言目录外文件读写 + Path privateFile = outside.resolve("private.txt"); + assertFalse(call(callbacks, "Read", Map.of("filePath", privateFile.toString())).contains("outside-marker")); + call(callbacks, "Write", Map.of("filePath", privateFile.toString(), "content", "changed")); + call(callbacks, "Edit", Map.of("filePath", root.resolve("escape/private.txt").toString(), + "old_string", "outside-marker", "new_string", "changed")); + // 断言 + assertEquals("outside-marker", Files.readString(privateFile)); + } finally { + Files.deleteIfExists(root.resolve("escape")); + Files.deleteIfExists(outside.resolve("private.txt")); + Files.deleteIfExists(outside); + } + } + + @Test + public void testTools_execute() throws Exception { + // 准备参数 Path skill = Files.createDirectory(root.resolve("probe")); Files.writeString(skill.resolve("SKILL.md"), "---\nname: probe\ndescription: 兼容性测试\n---\n执行测试脚本\n"); Files.writeString(root.resolve("probe.txt"), "compatibility-ok\n"); ToolCallback skillCallback = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); + + // 调用,并断言技能读取 assertTrue(skillCallback.getToolDefinition().inputSchema().contains("command")); assertTrue(skillCallback.call("{\"command\":\"probe\"}").contains("执行测试脚本")); + // 准备执行工具 Map callbacks = Arrays.stream(ToolCallbacks.from( ShellTools.builder().workingDirectory(root).build(), FileSystemTools.builder().allowedDirectory(root).build(), @@ -41,21 +99,27 @@ public class Ai1SkillRuntimeCompatibilityTest { ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); + // 调用,并断言命令执行和文件搜索 assertTrue(call(callbacks, "Bash", Map.of("command", "pwd", "timeout", 5000)).contains(root.toRealPath().toString())); assertTrue(call(callbacks, "Read", Map.of("filePath", root.resolve("probe.txt").toString())).contains("compatibility-ok")); assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt")).contains("probe.txt")); assertTrue(call(callbacks, "Grep", Map.of("pattern", "compatibility-ok")).contains("probe.txt")); assertTrue(call(callbacks, "ListDirectory", Map.of()).contains("probe.txt")); + // 调用 call(callbacks, "Write", Map.of("filePath", root.resolve("written.txt").toString(), "content", "before")); + // 断言 assertEquals("before", Files.readString(root.resolve("written.txt"))); + // 调用 call(callbacks, "Edit", Map.of("filePath", root.resolve("written.txt").toString(), "old_string", "before", "new_string", "after")); + // 断言 assertEquals("after", Files.readString(root.resolve("written.txt"))); - // 保留 0.12.0 的目录约束,不能因迁移而放宽文件和搜索工具访问范围。 + // 准备目录外文件 Path outside = Files.createTempFile(root.getParent(), "ai1-outside-", ".txt"); try { Files.writeString(outside, "private-marker"); + // 调用,并断言目录外访问 assertFalse(call(callbacks, "Read", Map.of("filePath", outside.toString())).contains("private-marker")); assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", root.getParent().toString())).contains(outside.getFileName().toString())); } finally { @@ -63,6 +127,17 @@ public class Ai1SkillRuntimeCompatibilityTest { } } + // ========== 工具方法 ========== + + private Map callbacks() { + return Arrays.stream(ToolCallbacks.from( + FileSystemTools.builder().allowedDirectory(root).build(), + GlobTool.builder().workingDirectory(root).allowedDirectory(root).build(), + GrepTool.builder().workingDirectory(root).allowedDirectory(root).build(), + ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) + .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); + } + private static String call(Map callbacks, String name, Map arguments) { ToolCallback callback = callbacks.get(name); assertNotNull(callback, name); diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java index f845c2a..c7fcc5f 100644 --- a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java @@ -160,6 +160,37 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); } + @Test + public void testBuildTools_duplicateFrontmatter() { + // 准备参数 + Ai1SkillDO first = new Ai1SkillDO().setId(10L).setName("first").setStatus(CommonStatusEnum.ENABLE.getStatus()); + Ai1SkillDO second = new Ai1SkillDO().setId(20L).setName("second").setStatus(CommonStatusEnum.ENABLE.getStatus()); + Ai1AgentDO agent = new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of(20L, 10L)); + // mock skillService 和 skillFileService 的方法 + when(skillService.getSkillList(agent.getSkillIds())).thenReturn(List.of(second, first)); + for (Ai1SkillDO skill : List.of(first, second)) { + String content = "---\nname: same\ndescription: " + skill.getName() + "\n---\n" + skill.getName() + "-content\n"; + when(skillFileService.getSkillFileListBySkillId(skill.getId())).thenReturn(List.of( + Ai1SkillFileDO.builder().id(skill.getId()).skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(Ai1SkillFileDO.NAME_SKILL).type(Ai1SkillFileTypeEnum.FILE.getType()).content(content).build())); + } + + // 调用 + ToolCallback tool = (ToolCallback) skillToolFactory.buildTools(agent).get(0); + String result = tool.call("{\"command\":\"same\"}"); + // 断言 + assertTrue(result.contains("first-content")); + assertTrue(result.contains(agentRoot().resolve("first").toString())); + assertFalse(result.contains("second-content")); + + // 调用 + skillToolFactory.evict(AGENT_ID); + ToolCallback rebuiltTool = (ToolCallback) skillToolFactory.buildTools(agent).get(0); + String rebuiltResult = rebuiltTool.call("{\"command\":\"same\"}"); + // 断言 + assertEquals(result, rebuiltResult); + } + // ========== 测试数据 ========== private Path agentRoot() { diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java index 58e0909..799bb05 100644 --- a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java @@ -25,6 +25,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; @@ -333,7 +334,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { public void testGetModelRespBO_success() { // mock 方法 Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()) - .setHeaders("[{\"key\":\"X-App\",\"value\":\"yudao\"}]")); + .setHeaders(Map.of("X-App", "yudao"))); when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); // mock 数据 Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId())); @@ -345,7 +346,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { assertEquals(provider.getBaseUrl(), respBO.getBaseUrl()); assertEquals(model.getModel(), respBO.getModel()); assertEquals(model.getType(), respBO.getModelType()); - assertEquals(Collections.singletonList("X-App"), convertList(respBO.getHeaders(), header -> header.get("key"))); + assertEquals(Map.of("X-App", "yudao"), respBO.getHeaders()); } // ========== 随机对象 ========== diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java index 9ab4fde..8e6bd82 100644 --- a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java @@ -14,17 +14,17 @@ import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.function.Consumer; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -52,7 +52,7 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { @Test public void testCreateProvider_success() { // 准备参数 - Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setHeaders("[{\"key\":\"X-Session\",\"value\":\"{session}\"}]")); + Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setHeaders(Map.of("X-Session", "{session}"))); // 调用 Long id = providerService.createProvider(reqVO); @@ -117,12 +117,12 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { @Test public void testTestProviderConnect_success() { // mock 数据 - Ai1ProviderDO dbProvider = randomProviderDO(o -> o.setHeaders("[{\"key\":\"X-App\",\"value\":\"yudao\"}]")); + Ai1ProviderDO dbProvider = randomProviderDO(o -> o.setHeaders(Map.of("X-App", "yudao"))); providerMapper.insert(dbProvider); // mock 方法 Ai1ProviderConnectRespVO result = randomPojo(Ai1ProviderConnectRespVO.class); when(providerTool.testConnect(eq(dbProvider.getBaseUrl()), eq(dbProvider.getApiKey()), - argThat(headers -> Collections.singletonList("X-App").equals(convertList(headers, header -> header.get("key")))))) + eq(Map.of("X-App", "yudao")))) .thenReturn(result); // 调用 @@ -131,6 +131,44 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { assertSame(result, connectResult); } + @Test + public void testCreateAndUpdateProvider_headers() { + // 准备参数 + Map headers = new LinkedHashMap<>(); + headers.put(" X-Z ", "${HEADER_TOKEN}"); + headers.put("X-A", "{session}"); + Ai1ProviderSaveReqVO req = randomProviderSaveReqVO(o -> o.setHeaders(headers)); + + // 调用 + Long id = providerService.createProvider(req); + // 断言 + Ai1ProviderDO created = providerMapper.selectById(id); + assertEquals(List.of("X-Z", "X-A"), List.copyOf(created.getHeaders().keySet())); + assertEquals("${HEADER_TOKEN}", created.getHeaders().get("X-Z")); + assertEquals("{session}", created.getHeaders().get("X-A")); + + // 准备参数 + req.setId(id).setHeaders(null); + // 调用 + providerService.updateProvider(req); + // 断言 + assertEquals(created.getHeaders(), providerMapper.selectById(id).getHeaders()); + + // 准备参数 + req.setHeaders(Map.of("X-New", "new")); + // 调用 + providerService.updateProvider(req); + // 断言 + assertEquals(req.getHeaders(), providerMapper.selectById(id).getHeaders()); + + // 准备参数 + req.setHeaders(Map.of()); + // 调用 + providerService.updateProvider(req); + // 断言 + assertEquals(Map.of(), providerMapper.selectById(id).getHeaders()); + } + // ========== 随机对象 ========== @SafeVarargs diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java new file mode 100644 index 0000000..514ba0f --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java @@ -0,0 +1,119 @@ +package cn.iocoder.yudao.module.ai1.util; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockEnvironment; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; + +public class Ai1UtilsTest { + + @Test + public void testNormalizeHeaders_orderAndValues() { + // 准备参数 + Map input = new LinkedHashMap<>(); + input.put(" X-Z ", "${TEST}"); + input.put("X-A", "{session}"); + + // 调用 + Map normalized = Ai1Utils.normalizeHeaders(input); + + // 断言 + assertInstanceOf(LinkedHashMap.class, normalized); + assertEquals(List.of("X-Z", "X-A"), List.copyOf(normalized.keySet())); + assertEquals("${TEST}", normalized.get("X-Z")); + assertEquals("{session}", normalized.get("X-A")); + assertTrue(input.containsKey(" X-Z ")); + assertNotSame(input, normalized); + } + + @Test + public void testNormalizeHeaders_invalid() { + // 准备参数 + Map duplicate = new LinkedHashMap<>(); + duplicate.put("X-Test", "static"); + duplicate.put(" x-test ", "{session}"); + Map nullValue = new LinkedHashMap<>(); + nullValue.put("X-Test", null); + Map nullKey = new LinkedHashMap<>(); + nullKey.put(null, "value"); + + // 调用,并断言异常 + for (Map input : List.of(duplicate, nullValue, nullKey, Map.of(" ", "value"))) { + assertFalse(Ai1Utils.isHeadersValid(input)); + ServiceException ex = assertThrows(ServiceException.class, () -> Ai1Utils.normalizeHeaders(input)); + assertEquals(PROVIDER_HEADERS_INVALID.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains("static")); + } + } + + @Test + public void testNormalizeHeaders_nullAndEmpty() { + // 调用,并断言 + assertNull(Ai1Utils.normalizeHeaders(null)); + assertEquals(Map.of(), Ai1Utils.normalizeHeaders(Map.of())); + assertEquals(Map.of("X-Test", ""), Ai1Utils.normalizeHeaders(Map.of("X-Test", ""))); + assertNull(Ai1Utils.resolveSpringPlaceholders((Map) null)); + } + + @Test + public void testIsHeadersValid_jsonLength() { + // 调用,并断言 + assertTrue(Ai1Utils.isHeadersValid(Map.of("X", "a".repeat(1992)))); + assertFalse(Ai1Utils.isHeadersValid(Map.of("X", "a".repeat(1993)))); + assertFalse(Ai1Utils.isHeadersValid(Map.of("X", "\"".repeat(1000)))); + } + + @Test + public void testResolveSpringPlaceholders_unresolved() { + // 准备参数 + try (MockedStatic spring = mockStatic(SpringUtil.class)) { + spring.when(() -> SpringUtil.getBean(Environment.class)).thenReturn(new MockEnvironment()); + // 调用,并断言异常 + for (String value : List.of("literal-private-api-key-${MISSING}", "https://private-token@host/${MISSING}")) { + ServiceException ex = assertThrows(ServiceException.class, () -> Ai1Utils.resolveSpringPlaceholders(value)); + assertEquals(CONFIG_PLACEHOLDER_NOT_RESOLVED.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains("private")); + assertFalse(ex.getMessage().contains("Header")); + assertFalse(ex.getMessage().contains(value)); + } + ServiceException env = assertThrows(ServiceException.class, () -> + Ai1Utils.resolveSpringPlaceholders(Map.of("MCP_TOKEN", "private-env-${MISSING}"))); + assertFalse(env.getMessage().contains("Header")); + assertFalse(env.getMessage().contains("private-env")); + } + } + + @Test + public void testResolveSpringPlaceholders_map() { + // 准备参数 + try (MockedStatic spring = mockStatic(SpringUtil.class)) { + spring.when(() -> SpringUtil.getBean(Environment.class)) + .thenReturn(new MockEnvironment().withProperty("HEADER_TEST", "a\"b\\c")); + Map input = new LinkedHashMap<>(); + input.put("X-Z", "${HEADER_TEST}-{session}"); + input.put("X-A", "unchanged"); + + // 调用 + Map resolved = Ai1Utils.resolveSpringPlaceholders(input); + + // 断言 + assertEquals("a\"b\\c-{session}", resolved.get("X-Z")); + assertEquals(List.of("X-Z", "X-A"), List.copyOf(resolved.keySet())); + assertEquals("${HEADER_TEST}-{session}", input.get("X-Z")); + ServiceException ex = assertThrows(ServiceException.class, () -> + Ai1Utils.resolveSpringPlaceholders(Map.of("X-Test", "literal-secret-${MISSING_HEADER_TEST}"))); + assertEquals(CONFIG_PLACEHOLDER_NOT_RESOLVED.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains("literal-secret")); + } + } +} -- libgit2 0.21.4