Commit 7c397bbfae3e768b26e3ee962c5aa7064e8d7c96

Authored by YunaiV
1 parent c1bddc86

feat(ai1): 同步 XXL-AI Cloud 增量,优化请求 Header、技能注册和首页图表

- 前后端请求 Header 统一为 Map 对象,保留顺序、逐值 ENV 解析及会话占位符,支持重复校验和空对象清空
- 升级 spring-ai-agent-utils 至 0.13.0,固定技能注册顺序,明确同名技能首次注册行为
- 避免占位符解析和供应商 HTTP 探测异常回显配置、密钥原文
- 首页图表关闭动画,支持容器尺寸变化、keepalive 恢复及卸载释放
- 补充模型、Header 和技能执行回归测试,统一 AI1 单测代码风格

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