前言本文记录一个宠物商店后台管理系统项目在宝塔面板上的部署流程，前端使用 Vue，后端为 Spring Boot。下文按“先部署后端，再部署前端”的顺序来说明。这篇教程是半年前部署一个零工平台前后端项目时写的，写了一半停了，最近又部署了一个顺便把它完善一下。项目概述前端：vue 项目后端：Spring Boot 3.3.5 + Java 17 + SQLite 数据库域名：https://petad.xuan.cyou后端端口：8083前端项目部署前的设置配置说明：API 接口路径与环境设置一般项目支持两种常见的接口路径配置方式，请根据后端接口的实际路径选择对应的方案。场景一：接口已统一包含 /api 前缀（本项目选择）若后端所有接口已统一添加 /api 前缀（例如：/api/product/list），则前端环境变量配置如下：# .env.development VITE_APP_BASE_API=http://localhost:8083 # .env.production VITE_APP_BASE_API=https://petad.xuan.cyou在 axios 实例中直接使用该变量作为 baseURL：// src/api/request.js 片段 const service = axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API || 'http://localhost:8083', timeout: 10000 })各接口定义中需添加 /api 前缀：// 示例接口：获取商品列表 export function getProductList(params) { return request({ url: '/api/product/list', // 添加/api前缀 method: 'get', params }) }场景二：接口未统一添加 /api 前缀若后端接口未统一添加 /api 前缀，则可将前缀直接配置在环境变量中：# .env.development VITE_APP_BASE_API=http://localhost:8083/api # .env.production VITE_APP_BASE_API=https://petad.xuan.cyou/api在 axios 配置中直接使用完整基础路径：// src/api/request.js 片段 const service = axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API || 'http://localhost:8083/api', timeout: 10000 })此时，接口定义中不再需要 /api 前缀：// 示例接口：获取商品列表 export function getProductList(params) { return request({ url: '/product/list', // 无需/api前缀 method: 'get', params }) }通用配置：开发环境代理无论选择上述哪种场景，在开发环境下均需在 vite.config.js 中配置代理，以便正确转发请求至后端服务：// vite.config.js 片段 server: { proxy: { '/api': { target: 'http://localhost:8083', // 后端服务地址 changeOrigin: true }, '/images': { target: 'http://localhost:8083', changeOrigin: true } } }补充说明：路由模式与刷新 404 问题如果项目使用 Vue Router 的 history 模式，部署后刷新非首页路由可能出现 404 错误。此时需在 Web 服务器（如 Nginx）中配置伪静态规则，将请求重定向至 index.html。以 Nginx 为例，可添加如下配置：location / { try_files $uri $uri/ /index.html; }云服务器准备1.选购我选用的是阿里云的云服务器，如果只是临时部署这个项目作为演示，选择最低配的就可以。镜像直接选择宝塔的应用镜像就行如果有别的需求，尽量选择Debian，Centos Stream，Ubuntu等如果选择的是上面三个，你需要手动配置宝塔面板并开放访问端口2.查看面板登录信息宝塔配置1. 登录并进入面板2. 创建一个静态网站我用的是 SQLite，这里无需新创建数据库。申请免费SSL证书，启用https3. 上传项目前端代码将前端打包后的 dist 目录上传到这里修改网站运行目录为 dist4. 上传项目数据库在网站目录 /www/wwwroot/petad.xuan.cyou 下上传项目数据库选择完文件后，点击 管理，可以看到数据库里的表就可以了。5. 上传后端 SpringBoot 打包后的 jar 包如果你不知道如何把 SpringBoot 项目打包，可以参考这篇文章[post cid="553" /]6. 配置后端添加 jar 包和 JDK（与开发环境一致）开放端口 80837. 配置反向代理这里的 8083 即为后端的运行端口注意配置文件写法是否正确，否则不会生效图片代理下面为代理的完整代码：#PROXY-START/ location /api { proxy_pass http://127.0.0.1:8083; proxy_set_header Host 127.0.0.1; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_http_version 1.1; # proxy_hide_header Upgrade; add_header X-Cache $upstream_cache_status; #Set Nginx Cache set $static_fileMUGM8Xk5 0; if ( $uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$" ) { set $static_fileMUGM8Xk5 1; expires 1m; } if ( $static_fileMUGM8Xk5 = 0 ) { add_header Cache-Control no-cache; } } # 图片资源代理 location /images { proxy_pass http://localhost:8083/images; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 静态资源缓存 expires 30d; } #PROXY-END/最后对部署的域名进行访问测试部署成功。
基于Spring Boot + Vue.js的GPT AI聊天应用：从开发到部署的完整实践
前言一个人一个下午做成的，为了应对周四的作业答辩。🚀 项目概述本文将详细介绍一个基于Spring Boot后端和Vue.js前端的GPT AI聊天应用的完整开发流程。该项目集成了OpenAI、阿里云通义千问、DeepSeek、豆包、Kimi等多个主流AI平台，支持文本对话和图像生成功能，并实现了用户认证、会话管理、管理员面板、用户统计等完整的业务功能。体验地址：https://gpt.xuan.cyou/技术栈后端: Spring Boot 3.5.5, Java 17, SQLite, JPA/Hibernate前端: Vue.js 3, Vite, Pinia, JavaScript ES6+AI集成: OpenAI GPT-4, 阿里云通义千问, DeepSeek, 豆包, Kimi部署: 宝塔面板, Nginx, SSL证书📁 项目架构GPT-AI-Project/ ├── gpt-ai-java/ # Spring Boot 后端 │ ├── src/main/java/ │ │ ├── controller/ # REST API控制器 (用户, AI, 管理员等) │ │ ├── service/ # 业务逻辑层 (多AI平台服务, 用户统计等) │ │ ├── entity/ # 数据实体 (用户, 对话历史, 统计数据等) │ │ ├── config/ # 配置类 │ │ └── adopter/ # AI平台适配器 (OpenAI, Ali, DeepSeek, Kimi等) │ ├── src/main/resources/ │ │ └── application.yaml # 应用配置 │ └── pom.xml # Maven依赖配置 └── frontend/ # Vue.js 前端 ├── src/ │ ├── components/ # Vue组件 │ ├── views/ # 页面视图 (聊天, 图像, 管理面板, 用户后台等) │ ├── api/ # API调用封装 │ └── store/ # 状态管理 (Pinia) ├── public/ # 静态资源 └── package.json # NPM依赖配置🔧 核心功能实现1. 后端核心架构1.1 多AI平台集成项目支持OpenAI和阿里云通义千问两个AI平台，通过适配器模式实现：// AI控制器统一入口 @RestController @RequestMapping("/api/ai") public class AiController { @GetMapping("/chat/text/stream") public SseEmitter streamChat( @RequestParam String platform, @RequestParam String model, @RequestParam String message ) { // 根据平台选择对应的AI服务 (示例) if ("openai".equals(platform)) { return openAiController.sendTextStream(model, message); } else if ("ali".equals(platform)) { return aliAiController.sendTextStream(model, message); } // ... 此处添加对 deepseek, doubao, kimi 等平台的路由逻辑 return null; // or throw an exception } }1.2 流式响应实现使用Server-Sent Events (SSE) 实现实时流式对话：@PostMapping("/chat/text/stream") public SseEmitter sendTextStream(String model, String message) { SseEmitter emitter = new SseEmitter(0L); CompletableFuture.runAsync(() -> { try { // 流式读取AI响应并实时推送 openAIService.sendText(model, message, true, delta -> { emitter.send(SseEmitter.event().name("delta").data(delta)); }); emitter.send(SseEmitter.event().name("done").data("[DONE]")); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); return emitter; }1.3 会话管理系统实现了完整的对话历史记录功能：@Entity public class ConversationHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String userId; private String sessionId; private String userMessage; private String assistantReply; private LocalDateTime timestamp; // ... getters and setters }1.4 跨域配置为支持前后端分离和域名部署，配置了完整的CORS支持：@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns( "http://localhost:*", "https://gpt.xuan.cyou" ) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true); } }2. 前端核心实现2.1 API客户端封装统一封装所有后端API调用：// 流式聊天 export function streamChat({ platform, model, message, onDelta, onDone }) { const url = `/api/ai/chat/text/stream?platform=${platform}&model=${model}&message=${encodeURIComponent(message)}`; const eventSource = new EventSource(url); eventSource.addEventListener('delta', (event) => { const data = JSON.parse(event.data); onDelta?.(data.delta); }); eventSource.addEventListener('done', () => { onDone?.(); eventSource.close(); }); } // 图像生成 export async function createImage(params) { const response = await fetch('/api/ai/create/image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params) }); return await response.json(); }2.2 实时对话界面Vue.js实现的流式对话体验： AI正在回复... 2.3 状态管理 (Pinia)项目使用Pinia进行全局状态管理，取代了Vuex，提供了更简洁的API和完整的TypeScript支持。// src/store/index.js import { defineStore } from 'pinia' export const useMainStore = defineStore('main', { state: () => ({ user: null, currentSession: null, settings: { platform: 'openai', textModel: 'gpt-4o-mini', imageModel: 'dall-e-3', backendUrl: 'http://localhost:8083' } }), actions: { setUser(user) { this.user = user; }, updateSettings(settings) { this.settings = { ...this.settings, ...settings }; } } })🛠️ 开发环境搭建1. 后端开发环境# 1. 克隆项目 git clone <repository-url> cd gpt-ai-java # 2. 配置application.yaml server: port: 8083 spring: application: name: gpt-ai datasource: url: jdbc:sqlite:identifier.sqlite driver-class-name: org.sqlite.JDBC # 3. 运行项目 mvn spring-boot:run2. 前端开发环境# 1. 安装依赖 cd frontend npm install # 2. 配置开发代理 # vite.config.js const target = process.env.BACKEND_URL || 'http://localhost:8083' export default defineConfig({ server: { proxy: { '/api': { target, changeOrigin: true } } } }) # 3. 启动开发服务器 npm run dev📦 生产环境部署1. 后端打包部署# Maven打包 mvn clean package -DskipTests # 部署运行 java -jar target/gpt-ai-0.0.1-SNAPSHOT.jar # 或使用systemd守护进程 sudo systemctl start gpt-ai2. 前端构建部署# 构建生产版本 npm run build # 部署到服务器 # 将dist/目录下所有文件上传到 /www/wwwroot/gpt.xuan.cyou/dist/3. Nginx配置针对宝塔面板的完整Nginx配置：server { listen 80; listen 443 ssl http2; server_name gpt.xuan.cyou; root /www/wwwroot/gpt.xuan.cyou/dist; # SSL配置 ssl_certificate /www/server/panel/vhost/cert/gpt.xuan.cyou/fullchain.pem; ssl_certificate_key /www/server/panel/vhost/cert/gpt.xuan.cyou/privkey.pem; # API代理到后端服务 location /api/ { proxy_pass http://127.0.0.1:8083; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 支持SSE长连接 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } # Vue.js SPA路由支持 location / { try_files $uri $uri/ @fallback; } location @fallback { rewrite ^.*$ /index.html last; } # 静态资源缓存优化 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control "public, immutable"; } }🎯 核心功能特性1. 多AI平台支持OpenAI集成: 支持GPT-4系列模型，DALL-E图像生成阿里云集成: 支持通义千问文本对话，通义万相图像生成新增平台: 已集成DeepSeek, 豆包, Kimi等更多主流AI模型统一接口: 通过platform参数无缝切换不同AI服务2. 实时流式对话SSE技术: Server-Sent Events实现实时数据推送流式体验: 模拟真实对话，逐字显示AI回复异常处理: 完善的连接错误和重连机制3. 会话管理系统历史记录: 完整保存用户对话历史会话分组: 支持多会话并行管理数据持久化: SQLite数据库存储4. 图像生成功能多平台支持: OpenAI DALL-E和阿里云通义万相参数配置: 支持不同尺寸和风格设置结果展示: 优雅的图像展示和下载功能5. 用户认证系统登录注册: 基本的用户认证功能权限控制: 用户数据隔离和访问控制设置管理: 个性化配置保存6. 管理员面板用户管理: 查看、管理所有注册用户系统监控: 实时查看系统使用统计数据数据分析: 提供用户行为和AI使用情况的图表分析7. 用户统计个人仪表板: 用户可以查看自己的AI使用历史和统计数据用量跟踪: 详细记录每日和累计的token使用量可视化图表: 直观展示个人使用趋势🔍 技术亮点1. 前后端分离架构RESTful API: 标准的REST接口设计跨域处理: 完善的CORS配置支持代理配置: 开发环境和生产环境的统一代理方案2. 响应式设计移动适配: 支持手机、平板等多种设备现代UI: 简洁美观的用户界面交互体验: 流畅的动画和反馈效果3. 性能优化静态资源缓存: 合理的缓存策略Gzip压缩: 减少传输数据量懒加载: 按需加载提升首屏速度4. 部署友好容器化支持: 可轻松容器化部署宝塔面板: 兼容主流服务器管理面板SSL支持: HTTPS安全访问🚨 常见问题解决1. API代理404错误问题: 前端API请求返回404错误解决: 确保Nginx配置了正确的API代理规则2. SSE连接中断问题: 流式对话中途断开解决: 配置Nginx的proxy_read_timeout参数3. 跨域问题问题: 浏览器CORS错误解决: 检查后端CORS配置和Nginx代理头设置4. 静态资源404问题: Vue路由刷新后404解决: 配置Nginx的try_files规则📈 未来扩展方向1. 功能增强语音对话: 集成语音识别和合成文件上传: 支持文档分析功能插件系统: 可扩展的功能插件架构2. 技术优化缓存机制: Redis缓存提升性能消息队列: 异步处理大量请求微服务架构: 拆分为多个独立服务3. 运维改进监控告警: 完善的系统监控日志分析: ELK日志分析栈自动部署: CI/CD持续集成部署💡 总结本项目展示了一个完整的现代Web应用开发流程，从技术选型、架构设计到开发实现和生产部署，涵盖了全栈开发的各个环节。通过Spring Boot和Vue.js的组合，实现了高效、可维护的前后端分离架构。项目的核心价值在于：技术栈现代化: 采用最新的技术栈和最佳实践功能完整性: 涵盖了AI对话应用的核心功能部署友好性: 支持多种部署方式和环境扩展性良好: 架构设计便于后续功能扩展希望本文能为想要开发类似AI应用的开发者提供有价值的参考和指导。Gitee仓库：前端 - https://gitee.com/wx6765/chatgpt_frontend后端 - https://gitee.com/wx6765/chatgpt_backend本文基于实际项目开发经验总结，代码示例均来自真实项目实现。
适合人群：具备 Vue 基础，希望接入 AI 文本/图片与流式输出的开发者或技术爱好者。建议先看：实践三（RESTful API 设计原则）、实践五（接口监控与限流）。你将收获：SSE 两种实现、Axios 标准化封装、vite 代理配置与常见坑。创建项目npm create vue@latest实现文本聊天页面目标：实现基础的文本输入、发送与消息列表展示。要点：输入框与发送按钮、消息时间顺序、滚动定位到最新消息。组件建议：ChatInput（输入区）、ChatList（消息区）。实现图片目标：支持根据文本生成图片并展示缩略图。要点：提交参数（prompt、size、数量）、生成态 loading、失败重试、下载按钮。组件建议：ImageGenerateForm、ImageGrid。实现文本聊天SSE的方式sse 前端实现方式第一种 EventSourceconst url = "/api/ai/chat/stream?platform=openai&model=gpt-4o&message=" + encodeURIComponent(message); const sse = new EventSource(url); const handleDelta = (event: MessageEvent) => { const data = (event as MessageEvent).data; }; const handleDone = () => { sse.close(); }; // Some servers don't set a named event; treat default messages as delta sse.onmessage = handleDelta; sse.addEventListener("delta", handleDelta as EventListener); sse.addEventListener("done", handleDone as EventListener); sse.onerror = (err) => { console.error("SSE error", err); sse.close(); };第二种 使用自带的fetch const res = await fetch("/api/ai/chat/stream", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ platform: "openai", model: "gpt-4o", message }), }); if (!res.ok || !res.body) throw new Error(`请求失败: ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Try to parse as SSE events: data: {json}\n\n let sepIndex: number; while ((sepIndex = buffer.indexOf("\n\n")) !== -1) { const rawEvent = buffer.slice(0, sepIndex); buffer = buffer.slice(sepIndex + 2); const dataLines = rawEvent .split("\n") .filter((l) => l.startsWith("data:")) .map((l) => l.replace(/^data:\s?/, "")) .join(""); console.log("dataLines", dataLines); if (!dataLines) continue; consumeChunk(dataLines, assistantId); } // Fallback: handle NDJSON lines if server doesn't use blank-line separators // let lineIndex: number; // while ((lineIndex = buffer.indexOf("\n")) !== -1) { // const rawLine = buffer.slice(0, lineIndex).trim(); // buffer = buffer.slice(lineIndex + 1); // if (!rawLine) continue; // if (rawLine.startsWith("data:")) { // consumeChunk(rawLine.replace(/^data:\s?/, ""), assistantId); // } else { // consumeChunk(rawLine, assistantId); // } // }增加对话框目标：多会话管理，支持创建、重命名、删除与切换。要点：侧边栏会话列表、高亮当前会话、未读/进行中状态提示。数据：本地 localStorage 或后端存储会话元信息与消息。API 调用信息目标：在界面中透明展示平台、模型、剩余次数/有效期等关键信息。要点：统一从 /api/user/quota 拉取；错误时给出可读提示（如未登录/已过期）。展示：头部条或设置抽屉中展示，避免打断主要流程。API升级目标：后端接口版本/平台能力变化时，前端具备平滑升级能力。要点：通过配置层映射平台与模型；在切换平台时热更新可用模型列表。回退：接口报错时回退到上一个可用版本并提示用户。登录与注册目标：支持注册/登录/登出与 Token 管理。要点：表单校验、登录态持久化（短期 Token 或 HttpOnly Cookie）、过期刷新。交互：失败清晰提示（401 引导重新登录）、成功跳转到最近会话。axios/** * Axios HTTP 封装：内置拦截器、Token 注入与类型友好的请求助手。 * baseURL 来自 VITE_API_BASE_URL，未配置则回退为 '/api'。 */ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' /** * 统一的接口返回结构（兼容常见服务约定）： * - success：显式标记是否成功 * - code：业务状态码（0/200 视为成功） * - data：实际数据载荷 * - message/msg：人类可读的提示 */ export interface ApiResponse<T = any> { code?: number | string data: T message?: string msg?: string success?: boolean } /** 所有请求的默认基础路径。 */ const baseURL = '/api' /** 预配置的 Axios 实例。 */ const http: AxiosInstance = axios.create({ baseURL, timeout: 15000, withCredentials: false, }) /** * 请求拦截器： * - 从 localStorage.token 注入 Authorization Bearer 令牌（若请求头未显式设置）。 */ http.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token && config.headers) { const hasAuth = typeof config.headers.Authorization === 'string' && config.headers.Authorization.length > 0 if (!hasAuth) { config.headers.Authorization = `Bearer ${token}` } } return config }, (error) => Promise.reject(error), ) /** 从响应载荷中提取可读消息（若存在）。 */ function extractMessage(payload: unknown): string | undefined { if (!payload || typeof payload !== 'object') return undefined const obj = payload as Record<string, unknown> const candidates = ['message', 'msg', 'error', 'detail'] for (const key of candidates) { const value = obj[key] if (typeof value === 'string' && value.trim().length > 0) { return value } } return undefined } /** 将 HTTP 状态码转换为本地化错误信息。 */ function httpStatusMessage(status: number): string { switch (status) { case 400: return '请求参数错误' case 401: return '未授权或登录已过期' case 403: return '无权限访问' case 404: return '资源未找到' case 408: return '请求超时' case 500: return '服务器错误' case 502: return '网关错误' case 503: return '服务不可用' case 504: return '网关超时' default: return `请求错误（${status}）` } } /** * 响应拦截器： * - 当 success === true 或 code ∈ {0, 200} 时直接返回 data * - 若存在 code 且表示失败，则使用提取的 message 拒绝 * - 统一网络/服务端错误为可读信息 */ http.interceptors.response.use( (response: AxiosResponse<ApiResponse>) => { const { data, status } = response if (status >= 200 && status < 300) { if (data && typeof data === 'object') { const code = data.code as number | string | undefined const success = data.success if (success === true) return (data.data as any) ?? (data as any) if (code === 0 || code === 200 || code === '0' || code === '200') { return (data.data as any) ?? (data as any) } if (typeof code === 'undefined') { return data as any } const message = extractMessage(data) || '请求失败' return Promise.reject(new Error(message)) } return response.data as any } return Promise.reject(new Error('网络错误')) }, (error) => { if (axios.isCancel(error)) return Promise.reject(error) if (error?.response) { const status = error.response.status as number const msg = (extractMessage(error.response.data) || httpStatusMessage(status)) ?? '请求出错' return Promise.reject(new Error(msg)) } const message = error?.message || '网络异常，请检查您的网络连接' return Promise.reject(new Error(message)) }, ) /** 与 AxiosRequestConfig 等价，用于保留下方助手的范型。 */ type RequestConfig<T = any> = AxiosRequestConfig<T> /** 低级请求助手，保留范型返回类型。 */ export function request<T = any>(config: RequestConfig): Promise<T> { return http.request<any, T>(config) } /** GET 快捷方法。 */ export const get = <T = any, R = T>(url: string, config?: RequestConfig) => http.get<R>(url, config) /** POST 快捷方法。 */ export const post = <T = any, R = T>(url: string, data?: T, config?: RequestConfig) => http.post<R>(url, data, config) /** PUT 快捷方法。 */ export const put = <T = any, R = T>( url: string, data?: T, config?: RequestConfig, ) => http.put<R>(url, data, config) /** DELETE 快捷方法。 */ export const del = <T = any, R = T>(url: string, config?: RequestConfig) => http.delete<R>(url, config) export default http 代理vite.config.ts server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 将 /api 前缀转发到后端时保留或重写 // 若后端实际无 /api 前缀，请取消注释下一行： rewrite: (path) => path.replace(/^\/api/, ''), }, }, },基本功能目标1、登录，注册2、api购买记录，有效期3、文本聊天，文本聊天sse模式4、图片生成5、多平台切换6、api调用次数统计 分1，7，30天，不同平台，不同api类型7、多窗口中级功能目标1、聊天内容保存2、支持模型选择3、AI回复内容，支持 文本复制，图片下载4、免费用户，每天只能调用三次5、api购买实现，plus一个月调用100次，pro一个月调用1000次高级功能1、图片支持size设置，生成图片个数支持，1，2，3，42、图片进行本地存储，异步执行最佳实践与常见问题SSE 断线重连：EventSource 自带重连，手写 fetch 流需在网络断开时恢复并合并内容。错误提示：服务端返回 429/5xx 时统一友好文案，区分鉴权类与系统类；必要时弹窗引导降载。Token 存储：避免将长效 Token 存在 localStorage，优先短时 Token 或 HttpOnly Cookie。代理重写：vite 代理 rewrite 与服务端路由前缀保持一致，避免 404/跨域问题。请求退避：对热点接口失败建议指数退避，避免前端风暴放大后端压力。图片下载：使用 a[download] 或 Blob URL；注意 CORS 与缓存策略。
软件工程实践七：AI 大模型 API 合并与适配层设计
适合人群：希望统一多厂商（OpenAI、阿里、科大讯飞等）调用体验的后端开发者或技术爱好者。建议先看：实践三（RESTful API 设计原则）、实践六（阿里大模型对接）。你将收获：一套可扩展的适配层模型与返回体规范，最小变更对接新厂商。设计目标与边界统一接口：隐藏厂商差异，暴露稳定域模型（模型、聊天、流式聊天、图片）。易于扩展：新增厂商仅需实现适配器，不影响上层调用与返回结构。一致的错误语义：统一 success/code/message，流式以 [process]/[done] 信号闭环。安全与合规：不在代码中硬编码密钥，敏感信息通过环境变量或配置中心注入。对输入参数进行统一几乎不改返回值统一模型@Data public class BigModel { private String id; public BigModel(String id) { this.id = id; } public BigModel() {} } 文字聊天@Data public class ChatMessage { private String id; private String content; private String status; // success fail private String error; }文字聊天流@Data public class ChatMessageStream { private String id; private String content; private String status; // done process }图片@Data public class ChatImage { private String id; private String imageUrl; private String status; // success fail private String error; }流程合并使用统一接口OpenAI@Service public class OpenAiAdopter { @Autowired private OpenAiService openAiService; public List<BigModel> getModels() { Map model = openAiService.getModel(); List<Map> models = (List<Map>) model.get("data"); return models.stream().map(m -> new BigModel(m.get("id").toString())).collect(Collectors.toList()); } public ChatMessage chat(String model, String message) { Map chat = openAiService.chat(model, message); String id = chat.get("id").toString(); List<Map> output = (List<Map>) chat.get("output"); ChatMessage chatMessage = new ChatMessage(); chatMessage.setId(id); String contentStr = ""; chatMessage.setStatus("completed"); for (Map map : output) { if (!map.get("type").toString().equals("message")) { continue; } if (!map.get("status").toString().equals("completed")) { continue; } List<Map> contents = (List<Map>) map.get("content"); for (Map content : contents) { contentStr += content.get("text").toString(); } } chatMessage.setContent(contentStr); return chatMessage; } public void chatStream(String model, String message, Consumer<ChatMessageStream> onDelta){ openAiService.chatStream(model, message, (delta)->{ ChatMessageStream chatMessageStream = new ChatMessageStream(); chatMessageStream.setId(UUID.randomUUID().toString()); chatMessageStream.setStatus("process"); chatMessageStream.setContent(delta); onDelta.accept(chatMessageStream); }); } public ChatImage generateImage(String model, String prompt, String size) { try { Map image = openAiService.generateImageUrl(model, prompt, size); ChatImage chatImage = new ChatImage(); chatImage.setId(UUID.randomUUID().toString()); List<Map> images = (List<Map>)image.get("data"); chatImage.setImageUrl(images.get(0).get("url").toString()); chatImage.setStatus("success"); return chatImage; } catch (Exception e) { throw new RuntimeException(e); } } }阿里@RestController @RequestMapping("/ai") public class AiController { @Autowired private AliAiAdopter aliAiAdopter; @Autowired private OpenAiAdopter openAiAdopter; @GetMapping("/get/models") public List<BigModel> getModels(@RequestParam("platform") String platform) { if ("ali".equals(platform)) { return aliAiAdopter.getModels(); } return openAiAdopter.getModels(); } @PostMapping("/chat") public ChatMessage chat(@RequestBody Map<String, String> param) throws JsonProcessingException { String platform = ""; if (param.get("platform") != null) { platform = param.get("platform"); } String model = ""; if (param.get("model") != null) { model = param.get("model"); } String message = ""; if (param.get("message") != null) { message = param.get("message"); } if ("ali".equals(platform)) { return aliAiAdopter.chat(model, message); } return openAiAdopter.chat(model, message); } @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestBody Map<String, String> param, HttpServletResponse response) { String platform = ""; if (param.get("platform") != null) { platform = param.get("platform"); } String model; if (param.get("model") != null) { model = param.get("model"); } else { model = ""; } String message; if (param.get("message") != null) { message = param.get("message"); } else { message = ""; } SseEmitter emitter = new SseEmitter(0L); // 0 表示不超时；可按需设置毫秒 if ("ali".equals(platform)) { CompletableFuture.runAsync(() -> { try { aliAiAdopter.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 emitter.send(SseEmitter.event().name("done").data("[DONE]")); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); } else { CompletableFuture.runAsync(() -> { try { openAiAdopter.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 ChatMessageStream chatMessageStream = new ChatMessageStream(); chatMessageStream.setId(UUID.randomUUID().toString()); chatMessageStream.setStatus("[done]"); chatMessageStream.setContent(""); emitter.send(SseEmitter.event().name("done").data(chatMessageStream)); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); } return emitter; } @PostMapping("/generate/image") public ChatImage generateImage(@RequestBody Map<String, String> param) { String model = param.getOrDefault("model", "dall-e-3"); String platform = param.getOrDefault("platform", "ali"); String prompt = param.getOrDefault("prompt", ""); String size = param.getOrDefault("size", "1024x1024"); if ("ali".equals(platform)) { return aliAiAdopter.generateImage(model, prompt, size); } return openAiAdopter.generateImage(model, prompt, size); } }接口统一@RestController @RequestMapping("/ai") public class AiController { @Autowired private AliAiAdopter aliAiAdopter; @Autowired private OpenAiAdopter openAiAdopter; @GetMapping("/get/models") public List<BigModel> getModels(@RequestParam("platform") String platform) { if ("ali".equals(platform)) { return aliAiAdopter.getModels(); } return openAiAdopter.getModels(); } @PostMapping("/chat") public ChatMessage chat(@RequestBody Map<String, String> param) throws JsonProcessingException { String platform = ""; if (param.get("platform") != null) { platform = param.get("platform"); } String model = ""; if (param.get("model") != null) { model = param.get("model"); } String message = ""; if (param.get("message") != null) { message = param.get("message"); } if ("ali".equals(platform)) { return aliAiAdopter.chat(model, message); } return openAiAdopter.chat(model, message); } @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestBody Map<String, String> param, HttpServletResponse response) { String platform = ""; if (param.get("platform") != null) { platform = param.get("platform"); } String model; if (param.get("model") != null) { model = param.get("model"); } else { model = ""; } String message; if (param.get("message") != null) { message = param.get("message"); } else { message = ""; } SseEmitter emitter = new SseEmitter(0L); // 0 表示不超时；可按需设置毫秒 if ("ali".equals(platform)) { CompletableFuture.runAsync(() -> { try { aliAiAdopter.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 emitter.send(SseEmitter.event().name("done").data("[DONE]")); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); } else { CompletableFuture.runAsync(() -> { try { openAiAdopter.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 ChatMessageStream chatMessageStream = new ChatMessageStream(); chatMessageStream.setId(UUID.randomUUID().toString()); chatMessageStream.setStatus("[done]"); chatMessageStream.setContent(""); emitter.send(SseEmitter.event().name("done").data(chatMessageStream)); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); } return emitter; } @PostMapping("/generate/image") public ChatImage generateImage(@RequestBody Map<String, String> param) { String model = param.getOrDefault("model", "dall-e-3"); String platform = param.getOrDefault("platform", "ali"); String prompt = param.getOrDefault("prompt", ""); String size = param.getOrDefault("size", "1024x1024"); if ("ali".equals(platform)) { return aliAiAdopter.generateImage(model, prompt, size); } return openAiAdopter.generateImage(model, prompt, size); } }
软件工程实践六：阿里大模型（通义千问）Java 对接最简方案
适用场景：快速在 Java 本地/服务端调用通义千问系列模型进行对话生成。适合人群：熟悉 Spring Boot/RestTemplate 的 Java 开发者或技术爱好者。建议先看：实践三（RESTful API 设计原则）。你将收获：最小可运行的文本对话/流式对话/图片生成调用，含代理与安全要点。1. 准备工作JDK: 8+（推荐 17+）构建工具: MavenAPI Key: 在阿里云百炼/灵积控制台创建 API-KEY百炼控制台：通义千问-文生图模型（Qwen-Image）创建RestTemplate（安全与代理）@Bean("aliAiHttpClient") public RestTemplate aliRestTemplate(RestTemplateBuilder builder) { ClientHttpRequestInterceptor authInterceptor = (request, body, execution) -> { request.getHeaders().setBearerAuth(System.getenv("ALI_API_KEY")); return execution.execute(request, body); }; RestTemplate restTemplate = builder .connectTimeout(Duration.ofSeconds(10)) .readTimeout(Duration.ofSeconds(60)) .additionalInterceptors(authInterceptor) .build(); SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); // 设置代理（以 http://127.0.0.1:7890 为例） Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 7890)); factory.setProxy(proxy); restTemplate.setRequestFactory(factory); return restTemplate; }获取模型public List getModel() { List<Map<String, String>> modelList = new ArrayList<>(); Map<String, String> model = new HashMap<>(); model.put("id", "qwen-plus"); modelList.add(model); model = new HashMap<>(); model.put("id", "qwen-image"); modelList.add(model); return modelList; }文字聊天public Map chat(String model, String message) throws JsonProcessingException { HttpHeaders headers = new HttpHeaders(); Map param = new HashMap(); param.put("model", model); Map messageMap = new HashMap(); messageMap.put("role", "user"); messageMap.put("content", message); param.put("messages", List.of(messageMap)); HttpEntity<Map> entity = new HttpEntity<>(param, headers); ResponseEntity<String> exchange = restTemplate.exchange("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", HttpMethod.POST, entity, String.class); return objectMapper.readValue(exchange.getBody(), Map.class); }文字聊天 SSE public void chatStream(String model, String message, Consumer<String> onDelta) throws JsonProcessingException { String url = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; Map<String, Object> body = new HashMap<>(); body.put("model", model); body.put("stream", true); Map messageMap = new HashMap(); messageMap.put("role", "user"); messageMap.put("content", message); body.put("messages", List.of(messageMap)); RequestCallback requestCallback = request -> { request.getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] json = objectMapper.writeValueAsBytes(body); StreamUtils.copy(json, request.getBody()); }; ResponseExtractor<Void> responseExtractor = response -> { try (InputStream in = response.getBody(); BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { log.info("chatStream,{}", line); if (!line.startsWith("data:")) { continue; // 忽略注释或空行 } try { String data = line.substring("data:".length()).trim(); if ("[DONE]".equals(data)) { break; } Map map = objectMapper.readValue(data, Map.class); List choices = (List) map.get("choices"); if (choices != null && !choices.isEmpty()) { for (Object choice : choices) { Map choiceMap = (Map) choice; Map delta = (Map) choiceMap.get("delta"); String content = delta.get("content").toString(); if (content != null && !content.isEmpty()) { onDelta.accept(content); } } } } catch (Exception e) { log.error(e.getMessage(), e); } } } return null; }; restTemplate.execute(url, HttpMethod.POST, requestCallback, responseExtractor); }控制器@PostMapping(value ="/chat/stream",produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestBody Map<String, String> param, HttpServletResponse response) throws JsonProcessingException { String model; if (param.get("model") != null) { model = param.get("model"); } else { model = ""; } String message; if (param.get("message") != null) { message = param.get("message"); } else { message = ""; } SseEmitter emitter = new SseEmitter(0L); // 0 表示不超时；可按需设置毫秒 CompletableFuture.runAsync(() -> { try { aliAiService.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 emitter.send(SseEmitter.event().name("done").data("[DONE]")); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); return emitter; }生成图片public Map generateImageUrl(String model, String prompt, String size) throws JsonProcessingException { HttpHeaders headers = new HttpHeaders(); Map body = new HashMap(); body.put("model", model); Map input = new HashMap(); Map message = new HashMap(); message.put("role", "user"); message.put("content", List.of(Map.of("text", prompt))); input.put("messages", List.of(message)); body.put("input", input); body.put("parameters", Map.of("negative_prompt", "", "prompt_extend", true, "watermark", true, "size", size)); HttpEntity<Map> entity = new HttpEntity<>(body, headers); ResponseEntity<String> exchange = restTemplate.exchange("https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", HttpMethod.POST, entity, String.class); return objectMapper.readValue(exchange.getBody(), Map.class); }控制器@PostMapping("/image/url") public Map imageUrl(@RequestBody Map<String, String> param) throws JsonProcessingException { String model = param.getOrDefault("model", "qwen-image"); String prompt = param.getOrDefault("prompt", ""); String size = param.getOrDefault("size", "1328*1328"); return aliAiService.generateImageUrl(model, prompt, size); }
适合人群：具备 Spring Web 基础、希望在接口层做统一治理的开发者或技术爱好者。建议先看：软件工程实践二（Spring Boot 知识回顾）、实践三（RESTful API 设计原则）。1. 方案选型速览Filter（Servlet 层）：最靠前，处理包括静态资源；适合全局跨域、XSS、统一编码等。HandlerInterceptor（Spring MVC 层）：在进入 Controller 前后执行；适合接口打点、鉴权、限流、审计。AOP（方法层）：对注解/切点进行横切；适合注解式限流、审计、幂等等。API Gateway（边界层）：最靠外；适合全局流控、黑白名单、熔断、灰度，一处管多服务。一般应用内优先用 Interceptor + AOP；如果有网关，也可在网关做粗粒度限流，在应用内做精细化限流与监控。国内比较熟悉的：Sentinel2. 基于 HandlerInterceptor 的接口监控目标：记录每次请求的关键信息与耗时，并输出到日志或指标系统。示例：// RequestLoggingInterceptor.java // 功能：为每个进入的 HTTP 请求生成请求ID、记录入站/出站日志与耗时 package com.example.demo.web; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import java.util.UUID; public class RequestLoggingInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(RequestLoggingInterceptor.class); private static final String REQ_ID = "reqId"; // MDC 中的请求 ID 键 private static final String START = "startAt"; // 请求开始时间属性键 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String requestId = UUID.randomUUID().toString(); // 生成请求唯一 ID，便于链路追踪 MDC.put(REQ_ID, requestId); // 放入日志 MDC request.setAttribute(START, System.currentTimeMillis()); // 记录起始时间 String method = request.getMethod(); String uri = request.getRequestURI(); String ip = getClientIp(request); // 识别真实客户端 IP（支持代理场景） log.info("[IN] {} {} ip={} ua={}", method, uri, ip, request.getHeader("User-Agent")); return true; // 返回 true 放行请求 } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { // 可在此处添加对响应体或模型的额外处理 } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { Long start = (Long) request.getAttribute(START); long costMs = start == null ? -1 : (System.currentTimeMillis() - start); // 计算耗时 int status = response.getStatus(); if (ex != null) { log.warn("[OUT] status={} costMs={} ex={}", status, costMs, ex.toString()); // 异常时告警 } else { log.info("[OUT] status={} costMs={}", status, costMs); // 正常返回 } MDC.remove(REQ_ID); // 清理 MDC，避免线程复用造成污染 } private String getClientIp(HttpServletRequest request) { String xff = request.getHeader("X-Forwarded-For"); // 代理链：第一个为原始客户端 IP if (xff != null && !xff.isEmpty()) { return xff.split(",")[0].trim(); } String realIp = request.getHeader("X-Real-IP"); // 一些代理使用该头 if (realIp != null && !realIp.isEmpty()) { return realIp; } return request.getRemoteAddr(); // 直接连接时的远端地址 } }注册拦截器：// WebMvcConfig.java // 功能：注册全局拦截器，并对健康检查/文档/静态资源等路径放行 package com.example.demo.config; import com.example.demo.web.RequestLoggingInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new RequestLoggingInterceptor()) // 注册请求日志拦截器 .addPathPatterns("/**") // 默认拦截所有路径 .excludePathPatterns( // 放行以下常见非业务路径 "/actuator/**", "/swagger-resources/**", "/v3/api-docs/**", "/doc.html", "/swagger-ui/**", "/error", "/static/**" ); } }3. log配置文件 logback-spring.xml<?xml version="1.0" encoding="UTF-8"?> <configuration> <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg %X%n</pattern> </encoder> </appender> <appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>7</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg %X%n</pattern> </encoder> </appender> <root level="info"> <appender-ref ref="console"/> <appender-ref ref="file"/> </root> </configuration> 4. 流量控制的常见策略与算法维度：全局、按接口、按 IP、按用户、按来源（AppId）、按租户等算法：固定窗口（simple, 可能边界抖动）滑动窗口（更平滑）令牌桶（Token Bucket，允许突发）漏桶（Leaky Bucket，恒定速率）选型建议：单机快速落地：Guava RateLimiter 或 Bucket4j分布式一致性：Redis网关层粗粒度 + 应用内细粒度5. 本地单机限流：Guava RateLimiter（简单上手） <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>33.0.0-jre</version> </dependency>// SimpleRateLimitInterceptor.java // 功能：针对 URI 维度基于 Guava RateLimiter 的单机限流 package com.example.demo.limit; import com.google.common.util.concurrent.RateLimiter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; import org.springframework.web.servlet.HandlerInterceptor; import java.nio.charset.StandardCharsets; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class SimpleRateLimitInterceptor implements HandlerInterceptor { private final ConcurrentMap<String, RateLimiter> uriToLimiter = new ConcurrentHashMap<>(); // 每个 URI 一个限流器 private final double permitsPerSecond; // 每秒允许的请求数（QPS） public SimpleRateLimitInterceptor(double permitsPerSecond) { this.permitsPerSecond = permitsPerSecond; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String key = request.getRequestURI(); // 维度选择：可替换为 URI+IP/用户ID 等 RateLimiter limiter = uriToLimiter.computeIfAbsent(key, k -> RateLimiter.create(permitsPerSecond)); boolean allowed = limiter.tryAcquire(); // 非阻塞获取令牌，失败立即返回 429 if (!allowed) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); byte[] body = "Too Many Requests".getBytes(StandardCharsets.UTF_8); response.getOutputStream().write(body); return false; } return true; } }注册：// 将限流器应用到 /api/** 的请求路径 registry.addInterceptor(new SimpleRateLimitInterceptor(50.0)) // 每秒 50 次/接口 .addPathPatterns("/api/**");优点：简单易用；缺点：单机生效，不适合分布式一致性。6. 本地单机限流：Bucket4j（功能更强） <dependency> <groupId>com.bucket4j</groupId> <artifactId>bucket4j-core</artifactId> <version>8.10.1</version> </dependency>// Bucket4jRateLimitInterceptor.java // 功能：基于 Bucket4j 的单机令牌桶限流 package com.example.demo.limit; import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket; import io.github.bucket4j.Bucket4j; import io.github.bucket4j.Refill; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; import org.springframework.web.servlet.HandlerInterceptor; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class Bucket4jRateLimitInterceptor implements HandlerInterceptor { private final ConcurrentMap<String, Bucket> bucketMap = new ConcurrentHashMap<>(); // 每个 URI 一个桶 private Bucket newBucket() { // 令牌桶配置：容量 100，按每秒补充 50 个令牌；允许一定突发 Bandwidth limit = Bandwidth.classic(100, Refill.intervally(50, Duration.ofSeconds(1))); return Bucket.builder().addLimit(limit).build(); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String key = request.getRequestURI(); Bucket bucket = bucketMap.computeIfAbsent(key, k -> newBucket()); // 延迟创建桶 if (bucket.tryConsume(1)) { // 消耗 1 个令牌 return true; } response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.getOutputStream().write("Too Many Requests".getBytes(StandardCharsets.UTF_8)); return false; } }优势：策略多、可配额外指标；劣势：默认仍是单机。可选用 bucket4j-redis 或 Hazelcast 实现分布式令牌桶。7. 分布式限流思路：使用固定窗口计数法（Fixed Window）。对某个限流 key（如 接口+IP），在一个窗口期内使用 INCR 计数，首次创建时设置 EXPIRE 为窗口大小，到期自动清除。无需 Lua，保证实现简单、易维护。特性：简单高效；适合大多数分布式限流需求。边界在窗口切换时可能出现短暂突发（固定窗口特性）。若需更平滑的滑动窗口，可使用 Redis 事务（WATCH/MULTI/EXEC）或 Lua（本文不使用 Lua）。// RedisFixedWindowRateLimitInterceptor.java // 功能：基于 Redis 的固定窗口限流（纯 Redis 命令：INCR + EXPIRE） package com.example.demo.limit; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.web.servlet.HandlerInterceptor; import java.nio.charset.StandardCharsets; import java.time.Duration; public class RedisFixedWindowRateLimitInterceptor implements HandlerInterceptor { private final StringRedisTemplate redis; private final int maxReq; // 窗口内最大请求数 private final Duration window; // 窗口大小 public RedisFixedWindowRateLimitInterceptor(StringRedisTemplate redis, int maxReq, Duration window) { this.redis = redis; this.maxReq = maxReq; this.window = window; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String baseKey = buildBaseKey(request); // 例如: rl:{uri}:{ip} // 将窗口时间片纳入 key，避免首次请求起始时间的不确定性 long windowMs = window.toMillis(); long bucket = System.currentTimeMillis() / windowMs; String key = baseKey + ":" + bucket; // 形如 rl:/api/xx:1.2.3.4:19736622 Long count = redis.opsForValue().increment(key); if (count != null && count == 1L) { // 首次创建时设置过期时间，避免键长期滞留 redis.expire(key, window); } if (count != null && count <= maxReq) { return true; } response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.getOutputStream().write("Too Many Requests".getBytes(StandardCharsets.UTF_8)); return false; } private String buildBaseKey(HttpServletRequest request) { String uri = request.getRequestURI(); String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty()) ip = request.getRemoteAddr(); return "rl:" + uri + ":" + ip; // key 结构：rl:/api/xxx:1.2.3.4 } }注册：// 在 /api/** 启用分布式固定窗口限流：1 秒内最多 100 次 registry.addInterceptor(new RedisFixedWindowRateLimitInterceptor(stringRedisTemplate, 100, Duration.ofSeconds(1))) .addPathPatterns("/api/**");说明：key 维度可按需自定义（接口/用户/租户/来源等）。如果需要更平滑的限流，可在应用层做“本地桶 + Redis 合并”或升级为滑动窗口（仍可用纯 Redis 事务实现）。返回 429（Too Many Requests）。8. 注解式限流（AOP）定义注解：// RateLimit.java // 功能：声明式限流参数，支持在类或方法上使用 package com.example.demo.limit; import java.lang.annotation.*; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RateLimit { int max() default 100; // 窗口内最大请求数 long windowMs() default 1000; // 窗口大小（毫秒） String key() default "#request.requestURI"; // 基于 SpEL 的 key 表达式 }切面实现（示例改为每个 key 一个 Guava RateLimiter；按注解参数换算速率）：@Aspect @Component public class RateLimitAspect { private final HttpServletRequest request; // private final ConcurrentMap<String, RateLimiter> uriToLimiter = new ConcurrentHashMap<>();// private final ConcurrentMap<String, Bucket> bucketMap = new ConcurrentHashMap<>(); // 每个 URI 一个桶 public RateLimitAspect(HttpServletRequest request) { this.request = request; } @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { String key = buildKey(rateLimit); // 可结合 SpEL 动态生成 double permitsPerSecond = calcPermitsPerSecond(rateLimit.max(), rateLimit.windowMs()); // RateLimiter limiter = uriToLimiter.computeIfAbsent(key, k -> RateLimiter.create(permitsPerSecond)); Bucket bucket = bucketMap.computeIfAbsent(key, (k)->{ Bandwidth limit = Bandwidth.classic(Long.parseLong(permitsPerSecond + ""), Refill.intervally(Long.parseLong(permitsPerSecond + ""), Duration.ofSeconds(1))); return Bucket.builder().addLimit(limit).build(); }); boolean allowed = bucket.tryConsume(1); if (allowed) { return pjp.proceed(); } throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Too Many Requests"); } private String buildKey(RateLimit rateLimit) { String uri = request.getRequestURI(); String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty()) ip = request.getRemoteAddr(); return "rl:" + uri + ":" + ip; } private double calcPermitsPerSecond(int max, long windowMs) { if (windowMs <= 0) return Double.POSITIVE_INFINITY; double sec = windowMs / 1000.0; double qps = max / sec; return Math.max(qps, 0.000001); // 避免 0 } }使用：// 在 1 秒窗口内限制最多 20 次 @RateLimit(max = 20, windowMs = 1000) @GetMapping("/api/resource") public String resource() { return "ok"; }9. 统一异常与统一返回// GlobalExceptionHandler.java // 功能：统一将异常转换为标准响应体，便于前端与调用方处理 package com.example.demo.web; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.server.ResponseStatusException; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResponseStatusException.class) public ResponseEntity<Map<String, Object>> handle(ResponseStatusException ex) { Map<String, Object> body = new HashMap<>(); body.put("code", ex.getStatusCode().value()); // 与 HTTP 状态一致 body.put("message", ex.getReason()); // 由抛出方提供的原因 return new ResponseEntity<>(body, ex.getStatusCode()); } @ExceptionHandler(Exception.class) public ResponseEntity<Map<String, Object>> handle(Exception ex) { Map<String, Object> body = new HashMap<>(); body.put("code", 500); body.put("message", ex.getMessage()); // 生产可隐藏或映射为通用提示 return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); } }10. 网关与真实 IP使用 Nginx/Ingress 时，转发需设置并在应用端读取 X-Forwarded-For 或 X-Real-IP。若部署在网关后：网关做全局粗粒度限流（防 DDoS），应用内做细粒度限流（按用户/接口）。11. 压测与验证curl 验证：# 并发 100，持续 10s（需要已安装 hey） hey -z 10s -c 100 http://localhost:8080/api/resource观察日志中 [IN]/[OUT] 与 429；查看 /actuator/prometheus 指标，并在 Grafana 绘制 QPS、P95、限流次数等。12. 最佳实践清单日志与指标：所有入口统一打点（请求、状态、耗时、异常）。限流维度：按场景选择（全局/接口/用户/IP/租户）。算法选择：突发流量多用令牌桶；需要平滑用滑动窗口。分布式一致性：使用 Redis/Lua 或 Bucket4j 分布式支持。白名单/开关：在限流前置判断白名单；提供动态阈值与紧急开关。安全：注意绕过手段（多 IP、代理）；对敏感接口更严格。性能：Redis 压力大时做本地缓存合并、批量/管道、热点降级。观测：Actuator + Micrometer + Prometheus + Grafana，统一观察服务健康。13. 参考依赖清单（择需）<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>33.0.0-jre</version> </dependency> <dependency> <groupId>com.github.vladimir-bukhtoyarov</groupId> <artifactId>bucket4j-core</artifactId> <version>8.10.1</version> </dependency> </dependencies>
概述目标：用最少代码完成单表 CRUD、分页查询，并给出 JOIN 查询的常见做法。环境：Spring Boot 3.x + MyBatis-Plus 3.5+ + MySQL 8.x。1. 依赖与连接配置1.1 Maven 依赖（片段）<dependencies> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.12</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>1.2 application.yml 连接spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true mapper-locations: classpath*:mapper/**/*.xml1.3 启动类与包扫描@SpringBootApplication @MapperScan("com.example.demo.mapper") // 若在接口上使用 @Mapper，可不加 @MapperScan public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }1.4 分页插件配置@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }2. 示例模型与 Mapper2.1 表结构（示例）CREATE TABLE `t_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL, `age` INT NOT NULL, `email` VARCHAR(128), `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP );2.2 实体 Entity@Data @TableName("t_user") public class User { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer age; private String email; @TableField("created_at") private LocalDateTime createdAt; }2.3 Mapper 接口public interface UserMapper extends BaseMapper<User> {}3. 常见查询（QueryWrapper / Lambda）3.1 基础查询@Autowired private UserMapper userMapper; // 主键 User u1 = userMapper.selectById(1L); // 批量主键 List<User> users = userMapper.selectBatchIds(List.of(1L, 2L, 3L)); // 条件查询（QueryWrapper） QueryWrapper<User> qw = new QueryWrapper<>(); qw.select("id", "name", "age") .eq("age", 20) .likeRight("name", "A") .orderByDesc("id"); List<User> list = userMapper.selectList(qw); // 计数 Long count = userMapper.selectCount(new QueryWrapper<User>().ge("age", 18));3.2 LambdaQueryWrapper（更安全的字段引用）LambdaQueryWrapper<User> lw = Wrappers.lambdaQuery(User.class) .select(User::getId, User::getName) .between(User::getAge, 18, 30) .like(User::getName, "Tom") .orderByAsc(User::getCreatedAt); List<User> list2 = userMapper.selectList(lw);3.3 按条件可选拼装String name = req.getName(); Integer minAge = req.getMinAge(); LambdaQueryWrapper<User> w = Wrappers.lambdaQuery(User.class) .like(StringUtils.hasText(name), User::getName, name) .ge(minAge != null, User::getAge, minAge); List<User> result = userMapper.selectList(w);4. 分页查询（Page）需先注册分页插件（见 1.4）。int pageNo = 1, pageSize = 10; Page<User> page = new Page<>(pageNo, pageSize); LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(User.class) .ge(User::getAge, 18) .orderByDesc(User::getId); Page<User> resultPage = userMapper.selectPage(page, wrapper); List<User> records = resultPage.getRecords(); long total = resultPage.getTotal(); long pages = resultPage.getPages();可在 Controller 中直接返回分页结果：@GetMapping("/users") public Page<User> list(@RequestParam int page, @RequestParam int size, @RequestParam(required = false) Integer minAge) { Page<User> p = new Page<>(page, size); LambdaQueryWrapper<User> w = Wrappers.lambdaQuery(User.class) .ge(minAge != null, User::getAge, minAge) .orderByDesc(User::getId); return userMapper.selectPage(p, w); }5. JOIN（连接）查询的常见做法MyBatis-Plus 聚焦单表 CRUD；多表 JOIN 推荐使用自定义 SQL（注解或 XML）。5.1 一对一（User ↔ UserProfile）表CREATE TABLE `user_profile` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL UNIQUE, `phone` VARCHAR(32), `address` VARCHAR(255) );DTO@Data public class UserProfileDTO { private Long id; private Long userId; private String phone; private String address; } @Data public class UserWithProfileDTO { private Long id; private String name; private Integer age; private String email; private UserProfileDTO profile; }Mapper 接口public interface UserProfileQueryMapper { UserWithProfileDTO selectUserWithProfile(@Param("userId") Long userId); }XML（resources/mapper/UserProfileQueryMapper.xml）<?xml version="1.0" encoding="UTF-8" ?> <mapper namespace="com.example.demo.mapper.UserProfileQueryMapper"> <resultMap id="UserProfileMap" type="com.example.demo.dto.UserProfileDTO"> <id property="id" column="p_id"/> <result property="userId" column="p_user_id"/> <result property="phone" column="p_phone"/> <result property="address" column="p_address"/> </resultMap> <resultMap id="UserWithProfileMap" type="com.example.demo.dto.UserWithProfileDTO"> <id property="id" column="u_id"/> <result property="name" column="u_name"/> <result property="age" column="u_age"/> <result property="email" column="u_email"/> <association property="profile" resultMap="UserProfileMap"/> </resultMap> SELECT u.id AS u_id, u.name AS u_name, u.age AS u_age, u.email AS u_email, p.id AS p_id, p.user_id AS p_user_id, p.phone AS p_phone, p.address AS p_address FROM t_user u LEFT JOIN user_profile p ON p.user_id = u.id WHERE u.id = #{userId} </mapper>5.2 一对多（User ↔ Orders）下例演示嵌套集合映射；若只需扁平结果，可参考 5.1/5.3 的 DTO 方式。表CREATE TABLE `order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `amount` DECIMAL(6,2) );DTO@Data public class OrderDTO { private Long id; private Long userId; private BigDecimal amount; } @Data public class UserWithOrdersDTO { private Long id; private String name; private List<OrderDTO> orders; }Mapper 接口public interface UserOrderNestedMapper { UserWithOrdersDTO selectUsersWithOrders(@Param("userId") Long userId); }XML（resources/mapper/UserOrderNestedMapper.xml）<mapper namespace="com.example.demo.mapper.UserOrderNestedMapper"> <resultMap id="OrderMap" type="com.example.demo.dto.OrderDTO"> <id property="id" column="o_id"/> <result property="amount" column="o_amount"/> </resultMap> <resultMap id="UserWithOrdersMap" type="com.example.demo.dto.UserWithOrdersDTO"> <id property="id" column="u_id"/> <result property="name" column="u_name"/> <collection property="orders" ofType="com.example.demo.dto.OrderDTO" resultMap="OrderMap"/> </resultMap> SELECT u.id AS u_id, u.name AS u_name, o.id AS o_id, o.amount AS o_amount FROM t_user u LEFT JOIN `order` o ON o.user_id = u.id <where> u.id = #{userId} </where> ORDER BY u.id DESC, o.id DESC </mapper>5.3 多对多（User ↔ Role，经 user_role 关联）表CREATE TABLE `role` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL ); CREATE TABLE `user_role` ( `user_id` BIGINT NOT NULL, `role_id` BIGINT NOT NULL, PRIMARY KEY (`user_id`, `role_id`) );DTO@Data public class RoleDTO { private Long id; private String name; } @Data public class UserWithRolesDTO { private Long id; private String name; private List<RoleDTO> roles; }Mapper 接口public interface UserRoleMapper { List<UserWithRolesDTO> selectUsersWithRoles(@Param("userIds") List<Long> userIds); }XML（resources/mapper/UserRoleMapper.xml）<mapper namespace="com.example.demo.mapper.UserRoleMapper"> <resultMap id="RoleMap" type="com.example.demo.dto.RoleDTO"> <id property="id" column="r_id"/> <result property="name" column="r_name"/> </resultMap> <resultMap id="UserWithRolesMap" type="com.example.demo.dto.UserWithRolesDTO"> <id property="id" column="u_id"/> <result property="name" column="u_name"/> <collection property="roles" ofType="com.example.demo.dto.RoleDTO" resultMap="RoleMap"/> </resultMap> SELECT u.id AS u_id, u.name AS u_name, r.id AS r_id, r.name AS r_name FROM user u LEFT JOIN user_role ur ON ur.user_id = u.id LEFT JOIN role r ON r.id = ur.role_id <where> <if test="userIds != null and userIds.size > 0"> u.id IN <foreach collection="userIds" item="id" open="(" close=")" separator=","> #{id} </foreach> </if> </where> ORDER BY u.id DESC, r.id DESC </mapper>小贴士：嵌套集合映射时，为每个表字段起唯一别名（如 u_id/o_id/r_id），避免列名冲突导致映射失败。6. Service 层（IService / ServiceImpl）通过继承通用接口/实现类，快速获得 CRUD 能力并封装业务逻辑。public interface UserService extends IService<User> { Page<User> pageUsers(int page, int size, Integer minAge); }@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Override public Page<User> pageUsers(int page, int size, Integer minAge) { Page<User> p = new Page<>(page, size); LambdaQueryWrapper<User> w = Wrappers.lambdaQuery(User.class) .ge(minAge != null, User::getAge, minAge) .orderByDesc(User::getId); return this.page(p, w); } }7. Controller 层（CRUD + 分页）控制器依赖 Service，进行参数校验与异常转换。@RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping public Page<User> list(@RequestParam int page, @RequestParam int size, @RequestParam(required = false) Integer minAge) { return userService.pageUsers(page, size, minAge); } @GetMapping("/{id}") public User detail(@PathVariable Long id) { return userService.getById(id); } @PostMapping public User create(@Valid @RequestBody User body) { userService.save(body); return body; } @PutMapping("/{id}") public User update(@PathVariable Long id, @Valid @RequestBody User body) { body.setId(id); userService.updateById(body); return body; } @DeleteMapping("/{id}") public void remove(@PathVariable Long id) { userService.removeById(id); } }8. 实用补充与优化建议参数校验（Jakarta Validation）在实体/DTO 上使用 @NotBlank/@NotNull/@Email/@Min 等注解；控制器方法参数加 @Valid。统一异常处理通过 @RestControllerAdvice + @ExceptionHandler 统一返回错误结构（包含 code/message）。DTO 与实体分离建议 Controller 使用请求/响应 DTO，Service 处理实体，避免直接暴露数据库结构。分页与排序安全对前端传入的排序字段做白名单；分页 size 设置上限。事务管理在需要的 Service 方法上使用 @Transactional，避免在 Controller 层开启事务。9. 小结连接：配置数据源 + @MapperScan；分页：注册 MybatisPlusInterceptor 的 PaginationInnerInterceptor，使用 Page<T>；Service：基于 IService/ServiceImpl 封装业务；Controller：提供 RESTful CRUD 与分页，配合校验与异常处理；查询：QueryWrapper/LambdaQueryWrapper 完成单表条件，JOIN 用自定义 SQL（注解/XML）。
目标与范围明确一致的接口风格，降低客户端心智负担，提升跨团队协作效率。规范涵盖：资源建模、URI 设计、HTTP 方法与状态码、请求/响应、错误、分页过滤排序、版本化、安全、缓存、可观测性与文档。1. 核心理念资源导向：一切皆资源（名词复数命名），动作用 HTTP 方法表达。统一接口：方法、状态码、媒体类型、错误结构一致。无状态：每个请求自包含认证与上下文，不依赖服务器会话。可缓存：合理使用条件请求与缓存头，降低延迟与负载。可演进：通过版本化与向后兼容策略平滑升级。2. 资源建模与 URI 规范资源命名：使用复数、短小、层级表达从属关系。/api/v1/users/api/v1/users/{userId}/api/v1/users/{userId}/orders关联过滤：也可使用查询参数表达关联（优先简单方案）。/api/v1/orders?userId=123避免在路径中使用动词；确需动作，用子资源表达。POST /api/v1/invoices/{id}/pay标识符：建议使用不可泄漏信息的 ID（UUID/雪花），避免自增 ID 暴露业务规模。路径示例与反例：推荐：GET /api/v1/users/{id}，PATCH /api/v1/users/{id}，DELETE /api/v1/users/{id}不推荐：POST /api/update/users/{id}、POST /api/create/users/{id}（应分别使用 PUT/PATCH 与 POST /api/v1/users）3. HTTP 方法语义与幂等性GET（安全、幂等）：获取资源或集合。POST（非幂等）：创建资源、触发计算/异步任务。PUT（幂等）：整体替换资源（客户端提供完整表述）。PATCH（建议近幂等）：局部更新，使用 JSON Merge Patch 或 JSON Patch。DELETE（幂等）：删除资源（软删/硬删在语义上对客户端保持透明）。4. 标准状态码2xx：200 OK：成功，返回资源或结果；201 Created：创建成功，Location 指向新资源；202 Accepted：已受理异步任务；204 No Content：成功但无响应体（删除、幂等更新）。4xx：400 Bad Request：参数错误/校验失败；401 Unauthorized：未认证或凭证无效；403 Forbidden：已认证但无权限；404 Not Found：资源不存在；409 Conflict：资源状态冲突（如唯一键冲突）；412 Precondition Failed：条件请求失败（ETag 并发控制）；415 Unsupported Media Type：媒体类型不支持；422 Unprocessable Entity：语义错误（校验未通过）；429 Too Many Requests：限流触发。5xx：服务器错误，尽量避免；记录告警并快速恢复。5. 请求与响应规范媒体类型：请求/响应 Content-Type/Accept 统一使用 application/json; charset=utf-8。字段命名与格式：推荐 camelCase；时间使用 ISO 8601（UTC），如 2025-08-20T10:30:00Z。大整数（如 ID）防止前端精度丢失可按 string 传输。统一返回结构（可选但推荐，便于一致性与观测）：{ "code": "OK", // 可选 "message": "success", // 可选 "data": { /* 资源对象或结果 */ }, "requestId": "f7a2b..."， }错误返回结构：{ "code": "VALIDATION_ERROR", // 可以是数字，字符串 "message": "email is invalid",// 提示 "requestId": "9e6d7...", // 可选 "details": [ // 可选 { "field": "email", "issue": "must be a valid email" } ] }6. 示例：Todo 列表接口（内存 List）用途：演示使用内存 List 存储并返回 todo 集合（示例代码，非生产）。路径：GET /api/v1/todos返回：200 OK，application/json; charset=utf-86.1 控制器示例（Java / Spring）package com.example.demo.todo; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicLong; @RestController @RequestMapping("/api/v1/todos") public class TodoController { private final List<Todo> store = new CopyOnWriteArrayList<>(); private final AtomicLong idGen = new AtomicLong(0); public record Todo(Long id, String title, boolean completed) {} public TodoController() { store.add(new Todo(idGen.incrementAndGet(), "Learn REST", false)); store.add(new Todo(idGen.incrementAndGet(), "Write docs", true)); } @GetMapping public List<Todo> list() { return store; // 直接返回内存 List } }6.2 响应示例[ { "id": 1, "title": "Learn REST", "completed": false }, { "id": 2, "title": "Write docs", "completed": true } ]6.3 创建 Todo（POST）路径：POST /api/v1/todos请求体：{ "title": string, "completed": boolean? }返回：201 Created（或 200 OK），Location 指向新资源，响应体为创建后的 todoPOST /api/v1/todos Content-Type: application/json { "title": "Read book", "completed": false }HTTP/1.1 201 Created Location: /api/v1/todos/3 Content-Type: application/json; charset=utf-8 { "id": 3, "title": "Read book", "completed": false }6.4 修改 Todo（PATCH）路径：PATCH /api/v1/todos/{id}请求体：可选字段，部分更新返回：200 OK，返回更新后的 todo；不存在返回 404 Not FoundPATCH /api/v1/todos/1 Content-Type: application/json { "completed": true }HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "id": 1, "title": "Learn REST", "completed": true }6.5 删除 Todo（DELETE）路径：DELETE /api/v1/todos/{id}返回：存在则 204 No Content，不存在返回 404 Not FoundDELETE /api/v1/todos/2HTTP/1.1 204 No Content6.6 完整控制器（含新增/修改/删除）package com.example.demo.todo; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; 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 java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicLong; @RestController @RequestMapping("/api/v1/todos") public class TodoController { private final List<Todo> store = new CopyOnWriteArrayList<>(); private final AtomicLong idGen = new AtomicLong(0); public record Todo(Long id, String title, boolean completed) {} public record CreateTodoRequest(String title, Boolean completed) {} public record PatchTodoRequest(String title, Boolean completed) {} public TodoController() { store.add(new Todo(idGen.incrementAndGet(), "Learn REST", false)); store.add(new Todo(idGen.incrementAndGet(), "Write docs", true)); } @GetMapping public List<Todo> list() { return store; } @PostMapping public ResponseEntity<Todo> create(@RequestBody CreateTodoRequest req) { if (req == null || req.title() == null || req.title().isBlank()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } boolean completed = req.completed() != null ? req.completed() : false; long id = idGen.incrementAndGet(); Todo todo = new Todo(id, req.title(), completed); store.add(todo); return ResponseEntity .status(HttpStatus.CREATED) .header(HttpHeaders.LOCATION, "/api/v1/todos/" + id) .body(todo); } @PatchMapping("/{id}") public ResponseEntity<Todo> patch(@PathVariable Long id, @RequestBody PatchTodoRequest req) { int idx = indexOfId(id); if (idx < 0) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } Todo old = store.get(idx); String newTitle = (req != null && req.title() != null) ? req.title() : old.title(); boolean newCompleted = (req != null && req.completed() != null) ? req.completed() : old.completed(); Todo updated = new Todo(id, newTitle, newCompleted); store.set(idx, updated); return ResponseEntity.ok(updated); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { boolean removed = store.removeIf(t -> t.id().equals(id)); return removed ? ResponseEntity.noContent().build() : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } private int indexOfId(Long id) { for (int i = 0; i < store.size(); i++) { if (store.get(i).id().equals(id)) { return i; } } return -1; } }7. 示例：Product 产品接口（内存 List）用途：演示“产品”资源的增删改查实现（示例代码，非生产）。路径：GET /api/v1/products 列表GET /api/v1/products/{id} 详情POST /api/v1/products 新建PATCH /api/v1/products/{id} 部分更新DELETE /api/v1/products/{id} 删除模型字段：id: number/string（响应中为数字，此处示例用 Long）description: string（产品描述）price: number（价格，建议十进制定点，Java 用 BigDecimal）stock: number（库存，非负整数）7.1 请求/响应示例列表：GET /api/v1/products[ { "id": 1, "description": "Demo A", "price": 99.90, "stock": 10 }, { "id": 2, "description": "Demo B", "price": 199.00, "stock": 5 } ]详情：GET /api/v1/products/1{ "id": 1, "description": "Demo A", "price": 99.90, "stock": 10 }新建：POST /api/v1/products Content-Type: application/json { "description": "New Product", "price": 9.99, "stock": 100 }HTTP/1.1 201 Created Location: /api/v1/products/3 Content-Type: application/json; charset=utf-8 { "id": 3, "description": "New Product", "price": 9.99, "stock": 100 }修改（部分字段）：PATCH /api/v1/products/1 Content-Type: application/json { "stock": 8 }HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "id": 1, "description": "Demo A", "price": 99.90, "stock": 8 }删除：DELETE /api/v1/products/2HTTP/1.1 204 No Content7.2 控制器示例（Java / Spring）package com.example.demo.product; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; 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 java.math.BigDecimal; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicLong; @RestController @RequestMapping("/api/v1/products") public class ProductController { private final List<Product> store = new CopyOnWriteArrayList<>(); private final AtomicLong idGen = new AtomicLong(0); public record Product(Long id, String description, BigDecimal price, int stock) {} public record CreateProductRequest(String description, BigDecimal price, Integer stock) {} public record PatchProductRequest(String description, BigDecimal price, Integer stock) {} public ProductController() { store.add(new Product(idGen.incrementAndGet(), "Demo A", new BigDecimal("99.90"), 10)); store.add(new Product(idGen.incrementAndGet(), "Demo B", new BigDecimal("199.00"), 5)); } @GetMapping public List<Product> list() { return store; } @GetMapping("/{id}") public ResponseEntity<Product> getById(@PathVariable Long id) { int idx = indexOfId(id); if (idx < 0) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } return ResponseEntity.ok(store.get(idx)); } @PostMapping public ResponseEntity<Product> create(@RequestBody CreateProductRequest req) { if (!isValidCreate(req)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } long id = idGen.incrementAndGet(); Product product = new Product(id, req.description(), req.price(), req.stock()); store.add(product); return ResponseEntity .status(HttpStatus.CREATED) .header(HttpHeaders.LOCATION, "/api/v1/products/" + id) .body(product); } @PatchMapping("/{id}") public ResponseEntity<Product> patch(@PathVariable Long id, @RequestBody PatchProductRequest req) { int idx = indexOfId(id); if (idx < 0) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } Product old = store.get(idx); String newDescription = req != null && req.description() != null ? req.description() : old.description(); BigDecimal newPrice = req != null && req.price() != null ? req.price() : old.price(); Integer newStockBoxed = req != null && req.stock() != null ? req.stock() : old.stock(); if (!isValidFields(newDescription, newPrice, newStockBoxed)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } Product updated = new Product(id, newDescription, newPrice, newStockBoxed); store.set(idx, updated); return ResponseEntity.ok(updated); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { boolean removed = store.removeIf(p -> p.id().equals(id)); return removed ? ResponseEntity.noContent().build() : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } private int indexOfId(Long id) { for (int i = 0; i < store.size(); i++) { if (store.get(i).id().equals(id)) { return i; } } return -1; } private boolean isValidCreate(CreateProductRequest req) { if (req == null || req.description() == null || req.description().isBlank()) return false; if (req.price() == null || req.price().compareTo(BigDecimal.ZERO) < 0) return false; if (req.stock() == null || req.stock() < 0) return false; return true; } private boolean isValidFields(String description, BigDecimal price, Integer stock) { if (description == null || description.isBlank()) return false; if (price == null || price.compareTo(BigDecimal.ZERO) < 0) return false; if (stock == null || stock < 0) return false; return true; } }
使用 IntelliJ IDEA 创建 Spring Boot Web 项目（仅 Web 依赖）目标：用 IntelliJ IDEA 快速创建只包含 Spring Web 的最小可运行项目，启动后通过浏览器访问 http://localhost:8080/api/hello 返回 JSON。一、创建项目（Spring Boot 向导）步骤说明：打开 IDEA → New Project → 选择 Spring Boot；服务地址保持默认。Project Metadata：Group：如 com.exampleArtifact/Name：如 demoPackaging：jar（默认）Java：17（或本机已安装的 LTS 版本）Build system：Maven（不要选 Gradle）Dependencies：搜索并仅勾选 Spring Web。Finish → 等待 Maven 下载依赖与索引完成。提示：若看不到 Spring Boot(Initializr)，确保安装了 IntelliJ 的 Spring 插件，或使用 File > New > Project... 再选择。二、项目最小代码示例1) 主启动类（IDE 已生成，检查包名与类名）package com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }2) Hello 控制器（新增）package com.example.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Date; import java.util.Map; @RestController @RequestMapping("/api") public class HelloController { @GetMapping("/hello") public Map<String, Object> hello() { return Map.of( "msg", "hello", "time", new Date() ); } }3) 应用配置（可选）# src/main/resources/application.yml server: # 如需修改端口，取消注释并调整值 # port: 8080三、运行与验证在 DemoApplication 类左侧点击绿色运行箭头，或使用菜单 Run。控制台看到 Started DemoApplication ... 表示启动成功。浏览器/工具访问：http://localhost:8080/api/hello期望响应示例：{"msg":"hello","time":"2025-01-01T00:00:00Z"}常见问题：端口占用：在 application.yml 配置 server.port: 8081。依赖未下载：检查网络代理或尝试 Reload All Maven Projects。JDK 不匹配：Project Structure > Project/Modules 指定与向导一致的 Java 版本。四、标准目录结构与说明 demo/ ← 工程根目录 ├─ pom.xml ← 构建脚本（Maven） ├─ src │ ├─ main │ │ ├─ java │ │ │ └─ com │ │ │ └─ example │ │ │ └─ demo │ │ │ ├─ DemoApplication.java ← 启动类 │ │ │ └─ controller │ │ │ └─ HelloController.java ← Web 控制器 │ │ └─ resources │ │ ├─ application.yml ← 应用配置 │ │ └─ static/ templates/ ← 静态资源/模板(可选) │ └─ test │ └─ java │ └─ com.example.demo │ └─ DemoApplicationTests.java ← 测试类DemoApplication：应用入口，@SpringBootApplication 聚合配置并触发自动装配。controller：放置 @RestController、@Controller 等 Web 层类。resources：application.yml|yaml：端口、数据源、日志级别等配置。static/：静态文件（css/js/img）。templates/：模板引擎（如 Thymeleaf）页面，需相应依赖时使用。test：单元/集成测试。五、Maven 依赖最小示例（仅供参考）Maven pom.xml 关键片段：<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.4</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>demo</name> <description>Demo project for Spring Boot</description> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>六、常用配置（application.yml 示例）spring: application: name: demo jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss server: port: 8080 # servlet: # context-path: /api logging: level: root: INFO com.example.demo: DEBUG # 多环境（profiles），开发/生产差异化配置（任选其一：此处或独立 profile 文件） # spring: # profiles: # active: dev多环境配置（Profiles）示例：# application-dev.yml（开发环境） logging: level: com.example.demo: DEBUG# application-prod.yml（生产环境） logging: level: root: INFO运行时指定环境：在 application.yml 写：spring: profiles: active: dev或启动参数：--spring.profiles.active=prod全局 CORS（跨域）配置（如需前端跨域访问）：package com.example.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebCorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:3000") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true); } }全局异常处理（将错误转换为统一 JSON 响应）：package com.example.demo.common; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public Map<String, Object> handle(Exception ex) { return Map.of( "success", false, "error", ex.getClass().getSimpleName(), "message", ex.getMessage() ); } }七、返回 JSON 与统一异常案例 1：路径参数 + 查询参数 + JSON 返回package com.example.demo.controller; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/users") public class UserController { @GetMapping("/{id}") public Map<String, Object> getUser(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean detail) { return Map.of( "id", id, "name", "Alice", "detail", detail ); } public record CreateUserRequest(String name, Integer age) {} @PostMapping public Map<String, Object> create(@RequestBody CreateUserRequest req) { long generatedId = System.currentTimeMillis(); return Map.of( "id", generatedId, "name", req.name(), "age", req.age() ); } }案例 2：触发异常并由全局异常处理返回统一结构将下述方法加入 UserController 以体验统一异常响应：@GetMapping("/error") public Map<String, Object> error() { throw new IllegalStateException("示例异常"); }测试命令示例：# 获取用户（携带查询参数 detail） curl "http://localhost:8080/api/users/1?detail=true" # 创建用户（POST JSON） curl -X POST "http://localhost:8080/api/users" \ -H "Content-Type: application/json" \ -d "{\"name\":\"Bob\",\"age\":20}" # 触发错误，查看统一异常返回 curl "http://localhost:8080/api/users/error"八、@Value 配置读取示例1) 在 application.yml 定义配置：app: name: demo-app timeout: 5s # 可自动转换为 Duration white-list: - 127.0.0.1 - 10.0.0.1 db: host: 127.0.0.1 port: 33062) 使用 @Value 读取（支持默认值与基础类型转换）：package com.example.demo.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.time.Duration; @Component public class AppValueExample { @Value("${app.name}") private String appName; // 默认值示例：若未配置则为 3s @Value("${app.timeout:3s}") private Duration timeout; // 读取列表元素（带默认值） @Value("${app.white-list[0]:127.0.0.1}") private String firstWhiteIp; // 读取嵌套对象字段（带默认值） @Value("${app.db.host:localhost}") private String dbHost; @Value("${app.db.port:3306}") private Integer dbPort; public String getAppName() { return appName; } public Duration getTimeout() { return timeout; } public String getFirstWhiteIp() { return firstWhiteIp; } public String getDbHost() { return dbHost; } public Integer getDbPort() { return dbPort; } }说明：默认值语法：${key:defaultValue}，当 key 不存在时使用 defaultValue。类型转换：Duration/int/boolean 等常见类型由 Spring 自动转换（如 5s -> Duration.ofSeconds(5)）。列表索引：${list[0]} 访问第一个元素。建议：若配置项较多/需要分组，优先使用 @ConfigurationProperties 进行批量绑定与校验。九、日志引入与配置（SLF4J + Logback）默认：spring-boot-starter-logging 已内置，提供 SLF4J API + Logback 实现。使用：在类中注入并使用 SLF4J Logger。package com.example.demo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Date; import java.util.Map; @RestController @RequestMapping("/api") public class HelloControllerWithLog { private static final Logger log = LoggerFactory.getLogger(HelloControllerWithLog.class); @GetMapping("/hello2") public Map<String, Object> hello() { log.info("hello endpoint called at {}", new Date()); log.debug("debug details..."); return Map.of("msg", "hello", "time", new Date()); } }最小 application.yml 配置：logging: level: root: INFO com.example.demo: DEBUG file: name: logs/app.log # 输出到文件（会自动创建文件夹） logback: rollingpolicy: # 基于 Logback 的滚动策略（无需 xml） max-file-size: 10MB max-history: 7 total-size-cap: 1GB常用说明：logging.level.<包名>：设置指定包日志级别。logging.file.name：直接指定日志文件；或使用 logging.file.path 指定目录（文件名默认为 spring.log）。logging.logback.rollingpolicy.*：文件按大小/历史保留自动滚动。自定义输出格式可用：logging.pattern.console / logging.pattern.file。进阶（可选）：使用 logback-spring.xml 进行更细粒度控制，或切换 Log4j2：切换 Log4j2：在 pom.xml 引入 spring-boot-starter-log4j2 并排除默认 logging 依赖。十、@ConfigurationProperties 批量绑定与校验配置（支持嵌套对象、列表、Map、时长等类型）：app: name: demo-app timeout: 5s white-list: - 127.0.0.1 - 10.0.0.1 db: host: 127.0.0.1 port: 3306属性类（开启校验，演示嵌套绑定与默认值）：package com.example.demo.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.convert.DurationUnit; import java.time.Duration; import java.time.temporal.ChronoUnit; import java.util.List; @ConfigurationProperties(prefix = "app") public class AppProperties { private String name; @DurationUnit(ChronoUnit.SECONDS) private Duration timeout = Duration.ofSeconds(3); private List<String> whiteList; private Database db = new Database(); public static class Database { private String host; private int port = 3306; public String getHost() { return host; } public void setHost(String host) { this.host = host; } public int getPort() { return port; } public void setPort(int port) { this.port = port; } } public String getName() { return name; } public void setName(String name) { this.name = name; } public Duration getTimeout() { return timeout; } public void setTimeout(Duration timeout) { this.timeout = timeout; } public List<String> getWhiteList() { return whiteList; } public void setWhiteList(List<String> whiteList) { this.whiteList = whiteList; } public Database getDb() { return db; } public void setDb(Database db) { this.db = db; } }启用方式（三选一）：在启动类上开启扫描（推荐）：import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.SpringApplication; @SpringBootApplication @ConfigurationPropertiesScan // 扫描同包及子包的 @ConfigurationProperties 类 public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }或在配置类上显式启用：import org.springframework.boot.context.properties.EnableConfigurationProperties; @EnableConfigurationProperties(AppProperties.class) public class PropertiesConfig {}或直接把属性类声明为 Bean：import org.springframework.context.annotation.Bean; @org.springframework.context.annotation.Configuration public class PropertiesConfig2 { @Bean public AppProperties appProperties() { return new AppProperties(); } }也可在属性类上加 @Component 直接让其被扫描成 Bean（需保证包路径可被扫描）。校验依赖（若使用 @Validated/约束注解，请确保已引入）：<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>十一、GET 参数处理与示例GET 适用于通过查询参数与路径变量传递轻量数据；GET 不能上传文件（multipart/form-data）。package com.example.demo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/get") public class GetParamController { private static final Logger log = LoggerFactory.getLogger(GetParamController.class); // 1) 简单类型 + 默认值 @GetMapping("/simple") public Map<String, Object> simple( @RequestParam String name, @RequestParam(defaultValue = "18") Integer age, @RequestParam(defaultValue = "false") boolean active) { log.info("GET simple: name={}, age={}, active={}", name, age, active); return Map.of("name", name, "age", age, "active", active); } // 2) 列表参数（重复 key：?tags=a&tags=b） @GetMapping("/list") public Map<String, Object> list(@RequestParam(required = false) List<String> tags) { log.info("GET list: tags={}", tags); return Map.of("tags", tags); } // 3) 所有查询参数（平铺） @GetMapping("/all") public Map<String, Object> all(@RequestParam Map<String, String> params) { log.info("GET all params: {}", params); return Map.of("params", params); } // 4) 所有查询参数（允许一个 key 多个值） @GetMapping("/all-multi") public Map<String, Object> allMulti(@RequestParam MultiValueMap<String, String> params) { log.info("GET all multi params: {}", params); return Map.of("params", params); } // 5) DTO 绑定（GET 默认使用 @ModelAttribute 绑定查询参数） public static class QueryDto { private String keyword; private Integer page; private Boolean highlight; public String getKeyword() { return keyword; } public void setKeyword(String keyword) { this.keyword = keyword; } public Integer getPage() { return page; } public void setPage(Integer page) { this.page = page; } public Boolean getHighlight() { return highlight; } public void setHighlight(Boolean highlight) { this.highlight = highlight; } } @GetMapping("/dto") public Map<String, Object> dto(@ModelAttribute QueryDto q) { log.info("GET dto: keyword={}, page={}, highlight={}", q.getKeyword(), q.getPage(), q.getHighlight()); return Map.of("q", Map.of( "keyword", q.getKeyword(), "page", q.getPage(), "highlight", q.getHighlight() )); } // 6) 路径参数 + 查询参数 @GetMapping("/users/{id}") public Map<String, Object> user(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean verbose) { log.info("GET user: id={}, verbose={}", id, verbose); return Map.of("id", id, "verbose", verbose); } // 7) 日期/时间/Duration（注意：Duration 推荐使用 ISO-8601，如 PT30S） @GetMapping("/time") public Map<String, Object> time( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate day, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime at, @RequestParam(required = false) Duration timeout) { log.info("GET time: day={}, at={}, timeout={}", day, at, timeout); return Map.of( "day", day, "at", at, "timeoutSeconds", timeout != null ? timeout.getSeconds() : null ); } }提示：GET 不支持文件上传；文件请使用 POST multipart/form-data。List<String> 通过重复 key 传递最稳妥（如 ?tags=a&tags=b）。Duration 在查询参数中推荐使用 ISO-8601 表达（如 PT30S、PT5M）。十二、POST 参数上传与示例常见 POST 载荷类型：application/json、application/x-www-form-urlencoded、multipart/form-data、text/plain、application/octet-stream。package com.example.demo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/post") public class PostParamController { private static final Logger log = LoggerFactory.getLogger(PostParamController.class); // 1) application/json：JSON 体绑定到 DTO public static class CreateUserRequest { private String name; private Integer age; private List<String> tags; public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public List<String> getTags() { return tags; } public void setTags(List<String> tags) { this.tags = tags; } } @PostMapping(value = "/json", consumes = "application/json") public Map<String, Object> json(@RequestBody CreateUserRequest req) { log.info("POST json: name={}, age={}, tags={}", req.getName(), req.getAge(), req.getTags()); return Map.of( "ok", true, "data", Map.of( "name", req.getName(), "age", req.getAge(), "tags", req.getTags() ) ); } // 2) application/x-www-form-urlencoded：表单键值 @PostMapping(value = "/form", consumes = "application/x-www-form-urlencoded") public Map<String, Object> form(@RequestParam String name, @RequestParam Integer age, @RequestParam(required = false) List<String> tags) { log.info("POST form: name={}, age={}, tags={}", name, age, tags); return Map.of("name", name, "age", age, "tags", tags); } // 3) multipart/form-data：单文件 + 其他字段 @PostMapping(value = "/file", consumes = "multipart/form-data") public Map<String, Object> uploadFile(@RequestParam("file") MultipartFile file, @RequestParam(required = false) String desc) throws IOException { log.info("POST file: name={}, size={}, desc={}", file.getOriginalFilename(), file.getSize(), desc); return Map.of( "filename", file.getOriginalFilename(), "size", file.getSize(), "desc", desc ); } // 4) multipart/form-data：多文件 @PostMapping(value = "/files", consumes = "multipart/form-data") public Map<String, Object> uploadFiles(@RequestParam("files") List<MultipartFile> files) { var names = files.stream().map(MultipartFile::getOriginalFilename).toList(); var sizes = files.stream().map(MultipartFile::getSize).toList(); log.info("POST files: count={}, names={}", files.size(), names); return Map.of( "count", files.size(), "names", names, "sizes", sizes ); } // 5) multipart/form-data：混合 JSON + 文件（JSON 放在 part 中） public static class Meta { private String title; private Integer count; public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public Integer getCount() { return count; } public void setCount(Integer count) { this.count = count; } } @PostMapping(value = "/mixed", consumes = "multipart/form-data") public Map<String, Object> mixed(@RequestPart("meta") Meta meta, @RequestPart("file") MultipartFile file) { log.info("POST mixed: meta.title={}, meta.count={}, file={}", meta.getTitle(), meta.getCount(), file.getOriginalFilename()); return Map.of( "meta", Map.of("title", meta.getTitle(), "count", meta.getCount()), "file", Map.of("name", file.getOriginalFilename(), "size", file.getSize()) ); } // 6) text/plain：纯文本 @PostMapping(value = "/text", consumes = "text/plain") public Map<String, Object> text(@RequestBody String body) { log.info("POST text: len={}", body != null ? body.length() : 0); return Map.of("length", body != null ? body.length() : 0, "body", body); } // 7) application/octet-stream：二进制流 @PostMapping(value = "/binary", consumes = "application/octet-stream") public Map<String, Object> binary(@RequestBody byte[] data) { log.info("POST binary: size={}", data != null ? data.length : 0); return Map.of("size", data != null ? data.length : 0); } }示例请求（curl）：# 1) JSON curl -X POST "http://localhost:8080/api/post/json" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20,"tags":["java","spring"]}' # 2) x-www-form-urlencoded（数组用重复 key） curl -X POST "http://localhost:8080/api/post/form" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "name=Bob&age=22&tags=java&tags=spring" # 3) 单文件上传（multipart） curl -X POST "http://localhost:8080/api/post/file" \ -F "file=@/path/to/a.png" \ -F "desc=头像" # 4) 多文件上传（同名字段） curl -X POST "http://localhost:8080/api/post/files" \ -F "files=@/path/to/a.png" \ -F "files=@/path/to/b.jpg" # 5) 混合 JSON + 文件（给 JSON part 设置类型） curl -X POST "http://localhost:8080/api/post/mixed" \ -F 'meta={"title":"doc","count":2};type=application/json' \ -F "file=@/path/to/a.pdf" # 6) 纯文本 curl -X POST "http://localhost:8080/api/post/text" \ -H "Content-Type: text/plain" \ --data-binary "hello world" # 7) 二进制流 curl -X POST "http://localhost:8080/api/post/binary" \ -H "Content-Type: application/octet-stream" \ --data-binary @/path/to/file.bin提示：表单数组使用重复 key：tags=a&tags=b；或后端声明 String[]/List<String> 显式绑定。混合 JSON + 文件时，JSON part 的 Content-Type 必须是 application/json 才能被 @RequestPart 解析。大文件上传请调整 spring.servlet.multipart.max-file-size 与 max-request-size。十三、过滤器 Filter 与案例过滤器位于最前层（Servlet 容器级），可用于日志、鉴权、跨域预处理等。执行顺序：Filter → Servlet/DispatcherServlet → Interceptor → Controller。package com.example.demo.filter; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.io.IOException; import java.time.Duration; import java.time.Instant; /** * 基础日志过滤器：记录请求方法、URI、耗时与状态码 */ @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 越小越先执行 public class RequestLoggingFilter implements Filter { private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; Instant start = Instant.now(); String method = req.getMethod(); String uri = req.getRequestURI(); try { chain.doFilter(request, response); } finally { long ms = Duration.between(start, Instant.now()).toMillis(); log.info("[REQ] {} {} -> status={}, {}ms", method, uri, resp.getStatus(), ms); } } }测试（与前文 GET/POST 控制器配合）：# 不带 token 访问受保护的 API（期望 401） curl -i "http://localhost:8080/api/post/json" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 携带错误 token（期望 403） curl -i "http://localhost:8080/api/post/json" \ -H "X-Auth-Token: wrong" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 携带正确 token（期望 200） curl -i "http://localhost:8080/api/post/json" \ -H "X-Auth-Token: demo-token" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 被排除的健康检查路径（无需 token，期望 200） curl -i "http://localhost:8080/health"提示：全局跨域建议使用 Spring WebMvcConfigurer 的 CORS 配置（见前文）。Filter 中处理 CORS 需注意 OPTIONS 预检放行。引入 Spring Security 时，其过滤器链可能优先于或穿插业务 Filter；鉴权逻辑推荐迁移到 Security 中。十四、拦截器 HandlerInterceptor 与案例拦截器运行在 Spring MVC 层（DispatcherServlet 之后、Controller 之前/之后），适合做登录鉴权、上下文注入、审计日志等。顺序：Filter → Interceptor → Controller → 异常处理（Advice）。package com.example.demo.interceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.lang.Nullable; import org.springframework.web.servlet.HandlerInterceptor; import java.time.Duration; import java.time.Instant; /** * 日志拦截器：记录方法、URI、耗时 */ public class LoggingInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(LoggingInterceptor.class); private static final String ATTR_START = "__start_time"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { request.setAttribute(ATTR_START, Instant.now()); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) { Object startObj = request.getAttribute(ATTR_START); long ms = 0; if (startObj instanceof Instant start) { ms = Duration.between(start, Instant.now()).toMillis(); } String method = request.getMethod(); String uri = request.getRequestURI(); int status = response.getStatus(); if (ex == null) { log.info("[INTCP] {} {} -> status={}, {}ms", method, uri, status, ms); } else { log.warn("[INTCP] {} {} -> status={}, {}ms, ex={}", method, uri, status, ms, ex.toString()); } } }鉴权拦截器（读取 Header 并写入请求属性）：package com.example.demo.interceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.servlet.HandlerInterceptor; /** * 简易 Token 校验：要求 Header: X-Auth-Token=demo-token * 校验通过后写入 request attribute: userId */ public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("X-Auth-Token"); if (token == null || token.isBlank()) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"success\":false,\"message\":\"Missing X-Auth-Token\"}"); return false; } if (!"demo-token".equals(token)) { response.setStatus(403); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"success\":false,\"message\":\"Invalid token\"}"); return false; } // 通过：写入请求属性，供 Controller 使用 request.setAttribute("userId", "demo-user"); return true; } }注册拦截器（路径包含/排除、顺序）：package com.example.demo.config; import com.example.demo.interceptor.AuthInterceptor; import com.example.demo.interceptor.LoggingInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { // 日志拦截器：对所有请求生效，最先执行 registry.addInterceptor(new LoggingInterceptor()) .addPathPatterns("/**") .order(1); // 鉴权拦截器：仅拦截 /api/**，排除无需登录的接口 registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns( "/health", "/actuator/**", "/public/**", "/api/get/**" ) .order(2); } }在控制器中读取拦截器写入的属性：package com.example.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController @RequestMapping("/api/me") public class MeController { @GetMapping public Map<String, Object> me(@RequestAttribute(value = "userId", required = false) String userId) { return Map.of( "authenticated", userId != null, "userId", userId ); } }测试命令（与前文 POST 接口一起验证鉴权）：# 1) 未带 token 访问受保护接口（期望 401） curl -i "http://localhost:8080/api/post/json" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 2) 错误 token（期望 403） curl -i "http://localhost:8080/api/post/json" \ -H "X-Auth-Token: wrong" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 3) 正确 token（期望 200） curl -i "http://localhost:8080/api/post/json" \ -H "X-Auth-Token: demo-token" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 4) 访问 /api/me（展示拦截器写入的 userId） curl -i "http://localhost:8080/api/me" -H "X-Auth-Token: demo-token" # 5) 被排除的路径（无需 token） curl -i "http://localhost:8080/health"提示：Interceptor 可访问 handler（方法/控制器信息），适合做基于注解的鉴权、审计。异常被抛出后将交由 @RestControllerAdvice 处理；如在 preHandle 已写响应并返回 false，后续链路不会继续。若引入 Spring Security，建议把登录鉴权放入 Security 过滤器链与授权机制中，拦截器更多用于业务级横切逻辑。十五、PUT 参数上传与示例PUT 常用于“更新”语义，通常与资源标识（如路径变量 id）配合；相较于 POST，PUT 更强调幂等性（同样的请求重复提交结果一致）。以下示例涵盖 JSON、x-www-form-urlencoded、multipart、text/plain 与二进制流。package com.example.demo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/put") public class PutParamController { private static final Logger log = LoggerFactory.getLogger(PutParamController.class); // 1) application/json：JSON 体绑定到 DTO public static class UpdateUserRequest { private String name; private Integer age; private List<String> tags; public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public List<String> getTags() { return tags; } public void setTags(List<String> tags) { this.tags = tags; } } @PutMapping(value = "/json", consumes = "application/json") public Map<String, Object> json(@RequestBody UpdateUserRequest req) { log.info("PUT json: name={}, age={}, tags={}", req.getName(), req.getAge(), req.getTags()); return Map.of( "ok", true, "data", Map.of( "name", req.getName(), "age", req.getAge(), "tags", req.getTags() ) ); } // 2) application/x-www-form-urlencoded：表单键值 @PutMapping(value = "/form", consumes = "application/x-www-form-urlencoded") public Map<String, Object> form(@RequestParam String name, @RequestParam Integer age, @RequestParam(required = false) List<String> tags) { log.info("PUT form: name={}, age={}, tags={}", name, age, tags); return Map.of("name", name, "age", age, "tags", tags); } // 3) multipart/form-data：单文件 + 其他字段 @PutMapping(value = "/file", consumes = "multipart/form-data") public Map<String, Object> uploadFile(@RequestParam("file") MultipartFile file, @RequestParam(required = false) String desc) throws IOException { log.info("PUT file: name={}, size={}, desc={}", file.getOriginalFilename(), file.getSize(), desc); return Map.of( "filename", file.getOriginalFilename(), "size", file.getSize(), "desc", desc ); } // 4) multipart/form-data：混合 JSON + 文件（JSON 放在 part 中） public static class Meta { private String title; private Integer count; public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public Integer getCount() { return count; } public void setCount(Integer count) { this.count = count; } } @PutMapping(value = "/mixed", consumes = "multipart/form-data") public Map<String, Object> mixed(@RequestPart("meta") Meta meta, @RequestPart("file") MultipartFile file) { log.info("PUT mixed: meta.title={}, meta.count={}, file={}", meta.getTitle(), meta.getCount(), file.getOriginalFilename()); return Map.of( "meta", Map.of("title", meta.getTitle(), "count", meta.getCount()), "file", Map.of("name", file.getOriginalFilename(), "size", file.getSize()) ); } // 5) text/plain：纯文本 @PutMapping(value = "/text", consumes = "text/plain") public Map<String, Object> text(@RequestBody String body) { log.info("PUT text: len={}", body != null ? body.length() : 0); return Map.of("length", body != null ? body.length() : 0, "body", body); } // 6) application/octet-stream：二进制流 @PutMapping(value = "/binary", consumes = "application/octet-stream") public Map<String, Object> binary(@RequestBody byte[] data) { log.info("PUT binary: size={}", data != null ? data.length : 0); return Map.of("size", data != null ? data.length : 0); } // 7) 路径变量 + JSON：典型“更新某个资源” @PutMapping(value = "/users/{id}", consumes = "application/json") public Map<String, Object> updateUser(@PathVariable Long id, @RequestBody UpdateUserRequest req) { log.info("PUT users/{}: name={}, age={}, tags={}", id, req.getName(), req.getAge(), req.getTags()); return Map.of( "id", id, "updated", true, "data", Map.of("name", req.getName(), "age", req.getAge(), "tags", req.getTags()) ); } }示例请求（curl）：# 1) JSON curl -X PUT "http://localhost:8080/api/put/json" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":21,"tags":["java","spring"]}' # 2) x-www-form-urlencoded（数组用重复 key） curl -X PUT "http://localhost:8080/api/put/form" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "name=Bob&age=22&tags=java&tags=spring" # 3) 单文件上传（multipart） curl -X PUT "http://localhost:8080/api/put/file" \ -F "file=@/path/to/a.png" \ -F "desc=头像" # 4) 混合 JSON + 文件（给 JSON part 设置类型） curl -X PUT "http://localhost:8080/api/put/mixed" \ -F 'meta={"title":"doc","count":2};type=application/json' \ -F "file=@/path/to/a.pdf" # 5) 纯文本 curl -X PUT "http://localhost:8080/api/put/text" \ -H "Content-Type: text/plain" \ --data-binary "hello world" # 6) 二进制流 curl -X PUT "http://localhost:8080/api/put/binary" \ -H "Content-Type: application/octet-stream" \ --data-binary @/path/to/file.bin # 7) 路径变量 + JSON（更新指定 id） curl -X PUT "http://localhost:8080/api/put/users/100" \ -H "Content-Type: application/json" \ -d '{"name":"Carol","age":23,"tags":["k8s"]}'注意：幂等性：PUT 语义倾向幂等，更新相同资源应返回相同结果；可配合 If-Match/ETag 做并发控制。鉴权：若启用了拦截器/过滤器鉴权，PUT 同样需要携带头 X-Auth-Token: demo-token 才能通过。表单/文件：multipart 与 x-www-form-urlencoded 对 PUT 同样适用；部分代理/网关可能对 PUT 有限制，需在网关放行并正确转发请求体。安全：开启 Spring Security 时，PUT 默认受 CSRF 保护；如为纯 API 服务可关闭 CSRF 或在请求中附带 CSRF token。十六、20 个 GET/POST 接口样例覆盖探活、版本、分页、详情、搜索、请求头/IP、速率限制、时间时区、登录、下单/支付、上传、表单、文本、数值计算等多样场景。每个接口提供功能说明、示例请求与“结果样式”。约定：若启用拦截器/安全，请携带 X-Auth-Token: demo-token。1) GET /api/sample/ping — 健康探活功能：返回服务运行状态与时间戳示例请求：curl "http://localhost:8080/api/sample/ping"结果样式：{"ok":true,"ts":1735700000000}2) GET /api/sample/version — 版本信息功能：返回应用名称、版本与构建curl "http://localhost:8080/api/sample/version"{"app":"demo","version":"1.0.0","build":"local"}3) GET /api/sample/users — 用户分页参数：page(默认1), size(默认10), keyword(可选)curl "http://localhost:8080/api/sample/users?page=1&size=2&keyword=ali"{"page":1,"size":2,"keyword":"ali","items":[{"id":1,"name":"Alice","age":20},{"id":2,"name":"Bob","age":22}]}4) GET /api/sample/users/{id} — 用户详情curl "http://localhost:8080/api/sample/users/1001"{"id":1001,"name":"User-1001","age":19}5) GET /api/sample/search — 关键字+标签搜索参数：q、tags(多值)curl "http://localhost:8080/api/sample/search?q=phone&tags=android&tags=5g"{"q":"phone","tags":["android","5g"],"results":["r1","r2"]}6) GET /api/sample/headers — 请求头读取功能：读取 X-Trace-Id 与 User-Agentcurl -H "X-Trace-Id: abc-123" -H "User-Agent: curl/8.0" "http://localhost:8080/api/sample/headers"{"traceId":"abc-123","userAgent":"curl/8.0"}7) GET /api/sample/ip — 客户端 IP/UAcurl -H "User-Agent: demo" "http://localhost:8080/api/sample/ip"{"ip":"127.0.0.1","userAgent":"demo"}8) GET /api/sample/rate-limit — 速率限制信息功能：返回窗口、配额与剩余；同时在响应头设置 X-RateLimit-*curl "http://localhost:8080/api/sample/rate-limit"{"window":"1m","limit":100,"remaining":40}9) GET /api/sample/echo — 回显 messagecurl "http://localhost:8080/api/sample/echo?message=hello"{"message":"hello","ts":1735700000000}10) GET /api/sample/time — 当前时间（可选时区）参数：tz(如 Asia/Shanghai)，非法时区自动回退系统默认curl "http://localhost:8080/api/sample/time?tz=Asia/Shanghai"{"now":"2025-01-01T08:00:00+08:00[Asia/Shanghai]","zone":"Asia/Shanghai","epochMs":1735700000000}11) POST /api/sample/users — 创建用户（JSON）curl -X POST "http://localhost:8080/api/sample/users" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20,"tags":["java","spring"]}'{"id":1700000000000,"name":"Alice","age":20,"tags":["java","spring"]}12) POST /api/sample/users/batch — 批量创建（JSON）curl -X POST "http://localhost:8080/api/sample/users/batch" \ -H "Content-Type: application/json" \ -d '{"users":[{"name":"A","age":18},{"name":"B","age":19}]}'{"created":2}13) POST /api/sample/login — 登录（JSON）curl -X POST "http://localhost:8080/api/sample/login" \ -H "Content-Type: application/json" \ -d '{"username":"u","password":"p"}'{"ok":true,"token":"demo-token"}14) POST /api/sample/orders — 创建订单（JSON）curl -X POST "http://localhost:8080/api/sample/orders" \ -H "Content-Type: application/json" \ -d '{"userId":"U1","items":[{"sku":"S1","quantity":2,"price":10.5},{"sku":"S2","quantity":1,"price":20}]}'{"orderId":"ORD-1700000000000","userId":"U1","total":41.0}15) POST /api/sample/orders/{id}/pay — 支付订单（JSON）curl -X POST "http://localhost:8080/api/sample/orders/ORD-1/pay" \ -H "Content-Type: application/json" \ -d '{"method":"alipay","channel":"app"}'{"orderId":"ORD-1","paid":true,"method":"alipay","channel":"app"}16) POST /api/sample/upload — 单文件上传（multipart）curl -X POST "http://localhost:8080/api/sample/upload" \ -F "file=@/path/to/a.png" -F "desc=头像"{"name":"a.png","size":12345,"desc":"头像"}17) POST /api/sample/uploads — 多文件上传（multipart）curl -X POST "http://localhost:8080/api/sample/uploads" \ -F "files=@/path/to/a.png" \ -F "files=@/path/to/b.jpg"{"count":2,"names":["a.png","b.jpg"],"sizes":[12345,23456]}18) POST /api/sample/feedback — 文本反馈（text/plain）curl -X POST "http://localhost:8080/api/sample/feedback" \ -H "Content-Type: text/plain" --data-binary "很好用！"{"received":12}19) POST /api/sample/submit — 表单提交（x-www-form-urlencoded）curl -X POST "http://localhost:8080/api/sample/submit" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "title=建议&content=内容很多&tags=java&tags=spring"{"title":"建议","contentLen":12,"tags":["java","spring"]}20) POST /api/sample/compute — 数值计算（JSON）功能：avg=true 返回平均值，否则返回总和curl -X POST "http://localhost:8080/api/sample/compute" \ -H "Content-Type: application/json" \ -d '{"numbers":[1,2,3.5],"avg":true}'{"avg":2.1666666667,"count":3}package com.example.demo.controller; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/sample") public class SampleApiController { private static final Logger log = LoggerFactory.getLogger(SampleApiController.class); // ---------- GET ---------- @GetMapping("/ping") public Map<String, Object> ping() { return Map.of("ok", true, "ts", System.currentTimeMillis()); } @GetMapping("/version") public Map<String, Object> version() { return Map.of("app", "demo", "version", "1.0.0", "build", "local"); } @GetMapping("/users") public Map<String, Object> listUsers(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String keyword) { return Map.of( "page", page, "size", size, "keyword", keyword, "items", List.of( Map.of("id", 1, "name", "Alice", "age", 20), Map.of("id", 2, "name", "Bob", "age", 22) ) ); } @GetMapping("/users/{id}") public Map<String, Object> userDetail(@PathVariable long id) { return Map.of("id", id, "name", "User-" + id, "age", 18 + (id % 10)); } @GetMapping("/search") public Map<String, Object> search(@RequestParam(name = "q") String keyword, @RequestParam(required = false) List<String> tags) { return Map.of("q", keyword, "tags", tags, "results", List.of("r1", "r2")); } @GetMapping("/headers") public Map<String, Object> headers(@RequestHeader(value = "X-Trace-Id", required = false) String traceId, @RequestHeader(value = "User-Agent", required = false) String userAgent) { return Map.of("traceId", traceId, "userAgent", userAgent); } @GetMapping("/ip") public Map<String, Object> ip(HttpServletRequest request, @RequestHeader(value = "User-Agent", required = false) String userAgent) { String xff = request.getHeader("X-Forwarded-For"); String ip = xff != null && !xff.isBlank() ? xff.split(",")[0].trim() : request.getRemoteAddr(); return Map.of("ip", ip, "userAgent", userAgent); } @GetMapping("/rate-limit") public Map<String, Object> rateLimit(HttpServletResponse response) { int limit = 100, remaining = 42; // 演示值 response.setHeader("X-RateLimit-Limit", String.valueOf(limit)); response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining)); response.setHeader("X-RateLimit-Window", "1m"); return Map.of("window", "1m", "limit", limit, "remaining", remaining); } @GetMapping("/echo") public Map<String, Object> echo(@RequestParam String message) { return Map.of("message", message, "ts", System.currentTimeMillis()); } @GetMapping("/time") public Map<String, Object> time(@RequestParam(required = false) String tz) { ZoneId zone; try { zone = tz != null && !tz.isBlank() ? ZoneId.of(tz) : ZoneId.systemDefault(); } catch (Exception e) { zone = ZoneId.systemDefault(); } ZonedDateTime now = ZonedDateTime.now(zone); return Map.of("now", now.toString(), "zone", zone.getId(), "epochMs", System.currentTimeMillis()); } // ---------- POST ---------- public static class CreateUserReq { private String name; private Integer age; private List<String> tags; public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public List<String> getTags() { return tags; } public void setTags(List<String> tags) { this.tags = tags; } } public static class BatchCreateUserReq { private List<CreateUserReq> users; public List<CreateUserReq> getUsers() { return users; } public void setUsers(List<CreateUserReq> users) { this.users = users; } } @PostMapping(value = "/users", consumes = "application/json") public Map<String, Object> createUser(@RequestBody CreateUserReq req) { long id = System.currentTimeMillis(); log.info("create user: {} {} {}", req.getName(), req.getAge(), req.getTags()); return Map.of("id", id, "name", req.getName(), "age", req.getAge(), "tags", req.getTags()); } @PostMapping(value = "/users/batch", consumes = "application/json") public Map<String, Object> batchCreate(@RequestBody BatchCreateUserReq req) { int count = req.getUsers() != null ? req.getUsers().size() : 0; return Map.of("created", count); } public static class LoginReq { private String username; private String password; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } @PostMapping(value = "/login", consumes = "application/json") public Map<String, Object> login(@RequestBody LoginReq req) { boolean ok = req.getUsername() != null && !req.getUsername().isBlank(); return Map.of("ok", ok, "token", ok ? "demo-token" : null); } public static class OrderItem { private String sku; private Integer quantity; private Double price; public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; } public Integer getQuantity() { return quantity; } public void setQuantity(Integer quantity) { this.quantity = quantity; } public Double getPrice() { return price; } public void setPrice(Double price) { this.price = price; } } public static class CreateOrderReq { private String userId; private List<OrderItem> items; public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public List<OrderItem> getItems() { return items; } public void setItems(List<OrderItem> items) { this.items = items; } } @PostMapping(value = "/orders", consumes = "application/json") public Map<String, Object> createOrder(@RequestBody CreateOrderReq req) { double total = 0.0; if (req.getItems() != null) { for (OrderItem it : req.getItems()) { if (it.getPrice() != null && it.getQuantity() != null) { total += it.getPrice() * it.getQuantity(); } } } String orderId = "ORD-" + System.currentTimeMillis(); return Map.of("orderId", orderId, "userId", req.getUserId(), "total", total); } public static class PayReq { private String method; // e.g., wechat, alipay, card private String channel; // e.g., app, web public String getMethod() { return method; } public void setMethod(String method) { this.method = method; } public String getChannel() { return channel; } public void setChannel(String channel) { this.channel = channel; } } @PostMapping(value = "/orders/{id}/pay", consumes = "application/json") public Map<String, Object> pay(@PathVariable String id, @RequestBody PayReq req) { return Map.of("orderId", id, "paid", true, "method", req.getMethod(), "channel", req.getChannel()); } @PostMapping(value = "/upload", consumes = "multipart/form-data") public Map<String, Object> upload(@RequestParam("file") MultipartFile file, @RequestParam(required = false) String desc) { return Map.of("name", file.getOriginalFilename(), "size", file.getSize(), "desc", desc); } @PostMapping(value = "/uploads", consumes = "multipart/form-data") public Map<String, Object> uploads(@RequestParam("files") List<MultipartFile> files) { var names = files.stream().map(MultipartFile::getOriginalFilename).toList(); var sizes = files.stream().map(MultipartFile::getSize).toList(); return Map.of("count", files.size(), "names", names, "sizes", sizes); } @PostMapping(value = "/feedback", consumes = "text/plain") public Map<String, Object> feedback(@RequestBody String body) { return Map.of("received", body != null ? body.length() : 0); } @PostMapping(value = "/submit", consumes = "application/x-www-form-urlencoded") public Map<String, Object> submit(@RequestParam String title, @RequestParam String content, @RequestParam(required = false) List<String> tags) { return Map.of("title", title, "contentLen", content.length(), "tags", tags); } public static class ComputeReq { private List<Double> numbers; private Boolean avg; // true: 返回平均值；false/null：返回总和 public List<Double> getNumbers() { return numbers; } public void setNumbers(List<Double> numbers) { this.numbers = numbers; } public Boolean getAvg() { return avg; } public void setAvg(Boolean avg) { this.avg = avg; } } @PostMapping(value = "/compute", consumes = "application/json") public Map<String, Object> compute(@RequestBody ComputeReq req) { double sum = 0.0; int n = 0; if (req.getNumbers() != null) { for (Double d : req.getNumbers()) { if (d != null) { sum += d; n++; } } } boolean avg = Boolean.TRUE.equals(req.getAvg()); return avg ? Map.of("avg", n > 0 ? sum / n : 0.0, "count", n) : Map.of("sum", sum, "count", n); } }提示：如启用了拦截器或安全配置，上述接口同样需要按规则携带必要的认证信息（如 X-Auth-Token）才能访问。
本文将深入探讨一个标准的Web应用后台管理系统中，图片上传功能的完整实现流程。我们将从前端用户交互开始，一直到后端的文件处理、存储和最终的URL返回。整体流程概览图片上传功能的核心流程可以分为以下几个步骤：前端交互：管理员在前端页面选择图片文件。文件上传：前端将图片文件通过HTTP POST请求发送到后端指定的API端点。后端接收：后端控制器（Controller）接收到上传的文件。文件处理：后端服务对文件进行处理，如生成唯一文件名、校验文件类型和大小等。文件存储：将处理后的文件保存到服务器的指定目录中。返回结果：后端向前端返回一个包含图片可访问URL的成功响应。前端展示：前端接收到URL后，将其用于图片预览或表单提交。下面是这个流程的可视化图表：后端实现细节后端采用Spring Boot框架，下面是关键代码的解析。1. 文件上传控制器 (UploadController.java)这个控制器是处理文件上传请求的入口。@RestController @RequestMapping("/api/admin/upload") @CrossOrigin(origins = "*") public class UploadController { private static final Logger logger = LoggerFactory.getLogger(UploadController.class); /** * 上传图片 */ @PostMapping("/image") public Result<?> uploadImage(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("上传失败，请选择文件"); } try { // 定义存储目录 String resourcePath = "src/main/resources/static/images"; File directory = new File(resourcePath); if (!directory.exists()) { directory.mkdirs(); } // 生成唯一文件名，防止重名覆盖 String originalFilename = file.getOriginalFilename(); String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = UUID.randomUUID().toString() + extension; // 保存文件 Path filePath = Paths.get(directory.getAbsolutePath(), filename); Files.write(filePath, file.getBytes()); // 构建返回给前端的数据 Map<String, String> data = new HashMap<>(); data.put("url", "/images/" + filename); // 返回相对路径 return Result.success(data); } catch (IOException e) { logger.error("上传图片失败", e); return Result.error("上传图片失败: " + e.getMessage()); } } }关键点：@PostMapping("/image")：定义了处理POST请求的API端点。@RequestParam("file") MultipartFile file：通过Spring MVC接收上传的文件。唯一文件名：使用 UUID.randomUUID() 来生成一个全球唯一的文件名，避免了用户上传同名文件时互相覆盖的问题。文件存储：文件被保存在 src/main/resources/static/images/ 目录下。这是一个标准的Maven项目静态资源目录。返回URL：向前端返回的是一个相对URL，如 /images/xxxx.png，而不是完整的文件系统路径。2. 静态资源服务控制器 (StaticResourceController.java)文件上传到服务器后，我们还需要一个机制让浏览器能够通过URL访问到这些图片。StaticResourceController 就是为此而生。@Controller @RequestMapping("/images") public class StaticResourceController { private static final Logger logger = LoggerFactory.getLogger(StaticResourceController.class); @GetMapping("/{filename:.+}") @ResponseBody public ResponseEntity<Resource> getImage(@PathVariable String filename) { logger.info("请求图片: " + filename); // 定义图片存储的物理路径 String imagePath = "src/main/resources/static/images/" + filename; File file = new File(imagePath); if (file.exists()) { try { String contentType = Files.probeContentType(file.toPath()); if (contentType == null) { contentType = "image/jpeg"; // 默认MIME类型 } // 将文件包装成资源返回 return ResponseEntity.ok() .contentType(MediaType.parseMediaType(contentType)) .body(new FileSystemResource(file)); } catch (IOException e) { logger.error("读取图片文件失败: " + imagePath, e); } } // 文件不存在则返回404 logger.warn("图片未找到: " + filename); return ResponseEntity.notFound().build(); } }关键点：@GetMapping("/{filename:.+}")：匹配所有对 /images/ 路径下的GET请求，例如 /images/xxxx.png。ResponseEntity<Resource>：这是返回文件流的标准方式。它允许我们设置HTTP头（如Content-Type）并返回文件本身。FileSystemResource：将服务器上的物理文件包装成Spring的Resource对象，以便于网络传输。404处理：如果请求的文件在服务器上不存在，则标准地返回一个 404 Not Found 响应。演示总结通过 UploadController 和 StaticResourceController 的配合，我们构建了一个图片上传和访问流程。这种前后端分离、职责明确的设计模式，使得功能易于理解、维护和扩展。管理员可以轻松上传图片，而系统能够确保这些图片被安全存储和高效访问。