编程导航Java话题讨论

Java

2.2k 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

厦门国投智能 java AI应用开发 面试题

1.面向对象的几大特征 2.jdk8的lambda的核心特点 3.简单字符拼接和使用append方式拼接的区别 4.面向接口和面向切面编程的区别 5.Spring框架的depend on的注解作用 6.@value注解和@Autowired的区别 7.并发编程的三大特性 8.Spring boot的启动顺序 9.SpringBoot框架的核心配置文件 10.单表千万级记录的情况下,在SQL中分页查询如何优化? 11.根据题目要求写出对应linux运维命令: (1)查询java进程 (2)压缩文件到tar.gz目录 (3)上传文件到远程 (4)访问接口 12.查询数组中频率最高的几个数字,用什么算法?(请写出两种方案)

厦门绩牛科技 AI应用开发一面

1.自我介绍 2.简述下RAG的基本流程 3.如何评估RAG生成的准确度 4.SpringCloud框架有哪些组件 5.SpringCloud线程池有哪几个线程池 6.公司项目的SpringCloud框架是怎么样的 7.SpringCloud框架微服务之间是怎么沟通的 8.平时开发RAG和AI智能体用到哪些工具和软件,具体说说你是如何运用的 9.公司未来想要开发生图生视频的AI软件,你是走那个模型渠道 10.在高负载,高并发的场景和不同业务下,你觉得最适合接入哪些模型,你会做什么样的优化? 11.你的AI生成前端代码项目,AI生成的代码,你是怎么去规范它的质量?如何保证AI生成的开发质量? 12.你对公司还有什么问题嘛

装饰器模式解决 AI 调用中间过程日志方案记录

# AI 调用中间过程日志记录方案 > 解决"日志只能看到用户发起的请求与最终返回,看不到中间 RAG 检索与 Tool 调用过程"的可观测性问题。 ## 1. 问题背景 项目使用自定义 `MyLoggerAdvisor`(实现 `CallAdvisor` + `StreamAdvisor`)记录对话日志,但实际运行发现: - ✅ 能看到:用户发起的信息(`Ai Request`) - ✅ 能看到:模型最终返回(`Ai Response`) - ❌ 看不到:中间 RAG 检索的查询文本、命中文档 - ❌ 看不到:中间 Tool 调用的入参、执行结果、耗时 ## 2. 根因分析 | 环节 | 发生位置 | 为何日志不可见 | | --- | --- | --- | | RAG 检索 | `QuestionAnswerAdvisor.before()` 内部 | Advisor 链只记录外层请求/响应,检索发生在顾问内部,`MyLoggerAdvisor` 无法感知 | | 云端检索 | `RetrievalAugmentationAdvisor` 内部 | 同上,检索逻辑被封装在顾问内部 | | Tool 调用循环 | `DefaultChatClient` 内部(`toolCallingManager` 执行循环) | 工具循环在 Advisor 链之外执行,根本不进入 Advisor 拦截点 | 结论:**Advisor 拦截点天然覆盖不到"请求与响应之间的中间过程"**,需要另寻挂载点。 ## 3. 方案对比与选型 | 方案 | 思路 | 优点 | 缺点 | | --- | --- | --- | --- | | A. 装饰器模式 | 包装 `ToolCallback` / `VectorStore` / `DocumentRetriever`,转发前后打日志 | 不改核心逻辑、零侵入、可观测点精准 | 需手动接线(3 处) | | B. Micrometer Observation | 注册 ObservationHandler 监听 AI 调用事件 | 官方机制、覆盖全 | 只能看到模型调用级别事件,仍看不到工具入参/检索命中等细节 | | C. Debug 日志开关 | 打开 Spring AI 内部 debug 日志 | 零代码 | 日志量爆炸、格式不可控、易淹没关键信息 | **选型结论:方案 A(装饰器模式)** —— 在不改变原有逻辑的前提下,把三个关键组件各包一层"日志外衣",精确输出中间过程。 ## 4. 实现方案:装饰器模式 ### 4.1 日志链路总览 ``` MyLoggerAdvisor(外层 请求/响应) │ ├── QuestionAnswerAdvisor(本地 RAG) │ └── LoggingVectorStore ──► PgVectorStore ([向量检索] 查询/命中/耗时) │ ├── RetrievalAugmentationAdvisor(云端 RAG) │ └── LoggingDocumentRetriever ──► DashScopeDocumentRetriever ([云端检索] 查询/命中/耗时) │ └── Tool 调用循环(ChatClient 内部) └── LoggingToolCallback ──► 原始 ToolCallback ([Tool调用] 入参/结果/耗时) ``` ### 4.2 装饰器一:LoggingToolCallback(工具调用日志) **文件**:`src/main/java/com/xiaokai/kimoaiagent/tools/LoggingToolCallback.java` 实现 `ToolCallback` 接口,转发全部方法,仅在核心的 `call()` 前后记录日志: - 调用前:`[Tool调用] 工具: {}, 入参: {}` - 成功后:`[Tool调用] 工具: {}, 执行成功, 耗时: {}ms, 结果: {}` - 失败后:`[Tool调用] 工具: {}, 执行失败, 耗时: {}ms, 错误: {}`(记录后继续抛出) ```java @Slf4j public class LoggingToolCallback implements ToolCallback { /** 被包装的原始工具回调,负责实际执行工具逻辑 */ private final ToolCallback delegate; public LoggingToolCallback(ToolCallback delegate) { this.delegate = delegate; } @Override public ToolDefinition getToolDefinition() { return delegate.getToolDefinition(); } @Override public ToolMetadata getToolMetadata() { return delegate.getToolMetadata(); } @Override public String call(String toolInput) { return this.call(toolInput, null); } @Override public String call(String toolInput, ToolContext toolContext) { String toolName = delegate.getToolDefinition().name(); long startTime = System.currentTimeMillis(); // 记录工具调用开始信息 log.info("[Tool调用] 工具: {}, 入参: {}", toolName, toolInput); try { // 根据是否携带上下文选择对应的执行入口 String result = (toolContext == null) ? delegate.call(toolInput) : delegate.call(toolInput, toolContext); // 记录工具调用成功信息及耗时 log.info("[Tool调用] 工具: {}, 执行成功, 耗时: {}ms, 结果: {}", toolName, System.currentTimeMillis() - startTime, result); return result; } catch (Exception e) { // 记录工具调用失败信息及耗时 log.error("[Tool调用] 工具: {}, 执行失败, 耗时: {}ms, 错误: {}", toolName, System.currentTimeMillis() - startTime, e.getMessage(), e); throw e; } } } ``` ### 4.3 装饰器二:LoggingVectorStore(本地向量库日志) **文件**:`src/main/java/com/xiaokai/kimoaiagent/rag/LoggingVectorStore.java` 实现 `VectorStore` 接口,覆盖全部操作: - `add`:`[向量库] 写入文档: {} 篇, 耗时: {}ms` - `delete(List)`:`[向量库] 按ID删除文档: {} 篇, 耗时: {}ms` - `delete(Expression)`:`[向量库] 按过滤条件删除文档, 过滤条件: {}, 耗时: {}ms` - `similaritySearch`:`[向量检索] 查询: {}, topK: {}, 阈值: {}, 命中: {} 篇, 耗时: {}ms` + 逐条输出命中文档 `id/score/内容摘要` ```java @Slf4j public class LoggingVectorStore implements VectorStore { /** 被包装的原始向量存储,负责实际执行文档读写与检索 */ private final VectorStore delegate; public LoggingVectorStore(VectorStore delegate) { this.delegate = delegate; } @Override public void add(List<Document> documents) { long startTime = System.currentTimeMillis(); delegate.add(documents); // 记录文档写入数量与耗时 log.info("[向量库] 写入文档: {} 篇, 耗时: {}ms", documents.size(), System.currentTimeMillis() - startTime); } @Override public void delete(List<String> idList) { long startTime = System.currentTimeMillis(); delegate.delete(idList); // 记录文档删除数量与耗时 log.info("[向量库] 按ID删除文档: {} 篇, 耗时: {}ms", idList.size(), System.currentTimeMillis() - startTime); } @Override public void delete(Filter.Expression filterExpression) { long startTime = System.currentTimeMillis(); delegate.delete(filterExpression); // 记录过滤删除操作与耗时 log.info("[向量库] 按过滤条件删除文档, 过滤条件: {}, 耗时: {}ms", filterExpression, System.currentTimeMillis() - startTime); } @Override public List<Document> similaritySearch(SearchRequest request) { long startTime = System.currentTimeMillis(); List<Document> documents = delegate.similaritySearch(request); // 记录检索请求与命中数量 log.info("[向量检索] 查询: {}, topK: {}, 阈值: {}, 命中: {} 篇, 耗时: {}ms", request.getQuery(), request.getTopK(), request.getSimilarityThreshold(), documents.size(), System.currentTimeMillis() - startTime); // 逐条输出命中文档的 ID、相似度分数与内容摘要 for (Document document : documents) { log.info("[向量检索] 命中文档 - id: {}, score: {}, 内容: {}", document.getId(), document.getScore(), truncate(document.getText(), 200)); } return documents; } /** * 截断长文本,保留前指定长度的字符用于日志输出 */ private String truncate(String text, int maxLength) { if (text == null || text.length() <= maxLength) { return text; } return text.substring(0, maxLength) + "..."; } } ``` ### 4.4 装饰器三:LoggingDocumentRetriever(云端检索日志) **文件**:`src/main/java/com/xiaokai/kimoaiagent/rag/LoggingDocumentRetriever.java` 实现 `DocumentRetriever` 接口(`Function<Query, List<Document>>` 的语义接口),仅包装核心 `retrieve()`: - `[云端检索] 查询: {}, 命中: {} 篇, 耗时: {}ms` - 逐条输出命中文档 `id/内容摘要` ```java @Slf4j public class LoggingDocumentRetriever implements DocumentRetriever { /** 被包装的原始文档检索器,负责实际执行检索逻辑 */ private final DocumentRetriever delegate; public LoggingDocumentRetriever(DocumentRetriever delegate) { this.delegate = delegate; } @Override public List<Document> retrieve(Query query) { long startTime = System.currentTimeMillis(); List<Document> documents = delegate.retrieve(query); // 记录检索查询与命中数量 log.info("[云端检索] 查询: {}, 命中: {} 篇, 耗时: {}ms", query.text(), documents.size(), System.currentTimeMillis() - startTime); // 逐条输出命中文档的 ID 与内容摘要 for (Document document : documents) { log.info("[云端检索] 命中文档 - id: {}, 内容: {}", document.getId(), truncate(document.getText(), 200)); } return documents; } /** 截断长文本,保留前指定长度的字符用于日志输出 */ private String truncate(String text, int maxLength) { if (text == null || text.length() <= maxLength) { return text; } return text.substring(0, maxLength) + "..."; } } ``` ### 4.5 接线配置(三处) **① 工具回调接线** —— `src/test/java/com/xiaokai/kimoaiagent/tools/ToolRegistration.java` 在 `allTools()` 中,将 `ToolCallbacks.from(...)` 生成的数组整体包装为 `LoggingToolCallback`: ```java // 将工具实例统一包装为 ToolCallback 数组 ToolCallback[] toolCallbacks = ToolCallbacks.from( fileOperationTool, webSearchTool, webScrapingTool, resourceDownloadTool, terminalOperationTool, pdfGenerationTool ); // 使用日志装饰器包装全部工具回调,记录每次工具调用的入参与执行结果 return Arrays.stream(toolCallbacks) .map(LoggingToolCallback::new) .toArray(ToolCallback[]::new); ``` > 注意:此配置类位于 `src/test/java` 目录,是当前测试链路中的工具注册中心。 **② 本地向量库接线** —— `src/main/java/com/xiaokai/kimoaiagent/rag/PgVectorVectorStoreConfig.java` 先构建 `PgVectorStore`,再包一层 `LoggingVectorStore` 返回: ```java @Bean public VectorStore pgVectorVectorStore(@Qualifier("pgJdbcTemplate") JdbcTemplate pgJdbcTemplate, EmbeddingModel dashscopeEmbeddingModel) { // 构建向量存储:1024 维向量 + 余弦距离 + HNSW 索引,并自动初始化 schema 与向量表 PgVectorStore pgVectorStore = PgVectorStore.builder(pgJdbcTemplate, dashscopeEmbeddingModel) .dimensions(1024) .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE) .indexType(PgVectorStore.PgIndexType.HNSW) .initializeSchema(true) .schemaName("public") .vectorTableName("vector_store") .maxDocumentBatchSize(10000) .build(); // 使用日志装饰器包装向量存储,记录文档写入与相似度检索过程 return new LoggingVectorStore(pgVectorStore); } ``` **③ 云端检索接线** —— `src/main/java/com/xiaokai/kimoaiagent/rag/LoveAppRagCloudAdvisorConfig.java` 构建 `DashScopeDocumentRetriever` 时套上 `LoggingDocumentRetriever`: ```java // 构建文档检索器:基于 DashScope 云端知识库,按索引名绑定恋爱大师知识库 DocumentRetriever documentRetriever = new LoggingDocumentRetriever(new DashScopeDocumentRetriever(dashScopeApi, DashScopeDocumentRetrieverOptions.builder().withIndexName(KNOWLEDGE_INDEX).build())); ``` ## 5. 日志效果示例 ``` Ai Request: ChatClientRequest[prompt=...] ← MyLoggerAdvisor(原有) [向量检索] 查询: 婚后关系不亲密怎么办, topK: 5, 阈值: 0.0, 命中: 3 篇, 耗时: 45ms [向量检索] 命中文档 - id: 550e8400-e29b-41d4-a716-446655440000, score: 0.82, 内容: 婚后亲密关系维护建议... [云端检索] 查询: 婚后关系不亲密怎么办, 命中: 2 篇, 耗时: 210ms [云端检索] 命中文档 - id: xxx, 内容: 婚姻保鲜的心理学方法... [Tool调用] 工具: webSearchTool, 入参: {"query":"星空情侣壁纸"} [Tool调用] 工具: webSearchTool, 执行成功, 耗时: 1200ms, 结果: {...} [Tool调用] 工具: resourceDownloadTool, 入参: {"url":"https://..."} [Tool调用] 工具: resourceDownloadTool, 执行成功, 耗时: 890ms, 结果: 图片已保存至... Ai Response: ChatClientResponse[...] ← MyLoggerAdvisor(原有) ``` ## 6. 相关文件清单 | 类型 | 文件路径 | 说明 | | --- | --- | --- | | 新建 | `src/main/java/com/xiaokai/kimoaiagent/tools/LoggingToolCallback.java` | 工具调用日志装饰器 | | 新建 | `src/main/java/com/xiaokai/kimoaiagent/rag/LoggingVectorStore.java` | 本地向量库日志装饰器 | | 新建 | `src/main/java/com/xiaokai/kimoaiagent/rag/LoggingDocumentRetriever.java` | 云端检索日志装饰器 | | 修改 | `src/test/java/com/xiaokai/kimoaiagent/tools/ToolRegistration.java` | 包装全部工具回调 | | 修改 | `src/main/java/com/xiaokai/kimoaiagent/rag/PgVectorVectorStoreConfig.java` | 包装 PgVectorStore | | 修改 | `src/main/java/com/xiaokai/kimoaiagent/rag/LoveAppRagCloudAdvisorConfig.java` | 包装 DashScopeDocumentRetriever | | 未改 | `src/main/java/com/xiaokai/kimoaiagent/advisor/MyLoggerAdvisor.java` | 外层请求/响应日志(保留原样) | ## 7. 可选延伸 若还想看**模型在工具循环中每一轮的完整 prompt**(比 Tool 调用日志更细一层),可再包一层 `ChatModel` 装饰器:实现 `ChatModel` 接口,包装 `DashScopeChatModel`,在 `call(Prompt)` 前后记录每轮完整请求与响应。当前方案已满足"入参/结果/耗时"级别的可观测性,是否需要更细粒度取决于后续排查需求。

LeetCode 1 两数之和

# LeetCode 1 两数之和 LeetCode 1 两数之和,哈希表入门题,顺便记几个容易踩的坑。 --- ## 题目 🟢 [两数之和](https://leetcode.cn/problems/two-sum/) > 数组里找两个数加起来等于 target,返回下标。只有唯一解,同一个元素不能用两次。 ``` nums = [3, 2, 4], target = 6 → [1, 2] nums = [2, 7, 11, 15], target = 9 → [0, 1] ``` --- ## 暴力做法 第一反应肯定是两层循环: ```java for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (nums[i] + nums[j] == target) { return new int[]{i, j}; } } } ``` 这玩意 LeetCode 上能过,测试数据没卡那么死。但实际上 O(n²),n 到 10⁴ 就是 5000 万次比较,换个语言或者数据再大一点就炸了。 慢在哪?内层循环就是一句话:**对于当前数,去后面找 target - 当前数**。这个"找"是 O(n) 的——每次都要把剩下的扫一遍。 --- ## 换 HashMap 换个问法:遍历的时候,每看到一个数 x,就问一句——**"target - x 之前出现过吗?"** 用 HashMap 把之前见过的数都记下来(值 → 下标),查一次 O(1)。边扫边记边查,一趟完事。 拿 `nums = [3, 2, 4], target = 6` 跑一下: ``` 开始,map 空的 i=0,值是 3 需要 6-3=3,map 里没有 → 把自己存进去 {3:0} i=1,值是 2 需要 6-2=4,map={3:0},没有 4 → 存进去 {3:0, 2:1} i=2,值是 4 需要 6-4=2,map 里有!下标是 1 → 返回 [1, 2] ``` 代码: ```java public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int need = target - nums[i]; if (map.containsKey(need)) { return new int[]{map.get(need), i}; } map.put(nums[i], i); } return new int[]{-1, -1}; // 题目说了一定有解,走不到这 } ``` 时间 O(n),空间 O(n)(运气不好要存到最后两个才找到)。 --- ## 踩过的坑 ### 先存后查,自己配自己 最开始我是这么写的: ```java map.put(nums[i], i); // 先存 if (map.containsKey(target - nums[i])) { ... } // 再查 ``` `nums = [3, 2, 4], target = 6` 跑过了,`[3, 3], target = 6` 也跑过了,以为没问题。后来碰到一个 case 才发现——i=0 时 `target - 3 = 3`,刚把自己存进去马上查,map 里当然有 3,直接返回 `[0, 0]`。 就是顺序问题,**先查后存**就行。先看历史记录里有没有我要的,没有的话再把自己写进历史,留给后面的人用。 ### 重复元素把下标覆盖了 `nums = [3, 3], target = 6`。第二个 3 来的时候,如果先存后查,put 会覆盖掉第一个 3 的下标,map 从 `{3:0}` 变成 `{3:1}`。好在题目保证只有唯一解,但下标变了总归不舒服。 先查后存刚好绕开——第二个 3 来的时候,查的是覆盖之前的 map(里面还是 `{3:0}`),直接返回 `[0, 1]`,根本不会走到 put 那一步。 ### containsKey 写成了 contains ```java if (map.contains(need)) { ... } // 错! ``` `contains` 是 `ArrayList` 的,HashMap 里也有但它是查 value 的(O(n)),不是查 key。LeetCode 不会报错,但语义完全不对,运气不好还会超时。 ### 最后那行 return ```java return new int[]{-1, -1}; ``` Java 编译器不管你逻辑上能不能走到这,方法签名的每一条分支都必须有 return。不写直接编译报错 `missing return statement`。写个 `{-1, -1}` 兜底就行,反正题目保证有解。 ### 值范围已知时可以用数组 `Map<Integer, Integer>` 涉及 int 和 Integer 之间的装箱拆箱,n 不大的时候无所谓,但面试官如果问"还能更快吗"——如果题目给了值范围(比如 1~1000),直接用 `int[]` 替代 HashMap: ```java int[] index = new int[1001]; Arrays.fill(index, -1); for (int i = 0; i < nums.length; i++) { int need = target - nums[i]; if (need >= 0 && need <= 1000 && index[need] != -1) { return new int[]{index[need], i}; } index[nums[i]] = i; } ``` 省了 hash 计算和自动装箱,常数会小很多。但这题没给范围,老老实实用 HashMap。 --- ## 复杂度 暴力:比较次数 = (n-1) + (n-2) + ... + 1 = n(n-1)/2 → O(n²),空间 O(1)。 HashMap:遍历 n 次,每次 containsKey + put 均摊 O(1) → O(n),空间 O(n)。 严格来说 Java 的 HashMap 极端情况下(所有 key 哈希碰撞)会退化成链表,单次操作 O(n)。但 Integer 的 hashCode 就是它自己,除非故意构造,正常数据不会撞。说"期望 O(n)"就行。 --- ## 为什么不排序 + 双指针 LeetCode 167(两数之和 II)是排好序的数组,双指针 O(n) + O(1) 空间,很漂亮。 但这题没排序。如果先排序再双指针:排序 O(n log n),还得额外记原始下标(pair 数组存值和索引),总开销 O(n log n) 时间 + O(n) 空间,比 HashMap 慢还啰嗦。 只有一种情况排序双指针更优——题目不要求返回下标,只返回值,并且要求 O(1) 空间。这题要下标,排序就没优势了。 ---

RAG 知识库文档重复入库问题解决方案 - 最新spring ai 1.1.2

# RAG 知识库文档重复入库问题解决方案 > 适用版本:Spring AI 1.1.2 / Spring Boot 3.5.3 > 关键词:增量更新、文档指纹、PgVectorStore、幂等入库 --- ## 一、问题背景 ### 1.1 现象 应用每次启动后,`resources/document/` 下的 Markdown 文档会被**全量重新读取、增强并写入** PostgreSQL pgvector 的 `vector_store` 表,导致: - 启动 N 次,表中出现 N 份内容完全相同的记录; - 相似度检索时 `topK` 结果被重复文档挤占,RAG 回答质量下降; - 每次启动都会对全量文档调用大模型生成关键词/摘要,造成无谓的 API 费用。 ### 1.2 原实现(问题代码) ```java @Configuration public class PgVectorVectorStoreConfig { @Resource private LoveAppDocumentLoader loveAppDocumentLoader; @Resource private MyDocumentEnricher myDocumentEnricher; @Bean public VectorStore pgVectorVectorStore(...) { PgVectorStore vectorStore = PgVectorStore.builder(...) .initializeSchema(true) // 仅建表,不清数据 .build(); // 每次启动都全量加载 + 增强 + 插入 List<Document> documents = loveAppDocumentLoader.loadDocuments(); List<Document> docsByKeyword = myDocumentEnricher.enrichDocumentsByKeyword(documents); List<Document> docsBySummary = myDocumentEnricher.enrichDocumentsBySummary(docsByKeyword); for (int i = 0; i < docsBySummary.size(); i += 10) { vectorStore.add(docsBySummary.subList(i, ...)); } return vectorStore; } } ``` ### 1.3 根因分析 | 因素 | 说明 | | --- | --- | | 入库时机错误 | 文档加载/入库逻辑写在 `@Bean` 方法中,Spring 每次启动创建 Bean 时必然执行 | | 无去重机制 | `PgVectorStore.add()` 是纯 INSERT,不检查内容是否已存在 | | 表数据持久化 | `initializeSchema(true)` 只在表**不存在**时建表,不会清空旧数据 | | Document ID 随机 | 未显式设置 ID 时每次生成新 UUID,插入永不冲突、只增不减 | --- ## 二、解决方案:指纹增量更新 ### 2.1 设计思路 1. **职责分离**:`@Bean` 只负责构建 `PgVectorStore`;文档加载/增强/入库独立到 `ApplicationRunner` 中执行; 2. **指纹去重**:以「文件名 + 内容」的确定性 UUID 作为 `Document` 唯一 ID(内容一致 → ID 稳定;内容变更 → 新 ID); 3. **增量比对**:启动时查询库中已有 ID 集合,仅对「新增/变更」文档执行大模型增强与入库; 4. **幂等写入**:Spring AI `PgVectorStore` 对相同 ID 执行 upsert(`ON CONFLICT (id) DO UPDATE`),天然幂等; 5. **可选清理**:开启 `app.rag.remove-orphans` 后,删除知识库中已不存在文档的残留向量。 ### 2.2 架构对比 ```mermaid graph TB subgraph 改造前 A1[启动] --> B1[pgVectorVectorStore Bean 方法] B1 --> C1[全量加载文档] C1 --> D1[全量大模型增强] D1 --> E1[全量 INSERT → 冗余累积] end subgraph 改造后 A2[启动] --> B2[pgVectorVectorStore Bean 仅构建存储] A2 --> C2[DocumentIngestionRunner 增量入库] C2 --> D2[加载文档 + 计算指纹 ID] D2 --> E2[比对库中已有 ID] E2 -->|新增/变更| F2[仅对增量调用大模型增强] F2 --> G2[分批 add → upsert 幂等写入] E2 -->|未变更| H2[直接跳过] E2 -->|remove-orphans| I2[清理残留向量] end ``` ### 2.3 改动文件清单 | 文件 | 改动 | | --- | --- | | `PgVectorVectorStoreConfig.java` | `@Bean` 移除加载/增强/入库逻辑,只构建存储 | | `DocumentIngestionRunner.java` | **新增**,`ApplicationRunner` 实现指纹增量入库 | | `LoveAppVectorStoreConfig.java` | 内存向量存储 Bean 加 `@Lazy`,避免启动时白调大模型 | | `application.yaml` | 新增 `app.rag.ingest-on-startup` / `app.rag.remove-orphans` 开关 | --- ## 三、核心代码 ### 3.1 配置类(只构建存储) ```java @Bean public VectorStore pgVectorVectorStore(@Qualifier("pgJdbcTemplate") JdbcTemplate pgJdbcTemplate, EmbeddingModel dashscopeEmbeddingModel) { // 构建向量存储:1024 维(对齐 text-embedding-v3)、余弦距离、HNSW 索引、自动建表 return PgVectorStore.builder(pgJdbcTemplate, dashscopeEmbeddingModel) .dimensions(1024) .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE) .indexType(PgVectorStore.PgIndexType.HNSW) .initializeSchema(true) .schemaName("public") .vectorTableName("vector_store") .maxDocumentBatchSize(10000) .build(); } ``` ### 3.2 增量入库启动器(核心逻辑) ```java @Override public void run(ApplicationArguments args) { if (!ingestOnStartup) { log.info("文档自动入库已关闭(app.rag.ingest-on-startup=false),跳过增量同步"); return; } try { // 1. 加载并切分知识库文档 List<Document> documents = loveAppDocumentLoader.loadDocuments(); if (documents.isEmpty()) { log.info("未读取到任何知识库文档,跳过增量同步"); return; } // 2. 为每篇文档计算指纹 ID(文件名 + 内容哈希),内容一旦变更即生成新 ID documents = documents.stream() .map(doc -> new Document(computeFingerprint(doc), doc.getText(), doc.getMetadata())) .toList(); // 3. 查询向量库中已存在的文档指纹 ID Set<String> existingIds = new HashSet<>( pgJdbcTemplate.queryForList("SELECT id FROM public.vector_store", String.class)); // 4. 筛选出新增或内容变更的文档,仅对这部分执行增强与入库 List<Document> toAdd = documents.stream() .filter(doc -> !existingIds.contains(doc.getId())) .toList(); if (!toAdd.isEmpty()) { // 5. 仅对新增文档调用大模型补充关键词与摘要元数据 List<Document> enriched = myDocumentEnricher.enrichDocumentsByKeyword(toAdd); enriched = myDocumentEnricher.enrichDocumentsBySummary(enriched); // 6. 分批写入向量库:相同 ID 走 upsert,重复启动不会产生冗余数据 for (int i = 0; i < enriched.size(); i += BATCH_SIZE) { int end = Math.min(i + BATCH_SIZE, enriched.size()); pgVectorVectorStore.add(enriched.subList(i, end)); } log.info("文档增量同步完成:加载 {} 篇,新增/更新 {} 篇", documents.size(), enriched.size()); } else { log.info("文档增量同步完成:加载 {} 篇,无新增文档", documents.size()); } // 7. 按配置清理已从知识库移除文档的残留向量 if (removeOrphans) { removeOrphanDocuments(existingIds, documents); } } catch (Exception e) { log.error("文档增量入库失败:{}", e.getMessage(), e); } } ``` ### 3.3 指纹生成(关键) ```java /** * 计算文档指纹 ID:基于文件名与文本内容的确定性 UUID(nameUUIDFromBytes 内部使用 MD5)。 * 内容一致时指纹稳定,内容变更时生成新指纹,从而支撑增量幂等入库。 * 指纹需为标准 UUID 格式,以满足 PgVectorStore 对文档 ID 的 UUID 解析要求。 */ private String computeFingerprint(Document document) { String filename = String.valueOf(document.getMetadata().getOrDefault("filename", "")); String raw = filename + "|" + document.getText(); return UUID.nameUUIDFromBytes(raw.getBytes(StandardCharsets.UTF_8)).toString(); } ``` ### 3.4 孤儿向量清理 ```java private void removeOrphanDocuments(Set<String> existingIds, List<Document> documents) { Set<String> localIds = new HashSet<>(); documents.forEach(doc -> localIds.add(doc.getId())); List<String> orphans = new ArrayList<>(); existingIds.forEach(id -> { if (!localIds.contains(id)) { orphans.add(id); } }); if (!orphans.isEmpty()) { pgVectorVectorStore.delete(orphans); log.info("已清理知识库中已不存在文档的残留向量 {} 条", orphans.size()); } } ``` --- ## 四、关键坑:Document ID 必须是标准 UUID 格式 ### 4.1 现象 启动时报错: ``` 文档增量入库失败:Invalid UUID string: 5bf225c4953dc43ce27e07c433da68ef java.lang.IllegalArgumentException: Invalid UUID string at java.util.UUID.fromString(UUID.java:260) at org.springframework.ai.vectorstore.pgvector.PgVectorStore.convertIdToPgType(PgVectorStore.java:318) ``` ### 4.2 原因 Spring AI 1.1.2 的 `PgVectorStore` 在写入时会调用 `UUID.fromString(documentId)` 将文档 ID **强制解析为 UUID**。若直接使用 32 位无连字符的 MD5 hex 字符串作为 ID,解析失败抛异常。 ### 4.3 结论 - 指纹必须为标准 UUID 格式(`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`); - 推荐 `UUID.nameUUIDFromBytes(...)`:内部基于 MD5 的确定性 UUID(v3),既保证幂等,又满足格式要求; - 若必须自定义 ID 格式,需确认所用 `PgVectorStore` 版本的 `convertIdToPgType` 实现是否带格式回退。 --- ## 五、配置说明(application.yaml) ```yaml # 知识库文档增量入库配置 app: rag: # 启动时是否执行知识库文档增量入库(按文档指纹比对,仅入库新增/变更的文档) ingest-on-startup: true # 是否清理已从知识库移除文档的残留向量 remove-orphans: false ``` | 配置项 | 默认值 | 说明 | | --- | --- | --- | | `app.rag.ingest-on-startup` | `true` | 关闭后启动不再执行任何文档入库 | | `app.rag.remove-orphans` | `false` | 开启后删除库中存在但知识库中已不存在的向量;默认关闭防止加载异常时误删数据 | --- ## 六、行为对比与验证 ### 6.1 行为对比 | 场景 | 改造前 | 改造后 | | --- | --- | --- | | 重复启动 N 次 | 全量重插,冗余 N 份 | 仅首次入库,后续零写入 | | 修改知识库文档 | 再插一份旧版残留 | 新指纹入库,旧版按配置可清理 | | 删除知识库文档 | 旧向量永久残留 | `remove-orphans: true` 时自动清理 | | 大模型 API 消耗 | 每次启动全量增强 | 仅对新增/变更文档增强 | ### 6.2 验证方式 ```sql -- 统计各文档重复条数(改造前可观测到 N 份相同内容) SELECT id, count(*) FROM public.vector_store GROUP BY id HAVING count(*) > 1; -- 清理历史冗余后重启应用,观察日志 -- 首次启动:文档增量同步完成:加载 37 篇,新增/更新 37 篇 -- 二次启动:文档增量同步完成:加载 37 篇,无新增文档 ``` ### 6.3 首次上线注意事项 数据库中存在历史重复数据时,先手动清空一次,再进入增量模式: ```sql DELETE FROM public.vector_store; ``` --- ## 七、相关文件 | 文件 | 职责 | | --- | --- | | `DocumentIngestionRunner.java` | 增量入库启动器(指纹比对、增量增强、分批 upsert、孤儿清理) | | `PgVectorVectorStoreConfig.java` | 仅构建 `PgVectorStore` Bean | | `LoveAppDocumentLoader.java` | 加载 `classpath:document/*.md` 并按水平线切分(保留) | | `MyDocumentEnricher.java` | 大模型生成关键词与摘要元数据(保留) | | `LoveAppVectorStoreConfig.java` | 内存向量存储,`@Lazy` 延迟初始化 | | `application.yaml` | `app.rag.*` 增量入库开关 |

Ai-Agent对话记忆引入 Redis 缓存的必要性分析

# 对话记忆引入 Redis 缓存的必要性分析 > 适用项目:恋爱大师ai-agent(Spring Boot 3.5.3 / JDK 21 / Spring AI 1.1.2 / MySQL 8.0.33 / DashScope) > 场景:用户数量多、并发压力大时,每次对话都读写数据库,是否会造成数据库压力过大?是否有必要引入 Redis? ## 一、结论 **当前阶段没有必要引入 Redis。** 对话记忆的数据库读写开销在整体对话链路中占比不足 0.5%,真正的瓶颈是大模型 API 调用本身(秒级),而非数据库(毫秒级)。 引入 Redis 会增加缓存一致性、失效策略、多实例同步等复杂性,收益极低。建议在出现以下任一信号后再考虑迁移: - 多实例水平部署(3 个以上实例同时直连数据库) - 会话量达到百万级,`SPRING_AI_CHAT_MEMORY` 表明显膨胀 - 监控发现聊天记忆表写入成为系统瓶颈 ## 二、当前实现的实际数据库开销 ### 2.1 每次对话的读写链路 基于 Spring AI 1.1.2 源码,一次对话由 `MessageChatMemoryAdvisor` 触发完整读写: ``` 对话请求 ├── before:chatMemory.get(conversationId) │ └── JdbcChatMemoryRepository.findByConversationId() → 1 次 SELECT └── after:chatMemory.add(conversationId, messages) ├── MessageWindowChatMemory.add() 内部 │ ├── findByConversationId() → 1 次 SELECT │ └── saveAll() 快照写入(事务内) │ ├── deleteByConversationId() → 1 次 DELETE │ └── batchUpdate(INSERT ...) → N 条批量 INSERT ``` 即每次对话约 **2 次 SELECT + 1 次 DELETE + 1 次批量 INSERT**,全部走 `TransactionTemplate` 事务。 ### 2.2 开销量化对比 | 对比项 | 量级 | 说明 | |---|---|---| | 单次对话数据库耗时 | 约 5~10 ms | 本地 MySQL,4 条 SQL | | 单次对话大模型调用耗时 | 2~10 s | DashScope API | | 数据库开销占比 | < 0.5% | 可忽略 | | 单会话数据量 | ≤ 20 条 | `maxMessages=20`,每次传输 < 几 KB | 即使 1000 并发对话,数据库也只需承接约 4000 次简单 SQL/秒,MySQL + HikariCP 轻松支撑。 ### 2.3 对话记忆的天然特性 - **会话访问严格串行**:同一 `chatId` 属于同一用户,只能顺序对话,不存在同一会话的高并发读写竞争。 - **数据量有上限**:`MessageWindowChatMemory` 窗口裁剪保证每会话最多 20 条。 - **丢失容忍度高**:记忆丢失后用户重新自我介绍即可恢复,属于弱一致性数据。 ## 三、什么情况下才需要考虑 Redis | 触发条件 | 原因 | |---|---| | 多实例水平部署 | 每实例都直连数据库,DB 压力随实例数翻倍;本地缓存方案失效 | | 会话量百万级 | `saveAll` 是"全删全插"快照语义,表膨胀后写放大明显 | | 对延迟极致敏感 | 需要把记忆读写压到亚毫秒级(当前 5~10ms 已足够快) | ## 四、如果迁移:Redis 做"存储"而非"缓存" 对话记忆**读多写少、天然有 TTL(会话活跃期)、丢失容忍度高**,因此正确姿势是让 Redis 直接作为存储层,而不是"MySQL + Redis 缓存"双写(后者要处理一致性,复杂度高、收益低)。 ### 4.1 推荐方案:Spring AI Alibaba Redis 记忆组件 项目使用 DashScope(spring-ai-alibaba),官方提供现成组件(版本 ≥ 1.0.0.3): ```xml <dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter-memory-redis</artifactId> <version>1.0.0.3</version> </dependency> ``` ```java // 用 RedissonChatMemoryRepository 直接替换 JdbcChatMemoryRepository,其余代码零改动 MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder() .chatMemoryRepository(redissonChatMemoryRepository) .maxMessages(20) .build(); ``` ### 4.2 迁移注意事项 1. **开启 AOF 持久化**:防止 Redis 宕机丢失全部对话记忆。 2. **设置 key TTL**(如 3~7 天):不活跃会话自动清理,避免 Redis 内存无限膨胀。 3. **保留 MySQL 兜底**(可选):双写一份到 MySQL 做历史归档/分析,Redis 只承担热数据。 ## 五、单实例的廉价替代方案 若只是主观上觉得"每次查库"心里不舒服,可在单实例场景用 Caffeine 本地缓存包装 `JdbcChatMemoryRepository`: - 同一会话串行访问,**不存在缓存一致性问题**; - 一个类即可实现,改动极小; - 注意:多实例部署时本地缓存命中率下降,此方案失效。 ## 六、建议 当前把精力放在业务与大模型调用上。待出现多实例部署或数据库指标告警时,再一步到位替换为 `spring-ai-alibaba-starter-memory-redis`,切换成本很低(仅改一个 Bean)。

MySql-JDBC 聊天记忆存储实现指南-最新Spring-Ai-Alibaba 1.1.2

# JDBC 聊天记忆存储实现指南 > 适用项目:恋爱大师ai-agent > 技术栈:Spring Boot 3.5.3 / JDK 21 / Spring AI 1.1.2 / MySQL 8.0.33(mysql-connector-java)/ DashScope(spring-ai-alibaba 1.1.2.0) > 核心依赖:`spring-ai-starter-model-chat-memory-repository-jdbc:1.1.2` > 存储表:`SPRING_AI_CHAT_MEMORY` ## 一、实现原理 ### 1.1 组件协作关系 ``` 用户对话 │ ▼ ChatClient(defaultAdvisors 装配) │ ▼ MessageChatMemoryAdvisor ──► ChatMemory(MessageWindowChatMemory,窗口上限 20 条) │ ▼ ChatMemoryRepository │ ├── JdbcChatMemoryRepository(本项目,MySQL 持久化) ├── InMemoryChatMemoryRepository(内存兜底,仅开发用) └── FileBasedChatMemory(本项目自定义,Kryo 文件持久化) ``` ### 1.2 一次对话的完整读写链路 ``` 请求前(before): ① chatMemory.get(chatId) → SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ② 将历史消息拼入 Prompt 发给大模型 请求后(after): ③ chatMemory.add(chatId, 新消息) └─ 内部先 findByConversationId() 查出历史 └─ 合并窗口裁剪后 saveAll()(事务内:DELETE 该会话全部 → 批量 INSERT 全部) ``` ### 1.3 存储语义(重要) - **快照语义**:`saveAll` 每次"先全删、再全插",表内永远是该会话的完整最新消息列表,不存在追加残留; - **事务保障**:删除 + 批量插入包在 `TransactionTemplate` 中,中途失败自动回滚; - **消息顺序**:`timestamp` 以秒为单位,通过 `AtomicLong` 自增保证同批消息严格有序(该列仅作排序号,不追求精确时间); - **TOOL 消息**:读取时还原为空内容 `ToolResponseMessage`(JDBC 方言不保存工具调用细节)。 ## 二、项目环境配置 ### 2.1 引入依赖(pom.xml) 已添加(版本 1.1.2,与 spring-ai-alibaba 1.1.2.0 对齐): ```xml <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId> <version>1.1.2</version> <scope>compile</scope> </dependency> ``` ### 2.2 数据源配置(application.yaml) 项目当前配置(本机 MySQL,库名 `xm-kimoaiagent`): ```yaml spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/xm-kimoaiagent?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&useSSL=false username: root password: abc123 ``` ### 2.3 建表 Spring AI 的 `spring.ai.chat.memory.repository.jdbc.initialize-schema` **默认值为 `embedded`**,即只对嵌入式数据库(H2 等)自动建表,**MySQL 不会自动建表**。两种方式任选: **方式一(推荐,手动建表)**: ```sql CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY ( conversation_id VARCHAR(36) NOT NULL, content TEXT NOT NULL, type VARCHAR(20) NOT NULL, timestamp TIMESTAMP NOT NULL, KEY idx_conversation_timestamp (conversation_id, timestamp) ); ``` > ⚠️ 表名大小写:Spring AI MySQL 方言的 SQL 硬编码为**全大写 `SPRING_AI_CHAT_MEMORY`**。 > - Windows 本机 MySQL 默认 `lower_case_table_names=1`,大小写不敏感,建小写表也能命中; > - **Linux 服务器默认大小写敏感**,必须建大写表名,否则报"Table doesn't exist"。 **方式二(自动建表)**,在 application.yaml 追加: ```yaml spring: ai: chat: memory: repository: jdbc: initialize-schema: always ``` ## 三、代码集成(LoveApp 当前实现) ```java @Component @Slf4j public class LoveApp { private final ChatClient chatClient; /** * 构造函数注入 JdbcChatMemoryRepository(关键:必须构造注入,不能字段注入) */ public LoveApp(ChatModel dashscopeChatModel, JdbcChatMemoryRepository jdbcChatMemoryRepository) { // 基于 JDBC 的窗口记忆:最多保存 20 条消息 MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder() .chatMemoryRepository(jdbcChatMemoryRepository) .maxMessages(20) .build(); // 注入带记忆的对话顾问 chatClient = ChatClient.builder(dashscopeChatModel) .defaultSystem(SYSTEM_PROMPT + "每次对话后都要生成恋爱结果,标题为{用户名}的恋爱报告,内容为建议列表") .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build(), new MyLoggerAdvisor(), new ReReadingAdvisor()) .build(); } public String doChat(String userMessage, String chatId) { ChatResponse chatResponse = chatClient.prompt() .user(userMessage) .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId)) .call() .chatResponse(); return chatResponse.getResult().getOutput().getText(); } } ``` 要点: 1. `MessageChatMemoryAdvisor` 通过 `ChatMemory.CONVERSATION_ID` 参数区分会话,调用方必须传 `chatId`; 2. `JdbcChatMemoryRepository` 由 Spring AI 自动配置(`@ConditionalOnMissingBean`),无需手动声明 Bean; 3. 记忆窗口 `maxMessages=20`:超过后优先保留 System 消息、裁剪最旧的非 System 消息。 ## 四、常见坑与排查 ### 4.1 【高频坑】@Resource 字段注入导致记忆"失效但无报错" ```java // ❌ 错误:字段注入发生在构造函数之后 @Resource JdbcChatMemoryRepository jdbcChatMemoryRepository; public LoveApp(ChatModel model) { // 这里 jdbcChatMemoryRepository 还是 null! MessageWindowChatMemory.builder().chatMemoryRepository(jdbcChatMemoryRepository)... } ``` Spring 创建 Bean 的生命周期:**构造函数 → 字段注入 → @PostConstruct**。 而 `MessageWindowChatMemory.Builder.build()` 对 null 有**静默容错**: ```java if (this.chatMemoryRepository == null) { this.chatMemoryRepository = new InMemoryChatMemoryRepository(); // 静默回退纯内存! } ``` **症状**:测试中多轮对话记忆正常(模型记得上一轮说的话),但数据库表始终为空、且不报任何错误。 **解决**:改为构造函数注入(见上文 LoveApp 写法)。 ### 4.2 表建了但写不进去 - 确认 `initialize-schema` 已按 2.3 配置(外部 MySQL 默认不建表); - 确认表名大小写与 MySQL 的 `lower_case_table_names` 匹配(Linux 必须大写); - 确认 `timestamp` 列类型为 `TIMESTAMP` 且非空(方言 SQL 直接写该列)。 ### 4.3 每次对话数据库有 4 条 SQL,是否压力大? 见同目录文档《Redis缓存必要性分析.md》:单次对话 DB 耗时约 5~10ms,占对话总耗时 < 0.5%,现阶段无需 Redis。 ## 五、验证方法 1. 运行 `mvn test -Dtest=LoveAppTest`(两轮对话 + 结构化输出测试); 2. 查询数据库: ```sql SELECT conversation_id, type, LEFT(content, 30), timestamp FROM SPRING_AI_CHAT_MEMORY ORDER BY conversation_id, timestamp; ``` 3. 预期:每个测试会话出现 USER / ASSISTANT 成对记录,第二轮对话的模型回复能正确引用第一轮的用户信息。

MyBatis中的 10 个宝藏技巧!

前言 说到 MyBatis,很多小伙伴都会用,但未必用得“惊艳”。 实际上,这个轻量级的持久层框架还有很多隐藏的“宝藏技巧”。 如果你能掌握这些技巧,不但能让开发更高效,还能避免掉入一些常见的“坑”。 今天就从浅入深,分享 10 个让人眼前一亮的 MyBatis 开发技巧,每一个都配上具体的场景和代码示例,务求通俗易懂,希望对你会有所帮助。 1. **灵活使用动态 SQL** 很多小伙伴在写 SQL 的时候,喜欢直接用拼接字符串的方式,比如: String sql = "SELECT * FROM user WHERE 1=1";if (name != null) { sql += " AND name = '" + name + "'";} 这种写法不仅麻烦,而且安全性很差(容易引发 SQL 注入)。 MyBatis 的动态 SQL 是专门为解决这种问题设计的,你可以用 if、choose、foreach 等标签来动态构造 SQL。 示例:动态条件查询 <select id="findUser" resultType="User"> SELECT * FROM user WHERE 1=1 <if test="name != null and name != ''"> AND name = #{name} </if> <if test="age != null"> AND age = #{age} </if></select> 这个代码的好处是,SQL 逻辑清晰,不会因为某个参数为空就导致整个 SQL 报错。 &#8203; 顺便吆喝一声,民族企业[color=rgb(54, 115, 254) !important][机会](https://jsj.top/f/o38ijj),前、后端/测试缺人,待遇给的还可以哦~ [color=rgb(54, 115, 254) !important]&#8203; **2. 善用 resultMap 自定义结果映射** 有些小伙伴会遇到这样的问题:数据库表字段是下划线命名,但 Java 对象是驼峰命名。比如 user_name 对应 userName。如果直接用默认的 resultType,MyBatis 是无法自动映射的。 这个时候,用 resultMap 就能完美解决。 示例:自定义结果映射 <resultMap id="userResultMap" type="User"> <id column="id" property="id"/> <result column="user_name" property="userName"/> <result column="age" property="age"/></resultMap><select id="getUserById" resultMap="userResultMap"> SELECT id, user_name, age FROM user WHERE id = #{id}</select> 有了 resultMap,再复杂的字段映射都可以轻松搞定。 **3. 利用 foreach 实现批量操作** 有些小伙伴可能会遇到这种需求:传入一个 ID 列表,查询所有匹配的用户信息。如果用拼接字符串的方式生成 IN 条件,不但代码丑,还容易踩坑。 MyBatis 提供了 foreach 标签,可以优雅地处理这种场景。 示例:批量查询 <select id="findUsersByIds" resultType="User"> SELECT * FROM user WHERE id IN <foreach item="id" collection="idList" open="(" separator="," close=")"> #{id} </foreach></select> 传入的 idList 是一个 List 或数组,MyBatis 会自动帮你展开为 IN (1, 2, 3) 这样的格式,完全不用担心语法问题。 **4. MyBatis-Plus 的分页功能** 很多小伙伴在做分页的时候,习惯自己写 LIMIT 的 SQL,这样不仅麻烦,还容易出错。 其实,用 MyBatis-Plus 的分页插件能省不少事。 示例:MyBatis-Plus 分页功能 Page<User> page = new Page<>(1, 10); // 第 1 页,每页 10 条IPage<User> userPage = userMapper.selectPage(page, null);System.out.println("总记录数:" + userPage.getTotal());System.out.println("当前页数据:" + userPage.getRecords()); 只需引入分页插件,就能轻松完成分页操作,简直不要太爽。 **5. 使用 @Mapper的接口代理** 有些小伙伴觉得 XML 文件太多太麻烦,其实 MyBatis 支持纯注解的开发模式,尤其是对于简单的 SQL,非常方便。 示例:注解方式查询 @Mapperpublic interface UserMapper { @Select("SELECT * FROM user WHERE id = #{id}") User getUserById(int id); @Insert("INSERT INTO user(name, age) VALUES(a class="linkToSearch" target="_blank" href="/platform/searchResult?k=%23%7Bname%7D%2C%26nbsp%3B%23&c=2" style="color:rgb(24,113,255);font-weight:600;padding:0 5px">#{name}, #{age})") void addUser(User user);} 用这种方式,可以完全省掉 XML 配置,代码更加简洁。 **6. 二级缓存** MyBatis 内置了一级缓存(SqlSession 范围内),但对于多次查询的场景,可以开启二级缓存来提升性能。 示例:开启二级缓存 <configuration> <settings> <setting name="cacheEnabled" value="true"/> </settings></configuration><mapper namespace="com.example.mapper.UserMapper"> <cache/> <select id="getUserById" resultType="User"> SELECT * FROM user WHERE id = #{id} </select></mapper> 开启二级缓存后,同一个 Mapper 下的查询会自动命中缓存,大幅提高性能。 **总结** MyBatis 的魅力在于简单、高效,但很多时候我们用得太“基础”,没有发挥它的全部潜力。 希望这 些技巧能帮你更高效地使用 MyBatis,也让你的代码看起来更“惊艳”。 如果觉得有帮助,记得收藏分享! **最后说一句** 如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。 求一键三连:点赞、转发、在看。 ——转载自:苏三说技术

Spring Boot 如何处理跨域请求(CORS):深度调研报告

# Spring Boot 如何处理跨域请求(CORS):深度调研报告 ## 执行摘要 跨源资源共享(CORS,Cross-Origin Resource Sharing)是现代 Web 开发中不可避免的基础课题。当浏览器执行的前端应用与后端 API 不处于同一源(协议+域名+端口)时,浏览器的同源策略会默认拦截这些跨域请求,而 CORS 协议提供了一套基于 HTTP 头的受控放行机制,使服务器能够精确声明哪些外部源可以访问其资源。Spring Framework 早在 4.2 版本就将 CORS 作为"一等公民"纳入 Spring MVC 的核心处理链,通过 `HandlerMapping` 内置机制统一处理预检请求与实际请求,开发者无需手写 Filter 即可声明式地完成配置。 Spring Boot 3.x 继承了 Spring Framework 6.x 的 CORS 体系,为开发者提供了由细到粗的四种主要配置方式:方法/类级 `@CrossOrigin` 注解、全局 `WebMvcConfigurer.addCorsMappings()` 路径映射、独立 `CorsFilter` Bean 注册,以及在引入 Spring Security 时的 `CorsConfigurationSource` + `.cors()` 集成配置。这四种方式共享同一套底层校验内核 `DefaultCorsProcessor`,通过 `checkOrigin → checkMethods → checkHeaders` 三段式校验确保跨域请求符合 W3C/WHATWG Fetch 规范要求。调研发现,所有方式的配置合并遵循"加法式"规则——`allowedOrigins` 和 `allowedMethods` 取并集,但 `allowCredentials` 和 `maxAge` 等单值属性由局部配置覆盖全局配置。 本次调研的核心发现之一,是 `allowedOrigins="*"` 与 `allowCredentials=true` 的组合冲突。这一限制源自 W3C CORS 规范中"凭证模式下 `Access-Control-Allow-Origin` 不得使用通配符"的硬性要求,自 Spring Framework 5.3(Spring Boot 2.4.0)起在框架层面以 `IllegalArgumentException` 形式强制执行。在所有活跃的 Spring Boot 版本中,开发者必须改用 Spring 5.3 引入的 `allowedOriginPatterns` 来实现"允许所有源 + 携带凭证"的场景,该属性采用 `AntPathMatcher` 模式匹配而非精确字符串比对,能够动态回显请求方的 Origin。 从安全维度审视,CORS 配置的不当是一个被严重低估的风险。奇安信与清华大学的联合测量研究显示,全球约 27.5% 的 CORS 配置网站存在不安全配置,其中最危险的是将 `Access-Control-Allow-Origin` 反射为请求方的 Origin 同时开启凭证传递——这一配置实质上完全绕过了同源策略。更值得警惕的是,11 款主流 CORS 框架中有 8 款在遇到 `Origin:* + Credentials:true` 这种矛盾配置时会自动转为反射 Origin(CVE-2018-8014),这构成了一个隐蔽但严重的安全漏洞。 从工程实践维度来看,Spring Security 集成是 CORS 踩坑的最高频场景。当 `SecurityFilterChain` 未显式调用 `.cors()` 时,OPTIONS 预检请求不携带 `JSESSIONID`,会被认证过滤器判定为未认证而返回 401/403,导致浏览器阻断实际请求。此外,现代浏览器 Chrome 80+ 的 `SameSite=Lax` 默认 Cookie 策略构成了跨域凭证传递的第二道防线,即使后端正确配置了 CORS,前端 Cookie 也必须显式设置 `SameSite=None; Secure` 才能在跨域请求中传递。在微服务架构中,API Gateway 层与下游服务的双重 CORS 配置会产生重复响应头,直接导致浏览器拒绝请求,这是分布式系统中最常见的 CORS 运维故障。 本报告从 CORS 协议原理、Spring 源码实现、四种配置方式详解、Spring Security 集成、Cookie 与凭证传递、安全最佳实践、常见错误与排查、2.x→3.x 迁移变更、性能优化等九个维度,对 Spring Boot CORS 处理机制进行了全面深度剖析,并在每个关键点提供了可运行的代码示例和生产环境配置建议。 ------ ## 第一章 引言 ### 1.1 研究背景 在现代 Web 开发实践中,前后端分离架构已成为主流范式。前端应用通常部署在独立的静态资源服务器(或 CDN)上,与后端 API 服务运行在不同的源(origin)中。这里的"源"由协议(scheme)、域名(host)和端口(port)三要素共同定义——只要任一要素不同,浏览器即视为跨源。例如,前端 `https://app.example.com` 调用后端 `https://api.example.com`,虽然域名主体相同,但因子域不同而构成跨源;再如 `http://localhost:3000` 调用 `http://localhost:8080`,因端口不同同样构成跨源。 浏览器的同源策略(Same-Origin Policy)是 Web 安全的基石之一,它限制脚本(如 JavaScript 的 `fetch()` 和 `XMLHttpRequest`)只能向加载当前页面的同一源发起请求,以防止恶意网站通过用户浏览器窃取其在其他站点的敏感数据。然而,合法的跨源需求(如前后端分离、微服务 API 网关、第三方 API 集成)必须被满足,CORS 协议正是为此而生——它允许服务器通过特定的 HTTP 响应头声明"我允许来自 XX 源的请求",浏览器在验证这些头后放行对应的跨域请求。 Spring Boot 作为 Java 生态中最流行的 Web 框架,对 CORS 提供了全方位的内置支持。但正是这种"全方位"带来了选择困难:开发者面对 `@CrossOrigin`、`WebMvcConfigurer`、`CorsFilter`、Spring Security `CorsConfigurationSource` 等多种方式,往往不清楚该选择哪种、它们之间有何差异、以及如何避免常见的配置冲突。更复杂的是,Spring Boot 3.x 基于 Spring Framework 6.x 和 Jakarta EE 9+,与广泛使用的 Spring Boot 2.x 存在若干破坏性变更,CORS 配置在迁移过程中可能产生隐蔽的兼容性问题。 ### 1.2 研究范围与方法 本调研聚焦于 Spring Boot 3.x 的 CORS 处理机制,同时系统对比 Spring Boot 2.x 的差异以确保迁移项目的兼容性。调研采用三阶段顺序执行的多 Agent 方法论:第一阶段(Agent 1)执行综合 Web 调研,从官方文档、权威技术博客和社区实践中收集 CORS 协议原理、配置方式和版本差异的基础信息;第二阶段(Agent 2)在第一阶段发现的基础上进行源码级深度分析,验证配置方式背后的底层实现机制,并深入探讨 Spring Security 集成、Cookie 凭证传递和性能优化等高级主题;第三阶段(Agent 3)交叉验证前两阶段的所有发现,识别共识与矛盾,补充安全最佳实践和常见错误排查。 调研数据来源涵盖 15+ 篇高质量文献,包括 Mozilla MDN 的 CORS 权威参考(A 级)、W3C/WHATWG Fetch 规范(A 级)、Spring Framework 和 Spring Security 官方文档(A 级)、Baeldung 等知名技术博客(B 级)、Spring 源码仓库与社区源码分析(B 级),以及奇安信与清华大学的 CORS 安全测量研究(A 级)。所有关键声明均经过多源交叉验证,对存在矛盾或条件性的结论已明确标注。 ### 1.3 报告结构 本报告共分九章。第二章从协议层面讲解 CORS 的工作原理,包括同源策略、简单请求与预检请求的判定、关键 HTTP 头和预检缓存。第三章深入 Spring Framework 的源码实现,追踪从请求到达到响应返回的完整 CORS 处理链。第四章详解 Spring Boot 3.x 的四种配置方式,每种方式均配有完整代码示例和适用场景说明。第五章专门讨论 Spring Security 集成中的 CORS 配置,这是生产环境踩坑最频繁的场景。第六章探讨 Cookie 与凭证传递的深度话题,包括 SameSite 策略和 JSESSIONID 跨域。第七章从安全视角审视 CORS 配置的风险与防护。第八章汇总常见错误与排查方法。第九章聚焦 Spring Boot 2.x → 3.x 的 CORS 迁移变更。第十章讨论性能优化策略。最后以综合分析和参考文献收尾。 ------ ## 第二章 CORS 协议原理 ### 2.1 同源策略与 CORS 的关系 同源策略是浏览器最核心的安全机制之一。根据 Mozilla MDN 的定义,同源策略限制了一个源中的脚本与另一个源中资源之间的交互,这种限制涵盖了 DOM 访问、Cookie/Storage 读取和跨域网络请求三个层面(Mozilla, 2025, "Cross-Origin Resource Sharing (CORS) — HTTP | MDN", https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)。同源策略的存在使得恶意网站无法通过 JavaScript 读取用户在银行网站上的数据——即使恶意网站成功诱导用户浏览器发起了指向银行 API 的请求,浏览器的同源策略也会阻止恶意网站的脚本读取该响应。 然而,同源策略过于严格,无法满足合法的跨源业务需求。CORS 协议的引入正是为了在同源策略的框架下提供一种受控的"豁免"机制。服务器通过在响应中添加特定的 HTTP 头(如 `Access-Control-Allow-Origin`),明确告知浏览器"我允许来自某源的跨域请求访问我的资源"。浏览器在验证这些头信息后,才会将响应数据交给发起请求的脚本。这一机制的关键在于:CORS 的拦截和放行完全由浏览器端执行,服务器只是"声明"策略,实际"执法"者是浏览器。这意味着如果用非浏览器客户端(如 curl、Postman)发起跨域请求,CORS 机制不会生效——服务器本身不会因为 CORS 而拒绝任何请求。 CORS 规范最初由 W3C 制定,现已被 WHATWG 的 Fetch 规范吸收和维护(WHATWG, 2024, "Fetch Standard — CORS Protocol", https://fetch.spec.whatwg.org/)。Fetch 规范中对 CORS 协议的描述更为精确,引入了"credentials mode"(凭证模式)和"response tainting"(响应污染级别)等概念,这些概念在后文的凭证传递分析中至关重要。 ### 2.2 简单请求与预检请求 CORS 协议将跨域请求分为两类:简单请求(Simple Request)和预检请求(Preflight Request)。区分二者的意义在于:简单请求只需一次客户端-服务器往返即可完成,而预检请求需要先发一个 OPTIONS 探测请求,服务器确认后才能发送实际请求,总共两次往返。 根据 MDN 的规范说明,一个请求被判定为"简单请求"需同时满足以下所有条件:仅使用 `GET`、`HEAD` 或 `POST` 方法;除浏览器自动设置的头外,手动设置的请求头只能属于 CORS 安全列表头集合(`Accept`、`Accept-Language`、`Content-Language`、`Content-Type`);且 `Content-Type` 仅限于 `application/x-www-form-urlencoded`、`multipart/form-data` 或 `text/plain` 三种。当请求不满足上述任一条件——例如使用了 `PUT`、`DELETE`、`PATCH` 方法,或 `Content-Type` 为 `application/json`(这是现代 RESTful API 最常用的格式),或携带了 `Authorization`、`X-Requested-With` 等自定义请求头——浏览器就会将其归类为"需要预检的请求"(Mozilla, 2025, MDN CORS)。 由于现代 Web API 几乎都以 JSON 作为数据格式并使用 `Authorization` 头传递 JWT Token,绝大多数实际的跨域 API 请求都会触发预检流程。这意味着预检请求的优化(后文详述)对 API 性能有实际影响。 ### 2.3 预检请求的工作流程 预检请求是一个以 `OPTIONS` 方法发送的 HTTP 请求,其目的是在发送实际请求之前,先"询问"服务器是否允许即将发起的跨域请求。预检请求的流程如下: 浏览器在发送实际请求前,先构造一个 OPTIONS 请求,其中携带两个关键请求头:`Access-Control-Request-Method`(告知服务器实际请求将使用的方法,如 `PUT`)和 `Access-Control-Request-Headers`(告知服务器实际请求将携带的自定义头,如 `authorization, content-type`)。服务器收到预检请求后,检查自身的 CORS 策略,如果允许,则在响应中返回一组 `Access-Control-Allow-*` 头予以确认,包括 `Access-Control-Allow-Origin`(允许的源)、`Access-Control-Allow-Methods`(允许的方法)、`Access-Control-Allow-Headers`(允许的头)、`Access-Control-Max-Age`(预检结果可缓存的秒数)。浏览器收到预检响应后,验证这些确认头是否覆盖了实际请求的需求。如果通过,浏览器才会发送实际请求;如果不通过,浏览器会直接报错,实际请求不会被发出。 实际请求发出后,服务器在响应中再次携带 `Access-Control-Allow-Origin` 等头(但不需要再携带 `Allow-Methods` 和 `Allow-Headers`,因为这些已在预检阶段确认)。浏览器对实际请求的响应进行二次校验,通过后才将响应数据交给 JavaScript 脚本。 ### 2.4 关键 HTTP 头详解 CORS 协议涉及一组以 `Access-Control-*` 为前缀的 HTTP 头,分为请求头(由浏览器发送)和响应头(由服务器发送)两类。 **请求头(浏览器→服务器):** - `Origin`:标识跨域请求的来源源(格式为 `scheme://host:port`),浏览器在所有跨域请求中自动携带,开发者无法手动设置或修改。 - `Access-Control-Request-Method`:仅在预检请求中出现,告知实际请求将使用的方法。 - `Access-Control-Request-Headers`:仅在预检请求中出现,告知实际请求将携带的自定义头列表(逗号分隔)。 **响应头(服务器→浏览器):** - `Access-Control-Allow-Origin`:指定允许访问资源的源。可以是具体的源(如 `https://app.example.com`),也可以是 `*`(表示允许所有源)。但当 `Access-Control-Allow-Credentials: true` 时,此头不能为 `*`,必须精确回显请求方的 Origin——这是 CORS 安全模型的核心约束之一。 - `Access-Control-Allow-Methods`:仅在预检响应中出现,指定允许的 HTTP 方法列表。 - `Access-Control-Allow-Headers`:仅在预检响应中出现,指定实际请求中允许携带的自定义头。 - `Access-Control-Expose-Headers`:指定哪些响应头可以被前端 JavaScript 读取。默认情况下,JavaScript 只能读取 CORS 安全列表中的头(`Cache-Control`、`Content-Language`、`Content-Length`、`Content-Type`、`Expires`、`Last-Modified`、`Pragma`)。如果前端需要读取自定义响应头(如 `X-Total-Count`、`X-Request-Id`),必须在此头中列出。 - `Access-Control-Allow-Credentials`:指示是否允许浏览器在跨域请求中携带凭证(Cookie、HTTP 认证信息、客户端 SSL 证书)。当为 `true` 时,`Access-Control-Allow-Origin` 不能为 `*`,且浏览器端 `fetch()` 必须设置 `credentials: 'include'`。 - `Access-Control-Max-Age`:仅在预检响应中出现,指定预检结果可被浏览器缓存的秒数。在缓存有效期内,对同一来源的相同跨域请求,浏览器不会再次发送预检请求。该值受浏览器自身上限约束 ——Chrome 上限为 7200 秒(2 小时),Firefox 上限为 86400 秒(24 小时)——即使服务器返回更大的值,浏览器也按自身上限截断(Mozilla, 2025, MDN CORS)。 ### 2.5 Vary 头与缓存正确性 一个容易被忽略但至关重要的细节是 `Vary` 响应头在 CORS 中的作用。当服务器根据请求的 `Origin` 头动态返回不同的 `Access-Control-Allow-Origin` 值时,如果缺少 `Vary: Origin` 头,浏览器或中间 CDN 可能缓存一个针对源 A 的响应并错误地返回给源 B 的请求,导致 CORS 校验失败或安全漏洞。Spring 的 `DefaultCorsProcessor` 在处理跨域请求时会自动添加 `Vary: Origin` 头(以及 `Vary: Access-Control-Request-Method` 和 `Vary: Access-Control-Request-Headers`),以确保缓存正确性。这一行为在 Spring Framework 源码的 `DefaultCorsProcessor` 类中可以清晰看到,是 Spring CORS 实现的一个内置且关键的细节(出门向左, 2017, "spring MVC cors跨域实现源码解析", https://www.cnblogs.com/leftthen/p/6378090.html)。 ------ ## 第三章 Spring Framework 源码级 CORS 处理流程 ### 3.1 处理入口:AbstractHandlerMapping Spring MVC 对 CORS 的处理深度嵌入在请求分发链中,而非外挂式的 Filter。理解这一点的关键是追踪 `AbstractHandlerMapping` 的源码。根据源码级分析(出门向左, 2017, "spring MVC cors跨域实现源码解析", https://www.cnblogs.com/leftthen/p/6378090.html),`AbstractHandlerMapping.getHandler(HttpServletRequest request)` 方法是整个 CORS 处理的入口,其执行链路可精确追溯如下: 首先,`getHandlerInternal(request)` 获取目标 Handler(即 Controller 方法)。然后,系统构建 `HandlerExecutionChain`,将其与拦截器链组装。关键步骤在于 CORS 检测:通过 `CorsUtils.isCorsRequest(request)` 判断请求头中是否包含 `Origin` 字段——如果存在且该 Origin 与当前服务器不同源,则识别为跨域请求。一旦确认为跨域请求,系统会从两个来源获取 CORS 配置并合并:全局配置来自 `UrlBasedCorsConfigurationSource.getCorsConfiguration(request)`(对应 `WebMvcConfigurer.addCorsMappings()` 注册的路径规则),局部配置来自 `getCorsConfiguration(handler, request)`(即解析 `@CrossOrigin` 注解的结果)。两者通过 `CorsConfiguration.combine()` 方法完成合并。 合并完成后,系统根据请求是否为预检请求(检测方法是 `CorsUtils.isPreFlightRequest(request)`,即 `OPTIONS` 方法且包含 `Access-Control-Request-Method` 头)做出分流处理。如果是预检请求,Handler 会被替换为内部类 `PreFlightHandler`,它直接返回 CORS 响应头而不执行实际的 Controller 逻辑;如果是普通的跨域请求(非预检),则在执行链中追加 `CorsInterceptor` 拦截器,该拦截器在 Controller 方法执行前后添加 CORS 校验和响应头注入。这一设计确保了预检请求不会走入业务逻辑,而实际请求的 CORS 头注入则与业务处理同步进行。 ### 3.2 配置合并逻辑:CorsConfiguration.combine() 全局配置与局部(`@CrossOrigin`)配置的合并遵循精确定义的规则,由 `CorsConfiguration#combine(CorsConfiguration)` 方法实现。源码分析显示(出门向左, 2017),合并策略按属性类型区分:对于 `allowedOrigins`、`allowedMethods`、`allowedHeaders`、`exposedHeaders` 等集合类属性,合并后取两个配置的**并集**,即全局允许的源和局部允许的源都被合并为最终允许列表;对于 `allowCredentials` 和 `maxAge` 等只能接受单一值的属性,**局部配置覆盖全局配置**——如果在 `@CrossOrigin` 上显式设置了 `allowCredentials`,则以注解值为准,否则继承全局配置。 这一"加法式"合并规则有一个重要的实际影响:如果你在全局配置中设置了 `allowCredentials(true)` 并允许了 `https://domain1.com`,而某个 Controller 方法上的 `@CrossOrigin` 额外允许了 `https://domain2.com`,那么对该 Controller 方法的请求最终的允许源列表是两个域的并集(`domain1.com` 和 `domain2.com` 都被允许),但凭证传递仍为 `true`。这种合并方式给开发者带来了灵活性,但也要求对全局和局部配置的交互保持清醒认知,避免因意外合并导致 CORS 策略宽于预期。 ### 3.3 校验内核:DefaultCorsProcessor 三段式校验 无论是 Servlet Filter 层的 `CorsFilter` 还是 MVC 拦截器层的 `CorsInterceptor`,它们共享同一个校验内核——`DefaultCorsProcessor`。该处理器在 `handleInternal()` 方法中执行严格的三段式校验(CSDN/ximeneschen, 2022, "@CrossOrigin及其实现跨域原理", https://blog.csdn.net/cristianoxm/article/details/124840435): 第一段是 `checkOrigin()`:检查请求的 `Origin` 是否在允许列表(`allowedOrigins`)或匹配允许的模式列表(`allowedOriginPatterns`)。如果允许列表为空或 Origin 不匹配,返回 `null`;如果匹配成功,返回将写入 `Access-Control-Allow-Origin` 响应头的值。这里的关键逻辑是:当 `allowCredentials` 为 `true` 时,`allowedOrigins` 不能包含 `"*"`——如果检测到这种组合,Spring 5.3+ 会直接抛出 `IllegalArgumentException`,而非静默失败。这是本文反复强调的那个限制的源码落点。对于 `allowedOriginPatterns`,`checkOrigin()` 使用 `AntPathMatcher` 进行模式匹配(如 `"https://*.example.com"` 可匹配 `https://app.example.com`),匹配成功后精确回显请求 Origin 而非通配符。 第二段是 `checkMethods()`(仅在预检请求中执行):检查 `Access-Control-Request-Method` 指定的方法是否在 `allowedMethods` 列表中。如果允许列表为 `*` 或包含该具体方法,返回该方法名(或 `*`),写入 `Access-Control-Allow-Methods` 响应头;否则返回 `null`。 第三段是 `checkHeaders()`(仅在预检请求中执行):检查 `Access-Control-Request-Headers` 中列出的每个头是否在 `allowedHeaders` 列表中。同样支持 `*` 通配符。匹配成功后返回这些头的列表,写入 `Access-Control-Allow-Headers` 响应头;任一头不匹配则返回 `null`。 **任何一段返回 `null` 都会导致请求被 `rejectRequest()` 拒绝**。但一个微妙的细节是,这里的"拒绝"并非返回 HTTP 403 状态码——`rejectRequest()` 实际上返回一个不带任何 CORS 响应头的 200(或与方法对应的)响应,让浏览器基于"缺少 CORS 头"自行拦截请求。这一设计避免了服务器暴露 CORS 策略细节给未授权来源,但也有开发者误以为是服务器逻辑错误而难以排查。 ### 3.4 CorsFilter vs CorsInterceptor 的执行时机 Spring 同时提供了 `CorsFilter`(Servlet Filter 层)和 `CorsInterceptor`(MVC 拦截器层)两条 CORS 处理路径,二者共享 `DefaultCorsProcessor` 但处于请求处理的不同阶段(CSDN/好运仔dzl, 2025, "SpringBoot源码解析(二十三):跨域处理CorsFilter的自动注册原理", https://blog.csdn.net/qq_50954361/article/details/148469418)。 `CorsFilter` 实现了 `jakarta.servlet.Filter`(Spring Boot 3.x)或 `javax.servlet.Filter`(2.x),它在 `DispatcherServlet` 之前执行。这意味着 CORS 处理发生在 Spring MVC 的请求分发之前——对于预检请求,可以直接返回 CORS 响应而不进入 MVC 链。`CorsFilter` 通常用于两种场景:一是需要以独立 Bean 形式管理 CORS 配置;二是与 Spring Security 集成时,需要确保 CORS 在安全过滤器链之前或紧随其后处理,以避免预检请求被认证逻辑拦截(详见第五章)。 `CorsInterceptor` 实现了 `HandlerInterceptor`,它在 `DispatcherServlet` 分发请求到 Handler 之前执行。这是 Spring MVC `@CrossOrigin` 和 `WebMvcConfigurer.addCorsMappings()` 配置的默认处理路径。`CorsInterceptor` 的优势是能够访问 Handler 级别的 `@CrossOrigin` 注解信息,实现细粒度的配置合并;其局限是无法在 Spring Security 等更早的 Filter 层介入。 理解二者差异的实际意义在于:当项目中同时存在 `CorsFilter`(或 Spring Security 的 CORS 配置)和 `@CrossOrigin`/`WebMvcConfigurer` 配置时,两者可能产生交互。一般而言,`CorsFilter` 先执行,如果它已经完成了 CORS 校验并注入了响应头,后续的 `CorsInterceptor` 不会重复处理;如果 `CorsFilter` 判定请求非跨域(如没有 `Origin` 头),`CorsInterceptor` 则正常接管。在配置时应避免冲突,通常推荐只选择一种路径以保证行为一致。 ### 3.5 Spring Boot 的 CORS 自动配置 Spring Boot 还提供了 `CorsAutoConfiguration` 自动配置类,当 classpath 上存在相关类且开发者未手动注册 `CorsFilter` 时,Spring Boot 会自动注册一个基于 `UrlBasedCorsConfigurationSource` 的 `CorsFilter` Bean。源码分析显示(CSDN/好运仔dzl, 2025),自动配置的触发条件包括 classpath 上存在 `CorsFilter`、`CorsConfigurationSource` 等类,以及开发者未显式提供这些 Bean。自动注册的 `CorsFilter` 通过 `FilterRegistrationBean` 指定其在过滤器链中的顺序,默认优先级较高。 然而,这一自动配置在引入 Spring Security 时有重要的交互效应:如果 Spring Security 的 `SecurityFilterChain` 未调用 `.cors()`,自动注册的 `CorsFilter` 可能因执行顺序问题而被 Security 的认证过滤器"抢先"拦截预检请求。这进一步印证了第五章将要强调的核心建议——使用 Spring Security 时必须显式配置 `.cors()`。 ------ ## 第四章 Spring Boot 3.x 的 CORS 配置方式详解 Spring Boot 3.x 继承了 Spring Framework 6.x 的 CORS 体系,提供了从细粒度到全局的多种配置方式。本章逐一详解每种方式的用法、适用场景、优缺点,并给出完整可运行的代码示例。 ### 4.1 方式一:@CrossOrigin 注解(方法级/类级) `@CrossOrigin` 是最细粒度的 CORS 配置方式,可直接标注在 Controller 方法或类上。根据 Spring Framework 官方文档(Spring, 2024, "CORS — Spring Framework Reference", https://docs.spring.io/spring-framework/reference/web/webmvc-cors.html),`@CrossOrigin` 的默认行为是:允许所有源(`origins = "*"`)、所有头、该方法映射的所有 HTTP 方法;`maxAge` 默认 30 分钟(1800 秒);`allowCredentials` 默认不启用(`false`)。 方法级使用示例: ```java @RestController @RequestMapping("/api") public class MyController { @CrossOrigin(origins = "https://frontend.example.com") @GetMapping("/data/{id}") public ResponseEntity<?> getData(@PathVariable Long id) { return ResponseEntity.ok(service.getData(id)); } @CrossOrigin(origins = {"https://app1.example.com", "https://app2.example.com"}, allowedHeaders = {"Authorization", "Content-Type"}, methods = {RequestMethod.GET, RequestMethod.POST}, maxAge = 3600) @PostMapping("/submit") public ResponseEntity<?> submit(@RequestBody RequestDto dto) { return ResponseEntity.ok(service.submit(dto)); } } ``` 类级使用示例,类级配置会被所有方法继承: ```java @CrossOrigin(origins = "https://domain2.com", maxAge = 3600) @RestController @RequestMapping("/account") public class AccountController { @GetMapping("/{id}") public Account retrieve(@PathVariable Long id) { // 该方法继承类级 @CrossOrigin 配置 return accountService.retrieve(id); } @DeleteMapping("/{id}") public void delete(@PathVariable Long id) { // 也在类级 CORS 策略覆盖范围内 accountService.delete(id); } } ``` 类级与方法级可组合使用,Spring 会按 `combine()` 规则合并属性。一个重要的细节是:方法级 `@CrossOrigin` 默认允许的 HTTP 方法**仅是该处理器方法映射的那一个方法**——例如 `@GetMapping` 标注的方法,其默认 `methods` 为 `GET`,而非全部 HTTP 方法。这与部分社区博客"允许所有方法"的笼统表述不同,是一个容易混淆的点。 `@CrossOrigin` 的优势在于精确控制——只有标注的方法/类受 CORS 策略约束,未标注的方法不受影响。其局限是当需要统一的跨域策略时,逐一标注较为繁琐,且配置分散难以维护。适用场景:少量特定接口需要与默认全局策略不同的 CORS 配置,如某个开放 API 允许任意源访问而其他接口仅限内部域名。 ### 4.2 方式二:WebMvcConfigurer.addCorsMappings() 全局配置 当需要对特定 URL 路径模式统一配置 CORS 时,`WebMvcConfigurer.addCorsMappings()` 是首选方案。通过实现 `WebMvcConfigurer` 接口并重写 `addCorsMappings` 方法,可按路径模式注册 CORS 规则: ```java @Configuration public class WebCorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { // 全局 API 路径 registry.addMapping("/api/**") .allowedOrigins("https://domain1.com", "https://domain2.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH") .allowedHeaders("Authorization", "Content-Type", "X-Requested-With") .exposedHeaders("X-Total-Count", "X-Request-Id") .allowCredentials(true) .maxAge(3600); // 公开接口,允许任意源 registry.addMapping("/public/**") .allowedOrigins("*") .allowedMethods("GET"); // 需要凭证 + 动态源,使用 allowedOriginPatterns registry.addMapping("/auth/**") .allowedOriginPatterns("https://*.example.com") .allowedMethods("POST", "GET") .allowCredentials(true) .maxAge(1800); } } ``` 全局配置默认行为是允许所有源、所有头,但方法仅限 `GET`、`HEAD`、`POST`。这与方法级 `@CrossOrigin` 默认仅允许映射的单个方法不同。使用时需注意:若不显式设置 `allowedMethods`,某些 `PUT`/`DELETE` 请求会被默认配置拦截。 `WebMvcConfigurer` 方式的优势是路径级统一控制,配置集中、易于维护,且支持路径通配符。其局限是配置粒度为 URL 模式,无法针对单个 Controller 方法定制(但可与 `@CrossOrigin` 组合使用,二者按 `combine()` 规则合并)。适用场景:项目中大部分接口遵循统一的 CORS 策略,仅个别接口需要例外。这是生产环境中最常用的配置方式。 ### 4.3 方式三:CorsFilter Bean 注册 当需要以 Servlet Filter 形式处理 CORS(例如需要在过滤器链更早阶段介入,或与 Spring Security 等框架更紧密集成时),可注册一个 `CorsFilter` Bean,传入一个 `CorsConfigurationSource`: ```java @Configuration public class CorsFilterConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("https://domain1.com"); // Spring Boot 2.4+ / 3.x 推荐用 OriginPatterns config.addAllowedOriginPattern("https://*.example.com"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.addExposedHeader("X-Total-Count"); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } } ``` `CorsFilter` 的优势在于它位于 Servlet 过滤器链中,在 `DispatcherServlet` 之前执行,因此能更早地完成 CORS 处理。这对于 Spring Security 集成尤其重要——在 Security 的过滤链中嵌入 `CorsFilter`(通过 `CorsConfigurationSource` Bean)可以确保预检请求不会被认证过滤器拦截。`CorsFilter` 也适合需要精确控制过滤器执行顺序的场景。 `CorsFilter` 的局限在于它不直接感知 `@CrossOrigin` 注解(因为注解在 MVC 层解析),因此如果项目中同时使用了 `@CrossOrigin`,`CorsFilter` 与 MVC 层的 `CorsInterceptor` 可能各自独立校验,需注意配置一致性。适用场景:需要与 Spring Security 集成、或项目未使用 Spring MVC 的全部栈(如仅使用 Spring WebFlux 的 Servlet 模式)时。 ### 4.4 方式四:Spring Security 中的 CorsConfigurationSource + .cors() 当项目引入 Spring Security 时,CORS 配置必须与 Security 过滤链正确集成。Spring Security 官方文档明确指出(Spring, 2024, "CORS — Spring Security Reference", https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html),最简洁的方式是提供一个 `CorsConfigurationSource` Bean 并在 `SecurityFilterChain` 中调用 `.cors()`: ```java @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(Customizer.withDefaults()) // 启用 CORS,使用下方的 CorsConfigurationSource Bean .csrf(csrf -> csrf.disable()) // 跨域场景通常禁用 CSRF(或按需配置) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) // ... 其他安全配置 ; return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of("https://app.example.com")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); configuration.setExposedHeaders(List.of("X-Request-Id")); configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } } ``` 一个关键的便利机制是:如果 classpath 上存在 Spring MVC 且调用了 `.cors(Customizer.withDefaults())` 但**未提供** `CorsConfigurationSource` Bean,Spring Security 会自动复用 Spring MVC 的 CORS 配置(即 `WebMvcConfigurer.addCorsMappings()` 注册的规则)。这意味着在纯 MVC + Security 的项目中,你可以只在 `WebMvcConfigurer` 中配置 CORS,然后在 Security 中调用 `.cors()` 即可,无需重复配置。但建议在生产环境中显式提供 `CorsConfigurationSource` Bean,以避免隐式依赖带来的混淆。 Spring Boot 3.x 搭配的 Spring Security 6.x 废弃了 Spring Security 5.x 的链式 DSL 写法 `http.cors().and().csrf().disable()...`,改为 Lambda 风格 `http.cors(Customizer.withDefaults())` 或 Kotlin DSL。这是从 Spring Boot 2.x 迁移到 3.x 时最常见的代码改动之一。Spring Security 7.x 还引入了 `PreFlightRequestHandler` 和 `PreFlightRequestFilter`,用于在 `CorsFilter` 之外处理预检请求,支持以每个 `SecurityFilterChain` 为单位配置不同的 `CorsConfigurationSource`,这为多租户或 API 分层场景提供了更灵活的控制(Spring, 2024, Spring Security Reference)。 ### 4.5 四种方式对比与选型建议 | 维度 | `@CrossOrigin` | `WebMvcConfigurer` | `CorsFilter` | Spring Security `.cors()` | | ----------------------- | ------------------------ | ------------------------ | ---------------------- | ------------------------------ | | 配置粒度 | 方法/类级 | URL 路径模式 | URL 路径模式 | URL 路径模式 | | 作用层级 | MVC 拦截器层 | MVC 拦截器层 | Servlet Filter 层 | Security Filter 层(最先执行) | | 执行时机 | DispatcherServlet 分发后 | DispatcherServlet 分发后 | DispatcherServlet 之前 | Security 过滤链入口 | | 感知 `@CrossOrigin` | 是 | 合并 | 否 | 取决于配置源 | | 与 Spring Security 协同 | 弱(需额外 `.cors()`) | 弱(需额外 `.cors()`) | 中等 | 强(原生集成) | | 适用场景 | 个别接口定制 | 路径级统一策略 | 需要 Filter 层控制 | 安全项目必选 | **选型建议:** - 纯 Spring MVC 项目(无 Spring Security):首选 `WebMvcConfigurer.addCorsMappings()`,配合 `@CrossOrigin` 处理个别例外接口。 - Spring Security 项目:**必须**使用 Spring Security 的 `.cors()` + `CorsConfigurationSource`,可同时用 `@CrossOrigin` 做局部微调。 - 需要在 Filter 层统一控制(如自定义认证 Filter 之前处理 CORS):使用 `CorsFilter` Bean。 - 微服务/API Gateway 架构:在 Gateway 层统一处理,下游服务不重复配置(避免双重头问题,详见第六章)。 ------ ## 第五章 Spring Security 集成中的 CORS 深度剖析 ### 5.1 为什么 Spring Security 环境下 CORS 必须特殊处理 Spring Security 集成是 CORS 踩坑的最高频场景。问题的根源在于 `FilterChainProxy` 管理的 `SecurityFilterChain` 中,安全过滤器的执行顺序在 Spring MVC 的 CORS 处理之前(百度开发者中心, 2024, "SpringBoot中使用SpringSecurity时CorsFilter配置不生效的原因与解决方法", https://developer.baidu.com/article/detail.html?id=2767051)。 当未调用 `.cors()` 时,OPTIONS 预检请求的处理路径如下:浏览器发送 OPTIONS 请求,该请求**不携带 Cookie**(这是 CORS 规范的规定——预检请求永远不携带凭证,即使实际请求会携带),因此没有 `JSESSIONID`。请求首先进入 `SecurityFilterChain`,被 `UsernamePasswordAuthenticationFilter` 或 `BearerTokenAuthenticationFilter` 等认证过滤器拦截。由于没有 Session/Token,Security 判定用户未认证,返回 401 Unauthorized 或 403 Forbidden。浏览器收到不含正确 CORS 头的错误响应,判定预检失败,阻断实际请求。 调用 `http.cors(Customizer.withDefaults())` 后,Spring Security 会在 `SecurityFilterChain` 的**最前端**插入一个 `CorsFilter`,其位置在所有认证和授权过滤器之前。这样 OPTIONS 请求在进入认证逻辑之前,就已经被 `CorsFilter` 处理并返回了正确的 CORS 响应头,浏览器验证通过后发送实际请求,实际请求携带凭证进入正常的认证流程。 ### 5.2 CORS 过滤器在 SecurityFilterChain 中的精确位置 深入 `SecurityFilterChain` 的过滤器链,`CorsFilter` 的位置并非随意。Spring Security 内部维护了一个有序的过滤器列表,`CorsFilter`(通过 `.cors()` 启用)被插入到 `SecurityContextHolderFilter`(或旧版的 `SecurityContextPersistenceFilter`)之后、`UsernamePasswordAuthenticationFilter` 等认证过滤器之前。这一位置选择有其深意:CORS 处理不需要 `SecurityContext`(预检请求本就无认证信息),但必须在任何认证尝试之前完成,以确保预检请求不会被认证逻辑阻断。 这种精确位置也意味着:如果你的 Security 配置中有自定义过滤器需要访问 CORS 响应头或需要在 CORS 之后、认证之前执行,可以通过 `addFilterAfter()` 或 `addFilterBefore()` 精确控制位置。但一般而言,直接使用 `.cors()` 的默认位置即可满足绝大多数场景。 ### 5.3 无状态(Stateless)会话策略下的 CORS 在 RESTful API 中常见的无状态会话策略(`SessionCreationPolicy.STATELESS`)下,CORS 的配置有一些特殊考量。无状态意味着服务器不创建或使用 HTTP Session,每次请求都必须携带认证凭证(如 JWT Token)。在这种策略下,认证不依赖 `JSESSIONID`,而是依赖 `Authorization` 头中的 Bearer Token。 对于预检请求,`Authorization` 头不会出现在预检请求中(预检请求只包含 `Access-Control-Request-Method` 和 `Access-Control-Request-Headers`)。因此预检请求的拦截问题与有状态场景一致——都需要 `.cors()` 在认证之前处理。对于实际请求,`Authorization` 头由浏览器在跨域请求中携带(前提是前端 `fetch` 设置了 `credentials: 'include'` 或 `xhr.withCredentials = true`,且 CORS 配置了 `allowCredentials(true)` 和 `allowedHeaders` 包含 `Authorization`)。 值得注意的一个细节是:JWT Token 跨域传递比 Cookie 跨域传递阻力更小。因为 Token 放在 `Authorization` 头中而非 Cookie 头中,不受 `SameSite` Cookie 策略的影响——只要 CORS 配置正确允许 `Authorization` 头且 `allowCredentials(true)`,Token 就能正常传递。这也是为什么前后端分离架构在 JWT 认证模式下更常见的 CORS 友好性原因。 ### 5.4 Spring WebFlux 环境下的 CORS 上述讨论基于 Servlet 栈(Spring WebMVC)。对于 Spring WebFlux(响应式栈),CORS 的处理方式有细节差异。WebFlux 不使用 `DispatcherServlet` 和 `HandlerInterceptor`,而是使用 `WebFilter` 和 `HandlerFilterFunction`。Spring WebFlux 提供了 `CorsWebFilter`(对应 Servlet 栈的 `CorsFilter`),其配置方式类似: ```java @Configuration @EnableWebFluxSecurity public class WebFluxSecurityConfig { @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { return http .cors(cors -> cors.configurationSource(corsConfigurationSource())) // ... 其他配置 .build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); // ... 同 Servlet 栈配置 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } } ``` WebFlux 中的 `@CrossOrigin` 同样可用,Spring WebFlux 的 `WebHandler` 层会解析注解并应用 CORS 配置。在 Spring Cloud Gateway(基于 WebFlux)中,CORS 通常通过 `CorsWebFilter` 或 Gateway 的 `cors` 配置项处理,详见第十章的微服务架构讨论。 ------ ## 第六章 Cookie 与凭证传递深度 ### 6.1 allowCredentials=true 的浏览器行为 当服务器端配置 `allowCredentials(true)` 且响应头为 `Access-Control-Allow-Credentials: true` 时,浏览器在跨域请求中的行为有以下关键变化:实际请求(非预检)会携带 Cookie(包括 `JSESSIONID`、自定义 Cookie)和 HTTP 认证信息(如 Basic Auth 头);浏览器允许 JavaScript 读取响应内容。但有一个重要的规范规定——预检请求**永远不携带 Cookie**,即使 `allowCredentials=true`。这是 CORS 规范的硬性规定,预检请求的目的是探测服务器策略,不应携带任何用户身份信息(Mozilla, 2025, MDN CORS)。 前端配合方面,如果使用 `fetch()` API,需要显式设置 `credentials: 'include'`: ```javascript fetch('https://api.example.com/data', { credentials: 'include', // 跨域携带 Cookie headers: { 'Content-Type': 'application/json' } }); ``` 如果使用 `XMLHttpRequest`,需要设置 `xhr.withCredentials = true`: ```javascript const xhr = new XMLHttpRequest(); xhr.withCredentials = true; xhr.open('GET', 'https://api.example.com/data'); xhr.send(); ``` ### 6.2 Access-Control-Allow-Origin 精确匹配的安全原理 在凭证模式下,`Access-Control-Allow-Origin` 不能为 `*`,必须精确回显请求方的 Origin。这一限制的深层安全原理在于防止"凭证泄露攻击"。 如果 `Access-Control-Allow-Origin` 为 `*`,浏览器无法将响应关联到特定的请求来源,意味着任何恶意网站都可以通过用户的浏览器携带 Cookie 访问受保护资源,而浏览器无法区分合法和非法来源。精确匹配 Origin 确保了响应只对发起该跨域请求的页面可见,形成了"请求-响应"的强绑定关系。Spring 的 `checkOrigin()` 方法在 `allowCredentials=true` 时强制执行这一约束——这也是 `allowedOrigins="*" + allowCredentials=true` 抛出 `IllegalArgumentException` 的根本原因。 ### 6.3 SameSite Cookie 属性:跨域凭证的第二道防线 现代浏览器 Chrome 80+ 默认 `SameSite=Lax` 的 Cookie 策略构成了跨域凭证传递的第二道防线(CSDN/Eward-an, 2026, "前端跨域进阶:CORS实战避坑", https://blog.csdn.net/an524415864/article/details/161681018)。即使后端正确设置了 `Access-Control-Allow-Credentials: true`,如果 Cookie 缺少 `SameSite=None; Secure` 属性,浏览器仍然不会在跨域请求中携带该 Cookie。 `SameSite` 属性有三个值:`Strict`(完全禁止跨站携带,即使导航链接也不携带)、`Lax`(默认值,允许顶级导航的 GET 请求携带,但禁止跨域 XHR/fetch 携带)、`None`(允许跨站携带,但必须同时设置 `Secure`)。对于需要在跨域 AJAX 请求中传递的 Cookie(如 `JSESSIONID`),必须设置为 `SameSite=None; Secure`。 这意味着现代跨域认证需要"双层配置":CORS 层面的凭证允许(`allowCredentials=true` + 精确 `Access-Control-Allow-Origin`)+ Cookie 层面的跨站放行(`SameSite=None; Secure`),两者缺一不可。对于 `JSESSIONID` 的跨域传递场景,这要求 Servlet 容器的 Session Cookie 配置也必须相应调整。在 Spring Boot 中可通过以下方式配置: ```java @Bean public WebServerFactoryCustomizer<TomcatServletWebServerFactory> cookieCustomizer() { return factory -> factory.addContextCustomizers(context -> { context.setSessionCookieDomain(".example.com"); // Spring Boot 3.x 中配置 SameSite // 注意:Servlet 6.0 规范尚未直接支持 SameSite,需通过 Tomcat 的 CookieProcessor }); } ``` 或在响应拦截器中手动设置: ```java @Component public class SameSiteCookieFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { filterChain.doFilter(request, response); Collection<String> headers = response.getHeaders(HttpHeaders.SET_COOKIE); if (!headers.isEmpty()) { response.setHeader(HttpHeaders.SET_COOKIE, headers.stream().map(h -> h + "; SameSite=None; Secure") .collect(Collectors.joining(","))); } } } ``` ### 6.4 OAuth2 / JWT Token 跨域传递 对于使用 OAuth2 或 JWT Token 进行认证的系统,Token 通常通过 `Authorization: Bearer <token>` 头传递,而非 Cookie。这种模式在跨域场景下比 Cookie 模式更为简洁——Token 不受 `SameSite` 限制,只要 CORS 配置中 `allowedHeaders` 包含 `Authorization` 且 `allowCredentials=true`(或对于纯 Token 无 Cookie 场景甚至可以用 `allowCredentials=false` + `allowedOrigins="*"`),Token 即可正常传递。 但需要注意:如果 Token 是通过 HttpOnly Cookie 自动携带的(如某些 OAuth2 实现),则回到 Cookie 跨域问题,需要 `SameSite=None; Secure` 配置。建议跨域认证场景优先考虑 Bearer Token + `Authorization` 头模式,以规避 Cookie 跨域的复杂性。 ------ ## 第七章 安全视角:CORS 配置风险与防护 ### 7.1 CORS 配置不当导致的安全漏洞 CORS 配置的不当是一个被严重低估的安全风险。奇安信与清华大学的联合测量研究(A级学术研究)显示,全球约 27.5% 的 CORS 配置网站存在不安全配置,研究识别出 7 类典型误配置模式。其中最危险的配置是:将 `Access-Control-Allow-Origin` 反射为请求方的 Origin 同时开启凭证传递。这种配置实质上完全绕过了同源策略——任何恶意网站都可以通过用户浏览器携带其 Cookie 访问该 API,读取敏感数据。 更值得警惕的是,调研发现 11 款主流 CORS 框架中有 8 款在遇到 `Origin:* + Credentials:true` 这种矛盾配置时,会自动转为反射 Origin(CVE-2018-8014),这构成了一个隐蔽但严重的安全漏洞。虽然 Spring Framework 的处理方式是直接抛出 `IllegalArgumentException` 阻止此类配置,但开发者如果使用自定义 Filter 绕过 Spring 的 CORS 机制,仍可能无意中实现反射 Origin 的危险行为。 ### 7.2 CORS 配置安全审计要点 基于安全研究结论,CORS 配置审计应关注以下要点: **审计点一:Origin 反射检测。** 检查响应头 `Access-Control-Allow-Origin` 是否始终等于请求 `Origin` 头的值。如果是,且 `Access-Control-Allow-Credentials: true`,则存在严重的安全漏洞。合法的配置应该维护一个允许源的白名单,而非无条件反射。 **审计点二:白名单过宽。** 检查 `allowedOrigins` 是否包含不必要的通配符模式(如 `https://*.com`),这实际上允许了几乎所有 HTTPS 网站。应仅允许业务必需的具体域名。 **审计点三:凭证模式下的头暴露。** 当 `allowCredentials=true` 时,检查 `exposedHeaders` 是否暴露了敏感的内部头(如 `X-Debug-Info`、`Server` 版本信息)。 **审计点四:预检缓存的 `maxAge` 过长。** 过长的 `maxAge` 意味着 CORS 策略变更后需要更长时间才能在客户端生效。生产环境建议 3600 秒(1 小时)到 86400 秒(24 小时)之间,平衡性能和安全策略生效速度。 **审计点五:`null` Origin 处理。** 某些场景(如 `file://` 协议、sandbox iframe)下 `Origin` 头为 `null`。如果 CORS 配置允许 `null` Origin,可能被利用从本地文件执行跨域读取。应在白名单中拒绝 `null`。 ### 7.3 CSRF 与 CORS 的关系和区别 CSRF(Cross-Site Request Forgery)和 CORS 经常被混淆,但它们是不同的安全机制。CSRF 攻击是利用用户已登录的身份,诱导用户浏览器在不知情的情况下发送请求(如通过隐藏表单自动提交到银行转账 API)。CSRF 防护通常通过 CSRF Token(同步令牌模式)或 `SameSite` Cookie 实现。 CORS 与 CSRF 的关系在于:CORS 是浏览器对**读取**跨域响应的限制,而 CSRF 防护是对**发送**跨域请求的限制。关键点是——**即使没有 CORS 配置(服务器不返回 CORS 头),浏览器仍然可以发送跨域请求(如 form 提交、img 标签),只是 JavaScript 无法读取响应**。这意味着 CSRF 攻击不依赖 CORS 配置漏洞,CORS 不是 CSRF 的防护机制。 在 Spring Security 中,跨域场景通常需要禁用 CSRF(`http.csrf(csrf -> csrf.disable())`)或配置 CSRF 的 CORS 兼容性,因为 CSRF Token 默认通过 Cookie 或 Header 传递,跨域时可能产生冲突。正确做法是:跨域 API 认证依赖 Bearer Token(不受 CSRF 影响),并禁用基于 Session 的 CSRF 防护。 ------ ## 第八章 常见错误与排查指南 ### 8.1 "No 'Access-Control-Allow-Origin' header is present" 这是最常见的 CORS 错误,浏览器错误信息通常为:"Access to fetch at '...' from origin '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource." **可能原因与排查:** 1. **CORS 配置未生效。** 最常见的原因。排查步骤:检查是否在 Spring Security 中调用了 `.cors()`;检查 `WebMvcConfigurer.addCorsMappings()` 的路径模式是否匹配请求路径;检查 `@CrossOrigin` 是否加在了正确的 Controller/方法上。验证方法:在 Controller 方法中打日志确认请求是否到达——如果请求未到达 Controller,说明在 Filter 链或 Security 链中被拦截。 2. **Origin 不在允许列表。** 检查请求的 `Origin` 头是否确实在 `allowedOrigins` 或匹配 `allowedOriginPatterns`。注意子域差异:`https://app.example.com` 与 `https://example.com` 是不同的源。验证方法:在 `CorsConfiguration` 的 `checkOrigin` 逻辑处打断点或添加日志。 3. **异常导致 CORS 头未注入。** 如果 Controller 抛出未处理异常,Spring 的异常处理流程可能不会注入 CORS 响应头,导致浏览器看到"无 CORS 头"错误。排查方法:检查服务器日志是否有异常;在 `@ControllerAdvice` 全局异常处理器中确保异常响应也携带 CORS 头(或确保 `CorsFilter` 在异常处理之前已注入头)。 4. **路径模式不匹配。** `addCorsMappings("/api/**")` 不匹配 `/api-v2/data`(虽然 `/api-v2` 不匹配 `/api/**`)。仔细检查 Ant 风格路径模式的匹配范围。 ### 8.2 "The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*'" 完整错误:"The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'." **根因:** 前端使用了 `credentials: 'include'`,但服务器返回了 `Access-Control-Allow-Origin: *`。这在凭证模式下被禁止。 **解决方案:** - 后端从 `allowedOrigins("*")` 改为 `allowedOriginPatterns("*")`(Spring Boot 2.4+/3.x),框架会自动精确回显 Origin 而非 `*`。 - 或显式列出允许的源:`allowedOrigins("https://app.example.com", "https://admin.example.com")`。 - 前端如果不需要携带凭证,改为 `credentials: 'omit'`(`fetch` 默认值),则服务器可以继续用 `*`。 ### 8.3 "The 'Access-Control-Allow-Origin' header contains multiple values" 完整错误:"The 'Access-Control-Allow-Origin' header contains multiple values 'https://a.com, https://b.com', but only one is allowed." **根因:** 服务器(或反向代理链)返回了重复的 `Access-Control-Allow-Origin` 头。CORS 规范要求该头只能出现一次。 **常见场景:** - Nginx 层和 Spring Boot 应用层都配置了 CORS,各自添加了一个 `Access-Control-Allow-Origin` 头。 - Spring Cloud Gateway 和下游服务同时配置 CORS。 - 过滤器链中多个 Filter 各自添加 CORS 头。 **解决方案:** 确保整个请求链路上只有一个组件处理 CORS。推荐在 API Gateway/Nginx 层统一处理,并在下游服务中移除 CORS 配置。如果必须多层配置,使用 Nginx 的 `proxy_hide_header` 指令隐藏下游返回的 CORS 头,由 Nginx 层重新添加。 ### 8.4 预检请求失败(OPTIONS 返回 401/403) **根因:** 最常见于 Spring Security 环境,OPTIONS 预检请求被认证过滤器拦截。 **排查与解决:** - 确认 `SecurityFilterChain` 中调用了 `.cors(Customizer.withDefaults())`。 - 确认提供了 `CorsConfigurationSource` Bean(或 Spring MVC 的 CORS 配置存在,Security 会复用)。 - 检查是否有自定义 Filter 在 `CorsFilter` 之前拦截了 OPTIONS 请求——通过 `addFilterBefore` 确保自定义 Filter 在 CORS 之后。 ### 8.5 WebSocket 跨域与 CORS 的关系 一个容易被忽略的事实是:WebSocket 跨域**独立于** HTTP CORS 机制(WHATWG, 2024, "Fetch Standard — CORS Protocol", https://fetch.spec.whatwg.org/)。WebSocket 协议在握手阶段使用 HTTP Upgrade 请求,但浏览器对 WebSocket 连接不应用同源策略——即 WebSocket 连接不受 CORS 限制。前端 JavaScript 可以向任意源的 WebSocket 端点发起连接,无需服务器配置 CORS。 然而,WebSocket 服务器仍应验证连接的 `Origin` 头以防止跨站 WebSocket 劫持攻击(CSWSH)。Spring 的 WebSocket 支持提供了 `setAllowedOrigins()` 配置来限制允许的源,这是应用层安全措施,非浏览器 CORS 机制。 ### 8.6 文件上传跨域的特殊处理 文件上传使用 `multipart/form-data` Content-Type,理论上是 CORS 安全列表内的 Content-Type,不应触发预检请求。但实践中常见意外触发预检的情况——原因是部分前端框架(如 jQuery、某些 axios 配置)会在 `Content-Type` 后追加 `; charset=UTF-8`,使其变为 `multipart/form-data; charset=UTF-8`,这超出了 CORS 安全列表,触发预检请求(CSDN/Eward-an, 2026, "前端跨域进阶:CORS实战避坑", https://blog.csdn.net/an524415864/article/details/161681018)。 **解决方案:** 前端避免为 `multipart/form-data` 设置 `charset` 后缀;后端在 `allowedHeaders` 中包含 `Content-Type`;或使用 `allowedHeaders("*")` 简化配置。 ------ ## 第九章 Spring Boot 2.x → 3.x 的 CORS 迁移变更 ### 9.1 Jakarta EE 迁移的影响 Spring Boot 3.0 基于 Spring Framework 6.x,要求 Java 17+,并将底层从 `javax.*` 命名空间全面迁移到 `jakarta.*`(百度智能云, 2025, "Spring Boot 2与3版本差异深度解析", https://cloud.baidu.com/article/4467747)。这意味着所有 Servlet API 相关的类从 `javax.servlet.*` 变为 `jakarta.servlet.*`。 对于 CORS 配置的直接影响:Spring 内置的 CORS 机制(`@CrossOrigin`、`WebMvcConfigurer`、`CorsFilter`)本身不直接依赖 `javax`/`jakarta` 的区别,它们的 API 表面保持一致,因此对于使用标准 Spring CORS 配置的项目,Jakarta 迁移**不会**导致 CORS 逻辑变更。但如果项目中有**自定义 Filter**实现 CORS(直接实现 `javax.servlet.Filter`),导入包名必须从 `javax.servlet.*` 改为 `jakarta.servlet.*`,否则编译失败。 间接影响体现在 Spring Security 集成上。Spring Security 6.x(Spring Boot 3.x 搭配版本)也完成了 Jakarta 迁移,所有 Security 相关的 Filter 和 Servlet API 引用都使用 `jakarta.*`(腾讯云, 2025, "Spring Boot 2 和 Spring Boot 3 中使用 Spring Security 的区别", https://cloud.tencent.com/developer/article/2486669)。自定义的 Security Filter 需相应更新 import。 ### 9.2 allowedOrigins="*" + allowCredentials=true 限制 这一限制实际上在 Spring Framework 5.3(Spring Boot 2.4.0)就已经强制生效,而非 Spring Boot 3.0 才引入。但由于 Spring Boot 2.4 之前的版本已不再被维护,当前所有活跃版本(2.4+ 和 3.x)均受此限制约束。从 2.x 早期版本(如 2.3.x)迁移到 3.x 的项目,可能需要进行 CORS 配置调整。 报错信息为:`java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. Use allowedOriginPatterns instead.`(CSDN/qq_73965541, 2025, "allowCredentials=true 与 allowedOrigins='*' 的冲突", https://blog.csdn.net/qq_73965541/article/details/150211871)。 **迁移改动:** ```java // Spring Boot 2.3 及更早版本(已过时,2.4+ 抛异常) config.addAllowedOrigin("*"); config.setAllowCredentials(true); // Spring Boot 2.4+ / 3.x 正确写法 config.addAllowedOriginPattern("*"); config.setAllowCredentials(true); ``` ### 9.3 allowedOriginPatterns 的引入 `allowedOriginPatterns` 从 Spring Framework 5.3(Spring Boot 2.4.0)引入(Baeldung, 2024, "CORS with Spring", https://www.baeldung.com/spring-cors),Spring Framework 6.x(Spring Boot 3.x)完全继承支持。它支持通配符模式匹配,解决了 `allowedOrigins` 无法与 `allowCredentials` 同时使用通配符的痛点。常用模式示例: - `"*"`:匹配所有源(配合 `allowCredentials=true` 安全使用,框架会精确回显 Origin) - `"https://*.example.com"`:匹配所有 example.com 子域 - `"https://*.example.com:8080"`:匹配特定端口 - `"https://app1.example.com, https://app2.example.com"`:在注解中使用逗号分隔多源 ### 9.4 Spring Security 5.x → 6.x 配置语法变化 Spring Security 6.x(Spring Boot 3.x)废弃了 5.x 的链式 DSL 写法,推荐 Lambda 风格: ```java // Spring Security 5.x(Spring Boot 2.x)写法 — 已废弃 @Override protected void configure(HttpSecurity http) throws Exception { http.cors().and() .csrf().disable() .authorizeRequests() .antMatchers("/public/**").permitAll() .anyRequest().authenticated(); } // Spring Security 6.x(Spring Boot 3.x)写法 — 推荐 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(Customizer.withDefaults()) .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ); return http.build(); } ``` 关键变化点:`antMatchers()` → `requestMatchers()`;`authorizeRequests()` → `authorizeHttpRequests()`;`WebSecurityConfigurerAdapter` 被移除,全部改为 `SecurityFilterChain` Bean 方式配置。这些变化不影响 CORS 的核心配置(`.cors()` 的语义不变),但需要适配新的 DSL 语法。 ------ ## 第十章 性能优化与架构考量 ### 10.1 预检请求对 API 延迟的影响 预检请求为每个复杂的跨域请求增加了一次额外的 OPTIONS 往返。根据网络条件,一次往返通常需要 1-2 个 RTT(Round-Trip Time)。对于高频 API 调用,如果每次都触发预检,累积的延迟开销不可忽视。 然而,预检请求的开销主要发生在**首次请求**——通过 `Access-Control-Max-Age` 的合理配置,后续同类请求可跳过预检。`maxAge` 指定预检结果可被浏览器缓存的秒数,在此期间,对同一来源的相同跨域请求(方法+头组合),浏览器不会再次发送预检请求。 ### 10.2 maxAge 配置建议 不同浏览器对 `maxAge` 的上限有不同限制:Chrome 的上限在 v76 前后分别为 600 秒(10 分钟)和 7200 秒(2 小时);Firefox 上限为 86400 秒(24 小时);Safari 上限为 604800 秒(7 天)(Mozilla, 2025, MDN CORS)。这意味着即使服务端设置更大的值,浏览器端实际缓存时间也受浏览器实现约束。 **生产环境推荐:** - 敏感 API:3600 秒(1 小时)——平衡性能和安全策略生效速度,策略变更后 1 小时内生效。 - 公开 API:86400 秒(24 小时)——最大化缓存命中率,但策略变更生效较慢(Firefox 和 Safari 可以,Chrome 仍 2 小时上限)。 - 开发环境:120 秒(2 分钟)——便于频繁调整策略即时生效。 ### 10.3 减少预检请求的最佳实践 除了 `maxAge` 缓存,还可以从请求层面减少预检触发: **实践一:避免不必要的自定义头。** 如果某些头非必需,移除以使请求变为简单请求。例如,优先使用标准 `Accept` 而非 `X-Accept-Version`。 **实践二:使用简单 Content-Type。** 如果 API 支持,使用 `text/plain` 或 `application/x-www-form-urlencoded` 而非 `application/json`。但现代 API 普遍需要 JSON,此实践适用性有限。 **实践三:合并 API 调用。** 将多个小 API 合并为一个批量 API,减少总请求数(同时减少预检数)。 **实践四:相同源的代理。** 前端开发环境使用 webpack-dev-server 或 Vite 的 proxy 功能将 API 请求代理到后端,使浏览器视角下跨域请求转为同源。 ### 10.4 反向代理 vs 应用层处理 CORS | 维度 | 反向代理层(Nginx/Gateway) | 应用层(Spring Boot) | | -------------- | ---------------------------------- | ---------------------------- | | 性能 | 请求无需到达应用层即可完成预检响应 | 每个请求需经过完整 Filter 链 | | 配置集中度 | 所有微服务共享统一 CORS 策略 | 每个服务需独立维护 | | 与安全框架协同 | 需确保不与应用层 Security 冲突 | 与 Spring Security 无缝集成 | | 细粒度控制 | 仅路径级 | 注解级(`@CrossOrigin`) | | 适用场景 | 微服务/API Gateway 架构 | 单体应用或简单架构 | ### 10.5 Spring Cloud Gateway 的 CORS 冲突处理 在微服务架构中,Spring Cloud Gateway 与下游 Spring Boot 服务同时配置 CORS 时,会产生"双重 CORS 头"问题——Gateway 和下游服务各添加一个 `Access-Control-Allow-Origin` 头,浏览器因"multiple values"错误拒绝请求。 **推荐架构:** 在 Gateway 层统一处理 CORS,下游服务移除 CORS 配置: ```yaml # Spring Cloud Gateway application.yml spring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowedOrigins: "https://app.example.com" allowedMethods: "*" allowedHeaders: "*" allowCredentials: true maxAge: 3600 ``` 下游服务移除所有 `@CrossOrigin`、`WebMvcConfigurer` 和 `CorsFilter` 配置。如果下游服务也使用 Spring Security,在 Security 配置中调用 `.cors(Customizer.withDefaults())` 但不提供 `CorsConfigurationSource` Bean——Gateway 层已处理 CORS,下游的 Security 仅需放行预检请求即可(预检请求到达下游时已无 `Origin` 头,或 Gateway 已剥离)。 ------ ## 第十一章 综合分析与跨主题洞察 ### 11.1 Spring CORS 体系的分层设计哲学 纵观 Spring Framework 的 CORS 实现,可以清晰地看到"关注点分离"(Separation of Concerns)的设计哲学。CORS 处理被同时部署在 Servlet Filter 层(`CorsFilter`)和 MVC Handler 层(`CorsInterceptor`/`PreFlightHandler`)两个位置,这种双轨设计并非冗余,而是服务于不同场景:`CorsFilter` 适用于安全框架集成场景,它可以在请求到达 DispatcherServlet 之前就完成 CORS 处理;`CorsInterceptor` 适用于纯 Spring MVC 场景,它能够访问 Handler 级别的 `@CrossOrigin` 注解信息,实现细粒度配置合并。`DefaultCorsProcessor` 作为两个执行路径的共享内核,统一了 CORS 校验逻辑,避免了行为不一致的风险。这种"多层部署 + 共享内核"的架构,使 Spring 能够在从简单 Web 应用到复杂微服务的各种场景下保持 CORS 行为的一致性和可预测性。 ### 11.2 凭证传递的多层防线 跨域凭证传递的安全防线不止一道,理解这些防线的层次性对正确配置至关重要。第一道防线是 CORS 协议本身——`allowCredentials=true` 时的精确 Origin 匹配要求。第二道防线是浏览器的 `SameSite` Cookie 策略——Chrome 80+ 默认 `Lax` 策略。第三道防线是 Spring Security 的过滤链顺序——`CorsFilter` 必须在认证过滤器之前。这三道防线相互补充:即使 CORS 配置正确,如果 Cookie 缺少 `SameSite=None; Secure`,跨域凭证仍无法传递;即使 Cookie 配置正确,如果 Security 未调用 `.cors()`,预检请求仍会被拦截。开发者需要同时关注这三层配置,任何一层的缺失都会导致跨域凭证传递失败。 ### 11.3 OAuth2/JWT 与 Cookie Session 的 CORS 友好性差异 一个值得强调的实践结论是:使用 Bearer Token(通过 `Authorization` 头传递)的认证模式在跨域场景下比 Cookie Session 模式更为简洁。Token 不受 `SameSite` 限制,也不需要 Cookie 跨域配置,只要 CORS 允许 `Authorization` 头即可。这降低了配置复杂度和踩坑概率。对于必须使用 Cookie Session 的场景(如传统的服务端渲染架构),则需要同时处理 CORS 凭证配置和 Cookie `SameSite` 配置,复杂度显著更高。这一差异也是前后端分离架构在 JWT 认证模式下更为常见的原因之一。 ### 11.4 微服务架构下的 CORS 治理 在微服务架构中,CORS 配置的治理需要"单一责任原则"——整个请求链路上应只有一个组件负责 CORS 处理。违反这一原则的最常见后果是"双重 CORS 头"导致的浏览器拒绝。推荐架构是在 API Gateway 层集中处理 CORS,下游服务不再配置 CORS(或仅保留 Spring Security 的 `.cors()` 以放行预检请求)。这种集中式治理不仅避免了冲突,还使 CORS 策略变更可以在一个位置统一执行,运维效率更高。 ------ ## 第十二章 研究局限与未来方向 ### 12.1 研究局限 本研究主要基于公开可获取的技术文档、源码分析和社区实践报告,未涉及 Spring Framework 源码的逐行审计或大规模生产环境的实证测量。在 CORS 安全风险章节引用的奇安信/清华大学测量研究虽然提供了量化的不安全配置比例,但其测量时间点和样本范围可能不完全代表当前最新状况。此外,Spring Security 7.x 的 `PreFlightRequestHandler` 机制在调研时仍处于演进中,相关公开资料有限,本报告对其的描述基于已有信息和趋势推断,实际发布版本可能有差异。 ### 12.2 需要进一步研究的领域 以下几个方向值得进一步深入调研:第一,Spring Framework 6.x 对 GraalVM Native Image 的支持对 CORS 配置的影响——Native Image 的静态分析可能影响 `CorsAutoConfiguration` 的自动配置行为。第二,HTTP/3(QUIC)协议下预检请求的行为是否有差异——QUIC 的连接复用可能改变预检请求的延迟模型。第三,CORS 与 Subresource Integrity(SRI)等新兴 Web 安全机制的交互。第四,新兴的 Cross-Origin-Embedder-Policy(COEP)和 Cross-Origin-Opener-Policy(COOP)等安全头与 CORS 的协同配置最佳实践。 ------ ## 第十三章 结论与实践建议 ### 13.1 核心结论 Spring Boot 3.x 为 CORS 处理提供了成熟且全面的内置支持,通过分层架构(Filter 层 + MVC 拦截器层)和共享校验内核(`DefaultCorsProcessor`)实现了灵活性与一致性的统一。四种配置方式(`@CrossOrigin`、`WebMvcConfigurer`、`CorsFilter`、Spring Security `.cors()`)覆盖了从方法级到全局、从纯 MVC 到安全集成的各种场景,开发者应根据项目复杂度和安全框架依赖选择合适的方式或组合。 跨域凭证传递是 CORS 最复杂也最容易出错的领域,涉及 CORS 协议、Cookie `SameSite` 策略和 Spring Security 过滤链顺序三层配置。`allowedOrigins="*" + allowCredentials=true` 的限制是 W3C 规范的硬性要求,Spring 从 2.4.0 起强制执行,`allowedOriginPatterns` 是推荐的安全替代方案。 CORS 配置不当构成实质性的安全风险——27.5% 的 CORS 网站存在不安全配置,最危险的是 Origin 反射。安全审计应重点关注 Origin 反射、白名单宽度、凭证模式下的头暴露和 `null` Origin 处理。 ### 13.2 实践建议清单 **配置选择:** 1. 纯 MVC 项目:`WebMvcConfigurer.addCorsMappings()` 全局配置 + `@CrossOrigin` 个别微调。 2. Spring Security 项目:**必须** `.cors(Customizer.withDefaults())` + `CorsConfigurationSource` Bean。 3. 微服务架构:Gateway 层集中处理 CORS,下游服务移除 CORS 配置。 **凭证模式:** 4. `allowCredentials=true` 时,使用 `allowedOriginPatterns` 替代 `allowedOrigins("*")`。 5. Cookie 跨域传递需同时设置 `SameSite=None; Secure`。 6. 优先考虑 Bearer Token + `Authorization` 头模式,规避 Cookie 跨域复杂性。 **安全防护:** 7. `allowedOrigins` 使用明确的白名单,避免过宽的通配符(如 `*.com`)。 8. 拒绝 `null` Origin。 9. 定期审计 `exposedHeaders`,避免暴露敏感内部头。 10. `maxAge` 生产环境建议 3600-86400 秒。 **迁移适配:** 11. Spring Boot 2.x → 3.x:`javax.*` → `jakarta.*`(自定义 Filter 需更新 import)。 12. Spring Security 5.x → 6.x:链式 DSL → Lambda DSL,`antMatchers` → `requestMatchers`。 13. 迁移后检查 `allowedOrigins("*") + allowCredentials(true)` 配置,改为 `allowedOriginPatterns("*")`。 ------ ## 参考文献 | 编号 | 作者/组织 | 日期 | 标题 | URL | 质量评级 | | ---- | ------------------------ | ---------- | ------------------------------------------------------------ | ------------------------------------------------------------ | -------- | | 1 | Mozilla | 2025-11-30 | Cross-Origin Resource Sharing (CORS) — HTTP \| MDN | https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS | A | | 2 | WHATWG | 2024 | Fetch Standard — CORS Protocol | https://fetch.spec.whatwg.org/ | A | | 3 | Spring (VMware/Broadcom) | 2024 | CORS — Spring Framework Reference | https://docs.spring.io/spring-framework/reference/web/webmvc-cors.html | A | | 4 | Spring (VMware/Broadcom) | 2024 | CORS — Spring Security Reference 7.1 | https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html | A | | 5 | Baeldung | 2024-05-11 | CORS with Spring | https://www.baeldung.com/spring-cors | B | | 6 | 百度智能云 | 2025-10-24 | Spring Boot 2与3版本差异深度解析:技术演进与迁移指南 | https://cloud.baidu.com/article/4467747 | B | | 7 | 腾讯云 | 2025-01-13 | Spring Boot 2 和 Spring Boot 3 中使用 Spring Security 的区别 | https://cloud.tencent.com/developer/article/2486669 | B | | 8 | CSDN/qq_73965541 | 2025-08-11 | Spring Boot 跨域配置踩坑记:allowCredentials=true 与 allowedOrigins='*' 的冲突 | https://blog.csdn.net/qq_73965541/article/details/150211871 | C | | 9 | 出门向左 | 2017 | spring MVC cors跨域实现源码解析 | https://www.cnblogs.com/leftthen/p/6378090.html | B | | 10 | 百度开发者中心 | 2024 | SpringBoot中使用SpringSecurity时CorsFilter配置不生效的原因与解决方法 | https://developer.baidu.com/article/detail.html?id=2767051 | B | | 11 | CSDN/ximeneschen | 2022 | @CrossOrigin及其实现跨域原理 | https://blog.csdn.net/cristianoxm/article/details/124840435 | C | | 12 | CSDN/好运仔dzl | 2025 | SpringBoot源码解析(二十三):跨域处理CorsFilter的自动注册原理 | https://blog.csdn.net/qq_50954361/article/details/148469418 | C | | 13 | CSDN/Eward-an | 2026 | 前端跨域进阶:CORS实战避坑 | https://blog.csdn.net/an524415864/article/details/161681018 | C | | 14 | 奇安信/清华大学 | — | CORS 安全大规模测量研究(CVE-2018-8014 相关) | 学术文献 | A | | 15 | Spring Projects GitHub | 持续更新 | spring-framework 源码仓库 | https://github.com/spring-projects/spring-framework | A | ------ ## 附录 A:配置速查表 ### A.1 快速选型决策树 ``` 项目中有 Spring Security? ├─ 是 → 在 SecurityFilterChain 中调用 .cors(Customizer.withDefaults()) │ └─ 提供 CorsConfigurationSource Bean(或复用 Spring MVC 配置) │ └─ 需要方法级微调? → 加 @CrossOrigin │ └─ 否 → 需要路径级统一配置? ├─ 是 → WebMvcConfigurer.addCorsMappings() │ └─ 需要方法级微调? → 加 @CrossOrigin └─ 否 → 需要在 Filter 层控制? ├─ 是 → 注册 CorsFilter Bean └─ 否 → 直接用 @CrossOrigin(少量接口) ``` ### A.2 常见配置组合速查 | 场景 | 配置 | | ------------------------- | ------------------------------------------------------------ | | 公开 API,允许任意源 | `allowedOrigins("*")`, `allowCredentials(false)` | | 内部应用,允许特定源 | `allowedOrigins("https://app.example.com")`, `allowCredentials(true)` | | 多子域 + 凭证 | `allowedOriginPatterns("https://*.example.com")`, `allowCredentials(true)` | | 所有源 + 凭证(安全模式) | `allowedOriginPatterns("*")`, `allowCredentials(true)` | | 微服务 Gateway | Gateway 层配置 CORS,下游移除 | ### A.3 maxAge 推荐值 | 环境 | maxAge(秒) | 说明 | | ------------- | ------------ | ---------------------- | | 开发环境 | 120 | 便于频繁调整策略 | | 敏感 API 生产 | 3600 | 1 小时,平衡性能与安全 | | 公开 API 生产 | 86400 | 24 小时,最大化缓存 | > **注意**:Chrome 上限 7200 秒,Firefox 上限 86400 秒,Safari 上限 604800 秒。 ------ ## 附录 B:源码关键类索引 | 类名 | 模块 | 作用 | | ----------------------------------------- | --------------- | -------------------------------------------------- | | `AbstractHandlerMapping` | spring-webmvc | CORS 处理入口,`getHandler()` 方法 | | `CorsUtils` | spring-web | `isCorsRequest()`, `isPreFlightRequest()` 判断方法 | | `CorsConfiguration` | spring-web | CORS 配置模型,`combine()` 合并逻辑 | | `UrlBasedCorsConfigurationSource` | spring-web | 基于 URL 模式的配置源 | | `DefaultCorsProcessor` | spring-web | 校验内核,`handleInternal()` 三段式校验 | | `CorsInterceptor` | spring-webmvc | MVC 拦截器层 CORS 处理 | | `CorsFilter` | spring-web | Servlet Filter 层 CORS 处理 | | `AbstractHandlerMapping.PreFlightHandler` | spring-webmvc | 预检请求专用 Handler | | `CorsAutoConfiguration` | spring-boot | Spring Boot 自动配置 | | `CorsConfigurationSource` | spring-security | Security 集成配置源接口 | ------ ## 附录 C:三阶段调研验证总结 | 核心声明 | 验证结果 | 共识强度 | 关键来源 | | ------------------------------------------------------------ | -------- | -------- | --------------------------------------------------- | | Spring MVC HandlerMapping 内置 CORS(DefaultCorsProcessor 统一处理) | 验证通过 | 强共识 | Spring 官方文档 + 源码分析[3][9] | | `allowedOrigins="*" + allowCredentials=true` 抛 IllegalArgumentException(Spring Boot 2.4+) | 验证通过 | 极强共识 | 7+ 来源 + W3C 规范[1][2][5][8] | | Spring Security 必须调用 `.cors()`,否则预检 401/403 | 验证通过 | 强共识 | Spring Security 文档 + FilterChainProxy 分析[4][10] | | `allowedOriginPatterns` 自 Spring Boot 2.4.0 引入 | 验证通过 | 中高共识 | Baeldung + Spring 文档[3][5] | | Cookie 跨域需 SameSite=None;Secure + CORS 双层配置 | 验证通过 | 极强共识 | MDN + Chrome 80 变更 + 实践报告[1][13] | | maxAge:Chrome 上限 7200 秒,Firefox 86400 秒 | 验证通过 | 极强共识 | MDN 官方文档[1] | | Gateway 与下游双重 CORS 配置导致重复头 | 验证通过 | 强共识 | 多实践报告 + 解决方案验证 | ------

🛰️ dtSpaceMap - 实时卫星追踪 3D 可视化平台

![3d可视化.png](https://pic.code-nav.cn/post_picture/1944355748262088705/bv9C7jugZSzzt1q7.webp) > 基于 Three.js 构建的实时卫星追踪 3D 可视化 Web 平台,支持 **30,000+ 卫星对象同时渲染**,帧率 ≥30fps。提供星座管理、碰撞预警、过境预测、TLE 解析、摄影炸弹、坐标转换等专业工具链。 ## 架构概览 ### 分层架构 (Layered Architecture) ![分层架构图.png](https://pic.code-nav.cn/post_picture/1944355748262088705/ygoyQXZTu0Hvnizx.webp) ### 通信架构 (Communication Architecture) ![通信架构图.png](https://pic.code-nav.cn/post_picture/1944355748262088705/knUlcM3NtPAoLF3h.webp) ## 核心业务流程 ### 1. 卫星数据同步与加载流程 ```mermaid graph TB A[用户点击分类加载] --> B{fetchLiveCategoryData} B --> C[Step 1: POST /api/public/sync/groups] C --> D{后端检查 sync_tracking} D -->|6小时内已同步| E[跳过拉取] D -->|未同步或过期| F[从 CelesTrak 拉取 TLE] F --> G[解析 TLE 文本] G --> H[批量写入数据库] H --> I[更新 sync_tracking 记录] E --> J[Step 2: GET /api/public/satellites/with-tle-by-groups] I --> J J --> K[从数据库加载卫星数据] K --> L{数据是否足够?} L -->|是| M[填充 Pinia Store] L -->|否| N[Step 3: 前端直连 CelesTrak 回退] N --> M M --> O[VisualizerView 桥接] O --> P[loadRealSatellites] P --> Q[创建 GPU InstancedMesh] Q --> R[初始化 SGP4 Web Worker] R --> S[实时渲染循环] ``` > **定时任务补充**: XXL-JOB 每天凌晨 02:00 自动执行增量同步,检查 `sync_tracking` 表,跳过 6 小时内已同步的分组。用户再次加载同一分类时,命中 `sync_tracking` 记录直接返回数据库数据。 ### 2. 用户认证与授权流程 ```mermaid graph TB A[用户访问受保护路由] --> B{Vue Router 导航守卫} B --> C{localStorage 有 accessToken?} C -->|否| D[重定向到 /login?redirect=原路径] C -->|是| E[放行进入页面] D --> F[用户填写登录表单] F --> G[POST /api/auth/login] G --> H{Spring Security 认证} H -->|失败| I[返回 401 + 错误信息] H -->|成功| J[生成 JWT accessToken + refreshToken] J --> K[返回令牌对] K --> L[前端存储到 localStorage] L --> M[重定向回原路径] N[Axios 请求拦截器] --> O{检测 token} O -->|存在| P[注入 Authorization Bearer 头] O -->|不存在| Q[放行匿名请求] R[Axios 响应拦截器] --> S{状态码 401?} S -->|是| T[清除本地 token] T --> U[跳转 /login] S -->|否| V[统一错误提示] ``` ### 3. SGP4 轨道计算与 3D 渲染循环 ```mermaid graph TB subgraph 主线程 Main Thread A[requestAnimationFrame] --> B[计算帧间隔 dt] B --> C{时间是否播放?} C -->|是| D[推进仿真时间 simTime] C -->|否| E{自动旋转?} E -->|是| F[缓慢旋转地球] F --> G[updateSatellitePositions] D --> G G --> H[逐卫星计算轨道位置] H --> I[Billboard 朝向相机] I --> J[距离自适应缩放] J --> K[更新 InstancedMesh Matrix] K --> L{有选中卫星?} L -->|是| M[更新高亮环/波纹/探照灯动画] M --> N[更新拖尾轨迹] L -->|否| O[checkHover 射线检测] O --> P{命中卫星?} P -->|是| Q[显示 hover 光环 + 标签 + 轨道] P -->|否| R[清除 hover 特效] N --> S[renderer.render] R --> S Q --> S S --> A end subgraph SGP4 Worker 线程 W1[接收 init 消息] --> W2[twoline2satrec 解析 TLE] W2 --> W3[存储 satrec 对象] W3 --> W4[postMessage initialized] W4 --> W5{每秒接收 propagate 消息} W5 --> W6[sgp4 批量传播计算] W6 --> W7[ECI → 大地坐标转换] W7 --> W8[postMessage 位置结果] W8 --> W5 end subgraph 真实数据模式 T1[loadRealSatellites] --> T2[createRealSatellites] T2 --> T3[initSgp4Worker] T3 --> W1 T3 --> T4[usingRealData = true] T4 --> T5[每秒 requestWorkerPositions] T5 --> W5 W8 --> T6[updateRealSatPositions] T6 --> K end ``` ### 4. 卫星交互操作流程 ```mermaid graph TB A[用户操作] --> B{操作类型} B -->|鼠标悬停| C[mousemove 事件] C --> D[更新 mouse 坐标] D --> E[checkHover 节流 35ms] E --> F[Raycaster 检测 allSatMeshes] F --> G{命中 InstancedMesh?} G -->|是| H[instanceToNorad 查找 satData] H --> I{与选中卫星相同?} I -->|否| J[显示 hover 光环 + 标签 + hover 轨道] I -->|是| K[保持选中高亮, 隐藏 hover] J --> K G -->|否| L[清除 hover 特效] B -->|鼠标点击| M[click 事件] M --> N[Raycaster 检测] N --> O{命中卫星?} O -->|是| P[设置 selectedSatellite] P --> Q[高亮轨道 activeOrbitLine] Q --> R[placeHighlight 多层脉冲环] R --> S[createRippleEffect 波纹扩散] S --> T[createSpotlightEffect 探照灯] T --> U[showLabelFor 名称标签] U --> V[聚焦动画 focusOnSatellite] V --> W[更新 Pinia Store] W --> X[弹出 SatelliteDetail 面板] O -->|否| Y[clearSelection 清除所有特效] B -->|空格键| Z[切换全局搜索面板] Z --> AA[输入关键词] AA --> AB[本地/后端搜索卫星] AB --> AC[选择卫星 → 触发点击流程] ``` ## 技术栈 ### 前端 | 技术 | 说明 | 版本 | | ------------ | ---------------------------- | -------- | | Vue 3 | 渐进式框架 (Composition API) | ^3.4.27 | | TypeScript | 类型安全 | ^5.4.5 | | Vite | 构建工具 | ^5.2.0 | | Pinia | 状态管理 | ^2.1.7 | | Three.js | 3D 渲染引擎 | ^0.165.0 | | Element Plus | UI 组件库 (暗色主题) | ^2.7.0 | | ECharts | 数据可视化图表 | ^5.5.0 | | satellite.js | SGP4/SDP4 轨道计算 | ^5.0.0 | | ootk | 航天轨道计算库 | ^7.0.3 | | Axios | HTTP 客户端 | ^1.7.2 | | Vue Router | 路由管理 | ^4.3.2 | | vue-i18n | 国际化 | ^9.13.0 | ### 后端 | 技术 | 说明 | 版本 | | --------------- | -------------- | -------- | | Spring Boot | 应用框架 | 3.2.5 | | Java | 运行环境 | 17 | | MyBatis Flex | ORM 框架 | 1.9.3 | | PostgreSQL | 关系数据库 | 14+ | | Redis | 缓存 | 7+ | | Flyway | 数据库迁移 | 10.11.1 | | Spring Security | 认证授权 | 6.x | | JWT (jjwt) | 令牌认证 | 0.12.5 | | XXL-JOB | 分布式定时调度 | 2.4.1 | | Knife4j | API 文档 | 4.5.0 | | Hutool | Java 工具库 | 5.8.28 | | MapStruct | 对象映射 | 1.5.5 | | Lombok | 代码简化 | 1.18.32 | | Spring AI | AI 集成 | 1.0.0-M4 | ### 运维 | 技术 | 说明 | | -------------------- | ----------------------------- | | Docker | 容器化部署 | | Docker Compose | 本地编排 (PostgreSQL + Redis) | | GitHub Actions | CI/CD 流水线 | | Prometheus + Grafana | 监控告警 | | Spring Boot Actuator | 应用健康检查 | ## 快速开始 ### 环境要求 - Node.js 20+ - Java 17+ (推荐 Temurin) - PostgreSQL 14+ - Redis 7+ - Docker & Docker Compose (可选) ### 方式一:本地开发 ```bash # 1. 启动基础服务 (PostgreSQL + Redis) cd docker cp .env.example .env docker-compose up -d # 2. 启动后端 cd ../backend mvn spring-boot:run # 后端启动后 Flyway 会自动执行数据库迁移 # 3. 启动前端 cd ../frontend npm install npm run dev ``` - 🌐 前端访问:http://localhost:3000 - 🔌 后端 API:http://localhost:8080/api/health - 📖 API 文档:http://localhost:8080/doc.html ### 方式二:Docker 全容器化 ```bash # 在 docker 目录下启动所有服务 cd docker docker-compose -f docker-compose.yml -f ../docker-compose.override.yml up -d ``` ## 核心功能 ### 3D 可视化引擎 - **Three.js 3D 地球渲染**:PBR 材质、程序化纹理、实时云图覆盖 (Matt Eason's Cloud Service) - **GPU 实例化渲染**:30,000+ 卫星对象同时渲染,帧率保持 ≥30fps - **倾角着色系统**:按赤道/低/中/高/逆行倾角带使用不同颜色区分 - **星空背景**:12,000 星点粒子系统 + 3,000 科技感动态粒子背景 - **多渲染风格**:Classic / Map / 4K 切换 - **时间控制系统**:暂停/加速/回溯,模拟时间推进 - **大气光晕**:双层 ShaderMaterial 大气散射效果 - **经纬网格与赤道环**:辅助空间定位 - **沉浸式模式**:全屏无装饰浏览 ### 卫星数据管理 - **CelesTrak 数据同步**:XXL-JOB 定时任务自动拉取 TLE 数据,增量/全量两种模式 - **DB 优先加载策略**:首次从 CelesTrak 获取后存入数据库,后续直接从数据库加载 - **六大分类加载**:特殊兴趣、气象与地球资源、通信、北斗、导航、科学卫星 - **实时 SGP4 传播**:Web Worker 分片计算(satellite.js v5),不阻塞主线程 - **卫星搜索与筛选**:按名称/NORAD ID 搜索,按轨道类型过滤 - **卫星分类筛选**:按倾角带过滤卫星显示 ### 专业工具链 | 工具 | 说明 | 路由 | | -------------- | ------------------------------------ | ----------------------- | | TLE 解析器 | 两行根数解析与轨道参数计算 | `/tools/tle` | | 过境预测 | 基于用户经纬度的卫星过境时间预测 | `/tools/transit` | | 碰撞预警 | 卫星间接近事件预警分析 | `/tools/collision` | | 摄影炸弹 | 预测卫星过境拍摄窗口 | `/tools/photo` | | 坐标转换 | 多坐标系之间的转换工具 | `/tools/coord` | | 多源轨道可视化 | SP3/RINEX/OEM 文件上传与多源轨道对比 | `/multi-source-orbit` | ### 数据同步机制 系统采用 **DB 优先 + CelesTrak 回退** 的数据加载策略,具体流程详见上方「核心业务流程 → 卫星数据同步与加载流程」Mermaid 流程图。 关键策略要点: - **同步间隔控制**:`sync_tracking` 表记录每分组最后同步时间,6 小时间隔内不再重复拉取 - **DB 优先**:首次加载时后端从 CelesTrak 拉取 → 写入数据库 → 后续直接从 DB 加载 - **定时增量同步**:XXL-JOB 每天凌晨 02:00 自动增量同步,跳过未过期分组 - **三级降级**:后端同步 → 数据库加载 → 前端直连 CelesTrak(后端不可用时自动降级) --- ## 关键代码示例 ### 1. Three.js 3D 引擎 — GPU 实例化卫星渲染 核心渲染引擎使用 `THREE.InstancedMesh` 实现数万颗卫星的高效渲染,按倾角带分组着色。 ```typescript // frontend/src/composables/useThreeScene.ts (简化) // 创建 GPU 实例化卫星(按倾角带分组) for (const [bandKey, band] of Object.entries(bands)) { // 可见卫星点 const visibleGeo = new THREE.CircleGeometry(0.01, 16) const visibleMat = new THREE.MeshBasicMaterial({ color: band.color, side: THREE.DoubleSide }) const visibleInstanced = new THREE.InstancedMesh(visibleGeo, visibleMat, typeCount) // 不可见的检测网格(用于射线拾取) const hitGeo = new THREE.SphereGeometry(0.04, 8, 8) const hitMat = new THREE.MeshBasicMaterial({ visible: false }) const hitInstanced = new THREE.InstancedMesh(hitGeo, hitMat, typeCount) const dummy = new THREE.Object3D() for (let i = 0; i < typeCount; i++) { // 根据轨道参数计算3D坐标 const x = alt * (Math.cos(ra) * Math.cos(ma) - Math.sin(ra) * Math.sin(ma) * Math.cos(inc)) const y = alt * Math.sin(ma) * Math.sin(inc) const z = alt * (Math.sin(ra) * Math.cos(ma) + Math.cos(ra) * Math.sin(ma) * Math.cos(inc)) dummy.position.set(x, y, z) dummy.updateMatrix() visibleInstanced.setMatrixAt(i, dummy.matrix) hitInstanced.setMatrixAt(i, dummy.matrix) } scene.add(visibleInstanced) scene.add(hitInstanced) } ``` ### 2. 大气散射 Shader — 边缘光晕效果 使用自定义 ShaderMaterial 实现菲涅尔效应的大气光晕,增强视觉真实感。 ```glsl // vertexShader varying vec3 vNormal; varying vec3 vWorldPos; void main() { vec4 worldPos = modelMatrix * vec4(position, 1.0); vWorldPos = worldPos.xyz; vNormal = normalize(mat3(modelMatrix) * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } // fragmentShader varying vec3 vNormal; varying vec3 vWorldPos; uniform vec3 uSunDir; void main() { vec3 viewDir = normalize(cameraPosition - vWorldPos); vec3 normal = normalize(vNormal); float fresnel = 1.0 - abs(dot(viewDir, normal)); fresnel = pow(fresnel, 3.0); float sunFac = dot(normal, normalize(uSunDir)) * 0.5 + 0.5; vec3 glowColor = mix(vec3(0.2, 0.5, 1.0), vec3(0.1, 0.7, 1.0), fresnel); float alpha = fresnel * 0.5 * (0.3 + sunFac * 0.7); gl_FragColor = vec4(glowColor, alpha); } ``` ### 3. CelesTrak TLE 数据服务 — 多分组拉取与解析 支持从多个 CelesTrak 分组拉取 TLE 数据,去重后提取轨道参数供 SGP4 计算。 ```typescript // frontend/src/services/satelliteService.ts (简化) export async function fetchAllSatellites(): Promise<SatelliteBasic[]> { const groups = ['active', 'stations', 'visual', 'weather', 'starlink', 'oneweb', 'gps', 'beidou', 'galileo', 'cubesat', 'geo'] const results: SatelliteBasic[] = [] const seenNoradIds = new Set<number>() for (const group of groups) { const records = await fetchTleData(group) for (const sat of records.map(extractSatInfo)) { if (!seenNoradIds.has(sat.noradId)) { seenNoradIds.add(sat.noradId) results.push(sat) } } } return results } // 从 TLE 行解析轨道参数 export function extractSatInfo(record: TleRecord): SatelliteBasic { return { noradId: parseInt(record.noradId), name: record.name, inclination: parseFloat(record.line2.substring(8, 16)), // 轨道倾角 raan: parseFloat(record.line2.substring(17, 25)), // 升交点赤经 eccentricity: parseFloat('0.' + record.line2.substring(26, 33)), // 偏心率 argPerigee: parseFloat(record.line2.substring(34, 42)), // 近地点幅角 meanAnomaly: parseFloat(record.line2.substring(43, 51)), // 平近点角 meanMotion: parseFloat(record.line2.substring(52, 63)), // 每天圈数 // ... 更多参数 } } ``` ## 页面路由一览 | 路径 | 页面 | 描述 | | ----------------------- | ----------------------- | -------------------- | | `/` | VisualizerView | 3D 可视化主视图 | | `/login` | LoginView | 用户登录 | | `/register` | RegisterView | 用户注册 | | `/dashboard` | DashboardView | 数据仪表盘(需认证) | | `/constellations` | ConstellationListView | 星座列表 | | `/constellations/:id` | ConstellationDetailView | 星座详情 | | `/tools` | ToolView | 专业工具入口 | | `/tools/collision` | CollisionWarningView | 碰撞预警 | | `/tools/transit` | TransitPredictionView | 过境预测 | | `/tools/tle` | TleParserView | TLE 解析器 | | `/tools/photo` | PhotoBombView | 摄影炸弹 | | `/tools/coord` | CoordConvertView | 坐标转换 | | `/multi-source-orbit` | MultiSourceOrbitView | 多源数据轨道可视化 | | `/settings` | UserSettingsView | 用户设置(需认证) | | `/admin/users` | AdminUsersView | 用户管理(需认证) | | `/info` | InfoView | 关于项目 | | `/credits` | CreditsView | 致谢名单 | ## API 概览 ### 认证接口 (`/api/auth`) | 端点 | 方法 | 说明 | | ------------------ | ---- | ------------------ | | `/auth/login` | POST | 登录 (返回 JWT) | | `/auth/register` | POST | 注册 | | `/auth/refresh` | POST | 刷新令牌 | | `/auth/me` | GET | 当前用户信息及权限 | ### 受保护接口 (`/api/satellites`, `/api/user`, `/api/tools`, `/api/orbit`) 需携带 `Authorization: Bearer <token>` 头访问。 **用户接口 (`/api/user`)** | 端点 | 说明 | | --------------------- | ------------------------------ | | `/user/preferences` | 用户偏好设置 (GET/PUT) | | `/user/bookmarks` | 卫星书签管理 (GET/POST/DELETE) | | `/user/profile` | 个人资料 (GET/PUT) | | `/user/admin/*` | 管理员用户管理接口 | **工具接口 (`/api/tools`)** | 端点 | 说明 | | ----------------------------------------- | ------------------- | | `/tools/collisions` | 碰撞事件列表 (分页) | | `/tools/collisions/satellite/{noradId}` | 指定卫星碰撞事件 | | `/tools/pass-predictor` | 过境预测计算 | **多源轨道接口 (`/api/orbit`)** | 端点 | 说明 | | ------------------------------ | ------------------ | | `/orbit/all` | 预置模拟轨道列表 | | `/orbit/kepler` | 开普勒轨道生成 | | `/orbit/state_vector_custom` | 自定义状态向量生成 | | `/orbit/determine` | IOD 轨道确定 | | `/orbit/upload/sp3` | SP3 文件上传解析 | | `/orbit/upload/rinex` | RINEX 文件上传解析 | | `/orbit/upload/tle` | TLE 文本上传解析 | ## Docker 部署 ### 基础设施 ```yaml # docker-compose.yml (核心) services: postgres: # PostgreSQL 14, 端口 5432 redis: # Redis 7, 端口 6379 ``` ### 构建镜像 ```bash # 后端 docker build -t dtspacemap-backend ./backend # 前端 (Nginx 静态服务) docker build -t dtspacemap-frontend ./frontend ``` ### CI/CD (GitHub Actions) 流水线包含三个阶段: 1. **Backend Build & Test**:JDK 17 + Maven 编译和测试 2. **Frontend Build & Lint**:Node.js 20 + ESLint 检查 + TypeScript 编译 3. **Build Docker Images**:main 分支合并后自动构建镜像 ## 开发规范 - **Git 提交**:遵循 Conventional Commits (`feat:`, `fix:`, `refactor:`) - **代码风格**:前端 ESLint + Prettier,后端遵循阿里 Java 规范 - **API 设计**:RESTful 规范,统一 `Result<T>` 响应格式 - **数据库变更**:通过 Flyway 迁移脚本管理,禁止手动修改

下载 APP