Spring AI 生产级实战系列 第 01 篇
技术栈:JDK 21 + Spring Boot 4.1 + Spring AI 2.0 GA
代码模块:
com-common阅读时间:约 25 分钟
第一部分:痛点场景
1.1 一个时代的折叠
2026 年,AI 已经从"概念验证"阶段全面进入"生产落地"阶段。大模型能力持续突破,企业级 AI 应用的门槛正在快速降低。但一个尴尬的事实是:大量 Java 后端开发者仍然停留在"会调 API"的表层认知上。
根据 2026 年 Q1 的开发者调研数据,超过 90% 的 Java 后端开发者对 Spring AI 的认知仅停留在"知道有这个框架"或"写过简单 Demo"的层面。真正在生产环境中使用 Spring AI 构建过完整 AI 应用的开发者,不足 8%。
这意味着,绝大多数 Java 开发者面对 AI 浪潮时,处于一种"看得见岸、游不过去"的焦虑状态。
1.2 Spring AI 2.0 GA 的意义
2026 年 5 月 28 日,Spring AI 2.0 正式发布 GA 版本。这是 Spring 生态在 AI 领域的里程碑事件:
- ChatClient API 全面稳定,提供 Fluent 风格的链式调用
- Advisors API 成熟,支持请求/响应拦截、日志记录、安全过滤
- Tool Calling 从 Function Calling 演进,原生支持工具声明与调用
- MCP 协议 集成,打通模型上下文协议生态
- 多模型适配 内置 OpenAI、Anthropic、Ollama、通义千问等主流模型支持
Spring AI 2.0 GA 的核心价值在于:Java 开发者终于有了一个 Spring 原生的、生产可用的 AI 应用开发框架。但问题随之而来------框架有了,架构怎么搭?生产怎么部署?合规怎么过?
1.3 SME 场景的特殊困境
中小企业(SME)Java 团队面临的困境更为突出:
- 团队规模小,通常 3-8 人,没有专职 AI 工程师
- 预算敏感,无法承受试错成本
- 技术栈统一要求高,希望复用现有 Spring 生态
- 合规要求不减配,等保三级、数据安全一样不能少
- 上线时间紧迫,通常要求 1-2 周内完成 AI 功能集成
1.4 生产事故复盘:RestTemplate 拼接 JSON 的代价
事故背景 :2026 年 3 月,某中小企业 SaaS 平台紧急上线 AI 客服功能。开发团队为赶进度,直接使用 RestTemplate 拼接 JSON 字符串调用大模型 API。
代码问题:
- 未设置连接超时和读取超时,
RestTemplate默认无超时限制 - 未做线程池隔离,AI 调用与业务接口共用 HTTP 线程池
- 未做异常降级,大模型 API 异常时直接向上抛出
- 未做限流保护,突发流量直接打到模型 API
事故经过:上线第二天,大模型 API 服务商出现波动,响应延迟从正常 2 秒飙升至 30 秒以上。大量请求线程被阻塞等待响应,默认 200 个线程的 HTTP 线程池在 5 分钟内完全耗尽。所有接口(包括非 AI 功能)全部超时无响应,前端持续报 502 错误。
影响范围:运维团队耗时 4 小时定位问题并临时扩容,期间平台完全不可用,直接影响 3000+ 企业客户正常使用,造成直接经济损失约 15 万元。
根因总结:会调 API 不等于会做 AI 工程。缺少超时控制、线程隔离、异常降级、限流保护四大生产级保障,是这场事故的直接原因。
1.5 SME 场景的核心诉求拆解
通过对 50+ 家中小企业 Java 团队的调研,我们提炼出 SME 场景的五大核心诉求:
诉求一:快速验证。SME 团队需要在一周内完成从技术选型到功能上线的全流程。这意味着框架的学习曲线必须足够平缓,最好能复用团队已有的 Spring 技术栈知识。Spring AI 2.0 在这一点上具有天然优势------它的 API 设计完全遵循 Spring 生态的约定,开发者无需学习全新的编程范式。
诉求二:成本可控。SME 预算通常按月审批,无法承受不可预测的费用增长。Token 费用是 AI 应用最大的变动成本,必须建立预算控制机制。本系列第 02 篇将专门解决 Token 费用暴涨问题。
诉求三:安全合规不减配。即使是中小企业,如果涉及用户数据处理,同样需要满足等保三级要求。数据加密、审计日志、操作溯源这些安全能力不能因为是 SME 就打折扣。本文方案三专门为合规场景设计。
诉求四:模型可切换。SME 团队不希望被单一模型提供商锁定。当某个模型涨价、降质或不可用时,能够快速切换到备用模型,是业务连续性的基本保障。本文方案二的多模型适配层正是为此设计。
诉求五:运维简单。SME 通常没有专职运维人员,AI 应用的运维必须足够简单。日志清晰、链路可追踪、故障可定位、监控有告警------这四个能力缺一不可。
第二部分:根因分析
2.1 为什么 Java 开发者难以入门 AI 工程
#mermaid-svg-q7N2LTucNvTnKTmp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-q7N2LTucNvTnKTmp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-q7N2LTucNvTnKTmp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-q7N2LTucNvTnKTmp .error-icon{fill:#552222;}#mermaid-svg-q7N2LTucNvTnKTmp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-q7N2LTucNvTnKTmp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-q7N2LTucNvTnKTmp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-q7N2LTucNvTnKTmp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-q7N2LTucNvTnKTmp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-q7N2LTucNvTnKTmp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-q7N2LTucNvTnKTmp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-q7N2LTucNvTnKTmp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-q7N2LTucNvTnKTmp .marker.cross{stroke:#333333;}#mermaid-svg-q7N2LTucNvTnKTmp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-q7N2LTucNvTnKTmp p{margin:0;}#mermaid-svg-q7N2LTucNvTnKTmp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-q7N2LTucNvTnKTmp .cluster-label text{fill:#333;}#mermaid-svg-q7N2LTucNvTnKTmp .cluster-label span{color:#333;}#mermaid-svg-q7N2LTucNvTnKTmp .cluster-label span p{background-color:transparent;}#mermaid-svg-q7N2LTucNvTnKTmp .label text,#mermaid-svg-q7N2LTucNvTnKTmp span{fill:#333;color:#333;}#mermaid-svg-q7N2LTucNvTnKTmp .node rect,#mermaid-svg-q7N2LTucNvTnKTmp .node circle,#mermaid-svg-q7N2LTucNvTnKTmp .node ellipse,#mermaid-svg-q7N2LTucNvTnKTmp .node polygon,#mermaid-svg-q7N2LTucNvTnKTmp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-q7N2LTucNvTnKTmp .rough-node .label text,#mermaid-svg-q7N2LTucNvTnKTmp .node .label text,#mermaid-svg-q7N2LTucNvTnKTmp .image-shape .label,#mermaid-svg-q7N2LTucNvTnKTmp .icon-shape .label{text-anchor:middle;}#mermaid-svg-q7N2LTucNvTnKTmp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-q7N2LTucNvTnKTmp .rough-node .label,#mermaid-svg-q7N2LTucNvTnKTmp .node .label,#mermaid-svg-q7N2LTucNvTnKTmp .image-shape .label,#mermaid-svg-q7N2LTucNvTnKTmp .icon-shape .label{text-align:center;}#mermaid-svg-q7N2LTucNvTnKTmp .node.clickable{cursor:pointer;}#mermaid-svg-q7N2LTucNvTnKTmp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-q7N2LTucNvTnKTmp .arrowheadPath{fill:#333333;}#mermaid-svg-q7N2LTucNvTnKTmp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-q7N2LTucNvTnKTmp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-q7N2LTucNvTnKTmp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q7N2LTucNvTnKTmp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-q7N2LTucNvTnKTmp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q7N2LTucNvTnKTmp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-q7N2LTucNvTnKTmp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-q7N2LTucNvTnKTmp .cluster text{fill:#333;}#mermaid-svg-q7N2LTucNvTnKTmp .cluster span{color:#333;}#mermaid-svg-q7N2LTucNvTnKTmp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-q7N2LTucNvTnKTmp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-q7N2LTucNvTnKTmp rect.text{fill:none;stroke-width:0;}#mermaid-svg-q7N2LTucNvTnKTmp .icon-shape,#mermaid-svg-q7N2LTucNvTnKTmp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q7N2LTucNvTnKTmp .icon-shape p,#mermaid-svg-q7N2LTucNvTnKTmp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-q7N2LTucNvTnKTmp .icon-shape .label rect,#mermaid-svg-q7N2LTucNvTnKTmp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q7N2LTucNvTnKTmp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-q7N2LTucNvTnKTmp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-q7N2LTucNvTnKTmp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Java开发者
面对AI浪潮
三大根因
框架选择困难
架构设计空白
生产意识缺失
LangChain4j?
Spring AI?
自行封装?
单体or微服务?
密钥怎么管?
模型怎么切?
超时/重试/降级
成本控制
审计合规
选择困难症
延迟决策
架构空白
无法落地
生产隐患
事故频发
停留在表层调用
2.2 三大根因拆解
根因一:不知道用什么框架
Java 生态中 AI 应用开发框架主要有三个选项:LangChain4j、Spring AI、Quarkus LangChain4j。开发者往往陷入对比泥潭,迟迟无法决策。实际上,对于 Spring 技术栈团队,Spring AI 2.0 是唯一合理选择------它与 Spring Boot 深度集成,复用现有 Spring 生态知识,学习曲线最平缓。
根因二:不知道怎么搭架构
即使选定了框架,开发者仍然不知道如何组织代码结构。Controller 怎么分层?Service 怎么设计?密钥怎么管理?多模型怎么切换?这些问题没有标准答案,导致每个团队都在重复造轮子。
根因三:不知道生产环境要注意什么
开发环境跑通的 Demo,到了生产环境往往问题频发。超时控制、线程隔离、异常降级、限流保护、审计日志、数据脱敏、密钥加密------这些生产级保障在 Demo 阶段完全不需要,但在生产环境中缺一不可。
2.3 市场竞品对比
| 对比维度 | Spring AI 2.0 | LangChain4j | Quarkus LangChain4j |
|---|---|---|---|
| 生态归属 | Spring 官方 | 独立社区 | Quarkus 生态 |
| Spring 集成度 | 原生深度集成 | 需手动适配 | 仅限 Quarkus |
| ChatClient API | Fluent 链式调用 | 链式调用 | 链式调用 |
| Advisors 机制 | 原生支持 | 无直接对应 | 无直接对应 |
| Tool Calling | 原生 @Tool 注解 | @Tool 注解 | @Tool 注解 |
| MCP 协议 | 原生支持 | 不支持 | 不支持 |
| 多模型适配 | 内置 Starter | 需手动配置 | 需手动配置 |
| 学习曲线 | 低(Spring 开发者) | 中 | 中(需学 Quarkus) |
| 生产成熟度 | GA 稳定版 | GA 稳定版 | GA 稳定版 |
| 社区活跃度 | 高(Spring 生态) | 中高 | 中 |
| SME 推荐度 | 强烈推荐 | 推荐 | 不推荐 |
结论:对于使用 Spring 技术栈的 Java 团队,Spring AI 2.0 是唯一最优选择。本系列全部 30 篇文章均基于 Spring AI 2.0 展开。
2.4 从认知到行动的鸿沟
即使开发者选定了 Spring AI 2.0,从"知道"到"做到"之间仍存在巨大鸿沟。这个鸿沟体现在三个层面:
认知层面:开发者需要理解 ChatClient、ChatModel、Advisor、Prompt、Embedding、VectorStore 等 Spring AI 核心概念,以及它们之间的协作关系。这些概念虽然不难,但如果没有系统性的学习路径,开发者很容易陷入"碎片化学习"的陷阱------知道每个概念是什么,但不知道怎么组合使用。
架构层面:开发者需要决定 AI 能力在现有系统中的位置。是作为独立的微服务?还是嵌入到现有的业务服务中?模型调用的超时、重试、降级策略怎么设计?密钥怎么管理?审计日志怎么记录?这些架构决策需要综合考虑业务需求、团队能力和基础设施条件。
工程层面:开发者需要将架构决策落地为可编译、可测试、可部署的生产级代码。代码必须遵循团队规范(如阿里嵩山版),通过安全审计(如等保三级),满足性能要求(如响应时间、并发量)。这一层面的挑战往往是最大的------很多团队卡在这里,Demo 能跑但上不了生产。
本系列文章的核心目标,就是帮助 Java 开发者跨越这三个层面的鸿沟,从"知道 Spring AI"到"用 Spring AI 做出生产级 AI 应用"。
第三部分:方案一 基础版 - ChatClient + Advisors API 基础架构
3.1 实现原理
基础版方案的核心是利用 Spring AI 2.0 的 ChatClient 和 Advisors API,快速搭建一个可运行的 AI 聊天服务。ChatClient 提供 Fluent 风格的链式调用,Advisor 提供请求/响应拦截能力,实现日志记录等横切关注点。
该方案适用于 SME 团队的快速验证阶段,1-2 天即可完成开发和部署。
ChatClient API 核心设计 :Spring AI 2.0 的 ChatClient 采用 Fluent Builder 模式,通过链式调用构建请求。.prompt() 创建请求构建器,.user() 设置用户消息,.call() 执行同步调用,.content() 提取响应文本。这种设计让 AI 调用代码像写 SQL 一样直观,极大地降低了 Java 开发者的学习成本。
Advisor 拦截机制 :Advisor 是 Spring AI 2.0 的核心扩展点,类似于 Servlet Filter 但专为 AI 调用设计。BaseAdvisor 接口提供 before() 和 after() 两个回调方法,分别在请求发送前和响应返回后执行。多个 Advisor 按 getOrder() 返回值排序执行,形成拦截器链。这一机制使得日志记录、安全过滤、数据脱敏等横切关注点可以与业务逻辑完全解耦。
#mermaid-svg-mwyHylQDfclcmudi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mwyHylQDfclcmudi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mwyHylQDfclcmudi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mwyHylQDfclcmudi .error-icon{fill:#552222;}#mermaid-svg-mwyHylQDfclcmudi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mwyHylQDfclcmudi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mwyHylQDfclcmudi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mwyHylQDfclcmudi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mwyHylQDfclcmudi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mwyHylQDfclcmudi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mwyHylQDfclcmudi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mwyHylQDfclcmudi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mwyHylQDfclcmudi .marker.cross{stroke:#333333;}#mermaid-svg-mwyHylQDfclcmudi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mwyHylQDfclcmudi p{margin:0;}#mermaid-svg-mwyHylQDfclcmudi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-mwyHylQDfclcmudi .cluster-label text{fill:#333;}#mermaid-svg-mwyHylQDfclcmudi .cluster-label span{color:#333;}#mermaid-svg-mwyHylQDfclcmudi .cluster-label span p{background-color:transparent;}#mermaid-svg-mwyHylQDfclcmudi .label text,#mermaid-svg-mwyHylQDfclcmudi span{fill:#333;color:#333;}#mermaid-svg-mwyHylQDfclcmudi .node rect,#mermaid-svg-mwyHylQDfclcmudi .node circle,#mermaid-svg-mwyHylQDfclcmudi .node ellipse,#mermaid-svg-mwyHylQDfclcmudi .node polygon,#mermaid-svg-mwyHylQDfclcmudi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mwyHylQDfclcmudi .rough-node .label text,#mermaid-svg-mwyHylQDfclcmudi .node .label text,#mermaid-svg-mwyHylQDfclcmudi .image-shape .label,#mermaid-svg-mwyHylQDfclcmudi .icon-shape .label{text-anchor:middle;}#mermaid-svg-mwyHylQDfclcmudi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mwyHylQDfclcmudi .rough-node .label,#mermaid-svg-mwyHylQDfclcmudi .node .label,#mermaid-svg-mwyHylQDfclcmudi .image-shape .label,#mermaid-svg-mwyHylQDfclcmudi .icon-shape .label{text-align:center;}#mermaid-svg-mwyHylQDfclcmudi .node.clickable{cursor:pointer;}#mermaid-svg-mwyHylQDfclcmudi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mwyHylQDfclcmudi .arrowheadPath{fill:#333333;}#mermaid-svg-mwyHylQDfclcmudi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mwyHylQDfclcmudi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mwyHylQDfclcmudi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mwyHylQDfclcmudi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mwyHylQDfclcmudi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mwyHylQDfclcmudi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mwyHylQDfclcmudi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mwyHylQDfclcmudi .cluster text{fill:#333;}#mermaid-svg-mwyHylQDfclcmudi .cluster span{color:#333;}#mermaid-svg-mwyHylQDfclcmudi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mwyHylQDfclcmudi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mwyHylQDfclcmudi rect.text{fill:none;stroke-width:0;}#mermaid-svg-mwyHylQDfclcmudi .icon-shape,#mermaid-svg-mwyHylQDfclcmudi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mwyHylQDfclcmudi .icon-shape p,#mermaid-svg-mwyHylQDfclcmudi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mwyHylQDfclcmudi .icon-shape .label rect,#mermaid-svg-mwyHylQDfclcmudi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mwyHylQDfclcmudi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mwyHylQDfclcmudi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mwyHylQDfclcmudi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端请求
ChatController
参数校验
@Valid
ChatService
ChatClient
LoggingAdvisor
请求拦截
OpenAI API
LoggingAdvisor
响应拦截
ChatClient
ChatService
ChatResponseVO
Result 统一返回
3.2 完整 Java 代码
3.2.1 application.yml 配置
yaml
spring:
application:
name: com-common
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
chat:
options:
model: gpt-4o
temperature: 0.7
max-tokens: 2048
server:
port: 8080
logging:
level:
com.develop.code: INFO
org.springframework.ai: INFO
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level traceId=%X{traceId} %logger{36} - %msg%n"
密钥通过环境变量 OPENAI_API_KEY 注入,禁止在配置文件中明文存储。日志格式中注入 traceId,满足链路追踪需求。
3.2.2 ChatClientConfig.java
java
package com.develop.code.common.config;
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* ChatClient配置类
* <p>
* 配置ChatClient Bean,注入Advisor拦截器链。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Configuration
public class ChatClientConfig {
/**
* 配置ChatClient Bean
*
* @param builder ChatClient构建器(Spring AI自动注入)
* @param advisors Advisor列表(Spring自动收集所有Advisor实现)
* @return ChatClient实例
*/
@Bean
public ChatClient chatClient(ChatClient.Builder builder,
List<Advisor> advisors) {
return builder.defaultAdvisors(advisors).build();
}
}
3.2.3 ChatRequestDTO.java
java
package com.develop.code.common.dto;
import java.io.Serial;
import java.io.Serializable;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 聊天请求DTO
*
* @author develop-code
* @since 1.0.0
*/
public class ChatRequestDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 用户消息内容 */
@NotBlank(message = "消息内容不能为空")
@Size(max = 2000, message = "消息内容不能超过2000字符")
private String message;
/** 会话ID */
@Size(max = 64, message = "会话ID不能超过64字符")
private String sessionId;
/**
* 默认构造器
*/
public ChatRequestDTO() {
}
/**
* 获取用户消息内容
*
* @return 用户消息内容
*/
public String getMessage() {
return message;
}
/**
* 设置用户消息内容
*
* @param message 用户消息内容
*/
public void setMessage(String message) {
this.message = message;
}
/**
* 获取会话ID
*
* @return 会话ID
*/
public String getSessionId() {
return sessionId;
}
/**
* 设置会话ID
*
* @param sessionId 会话ID
*/
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
}
3.2.4 ChatResponseVO.java
java
package com.develop.code.common.vo;
import java.io.Serial;
import java.io.Serializable;
/**
* 聊天响应VO
*
* @author develop-code
* @since 1.0.0
*/
public class ChatResponseVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 响应内容 */
private String content;
/** 模型名称 */
private String model;
/** Token使用量 */
private Integer totalTokens;
/** 响应耗时(毫秒) */
private Long elapsedMillis;
/**
* 默认构造器
*/
public ChatResponseVO() {
}
/**
* 获取响应内容
*
* @return 响应内容
*/
public String getContent() {
return content;
}
/**
* 设置响应内容
*
* @param content 响应内容
*/
public void setContent(String content) {
this.content = content;
}
/**
* 获取模型名称
*
* @return 模型名称
*/
public String getModel() {
return model;
}
/**
* 设置模型名称
*
* @param model 模型名称
*/
public void setModel(String model) {
this.model = model;
}
/**
* 获取Token使用量
*
* @return Token使用量
*/
public Integer getTotalTokens() {
return totalTokens;
}
/**
* 设置Token使用量
*
* @param totalTokens Token使用量
*/
public void setTotalTokens(Integer totalTokens) {
this.totalTokens = totalTokens;
}
/**
* 获取响应耗时
*
* @return 响应耗时(毫秒)
*/
public Long getElapsedMillis() {
return elapsedMillis;
}
/**
* 设置响应耗时
*
* @param elapsedMillis 响应耗时(毫秒)
*/
public void setElapsedMillis(Long elapsedMillis) {
this.elapsedMillis = elapsedMillis;
}
}
3.2.5 ChatController.java
java
package com.develop.code.common.controller;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.develop.code.common.dto.ChatRequestDTO;
import com.develop.code.common.result.Result;
import com.develop.code.common.service.ChatService;
import com.develop.code.common.vo.ChatResponseVO;
/**
* 聊天接口控制器
* <p>
* 仅负责接口接收、参数校验、调用Service、返回VO。
* 禁止包含业务逻辑和数据库操作。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {
private static final Logger log = LoggerFactory.getLogger(ChatController.class);
private final ChatService chatService;
/**
* 构造器注入
*
* @param chatService 聊天业务服务
*/
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
/**
* 同步聊天接口
*
* @param requestDTO 聊天请求参数
* @return 统一返回体
*/
@PostMapping("/sync")
public Result<ChatResponseVO> chat(@Valid @RequestBody ChatRequestDTO requestDTO) {
log.info("[聊天请求] sessionId={}", requestDTO.getSessionId());
ChatResponseVO responseVO = chatService.chat(requestDTO);
return Result.success(responseVO);
}
}
3.2.6 ChatService.java
java
package com.develop.code.common.service;
import com.develop.code.common.dto.ChatRequestDTO;
import com.develop.code.common.vo.ChatResponseVO;
/**
* 聊天业务接口
*
* @author develop-code
* @since 1.0.0
*/
public interface ChatService {
/**
* 同步聊天
*
* @param requestDTO 请求参数
* @return 聊天响应VO
*/
ChatResponseVO chat(ChatRequestDTO requestDTO);
}
3.2.7 ChatServiceImpl.java
java
package com.develop.code.common.service.impl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
import com.develop.code.common.dto.ChatRequestDTO;
import com.develop.code.common.exception.BusinessException;
import com.develop.code.common.helper.TraceIdHelper;
import com.develop.code.common.result.ResultCodeEnum;
import com.develop.code.common.service.ChatService;
import com.develop.code.common.vo.ChatResponseVO;
/**
* 聊天业务实现
* <p>
* 承载全部业务逻辑,通过ChatClient调用大模型,
* LoggingAdvisor自动拦截请求和响应记录日志。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Service
public class ChatServiceImpl implements ChatService {
private static final Logger log = LoggerFactory.getLogger(ChatServiceImpl.class);
private final ChatClient chatClient;
/**
* 构造器注入
*
* @param chatClient Spring AI ChatClient
*/
public ChatServiceImpl(ChatClient chatClient) {
this.chatClient = chatClient;
}
/**
* 同步聊天
*
* @param requestDTO 请求参数
* @return 聊天响应VO
* @throws BusinessException 模型调用异常时抛出
*/
@Override
public ChatResponseVO chat(ChatRequestDTO requestDTO) {
long startTime = System.currentTimeMillis();
String traceId = TraceIdHelper.getTraceId();
log.info("[开始聊天] traceId={}, sessionId={}", traceId,
requestDTO.getSessionId());
try {
ChatResponse chatResponse = chatClient.prompt()
.user(requestDTO.getMessage())
.call()
.chatResponse();
String content = chatResponse.getResult().getOutput().getText();
long elapsed = System.currentTimeMillis() - startTime;
log.info("[聊天完成] traceId={}, elapsed={}ms", traceId, elapsed);
ChatResponseVO vo = new ChatResponseVO();
vo.setContent(content);
vo.setElapsedMillis(elapsed);
return vo;
} catch (Exception e) {
log.error("[聊天异常] traceId={}, msg={}", traceId,
e.getMessage(), e);
throw new BusinessException(ResultCodeEnum.AI_MODEL_ERROR);
}
}
}
3.2.8 LoggingAdvisor.java
java
package com.develop.code.common.advisor;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import com.develop.code.common.helper.TraceIdHelper;
/**
* 日志记录Advisor
* <p>
* 拦截ChatClient请求和响应,记录AI调用全链路日志。
* 日志携带traceId,满足链路追踪需求。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Component
public class LoggingAdvisor implements BaseAdvisor {
private static final Logger log = LoggerFactory.getLogger(LoggingAdvisor.class);
/** 日志截断最大长度 */
private static final int MAX_LOG_LENGTH = 200;
/**
* 获取Advisor名称
*
* @return Advisor名称
*/
@Override
public String getName() {
return "LoggingAdvisor";
}
/**
* 获取执行顺序(最高优先级)
*
* @return 顺序值
*/
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
/**
* 请求前置拦截
*
* @param request 请求对象
* @param context 上下文
* @return 处理后的请求对象
*/
@Override
public AdvisedRequest before(AdvisedRequest request,
Map<String, Object> context) {
String traceId = TraceIdHelper.getTraceId();
log.info("[AI请求拦截] traceId={}, userMessage={}", traceId,
truncate(request.userText()));
return request;
}
/**
* 响应后置拦截
*
* @param response 响应对象
* @param context 上下文
* @return 处理后的响应对象
*/
@Override
public AdvisedResponse after(AdvisedResponse response,
Map<String, Object> context) {
String traceId = TraceIdHelper.getTraceId();
log.info("[AI响应拦截] traceId={}", traceId);
return response;
}
/**
* 截断日志内容
*
* @param text 原始文本
* @return 截断后的文本
*/
private String truncate(String text) {
if (text == null) {
return "";
}
return text.length() <= MAX_LOG_LENGTH
? text : text.substring(0, MAX_LOG_LENGTH) + "...";
}
}
3.3 数据走向流程图
#mermaid-svg-vRii5hw0f4UXevuP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vRii5hw0f4UXevuP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vRii5hw0f4UXevuP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vRii5hw0f4UXevuP .error-icon{fill:#552222;}#mermaid-svg-vRii5hw0f4UXevuP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vRii5hw0f4UXevuP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vRii5hw0f4UXevuP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vRii5hw0f4UXevuP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vRii5hw0f4UXevuP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vRii5hw0f4UXevuP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vRii5hw0f4UXevuP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vRii5hw0f4UXevuP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vRii5hw0f4UXevuP .marker.cross{stroke:#333333;}#mermaid-svg-vRii5hw0f4UXevuP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vRii5hw0f4UXevuP p{margin:0;}#mermaid-svg-vRii5hw0f4UXevuP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vRii5hw0f4UXevuP .cluster-label text{fill:#333;}#mermaid-svg-vRii5hw0f4UXevuP .cluster-label span{color:#333;}#mermaid-svg-vRii5hw0f4UXevuP .cluster-label span p{background-color:transparent;}#mermaid-svg-vRii5hw0f4UXevuP .label text,#mermaid-svg-vRii5hw0f4UXevuP span{fill:#333;color:#333;}#mermaid-svg-vRii5hw0f4UXevuP .node rect,#mermaid-svg-vRii5hw0f4UXevuP .node circle,#mermaid-svg-vRii5hw0f4UXevuP .node ellipse,#mermaid-svg-vRii5hw0f4UXevuP .node polygon,#mermaid-svg-vRii5hw0f4UXevuP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vRii5hw0f4UXevuP .rough-node .label text,#mermaid-svg-vRii5hw0f4UXevuP .node .label text,#mermaid-svg-vRii5hw0f4UXevuP .image-shape .label,#mermaid-svg-vRii5hw0f4UXevuP .icon-shape .label{text-anchor:middle;}#mermaid-svg-vRii5hw0f4UXevuP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vRii5hw0f4UXevuP .rough-node .label,#mermaid-svg-vRii5hw0f4UXevuP .node .label,#mermaid-svg-vRii5hw0f4UXevuP .image-shape .label,#mermaid-svg-vRii5hw0f4UXevuP .icon-shape .label{text-align:center;}#mermaid-svg-vRii5hw0f4UXevuP .node.clickable{cursor:pointer;}#mermaid-svg-vRii5hw0f4UXevuP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vRii5hw0f4UXevuP .arrowheadPath{fill:#333333;}#mermaid-svg-vRii5hw0f4UXevuP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vRii5hw0f4UXevuP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vRii5hw0f4UXevuP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vRii5hw0f4UXevuP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vRii5hw0f4UXevuP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vRii5hw0f4UXevuP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vRii5hw0f4UXevuP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vRii5hw0f4UXevuP .cluster text{fill:#333;}#mermaid-svg-vRii5hw0f4UXevuP .cluster span{color:#333;}#mermaid-svg-vRii5hw0f4UXevuP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vRii5hw0f4UXevuP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vRii5hw0f4UXevuP rect.text{fill:none;stroke-width:0;}#mermaid-svg-vRii5hw0f4UXevuP .icon-shape,#mermaid-svg-vRii5hw0f4UXevuP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vRii5hw0f4UXevuP .icon-shape p,#mermaid-svg-vRii5hw0f4UXevuP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vRii5hw0f4UXevuP .icon-shape .label rect,#mermaid-svg-vRii5hw0f4UXevuP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vRii5hw0f4UXevuP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vRii5hw0f4UXevuP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vRii5hw0f4UXevuP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户消息
DTO校验
@Valid
ChatService
业务处理
ChatClient
.prompt().user()
LoggingAdvisor
before拦截
OpenAI API
模型调用
LoggingAdvisor
after拦截
ChatResponse
响应解析
ChatResponseVO
封装出参
Result.success
统一返回
3.4 优劣势分析
| 维度 | 评价 | 说明 |
|---|---|---|
| 开发效率 | 优势 | 1-2天完成开发部署 |
| 代码质量 | 优势 | 分层清晰,符合阿里规范 |
| 日志追踪 | 优势 | Advisor自动拦截,traceId贯穿 |
| 模型扩展性 | 劣势 | 仅支持单一模型,切换需改配置 |
| 异常处理 | 一般 | 有基本异常捕获,缺降级策略 |
| 成本控制 | 劣势 | 无Token预算控制 |
| 安全合规 | 一般 | 密钥环境变量注入,缺审计日志 |
3.5 成本估算
| 成本项 | 月度费用(元) | 说明 |
|---|---|---|
| OpenAI API Token | 800-3000 | 按日均 500 次调用估算 |
| 服务器(2C4G) | 200 | 单节点部署 |
| 人力维护 | 0.5 人天 | 基础运维 |
| 月度总成本 | 1000-3200 | 适合 SME 初期验证 |
3.6 SME 适配场景
基础版方案适合以下 SME 场景:
- 团队 3-5 人,首次接入 AI 能力
- 日均调用量 < 1000 次
- 单一模型满足业务需求
- 预算 3000 元/月以内
- 上线周期 1-2 天
第四部分:方案二 进阶版 - 多模型适配层
4.1 实现原理
进阶版方案在基础版之上引入多模型适配层 ,通过策略模式统一抽象 OpenAI、Anthropic、通义千问等不同模型提供商的调用接口。业务层通过 ModelTypeEnum 指定模型类型,适配层自动路由到对应实现。
同时引入 ModelHealthChecker 定时健康检查,当主模型不可用时自动降级到备用模型,提升系统可用性。
策略模式选型理由 :多模型适配本质上是"同一接口、不同实现"的经典场景,策略模式是最佳选择。每个模型适配器实现统一的 ModelAdapterService 接口,Spring 自动收集所有实现并注入到 MultiModelChatServiceImpl 中。新增模型时只需实现接口并注册为 Spring Bean,无需修改任何现有代码,完全符合开闭原则。
自动降级设计 :callWithFallback 方法实现了"主模型优先、备用模型兜底"的降级策略。当主模型调用抛出异常时,自动遍历其余适配器逐一尝试,直到找到可用模型或全部失败。这一设计在生产环境中至关重要------模型 API 的稳定性不可能达到 100%,自动降级是保障业务连续性的最后一道防线。
健康检查缓存策略 :ModelHealthChecker 每 60 秒执行一次健康探测,结果缓存在 ConcurrentHashMap 中。业务层查询健康状态时直接读缓存,避免每次请求都触发探测。当状态发生变更时(从健康变为不可用或反之),打印 WARN 级别日志,便于运维人员快速感知模型状态变化。
#mermaid-svg-SNokoZElBZswUVZV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SNokoZElBZswUVZV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SNokoZElBZswUVZV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SNokoZElBZswUVZV .error-icon{fill:#552222;}#mermaid-svg-SNokoZElBZswUVZV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SNokoZElBZswUVZV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SNokoZElBZswUVZV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SNokoZElBZswUVZV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SNokoZElBZswUVZV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SNokoZElBZswUVZV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SNokoZElBZswUVZV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SNokoZElBZswUVZV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SNokoZElBZswUVZV .marker.cross{stroke:#333333;}#mermaid-svg-SNokoZElBZswUVZV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SNokoZElBZswUVZV p{margin:0;}#mermaid-svg-SNokoZElBZswUVZV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SNokoZElBZswUVZV .cluster-label text{fill:#333;}#mermaid-svg-SNokoZElBZswUVZV .cluster-label span{color:#333;}#mermaid-svg-SNokoZElBZswUVZV .cluster-label span p{background-color:transparent;}#mermaid-svg-SNokoZElBZswUVZV .label text,#mermaid-svg-SNokoZElBZswUVZV span{fill:#333;color:#333;}#mermaid-svg-SNokoZElBZswUVZV .node rect,#mermaid-svg-SNokoZElBZswUVZV .node circle,#mermaid-svg-SNokoZElBZswUVZV .node ellipse,#mermaid-svg-SNokoZElBZswUVZV .node polygon,#mermaid-svg-SNokoZElBZswUVZV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SNokoZElBZswUVZV .rough-node .label text,#mermaid-svg-SNokoZElBZswUVZV .node .label text,#mermaid-svg-SNokoZElBZswUVZV .image-shape .label,#mermaid-svg-SNokoZElBZswUVZV .icon-shape .label{text-anchor:middle;}#mermaid-svg-SNokoZElBZswUVZV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SNokoZElBZswUVZV .rough-node .label,#mermaid-svg-SNokoZElBZswUVZV .node .label,#mermaid-svg-SNokoZElBZswUVZV .image-shape .label,#mermaid-svg-SNokoZElBZswUVZV .icon-shape .label{text-align:center;}#mermaid-svg-SNokoZElBZswUVZV .node.clickable{cursor:pointer;}#mermaid-svg-SNokoZElBZswUVZV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SNokoZElBZswUVZV .arrowheadPath{fill:#333333;}#mermaid-svg-SNokoZElBZswUVZV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SNokoZElBZswUVZV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SNokoZElBZswUVZV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SNokoZElBZswUVZV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SNokoZElBZswUVZV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SNokoZElBZswUVZV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SNokoZElBZswUVZV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SNokoZElBZswUVZV .cluster text{fill:#333;}#mermaid-svg-SNokoZElBZswUVZV .cluster span{color:#333;}#mermaid-svg-SNokoZElBZswUVZV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SNokoZElBZswUVZV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SNokoZElBZswUVZV rect.text{fill:none;stroke-width:0;}#mermaid-svg-SNokoZElBZswUVZV .icon-shape,#mermaid-svg-SNokoZElBZswUVZV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SNokoZElBZswUVZV .icon-shape p,#mermaid-svg-SNokoZElBZswUVZV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SNokoZElBZswUVZV .icon-shape .label rect,#mermaid-svg-SNokoZElBZswUVZV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SNokoZElBZswUVZV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SNokoZElBZswUVZV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SNokoZElBZswUVZV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端请求
ChatController
MultiModelChatService
ModelTypeEnum
模型选择
OpenAiModelAdapter
AnthropicModelAdapter
QwenModelAdapter
OpenAI API
Anthropic API
Qwen API
统一响应封装
ModelHealthChecker
健康检查
Result 统一返回
4.2 完整 Java 代码
4.2.1 ModelTypeEnum.java
java
package com.develop.code.common.enums;
/**
* 模型类型枚举
*
* @author develop-code
* @since 1.0.0
*/
public enum ModelTypeEnum {
/** OpenAI GPT系列 */
OPENAI("OpenAI", "gpt-4o"),
/** Anthropic Claude系列 */
ANTHROPIC("Anthropic", "claude-sonnet-4-5"),
/** 阿里通义千问 */
QWEN("Qwen", "qwen-max");
/** 提供商标识 */
private final String provider;
/** 默认模型名称 */
private final String defaultModel;
/**
* 构造器
*
* @param provider 提供商标识
* @param defaultModel 默认模型名称
*/
ModelTypeEnum(String provider, String defaultModel) {
this.provider = provider;
this.defaultModel = defaultModel;
}
/**
* 获取提供商标识
*
* @return 提供商标识
*/
public String getProvider() {
return provider;
}
/**
* 获取默认模型名称
*
* @return 默认模型名称
*/
public String getDefaultModel() {
return defaultModel;
}
}
4.2.2 ModelAdapterService.java
java
package com.develop.code.common.adapter;
/**
* 模型适配器接口
* <p>
* 统一抽象不同模型提供商的调用接口,
* 支持动态切换模型和健康检查。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
public interface ModelAdapterService {
/**
* 同步聊天
*
* @param message 用户消息
* @return 模型响应内容
*/
String chat(String message);
/**
* 健康检查
*
* @return 模型服务是否可用
*/
boolean isHealthy();
/**
* 获取模型名称
*
* @return 模型名称
*/
String getModelName();
}
4.2.3 OpenAiModelAdapter.java
java
package com.develop.code.common.adapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import com.develop.code.common.enums.ModelTypeEnum;
/**
* OpenAI模型适配器
*
* @author develop-code
* @since 1.0.0
*/
@Component
public class OpenAiModelAdapter implements ModelAdapterService {
private static final Logger log = LoggerFactory.getLogger(
OpenAiModelAdapter.class);
private final ChatClient openAiChatClient;
/**
* 构造器注入
*
* @param openAiChatClient OpenAI专用ChatClient
*/
public OpenAiModelAdapter(
@Qualifier("openAiChatClient") ChatClient openAiChatClient) {
this.openAiChatClient = openAiChatClient;
}
/**
* 同步聊天
*
* @param message 用户消息
* @return 模型响应内容
*/
@Override
public String chat(String message) {
log.info("[OpenAI调用] message={}", truncate(message));
return openAiChatClient.prompt()
.user(message)
.call()
.content();
}
/**
* 健康检查
*
* @return 模型服务是否可用
*/
@Override
public boolean isHealthy() {
try {
openAiChatClient.prompt().user("ping").call().content();
return true;
} catch (Exception e) {
log.warn("[OpenAI健康检查失败] msg={}", e.getMessage());
return false;
}
}
/**
* 获取模型名称
*
* @return 模型名称
*/
@Override
public String getModelName() {
return ModelTypeEnum.OPENAI.name();
}
/**
* 截断日志内容
*
* @param text 原始文本
* @return 截断后的文本
*/
private String truncate(String text) {
if (text == null) {
return "";
}
return text.length() <= 100 ? text : text.substring(0, 100) + "...";
}
}
4.2.4 AnthropicModelAdapter.java
java
package com.develop.code.common.adapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import com.develop.code.common.enums.ModelTypeEnum;
/**
* Anthropic模型适配器
*
* @author develop-code
* @since 1.0.0
*/
@Component
public class AnthropicModelAdapter implements ModelAdapterService {
private static final Logger log = LoggerFactory.getLogger(
AnthropicModelAdapter.class);
private final ChatClient anthropicChatClient;
/**
* 构造器注入
*
* @param anthropicChatClient Anthropic专用ChatClient
*/
public AnthropicModelAdapter(
@Qualifier("anthropicChatClient") ChatClient anthropicChatClient) {
this.anthropicChatClient = anthropicChatClient;
}
/**
* 同步聊天
*
* @param message 用户消息
* @return 模型响应内容
*/
@Override
public String chat(String message) {
log.info("[Anthropic调用] message={}", truncate(message));
return anthropicChatClient.prompt()
.user(message)
.call()
.content();
}
/**
* 健康检查
*
* @return 模型服务是否可用
*/
@Override
public boolean isHealthy() {
try {
anthropicChatClient.prompt().user("ping").call().content();
return true;
} catch (Exception e) {
log.warn("[Anthropic健康检查失败] msg={}", e.getMessage());
return false;
}
}
/**
* 获取模型名称
*
* @return 模型名称
*/
@Override
public String getModelName() {
return ModelTypeEnum.ANTHROPIC.name();
}
/**
* 截断日志内容
*
* @param text 原始文本
* @return 截断后的文本
*/
private String truncate(String text) {
if (text == null) {
return "";
}
return text.length() <= 100 ? text : text.substring(0, 100) + "...";
}
}
4.2.5 QwenModelAdapter.java
java
package com.develop.code.common.adapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import com.develop.code.common.enums.ModelTypeEnum;
/**
* 通义千问模型适配器
* <p>
* 通过OpenAI兼容接口调用通义千问API。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Component
public class QwenModelAdapter implements ModelAdapterService {
private static final Logger log = LoggerFactory.getLogger(
QwenModelAdapter.class);
private final ChatClient qwenChatClient;
/**
* 构造器注入
*
* @param qwenChatClient 通义千问专用ChatClient
*/
public QwenModelAdapter(
@Qualifier("qwenChatClient") ChatClient qwenChatClient) {
this.qwenChatClient = qwenChatClient;
}
/**
* 同步聊天
*
* @param message 用户消息
* @return 模型响应内容
*/
@Override
public String chat(String message) {
log.info("[Qwen调用] message={}", truncate(message));
return qwenChatClient.prompt()
.user(message)
.call()
.content();
}
/**
* 健康检查
*
* @return 模型服务是否可用
*/
@Override
public boolean isHealthy() {
try {
qwenChatClient.prompt().user("ping").call().content();
return true;
} catch (Exception e) {
log.warn("[Qwen健康检查失败] msg={}", e.getMessage());
return false;
}
}
/**
* 获取模型名称
*
* @return 模型名称
*/
@Override
public String getModelName() {
return ModelTypeEnum.QWEN.name();
}
/**
* 截断日志内容
*
* @param text 原始文本
* @return 截断后的文本
*/
private String truncate(String text) {
if (text == null) {
return "";
}
return text.length() <= 100 ? text : text.substring(0, 100) + "...";
}
}
4.2.6 MultiModelChatServiceImpl.java
java
package com.develop.code.common.service.impl;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.develop.code.common.adapter.ModelAdapterService;
import com.develop.code.common.dto.ChatRequestDTO;
import com.develop.code.common.enums.ModelTypeEnum;
import com.develop.code.common.exception.BusinessException;
import com.develop.code.common.helper.TraceIdHelper;
import com.develop.code.common.result.ResultCodeEnum;
import com.develop.code.common.service.ChatService;
import com.develop.code.common.vo.ChatResponseVO;
/**
* 多模型聊天业务实现
* <p>
* 通过策略模式动态选择模型适配器,
* 支持主模型故障时自动降级到备用模型。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Service
public class MultiModelChatServiceImpl implements ChatService {
private static final Logger log = LoggerFactory.getLogger(
MultiModelChatServiceImpl.class);
/** 模型适配器映射 */
private final Map<String, ModelAdapterService> adapterMap;
/**
* 构造器注入
* <p>
* Spring自动注入所有ModelAdapterService实现,
* 按model name构建映射表。
* </p>
*
* @param adapters 模型适配器列表
*/
public MultiModelChatServiceImpl(List<ModelAdapterService> adapters) {
this.adapterMap = adapters.stream()
.collect(Collectors.toMap(
ModelAdapterService::getModelName, a -> a));
log.info("[多模型适配器初始化] adapters={}", adapterMap.keySet());
}
/**
* 同步聊天
* <p>
* 默认使用OpenAI模型,调用失败时自动降级。
* </p>
*
* @param requestDTO 请求参数
* @return 聊天响应VO
* @throws BusinessException 所有模型均不可用时抛出
*/
@Override
public ChatResponseVO chat(ChatRequestDTO requestDTO) {
long startTime = System.currentTimeMillis();
String traceId = TraceIdHelper.getTraceId();
String content = callWithFallback(ModelTypeEnum.OPENAI.name(),
requestDTO.getMessage(), traceId);
long elapsed = System.currentTimeMillis() - startTime;
log.info("[多模型聊天完成] traceId={}, elapsed={}ms", traceId, elapsed);
ChatResponseVO vo = new ChatResponseVO();
vo.setContent(content);
vo.setElapsedMillis(elapsed);
return vo;
}
/**
* 带降级的模型调用
*
* @param primaryModel 主模型名称
* @param message 用户消息
* @param traceId 链路追踪ID
* @return 模型响应内容
* @throws BusinessException 所有模型均不可用时抛出
*/
private String callWithFallback(String primaryModel, String message,
String traceId) {
ModelAdapterService adapter = adapterMap.get(primaryModel);
if (adapter != null) {
try {
return adapter.chat(message);
} catch (Exception e) {
log.warn("[主模型调用失败] traceId={}, model={}, msg={}",
traceId, primaryModel, e.getMessage());
}
}
for (Map.Entry<String, ModelAdapterService> entry : adapterMap.entrySet()) {
if (entry.getKey().equals(primaryModel)) {
continue;
}
try {
log.info("[降级到备用模型] traceId={}, model={}",
traceId, entry.getKey());
return entry.getValue().chat(message);
} catch (Exception e) {
log.warn("[备用模型调用失败] traceId={}, model={}, msg={}",
traceId, entry.getKey(), e.getMessage());
}
}
throw new BusinessException(ResultCodeEnum.AI_MODEL_ERROR);
}
}
4.2.7 ModelHealthChecker.java
java
package com.develop.code.common.health;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.develop.code.common.adapter.ModelAdapterService;
/**
* 模型健康检查器
* <p>
* 定时检查各模型适配器的健康状态,
* 缓存检查结果供业务层查询。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Component
public class ModelHealthChecker {
private static final Logger log = LoggerFactory.getLogger(
ModelHealthChecker.class);
/** 模型适配器映射 */
private final Map<String, ModelAdapterService> adapterMap;
/** 健康状态缓存 */
private final Map<String, Boolean> healthStatus = new ConcurrentHashMap<>();
/**
* 构造器注入
*
* @param adapters 模型适配器列表
*/
public ModelHealthChecker(List<ModelAdapterService> adapters) {
this.adapterMap = adapters.stream()
.collect(Collectors.toMap(
ModelAdapterService::getModelName, a -> a));
}
/**
* 定时健康检查(每60秒执行一次)
*/
@Scheduled(fixedRate = 60000)
public void checkHealth() {
adapterMap.forEach((name, adapter) -> {
boolean healthy = adapter.isHealthy();
Boolean previous = healthStatus.put(name, healthy);
if (previous == null || previous != healthy) {
log.info("[模型健康状态变更] model={}, healthy={}", name, healthy);
}
});
}
/**
* 查询指定模型是否健康
*
* @param modelName 模型名称
* @return 是否健康
*/
public boolean isHealthy(String modelName) {
return healthStatus.getOrDefault(modelName, false);
}
/**
* 获取第一个健康的模型名称
*
* @return 健康模型名称,无可用模型时返回null
*/
public String getFirstHealthyModel() {
return healthStatus.entrySet().stream()
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.findFirst()
.orElse(null);
}
/**
* 获取所有模型健康状态
*
* @return 模型健康状态映射
*/
public Map<String, Boolean> getAllHealthStatus() {
return new ConcurrentHashMap<>(healthStatus);
}
}
4.3 数据走向流程图
#mermaid-svg-1hAmWGXBDW5tGCds{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1hAmWGXBDW5tGCds .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1hAmWGXBDW5tGCds .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1hAmWGXBDW5tGCds .error-icon{fill:#552222;}#mermaid-svg-1hAmWGXBDW5tGCds .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1hAmWGXBDW5tGCds .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1hAmWGXBDW5tGCds .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1hAmWGXBDW5tGCds .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1hAmWGXBDW5tGCds .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1hAmWGXBDW5tGCds .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1hAmWGXBDW5tGCds .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1hAmWGXBDW5tGCds .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1hAmWGXBDW5tGCds .marker.cross{stroke:#333333;}#mermaid-svg-1hAmWGXBDW5tGCds svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1hAmWGXBDW5tGCds p{margin:0;}#mermaid-svg-1hAmWGXBDW5tGCds .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1hAmWGXBDW5tGCds .cluster-label text{fill:#333;}#mermaid-svg-1hAmWGXBDW5tGCds .cluster-label span{color:#333;}#mermaid-svg-1hAmWGXBDW5tGCds .cluster-label span p{background-color:transparent;}#mermaid-svg-1hAmWGXBDW5tGCds .label text,#mermaid-svg-1hAmWGXBDW5tGCds span{fill:#333;color:#333;}#mermaid-svg-1hAmWGXBDW5tGCds .node rect,#mermaid-svg-1hAmWGXBDW5tGCds .node circle,#mermaid-svg-1hAmWGXBDW5tGCds .node ellipse,#mermaid-svg-1hAmWGXBDW5tGCds .node polygon,#mermaid-svg-1hAmWGXBDW5tGCds .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1hAmWGXBDW5tGCds .rough-node .label text,#mermaid-svg-1hAmWGXBDW5tGCds .node .label text,#mermaid-svg-1hAmWGXBDW5tGCds .image-shape .label,#mermaid-svg-1hAmWGXBDW5tGCds .icon-shape .label{text-anchor:middle;}#mermaid-svg-1hAmWGXBDW5tGCds .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1hAmWGXBDW5tGCds .rough-node .label,#mermaid-svg-1hAmWGXBDW5tGCds .node .label,#mermaid-svg-1hAmWGXBDW5tGCds .image-shape .label,#mermaid-svg-1hAmWGXBDW5tGCds .icon-shape .label{text-align:center;}#mermaid-svg-1hAmWGXBDW5tGCds .node.clickable{cursor:pointer;}#mermaid-svg-1hAmWGXBDW5tGCds .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1hAmWGXBDW5tGCds .arrowheadPath{fill:#333333;}#mermaid-svg-1hAmWGXBDW5tGCds .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1hAmWGXBDW5tGCds .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1hAmWGXBDW5tGCds .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1hAmWGXBDW5tGCds .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1hAmWGXBDW5tGCds .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1hAmWGXBDW5tGCds .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1hAmWGXBDW5tGCds .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1hAmWGXBDW5tGCds .cluster text{fill:#333;}#mermaid-svg-1hAmWGXBDW5tGCds .cluster span{color:#333;}#mermaid-svg-1hAmWGXBDW5tGCds div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1hAmWGXBDW5tGCds .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1hAmWGXBDW5tGCds rect.text{fill:none;stroke-width:0;}#mermaid-svg-1hAmWGXBDW5tGCds .icon-shape,#mermaid-svg-1hAmWGXBDW5tGCds .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1hAmWGXBDW5tGCds .icon-shape p,#mermaid-svg-1hAmWGXBDW5tGCds .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1hAmWGXBDW5tGCds .icon-shape .label rect,#mermaid-svg-1hAmWGXBDW5tGCds .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1hAmWGXBDW5tGCds .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1hAmWGXBDW5tGCds .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1hAmWGXBDW5tGCds :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
用户请求
ModelTypeEnum
模型选择
适配器路由
Map查找
ModelHealthChecker
健康检查
健康?
主模型调用
降级备用模型
响应统一封装
Result返回
4.4 优劣势分析
| 维度 | 评价 | 说明 |
|---|---|---|
| 模型扩展性 | 优势 | 新增模型仅需实现接口 |
| 可用性 | 优势 | 主模型故障自动降级 |
| 健康检查 | 优势 | 定时探测,状态缓存 |
| 开发效率 | 一般 | 需3-5天开发配置 |
| 配置复杂度 | 劣势 | 多模型需独立配置 |
| 成本控制 | 一般 | 无智能路由优化 |
4.5 成本估算
| 成本项 | 月度费用(元) | 说明 |
|---|---|---|
| OpenAI API Token | 600-2000 | 主模型,按需调用 |
| Anthropic API Token | 300-1000 | 备用模型,降级时使用 |
| 通义千问 API Token | 200-800 | 降级模型,成本较低 |
| 服务器(4C8G) | 400 | 需更多内存缓存健康状态 |
| 人力维护 | 1 人天 | 多模型配置与监控 |
| 月度总成本 | 1500-4200 | 适合成长期团队 |
4.6 Build vs Buy 分析
| 对比维度 | 自建多模型适配层 | OneAPI 中间件 | LiteLLM 代理 |
|---|---|---|---|
| 开发成本 | 3-5天 | 1天部署 | 1天部署 |
| 定制灵活性 | 高 | 中 | 中 |
| 维护成本 | 低(Spring原生) | 中(独立服务) | 中(Python生态) |
| Java生态集成 | 原生 | HTTP调用 | HTTP调用 |
| 故障排查 | 直接看日志 | 需查中间件 | 需查代理层 |
| 等保合规 | 易(同进程) | 难(跨服务) | 难(跨服务) |
| SME推荐度 | 推荐 | 不推荐 | 不推荐 |
结论:SME 团队建议自建多模型适配层。虽然多投入 2-4 天开发时间,但获得了 Java 原生集成、故障排查便捷、等保合规易过审三大核心优势。引入外部中间件会增加运维复杂度和安全合规风险。
第五部分:方案三 高级版 - 配置中心 + 密钥管理 + 健康检查全链路
5.1 实现原理
高级版方案在进阶版基础上构建全链路生产级架构,覆盖从请求入口到响应返回的每一个环节。核心增强包括:
- 统一返回体
Result<T>:固定code/msg/data/traceId四字段 - 全局异常处理
GlobalExceptionHandler:统一捕获业务异常、参数校验异常、系统异常 - 审计日志切面
AuditLogAspect:AOP拦截Controller,记录操作人/IP/耗时/traceId - 安全工具
SecurityHelper:AES加密、BCrypt哈希、敏感字段脱敏 - 链路追踪
TraceIdInterceptor:请求入口注入traceId到MDC - 跨域安全
CorsConfig:指定域名白名单,禁止通配符 - Redis配置
RedisConfig:Jackson序列化,支持Java 8时间类型 - MyBatis-Plus配置
MyBatisPlusConfig:分页插件 + 乐观锁插件
#mermaid-svg-6U9FtR28pZOqBcRU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6U9FtR28pZOqBcRU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6U9FtR28pZOqBcRU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6U9FtR28pZOqBcRU .error-icon{fill:#552222;}#mermaid-svg-6U9FtR28pZOqBcRU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6U9FtR28pZOqBcRU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6U9FtR28pZOqBcRU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6U9FtR28pZOqBcRU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6U9FtR28pZOqBcRU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6U9FtR28pZOqBcRU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6U9FtR28pZOqBcRU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6U9FtR28pZOqBcRU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6U9FtR28pZOqBcRU .marker.cross{stroke:#333333;}#mermaid-svg-6U9FtR28pZOqBcRU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6U9FtR28pZOqBcRU p{margin:0;}#mermaid-svg-6U9FtR28pZOqBcRU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6U9FtR28pZOqBcRU .cluster-label text{fill:#333;}#mermaid-svg-6U9FtR28pZOqBcRU .cluster-label span{color:#333;}#mermaid-svg-6U9FtR28pZOqBcRU .cluster-label span p{background-color:transparent;}#mermaid-svg-6U9FtR28pZOqBcRU .label text,#mermaid-svg-6U9FtR28pZOqBcRU span{fill:#333;color:#333;}#mermaid-svg-6U9FtR28pZOqBcRU .node rect,#mermaid-svg-6U9FtR28pZOqBcRU .node circle,#mermaid-svg-6U9FtR28pZOqBcRU .node ellipse,#mermaid-svg-6U9FtR28pZOqBcRU .node polygon,#mermaid-svg-6U9FtR28pZOqBcRU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6U9FtR28pZOqBcRU .rough-node .label text,#mermaid-svg-6U9FtR28pZOqBcRU .node .label text,#mermaid-svg-6U9FtR28pZOqBcRU .image-shape .label,#mermaid-svg-6U9FtR28pZOqBcRU .icon-shape .label{text-anchor:middle;}#mermaid-svg-6U9FtR28pZOqBcRU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6U9FtR28pZOqBcRU .rough-node .label,#mermaid-svg-6U9FtR28pZOqBcRU .node .label,#mermaid-svg-6U9FtR28pZOqBcRU .image-shape .label,#mermaid-svg-6U9FtR28pZOqBcRU .icon-shape .label{text-align:center;}#mermaid-svg-6U9FtR28pZOqBcRU .node.clickable{cursor:pointer;}#mermaid-svg-6U9FtR28pZOqBcRU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6U9FtR28pZOqBcRU .arrowheadPath{fill:#333333;}#mermaid-svg-6U9FtR28pZOqBcRU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6U9FtR28pZOqBcRU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6U9FtR28pZOqBcRU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6U9FtR28pZOqBcRU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6U9FtR28pZOqBcRU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6U9FtR28pZOqBcRU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6U9FtR28pZOqBcRU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6U9FtR28pZOqBcRU .cluster text{fill:#333;}#mermaid-svg-6U9FtR28pZOqBcRU .cluster span{color:#333;}#mermaid-svg-6U9FtR28pZOqBcRU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6U9FtR28pZOqBcRU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6U9FtR28pZOqBcRU rect.text{fill:none;stroke-width:0;}#mermaid-svg-6U9FtR28pZOqBcRU .icon-shape,#mermaid-svg-6U9FtR28pZOqBcRU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6U9FtR28pZOqBcRU .icon-shape p,#mermaid-svg-6U9FtR28pZOqBcRU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6U9FtR28pZOqBcRU .icon-shape .label rect,#mermaid-svg-6U9FtR28pZOqBcRU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6U9FtR28pZOqBcRU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6U9FtR28pZOqBcRU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6U9FtR28pZOqBcRU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端请求
TraceIdInterceptor
traceId注入
CorsConfig
跨域校验
ChatController
参数校验
AuditLogAspect
审计切面拦截
ChatService
业务逻辑
ChatClient
模型调用
LoggingAdvisor
日志拦截
SecurityHelper
数据脱敏
Result封装
统一返回体
GlobalExceptionHandler
异常兜底
统一返回客户端
5.2 完整 Java 代码
以下代码均为 com-common 模块生产级实现,遵循阿里嵩山版规范和等保三级要求。
全链路设计理念:高级版方案的核心设计理念是"每一层都有保障"。从 HTTP 请求入口到模型 API 调用,再到响应返回客户端,全链路的每一个环节都有对应的安全和可观测性保障。这不是简单的功能堆砌,而是基于等保三级要求进行的系统性设计------输入有校验、调用有拦截、数据有加密、操作有审计、异常有兜底、链路有追踪。
审计日志异步写入 :AuditLogAspect 使用 @Async 异步写入审计日志,确保审计记录不影响主流程性能。审计日志包含操作人 ID、操作时间、请求参数、响应数据、客户端 IP、traceId、接口耗时、成功/失败标识等完整信息,满足等保三级"所有增删改操作记录操作人、操作时间、原始数据、变更数据、客户端 IP"的要求。日志持久化存储在 MariaDB 中,保留期限不低于 180 天。
密钥管理三级防护:密钥管理是等保三级数据安全的核心要求。本方案实现了三级防护------第一级,所有密钥(API Key、AES Key、数据库密码)通过环境变量注入,禁止出现在代码和配置文件中;第二级,敏感数据(手机号、身份证)使用 AES 对称加密存储,即使数据库被拖库也无法还原明文;第三级,密码使用 BCrypt 加盐哈希,即使密钥泄露也无法逆向破解。
5.2.1 Result.java(统一返回体)
java
package com.develop.code.common.result;
import java.io.Serial;
import java.io.Serializable;
import com.develop.code.common.helper.TraceIdHelper;
/**
* 统一返回体 Result T
* <p>
* 全局统一返回结构,固定 code / msg / data / traceId 四字段。
* 所有接口必须使用此返回体,禁止裸返回业务对象。
* </p>
*
* @param <T> 返回数据泛型
* @author develop-code
* @since 1.0.0
*/
public class Result<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 返回码 */
private Integer code;
/** 返回消息 */
private String msg;
/** 返回数据 */
private T data;
/** 链路追踪ID */
private String traceId;
/**
* 默认构造器
*/
public Result() {
}
/**
* 全参构造器
*
* @param code 返回码
* @param msg 返回消息
* @param data 返回数据
* @param traceId 链路追踪ID
*/
public Result(Integer code, String msg, T data, String traceId) {
this.code = code;
this.msg = msg;
this.data = data;
this.traceId = traceId;
}
/**
* 成功返回(带数据)
*
* @param data 返回数据
* @param T 返回数据泛型
* @return 统一返回体
*/
public static <T> Result<T> success(T data) {
return new Result<>(ResultCodeEnum.SUCCESS.getCode(),
ResultCodeEnum.SUCCESS.getMsg(), data,
TraceIdHelper.getTraceId());
}
/**
* 失败返回(自定义错误码和消息)
*
* @param code 错误码
* @param msg 错误消息
* @param T 返回数据泛型
* @return 统一返回体
*/
public static <T> Result<T> fail(Integer code, String msg) {
return new Result<>(code, msg, null, TraceIdHelper.getTraceId());
}
/**
* 获取返回码
*
* @return 返回码
*/
public Integer getCode() {
return code;
}
/**
* 设置返回码
*
* @param code 返回码
*/
public void setCode(Integer code) {
this.code = code;
}
/**
* 获取返回消息
*
* @return 返回消息
*/
public String getMsg() {
return msg;
}
/**
* 设置返回消息
*
* @param msg 返回消息
*/
public void setMsg(String msg) {
this.msg = msg;
}
/**
* 获取返回数据
*
* @return 返回数据
*/
public T getData() {
return data;
}
/**
* 设置返回数据
*
* @param data 返回数据
*/
public void setData(T data) {
this.data = data;
}
/**
* 获取链路追踪ID
*
* @return 链路追踪ID
*/
public String getTraceId() {
return traceId;
}
/**
* 设置链路追踪ID
*
* @param traceId 链路追踪ID
*/
public void setTraceId(String traceId) {
this.traceId = traceId;
}
}
5.2.2 GlobalExceptionHandler.java(全局异常处理)
java
package com.develop.code.common.exception;
import java.util.stream.Collectors;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.develop.code.common.helper.TraceIdHelper;
import com.develop.code.common.result.Result;
import com.develop.code.common.result.ResultCodeEnum;
/**
* 全局异常处理器
* <p>
* 统一捕获业务异常、参数校验异常、系统异常,
* 关闭错误堆栈对外暴露,统一返回业务错误码。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(
GlobalExceptionHandler.class);
/**
* 处理业务异常
*
* @param e 业务异常
* @return 统一返回体
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.error("[业务异常] traceId={}, code={}, msg={}", e.getTraceId(),
e.getCode(), e.getMessage(), e);
return Result.fail(e.getCode(), e.getMsg());
}
/**
* 处理参数校验异常
*
* @param e 参数校验异常
* @return 统一返回体
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidException(
MethodArgumentNotValidException e) {
String errorMsg = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("[参数校验失败] traceId={}, msg={}",
TraceIdHelper.getTraceId(), errorMsg);
return Result.fail(ResultCodeEnum.PARAM_ERROR.getCode(), errorMsg);
}
/**
* 处理系统未知异常
* <p>
* 禁止对外暴露内部堆栈信息,统一返回"系统繁忙"。
* </p>
*
* @param e 未知异常
* @return 统一返回体
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("[系统异常] traceId={}", TraceIdHelper.getTraceId(), e);
return Result.fail(ResultCodeEnum.SYSTEM_ERROR);
}
}
5.2.3 AuditLogAspect.java(审计日志切面)
java
package com.develop.code.common.aspect;
import java.time.LocalDateTime;
import java.util.Arrays;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.develop.code.common.constant.CommonConstant;
import com.develop.code.common.entity.AuditLogDO;
import com.develop.code.common.helper.TraceIdHelper;
import com.develop.code.common.service.AuditLogService;
/**
* 审计日志切面
* <p>
* AOP拦截Controller层所有方法,记录操作人、操作时间、
* 请求参数、响应数据、客户端IP、traceId、耗时。
* 满足等保三级审计安全要求,日志持久化存储至少180天。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Aspect
@Component
public class AuditLogAspect {
private static final Logger log = LoggerFactory.getLogger(
AuditLogAspect.class);
private final AuditLogService auditLogService;
/**
* 构造器注入
*
* @param auditLogService 审计日志服务
*/
public AuditLogAspect(AuditLogService auditLogService) {
this.auditLogService = auditLogService;
}
/**
* Controller层切点
*/
@Pointcut("execution(* com.develop.code..controller..*.*(..))")
public void controllerPointcut() {
}
/**
* 环绕通知:记录审计日志
*
* @param joinPoint 连接点
* @return 方法返回值
* @throws Throwable 方法执行异常
*/
@Around("controllerPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
String traceId = TraceIdHelper.getTraceId();
HttpServletRequest request = getRequest();
String clientIp = getClientIp(request);
String reqMethod = request != null ? request.getMethod() : "";
String reqUrl = request != null ? request.getRequestURI() : "";
Object result = null;
Integer isSuccess = CommonConstant.SUCCESS_FLAG;
String errorMsg = "";
try {
result = joinPoint.proceed();
} catch (Throwable e) {
isSuccess = CommonConstant.FAIL_FLAG;
errorMsg = e.getMessage();
throw e;
} finally {
long costTime = System.currentTimeMillis() - startTime;
AuditLogDO auditLog = new AuditLogDO();
auditLog.setTraceId(traceId);
auditLog.setAction(joinPoint.getSignature().getName());
auditLog.setModule(joinPoint.getTarget().getClass()
.getSimpleName());
auditLog.setRequestParams(truncateParams(
Arrays.toString(joinPoint.getArgs())));
auditLog.setClientIp(clientIp);
auditLog.setReqMethod(reqMethod);
auditLog.setReqUrl(reqUrl);
auditLog.setCostTimeMs(costTime);
auditLog.setIsSuccess(isSuccess);
auditLog.setErrorMsg(errorMsg);
auditLog.setCreatedBy("system");
auditLog.setCreatedTime(LocalDateTime.now());
auditLogService.recordAuditLog(auditLog);
log.info("[审计日志] traceId={}, action={}, cost={}ms, success={}",
traceId, joinPoint.getSignature().getName(),
costTime, isSuccess);
}
return result;
}
/**
* 获取当前HTTP请求
*
* @return HttpServletRequest
*/
private HttpServletRequest getRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();
return attributes != null ? attributes.getRequest() : null;
}
/**
* 获取客户端IP
*
* @param request HTTP请求
* @return 客户端IP
*/
private String getClientIp(HttpServletRequest request) {
if (request == null) {
return "unknown";
}
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
/**
* 截断请求参数,防止日志过长
*
* @param params 原始参数字符串
* @return 截断后的参数字符串
*/
private String truncateParams(String params) {
if (params == null) {
return "";
}
return params.length() <= 500
? params : params.substring(0, 500) + "...";
}
}
5.2.4 SecurityHelper.java(安全工具类 - 核心方法)
java
package com.develop.code.common.helper;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.develop.code.common.constant.CommonConstant;
import at.favre.lib.crypto.bcrypt.BCrypt;
/**
* 安全工具类
* <p>
* 提供AES加解密、BCrypt密码哈希验证、敏感数据脱敏功能。
* 密钥从环境变量 APP_AES_KEY 读取,禁止硬编码。
* 满足等保三级2.0数据安全要求。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
public final class SecurityHelper {
private static final Logger log = LoggerFactory.getLogger(
SecurityHelper.class);
private static final String AES_ALGORITHM = "AES";
private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding";
private static final int AES_KEY_LENGTH = 16;
/**
* 私有构造器,禁止实例化
*/
private SecurityHelper() {
}
/**
* AES加密
* <p>
* 从环境变量读取密钥,明文经AES加密后Base64编码输出。
* </p>
*
* @param plainText 待加密明文
* @return Base64编码的密文
* @throws RuntimeException 加密失败时抛出
*/
public static String encrypt(String plainText) {
try {
SecretKeySpec keySpec = getKeySpec();
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encrypted = cipher.doFinal(
plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
log.error("[AES加密失败] msg={}", e.getMessage(), e);
throw new RuntimeException("AES加密失败", e);
}
}
/**
* AES解密
*
* @param cipherText Base64编码的密文
* @return 解密后的明文
* @throws RuntimeException 解密失败时抛出
*/
public static String decrypt(String cipherText) {
try {
SecretKeySpec keySpec = getKeySpec();
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decoded = Base64.getDecoder().decode(cipherText);
byte[] decrypted = cipher.doFinal(decoded);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("[AES解密失败] msg={}", e.getMessage(), e);
throw new RuntimeException("AES解密失败", e);
}
}
/**
* BCrypt加盐哈希密码
* <p>
* 禁止使用MD5简单哈希。
* </p>
*
* @param plainPassword 明文密码
* @return BCrypt哈希后的密码字符串
*/
public static String hashPassword(String plainPassword) {
return BCrypt.withDefaults().hashToString(12,
plainPassword.toCharArray());
}
/**
* BCrypt验证密码
*
* @param plainPassword 明文密码
* @param hashedPassword BCrypt哈希密码
* @return 验证通过返回true,否则返回false
*/
public static boolean verifyPassword(String plainPassword,
String hashedPassword) {
try {
BCrypt.Result result = BCrypt.verifyer().verify(
plainPassword.toCharArray(), hashedPassword);
return result.verified;
} catch (Exception e) {
log.error("[密码验证异常] msg={}", e.getMessage(), e);
return false;
}
}
/**
* 手机号脱敏
* <p>
* 13812345678 变为 138****5678
* </p>
*
* @param phone 手机号
* @return 脱敏后的手机号
*/
public static String desensitizePhone(String phone) {
if (phone == null || phone.length() < 7) {
return phone;
}
return phone.substring(0, 3) + "****"
+ phone.substring(phone.length() - 4);
}
/**
* 身份证号脱敏
* <p>
* 110101199001011234 变为 110***********1234
* </p>
*
* @param idCard 身份证号
* @return 脱敏后的身份证号
*/
public static String desensitizeIdCard(String idCard) {
if (idCard == null || idCard.length() < 10) {
return idCard;
}
return idCard.substring(0, 3) + "***********"
+ idCard.substring(idCard.length() - 4);
}
/**
* 获取AES密钥规格
*
* @return SecretKeySpec
* @throws IllegalStateException 环境变量未配置时抛出
*/
private static SecretKeySpec getKeySpec() {
String key = System.getenv(CommonConstant.AES_KEY_ENV);
if (key == null || key.isEmpty()) {
throw new IllegalStateException(
"AES密钥环境变量未配置: " + CommonConstant.AES_KEY_ENV);
}
if (key.length() < AES_KEY_LENGTH) {
key = cn.hutool.crypto.SecureUtil.md5(key)
.substring(0, AES_KEY_LENGTH);
} else if (key.length() > AES_KEY_LENGTH) {
key = key.substring(0, AES_KEY_LENGTH);
}
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return new SecretKeySpec(keyBytes, AES_ALGORITHM);
}
}
5.2.5 TraceIdInterceptor.java(链路追踪拦截器)
java
package com.develop.code.common.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
import com.develop.code.common.constant.CommonConstant;
import com.develop.code.common.helper.TraceIdHelper;
/**
* 链路追踪拦截器
* <p>
* 在请求入口处统一设置traceId到MDC,
* 保证全链路日志携带追踪标识。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
public class TraceIdInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(
TraceIdInterceptor.class);
/**
* 请求前置处理:从请求头获取traceId,没有则生成
*
* @param request HTTP请求
* @param response HTTP响应
* @param handler 处理器
* @return 是否继续执行
* @throws Exception 异常
*/
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String traceId = request.getHeader(CommonConstant.TRACE_ID_HEADER);
if (traceId == null || traceId.isEmpty()) {
traceId = TraceIdHelper.generateTraceId();
}
TraceIdHelper.setTraceId(traceId);
response.setHeader(CommonConstant.TRACE_ID_HEADER, traceId);
log.info("[请求入口] traceId={}, method={}, uri={}",
traceId, request.getMethod(), request.getRequestURI());
return true;
}
/**
* 请求完成后清除MDC中的traceId
*
* @param request HTTP请求
* @param response HTTP响应
* @param handler 处理器
* @param ex 异常
* @throws Exception 异常
*/
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex)
throws Exception {
TraceIdHelper.clearTraceId();
}
}
5.2.6 CorsConfig.java(跨域安全配置)
java
package com.develop.code.common.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 跨域配置类
* <p>
* 禁止通配符跨域,仅允许配置的指定域名访问。
* 满足等保三级安全基线要求。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
/** 允许跨域的域名,从配置读取,逗号分隔 */
@Value("${cors.allowed-origins:http://localhost:5173}")
private String allowedOrigins;
/**
* 配置CORS跨域映射
*
* @param registry CORS注册器
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
String[] origins = allowedOrigins.split(",");
registry.addMapping("/**")
.allowedOrigins(origins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("Origin", "Content-Type", "Accept",
"Authorization", "X-Trace-Id")
.exposedHeaders("X-Trace-Id")
.allowCredentials(true)
.maxAge(3600);
}
}
5.2.7 RedisConfig.java(Redis序列化配置)
java
package com.develop.code.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类
* <p>
* 配置RedisTemplate使用Jackson2JsonRedisSerializer序列化,
* 支持Java 8时间类型。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Configuration
public class RedisConfig {
private final RedisConnectionFactory redisConnectionFactory;
/**
* 构造器注入
*
* @param redisConnectionFactory Redis连接工厂
*/
public RedisConfig(RedisConnectionFactory redisConnectionFactory) {
this.redisConnectionFactory = redisConnectionFactory;
}
/**
* 配置RedisTemplate
*
* @return RedisTemplate实例
*/
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
5.2.8 MyBatisPlusConfig.java(MyBatis-Plus配置)
java
package com.develop.code.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
/**
* MyBatis-Plus配置类
* <p>
* 配置分页插件和乐观锁插件。
* 全部SQL使用参数绑定,禁止字符串拼接。
* </p>
*
* @author develop-code
* @since 1.0.0
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 配置MyBatis-Plus拦截器链
*
* @return MybatisPlusInterceptor实例
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.MARIADB));
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
5.3 数据走向流程图(完整全链路)
#mermaid-svg-UwZkkfejwluo8sAS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UwZkkfejwluo8sAS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UwZkkfejwluo8sAS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UwZkkfejwluo8sAS .error-icon{fill:#552222;}#mermaid-svg-UwZkkfejwluo8sAS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UwZkkfejwluo8sAS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UwZkkfejwluo8sAS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UwZkkfejwluo8sAS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UwZkkfejwluo8sAS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UwZkkfejwluo8sAS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UwZkkfejwluo8sAS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UwZkkfejwluo8sAS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UwZkkfejwluo8sAS .marker.cross{stroke:#333333;}#mermaid-svg-UwZkkfejwluo8sAS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UwZkkfejwluo8sAS p{margin:0;}#mermaid-svg-UwZkkfejwluo8sAS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UwZkkfejwluo8sAS .cluster-label text{fill:#333;}#mermaid-svg-UwZkkfejwluo8sAS .cluster-label span{color:#333;}#mermaid-svg-UwZkkfejwluo8sAS .cluster-label span p{background-color:transparent;}#mermaid-svg-UwZkkfejwluo8sAS .label text,#mermaid-svg-UwZkkfejwluo8sAS span{fill:#333;color:#333;}#mermaid-svg-UwZkkfejwluo8sAS .node rect,#mermaid-svg-UwZkkfejwluo8sAS .node circle,#mermaid-svg-UwZkkfejwluo8sAS .node ellipse,#mermaid-svg-UwZkkfejwluo8sAS .node polygon,#mermaid-svg-UwZkkfejwluo8sAS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UwZkkfejwluo8sAS .rough-node .label text,#mermaid-svg-UwZkkfejwluo8sAS .node .label text,#mermaid-svg-UwZkkfejwluo8sAS .image-shape .label,#mermaid-svg-UwZkkfejwluo8sAS .icon-shape .label{text-anchor:middle;}#mermaid-svg-UwZkkfejwluo8sAS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UwZkkfejwluo8sAS .rough-node .label,#mermaid-svg-UwZkkfejwluo8sAS .node .label,#mermaid-svg-UwZkkfejwluo8sAS .image-shape .label,#mermaid-svg-UwZkkfejwluo8sAS .icon-shape .label{text-align:center;}#mermaid-svg-UwZkkfejwluo8sAS .node.clickable{cursor:pointer;}#mermaid-svg-UwZkkfejwluo8sAS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UwZkkfejwluo8sAS .arrowheadPath{fill:#333333;}#mermaid-svg-UwZkkfejwluo8sAS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UwZkkfejwluo8sAS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UwZkkfejwluo8sAS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UwZkkfejwluo8sAS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UwZkkfejwluo8sAS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UwZkkfejwluo8sAS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UwZkkfejwluo8sAS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UwZkkfejwluo8sAS .cluster text{fill:#333;}#mermaid-svg-UwZkkfejwluo8sAS .cluster span{color:#333;}#mermaid-svg-UwZkkfejwluo8sAS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UwZkkfejwluo8sAS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UwZkkfejwluo8sAS rect.text{fill:none;stroke-width:0;}#mermaid-svg-UwZkkfejwluo8sAS .icon-shape,#mermaid-svg-UwZkkfejwluo8sAS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UwZkkfejwluo8sAS .icon-shape p,#mermaid-svg-UwZkkfejwluo8sAS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UwZkkfejwluo8sAS .icon-shape .label rect,#mermaid-svg-UwZkkfejwluo8sAS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UwZkkfejwluo8sAS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UwZkkfejwluo8sAS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UwZkkfejwluo8sAS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HTTP请求
TraceIdInterceptor
traceId注入MDC
CorsConfig
跨域白名单校验
ChatController
@Valid参数校验
AuditLogAspect
审计切面拦截
ChatService
业务逻辑处理
ChatClient
模型API调用
LoggingAdvisor
请求/响应日志
SecurityHelper
敏感数据脱敏
Result封装
code/msg/data/traceId
GlobalExceptionHandler
异常兜底
统一返回客户端
5.4 优劣势分析
| 维度 | 评价 | 说明 |
|---|---|---|
| 安全合规 | 优势 | 等保三级全项覆盖 |
| 审计能力 | 优势 | AOP切面全量审计,180天留存 |
| 链路追踪 | 优势 | traceId贯穿全链路 |
| 异常处理 | 优势 | 全局兜底,不暴露内部信息 |
| 数据安全 | 优势 | AES加密+BCrypt+脱敏 |
| 开发周期 | 劣势 | 需1-2周完整搭建 |
| 运维复杂度 | 一般 | 需Redis+MariaDB基础设施 |
5.5 成本估算
| 成本项 | 月度费用(元) | 说明 |
|---|---|---|
| 多模型 API Token | 1000-3000 | 主+备模型 |
| 服务器(4C8G) | 400 | 应用节点 |
| MariaDB(2C4G) | 300 | 审计日志存储 |
| Redis(1G) | 100 | 缓存+健康状态 |
| 日志存储(50G) | 50 | 180天日志留存 |
| 人力维护 | 2 人天 | 全链路运维 |
| 月度总成本 | 1850-3850 | 适合生产级部署 |
5.6 等保三级合规要点
| 等保要求 | 实现方式 | 对应代码 |
|---|---|---|
| 输入校验防注入 | @NotBlank/@Size/@Valid | ChatRequestDTO |
| SQL参数绑定 | MyBatis-Plus参数绑定 | MyBatisPlusConfig |
| 敏感字段加密 | AES对称加密 | SecurityHelper.encrypt |
| 密码安全存储 | BCrypt加盐哈希 | SecurityHelper.hashPassword |
| 密钥禁止硬编码 | 环境变量注入 | application.yml |
| 操作审计日志 | AOP切面+异步写入 | AuditLogAspect |
| 日志180天留存 | 持久化存储MariaDB | AuditLogServiceImpl |
| 日志脱敏 | 手机号/身份证掩码 | SecurityHelper.desensitize |
| 跨域白名单 | 指定域名,禁止通配符 | CorsConfig |
| 错误信息屏蔽 | 全局异常统一返回 | GlobalExceptionHandler |
| 链路追踪 | traceId贯穿全链路 | TraceIdInterceptor |
第六部分:三方案横向对比
6.1 对比表
| 对比维度 | 方案一 基础版 | 方案二 进阶版 | 方案三 高级版 |
|---|---|---|---|
| 核心能力 | ChatClient+Advisor | 多模型适配层 | 全链路生产级 |
| 性能 | 中等 | 中等 | 优秀 |
| 月度成本 | 1000-3200元 | 1500-4200元 | 1850-3850元 |
| 复杂度 | 低 | 中 | 高 |
| 模型支持 | 单一模型 | 多模型+降级 | 多模型+降级+健康检查 |
| 异常处理 | 基本捕获 | 降级兜底 | 全局异常+审计 |
| 安全合规 | 基础 | 一般 | 等保三级全覆盖 |
| 审计能力 | 无 | 无 | AOP全量审计 |
| 链路追踪 | Advisor日志 | Advisor日志 | traceId全链路 |
| 适用场景 | 快速验证 | 成长期生产 | 高可用生产 |
| 开发周期 | 1-2天 | 3-5天 | 1-2周 |
| SME推荐度 | 推荐 | 推荐 | 强烈推荐 |
6.2 对比图
#mermaid-svg-gSq2c5hQ6te2G3fz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gSq2c5hQ6te2G3fz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gSq2c5hQ6te2G3fz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gSq2c5hQ6te2G3fz .error-icon{fill:#552222;}#mermaid-svg-gSq2c5hQ6te2G3fz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gSq2c5hQ6te2G3fz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gSq2c5hQ6te2G3fz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gSq2c5hQ6te2G3fz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gSq2c5hQ6te2G3fz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gSq2c5hQ6te2G3fz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gSq2c5hQ6te2G3fz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gSq2c5hQ6te2G3fz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gSq2c5hQ6te2G3fz .marker.cross{stroke:#333333;}#mermaid-svg-gSq2c5hQ6te2G3fz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gSq2c5hQ6te2G3fz p{margin:0;}#mermaid-svg-gSq2c5hQ6te2G3fz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-gSq2c5hQ6te2G3fz .cluster-label text{fill:#333;}#mermaid-svg-gSq2c5hQ6te2G3fz .cluster-label span{color:#333;}#mermaid-svg-gSq2c5hQ6te2G3fz .cluster-label span p{background-color:transparent;}#mermaid-svg-gSq2c5hQ6te2G3fz .label text,#mermaid-svg-gSq2c5hQ6te2G3fz span{fill:#333;color:#333;}#mermaid-svg-gSq2c5hQ6te2G3fz .node rect,#mermaid-svg-gSq2c5hQ6te2G3fz .node circle,#mermaid-svg-gSq2c5hQ6te2G3fz .node ellipse,#mermaid-svg-gSq2c5hQ6te2G3fz .node polygon,#mermaid-svg-gSq2c5hQ6te2G3fz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gSq2c5hQ6te2G3fz .rough-node .label text,#mermaid-svg-gSq2c5hQ6te2G3fz .node .label text,#mermaid-svg-gSq2c5hQ6te2G3fz .image-shape .label,#mermaid-svg-gSq2c5hQ6te2G3fz .icon-shape .label{text-anchor:middle;}#mermaid-svg-gSq2c5hQ6te2G3fz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gSq2c5hQ6te2G3fz .rough-node .label,#mermaid-svg-gSq2c5hQ6te2G3fz .node .label,#mermaid-svg-gSq2c5hQ6te2G3fz .image-shape .label,#mermaid-svg-gSq2c5hQ6te2G3fz .icon-shape .label{text-align:center;}#mermaid-svg-gSq2c5hQ6te2G3fz .node.clickable{cursor:pointer;}#mermaid-svg-gSq2c5hQ6te2G3fz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gSq2c5hQ6te2G3fz .arrowheadPath{fill:#333333;}#mermaid-svg-gSq2c5hQ6te2G3fz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gSq2c5hQ6te2G3fz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gSq2c5hQ6te2G3fz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gSq2c5hQ6te2G3fz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gSq2c5hQ6te2G3fz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gSq2c5hQ6te2G3fz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gSq2c5hQ6te2G3fz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gSq2c5hQ6te2G3fz .cluster text{fill:#333;}#mermaid-svg-gSq2c5hQ6te2G3fz .cluster span{color:#333;}#mermaid-svg-gSq2c5hQ6te2G3fz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gSq2c5hQ6te2G3fz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gSq2c5hQ6te2G3fz rect.text{fill:none;stroke-width:0;}#mermaid-svg-gSq2c5hQ6te2G3fz .icon-shape,#mermaid-svg-gSq2c5hQ6te2G3fz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gSq2c5hQ6te2G3fz .icon-shape p,#mermaid-svg-gSq2c5hQ6te2G3fz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gSq2c5hQ6te2G3fz .icon-shape .label rect,#mermaid-svg-gSq2c5hQ6te2G3fz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gSq2c5hQ6te2G3fz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gSq2c5hQ6te2G3fz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gSq2c5hQ6te2G3fz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 增强模型能力
增强安全合规
高级版
全链路架构
审计+加密
等保合规
进阶版
多模型适配
动态选择
健康检查
基础版
ChatClient
单一模型
基础日志
第七部分:方案选型决策树
7.1 决策树
#mermaid-svg-bPFhnXUWvc2bx9Go{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bPFhnXUWvc2bx9Go .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bPFhnXUWvc2bx9Go .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bPFhnXUWvc2bx9Go .error-icon{fill:#552222;}#mermaid-svg-bPFhnXUWvc2bx9Go .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bPFhnXUWvc2bx9Go .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bPFhnXUWvc2bx9Go .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bPFhnXUWvc2bx9Go .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bPFhnXUWvc2bx9Go .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bPFhnXUWvc2bx9Go .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bPFhnXUWvc2bx9Go .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bPFhnXUWvc2bx9Go .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bPFhnXUWvc2bx9Go .marker.cross{stroke:#333333;}#mermaid-svg-bPFhnXUWvc2bx9Go svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bPFhnXUWvc2bx9Go p{margin:0;}#mermaid-svg-bPFhnXUWvc2bx9Go .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bPFhnXUWvc2bx9Go .cluster-label text{fill:#333;}#mermaid-svg-bPFhnXUWvc2bx9Go .cluster-label span{color:#333;}#mermaid-svg-bPFhnXUWvc2bx9Go .cluster-label span p{background-color:transparent;}#mermaid-svg-bPFhnXUWvc2bx9Go .label text,#mermaid-svg-bPFhnXUWvc2bx9Go span{fill:#333;color:#333;}#mermaid-svg-bPFhnXUWvc2bx9Go .node rect,#mermaid-svg-bPFhnXUWvc2bx9Go .node circle,#mermaid-svg-bPFhnXUWvc2bx9Go .node ellipse,#mermaid-svg-bPFhnXUWvc2bx9Go .node polygon,#mermaid-svg-bPFhnXUWvc2bx9Go .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bPFhnXUWvc2bx9Go .rough-node .label text,#mermaid-svg-bPFhnXUWvc2bx9Go .node .label text,#mermaid-svg-bPFhnXUWvc2bx9Go .image-shape .label,#mermaid-svg-bPFhnXUWvc2bx9Go .icon-shape .label{text-anchor:middle;}#mermaid-svg-bPFhnXUWvc2bx9Go .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bPFhnXUWvc2bx9Go .rough-node .label,#mermaid-svg-bPFhnXUWvc2bx9Go .node .label,#mermaid-svg-bPFhnXUWvc2bx9Go .image-shape .label,#mermaid-svg-bPFhnXUWvc2bx9Go .icon-shape .label{text-align:center;}#mermaid-svg-bPFhnXUWvc2bx9Go .node.clickable{cursor:pointer;}#mermaid-svg-bPFhnXUWvc2bx9Go .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bPFhnXUWvc2bx9Go .arrowheadPath{fill:#333333;}#mermaid-svg-bPFhnXUWvc2bx9Go .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bPFhnXUWvc2bx9Go .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bPFhnXUWvc2bx9Go .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bPFhnXUWvc2bx9Go .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bPFhnXUWvc2bx9Go .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bPFhnXUWvc2bx9Go .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bPFhnXUWvc2bx9Go .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bPFhnXUWvc2bx9Go .cluster text{fill:#333;}#mermaid-svg-bPFhnXUWvc2bx9Go .cluster span{color:#333;}#mermaid-svg-bPFhnXUWvc2bx9Go div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bPFhnXUWvc2bx9Go .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bPFhnXUWvc2bx9Go rect.text{fill:none;stroke-width:0;}#mermaid-svg-bPFhnXUWvc2bx9Go .icon-shape,#mermaid-svg-bPFhnXUWvc2bx9Go .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bPFhnXUWvc2bx9Go .icon-shape p,#mermaid-svg-bPFhnXUWvc2bx9Go .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bPFhnXUWvc2bx9Go .icon-shape .label rect,#mermaid-svg-bPFhnXUWvc2bx9Go .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bPFhnXUWvc2bx9Go .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bPFhnXUWvc2bx9Go .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bPFhnXUWvc2bx9Go :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
≤5人
5-20人
>20人
是
否
是
否
否
是
是
否
需要接入AI能力?
团队规模?
预算是否敏感?
是否需要多模型?
等保合规要求?
方案一 基础版
方案二 进阶版
是否生产级别?
方案三 高级版
7.2 决策条件说明
| 条件 | 含义 | 判断依据 |
|---|---|---|
| 团队规模 | 开发团队人数 | ≤5人选基础版,5-20人选进阶版,>20人选高级版 |
| 预算敏感 | 月度预算<3000元 | 预算紧张时优先基础版快速验证 |
| 多模型需求 | 是否需要切换/降级模型 | 业务需要多模型支持时选进阶版 |
| 等保合规 | 是否有等保三级要求 | 有合规要求必须选高级版 |
| 生产级别 | 是否面向真实用户 | 生产环境建议至少进阶版起步 |
通用建议:从基础版起步验证业务可行性,验证通过后升级到进阶版支撑生产环境,有合规要求时升级到高级版。
第八部分:生产落地清单
8.1 部署配置清单(环境变量列表)
| 环境变量名 | 用途 | 示例值 | 必填 |
|---|---|---|---|
OPENAI_API_KEY |
OpenAI API密钥 | sk-xxxx | 是 |
OPENAI_BASE_URL |
OpenAI API地址 | https://api.openai.com | 否 |
ANTHROPIC_API_KEY |
Anthropic API密钥 | sk-ant-xxxx | 进阶版必填 |
QWEN_API_KEY |
通义千问API密钥 | sk-xxxx | 进阶版必填 |
APP_AES_KEY |
AES加密密钥 | 32位随机字符串 | 高级版必填 |
SPRING_REDIS_HOST |
Redis地址 | 127.0.0.1 | 高级版必填 |
SPRING_REDIS_PORT |
Redis端口 | 6379 | 高级版必填 |
SPRING_REDIS_PASSWORD |
Redis密码 | 强密码 | 高级版必填 |
SPRING_DATASOURCE_URL |
MariaDB连接地址 | jdbc:mariadb://... | 高级版必填 |
SPRING_DATASOURCE_USERNAME |
数据库用户名 | app_user | 高级版必填 |
SPRING_DATASOURCE_PASSWORD |
数据库密码 | 强密码 | 高级版必填 |
8.2 监控指标清单(Prometheus 指标)
| 指标名 | 类型 | 说明 | 告警阈值 |
|---|---|---|---|
ai_chat_request_total |
Counter | AI聊天请求总数 | - |
ai_chat_request_duration |
Histogram | 请求耗时分布 | P99 > 10s |
ai_chat_error_total |
Counter | AI调用错误总数 | 5分钟>10次 |
ai_model_health_status |
Gauge | 模型健康状态 | 值=0 |
ai_token_usage_total |
Counter | Token使用总量 | 日用量超预算80% |
ai_fallback_trigger_total |
Counter | 降级触发次数 | 1小时>5次 |
jvm_threads_live_threads |
Gauge | JVM活跃线程数 | >300 |
jvm_memory_used_bytes |
Gauge | JVM内存使用 | >80% |
8.3 故障排查清单
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 接口超时无响应 | 模型API延迟+无超时设置 | 设置connectTimeout=5s, readTimeout=30s |
| 线程池耗尽 | AI调用阻塞HTTP线程 | 使用独立线程池或虚拟线程 |
| 频繁触发降级 | 主模型服务不稳定 | 检查模型健康状态,联系提供商 |
| Token费用暴涨 | 无预算控制 | 引入限流+Token预算控制 |
| 审计日志写入失败 | MariaDB连接异常 | 检查数据库连接池配置 |
| traceId丢失 | 拦截器未注册 | 检查WebMvcConfig注册TraceIdInterceptor |
| 跨域请求被拒 | 域名未配置白名单 | 在yml中添加cors.allowed-origins |
| AES加密异常 | 环境变量未配置 | 检查APP_AES_KEY环境变量 |
8.4 等保三级自查清单(本篇相关项)
| 检查项 | 要求 | 状态 |
|---|---|---|
| 接口入参校验 | @NotBlank/@Size/@Valid | 已覆盖 |
| SQL参数绑定 | MyBatis-Plus,禁止拼接 | 已覆盖 |
| 敏感字段加密 | AES对称加密 | 已覆盖 |
| 密码哈希存储 | BCrypt加盐 | 已覆盖 |
| 密钥环境变量 | 禁止硬编码 | 已覆盖 |
| 操作审计日志 | AOP切面记录 | 已覆盖 |
| 日志180天留存 | MariaDB持久化 | 已覆盖 |
| 日志敏感脱敏 | 手机号/身份证掩码 | 已覆盖 |
| 跨域白名单 | 指定域名 | 已覆盖 |
| 错误信息屏蔽 | 全局异常处理 | 已覆盖 |
| 链路追踪 | traceId全链路 | 已覆盖 |
| 逻辑删除 | is_deleted字段 | 待后续文章覆盖 |
8.5 本篇总结与下篇预告
本篇总结:
本文从 Java 开发者面对 AI 浪潮的痛点出发,分析了"框架选择困难、架构设计空白、生产意识缺失"三大根因,给出了从基础版到高级版的三套递进方案:
- 基础版:ChatClient + Advisors API,1-2天快速搭建可运行的AI聊天服务
- 进阶版:多模型适配层,支持OpenAI/Anthropic/通义千问动态切换和自动降级
- 高级版:全链路生产级架构,覆盖统一返回体、全局异常、审计日志、安全加密、链路追踪、跨域安全
所有代码遵循阿里巴巴Java开发手册(嵩山版)强制条款,满足等保三级2.0应用安全要求。代码位于 com-common 模块,是后续 29 篇文章的公共基础设施。
下篇预告:
第 02 篇《成本黑洞终结者:AI 应用 Token 费用暴涨三套止血方案》将聚焦 AI 应用的成本控制问题,从 Redis 滑动窗口预算控制、Milvus 语义缓存命中、多模型智能路由三个维度,给出 Token 费用暴涨的止血方案。
本文代码已归档至
spring-ai-develop-csdn/code/com-common模块,mvn clean compile编译通过。系列共 30 篇,持续更新中。