From 2b48084594e259c2d26e847e4ed88b5cd45ae393 Mon Sep 17 00:00:00 2001 From: yuoon Date: Wed, 10 Dec 2025 21:10:01 +0800 Subject: [PATCH 1/6] single-login --- springboot-single-login/.gitignore | 44 ++ springboot-single-login/pom.xml | 78 ++++ .../com/example/login/LoginApplication.java | 11 + .../example/login/config/LoginProperties.java | 54 +++ .../com/example/login/config/WebConfig.java | 37 ++ .../login/controller/AuthController.java | 156 +++++++ .../login/controller/PageController.java | 22 + .../login/interceptor/LoginInterceptor.java | 92 ++++ .../com/example/login/model/ApiResponse.java | 31 ++ .../com/example/login/model/LoginInfo.java | 18 + .../com/example/login/model/LoginRequest.java | 12 + .../com/example/login/model/TokenInfo.java | 16 + .../example/login/service/SessionManager.java | 58 +++ .../login/service/impl/MapSessionManager.java | 148 ++++++ .../src/main/resources/application.yml | 37 ++ .../src/main/resources/static/admin.html | 362 +++++++++++++++ .../src/main/resources/static/index.html | 429 ++++++++++++++++++ .../src/main/resources/static/js/api.js | 118 +++++ .../src/main/resources/static/login.html | 201 ++++++++ 19 files changed, 1924 insertions(+) create mode 100644 springboot-single-login/.gitignore create mode 100644 springboot-single-login/pom.xml create mode 100644 springboot-single-login/src/main/java/com/example/login/LoginApplication.java create mode 100644 springboot-single-login/src/main/java/com/example/login/config/LoginProperties.java create mode 100644 springboot-single-login/src/main/java/com/example/login/config/WebConfig.java create mode 100644 springboot-single-login/src/main/java/com/example/login/controller/AuthController.java create mode 100644 springboot-single-login/src/main/java/com/example/login/controller/PageController.java create mode 100644 springboot-single-login/src/main/java/com/example/login/interceptor/LoginInterceptor.java create mode 100644 springboot-single-login/src/main/java/com/example/login/model/ApiResponse.java create mode 100644 springboot-single-login/src/main/java/com/example/login/model/LoginInfo.java create mode 100644 springboot-single-login/src/main/java/com/example/login/model/LoginRequest.java create mode 100644 springboot-single-login/src/main/java/com/example/login/model/TokenInfo.java create mode 100644 springboot-single-login/src/main/java/com/example/login/service/SessionManager.java create mode 100644 springboot-single-login/src/main/java/com/example/login/service/impl/MapSessionManager.java create mode 100644 springboot-single-login/src/main/resources/application.yml create mode 100644 springboot-single-login/src/main/resources/static/admin.html create mode 100644 springboot-single-login/src/main/resources/static/index.html create mode 100644 springboot-single-login/src/main/resources/static/js/api.js create mode 100644 springboot-single-login/src/main/resources/static/login.html diff --git a/springboot-single-login/.gitignore b/springboot-single-login/.gitignore new file mode 100644 index 0000000..ee99290 --- /dev/null +++ b/springboot-single-login/.gitignore @@ -0,0 +1,44 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +###logs### +logs/ +*.log + +###env### +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/springboot-single-login/pom.xml b/springboot-single-login/pom.xml new file mode 100644 index 0000000..2bb5d07 --- /dev/null +++ b/springboot-single-login/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + com.example + springboot-single-login + 1.0.0 + jar + + Spring Boot Single Login + 基于Token的单点登录实现 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + + 8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.apache.commons + commons-lang3 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/LoginApplication.java b/springboot-single-login/src/main/java/com/example/login/LoginApplication.java new file mode 100644 index 0000000..650ea43 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/LoginApplication.java @@ -0,0 +1,11 @@ +package com.example.login; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LoginApplication { + public static void main(String[] args) { + SpringApplication.run(LoginApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/config/LoginProperties.java b/springboot-single-login/src/main/java/com/example/login/config/LoginProperties.java new file mode 100644 index 0000000..f587527 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/config/LoginProperties.java @@ -0,0 +1,54 @@ +package com.example.login.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 登录配置类 + */ +@Data +@Component +@ConfigurationProperties(prefix = "app.login") +public class LoginProperties { + + /** + * 登录模式:SINGLE-单用户单登录,MULTIPLE-单用户多登录 + */ + private LoginMode mode = LoginMode.SINGLE; + + /** + * Token有效期(秒) + */ + private long tokenExpireTime = 30 * 60; + + /** + * Token前缀 + */ + private String tokenPrefix = "TOKEN_"; + + /** + * Token请求头名称 + */ + private String tokenHeader = "Authorization"; + + /** + * 是否启用自动清理过期Token + */ + private boolean enableAutoClean = true; + + /** + * 清理间隔(分钟) + */ + private int cleanInterval = 5; + + /** + * 登录模式枚举 + */ + public enum LoginMode { + // 单用户单登录(新登录踢出旧登录) + SINGLE, + // 单用户多登录(允许多个设备同时登录) + MULTIPLE + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/config/WebConfig.java b/springboot-single-login/src/main/java/com/example/login/config/WebConfig.java new file mode 100644 index 0000000..ae1af2b --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/config/WebConfig.java @@ -0,0 +1,37 @@ +package com.example.login.config; + +import com.example.login.interceptor.LoginInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web配置类 + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Autowired + private LoginInterceptor loginInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(loginInterceptor) + .addPathPatterns("/**") // 拦截所有请求 + .excludePathPatterns( + "/", // 首页 + "/login.html", // 登录页面 + "/index.html", // 主页 + "/admin.html", // 管理页面 + "/error", // 错误页面 + "/favicon.ico", // 图标 + "/css/**", // CSS文件 + "/js/**", // JS文件 + "/images/**", // 图片文件 + "/api/auth/login", // 登录API + "/api/auth/register", // 注册API(如果有) + "/api/status" // 状态检查API + ); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/controller/AuthController.java b/springboot-single-login/src/main/java/com/example/login/controller/AuthController.java new file mode 100644 index 0000000..924a229 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/controller/AuthController.java @@ -0,0 +1,156 @@ +package com.example.login.controller; + +import com.example.login.config.LoginProperties; +import com.example.login.model.ApiResponse; +import com.example.login.model.LoginInfo; +import com.example.login.model.LoginRequest; +import com.example.login.model.TokenInfo; +import com.example.login.service.SessionManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.*; + +/** + * 认证控制器(API接口) + */ +@RestController +@RequestMapping("/api/auth") +@Slf4j +public class AuthController { + + @Autowired + private SessionManager sessionManager; + + @Autowired + private LoginProperties loginProperties; + + // 模拟用户数据库 + private static final Map USER_DB = new HashMap<>(); + static { + USER_DB.put("admin", "admin123"); + USER_DB.put("user1", "user123"); + USER_DB.put("user2", "user123"); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request, + HttpServletRequest httpRequest) { + String username = request.getUsername(); + String password = request.getPassword(); + + // 1. 验证用户名密码 + if (!USER_DB.containsKey(username) || + !USER_DB.get(username).equals(password)) { + return ResponseEntity.status(401) + .body(ApiResponse.fail("用户名或密码错误")); + } + + // 2. 创建登录信息 + LoginInfo loginInfo = new LoginInfo( + getClientIp(httpRequest), + getClientDevice(httpRequest), + httpRequest.getHeader("User-Agent"), + System.currentTimeMillis() + ); + + // 3. 执行登录 + String token = sessionManager.login(username, loginInfo); + + // 4. 返回登录结果 + Map data = new HashMap<>(); + data.put("token", token); + data.put("username", username); + data.put("expireTime", System.currentTimeMillis() + + loginProperties.getTokenExpireTime() * 1000); + data.put("loginMode", loginProperties.getMode()); + + return ResponseEntity.ok(ApiResponse.success("登录成功", data)); + } + + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader(value = "${app.login.token-header:Authorization}", required = false) String token) { + if (org.apache.commons.lang3.StringUtils.isNotBlank(token)) { + sessionManager.logout(token); + } + return ResponseEntity.ok(ApiResponse.success("退出登录成功")); + } + + @PostMapping("/kickout") + public ResponseEntity kickout(@RequestParam String username) { + sessionManager.kickoutUser(username); + return ResponseEntity.ok(ApiResponse.success("已踢出用户:" + username)); + } + + @GetMapping("/online") + public ResponseEntity getOnlineUsers() { + Set users = sessionManager.getOnlineUsers(); + return ResponseEntity.ok(ApiResponse.success("获取成功", users)); + } + + /** + * 获取当前用户信息 + */ + @GetMapping("/current") + public ResponseEntity getCurrentUser(HttpServletRequest request) { + TokenInfo tokenInfo = (TokenInfo) request.getAttribute("tokenInfo"); + if (tokenInfo == null) { + return ResponseEntity.status(401) + .body(ApiResponse.fail(401, "未登录")); + } + + Map data = new HashMap<>(); + data.put("username", tokenInfo.getUsername()); + data.put("loginTime", tokenInfo.getLoginInfo().getLoginTime()); + data.put("ip", tokenInfo.getLoginInfo().getIp()); + data.put("device", tokenInfo.getLoginInfo().getDevice()); + data.put("userAgent", tokenInfo.getLoginInfo().getUserAgent()); + data.put("expireTime", tokenInfo.getExpireTime()); + + return ResponseEntity.ok(ApiResponse.success("获取成功", data)); + } + + @GetMapping("/tokens") + public ResponseEntity getUserTokens(@RequestParam String username) { + List tokens = sessionManager.getUserTokens(username); + return ResponseEntity.ok(ApiResponse.success("获取成功", tokens)); + } + + /** + * 获取客户端IP + */ + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; + } + + /** + * 获取客户端设备信息 + */ + private String getClientDevice(HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + if (userAgent == null) { + return "Unknown"; + } + + if (userAgent.contains("Mobile")) { + return "Mobile"; + } else if (userAgent.contains("Tablet")) { + return "Tablet"; + } else { + return "PC"; + } + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/controller/PageController.java b/springboot-single-login/src/main/java/com/example/login/controller/PageController.java new file mode 100644 index 0000000..ea93492 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/controller/PageController.java @@ -0,0 +1,22 @@ +package com.example.login.controller; + +import com.example.login.model.ApiResponse; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +/** + * 页面控制器 + */ +@Controller +public class PageController { + + /** + * API首页 - 检查登录状态 + */ + @GetMapping({"/api", "/api/status"}) + @ResponseBody + public ApiResponse apiStatus() { + return ApiResponse.success("API服务正常"); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/interceptor/LoginInterceptor.java b/springboot-single-login/src/main/java/com/example/login/interceptor/LoginInterceptor.java new file mode 100644 index 0000000..d1c6931 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/interceptor/LoginInterceptor.java @@ -0,0 +1,92 @@ +package com.example.login.interceptor; + +import com.example.login.model.ApiResponse; +import com.example.login.model.TokenInfo; +import com.example.login.service.SessionManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.lang.invoke.MethodHandle; +import java.util.Arrays; +import java.util.List; + +/** + * 登录验证拦截器 + */ +@Slf4j +@Component +public class LoginInterceptor implements HandlerInterceptor { + + @Autowired + private SessionManager sessionManager; + + @Value("${app.login.token-header:Authorization}") + private String tokenHeader; + + @Override + public boolean preHandle(HttpServletRequest request, + HttpServletResponse response, + Object handler) throws Exception { + + String requestURI = request.getRequestURI(); + + if(!(handler instanceof HandlerMethod)){ + return true; + } + + // 获取Token + String token = getTokenFromRequest(request); + if (token == null) { + return handleUnauthorized(response, "请先登录"); + } + + // 验证Token + TokenInfo tokenInfo = sessionManager.validateToken(token); + if (tokenInfo == null) { + return handleUnauthorized(response, "登录已过期,请重新登录"); + } + + // 将Token信息存入请求属性 + request.setAttribute("tokenInfo", tokenInfo); + request.setAttribute("username", tokenInfo.getUsername()); + + return true; + } + + /** + * 从请求中获取Token + */ + private String getTokenFromRequest(HttpServletRequest request) { + // 从Header中获取 + String token = request.getHeader(tokenHeader); + if (StringUtils.isNotBlank(token)) { + return token; + } + return null; + } + + /** + * 处理未授权请求 + */ + private boolean handleUnauthorized(HttpServletResponse response, String message) + throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(401); + + ApiResponse result = ApiResponse.fail(401, message); + response.getWriter().write( + new ObjectMapper().writeValueAsString(result) + ); + + return false; + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/model/ApiResponse.java b/springboot-single-login/src/main/java/com/example/login/model/ApiResponse.java new file mode 100644 index 0000000..a79ee3d --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/model/ApiResponse.java @@ -0,0 +1,31 @@ +package com.example.login.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 统一响应格式 + */ +@Data +@AllArgsConstructor +public class ApiResponse { + private int code; + private String message; + private T data; + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + public static ApiResponse success(String message) { + return success(message, null); + } + + public static ApiResponse fail(String message) { + return new ApiResponse<>(500, message, null); + } + + public static ApiResponse fail(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/model/LoginInfo.java b/springboot-single-login/src/main/java/com/example/login/model/LoginInfo.java new file mode 100644 index 0000000..5bd153f --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/model/LoginInfo.java @@ -0,0 +1,18 @@ +package com.example.login.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 登录信息 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class LoginInfo { + private String ip; // IP地址 + private String device; // 设备信息 + private String userAgent; // User-Agent + private long loginTime; // 登录时间 +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/model/LoginRequest.java b/springboot-single-login/src/main/java/com/example/login/model/LoginRequest.java new file mode 100644 index 0000000..ffbea8f --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/model/LoginRequest.java @@ -0,0 +1,12 @@ +package com.example.login.model; + +import lombok.Data; + +/** + * 登录请求 + */ +@Data +public class LoginRequest { + private String username; + private String password; +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/model/TokenInfo.java b/springboot-single-login/src/main/java/com/example/login/model/TokenInfo.java new file mode 100644 index 0000000..b406158 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/model/TokenInfo.java @@ -0,0 +1,16 @@ +package com.example.login.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * Token信息 + */ +@Data +@AllArgsConstructor +public class TokenInfo { + private String token; // Token值 + private String username; // 用户名 + private LoginInfo loginInfo; // 登录信息 + private long expireTime; // 过期时间 +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/service/SessionManager.java b/springboot-single-login/src/main/java/com/example/login/service/SessionManager.java new file mode 100644 index 0000000..884963d --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/service/SessionManager.java @@ -0,0 +1,58 @@ +package com.example.login.service; + +import com.example.login.model.LoginInfo; +import com.example.login.model.TokenInfo; + +import java.util.List; +import java.util.Set; + +/** + * 会话管理接口 + */ +public interface SessionManager { + + /** + * 用户登录 + * @param username 用户名 + * @param loginInfo 登录信息(IP、设备等) + * @return 登录Token + */ + String login(String username, LoginInfo loginInfo); + + /** + * 用户登出 + * @param token 登录Token + */ + void logout(String token); + + /** + * 验证Token是否有效 + * @param token 登录Token + * @return Token信息 + */ + TokenInfo validateToken(String token); + + /** + * 获取用户的所有Token + * @param username 用户名 + * @return Token列表 + */ + List getUserTokens(String username); + + /** + * 踢出用户的所有会话 + * @param username 用户名 + */ + void kickoutUser(String username); + + /** + * 获取所有在线用户 + * @return 在线用户列表 + */ + Set getOnlineUsers(); + + /** + * 清理过期Token + */ + void cleanExpiredTokens(); +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/service/impl/MapSessionManager.java b/springboot-single-login/src/main/java/com/example/login/service/impl/MapSessionManager.java new file mode 100644 index 0000000..ed465d2 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/service/impl/MapSessionManager.java @@ -0,0 +1,148 @@ +package com.example.login.service.impl; + +import com.example.login.config.LoginProperties; +import com.example.login.model.LoginInfo; +import com.example.login.model.TokenInfo; +import com.example.login.service.SessionManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 基于Map的会话管理器实现 + */ +@Slf4j +@Service +public class MapSessionManager implements SessionManager { + + private final LoginProperties properties; + + // 用户名 -> Token列表 + private final Map> userTokenMap = new ConcurrentHashMap<>(); + + // Token -> Token信息 + private final Map tokenMap = new ConcurrentHashMap<>(); + + public MapSessionManager(LoginProperties properties) { + this.properties = properties; + } + + @Override + public String login(String username, LoginInfo loginInfo) { + // 生成Token + String token = generateToken(); + + // 设置登录时间 + if (loginInfo.getLoginTime() == 0) { + loginInfo.setLoginTime(System.currentTimeMillis()); + } + + // 创建Token信息 + TokenInfo tokenInfo = new TokenInfo( + token, + username, + loginInfo, + System.currentTimeMillis() + properties.getTokenExpireTime() * 1000 + ); + + // 根据登录模式处理 + if (properties.getMode() == LoginProperties.LoginMode.SINGLE) { + // 单用户单登录:先踢出旧Token + kickoutUser(username); + } + + // 保存Token + tokenMap.put(token, tokenInfo); + userTokenMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()) + .add(token); + + log.info("用户登录成功: username={}, token={}, mode={}", + username, token, properties.getMode()); + + return token; + } + + @Override + public void logout(String token) { + TokenInfo tokenInfo = tokenMap.remove(token); + if (tokenInfo != null) { + Set tokens = userTokenMap.get(tokenInfo.getUsername()); + if (tokens != null) { + tokens.remove(token); + if (tokens.isEmpty()) { + userTokenMap.remove(tokenInfo.getUsername()); + } + } + log.info("用户登出: username={}, token={}", + tokenInfo.getUsername(), token); + } + } + + @Override + public TokenInfo validateToken(String token) { + TokenInfo tokenInfo = tokenMap.get(token); + if (tokenInfo == null) { + return null; + } + + // 检查是否过期 + if (System.currentTimeMillis() > tokenInfo.getExpireTime()) { + logout(token); + return null; + } + + // 更新过期时间(续期) + tokenInfo.setExpireTime( + System.currentTimeMillis() + properties.getTokenExpireTime() * 1000 + ); + + return tokenInfo; + } + + @Override + public List getUserTokens(String username) { + Set tokens = userTokenMap.get(username); + return tokens != null ? new ArrayList<>(tokens) : Collections.emptyList(); + } + + @Override + public void kickoutUser(String username) { + Set tokens = userTokenMap.remove(username); + if (tokens != null) { + for (String token : tokens) { + tokenMap.remove(token); + } + log.info("踢出用户所有会话: username={}", username); + } + } + + @Override + public Set getOnlineUsers() { + return new HashSet<>(userTokenMap.keySet()); + } + + @Override + public void cleanExpiredTokens() { + long now = System.currentTimeMillis(); + List expiredTokens = new ArrayList<>(); + + tokenMap.forEach((token, info) -> { + if (now > info.getExpireTime()) { + expiredTokens.add(token); + } + }); + + expiredTokens.forEach(this::logout); + log.info("清理过期Token: {}个", expiredTokens.size()); + } + + /** + * 生成Token + */ + private String generateToken() { + return properties.getTokenPrefix() + + UUID.randomUUID().toString().replace("-", ""); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/resources/application.yml b/springboot-single-login/src/main/resources/application.yml new file mode 100644 index 0000000..f688beb --- /dev/null +++ b/springboot-single-login/src/main/resources/application.yml @@ -0,0 +1,37 @@ +server: + port: 8080 + +app: + login: + # 登录模式:SINGLE-单用户单登录,MULTIPLE-单用户多登录 + mode: MULTIPLE + # Token有效期(秒) + token-expire-time: 1800 + # Token前缀 + token-prefix: TOKEN_ + # Token请求头名称 + token-header: Authorization + # 是否启用自动清理 + enable-auto-clean: true + # 清理间隔(分钟) + clean-interval: 5 + +# Redis配置(可选,用于分布式部署) +# spring: +# redis: +# host: localhost +# port: 6379 +# database: 0 +# timeout: 3000ms +# lettuce: +# pool: +# max-active: 8 +# max-idle: 8 +# min-idle: 0 + +# 日志配置 +logging: + level: + com.example.login: DEBUG + org.springframework.web: DEBUG + root: INFO \ No newline at end of file diff --git a/springboot-single-login/src/main/resources/static/admin.html b/springboot-single-login/src/main/resources/static/admin.html new file mode 100644 index 0000000..8b68b0d --- /dev/null +++ b/springboot-single-login/src/main/resources/static/admin.html @@ -0,0 +1,362 @@ + + + + + + 管理页面 - 登录系统 + + + + + + + + +
+
+ +
+

系统管理

+

管理在线用户和系统配置

+
+ + +
+
+
+
+ 当前在线用户数 +
+
0
+
+
+ +
+
+
+ 总登录会话数 +
+
0
+
+
+ +
+
+
+ 系统运行时间 +
+
0
+
+
+
+ + +
+
+
+

在线用户列表

+ +
+
+
+ + + + + + + + + + + + + + +
+ 用户名 + + 登录会话数 + + 最后活动时间 + + 操作 +
+ 正在加载... +
+
+
+
+
+ + +
+
+

系统配置

+
+
+
登录模式
+
+ + 单用户多登录 + +
+
+
+
Token有效期
+
30分钟
+
+
+
自动清理
+
已启用(每5分钟)
+
+
+
存储方式
+
内存存储(Map)
+
+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/springboot-single-login/src/main/resources/static/index.html b/springboot-single-login/src/main/resources/static/index.html new file mode 100644 index 0000000..b3f8840 --- /dev/null +++ b/springboot-single-login/src/main/resources/static/index.html @@ -0,0 +1,429 @@ + + + + + + 主页 - 登录系统 + + + + + + + + +
+
+ +
+
+

欢迎回来!

+

您已成功登录系统。当前登录模式为单用户多登录模式。

+

允许同一账号在多个设备上同时登录。

+
+
+ + +
+ +
+
+
+ 在线用户 +
+
-
+
+
+
+ +
+
+
+ + +
+
+
+ 当前登录设备 +
+
1
+
+
+
+ +
+
+
+ + +
+
+
+ 登录时间 +
+
-
+
+ +
+
+ + +
+
+

用户信息

+
+
+
用户名
+
-
+
+
+
登录IP
+
-
+
+
+
设备类型
+
-
+
+
+
Token过期时间
+
-
+
+
+
+ + + +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/springboot-single-login/src/main/resources/static/js/api.js b/springboot-single-login/src/main/resources/static/js/api.js new file mode 100644 index 0000000..f62fedc --- /dev/null +++ b/springboot-single-login/src/main/resources/static/js/api.js @@ -0,0 +1,118 @@ +// API配置 +const API_BASE_URL = ''; + +// 获取Token +export function getToken() { + return localStorage.getItem('token') || sessionStorage.getItem('token'); +} + +// 设置Token +export function setToken(token, remember) { + if (remember) { + localStorage.setItem('token', token); + } else { + sessionStorage.setItem('token', token); + } +} + +// 清除Token +export function clearToken() { + localStorage.removeItem('token'); + sessionStorage.removeItem('token'); +} + +// API请求封装 +async function apiRequest(url, options = {}) { + const token = localStorage.getItem('token') || sessionStorage.getItem('token'); + if (token) { + options.headers = { + ...options.headers, + 'Authorization': token + }; + } + + try { + const response = await fetch(API_BASE_URL + url, options); + + // 如果返回401,说明token失效,跳转到登录页 + if (response.status === 401) { + clearToken(); + // 只有不在登录页时才跳转 + if (window.location.pathname !== '/login.html') { + window.location.href = '/login.html'; + } + return null; + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('API请求失败:', error); + throw error; + } +} + +// 登录API +export async function loginApi(username, password) { + return apiRequest('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: username, + password: password + }) + }); +} + +// 登出API +export async function logoutApi() { + try { + await apiRequest('/api/auth/logout', { + method: 'POST' + }); + } finally { + clearToken(); + } +} + +// 获取当前用户信息API +export async function getCurrentUserApi() { + return apiRequest('/api/auth/current'); +} + +// 获取在线用户API +export async function getOnlineUsersApi() { + return apiRequest('/api/auth/online'); +} + +// 获取用户Token列表API +export async function getUserTokensApi(username) { + return apiRequest(`/api/auth/tokens?username=${username}`); +} + +// 踢出用户API +export async function kickoutUserApi(username) { + return apiRequest(`/api/auth/kickout?username=${username}`, { + method: 'POST' + }); +} + +// 检查登录状态 +export async function checkLoginStatus() { + const token = getToken(); + if (!token) { + return false; + } + + try { + const response = await apiRequest('/api/auth/current'); + return response && response.code === 200; + } catch (error) { + return false; + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/resources/static/login.html b/springboot-single-login/src/main/resources/static/login.html new file mode 100644 index 0000000..2702227 --- /dev/null +++ b/springboot-single-login/src/main/resources/static/login.html @@ -0,0 +1,201 @@ + + + + + + 登录系统 + + + + +
+
+

+ 登录您的账户 +

+

+ 单用户多登录模式 +

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ +
+
+ + + + + +
+

+ 测试账号:
+ admin / admin123 (管理员)
+ user1 / user123
+ user2 / user123 +

+
+
+ + + + \ No newline at end of file From 57f4ad118cdce8bc9590f5b293d8ea15b29ee29f Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 14 Dec 2025 09:42:34 +0800 Subject: [PATCH 2/6] springboot-cli --- springboot-cli/README.md | 48 +++++ springboot-cli/cli-client/pom.xml | 52 +++++ .../com/example/cli/CliClientApplication.java | 14 ++ .../com/example/cli/CliClientProperties.java | 77 ++++++++ .../com/example/cli/command/ExecCommand.java | 149 ++++++++++++++ .../src/main/resources/application.yml | 34 ++++ springboot-cli/cli-common/pom.xml | 26 +++ .../java/com/example/cli/CommandHandler.java | 31 +++ .../com/example/cli/dto/CommandRequest.java | 34 ++++ .../com/example/cli/dto/CommandResponse.java | 55 ++++++ springboot-cli/cli-server/pom.xml | 27 +++ .../java/com/example/cli/CliProperties.java | 55 ++++++ .../com/example/cli/CliServerApplication.java | 14 ++ .../example/cli/controller/CliController.java | 112 +++++++++++ .../com/example/cli/service/RoleService.java | 153 ++++++++++++++ .../example/cli/service/SystemService.java | 186 ++++++++++++++++++ .../com/example/cli/service/UserService.java | 133 +++++++++++++ .../src/main/resources/application.yml | 36 ++++ springboot-cli/pom.xml | 59 ++++++ 19 files changed, 1295 insertions(+) create mode 100644 springboot-cli/README.md create mode 100644 springboot-cli/cli-client/pom.xml create mode 100644 springboot-cli/cli-client/src/main/java/com/example/cli/CliClientApplication.java create mode 100644 springboot-cli/cli-client/src/main/java/com/example/cli/CliClientProperties.java create mode 100644 springboot-cli/cli-client/src/main/java/com/example/cli/command/ExecCommand.java create mode 100644 springboot-cli/cli-client/src/main/resources/application.yml create mode 100644 springboot-cli/cli-common/pom.xml create mode 100644 springboot-cli/cli-common/src/main/java/com/example/cli/CommandHandler.java create mode 100644 springboot-cli/cli-common/src/main/java/com/example/cli/dto/CommandRequest.java create mode 100644 springboot-cli/cli-common/src/main/java/com/example/cli/dto/CommandResponse.java create mode 100644 springboot-cli/cli-server/pom.xml create mode 100644 springboot-cli/cli-server/src/main/java/com/example/cli/CliProperties.java create mode 100644 springboot-cli/cli-server/src/main/java/com/example/cli/CliServerApplication.java create mode 100644 springboot-cli/cli-server/src/main/java/com/example/cli/controller/CliController.java create mode 100644 springboot-cli/cli-server/src/main/java/com/example/cli/service/RoleService.java create mode 100644 springboot-cli/cli-server/src/main/java/com/example/cli/service/SystemService.java create mode 100644 springboot-cli/cli-server/src/main/java/com/example/cli/service/UserService.java create mode 100644 springboot-cli/cli-server/src/main/resources/application.yml create mode 100644 springboot-cli/pom.xml diff --git a/springboot-cli/README.md b/springboot-cli/README.md new file mode 100644 index 0000000..7591628 --- /dev/null +++ b/springboot-cli/README.md @@ -0,0 +1,48 @@ +# Spring Boot CLI 通用命令系统 + +基于 Spring Boot + Spring Shell 的通用CLI系统,实现了"通用命令 + 动态分发"的设计模式,支持通过一个命令动态调用服务端的多个服务。 + +## 快速开始 + +### 1. 启动服务端 + +```bash +cd cli-server +mvn spring-boot:run +``` + +服务端将在 http://localhost:8080 启动 + +### 2. 启动客户端 + +```bash +cd cli-client +mvn spring-boot:run +``` + +### 3. 使用CLI命令 + +客户端启动后,进入Spring Shell交互模式,可使用以下命令: + +```shell +# 查看帮助 +help-exec + +# 列出所有可用服务 +list-services + +# 用户服务示例 +exec userService --args list +exec userService --args get 1 +exec userService --args count admin + +# 角色服务示例 +exec roleService --args list +exec roleService --args users admin +exec roleService --args check 1 admin + +# 系统服务示例 +exec systemService --args status +exec systemService --args memory +exec systemService --args time +``` \ No newline at end of file diff --git a/springboot-cli/cli-client/pom.xml b/springboot-cli/cli-client/pom.xml new file mode 100644 index 0000000..bc58e9a --- /dev/null +++ b/springboot-cli/cli-client/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + com.example + springboot-cli + 1.0.0 + + + cli-client + + + + com.example + cli-common + ${project.version} + + + org.springframework.shell + spring-shell-starter + + + org.springframework.boot + spring-boot-starter + + + cn.hutool + hutool-all + 5.8.16 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + \ No newline at end of file diff --git a/springboot-cli/cli-client/src/main/java/com/example/cli/CliClientApplication.java b/springboot-cli/cli-client/src/main/java/com/example/cli/CliClientApplication.java new file mode 100644 index 0000000..362d3d0 --- /dev/null +++ b/springboot-cli/cli-client/src/main/java/com/example/cli/CliClientApplication.java @@ -0,0 +1,14 @@ +package com.example.cli; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * CLI客户端主程序 + */ +@SpringBootApplication +public class CliClientApplication { + public static void main(String[] args) { + SpringApplication.run(CliClientApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-cli/cli-client/src/main/java/com/example/cli/CliClientProperties.java b/springboot-cli/cli-client/src/main/java/com/example/cli/CliClientProperties.java new file mode 100644 index 0000000..5b066d2 --- /dev/null +++ b/springboot-cli/cli-client/src/main/java/com/example/cli/CliClientProperties.java @@ -0,0 +1,77 @@ +package com.example.cli; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * CLI客户端配置属性 + */ +@Component +@ConfigurationProperties(prefix = "cli.client") +public class CliClientProperties { + + /** + * 服务端URL + */ + private String serverUrl = "http://localhost:8080"; + + /** + * 请求超时时间(毫秒) + */ + private long timeout = 30000; + + /** + * 连接超时时间(毫秒) + */ + private long connectTimeout = 5000; + + /** + * 是否启用请求重试 + */ + private boolean retryEnabled = true; + + /** + * 最大重试次数 + */ + private int maxRetries = 3; + + public String getServerUrl() { + return serverUrl; + } + + public void setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + public long getTimeout() { + return timeout; + } + + public void setTimeout(long timeout) { + this.timeout = timeout; + } + + public long getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(long connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public boolean isRetryEnabled() { + return retryEnabled; + } + + public void setRetryEnabled(boolean retryEnabled) { + this.retryEnabled = retryEnabled; + } + + public int getMaxRetries() { + return maxRetries; + } + + public void setMaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + } +} \ No newline at end of file diff --git a/springboot-cli/cli-client/src/main/java/com/example/cli/command/ExecCommand.java b/springboot-cli/cli-client/src/main/java/com/example/cli/command/ExecCommand.java new file mode 100644 index 0000000..772c3f2 --- /dev/null +++ b/springboot-cli/cli-client/src/main/java/com/example/cli/command/ExecCommand.java @@ -0,0 +1,149 @@ +package com.example.cli.command; + +import cn.hutool.http.HttpUtil; +import com.example.cli.CliClientProperties; +import com.example.cli.dto.CommandRequest; +import com.example.cli.dto.CommandResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.standard.ShellOption; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +/** + * 通用CLI命令 (Executive Command Service) + */ +@ShellComponent +@Component +public class ExecCommand { + + private static final Logger logger = LoggerFactory.getLogger(ExecCommand.class); + + @Autowired + private CliClientProperties properties; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 执行远程服务命令 + * @param serviceName 服务名称 + * @return 执行结果 + */ + @ShellMethod(key = "exec", value = "执行远程服务命令。用法: exec --args arg1 arg2 ...") + public String executeCommand( + @ShellOption(value = {"", "service"}, help = "服务名称") String serviceName, + @ShellOption(value = "--args", help = "命令参数", arity = 100) String[] args) { + + if (serviceName == null || serviceName.trim().isEmpty()) { + return "错误:服务名称不能为空\n用法: exec [--args arg1 arg2 ...]"; + } + + // 处理 null args + if (args == null) { + args = new String[0]; + } + + try { + // 构建请求 + CommandRequest request = new CommandRequest(serviceName.trim(), + Arrays.asList(args)); + + // 发送请求 + String response = HttpUtil.post(properties.getServerUrl() + "/cli", objectMapper.writeValueAsString(request)); + + // 解析响应 + CommandResponse commandResponse = objectMapper.readValue( + response, new TypeReference() {}); + + if (commandResponse.isSuccess()) { + return formatResponse(commandResponse.getData()); + } else { + return "错误: " + commandResponse.getMessage(); + } + + } catch (Exception e) { + logger.error("命令执行失败", e); + return "执行失败: " + e.getMessage(); + } + } + + /** + * 列出所有可用的服务 + */ + @ShellMethod(key = "list-services", value = "列出所有可用的服务") + public String listServices() { + try { + /* String response = webClient.get() + .uri(properties.getServerUrl() + "/cli/services") + .retrieve() + .bodyToMono(String.class) + .timeout(Duration.ofMillis(properties.getTimeout())) + .block(); + .block(); + */ + + String response = HttpUtil.post(properties.getServerUrl() + "/cli/services", new HashMap<>()); + + ObjectMapper mapper = new ObjectMapper(); + Object result = mapper.readValue(response, Object.class); + + return "可用服务列表:\n" + + mapper.writerWithDefaultPrettyPrinter().writeValueAsString(result); + + } catch (Exception e) { + logger.error("获取服务列表失败", e); + return "获取服务列表失败: " + e.getMessage(); + } + } + + /** + * 显示帮助信息 + */ + @ShellMethod(key = "help-exec", value = "显示EXEC命令帮助") + public String help() { + return """ + 通用命令服务 (EXEC) 使用说明: + + 基本命令: + exec --args [arg1 arg2 ...] - 执行远程服务命令 + list-services - 列出所有可用服务 + help-exec - 显示此帮助信息 + + 示例: + exec userService --args list - 获取用户列表 + exec userService --args get 1 - 获取ID为1的用户 + exec roleService --args users admin - 获取管理员角色列表 + exec systemService --args status - 获取系统状态 + + 配置: + 服务器地址: """ + properties.getServerUrl() + "\n" + + "超时时间: " + properties.getTimeout() + "ms\n"; + } + + /** + * 格式化响应输出 + */ + private String formatResponse(String data) { + if (data == null) { + return "命令执行成功,无返回数据"; + } + + // 如果是JSON格式,尝试美化输出 + try { + Object json = objectMapper.readValue(data, Object.class); + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(json); + } catch (Exception e) { + // 不是JSON格式,直接返回 + return data; + } + } +} \ No newline at end of file diff --git a/springboot-cli/cli-client/src/main/resources/application.yml b/springboot-cli/cli-client/src/main/resources/application.yml new file mode 100644 index 0000000..3d98fc1 --- /dev/null +++ b/springboot-cli/cli-client/src/main/resources/application.yml @@ -0,0 +1,34 @@ +spring: + application: + name: cli-client + +# CLI客户端配置 +cli: + client: + # 服务端URL + server-url: http://localhost:8080 + # 请求超时时间(毫秒) + timeout: 30000 + # 连接超时时间(毫秒) + connect-timeout: 5000 + # 是否启用请求重试 + retry-enabled: true + # 最大重试次数 + max-retries: 3 + +# Spring Shell配置 +shell: + # 启用交互模式 + interactive: + enabled: true + # 历史记录 + history: + enabled: true + size: 100 + +# 日志配置 +logging: + level: + com.example.cli: INFO + pattern: + console: "%d{HH:mm:ss} %-5level %logger{36} - %msg%n" \ No newline at end of file diff --git a/springboot-cli/cli-common/pom.xml b/springboot-cli/cli-common/pom.xml new file mode 100644 index 0000000..ffc30c2 --- /dev/null +++ b/springboot-cli/cli-common/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + + com.example + springboot-cli + 1.0.0 + + + cli-common + + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + \ No newline at end of file diff --git a/springboot-cli/cli-common/src/main/java/com/example/cli/CommandHandler.java b/springboot-cli/cli-common/src/main/java/com/example/cli/CommandHandler.java new file mode 100644 index 0000000..0ab7c5c --- /dev/null +++ b/springboot-cli/cli-common/src/main/java/com/example/cli/CommandHandler.java @@ -0,0 +1,31 @@ +package com.example.cli; + +/** + * 统一命令处理接口 + * 所有需要通过CLI调用的服务都必须实现此接口 + */ +public interface CommandHandler { + + /** + * 处理CLI命令 + * @param args 命令参数数组 + * @return 命令执行结果 + */ + String handle(String[] args); + + /** + * 获取命令描述信息 + * @return 命令描述 + */ + default String getDescription() { + return "No description available"; + } + + /** + * 获取命令使用帮助 + * @return 帮助信息 + */ + default String getUsage() { + return "Usage: command [args...]"; + } +} \ No newline at end of file diff --git a/springboot-cli/cli-common/src/main/java/com/example/cli/dto/CommandRequest.java b/springboot-cli/cli-common/src/main/java/com/example/cli/dto/CommandRequest.java new file mode 100644 index 0000000..e201ce8 --- /dev/null +++ b/springboot-cli/cli-common/src/main/java/com/example/cli/dto/CommandRequest.java @@ -0,0 +1,34 @@ +package com.example.cli.dto; + +import java.util.List; + +/** + * CLI命令请求DTO + */ +public class CommandRequest { + private String service; + private List args; + + public CommandRequest() {} + + public CommandRequest(String service, List args) { + this.service = service; + this.args = args; + } + + public String getService() { + return service; + } + + public void setService(String service) { + this.service = service; + } + + public List getArgs() { + return args; + } + + public void setArgs(List args) { + this.args = args; + } +} \ No newline at end of file diff --git a/springboot-cli/cli-common/src/main/java/com/example/cli/dto/CommandResponse.java b/springboot-cli/cli-common/src/main/java/com/example/cli/dto/CommandResponse.java new file mode 100644 index 0000000..4b85d2e --- /dev/null +++ b/springboot-cli/cli-common/src/main/java/com/example/cli/dto/CommandResponse.java @@ -0,0 +1,55 @@ +package com.example.cli.dto; + +/** + * CLI命令响应DTO + */ +public class CommandResponse { + private boolean success; + private String message; + private String data; + + public CommandResponse() {} + + public CommandResponse(boolean success, String message) { + this.success = success; + this.message = message; + } + + public CommandResponse(boolean success, String message, String data) { + this.success = success; + this.message = message; + this.data = data; + } + + public static CommandResponse success(String data) { + return new CommandResponse(true, "Success", data); + } + + public static CommandResponse error(String message) { + return new CommandResponse(false, message); + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } +} \ No newline at end of file diff --git a/springboot-cli/cli-server/pom.xml b/springboot-cli/cli-server/pom.xml new file mode 100644 index 0000000..df269af --- /dev/null +++ b/springboot-cli/cli-server/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + + com.example + springboot-cli + 1.0.0 + + + cli-server + + + + com.example + cli-common + ${project.version} + + + org.springframework.boot + spring-boot-starter-web + + + \ No newline at end of file diff --git a/springboot-cli/cli-server/src/main/java/com/example/cli/CliProperties.java b/springboot-cli/cli-server/src/main/java/com/example/cli/CliProperties.java new file mode 100644 index 0000000..4b7366a --- /dev/null +++ b/springboot-cli/cli-server/src/main/java/com/example/cli/CliProperties.java @@ -0,0 +1,55 @@ +package com.example.cli; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Set; + +/** + * CLI配置属性 + */ +@Component +@ConfigurationProperties(prefix = "cli") +public class CliProperties { + + /** + * 允许通过CLI访问的服务列表 + * 如果为空,则允许所有实现了CommandHandler的服务 + */ + private Set allowedServices = new HashSet<>(); + + /** + * 是否启用命令执行日志 + */ + private boolean enableExecutionLog = true; + + /** + * 命令执行超时时间(毫秒) + */ + private long executionTimeout = 30000; + + public Set getAllowedServices() { + return allowedServices; + } + + public void setAllowedServices(Set allowedServices) { + this.allowedServices = allowedServices; + } + + public boolean isEnableExecutionLog() { + return enableExecutionLog; + } + + public void setEnableExecutionLog(boolean enableExecutionLog) { + this.enableExecutionLog = enableExecutionLog; + } + + public long getExecutionTimeout() { + return executionTimeout; + } + + public void setExecutionTimeout(long executionTimeout) { + this.executionTimeout = executionTimeout; + } +} \ No newline at end of file diff --git a/springboot-cli/cli-server/src/main/java/com/example/cli/CliServerApplication.java b/springboot-cli/cli-server/src/main/java/com/example/cli/CliServerApplication.java new file mode 100644 index 0000000..700f8e1 --- /dev/null +++ b/springboot-cli/cli-server/src/main/java/com/example/cli/CliServerApplication.java @@ -0,0 +1,14 @@ +package com.example.cli; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * CLI服务端主程序 + */ +@SpringBootApplication +public class CliServerApplication { + public static void main(String[] args) { + SpringApplication.run(CliServerApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-cli/cli-server/src/main/java/com/example/cli/controller/CliController.java b/springboot-cli/cli-server/src/main/java/com/example/cli/controller/CliController.java new file mode 100644 index 0000000..74a2d72 --- /dev/null +++ b/springboot-cli/cli-server/src/main/java/com/example/cli/controller/CliController.java @@ -0,0 +1,112 @@ +package com.example.cli.controller; + +import com.example.cli.CliProperties; +import com.example.cli.CommandHandler; +import com.example.cli.dto.CommandRequest; +import com.example.cli.dto.CommandResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * CLI统一命令接口控制器 + */ +@RestController +@RequestMapping("/cli") +@Validated +@EnableConfigurationProperties(CliProperties.class) +public class CliController { + + private static final Logger logger = LoggerFactory.getLogger(CliController.class); + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private CliProperties cliProperties; + + private final Set allowedServices = new HashSet<>(); + + /** + * 初始化允许访问的服务列表 + */ + private void initializeAllowedServices() { + if (allowedServices.isEmpty()) { + allowedServices.addAll(cliProperties.getAllowedServices()); + } + } + + /** + * 执行CLI命令 + */ + @PostMapping + public ResponseEntity execute( + @RequestBody CommandRequest request, + HttpServletRequest httpRequest) { + + initializeAllowedServices(); + + String serviceName = request.getService(); + String[] args = request.getArgs() != null ? + request.getArgs().toArray(new String[0]) : new String[0]; + + logger.info("CLI请求 - 服务: {}, 参数: {}, 来源: {}", + serviceName, Arrays.toString(args), httpRequest.getRemoteAddr()); + + // 检查服务是否在白名单中 + if (!allowedServices.isEmpty() && !allowedServices.contains(serviceName)) { + logger.warn("未授权的服务访问: {}", serviceName); + return ResponseEntity.ok(CommandResponse.error("未授权的服务: " + serviceName)); + } + + // 获取Service Bean + Object serviceBean; + try { + serviceBean = applicationContext.getBean(serviceName); + } catch (NoSuchBeanDefinitionException e) { + logger.warn("服务不存在: {}", serviceName); + return ResponseEntity.ok(CommandResponse.error("服务不存在: " + serviceName)); + } + + // 检查是否实现了CommandHandler接口 + if (!(serviceBean instanceof CommandHandler handler)) { + logger.warn("服务未实现CommandHandler接口: {}", serviceName); + return ResponseEntity.ok(CommandResponse.error("服务未实现CommandHandler接口: " + serviceName)); + } + + try { + // 执行命令 + String result = handler.handle(args); + logger.info("命令执行成功 - 服务: {}", serviceName); + return ResponseEntity.ok(CommandResponse.success(result)); + } catch (Exception e) { + logger.error("命令执行失败 - 服务: " + serviceName, e); + return ResponseEntity.ok(CommandResponse.error("命令执行失败: " + e.getMessage())); + } + } + + /** + * 获取所有可用的服务列表 + */ + @GetMapping("/services") + public ResponseEntity getServices() { + initializeAllowedServices(); + + return ResponseEntity.ok(new Object() { + public final Set availableServices = allowedServices; + public final LocalDateTime timestamp = LocalDateTime.now(); + }); + } +} \ No newline at end of file diff --git a/springboot-cli/cli-server/src/main/java/com/example/cli/service/RoleService.java b/springboot-cli/cli-server/src/main/java/com/example/cli/service/RoleService.java new file mode 100644 index 0000000..cc3fbee --- /dev/null +++ b/springboot-cli/cli-server/src/main/java/com/example/cli/service/RoleService.java @@ -0,0 +1,153 @@ +package com.example.cli.service; + +import com.example.cli.CommandHandler; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * 角色服务示例 + */ +@Service("roleService") +public class RoleService implements CommandHandler { + + private final Map> userRoles = new HashMap<>(); + private final Map roleDescriptions = new HashMap<>(); + + public RoleService() { + // 初始化角色数据 + roleDescriptions.put("admin", "系统管理员"); + roleDescriptions.put("user", "普通用户"); + roleDescriptions.put("guest", "访客"); + roleDescriptions.put("developer", "开发者"); + roleDescriptions.put("operator", "运维人员"); + + // 初始化用户角色关系 + userRoles.put("1", new HashSet<>(Arrays.asList("admin", "developer"))); + userRoles.put("2", new HashSet<>(Arrays.asList("user"))); + userRoles.put("3", new HashSet<>(Arrays.asList("user", "operator"))); + userRoles.put("4", new HashSet<>(Arrays.asList("guest"))); + } + + @Override + public String handle(String[] args) { + if (args.length == 0) { + return getUsage(); + } + + String command = args[0].toLowerCase(); + + switch (command) { + case "list": + return listRoles(); + case "users": + if (args.length < 2) { + return "错误:请提供角色名称\n用法: roleService users "; + } + return getUsersByRole(args[1]); + case "check": + if (args.length < 3) { + return "错误:请提供用户ID和角色名称\n用法: roleService check "; + } + return checkUserRole(args[1], args[2]); + case "info": + if (args.length < 2) { + return listRoles(); + } + return getRoleInfo(args[1]); + default: + return "未知命令: " + command + "\n" + getUsage(); + } + } + + private String listRoles() { + StringBuilder sb = new StringBuilder(); + sb.append("可用角色列表:\n"); + sb.append("-".repeat(40)).append("\n"); + + roleDescriptions.forEach((role, desc) -> { + long userCount = userRoles.values().stream() + .filter(roles -> roles.contains(role)) + .count(); + sb.append(String.format("%s - %s (用户数: %d)\n", role, desc, userCount)); + }); + + return sb.toString(); + } + + private String getUsersByRole(String roleName) { + if (!roleDescriptions.containsKey(roleName)) { + return "角色不存在: " + roleName; + } + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("拥有角色 [%s] 的用户:\n", roleName)); + sb.append("-".repeat(40)).append("\n"); + + userRoles.entrySet().stream() + .filter(entry -> entry.getValue().contains(roleName)) + .forEach(entry -> { + sb.append(String.format("用户ID: %s\n", entry.getKey())); + }); + + return sb.toString(); + } + + private String checkUserRole(String userId, String roleName) { + if (!roleDescriptions.containsKey(roleName)) { + return "角色不存在: " + roleName; + } + + Set roles = userRoles.get(userId); + if (roles == null) { + return "用户不存在: " + userId; + } + + boolean hasRole = roles.contains(roleName); + return String.format("用户 %s %s角色 [%s]", + userId, hasRole ? "拥有" : "没有", roleName); + } + + private String getRoleInfo(String roleName) { + if (!roleDescriptions.containsKey(roleName)) { + return "角色不存在: " + roleName; + } + + long userCount = userRoles.values().stream() + .filter(roles -> roles.contains(roleName)) + .count(); + + return String.format(""" + 角色信息: + 名称: %s + 描述: %s + 用户数: %d + """, roleName, roleDescriptions.get(roleName), userCount); + } + + @Override + public String getDescription() { + return "角色管理服务"; + } + + @Override + public String getUsage() { + return """ + 角色服务使用说明: + + 命令格式: roleService [args] + + 可用命令: + list - 列出所有角色 + users - 查看拥有指定角色的用户 + check - 检查用户是否拥有指定角色 + info [role] - 获取角色信息 + + 示例: + roleService list - 列出所有角色 + roleService users admin - 查看管理员用户 + roleService check 1 admin - 检查用户1是否是管理员 + roleService info user - 获取user角色信息 + """; + } +} \ No newline at end of file diff --git a/springboot-cli/cli-server/src/main/java/com/example/cli/service/SystemService.java b/springboot-cli/cli-server/src/main/java/com/example/cli/service/SystemService.java new file mode 100644 index 0000000..b16cb23 --- /dev/null +++ b/springboot-cli/cli-server/src/main/java/com/example/cli/service/SystemService.java @@ -0,0 +1,186 @@ +package com.example.cli.service; + +import com.example.cli.CommandHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.info.BuildProperties; +import org.springframework.stereotype.Service; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.OperatingSystemMXBean; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.TimeZone; + +/** + * 系统服务示例 + */ +@Service("systemService") +public class SystemService implements CommandHandler { + + @Autowired(required = false) + private BuildProperties buildProperties; + + @Override + public String handle(String[] args) { + if (args.length == 0) { + return getUsage(); + } + + String command = args[0].toLowerCase(); + + switch (command) { + case "status": + return getSystemStatus(); + case "info": + return getSystemInfo(); + case "time": + return getCurrentTime(args.length > 1 && "utc".equalsIgnoreCase(args[1])); + case "memory": + return getMemoryInfo(); + case "version": + return getVersion(); + default: + return "未知命令: " + command + "\n" + getUsage(); + } + } + + private String getSystemStatus() { + Runtime runtime = Runtime.getRuntime(); + OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); + + return String.format(""" + 系统状态 + -------- + 操作系统: %s %s + 可用处理器: %d + 系统负载: %.2f%% + Java版本: %s + JVM运行时间: %s + """, + osBean.getName(), osBean.getVersion(), + runtime.availableProcessors(), + osBean.getSystemLoadAverage() * 100, + System.getProperty("java.version"), + formatUptime(ManagementFactory.getRuntimeMXBean().getUptime())); + } + + private String getSystemInfo() { + OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); + + return String.format(""" + 系统信息 + -------- + 操作系统: %s + 系统版本: %s + 系统架构: %s + 可用处理器数: %d + 系统负载平均值: %.2f + """, + osBean.getName(), + osBean.getVersion(), + System.getProperty("os.arch"), + osBean.getAvailableProcessors(), + osBean.getSystemLoadAverage()); + } + + private String getCurrentTime(boolean utc) { + LocalDateTime now = LocalDateTime.now(); + if (utc) { + return "当前时间 (UTC): " + now.atZone(TimeZone.getTimeZone("UTC").toZoneId()) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } else { + return "当前时间 (本地): " + now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + } + + private String getMemoryInfo() { + MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + Runtime runtime = Runtime.getRuntime(); + + long maxMemory = runtime.maxMemory(); + long totalMemory = runtime.totalMemory(); + long freeMemory = runtime.freeMemory(); + long usedMemory = totalMemory - freeMemory; + + return String.format(""" + 内存信息 + -------- + JVM最大内存: %s + JVM总内存: %s + 已使用内存: %s + 空闲内存: %s + 内存使用率: %.1f%% + 堆内存使用: %s + 堆内存最大: %s + """, + formatBytes(maxMemory), + formatBytes(totalMemory), + formatBytes(usedMemory), + formatBytes(freeMemory), + (double) usedMemory / maxMemory * 100, + formatBytes(memoryBean.getHeapMemoryUsage().getUsed()), + formatBytes(memoryBean.getHeapMemoryUsage().getMax())); + } + + private String getVersion() { + StringBuilder sb = new StringBuilder(); + sb.append("版本信息:\n"); + sb.append("-".repeat(30)).append("\n"); + + if (buildProperties != null) { + sb.append(String.format("应用版本: %s\n", buildProperties.getVersion())); + sb.append(String.format("构建时间: %s\n", buildProperties.getTime())); + } else { + sb.append("应用版本: 未知\n"); + } + + sb.append(String.format("Spring Boot版本: %s\n", + org.springframework.boot.SpringBootVersion.getVersion())); + sb.append(String.format("Java版本: %s\n", System.getProperty("java.version"))); + + return sb.toString(); + } + + private String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024)); + return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024)); + } + + private String formatUptime(long uptimeMs) { + long hours = uptimeMs / (1000 * 60 * 60); + long minutes = (uptimeMs % (1000 * 60 * 60)) / (1000 * 60); + long seconds = (uptimeMs % (1000 * 60)) / 1000; + + return String.format("%d小时%d分钟%d秒", hours, minutes, seconds); + } + + @Override + public String getDescription() { + return "系统信息和服务监控"; + } + + @Override + public String getUsage() { + return """ + 系统服务使用说明: + + 命令格式: systemService [args] + + 可用命令: + status - 获取系统状态 + info - 获取系统信息 + time [utc] - 获取当前时间(加utc参数显示UTC时间) + memory - 获取内存使用情况 + version - 获取版本信息 + + 示例: + systemService status - 获取系统状态 + systemService time - 获取本地时间 + systemService time utc - 获取UTC时间 + systemService memory - 获取内存信息 + """; + } +} \ No newline at end of file diff --git a/springboot-cli/cli-server/src/main/java/com/example/cli/service/UserService.java b/springboot-cli/cli-server/src/main/java/com/example/cli/service/UserService.java new file mode 100644 index 0000000..f186bf0 --- /dev/null +++ b/springboot-cli/cli-server/src/main/java/com/example/cli/service/UserService.java @@ -0,0 +1,133 @@ +package com.example.cli.service; + +import com.example.cli.CommandHandler; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * 用户服务示例 + */ +@Service("userService") +public class UserService implements CommandHandler { + + private final Map> users = new HashMap<>(); + + public UserService() { + // 初始化一些示例数据 + Map user1 = new HashMap<>(); + user1.put("id", "1"); + user1.put("name", "张三"); + user1.put("email", "zhangsan@example.com"); + user1.put("type", "admin"); + users.put("1", user1); + + Map user2 = new HashMap<>(); + user2.put("id", "2"); + user2.put("name", "李四"); + user2.put("email", "lisi@example.com"); + user2.put("type", "user"); + users.put("2", user2); + + Map user3 = new HashMap<>(); + user3.put("id", "3"); + user3.put("name", "王五"); + user3.put("email", "wangwu@example.com"); + user3.put("type", "user"); + users.put("3", user3); + } + + @Override + public String handle(String[] args) { + if (args.length == 0) { + return getUsage(); + } + + String command = args[0].toLowerCase(); + + switch (command) { + case "list": + return listUsers(args.length > 1 ? args[1] : null); + case "get": + if (args.length < 2) { + return "错误:请提供用户ID\n用法: userService get "; + } + return getUser(args[1]); + case "count": + return countUsers(args.length > 1 ? args[1] : null); + default: + return "未知命令: " + command + "\n" + getUsage(); + } + } + + private String listUsers(String type) { + StringBuilder sb = new StringBuilder(); + sb.append("用户列表:\n"); + sb.append("-".repeat(60)).append("\n"); + + users.values().stream() + .filter(user -> type == null || type.equalsIgnoreCase(user.get("type"))) + .forEach(user -> { + sb.append(String.format("ID: %s, 姓名: %s, 邮箱: %s, 类型: %s\n", + user.get("id"), user.get("name"), + user.get("email"), user.get("type"))); + }); + + return sb.toString(); + } + + private String getUser(String userId) { + Map user = users.get(userId); + if (user == null) { + return "用户不存在: " + userId; + } + + return String.format(""" + 用户详情: + ID: %s + 姓名: %s + 邮箱: %s + 类型: %s + """, user.get("id"), user.get("name"), + user.get("email"), user.get("type")); + } + + private String countUsers(String type) { + long count = users.values().stream() + .filter(user -> type == null || type.equalsIgnoreCase(user.get("type"))) + .count(); + + if (type != null) { + return String.format("%s类型的用户数量: %d", type, count); + } else { + return String.format("总用户数量: %d", count); + } + } + + @Override + public String getDescription() { + return "用户管理服务"; + } + + @Override + public String getUsage() { + return """ + 用户服务使用说明: + + 命令格式: userService [args] + + 可用命令: + list [type] - 列出用户,可指定类型(admin/user) + get - 获取指定ID的用户详情 + count [type] - 统计用户数量,可指定类型 + + 示例: + userService list - 列出所有用户 + userService list admin - 列出管理员用户 + userService get 1 - 获取ID为1的用户 + userService count - 统计总用户数 + userService count user - 统计普通用户数 + """; + } +} \ No newline at end of file diff --git a/springboot-cli/cli-server/src/main/resources/application.yml b/springboot-cli/cli-server/src/main/resources/application.yml new file mode 100644 index 0000000..049eed8 --- /dev/null +++ b/springboot-cli/cli-server/src/main/resources/application.yml @@ -0,0 +1,36 @@ +server: + port: 8080 + +spring: + application: + name: cli-server + +# CLI服务配置 +cli: + # 允许访问的服务列表,如果为空则允许所有实现了CommandHandler的服务 + allowed-services: + - userService + - roleService + - systemService + # 是否启用命令执行日志 + enable-execution-log: true + # 命令执行超时时间(毫秒) + execution-timeout: 30000 + +# 日志配置 +logging: + level: + com.example.cli: DEBUG + org.springframework.security: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + +# 管理端点配置 +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: always \ No newline at end of file diff --git a/springboot-cli/pom.xml b/springboot-cli/pom.xml new file mode 100644 index 0000000..c3aa4be --- /dev/null +++ b/springboot-cli/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + com.example + springboot-cli + 1.0.0 + pom + + + 17 + 17 + UTF-8 + 3.2.0 + 3.2.0 + + + + cli-common + cli-server + cli-client + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.springframework.shell + spring-shell-dependencies + ${spring-shell.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + + \ No newline at end of file From 51c700fcfefa82518898261b2111e2f44bb94915 Mon Sep 17 00:00:00 2001 From: yuoon Date: Thu, 1 Jan 2026 09:51:18 +0800 Subject: [PATCH 3/6] text-diff --- springboot-text-diff/pom.xml | 82 ++++ .../com/example/diff/DiffApplication.java | 12 + .../diff/controller/DiffController.java | 37 ++ .../controller/PropertiesDiffController.java | 34 ++ .../diff/controller/YamlDiffController.java | 51 +++ .../com/example/diff/model/DiffChange.java | 13 + .../java/com/example/diff/model/DiffLine.java | 25 ++ .../com/example/diff/model/DiffResult.java | 67 +++ .../diff/model/PropertiesDiffResult.java | 36 ++ .../com/example/diff/service/DiffService.java | 116 +++++ .../diff/service/PropertiesDiffService.java | 62 +++ .../example/diff/service/YamlDiffService.java | 34 ++ .../src/main/resources/application.properties | 9 + .../src/main/resources/static/index.html | 410 ++++++++++++++++++ 14 files changed, 988 insertions(+) create mode 100644 springboot-text-diff/pom.xml create mode 100644 springboot-text-diff/src/main/java/com/example/diff/DiffApplication.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/controller/DiffController.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/controller/PropertiesDiffController.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/controller/YamlDiffController.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/model/DiffChange.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/model/DiffLine.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/model/DiffResult.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/model/PropertiesDiffResult.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/service/DiffService.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/service/PropertiesDiffService.java create mode 100644 springboot-text-diff/src/main/java/com/example/diff/service/YamlDiffService.java create mode 100644 springboot-text-diff/src/main/resources/application.properties create mode 100644 springboot-text-diff/src/main/resources/static/index.html diff --git a/springboot-text-diff/pom.xml b/springboot-text-diff/pom.xml new file mode 100644 index 0000000..d3c7467 --- /dev/null +++ b/springboot-text-diff/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.1 + + + + com.example + springboot-text-diff + 1.0.0 + springboot-text-diff + Text diff utility with Spring Boot 3 + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + io.github.java-diff-utils + java-diff-utils + 4.12 + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/springboot-text-diff/src/main/java/com/example/diff/DiffApplication.java b/springboot-text-diff/src/main/java/com/example/diff/DiffApplication.java new file mode 100644 index 0000000..655d42a --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/DiffApplication.java @@ -0,0 +1,12 @@ +package com.example.diff; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DiffApplication { + + public static void main(String[] args) { + SpringApplication.run(DiffApplication.class, args); + } +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/controller/DiffController.java b/springboot-text-diff/src/main/java/com/example/diff/controller/DiffController.java new file mode 100644 index 0000000..f589a94 --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/controller/DiffController.java @@ -0,0 +1,37 @@ +package com.example.diff.controller; + +import com.example.diff.model.DiffResult; +import com.example.diff.service.DiffService; +import lombok.Data; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/diff") +@CrossOrigin(origins = "*") +public class DiffController { + + private final DiffService diffService; + + public DiffController(DiffService diffService) { + this.diffService = diffService; + } + + /** + * 比对两个文本的差异(Git 风格) + */ + @PostMapping("/text") + public ResponseEntity compareText(@RequestBody DiffRequest request) { + DiffResult result = diffService.compareConfigs( + request.getOriginal(), + request.getRevised() + ); + return ResponseEntity.ok(result); + } + + @Data + public static class DiffRequest { + private String original; + private String revised; + } +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/controller/PropertiesDiffController.java b/springboot-text-diff/src/main/java/com/example/diff/controller/PropertiesDiffController.java new file mode 100644 index 0000000..e1186ec --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/controller/PropertiesDiffController.java @@ -0,0 +1,34 @@ +package com.example.diff.controller; + +import com.example.diff.model.PropertiesDiffResult; +import com.example.diff.service.PropertiesDiffService; +import lombok.Data; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/diff/properties") +@CrossOrigin(origins = "*") +public class PropertiesDiffController { + + private final PropertiesDiffService diffService; + + public PropertiesDiffController(PropertiesDiffService diffService) { + this.diffService = diffService; + } + + @PostMapping("/compare") + public ResponseEntity compareProperties(@RequestBody DiffRequest request) { + PropertiesDiffResult result = diffService.compareProperties( + request.getOriginal(), + request.getRevised() + ); + return ResponseEntity.ok(result); + } + + @Data + public static class DiffRequest { + private String original; + private String revised; + } +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/controller/YamlDiffController.java b/springboot-text-diff/src/main/java/com/example/diff/controller/YamlDiffController.java new file mode 100644 index 0000000..eda7bd4 --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/controller/YamlDiffController.java @@ -0,0 +1,51 @@ +package com.example.diff.controller; + +import com.example.diff.model.DiffResult; +import com.example.diff.service.YamlDiffService; +import lombok.Data; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/diff/yaml") +@CrossOrigin(origins = "*") +public class YamlDiffController { + + private final YamlDiffService yamlDiffService; + + public YamlDiffController(YamlDiffService yamlDiffService) { + this.yamlDiffService = yamlDiffService; + } + + @PostMapping("/compare") + public ResponseEntity compareYaml(@RequestBody DiffRequest request) { + try { + DiffResult result = yamlDiffService.compareYaml( + request.getOriginal(), + request.getRevised() + ); + return ResponseEntity.ok(result); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } + } + + @PostMapping("/html") + public ResponseEntity compareYamlHtml(@RequestBody DiffRequest request) { + try { + DiffResult result = yamlDiffService.compareYaml( + request.getOriginal(), + request.getRevised() + ); + return ResponseEntity.ok(result.toHtml()); + } catch (Exception e) { + return ResponseEntity.badRequest().body("Error: " + e.getMessage()); + } + } + + @Data + public static class DiffRequest { + private String original; + private String revised; + } +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/model/DiffChange.java b/springboot-text-diff/src/main/java/com/example/diff/model/DiffChange.java new file mode 100644 index 0000000..95e667a --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/model/DiffChange.java @@ -0,0 +1,13 @@ +package com.example.diff.model; + +import lombok.Data; +import java.util.List; + +@Data +public class DiffChange { + private String type; // INSERT, DELETE, CHANGE + private int sourceLine; // 原配置行号 + private int targetLine; // 新配置行号 + private List originalLines; + private List revisedLines; +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/model/DiffLine.java b/springboot-text-diff/src/main/java/com/example/diff/model/DiffLine.java new file mode 100644 index 0000000..4d715db --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/model/DiffLine.java @@ -0,0 +1,25 @@ +package com.example.diff.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiffLine { + /** + * 行类型: EQUAL(相同), INSERT(新增), DELETE(删除), CHANGE(修改) + */ + private String type; + + /** + * 原始行的内容 + */ + private String originalLine; + + /** + * 修改后行的内容 + */ + private String revisedLine; +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/model/DiffResult.java b/springboot-text-diff/src/main/java/com/example/diff/model/DiffResult.java new file mode 100644 index 0000000..9e6acab --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/model/DiffResult.java @@ -0,0 +1,67 @@ +package com.example.diff.model; + +import lombok.Data; +import java.util.ArrayList; +import java.util.List; + +@Data +public class DiffResult { + private boolean hasChanges; + private List changes = new ArrayList<>(); + private List diffLines = new ArrayList<>(); + + public String toUnifiedFormat() { + StringBuilder sb = new StringBuilder(); + for (DiffChange change : changes) { + sb.append(String.format("@@ -%d,%d +%d,%d @@%n", + change.getSourceLine(), + change.getOriginalLines().size(), + change.getTargetLine(), + change.getRevisedLines().size())); + + for (String line : change.getOriginalLines()) { + sb.append("- ").append(line).append("\n"); + } + for (String line : change.getRevisedLines()) { + sb.append("+ ").append(line).append("\n"); + } + } + return sb.toString(); + } + + public String toHtml() { + StringBuilder html = new StringBuilder(); + html.append("
"); + + for (DiffChange change : changes) { + int srcLine = change.getSourceLine(); + int tgtLine = change.getTargetLine(); + + html.append("
") + .append(String.format("@@ -%d +%d @@ [%s]", srcLine, tgtLine, change.getType())) + .append("
"); + + for (String line : change.getOriginalLines()) { + html.append("
") + .append("- ").append(escapeHtml(line)) + .append("
"); + } + + for (String line : change.getRevisedLines()) { + html.append("
") + .append("+ ").append(escapeHtml(line)) + .append("
"); + } + } + + html.append("
"); + return html.toString(); + } + + private String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/model/PropertiesDiffResult.java b/springboot-text-diff/src/main/java/com/example/diff/model/PropertiesDiffResult.java new file mode 100644 index 0000000..18d7ba1 --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/model/PropertiesDiffResult.java @@ -0,0 +1,36 @@ +package com.example.diff.model; + +import lombok.Data; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Data +public class PropertiesDiffResult { + private Set removedKeys = new HashSet<>(); + private Set addedKeys = new HashSet<>(); + private Set modifiedKeys = new HashSet<>(); + private Map modifiedKeyChanges = new HashMap<>(); + + public boolean hasChanges() { + return !removedKeys.isEmpty() || !addedKeys.isEmpty() || !modifiedKeys.isEmpty(); + } + + public void addModifiedKey(String key, String oldValue, String newValue) { + KeyValueChange change = new KeyValueChange(); + change.setKey(key); + change.setOldValue(oldValue); + change.setNewValue(newValue); + modifiedKeyChanges.put(key, change); + } + + @Data + public static class KeyValueChange { + private String key; + private String oldValue; + private String newValue; + } +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/service/DiffService.java b/springboot-text-diff/src/main/java/com/example/diff/service/DiffService.java new file mode 100644 index 0000000..15d6d3b --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/service/DiffService.java @@ -0,0 +1,116 @@ +package com.example.diff.service; + +import com.example.diff.model.DiffLine; +import com.example.diff.model.DiffResult; +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.AbstractDelta; +import com.github.difflib.patch.DeltaType; +import com.github.difflib.patch.Patch; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Service +public class DiffService { + + /** + * 比对两个配置文本的差异,返回 Git 风格的左右对比结果 + */ + public DiffResult compareConfigs(String original, String revised) { + List originalLines = Arrays.asList(original.split("\\r?\\n")); + List revisedLines = Arrays.asList(revised.split("\\r?\\n")); + + Patch patch = DiffUtils.diff(originalLines, revisedLines); + + DiffResult result = new DiffResult(); + result.setHasChanges(!patch.getDeltas().isEmpty()); + + // 构建 Git 风格的行级对比 + List diffLines = buildGitStyleDiff(originalLines, revisedLines, patch); + result.setDiffLines(diffLines); + + // 同时保留原有的 change 信息(用于其他用途) + for (AbstractDelta delta : patch.getDeltas()) { + com.example.diff.model.DiffChange change = new com.example.diff.model.DiffChange(); + change.setType(delta.getType().name()); + change.setSourceLine(delta.getSource().getPosition() + 1); + change.setTargetLine(delta.getTarget().getPosition() + 1); + change.setOriginalLines(new ArrayList<>(delta.getSource().getLines())); + change.setRevisedLines(new ArrayList<>(delta.getTarget().getLines())); + result.getChanges().add(change); + } + + return result; + } + + /** + * 构建 Git 风格的左右对比 diff + */ + private List buildGitStyleDiff(List originalLines, List revisedLines, Patch patch) { + List result = new ArrayList<>(); + int origIdx = 0; + int revIdx = 0; + + for (AbstractDelta delta : patch.getDeltas()) { + int origDeltaStart = delta.getSource().getPosition(); + int revDeltaStart = delta.getTarget().getPosition(); + + // 添加差异之前的相同内容 + while (origIdx < origDeltaStart && revIdx < revDeltaStart) { + result.add(new DiffLine("EQUAL", originalLines.get(origIdx), revisedLines.get(revIdx))); + origIdx++; + revIdx++; + } + + // 处理差异块 + DeltaType type = delta.getType(); + List origLines = delta.getSource().getLines(); + List revLines = delta.getTarget().getLines(); + + if (type == DeltaType.INSERT) { + // INSERT: 右侧新增,左侧为空 + for (String line : revLines) { + result.add(new DiffLine("INSERT", null, line)); + } + revIdx += revLines.size(); + } else if (type == DeltaType.DELETE) { + // DELETE: 左侧删除,右侧为空 + for (String line : origLines) { + result.add(new DiffLine("DELETE", line, null)); + } + origIdx += origLines.size(); + } else if (type == DeltaType.CHANGE) { + // CHANGE: 两侧都有内容 + int maxLines = Math.max(origLines.size(), revLines.size()); + for (int i = 0; i < maxLines; i++) { + String origLine = i < origLines.size() ? origLines.get(i) : null; + String revLine = i < revLines.size() ? revLines.get(i) : null; + result.add(new DiffLine("CHANGE", origLine, revLine)); + } + origIdx += origLines.size(); + revIdx += revLines.size(); + } + } + + // 添加最后一个差异块之后的相同内容 + while (origIdx < originalLines.size() && revIdx < revisedLines.size()) { + result.add(new DiffLine("EQUAL", originalLines.get(origIdx), revisedLines.get(revIdx))); + origIdx++; + revIdx++; + } + + // 处理剩余的行(一边还有内容) + while (origIdx < originalLines.size()) { + result.add(new DiffLine("DELETE", originalLines.get(origIdx), null)); + origIdx++; + } + while (revIdx < revisedLines.size()) { + result.add(new DiffLine("INSERT", null, revisedLines.get(revIdx))); + revIdx++; + } + + return result; + } +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/service/PropertiesDiffService.java b/springboot-text-diff/src/main/java/com/example/diff/service/PropertiesDiffService.java new file mode 100644 index 0000000..fb21943 --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/service/PropertiesDiffService.java @@ -0,0 +1,62 @@ +package com.example.diff.service; + +import com.example.diff.model.PropertiesDiffResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.util.HashSet; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; + +@Service +public class PropertiesDiffService { + + /** + * 智能比对 Properties 配置 + */ + public PropertiesDiffResult compareProperties(String originalContent, String revisedContent) { + Properties original = parseProperties(originalContent); + Properties revised = parseProperties(revisedContent); + + PropertiesDiffResult result = new PropertiesDiffResult(); + + // 找出删除的 key + Set removedKeys = new HashSet<>(original.stringPropertyNames()); + removedKeys.removeAll(revised.stringPropertyNames()); + result.setRemovedKeys(removedKeys); + + // 找出新增的 key + Set addedKeys = new HashSet<>(revised.stringPropertyNames()); + addedKeys.removeAll(original.stringPropertyNames()); + result.setAddedKeys(addedKeys); + + // 找出修改的 key + Set modifiedKeys = new HashSet<>(); + for (String key : original.stringPropertyNames()) { + if (revised.containsKey(key)) { + String oldValue = original.getProperty(key); + String newValue = revised.getProperty(key); + if (!Objects.equals(oldValue, newValue)) { + modifiedKeys.add(key); + result.addModifiedKey(key, oldValue, newValue); + } + } + } + result.setModifiedKeys(modifiedKeys); + + return result; + } + + private Properties parseProperties(String content) { + Properties props = new Properties(); + try (ByteArrayInputStream bis = new ByteArrayInputStream(content.getBytes())) { + props.load(bis); + } catch (Exception e) { + throw new RuntimeException("Failed to parse properties", e); + } + return props; + } +} diff --git a/springboot-text-diff/src/main/java/com/example/diff/service/YamlDiffService.java b/springboot-text-diff/src/main/java/com/example/diff/service/YamlDiffService.java new file mode 100644 index 0000000..57266c7 --- /dev/null +++ b/springboot-text-diff/src/main/java/com/example/diff/service/YamlDiffService.java @@ -0,0 +1,34 @@ +package com.example.diff.service; + +import com.example.diff.model.DiffResult; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.springframework.stereotype.Service; + +@Service +public class YamlDiffService { + + private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + private final ObjectMapper jsonMapper = new ObjectMapper(); + + /** + * 比对 YAML 配置 + * 先解析为 JSON 树,再转为规范格式进行比对 + */ + public DiffResult compareYaml(String originalYaml, String revisedYaml) throws Exception { + // 解析 YAML + JsonNode originalTree = yamlMapper.readTree(originalYaml); + JsonNode revisedTree = yamlMapper.readTree(revisedYaml); + + // 转为规范 JSON 字符串 + String originalJson = jsonMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(originalTree); + String revisedJson = jsonMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(revisedTree); + + // 使用 DiffService 进行文本比对 + DiffService diffService = new DiffService(); + return diffService.compareConfigs(originalJson, revisedJson); + } +} diff --git a/springboot-text-diff/src/main/resources/application.properties b/springboot-text-diff/src/main/resources/application.properties new file mode 100644 index 0000000..170fb94 --- /dev/null +++ b/springboot-text-diff/src/main/resources/application.properties @@ -0,0 +1,9 @@ +# Server Configuration +server.port=8080 + +# Application Name +spring.application.name=text-diff + +# Logging +logging.level.com.example.diff=DEBUG +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n diff --git a/springboot-text-diff/src/main/resources/static/index.html b/springboot-text-diff/src/main/resources/static/index.html new file mode 100644 index 0000000..5591ffc --- /dev/null +++ b/springboot-text-diff/src/main/resources/static/index.html @@ -0,0 +1,410 @@ + + + + + + 配置差异比对工具 + + + + +
+ +
+

配置差异比对工具

+

基于 java-diff-utils 的文本/Properties/YAML 配置比对

+
+ + +
+
+ + + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + +
+ + + + + +
+ + + + + + +
+ + + + From 1583ed257446947b43974aa6d1373263746eec4d Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 4 Jan 2026 20:57:08 +0800 Subject: [PATCH 4/6] pipeline --- springboot-pipeline/pom.xml | 72 +++++++++ .../example/pipeline/PipelineApplication.java | 35 ++++ .../pipeline/controller/OrderController.java | 78 +++++++++ .../com/example/pipeline/model/Order.java | 109 +++++++++++++ .../pipeline/model/OrderException.java | 28 ++++ .../example/pipeline/model/OrderRequest.java | 84 ++++++++++ .../example/pipeline/model/OrderResponse.java | 56 +++++++ .../pipeline/nodes/AbstractOrderNode.java | 36 +++++ .../pipeline/nodes/AsyncRiskCheckNode.java | 150 ++++++++++++++++++ .../pipeline/nodes/BusinessValidateNode.java | 60 +++++++ .../pipeline/nodes/CreateOrderNode.java | 59 +++++++ .../pipeline/nodes/NotificationNode.java | 63 ++++++++ .../pipeline/nodes/OperateLogNode.java | 55 +++++++ .../pipeline/nodes/ParamValidateNode.java | 48 ++++++ .../pipeline/nodes/PermissionCheckNode.java | 47 ++++++ .../pipeline/pipeline/ExecutionPipeline.java | 69 ++++++++ .../pipeline/pipeline/FailureStrategy.java | 25 +++ .../example/pipeline/pipeline/Pipeline.java | 27 ++++ .../pipeline/pipeline/PipelineBuilder.java | 79 +++++++++ .../pipeline/pipeline/PipelineContext.java | 119 ++++++++++++++ .../pipeline/pipeline/PipelineException.java | 23 +++ .../pipeline/pipeline/PipelineNode.java | 36 +++++ .../pipeline/service/OrderService.java | 121 ++++++++++++++ .../src/main/resources/application.yml | 18 +++ .../test-requests/01-normal-order.json | 10 ++ .../test-requests/02-missing-param.json | 7 + .../test-requests/03-blacklist-user.json | 8 + 27 files changed, 1522 insertions(+) create mode 100644 springboot-pipeline/pom.xml create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/PipelineApplication.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/controller/OrderController.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/model/Order.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/model/OrderException.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/model/OrderRequest.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/model/OrderResponse.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/nodes/AbstractOrderNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/nodes/AsyncRiskCheckNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/nodes/BusinessValidateNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/nodes/CreateOrderNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/nodes/NotificationNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/nodes/OperateLogNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/nodes/ParamValidateNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/nodes/PermissionCheckNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/pipeline/ExecutionPipeline.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/pipeline/FailureStrategy.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/pipeline/Pipeline.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineBuilder.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineContext.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineException.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineNode.java create mode 100644 springboot-pipeline/src/main/java/com/example/pipeline/service/OrderService.java create mode 100644 springboot-pipeline/src/main/resources/application.yml create mode 100644 springboot-pipeline/test-requests/01-normal-order.json create mode 100644 springboot-pipeline/test-requests/02-missing-param.json create mode 100644 springboot-pipeline/test-requests/03-blacklist-user.json diff --git a/springboot-pipeline/pom.xml b/springboot-pipeline/pom.xml new file mode 100644 index 0000000..25e507c --- /dev/null +++ b/springboot-pipeline/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + + com.example + springboot-pipeline + 1.0.0 + Spring Boot Pipeline Pattern + Execution Pipeline Pattern Implementation with Spring Boot 3 + + + 17 + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/PipelineApplication.java b/springboot-pipeline/src/main/java/com/example/pipeline/PipelineApplication.java new file mode 100644 index 0000000..dc21154 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/PipelineApplication.java @@ -0,0 +1,35 @@ +package com.example.pipeline; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Spring Boot 执行管道模式示例应用 + */ +@SpringBootApplication +public class PipelineApplication { + + public static void main(String[] args) { + SpringApplication.run(PipelineApplication.class, args); + System.out.println(""" + ======================================== + Spring Boot Pipeline 应用已启动! + + 访问示例: + POST http://localhost:8080/api/orders + + 请求体示例: + { + "user_id": 1, + "product_id": 100, + "product_name": "iPhone 15 Pro", + "quantity": 1, + "unit_price": 7999.00, + "address": "北京市朝阳区", + "remark": "尽快发货", + "source": "WEB" + } + ======================================== + """); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/controller/OrderController.java b/springboot-pipeline/src/main/java/com/example/pipeline/controller/OrderController.java new file mode 100644 index 0000000..cd5bb54 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/controller/OrderController.java @@ -0,0 +1,78 @@ +package com.example.pipeline.controller; + +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.model.OrderResponse; +import com.example.pipeline.service.OrderService; +import com.example.pipeline.nodes.AsyncRiskCheckNode; +import com.example.pipeline.nodes.BusinessValidateNode; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 订单控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/orders") +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + + /** + * 创建订单 + * + * @param request 订单请求 + * @return 订单响应 + */ + @PostMapping + public ResponseEntity createOrder(@Valid @RequestBody OrderRequest request) { + log.info("收到订单创建请求: userId={}, productId={}", request.getUserId(), request.getProductId()); + OrderResponse response = orderService.createOrder(request); + return ResponseEntity.ok(response); + } + + /** + * 查询风控检查结果 + * + * @param orderId 订单ID + * @return 风控检查结果 + */ + @GetMapping("/{orderId}/risk-check") + public ResponseEntity getRiskCheckResult(@PathVariable Long orderId) { + AsyncRiskCheckNode.RiskCheckResult result = AsyncRiskCheckNode.getRiskCheckResult(orderId); + + if (result == null) { + return ResponseEntity.ok(new RiskCheckResponse(false, "风控检查中或未执行", false)); + } + + return ResponseEntity.ok(new RiskCheckResponse( + true, + result.getReason(), + result.isRisky() + )); + } + + /** + * 重置用户订单计数(测试接口) + * + * @param userId 用户ID + */ + @PostMapping("/test/reset-user-count/{userId}") + public ResponseEntity resetUserOrderCount(@PathVariable Long userId) { + BusinessValidateNode.resetUserOrderCount(userId); + return ResponseEntity.ok("用户订单计数已重置: userId=" + userId); + } + + /** + * 风控检查响应 + */ + public record RiskCheckResponse( + boolean checked, + String message, + boolean risky + ) {} +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/model/Order.java b/springboot-pipeline/src/main/java/com/example/pipeline/model/Order.java new file mode 100644 index 0000000..0a59e6f --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/model/Order.java @@ -0,0 +1,109 @@ +package com.example.pipeline.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 订单实体 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Order { + + /** + * 订单ID + */ + private Long id; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 用户ID + */ + private Long userId; + + /** + * 商品ID + */ + private Long productId; + + /** + * 商品名称 + */ + private String productName; + + /** + * 数量 + */ + private Integer quantity; + + /** + * 单价 + */ + private BigDecimal unitPrice; + + /** + * 总金额 + */ + private BigDecimal totalAmount; + + /** + * 收货地址 + */ + private String address; + + /** + * 备注 + */ + private String remark; + + /** + * 订单来源 + */ + private String source; + + /** + * 订单状态 + */ + private OrderStatus status; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + + /** + * 订单状态枚举 + */ + public enum OrderStatus { + PENDING, // 待处理 + CONFIRMED, // 已确认 + PAID, // 已支付 + SHIPPED, // 已发货 + COMPLETED, // 已完成 + CANCELLED, // 已取消 + FAILED // 失败 + } + + /** + * 创建订单号(模拟) + */ + public static String generateOrderNo() { + return "ORD" + System.currentTimeMillis(); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderException.java b/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderException.java new file mode 100644 index 0000000..003e354 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderException.java @@ -0,0 +1,28 @@ +package com.example.pipeline.model; + +/** + * 订单业务异常 + */ +public class OrderException extends RuntimeException { + + private final String errorCode; + + public OrderException(String message) { + super(message); + this.errorCode = "ORDER_ERROR"; + } + + public OrderException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public OrderException(String message, Throwable cause) { + super(message, cause); + this.errorCode = "ORDER_ERROR"; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderRequest.java b/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderRequest.java new file mode 100644 index 0000000..fd8feba --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderRequest.java @@ -0,0 +1,84 @@ +package com.example.pipeline.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 订单创建请求 + */ +@Data +public class OrderRequest { + + /** + * 用户ID + */ + @NotNull(message = "用户ID不能为空") + @JsonProperty("user_id") + private Long userId; + + /** + * 商品ID + */ + @NotNull(message = "商品ID不能为空") + @JsonProperty("product_id") + private Long productId; + + /** + * 商品名称 + */ + @NotBlank(message = "商品名称不能为空") + @JsonProperty("product_name") + private String productName; + + /** + * 数量 + */ + @NotNull(message = "数量不能为空") + @Min(value = 1, message = "数量必须大于0") + @JsonProperty("quantity") + private Integer quantity; + + /** + * 单价 + */ + @NotNull(message = "单价不能为空") + @JsonProperty("unit_price") + private BigDecimal unitPrice; + + /** + * 收货地址 + */ + @NotBlank(message = "收货地址不能为空") + @JsonProperty("address") + private String address; + + /** + * 备注 + */ + @JsonProperty("remark") + private String remark; + + /** + * 订单来源 + */ + @JsonProperty("source") + private String source = "WEB"; + + /** + * 是否跳过风控(用于测试) + */ + @JsonProperty("skip_risk_check") + private Boolean skipRiskCheck = false; + + /** + * 计算总金额 + */ + public BigDecimal getTotalAmount() { + return unitPrice.multiply(BigDecimal.valueOf(quantity)); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderResponse.java b/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderResponse.java new file mode 100644 index 0000000..dfa286f --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/model/OrderResponse.java @@ -0,0 +1,56 @@ +package com.example.pipeline.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 订单创建响应 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderResponse { + + /** + * 订单信息 + */ + private Order order; + + /** + * 是否成功 + */ + private Boolean success; + + /** + * 错误信息 + */ + private String errorMessage; + + /** + * 执行的节点列表 + */ + private List executedNodes; + + /** + * 失败的节点列表 + */ + private List failures; + + /** + * 失败节点信息 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FailureInfo { + private String nodeName; + private String reason; + private Long timestamp; + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/nodes/AbstractOrderNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/AbstractOrderNode.java new file mode 100644 index 0000000..21bb3f1 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/AbstractOrderNode.java @@ -0,0 +1,36 @@ +package com.example.pipeline.nodes; + +import com.example.pipeline.model.Order; +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.pipeline.PipelineContext; +import com.example.pipeline.pipeline.PipelineNode; +import lombok.extern.slf4j.Slf4j; + +/** + * 订单节点抽象基类 + * 提供通用方法和属性 + */ +@Slf4j +public abstract class AbstractOrderNode implements PipelineNode { + + /** + * 从上下文中获取订单 + */ + protected Order getOrder(PipelineContext context) { + return context.getAttribute("ORDER"); + } + + /** + * 将订单放入上下文 + */ + protected void setOrder(PipelineContext context, Order order) { + context.setAttribute("ORDER", order); + } + + /** + * 从上下文中获取订单请求 + */ + protected OrderRequest getRequest(PipelineContext context) { + return context.getData(); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/nodes/AsyncRiskCheckNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/AsyncRiskCheckNode.java new file mode 100644 index 0000000..749a716 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/AsyncRiskCheckNode.java @@ -0,0 +1,150 @@ +package com.example.pipeline.nodes; + +import com.example.pipeline.model.Order; +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.pipeline.FailureStrategy; +import com.example.pipeline.pipeline.PipelineContext; +import com.example.pipeline.pipeline.PipelineException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Random; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 异步风控检查节点 + * 异步执行风控检查,不阻塞主流程 + */ +@Slf4j +@Component +public class AsyncRiskCheckNode extends AbstractOrderNode { + + // 模拟风控黑名单用户 + private static final Set RISK_USERS = Set.of(777L); + + // 风控检查结果缓存 + private static final ConcurrentHashMap RISK_RESULT_CACHE = new ConcurrentHashMap<>(); + + private final Random random = new Random(); + + @Override + public void execute(PipelineContext context) throws PipelineException { + Order order = getOrder(context); + + if (order == null) { + log.warn("订单不存在,跳过风控检查"); + return; + } + + OrderRequest request = getRequest(context); + + // 测试环境下可以跳过风控 + if (Boolean.TRUE.equals(request.getSkipRiskCheck())) { + log.info("测试环境,跳过风控检查: orderId={}", order.getId()); + return; + } + + try { + // 异步执行风控检查 + final Long orderId = order.getId(); + CompletableFuture.runAsync(() -> performRiskCheck(order)) + .exceptionally(e -> { + log.error("风控检查异步执行失败: orderId={}", orderId, e); + return null; + }); + + log.info("风控检查已提交异步执行: orderId={}", order.getId()); + + } catch (Exception e) { + log.error("风控检查提交失败", e); + throw new PipelineException(getName(), "风控检查提交失败: " + e.getMessage(), e); + } + } + + private void performRiskCheck(Order order) { + try { + // 模拟风控检查耗时 + Thread.sleep(500 + random.nextInt(1000)); + + Long userId = order.getUserId(); + + // 检查是否命中风控规则 + RiskCheckResult result = checkRiskRules(order); + + RISK_RESULT_CACHE.put(order.getId(), result); + + if (result.isRisky()) { + log.warn("风控检查发现异常: orderId={}, userId={}, riskReason={}", + order.getId(), userId, result.getReason()); + } else { + log.info("风控检查通过: orderId={}, userId={}", order.getId(), userId); + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("风控检查被中断: orderId={}", order.getId(), e); + } + } + + private RiskCheckResult checkRiskRules(Order order) { + // 规则1: 检查用户是否在风控黑名单 + if (RISK_USERS.contains(order.getUserId())) { + return new RiskCheckResult(true, "用户在风控黑名单中"); + } + + // 规则2: 检查订单金额是否异常 + if (order.getTotalAmount().compareTo(new java.math.BigDecimal("10000")) > 0) { + return new RiskCheckResult(true, "订单金额异常"); + } + + // 规则3: 检查是否为恶意刷单(模拟) + if (order.getQuantity() > 100) { + return new RiskCheckResult(true, "订单数量异常"); + } + + // 风控检查通过 + return new RiskCheckResult(false, "正常"); + } + + @Override + public FailureStrategy getFailureStrategy() { + return FailureStrategy.CONTINUE; + } + + /** + * 获取风控检查结果 + */ + public static RiskCheckResult getRiskCheckResult(Long orderId) { + return RISK_RESULT_CACHE.get(orderId); + } + + /** + * 清除风控检查结果 + */ + public static void clearRiskCheckResult(Long orderId) { + RISK_RESULT_CACHE.remove(orderId); + } + + /** + * 风控检查结果 + */ + public static class RiskCheckResult { + private final boolean risky; + private final String reason; + + public RiskCheckResult(boolean risky, String reason) { + this.risky = risky; + this.reason = reason; + } + + public boolean isRisky() { + return risky; + } + + public String getReason() { + return reason; + } + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/nodes/BusinessValidateNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/BusinessValidateNode.java new file mode 100644 index 0000000..b3910cd --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/BusinessValidateNode.java @@ -0,0 +1,60 @@ +package com.example.pipeline.nodes; + +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.pipeline.PipelineContext; +import com.example.pipeline.pipeline.PipelineException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 业务校验节点 + * 检查业务规则是否满足 + */ +@Slf4j +@Component +public class BusinessValidateNode extends AbstractOrderNode { + + // 模拟用户订单计数 + public static final ConcurrentHashMap USER_ORDER_COUNT = new ConcurrentHashMap<>(); + + // 每个用户最大订单数量 + private static final int MAX_ORDERS_PER_USER = 10; + + @Override + public void execute(PipelineContext context) throws PipelineException { + OrderRequest request = getRequest(context); + Long userId = request.getUserId(); + + // 检查用户订单数量限制 + AtomicInteger count = USER_ORDER_COUNT.computeIfAbsent(userId, k -> new AtomicInteger(0)); + int currentCount = count.get(); + + if (currentCount >= MAX_ORDERS_PER_USER) { + throw new PipelineException(getName(), + String.format("用户订单数量已达上限 (%d/%d)", currentCount, MAX_ORDERS_PER_USER)); + } + + // 检查商品库存(模拟) + if (request.getProductId() == 1001) { + throw new PipelineException(getName(), "商品已售罄"); + } + + // 检查收货地址格式(模拟) + if (request.getAddress().length() < 5) { + throw new PipelineException(getName(), "收货地址格式不正确"); + } + + log.info("业务校验通过: userId={}, productId={}, currentOrderCount={}", + userId, request.getProductId(), currentCount); + } + + /** + * 重置用户订单计数(测试用) + */ + public static void resetUserOrderCount(Long userId) { + USER_ORDER_COUNT.remove(userId); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/nodes/CreateOrderNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/CreateOrderNode.java new file mode 100644 index 0000000..75e189a --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/CreateOrderNode.java @@ -0,0 +1,59 @@ +package com.example.pipeline.nodes; + +import com.example.pipeline.model.Order; +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.pipeline.PipelineContext; +import com.example.pipeline.pipeline.PipelineException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 创建订单节点 + * 核心业务节点,创建订单记录 + */ +@Slf4j +@Component +public class CreateOrderNode extends AbstractOrderNode { + + // 模拟订单ID生成器 + private static final AtomicLong ORDER_ID_GENERATOR = new AtomicLong(1000); + + @Override + public void execute(PipelineContext context) throws PipelineException { + OrderRequest request = getRequest(context); + + // 构建订单对象 + Order order = Order.builder() + .id(ORDER_ID_GENERATOR.incrementAndGet()) + .orderNo(Order.generateOrderNo()) + .userId(request.getUserId()) + .productId(request.getProductId()) + .productName(request.getProductName()) + .quantity(request.getQuantity()) + .unitPrice(request.getUnitPrice()) + .totalAmount(request.getTotalAmount()) + .address(request.getAddress()) + .remark(request.getRemark()) + .source(request.getSource()) + .status(Order.OrderStatus.PENDING) + .createTime(LocalDateTime.now()) + .updateTime(LocalDateTime.now()) + .build(); + + // 将订单放入上下文 + setOrder(context, order); + + // 更新用户订单计数 + BusinessValidateNode.USER_ORDER_COUNT + .computeIfAbsent(request.getUserId(), k -> new AtomicInteger(0)) + .incrementAndGet(); + + log.info("订单创建成功: orderId={}, orderNo={}, userId={}, amount={}", + order.getId(), order.getOrderNo(), order.getUserId(), order.getTotalAmount()); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/nodes/NotificationNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/NotificationNode.java new file mode 100644 index 0000000..3eae3bb --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/NotificationNode.java @@ -0,0 +1,63 @@ +package com.example.pipeline.nodes; + +import com.example.pipeline.model.Order; +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.pipeline.FailureStrategy; +import com.example.pipeline.pipeline.PipelineContext; +import com.example.pipeline.pipeline.PipelineException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 通知节点 + * 发送订单创建通知(失败不影响主流程) + */ +@Slf4j +@Component +public class NotificationNode extends AbstractOrderNode { + + @Override + public void execute(PipelineContext context) throws PipelineException { + Order order = getOrder(context); + + if (order == null) { + log.warn("订单不存在,跳过通知发送"); + return; + } + + try { + // 模拟发送短信通知 + sendSmsNotification(order); + + // 模拟发送邮件通知 + sendEmailNotification(order); + + // 模拟推送通知 + sendPushNotification(order); + + log.info("订单通知发送成功: orderId={}, userId={}", order.getId(), order.getUserId()); + + } catch (Exception e) { + log.error("通知发送失败", e); + throw new PipelineException(getName(), "通知发送失败: " + e.getMessage(), e); + } + } + + private void sendSmsNotification(Order order) { + log.info("【订单提醒】您已成功创建订单,订单号: {},金额: {}元", + order.getOrderNo(), order.getTotalAmount()); + } + + private void sendEmailNotification(Order order) { + log.info("发送邮件给用户 {}: 订单 {} 创建成功", order.getUserId(), order.getOrderNo()); + } + + private void sendPushNotification(Order order) { + log.info("推送通知: 订单 {} 创建成功", order.getOrderNo()); + } + + @Override + public FailureStrategy getFailureStrategy() { + return FailureStrategy.CONTINUE; + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/nodes/OperateLogNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/OperateLogNode.java new file mode 100644 index 0000000..dc07329 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/OperateLogNode.java @@ -0,0 +1,55 @@ +package com.example.pipeline.nodes; + +import com.example.pipeline.model.Order; +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.pipeline.FailureStrategy; +import com.example.pipeline.pipeline.PipelineContext; +import com.example.pipeline.pipeline.PipelineException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 操作日志节点 + * 记录订单创建日志(失败不影响主流程) + */ +@Slf4j +@Component +public class OperateLogNode extends AbstractOrderNode { + + @Override + public void execute(PipelineContext context) throws PipelineException { + Order order = getOrder(context); + + if (order == null) { + log.warn("订单不存在,跳过日志记录"); + return; + } + + try { + // 模拟写日志 + log.info("=== 操作日志 ==="); + log.info("操作类型: CREATE"); + log.info("订单ID: {}", order.getId()); + log.info("订单号: {}", order.getOrderNo()); + log.info("用户ID: {}", order.getUserId()); + log.info("商品: {} (ID: {})", order.getProductName(), order.getProductId()); + log.info("数量: {}", order.getQuantity()); + log.info("单价: {}", order.getUnitPrice()); + log.info("总金额: {}", order.getTotalAmount()); + log.info("收货地址: {}", order.getAddress()); + log.info("订单来源: {}", order.getSource()); + log.info("创建时间: {}", order.getCreateTime()); + log.info("==============="); + + } catch (Exception e) { + // 日志记录失败不影响主流程 + log.error("日志记录失败", e); + throw new PipelineException(getName(), "日志记录失败: " + e.getMessage(), e); + } + } + + @Override + public FailureStrategy getFailureStrategy() { + return FailureStrategy.CONTINUE; + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/nodes/ParamValidateNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/ParamValidateNode.java new file mode 100644 index 0000000..89e091d --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/ParamValidateNode.java @@ -0,0 +1,48 @@ +package com.example.pipeline.nodes; + +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.pipeline.PipelineContext; +import com.example.pipeline.pipeline.PipelineException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.util.Set; + +/** + * 参数校验节点 + * 使用 JSR-303 验证请求参数 + */ +@Component +public class ParamValidateNode extends AbstractOrderNode { + + private static final Logger logger = LoggerFactory.getLogger(ParamValidateNode.class); + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Override + public void execute(PipelineContext context) throws PipelineException { + OrderRequest request = getRequest(context); + + Set> violations = validator.validate(request); + + if (!violations.isEmpty()) { + StringBuilder sb = new StringBuilder("参数校验失败: "); + for (ConstraintViolation violation : violations) { + sb.append(violation.getMessage()).append("; "); + } + throw new PipelineException(getName(), sb.toString()); + } + + // 额外业务校验 + if (request.getTotalAmount().compareTo(java.math.BigDecimal.ZERO) <= 0) { + throw new PipelineException(getName(), "订单总金额必须大于0"); + } + + logger.info("参数校验通过: userId={}, productId={}, amount={}", + request.getUserId(), request.getProductId(), request.getTotalAmount()); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/nodes/PermissionCheckNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/PermissionCheckNode.java new file mode 100644 index 0000000..eb99e74 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/nodes/PermissionCheckNode.java @@ -0,0 +1,47 @@ +package com.example.pipeline.nodes; + +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.pipeline.PipelineContext; +import com.example.pipeline.pipeline.PipelineException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Set; + +/** + * 权限校验节点 + * 检查用户是否有创建订单的权限 + */ +@Slf4j +@Component +public class PermissionCheckNode extends AbstractOrderNode { + + // 模拟黑名单用户 + private static final Set BLACKLIST_USERS = Set.of(999L, 888L); + + // 模拟被封禁的用户 + private static final Set BANNED_USERS = new HashSet<>(); + + static { + BANNED_USERS.add(666L); + } + + @Override + public void execute(PipelineContext context) throws PipelineException { + OrderRequest request = getRequest(context); + Long userId = request.getUserId(); + + // 检查黑名单 + if (BLACKLIST_USERS.contains(userId)) { + throw new PipelineException(getName(), "用户在黑名单中,无法创建订单"); + } + + // 检查封禁状态 + if (BANNED_USERS.contains(userId)) { + throw new PipelineException(getName(), "用户已被封禁,无法创建订单"); + } + + log.info("权限校验通过: userId={}", userId); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/ExecutionPipeline.java b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/ExecutionPipeline.java new file mode 100644 index 0000000..9460852 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/ExecutionPipeline.java @@ -0,0 +1,69 @@ +package com.example.pipeline.pipeline; + +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +/** + * 执行管道实现 + * + * @param 数据类型 + */ +@Slf4j +public class ExecutionPipeline implements Pipeline { + + private final List> nodes; + private final String name; + + ExecutionPipeline(List> nodes, String name) { + this.nodes = nodes; + this.name = name; + } + + @Override + public PipelineContext execute(T data) { + log.info("Pipeline [{}] started with {} nodes", name, nodes.size()); + + PipelineContext context = new PipelineContext<>(data); + + for (PipelineNode node : nodes) { + if (context.isInterrupted()) { + log.info("Pipeline [{}] interrupted: {}", name, context.getInterruptReason()); + break; + } + + executeNode(node, context); + } + + log.info("Pipeline [{}] completed. Executed: {}, Failures: {}", + name, context.getExecutedNodes().size(), context.getFailures().size()); + + return context; + } + + private void executeNode(PipelineNode node, PipelineContext context) { + String nodeName = node.getName(); + log.debug("Executing node: {}", nodeName); + + try { + node.execute(context); + context.markNodeExecuted(nodeName); + log.debug("Node [{}] executed successfully", nodeName); + } catch (Exception e) { + log.error("Node [{}] execution failed", nodeName, e); + + FailureStrategy strategy = node.getFailureStrategy(); + context.recordFailure(nodeName, e.getMessage(), e); + + switch (strategy) { + case STOP: + context.interrupt("Node [" + nodeName + "] failed with STOP strategy"); + break; + case CONTINUE: + case SKIP: + // 继续执行下一个节点 + break; + } + } + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/FailureStrategy.java b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/FailureStrategy.java new file mode 100644 index 0000000..325449c --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/FailureStrategy.java @@ -0,0 +1,25 @@ +package com.example.pipeline.pipeline; + +/** + * 节点失败策略枚举 + */ +public enum FailureStrategy { + + /** + * 失败后继续执行下一个节点 + * 适用于:日志、通知等非关键操作 + */ + CONTINUE, + + /** + * 失败后中断管道执行 + * 适用于:参数校验、权限校验等关键操作 + */ + STOP, + + /** + * 失败后跳过当前节点,继续执行 + * 适用于:可选的操作 + */ + SKIP +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/Pipeline.java b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/Pipeline.java new file mode 100644 index 0000000..30e35a3 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/Pipeline.java @@ -0,0 +1,27 @@ +package com.example.pipeline.pipeline; + +/** + * 管道接口 + * + * @param 数据类型 + */ +public interface Pipeline { + + /** + * 执行管道 + * + * @param data 输入数据 + * @return 执行结果上下文 + */ + PipelineContext execute(T data); + + /** + * 创建管道构建器 + * + * @param 数据类型 + * @return 构建器 + */ + static PipelineBuilder builder() { + return new PipelineBuilder<>(); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineBuilder.java b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineBuilder.java new file mode 100644 index 0000000..5074634 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineBuilder.java @@ -0,0 +1,79 @@ +package com.example.pipeline.pipeline; + +import java.util.ArrayList; +import java.util.List; + +/** + * 管道构建器 + * 使用 Builder 模式构建管道 + * + * @param 数据类型 + */ +public class PipelineBuilder { + + private final List> nodes; + private String name = "DefaultPipeline"; + + public PipelineBuilder() { + this.nodes = new ArrayList<>(); + } + + /** + * 添加节点 + * + * @param node 节点 + * @return this + */ + public PipelineBuilder add(PipelineNode node) { + this.nodes.add(node); + return this; + } + + /** + * 添加多个节点 + * + * @param newNodes 节点列表 + * @return this + */ + public PipelineBuilder addAll(List> newNodes) { + this.nodes.addAll(newNodes); + return this; + } + + /** + * 设置管道名称 + * + * @param name 名称 + * @return this + */ + public PipelineBuilder name(String name) { + this.name = name; + return this; + } + + /** + * 条件添加节点 + * + * @param condition 条件 + * @param node 节点 + * @return this + */ + public PipelineBuilder addIf(boolean condition, PipelineNode node) { + if (condition) { + this.nodes.add(node); + } + return this; + } + + /** + * 构建管道 + * + * @return 管道实例 + */ + public Pipeline build() { + if (nodes.isEmpty()) { + throw new IllegalStateException("Pipeline must have at least one node"); + } + return new ExecutionPipeline<>(nodes, name); + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineContext.java b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineContext.java new file mode 100644 index 0000000..5360baa --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineContext.java @@ -0,0 +1,119 @@ +package com.example.pipeline.pipeline; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 管道上下文 + * 用于在节点之间传递数据和状态 + * + * @param 主要数据类型 + */ +@Getter +public class PipelineContext { + + /** + * 主要业务数据 + */ + private final T data; + + /** + * 是否中断管道执行 + */ + private boolean interrupted; + + /** + * 中断原因 + */ + private String interruptReason; + + /** + * 执行过的节点列表 + */ + private final List executedNodes; + + /** + * 失败的节点列表 + */ + private final List failures; + + /** + * 扩展属性,用于节点间传递额外数据 + */ + private final Map attributes; + + public PipelineContext(T data) { + this.data = data; + this.executedNodes = new ArrayList<>(); + this.failures = new ArrayList<>(); + this.attributes = new HashMap<>(); + this.interrupted = false; + } + + /** + * 中断管道执行 + */ + public void interrupt(String reason) { + this.interrupted = true; + this.interruptReason = reason; + } + + /** + * 标记节点执行完成 + */ + public void markNodeExecuted(String nodeName) { + this.executedNodes.add(nodeName); + } + + /** + * 记录节点失败 + */ + public void recordFailure(String nodeName, String reason, Throwable cause) { + this.failures.add(new NodeFailure(nodeName, reason, cause)); + } + + /** + * 设置扩展属性 + */ + public void setAttribute(String key, Object value) { + this.attributes.put(key, value); + } + + /** + * 获取扩展属性 + */ + @SuppressWarnings("unchecked") + public V getAttribute(String key) { + return (V) this.attributes.get(key); + } + + /** + * 获取扩展属性,支持默认值 + */ + @SuppressWarnings("unchecked") + public V getAttribute(String key, V defaultValue) { + return (V) this.attributes.getOrDefault(key, defaultValue); + } + + /** + * 节点失败记录 + */ + @Getter + public static class NodeFailure { + private final String nodeName; + private final String reason; + private final Throwable cause; + private final long timestamp; + + public NodeFailure(String nodeName, String reason, Throwable cause) { + this.nodeName = nodeName; + this.reason = reason; + this.cause = cause; + this.timestamp = System.currentTimeMillis(); + } + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineException.java b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineException.java new file mode 100644 index 0000000..c4f04b8 --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineException.java @@ -0,0 +1,23 @@ +package com.example.pipeline.pipeline; + +/** + * 管道执行异常 + */ +public class PipelineException extends Exception { + + private final String nodeName; + + public PipelineException(String nodeName, String message) { + super(message); + this.nodeName = nodeName; + } + + public PipelineException(String nodeName, String message, Throwable cause) { + super(message, cause); + this.nodeName = nodeName; + } + + public String getNodeName() { + return nodeName; + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineNode.java b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineNode.java new file mode 100644 index 0000000..591d16c --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/pipeline/PipelineNode.java @@ -0,0 +1,36 @@ +package com.example.pipeline.pipeline; + +/** + * 管道节点接口 + * 每个节点只做一件事,不关心前后节点是谁 + * + * @param 上下文数据类型 + */ +public interface PipelineNode { + + /** + * 执行节点逻辑 + * + * @param context 管道上下文 + * @throws PipelineException 节点执行异常 + */ + void execute(PipelineContext context) throws PipelineException; + + /** + * 获取节点名称 + * + * @return 节点名称 + */ + default String getName() { + return this.getClass().getSimpleName(); + } + + /** + * 获取节点失败策略 + * + * @return 失败策略 + */ + default FailureStrategy getFailureStrategy() { + return FailureStrategy.STOP; + } +} diff --git a/springboot-pipeline/src/main/java/com/example/pipeline/service/OrderService.java b/springboot-pipeline/src/main/java/com/example/pipeline/service/OrderService.java new file mode 100644 index 0000000..865aefb --- /dev/null +++ b/springboot-pipeline/src/main/java/com/example/pipeline/service/OrderService.java @@ -0,0 +1,121 @@ +package com.example.pipeline.service; + +import com.example.pipeline.model.Order; +import com.example.pipeline.model.OrderRequest; +import com.example.pipeline.model.OrderResponse; +import com.example.pipeline.nodes.*; +import com.example.pipeline.pipeline.Pipeline; +import com.example.pipeline.pipeline.PipelineContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 订单服务 + * 使用执行管道处理订单创建流程 + */ +@Slf4j +@Service +public class OrderService { + + private final ParamValidateNode paramValidateNode; + private final PermissionCheckNode permissionCheckNode; + private final BusinessValidateNode businessValidateNode; + private final CreateOrderNode createOrderNode; + private final OperateLogNode operateLogNode; + private final NotificationNode notificationNode; + private final AsyncRiskCheckNode asyncRiskCheckNode; + + public OrderService( + ParamValidateNode paramValidateNode, + PermissionCheckNode permissionCheckNode, + BusinessValidateNode businessValidateNode, + CreateOrderNode createOrderNode, + OperateLogNode operateLogNode, + NotificationNode notificationNode, + AsyncRiskCheckNode asyncRiskCheckNode) { + this.paramValidateNode = paramValidateNode; + this.permissionCheckNode = permissionCheckNode; + this.businessValidateNode = businessValidateNode; + this.createOrderNode = createOrderNode; + this.operateLogNode = operateLogNode; + this.notificationNode = notificationNode; + this.asyncRiskCheckNode = asyncRiskCheckNode; + } + + /** + * 创建订单 + * 使用执行管道模式处理订单创建流程 + * + * @param request 订单请求 + * @return 订单响应 + */ + public OrderResponse createOrder(OrderRequest request) { + log.info("开始创建订单: userId={}, productId={}", request.getUserId(), request.getProductId()); + + // 构建订单创建管道 + Pipeline pipeline = Pipeline.builder() + .name("OrderCreationPipeline") + .add(paramValidateNode) // 1. 参数校验 + .add(permissionCheckNode) // 2. 权限校验 + .add(businessValidateNode) // 3. 业务校验 + .add(createOrderNode) // 4. 创建订单 + .add(operateLogNode) // 5. 记录日志 + .add(notificationNode) // 6. 发送通知 + .add(asyncRiskCheckNode) // 7. 风控检查(异步) + .build(); + + // 执行管道 + PipelineContext context = pipeline.execute(request); + + // 构建响应 + return buildResponse(context); + } + + /** + * 获取订单详情(从管道上下文中获取) + */ + public Order getOrderFromContext(PipelineContext context) { + return context.getAttribute("ORDER"); + } + + /** + * 构建响应对象 + */ + private OrderResponse buildResponse(PipelineContext context) { + Order order = context.getAttribute("ORDER"); + + // 转换失败信息 + List failureInfos = context.getFailures().stream() + .map(f -> OrderResponse.FailureInfo.builder() + .nodeName(f.getNodeName()) + .reason(f.getReason()) + .timestamp(f.getTimestamp()) + .build()) + .toList(); + + boolean success = (order != null) && context.getFailures().isEmpty(); + + return OrderResponse.builder() + .order(order) + .success(success) + .errorMessage(success ? null : getErrorMessage(context)) + .executedNodes(context.getExecutedNodes()) + .failures(failureInfos) + .build(); + } + + /** + * 获取错误信息 + */ + private String getErrorMessage(PipelineContext context) { + if (context.getInterruptReason() != null) { + return context.getInterruptReason(); + } + if (!context.getFailures().isEmpty()) { + return context.getFailures().get(0).getReason(); + } + return "未知错误"; + } +} diff --git a/springboot-pipeline/src/main/resources/application.yml b/springboot-pipeline/src/main/resources/application.yml new file mode 100644 index 0000000..ddbd82f --- /dev/null +++ b/springboot-pipeline/src/main/resources/application.yml @@ -0,0 +1,18 @@ +server: + port: 8080 + +spring: + application: + name: springboot-pipeline + + jackson: + default-property-inclusion: non_null + serialization: + indent-output: true + +logging: + level: + root: INFO + com.example.pipeline: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" diff --git a/springboot-pipeline/test-requests/01-normal-order.json b/springboot-pipeline/test-requests/01-normal-order.json new file mode 100644 index 0000000..5471d4f --- /dev/null +++ b/springboot-pipeline/test-requests/01-normal-order.json @@ -0,0 +1,10 @@ +{ + "user_id": 1, + "product_id": 100, + "product_name": "iPhone 15 Pro", + "quantity": 1, + "unit_price": 7999.00, + "address": "北京市朝阳区望京SOHO", + "remark": "尽快发货", + "source": "WEB" +} diff --git a/springboot-pipeline/test-requests/02-missing-param.json b/springboot-pipeline/test-requests/02-missing-param.json new file mode 100644 index 0000000..4160ac2 --- /dev/null +++ b/springboot-pipeline/test-requests/02-missing-param.json @@ -0,0 +1,7 @@ +{ + "product_id": 100, + "product_name": "iPhone 15 Pro", + "quantity": 1, + "unit_price": 7999.00, + "address": "北京市" +} diff --git a/springboot-pipeline/test-requests/03-blacklist-user.json b/springboot-pipeline/test-requests/03-blacklist-user.json new file mode 100644 index 0000000..a6c59ab --- /dev/null +++ b/springboot-pipeline/test-requests/03-blacklist-user.json @@ -0,0 +1,8 @@ +{ + "user_id": 999, + "product_id": 100, + "product_name": "iPhone 15 Pro", + "quantity": 1, + "unit_price": 7999.00, + "address": "北京市朝阳区望京SOHO" +} From c8e898c1b84de1a9723352a706d4d268200aa38f Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 11 Jan 2026 18:36:27 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E7=BD=91=E7=BB=9C=E9=99=90=E9=80=9F?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- springboot-netspeed-limit/README.md | 287 ++++++++++++ springboot-netspeed-limit/pom.xml | 80 ++++ .../netspeed/BandwidthLimitApplication.java | 15 + .../netspeed/annotation/BandwidthLimit.java | 75 ++++ .../netspeed/annotation/BandwidthUnit.java | 37 ++ .../netspeed/annotation/LimitType.java | 23 + .../netspeed/config/BandwidthLimitConfig.java | 36 ++ .../netspeed/controller/TestController.java | 217 +++++++++ .../core/RateLimitedOutputStream.java | 256 +++++++++++ .../example/netspeed/core/TokenBucket.java | 189 ++++++++ .../manager/BandwidthLimitManager.java | 242 ++++++++++ .../netspeed/web/BandwidthLimitHelper.java | 35 ++ .../web/BandwidthLimitInterceptor.java | 152 +++++++ .../web/BandwidthLimitResponseWrapper.java | 160 +++++++ .../src/main/resources/application.yml | 11 + .../src/main/resources/static/index.html | 419 ++++++++++++++++++ 16 files changed, 2234 insertions(+) create mode 100644 springboot-netspeed-limit/README.md create mode 100644 springboot-netspeed-limit/pom.xml create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/BandwidthLimitApplication.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/BandwidthLimit.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/BandwidthUnit.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/LimitType.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/config/BandwidthLimitConfig.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/controller/TestController.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/core/RateLimitedOutputStream.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/core/TokenBucket.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/manager/BandwidthLimitManager.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitHelper.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitInterceptor.java create mode 100644 springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitResponseWrapper.java create mode 100644 springboot-netspeed-limit/src/main/resources/application.yml create mode 100644 springboot-netspeed-limit/src/main/resources/static/index.html diff --git a/springboot-netspeed-limit/README.md b/springboot-netspeed-limit/README.md new file mode 100644 index 0000000..a3a12f1 --- /dev/null +++ b/springboot-netspeed-limit/README.md @@ -0,0 +1,287 @@ +# 概述 + +本文介绍在 Spring Boot 3 中实现多维度网络带宽限速的完整方案。基于**令牌桶算法**手动实现核心逻辑,通过自定义 `HandlerInterceptor` 拦截请求、`HttpServletResponseWrapper` 包装响应流、`RateLimitedOutputStream` 控制输出速率,实现对文件下载、视频流等场景的精确速度控制。 + +# 为什么需要带宽限速 + +带宽限速与常见的 API 限流不同:限流控制的是**请求次数**(如每分钟100次),而限速控制的是**网络带宽**(如每秒200KB)。在实际应用中,带宽限速有着重要的业务价值: + +**场景一:文件下载服务** +对于网盘或资源分发平台,免费用户限制在 200KB/s,VIP 用户提升到 2MB/s,既能保障基础体验,又能激励付费转化。 + +**场景二:视频流媒体** +不同清晰度对应不同带宽限制(480P 用 500KB/s,1080P 用 3MB/s),避免高码率视频占用过多服务器带宽。 + +**场景三:API 接口保护** +大数据量接口(如导出报表)如果没有带宽控制,单个请求可能占满整个出口带宽,影响其他用户访问。 + +# 核心原理:令牌桶算法 + +令牌桶算法是流量控制的经典方案,其思想非常直观:想象一个桶,系统以固定速率向桶中放入令牌,请求数据时必须从桶中取走对应数量的令牌。 + +**核心参数解析:** + +**1. 桶容量(Capacity)**:决定能承受多大突发流量。容量为 200KB 时,即使桶已满,最多也只能连续发送 200KB 数据,之后必须等待令牌补充。 + +**2. 填充速率(Refill Rate)**:决定长期平均传输速度。每秒补充 200KB 令牌,意味着平均速度就是 200KB/s。 + +**3. 分块大小(Chunk Size)**:影响流量平滑度。将 8KB 数据拆分成 2KB×4 次写入,每次写入之间进行令牌检查,比一次性写入 8KB 更加平滑。 + +**算法流程:** +``` +发送数据前: +1. 计算距离上次补充的时间差 +2. 根据 时间差 × 填充速率 计算新增令牌数 +3. 更新桶中令牌数(不超过容量上限) + +发送数据时: +1. 检查令牌是否足够 +2. 足够:直接扣除令牌,发送数据 +3. 不足:计算 (缺少令牌数 / 填充速率) 得到等待时间,精确等待后发送 +``` + +# 技术设计 + +### 整体流程 + +本方案采用拦截器模式,在请求处理的早期阶段完成限速组件的初始化,通过请求属性传递包装后的响应对象。 + +``` +请求流程: +┌─────────────────────────────────────────────────────────────────────┐ +│ 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,替换 OutputStream | +| `RateLimitedOutputStream` | 实现限速逻辑,包装 TokenBucket | +| `TokenBucket` | 令牌桶算法实现 | +| `BandwidthLimitHelper` | 从请求属性中获取包装后的响应对象 | + +# 多维度限速实现 + +本方案支持四种限速维度,满足不同业务场景需求: + +### 全局限速(GLOBAL) + +所有请求共享同一个限速桶,适合保护服务器整体出口带宽。例如设置 10MB/s 全局限制,即使有100个并发下载,总带宽也不会超过 10MB/s。 + +```java +@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,两个接口可以同时达到各自的速度上限。 + +```java +@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` 参数,可实现差异化服务: + +```java +@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)。 + +```java +@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()` 获取纳秒级时间戳,确保高精度速率控制。 + +```java +public 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()` 方法返回自定义的限速输出流。 + +```java +public class BandwidthLimitResponseWrapper extends HttpServletResponseWrapper { + private final 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` 获取。 + +```java +@Override +public 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()` 获取包装后的响应,所有写入操作都会自动限速。 + +```java +@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 秒突发 | 需要良好首屏加载 | + +```java +// 注解配置 +@BandwidthLimit(value = 200, unit = BandwidthUnit.KB, capacityMultiplier = 1.0) +``` + +### 分块大小选择 + +分块大小影响流量平滑度,经验公式:`chunkSize = bandwidth / 50` + +| 带宽 | 推荐分块 | 理由 | +|------|----------|------| +| 200 KB/s | 1-4 KB | 小分块保证平滑 | +| 1 MB/s | 4-8 KB | 平衡平滑与性能 | +| 5 MB/s+ | 8-16 KB | 减少系统调用开销 | + +```java +// 自动计算(推荐) +@BandwidthLimit(value = 200, unit = BandwidthUnit.KB, chunkSize = -1) + +// 手动指定 +@BandwidthLimit(value = 200, unit = BandwidthUnit.KB, chunkSize = 4096) +``` + +# 总结 + +本文基于令牌桶算法,通过 HandlerInterceptor + HttpServletResponseWrapper,在 Spring Boot 中实现了多维度带宽限速。支持全局/API/用户/IP 四种限速维度,提供实时统计监控,适用于API接口保护、文件下载、视频流等场景。 \ No newline at end of file diff --git a/springboot-netspeed-limit/pom.xml b/springboot-netspeed-limit/pom.xml new file mode 100644 index 0000000..ee1747c --- /dev/null +++ b/springboot-netspeed-limit/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + + com.example + springboot-netspeed-limit + 1.0.0 + Spring Boot Network Speed Limit + Bandwidth limit implementation with Token Bucket algorithm + + + 21 + 21 + 21 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/BandwidthLimitApplication.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/BandwidthLimitApplication.java new file mode 100644 index 0000000..7ca7de7 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/BandwidthLimitApplication.java @@ -0,0 +1,15 @@ +package com.example.netspeed; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Spring Boot Bandwidth Limit Application + */ +@SpringBootApplication +public class BandwidthLimitApplication { + + public static void main(String[] args) { + SpringApplication.run(BandwidthLimitApplication.class, args); + } +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/BandwidthLimit.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/BandwidthLimit.java new file mode 100644 index 0000000..184c1d9 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/BandwidthLimit.java @@ -0,0 +1,75 @@ +package com.example.netspeed.annotation; + +import com.example.netspeed.annotation.BandwidthUnit; +import com.example.netspeed.annotation.LimitType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 带宽限速注解 + * + * 支持多种限速维度: + * - GLOBAL: 全局限速,所有请求共享限速桶 + * - API: 按接口限速,每个接口独立限速 + * - USER: 按用户限速,根据用户标识(如请求头 X-User-Id)限速 + * - IP: 按IP限速,根据请求IP限速 + * + * 使用示例: + *
+ * // 限速 200 KB/s
+ * {@code @BandwidthLimit(value = 200, unit = BandwidthUnit.KB)}
+ *
+ * // 按用户限速,免费用户 200KB/s,VIP用户 1MB/s
+ * {@code @BandwidthLimit(value = 200, unit = BandwidthUnit.KB, type = LimitType.USER)}
+ * 
+ */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface BandwidthLimit { + + /** + * 限速值 + */ + long value() default 200; + + /** + * 限速单位 + */ + BandwidthUnit unit() default BandwidthUnit.KB; + + /** + * 限速类型 + */ + LimitType type() default LimitType.GLOBAL; + + /** + * 免费用户限速值(-1表示不区分) + */ + long free() default -1; + + /** + * VIP用户限速值(-1表示不区分) + */ + long vip() default -1; + + /** + * 桶容量倍数(相对于填充速率) + * 1.0 表示桶容量 = 1秒流量 + * 0.5 表示桶容量 = 0.5秒流量(更平滑) + * 2.0 表示桶容量 = 2秒流量(允许更大突发) + */ + double capacityMultiplier() default 1.0; + + /** + * 分块大小(字节),-1 表示自动计算 + */ + int chunkSize() default -1; + + /** + * 用户标识请求头名称(用于 USER 类型限速) + */ + String userHeader() default "X-User-Id"; +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/BandwidthUnit.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/BandwidthUnit.java new file mode 100644 index 0000000..094909f --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/BandwidthUnit.java @@ -0,0 +1,37 @@ +package com.example.netspeed.annotation; + +/** + * 带宽单位枚举 + */ +public enum BandwidthUnit { + B(1), + KB(1024), + MB(1024 * 1024), + GB(1024 * 1024 * 1024); + + private final long bytesPerSecond; + + BandwidthUnit(long bytesPerSecond) { + this.bytesPerSecond = bytesPerSecond; + } + + public long toBytesPerSecond(long value) { + return value * bytesPerSecond; + } + + public long getBytesPerUnit() { + return bytesPerSecond; + } + + public static String formatBytes(long bytes) { + if (bytes < KB.getBytesPerUnit()) { + return bytes + " B"; + } else if (bytes < MB.getBytesPerUnit()) { + return String.format("%.2f KB", bytes / (double) KB.getBytesPerUnit()); + } else if (bytes < GB.getBytesPerUnit()) { + return String.format("%.2f MB", bytes / (double) MB.getBytesPerUnit()); + } else { + return String.format("%.2f GB", bytes / (double) GB.getBytesPerUnit()); + } + } +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/LimitType.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/LimitType.java new file mode 100644 index 0000000..8b64014 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/annotation/LimitType.java @@ -0,0 +1,23 @@ +package com.example.netspeed.annotation; + +/** + * 限速类型枚举 + */ +public enum LimitType { + /** + * 全局限速 - 所有请求共享限速桶 + */ + GLOBAL, + /** + * 按接口限速 - 每个接口独立限速 + */ + API, + /** + * 按用户限速 - 根据用户标识(如用户ID、token)限速 + */ + USER, + /** + * 按IP限速 - 根据请求IP限速 + */ + IP +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/config/BandwidthLimitConfig.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/config/BandwidthLimitConfig.java new file mode 100644 index 0000000..638ee7a --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/config/BandwidthLimitConfig.java @@ -0,0 +1,36 @@ +package com.example.netspeed.config; + +import com.example.netspeed.manager.BandwidthLimitManager; +import com.example.netspeed.web.BandwidthLimitInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 带宽限速配置类 + */ +@Configuration +public class BandwidthLimitConfig implements WebMvcConfigurer { + + private BandwidthLimitInterceptor bandwidthLimitInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(bandwidthLimitInterceptor()) + .addPathPatterns("/api/**"); + } + + @Bean + public BandwidthLimitInterceptor bandwidthLimitInterceptor() { + if (bandwidthLimitInterceptor == null) { + bandwidthLimitInterceptor = new BandwidthLimitInterceptor(); + } + return bandwidthLimitInterceptor; + } + + @Bean + public BandwidthLimitManager bandwidthLimitManager() { + return new BandwidthLimitManager(); + } +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/controller/TestController.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/controller/TestController.java new file mode 100644 index 0000000..5518d60 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/controller/TestController.java @@ -0,0 +1,217 @@ +package com.example.netspeed.controller; + +import com.example.netspeed.annotation.BandwidthLimit; +import com.example.netspeed.annotation.BandwidthUnit; +import com.example.netspeed.annotation.LimitType; +import com.example.netspeed.manager.BandwidthLimitManager; +import com.example.netspeed.web.BandwidthLimitHelper; +import com.example.netspeed.web.BandwidthLimitInterceptor; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * 测试控制器 - 提供各种限速维度的测试接口 + */ +@RestController +@RequestMapping("/api") +public class TestController { + + @Autowired(required = false) + private BandwidthLimitInterceptor bandwidthLimitInterceptor; + + /** + * 全局限速测试 - 200 KB/s + */ + @BandwidthLimit(value = 200, unit = BandwidthUnit.KB, type = LimitType.GLOBAL) + @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=global-limit-test.bin"); + limitedResponse.setHeader("X-Test-Type", "Global Limit"); + generateTestData(limitedResponse, 5 * 1024 * 1024); // 5MB + } + + /** + * API维度限速测试 - 500 KB/s + */ + @BandwidthLimit(value = 500, unit = BandwidthUnit.KB, type = LimitType.API) + @GetMapping("/download/api") + public void downloadByApi(HttpServletRequest request, HttpServletResponse response) throws IOException { + HttpServletResponse limitedResponse = BandwidthLimitHelper.getLimitedResponse(request, response); + limitedResponse.setContentType("application/octet-stream"); + limitedResponse.setHeader("Content-Disposition", "attachment; filename=api-limit-test.bin"); + limitedResponse.setHeader("X-Test-Type", "API Limit"); + generateTestData(limitedResponse, 5 * 1024 * 1024); // 5MB + } + + /** + * 用户维度限速测试 - 普通 200 KB/s,VIP 1 MB/s + */ + @BandwidthLimit(value = 200, unit = BandwidthUnit.KB, type = LimitType.USER, free = 200, vip = 1024) + @GetMapping("/download/user") + public void downloadByUser(HttpServletRequest request, + @RequestHeader(value = "X-User-Type", defaultValue = "free") String userType, + @RequestHeader(value = "X-User-Id", defaultValue = "anonymous") String userId, + HttpServletResponse response) throws IOException { + HttpServletResponse limitedResponse = BandwidthLimitHelper.getLimitedResponse(request, response); + limitedResponse.setContentType("application/octet-stream"); + limitedResponse.setHeader("Content-Disposition", "attachment; filename=user-limit-test.bin"); + limitedResponse.setHeader("X-Test-Type", "User Limit - " + userType); + limitedResponse.setHeader("X-User-Type", userType); + limitedResponse.setHeader("X-User-Id", userId); + generateTestData(limitedResponse, 5 * 1024 * 1024); // 5MB + } + + /** + * IP维度限速测试 - 300 KB/s + */ + @BandwidthLimit(value = 300, unit = BandwidthUnit.KB, type = LimitType.IP) + @GetMapping("/download/ip") + public void downloadByIp(HttpServletRequest request, HttpServletResponse response) throws IOException { + HttpServletResponse limitedResponse = BandwidthLimitHelper.getLimitedResponse(request, response); + limitedResponse.setContentType("application/octet-stream"); + limitedResponse.setHeader("Content-Disposition", "attachment; filename=ip-limit-test.bin"); + limitedResponse.setHeader("X-Test-Type", "IP Limit"); + generateTestData(limitedResponse, 5 * 1024 * 1024); // 5MB + } + + /** + * 自定义限速测试 + */ + @GetMapping("/download/custom") + public void downloadCustom(@RequestParam long bandwidth, + @RequestParam(defaultValue = "KB") String unit, + @RequestParam(defaultValue = "GLOBAL") String type, + HttpServletResponse response) throws IOException { + BandwidthUnit bandwidthUnit = BandwidthUnit.valueOf(unit.toUpperCase()); + LimitType limitType = LimitType.valueOf(type.toUpperCase()); + + response.setContentType("application/json"); + response.setHeader("X-Test-Type", "Custom Limit"); + response.setHeader("X-Bandwidth", bandwidth + " " + unit); + response.setHeader("X-Limit-Type", type); + + Map result = new HashMap<>(); + result.put("message", "Custom bandwidth limit request"); + result.put("bandwidth", bandwidth); + result.put("unit", unit); + result.put("type", type); + result.put("note", "This endpoint shows the parameters. Use the annotated endpoints for actual limiting."); + + response.getWriter().write(toJson(result)); + } + + /** + * 获取限速统计信息 + */ + @GetMapping("/stats") + public Map getStats() { + Map stats = new HashMap<>(); + + if (bandwidthLimitInterceptor != null) { + BandwidthLimitManager.BandwidthLimitStats limitStats = bandwidthLimitInterceptor.getStats(); + stats.put("globalCapacity", BandwidthUnit.formatBytes(limitStats.globalCapacity())); + stats.put("globalRefillRate", BandwidthUnit.formatBytes(limitStats.globalRefillRate()) + "/s"); + stats.put("globalAvailableTokens", BandwidthUnit.formatBytes(limitStats.globalAvailableTokens())); + stats.put("globalBytesConsumed", BandwidthUnit.formatBytes(limitStats.globalBytesConsumed())); + stats.put("globalActualRate", BandwidthUnit.formatBytes((long) limitStats.globalActualRate()) + "/s"); + stats.put("globalUtilization", String.format("%.1f%%", limitStats.globalUtilization() * 100)); + stats.put("apiBucketCount", limitStats.apiBucketCount()); + stats.put("userBucketCount", limitStats.userBucketCount()); + stats.put("ipBucketCount", limitStats.ipBucketCount()); + } else { + stats.put("error", "BandwidthLimitInterceptor not available"); + } + + return stats; + } + + /** + * 重置全局限速 + */ + @PostMapping("/reset/global") + public Map resetGlobal() { + Map result = new HashMap<>(); + if (bandwidthLimitInterceptor != null) { + bandwidthLimitInterceptor.resetGlobalBucket(); + result.put("status", "success"); + result.put("message", "Global bandwidth limit bucket reset"); + } else { + result.put("status", "error"); + result.put("message", "BandwidthLimitInterceptor not available"); + } + return result; + } + + /** + * 清除所有限速桶 + */ + @PostMapping("/reset/all") + public Map resetAll() { + Map result = new HashMap<>(); + if (bandwidthLimitInterceptor != null) { + bandwidthLimitInterceptor.clearAllBuckets(); + result.put("status", "success"); + result.put("message", "All bandwidth limit buckets cleared"); + } else { + result.put("status", "error"); + result.put("message", "BandwidthLimitInterceptor not available"); + } + return result; + } + + /** + * 生成测试数据 + */ + private void generateTestData(HttpServletResponse response, int size) throws IOException { + response.setContentLengthLong(size); + + byte[] buffer = new byte[8192]; + byte[] pattern = "This is a bandwidth limit test data. ".getBytes(StandardCharsets.UTF_8); + + int patternPos = 0; + int remaining = size; + + while (remaining > 0) { + int chunkSize = Math.min(buffer.length, remaining); + for (int i = 0; i < chunkSize; i++) { + buffer[i] = pattern[patternPos]; + patternPos = (patternPos + 1) % pattern.length; + } + response.getOutputStream().write(buffer, 0, chunkSize); + remaining -= chunkSize; + } + + response.getOutputStream().flush(); + } + + private String toJson(Map map) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(","); + } + sb.append("\"").append(entry.getKey()).append("\":"); + Object value = entry.getValue(); + if (value instanceof String) { + sb.append("\"").append(value).append("\""); + } else if (value instanceof Number) { + sb.append(value); + } else { + sb.append("\"").append(value).append("\""); + } + first = false; + } + sb.append("}"); + return sb.toString(); + } +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/core/RateLimitedOutputStream.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/core/RateLimitedOutputStream.java new file mode 100644 index 0000000..7766b3d --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/core/RateLimitedOutputStream.java @@ -0,0 +1,256 @@ +package com.example.netspeed.core; + +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * 限速输出流(支持分块写入) + * + * 使用令牌桶算法控制写入速率,实现精确的带宽限速 + */ +@Slf4j +public class RateLimitedOutputStream extends ServletOutputStream { + + private final OutputStream outputStream; + private final TokenBucket tokenBucket; + private final int chunkSize; + private final long bandwidthBytesPerSecond; + + // 统计信息 + private long totalBytesWritten = 0; + private final long startTime = System.nanoTime(); + private volatile boolean closed = false; + private boolean logged = false; + + public RateLimitedOutputStream(OutputStream outputStream, long bandwidthBytesPerSecond) { + this(outputStream, bandwidthBytesPerSecond, calculateOptimalChunkSize(bandwidthBytesPerSecond)); + } + + /** + * 使用已有的 TokenBucket(共享限速状态) + * + * @param outputStream 底层输出流 + * @param tokenBucket 共享的令牌桶 + * @param bandwidthBytesPerSecond 限速(字节/秒) + */ + public RateLimitedOutputStream(OutputStream outputStream, + TokenBucket tokenBucket, + long bandwidthBytesPerSecond) { + this(outputStream, tokenBucket, bandwidthBytesPerSecond, calculateOptimalChunkSize(bandwidthBytesPerSecond)); + } + + /** + * 使用已有的 TokenBucket(共享限速状态),指定分块大小 + * + * @param outputStream 底层输出流 + * @param tokenBucket 共享的令牌桶 + * @param bandwidthBytesPerSecond 限速(字节/秒) + * @param chunkSize 分块大小 + */ + public RateLimitedOutputStream(OutputStream outputStream, + TokenBucket tokenBucket, + long bandwidthBytesPerSecond, + int chunkSize) { + this.outputStream = outputStream; + this.bandwidthBytesPerSecond = bandwidthBytesPerSecond; + this.chunkSize = Math.max(512, Math.min(chunkSize, 65536)); + this.tokenBucket = tokenBucket; + + log.info("RateLimitedOutputStream created with shared bucket: bandwidth={}/s, chunkSize={}", + formatBytes(bandwidthBytesPerSecond), chunkSize); + } + + /** + * @param outputStream 底层输出流 + * @param bandwidthBytesPerSecond 限速(字节/秒) + * @param chunkSize 分块大小,越小越平滑 + */ + public RateLimitedOutputStream(OutputStream outputStream, + long bandwidthBytesPerSecond, + int chunkSize) { + this.outputStream = outputStream; + this.bandwidthBytesPerSecond = bandwidthBytesPerSecond; + this.chunkSize = Math.max(512, Math.min(chunkSize, 65536)); + + // 桶容量 = 1秒流量,允许短时突发 + long capacity = bandwidthBytesPerSecond; + this.tokenBucket = new TokenBucket(capacity, bandwidthBytesPerSecond); + + log.info("RateLimitedOutputStream created: bandwidth={}/s, chunkSize={}, capacity={}/s", + formatBytes(bandwidthBytesPerSecond), chunkSize, formatBytes(capacity)); + } + + /** + * 计算最佳分块大小 + * 经验公式:chunkSize = bandwidthBytesPerSecond / 50 + */ + private static int calculateOptimalChunkSize(long bandwidthBytesPerSecond) { + if (bandwidthBytesPerSecond < 200 * 1024) { + // 低于 200KB/s,使用 1-4KB + return 1024; + } else if (bandwidthBytesPerSecond < 1024 * 1024) { + // 200KB/s - 1MB/s,使用 4-8KB + return 4096; + } else if (bandwidthBytesPerSecond < 5 * 1024 * 1024) { + // 1MB/s - 5MB/s,使用 8-16KB + return 8192; + } else { + // 高于 5MB/s,使用 16-32KB + return 16384; + } + } + + private String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.2f KB", bytes / 1024.0); + } else if (bytes < 1024 * 1024 * 1024) { + return String.format("%.2f MB", bytes / (1024.0 * 1024)); + } else { + return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); + } + } + + @Override + public void write(int b) throws IOException { + checkClosed(); + tokenBucket.acquire(1); + outputStream.write(b); + totalBytesWritten++; + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + checkClosed(); + if (len == 0) { + return; + } + + if (!logged) { + log.info("RateLimitedOutputStream.write() called with len={} bytes", len); + logged = true; + } + + // 分块写入,使流量更平滑 + int remaining = len; + int offset = off; + + while (remaining > 0) { + int size = Math.min(chunkSize, remaining); + tokenBucket.acquire(size); + outputStream.write(b, offset, size); + offset += size; + remaining -= size; + totalBytesWritten += size; + } + + if (totalBytesWritten % (1024 * 1024) == 0) { + double elapsed = (System.nanoTime() - startTime) / 1_000_000_000.0; + double rate = elapsed > 0 ? (totalBytesWritten / elapsed) / 1024.0 : 0; + log.info("Written {} bytes, actual rate: {} KB/s", totalBytesWritten, String.format("%.2f", rate)); + } + } + + @Override + public void flush() throws IOException { + checkClosed(); + outputStream.flush(); + } + + @Override + public void close() throws IOException { + if (!closed) { + closed = true; + double elapsed = (System.nanoTime() - startTime) / 1_000_000_000.0; + double rate = elapsed > 0 ? (totalBytesWritten / elapsed) / 1024.0 : 0; + log.info("RateLimitedOutputStream closing: total bytes={}, elapsed={}s, rate={} KB/s", + totalBytesWritten, String.format("%.2f", elapsed), String.format("%.2f", rate)); + outputStream.flush(); + outputStream.close(); + } + } + + private void checkClosed() throws IOException { + if (closed) { + throw new IOException("Stream is closed"); + } + } + + @Override + public boolean isReady() { + return !closed; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + throw new UnsupportedOperationException("Async write not supported"); + } + + /** + * 动态调整带宽 + */ + public void setBandwidth(long newBandwidth) { + tokenBucket.setRefillRate(newBandwidth); + } + + /** + * 获取当前可用令牌 + */ + public long getAvailableTokens() { + return tokenBucket.getAvailableTokens(); + } + + /** + * 获取实际传输速率 + */ + public double getActualRate() { + long elapsedNanos = System.nanoTime() - startTime; + if (elapsedNanos <= 0) { + return 0; + } + long elapsedSeconds = elapsedNanos / 1_000_000_000L; + return elapsedSeconds > 0 ? (double) totalBytesWritten / elapsedSeconds : 0; + } + + /** + * 获取总写入字节数 + */ + public long getTotalBytesWritten() { + return totalBytesWritten; + } + + /** + * 获取配置的带宽 + */ + public long getBandwidthBytesPerSecond() { + return bandwidthBytesPerSecond; + } + + /** + * 获取分块大小 + */ + public int getChunkSize() { + return chunkSize; + } + + /** + * 获取令牌桶利用率 + */ + public double getBucketUtilization() { + return tokenBucket.getUtilization(); + } + + public TokenBucket getTokenBucket() { + return tokenBucket; + } +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/core/TokenBucket.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/core/TokenBucket.java new file mode 100644 index 0000000..9351d56 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/core/TokenBucket.java @@ -0,0 +1,189 @@ +package com.example.netspeed.core; + +import java.util.concurrent.locks.LockSupport; + +/** + * 令牌桶算法实现 + * + * 核心原理: + * 1. 桶容量:允许的突发流量上限 + * 2. 填充速率:长期平均传输速度 + * 3. 获取令牌:消耗对应数量的令牌,不足则等待 + */ +public class TokenBucket { + + private final long capacity; // 桶容量(字节) + private final long initialRefillRate; // 初始填充速率(字节/秒) + private volatile long refillRate; // 当前填充速率(字节/秒) + private volatile long tokens; // 当前令牌数(字节) + private volatile long lastRefillTime; // 上次填充时间(纳秒) + + // 统计信息 + private volatile long totalBytesConsumed; + private volatile long totalWaitTimeNanos; + private final long creationTime; + + public TokenBucket(long capacity, long refillRate) { + this.capacity = capacity; + this.initialRefillRate = refillRate; + this.refillRate = refillRate; + this.tokens = capacity; + this.lastRefillTime = System.nanoTime(); + this.totalBytesConsumed = 0; + this.totalWaitTimeNanos = 0; + this.creationTime = System.nanoTime(); + } + + /** + * 获取令牌(阻塞等待) + * + * @param permits 需要的令牌数(字节数) + */ + public synchronized void acquire(long permits) { + if (permits <= 0) { + return; + } + + long waitTime = refillAndCalculateWait(permits); + + if (waitTime > 0) { + sleepNanos(waitTime); + totalWaitTimeNanos += waitTime; + // 等待后再次填充并消费 + refill(); + tokens = Math.max(0, tokens - permits); + } else { + tokens -= permits; + } + + totalBytesConsumed += permits; + } + + /** + * 尝试获取令牌(非阻塞) + * + * @param permits 需要的令牌数 + * @return 是否成功获取 + */ + public synchronized boolean tryAcquire(long permits) { + if (permits <= 0) { + return true; + } + + refill(); + + if (tokens >= permits) { + tokens -= permits; + totalBytesConsumed += permits; + return true; + } + + return false; + } + + /** + * 填充令牌并计算需要等待的时间 + */ + private long refillAndCalculateWait(long permits) { + refill(); + + if (tokens >= permits) { + return 0; + } + + // 令牌不足,计算需要等待的时间 + long deficit = permits - tokens; + return (deficit * 1_000_000_000L) / refillRate; + } + + /** + * 填充令牌(核心逻辑) + */ + private void refill() { + long now = System.nanoTime(); + long elapsedNanos = now - lastRefillTime; + + if (elapsedNanos <= 0) { + return; + } + + // 根据时间差计算补充的令牌数 + long newTokens = (elapsedNanos * refillRate) / 1_000_000_000L; + + if (newTokens > 0) { + tokens = Math.min(capacity, tokens + newTokens); + lastRefillTime = now; + } + } + + /** + * 精确纳秒级等待 + */ + private void sleepNanos(long nanos) { + if (nanos <= 0) { + return; + } + + long end = System.nanoTime() + nanos; + while (System.nanoTime() < end) { + LockSupport.parkNanos(Math.max(1000, end - System.nanoTime())); + } + } + + /** + * 获取当前可用令牌数 + */ + public long getAvailableTokens() { + refill(); + return tokens; + } + + /** + * 动态调整填充速率 + */ + public synchronized void setRefillRate(long newRate) { + this.refillRate = newRate; + refill(); + } + + /** + * 重置令牌桶 + */ + public synchronized void reset() { + this.tokens = capacity; + this.refillRate = initialRefillRate; + this.lastRefillTime = System.nanoTime(); + } + + /** + * 获取实际传输速率 + */ + public double getActualRate() { + long elapsedNanos = System.nanoTime() - creationTime; + if (elapsedNanos <= 0) { + return 0; + } + long elapsedSeconds = elapsedNanos / 1_000_000_000L; + return elapsedSeconds > 0 ? (double) totalBytesConsumed / elapsedSeconds : 0; + } + + public long getCapacity() { + return capacity; + } + + public long getRefillRate() { + return refillRate; + } + + public long getTotalBytesConsumed() { + return totalBytesConsumed; + } + + public long getTotalWaitTimeNanos() { + return totalWaitTimeNanos; + } + + public double getUtilization() { + return capacity > 0 ? (double) tokens / capacity : 0; + } +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/manager/BandwidthLimitManager.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/manager/BandwidthLimitManager.java new file mode 100644 index 0000000..f7d89e8 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/manager/BandwidthLimitManager.java @@ -0,0 +1,242 @@ +package com.example.netspeed.manager; + +import com.example.netspeed.annotation.LimitType; +import com.example.netspeed.core.TokenBucket; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 带宽限速管理器 + * + * 管理多维度的令牌桶: + * - GLOBAL: 全局共享一个令牌桶 + * - API: 每个接口路径一个令牌桶 + * - USER: 每个用户ID一个令牌桶 + * - IP: 每个IP地址一个令牌桶 + */ +@Slf4j +public class BandwidthLimitManager { + + // 全局限速桶 + private TokenBucket globalBucket; + + // API维度限速桶 (path -> TokenBucket) + private final ConcurrentHashMap apiBuckets = new ConcurrentHashMap<>(); + + // 用户维度限速桶 (userId -> TokenBucket) + private final ConcurrentHashMap userBuckets = new ConcurrentHashMap<>(); + + // IP维度限速桶 (ip -> TokenBucket) + private final ConcurrentHashMap ipBuckets = new ConcurrentHashMap<>(); + + // 定时清理服务 + private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "bandwidth-limit-cleanup"); + thread.setDaemon(true); + return thread; + }); + + // 最后使用时间记录 + private final ConcurrentHashMap lastAccessTime = new ConcurrentHashMap<>(); + + // 空闲超时时间(毫秒) + private static final long IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5分钟 + + public BandwidthLimitManager() { + // 启动定时清理任务 + startCleanupTask(); + } + + /** + * 获取或创建令牌桶 + */ + public TokenBucket getBucket(LimitType type, String key, long capacity, long refillRate) { + return switch (type) { + case GLOBAL -> getGlobalBucket(capacity, refillRate); + case API -> getOrCreateBucket(apiBuckets, key, capacity, refillRate); + case USER -> getOrCreateBucket(userBuckets, key, capacity, refillRate); + case IP -> getOrCreateBucket(ipBuckets, key, capacity, refillRate); + }; + } + + /** + * 获取全局限速桶 + */ + private synchronized TokenBucket getGlobalBucket(long capacity, long refillRate) { + if (globalBucket == null) { + globalBucket = new TokenBucket(capacity, refillRate); + log.info("Created global bandwidth limit bucket: capacity={}, rate={}/s", + capacity, formatBytes(refillRate)); + } else if (globalBucket.getRefillRate() != refillRate) { + // 动态调整速率 + globalBucket.setRefillRate(refillRate); + log.info("Updated global bandwidth limit rate: {}/s", formatBytes(refillRate)); + } + return globalBucket; + } + + /** + * 获取或创建指定维度的令牌桶 + */ + private TokenBucket getOrCreateBucket(ConcurrentHashMap buckets, + String key, + long capacity, + long refillRate) { + return buckets.compute(key, (k, existing) -> { + if (existing == null) { + log.debug("Created new bandwidth limit bucket for {}: capacity={}, rate={}/s", + k, capacity, formatBytes(refillRate)); + return new TokenBucket(capacity, refillRate); + } + + // 更新最后访问时间 + lastAccessTime.put(key, System.currentTimeMillis()); + + // 动态调整速率 + if (existing.getRefillRate() != refillRate) { + existing.setRefillRate(refillRate); + log.debug("Updated bandwidth limit rate for {}: {}/s", k, formatBytes(refillRate)); + } + + return existing; + }); + } + + /** + * 启动定时清理任务 + */ + private void startCleanupTask() { + cleanupExecutor.scheduleAtFixedRate(() -> { + try { + cleanupIdleBuckets(); + } catch (Exception e) { + log.error("Error during cleanup", e); + } + }, 1, 1, TimeUnit.MINUTES); + } + + /** + * 清理空闲的令牌桶 + */ + private void cleanupIdleBuckets() { + long now = System.currentTimeMillis(); + + // 清理 API 维度 + cleanupMap(apiBuckets, now, "API"); + // 清理用户维度 + cleanupMap(userBuckets, now, "USER"); + // 清理 IP 维度 + cleanupMap(ipBuckets, now, "IP"); + + // 清理访问时间记录 + lastAccessTime.entrySet().removeIf(entry -> { + if (now - entry.getValue() > IDLE_TIMEOUT_MS) { + return true; + } + return false; + }); + } + + private void cleanupMap(ConcurrentHashMap buckets, long now, String type) { + buckets.keySet().removeIf(key -> { + Long lastAccess = lastAccessTime.get(key); + if (lastAccess == null || now - lastAccess > IDLE_TIMEOUT_MS) { + log.debug("Removed idle {} bandwidth bucket: {}", type, key); + lastAccessTime.remove(key); + return true; + } + return false; + }); + } + + /** + * 获取统计信息 + */ + public BandwidthLimitStats getStats() { + if (globalBucket != null) { + return new BandwidthLimitStats( + globalBucket.getCapacity(), + globalBucket.getRefillRate(), + globalBucket.getAvailableTokens(), + globalBucket.getTotalBytesConsumed(), + globalBucket.getActualRate(), + globalBucket.getTotalWaitTimeNanos(), + globalBucket.getUtilization(), + apiBuckets.size(), + userBuckets.size(), + ipBuckets.size() + ); + } + return new BandwidthLimitStats( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ); + } + + /** + * 重置全局限速桶 + */ + public void resetGlobalBucket() { + if (globalBucket != null) { + globalBucket.reset(); + log.info("Reset global bandwidth limit bucket"); + } + } + + /** + * 清除所有维度的限速桶(除了全局) + */ + public void clearAllBuckets() { + apiBuckets.clear(); + userBuckets.clear(); + ipBuckets.clear(); + lastAccessTime.clear(); + log.info("Cleared all bandwidth limit buckets"); + } + + /** + * 关闭管理器 + */ + public void shutdown() { + cleanupExecutor.shutdown(); + try { + if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + cleanupExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + cleanupExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + private String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.2f KB", bytes / 1024.0); + } else if (bytes < 1024 * 1024 * 1024) { + return String.format("%.2f MB", bytes / (1024.0 * 1024)); + } else { + return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); + } + } + + /** + * 统计信息 + */ + public record BandwidthLimitStats( + long globalCapacity, // 全局桶容量 + long globalRefillRate, // 全局填充速率(字节/秒) + long globalAvailableTokens, // 全局可用令牌 + long globalBytesConsumed, // 全局已消耗字节 + double globalActualRate, // 全局实际传输速率(字节/秒) + long globalWaitTimeNanos, // 全局等待时间(纳秒) + double globalUtilization, // 全局利用率(0-1) + int apiBucketCount, // API限速桶数量 + int userBucketCount, // 用户限速桶数量 + int ipBucketCount // IP限速桶数量 + ) {} +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitHelper.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitHelper.java new file mode 100644 index 0000000..86de7d3 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitHelper.java @@ -0,0 +1,35 @@ +package com.example.netspeed.web; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * 带宽限速辅助类 + * + * 用于从请求中获取限速响应包装器 + */ +public class BandwidthLimitHelper { + + private static final String WRAPPED_RESPONSE_ATTR = "BandwidthLimitWrappedResponse"; + + /** + * 获取限速响应包装器(如果存在) + */ + public static HttpServletResponse getLimitedResponse(HttpServletRequest request, HttpServletResponse defaultResponse) { + BandwidthLimitResponseWrapper wrappedResponse = + (BandwidthLimitResponseWrapper) request.getAttribute(WRAPPED_RESPONSE_ATTR); + + if (wrappedResponse != null) { + return wrappedResponse; + } + + return defaultResponse; + } + + /** + * 检查是否应用了限速 + */ + public static boolean isLimited(HttpServletRequest request) { + return request.getAttribute(WRAPPED_RESPONSE_ATTR) != null; + } +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitInterceptor.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitInterceptor.java new file mode 100644 index 0000000..c060da3 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitInterceptor.java @@ -0,0 +1,152 @@ +package com.example.netspeed.web; + +import com.example.netspeed.annotation.BandwidthLimit; +import com.example.netspeed.annotation.BandwidthUnit; +import com.example.netspeed.annotation.LimitType; +import com.example.netspeed.core.TokenBucket; +import com.example.netspeed.manager.BandwidthLimitManager; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 带宽限速拦截器 + * + * 在 preHandle 中包装响应,在 afterCompletion 中关闭 + */ +@Slf4j +public class BandwidthLimitInterceptor implements HandlerInterceptor { + + private final BandwidthLimitManager limitManager = new BandwidthLimitManager(); + + private static final String WRAPPED_RESPONSE_ATTR = "BandwidthLimitWrappedResponse"; + private static final String ORIGINAL_RESPONSE_ATTR = "BandwidthLimitOriginalResponse"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + + if (!(handler instanceof HandlerMethod)) { + return true; + } + + HandlerMethod handlerMethod = (HandlerMethod) handler; + BandwidthLimit annotation = handlerMethod.getMethodAnnotation(BandwidthLimit.class); + + // 如果方法没有注解,检查类级别的注解 + if (annotation == null) { + annotation = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), BandwidthLimit.class); + } + + if (annotation != null) { + String path = request.getRequestURI(); + log.info("========== Interceptor: Found @BandwidthLimit for path: {}, type: {}, value: {} {}/s ==========", + path, annotation.type(), annotation.value(), annotation.unit()); + + // 获取带宽参数 + LimitType type = annotation.type(); + long bandwidth = calculateBandwidth(request, annotation); + long bandwidthBytesPerSecond = annotation.unit().toBytesPerSecond(bandwidth); + long capacity = (long) (bandwidthBytesPerSecond * annotation.capacityMultiplier()); + String key = getLimitKey(request, type, path, annotation); + + // 获取或创建令牌桶 + TokenBucket bucket = limitManager.getBucket(type, key, capacity, bandwidthBytesPerSecond); + + log.info("Interceptor: Token bucket created - type={}, key={}, capacity={}/s, rate={}/s", + type, key, BandwidthUnit.formatBytes(capacity), BandwidthUnit.formatBytes(bandwidthBytesPerSecond)); + + // 设置响应头到原始响应(这样浏览器才能看到) + response.setHeader("X-Bandwidth-Limit", BandwidthUnit.formatBytes(bandwidthBytesPerSecond) + "/s"); + response.setHeader("X-Bandwidth-Type", type.name()); + response.setHeader("X-Bandwidth-Key", key); + response.setHeader("X-Bandwidth-Capacity", BandwidthUnit.formatBytes(capacity)); + + log.info("Interceptor: Response headers set - X-Bandwidth-Limit={}", + BandwidthUnit.formatBytes(bandwidthBytesPerSecond) + "/s"); + + // 创建限速响应包装器(传入共享的 TokenBucket) + BandwidthLimitResponseWrapper wrappedResponse = new BandwidthLimitResponseWrapper( + response, bucket, bandwidthBytesPerSecond, annotation.chunkSize()); + + // 将包装器保存到请求中 + request.setAttribute(WRAPPED_RESPONSE_ATTR, wrappedResponse); + request.setAttribute(ORIGINAL_RESPONSE_ATTR, response); + request.setAttribute("BandwidthLimit", annotation); + } + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + // 清理资源 + BandwidthLimitResponseWrapper wrappedResponse = + (BandwidthLimitResponseWrapper) request.getAttribute(WRAPPED_RESPONSE_ATTR); + if (wrappedResponse != null) { + try { + wrappedResponse.close(); + } catch (Exception e) { + log.error("Error closing wrapped response", e); + } + } + } + + private long calculateBandwidth(HttpServletRequest request, BandwidthLimit annotation) { + if (annotation.free() > 0 || annotation.vip() > 0) { + String userType = request.getHeader("X-User-Type"); + if ("vip".equalsIgnoreCase(userType)) { + return annotation.vip() > 0 ? annotation.vip() : annotation.value(); + } else if ("free".equalsIgnoreCase(userType)) { + return annotation.free() > 0 ? annotation.free() : annotation.value(); + } + } + return annotation.value(); + } + + private String getLimitKey(HttpServletRequest request, LimitType type, String path, BandwidthLimit annotation) { + return switch (type) { + case GLOBAL -> "global"; + case API -> path; + case USER -> { + String userId = request.getHeader(annotation.userHeader()); + yield userId != null ? userId : request.getRemoteAddr(); + } + case IP -> getClientIp(request); + }; + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + + public BandwidthLimitManager.BandwidthLimitStats getStats() { + return limitManager.getStats(); + } + + public void resetGlobalBucket() { + limitManager.resetGlobalBucket(); + } + + public void clearAllBuckets() { + limitManager.clearAllBuckets(); + } + + public void shutdown() { + limitManager.shutdown(); + } +} diff --git a/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitResponseWrapper.java b/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitResponseWrapper.java new file mode 100644 index 0000000..0be45f2 --- /dev/null +++ b/springboot-netspeed-limit/src/main/java/com/example/netspeed/web/BandwidthLimitResponseWrapper.java @@ -0,0 +1,160 @@ +package com.example.netspeed.web; + +import com.example.netspeed.core.RateLimitedOutputStream; +import com.example.netspeed.core.TokenBucket; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; + +/** + * 带宽限速响应包装器 + * + * 包装 HttpServletResponse 的 OutputStream,使用 RateLimitedOutputStream 实现限速 + */ +@Slf4j +public class BandwidthLimitResponseWrapper extends HttpServletResponseWrapper { + + private final long bandwidthBytesPerSecond; + private final int chunkSize; + private final TokenBucket sharedTokenBucket; + private RateLimitedOutputStream limitedOutputStream; + private PrintWriter writer; + private boolean outputStreamUsed = false; + private boolean headersCopied = false; + + public BandwidthLimitResponseWrapper(HttpServletResponse response, long bandwidthBytesPerSecond) { + this(response, null, bandwidthBytesPerSecond, -1); + } + + public BandwidthLimitResponseWrapper(HttpServletResponse response, long bandwidthBytesPerSecond, int chunkSize) { + this(response, null, bandwidthBytesPerSecond, chunkSize); + } + + public BandwidthLimitResponseWrapper(HttpServletResponse response, + TokenBucket tokenBucket, + long bandwidthBytesPerSecond, + int chunkSize) { + super(response); + this.sharedTokenBucket = tokenBucket; + this.bandwidthBytesPerSecond = bandwidthBytesPerSecond; + this.chunkSize = chunkSize; + } + + private String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.2f KB", bytes / 1024.0); + } else if (bytes < 1024 * 1024 * 1024) { + return String.format("%.2f MB", bytes / (1024.0 * 1024)); + } else { + return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); + } + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (!outputStreamUsed) { + log.info("BandwidthLimitResponseWrapper.getOutputStream() called, bandwidth={}/s, sharedBucket={}", + formatBytes(bandwidthBytesPerSecond), sharedTokenBucket != null); + outputStreamUsed = true; + } + if (limitedOutputStream == null) { + if (sharedTokenBucket != null) { + // 使用共享的 TokenBucket + if (chunkSize > 0) { + limitedOutputStream = new RateLimitedOutputStream( + super.getOutputStream(), + sharedTokenBucket, + bandwidthBytesPerSecond, + chunkSize + ); + } else { + limitedOutputStream = new RateLimitedOutputStream( + super.getOutputStream(), + sharedTokenBucket, + bandwidthBytesPerSecond + ); + } + } else { + // 创建新的 TokenBucket(兼容旧代码) + if (chunkSize > 0) { + limitedOutputStream = new RateLimitedOutputStream( + super.getOutputStream(), + bandwidthBytesPerSecond, + chunkSize + ); + } else { + limitedOutputStream = new RateLimitedOutputStream( + super.getOutputStream(), + bandwidthBytesPerSecond + ); + } + } + } + return limitedOutputStream; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (writer == null) { + writer = new PrintWriter(new OutputStreamWriter(getOutputStream(), getCharacterEncoding()), true); + } + return writer; + } + + @Override + public void flushBuffer() throws IOException { + if (writer != null) { + writer.flush(); + } else if (limitedOutputStream != null) { + limitedOutputStream.flush(); + } + super.flushBuffer(); + } + + @Override + public void setContentType(String type) { + super.setContentType(type); + } + + @Override + public void setCharacterEncoding(String charset) { + super.setCharacterEncoding(charset); + } + + @Override + public void setHeader(String name, String value) { + super.setHeader(name, value); + } + + @Override + public void addHeader(String name, String value) { + super.addHeader(name, value); + } + + @Override + public void setIntHeader(String name, int value) { + super.setIntHeader(name, value); + } + + /** + * 获取限速输出流(用于获取统计信息) + */ + public RateLimitedOutputStream getRateLimitedOutputStream() { + return limitedOutputStream; + } + + public void close() throws IOException { + if (limitedOutputStream != null) { + log.info("BandwidthLimitResponseWrapper closing, total bytes: {}", + limitedOutputStream.getTotalBytesWritten()); + limitedOutputStream.close(); + } + } +} diff --git a/springboot-netspeed-limit/src/main/resources/application.yml b/springboot-netspeed-limit/src/main/resources/application.yml new file mode 100644 index 0000000..148325c --- /dev/null +++ b/springboot-netspeed-limit/src/main/resources/application.yml @@ -0,0 +1,11 @@ +server: + port: 8080 + +spring: + application: + name: bandwidth-limit + +logging: + level: + com.example.netspeed: DEBUG + org.springframework.web: INFO diff --git a/springboot-netspeed-limit/src/main/resources/static/index.html b/springboot-netspeed-limit/src/main/resources/static/index.html new file mode 100644 index 0000000..0f3d22e --- /dev/null +++ b/springboot-netspeed-limit/src/main/resources/static/index.html @@ -0,0 +1,419 @@ + + + + + + Spring Boot 带宽限速测试 + + + + +
+ +
+

+ Spring Boot 网络带宽限速 +

+

基于令牌桶算法的多维度流量控制

+
+ + +
+

+ + + + 限速统计信息 + - +

+
+
+
-
+
已传输字节
+
+
+
-
+
实际传输速率
+
+
+
-
+
令牌利用率
+
+
+
-
+
可用配额 (字节)
+
+
+
-
+
API限速桶
+
+
+
-
+
用户限速桶
+
+
+
+ + +
+ +
+
+

全局限速

+ 200 KB/s +
+

所有请求共享同一个限速桶,适合保护服务器整体带宽

+ + +
+ + +
+
+

API维度限速

+ 500 KB/s +
+

每个接口独立限速,不同接口的限速桶互不影响

+ + +
+ + +
+
+

用户维度限速

+ 200KB/s - 1MB/s +
+

根据用户类型限速,免费用户200KB/s,VIP用户1MB/s

+
+ + +
+ + +
+ + +
+
+

IP维度限速

+ 300 KB/s +
+

根据客户端IP限速,每个IP地址拥有独立的限速桶

+ + +
+
+ + +
+

+ + + + + 控制面板 +

+
+ + + +
+
+ + +
+

关于限速算法

+
+
+

令牌桶算法原理

+
    +
  • • 桶容量:允许的突发流量上限
  • +
  • • 填充速率:长期平均传输速度
  • +
  • • 分块大小:影响流量平滑度
  • +
+
+
+

应用场景

+
    +
  • • 文件下载服务的速度控制
  • +
  • • 视频流媒体的带宽管理
  • +
  • • API接口的响应限速
  • +
+
+
+
+
+ + + + From 79661c4f6578e146158da591ada2a668c1d30651 Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 18 Jan 2026 19:12:43 +0800 Subject: [PATCH 6/6] chat-stream --- springboot-chat-stream/README.md | 355 ++++++++++ springboot-chat-stream/pom.xml | 48 ++ .../example/chat/ChatStreamApplication.java | 15 + .../chat/controller/StreamChatController.java | 36 + .../chat/service/StreamChatService.java | 77 ++ .../src/main/resources/application.yml | 13 + .../src/main/resources/static/index.html | 667 ++++++++++++++++++ 7 files changed, 1211 insertions(+) create mode 100644 springboot-chat-stream/README.md create mode 100644 springboot-chat-stream/pom.xml create mode 100644 springboot-chat-stream/src/main/java/com/example/chat/ChatStreamApplication.java create mode 100644 springboot-chat-stream/src/main/java/com/example/chat/controller/StreamChatController.java create mode 100644 springboot-chat-stream/src/main/java/com/example/chat/service/StreamChatService.java create mode 100644 springboot-chat-stream/src/main/resources/application.yml create mode 100644 springboot-chat-stream/src/main/resources/static/index.html diff --git a/springboot-chat-stream/README.md b/springboot-chat-stream/README.md new file mode 100644 index 0000000..7566bbb --- /dev/null +++ b/springboot-chat-stream/README.md @@ -0,0 +1,355 @@ +# 像 ChatGPT 一样丝滑:Spring Boot 如何实现大模型流式(Streaming)响应? + +## 一、为什么需要流式响应? + +同样的 HTTP 请求,为什么像 ChatGPT 这类模型的回答能像打字机一样逐字输出,而我们平时写的接口却要等全部处理完才返回? + +问题的核心在于 **响应模式**: + +| 传统模式 | 流式模式 | +|---------|---------| +| 服务器处理完成 → 一次性返回 | 生成一部分 → 立即推送 | +| 客户端等待总时长 = 服务器处理时间 | 客户端首字等待时间通常很短 | +| 适合快速查询 | 适合耗时生成 | + +对于大模型这种 **生成式 AI**,一个响应可能需要几秒甚至几十秒。如果用传统模式,用户体验就是: + +``` +提问 → (10秒空白) → 答案全部出现 +``` + +而流式响应的体验是: + +``` +提问 → 0.1秒后 → "我" → "认" → "为" → ... → 逐字呈现 +``` + +实现这种效果有多种技术方案,本文将介绍基于 Spring Boot WebFlux + SSE 的实现方式。 + +--- + +## 二、核心技术选型 + +实现流式响应主要有以下几种方案: + +| 方案 | 优点 | 缺点 | 适用场景 | +|------|------|------|----------| +| **SSE** | 单向推送、HTTP协议、实现简单 | 不支持双向通信 | 服务端主动推送 | +| **WebSocket** | 双向通信、实时性强 | 实现复杂、需要额外协议 | 聊天、游戏 | +| **长轮询** | 兼容性好 | 资源消耗大 | 低频数据更新 | + +**本文选择 SSE 方案**,原因如下: +- Spring Boot 原生支持 `ResponseEntity>` +- 基于标准 HTTP,无需额外协议协商 +- 代码简洁,易于理解和维护 + +--- + +## 三、项目依赖配置 + +### 3.1 Maven 依赖 + +```xml + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.example + springboot-chat-stream + 1.0.0 + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.projectlombok + lombok + true + + + +``` + +### 3.2 关键依赖说明 + +- **spring-boot-starter-webflux**:提供响应式 Web 支持,核心是 Reactor 的 `Flux` 类型 +- **Reactor**:响应式编程库,`Flux` 表示 0-N 个元素的异步序列 + +--- + +## 四、核心代码实现 + +### 4.1 Controller 层:流式响应入口 + +```java +package com.example.chat.controller; + +import com.example.chat.service.StreamChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") // 开发环境允许跨域 +public class StreamChatController { + + private final StreamChatService chatService; + + /** + * 流式聊天接口 + * @param prompt 用户输入的问题 + * @return 流式响应,text/event-stream 格式 + */ + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity> streamChat(@RequestParam String prompt) { + return ResponseEntity.ok() + .header("Cache-Control", "no-cache") + .header("Connection", "keep-alive") + .body(chatService.streamResponse(prompt)); + } +} +``` + +**关键点解析:** + +1. `produces = MediaType.TEXT_EVENT_STREAM_VALUE`:声明返回 SSE 格式 +2. `Flux`:响应式流,可以发送多个数据块 +3. `Cache-Control: no-cache`:禁用缓存,确保实时推送 + +### 4.2 Service 层:模拟大模型流式生成 + +```java +package com.example.chat.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; + +import java.time.Duration; + +@Slf4j +@Service +public class StreamChatService { + + /** + * 模拟大模型流式生成响应 + * @param prompt 用户问题 + * @return 按字符/词汇流式输出的响应 + */ + public Flux streamResponse(String prompt) { + log.info("收到用户提问: {}", prompt); + + // 模拟大模型生成的回复内容 + String response = mockLLMResponse(prompt); + + // 将响应拆分为字符流,每 50ms 发送一个字符 + return Flux.fromArray(response.split("")) + .delayElements(Duration.ofMillis(50)) + .doOnNext(chunk -> log.debug("发送数据块: {}", chunk)) + .doOnComplete(() -> log.info("流式响应完成")) + .doOnError(e -> log.error("流式响应异常", e)); + } + + /** + * 模拟大模型生成内容(实际项目可接入 OpenAI/通义千问等) + */ + private String mockLLMResponse(String prompt) { + return """ + 【Spring Boot 流式响应】 + 您的问题是:%s + + 这是一个模拟大模型流式输出的示例。 + 在实际应用中,你可以: + 1. 接入 OpenAI API 使用 GPT-4 + 2. 接入阿里云通义千问 API + 3. 接入本地部署的大模型 + + 流式响应的核心是: + - 使用 Spring WebFlux 的 Flux + - 返回 text/event-stream 格式 + - 前端使用 EventSource 或 fetch 接收 + + 这样就能实现像 ChatGPT 一样的丝滑体验! + """.formatted(prompt); + } +} +``` + +**核心逻辑:** + +1. `Flux.fromArray(response.split(""))`:将字符串拆分为字符数组转为流 +2. `.delayElements(Duration.ofMillis(50))`:每个字符延迟 50ms 发送 +3. `.doOnNext()/.doOnComplete()/.doOnError()`:生命周期钩子,用于日志记录 + +### 4.3 接入真实大模型 API(扩展) + +```java +// 接入 OpenAI Streaming API 的示例(伪代码) +public Flux streamOpenAI(String prompt) { + WebClient webClient = WebClient.builder() + .baseUrl("https://api.openai.com/v1") + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer YOUR_API_KEY") + .build(); + + return webClient.post() + .uri("/chat/completions") + .bodyValue(Map.of( + "model", "gpt-4", + "messages", List.of(Map.of("role", "user", "content", prompt)), + "stream", true + )) + .retrieve() + .bodyToFlux(String.class) + .map(this::extractContentFromSSE); // 解析 SSE 格式提取 content +} +``` + +### 4.4 启动类 + +```java +package com.example.chat; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ChatStreamApplication { + public static void main(String[] args) { + SpringApplication.run(ChatStreamApplication.class, args); + } +} +``` + +### 4.5 配置文件 + +```yaml +server: + port: 8080 + +spring: + application: + name: chat-stream-demo + +# 日志配置 +logging: + level: + com.example.chat: DEBUG +``` + +--- + +## 五、前端对接示例 + +### 5.1 使用 EventSource 接收流 + +```html + + + + + Spring Boot 流式响应示例 + + + +

Spring Boot 流式聊天

+ + +
+ + + + +``` + +### 5.2 使用 Fetch API(推荐) + +```javascript +async function streamChat(prompt) { + const response = await fetch(`/api/chat/stream?prompt=${encodeURIComponent(prompt)}`); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + console.log('收到数据:', chunk); + // 更新 UI + } +} +``` + +--- + +## 六、运行效果 + +启动项目后,访问 `http://localhost:8080`(需添加静态页面支持),输入问题,你会看到: + +``` +【Spring Boot 流式响应】 +您的问题是:如何学习 Spring Boot? + +这是一个模拟大模型流式输出的示例。 +... +``` + +文字像打字机一样逐字出现,体验丝滑! + + +--- + +## 七、总结 + +本文介绍了如何使用 Spring Boot WebFlux 实现 SSE 流式响应。核心是通过 `Flux` + `TEXT_EVENT_STREAM_VALUE` 将数据分块推送,配合前端 `EventSource` 实现逐字显示效果。相比传统一次性返回,流式响应能显著降低用户等待感知,特别适合大模型对话等耗时生成场景。 diff --git a/springboot-chat-stream/pom.xml b/springboot-chat-stream/pom.xml new file mode 100644 index 0000000..f7fa439 --- /dev/null +++ b/springboot-chat-stream/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.example + springboot-chat-stream + 1.0.0 + Spring Boot Chat Stream Demo + 流式响应演示项目 - 像 ChatGPT 一样丝滑 + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.projectlombok + lombok + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/springboot-chat-stream/src/main/java/com/example/chat/ChatStreamApplication.java b/springboot-chat-stream/src/main/java/com/example/chat/ChatStreamApplication.java new file mode 100644 index 0000000..deb5369 --- /dev/null +++ b/springboot-chat-stream/src/main/java/com/example/chat/ChatStreamApplication.java @@ -0,0 +1,15 @@ +package com.example.chat; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Spring Boot 流式响应演示应用 + */ +@SpringBootApplication +public class ChatStreamApplication { + + public static void main(String[] args) { + SpringApplication.run(ChatStreamApplication.class, args); + } +} diff --git a/springboot-chat-stream/src/main/java/com/example/chat/controller/StreamChatController.java b/springboot-chat-stream/src/main/java/com/example/chat/controller/StreamChatController.java new file mode 100644 index 0000000..e245810 --- /dev/null +++ b/springboot-chat-stream/src/main/java/com/example/chat/controller/StreamChatController.java @@ -0,0 +1,36 @@ +package com.example.chat.controller; + +import com.example.chat.service.StreamChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +/** + * 流式聊天控制器 + * 提供 SSE 流式响应接口 + */ +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class StreamChatController { + + private final StreamChatService chatService; + + /** + * 流式聊天接口(SSE) + * + * @param prompt 用户输入的问题 + * @return SSE 流式响应 + */ + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux streamChat(@RequestParam String prompt) { + return chatService.streamResponse(prompt); + } +} diff --git a/springboot-chat-stream/src/main/java/com/example/chat/service/StreamChatService.java b/springboot-chat-stream/src/main/java/com/example/chat/service/StreamChatService.java new file mode 100644 index 0000000..81f1053 --- /dev/null +++ b/springboot-chat-stream/src/main/java/com/example/chat/service/StreamChatService.java @@ -0,0 +1,77 @@ +package com.example.chat.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; + +import java.time.Duration; + +/** + * 流式聊天服务 + * 模拟大模型流式生成响应 + */ +@Slf4j +@Service +public class StreamChatService { + + /** + * 模拟大模型流式生成响应 + * + * @param prompt 用户问题 + * @return 按字符/词汇流式输出的响应 + */ + public Flux streamResponse(String prompt) { + log.info("收到用户提问: {}", prompt); + + // 模拟大模型生成的回复内容 + String response = mockLLMResponse(prompt); + + // 将文本拆分成小块,使用响应式延迟模拟打字效果 + int chunkSize = 2; // 每次发送 2 个字符 + + return Flux.fromArray(splitIntoChunks(response, chunkSize)) + .delayElements(Duration.ofMillis(30)) // 每 30ms 发送一个块 + .doOnComplete(() -> log.info("流式响应完成")); + } + + /** + * 将字符串拆分成固定大小的块 + */ + private String[] splitIntoChunks(String text, int chunkSize) { + int length = (text.length() + chunkSize - 1) / chunkSize; + String[] chunks = new String[length]; + for (int i = 0; i < length; i++) { + int start = i * chunkSize; + int end = Math.min(start + chunkSize, text.length()); + chunks[i] = text.substring(start, end); + } + return chunks; + } + + /** + * 模拟大模型生成内容 + * 实际项目可接入 OpenAI/通义千问等 API + * + * @param prompt 用户问题 + * @return 模拟的回复内容 + */ + private String mockLLMResponse(String prompt) { + return """ + 【Spring Boot 流式响应】 + 您的问题是:%s + + 这是一个模拟大模型流式输出的示例。 + 在实际应用中,你可以: + 1. 接入 OpenAI API 使用 GPT-4 + 2. 接入阿里云通义千问 API + 3. 接入本地部署的大模型 + + 流式响应的核心是: + - 使用 Spring WebFlux 的 Flux + - 返回 text/event-stream 格式 + - 前端使用 EventSource 或 fetch 接收 + + 这样就能实现像 ChatGPT 一样的丝滑体验! + """.formatted(prompt); + } +} diff --git a/springboot-chat-stream/src/main/resources/application.yml b/springboot-chat-stream/src/main/resources/application.yml new file mode 100644 index 0000000..3c2bc6b --- /dev/null +++ b/springboot-chat-stream/src/main/resources/application.yml @@ -0,0 +1,13 @@ +server: + port: 8080 + +spring: + application: + name: chat-stream-demo + +# 日志配置 +logging: + level: + com.example.chat: DEBUG + pattern: + console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" diff --git a/springboot-chat-stream/src/main/resources/static/index.html b/springboot-chat-stream/src/main/resources/static/index.html new file mode 100644 index 0000000..b7ae4f1 --- /dev/null +++ b/springboot-chat-stream/src/main/resources/static/index.html @@ -0,0 +1,667 @@ + + + + + + AI 流式响应演示 + + + + + + + +
+ +
+
+
+ 在线 +
+
+
+ + +
+ +
+

👋 欢迎体验 AI 流式响应

+

基于 Spring Boot WebFlux + SSE 实现实时流式输出
输入任意问题,感受像 ChatGPT 一样的逐字显示效果

+
+ + +
+
+ +
+
🤖
+
+
+ AI 助手 + 现在 +
+
+ 你好!我是基于 Spring Boot 的流式响应助手。你可以问我任何问题,我会逐字回复,让你体验流畅的打字效果! +
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+ + + +