基于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本文基于实际项目开发经验总结，代码示例均来自真实项目实现。
适合人群：具备 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 的配合，我们构建了一个图片上传和访问流程。这种前后端分离、职责明确的设计模式，使得功能易于理解、维护和扩展。管理员可以轻松上传图片，而系统能够确保这些图片被安全存储和高效访问。
本文将介绍如何在Spring Boot应用中实现短信验证码注册和登录功能，为用户提供便捷的移动端认证体验。1. 功能概述我们将实现以下功能：手机号+验证码注册手机号+验证码登录2. 技术栈Spring Boot 3.2.0互亿无线短信平台MyBatis FlexJWT3. 实现步骤3.1 短信服务集成首先，需要在application.properties中配置短信平台信息：# 短信配置 sms.account=C09251523 sms.apikey=826efdf7322e3c49355428a523c10eec3.2 创建短信工具类创建SmsUtil工具类处理短信发送：@Component public class SmsUtil { private static final Logger logger = LoggerFactory.getLogger(SmsUtil.class); // 短信发送API地址 private static final String SMS_API_URL = "http://106.ihuyi.com/webservice/sms.php?method=Submit"; // API账号和密码 @Value("${sms.account:C09***523}") private String account; @Value("${sms.apikey:826efdf7322e3c49355***a523c10eec}") private String apiKey; /** * 发送短信验证码 * @param phone 手机号 * @param code 验证码 * @return 发送结果，包含code和msg */ public Map<String, String> sendVerificationCode(String phone, String code) { Map<String, String> result = new HashMap<>(); try { // 短信内容 String content = "您的验证码是：" + code + "。请不要把验证码泄露给其他人。"; // 发送请求 String response = sendSmsRequest(phone, content); // 解析结果 result = parseXmlResponse(response); // 记录日志 if ("2".equals(result.get("code"))) { logger.info("短信发送成功，手机号: {}, 验证码: {}", phone, code); } else { logger.error("短信发送失败，手机号: {}, 错误信息: {}", phone, result.get("msg")); } } catch (Exception e) { logger.error("发送短信验证码异常", e); result.put("code", "0"); result.put("msg", "系统异常，短信发送失败"); } return result; } // 省略发送请求和解析响应的方法... }3.3 验证码生成和存储在UserServiceImpl中实现验证码管理：// 在UserServiceImpl中 private final Map<String, String> smsCodeCache = new HashMap<>(); @Override public boolean sendSmsCode(String phone, String type) { try { // 校验手机号格式 if (!isValidPhoneNumber(phone)) { return false; } // 生成6位随机验证码 String code = generateRandomCode(6); // 将验证码存入缓存，实际项目中应使用Redis等缓存服务 smsCodeCache.put(phone + ":" + type, code); // 发送短信 Map<String, String> result = smsUtil.sendVerificationCode(phone, code); // 检查发送结果 return "2".equals(result.get("code")); } catch (Exception e) { logger.error("发送短信验证码失败", e); return false; } } @Override public String getSmsCode(String phone, String type) { return smsCodeCache.get(phone + ":" + type); } @Override public void removeSmsCode(String phone, String type) { smsCodeCache.remove(phone + ":" + type); }3.4 控制器实现发送短信验证码@PostMapping("/send/sms") public ResponseEntity<?> sendSmsCode( @RequestParam @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone, @RequestParam @NotBlank String type) { logger.info("发送短信验证码，手机号: {}, 类型: {}", phone, type); boolean result = userService.sendSmsCode(phone, type); if (result) { return ResponseEntity.ok().body(Map.of("code", 200, "message", "验证码发送成功")); } else { return ResponseEntity.badRequest().body(Map.of("code", 400, "message", "验证码发送失败")); } }手机号+验证码注册@PostMapping("/register/sms") public ResponseEntity<?> registerBySms( @RequestBody(required = false) Map<String, String> registerRequest, @RequestParam(required = false) String phone, @RequestParam(required = false) String password, @RequestParam(required = false) String code, @RequestParam(required = false) String nickname) { // 优先使用URL参数，如果没有则使用请求体 if (registerRequest != null) { if (phone == null) phone = registerRequest.get("phone"); if (password == null) password = registerRequest.get("password"); if (code == null) code = registerRequest.get("code"); if (nickname == null) nickname = registerRequest.get("nickname"); } if (phone == null || password == null || code == null) { return ResponseEntity.badRequest().body(Map.of( "code", 400, "message", "手机号、密码和验证码不能为空")); } // 验证手机号格式 if (!phone.matches("^1[3-9]\\d{9}$")) { return ResponseEntity.badRequest().body(Map.of( "code", 400, "message", "手机号格式不正确")); } logger.info("短信验证码一步式注册，手机号: {}", phone); // 验证验证码 String cachedCode = userService.getSmsCode(phone, "register"); if (cachedCode == null || !cachedCode.equals(code)) { return ResponseEntity.status(400).body(Map.of( "code", 400, "message", "验证码错误或已过期")); } // 创建用户 User user = new User(); user.setPhone(phone); user.setUsername(phone); // 使用手机号作为用户名 user.setPassword(password); // 未加密的密码，在service层会加密 user.setNickname(nickname != null ? nickname : "用户" + phone.substring(phone.length() - 4)); // 注册用户 Map<String, Object> registerResult = userService.register(user, true); int registerCode = (int) registerResult.get("code"); // 验证码验证成功后，删除缓存中的验证码 if (registerCode == 200) { userService.removeSmsCode(phone, "register"); } return ResponseEntity.status(registerCode == 200 ? 200 : 400).body(registerResult); }短信验证码登录@PostMapping("/login/sms") public ResponseEntity<?> loginBySms( @RequestParam @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone, @RequestParam @NotBlank String code) { logger.info("短信验证码登录，手机号: {}", phone); Map<String, Object> result = userService.smsLogin(phone, code); int code2 = (int) result.get("code"); return ResponseEntity.status(code2 == 200 ? 200 : 400).body(result); }3.5 服务层实现实现短信验证码登录的业务逻辑：@Override public Map<String, Object> smsLogin(String phone, String code) { Map<String, Object> result = new HashMap<>(); // 验证验证码 String cachedCode = getSmsCode(phone, "login"); if (cachedCode == null || !cachedCode.equals(code)) { result.put("code", 400); result.put("message", "验证码错误或已过期"); return result; } // 清除验证码缓存 removeSmsCode(phone, "login"); // 根据手机号查询用户 User user = getOneOrNull(QueryWrapper.create().where("phone = ?", phone)); if (user == null) { result.put("code", 400); result.put("message", "用户不存在，请先注册"); return result; } // 检查用户状态 if (user.getStatus() != 1) { result.put("code", 403); result.put("message", "账号已被禁用"); return result; } // 更新登录信息 user.setLastLoginTime(LocalDateTime.now()); updateById(user); // 生成token String token = jwtUtil.generateToken(user.getUsername()); // 准备返回数据 Map<String, Object> userData = new HashMap<>(); userData.put("id", user.getId()); userData.put("username", user.getUsername()); userData.put("nickname", user.getNickname()); userData.put("avatar", user.getAvatar()); userData.put("phone", user.getPhone()); result.put("code", 200); result.put("message", "登录成功"); result.put("data", new HashMap<String, Object>() {{ put("token", token); put("user", userData); }}); return result; }4. 前端实现4.1 API层// 用户相关接口 export const userApi = { // 手机号验证码注册 registerBySms: (data) => { return request({ url: '/user/register/sms', method: 'post', data }) }, // 手机号验证码登录 smsLogin: (data) => { return request({ url: '/user/login/sms', method: 'post', data }) }, // 发送短信验证码 sendSmsCode: (phone, type) => { return request({ url: '/user/send/sms', method: 'post', params: { phone, type // login-登录, register-注册, reset-重置密码 } }) } }4.2 手机号注册页面 手机号 验证码 0" @click="sendCode"> {{ countdown > 0 ? `${countdown}s后重发` : '获取验证码' }} 密码 昵称 注册 5. 最佳实践验证码安全设置验证码过期时间（通常5分钟）限制发送频率，避免短信轰炸使用Redis存储验证码，设置TTL手机号验证正则验证手机号格式检查手机号是否已注册短信内容遵守短信平台规范内容简洁明了包含必要的安全提示防护措施IP限制图形验证码预校验短信发送频率限制6. 总结短信验证码注册登录是移动互联网应用的标准功能，不仅提高了用户体验，也增强了账户安全性。本文介绍了实现这一功能的完整流程，从后端API到前端UI。在实际应用中，可以根据需求进一步优化：添加图形验证码预校验使用Redis存储验证码实现短信模板多样化增加短信登录的安全策略
课设需要做一个宠物商店APP，在登录时我加了一个邮箱注册登录。本文将详细介绍如何在Spring Boot应用中实现邮箱验证码注册和登录功能。1. 功能概述我们将实现以下功能：邮箱+验证码注册邮箱+验证码登录邮箱+密码登录（传统登录方式）这些功能涵盖了用户注册和登录的主要场景，为用户提供了灵活的选择。效果预览2. 技术栈Spring Boot 3.2.0Spring SecuritySpring MailThymeleaf (邮件模板)MyBatis FlexJWT (认证)3. 实现步骤3.1 邮件服务配置首先，我们需要配置邮件服务。在application.properties文件中添加：# 邮件服务配置 spring.mail.host=smtp.qq.com spring.mail.port=587 spring.mail.username=your-email@qq.com spring.mail.password=your-authorization-code spring.mail.default-encoding=UTF-8 spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true[scode type="blue"]对于QQ邮箱，密码不是邮箱密码，而是授权码，需要在QQ邮箱设置中获取。[/scode]3.2 创建邮件服务创建EmailService接口和实现类：public interface EmailService { boolean sendVerificationEmail(String to, String subject, String code, String type); }@Service public class EmailServiceImpl implements EmailService { private static final Logger logger = LoggerFactory.getLogger(EmailServiceImpl.class); private final JavaMailSender mailSender; private final TemplateEngine templateEngine; @Value("${spring.mail.username}") private String from; public EmailServiceImpl(JavaMailSender mailSender, TemplateEngine templateEngine) { this.mailSender = mailSender; this.templateEngine = templateEngine; } @Override public boolean sendVerificationEmail(String to, String subject, String code, String type) { try { // 准备Thymeleaf模板上下文 Context context = new Context(); context.setVariable("code", code); context.setVariable("type", type); context.setVariable("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); // 处理模板生成HTML内容 String htmlContent = templateEngine.process("email/verification-code", context); // 创建MIME消息 MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setFrom(from); helper.setTo(to); helper.setSubject(subject); helper.setText(htmlContent, true); // 发送邮件 mailSender.send(message); logger.info("邮件发送成功：{}", to); return true; } catch (Exception e) { logger.error("邮件发送失败", e); return false; } } }3.3 验证码生成和存储为了管理验证码，我们需要创建生成验证码的方法和存储验证码的机制：// 在UserServiceImpl中 private final Map<String, String> emailCodeCache = new HashMap<>(); private String generateRandomCode(int length) { StringBuilder sb = new StringBuilder(); Random random = new Random(); for (int i = 0; i < length; i++) { sb.append(random.nextInt(10)); } return sb.toString(); } @Override public boolean sendEmailCode(String email, String type) { try { // 生成6位随机验证码 String code = generateRandomCode(6); // 将验证码存入缓存 emailCodeCache.put(email + ":" + type, code); // 验证码类型对应的邮件主题 String subject; switch (type) { case "login": subject = "登录验证码"; break; case "register": subject = "注册验证码"; break; case "reset": subject = "重置密码验证码"; break; default: subject = "验证码"; } // 使用邮件服务发送验证码 return emailService.sendVerificationEmail(email, subject, code, type); } catch (Exception e) { logger.error("发送邮箱验证码失败", e); return false; } } @Override public String getEmailCode(String email, String type) { return emailCodeCache.get(email + ":" + type); } @Override public void removeEmailCode(String email, String type) { emailCodeCache.remove(email + ":" + type); }[scode type="blue"]在生产环境中，应该使用Redis等分布式缓存来存储验证码，并设置过期时间。[/scode]3.4 控制器实现接下来，实现处理注册和登录请求的控制器方法：发送邮箱验证码@PostMapping("/send/email") public ResponseEntity<?> sendEmailCode( @RequestParam @NotBlank @Email String email, @RequestParam @NotBlank String type) { logger.info("发送邮箱验证码，邮箱: {}, 类型: {}", email, type); boolean result = userService.sendEmailCode(email, type); if (result) { return ResponseEntity.ok().body(Map.of("code", 200, "message", "验证码发送成功")); } else { return ResponseEntity.badRequest().body(Map.of("code", 400, "message", "验证码发送失败")); } }邮箱+验证码注册@PostMapping("/register") public ResponseEntity<?> register( @RequestBody(required = false) Map<String, String> registerRequest, @RequestParam(required = false) String email, @RequestParam(required = false) String password, @RequestParam(required = false) String code, @RequestParam(required = false) String nickname) { // 优先使用URL参数，如果没有则使用请求体 if (registerRequest != null) { if (email == null) email = registerRequest.get("email"); if (password == null) password = registerRequest.get("password"); if (code == null) code = registerRequest.get("code"); if (nickname == null) nickname = registerRequest.get("nickname"); } if (email == null || password == null || code == null) { return ResponseEntity.badRequest().body(Map.of("code", 400, "message", "邮箱、密码和验证码不能为空")); } logger.info("用户一步式注册，邮箱: {}", email); // 验证验证码 String cachedCode = userService.getEmailCode(email, "register"); if (cachedCode == null || !cachedCode.equals(code)) { return ResponseEntity.status(400).body(Map.of("code", 400, "message", "验证码错误或已过期")); } // 创建用户 User user = new User(); user.setEmail(email); user.setUsername(email); // 使用邮箱作为用户名 user.setPassword(password); // 未加密的密码，在service层会加密 user.setNickname(nickname != null ? nickname : "用户" + email.substring(0, email.indexOf("@"))); // 调用服务层方法创建用户 Map<String, Object> registerResult = userService.register(user, true); int registerCode = (int) registerResult.get("code"); // 验证码验证成功后，删除缓存中的验证码 if (registerCode == 200) { userService.removeEmailCode(email, "register"); } return ResponseEntity.status(registerCode == 200 ? 200 : 400).body(registerResult); }邮箱+验证码登录@PostMapping("/login/email") public ResponseEntity<?> loginByEmail( @RequestParam @NotBlank @Email String email, @RequestParam @NotBlank String code) { logger.info("邮箱验证码登录，邮箱: {}", email); Map<String, Object> result = userService.emailLogin(email, code); int code2 = (int) result.get("code"); return ResponseEntity.status(code2 == 200 ? 200 : 400).body(result); }3.5 服务层实现在服务层，实现用户注册和登录的业务逻辑：邮箱验证码登录@Override public Map<String, Object> emailLogin(String email, String code) { Map<String, Object> result = new HashMap<>(); // 验证验证码 String cachedCode = getEmailCode(email, "login"); if (cachedCode == null || !cachedCode.equals(code)) { result.put("code", 400); result.put("message", "验证码错误或已过期"); return result; } // 清除验证码缓存 removeEmailCode(email, "login"); // 根据邮箱查询用户 User user = getOneOrNull(QueryWrapper.create().where("email = ?", email)); if (user == null) { result.put("code", 400); result.put("message", "用户不存在，请先注册"); return result; } // 检查用户状态 if (user.getStatus() != 1) { result.put("code", 403); result.put("message", "账号已被禁用"); return result; } // 更新登录信息 user.setLastLoginTime(LocalDateTime.now()); updateById(user); // 生成token String token = jwtUtil.generateToken(user.getUsername()); // 准备返回数据 Map<String, Object> userData = new HashMap<>(); userData.put("id", user.getId()); userData.put("username", user.getUsername()); userData.put("nickname", user.getNickname()); userData.put("avatar", user.getAvatar()); userData.put("email", user.getEmail()); result.put("code", 200); result.put("message", "登录成功"); result.put("data", new HashMap<String, Object>() {{ put("token", token); put("user", userData); }}); return result; }3.6 邮件模板创建verification-code.html邮件模板（放在resources/templates/email目录下）： 验证码 宠物商店 - 验证码 尊敬的用户： 您正在进行操作，您的验证码为： 123456 验证码有效期为5分钟，请勿将验证码泄露给他人。 如非本人操作，请忽略此邮件。 宠物商店团队 发送时间：2023-01-01 12:00:00 4. 前端实现在前端，我们需要创建对应的API调用和页面：4.1 API层// 用户相关接口 export const userApi = { // 邮箱验证码注册 register: (data) => { return request({ url: '/user/register', method: 'post', data }) }, // 邮箱验证码登录 emailLogin: (data) => { return request({ url: '/user/login/email', method: 'post', data }) }, // 发送邮箱验证码 sendEmailCode: (email, type) => { return request({ url: '/user/send/email', method: 'post', params: { email, type // login-登录, register-注册, reset-重置密码 } }) } }4.2 注册页面 邮箱 验证码 0" @click="sendCode"> {{ countdown > 0 ? `${countdown}s后重发` : '获取验证码' }} 密码 昵称 注册 4.3 登录页面 密码登录 验证码登录 邮箱 密码 登录 邮箱 验证码 0" @click="sendCode"> {{ countdown > 0 ? `${countdown}s后重发` : '获取验证码' }} 登录 5. 最佳实践在实现邮箱验证码注册登录功能时，有以下最佳实践：验证码安全性：设置验证码过期时间（通常5-10分钟）限制验证码尝试次数，防止暴力破解使用Redis存储验证码，方便设置过期时间并在分布式环境中共享邮件模板设计：使用响应式设计，适配各种设备邮件内容简洁明了，突出验证码提供品牌识别元素（Logo、颜色等）性能优化：邮件发送应异步处理，不阻塞主线程考虑使用消息队列处理邮件发送任务邮件服务配置连接池，优化性能安全防护：防止短时间内重复发送验证码验证邮箱格式实现IP限制，防止恶意请求6. 总结邮箱验证码注册登录功能是现代Web应用的标准功能，通过本文的实现，我们可以为用户提供一种安全、便捷的注册和登录方式。在实际应用中，可以根据具体需求扩展和优化这些功能，例如：添加邮箱绑定和解绑功能实现邮箱地址变更功能支持通过邮箱重置密码结合第三方登录（如QQ、微信等）希望本文对你实现邮箱验证码注册登录功能有所帮助！
什么是 TableDef？TableDef 是 MyBatis-Flex 框架提供的一个强大特性，用于定义数据库表的元数据。它提供了一种类型安全的方式来引用数据库表和列，避免了直接使用字符串字面量可能带来的问题。为什么需要 TableDef？在传统的 MyBatis 开发中，我们经常需要写这样的代码：QueryWrapper query = QueryWrapper.create() .where("user_id = ?", userId) .and("status = ?", status);这种方式存在以下问题：容易拼写错误重构困难没有代码提示类型不安全TableDef 的优势使用 TableDef 后，代码变成了这样：QueryWrapper query = QueryWrapper.create() .where(USER.USER_ID.eq(userId)) .and(USER.STATUS.eq(status));主要优势：类型安全，编译时就能发现错误代码提示，IDE 可以自动补全重构方便，如果表名或字段名改变，只需要修改一处代码更清晰，易于维护实际项目中的应用让我们通过宠物商店项目中的实际例子来看看 TableDef 的使用：1. 定义 TableDef首先，我们需要为每个表创建一个 TableDef 类：public class PetTableDef extends TableDef { public static final PetTableDef PET = new PetTableDef(); public final QueryColumn ID = new QueryColumn(this, "id"); public final QueryColumn USER_ID = new QueryColumn(this, "user_id"); public final QueryColumn NAME = new QueryColumn(this, "name"); public final QueryColumn BREED = new QueryColumn(this, "breed"); public final QueryColumn AVATAR = new QueryColumn(this, "avatar"); public PetTableDef() { super("pet", "p"); } }2. 在查询中使用在 Service 层中使用 TableDef 进行查询：@Service public class PetServiceImpl implements PetService { @Autowired private PetMapper petMapper; @Override public List<Pet> getPetsByUserId(Integer userId) { return petMapper.selectListByQuery( QueryWrapper.create() .where(PET.USER_ID.eq(userId)) ); } }3. 复杂查询示例使用 TableDef 可以轻松构建复杂的查询：// 多表联查 QueryWrapper query = QueryWrapper.create() .select( PET.NAME, PET.BREED, USER.NICKNAME.as("ownerName") ) .from(PET) .leftJoin(USER).on(PET.USER_ID.eq(USER.ID)) .where(PET.USER_ID.eq(userId)); // 条件查询 QueryWrapper query = QueryWrapper.create() .where(PET.NAME.like(keyword)) .and(PET.BREED.eq(breed)) .orderBy(PET.ID.desc()); // 分页查询 Page<Pet> page = petMapper.paginate( new Page<>(pageNum, pageSize), QueryWrapper.create() .where(PET.USER_ID.eq(userId)) );4. 动态 SQLTableDef 也支持动态 SQL 的构建：QueryWrapper query = QueryWrapper.create() .where(PET.USER_ID.eq(userId)); if (StringUtils.isNotBlank(keyword)) { query.and(PET.NAME.like(keyword)); } if (StringUtils.isNotBlank(breed)) { query.and(PET.BREED.eq(breed)); }最佳实践为每个表创建对应的 TableDef 类使用有意义的表别名（如示例中的 "p" 代表 pet）将 TableDef 实例定义为静态常量在查询中始终使用 TableDef 而不是字符串合理组织查询条件，保持代码清晰总结TableDef 是 MyBatis-Flex 框架中一个非常实用的特性，它能够：提高代码的类型安全性提供更好的开发体验减少错误提高代码可维护性通过合理使用 TableDef，我们可以写出更加健壮和易于维护的数据库操作代码。
智慧零工平台后端开发实战：SpringBoot + MyBatis-Flex 现代化架构
本文将详细分享我使用Spring Boot 3.3.5 + MyBatis-Flex + SQLite构建零工平台后端系统的完整经验，包含架构设计、技术选型、核心功能实现和生产部署。项目背景随着零工经济的快速发展，我决定开发一个现代化的零工服务平台。后端系统需要支持工作发布、申请匹配、用户管理、支付结算等复杂业务场景，同时要保证高性能、高可用和易扩展。项目概览项目名称: 智慧零工平台后端系统技术栈: Spring Boot 3.3.5 + Java 21 + MyBatis-Flex + SQLite项目地址: GitHub - Gigplatform_backendAPI文档: 24个业务模块，100+接口部署方式: 宝塔面板 + PM2 + Nginx技术选型分析为什么选择Spring Boot 3？Java 21支持: 享受最新的语言特性和性能优化原生镜像: 支持GraalVM原生编译，启动更快响应式编程: 内置WebFlux支持可观测性: 增强的监控和链路追踪安全性: 升级的Spring SecurityMyBatis-Flex vs MyBatis-Plus经过对比，我选择了MyBatis-Flex：// MyBatis-Flex 优势示例 // 1. 类型安全的查询 List<Job> jobs = queryWrapper .select(JOB.ALL_COLUMNS) .from(JOB) .where(JOB.STATUS.eq("ACTIVE")) .and(JOB.SALARY.ge(5000)) .orderBy(JOB.CREATE_TIME.desc()) .limit(10) .list(); // 2. 智能字段映射 @Table("t_job") public class Job { @Id(keyType = KeyType.Auto) private Long id; @Column("job_title") private String title; // 自动处理驼峰命名 private String companyName; // -> company_name }SQLite的选择对于中小型项目，SQLite具有独特优势：零配置: 无需独立数据库服务器高性能: 本地文件访问，减少网络开销事务支持: 完整的ACID特性跨平台: 易于开发和部署成本低: 降低运维复杂度架构设计整体架构┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ 前端应用 │ │ API网关 │ │ 负载均衡 │ │ (uni-app) │◄───│ (Nginx) │◄───│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ ▼ ┌─────────────────────────┐ │ Spring Boot │ │ (业务逻辑层) │ └─────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 认证授权 │ │ 业务服务 │ │ 数据访问 │ │ (JWT) │ │ (Service) │ │ (MyBatis) │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ ┌─────────────────┐ │ SQLite 数据库 │ └─────────────────┘项目模块结构com.example.backend/ ├── controller/ # 控制器层 │ ├── AuthController # 认证授权 │ ├── JobsController # 工作管理 │ ├── UsersController # 用户管理 │ └── ... # 其他业务控制器 ├── service/ # 业务逻辑层 │ ├── impl/ # 实现类 │ └── interfaces/ # 接口定义 ├── mapper/ # 数据访问层 ├── entity/ # 实体类 ├── config/ # 配置类 ├── common/ # 公共组件 │ ├── Result.java # 统一返回结果 │ ├── JwtUtil.java # JWT工具 │ └── JwtFilter.java # JWT过滤器 └── BackendApplication.java # 启动类核心功能实现1. JWT认证体系@Component public class JwtUtil { @Value("${jwt.secret:default-secret}") private String secret; @Value("${jwt.expiration:86400}") private Long expiration; public String generateToken(String username) { Date expirationDate = new Date(System.currentTimeMillis() + expiration * 1000); return Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(expirationDate) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } public Claims getClaimsFromToken(String token) { try { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } catch (Exception e) { return null; } } public boolean validateToken(String token) { try { Claims claims = getClaimsFromToken(token); return claims != null && !claims.getExpiration().before(new Date()); } catch (Exception e) { return false; } } } // JWT过滤器 @Component public class JwtFilter implements Filter { @Autowired private JwtUtil jwtUtil; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // 跳过认证的路径 String path = httpRequest.getRequestURI(); if (isPublicPath(path)) { chain.doFilter(request, response); return; } String token = extractToken(httpRequest); if (token == null || !jwtUtil.validateToken(token)) { httpResponse.setStatus(HttpStatus.UNAUTHORIZED.value()); httpResponse.getWriter().write( JSON.toJSONString(Result.error(102, "未登录，请先登录")) ); return; } // 设置用户信息到上下文 Claims claims = jwtUtil.getClaimsFromToken(token); LoginUser loginUser = new LoginUser(); loginUser.setUsername(claims.getSubject()); UserContext.setCurrentUser(loginUser); chain.doFilter(request, response); } }2. 统一结果封装@Data @AllArgsConstructor @NoArgsConstructor public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success() { return new Result<>(0, "调用成功", null); } public static <T> Result<T> success(T data) { return new Result<>(0, "调用成功", data); } public static <T> Result<T> error(Integer code, String message) { return new Result<>(code, message, null); } public static <T> Result<T> error(String message) { return new Result<>(200, message, null); } } // 全局异常处理 @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public Result<String> handleException(Exception e) { log.error("系统异常", e); return Result.error("系统繁忙，请稍后重试"); } @ExceptionHandler(MethodArgumentNotValidException.class) public Result<String> handleValidationException(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldError().getDefaultMessage(); return Result.error(220, message); } @ExceptionHandler(IllegalArgumentException.class) public Result<String> handleIllegalArgumentException(IllegalArgumentException e) { return Result.error(220, e.getMessage()); } }3. 工作管理核心业务@RestController @RequestMapping("/api/jobs") @Slf4j public class JobsController { @Autowired private JobsService jobsService; // 获取工作列表（支持多条件筛选） @GetMapping("/latest/list") public Result<Map<String, Object>> getJobList( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String keyword, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String location, @RequestParam(required = false) String salaryRange) { try { Page<Job> jobPage = jobsService.getJobList( page, size, keyword, categoryId, location, salaryRange ); Map<String, Object> result = new HashMap<>(); result.put("list", jobPage.getRecords()); result.put("total", jobPage.getTotal()); result.put("pages", jobPage.getPages()); result.put("current", jobPage.getCurrent()); return Result.success(result); } catch (Exception e) { log.error("获取工作列表失败", e); return Result.error("获取工作列表失败"); } } // 申请工作 @PostMapping("/apply/{jobId}") public Result<String> applyForJob(@PathVariable Long jobId) { try { LoginUser currentUser = UserContext.getCurrentUser(); // 获取工人资料 WorkerProfile profile = workerProfilesService.getByUserId(currentUser.getUserId()); if (profile == null) { return Result.error(220, "请先完善工人资料"); } // 检查是否已申请 boolean hasApplied = jobApplicationsService.hasApplied(jobId, profile.getId()); if (hasApplied) { return Result.error(220, "您已申请过该工作"); } // 创建申请记录 JobApplication application = new JobApplication(); application.setJobId(jobId); application.setWorkerId(profile.getId()); application.setStatus("PENDING"); application.setApplyTime(new Date()); jobApplicationsService.save(application); // 更新工作申请数量 jobsService.incrementApplyCount(jobId); return Result.success("申请成功"); } catch (Exception e) { log.error("申请工作失败, jobId: {}", jobId, e); return Result.error("申请失败，请稍后重试"); } } } // 业务服务实现 @Service @Transactional public class JobsServiceImpl implements JobsService { @Autowired private JobsMapper jobsMapper; @Override public Page<Job> getJobList(int page, int size, String keyword, Long categoryId, String location, String salaryRange) { QueryWrapper query = QueryWrapper.create() .select(JOB.ALL_COLUMNS) .from(JOB) .where(JOB.STATUS.eq("ACTIVE")); // 关键词搜索 if (StringUtils.hasText(keyword)) { query.and(JOB.TITLE.like(keyword) .or(JOB.DESCRIPTION.like(keyword)) .or(JOB.COMPANY_NAME.like(keyword))); } // 分类筛选 if (categoryId != null) { query.and(JOB.CATEGORY_ID.eq(categoryId)); } // 地区筛选 if (StringUtils.hasText(location)) { query.and(JOB.LOCATION.like(location)); } // 薪资范围筛选 if (StringUtils.hasText(salaryRange)) { applySalaryFilter(query, salaryRange); } query.orderBy(JOB.CREATE_TIME.desc()); return jobsMapper.paginate(page, size, query); } private void applySalaryFilter(QueryWrapper query, String salaryRange) { switch (salaryRange) { case "0-3000": query.and(JOB.SALARY_MIN.le(3000)); break; case "3000-5000": query.and(JOB.SALARY_MIN.ge(3000).and(JOB.SALARY_MAX.le(5000))); break; case "5000-8000": query.and(JOB.SALARY_MIN.ge(5000).and(JOB.SALARY_MAX.le(8000))); break; case "8000+": query.and(JOB.SALARY_MIN.ge(8000)); break; } } }4. 文件上传服务@RestController @RequestMapping("/api/upload") @Slf4j public class UploadController { @Value("${file.upload.dir:uploads}") private String uploadDir; @Value("${file.upload.max-size:10485760}") // 10MB private long maxFileSize; @PostMapping("/image") public Result<Map<String, String>> uploadImage(@RequestParam("file") MultipartFile file) { try { // 验证文件 if (file.isEmpty()) { return Result.error(220, "文件不能为空"); } if (file.getSize() > maxFileSize) { return Result.error(220, "文件大小不能超过10MB"); } // 验证文件类型 String contentType = file.getContentType(); if (!isValidImageType(contentType)) { return Result.error(220, "只支持 jpg、png、gif 格式的图片"); } // 生成文件名 String originalFilename = file.getOriginalFilename(); String extension = getFileExtension(originalFilename); String filename = generateFileName() + "." + extension; // 创建目录 Path uploadPath = Paths.get(uploadDir); if (!Files.exists(uploadPath)) { Files.createDirectories(uploadPath); } // 保存文件 Path filePath = uploadPath.resolve(filename); Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); // 返回访问URL String fileUrl = "/uploads/" + filename; Map<String, String> result = new HashMap<>(); result.put("url", fileUrl); result.put("filename", filename); return Result.success(result); } catch (Exception e) { log.error("文件上传失败", e); return Result.error("文件上传失败"); } } private boolean isValidImageType(String contentType) { return contentType != null && ( contentType.equals("image/jpeg") || contentType.equals("image/png") || contentType.equals("image/gif") ); } private String generateFileName() { return System.currentTimeMillis() + "_" + ThreadLocalRandom.current().nextInt(1000, 9999); } }数据库设计核心表结构-- 用户表 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, email VARCHAR(100), phone VARCHAR(20) NOT NULL, user_type VARCHAR(20) NOT NULL, -- 'employer' 或 'employee' avatar VARCHAR(255), status VARCHAR(20) DEFAULT 'ACTIVE', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 工作表 CREATE TABLE jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(200) NOT NULL, description TEXT, category_id INTEGER, employer_id INTEGER NOT NULL, company_name VARCHAR(100), location VARCHAR(100), salary_min DECIMAL(10,2), salary_max DECIMAL(10,2), salary_type VARCHAR(20), -- 'hourly', 'daily', 'monthly' requirements TEXT, contact_info VARCHAR(255), status VARCHAR(20) DEFAULT 'ACTIVE', apply_count INTEGER DEFAULT 0, view_count INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (employer_id) REFERENCES users(id), FOREIGN KEY (category_id) REFERENCES categories(id) ); -- 工作申请表 CREATE TABLE job_applications ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER NOT NULL, worker_id INTEGER NOT NULL, status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, response_time DATETIME, employer_comment TEXT, FOREIGN KEY (job_id) REFERENCES jobs(id), FOREIGN KEY (worker_id) REFERENCES worker_profiles(id), UNIQUE(job_id, worker_id) ); -- 工人资料表 CREATE TABLE worker_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, real_name VARCHAR(50), age INTEGER, gender VARCHAR(10), education VARCHAR(50), experience_years INTEGER, skills TEXT, -- JSON格式存储技能列表 resume TEXT, certification VARCHAR(255), location VARCHAR(100), available_time VARCHAR(255), hourly_rate DECIMAL(8,2), rating DECIMAL(3,2) DEFAULT 0, completed_jobs INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) );索引优化-- 性能优化索引 CREATE INDEX idx_jobs_status_created ON jobs(status, created_at); CREATE INDEX idx_jobs_category_location ON jobs(category_id, location); CREATE INDEX idx_jobs_employer ON jobs(employer_id); CREATE INDEX idx_applications_job_worker ON job_applications(job_id, worker_id); CREATE INDEX idx_applications_status ON job_applications(status); CREATE INDEX idx_worker_profiles_user ON worker_profiles(user_id); CREATE INDEX idx_worker_profiles_location ON worker_profiles(location);部署实战1. 宝塔面板部署创建部署脚本：#!/bin/bash # deploy.sh - 自动化部署脚本 echo "=== 智慧零工平台后端部署脚本 ===" # 1. 环境检查 check_environment() { echo "检查部署环境..." # 检查Java版本 if ! java -version 2>&1 | grep "21\." > /dev/null; then echo "错误: 需要Java 21" exit 1 fi # 检查PM2 if ! command -v pm2 &> /dev/null; then echo "安装PM2..." npm install -g pm2 fi echo "环境检查完成" } # 2. 备份数据 backup_data() { echo "备份数据库..." if [ -f "/www/wwwroot/smartgigplatform.db" ]; then cp /www/wwwroot/smartgigplatform.db \ /www/wwwroot/backup/smartgigplatform_$(date +%Y%m%d_%H%M%S).db echo "数据库备份完成" fi } # 3. 部署应用 deploy_app() { echo "部署应用..." # 停止旧版本 pm2 stop smart-gig-platform 2>/dev/null || true # 部署新版本 cp smart-gig-platformbackend-0.0.1-SNAPSHOT.jar /www/wwwroot/ cp application-prod.properties /www/wwwroot/ cp ecosystem.config.js /www/wwwroot/ # 设置权限 chown www:www /www/wwwroot/smart-gig-platformbackend-0.0.1-SNAPSHOT.jar chmod 755 /www/wwwroot/smart-gig-platformbackend-0.0.1-SNAPSHOT.jar # 启动应用 cd /www/wwwroot pm2 start ecosystem.config.js echo "应用部署完成" } # 4. 健康检查 health_check() { echo "进行健康检查..." sleep 10 # 检查进程状态 if pm2 show smart-gig-platform | grep "online" > /dev/null; then echo "✅ 应用启动成功" else echo "❌ 应用启动失败" pm2 logs smart-gig-platform --lines 20 exit 1 fi # 检查API接口 if curl -s http://localhost:8081/actuator/health | grep "UP" > /dev/null; then echo "✅ API健康检查通过" else echo "❌ API健康检查失败" exit 1 fi } # 执行部署 main() { check_environment backup_data deploy_app health_check echo "=== 部署完成 ===" echo "应用状态: http://localhost:8081/actuator/health" echo "日志查看: pm2 logs smart-gig-platform" } main "$@"2. PM2进程管理// ecosystem.config.js module.exports = { apps: [{ name: 'smart-gig-platform', script: '/www/server/java/jdk-21.0.2/bin/java', args: [ '-server', '-Xms512m', '-Xmx1024m', '-Dspring.profiles.active=prod', '-Dfile.encoding=UTF-8', '-jar', 'smart-gig-platformbackend-0.0.1-SNAPSHOT.jar' ], cwd: '/www/wwwroot', instances: 1, autorestart: true, watch: false, max_memory_restart: '1G', env: { NODE_ENV: 'production' }, log_file: '/www/wwwroot/logs/app.log', out_file: '/www/wwwroot/logs/app-out.log', error_file: '/www/wwwroot/logs/app-error.log', log_date_format: 'YYYY-MM-DD HH:mm:ss', merge_logs: true }] }3. Nginx配置# lgpt.ybyq.wang.conf server { listen 80; server_name lgpt.ybyq.wang; root /www/wwwroot/lgpt.ybyq.wang/h5; index index.html; # API代理 location /api/ { proxy_pass http://127.0.0.1:8081/api/; 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; # 超时设置 proxy_connect_timeout 30s; proxy_send_timeout 30s; proxy_read_timeout 30s; # 缓冲设置 proxy_buffering on; proxy_buffer_size 8k; proxy_buffers 8 8k; } # 文件上传代理 location /uploads/ { alias /www/wwwroot/uploads/; expires 1y; add_header Cache-Control "public, immutable"; } # SPA路由支持 location / { try_files $uri $uri/ /index.html; } # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; } # 安全头 add_header X-Frame-Options "SAMEORIGIN"; add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection "1; mode=block"; # Gzip压缩 gzip on; gzip_vary on; gzip_min_length 1k; gzip_comp_level 6; gzip_types application/json application/javascript text/css text/javascript text/plain; # 访问日志 access_log /www/wwwlogs/lgpt.ybyq.wang.log; error_log /www/wwwlogs/lgpt.ybyq.wang.error.log; }监控与运维1. 应用监控// 自定义健康检查 @Component public class CustomHealthIndicator implements HealthIndicator { @Autowired private DataSource dataSource; @Override public Health health() { try { // 检查数据库连接 try (Connection connection = dataSource.getConnection()) { boolean isValid = connection.isValid(5); if (isValid) { return Health.up() .withDetail("database", "可用") .withDetail("connection_pool", getConnectionPoolInfo()) .build(); } } } catch (Exception e) { return Health.down() .withDetail("database", "不可用") .withDetail("error", e.getMessage()) .build(); } return Health.down().build(); } private Map<String, Object> getConnectionPoolInfo() { Map<String, Object> info = new HashMap<>(); info.put("active", "查询活跃连接数"); info.put("idle", "查询空闲连接数"); return info; } } // 自定义指标 @Component public class BusinessMetrics { private final MeterRegistry meterRegistry; private final Counter jobCreatedCounter; private final Counter jobAppliedCounter; public BusinessMetrics(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; this.jobCreatedCounter = Counter.builder("jobs.created") .description("创建的工作数量") .register(meterRegistry); this.jobAppliedCounter = Counter.builder("jobs.applied") .description("申请的工作数量") .register(meterRegistry); } public void incrementJobCreated() { jobCreatedCounter.increment(); } public void incrementJobApplied() { jobAppliedCounter.increment(); } }2. 日志管理# application-prod.properties logging: level: com.example.backend: INFO org.springframework.web: WARN org.springframework.security: WARN pattern: console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n" file: name: /www/wwwroot/logs/application.log max-size: 100MB max-history: 303. 备份策略#!/bin/bash # backup.sh - 数据备份脚本 BACKUP_DIR="/www/wwwroot/backup" DB_FILE="/www/wwwroot/smartgigplatform.db" UPLOAD_DIR="/www/wwwroot/uploads" # 创建备份目录 mkdir -p $BACKUP_DIR # 数据库备份 backup_database() { local timestamp=$(date +%Y%m%d_%H%M%S) local backup_file="$BACKUP_DIR/smartgigplatform_$timestamp.db" echo "备份数据库..." cp $DB_FILE $backup_file gzip $backup_file echo "数据库备份完成: $backup_file.gz" } # 文件备份 backup_uploads() { local timestamp=$(date +%Y%m%d_%H%M%S) local backup_file="$BACKUP_DIR/uploads_$timestamp.tar.gz" echo "备份上传文件..." tar -czf $backup_file -C /www/wwwroot uploads/ echo "文件备份完成: $backup_file" } # 清理旧备份（保留7天） cleanup_old_backups() { echo "清理旧备份文件..." find $BACKUP_DIR -name "*.gz" -mtime +7 -delete echo "清理完成" } # 执行备份 main() { echo "=== 开始备份 $(date) ===" backup_database backup_uploads cleanup_old_backups echo "=== 备份完成 $(date) ===" } main性能优化1. 数据库查询优化// 使用MyBatis-Flex的批量操作 @Service public class JobsBatchService { @Autowired private JobsMapper jobsMapper; // 批量插入工作 @Transactional public void batchCreateJobs(List<Job> jobs) { // 使用MyBatis-Flex的批量插入 jobsMapper.insertBatch(jobs); } // 批量更新状态 @Transactional public void batchUpdateStatus(List<Long> jobIds, String status) { UpdateWrapper updateWrapper = UpdateWrapper.create() .set(JOB.STATUS, status) .set(JOB.UPDATED_AT, new Date()) .where(JOB.ID.in(jobIds)); jobsMapper.update(null, updateWrapper); } // 分页查询优化 public Page<JobVO> getJobsWithDetails(int page, int size) { // 使用关联查询减少N+1问题 QueryWrapper query = QueryWrapper.create() .select( JOB.ALL_COLUMNS, CATEGORY.NAME.as("categoryName"), USER.USERNAME.as("employerName") ) .from(JOB) .leftJoin(CATEGORY).on(JOB.CATEGORY_ID.eq(CATEGORY.ID)) .leftJoin(USER).on(JOB.EMPLOYER_ID.eq(USER.ID)) .where(JOB.STATUS.eq("ACTIVE")) .orderBy(JOB.CREATE_TIME.desc()); return jobsMapper.paginate(page, size, query); } }2. 缓存策略// Redis缓存配置 @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { RedisCacheManager.Builder builder = RedisCacheManager .RedisCacheManagerBuilder .fromConnectionFactory(redisConnectionFactory()) .cacheDefaults(cacheConfiguration()); return builder.build(); } private RedisCacheConfiguration cacheConfiguration() { return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())); } } // 使用缓存 @Service public class JobsCacheService { @Cacheable(value = "jobs", key = "#jobId") public Job getJobById(Long jobId) { return jobsMapper.selectOneById(jobId); } @Cacheable(value = "job-list", key = "#page + '_' + #size + '_' + #keyword") public Page<Job> getJobList(int page, int size, String keyword) { // 查询逻辑 return jobsMapper.paginate(page, size, query); } @CacheEvict(value = "jobs", key = "#job.id") public void updateJob(Job job) { jobsMapper.update(job); } @CacheEvict(value = {"jobs", "job-list"}, allEntries = true) public void clearAllCache() { // 清空所有缓存 } }开发经验总结最佳实践分层架构: 严格按照Controller-Service-Mapper分层异常处理: 统一异常处理和错误码管理参数校验: 使用@Valid注解进行参数验证事务管理: 合理使用@Transactional注解日志记录: 关键操作添加日志记录API文档: 使用Swagger生成API文档踩过的坑SQLite并发问题: 使用连接池解决并发访问JWT安全性: 定期更换密钥，设置合理过期时间文件上传: 限制文件大小和类型，防止恶意上传数据库备份: 定期备份，避免数据丢失性能优化: 合理使用索引，避免N+1查询问题结语通过这次Spring Boot 3 + MyBatis-Flex的实践，我深入体验了现代Java开发的便利性。项目从零到生产部署，积累了丰富的实战经验。关键收获包括：现代化技术栈能显著提升开发效率合理的架构设计是项目成功的基石性能优化需要在开发过程中持续关注监控运维对生产环境稳定性至关重要希望这篇文章能为Java后端开发者提供一些参考和启发。项目链接:💻 GitHub仓库: https://github.com/BXCQ/Gigplatform_backend🌐 在线API: https://lgpt.ybyq.wang/api/📚 技术博客: https://blog.ybyq.wang/欢迎交流讨论！邮箱: 676567473@qq.com