-
为什么需要带宽限速带宽限速与常见的 API 限流不同:限流控制的是请求次数(如每分钟100次),而限速控制的是网络带宽(如每秒200KB)。在实际应用中,带宽限速有着重要的业务价值:场景一:文件下载服务对于网盘或资源分发平台,免费用户限制在 200KB/s,VIP 用户提升到 2MB/s,既能保障基础体验,又能激励付费转化。场景二:视频流媒体不同清晰度对应不同带宽限制(480P 用 500KB/s,1080P 用 3MB/s),避免高码率视频占用过多服务器带宽。场景三:API 接口保护大数据量接口(如导出报表)如果没有带宽控制,单个请求可能占满整个出口带宽,影响其他用户访问。核心原理:令牌桶算法令牌桶算法是流量控制的经典方案,其思想非常直观:想象一个桶,系统以固定速率向桶中放入令牌,请求数据时必须从桶中取走对应数量的令牌。核心参数解析:桶容量(Capacity): 决定能承受多大突发流量。容量为 200KB 时,即使桶已满,最多也只能连续发送 200KB 数据,之后必须等待令牌补充。填充速率(Refill Rate): 决定长期平均传输速度。每秒补充 200KB 令牌,意味着平均速度就是 200KB/s。分块大小(Chunk Size): 影响流量平滑度。将 8KB 数据拆分成 2KB×4 次写入,每次写入之间进行令牌检查,比一次性写入 8KB 更加平滑。算法流程:发送数据前:计算距离上次补充的时间差根据 时间差 × 填充速率 计算新增令牌数更新桶中令牌数(不超过容量上限)发送数据时:检查令牌是否足够足够:直接扣除令牌,发送数据不足:计算 (缺少令牌数 / 填充速率) 得到等待时间,精确等待后发送技术设计整体流程本方案采用拦截器模式,在请求处理的早期阶段完成限速组件的初始化,通过请求属性传递包装后的响应对象。12345678910111213141516171819202122请求流程:┌─────────────────────────────────────────────────────────────────────┐│ 1. DispatcherServlet 分发请求 │└─────────────────────────────────────────────────────────────────────┘ ↓┌─────────────────────────────────────────────────────────────────────┐│ 2. BandwidthLimitInterceptor.preHandle() ││ - 解析 @BandwidthLimit 注解 ││ - 从 BandwidthLimitManager 获取共享 TokenBucket ││ - 创建 BandwidthLimitResponseWrapper 并存入 request attribute │└─────────────────────────────────────────────────────────────────────┘ ↓┌─────────────────────────────────────────────────────────────────────┐│ 3. Controller 处理请求 ││ - 通过 BandwidthLimitHelper.getLimitedResponse() 获取包装后的响应 ││ - 向响应流写入数据(自动触发限速) │└─────────────────────────────────────────────────────────────────────┘ ↓┌─────────────────────────────────────────────────────────────────────┐│ 4. BandwidthLimitInterceptor.afterCompletion() ││ - 清理资源,关闭流 │└─────────────────────────────────────────────────────────────────────┘为什么选择 HandlerInterceptor在 Spring Boot 中实现请求处理,有两种常见方式:Filter 和 HandlerInterceptor。本方案选择 HandlerInterceptor 的关键原因是:注解解析需要 HandlerMethod 对象。Filter 在 DispatcherServlet 之前执行,此时还没有确定具体的处理方法,无法获取方法上的 @BandwidthLimit 注解。而 HandlerInterceptor 在处理器确定后执行,可以通过 HandlerMethod 精确获取方法级别和类级别的注解信息。核心组件职责组件职责@BandwidthLimit声明式注解,配置限速参数BandwidthLimitInterceptor拦截请求,解析注解,创建响应包装器BandwidthLimitManager管理多维度限速桶(全局/API/用户/IP)BandwidthLimitResponseWrapper包装 HttpServletResponse,替换 OutputStreamRateLimitedOutputStream实现限速逻辑,包装 TokenBucketTokenBucket令牌桶算法实现BandwidthLimitHelper从请求属性中获取包装后的响应对象多维度限速实现本方案支持四种限速维度,满足不同业务场景需求:全局限速(GLOBAL)所有请求共享同一个限速桶,适合保护服务器整体出口带宽。例如设置 10MB/s 全局限制,即使有100个并发下载,总带宽也不会超过 10MB/s。123456@BandwidthLimit(value = 200, unit = BandwidthUnit.KB, type = LimitType.GLOBAL)@GetMapping("/download/global")public void downloadGlobal(HttpServletResponse response) throws IOException { HttpServletResponse limitedResponse = BandwidthLimitHelper.getLimitedResponse(request, response); // 写入数据...}API 维度限速(API)每个接口路径独立限速,不同接口的流量互不影响。/api/file/download 限制 500KB/s,/api/video/stream 限制 2MB/s,两个接口可以同时达到各自的速度上限。1234567891011@BandwidthLimit(value = 500, unit = BandwidthUnit.KB, type = LimitType.API)@GetMapping("/download/file")public void downloadFile(HttpServletResponse response) throws IOException { // 文件下载逻辑} @BandwidthLimit(value = 2048, unit = BandwidthUnit.KB, type = LimitType.API)@GetMapping("/stream/video")public void streamVideo(HttpServletResponse response) throws IOException { // 视频流逻辑}用户维度限速(USER)根据用户标识(如请求头 X-User-Id)进行限速,每个用户独立计算带宽。配合 free 和 vip 参数,可实现差异化服务:1234567@BandwidthLimit(value = 200, unit = BandwidthUnit.KB, type = LimitType.USER, free = 200, vip = 2048)@GetMapping("/download/user")public void downloadByUser(@RequestHeader("X-User-Type") String userType, HttpServletResponse response) throws IOException { // 根据请求头 X-User-Type 自动应用 200KB/s 或 2MB/s 限速}IP 维度限速(IP)根据客户端 IP 地址限速,防止单个 IP 占用过多带宽。支持代理环境下的 IP 获取(X-Forwarded-For、X-Real-IP)。12345@BandwidthLimit(value = 300, unit = BandwidthUnit.KB, type = LimitType.IP)@GetMapping("/download/ip")public void downloadByIp(HttpServletResponse response) throws IOException { // 每个独立 IP 限制 300KB/s}关键代码实现1. 令牌桶核心算法TokenBucket 的核心在于精确的时间计算和令牌补充。使用 System.nanoTime()获取纳秒级时间戳,确保高精度速率控制。123456789101112131415161718192021222324252627public synchronized void acquire(long permits) { // 1. 补充令牌 refill(); // 2. 计算等待时间 if (tokens >= permits) { tokens -= permits; return; } long deficit = permits - tokens; long waitNanos = (deficit * 1_000_000_000L) / refillRate; // 3. 精确等待 sleepNanos(waitNanos); // 4. 等待后消费 tokens = 0;} private void refill() { long now = System.nanoTime(); long elapsedNanos = now - lastRefillTime; long newTokens = (elapsedNanos * refillRate) / 1_000_000_000L; tokens = Math.min(capacity, tokens + newTokens); lastRefillTime = now;}2. 响应包装器HttpServletResponseWrapper 是 Servlet 规范提供的响应包装基类,通过覆盖 getOutputStream() 方法返回自定义的限速输出流。12345678910111213141516public class BandwidthLimitResponseWrapper extends HttpServletResponseWrapper { privatefinal TokenBucket sharedTokenBucket; // 共享的令牌桶 @Override public ServletOutputStream getOutputStream() throws IOException { if (limitedOutputStream == null && sharedTokenBucket != null) { // 使用共享 TokenBucket,确保多维度统计正确 limitedOutputStream = new RateLimitedOutputStream( super.getOutputStream(), sharedTokenBucket, bandwidthBytesPerSecond ); } return limitedOutputStream; }}3. 拦截器获取包装响应拦截器在 preHandle 中创建响应包装器,存储到 request attribute,Controller 通过 BandwidthLimitHelper 获取。1234567891011121314@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { BandwidthLimit annotation = findAnnotation(handler); if (annotation != null) { // 从 Manager 获取共享 TokenBucket TokenBucket bucket = limitManager.getBucket(type, key, capacity, rate); // 创建包装器并存储 BandwidthLimitResponseWrapper wrappedResponse = new BandwidthLimitResponseWrapper(response, bucket, bandwidthBytesPerSecond, chunkSize); request.setAttribute("BandwidthLimitWrappedResponse", wrappedResponse); } return true;}4. Controller 获取限速响应Controller 通过 BandwidthLimitHelper.getLimitedResponse() 获取包装后的响应,所有写入操作都会自动限速。12345678910@GetMapping("/download/global")public void downloadGlobal(HttpServletRequest request, HttpServletResponse response) throws IOException { HttpServletResponse limitedResponse = BandwidthLimitHelper.getLimitedResponse(request, response); limitedResponse.setContentType("application/octet-stream"); limitedResponse.setHeader("Content-Disposition", "attachment; filename=test.bin"); // 写入数据时自动限速 limitedResponse.getOutputStream().write(data);}参数调优指南桶容量选择容量决定突发流量承受能力:容量设置突发能力适用场景速率 × 0.5平滑,无突发流量控制严格的场景速率 × 1.0允许 1 秒突发默认推荐值速率 × 2.0允许 2 秒突发需要良好首屏加载12// 注解配置@BandwidthLimit(value = 200, unit = BandwidthUnit.KB, capacityMultiplier = 1.0)分块大小选择分块大小影响流量平滑度,经验公式:chunkSize = bandwidth / 50带宽推荐分块理由200 KB/s1-4 KB小分块保证平滑1 MB/s4-8 KB平衡平滑与性能5 MB/s+8-16 KB减少系统调用开销12345// 自动计算(推荐)@BandwidthLimit(value = 200, unit = BandwidthUnit.KB, chunkSize = -1) // 手动指定@BandwidthLimit(value = 200, unit = BandwidthUnit.KB, chunkSize = 4096)
-
根据实际场景需求去选择需要的解决方案。HTTP客户端选择方案:RestTemplate、Feign、WebClient。同步方案:全量同步、增量同步、实时同步 三种核心方案。一、HTTP客户端方案Spring Boot 对接第三方接口有多种常用方案,适配不同场景,比如简单场景用RestTemplate,微服务架构用Feign,高并发场景用响应式的WebClient。以下是每种方案的详细教程,包含依赖配置、代码实现和核心说明。Spring Boot 官方在文档中推荐使用 RestTemplate(传统项目)或 WebClient(响应式项目),而 Feign 作为 Spring Cloud 的一部分,也是微服务场景的首选。方案一:RestTemplate(同步基础款,适合简单场景)RestTemplate是 Spring 框架提供的同步 HTTP 客户端,适配大多数简单的第三方接口调用场景,Spring Boot 2.x 中可直接集成使用。步骤1:添加依赖Spring Boot 2.x 的spring-boot-starter-web已内置RestTemplate,在pom.xml中添加 web 依赖即可:123456<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency></dependencies>步骤2:配置 RestTemplate Bean 创建配置类,将RestTemplate注入 Spring 容器,可配置超时时间等参数:12345678910@Configurationpublic class RestTemplateConfig { @Bean public RestTemplate restTemplate() { HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(5000); // 连接超时5秒 factory.setReadTimeout(5000); // 读取超时5秒 return new RestTemplate(factory); }}步骤3: 调用第三方接口在 Service 层注入RestTemplate,分别实现 GET 和 POST 请求调用。这里以调用模拟的用户接口为例:12345678910111213141516171819@Servicepublic class ThirdPartyService { @Resource private RestTemplate restTemplate; // GET请求:根据ID查询用户 public UserDTO getUserById(Long userId) { String url = "https://api.example.com/users/{id}"; // 占位符替换,返回结果自动转为UserDTO return restTemplate.getForObject(url, UserDTO.class, userId); } // POST请求:创建用户 public UserDTO createUser(UserRequest request) { String url = "https://api.example.com/users"; // 发送POST请求,携带JSON请求体,返回UserDTO return restTemplate.postForObject(url, request, UserDTO.class); }}步骤4: 定义实体类创建与接口请求 / 响应对应的实体类UserRequest和UserDTO:1234567891011121314// 请求实体public class UserRequest { private String username; private String email; // getter和setter} // 响应实体public class UserDTO { private Long id; private String username; private String email; // getter和setter}方案二:Feign(声明式调用,适配微服务)Feign 是声明式 HTTP 客户端,通过注解简化请求代码,且能与 Spring Cloud 集成实现负载均衡,适合微服务架构下的第三方接口调用。步骤1:添加依赖在pom.xml中添加 OpenFeign 依赖:1234567891011<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> <version>3.1.3</version> </dependency></dependencies>步骤2:启用 Feign 客户端在 Spring Boot 启动类添加@EnableFeignClients注解:1234567@SpringBootApplication@EnableFeignClients // 启用Feign客户端public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); }}步骤3:定义 Feign 接口创建 Feign 接口,通过注解声明第三方接口的请求规则:12345678910// name为客户端名称,url为第三方接口基地址@FeignClient(name = "user-api", url = "https://api.example.com")public interface UserFeignClient { @GetMapping("/users/{id}") UserDTO getUserById(@PathVariable("id") Long userId); @PostMapping("/users") UserDTO createUser(@RequestBody UserRequest request);}步骤4:调用 Feign 接口在 Service 层注入 Feign 接口直接调用,无需手动构建请求:12345678910111213@Servicepublic class UserService { @Resource private UserFeignClient userFeignClient; public UserDTO getUser(Long userId) { return userFeignClient.getUserById(userId); } public UserDTO addUser(UserRequest request) { return userFeignClient.createUser(request); }}方案三:WebClient(响应式非阻塞,适配高并发)WebClient是 Spring WebFlux 提供的响应式 HTTP 客户端,非阻塞 IO,适合高并发场景,Spring Boot 2.x 及以上版本支持。步骤1:添加依赖在pom.xml中添加 WebFlux 依赖(内置 WebClient):123456<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency></dependencies>步骤2:配置 WebClient Bean创建配置类,统一配置基础 URL 和请求头:12345678910@Configurationpublic class WebClientConfig { @Bean public WebClient webClient() { return WebClient.builder() .baseUrl("https://api.example.com") // 第三方接口基地址 .defaultHeader("Content-Type", "application/json") .build(); }}步骤3:调用第三方接口WebClient返回Mono(单结果)或Flux(多结果),通过响应式编程处理结果:12345678910111213141516171819202122@Servicepublic class ReactiveThirdPartyService { @Resource private WebClient webClient; // GET请求:查询用户 public Mono<UserDTO> getUserById(Long userId) { return webClient.get() .uri("/users/{id}", userId) .retrieve() // 发送请求并接收响应 .bodyToMono(UserDTO.class); // 响应体转为UserDTO的Mono对象 } // POST请求:创建用户 public Mono<UserDTO> createUser(UserRequest request) { return webClient.post() .uri("/users") .bodyValue(request) // 设置请求体 .retrieve() .bodyToMono(UserDTO.class); }}步骤4:控制器层调用响应式接口需返回Mono或Flux对象:12345678910111213141516@RestController@RequestMapping("/api/users")public class UserController { @Resource private ReactiveThirdPartyService service; @GetMapping("/{id}") public Mono<UserDTO> getUser(@PathVariable Long id) { return service.getUserById(id); } @PostMapping public Mono<UserDTO> addUser(@RequestBody UserRequest request) { return service.createUser(request); }}二、数据同步方案方案一:定时全量同步适用于数据量小、对实时性要求不高的场景。实现思路:每天凌晨2点执行一次全量拉取删除旧数据,插入新数据(或软删除 + 更新)使用事务保证一致性1、全量删除 + 批量插入1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071@Slf4j@Component@RequiredArgsConstructorpublic class FullSyncScheduler { private final RestTemplate restTemplate = new RestTemplate(); private final DepartmentService departmentService; private final UserService userService; @Value("${third-party.api-base-url}") private String apiBaseUrl; // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * *") public void performFullSync() { log.info("--- 开始执行全量同步 ---"); Instant startTime = Instant.now(); try { // 步骤 1: 删除本地所有数据 departmentService.remove(new QueryWrapper<>()); UserService.remove(new QueryWrapper<>()); // 步骤 2: 从第三方拉取全量数据 syncDepartments(); // 1. 同步部门 syncUsers(); // 2. 同步用户 log.info("--- 全量同步成功完成,总耗时: {} ms ---", Duration.between(startTime, Instant.now()).toMillis()); } catch (Exception e) { log.error("全量同步失败", e); } } // 同步部门逻辑 private void syncDepartments() { log.info("同步部门数据..."); Instant depStartTime = Instant.now(); // 通过第三方接口获取数据 String url = apiBaseUrl + "/api/departments"; Department[] remoteDepartments = restTemplate.getForObject(url, Department[].class); if (remoteDepartments == null || remoteDepartments.length == 0) { log.warn("从第三方API获取部门数据为空"); return; } List<Department> deptList = Arrays.asList(remoteDepartments); // 批量插入到本地数据库 departmentService.saveBatch(deptList); log.info("部门同步完成,共 {} 个部门,耗时:{}", remoteDepartments.length,Duration.between(depStartTime, Instant.now()).toMillis()); } // 同步用户逻辑 private void syncUsers() { log.info("同步用户数据..."); // 通过第三方接口获取数据 String url = apiBaseUrl + "/api/users"; User[] remoteUsers = restTemplate.getForObject(url, User[].class); if (remoteUsers == null || remoteUsers.length == 0) { log.warn("从第三方API获取用户数据为空"); return; } List<User> userList = Arrays.asList(remoteUsers); // 批量插入到本地数据库 userService.saveBatch(userList); log.info("用户同步完成,共 {} 个用户。", remoteUsers.length); }}2、UPSERT + 删除多余 (SaveOrUpdateBatch + Delete Not In)123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687@Slf4j@Component@RequiredArgsConstructorpublic class FullSyncScheduler { private final RestTemplate restTemplate = new RestTemplate(); private final DepartmentService departmentService; private final UserService userService; @Value("${third-party.api-base-url}") private String apiBaseUrl; // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * *") public void performFullSync() { log.info("--- 开始执行全量同步 ---"); Instant startTime = Instant.now(); try { // 1. 同步部门 syncDepartments(); // 2. 同步用户 syncUsers(); log.info("--- 全量同步成功完成,总耗时: {} ms ---", Duration.between(startTime, Instant.now()).toMillis()); } catch (Exception e) { log.error("全量同步失败", e); } } // 同步部门逻辑 private void syncDepartments() { log.info("同步部门数据..."); Instant depStartTime = Instant.now(); // 步骤 1: 从第三方拉取全量数据 String url = apiBaseUrl + "/api/departments"; Department[] remoteDepartments = restTemplate.getForObject(url, Department[].class); if (remoteDepartments == null || remoteDepartments.length == 0) { log.warn("从第三方API获取部门数据为空"); return; } List<Department> deptList = Arrays.asList(remoteDepartments); List<String> remoteIds = deptList.stream() .map(Department::getExternalId) .collect(Collectors.toList()); // 步骤 2: 执行 UPSERT (更新或插入) departmentService.saveOrUpdateBatch(deptList); // 收集 externalId List<String> remoteIds = deptList.stream() .map(Department::getExternalId) .collect(Collectors.toList()); // 步骤3:找出并删除本地存在但远程不存在的数据 departmentService.removeByExternalIdNotIn(remoteIds); log.info("部门同步完成,共 {} 个部门,耗时:{}", remoteDepartments.length,Duration.between(depStartTime, Instant.now()).toMillis()); } // 同步用户逻辑 private void syncUsers() { log.info("同步用户数据..."); // 步骤 1: 从第三方拉取全量数据 String url = apiBaseUrl + "/api/users"; User[] remoteUsers = restTemplate.getForObject(url, User[].class); if (remoteUsers == null || remoteUsers.length == 0) { log.warn("从第三方API获取用户数据为空"); return; } List<User> userList = Arrays.asList(remoteUsers); // 步骤 2: 执行 UPSERT (更新或插入) userService.saveOrUpdateBatch(userList); // 收集 externalId List<String> remoteIds = userList.stream() .map(User::getExternalId) .collect(Collectors.toList()); // 步骤3:找出并删除本地存在但远程不存在的数据 userService.removeByExternalIdNotIn(remoteIds); log.info("用户同步完成,共 {} 个用户。", remoteUsers.length); }}几乎在所有其他情况下,方案二都是更优、更安全的选择。它能最大限度地保证数据的一致性和业务的连续性,虽然在性能上可能比方案一略逊一筹,但在绝大多数企业级应用中,数据一致性和系统稳定性远比同步快几秒更为重要。因此,在组织架构同步场景中,强烈推荐使用方案二(saveOrUpdateBatch + delete not in) 。它能确保在同步过程中,业务系统总能查询到有效的部门和用户信息,避免了因同步失败或数据真空期导致的业务异常。方案二:定时增量同步1、基于时间戳的增量同步(最常用)记录上次同步的时间戳(如 last_sync_time),每次同步时只拉取第三方系统中 update_time > last_sync_time 的数据。步骤1:记录同步时间戳在本地数据库中维护一张同步记录表(如 sync_checkpoint),存储每个同步任务的上次成功时间戳。1234567CREATE TABLE sync_checkpoint ( id INT PRIMARY KEY AUTO_INCREMENT, task_name VARCHAR(50) NOT NULL COMMENT '任务名称(如部门同步、用户同步)', last_sync_time DATETIME NOT NULL COMMENT '上次同步时间戳', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP);步骤2:拉取增量数据 每次同步时,从 sync_checkpoint 读取 last_sync_time,调用第三方 API 时传入该时间戳,只获取更新时间晚于该值的数据。12GET /api/departments?since=2024-05-20T10:00:00ZGET /api/users?since=2024-05-20T10:00:00Z步骤3:更新时间戳同步成功后,将 last_sync_time 更新为当前时间(或第三方返回的最新数据时间戳)。优点实现简单,第三方 API 通常自带 since/update_time 筛选参数。资源消耗低,只处理变化的数据。缺点依赖第三方系统的 update_time 字段准确性(若第三方未正确更新该字段,会导致数据漏同步)。若同步失败,需手动处理时间戳回滚,否则会丢失中间数据。适用场景第三方 API 支持按时间戳筛选(如钉钉、企业微信的增量接口)。数据变更频率适中,对漏同步可通过后续全量同步兜底。123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106@Component@Slf4j@RequiredArgsConstructorpublic class IncrementalSyncScheduler { private final RestTemplate restTemplate = new RestTemplate(); private final DepartmentService departmentService; private final UserService userService; private final SyncCheckpointService checkpointService; @Value("${third-party.api-base-url}") private String apiBaseUrl; private static final String TASK_NAME = "DEPT_USER_SYNC_MP"; private static final DateTimeFormatter dtf = DateTimeFormatter.ISO_LOCAL_DATE_TIME; // 每10分钟执行一次 @Scheduled(cron = "0 */10 * * * *") public void performIncrementalSync() { log.info("--- 开始执行增量同步 ---"); try { // 1. 获取上次同步时间 LocalDateTime lastSyncTime = getLastSyncTime(); // 2. 拉取增量数据 String url = apiBaseUrl + "/api/changes?since=" + dtf.format(lastSyncTime); ChangeEventWrapper changes = restTemplate.getForObject(url, ChangeEventWrapper.class); if (changes == null || (changes.getDepartments().isEmpty() && changes.getUsers().isEmpty())) { log.info("没有增量数据。"); updateCheckpoint(); return; } // 3. 应用变更 applyChanges(changes); // 4. 更新检查点 updateCheckpoint(); log.info("--- [MyBatis-Plus] 增量同步成功完成 ---"); } catch (Exception e) { log.error("[MyBatis-Plus] 增量同步失败", e); } } private void applyChanges(ChangeEventWrapper changes) { if (!changes.getDepartments().isEmpty()) { log.info("处理 {} 条部门变更...", changes.getDepartments().size()); for (ChangeEvent<Department> event : changes.getDepartments()) { Department data = event.getData(); switch (event.getType()) { case CREATE: case UPDATE: departmentService.saveOrUpdate(data); break; case DELETE: departmentService.removeById(data.getId()); break; } } } if (!changes.getUsers().isEmpty()) { log.info("处理 {} 条用户变更...", changes.getUsers().size()); for (ChangeEvent<User> event : changes.getUsers()) { User data = event.getData(); switch (event.getType()) { case CREATE: case UPDATE: userService.saveOrUpdate(data); break; case DELETE: userService.removeById(data.getId()); break; } } } } private LocalDateTime getLastSyncTime() { SyncCheckpoint checkpoint = checkpointService.getByTaskName(TASK_NAME); return checkpoint != null ? checkpoint.getLastSyncTimestamp() : LocalDateTime.of(2000, 1, 1, 0, 0); } private void updateCheckpoint() { SyncCheckpoint checkpoint = checkpointService.getByTaskName(TASK_NAME); if (checkpoint == null) { checkpoint = new SyncCheckpoint(); checkpoint.setTaskName(TASK_NAME); } checkpoint.setLastSyncTimestamp(LocalDateTime.now()); checkpointService.saveOrUpdate(checkpoint); } // 辅助类 public static class ChangeEventWrapper { private java.util.List<ChangeEvent<Department>> departments; private java.util.List<ChangeEvent<User>> users; // getters and setters } public static class ChangeEvent<T> { private String type; private T data; // getters and setters }}2、方案二:基于变更 ID 的增量同步(高可靠性)第三方系统为每条数据分配唯一的变更 ID(如 change_id),每次同步时只拉取 change_id > last_change_id 的数据。变更 ID 通常按时间递增生成。步骤1:记录上次变更 ID在 sync_checkpoint 表中增加 last_change_id 字段,存储上次同步的最大变更 ID。1ALTER TABLE sync_checkpoint ADD COLUMN last_change_id BIGINT DEFAULT 0 COMMENT '上次同步的最大变更ID';步骤2:拉取增量数据调用第三方 API 时传入 last_change_id,只获取变更 ID 更大的数据。示例 API 请求:1GET /api/changes?last_change_id=12345步骤3:更新变更 ID同步成功后,将 last_change_id 更新为本次同步到的最大变更 ID。总结:可靠性高,变更 ID 唯一且递增,不会漏同步或重复同步。无需依赖时间戳,避免因时间偏差导致的问题。第三方系统需支持变更 ID 筛选(并非所有 API 都提供)。对数据一致性要求极高的场景(如金融、支付数据同步)。其实这种方案实现思路和时间戳类似,只是手动维护了一个自增的变更ID,用来规避时间戳未设值之类的情况。方案三:实时同步 (Webhook)实时同步的核心目标是 “数据变更后立即同步” ,实现 “准实时” 或 “实时” 的数据一致性。与定时同步不同,实时同步无需依赖定时任务触发,而是由 “事件驱动” (数据变更事件触发同步)。以下是几种常见的实时同步实现方案,从简单到复杂,覆盖不同技术栈和场景:1、Webhook 回调(最常用)第三方系统(如钉钉、企业微信、CRM 系统)在数据发生变更时(如新增用户、修改部门),主动调用你的系统提供的 回调接口(Webhook Endpoint) ,将变更数据推送到你的系统,你的系统接收并处理这些数据。步骤1:提供 Webhook 接口在你的系统中开发一个公开的接口(如 /api/webhook/sync),用于接收第三方推送的变更事件。接口需支持 POST 请求,通常接收 JSON 格式的事件数据。步骤2:配置第三方 Webhook在第三方系统的管理后台(如钉钉开放平台),配置你的 Webhook 接口地址,并选择需要监听的事件类型(如用户新增、部门删除)。步骤3:接收并处理事件你的系统接收事件数据后,解析数据内容(如变更类型、变更数据、时间戳),并执行同步操作(插入、更新、删除本地数据库)。关键注意点:签名验证:第三方会在请求头中携带签名(如 X-Signature),你需要验证签名的合法性,防止恶意请求。幂等性处理:由于网络重试等原因,可能会收到重复事件,需确保同步逻辑幂等(如通过 event_id 去重)。异步处理:接收到事件后,应立即返回响应(如 HTTP 200),再通过线程池或消息队列异步处理同步逻辑,避免阻塞第三方的回调请求。123456789101112131415161718192021222324252627282930313233@RestController@RequestMapping("/api/webhook")@Slf4jpublic class WebhookController { @Autowired private SyncService syncService; @Autowired private WebhookSignatureService signatureService; @PostMapping("/dingtalk") public ResponseEntity<?> handleDingTalkWebhook( @RequestBody String requestBody, @RequestHeader("X-Signature") String signature, @RequestHeader("X-Timestamp") String timestamp) { // 1. 验证签名 if (!signatureService.validateSignature(requestBody, timestamp, signature)) { log.warn("Webhook签名验证失败"); return ResponseEntity.badRequest().body("Invalid signature"); } // 2. 解析事件数据 DingTalkWebhookEvent event = JsonUtils.parseObject(requestBody, DingTalkWebhookEvent.class); log.info("收到钉钉Webhook事件:{}", event.getEventType()); // 3. 异步处理同步逻辑(避免阻塞) syncService.asyncProcessEvent(event); // 4. 立即返回响应 return ResponseEntity.ok().body("{"errcode":0,"errmsg":"success"}"); }}总结:第三方系统支持 Webhook(如钉钉、企业微信、GitHub、PayPal 等)。对实时性要求中等(秒级延迟可接受),且不希望引入复杂中间件的场景。2、消息队列(MQ)异步同步(高可靠)通过 消息队列(如 RabbitMQ、Kafka、RocketMQ)解耦数据变更源和同步目标:数据变更源(如业务系统、第三方 API)将变更事件写入消息队列。你的系统作为消费者,监听消息队列,读取事件并执行同步操作。步骤1:选择并部署消息队列根据场景选择 MQ(如 RabbitMQ 适合可靠性优先,Kafka 适合高吞吐)。步骤2:生产端写入消息数据变更时(如用户更新),生产端(如业务系统的服务)将变更事件(如用户 ID、变更字段、操作类型)序列化为消息,发送到 MQ 的指定主题 / 队列。示例(Java + RabbitMQ):1234567891011@Servicepublic class EventProducer { @Autowired private RabbitTemplate rabbitTemplate; public void sendUserUpdateEvent(User user) { UserUpdateEvent event = new UserUpdateEvent(user.getId(), user.getName(), LocalDateTime.now()); rabbitTemplate.convertAndSend("user.sync.exchange", "user.update", event); log.info("发送用户更新事件:{}", user.getId()); }}步骤3:消费端处理消息你的系统作为消费者,订阅 MQ 的主题 / 队列,接收消息后解析并同步到本地数据库。示例(Java + RabbitMQ):123456789101112131415@Servicepublic class EventConsumer { @Autowired private UserRepository userRepository; @RabbitListener(queues = "user.update.queue") public void handleUserUpdateEvent(UserUpdateEvent event) { log.info("接收用户更新事件:{}", event.getUserId()); // 执行同步操作 User user = userRepository.findByExternalId(event.getUserId()) .orElseThrow(() -> new RuntimeException("用户不存在")); user.setName(event.getUserName()); userRepository.save(user); }}步骤4:保障可靠性消息持久化:将消息和队列设置为持久化,避免 MQ 重启后消息丢失。消费者确认(Ack) :消费者处理完消息后手动发送 Ack,确保消息被成功处理。死信队列(DLQ) :处理失败的消息(如数据库异常)转入死信队列,避免阻塞正常消息,后续可人工重试。总结:对数据可靠性要求高(如金融交易、订单同步),不允许消息丢失。高并发场景(如每秒数千条数据变更),需要 MQ 削峰填谷。多系统间数据同步(如业务系统 → 数据仓库 → 报表系统)。
-
在企业级应用开发中,数据持久化是核心模块之一,负责将业务数据存储到数据库并提供读写操作。MyBatis-Plus(简称MP)是MyBatis的增强工具,在MyBatis基础上简化了CRUD操作,提供了代码生成器、分页插件、条件构造器等强大功能,大幅提升开发效率。本文将详细讲解Spring Boot集成MyBatis-Plus的完整流程,实现数据库表设计、实体类映射、CRUD操作、分页查询、条件查询等核心功能,结合实际业务场景提供完整代码示例,帮助开发者快速掌握MyBatis-Plus的实战应用。一、核心技术栈与环境准备1. 技术栈选型核心框架:Spring Boot 2.7.x;数据持久化:MyBatis-Plus 3.5.x;数据库:MySQL 8.0;核心依赖:Spring Web、MyBatis-Plus Starter、MySQL Connector Java、Lombok、Validation;开发工具:IntelliJ IDEA、Navicat(数据库管理)、Postman。2. 数据库环境准备创建MySQL数据库(名称:springboot_mp_demo),并创建用户表(user),SQL语句如下: -- 创建数据库 CREATE DATABASE IF NOT EXISTS springboot_mp_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 使用数据库 USE springboot_mp_demo; -- 创建用户表 CREATE TABLE IF NOT EXISTS `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID(主键)', `username` varchar(50) NOT NULL COMMENT '用户名(唯一)', `password` varchar(100) NOT NULL COMMENT '密码(加密存储)', `email` varchar(100) NOT NULL COMMENT '邮箱', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态(1:正常,0:禁用)', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), UNIQUE KEY `uk_email` (`email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; 3. 项目初始化与依赖配置通过Spring Initializr创建项目,引入核心依赖,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>2.7.15</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>springboot-mp-demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>springboot-mp-demo</name> <description>Spring Boot 集成 MyBatis-Plus 实战项目</description> <properties> <java.version>1.8</java.version> <mybatis-plus.version>3.5.3.1</mybatis-plus.version> <mysql-connector.version>8.0.33</mysql-connector.version> </properties> <dependencies> <!-- Spring Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus Starter(核心依赖,自动集成MyBatis) --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency><!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>${mysql-connector.version}</version> <scope>runtime</scope> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 数据校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- MyBatis-Plus 代码生成器(可选,快速生成代码) --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>${mybatis-plus.version}</version> </dependency> <!-- 代码生成器模板引擎(Freemarker) --> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</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> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project> 二、核心配置(application.yml)配置数据库连接信息、MyBatis-Plus核心参数(如 mapper 扫描路径、日志级别、分页插件等),application.yml文件如下: spring: # 数据库配置 datasource: url: jdbc:mysql://localhost:3306/springboot_mp_demo?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root # 你的MySQL用户名 password: root # 你的MySQL密码 driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 8.0+ 驱动类 # MyBatis-Plus 配置 mybatis-plus: # mapper.xml 文件扫描路径(默认classpath:/mapper/**/*.xml) mapper-locations: classpath:/mapper/**/*.xml # 实体类扫描路径(配置后可省略@TableName注解的schema属性) type-aliases-package: com.example.springbootmpdemo.entity # 全局配置 global-config: db-config: # 主键生成策略(AUTO:自增,NONE:手动输入,INPUT:手动输入,ASSIGN_ID:雪花算法,ASSIGN_UUID:UUID) id-type: AUTO # 数据库表前缀(如果表名有前缀,如t_user,可配置为t_,实体类无需写前缀) table-prefix: # 逻辑删除字段(软删除,需在实体类对应字段添加@TableLogic注解) logic-delete-field: status # 逻辑删除值(1:正常,0:删除/禁用) logic-not-delete-value: 1 logic-delete-value: 0 # 配置日志(STDOUT_LOGGING:控制台日志,可替换为LOG4J2等) configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开启驼峰命名自动映射(数据库字段下划线转实体类驼峰命名) map-underscore-to-camel-case: true # 服务器配置 server: port: 8081 servlet: context-path: / 三、MyBatis-Plus核心功能实现1. 项目目录结构 src/ ├── main/ │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── springbootmpdemo/ │ │ ├── SpringbootMpDemoApplication.java # 启动类 │ │ ├── entity/ # 实体类(与数据库表映射) │ │ │ └── User.java │ │ ├── mapper/ # Mapper接口(继承BaseMapper) │ │ │ └── UserMapper.java │ │ ├── service/ # 业务层(继承IService/ServiceImpl) │ │ │ ├── UserService.java │ │ │ └── impl/ │ │ │ └── UserServiceImpl.java │ │ ├── controller/ # 控制层 │ │ │ └── UserController.java │ │ ├── dto/ # 请求/响应DTO │ │ │ ├── UserQueryDTO.java │ │ │ ├── UserRequestDTO.java │ │ │ └── UserResponseDTO.java │ │ ├── exception/ # 异常处理(同第一篇文章结构) │ │ ├── config/ # 配置类 │ │ │ └── MyBatisPlusConfig.java # MP分页插件配置 │ │ └── util/ # 工具类 │ └── resources/ │ ├── application.yml # 核心配置 │ └── mapper/ # mapper.xml文件目录 │ └── UserMapper.xml └── test/ # 测试目录 2. 启动类配置(添加@MapperScan注解)在启动类上添加@MapperScan注解,指定Mapper接口扫描路径,让Spring Boot自动扫描并注册Mapper接口: package com.example.springbootmpdemo; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** * 启动类 * @MapperScan:扫描Mapper接口所在包 */ @SpringBootApplication @MapperScan("com.example.springbootmpdemo.mapper") public class SpringbootMpDemoApplication { public static void main(String[] args) { SpringApplication.run(SpringbootMpDemoApplication.class, args); System.out.println("Spring Boot + MyBatis-Plus 项目启动成功!"); } } 3. 实体类(User.java)与数据库表映射通过MyBatis-Plus的注解实现实体类与数据库表的映射,核心注解包括@TableName(表名映射)、@TableId(主键映射)、@TableField(字段映射)、@TableLogic(逻辑删除)等: package com.example.springbootmpdemo.entity;import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import lombok.EqualsAndHashCode; import java.time.LocalDateTime;/** * 用户实体类(与user表映射) * @Data:Lombok注解,生成getter/setter等 * @TableName:指定数据库表名(如果实体类名与表名一致,可省略) * @EqualsAndHashCode:生成equals和hashCode方法 */ @Data @EqualsAndHashCode(callSuper = false) @TableName("user") public class User { /** * 主键ID * @TableId:标识主键,type指定主键生成策略(与配置文件全局配置一致,可省略) */ @TableId(type = IdType.AUTO) private Long id; /** * 用户名 * @TableField:字段映射(如果实体类字段与数据库字段一致,可省略) * value:数据库字段名 * unique:是否唯一(与数据库表的唯一约束对应) */ @TableField(value = "username", unique = true) private String username; /** * 密码 */ private String password; /** * 邮箱 */ @TableField(unique = true) private String email; /** * 手机号 * exist:是否为数据库字段(false表示非数据库字段,仅用于
-
Spring Boot作为Spring生态的核心子项目,以“约定优于配置”为核心理念,大幅简化了Spring应用的初始化搭建与开发流程,无需繁琐的XML配置,即可快速构建可运行的企业级应用。本文将从零开始,搭建一个标准的企业级RESTful API项目,涵盖项目初始化、目录结构设计、核心依赖引入、接口开发、全局异常处理、数据校验、日志配置等核心功能,提供完整可运行的代码示例,帮助开发者快速上手Spring Boot基础开发。一、核心技术栈与环境准备1. 技术栈选型核心框架:Spring Boot 2.7.x(稳定版);核心依赖:Spring Web(RESTful API开发)、Spring Boot DevTools(热部署)、Lombok(简化代码)、Validation(数据校验)、Spring Boot Starter Logging(日志);开发工具:IntelliJ IDEA 2023、Postman(接口测试);JDK版本:JDK 1.8+;构建工具:Maven 3.6+。2. 项目初始化(两种方式)方式一:通过Spring Initializr(在线初始化),访问地址:https://start.spring.io/,填写项目信息(Group、Artifact、Name等),选择对应依赖(Spring Web、Lombok、Validation),下载项目压缩包后解压导入IDEA。方式二:通过IDEA直接创建,打开IDEA → New Project → 选择Spring Initializr,配置项目基本信息,选择依赖后直接创建,IDEA会自动完成项目初始化与依赖下载。3. 核心依赖配置(pom.xml)项目初始化后,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>2.7.15</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>springboot-rest-api-demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>springboot-rest-api-demo</name> <description>Spring Boot RESTful API 基础实战项目</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <!-- Spring Web 核心依赖(RESTful API开发) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Lombok 简化代码(消除getter/setter/构造器等) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 数据校验依赖(参数校验) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- 热部署依赖(开发时无需重启项目) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <!-- 测试依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <!-- Spring Boot Maven插件(打包运行) --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project> 二、标准项目目录结构设计为保证项目的可维护性、可扩展性,遵循“分层架构”设计思想,标准目录结构如下,各层职责清晰,便于团队协作开发: src/ ├── main/ │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── springbootrestapidemo/ # 项目根包 │ │ ├── SpringbootRestApiDemoApplication.java # 项目启动类 │ │ ├── controller/ # 控制层(接口暴露) │ │ │ └── UserController.java # 用户相关接口 │ │ ├── entity/ # 实体类层(映射数据库/请求响应模型) │ │ │ ├── User.java # 用户实体 │ │ │ └── dto/ # 数据传输对象(请求/响应专用) │ │ │ ├── UserRequestDTO.java │ │ │ └── UserResponseDTO.java │ │ ├── service/ # 业务逻辑层 │ │ │ ├── UserService.java # 业务接口 │ │ │ └── impl/ # 业务实现类 │ │ │ └── UserServiceImpl.java │ │ ├── exception/ # 异常处理层 │ │ │ ├── GlobalExceptionHandler.java # 全局异常处理器 │ │ │ ├── BusinessException.java # 自定义业务异常 │ │ │ └── ErrorCode.java # 错误码枚举 │ │ ├── util/ # 工具类层 │ │ │ └── ResultUtil.java # 响应结果工具类 │ │ └── config/ # 配置层 │ │ └── WebConfig.java # Web相关配置 │ └── resources/ # 资源文件目录 │ ├── application.yml # 核心配置文件(替代application.properties) │ ├── application-dev.yml # 开发环境配置 │ └── application-prod.yml # 生产环境配置 └── test/ # 测试目录(与main目录结构对应) └── java/ └── com/ └── example/ └── springbootrestapidemo/ └── controller/ └── UserControllerTest.java 三、核心功能代码实现1. 项目启动类(入口类)启动类是Spring Boot项目的入口,通过@SpringBootApplication注解开启自动配置、组件扫描等核心功能,必须放在项目根包下(保证组件扫描范围): package com.example.springbootrestapidemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** * Spring Boot 项目启动类 * @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan */ @SpringBootApplication public class SpringbootRestApiDemoApplication { public static void main(String[] args) { // 启动Spring Boot应用 SpringApplication.run(SpringbootRestApiDemoApplication.class, args); System.out.println("项目启动成功!访问地址:http://localhost:8080"); } } 2. 实体类与DTO设计实体类(Entity)用于映射业务模型,DTO(Data Transfer Object)用于请求参数接收与响应结果返回,避免直接暴露实体类,保证数据传输的安全性与灵活性。(1)用户实体类(User.java): package com.example.springbootrestapidemo.entity; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** * 用户实体类(业务模型) * Lombok注解:@Data(生成getter/setter/toString等)、@NoArgsConstructor(无参构造)、@AllArgsConstructor(全参构造) */ @Data @NoArgsConstructor @AllArgsConstructor public class User { // 用户ID private Long id; // 用户名(唯一) private String username; // 密码(实际开发中需加密存储) private String password; // 邮箱 private String email; // 手机号 private String phone; // 创建时间 private LocalDateTime createTime; // 更新时间 private LocalDateTime updateTime; } (2)用户请求DTO(UserRequestDTO.java):用于接收前端传递的用户新增/修改参数,并添加数据校验规则: package com.example.springbootrestapidemo.entity.dto; import lombok.Data; import org.hibernate.validator.constraints.Length; import javax.validation.constraints.Email; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; /** * 用户请求DTO(接收前端参数) */ @Data public class UserRequestDTO { // 用户名:非空,长度3-20位 @NotBlank(message = "用户名不能为空") @Length(min = 3, max = 20, message = "用户名长度必须在3-20位之间") private String username; // 密码:非空,长度6-16位,包含字母和数字 @NotBlank(message = "密码不能为空") @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(.{6,16})$", message = "密码必须6-16位,包含字母和数字") private String password; // 邮箱:非空,格式正确 @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") private String email; // 手机号:非空,格式正确(11位手机号) @NotBlank(message = "手机号不能为空") @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone; } (3)用户响应DTO(UserResponseDTO.java):用于向前端返回用户数据,隐藏敏感字段(如password): package com.example.springbootrestapidemo.entity.dto; import lombok.Data; import java.time.LocalDateTime; /** * 用户响应DTO(返回前端数据) */ @Data public class UserResponseDTO { private Long id; private String username; private String email; private String phone; private LocalDateTime createTime; private LocalDateTime updateTime; } 3. 业务逻辑层(Service)业务逻辑层负责核心业务处理,通过接口与实现类分离的方式,降低耦合度,便于后续扩展与测试。本文采用内存模拟数据存储(实际开发中替换为数据库交互)。(1)业务接口(UserService.java): package com.example.springbootrestapidemo.service; import com.example.springbootrestapidemo.entity.User; import com.example.springbootrestapidemo.entity.dto.UserRequestDTO; import java.util.List; /** * 用户业务逻辑接口 */ public interface UserService { /** * 新增用户 * @param userRequestDTO 用户请求参数 * @return 新增后的用户 */ User addUser(UserRequestDTO userRequestDTO); /** * 根据ID查询用户 * @param id 用户ID * @return 用户信息 */ User getUserById(Long id); /** * 查询所有用户(分页,本文简化为不分页) * @return 用户列表 */ List<User> getAllUsers(); /** * 根据ID修改用户 * @param id 用户ID * @param userRequestDTO 修改参数 * @return 修改后的用户 */ User updateUser(Long id, UserRequestDTO userRequestDTO); /** * 根据ID删除用户 * @param id 用户ID * @return 删除结果(true/false) */ Boolean deleteUser(Long id); } (2)业务实现类(UserServiceImpl.java): package com.example.springbootrestapidemo.service.impl; import com.example.springbootrestapidemo.entity.User; import com.example.springbootrestapidemo.entity.dto.UserRequestDTO; import com.example.springbootrestapidemo.exception.BusinessException; import com.example.springbootrestapidemo.exception.ErrorCode; import com.example.springbootrestapidemo.service.UserService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicLong; /** * 用户业务逻辑实现类 * @Slf4j:Lombok注解,生成日志对象 * @Service:Spring注解,标识为业务层组件,自动注入 */ @Service @Slf4j public class UserServiceImpl implements UserService { // 内存存储用户数据(ConcurrentHashMap保证线程安全) private static final ConcurrentMap<Long, User> USER_MAP = new ConcurrentHashMap<>(); // 自增ID生成器(AtomicLong保证原子性) private static final AtomicLong USER_ID_GENERATOR = new AtomicLong(1); @Override public User addUser(UserRequestDTO userRequestDTO) { // 1. 业务校验:用户名是否已存在 boolean usernameExists = USER_MAP.values().stream() .anyMatch(user -> user.getUsername().equals(userRequestDTO.getUsername())); if (usernameExists) { log.error("用户名已存在:{}", userRequestDTO.getUsername()); throw new BusinessException(ErrorCode.USERNAME_ALREADY_EXISTS); } // 2. 构建User对象 User user = new User(); Long userId = USER_ID_GENERATOR.getAndIncrement(); user.setId(userId); user.setUsername(userRequestDTO.getUsername()); user.setPassword(userRequestDTO.getPassword()); // 实际开发中需加密(如BCrypt) user.setEmail(userRequestDTO.getEmail()); user.setPhone(userRequestDTO.getPhone()); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); // 3. 存储用户数据 USER_MAP.put(userId, user); log.info("新增用户成功,用户ID:{}", userId); return user; } @Override public User getUserById(Long id) { // 1. 校验ID是否存在 User user = USER_MAP.get(id); if (user == null) { log.error("用户不存在,用户ID:{}", id); throw new BusinessException(ErrorCode.USER_NOT_FOUND); } return user; } @Override public List<User> getAllUsers() { // 返回所有用户列表 return new ArrayList<>(USER_MAP.values()); } @Override public User updateUser(Long id, UserRequestDTO userRequestDTO) { // 1. 校验用户是否存在 User user = getUserById(id); // 2. 业务校验:用户名是否重复(排除当前用户) boolean usernameExists = USER_MAP.values().stream() .anyMatch(u -> u.getUsername().equals(userRequestDTO.getUsername()) && !u.getId().equals(id)); if (usernameExists) { log.error("用户名已存在:{}", userRequestDTO.getUsername()); throw new BusinessException(ErrorCode.USERNAME_ALREADY_EXISTS); } // 3. 更新用户信息 user.setUsername(userRequestDTO.getUsername()); user.setPassword(userRequestDTO.getPassword()); // 实际开发中需加密 user.setEmail(userRequestDTO.getEmail()); user.setPhone(userRequestDTO.getPhone()); user.setUpdateTime(LocalDateTime.now()); // 4. 保存更新后的数据 USER_MAP.put(id, user); log.info("修改用户成功,用户ID:{}", id); return user; } @Override public Boolean deleteUser(Long id) { // 1. 校验用户是否存在 getUserById(id); // 2. 删除用户数据 USER_MAP.remove(id); log.info("删除用户成功,用户ID:{}", id); return true; } } 4. 控制层(Controller)控制层负责暴露RESTful API接口,接收前端请求,调用业务层处理,返回响应结果。通过@RestController注解标识为REST接口控制器,自动返回JSON格式数据。 package com.example.springbootrestapidemo.controller; import com.example.springbootrestapidemo.entity.User; import com.example.springbootrestapidemo.entity.dto.UserRequestDTO; import com.example.springbootrestapidemo.entity.dto.UserResponseDTO; import com.example.springbootrestapidemo.service.UserService; import com.example.springbootrestapidemo.util.ResultUtil; import com.example.springbootrestapidemo.util.ResultVO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.util.List; import java.util.stream.Collectors; /** * 用户接口控制器(RESTful API) * @RestController = @Controller + @ResponseBody(自动返回JSON) * @RequestMapping:配置接口基础路径 */ @RestController @RequestMapping("/api/v1/users") @Slf4j public class UserController { // 注入业务层对象(@Resource:按名称注入,@Autowired:按类型注入) @Resource private UserService userService; /** * 新增用户 * @PostMapping:POST请求方式,对应新增操作 * @Validated:开启参数校验(配合DTO中的校验注解) * @RequestBody:接收JSON格式请求体 */ @PostMapping public ResultVO<UserResponseDTO> addUser(@Validated @RequestBody UserRequestDTO userRequestDTO) { log.info("接收新增用户请求,参数:{}", userRequestDTO); User user = userService.addUser(userRequestDTO); // 实体类转换为响应DTO(BeanUtils.copyProperties:属性拷贝) UserResponseDTO responseDTO = new UserResponseDTO(); BeanUtils.copyProperties(user, responseDTO); return ResultUtil.success(responseDTO, "新增用户成功"); } /** * 根据ID查询用户 * @GetMapping("/{id}"):GET请求方式,路径参数ID * @PathVariable:获取路径参数 */ @GetMapping("/{id}") public ResultVO<UserResponseDTO> getUserById(@PathVariable Long id) { log.info("接收查询用户请求,用户ID:{}", id); User user = userService.getUserById(id); UserResponseDTO responseDTO = new UserResponseDTO(); BeanUtils.copyProperties(user, responseDTO); return ResultUtil.success(responseDTO); } /** * 查询所有用户 * @GetMapping:GET请求方式,对应查询操作 */ @GetMapping public ResultVO<List<UserResponseDTO>> getAllUsers() { log.info("接收查询所有用户请求"); List<User> userList = userService.getAllUsers(); // 批量转换为响应DTO List<UserResponseDTO> responseDTOList = userList.stream() .map(user -> { UserResponseDTO dto = new UserResponseDTO(); BeanUtils.copyProperties(user, dto); return dto; }) .collect(Collectors.toList()); return ResultUtil.success(responseDTOList); } /** * 根据ID修改用户 * @PutMapping("/{id}"):PUT请求方式,对应修改操作 */ @PutMapping("/{id}") public ResultVO<UserResponseDTO> updateUser(@PathVariable Long id, @Validated @RequestBody UserRequestDTO userRequestDTO) { log.info("接收修改用户请求,用户ID:{},参数:{}", id, userRequestDTO); User user = userService.updateUser(id, userRequestDTO); UserResponseDTO responseDTO = new UserResponseDTO(); BeanUtils.copyProperties(user, responseDTO); return ResultUtil.success(responseDTO, "修改用户成功"); } /** * 根据ID删除用户 * @DeleteMapping("/{id}"):DELETE请求方式,对应删除操作 */ @DeleteMapping("/{id}") public ResultVO<Boolean> deleteUser(@PathVariable Long id) { log.info("接收删除用户请求,用户ID:{}", id); Boolean result = userService.deleteUser(id); return ResultUtil.success(result, "删除用户成功"); } } 5. 全局异常处理与响应工具(1)错误码枚举(ErrorCode.java):统一管理错误码与错误信息,便于维护: package com.example.springbootrestapidemo.exception; import lombok.AllArgsConstructor; import lombok.Getter; /** * 错误码枚举 */ @Getter @AllArgsConstructor public enum ErrorCode { // 通用错误 SUCCESS(200, "操作成功"), PARAM_ERROR(400, "参数校验失败"), USER_NOT_FOUND(404, "用户不存在"), USERNAME_ALREADY_EXISTS(409, "用户名已存在"), SYSTEM_ERROR(500, "系统内部错误"); // 错误码 private final Integer code; // 错误信息 private final String message; } (2)自定义业务异常(BusinessException.java):处理业务层面异常: package com.example.springbootrestapidemo.exception; import lombok.Getter; /** * 自定义业务异常 */ @Getter public class BusinessException extends RuntimeException { // 错误码 private final Integer code; // 错误信息 private final String message; // 构造方法(接收错误码枚举) public BusinessException(ErrorCode errorCode) { super(errorCode.getMessage()); this.code = errorCode.getCode(); this.message = errorCode.getMessage(); } // 构造方法(自定义错误信息) public BusinessException(Integer code, String message) { super(message); this.code = code; this.message = message; } } (3)全局异常处理器(GlobalExceptionHandler.java):统一捕获所有异常,返回标准化响应,避免直接向客户端暴露异常堆栈信息: package com.example.springbootrestapidemo.exception; import com.example.springbootrestapidemo.util.ResultUtil; import com.example.springbootrestapidemo.util.ResultVO; import lombok.extern.slf4j.Slf4j; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import javax.servlet.http.HttpServletRequest; /** * 全局异常处理器 * @RestControllerAdvice:全局异常处理注解,作用于所有@RestController */ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { /** * 处理自定义业务异常 */ @ExceptionHandler(BusinessException.class) public ResultVO<Void> handleBusinessException(BusinessException e, HttpServletRequest request) { log.error("业务异常:请求路径={},错误码={},错误信息={}", request.getRequestURI(), e.getCode(), e.getMessage(), e); return ResultUtil.fail(e.getCode(), e.getMessage()); } /** * 处理参数校验异常(@Validated注解触发) */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResultVO<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) { BindingResult bindingResult = e.getBindingResult(); // 获取第一个校验失败的字段与信息 FieldError fieldError = bindingResult.getFieldError(); String errorMsg = fieldError != null ? fieldError.getDefaultMessage() : "参数校验失败"; log.error("参数校验异常:请求路径={},错误信息={}", request.getRequestURI(), errorMsg, e); return ResultUtil.fail(ErrorCode.PARAM_ERROR.getCode(), errorMsg); } /** * 处理系统异常(兜底异常处理) */ @ExceptionHandler(Exception.class) public ResultVO<Void> handleSystemException(Exception e, HttpServletRequest request) { log.error("系统异常:请求路径={},错误信息={}", request.getRequestURI(), e.getMessage(), e); return ResultUtil.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage()); } } (4)响应结果工具类(ResultUtil.java)与响应VO(ResultVO.java):统一响应格式,便于前端解析: package com.example.springbootrestapidemo.util; import com.example.springbootrestapidemo.exception.ErrorCode; import lombok.Data; /** * 统一响应VO(View Object) * @param <T> 响应数据类型 */ @Data public class ResultVO<T> { // 状态码(200=成功,其他=失败) private Integer code; // 响应信息 private String message; // 响应数据 private T data; // 私有构造方法(通过静态方法创建对象) private ResultVO(Integer code, String message, T data) { this.code = code; this.message = message; this.data = data; } } // ResultUtil.java package com.example.springbootrestapidemo.util; import com.example.springbootrestapidemo.exception.ErrorCode; /** * 响应结果工具类(构建统一响应VO) */ public class ResultUtil { /** * 成功响应(无数据,默认信息) */ public static <T> ResultVO<T> success() { return new ResultVO<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null); } /** * 成功响应(有数据,默认信息) */ public static <T> ResultVO<T> success(T data) { return new ResultVO<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data); } /** * 成功响应(有数据,自定义信息) */ public static <T> ResultVO<T> success(T data, String message) { return new ResultVO<>(ErrorCode.SUCCESS.getCode(), message, data); } /** * 失败响应(自定义错误码与信息) */ public static <T> ResultVO<T> fail(Integer code, String message) { return new ResultVO<>(code, message, null); } /** * 失败响应(通过错误码枚举) */ public static <T> ResultVO<T> fail(ErrorCode errorCode) { return new ResultVO<>(errorCode.getCode(), errorCode.getMessage(), null); } } 6. 核心配置文件(application.yml)Spring Boot支持application.yml(推荐)与application.properties两种配置文件格式,yml格式更简洁,支持分层结构。本文配置端口、日志级别、热部署等核心参数: # 核心配置文件(默认环境) spring: profiles: active: dev # 激活开发环境配置(application-dev.yml) devtools: restart: enabled: true # 开启热部署 additional-paths: src/main/java # 热部署监听目录 exclude: WEB-INF/** # 热部署排除目录 # 服务器配置 server: port: 8080 # 服务端口 servlet: context-path: / # 应用上下文路径(默认/) # 日志配置 logging: level: root: INFO # 根日志级别 com.example.springbootrestapidemo: DEBUG # 项目包日志级别(开发环境设为DEBUG,生产环境设为INFO) file: name: logs/springboot-rest-api.log # 日志文件路径 max-size: 10MB # 单个日志文件最大大小 max-history: 7 # 日志文件保留天数 # 开发环境配置(application-dev.yml) # spring: # profiles: dev # logging: # level: # com.example.springbootrestapidemo: DEBUG # 生产环境配置(application-prod.yml) # spring: # profiles: prod # logging: # level: # com.example.springbootrestapidemo: INFO # server: # port: 80 # 生产环境默认80端口 四、接口测试与项目运行1. 项目运行直接运行SpringbootRestApiDemoApplication类的main方法,控制台输出“项目启动成功!访问地址:http://localhost:8080”表示启动成功。2. 接口测试(使用Postman)(1)新增用户:POST http://localhost:8080/api/v1/users,请求体(JSON): { "username": "zhangsan", "password": "Zhangsan123", "email": "zhangsan@example.com", "phone": "13812345678" } 响应结果(成功): { "code": 200, "message": "新增用户成功", "data": { "id": 1, "username": "zhangsan", "email": "zhangsan@example.com", "phone": "13812345678", "createTime": "2024-05-20T15:30:00", "updateTime": "2024-05-20T15:30:00" } } (2)查询用户:GET http://localhost:8080/api/v1/users/1,响应结果包含用户详细信息。(3)修改用户:PUT http://localhost:8080/api/v1/users/1,请求体修改对应参数,响应修改后的用户信息。(4)删除用户:DELETE http://localhost:8080/api/v1/users/1,响应删除成功信息。五、项目优化与扩展建议1. 密码加密:实际开发中,用户密码需通过BCrypt、SHA256等算法加密存储,Spring Boot提供了BCryptPasswordEncoder工具类,可直接使用。2. 数据库集成:替换内存存储为MySQL、PostgreSQL等关系型数据库,引入Spring Boot Starter Data JPA或MyBatis-Plus实现数据持久化。3. 分页查询:查询所有用户接口添加分页功能,通过PageHelper或Spring Data JPA的Pageable实现。4. 接口文档:引入Swagger/Knife4j自动生成接口文档,便于前后端对接。5. 权限控制:后续可引入Spring Security或Shiro实现接口权限管控。
-
1、 使用Git实现revert的完整操作步骤【转载】cid:link_02、C++中new关键字用法示例详解【转载】cid:link_13、在C# WinForm项目中跨.cs文件传值的六种常用方案【转载】cid:link_24、 一文带你搞懂Java中Error和Exception的区别【转载】cid:link_35、 Java中实现Word和TXT之间互相转换的实用教程【转载】cid:link_46、MyBatis-Plus 默认不更新null的4种方法【转载】cid:link_57、SpringBoot接口防抖的5种高效方案【转载】cid:link_68、 Java中锁分类及在什么场景下使用【转载】cid:link_79、 Java中锁的全面解析之类型、使用场景、优缺点及实现方式(示例代码【转载】cid:link_810、 Caffeine结合Redis空值缓存实现多级缓存【转载】cid:link_911、在PostgreSQL中优雅高效地进行全文检索的完整过程【转载】cid:link_1012、MySQL CDC原理解析及实现方案【转载】cid:link_1113、 PostgreSQL优雅的进行递归查询的实战指南【转载】cid:link_1214、Redis 常用命令之基础、进阶与场景化实战案例【转载】https://bbs.huaweicloud.com/forum/thread-0212720487861500817-1-1.html15、Git中忽略文件机制的.gitignore与.git/info/exclude两种方式详解【转载】https://bbs.huaweicloud.com/forum/thread-0212720487688092711-1-1.html
-
了解RedisRedis(Remote Dictionary Server)是一个开源的高性能键值对存储数据库。它支持多种数据结构,包括字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等。Redis的特点包括:内存存储:Redis将数据存储在内存中,因此读写速度非常快,适用于对性能有较高要求的场景。持久化:Redis支持持久化将内存中的数据保存到硬盘上,以便在服务器重启后能够恢复数据。数据结构多样:Redis不仅仅支持简单的键值对存储,还支持丰富的数据结构,例如列表、集合、有序集合等,使其具备更多的功能和用途。高并发:Redis是单线程模型,通过使用异步I/O和非阻塞I/O来支持高并发。多语言支持:Redis支持多种编程语言的客户端,如Java、Python、C#等,便于开发人员在不同平台上使用。发布/订阅:Redis支持发布/订阅模式,允许客户端订阅一个或多个频道并接收对应频道的消息。事务支持:Redis支持事务,可以在一个事务中执行多个命令,并保证这些命令的原子性。由于Redis具有高性能、灵活的数据结构和丰富的功能,它被广泛用于缓存、消息队列、计数器、实时排行榜、会话管理等多种应用场景。需求&为什么需要接口限流需求:针对相同IP,60s的接口请求次数不能超过10000次接口限流是为了保护系统和服务,防止因为过多的请求而导致系统过载、性能下降甚至崩溃。以下是进行接口限流的几个主要原因:防止恶意攻击:接口限流可以防止恶意用户或者攻击者通过大量的请求来攻击系统,保护系统的稳定性和安全性。保护系统资源:对于一些计算密集型或者资源消耗较大的接口,限制请求的频率可以避免服务器资源被过度消耗,保障其他正常请求的处理。避免雪崩效应:当某个服务不可用或者响应时间过长时,如果没有限流措施,大量请求可能会涌入后端,导致更多的请求失败,产生雪崩效应。提升系统性能:限流可以控制并发请求数,避免过多的请求导致服务器负载过高,从而提升系统的整体性能和响应速度。提供公平资源分配:通过限流,可以实现对不同用户或者不同服务请求的公平分配,避免某些请求占用过多资源而影响其他请求。综上所述,进行接口限流是保护系统和提升性能的重要手段,对于高并发的系统尤为重要。通过合理设置限流策略,可以有效地平衡资源利用和系统稳定性,提供更好的用户体验。实现方案方案一:固定时间段思路:当用户在第一次访问该接口时,向Redis中设置一个包含了用户IP和接口方法名的key,value的值初始化为1(表示第一次访问当前接口),同时设置该key的过期时间(60秒),只要此Redis的key没有过期,每次访问都将value的值自增1次,用户每次访问接口前,先从Redis中拿到当前接口访问次数,如果发现访问次数大于规定的次数(超过10000次),则向用户返回接口访问失败的标识。实现:(一)拦截器1、添加Redis依赖:首先在pom.xml文件中添加Spring Data Redis依赖1234<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId></dependency>2、 配置Redis连接信息:在application.properties或application.yml中配置Redis的连接信息,包括主机、端口、密码等。3、创建限流拦截器:在项目中创建一个限流拦截器,用于对用户IP进行接口限流。拦截器可以实现HandlerInterceptor接口,并重写preHandle方法进行限流逻辑。12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152import org.springframework.beans.factory.annotation.Autowired;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.util.concurrent.TimeUnit; public class RateLimitInterceptor implements HandlerInterceptor { @Autowired private RedisTemplate<String, String> redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String ipAddress = getIpAddress(request); String uri = request.getRequestURI().replace("/","_"); String key = "apiVisits:" + uri + ":" + ipAddress; // 判断是否已经达到限流次数 String value = redisTemplate.opsForValue().get(key); // key 不存在,则是第一次请求设置过期时间 if(StringUtils.isBlank(value)){ redisTemplate.opsForValue().increment(key, 1); redisTemplate.expire(key, time, TimeUnit.SECONDS); return true; } if (value != null && Integer.parseInt(value) > 10) { response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS); return false; } // 未达到限流次数,自增 redisTemplate.opsForValue().increment(key, 1); return true; } private String getIpAddress(HttpServletRequest request) { // 从请求头或代理头中获取真实IP地址 String ipAddress = request.getHeader("X-Forwarded-For"); if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getHeader("Proxy-Client-IP"); } if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getHeader("WL-Proxy-Client-IP"); } if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getRemoteAddr(); } return ipAddress; }}4、注册拦截器:在配置类中注册自定义的限流拦截器。123456789101112131415import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.InterceptorRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configurationpublic class WebMvcConfig implements WebMvcConfigurer { @Autowired private RateLimitInterceptor rateLimitInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/**"); }}(二)AOP以注解+切面的方式实现,将需要进行限流的API加上注解即可1、创建注解12345678910111213141516171819@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface CurrentLimiting { /** * 缓存key */ String key() default "apiVisits:"; /** * 限流时间,单位秒 */ int time() default 5; /** * 限流次数 */ int count() default 10;}2、创建AOP切面12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849@Slf4j@Aspect@Component@RequiredArgsConstructorpublic class CurrentLimitingAspect { private final RedisTemplate redisTemplate; /** * 带有注解的方法之前执行 */ @SuppressWarnings("unchecked") @Before("@annotation(currentLimiting)") public void doBefore(JoinPoint point, CurrentLimiting currentLimiting) throws Throwable { int time = currentLimiting.time(); int count = currentLimiting.count(); // 将接口方法和用户IP构建Redis的key String key = getCurrentLimitingKey(currentLimiting.key(), point); // 判断是否已经达到限流次数 String value = redisTemplate.opsForValue().get(key); if (value != null && Integer.parseInt(value) > count) { log.error("接口限流,key:{},count:{},currentCount:{}", key, count, value); throw new RuntimeException("访问过于频繁,请稍后再试!"); } // 未达到限流次数,自增 redisTemplate.opsForValue().increment(key, 1); // key 不存在,则是第一次请求设置过期时间 if(StringUtils.isBlank(value)){ redisTemplate.expire(key, time, TimeUnit.SECONDS); } } /** * 组装 redis 的 key */ private String getCurrentLimitingKey(String prefixKey,JoinPoint point) { StringBuilder sb = new StringBuilder(prefixKey); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); sb.append( Utils.getIpAddress(request) ); MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); Class<?> targetClass = method.getDeclaringClass(); return sb.append("_").append( targetClass.getName() ) .append("_").append(method.getName()).toString(); }}缺陷:当在10:00访问接口,这个时候向Reids写入一条数据访问次数为1,在10:59的时候突然访问了9999次,然后redis过期,在11:00访问了9999次,这样出现的问题就是在10:59到11:00之间访问了9999+9999次。故以固定时间段的方式进行限流可能会不起作用,会存在Reids过期的临界点内造成大量的用户访问。方案二:滑动窗口思路:由于方案一的时间是固定的,我们可以把固定的时间段改成动态的,也就是在用户每次访问接口时,记录当前用户访问的时间点(时间戳),并计算前一分钟内用户访问该接口的总次数。如果总次数大于限流次数,则不允许用户访问该接口。这样就能保证在任意时刻用户的访问次数不会超过10000次。实现:1、创建注解12345678910111213141516171819@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface CurrentLimiting { /** * 缓存key */ String key() default "apiVisits:"; /** * 限流时间,单位秒 */ int time() default 5; /** * 限流次数 */ int count() default 10;}2、创建AOP切面123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657@Slf4j@Aspect@Component@RequiredArgsConstructorpublic class CurrentLimitingAspect { private final RedisTemplate redisTemplate; /** * 带有注解的方法之前执行 */ @SuppressWarnings("unchecked") @Before("@annotation(currentLimiting)") public void doBefore(JoinPoint point, CurrentLimiting currentLimiting) throws Throwable { int time = currentLimiting.time(); int count = currentLimiting.count(); // 将接口方法和用户IP构建Redis的key String key = getCurrentLimitingKey(currentLimiting.key(), point); // 使用Zset的 score 设置成用户访问接口的时间戳 ZSetOperations zSetOperations = redisTemplate.opsForZSet(); // 当前时间戳 long currentTime = System.currentTimeMillis(); zSetOperations.add(key, currentTime, currentTime); // 设置过期时间防止key不消失 redisTemplate.expire(key, time, TimeUnit.SECONDS); // 移除 time 秒之前的访问记录,动态时间段 zSetOperations.removeRangeByScore(key, 0, currentTime - time * 1000); // 获得当前时间窗口内的访问记录数 Long currentCount = zSetOperations.zCard(key); // 限流判断 if (currentCount > count) { log.error("接口限流,key:{},count:{},currentCount:{}", key, count, currentCount); throw new RuntimeException("访问过于频繁,请稍后再试!"); } } /** * 组装 redis 的 key */ private String getCurrentLimitingKey(String prefixKey,JoinPoint point) { StringBuilder sb = new StringBuilder(prefixKey); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); sb.append( Utils.getIpAddress(request) ); MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); Class<?> targetClass = method.getDeclaringClass(); return sb.append("_").append( targetClass.getName() ) .append("_").append(method.getName()).toString(); }}
-
接口防抖是一种用于限制用户重复提交请求的机制。在Web开发中,用户可能会因为网络延迟或者多次点击按钮而导致多次提交同一个请求,这可能会对系统产生不必要的压力,或者导致数据异常。接口防抖就是为了解决这个问题而设计的。接口防抖的基本原理是,在接收到一个请求后,服务器会在一定的时间内暂时忽略后续相同的请求,直到这段时间过去,才会再次处理新的请求。这样可以有效地避免重复提交导致的问题。常见的接口防抖实现方式包括基于缓存、基于Token、基于拦截器等。在Spring Boot中,我们可以利用拦截器、过滤器或者切面等机制来实现接口防抖功能。下面我们就来看看在SpringBoot中如何实现这些操作。基于缓存实现防抖这里使用了ConcurrentHashMap作为缓存来实现,当然在实际操作的时候还可以使用Redis缓存或者是Memcached来作为缓存操作。123456789101112131415161718192021@Componentpublic class DebounceInterceptor { private static final ConcurrentHashMap<String, Long> CACHE = new ConcurrentHashMap<>(); private static final long EXPIRE_TIME = 5000; // 5秒内重复请求会被拦截 public boolean shouldIntercept(HttpServletRequest request) { String key = request.getMethod() + ":" + request.getRequestURI() + ":" + request.getParameterMap(); long now = System.currentTimeMillis(); if (CACHE.containsKey(key)) { long lastRequestTime = CACHE.get(key); if (now - lastRequestTime < EXPIRE_TIME) { return true; // 请求被拦截 } } CACHE.put(key, now); return false; // 请求通过 }}在上面的代码中,我们使用ConcurrentHashMap来存储请求信息,其中key是请求的方法、URL和参数,value是请求的时间戳。如果同样的请求在5秒内重复出现,则会被拦截。然后,我们创建一个拦截器来应用这个防抖逻辑,如下所示。12345678910111213141516public class DebounceInterceptor implements HandlerInterceptor { @Autowired private DebounceService debounceService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (debounceService.shouldIntercept(request)) { // 返回429状态码表示请求过多 response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS); return false; } return true; }}最后,在配置类中注册这个拦截器。如下所示。12345678@Configurationpublic class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new DebounceInterceptor()).addPathPatterns("/**"); }}基于Token实现防抖通过在请求中添加Token,在一定时间内禁止相同的Token重复提交,代码如下所示。1234567891011121314151617181920212223@Componentpublic class TokenInterceptor { private static final ConcurrentHashMap<String, Long> CACHE = new ConcurrentHashMap<>(); private static final long EXPIRE_TIME = 5000; // 5秒内重复Token会被拦截 public boolean shouldIntercept(HttpServletRequest request) { String token = request.getHeader("X-Request-Token"); if (StringUtils.isEmpty(token)) { return false; // 请求通过,没有Token } long now = System.currentTimeMillis(); if (CACHE.containsKey(token)) { long lastRequestTime = CACHE.get(token); if (now - lastRequestTime < EXPIRE_TIME) { return true; // 请求被拦截 } } CACHE.put(token, now); return false; // 请求通过 }}在这种方法中,我们需要在请求头中添加一个Token,然后使用ConcurrentHashMap来存储Token和请求时间的映射关系。如果相同的Token在5秒内重复出现,则会被拦截。然后,我们创建一个拦截器来应用这个防抖逻辑,与上面的方法类似。基于注解实现防抖通过自定义注解来实现防抖,使得只需要在需要防抖的接口上添加注解即可。定义注解类12345@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface Debounce { long value() default 5000; // 默认5秒内重复请求会被拦截}创建切面类来实现拦截注解的操作。1234567891011121314151617181920212223242526@Aspect@Componentpublic class DebounceAspect { private static final ConcurrentHashMap<String, Long> CACHE = new ConcurrentHashMap<>(); @Autowired private HttpServletRequest request; @Around("@annotation(debounce)") public Object debounce(ProceedingJoinPoint joinPoint, Debounce debounce) throws Throwable { String key = request.getMethod() + ":" + request.getRequestURI() + ":" + request.getParameterMap(); long now = System.currentTimeMillis(); if (CACHE.containsKey(key)) { long lastRequestTime = CACHE.get(key); if (now - lastRequestTime < debounce.value()) { return null; // 请求被拦截 } } CACHE.put(key, now); return joinPoint.proceed(); // 请求通过 }}
-
背景:已经运行的项目,数据采用的是mysql数据,使用springBatch框架做的数据同步。然后信创改造,最近公司采购了华为GaussDB,需要切换mysql数据库到GaussDB。由于springBtach框架最新版本也不支持GaussDB。存在的问题:1、直接切换数据库连接,项目启动时springBatch会检查数据库类型,直接报错。求助:在不更换springBatch框架的情况下,有没有代码改动最小的改造方案?
-
一、监控体系设计1.1 关键监控指标java@Componentpublic class SSOMetricsCollector { private final MeterRegistry meterRegistry; // 计数器指标 private final Counter loginSuccessCounter; private final Counter loginFailureCounter; private final Counter tokenIssuedCounter; private final Counter tokenRefreshedCounter; private final Counter tokenRevokedCounter; // 计时器指标 private final Timer loginProcessingTimer; private final Timer tokenValidationTimer; private final Timer userInfoRetrievalTimer; // 仪表盘指标 private final Gauge activeSessionsGauge; private final Gauge activeTokensGauge; private final Gauge concurrentRequestsGauge; public SSOMetricsCollector(MeterRegistry meterRegistry, SessionService sessionService, TokenService tokenService) { this.meterRegistry = meterRegistry; // 初始化计数器 loginSuccessCounter = Counter.builder("sso.login.success") .description("Successful login attempts") .tag("type", "total") .register(meterRegistry); loginFailureCounter = Counter.builder("sso.login.failure") .description("Failed login attempts") .tag("type", "total") .register(meterRegistry); tokenIssuedCounter = Counter.builder("sso.token.issued") .description("Tokens issued") .register(meterRegistry); // 初始化计时器 loginProcessingTimer = Timer.builder("sso.login.duration") .description("Time spent processing login requests") .publishPercentiles(0.5, 0.95, 0.99) .register(meterRegistry); // 初始化仪表盘 activeSessionsGauge = Gauge.builder("sso.sessions.active", sessionService::getActiveSessionCount) .description("Number of active sessions") .register(meterRegistry); activeTokensGauge = Gauge.builder("sso.tokens.active", tokenService::getActiveTokenCount) .description("Number of active tokens") .register(meterRegistry); } public void recordLoginSuccess(String username, String clientId, long duration) { loginSuccessCounter.increment(); // 按用户和客户端统计 meterRegistry.counter("sso.login.success", "username", username, "client_id", clientId ).increment(); loginProcessingTimer.record(duration, TimeUnit.MILLISECONDS); } public void recordLoginFailure(String username, String clientId, String reason) { loginFailureCounter.increment(); meterRegistry.counter("sso.login.failure", "username", username, "client_id", clientId, "reason", reason ).increment(); } public void recordTokenIssued(String tokenType, String username, String clientId) { tokenIssuedCounter.increment(); meterRegistry.counter("sso.token.issued.detail", "token_type", tokenType, "username", username, "client_id", clientId ).increment(); } public Map<String, Object> getHealthMetrics() { Map<String, Object> metrics = new HashMap<>(); // 系统健康指标 metrics.put("status", "UP"); metrics.put("timestamp", Instant.now().toString()); // 性能指标 metrics.put("login_success_rate", calculateSuccessRate()); metrics.put("avg_login_duration_ms", getAverageLoginDuration()); metrics.put("active_sessions", activeSessionsGauge.value()); metrics.put("active_tokens", activeTokensGauge.value()); // 系统资源指标 Runtime runtime = Runtime.getRuntime(); metrics.put("memory_used_mb", (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024); metrics.put("memory_max_mb", runtime.maxMemory() / 1024 / 1024); metrics.put("available_processors", runtime.availableProcessors()); return metrics; } private double calculateSuccessRate() { double success = loginSuccessCounter.count(); double failure = loginFailureCounter.count(); double total = success + failure; return total > 0 ? (success / total) * 100 : 0; } private double getAverageLoginDuration() { return loginProcessingTimer.mean(TimeUnit.MILLISECONDS); }}1.2 实时监控端点java@RestController@RequestMapping("/api/monitor")public class MonitorController { @Autowired private SSOMetricsCollector metricsCollector; @Autowired private AuditLogService auditLogService; @GetMapping("/health") public ResponseEntity<Map<String, Object>> health() { Map<String, Object> healthInfo = new HashMap<>(); healthInfo.put("service", "SSO Service"); healthInfo.put("status", "UP"); healthInfo.put("version", "1.0.0"); healthInfo.put("timestamp", Instant.now().toString()); // 添加指标数据 healthInfo.put("metrics", metricsCollector.getHealthMetrics()); // 检查依赖服务 healthInfo.put("dependencies", checkDependencies()); return ResponseEntity.ok(healthInfo); } @GetMapping("/metrics") public ResponseEntity<Map<String, Object>> metrics() { Map<String, Object> metrics = new HashMap<>(); // JVM指标 metrics.put("jvm", getJvmMetrics()); // 应用指标 metrics.put("application", getApplicationMetrics()); // 业务指标 metrics.put("business", getBusinessMetrics()); // 性能指标 metrics.put("performance", getPerformanceMetrics()); return ResponseEntity.ok(metrics); } @GetMapping("/audit/recent") public ResponseEntity<Page<AuditLog>> getRecentAuditLogs( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "timestamp")); Page<AuditLog> logs = auditLogService.getRecentLogs(pageable); return ResponseEntity.ok(logs); } @GetMapping("/audit/search") public ResponseEntity<List<AuditLog>> searchAuditLogs( @RequestParam(required = false) String username, @RequestParam(required = false) String action, @RequestParam(required = false) String resource, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate) { List<AuditLog> logs = auditLogService.searchLogs( username, action, resource, fromDate, toDate); return ResponseEntity.ok(logs); } @GetMapping("/alerts") public ResponseEntity<List<Alert>> getActiveAlerts() { List<Alert> alerts = alertService.getActiveAlerts(); return ResponseEntity.ok(alerts); } private Map<String, Object> getJvmMetrics() { Map<String, Object> jvmMetrics = new HashMap<>(); Runtime runtime = Runtime.getRuntime(); jvmMetrics.put("memory", Map.of( "used", runtime.totalMemory() - runtime.freeMemory(), "max", runtime.maxMemory(), "free", runtime.freeMemory() )); jvmMetrics.put("threads", Map.of( "active", Thread.activeCount(), "peak", ManagementFactory.getThreadMXBean().getPeakThreadCount() )); jvmMetrics.put("gc", getGarbageCollectionMetrics()); return jvmMetrics; } private Map<String, Object> getGarbageCollectionMetrics() { Map<String, Object> gcMetrics = new HashMap<>(); for (GarbageCollectorMXBean gcBean : ManagementFactory.getGarbageCollectorMXBeans()) { gcMetrics.put(gcBean.getName(), Map.of( "count", gcBean.getCollectionCount(), "time", gcBean.getCollectionTime() )); } return gcMetrics; }}二、结构化日志2.1 Logback配置xml<?xml version="1.0" encoding="UTF-8"?><configuration> <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/> <property name="JSON_LOG_PATTERN" value='{"timestamp":"%d{yyyy-MM-dd HH:mm:ss.SSS}", "level":"%level", "thread":"%thread", "logger":"%logger", "message":"%msg", "tenant":"%X{tenantId}", "user":"%X{username}", "traceId":"%X{traceId}", "spanId":"%X{spanId}"}%n'/> <!-- 控制台输出(开发环境) --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>${LOG_PATTERN}</pattern> </encoder> </appender> <!-- JSON文件输出(生产环境) --> <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/sso-service.json</file> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <customFields>{"service":"sso-service","environment":"${ENV:-dev}"}</customFields> <includeContext>true</includeContext> <includeMdc>true</includeMdc> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/sso-service-%d{yyyy-MM-dd}.json.gz</fileNamePattern> <maxHistory>30</maxHistory> <totalSizeCap>3GB</totalSizeCap> </rollingPolicy> </appender> <!-- 审计日志单独文件 --> <appender name="AUDIT_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/audit.log</file> <encoder> <pattern>${JSON_LOG_PATTERN}</pattern> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/audit-%d{yyyy-MM-dd}.log.gz</fileNamePattern> <maxHistory>90</maxHistory> </rollingPolicy> </appender> <!-- 安全日志单独文件 --> <appender name="SECURITY_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/security.log</file> <encoder> <pattern>${JSON_LOG_PATTERN}</pattern> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/security-%d{yyyy-MM-dd}.log.gz</fileNamePattern> <maxHistory>90</maxHistory> </rollingPolicy> </appender> <!-- Logger配置 --> <logger name="com.example.sso.audit" level="INFO" additivity="false"> <appender-ref ref="AUDIT_FILE"/> </logger> <logger name="com.example.sso.security" level="WARN" additivity="false"> <appender-ref ref="SECURITY_FILE"/> </logger> <!-- 第三方库日志级别 --> <logger name="org.springframework.security" level="INFO"/> <logger name="org.springframework.web" level="INFO"/> <logger name="org.hibernate" level="WARN"/> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="JSON_FILE"/> </root> </configuration>2.2 审计日志服务java@Service@Slf4jpublic class AuditLogService { @Autowired private AuditLogRepository auditLogRepository; @Autowired private MDCService mdcService; public void logAuthenticationSuccess(String username, String clientId, String ipAddress, String userAgent) { AuditLog log = AuditLog.builder() .timestamp(Instant.now()) .username(username) .action("LOGIN_SUCCESS") .resource("AUTHENTICATION") .details(Map.of( "client_id", clientId, "ip_address", ipAddress, "user_agent", userAgent, "result", "SUCCESS" )) .tenantId(mdcService.getTenantId()) .traceId(mdcService.getTraceId()) .build(); auditLogRepository.save(log); // 同时输出到日志文件 log.info("用户 {} 登录成功,客户端: {},IP: {}", username, clientId, ipAddress); } public void logAuthenticationFailure(String username, String clientId, String ipAddress, String userAgent, String failureReason) { AuditLog log = AuditLog.builder() .timestamp(Instant.now()) .username(username) .action("LOGIN_FAILURE") .resource("AUTHENTICATION") .details(Map.of( "client_id", clientId, "ip_address", ipAddress, "user_agent", userAgent, "failure_reason", failureReason, "result", "FAILURE" )) .tenantId(mdcService.getTenantId()) .traceId(mdcService.getTraceId()) .build(); auditLogRepository.save(log); // 安全告警日志 log.warn("用户 {} 登录失败,原因: {},IP: {}", username, failureReason, ipAddress); } public void logTokenIssued(String username, String tokenType, String clientId, Map<String, Object> tokenDetails) { AuditLog log = AuditLog.builder() .timestamp(Instant.now()) .username(username) .action("TOKEN_ISSUED") .resource("TOKEN") .details(Map.of( "token_type", tokenType, "client_id", clientId, "token_details", tokenDetails )) .tenantId(mdcService.getTenantId()) .traceId(mdcService.getTraceId()) .build(); auditLogRepository.save(log); } public void logAccess(String username, String resource, String action, boolean allowed, Map<String, Object> accessDetails) { AuditLog log = AuditLog.builder() .timestamp(Instant.now()) .username(username) .action(action) .resource(resource) .details(Map.of( "access_allowed", allowed, "access_details", accessDetails )) .tenantId(mdcService.getTenantId()) .traceId(mdcService.getTraceId()) .build(); auditLogRepository.save(log); } public void logAdminAction(String adminUsername, String action, String targetResource, Map<String, Object> actionDetails) { AuditLog log = AuditLog.builder() .timestamp(Instant.now()) .username(adminUsername) .action("ADMIN_" + action) .resource(targetResource) .details(actionDetails) .tenantId(mdcService.getTenantId()) .traceId(mdcService.getTraceId()) .build(); auditLogRepository.save(log); // 管理员操作需要特别记录 log.info("管理员 {} 执行操作: {},目标资源: {}", adminUsername, action, targetResource); } public Page<AuditLog> searchLogs(String username, String action, String resource, LocalDate fromDate, LocalDate toDate, Pageable pageable) { Specification<AuditLog> spec = Specification.where(null); if (username != null) { spec = spec.and((root, query, cb) -> cb.equal(root.get("username"), username)); } if (action != null) { spec = spec.and((root, query, cb) -> cb.equal(root.get("action"), action)); } if (resource != null) { spec = spec.and((root, query, cb) -> cb.equal(root.get("resource"), resource)); } if (fromDate != null) { spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("timestamp"), fromDate.atStartOfDay())); } if (toDate != null) { spec = spec.and((root, query, cb) -> cb.lessThan(root.get("timestamp"), toDate.plusDays(1).atStartOfDay())); } return auditLogRepository.findAll(spec, pageable); } public List<AuditStat> getAuditStatistics(LocalDate fromDate, LocalDate toDate) { List<AuditStat> stats = new ArrayList<>(); // 按操作类型统计 List<Object[]> actionStats = auditLogRepository .countByActionType(fromDate.atStartOfDay(), toDate.plusDays(1).atStartOfDay()); for (Object[] row : actionStats) { AuditStat stat = new AuditStat(); stat.setCategory("ACTION_TYPE"); stat.setKey((String) row[0]); stat.setValue(((Long) row[1]).intValue()); stats.add(stat); } // 按用户统计 List<Object[]> userStats = auditLogRepository .countByUser(fromDate.atStartOfDay(), toDate.plusDays(1).atStartOfDay(), 10); // 前10个 for (Object[] row : userStats) { AuditStat stat = new AuditStat(); stat.setCategory("TOP_USERS"); stat.setKey((String) row[0]); stat.setValue(((Long) row[1]).intValue()); stats.add(stat); } // 按时间段统计 List<Object[]> hourlyStats = auditLogRepository .countByHour(fromDate.atStartOfDay(), toDate.plusDays(1).atStartOfDay()); for (Object[] row : hourlyStats) { AuditStat stat = new AuditStat(); stat.setCategory("HOURLY_DISTRIBUTION"); stat.setKey("HOUR_" + row[0]); stat.setValue(((Long) row[1]).intValue()); stats.add(stat); } return stats; }}三、安全审计跟踪3.1 合规性审计java@Componentpublic class ComplianceAuditService { @Autowired private AuditLogRepository auditLogRepository; @Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点执行 public void generateDailyComplianceReport() { LocalDate yesterday = LocalDate.now().minusDays(1); ComplianceReport report = new ComplianceReport(); report.setReportDate(yesterday); report.setGeneratedAt(Instant.now()); // 收集审计数据 report.setLoginStats(getLoginStatistics(yesterday)); report.setAccessStats(getAccessStatistics(yesterday)); report.setSecurityEvents(getSecurityEvents(yesterday)); report.setAdminActions(getAdminActions(yesterday)); // 检查合规性规则 report.setComplianceChecks(runComplianceChecks(yesterday)); // 生成报告文件 String reportContent = generateReportContent(report); saveReportToStorage(reportContent, yesterday); // 发送报告给相关人员 sendReportByEmail(report); log.info("生成合规性报告完成: {}", yesterday); } private Map<String, Object> getLoginStatistics(LocalDate date) { Map<String, Object> stats = new HashMap<>(); // 总登录次数 Long totalLogins = auditLogRepository.countLoginsByDate( date.atStartOfDay(), date.plusDays(1).atStartOfDay()); stats.put("total_logins", totalLogins); // 成功/失败次数 Long successLogins = auditLogRepository.countSuccessfulLoginsByDate( date.atStartOfDay(), date.plusDays(1).atStartOfDay()); Long failedLogins = auditLogRepository.countFailedLoginsByDate( date.atStartOfDay(), date.plusDays(1).atStartOfDay()); stats.put("successful_logins", successLogins); stats.put("failed_logins", failedLogins); stats.put("success_rate", totalLogins > 0 ? (double) successLogins / totalLogins * 100 : 0); // 唯一用户数 Long uniqueUsers = auditLogRepository.countUniqueUsersByDate( date.atStartOfDay(), date.plusDays(1).atStartOfDay()); stats.put("unique_users", uniqueUsers); return stats; } private List<ComplianceCheck> runComplianceChecks(LocalDate date) { List<ComplianceCheck> checks = new ArrayList<>(); // 检查1: 是否有多次失败登录 ComplianceCheck check1 = new ComplianceCheck(); check1.setCheckName("多次失败登录检查"); check1.setDescription("检查是否有账户存在多次失败登录"); List<FailedLoginAttempt> failedAttempts = auditLogRepository .findExcessiveFailedLogins(date.atStartOfDay(), date.plusDays(1).atStartOfDay(), 5); check1.setPassed(failedAttempts.isEmpty()); check1.setDetails(failedAttempts); checks.add(check1); // 检查2: 管理员操作审计 ComplianceCheck check2 = new ComplianceCheck(); check2.setCheckName("管理员操作审计"); check2.setDescription("检查管理员关键操作是否都有记录"); List<AdminAction> adminActions = auditLogRepository .findAdminActionsWithoutAudit(date.atStartOfDay(), date.plusDays(1).atStartOfDay()); check2.setPassed(adminActions.isEmpty()); check2.setDetails(adminActions); checks.add(check2); // 检查3: 令牌使用合规性 ComplianceCheck check3 = new ComplianceCheck(); check3.setCheckName("令牌合规性检查"); check3.setDescription("检查令牌颁发和使用是否符合策略"); List<TokenViolation> tokenViolations = tokenService .findTokenPolicyViolations(date.atStartOfDay(), date.plusDays(1).atStartOfDay()); check3.setPassed(tokenViolations.isEmpty()); check3.setDetails(tokenViolations); checks.add(check3); return checks; }}3.2 实时告警系统java@Servicepublic class RealTimeAlertService { @Autowired private AlertRepository alertRepository; @Autowired private NotificationService notificationService; private final Map<String, AlertRule> alertRules = new ConcurrentHashMap<>(); @PostConstruct public void initAlertRules() { // 初始化告警规则 alertRules.put("MULTIPLE_FAILED_LOGINS", AlertRule.builder() .ruleId("MULTIPLE_FAILED_LOGINS") .name("多次登录失败") .description("同一账户在短时间内多次登录失败") .threshold(5) .timeWindow(300) // 5分钟 .severity(AlertSeverity.HIGH) .enabled(true) .build()); alertRules.put("SUSPICIOUS_IP", AlertRule.builder() .ruleId("SUSPICIOUS_IP") .name("可疑IP地址") .description("来自异常地理位置的登录") .threshold(1) .timeWindow(3600) // 1小时 .severity(AlertSeverity.MEDIUM) .enabled(true) .build()); alertRules.put("TOKEN_ABUSE", AlertRule.builder() .ruleId("TOKEN_ABUSE") .name("令牌滥用") .description("同一令牌在短时间内被频繁使用") .threshold(100) .timeWindow(60) // 1分钟 .severity(AlertSeverity.HIGH) .enabled(true) .build()); } @EventListener public void handleLoginFailure(AuthenticationFailureEvent event) { String username = event.getUsername(); String ipAddress = event.getIpAddress(); // 检查是否触发告警规则 checkMultipleFailedLogins(username, ipAddress); checkSuspiciousLocation(username, ipAddress); } @EventListener public void handleTokenUsage(TokenUsageEvent event) { String token = event.getToken(); String username = event.getUsername(); // 检查令牌使用频率 checkTokenUsageFrequency(token, username); } private void checkMultipleFailedLogins(String username, String ipAddress) { AlertRule rule = alertRules.get("MULTIPLE_FAILED_LOGINS"); if (!rule.isEnabled()) { return; } // 查询最近失败次数 long failedCount = auditLogRepository.countFailedLogins( username, ipAddress, rule.getTimeWindow()); if (failedCount >= rule.getThreshold()) { // 触发告警 Alert alert = Alert.builder() .alertId(UUID.randomUUID().toString()) .ruleId(rule.getRuleId()) .severity(rule.getSeverity()) .title("多次登录失败告警") .description(String.format("用户 %s 在 %d 分钟内登录失败 %d 次", username, rule.getTimeWindow() / 60, failedCount)) .details(Map.of( "username", username, "ip_address", ipAddress, "failed_count", failedCount, "time_window_minutes", rule.getTimeWindow() / 60 )) .triggeredAt(Instant.now()) .status(AlertStatus.ACTIVE) .build(); saveAlert(alert); sendAlertNotifications(alert); } } private void checkSuspiciousLocation(String username, String ipAddress) { // 检查IP地理位置 GeoLocation location = ipGeoService.getLocation(ipAddress); if (location == null) { return; } // 获取用户常用登录地点 List<GeoLocation> usualLocations = userService.getUsualLocations(username); // 检查是否与常用地点差异较大 boolean isSuspicious = usualLocations.stream() .noneMatch(usual -> isNearby(usual, location, 100)); // 100公里内 if (isSuspicious) { Alert alert = Alert.builder() .alertId(UUID.randomUUID().toString()) .ruleId("SUSPICIOUS_IP") .severity(AlertSeverity.MEDIUM) .title("可疑地理位置登录") .description(String.format("用户 %s 从异常位置登录: %s", username, location.getCity())) .details(Map.of( "username", username, "ip_address", ipAddress, "location", location.toString(), "usual_locations", usualLocations.toString() )) .triggeredAt(Instant.now()) .status(AlertStatus.ACTIVE) .build(); saveAlert(alert); sendAlertNotifications(alert); } } private void checkTokenUsageFrequency(String token, String username) { AlertRule rule = alertRules.get("TOKEN_ABUSE"); if (!rule.isEnabled()) { return; } // 检查令牌使用频率 long usageCount = tokenUsageService.getUsageCount( token, rule.getTimeWindow()); if (usageCount >= rule.getThreshold()) { Alert alert = Alert.builder() .alertId(UUID.randomUUID().toString()) .ruleId(rule.getRuleId()) .severity(rule.getSeverity()) .title("令牌滥用告警") .description(String.format("令牌在 %d 秒内被使用 %d 次", rule.getTimeWindow(), usageCount)) .details(Map.of( "username", username, "token", maskToken(token), "usage_count", usageCount, "time_window_seconds", rule.getTimeWindow() )) .triggeredAt(Instant.now()) .status(AlertStatus.ACTIVE) .build(); saveAlert(alert); sendAlertNotifications(alert); // 可以自动采取措施,如临时锁定账户 userService.temporarilyLockAccount(username, 300); // 锁定5分钟 } } private void saveAlert(Alert alert) { alertRepository.save(alert); log.warn("触发安全告警: {}", alert.getTitle()); } private void sendAlertNotifications(Alert alert) { // 发送给安全团队 notificationService.sendSecurityAlert(alert); // 发送给用户(如果是其账户相关) if (alert.getDetails().containsKey("username")) { String username = (String) alert.getDetails().get("username"); notificationService.sendUserSecurityAlert(username, alert); } // 记录到审计日志 auditLogService.logSecurityAlert(alert); } private String maskToken(String token) { if (token == null || token.length() <= 8) { return "***"; } return token.substring(0, 4) + "..." + token.substring(token.length() - 4); } private boolean isNearby(GeoLocation loc1, GeoLocation loc2, double maxDistanceKm) { // 计算两个地理位置之间的距离 double distance = calculateDistance(loc1, loc2); return distance <= maxDistanceKm; }}
-
一、多租户架构设计1.1 租户隔离策略java@Configurationpublic class MultiTenantConfig { @Bean public CurrentTenantIdentifierResolver tenantIdentifierResolver() { return new CurrentTenantIdentifierResolver() { @Override public String resolveCurrentTenantIdentifier() { // 从请求头获取租户ID RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); String tenantId = request.getHeader("X-Tenant-Id"); if (tenantId == null) { // 从域名解析租户 tenantId = extractTenantFromDomain(request.getServerName()); } return tenantId != null ? tenantId : "default"; } return "default"; } @Override public boolean validateExistingCurrentSessions() { return false; } }; } @Bean public MultiTenantConnectionProvider multiTenantConnectionProvider() { return new MultiTenantConnectionProvider() { @Override public Connection getConnection(String tenantIdentifier) throws SQLException { DataSource dataSource = getTenantDataSource(tenantIdentifier); return dataSource.getConnection(); } @Override public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException { connection.close(); } @Override public boolean supportsAggressiveRelease() { return true; } private DataSource getTenantDataSource(String tenantId) { // 根据租户ID获取对应的数据源 return dataSourceMap.get(tenantId); } }; } @Bean public LocalContainerEntityManagerFactoryBean entityManagerFactory( MultiTenantConnectionProvider multiTenantConnectionProvider, CurrentTenantIdentifierResolver tenantIdentifierResolver) { LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean(); em.setDataSource(dataSource()); em.setPackagesToScan("com.example.sso"); em.setJpaVendorAdapter(jpaVendorAdapter()); Map<String, Object> properties = new HashMap<>(); properties.put("hibernate.multiTenancy", "DATABASE"); properties.put("hibernate.tenant_identifier_resolver", tenantIdentifierResolver); properties.put("hibernate.multi_tenant_connection_provider", multiTenantConnectionProvider); em.setJpaPropertyMap(properties); return em; }}1.2 租户数据路由java@Componentpublic class TenantDataRouter { private final Map<String, TenantConfig> tenantConfigs = new ConcurrentHashMap<>(); public TenantConfig getTenantConfig(String tenantId) { return tenantConfigs.computeIfAbsent(tenantId, this::loadTenantConfig); } public String getTenantDatabase(String tenantId) { TenantConfig config = getTenantConfig(tenantId); if (config.getDatabaseStrategy() == DatabaseStrategy.SHARED) { return "shared_db"; } else if (config.getDatabaseStrategy() == DatabaseStrategy.ISOLATED) { return "tenant_db_" + tenantId; } else { // 分片策略 int shard = Math.abs(tenantId.hashCode()) % 10; return "shard_db_" + shard; } } public String getTenantRedisPrefix(String tenantId) { return "tenant:" + tenantId + ":"; } public String getTenantStoragePath(String tenantId) { TenantConfig config = getTenantConfig(tenantId); if (config.getStorageStrategy() == StorageStrategy.SHARED) { return "/storage/shared/" + tenantId + "/"; } else { return "/storage/tenants/" + tenantId + "/"; } } private TenantConfig loadTenantConfig(String tenantId) { // 从数据库或配置中心加载租户配置 TenantConfig config = new TenantConfig(); config.setTenantId(tenantId); config.setDatabaseStrategy(DatabaseStrategy.ISOLATED); config.setStorageStrategy(StorageStrategy.SHARED); config.setCustomLoginPageEnabled(true); config.setBrandingEnabled(true); config.setMaxUsers(1000); config.setActive(true); return config; }}二、租户定制化2.1 动态登录页面java@Controller@RequestMapping("/{tenantId}/login")public class TenantLoginController { @Autowired private TenantService tenantService; @GetMapping public String loginPage(@PathVariable String tenantId, Model model, HttpServletRequest request) { TenantConfig tenantConfig = tenantService.getTenantConfig(tenantId); if (!tenantConfig.isActive()) { return "error/tenant-inactive"; } // 设置租户特定的属性 model.addAttribute("tenantId", tenantId); model.addAttribute("tenantName", tenantConfig.getTenantName()); model.addAttribute("logoUrl", tenantConfig.getLogoUrl()); model.addAttribute("primaryColor", tenantConfig.getPrimaryColor()); model.addAttribute("backgroundColor", tenantConfig.getBackgroundColor()); // 自定义字段 if (tenantConfig.getCustomFields() != null) { model.addAttribute("customFields", tenantConfig.getCustomFields()); } // 检查是否有自定义模板 String templatePath = findTenantTemplate(tenantId); if (templatePath != null) { return templatePath; } return "login/default"; } @PostMapping public String processLogin(@PathVariable String tenantId, @RequestParam String username, @RequestParam String password, HttpServletRequest request) { // 验证租户状态 if (!tenantService.isTenantActive(tenantId)) { return "redirect:/error/tenant-inactive"; } // 租户特定的认证逻辑 Authentication authentication = tenantAuthenticationService .authenticate(tenantId, username, password); if (authentication != null && authentication.isAuthenticated()) { // 设置租户上下文 TenantContext.setCurrentTenant(tenantId); // 生成租户特定的令牌 String token = tokenService.generateTenantToken( authentication, tenantId ); // 重定向到租户首页 return "redirect:/" + tenantId + "/dashboard"; } return "redirect:/" + tenantId + "/login?error"; } private String findTenantTemplate(String tenantId) { // 检查是否存在租户自定义模板 String[] templateLocations = { "classpath:templates/tenants/" + tenantId + "/login.html", "classpath:templates/tenants/" + tenantId + "/login.ftl", "file:/var/www/templates/" + tenantId + "/login.html" }; for (String location : templateLocations) { Resource resource = resourceLoader.getResource(location); if (resource.exists()) { return "tenants/" + tenantId + "/login"; } } return null; }}2.2 租户品牌管理java@Servicepublic class TenantBrandingService { @Autowired private StorageService storageService; public TenantBranding getBranding(String tenantId) { TenantBranding branding = cacheService.get("branding:" + tenantId, TenantBranding.class); if (branding == null) { branding = loadBrandingFromStorage(tenantId); cacheService.put("branding:" + tenantId, branding, 1, TimeUnit.HOURS); } return branding; } public void updateBranding(String tenantId, BrandingUpdateRequest request) { TenantBranding branding = new TenantBranding(); branding.setTenantId(tenantId); branding.setLogoUrl(request.getLogoUrl()); branding.setFaviconUrl(request.getFaviconUrl()); branding.setPrimaryColor(request.getPrimaryColor()); branding.setSecondaryColor(request.getSecondaryColor()); branding.setFontFamily(request.getFontFamily()); branding.setCustomCss(request.getCustomCss()); branding.setCustomJs(request.getCustomJs()); // 上传Logo文件 if (request.getLogoFile() != null && !request.getLogoFile().isEmpty()) { String logoPath = uploadBrandingFile(tenantId, "logo", request.getLogoFile()); branding.setLogoUrl(logoPath); } // 保存到存储 saveBrandingToStorage(tenantId, branding); // 清除缓存 cacheService.evict("branding:" + tenantId); // 生成CSS文件 generateBrandingCss(tenantId, branding); } private String uploadBrandingFile(String tenantId, String type, MultipartFile file) { String fileName = type + "_" + System.currentTimeMillis() + getFileExtension(file.getOriginalFilename()); String path = "tenants/" + tenantId + "/branding/" + fileName; storageService.upload(path, file); return storageService.getPublicUrl(path); } private void generateBrandingCss(String tenantId, TenantBranding branding) { String css = String.format(""" :root { --primary-color: %s; --secondary-color: %s; --font-family: %s; } .tenant-%s .header { background-color: %s; } .tenant-%s .btn-primary { background-color: %s; border-color: %s; } %s """, branding.getPrimaryColor(), branding.getSecondaryColor(), branding.getFontFamily(), tenantId, branding.getPrimaryColor(), tenantId, branding.getPrimaryColor(), branding.getSecondaryColor(), branding.getCustomCss() ); String cssPath = "tenants/" + tenantId + "/branding/custom.css"; storageService.upload(cssPath, css.getBytes()); }}三、租户配额管理java@Servicepublic class TenantQuotaService { @Autowired private TenantUsageRepository usageRepository; @Autowired private TenantQuotaRepository quotaRepository; public boolean checkUserQuota(String tenantId) { TenantQuota quota = quotaRepository.findByTenantId(tenantId); TenantUsage usage = usageRepository.findByTenantId(tenantId); if (quota == null || usage == null) { return true; // 无限制 } if (quota.getMaxUsers() > 0 && usage.getActiveUsers() >= quota.getMaxUsers()) { return false; } return true; } public boolean checkApiQuota(String tenantId, String apiEndpoint) { TenantQuota quota = quotaRepository.findByTenantId(tenantId); if (quota == null) { return true; } // 检查API调用频率限制 String rateLimitKey = "ratelimit:" + tenantId + ":" + apiEndpoint; Long count = redisTemplate.opsForValue().increment(rateLimitKey); if (count == 1) { redisTemplate.expire(rateLimitKey, 60, TimeUnit.SECONDS); } Integer rateLimit = quota.getApiRateLimits().get(apiEndpoint); if (rateLimit != null && count > rateLimit) { return false; } return true; } public boolean checkStorageQuota(String tenantId, long additionalBytes) { TenantQuota quota = quotaRepository.findByTenantId(tenantId); TenantUsage usage = usageRepository.findByTenantId(tenantId); if (quota == null || usage == null) { return true; } long newUsage = usage.getStorageUsed() + additionalBytes; if (quota.getMaxStorage() > 0 && newUsage > quota.getMaxStorage()) { return false; } return true; } public void recordApiCall(String tenantId, String apiEndpoint) { // 记录API调用 String key = "api_usage:" + tenantId + ":" + LocalDate.now().format(DateTimeFormatter.ISO_DATE); redisTemplate.opsForHash().increment(key, apiEndpoint, 1); redisTemplate.expire(key, 30, TimeUnit.DAYS); // 更新每日统计 updateDailyUsage(tenantId); } public TenantUsageStats getUsageStats(String tenantId, LocalDate startDate, LocalDate endDate) { TenantUsageStats stats = new TenantUsageStats(); stats.setTenantId(tenantId); stats.setPeriod(startDate + " - " + endDate); // 获取活跃用户数 stats.setActiveUsers(getActiveUsersCount(tenantId)); // 获取API调用统计 stats.setApiCalls(getApiCallsCount(tenantId, startDate, endDate)); // 获取存储使用量 stats.setStorageUsed(getStorageUsage(tenantId)); // 获取登录统计 stats.setLoginCount(getLoginCount(tenantId, startDate, endDate)); return stats; }}四、租户管理APIjava@RestController@RequestMapping("/api/admin/tenants")@PreAuthorize("hasRole('SUPER_ADMIN')")public class TenantAdminController { @Autowired private TenantService tenantService; @PostMapping public ResponseEntity<TenantResponse> createTenant( @RequestBody @Valid CreateTenantRequest request) { Tenant tenant = tenantService.createTenant(request); TenantResponse response = TenantResponse.builder() .tenantId(tenant.getTenantId()) .name(tenant.getName()) .status(tenant.getStatus()) .createdAt(tenant.getCreatedAt()) .adminEmail(tenant.getAdminEmail()) .build(); return ResponseEntity.status(HttpStatus.CREATED) .body(response); } @PutMapping("/{tenantId}") public ResponseEntity<TenantResponse> updateTenant( @PathVariable String tenantId, @RequestBody @Valid UpdateTenantRequest request) { Tenant tenant = tenantService.updateTenant(tenantId, request); TenantResponse response = TenantResponse.builder() .tenantId(tenant.getTenantId()) .name(tenant.getName()) .status(tenant.getStatus()) .updatedAt(tenant.getUpdatedAt()) .build(); return ResponseEntity.ok(response); } @PostMapping("/{tenantId}/suspend") public ResponseEntity<Void> suspendTenant( @PathVariable String tenantId, @RequestParam(required = false) String reason) { tenantService.suspendTenant(tenantId, reason); // 通知租户管理员 notificationService.sendTenantSuspendedNotification(tenantId, reason); return ResponseEntity.ok().build(); } @PostMapping("/{tenantId}/activate") public ResponseEntity<Void> activateTenant(@PathVariable String tenantId) { tenantService.activateTenant(tenantId); return ResponseEntity.ok().build(); } @GetMapping("/{tenantId}/usage") public ResponseEntity<TenantUsageResponse> getTenantUsage( @PathVariable String tenantId, @RequestParam(defaultValue = "30") int days) { LocalDate endDate = LocalDate.now(); LocalDate startDate = endDate.minusDays(days); TenantUsageResponse usage = tenantService.getTenantUsage( tenantId, startDate, endDate); return ResponseEntity.ok(usage); } @PostMapping("/{tenantId}/quota") public ResponseEntity<Void> updateQuota( @PathVariable String tenantId, @RequestBody @Valid QuotaUpdateRequest request) { tenantService.updateQuota(tenantId, request); // 记录配额变更 auditService.logQuotaChange(tenantId, request); return ResponseEntity.ok().build(); } @GetMapping("/{tenantId}/billing") public ResponseEntity<BillingInfoResponse> getBillingInfo( @PathVariable String tenantId, @RequestParam(defaultValue = "current") String period) { BillingInfoResponse billing = billingService.getBillingInfo( tenantId, period); return ResponseEntity.ok(billing); }}五、租户间数据隔离java@Componentpublic class TenantDataFilter { @Autowired private TenantContext tenantContext; public <T> Specification<T> tenantSpecification() { return (root, query, criteriaBuilder) -> { String currentTenant = tenantContext.getCurrentTenant(); if (currentTenant == null || "super_admin".equals(currentTenant)) { return criteriaBuilder.conjunction(); // 超级管理员可以看到所有 } return criteriaBuilder.equal(root.get("tenantId"), currentTenant); }; } public Predicate addTenantFilter(CriteriaBuilder cb, Root<?> root, String tenantId) { return cb.equal(root.get("tenantId"), tenantId); } public void validateTenantAccess(String resourceTenantId) { String currentTenant = tenantContext.getCurrentTenant(); if (!resourceTenantId.equals(currentTenant) && !"super_admin".equals(currentTenant)) { throw new AccessDeniedException("无权访问其他租户数据"); } }}// JPA Repository使用示例@Repositorypublic interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { default List<User> findAllByTenant() { return findAll(tenantDataFilter.tenantSpecification()); } default Page<User> findPageByTenant(Pageable pageable) { return findAll(tenantDataFilter.tenantSpecification(), pageable); } @Query("SELECT u FROM User u WHERE u.tenantId = :tenantId AND u.username = :username") User findByTenantAndUsername(@Param("tenantId") String tenantId, @Param("username") String username);}
-
一、集群部署架构1.1 Redis集群配置yamlspring: redis: cluster: nodes: - redis-node1:6379 - redis-node2:6379 - redis-node3:6379 max-redirects: 3 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 shutdown-timeout: 100ms1.2 会话同步策略java@Configurationpublic class SessionConfig { @Bean public HttpSessionStrategy httpSessionStrategy() { return new HeaderHttpSessionStrategy(); } @Bean public FindByIndexNameSessionRepository<?> sessionRepository() { MapSessionRepository sessionRepository = new MapSessionRepository(); // 配置会话同步 RedisIndexedSessionRepository redisSessionRepository = new RedisIndexedSessionRepository(redisConnectionFactory()); redisSessionRepository.setDefaultMaxInactiveInterval(1800); redisSessionRepository.setRedisKeyNamespace("spring:session"); return redisSessionRepository; }}二、负载均衡策略2.1 Nginx配置示例nginxupstream sso_servers { least_conn; # 最少连接策略 server sso1.example.com:8080 weight=3; server sso2.example.com:8080 weight=2; server sso3.example.com:8080 weight=1; keepalive 32;}server { listen 443 ssl; server_name sso.example.com; ssl_certificate /etc/ssl/certs/sso.crt; ssl_certificate_key /etc/ssl/private/sso.key; location / { proxy_pass http://sso_servers; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; 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_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; proxy_connect_timeout 5s; proxy_send_timeout 10s; proxy_read_timeout 10s; } # 健康检查端点 location /health { access_log off; return 200 "healthy\n"; }}三、缓存优化3.1 多级缓存策略java@Servicepublic class MultiLevelCacheService { @Autowired private RedisTemplate<String, Object> redisTemplate; private final Cache<String, Object> localCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); public Object getWithCache(String key, Supplier<Object> loader) { // 1. 检查本地缓存 Object value = localCache.getIfPresent(key); if (value != null) { return value; } // 2. 检查Redis缓存 value = redisTemplate.opsForValue().get(key); if (value != null) { localCache.put(key, value); return value; } // 3. 从数据源加载 value = loader.get(); if (value != null) { // 异步写入缓存 CompletableFuture.runAsync(() -> { redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES); localCache.put(key, value); }); } return value; } public void refreshCache(String key) { localCache.invalidate(key); redisTemplate.delete(key); }}3.2 令牌缓存优化java@Componentpublic class TokenCacheManager { private final Cache<String, Claims> tokenCache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats() .build(); public Claims getCachedClaims(String token) { return tokenCache.getIfPresent(token); } public void cacheClaims(String token, Claims claims) { tokenCache.put(token, claims); } public void invalidateToken(String token) { tokenCache.invalidate(token); } public CacheStats getStats() { return tokenCache.stats(); }}四、数据库优化4.1 分库分表策略java@Componentpublic class ShardingStrategy { public String getDatabaseName(String userId) { // 根据用户ID哈希分库 int hash = Math.abs(userId.hashCode()); int dbIndex = hash % 4; // 4个数据库 return "sso_db_" + dbIndex; } public String getTableName(String userId, String tablePrefix) { // 根据时间分表 LocalDateTime now = LocalDateTime.now(); String month = now.format(DateTimeFormatter.ofPattern("yyyyMM")); return tablePrefix + "_" + month; }}4.2 读写分离配置java@Configurationpublic class DataSourceConfig { @Bean @Primary public DataSource dataSource() { HikariDataSource master = createDataSource("master"); HikariDataSource slave = createDataSource("slave"); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", master); targetDataSources.put("slave", slave); RoutingDataSource routingDataSource = new RoutingDataSource(); routingDataSource.setDefaultTargetDataSource(master); routingDataSource.setTargetDataSources(targetDataSources); return routingDataSource; } @Bean public AbstractRoutingDataSource routingDataSource() { return new ReadWriteRoutingDataSource(); }}public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); return isReadOnly ? "slave" : "master"; }}五、异步处理5.1 异步认证处理java@Servicepublic class AsyncAuthService { @Autowired private ThreadPoolTaskExecutor authTaskExecutor; @Async("authTaskExecutor") public CompletableFuture<AuthResult> authenticateAsync(LoginRequest request) { // 异步执行认证逻辑 AuthResult result = performAuthentication(request); return CompletableFuture.completedFuture(result); } @Bean public ThreadPoolTaskExecutor authTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix("auth-executor-"); executor.initialize(); return executor; }}六、性能监控6.1 Micrometer监控配置java@Configurationpublic class MetricsConfig { @Bean public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() { return registry -> registry.config() .commonTags("application", "sso-service") .commonTags("environment", System.getenv("ENV")); } @Bean public TimedAspect timedAspect(MeterRegistry registry) { return new TimedAspect(registry); }}@Servicepublic class AuthMetricsService { private final MeterRegistry meterRegistry; private final Counter loginSuccessCounter; private final Counter loginFailureCounter; private final Timer loginTimer; public AuthMetricsService(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; loginSuccessCounter = Counter.builder("sso.login.success") .description("Successful login attempts") .register(meterRegistry); loginFailureCounter = Counter.builder("sso.login.failure") .description("Failed login attempts") .register(meterRegistry); loginTimer = Timer.builder("sso.login.duration") .description("Login processing time") .register(meterRegistry); } public void recordLoginSuccess(String username) { loginSuccessCounter.increment(); meterRegistry.counter("sso.login.user", "username", username).increment(); } public Timer.Sample startLoginTimer() { return Timer.start(meterRegistry); } public void stopLoginTimer(Timer.Sample sample, String status) { sample.stop(loginTimer); }}
-
一、CSRF防护机制1.1 Spring Security CSRF配置java@Configuration@EnableWebSecuritypublic class CsrfSecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) .ignoringRequestMatchers( "/api/auth/login", "/api/auth/logout", "/webhook/**" ) ) .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class); return http.build(); }}@Componentpublic class CsrfCookieFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); if (csrfToken != null) { Cookie cookie = new Cookie("XSRF-TOKEN", csrfToken.getToken()); cookie.setPath("/"); cookie.setHttpOnly(false); cookie.setSecure(request.isSecure()); response.addCookie(cookie); } filterChain.doFilter(request, response); }}1.2 自定义CSRF策略java@Componentpublic class CustomCsrfTokenRepository implements CsrfTokenRepository { private final RedisTemplate<String, String> redisTemplate; private static final String CSRF_KEY_PREFIX = "csrf:"; @Override public CsrfToken generateToken(HttpServletRequest request) { String tokenId = UUID.randomUUID().toString(); return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", tokenId); } @Override public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { String sessionId = request.getSession().getId(); if (token == null) { redisTemplate.delete(CSRF_KEY_PREFIX + sessionId); } else { redisTemplate.opsForValue().set( CSRF_KEY_PREFIX + sessionId, token.getToken(), 30, TimeUnit.MINUTES ); } } @Override public CsrfToken loadToken(HttpServletRequest request) { String sessionId = request.getSession().getId(); String token = redisTemplate.opsForValue().get(CSRF_KEY_PREFIX + sessionId); if (token != null) { return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token); } return null; }}二、XSS攻击防护2.1 输入输出过滤java@Componentpublic class XssFilter { private static final HtmlPolicyBuilder POLICY_BUILDER = new HtmlPolicyBuilder() .allowElements("a", "b", "i", "em", "strong", "p", "br") .allowAttributes("href").onElements("a") .requireRelNofollowOnLinks(); public String sanitize(String html) { if (StringUtils.isEmpty(html)) { return html; } PolicyFactory policy = POLICY_BUILDER.toFactory(); return policy.sanitize(html); } public Map<String, Object> sanitizeMap(Map<String, Object> data) { return data.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, entry -> entry.getValue() instanceof String ? sanitize((String) entry.getValue()) : entry.getValue() )); }}2.2 响应头安全配置java@Configurationpublic class SecurityHeadersConfig { @Bean public SecurityFilterChain securityHeadersFilter(HttpSecurity http) throws Exception { http .headers(headers -> headers .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'") ) .xssProtection(xss -> xss .headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK) ) .httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .maxAgeInSeconds(31536000) ) .frameOptions(frame -> frame .sameOrigin() ) .contentTypeOptions(contentType -> {}) ); return http.build(); }}三、重放攻击防范3.1 Nonce机制实现java@Servicepublic class NonceService { @Autowired private RedisTemplate<String, String> redisTemplate; private static final String NONCE_KEY_PREFIX = "nonce:"; private static final long NONCE_EXPIRE_SECONDS = 300; // 5分钟 public String generateNonce(String clientId) { String nonce = UUID.randomUUID().toString(); String key = NONCE_KEY_PREFIX + clientId + ":" + nonce; redisTemplate.opsForValue().set( key, String.valueOf(System.currentTimeMillis()), NONCE_EXPIRE_SECONDS, TimeUnit.SECONDS ); return nonce; } public boolean validateNonce(String clientId, String nonce) { String key = NONCE_KEY_PREFIX + clientId + ":" + nonce; if (!Boolean.TRUE.equals(redisTemplate.hasKey(key))) { return false; } // 使用后删除,防止重用 redisTemplate.delete(key); return true; }}3.2 时间戳验证java@Componentpublic class TimestampValidator { private static final long MAX_TIME_DIFF = 5 * 60 * 1000; // 5分钟 public boolean validateTimestamp(long requestTimestamp) { long currentTime = System.currentTimeMillis(); long timeDiff = Math.abs(currentTime - requestTimestamp); return timeDiff <= MAX_TIME_DIFF; } public boolean validateRequest(String clientId, long timestamp, String nonce, String signature) { // 1. 验证时间戳 if (!validateTimestamp(timestamp)) { return false; } // 2. 验证Nonce NonceService nonceService = new NonceService(); if (!nonceService.validateNonce(clientId, nonce)) { return false; } // 3. 验证签名 String dataToSign = clientId + timestamp + nonce; String expectedSignature = calculateSignature(dataToSign); return expectedSignature.equals(signature); } private String calculateSignature(String data) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("计算签名失败", e); } }}四、暴力破解防护4.1 登录尝试限制java@Servicepublic class LoginAttemptService { @Autowired private RedisTemplate<String, String> redisTemplate; private static final String ATTEMPT_KEY_PREFIX = "login:attempts:"; private static final String BLOCKED_KEY_PREFIX = "login:blocked:"; private static final int MAX_ATTEMPTS = 5; private static final long BLOCK_DURATION = 15 * 60 * 1000; // 15分钟 public void loginSucceeded(String username, String ipAddress) { String key = ATTEMPT_KEY_PREFIX + username + ":" + ipAddress; redisTemplate.delete(key); } public void loginFailed(String username, String ipAddress) { String key = ATTEMPT_KEY_PREFIX + username + ":" + ipAddress; String attemptsStr = redisTemplate.opsForValue().get(key); int attempts = attemptsStr != null ? Integer.parseInt(attemptsStr) : 0; attempts++; redisTemplate.opsForValue().set(key, String.valueOf(attempts), 1, TimeUnit.HOURS); if (attempts >= MAX_ATTEMPTS) { blockUser(username, ipAddress); } } public boolean isBlocked(String username, String ipAddress) { String key = BLOCKED_KEY_PREFIX + username + ":" + ipAddress; if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { // 检查是否超过封锁时间 Long ttl = redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); return ttl != null && ttl > 0; } return false; } private void blockUser(String username, String ipAddress) { String key = BLOCKED_KEY_PREFIX + username + ":" + ipAddress; redisTemplate.opsForValue().set(key, "blocked", BLOCK_DURATION, TimeUnit.MILLISECONDS); }}4.2 验证码集成java@Servicepublic class CaptchaService { @Autowired private RedisTemplate<String, String> redisTemplate; public CaptchaResponse generateCaptcha(String sessionId) { // 生成验证码文本 String captchaText = generateRandomText(6); // 生成验证码图片 BufferedImage image = generateCaptchaImage(captchaText); // 存储验证码 String key = "captcha:" + sessionId; redisTemplate.opsForValue().set(key, captchaText, 5, TimeUnit.MINUTES); // 转换为Base64 String imageBase64 = imageToBase64(image); return new CaptchaResponse(imageBase64, sessionId); } public boolean validateCaptcha(String sessionId, String userInput) { String key = "captcha:" + sessionId; String storedCaptcha = redisTemplate.opsForValue().get(key); if (storedCaptcha == null) { return false; } // 验证后删除 redisTemplate.delete(key); return storedCaptcha.equalsIgnoreCase(userInput); } public boolean isCaptchaRequired(String username, String ipAddress) { LoginAttemptService attemptService = new LoginAttemptService(); return attemptService.isBlocked(username, ipAddress); }}五、会话安全5.1 会话固定防护java@Configurationpublic class SessionFixationProtectionConfig { @Bean public SecurityFilterChain sessionFixationFilter(HttpSecurity http) throws Exception { http .sessionManagement(session -> session .sessionFixation(sessionFixation -> sessionFixation .migrateSession() ) .maximumSessions(1) .maxSessionsPreventsLogin(false) .expiredUrl("/login?expired") ); return http.build(); }}5.2 会话劫持检测java@Componentpublic class SessionHijackingDetector { public boolean detectHijacking(HttpServletRequest request, HttpSession session) { // 检查User-Agent String currentUserAgent = request.getHeader("User-Agent"); String storedUserAgent = (String) session.getAttribute("userAgent"); if (storedUserAgent != null && !storedUserAgent.equals(currentUserAgent)) { return true; } // 检查IP地址 String currentIp = getClientIp(request); String storedIp = (String) session.getAttribute("clientIp"); if (storedIp != null && !storedIp.equals(currentIp)) { return true; } // 更新会话属性 session.setAttribute("userAgent", currentUserAgent); session.setAttribute("clientIp", currentIp); return false; } private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; }}六、安全最佳实践6.1 定期安全扫描java@Component@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行public class SecurityScanner { public void scanForVulnerabilities() { // 1. 检查弱密码用户 scanWeakPasswords(); // 2. 检查过期会话 cleanExpiredSessions(); // 3. 检查异常登录 analyzeLoginPatterns(); // 4. 生成安全报告 generateSecurityReport(); } private void scanWeakPasswords() { // 实现弱密码检测逻辑 } private void cleanExpiredSessions() { // 清理过期会话 }}
-
一、微服务SSO架构1.1 网关统一认证text┌─────────┐ ┌─────────────┐ ┌─────────────┐│ 客户端 │──▶│ API网关 │──▶│ 认证服务 │└─────────┘ └─────────────┘ └─────────────┘ │ │ ┌──────▼──────┐ ┌──────▼──────┐ │ 服务A │ │ 用户服务 │ └─────────────┘ └─────────────┘ │ ┌──────▼──────┐ │ 服务B │ └─────────────┘二、Spring Cloud Gateway集成2.1 网关配置yamlspring: cloud: gateway: routes: - id: auth-service uri: http://localhost:8081 predicates: - Path=/auth/** filters: - StripPrefix=1 - id: user-service uri: http://localhost:8082 predicates: - Path=/api/users/** filters: - name: JwtAuthFilter args: jwt-secret: ${JWT_SECRET} - id: product-service uri: http://localhost:8083 predicates: - Path=/api/products/** filters: - name: JwtAuthFilter - AddRequestHeader=X-User-Id, ${user.id}2.2 网关JWT过滤器java@Componentpublic class JwtAuthFilter extends AbstractGatewayFilterFactory<JwtAuthFilter.Config> { @Autowired private JwtTokenProvider tokenProvider; public JwtAuthFilter() { super(Config.class); } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); // 检查是否为公开端点 if (isPublicEndpoint(request.getPath().toString())) { return chain.filter(exchange); } // 获取令牌 String token = extractToken(request); if (token == null || !tokenProvider.validateToken(token)) { return unauthorized(exchange, "无效的认证令牌"); } // 验证通过,添加用户信息到请求头 String username = tokenProvider.getUsername(token); List<String> roles = tokenProvider.getRoles(token); ServerHttpRequest mutatedRequest = request.mutate() .header("X-User-Id", username) .header("X-User-Roles", String.join(",", roles)) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); }; } private boolean isPublicEndpoint(String path) { return path.startsWith("/auth/login") || path.startsWith("/auth/register") || path.startsWith("/public/"); } private String extractToken(ServerHttpRequest request) { List<String> authHeaders = request.getHeaders().get("Authorization"); if (authHeaders != null && !authHeaders.isEmpty()) { String authHeader = authHeaders.get(0); if (authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } } return null; } private Mono<Void> unauthorized(ServerWebExchange exchange, String message) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); String body = String.format("{\"error\": \"Unauthorized\", \"message\": \"%s\"}", message); DataBuffer buffer = response.bufferFactory().wrap(body.getBytes()); return response.writeWith(Mono.just(buffer)); } public static class Config { // 配置属性 }}三、服务间认证3.1 Feign客户端配置java@Configurationpublic class FeignConfig { @Bean public RequestInterceptor requestInterceptor() { return requestTemplate -> { // 从安全上下文获取当前用户的令牌 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getCredentials() instanceof String) { String token = (String) authentication.getCredentials(); requestTemplate.header("Authorization", "Bearer " + token); } else if (authentication instanceof JwtAuthenticationToken) { JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication; requestTemplate.header("Authorization", "Bearer " + jwtAuth.getToken().getTokenValue()); } }; }}3.2 服务间JWT传播java@Servicepublic class ServiceClient { @Autowired private RestTemplate restTemplate; @Value("${service-b.url}") private String serviceBUrl; public String callServiceB() { // 获取当前用户的JWT令牌 String token = getCurrentUserToken(); HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + token); headers.set("X-Service-Request", "true"); HttpEntity<String> entity = new HttpEntity<>(headers); ResponseEntity<String> response = restTemplate.exchange( serviceBUrl + "/api/data", HttpMethod.GET, entity, String.class ); return response.getBody(); } private String getCurrentUserToken() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication instanceof JwtAuthenticationToken) { JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication; return jwtAuth.getToken().getTokenValue(); } else if (authentication instanceof AbstractOAuth2TokenAuthenticationToken) { AbstractOAuth2TokenAuthenticationToken<?> oauth2Auth = (AbstractOAuth2TokenAuthenticationToken<?>) authentication; return oauth2Auth.getToken().getTokenValue(); } throw new IllegalStateException("无法获取当前用户的认证令牌"); }}四、分布式会话管理4.1 Spring Session Redis配置java@Configuration@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)public class SessionConfig { @Bean public LettuceConnectionFactory connectionFactory() { return new LettuceConnectionFactory(); } @Bean public HttpSessionIdResolver httpSessionIdResolver() { // 支持多种方式传递Session ID return new HeaderHttpSessionIdResolver("X-Auth-Token"); }}4.2 会话共享过滤器java@Componentpublic class SessionSharingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // 从请求头获取会话ID String sessionId = httpRequest.getHeader("X-Session-Id"); if (sessionId != null) { // 设置当前会话ID RequestContextHolder.currentRequestAttributes().setSessionId(sessionId); } chain.doFilter(request, response); // 将会话ID添加到响应头 if (sessionId == null) { HttpSession session = httpRequest.getSession(false); if (session != null) { httpResponse.setHeader("X-Session-Id", session.getId()); } } }}五、服务注册发现集成5.1 Eureka客户端配置java@SpringBootApplication@EnableDiscoveryClientpublic class AuthServiceApplication { public static void main(String[] args) { SpringApplication.run(AuthServiceApplication.class, args); }}5.2 动态服务发现java@Servicepublic class ServiceDiscoveryClient { @Autowired private DiscoveryClient discoveryClient; @Autowired private LoadBalancerClient loadBalancer; public String getServiceUrl(String serviceId) { List<ServiceInstance> instances = discoveryClient.getInstances(serviceId); if (instances.isEmpty()) { throw new ServiceUnavailableException("服务 " + serviceId + " 不可用"); } ServiceInstance instance = loadBalancer.choose(serviceId); return instance.getUri().toString(); } public List<String> getAllAuthServices() { return discoveryClient.getServices().stream() .filter(service -> service.endsWith("-auth-service")) .collect(Collectors.toList()); }}六、配置中心集成6.1 Spring Cloud Config配置yaml# bootstrap.ymlspring: application: name: sso-service cloud: config: uri: http://config-server:8888 fail-fast: true retry: max-attempts: 6 max-interval: 10000 encrypt: key: ${CONFIG_ENCRYPT_KEY}6.2 动态JWT配置java@Configuration@RefreshScopepublic class DynamicJwtConfig { @Value("${jwt.secret:default-secret}") private String secret; @Value("${jwt.expiration:3600}") private Long expiration; @Bean @RefreshScope public JwtTokenProvider jwtTokenProvider() { return new JwtTokenProvider(secret, expiration); }}七、熔断与降级7.1 Resilience4j配置yamlresilience4j: circuitbreaker: instances: authService: registerHealthIndicator: true slidingWindowSize: 10 minimumNumberOfCalls: 5 permittedNumberOfCallsInHalfOpenState: 3 automaticTransitionFromOpenToHalfOpenEnabled: true waitDurationInOpenState: 5s failureRateThreshold: 50 retry: instances: authService: maxAttempts: 3 waitDuration: 500ms timelimiter: instances: authService: timeoutDuration: 3s7.2 认证服务熔断器java@Service@Slf4jpublic class AuthServiceWithCircuitBreaker { @Autowired private AuthService authService; @CircuitBreaker(name = "authService", fallbackMethod = "fallbackLogin") @TimeLimiter(name = "authService") @Retry(name = "authService") public CompletableFuture<AuthResponse> loginAsync(LoginRequest request) { return CompletableFuture.supplyAsync(() -> authService.login(request)); } private CompletableFuture<AuthResponse> fallbackLogin( LoginRequest request, Throwable throwable) { log.warn("认证服务降级处理,请求: {}, 异常: {}", request.getUsername(), throwable.getMessage()); // 返回降级响应 AuthResponse response = new AuthResponse(); response.setError("认证服务暂时不可用,请稍后重试"); response.setFallback(true); return CompletableFuture.completedFuture(response); }}八、监控与追踪8.1 Spring Cloud Sleuth集成yamlspring: sleuth: sampler: probability: 1.0 propagation: type: B3 baggage: remote-fields: - X-User-Id - X-Request-ID logging: pattern: level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"8.2 自定义追踪过滤器java@Componentpublic class TraceFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; // 添加追踪信息 String traceId = httpRequest.getHeader("X-B3-TraceId"); if (traceId == null) { traceId = generateTraceId(); } MDC.put("traceId", traceId); MDC.put("spanId", generateSpanId()); try { chain.doFilter(request, response); } finally { MDC.clear(); } } private String generateTraceId() { return UUID.randomUUID().toString().replace("-", ""); } private String generateSpanId() { return Long.toHexString(new Random().nextLong()); }}九、API版本管理9.1 版本化认证端点java@RestController@RequestMapping("/api/v{auth-api-version}/auth")public class VersionedAuthController { @PostMapping("/login") public ResponseEntity<?> loginV1(@PathVariable("auth-api-version") String version, @RequestBody LoginRequest request) { if ("1".equals(version)) { return loginV1(request); } else if ("2".equals(version)) { return loginV2(request); } return ResponseEntity.status(HttpStatus.NOT_FOUND) .body("不支持的API版本"); } private ResponseEntity<?> loginV1(LoginRequest request) { // V1版本实现 return ResponseEntity.ok("V1 response"); } private ResponseEntity<?> loginV2(LoginRequest request) { // V2版本实现,支持多因素认证 return ResponseEntity.ok("V2 response with MFA support"); }}十、微服务SSO最佳实践服务治理:使用API网关统一入口实施服务熔断和降级配置合理的超时和重试安全策略:服务间使用mTLS相互认证实施最小权限原则定期轮换服务凭证可观测性:集成分布式追踪收集认证相关指标设置告警规则部署策略:认证服务独立部署支持蓝绿部署配置自动伸缩数据一致性:使用分布式会话存储实现最终一致性定期清理过期数据
-
一、JWT核心原理1.1 JWT结构详解JWT由三部分组成,以点分隔:textHeader.Payload.SignatureHeader示例:json{ "alg": "HS256", "typ": "JWT"}Payload示例:json{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022, "exp": 1516242622, "roles": ["USER", "ADMIN"]}二、JWT工具类实现2.1 完整JWT工具类java@Component@Slf4jpublic class JwtTokenProvider { @Value("${jwt.secret}") private String secretKey; @Value("${jwt.expiration}") private long validityInMilliseconds; @Value("${jwt.refresh-expiration}") private long refreshValidityInMilliseconds; // 生成访问令牌 public String createAccessToken(String username, List<String> roles) { Claims claims = Jwts.claims().setSubject(username); claims.put("roles", roles); claims.put("type", "ACCESS"); Date now = new Date(); Date validity = new Date(now.getTime() + validityInMilliseconds); return Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(validity) .signWith(SignatureAlgorithm.HS256, secretKey.getBytes()) .compact(); } // 生成刷新令牌 public String createRefreshToken(String username) { Claims claims = Jwts.claims().setSubject(username); claims.put("type", "REFRESH"); Date now = new Date(); Date validity = new Date(now.getTime() + refreshValidityInMilliseconds); return Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(validity) .signWith(SignatureAlgorithm.HS256, secretKey.getBytes()) .compact(); } // 解析用户名 public String getUsername(String token) { return getAllClaimsFromToken(token).getSubject(); } // 获取用户角色 @SuppressWarnings("unchecked") public List<String> getRoles(String token) { return getAllClaimsFromToken(token).get("roles", List.class); } // 获取过期时间 public Date getExpirationDate(String token) { return getAllClaimsFromToken(token).getExpiration(); } // 验证令牌是否过期 public boolean isTokenExpired(String token) { final Date expiration = getExpirationDate(token); return expiration.before(new Date()); } // 验证令牌有效性 public boolean validateToken(String token) { try { Jwts.parser() .setSigningKey(secretKey.getBytes()) .parseClaimsJws(token); return !isTokenExpired(token); } catch (JwtException | IllegalArgumentException e) { log.warn("无效的JWT令牌: {}", e.getMessage()); return false; } } // 获取令牌中的所有声明 private Claims getAllClaimsFromToken(String token) { return Jwts.parser() .setSigningKey(secretKey.getBytes()) .parseClaimsJws(token) .getBody(); } // 从请求头提取令牌 public String resolveToken(HttpServletRequest req) { String bearerToken = req.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; }}三、JWT过滤器配置3.1 JWT认证过滤器java@Component@Slf4jpublic class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtTokenProvider jwtTokenProvider; @Autowired private CustomUserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String token = jwtTokenProvider.resolveToken(request); if (token != null && jwtTokenProvider.validateToken(token)) { String username = jwtTokenProvider.getUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); // 刷新令牌逻辑 refreshTokenIfNeeded(token, response); } } catch (Exception e) { log.error("无法设置用户认证: {}", e.getMessage()); } filterChain.doFilter(request, response); } private void refreshTokenIfNeeded(String token, HttpServletResponse response) { long expirationTime = jwtTokenProvider.getExpirationDate(token).getTime(); long currentTime = System.currentTimeMillis(); long remainingTime = expirationTime - currentTime; // 如果令牌将在30分钟内过期,则刷新 if (remainingTime < 30 * 60 * 1000) { String username = jwtTokenProvider.getUsername(token); List<String> roles = jwtTokenProvider.getRoles(token); String newToken = jwtTokenProvider.createAccessToken(username, roles); response.setHeader("X-New-Access-Token", newToken); } }}四、令牌黑名单实现4.1 黑名单管理java@Servicepublic class TokenBlacklistService { @Autowired private RedisTemplate<String, String> redisTemplate; private static final String BLACKLIST_KEY = "token:blacklist:"; // 添加令牌到黑名单 public void blacklistToken(String token, long ttlInSeconds) { String key = BLACKLIST_KEY + DigestUtils.md5DigestAsHex(token.getBytes()); redisTemplate.opsForValue().set(key, "blacklisted", ttlInSeconds, TimeUnit.SECONDS); } // 检查令牌是否在黑名单中 public boolean isTokenBlacklisted(String token) { String key = BLACKLIST_KEY + DigestUtils.md5DigestAsHex(token.getBytes()); return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } // 从黑名单中移除令牌 public void removeFromBlacklist(String token) { String key = BLACKLIST_KEY + DigestUtils.md5DigestAsHex(token.getBytes()); redisTemplate.delete(key); }}4.2 增强的JWT验证java@Componentpublic class EnhancedJwtTokenProvider extends JwtTokenProvider { @Autowired private TokenBlacklistService blacklistService; @Override public boolean validateToken(String token) { if (blacklistService.isTokenBlacklisted(token)) { return false; } return super.validateToken(token); }}五、配置文件示例5.1 application.ymlyamljwt: secret: "mySuperSecretKeyThatIsAtLeast32BytesLongForHS256" expiration: 3600000 # 1小时(毫秒) refresh-expiration: 86400000 # 24小时(毫秒) issuer: "sso-server" audience: "web-client" redis: host: localhost port: 6379 password: timeout: 2000ms database: 0 jedis: pool: max-active: 8 max-idle: 8 min-idle: 0六、API接口设计6.1 认证控制器java@RestController@RequestMapping("/api/auth")public class AuthController { @Autowired private JwtTokenProvider tokenProvider; @Autowired private AuthenticationManager authenticationManager; @Autowired private TokenBlacklistService blacklistService; @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) { try { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); User user = (User) authentication.getPrincipal(); List<String> roles = user.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); // 生成令牌 String accessToken = tokenProvider.createAccessToken(user.getUsername(), roles); String refreshToken = tokenProvider.createRefreshToken(user.getUsername()); AuthResponse response = AuthResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) .tokenType("Bearer") .expiresIn(tokenProvider.getExpirationDate(accessToken).getTime()) .username(user.getUsername()) .roles(roles) .build(); return ResponseEntity.ok(response); } catch (AuthenticationException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ErrorResponse.of("认证失败", e.getMessage())); } } @PostMapping("/refresh") public ResponseEntity<?> refreshToken(@RequestBody RefreshTokenRequest request) { try { String refreshToken = request.getRefreshToken(); if (!tokenProvider.validateToken(refreshToken)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ErrorResponse.of("刷新令牌无效或已过期")); } String username = tokenProvider.getUsername(refreshToken); UserDetails userDetails = userDetailsService.loadUserByUsername(username); List<String> roles = userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); String newAccessToken = tokenProvider.createAccessToken(username, roles); AuthResponse response = AuthResponse.builder() .accessToken(newAccessToken) .refreshToken(refreshToken) // 可以重新生成刷新令牌 .tokenType("Bearer") .expiresIn(tokenProvider.getExpirationDate(newAccessToken).getTime()) .username(username) .roles(roles) .build(); return ResponseEntity.ok(response); } catch (Exception e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ErrorResponse.of("令牌刷新失败", e.getMessage())); } } @PostMapping("/logout") public ResponseEntity<?> logout(HttpServletRequest request) { String token = tokenProvider.resolveToken(request); if (token != null) { // 将令牌加入黑名单,剩余有效期同令牌有效期 long expirationTime = tokenProvider.getExpirationDate(token).getTime(); long currentTime = System.currentTimeMillis(); long ttlInSeconds = (expirationTime - currentTime) / 1000; if (ttlInSeconds > 0) { blacklistService.blacklistToken(token, ttlInSeconds); } // 清除安全上下文 SecurityContextHolder.clearContext(); } return ResponseEntity.ok("登出成功"); } @GetMapping("/validate") public ResponseEntity<?> validateToken(@RequestParam String token) { boolean isValid = tokenProvider.validateToken(token); Map<String, Object> response = new HashMap<>(); response.put("valid", isValid); if (isValid) { response.put("username", tokenProvider.getUsername(token)); response.put("roles", tokenProvider.getRoles(token)); response.put("expiresAt", tokenProvider.getExpirationDate(token)); } return ResponseEntity.ok(response); }}七、安全配置7.1 Spring Security配置java@Configuration@EnableWebSecuritypublic class SecurityConfig { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Autowired private CustomUserDetailsService userDetailsService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/**").permitAll() .antMatchers("/api/public/**").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling() .authenticationEntryPoint(jwtAuthenticationEntryPoint()); return http.build(); } @Bean public AuthenticationEntryPoint jwtAuthenticationEntryPoint() { return (request, response, authException) -> { response.setContentType("application/json"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write( "{\"error\": \"Unauthorized\", \"message\": \"无效或过期的令牌\"}" ); }; } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService); authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; }}八、JWT优化实践8.1 令牌压缩(针对长令牌)java@Componentpublic class TokenCompressionUtil { // 压缩令牌(当payload很大时) public String compressToken(String token) { byte[] compressed = LZ4Factory.fastestInstance() .fastCompressor() .compress(token.getBytes(StandardCharsets.UTF_8)); return Base64.getUrlEncoder().encodeToString(compressed); } // 解压令牌 public String decompressToken(String compressedToken) { byte[] decoded = Base64.getUrlDecoder().decode(compressedToken); byte[] decompressed = LZ4Factory.fastestInstance() .fastDecompressor() .decompress(decoded, 1024 * 1024); // 1MB max return new String(decompressed, StandardCharsets.UTF_8); }}8.2 令牌轮换策略java@Servicepublic class TokenRotationService { @Autowired private RedisTemplate<String, String> redisTemplate; private static final String TOKEN_FAMILY_KEY = "token:family:"; // 创建令牌族(用于刷新令牌时轮换) public String createTokenFamily(String userId) { String familyId = UUID.randomUUID().toString(); String key = TOKEN_FAMILY_KEY + userId + ":" + familyId; redisTemplate.opsForValue().set(key, "active", 30, TimeUnit.DAYS); return familyId; } // 验证令牌族 public boolean isValidTokenFamily(String userId, String familyId) { String key = TOKEN_FAMILY_KEY + userId + ":" + familyId; return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } // 撤销令牌族 public void revokeTokenFamily(String userId, String familyId) { String key = TOKEN_FAMILY_KEY + userId + ":" + familyId; redisTemplate.delete(key); }}九、监控和审计9.1 JWT使用监控java@Component@Slf4jpublic class JwtUsageMonitor { private final MeterRegistry meterRegistry; public JwtUsageMonitor(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; } // 记录令牌生成 public void recordTokenGeneration(String username) { meterRegistry.counter("jwt.tokens.generated", "username", username).increment(); log.info("为用户 {} 生成了新的JWT令牌", username); } // 记录令牌验证 public void recordTokenValidation(String username, boolean valid) { if (valid) { meterRegistry.counter("jwt.tokens.validated.success", "username", username).increment(); } else { meterRegistry.counter("jwt.tokens.validated.failed", "username", username).increment(); log.warn("用户 {} 的JWT令牌验证失败", username); } } // 记录令牌刷新 public void recordTokenRefresh(String username) { meterRegistry.counter("jwt.tokens.refreshed", "username", username).increment(); log.info("用户 {} 刷新了JWT令牌", username); }}十、最佳实践总结密钥管理:使用至少32字节的密钥定期轮换密钥密钥与代码分离存储令牌设计:访问令牌有效期1-2小时刷新令牌有效期7-30天包含必要的最小信息安全措施:强制HTTPS传输实现令牌黑名单防止重放攻击监控异常使用模式性能优化:合理设置令牌大小使用高效的签名算法缓存验证结果运维建议:记录所有令牌操作设置令牌使用阈值告警定期审计令牌策略
-
一、OAuth2.0核心概念OAuth2.0是一个授权框架,不是认证协议,但常被用于实现SSO。它定义了四种授权模式,其中授权码模式最适合Web应用的SSO场景。1.1 核心角色资源所有者:用户客户端:需要访问资源的应用授权服务器:颁发访问令牌资源服务器:保护资源的服务器1.2 授权流程(授权码模式)text1. 用户访问客户端2. 客户端重定向到授权服务器3. 用户登录并授权4. 授权服务器返回授权码5. 客户端用授权码交换访问令牌6. 客户端用访问令牌访问资源二、Spring Security OAuth2配置2.1 授权服务器配置java@Configuration@EnableAuthorizationServerpublic class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("webapp") .secret(passwordEncoder.encode("websecret")) .authorizedGrantTypes("authorization_code", "refresh_token", "password") .scopes("read", "write") .redirectUris("http://localhost:8081/login/oauth2/code/webapp") .accessTokenValiditySeconds(3600) .refreshTokenValiditySeconds(86400); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints .authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .tokenStore(tokenStore()) .accessTokenConverter(accessTokenConverter()); } @Bean public TokenStore tokenStore() { return new JwtTokenStore(accessTokenConverter()); } @Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("signing-key"); return converter; }}2.2 资源服务器配置java@Configuration@EnableResourceServerpublic class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/api/public/**").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .antMatchers("/api/**").authenticated() .and() .oauth2ResourceServer() .jwt(); }}2.3 安全配置java@Configuration@EnableWebSecuritypublic class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests() .antMatchers("/oauth/**", "/login/**").permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage("/login") .permitAll(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }}三、JWT令牌集成3.1 JWT配置增强java@Configurationpublic class JwtConfig { @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("my-signing-key"); // 自定义JWT声明 DefaultAccessTokenConverter accessTokenConverter = new DefaultAccessTokenConverter(); accessTokenConverter.setUserTokenConverter( new CustomUserAuthenticationConverter()); converter.setAccessTokenConverter(accessTokenConverter); return converter; } @Bean public TokenStore tokenStore() { return new JwtTokenStore(jwtAccessTokenConverter()); }}3.2 自定义用户信息转换javapublic class CustomUserAuthenticationConverter implements UserAuthenticationConverter { @Override public Map<String, ?> convertUserAuthentication( Authentication authentication) { Map<String, Object> response = new LinkedHashMap<>(); response.put(USERNAME, authentication.getName()); if (authentication.getAuthorities() != null && !authentication.getAuthorities().isEmpty()) { response.put(AUTHORITIES, AuthorityUtils.authorityListToSet( authentication.getAuthorities())); } // 添加自定义声明 if (authentication.getDetails() != null) { response.put("details", authentication.getDetails()); } return response; }}四、客户端应用集成4.1 客户端配置yamlspring: security: oauth2: client: registration: sso-server: client-id: webapp client-secret: websecret authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/sso-server" scope: read,write provider: sso-server: authorization-uri: http://localhost:8080/oauth/authorize token-uri: http://localhost:8080/oauth/token user-info-uri: http://localhost:8080/userinfo user-name-attribute: name4.2 客户端安全配置java@Configuration@EnableWebSecuritypublic class ClientSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/", "/error", "/webjars/**").permitAll() .anyRequest().authenticated() .and() .oauth2Login() .loginPage("/oauth2/authorization/sso-server") .defaultSuccessUrl("/home", true) .failureUrl("/login?error=true"); }}五、用户信息端点5.1 用户信息服务java@RestControllerpublic class UserInfoController { @GetMapping("/userinfo") public Map<String, Object> getUserInfo(@AuthenticationPrincipal OAuth2User principal) { Map<String, Object> userInfo = new HashMap<>(); userInfo.put("username", principal.getName()); userInfo.put("authorities", principal.getAuthorities()); userInfo.put("attributes", principal.getAttributes()); return userInfo; } @GetMapping("/api/me") public UserProfile getCurrentUser(@AuthenticationPrincipal(expression = "username") String username) { return userService.getUserProfile(username); }}六、令牌管理6.1 令牌增强器java@Componentpublic class CustomTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Map<String, Object> additionalInfo = new HashMap<>(); // 添加额外信息到令牌 additionalInfo.put("organization", "Example Corp"); // 如果是密码模式,添加用户详情 if (authentication.getUserAuthentication() != null) { Object principal = authentication.getUserAuthentication().getPrincipal(); if (principal instanceof UserDetails) { UserDetails user = (UserDetails) principal; additionalInfo.put("username", user.getUsername()); additionalInfo.put("authorities", user.getAuthorities()); } } ((DefaultOAuth2AccessToken) accessToken) .setAdditionalInformation(additionalInfo); return accessToken; }}6.2 令牌存储策略java@Configurationpublic class TokenStoreConfig { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean @Primary public TokenStore tokenStore() { // 使用Redis存储令牌(支持集群) RedisTokenStore tokenStore = new RedisTokenStore(redisConnectionFactory); tokenStore.setPrefix("oauth:"); return tokenStore; } @Bean @ConditionalOnProperty(name = "oauth2.token.store", havingValue = "jwt") public TokenStore jwtTokenStore() { // 使用JWT令牌(无状态) return new JwtTokenStore(jwtAccessTokenConverter()); }}七、单点登出实现7.1 全局登出端点java@RestControllerpublic class LogoutController { @Autowired private TokenStore tokenStore; @PostMapping("/oauth/revoke-token") public ResponseEntity<?> revokeToken(@RequestParam("token") String tokenValue) { OAuth2AccessToken accessToken = tokenStore.readAccessToken(tokenValue); if (accessToken != null) { tokenStore.removeAccessToken(accessToken); // 如果支持刷新令牌,也一并删除 OAuth2RefreshToken refreshToken = accessToken.getRefreshToken(); if (refreshToken != null) { tokenStore.removeRefreshToken(refreshToken); } } return ResponseEntity.ok().build(); } @PostMapping("/global-logout") public ResponseEntity<?> globalLogout( @RequestParam("access_token") String accessToken, HttpServletRequest request) { // 1. 使当前令牌失效 revokeToken(accessToken); // 2. 获取所有活跃会话并通知登出 List<String> clientIds = getActiveClients(accessToken); notifyClientsLogout(clientIds); // 3. 清除安全上下文 SecurityContextHolder.clearContext(); // 4. 使HTTP会话失效 HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } return ResponseEntity.ok("登出成功"); }}八、安全加固措施8.1 防止CSRF攻击java@Configurationpublic class CsrfConfig { @Bean public CsrfTokenRepository csrfTokenRepository() { CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse(); repository.setCookiePath("/"); repository.setCookieDomain("example.com"); return repository; }}8.2 防止重放攻击java@Componentpublic class ReplayAttackProtection { @Autowired private RedisTemplate<String, String> redisTemplate; public boolean isReplayAttack(String token, String requestId) { String key = "token:nonce:" + token; // 检查请求ID是否已使用过 if (redisTemplate.hasKey(key)) { String existingRequestId = redisTemplate.opsForValue().get(key); return existingRequestId.equals(requestId); } // 存储新的请求ID,有效期5分钟 redisTemplate.opsForValue().set(key, requestId, 5, TimeUnit.MINUTES); return false; }}九、性能优化9.1 令牌缓存java@Configuration@EnableCachingpublic class TokenCacheConfig { @Bean public CacheManager cacheManager() { ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager(); cacheManager.setCacheNames(Arrays.asList( "accessTokens", "refreshTokens", "authorizationCodes")); return cacheManager; } @Bean public TokenServices tokenServices() { DefaultTokenServices tokenServices = new DefaultTokenServices(); tokenServices.setTokenStore(tokenStore()); tokenServices.setSupportRefreshToken(true); tokenServices.setReuseRefreshToken(false); tokenServices.setAccessTokenValiditySeconds(3600); tokenServices.setRefreshTokenValiditySeconds(86400); return tokenServices; }}十、监控与日志10.1 审计日志java@Componentpublic class OAuth2AuditLogger { private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT"); public void logAuthorizationRequest(OAuth2Request request) { auditLogger.info("Authorization request: clientId={}, scope={}, approved={}", request.getClientId(), request.getScope(), request.isApproved()); } public void logTokenIssue(String clientId, String username) { auditLogger.info("Token issued: clientId={}, username={}", clientId, username); }}总结SpringBoot结合OAuth2.0实现SSO提供了企业级的认证授权解决方案。通过合理的配置和扩展,可以满足不同场景的需求。关键点包括:正确选择授权模式:Web应用使用授权码模式安全令牌管理:合理设置有效期,实现令牌刷新完善的登出机制:支持全局登出性能优化:合理使用缓存安全加固:防止CSRF、重放等攻击监控审计:记录关键操作日志
上滑加载中
推荐直播
-
HDC深度解读系列 - Serverless与MCP融合创新,构建AI应用全新智能中枢2025/08/20 周三 16:30-18:00
张昆鹏 HCDG北京核心组代表
HDC2025期间,华为云展示了Serverless与MCP融合创新的解决方案,本期访谈直播,由华为云开发者专家(HCDE)兼华为云开发者社区组织HCDG北京核心组代表张鹏先生主持,华为云PaaS服务产品部 Serverless总监Ewen为大家深度解读华为云Serverless与MCP如何融合构建AI应用全新智能中枢
回顾中 -
关于RISC-V生态发展的思考2025/09/02 周二 17:00-18:00
中国科学院计算技术研究所副所长包云岗教授
中科院包云岗老师将在本次直播中,探讨处理器生态的关键要素及其联系,分享过去几年推动RISC-V生态建设实践过程中的经验与教训。
回顾中 -
一键搞定华为云万级资源,3步轻松管理企业成本2025/09/09 周二 15:00-16:00
阿言 华为云交易产品经理
本直播重点介绍如何一键续费万级资源,3步轻松管理成本,帮助提升日常管理效率!
回顾中
热门标签