diff --git a/springboot-api-signature/README.md b/springboot-api-signature/README.md new file mode 100644 index 0000000..6190b28 --- /dev/null +++ b/springboot-api-signature/README.md @@ -0,0 +1,124 @@ +# Spring Boot API Signature + +基于 Spring Boot 和 HMAC-SHA256 的 API 接口签名验证解决方案,提供安全可靠的接口验证机制。 + +## 🚀 项目特性 + +- **安全性高**:采用成熟的 HMAC-SHA256 算法,确保签名的不可伪造性 +- **易于集成**:基于 Spring Boot 拦截器机制,对现有代码侵入性小 +- **防重放攻击**:通过时间戳验证有效防止请求重放 +- **灵活配置**:支持多客户端、多密钥管理 +- **完整示例**:提供完整的使用示例和测试用例 + +## 🛠️ 快速开始 + +### 环境要求 + +- JDK 8+ +- Maven 3.6+ +- Spring Boot 2.7.14 + +### 1. 克隆项目 + +```bash +git clone +cd springboot-api-signature +``` + +### 2. 构建项目 + +```bash +mvn clean compile +``` + +### 3. 运行应用 + +```bash +mvn spring-boot:run +``` + +应用将在 `http://localhost:8080` 启动。 + +## 📖 使用说明 + +### 签名生成算法 + +客户端需要按照以下规则生成签名: + +1. **准备参数**:将所有请求参数(不包括签名本身)收集到 Map 中 +2. **参数排序**:按参数名的字典序排序 +3. **参数拼接**:将排序后的参数用 `&` 连接:`key1=value1&key2=value2` +4. **构建待签字符串**:`时间戳 + 参数字符串` +5. **生成签名**:使用 HMAC-SHA256 算法和密钥对待签字符串进行加密 +6. **Base64 编码**:对加密结果进行 Base64 编码 + +### 请求头设置 + +客户端需要在请求头中包含以下字段: + +- `X-Api-Key`: API 密钥标识 +- `X-Timestamp`: 当前时间戳(秒级) +- `X-Signature`: 生成的签名 + +### 示例请求 + +```java +// 1. 准备参数 +Map params = new HashMap<>(); +params.put("userId", "12345"); +params.put("type", "profile"); + +// 2. 生成时间戳 +String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + +// 3. 生成签名 +String signature = SignatureUtils.generateSignature(params, timestamp, "your-secret"); + +// 4. 设置请求头 +Headers headers = new Headers(); +headers.set("X-Api-Key", "client1"); +headers.set("X-Timestamp", timestamp); +headers.set("X-Signature", signature); + +// 5. 发送请求 +// GET /api/protected/data?userId=12345&type=profile +``` + +## 配置参数说明 + +| 参数 | 说明 | 默认值 | +|-----|------|--------| +| `api.security.enabled` | 是否启用签名验证 | `true` | +| `api.security.time-tolerance` | 时间戳容忍度(秒) | `300` | +| `api.security.enable-request-log` | 是否启用请求日志 | `true` | +| `api.security.enable-response-log` | 是否启用响应日志 | `false` | + +## 🧪 测试 + +### 1. 启动应用 + +```bash +mvn spring-boot:run +``` + +### 2. 运行客户端测试 + +```java +// 运行 ApiClient 的 main 方法 +// 或使用 curl 命令测试 +``` + +### 3. 使用 curl 测试 + +```bash +# 1. 生成签名 +timestamp=$(date +%s) +params="userId=12345&type=profile" +signature=$(echo -n "${timestamp}${params}" | openssl dgst -sha256 -hmac "demo-secret-key-for-client1-2024" -binary | base64) + +# 2. 发送请求 +curl -X GET "http://localhost:8080/api/protected/data?userId=12345&type=profile" \ + -H "X-Api-Key: client1" \ + -H "X-Timestamp: ${timestamp}" \ + -H "X-Signature: ${signature}" +``` diff --git a/springboot-api-signature/pom.xml b/springboot-api-signature/pom.xml new file mode 100644 index 0000000..85b4f0e --- /dev/null +++ b/springboot-api-signature/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + com.example + springboot-api-signature + 1.0.0 + jar + + Spring Boot API Signature + API Signature Validation with HMAC-SHA256 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.14 + + + + + 11 + 11 + 11 + UTF-8 + 5.8.16 + 2.0.25 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + cn.hutool + hutool-all + ${hutool.version} + + + + + com.alibaba + fastjson + ${fastjson.version} + + + + + org.projectlombok + lombok + true + + + + + org.apache.httpcomponents + httpclient + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + ${java.version} + ${java.version} + ${project.build.sourceEncoding} + + + + + \ No newline at end of file diff --git a/springboot-api-signature/src/main/java/com/example/sign/ApiSignatureApplication.java b/springboot-api-signature/src/main/java/com/example/sign/ApiSignatureApplication.java new file mode 100644 index 0000000..2104ec6 --- /dev/null +++ b/springboot-api-signature/src/main/java/com/example/sign/ApiSignatureApplication.java @@ -0,0 +1,24 @@ +package com.example.sign; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Spring Boot API签名验证应用启动类 + * + */ +@Slf4j +@SpringBootApplication +public class ApiSignatureApplication { + + public static void main(String[] args) { + SpringApplication.run(ApiSignatureApplication.class, args); + log.info("========================================"); + log.info("Spring Boot API Signature Application Started Successfully!"); + log.info("API Base URL: http://localhost:8080/api"); + log.info("Health Check: http://localhost:8080/api/public/health"); + log.info("Public Info: http://localhost:8080/api/public/info"); + log.info("========================================"); + } +} \ No newline at end of file diff --git a/springboot-api-signature/src/main/java/com/example/sign/client/ApiClient.java b/springboot-api-signature/src/main/java/com/example/sign/client/ApiClient.java new file mode 100644 index 0000000..29afe1d --- /dev/null +++ b/springboot-api-signature/src/main/java/com/example/sign/client/ApiClient.java @@ -0,0 +1,206 @@ +package com.example.sign.client; + +import com.example.sign.util.SignatureUtil; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * API客户端调用示例 + * + */ +public class ApiClient { + + private static final String BASE_URL = "http://localhost:8080"; + private static final String API_KEY = "client1"; + private static final String SECRET = "demo-secret-key-for-client1-2024"; + private static final String CHARSET = "UTF-8"; + + /** + * 发送GET请求 + * + * @param path 请求路径 + * @param params 请求参数 + * @return 响应结果 + */ + public static String sendGet(String path, Map params) { + try { + // 构建URL参数 + StringBuilder urlBuilder = new StringBuilder(BASE_URL + path); + if (params != null && !params.isEmpty()) { + urlBuilder.append("?"); + for (Map.Entry entry : params.entrySet()) { + if (urlBuilder.toString().contains("?")) { + urlBuilder.append("&"); + } else { + urlBuilder.append("?"); + } + urlBuilder.append(entry.getKey()).append("=").append(entry.getValue()); + } + } + + // 生成时间戳和签名 + String timestamp = SignatureUtil.getCurrentTimestamp(); + String signature = SignatureUtil.generateSignature(params, timestamp, SECRET); + + // 创建HTTP请求 + HttpClient httpClient = HttpClients.createDefault(); + HttpGet httpGet = new HttpGet(urlBuilder.toString()); + + // 设置请求头 + httpGet.setHeader("Content-Type", "application/json;charset=" + CHARSET); + httpGet.setHeader("X-Api-Key", API_KEY); + httpGet.setHeader("X-Timestamp", timestamp); + httpGet.setHeader("X-Signature", signature); + + System.out.println("=== GET Request ==="); + System.out.println("URL: " + urlBuilder.toString()); + System.out.println("X-Api-Key: " + API_KEY); + System.out.println("X-Timestamp: " + timestamp); + System.out.println("X-Signature: " + signature); + + // 执行请求 + HttpResponse response = httpClient.execute(httpGet); + String responseBody = EntityUtils.toString(response.getEntity(), CHARSET); + + System.out.println("Response Status: " + response.getStatusLine().getStatusCode()); + System.out.println("Response Body: " + responseBody); + System.out.println("==================="); + + return responseBody; + + } catch (IOException e) { + e.printStackTrace(); + return "{\"error\":\"Request failed: " + e.getMessage() + "\"}"; + } + } + + /** + * 发送POST请求 + * + * @param path 请求路径 + * @param params URL参数 + * @param requestBody 请求体 + * @return 响应结果 + */ + public static String sendPost(String path, Map params, String requestBody) { + try { + // 构建URL + StringBuilder urlBuilder = new StringBuilder(BASE_URL + path); + if (params != null && !params.isEmpty()) { + urlBuilder.append("?"); + for (Map.Entry entry : params.entrySet()) { + urlBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&"); + } + urlBuilder.setLength(urlBuilder.length() - 1); // 移除最后一个& + } + + // 生成时间戳和签名 + String timestamp = SignatureUtil.getCurrentTimestamp(); + String signature = SignatureUtil.generateSignature(params, timestamp, SECRET); + + // 创建HTTP请求 + HttpClient httpClient = HttpClients.createDefault(); + HttpPost httpPost = new HttpPost(urlBuilder.toString()); + + // 设置请求头 + httpPost.setHeader("Content-Type", "application/json;charset=" + CHARSET); + httpPost.setHeader("X-Api-Key", API_KEY); + httpPost.setHeader("X-Timestamp", timestamp); + httpPost.setHeader("X-Signature", signature); + + // 设置请求体 + if (requestBody != null) { + httpPost.setEntity(new StringEntity(requestBody, CHARSET)); + } + + System.out.println("=== POST Request ==="); + System.out.println("URL: " + urlBuilder.toString()); + System.out.println("Request Body: " + requestBody); + System.out.println("X-Api-Key: " + API_KEY); + System.out.println("X-Timestamp: " + timestamp); + System.out.println("X-Signature: " + signature); + + // 执行请求 + HttpResponse response = httpClient.execute(httpPost); + String responseBody = EntityUtils.toString(response.getEntity(), CHARSET); + + System.out.println("Response Status: " + response.getStatusLine().getStatusCode()); + System.out.println("Response Body: " + responseBody); + System.out.println("===================="); + + return responseBody; + + } catch (IOException e) { + e.printStackTrace(); + return "{\"error\":\"Request failed: " + e.getMessage() + "\"}"; + } + } + + /** + * 生成签名工具方法 + * + * @param params 参数 + * @return 签名信息 + */ + public static Map generateSignatureInfo(Map params) { + String timestamp = SignatureUtil.getCurrentTimestamp(); + String signature = SignatureUtil.generateSignature(params, timestamp, SECRET); + + Map signatureInfo = new HashMap<>(); + signatureInfo.put("X-Api-Key", API_KEY); + signatureInfo.put("X-Timestamp", timestamp); + signatureInfo.put("X-Signature", signature); + + return signatureInfo; + } + + /** + * 测试方法 + */ + public static void main(String[] args) { + System.out.println("=== API Client Test ==="); + + // 测试1:GET请求 - 获取保护数据 + System.out.println("\n1. Testing GET /api/protected/data"); + Map getParams = new HashMap<>(); + getParams.put("userId", "12345"); + getParams.put("type", "profile"); + sendGet("/api/protected/data", getParams); + + // 测试2:GET请求 - 获取用户信息 + System.out.println("\n2. Testing GET /api/protected/user/67890"); + Map userParams = new HashMap<>(); + userParams.put("includeDetails", "true"); + sendGet("/api/protected/user/67890", userParams); + + // 测试3:POST请求 - 创建数据 + System.out.println("\n3. Testing POST /api/protected/create"); + Map postParams = new HashMap<>(); + String requestBody = "{\"name\":\"Test Product\",\"price\":99.99,\"category\":\"Electronics\"}"; + sendPost("/api/protected/create", postParams, requestBody); + + // 测试4:公开接口 - 健康检查 + System.out.println("\n4. Testing GET /api/public/health (no signature required)"); + sendGet("/api/public/health", null); + + // 测试5:生成签名工具 + System.out.println("\n5. Generate signature for custom parameters"); + Map customParams = new HashMap<>(); + customParams.put("action", "test"); + customParams.put("userId", "999"); + customParams.put("timestamp", "1640995200"); + Map signatureInfo = generateSignatureInfo(customParams); + System.out.println("Generated Headers: " + signatureInfo); + + System.out.println("\n=== Test Complete ==="); + } +} \ No newline at end of file diff --git a/springboot-api-signature/src/main/java/com/example/sign/config/ApiSecurityProperties.java b/springboot-api-signature/src/main/java/com/example/sign/config/ApiSecurityProperties.java new file mode 100644 index 0000000..deef3c0 --- /dev/null +++ b/springboot-api-signature/src/main/java/com/example/sign/config/ApiSecurityProperties.java @@ -0,0 +1,88 @@ +package com.example.sign.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * API 安全配置属性 + * + */ +@Data +@Component +@ConfigurationProperties(prefix = "api.security") +public class ApiSecurityProperties { + + /** + * 是否启用签名验证 + */ + private boolean enabled = true; + + /** + * 时间戳容忍度(秒) + */ + private long timeTolerance = 300; // 5分钟 + + /** + * API 密钥映射 + */ + private Map apiKeys = new HashMap<>(); + + /** + * 签名算法 + */ + private String algorithm = "HMAC-SHA256"; + + /** + * 是否启用请求日志 + */ + private boolean enableRequestLog = true; + + /** + * 是否启用响应日志 + */ + private boolean enableResponseLog = false; + + /** + * 获取指定 API Key 对应的密钥 + * + * @param apiKey API Key + * @return 密钥 + */ + public String getApiSecret(String apiKey) { + return apiKeys.get(apiKey); + } + + /** + * 添加 API Key 和密钥 + * + * @param apiKey API Key + * @param secret 密钥 + */ + public void addApiKey(String apiKey, String secret) { + apiKeys.put(apiKey, secret); + } + + /** + * 移除 API Key + * + * @param apiKey API Key + * @return 是否移除成功 + */ + public boolean removeApiKey(String apiKey) { + return apiKeys.remove(apiKey) != null; + } + + /** + * 检查 API Key 是否存在 + * + * @param apiKey API Key + * @return 是否存在 + */ + public boolean containsApiKey(String apiKey) { + return apiKeys.containsKey(apiKey); + } +} \ No newline at end of file diff --git a/springboot-api-signature/src/main/java/com/example/sign/config/WebMvcConfig.java b/springboot-api-signature/src/main/java/com/example/sign/config/WebMvcConfig.java new file mode 100644 index 0000000..d5b3e84 --- /dev/null +++ b/springboot-api-signature/src/main/java/com/example/sign/config/WebMvcConfig.java @@ -0,0 +1,42 @@ +package com.example.sign.config; + +import com.example.sign.interceptor.SignatureValidationInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 配置 + * + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Autowired + private SignatureValidationInterceptor signatureValidationInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(signatureValidationInterceptor) + .addPathPatterns("/api/**") // 拦截所有API请求 + .excludePathPatterns( + "/api/public/**", // 排除公开接口 + "/api/health/**", // 排除健康检查接口 + "/api/docs/**", // 排除文档接口 + "/error" // 排除错误处理接口 + ); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .exposedHeaders("X-Timestamp", "X-Signature", "X-Api-Key") + .allowCredentials(true) + .maxAge(3600); + } +} \ No newline at end of file diff --git a/springboot-api-signature/src/main/java/com/example/sign/controller/TestController.java b/springboot-api-signature/src/main/java/com/example/sign/controller/TestController.java new file mode 100644 index 0000000..8f092eb --- /dev/null +++ b/springboot-api-signature/src/main/java/com/example/sign/controller/TestController.java @@ -0,0 +1,158 @@ +package com.example.sign.controller; + +import com.example.sign.util.SignatureUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 测试控制器 + * + */ +@Slf4j +@RestController +@RequestMapping("/api") +public class TestController { + + /** + * 需要签名的接口 - GET请求 + */ + @GetMapping("/protected/data") + public Map getProtectedData(@RequestParam String userId, + @RequestParam String type) { + log.info("Received protected data request for userId: {}, type: {}", userId, type); + + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "success"); + result.put("data", "This is protected data for user: " + userId); + result.put("type", type); + result.put("timestamp", System.currentTimeMillis()); + + return result; + } + + /** + * 需要签名的接口 - POST请求 + */ + @PostMapping("/protected/create") + public Map createProtectedData(@RequestBody Map requestData) { + log.info("Received protected create request: {}", requestData); + + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "Data created successfully"); + result.put("data", requestData); + result.put("id", "DATA_" + System.currentTimeMillis()); + result.put("timestamp", System.currentTimeMillis()); + + return result; + } + + /** + * 需要签名的接口 - 混合参数请求 + */ + @PostMapping("/protected/mixed") + public Map mixedRequest(@RequestParam String action, + @RequestBody Map requestData) { + log.info("Received mixed request - action: {}, data: {}", action, requestData); + + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "Mixed request processed successfully"); + result.put("action", action); + result.put("requestData", requestData); + result.put("timestamp", System.currentTimeMillis()); + + return result; + } + + /** + * 公开接口(不需要签名)- 健康检查 + */ + @GetMapping("/public/health") + public Map healthCheck() { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "Service is healthy"); + result.put("timestamp", System.currentTimeMillis()); + result.put("version", "1.0.0"); + + return result; + } + + /** + * 公开接口(不需要签名)- 获取服务器信息 + */ + @GetMapping("/public/info") + public Map getPublicInfo() { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "This is public API"); + result.put("data", "Public information"); + result.put("algorithm", "HMAC-SHA256"); + result.put("timestamp", System.currentTimeMillis()); + + return result; + } + + /** + * 公开接口(不需要签名)- 生成签名的工具接口(仅用于测试) + */ + @PostMapping("/public/generate-signature") + public Map generateSignature(@RequestParam Map params, + @RequestParam String apiKey, + @RequestParam String secret) { + log.info("Generate signature request for apiKey: {}", apiKey); + + String timestamp = SignatureUtil.getCurrentTimestamp(); + String signature = SignatureUtil.generateSignature(params, timestamp, secret); + + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "Signature generated successfully"); + result.put("timestamp", timestamp); + result.put("signature", signature); + Map headers = new HashMap<>(); + headers.put("X-Api-Key", apiKey); + headers.put("X-Timestamp", timestamp); + headers.put("X-Signature", signature); + result.put("headers", headers); + + return result; + } + + /** + * 需要签名的接口 - 获取用户信息 + */ + @GetMapping("/protected/user/{userId}") + public Map getUserInfo(@PathVariable String userId, + @RequestParam(required = false) String includeDetails) { + log.info("Get user info request for userId: {}, includeDetails: {}", userId, includeDetails); + + Map userInfo = new HashMap<>(); + userInfo.put("userId", userId); + userInfo.put("username", "user_" + userId); + userInfo.put("email", userId + "@example.com"); + userInfo.put("status", "active"); + + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "User info retrieved successfully"); + result.put("user", userInfo); + + if ("true".equals(includeDetails)) { + Map details = new HashMap<>(); + details.put("createdTime", "2024-01-01T00:00:00Z"); + details.put("lastLoginTime", "2024-01-15T10:30:00Z"); + details.put("loginCount", 42); + result.put("details", details); + } + + result.put("timestamp", System.currentTimeMillis()); + + return result; + } +} \ No newline at end of file diff --git a/springboot-api-signature/src/main/java/com/example/sign/interceptor/SignatureValidationInterceptor.java b/springboot-api-signature/src/main/java/com/example/sign/interceptor/SignatureValidationInterceptor.java new file mode 100644 index 0000000..3381d01 --- /dev/null +++ b/springboot-api-signature/src/main/java/com/example/sign/interceptor/SignatureValidationInterceptor.java @@ -0,0 +1,150 @@ +package com.example.sign.interceptor; + +import com.alibaba.fastjson.JSON; +import com.example.sign.config.ApiSecurityProperties; +import com.example.sign.util.SignatureUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * API 签名验证拦截器 + * + */ +@Slf4j +@Component +public class SignatureValidationInterceptor implements HandlerInterceptor { + + private final ApiSecurityProperties securityProperties; + + public SignatureValidationInterceptor(ApiSecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + // 如果未启用签名验证,直接放行 + if (!securityProperties.isEnabled()) { + return true; + } + + // 记录请求信息 + if (securityProperties.isEnableRequestLog()) { + log.info("=== API Request ==="); + log.info("URI: {}", request.getRequestURI()); + log.info("Method: {}", request.getMethod()); + log.info("Remote IP: {}", getClientIpAddress(request)); + } + + // 获取请求头中的签名信息 + String timestamp = request.getHeader("X-Timestamp"); + String signature = request.getHeader("X-Signature"); + String apiKey = request.getHeader("X-Api-Key"); + + if (securityProperties.isEnableRequestLog()) { + log.info("Timestamp: {}, API Key: {}", timestamp, apiKey); + } + + // 验证必要参数 + if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(signature) || !StringUtils.hasText(apiKey)) { + log.warn("Missing required signature headers. Timestamp: {}, Signature: {}, API Key: {}", + StringUtils.hasText(timestamp) ? timestamp : "null", + StringUtils.hasText(signature) ? signature.substring(0, Math.min(signature.length(), 10)) + "..." : "null", + StringUtils.hasText(apiKey) ? apiKey : "null"); + + return writeErrorResponse(response, 401, "Missing required signature headers"); + } + + // 验证时间戳(防重放攻击) + if (!SignatureUtil.validateTimestamp(timestamp, securityProperties.getTimeTolerance())) { + log.warn("Invalid timestamp: {}", timestamp); + return writeErrorResponse(response, 401, "Invalid timestamp"); + } + + // 获取密钥 + String secret = securityProperties.getApiSecret(apiKey); + if (secret == null) { + log.warn("Invalid API key: {}", apiKey); + return writeErrorResponse(response, 401, "Invalid API key"); + } + + // 提取请求参数 + Map params = SignatureUtil.extractParams(request); + + // 验证签名 + if (!SignatureUtil.verifySignature(params, timestamp, secret, signature)) { + // 生成预期签名用于日志对比(注意:生产环境中不要输出完整签名) + String expectedSignature = SignatureUtil.generateSignature(params, timestamp, secret); + log.warn("Signature validation failed. Expected: {}..., Actual: {}...", + expectedSignature.substring(0, Math.min(expectedSignature.length(), 10)), + signature.substring(0, Math.min(signature.length(), 10))); + + return writeErrorResponse(response, 401, "Invalid signature"); + } + + log.info("Signature validation successful"); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) throws Exception { + // 记录响应信息 + if (securityProperties.isEnableResponseLog()) { + log.info("=== API Response ==="); + log.info("Status: {}", response.getStatus()); + log.info("Content-Type: {}", response.getContentType()); + } + } + + /** + * 写入错误响应 + * + * @param response HTTP响应 + * @param status 状态码 + * @param message 错误消息 + * @return 始终返回false + */ + private boolean writeErrorResponse(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + + Map errorResult = new HashMap<>(); + errorResult.put("code", status); + errorResult.put("message", message); + errorResult.put("timestamp", System.currentTimeMillis()); + + String jsonResult = JSON.toJSONString(errorResult); + response.getWriter().write(jsonResult); + response.getWriter().flush(); + + return false; + } + + /** + * 获取客户端真实IP地址 + * + * @param request HTTP请求 + * @return 客户端IP地址 + */ + private String getClientIpAddress(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (StringUtils.hasText(xForwardedFor) && !"unknown".equalsIgnoreCase(xForwardedFor)) { + return xForwardedFor.split(",")[0].trim(); + } + + String xRealIp = request.getHeader("X-Real-IP"); + if (StringUtils.hasText(xRealIp) && !"unknown".equalsIgnoreCase(xRealIp)) { + return xRealIp; + } + + return request.getRemoteAddr(); + } +} \ No newline at end of file diff --git a/springboot-api-signature/src/main/java/com/example/sign/util/SignatureUtil.java b/springboot-api-signature/src/main/java/com/example/sign/util/SignatureUtil.java new file mode 100644 index 0000000..9948495 --- /dev/null +++ b/springboot-api-signature/src/main/java/com/example/sign/util/SignatureUtil.java @@ -0,0 +1,160 @@ +package com.example.sign.util; + +import cn.hutool.crypto.digest.HMac; +import cn.hutool.crypto.digest.HmacAlgorithm; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.*; + +/** + * HMAC-SHA256 签名工具类 + * + */ +public class SignatureUtil { + + /** + * 生成签名 + * + * @param params 请求参数 + * @param timestamp 时间戳 + * @param secret 密钥 + * @return 签名字符串 + */ + public static String generateSignature(Map params, String timestamp, String secret) { + // 1. 参数排序 + String sortedParams = sortParams(params); + + // 2. 构建待签名字符串 + String dataToSign = timestamp + sortedParams; + + // 3. HMAC-SHA256 加密 + HMac hmac = new HMac(HmacAlgorithm.HmacSHA256, secret.getBytes(StandardCharsets.UTF_8)); + byte[] digest = hmac.digest(dataToSign); + + // 4. Base64 编码 + return Base64.getEncoder().encodeToString(digest); + } + + /** + * 参数排序并拼接 + * + * @param params 请求参数 + * @return 排序后的参数字符串 + */ + public static String sortParams(Map params) { + if (params == null || params.isEmpty()) { + return ""; + } + + // 过滤空值参数并按字典序排序 + List keys = new ArrayList<>(); + for (Map.Entry entry : params.entrySet()) { + if (entry.getValue() != null && StringUtils.hasText(entry.getValue().toString())) { + keys.add(entry.getKey()); + } + } + + Collections.sort(keys); + + // 拼接参数 + StringBuilder sb = new StringBuilder(); + for (String key : keys) { + if (sb.length() > 0) { + sb.append("&"); + } + sb.append(key).append("=").append(params.get(key)); + } + + return sb.toString(); + } + + /** + * 从请求中提取参数 + * + * @param request HTTP请求 + * @return 参数Map + */ + public static Map extractParams(HttpServletRequest request) { + Map params = new HashMap<>(); + + // 获取URL参数 + Enumeration parameterNames = request.getParameterNames(); + while (parameterNames.hasMoreElements()) { + String paramName = parameterNames.nextElement(); + String paramValue = request.getParameter(paramName); + params.put(paramName, paramValue); + } + + return params; + } + + /** + * 验证时间戳(防重放攻击) + * + * @param timestamp 时间戳 + * @param tolerance 容忍时间差(秒) + * @return 是否有效 + */ + public static boolean validateTimestamp(String timestamp, long tolerance) { + if (!StringUtils.hasText(timestamp)) { + return false; + } + + try { + long requestTime = Long.parseLong(timestamp); + long currentTime = System.currentTimeMillis() / 1000; + return Math.abs(currentTime - requestTime) <= tolerance; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * 验证签名 + * + * @param params 请求参数 + * @param timestamp 时间戳 + * @param secret 密钥 + * @param receivedSignature 接收到的签名 + * @return 是否验证通过 + */ + public static boolean verifySignature(Map params, String timestamp, + String secret, String receivedSignature) { + if (!StringUtils.hasText(receivedSignature)) { + return false; + } + + String expectedSignature = generateSignature(params, timestamp, secret); + return expectedSignature.equals(receivedSignature); + } + + /** + * 生成随机密钥 + * + * @param length 密钥长度 + * @return 随机密钥 + */ + public static String generateSecretKey(int length) { + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + StringBuilder sb = new StringBuilder(); + Random random = new Random(); + + for (int i = 0; i < length; i++) { + sb.append(chars.charAt(random.nextInt(chars.length()))); + } + + return sb.toString(); + } + + /** + * 生成当前时间戳(秒级) + * + * @return 时间戳字符串 + */ + public static String getCurrentTimestamp() { + return String.valueOf(System.currentTimeMillis() / 1000); + } +} \ No newline at end of file diff --git a/springboot-api-signature/src/main/resources/application.yml b/springboot-api-signature/src/main/resources/application.yml new file mode 100644 index 0000000..005a4aa --- /dev/null +++ b/springboot-api-signature/src/main/resources/application.yml @@ -0,0 +1,67 @@ +server: + port: 8080 + servlet: + context-path: / + tomcat: + uri-encoding: UTF-8 + +spring: + application: + name: springboot-api-signature + + # JSON配置 + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + serialization: + write-dates-as-timestamps: false + deserialization: + fail-on-unknown-properties: false + +# API安全配置 +api: + security: + # 是否启用签名验证 + enabled: true + + # 时间戳容忍度(秒) + time-tolerance: 300 # 5分钟 + + # 签名算法 + algorithm: HMAC-SHA256 + + # 是否启用请求日志 + enable-request-log: true + + # 是否启用响应日志 + enable-response-log: false + + # API密钥配置(生产环境建议使用外部配置或密钥管理服务) + api-keys: + # API Key: Secret + client1: "demo-secret-key-for-client1-2024" + client2: "demo-secret-key-for-client2-2024" + client3: "demo-secret-key-for-client3-2024" + +# 日志配置 +logging: + level: + com.example.sign: INFO + org.springframework.web: INFO + root: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + +--- +# 开发环境配置 +spring: + profiles: dev + +server: + port: 8080 + +logging: + level: + com.example.sign: DEBUG + org.springframework.web: DEBUG 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 的流式响应助手。你可以问我任何问题,我会逐字回复,让你体验流畅的打字效果! +
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+ + + + 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 diff --git a/springboot-column-encryption/README.md b/springboot-column-encryption/README.md new file mode 100644 index 0000000..94ea81e --- /dev/null +++ b/springboot-column-encryption/README.md @@ -0,0 +1,77 @@ +# Spring Boot 字段级加密演示项目 + +## 🎯 项目概述 + +这是一个基于 Spring Boot 3 + MyBatis 的字段级加解密演示项目,实现了**透明**的字段级加密功能。通过简单的 `@Encrypted` 注解,即可实现敏感数据的自动加密存储和解密读取。 + +### ✨ 核心特性 + +- 🔐 **透明加密**:业务代码零侵入,自动加解密 +- 🛡️ **安全算法**:使用 AES-GCM 加密算法,支持防篡改 +- 🚀 **零配置**:注解驱动,开箱即用 +- 🔧 **可扩展**:支持自定义加密算法和密钥管理 + +## 🚀 快速开始 + +### 1. 环境要求 + +- JDK 17+ +- Maven 3.6+ + +### 2. 运行项目 + +```bash +# 克隆项目 +git clone +cd springboot-column-encryption + +# 编译运行 +mvn spring-boot:run +``` + +### 3. 访问应用 + +- **前端界面**:http://localhost:8080 +- **API接口**:http://localhost:8080/api/users +- **H2控制台**:http://localhost:8080/h2-console + - JDBC URL: `jdbc:h2:mem:testdb` + - 用户名: `sa` + - 密码: `password` + +## 📖 使用指南 + +### 基本用法 + +1. **在实体类字段上添加注解**: + +```java +@Data +public class User { + private Long id; + private String username; + + @Encrypted // 添加此注解即可实现自动加密 + private String phone; + + @Encrypted + private String idCard; + + // 普通字段不会加密 + private Integer age; +} +``` + +2. **正常使用 MyBatis 操作**: + +```java +// 插入数据 - 自动加密敏感字段 +User user = new User(); +user.setUsername("张三"); +user.setPhone("13812345678"); // 会自动加密存储 +user.setIdCard("110101199001011234"); // 会自动加密存储 +userMapper.insert(user); + +// 查询数据 - 自动解密敏感字段 +User result = userMapper.findById(user.getId()); +System.out.println(result.getPhone()); // 输出: 13812345678 (已自动解密) +``` \ No newline at end of file diff --git a/springboot-column-encryption/pom.xml b/springboot-column-encryption/pom.xml new file mode 100644 index 0000000..fa1ef72 --- /dev/null +++ b/springboot-column-encryption/pom.xml @@ -0,0 +1,91 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.example + springboot-column-encryption + 1.0.0 + jar + + Spring Boot Column Encryption Demo + 字段级加解密演示项目 + + + 17 + 3.0.3 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + ${mybatis-spring-boot.version} + + + + + com.h2database + h2 + runtime + + + + + 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 + + + + + + + \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/ColumnEncryptionApplication.java b/springboot-column-encryption/src/main/java/com/example/encryption/ColumnEncryptionApplication.java new file mode 100644 index 0000000..5c85f1c --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/ColumnEncryptionApplication.java @@ -0,0 +1,15 @@ +package com.example.encryption; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ColumnEncryptionApplication { + + public static void main(String[] args) { + SpringApplication.run(ColumnEncryptionApplication.class, args); + System.out.println("🚀 Spring Boot 字段级加密演示项目启动成功!"); + System.out.println("📱 前端访问地址: http://localhost:8080"); + System.out.println("🔧 API文档地址: http://localhost:8080/api/users"); + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/annotation/Encrypted.java b/springboot-column-encryption/src/main/java/com/example/encryption/annotation/Encrypted.java new file mode 100644 index 0000000..e2a8e0f --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/annotation/Encrypted.java @@ -0,0 +1,31 @@ +package com.example.encryption.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 字段级加密注解 + * 标记需要进行加解密的字段 + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Encrypted { + /** + * 加密算法类型,默认为 AES-GCM + */ + Algorithm algorithm() default Algorithm.AES_GCM; + + /** + * 是否支持模糊查询 + */ + boolean searchable() default false; + + /** + * 支持的加密算法枚举 + */ + enum Algorithm { + AES_GCM + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/config/DataInitializer.java b/springboot-column-encryption/src/main/java/com/example/encryption/config/DataInitializer.java new file mode 100644 index 0000000..11ebce1 --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/config/DataInitializer.java @@ -0,0 +1,100 @@ +package com.example.encryption.config; + +import com.example.encryption.entity.User; +import com.example.encryption.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 示例数据初始化器 + * + * 功能: + * - 通过 Java 代码插入示例数据,确保数据经过加密拦截器处理 + * - 避免直接 SQL 插入导致的数据未加密问题 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@Order(1) +public class DataInitializer implements CommandLineRunner { + + private final UserService userService; + + @Override + public void run(String... args) throws Exception { + log.info("🔄 开始初始化示例数据..."); + + try { + // 检查是否已有数据 + long userCount = userService.countUsers(); + if (userCount > 0) { + log.info("📊 数据库已包含 {} 条用户数据,跳过初始化", userCount); + return; + } + + // 创建示例用户数据 + createSampleUsers(); + log.info("✅ 示例数据初始化完成"); + + } catch (Exception e) { + log.error("❌ 示例数据初始化失败", e); + // 不抛出异常,允许应用继续启动 + } + } + + /** + * 创建示例用户数据 + */ + private void createSampleUsers() { + log.info("👥 创建示例用户数据..."); + + // 示例用户1 + User user1 = new User(); + user1.setUsername("数据库初始用户"); + user1.setPhone("13899990001"); + user1.setIdCard("110101199009099999"); + user1.setEmail("db.init@example.com"); + user1.setBankCard("6222021234567899999"); + user1.setAddress("北京市海淀区中关村大街1号"); + user1.setAge(35); + user1.setGender("男"); + user1.setOccupation("系统管理员"); + user1.setRemark("数据库初始化用户 - 展示加密效果"); + userService.createUser(user1); + + // 示例用户2 + User user2 = new User(); + user2.setUsername("示例用户小明"); + user2.setPhone("13899990002"); + user2.setIdCard("110101199010101010"); + user2.setEmail("xiaoming@example.com"); + user2.setBankCard("6222021234567898888"); + user2.setAddress("上海市浦东新区世纪大道200号"); + user2.setAge(26); + user2.setGender("男"); + user2.setOccupation("Java开发工程师"); + user2.setRemark("数据库初始化用户 - 展示加密效果"); + userService.createUser(user2); + + // 示例用户3 + User user3 = new User(); + user3.setUsername("示例用户小红"); + user3.setPhone("13899990003"); + user3.setIdCard("110101199011111111"); + user3.setEmail("xiaohong@example.com"); + user3.setBankCard("6222021234567897777"); + user3.setAddress("广州市天河区珠江新城100号"); + user3.setAge(24); + user3.setGender("女"); + user3.setOccupation("前端开发工程师"); + user3.setRemark("数据库初始化用户 - 展示加密效果"); + userService.createUser(user3); + + log.info("👥 成功创建 3 个示例用户"); + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/config/EncryptionAutoConfiguration.java b/springboot-column-encryption/src/main/java/com/example/encryption/config/EncryptionAutoConfiguration.java new file mode 100644 index 0000000..b4ab6ce --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/config/EncryptionAutoConfiguration.java @@ -0,0 +1,117 @@ +package com.example.encryption.config; + +import com.example.encryption.handler.EncryptTypeHandler; +import com.example.encryption.interceptor.EncryptionInterceptor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.type.JdbcType; +import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +/** + * 字段级加密自动配置类 + * + * 功能: + * - 自动注册加密相关的组件到 MyBatis + * - 配置 ObjectWrapperFactory + * - 配置 TypeHandler + * - 提供开关控制 + */ +@Slf4j +@org.springframework.context.annotation.Configuration +@ConditionalOnProperty(name = "encryption.enabled", havingValue = "true", matchIfMissing = true) +public class EncryptionAutoConfiguration { + + /** + * 注册加密拦截器 + */ + @Bean + public EncryptionInterceptor encryptionInterceptor() { + return new EncryptionInterceptor(); + } + + /** + * 注册 MyBatis ConfigurationCustomizer + * 用于配置加密相关的组件 + */ + @Bean + public ConfigurationCustomizer encryptionConfigurationCustomizer(EncryptionInterceptor encryptionInterceptor) { + return new EncryptionConfigurationCustomizer(encryptionInterceptor); + } + + /** + * 加密配置自定义器 + */ + public static class EncryptionConfigurationCustomizer implements ConfigurationCustomizer { + + private final EncryptionInterceptor encryptionInterceptor; + + public EncryptionConfigurationCustomizer(EncryptionInterceptor encryptionInterceptor) { + this.encryptionInterceptor = encryptionInterceptor; + } + + @Override + public void customize(Configuration configuration) { + log.info("🔐 开始配置 MyBatis 字段级加密功能"); + + // 注册加密拦截器(主要加密机制) + configuration.addInterceptor(encryptionInterceptor); + log.info("✅ 已注册加密拦截器 - 主要加密机制"); + + // 暂时不注册 TypeHandler,避免与拦截器冲突 + // 拦截器会处理所有实体对象的加密 + log.info("⚠️ TypeHandler 已禁用,使用拦截器统一处理加密"); + + log.info("🎉 MyBatis 字段级加密功能配置完成"); + log.info("💡 使用方法:在需要加密的字段上添加 @Encrypted 注解即可"); + } + } + + /** + * 加密配置属性类 + */ + @org.springframework.context.annotation.Configuration + @ConditionalOnProperty(name = "encryption.enabled", havingValue = "true", matchIfMissing = true) + public static class EncryptionProperties { + + /** + * 是否启用加密功能 + */ + private boolean enabled = true; + + /** + * 默认加密算法 + */ + private String algorithm = "AES-GCM"; + + /** + * 密钥 + */ + private String secretKey = "MySecretKey12345MySecretKey12345"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/config/EncryptionTestRunner.java b/springboot-column-encryption/src/main/java/com/example/encryption/config/EncryptionTestRunner.java new file mode 100644 index 0000000..667537d --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/config/EncryptionTestRunner.java @@ -0,0 +1,132 @@ +package com.example.encryption.config; + +import com.example.encryption.entity.User; +import com.example.encryption.service.UserService; +import com.example.encryption.util.CryptoUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * 加密功能测试运行器 + * + * 用于验证加密功能是否正常工作 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@Order(2) +public class EncryptionTestRunner implements CommandLineRunner { + + private final UserService userService; + + @Override + public void run(String... args) throws Exception { + log.info("🧪 开始运行完整的加密功能测试..."); + + try { + // 1. 测试加密工具类 + testCryptoUtil(); + + // 2. 测试数据库加密存储 + //testDatabaseEncryption(); + + log.info("🎉 所有加密功能测试通过!数据入库加密功能正常工作!"); + + } catch (Exception e) { + log.error("❌ 加密功能测试失败", e); + } + } + + /** + * 测试加密工具类 + */ + private void testCryptoUtil() { + log.info("🔐 测试加密工具类..."); + + String originalText = "13812345678"; + log.info("原始文本: {}", originalText); + + // 测试加密 + String encrypted = CryptoUtil.encrypt(originalText); + log.info("加密后: {}", encrypted); + + // 验证加密格式 + if (!CryptoUtil.isEncrypted(encrypted)) { + throw new RuntimeException("加密格式验证失败"); + } + + // 测试解密 + String decrypted = CryptoUtil.decrypt(encrypted); + log.info("解密后: {}", decrypted); + + // 验证解密结果 + if (!originalText.equals(decrypted)) { + throw new RuntimeException("解密结果与原文不匹配"); + } + + log.info("✅ 加密工具类测试通过"); + } + + /** + * 测试数据库加密存储 + */ + private void testDatabaseEncryption() { + log.info("💾 测试数据库加密存储..."); + + // 创建测试用户 + User testUser = new User(); + testUser.setUsername("加密测试用户_" + System.currentTimeMillis()); + testUser.setPhone("13888889999"); + testUser.setIdCard("110101199012121212"); + testUser.setEmail("encryption.test@example.com"); + testUser.setBankCard("6222021234567891234"); + testUser.setAddress("加密测试地址"); + testUser.setAge(30); + testUser.setGender("男"); + testUser.setOccupation("加密测试工程师"); + testUser.setRemark("用于测试加密功能"); + + log.info("📝 创建测试用户: {}", testUser.getUsername()); + log.info("📱 原始手机号: {}", testUser.getPhone()); + log.info("📧 原始邮箱: {}", testUser.getEmail()); + + // 保存用户(此时应该通过拦截器或TypeHandler进行加密) + User savedUser = userService.createUser(testUser); + log.info("💾 保存用户成功,ID: {}", savedUser.getId()); + + // 从数据库重新查询用户(此时应该通过拦截器或TypeHandler进行解密) + var foundUser = userService.getUserById(savedUser.getId()); + if (foundUser.isPresent()) { + User user = foundUser.get(); + log.info("🔍 查询到用户: {}", user.getUsername()); + log.info("📱 查询到的手机号: {} (长度: {})", user.getPhone(), user.getPhone() != null ? user.getPhone().length() : 0); + log.info("📧 查询到的邮箱: {} (长度: {})", user.getEmail(), user.getEmail() != null ? user.getEmail().length() : 0); + log.info("🆔 查询到的身份证: {} (长度: {})", user.getIdCard(), user.getIdCard() != null ? user.getIdCard().length() : 0); + + // 验证数据是否被正确解密 + boolean phoneMatch = testUser.getPhone().equals(user.getPhone()); + boolean emailMatch = testUser.getEmail().equals(user.getEmail()); + boolean idCardMatch = testUser.getIdCard().equals(user.getIdCard()); + boolean bankCardMatch = testUser.getBankCard().equals(user.getBankCard()); + boolean addressMatch = testUser.getAddress().equals(user.getAddress()); + + log.info("🔍 验证结果:"); + log.info(" 手机号匹配: {} ({})", phoneMatch, phoneMatch ? "✅" : "❌"); + log.info(" 邮箱匹配: {} ({})", emailMatch, emailMatch ? "✅" : "❌"); + log.info(" 身份证匹配: {} ({})", idCardMatch, idCardMatch ? "✅" : "❌"); + log.info(" 银行卡匹配: {} ({})", bankCardMatch, bankCardMatch ? "✅" : "❌"); + log.info(" 地址匹配: {} ({})", addressMatch, addressMatch ? "✅" : "❌"); + + if (phoneMatch && emailMatch && idCardMatch && bankCardMatch && addressMatch) { + log.info("✅ 数据库加密存储测试通过!数据入库时被正确加密,查询时被正确解密!"); + } else { + throw new RuntimeException("数据库加密存储测试失败:部分字段加解密不匹配"); + } + } else { + throw new RuntimeException("无法查询到测试用户!"); + } + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/controller/UserController.java b/springboot-column-encryption/src/main/java/com/example/encryption/controller/UserController.java new file mode 100644 index 0000000..4da4945 --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/controller/UserController.java @@ -0,0 +1,446 @@ +package com.example.encryption.controller; + +import com.example.encryption.entity.User; +import com.example.encryption.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.validation.BindingResult; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 用户控制器 + * + * 提供完整的RESTful API接口 + * 支持用户的基本CRUD操作 + * 演示字段级加密的实际效果 + */ +@Slf4j +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@Validated +@CrossOrigin(origins = "*", maxAge = 3600) +public class UserController { + + private final UserService userService; + + /** + * 获取系统信息 + */ + @GetMapping("/info") + public ResponseEntity> getSystemInfo() { + Map info = new HashMap<>(); + info.put("system", "Spring Boot 字段级加密演示系统"); + info.put("version", "1.0.0"); + info.put("description", "基于 @Encrypted 注解的透明字段级加解密"); + info.put("features", Arrays.asList( + "自动字段加密", + "透明加解密处理", + "支持 AES-GCM 加密算法", + "零代码侵入", + "MyBatis 自动集成" + )); + info.put("timestamp", LocalDateTime.now()); + return ResponseEntity.ok(info); + } + + /** + * 创建用户 + */ + @PostMapping + public ResponseEntity> createUser(@Valid @RequestBody User user, BindingResult bindingResult) { + try { + log.info("📥 创建用户请求: {}", user.getUsername()); + log.info("📋 用户数据详情: username={}, phone={}, email={}, bankCard={}", + user.getUsername(), user.getPhone(), user.getEmail(), user.getBankCard()); + + // 检查验证结果 + if (bindingResult.hasErrors()) { + StringBuilder errorMessage = new StringBuilder("参数验证失败: "); + bindingResult.getFieldErrors().forEach(error -> { + errorMessage.append(error.getField()).append(": ").append(error.getDefaultMessage()).append("; "); + }); + log.error("❌ 参数验证失败: {}", errorMessage.toString()); + return ResponseEntity.badRequest().body(createErrorMap(errorMessage.toString())); + } + + // 检查用户名是否已存在 + if (userService.usernameExists(user.getUsername())) { + return ResponseEntity.badRequest().body(createErrorMap("用户名已存在")); + } + + // 检查手机号是否已存在 + if (user.getPhone() != null && userService.phoneExists(user.getPhone())) { + return ResponseEntity.badRequest().body(createErrorMap("手机号已存在")); + } + + // 检查邮箱是否已存在 + if (user.getEmail() != null && userService.emailExists(user.getEmail())) { + return ResponseEntity.badRequest().body(createErrorMap("邮箱已存在")); + } + + User createdUser = userService.createUser(user); + return ResponseEntity.status(HttpStatus.CREATED).body(createSuccessMap("用户创建成功", createdUser)); + + } catch (Exception e) { + log.error("创建用户失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("创建用户失败: " + e.getMessage())); + } + } + + /** + * 批量创建用户 + */ + @PostMapping("/batch") + public ResponseEntity> batchCreateUsers(@Valid @RequestBody @NotEmpty List users) { + try { + log.info("批量创建用户请求,数量: {}", users.size()); + + List createdUsers = userService.createUsers(users); + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", "批量创建用户成功"); + result.put("count", createdUsers.size()); + result.put("data", createdUsers); + + return ResponseEntity.status(HttpStatus.CREATED).body(result); + + } catch (Exception e) { + log.error("批量创建用户失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("批量创建用户失败: " + e.getMessage())); + } + } + + /** + * 根据ID获取用户 + */ + @GetMapping("/{id}") + public ResponseEntity> getUserById(@PathVariable @NotNull Long id) { + try { + Optional user = userService.getUserById(id); + if (user.isPresent()) { + return ResponseEntity.ok(createSuccessMap("查询成功", user.get())); + } else { + return ResponseEntity.notFound().build(); + } + } catch (Exception e) { + log.error("查询用户失败,ID: {}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("查询用户失败: " + e.getMessage())); + } + } + + /** + * 根据用户名获取用户 + */ + @GetMapping("/username/{username}") + public ResponseEntity> getUserByUsername(@PathVariable @NotBlank String username) { + try { + Optional user = userService.getUserByUsername(username); + if (user.isPresent()) { + return ResponseEntity.ok(createSuccessMap("查询成功", user.get())); + } else { + return ResponseEntity.notFound().build(); + } + } catch (Exception e) { + log.error("查询用户失败,用户名: {}", username, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("查询用户失败: " + e.getMessage())); + } + } + + /** + * 根据手机号获取用户 + */ + @GetMapping("/phone/{phone}") + public ResponseEntity> getUserByPhone(@PathVariable @NotBlank String phone) { + try { + Optional user = userService.getUserByPhone(phone); + if (user.isPresent()) { + return ResponseEntity.ok(createSuccessMap("查询成功", user.get())); + } else { + return ResponseEntity.notFound().build(); + } + } catch (Exception e) { + log.error("查询用户失败,手机号: {}", phone, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("查询用户失败: " + e.getMessage())); + } + } + + /** + * 获取所有用户 + */ + @GetMapping + public ResponseEntity> getAllUsers() { + try { + List users = userService.getAllUsers(); + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", "查询成功"); + result.put("count", users.size()); + result.put("data", users); + return ResponseEntity.ok(result); + } catch (Exception e) { + log.error("查询所有用户失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("查询用户失败: " + e.getMessage())); + } + } + + /** + * 分页获取用户 + */ + @GetMapping("/page") + public ResponseEntity> getUsersByPage( + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "10") @Min(1) int size) { + try { + List users = userService.getUsersByPage(page, size); + long total = userService.countUsers(); + + Page userPage = new PageImpl<>(users, + org.springframework.data.domain.PageRequest.of(page - 1, size), total); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", "查询成功"); + result.put("data", Map.of( + "content", userPage.getContent(), + "totalElements", userPage.getTotalElements(), + "totalPages", userPage.getTotalPages(), + "currentPage", page, + "pageSize", size + )); + return ResponseEntity.ok(result); + + } catch (Exception e) { + log.error("分页查询用户失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("分页查询用户失败: " + e.getMessage())); + } + } + + /** + * 搜索用户 + */ + @GetMapping("/search") + public ResponseEntity> searchUsers( + @RequestParam(required = false) String username, + @RequestParam(required = false) Boolean enabled, + @RequestParam(required = false) Integer age, + @RequestParam(required = false) String gender, + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "10") @Min(1) int size) { + try { + List users = userService.searchUsers(username, enabled, age, gender, page, size); + long total = userService.countUsersByCondition(username, enabled, age, gender); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", "搜索成功"); + result.put("data", Map.of( + "content", users, + "totalElements", total, + "currentPage", page, + "pageSize", size + )); + return ResponseEntity.ok(result); + + } catch (Exception e) { + log.error("搜索用户失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("搜索用户失败: " + e.getMessage())); + } + } + + /** + * 更新用户 + */ + @PutMapping("/{id}") + public ResponseEntity> updateUser( + @PathVariable @NotNull Long id, + @Valid @RequestBody User user) { + try { + log.info("更新用户请求,ID: {}", id); + + if (!userService.userExists(id)) { + return ResponseEntity.notFound().build(); + } + + user.setId(id); + User updatedUser = userService.updateUser(user); + return ResponseEntity.ok(createSuccessMap("用户更新成功", updatedUser)); + + } catch (Exception e) { + log.error("更新用户失败,ID: {}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("更新用户失败: " + e.getMessage())); + } + } + + /** + * 部分更新用户 + */ + @PatchMapping("/{id}") + public ResponseEntity> updateUserPartial( + @PathVariable @NotNull Long id, + @RequestBody Map updates) { + try { + log.info("部分更新用户请求,ID: {}", id); + + Optional existingUser = userService.getUserById(id); + if (!existingUser.isPresent()) { + return ResponseEntity.notFound().build(); + } + + // 更新指定字段 + User user = existingUser.get(); + updates.forEach((key, value) -> { + switch (key) { + case "username": user.setUsername((String) value); break; + case "phone": user.setPhone((String) value); break; + case "idCard": user.setIdCard((String) value); break; + case "email": user.setEmail((String) value); break; + case "bankCard": user.setBankCard((String) value); break; + case "address": user.setAddress((String) value); break; + case "age": user.setAge((Integer) value); break; + case "gender": user.setGender((String) value); break; + case "occupation": user.setOccupation((String) value); break; + case "enabled": user.setEnabled((Boolean) value); break; + case "remark": user.setRemark((String) value); break; + default: log.warn("忽略未知字段: {}", key); break; + } + }); + + User updatedUser = userService.updateUserSelective(user); + return ResponseEntity.ok(createSuccessMap("用户部分更新成功", updatedUser)); + + } catch (Exception e) { + log.error("部分更新用户失败,ID: {}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("部分更新用户失败: " + e.getMessage())); + } + } + + /** + * 删除用户 + */ + @DeleteMapping("/{id}") + public ResponseEntity> deleteUser(@PathVariable @NotNull Long id) { + try { + log.info("删除用户请求,ID: {}", id); + + if (!userService.userExists(id)) { + return ResponseEntity.notFound().build(); + } + + boolean success = userService.deleteUser(id); + if (success) { + return ResponseEntity.ok(createSuccessMap("用户删除成功", null)); + } else { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("用户删除失败")); + } + + } catch (Exception e) { + log.error("删除用户失败,ID: {}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("删除用户失败: " + e.getMessage())); + } + } + + /** + * 批量删除用户 + */ + @DeleteMapping("/batch") + public ResponseEntity> batchDeleteUsers(@RequestBody @NotEmpty List ids) { + try { + log.info("批量删除用户请求,数量: {}", ids.size()); + + int deletedCount = userService.batchDeleteUsers(ids); + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", "批量删除用户完成"); + result.put("deletedCount", deletedCount); + result.put("requestedCount", ids.size()); + + return ResponseEntity.ok(result); + + } catch (Exception e) { + log.error("批量删除用户失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("批量删除用户失败: " + e.getMessage())); + } + } + + /** + * 获取统计信息 + */ + @GetMapping("/stats") + public ResponseEntity> getStats() { + try { + long totalCount = userService.countUsers(); + long enabledCount = userService.countUsersByCondition(null, true, null, null); + long disabledCount = userService.countUsersByCondition(null, false, null, null); + + Map stats = new HashMap<>(); + stats.put("totalCount", totalCount); + stats.put("enabledCount", enabledCount); + stats.put("disabledCount", disabledCount); + stats.put("enabledRate", totalCount > 0 ? (double) enabledCount / totalCount * 100 : 0); + + return ResponseEntity.ok(createSuccessMap("统计信息查询成功", stats)); + + } catch (Exception e) { + log.error("获取统计信息失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(createErrorMap("获取统计信息失败: " + e.getMessage())); + } + } + + /** + * 创建成功响应 + */ + private Map createSuccessMap(String message, Object data) { + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", message); + result.put("timestamp", LocalDateTime.now()); + if (data != null) { + result.put("data", data); + } + return result; + } + + /** + * 创建错误响应 + */ + private Map createErrorMap(String message) { + Map result = new HashMap<>(); + result.put("success", false); + result.put("message", message); + result.put("timestamp", LocalDateTime.now()); + return result; + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/entity/User.java b/springboot-column-encryption/src/main/java/com/example/encryption/entity/User.java new file mode 100644 index 0000000..496ec53 --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/entity/User.java @@ -0,0 +1,191 @@ +package com.example.encryption.entity; + +import com.example.encryption.annotation.Encrypted; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +/** + * 用户实体类 + * + * 包含需要加密的敏感字段: + * - phone: 手机号 + * - idCard: 身份证号 + * - email: 邮箱 + * - bankCard: 银行卡号 + * - address: 家庭住址 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class User { + + /** + * 用户ID(主键) + */ + private Long id; + + /** + * 用户名 + */ + @NotBlank(message = "用户名不能为空") + @Size(min = 2, max = 50, message = "用户名长度必须在2-50字符之间") + private String username; + + /** + * 手机号(加密字段) + */ + @Encrypted + @NotBlank(message = "手机号不能为空") + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + private String phone; + + /** + * 身份证号(加密字段) + */ + @Encrypted + @NotBlank(message = "身份证号不能为空") + private String idCard; + + /** + * 邮箱(加密字段) + */ + @Encrypted + @Email(message = "邮箱格式不正确") + private String email; + + /** + * 银行卡号(加密字段) + */ + @Encrypted + @Pattern(regexp = "^\\d{16,19}$", message = "银行卡号格式不正确") + private String bankCard; + + /** + * 家庭住址(加密字段) + */ + @Encrypted + @Size(max = 200, message = "地址长度不能超过200字符") + private String address; + + /** + * 年龄 + */ + private Integer age; + + /** + * 性别 + */ + private String gender; + + /** + * 职业 + */ + private String occupation; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + + /** + * 是否启用 + */ + private Boolean enabled; + + /** + * 备注 + */ + private String remark; + + /** + * 重载toString方法,避免敏感信息泄露 + */ + @Override + public String toString() { + return "User{" + + "id=" + id + + ", username='" + username + '\'' + + ", phone='" + maskPhone(phone) + '\'' + + ", idCard='" + maskIdCard(idCard) + '\'' + + ", email='" + maskEmail(email) + '\'' + + ", bankCard='" + maskBankCard(bankCard) + '\'' + + ", address='" + maskAddress(address) + '\'' + + ", age=" + age + + ", gender='" + gender + '\'' + + ", occupation='" + occupation + '\'' + + ", createTime=" + createTime + + ", updateTime=" + updateTime + + ", enabled=" + enabled + + ", remark='" + remark + '\'' + + '}'; + } + + /** + * 手机号脱敏 + */ + private String maskPhone(String phone) { + if (phone == null || phone.length() < 11) { + return phone; + } + return phone.substring(0, 3) + "****" + phone.substring(7); + } + + /** + * 身份证号脱敏 + */ + private String maskIdCard(String idCard) { + if (idCard == null || idCard.length() < 18) { + return idCard; + } + return idCard.substring(0, 6) + "********" + idCard.substring(14); + } + + /** + * 邮箱脱敏 + */ + private String maskEmail(String email) { + if (email == null || !email.contains("@")) { + return email; + } + int atIndex = email.indexOf("@"); + String prefix = email.substring(0, atIndex); + String suffix = email.substring(atIndex); + + if (prefix.length() <= 3) { + return prefix.charAt(0) + "***" + suffix; + } + return prefix.substring(0, 3) + "***" + suffix; + } + + /** + * 银行卡号脱敏 + */ + private String maskBankCard(String bankCard) { + if (bankCard == null || bankCard.length() < 8) { + return bankCard; + } + return bankCard.substring(0, 4) + " **** **** " + bankCard.substring(bankCard.length() - 4); + } + + /** + * 地址脱敏 + */ + private String maskAddress(String address) { + if (address == null || address.length() <= 10) { + return address; + } + return address.substring(0, 6) + "******"; + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/handler/EncryptTypeHandler.java b/springboot-column-encryption/src/main/java/com/example/encryption/handler/EncryptTypeHandler.java new file mode 100644 index 0000000..5c3edbc --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/handler/EncryptTypeHandler.java @@ -0,0 +1,168 @@ +package com.example.encryption.handler; + +import com.example.encryption.annotation.Encrypted; +import com.example.encryption.util.CryptoUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * MyBatis 字段级加密 TypeHandler + * + * 功能: + * - 自动检测字段是否标记了 @Encrypted 注解 + * - 写入数据库时自动加密 + * - 从数据库读取时自动解密 + * - 支持查询参数加密处理 + */ +@Slf4j +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(String.class) +public class EncryptTypeHandler extends BaseTypeHandler { + + /** + * 设置参数时进行加密 + * 这个方法在 INSERT/UPDATE 操作时被调用 + */ + @Override + public void setNonNullParameter(PreparedStatement ps, int i, String value, JdbcType jdbcType) throws SQLException { + try { + // 如果值为空,直接使用 + if (value == null || value.isEmpty()) { + ps.setString(i, value); + return; + } + + // 检查是否已经是加密格式,避免重复加密 + if (CryptoUtil.isEncrypted(value)) { + log.debug("字段已经是加密格式,跳过加密: 位置={}", i); + ps.setString(i, value); + return; + } + + // 加密后设置参数 + String encrypted = CryptoUtil.encrypt(value); + ps.setString(i, encrypted); + + log.info("🔐 TypeHandler参数加密成功: 位置={}, 原始长度={}, 加密后长度={}", i, value.length(), encrypted.length()); + + } catch (Exception e) { + log.error("❌ TypeHandler参数加密失败: 位置={}, 值={}", i, value, e); + // 加密失败时使用原始值,避免数据丢失 + ps.setString(i, value); + } + } + + /** + * 从 ResultSet 通过列名获取值时进行解密 + */ + @Override + public String getNullableResult(ResultSet rs, String columnName) throws SQLException { + try { + String value = rs.getString(columnName); + return decryptValue(value, columnName); + } catch (Exception e) { + log.error("解密失败: 列名={}", columnName, e); + return rs.getString(columnName); + } + } + + /** + * 从 ResultSet 通过列索引获取值时进行解密 + */ + @Override + public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + try { + String value = rs.getString(columnIndex); + return decryptValue(value, "索引" + columnIndex); + } catch (Exception e) { + log.error("解密失败: 列索引={}", columnIndex, e); + return rs.getString(columnIndex); + } + } + + /** + * 从 CallableStatement 获取值时进行解密 + */ + @Override + public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + try { + String value = cs.getString(columnIndex); + return decryptValue(value, "存储过程索引" + columnIndex); + } catch (Exception e) { + log.error("解密失败: 存储过程索引={}", columnIndex, e); + return cs.getString(columnIndex); + } + } + + /** + * 解密值的统一方法 + */ + private String decryptValue(String value, String source) { + if (value == null || value.isEmpty()) { + return value; + } + + try { + // 检查是否为加密格式 + if (!CryptoUtil.isEncrypted(value)) { + log.debug("值不是加密格式,跳过解密: 来源={}", source); + return value; + } + + String decrypted = CryptoUtil.decrypt(value); + log.info("🔓 TypeHandler值解密成功: 来源={}, 加密长度={}, 解密后长度={}", source, value.length(), decrypted.length()); + return decrypted; + + } catch (Exception e) { + log.error("❌ TypeHandler解密失败: 来源={}, 值前缀={}", source, + value.length() > 10 ? value.substring(0, 10) : value, e); + // 解密失败时返回原始值 + return value; + } + } + + /** + * 检查字段是否应该被加密 + * 这个方法主要用于调试和日志记录 + */ + public static boolean shouldEncrypt(Object obj, String fieldName) { + if (obj == null || fieldName == null) { + return false; + } + + try { + Class clazz = obj.getClass(); + java.lang.reflect.Field field = findField(clazz, fieldName); + return field != null && field.isAnnotationPresent(Encrypted.class); + } catch (Exception e) { + log.debug("检查字段加密注解时出错: 对象类型={}, 字段名={}", + obj.getClass().getSimpleName(), fieldName, e); + return false; + } + } + + /** + * 递归查找字段,包括父类 + */ + private static java.lang.reflect.Field findField(Class clazz, String fieldName) { + Class currentClass = clazz; + while (currentClass != null && currentClass != Object.class) { + try { + java.lang.reflect.Field field = currentClass.getDeclaredField(fieldName); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException e) { + currentClass = currentClass.getSuperclass(); + } + } + return null; + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/interceptor/EncryptionInterceptor.java b/springboot-column-encryption/src/main/java/com/example/encryption/interceptor/EncryptionInterceptor.java new file mode 100644 index 0000000..f2068a1 --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/interceptor/EncryptionInterceptor.java @@ -0,0 +1,275 @@ +package com.example.encryption.interceptor; + +import com.example.encryption.annotation.Encrypted; +import com.example.encryption.util.CryptoUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.plugin.*; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; + +/** + * MyBatis 加密拦截器 + * + * 功能: + * - 拦截 INSERT 和 UPDATE 操作,自动加密 @Encrypted 注解字段 + * - 拦截查询结果,自动解密 @Encrypted 注解字段 + * - 使用缓存提高性能 + */ +@Slf4j +@Intercepts({ + @Signature( + type = Executor.class, + method = "update", + args = {MappedStatement.class, Object.class} + ), + @Signature( + type = Executor.class, + method = "query", + args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} + ) +}) +public class EncryptionInterceptor implements Interceptor { + + /** + * 字段加密缓存 + */ + private final Map encryptionCache = new ConcurrentHashMap<>(); + + @Override + public Object intercept(Invocation invocation) throws Throwable { + MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0]; + + // 根据参数数量获取parameter对象 + Object parameter = null; + if (invocation.getArgs().length > 1) { + parameter = invocation.getArgs()[1]; + } + + String methodName = invocation.getMethod().getName(); + + log.info("🔍 MyBatis拦截器执行: {}, 方法: {}, 参数数量: {}", mappedStatement.getId(), methodName, invocation.getArgs().length); + + // 只处理 update 方法(包括 INSERT、UPDATE、DELETE) + if ("update".equals(methodName)) { + // 处理 INSERT/UPDATE 操作的加密 + if (parameter != null) { + log.info("🔒 MyBatis拦截器处理加密参数: {}", parameter.getClass().getSimpleName()); + encryptParameter(parameter); + log.info("✅ MyBatis拦截器加密处理完成"); + } else { + log.debug("🔒 MyBatis拦截器跳过null参数"); + } + } + + // 继续执行原始操作 + Object result = invocation.proceed(); + + // 处理查询结果的解密(只对query方法) + if ("query".equals(methodName)) { + if (result != null) { + log.info("🔓 处理查询结果解密: {}", result.getClass().getSimpleName()); + + if (result instanceof List) { + @SuppressWarnings("unchecked") + List list = (List) result; + log.info("🔓 解密列表,包含 {} 个元素", list.size()); + for (Object item : list) { + decryptObject(item); + } + } else { + decryptObject(result); + } + } + } + + return result; + } + + /** + * 加密参数对象中标记了 @Encrypted 注解的字段 + * 注意:这里我们只处理实体对象,不处理单个参数值(单个参数值由 TypeHandler 处理) + */ + private void encryptParameter(Object parameter) { + if (parameter == null) { + return; + } + + try { + Class clazz = parameter.getClass(); + + // 跳过基本类型、Map、和集合类型 - 这些通常作为查询参数,由 TypeHandler 处理 + if (isBasicType(clazz) || parameter instanceof Map || parameter instanceof java.util.Collection) { + log.debug("跳过基本类型、Map或集合参数: {}", clazz.getSimpleName()); + return; + } + + // 只处理实体对象(包含 @Encrypted 注解的类) + boolean hasEncryptedFields = false; + Class currentClass = clazz; + while (currentClass != null && currentClass != Object.class) { + java.lang.reflect.Field[] fields = currentClass.getDeclaredFields(); + for (java.lang.reflect.Field field : fields) { + if (field.isAnnotationPresent(Encrypted.class)) { + hasEncryptedFields = true; + break; + } + } + currentClass = currentClass.getSuperclass(); + } + + if (hasEncryptedFields) { + log.info("🔒 拦截器发现实体对象,开始加密: {}", clazz.getSimpleName()); + encryptFields(parameter, clazz); + } else { + log.debug("对象没有加密字段,跳过处理: {}", clazz.getSimpleName()); + } + + } catch (Exception e) { + log.error("❌ 加密参数失败: {}", parameter.getClass().getSimpleName(), e); + } + } + + /** + * 递归加密对象的字段 + */ + private void encryptFields(Object obj, Class clazz) { + log.info("🔒 拦截器开始加密对象: {}", clazz.getSimpleName()); + Class currentClass = clazz; + int encryptedCount = 0; + + while (currentClass != null && currentClass != Object.class) { + Field[] fields = currentClass.getDeclaredFields(); + for (Field field : fields) { + try { + field.setAccessible(true); + Object value = field.get(obj); + + if (value instanceof String) { + String fieldName = field.getName(); + String cacheKey = clazz.getName() + "." + fieldName; + + // 检查缓存 + Boolean shouldEncrypt = encryptionCache.get(cacheKey); + if (shouldEncrypt == null) { + shouldEncrypt = field.isAnnotationPresent(Encrypted.class); + encryptionCache.put(cacheKey, shouldEncrypt); + log.debug("字段 {}.{} 加密状态: {}", clazz.getSimpleName(), fieldName, shouldEncrypt); + } + + if (shouldEncrypt) { + String stringValue = (String) value; + if (stringValue != null && !stringValue.isEmpty() && !CryptoUtil.isEncrypted(stringValue)) { + log.info("🔐 拦截器正在加密字段: {}.{} = {}", clazz.getSimpleName(), fieldName, stringValue); + String encryptedValue = CryptoUtil.encrypt(stringValue); + field.set(obj, encryptedValue); + encryptedCount++; + log.info("✅ 拦截器加密完成: {}.{} -> {}", clazz.getSimpleName(), fieldName, encryptedValue.substring(0, Math.min(20, encryptedValue.length())) + "..."); + } else if (stringValue != null && stringValue.isEmpty()) { + log.debug("跳过空字段: {}.{}", clazz.getSimpleName(), fieldName); + } else if (stringValue != null && CryptoUtil.isEncrypted(stringValue)) { + log.debug("字段已加密,跳过: {}.{}", clazz.getSimpleName(), fieldName); + } + } + } + } catch (Exception e) { + log.error("❌ 拦截器处理字段失败: {}", field.getName(), e); + } + } + currentClass = currentClass.getSuperclass(); + } + log.info("🎉 拦截器对象加密完成: {}, 共加密 {} 个字段", clazz.getSimpleName(), encryptedCount); + } + + /** + * 解密对象中标记了 @Encrypted 注解的字段 + */ + private void decryptObject(Object obj) { + if (obj == null) { + return; + } + + try { + Class clazz = obj.getClass(); + + // 跳过基本类型和Map + if (isBasicType(clazz) || obj instanceof Map) { + return; + } + + // 递归处理字段 + decryptFields(obj, clazz); + + } catch (Exception e) { + log.error("解密对象失败: {}", obj.getClass().getSimpleName(), e); + } + } + + /** + * 递归解密对象的字段 + */ + private void decryptFields(Object obj, Class clazz) { + Class currentClass = clazz; + while (currentClass != null && currentClass != Object.class) { + Field[] fields = currentClass.getDeclaredFields(); + for (Field field : fields) { + try { + field.setAccessible(true); + Object value = field.get(obj); + + if (value instanceof String) { + String fieldName = field.getName(); + String cacheKey = clazz.getName() + "." + fieldName; + + // 检查缓存 + Boolean shouldEncrypt = encryptionCache.get(cacheKey); + if (shouldEncrypt == null) { + shouldEncrypt = field.isAnnotationPresent(Encrypted.class); + encryptionCache.put(cacheKey, shouldEncrypt); + } + + if (shouldEncrypt) { + String stringValue = (String) value; + if (stringValue != null && !stringValue.isEmpty() && CryptoUtil.isEncrypted(stringValue)) { + String decryptedValue = CryptoUtil.decrypt(stringValue); + field.set(obj, decryptedValue); + log.debug("解密字段: {}.{} -> {}", clazz.getSimpleName(), fieldName, decryptedValue.substring(0, Math.min(10, decryptedValue.length()))); + } + } + } + } catch (Exception e) { + log.error("处理字段失败: {}", field.getName(), e); + } + } + currentClass = currentClass.getSuperclass(); + } + } + + /** + * 检查是否为基本类型 + */ + private boolean isBasicType(Class clazz) { + return clazz.isPrimitive() || + clazz == String.class || + Number.class.isAssignableFrom(clazz) || + clazz == Boolean.class || + clazz == Character.class; + } + + @Override + public Object plugin(Object target) { + return Plugin.wrap(target, this); + } + + @Override + public void setProperties(Properties properties) { + // 初始化属性 + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/mapper/UserMapper.java b/springboot-column-encryption/src/main/java/com/example/encryption/mapper/UserMapper.java new file mode 100644 index 0000000..15db450 --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/mapper/UserMapper.java @@ -0,0 +1,181 @@ +package com.example.encryption.mapper; + +import com.example.encryption.entity.User; +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Optional; + +/** + * 用户数据访问层 + * + * 注意: + * 1. 不需要手动指定 TypeHandler,加密会自动处理 + * 2. 查询条件中的加密字段需要在应用层处理 + * 3. 支持复杂的查询操作 + */ +@Mapper +public interface UserMapper { + + /** + * 插入用户 + * 加密字段会自动加密存储 + */ + @Insert("INSERT INTO users (username, phone, id_card, email, bank_card, address, age, gender, occupation, create_time, update_time, enabled, remark) " + + "VALUES (#{username}, #{phone}, #{idCard}, #{email}, #{bankCard}, #{address}, #{age}, #{gender}, #{occupation}, " + + "#{createTime}, #{updateTime}, #{enabled}, #{remark})") + @Options(useGeneratedKeys = true, keyProperty = "id") + int insert(User user); + + /** + * 批量插入用户 + */ + @Insert({ + "" + }) + int batchInsert(@Param("users") List users); + + /** + * 根据ID查询用户 + * 加密字段会自动解密返回 + */ + @Select("SELECT id, username, phone, id_card, email, bank_card, address, age, gender, occupation, create_time, update_time, enabled, remark " + + "FROM users WHERE id = #{id}") + Optional findById(Long id); + + /** + * 根据用户名查询用户 + */ + @Select("SELECT id, username, phone, id_card, email, bank_card, address, age, gender, occupation, create_time, update_time, enabled, remark " + + "FROM users WHERE username = #{username}") + Optional findByUsername(String username); + + /** + * 查询所有用户 + */ + @Select("SELECT id, username, phone, id_card, email, bank_card, address, age, gender, occupation, create_time, update_time, enabled, remark " + + "FROM users ORDER BY create_time DESC") + List findAll(); + + /** + * 根据手机号查询用户(注意:由于手机号加密,这里需要在应用层处理) + */ + @Select("SELECT id, username, phone, id_card, email, bank_card, address, age, gender, occupation, create_time, update_time, enabled, remark " + + "FROM users WHERE phone = #{encryptedPhone}") + Optional findByPhone(String encryptedPhone); + + /** + * 根据邮箱查询用户 + */ + @Select("SELECT id, username, phone, id_card, email, bank_card, address, age, gender, occupation, create_time, update_time, enabled, remark " + + "FROM users WHERE email = #{encryptedEmail}") + Optional findByEmail(String encryptedEmail); + + /** + * 分页查询用户 + */ + @Select("SELECT id, username, phone, id_card, email, bank_card, address, age, gender, occupation, create_time, update_time, enabled, remark " + + "FROM users ORDER BY create_time DESC LIMIT #{offset}, #{limit}") + List findByPage(@Param("offset") int offset, @Param("limit") int limit); + + /** + * 统计用户总数 + */ + @Select("SELECT COUNT(*) FROM users") + long count(); + + /** + * 更新用户信息 + */ + @Update("UPDATE users SET username = #{username}, phone = #{phone}, id_card = #{idCard}, email = #{email}, " + + "bank_card = #{bankCard}, address = #{address}, age = #{age}, gender = #{gender}, " + + "occupation = #{occupation}, update_time = #{updateTime}, enabled = #{enabled}, remark = #{remark} " + + "WHERE id = #{id}") + int update(User user); + + /** + * 更新部分用户信息 + */ + @Update({ + "" + }) + int updateSelective(User user); + + /** + * 删除用户 + */ + @Delete("DELETE FROM users WHERE id = #{id}") + int deleteById(Long id); + + /** + * 批量删除用户 + */ + @Delete({ + "" + }) + int batchDeleteByIds(@Param("ids") List ids); + + /** + * 根据条件查询用户数量 + */ + @Select({ + "" + }) + long countByCondition(@Param("username") String username, + @Param("enabled") Boolean enabled, + @Param("age") Integer age, + @Param("gender") String gender); + + /** + * 根据条件查询用户列表 + */ + @Select({ + "" + }) + List findByCondition(@Param("username") String username, + @Param("enabled") Boolean enabled, + @Param("age") Integer age, + @Param("gender") String gender, + @Param("offset") int offset, + @Param("limit") int limit); +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/service/UserService.java b/springboot-column-encryption/src/main/java/com/example/encryption/service/UserService.java new file mode 100644 index 0000000..ddae5f9 --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/service/UserService.java @@ -0,0 +1,281 @@ +package com.example.encryption.service; + +import com.example.encryption.entity.User; +import com.example.encryption.mapper.UserMapper; +import com.example.encryption.util.CryptoUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 用户服务层 + * + * 功能: + * - 提供用户相关的业务逻辑 + * - 处理加密字段的查询逻辑 + * - 事务管理 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserMapper userMapper; + + /** + * 创建用户 + */ + @Transactional + public User createUser(User user) { + log.info("创建用户: {}", user.getUsername()); + + // 设置时间戳 + LocalDateTime now = LocalDateTime.now(); + user.setCreateTime(now); + user.setUpdateTime(now); + user.setEnabled(true); + + // 插入数据库(加密字段会自动加密) + int result = userMapper.insert(user); + if (result > 0) { + log.info("用户创建成功,ID: {}", user.getId()); + return user; + } else { + throw new RuntimeException("用户创建失败"); + } + } + + /** + * 批量创建用户 + */ + @Transactional + public List createUsers(List users) { + log.info("批量创建用户,数量: {}", users.size()); + + // 设置时间戳 + LocalDateTime now = LocalDateTime.now(); + users.forEach(user -> { + user.setCreateTime(now); + user.setUpdateTime(now); + user.setEnabled(true); + }); + + // 批量插入 + int result = userMapper.batchInsert(users); + if (result == users.size()) { + log.info("批量用户创建成功,数量: {}", result); + return users; + } else { + throw new RuntimeException("批量用户创建失败,预期: " + users.size() + ",实际: " + result); + } + } + + /** + * 根据ID查询用户 + */ + public Optional getUserById(Long id) { + log.debug("查询用户,ID: {}", id); + return userMapper.findById(id); + } + + /** + * 根据用户名查询用户 + */ + public Optional getUserByUsername(String username) { + log.debug("查询用户,用户名: {}", username); + return userMapper.findByUsername(username); + } + + /** + * 根据手机号查询用户 + * 注意:由于手机号在数据库中是加密存储的,需要先加密再查询 + */ + public Optional getUserByPhone(String phone) { + log.debug("查询用户,手机号: {}", phone); + + try { + // 先加密手机号,再查询 + String encryptedPhone = CryptoUtil.encrypt(phone); + return userMapper.findByPhone(encryptedPhone); + } catch (Exception e) { + log.error("查询用户失败,手机号: {}", phone, e); + return Optional.empty(); + } + } + + /** + * 根据邮箱查询用户 + */ + public Optional getUserByEmail(String email) { + log.debug("查询用户,邮箱: {}", email); + + try { + // 先加密邮箱,再查询 + String encryptedEmail = CryptoUtil.encrypt(email); + return userMapper.findByEmail(encryptedEmail); + } catch (Exception e) { + log.error("查询用户失败,邮箱: {}", email, e); + return Optional.empty(); + } + } + + /** + * 查询所有用户 + */ + public List getAllUsers() { + log.debug("查询所有用户"); + return userMapper.findAll(); + } + + /** + * 分页查询用户 + */ + public List getUsersByPage(int page, int size) { + log.debug("分页查询用户,页码: {}, 每页大小: {}", page, size); + int offset = (page - 1) * size; + return userMapper.findByPage(offset, size); + } + + /** + * 更新用户信息 + */ + @Transactional + public User updateUser(User user) { + log.info("更新用户,ID: {}", user.getId()); + + // 设置更新时间 + user.setUpdateTime(LocalDateTime.now()); + + // 更新数据库(加密字段会自动加密) + int result = userMapper.update(user); + if (result > 0) { + log.info("用户更新成功,ID: {}", user.getId()); + return user; + } else { + throw new RuntimeException("用户更新失败,ID: " + user.getId()); + } + } + + /** + * 部分更新用户信息 + */ + @Transactional + public User updateUserSelective(User user) { + log.info("部分更新用户,ID: {}", user.getId()); + + // 设置更新时间 + user.setUpdateTime(LocalDateTime.now()); + + // 更新数据库(加密字段会自动加密) + int result = userMapper.updateSelective(user); + if (result > 0) { + log.info("用户部分更新成功,ID: {}", user.getId()); + // 重新查询完整的用户信息 + return getUserById(user.getId()) + .orElseThrow(() -> new RuntimeException("更新后查询用户失败,ID: " + user.getId())); + } else { + throw new RuntimeException("用户部分更新失败,ID: " + user.getId()); + } + } + + /** + * 删除用户 + */ + @Transactional + public boolean deleteUser(Long id) { + log.info("删除用户,ID: {}", id); + int result = userMapper.deleteById(id); + boolean success = result > 0; + if (success) { + log.info("用户删除成功,ID: {}", id); + } else { + log.warn("用户删除失败,ID: {}", id); + } + return success; + } + + /** + * 批量删除用户 + */ + @Transactional + public int batchDeleteUsers(List ids) { + log.info("批量删除用户,数量: {}", ids.size()); + int result = userMapper.batchDeleteByIds(ids); + log.info("批量删除用户完成,成功: {}", result); + return result; + } + + /** + * 统计用户总数 + */ + public long countUsers() { + log.debug("统计用户总数"); + return userMapper.count(); + } + + /** + * 根据条件统计用户数量 + */ + public long countUsersByCondition(String username, Boolean enabled, Integer age, String gender) { + log.debug("根据条件统计用户数量"); + return userMapper.countByCondition(username, enabled, age, gender); + } + + /** + * 根据条件查询用户 + */ + public List searchUsers(String username, Boolean enabled, Integer age, String gender, int page, int size) { + log.debug("根据条件查询用户"); + int offset = (page - 1) * size; + return userMapper.findByCondition(username, enabled, age, gender, offset, size); + } + + /** + * 启用/禁用用户 + */ + @Transactional + public User toggleUserStatus(Long id, boolean enabled) { + log.info("切换用户状态,ID: {}, 状态: {}", id, enabled); + + User user = getUserById(id) + .orElseThrow(() -> new RuntimeException("用户不存在,ID: " + id)); + + user.setEnabled(enabled); + user.setUpdateTime(LocalDateTime.now()); + + return updateUserSelective(user); + } + + /** + * 检查用户是否存在 + */ + public boolean userExists(Long id) { + return getUserById(id).isPresent(); + } + + /** + * 检查用户名是否存在 + */ + public boolean usernameExists(String username) { + return getUserByUsername(username).isPresent(); + } + + /** + * 检查手机号是否存在 + */ + public boolean phoneExists(String phone) { + return getUserByPhone(phone).isPresent(); + } + + /** + * 检查邮箱是否存在 + */ + public boolean emailExists(String email) { + return getUserByEmail(email).isPresent(); + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/java/com/example/encryption/util/CryptoUtil.java b/springboot-column-encryption/src/main/java/com/example/encryption/util/CryptoUtil.java new file mode 100644 index 0000000..57f5a46 --- /dev/null +++ b/springboot-column-encryption/src/main/java/com/example/encryption/util/CryptoUtil.java @@ -0,0 +1,156 @@ +package com.example.encryption.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.*; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * AES-GCM 加密工具类 + * + * 特性: + * - 使用 AES-GCM 算法,提供强加密和完整性验证 + * - 每次加密使用随机 IV,确保相同明文产生不同密文 + * - 密文格式: Base64(IV):Base64(EncryptedData) + * - 支持密钥热更新 + */ +@Slf4j +@Component +public class CryptoUtil { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; // GCM 推荐的 IV 长度 + private static final int GCM_TAG_LENGTH = 128; // GCM 认证标签长度 + + // 默认密钥 - 实际项目中应该从安全的密钥管理系统获取 + private static final String DEFAULT_KEY = "MySecretKey12345MySecretKey12345"; + + private static SecretKeySpec secretKey; + + static { + initKey(DEFAULT_KEY); + } + + /** + * 初始化密钥 + */ + public static void initKey(String base64Key) { + byte[] keyBytes; + try { + keyBytes = base64Key.getBytes(StandardCharsets.UTF_8); + // 确保密钥长度为 32 字节 (256 位) + byte[] finalKeyBytes = new byte[32]; + System.arraycopy(keyBytes, 0, finalKeyBytes, 0, Math.min(keyBytes.length, 32)); + secretKey = new SecretKeySpec(finalKeyBytes, "AES"); + } catch (Exception e) { + log.error("密钥初始化失败", e); + throw new RuntimeException("密钥初始化失败", e); + } + } + + /** + * 加密明文字符串 + * + * @param plainText 明文 + * @return 格式为 "Base64(IV):Base64(EncryptedData)" 的密文 + */ + public static String encrypt(String plainText) { + if (plainText == null || plainText.isEmpty()) { + return plainText; + } + + try { + // 生成随机 IV + byte[] iv = new byte[IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + // 初始化加密器 + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec); + + // 执行加密 + byte[] encryptedData = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + // 组合 IV 和加密数据,使用 Base64 编码 + String ivBase64 = Base64.getEncoder().encodeToString(iv); + String encryptedBase64 = Base64.getEncoder().encodeToString(encryptedData); + + return ivBase64 + ":" + encryptedBase64; + + } catch (Exception e) { + log.error("加密失败: {}", e.getMessage(), e); + throw new RuntimeException("加密失败", e); + } + } + + /** + * 解密密文字符串 + * + * @param cipherText 格式为 "Base64(IV):Base64(EncryptedData)" 的密文 + * @return 明文 + */ + public static String decrypt(String cipherText) { + if (cipherText == null || cipherText.isEmpty()) { + return cipherText; + } + + try { + // 分离 IV 和加密数据 + String[] parts = cipherText.split(":"); + if (parts.length != 2) { + throw new IllegalArgumentException("密文格式错误"); + } + + byte[] iv = Base64.getDecoder().decode(parts[0]); + byte[] encryptedData = Base64.getDecoder().decode(parts[1]); + + // 初始化解密器 + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec); + + // 执行解密 + byte[] decryptedData = cipher.doFinal(encryptedData); + + return new String(decryptedData, StandardCharsets.UTF_8); + + } catch (Exception e) { + log.error("解密失败: {}", e.getMessage(), e); + throw new RuntimeException("解密失败", e); + } + } + + /** + * 检查字符串是否为加密格式 + */ + public static boolean isEncrypted(String text) { + if (text == null || text.isEmpty()) { + return false; + } + return text.contains(":") && text.split(":").length == 2; + } + + /** + * 热更新密钥 + */ + public static void updateKey(String newKey) { + log.info("正在更新加密密钥..."); + initKey(newKey); + log.info("加密密钥更新完成"); + } + + /** + * 生成随机密钥 + */ + public static String generateRandomKey() { + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } +} \ No newline at end of file diff --git a/springboot-column-encryption/src/main/resources/application.yml b/springboot-column-encryption/src/main/resources/application.yml new file mode 100644 index 0000000..08a5ce1 --- /dev/null +++ b/springboot-column-encryption/src/main/resources/application.yml @@ -0,0 +1,78 @@ +server: + port: 8080 + servlet: + context-path: / + encoding: + charset: UTF-8 + enabled: true + +spring: + application: + name: springboot-column-encryption + + # 数据库配置 + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: password + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + idle-timeout: 300000 + connection-timeout: 20000 + + # H2 控制台配置 + h2: + console: + enabled: true + path: /h2-console + settings: + web-allow-others: true + + # SQL 初始化配置 - 只初始化表结构 + sql: + init: + mode: always + schema-locations: classpath:sql/schema.sql + encoding: UTF-8 + + # Jackson 配置 + jackson: + default-property-inclusion: non_null + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + +# MyBatis 配置 +mybatis: + mapper-locations: classpath:mapper/*.xml + type-aliases-package: com.example.encryption.entity + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + # 启用自动映射 + auto-mapping-behavior: partial + # 启用延迟加载 + lazy-loading-enabled: true + # 设置超时时间 + default-statement-timeout: 30 + # 设置获取数据的策略 + default-fetch-size: 100 + +# 日志配置 +logging: + level: + com.example.encryption: INFO + com.example.encryption.interceptor: INFO + org.apache.ibatis: DEBUG + org.springframework.web: INFO + root: INFO + pattern: + console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n" + +# 字段级加密配置 +encryption: + enabled: true + algorithm: AES-GCM + secret-key: MySecretKey12345MySecretKey12345 \ No newline at end of file diff --git a/springboot-column-encryption/src/main/resources/sql/data.sql b/springboot-column-encryption/src/main/resources/sql/data.sql new file mode 100644 index 0000000..0bf2642 --- /dev/null +++ b/springboot-column-encryption/src/main/resources/sql/data.sql @@ -0,0 +1,32 @@ +-- 示例数据(插入前会自动加密,查询时会自动解密) +-- 这些数据展示 @Encrypted 注解的透明加解密效果 + +-- 清空现有数据 +DELETE FROM users; + +-- 插入初始化示例用户数据 +INSERT INTO users (username, phone, id_card, email, bank_card, address, age, gender, occupation, enabled, remark) VALUES +('数据库初始用户', '13899990001', '110101199009099999', 'db.init@example.com', '6222021234567899999', '北京市海淀区中关村大街1号', 35, '男', '系统管理员', TRUE, '数据库初始化用户 - 展示加密效果'), +('示例用户小明', '13899990002', '110101199010101010', 'xiaoming@example.com', '6222021234567898888', '上海市浦东新区世纪大道200号', 26, '男', 'Java开发工程师', TRUE, '数据库初始化用户 - 展示加密效果'), +('示例用户小红', '13899990003', '110101199011111111', 'xiaohong@example.com', '6222021234567897777', '广州市天河区珠江新城100号', 24, '女', '前端开发工程师', TRUE, '数据库初始化用户 - 展示加密效果'); + +-- 查询确认数据插入 +SELECT + id, + username, + phone AS encrypted_phone, + id_card AS encrypted_id_card, + email AS encrypted_email, + bank_card AS encrypted_bank_card, + address AS encrypted_address, + age, + gender, + occupation, + enabled, + remark, + create_time +FROM users +ORDER BY create_time; + +-- 注意:上面的查询结果中,phone, id_card, email, bank_card, address 字段显示的是加密后的密文 +-- 当通过 MyBatis 查询时,这些字段会自动解密为明文返回给应用层 \ No newline at end of file diff --git a/springboot-column-encryption/src/main/resources/sql/schema.sql b/springboot-column-encryption/src/main/resources/sql/schema.sql new file mode 100644 index 0000000..4970a17 --- /dev/null +++ b/springboot-column-encryption/src/main/resources/sql/schema.sql @@ -0,0 +1,28 @@ +-- 用户表结构 (H2数据库兼容版本) +-- 注意:加密字段在数据库中存储为 VARCHAR 类型,应用层会自动进行加解密处理 + +DROP TABLE IF EXISTS users; + +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + phone VARCHAR(500), + id_card VARCHAR(500), + email VARCHAR(500), + bank_card VARCHAR(500), + address VARCHAR(500), + age INT, + gender VARCHAR(10), + occupation VARCHAR(100), + enabled BOOLEAN DEFAULT TRUE, + remark VARCHAR(500), + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_phone ON users(phone); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_enabled ON users(enabled); +CREATE INDEX idx_users_create_time ON users(create_time); \ No newline at end of file diff --git a/springboot-column-encryption/src/main/resources/static/index.html b/springboot-column-encryption/src/main/resources/static/index.html new file mode 100644 index 0000000..684f1cc --- /dev/null +++ b/springboot-column-encryption/src/main/resources/static/index.html @@ -0,0 +1,840 @@ + + + + + + Spring Boot 字段级加密演示 + + + + + + + + + +
+ +
+
+
+

系统特性

+
+
+ + 自动字段加密 +
+
+ + 透明加解密 +
+
+ + AES-GCM 加密 +
+
+ + 零代码侵入 +
+
+ + MyBatis 集成 +
+
+ + 可热更新密钥 +
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+
+

+ 添加用户 +

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

+ 批量添加示例数据 +

+ +
+
+ + +
+
+
+

+ 用户列表 +

+
+ + + +
+
+ + +
+ + + + + + + + + + + + + +
用户联系方式个人信息状态操作
+ +
+ + + +
+
+
+ + +
+

+ 加密效果演示 +

+
+
+

🔐 加密字段(存储在数据库中)

+
+

添加用户后,这里将显示实际的加密数据...

+
+
+
+

🔓 解密字段(应用中显示)

+
+

应用层自动解密,业务代码无感知...

+
+
+
+
+
+ + +
+ + + + + \ No newline at end of file diff --git a/springboot-dfa/README.md b/springboot-dfa/README.md new file mode 100644 index 0000000..12ab724 --- /dev/null +++ b/springboot-dfa/README.md @@ -0,0 +1,21 @@ +# DFA 敏感词过滤系统 + +基于 DFA (Deterministic Finite Automaton) 算法和 Trie 树数据结构实现的敏感词过滤示例。 + +## 🚀 项目特性 + +- **高效算法**: 基于 DFA 算法,时间复杂度 O(n) +- **前缀共享**: 使用 Trie 树优化内存使用 +- **RESTful API**: 标准化的 API 接口 + +## 📋 功能列表 + +### 核心功能 +- ✅ 敏感词检测 +- ✅ 文本过滤 +- ✅ 批量敏感词管理 + +### 管理功能 +- ✅ 添加单个敏感词 +- ✅ 批量添加敏感词 +- ✅ 动态词库更新 \ No newline at end of file diff --git a/springboot-dfa/pom.xml b/springboot-dfa/pom.xml new file mode 100644 index 0000000..4f3a24d --- /dev/null +++ b/springboot-dfa/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.example + springboot-dfa + 1.0.0 + Spring Boot DFA Sensitive Word Filter + DFA敏感词过滤系统 + + + 17 + 17 + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.projectlombok + lombok + true + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + \ No newline at end of file diff --git a/springboot-dfa/src/main/java/com/example/dfa/DfaApplication.java b/springboot-dfa/src/main/java/com/example/dfa/DfaApplication.java new file mode 100644 index 0000000..d26deff --- /dev/null +++ b/springboot-dfa/src/main/java/com/example/dfa/DfaApplication.java @@ -0,0 +1,29 @@ +package com.example.dfa; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@SpringBootApplication +public class DfaApplication { + + public static void main(String[] args) { + SpringApplication.run(DfaApplication.class, args); + } + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .maxAge(3600); + } + }; + } +} \ No newline at end of file diff --git a/springboot-dfa/src/main/java/com/example/dfa/controller/IndexController.java b/springboot-dfa/src/main/java/com/example/dfa/controller/IndexController.java new file mode 100644 index 0000000..6b51124 --- /dev/null +++ b/springboot-dfa/src/main/java/com/example/dfa/controller/IndexController.java @@ -0,0 +1,19 @@ +package com.example.dfa.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * 首页控制器 + */ +@Controller +public class IndexController { + + /** + * 首页 + */ + @GetMapping("/") + public String index() { + return "forward:/index.html"; + } +} \ No newline at end of file diff --git a/springboot-dfa/src/main/java/com/example/dfa/controller/SensitiveWordController.java b/springboot-dfa/src/main/java/com/example/dfa/controller/SensitiveWordController.java new file mode 100644 index 0000000..300e6b1 --- /dev/null +++ b/springboot-dfa/src/main/java/com/example/dfa/controller/SensitiveWordController.java @@ -0,0 +1,243 @@ +package com.example.dfa.controller; + +import com.example.dfa.entity.SensitiveWordResult; +import com.example.dfa.service.SensitiveWordService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 敏感词过滤器 API 控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/sensitive-word") +@CrossOrigin(origins = "*") +public class SensitiveWordController { + + @Autowired + private SensitiveWordService sensitiveWordService; + + /** + * 检查文本是否包含敏感词 + */ + @GetMapping("/check") + public ResponseEntity> checkSensitiveWord(@RequestParam String text) { + try { + boolean hasSensitive = sensitiveWordService.containsSensitiveWord(text); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("hasSensitive", hasSensitive); + response.put("text", text); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("检查敏感词失败", e); + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "检查失败: " + e.getMessage() + )); + } + } + + /** + * 过滤文本中的敏感词 + */ + @GetMapping("/filter") + public ResponseEntity> filterText( + @RequestParam String text, + @RequestParam(defaultValue = "*") String replacement) { + try { + SensitiveWordService.FilterResult result = + sensitiveWordService.getFilterResult(text, replacement); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("originalText", result.getOriginalText()); + response.put("filteredText", result.getFilteredText()); + response.put("hasSensitive", result.isHasSensitive()); + response.put("sensitiveWordCount", result.getSensitiveWordCount()); + response.put("sensitiveWords", result.getSensitiveWords()); + response.put("replacement", replacement); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("过滤敏感词失败", e); + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "过滤失败: " + e.getMessage() + )); + } + } + + /** + * 查找文本中的所有敏感词 + */ + @GetMapping("/find-all") + public ResponseEntity> findAllSensitiveWords(@RequestParam String text) { + try { + List sensitiveWords = + sensitiveWordService.findAllSensitiveWords(text); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("text", text); + response.put("sensitiveWords", sensitiveWords); + response.put("count", sensitiveWords.size()); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("查找敏感词失败", e); + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "查找失败: " + e.getMessage() + )); + } + } + + /** + * 添加敏感词到词库 + */ + @PostMapping("/add") + public ResponseEntity> addSensitiveWord(@RequestBody Map request) { + try { + String word = request.get("word"); + if (word == null || word.trim().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "敏感词不能为空" + )); + } + + sensitiveWordService.addSensitiveWord(word); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "敏感词添加成功"); + response.put("word", word); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("添加敏感词失败", e); + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "添加失败: " + e.getMessage() + )); + } + } + + /** + * 批量添加敏感词 + */ + @PostMapping("/add-batch") + public ResponseEntity> addSensitiveWords(@RequestBody Map request) { + try { + @SuppressWarnings("unchecked") + List words = (List) request.get("words"); + + if (words == null || words.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "敏感词列表不能为空" + )); + } + + sensitiveWordService.addSensitiveWords(words); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "批量添加成功"); + response.put("count", words.size()); + response.put("words", words); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("批量添加敏感词失败", e); + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "批量添加失败: " + e.getMessage() + )); + } + } + + /** + * 重新加载敏感词库 + */ + @PostMapping("/reload") + public ResponseEntity> reloadSensitiveWords(@RequestBody Map request) { + try { + @SuppressWarnings("unchecked") + List words = (List) request.get("words"); + + if (words == null || words.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "敏感词列表不能为空" + )); + } + + sensitiveWordService.reloadSensitiveWords(words); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "词库重新加载成功"); + response.put("count", words.size()); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("重新加载敏感词库失败", e); + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "重新加载失败: " + e.getMessage() + )); + } + } + + /** + * 获取系统状态 + */ + @GetMapping("/status") + public ResponseEntity> getSystemStatus() { + try { + Map response = new HashMap<>(); + response.put("success", true); + response.put("status", "running"); + response.put("algorithm", "DFA (Deterministic Finite Automaton)"); + response.put("dataStructure", "Trie Tree"); + response.put("features", List.of( + "高效敏感词检测", + "实时文本过滤", + "批量敏感词管理", + "前缀共享优化", + "线性时间复杂度" + )); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("获取系统状态失败", e); + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "获取状态失败: " + e.getMessage() + )); + } + } + + /** + * 健康检查接口 + */ + @GetMapping("/health") + public ResponseEntity> health() { + Map response = new HashMap<>(); + response.put("status", "UP"); + response.put("timestamp", System.currentTimeMillis()); + response.put("service", "DFA Sensitive Word Filter"); + + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/springboot-dfa/src/main/java/com/example/dfa/entity/SensitiveWordResult.java b/springboot-dfa/src/main/java/com/example/dfa/entity/SensitiveWordResult.java new file mode 100644 index 0000000..0213570 --- /dev/null +++ b/springboot-dfa/src/main/java/com/example/dfa/entity/SensitiveWordResult.java @@ -0,0 +1,33 @@ +package com.example.dfa.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 敏感词检测结果 + */ +@Data +@NoArgsConstructor +public class SensitiveWordResult { + /** + * 敏感词内容 + */ + private String word; + + /** + * 起始位置 + */ + private int start; + + /** + * 结束位置 + */ + private int end; + + public SensitiveWordResult(String word, int start, int end) { + this.word = word; + this.start = start; + this.end = end; + } +} \ No newline at end of file diff --git a/springboot-dfa/src/main/java/com/example/dfa/entity/TrieNode.java b/springboot-dfa/src/main/java/com/example/dfa/entity/TrieNode.java new file mode 100644 index 0000000..0298031 --- /dev/null +++ b/springboot-dfa/src/main/java/com/example/dfa/entity/TrieNode.java @@ -0,0 +1,50 @@ +package com.example.dfa.entity; + +import lombok.Data; + +import java.util.HashMap; +import java.util.Map; + +/** + * Trie 树节点 + * DFA 算法的核心数据结构 + */ +@Data +public class TrieNode { + // 子节点映射:字符 -> Trie节点 + private Map children = new HashMap<>(); + + // 是否为敏感词的结束节点 + private boolean isEnd = false; + + // 完整敏感词内容(便于输出) + private String keyword; + + /** + * 获取子节点 + */ + public TrieNode getChild(char c) { + return children.get(c); + } + + /** + * 添加子节点 + */ + public TrieNode addChild(char c) { + return children.computeIfAbsent(c, k -> new TrieNode()); + } + + /** + * 是否包含指定字符的子节点 + */ + public boolean hasChild(char c) { + return children.containsKey(c); + } + + /** + * 获取所有子节点 + */ + public Map getChildren() { + return children; + } +} \ No newline at end of file diff --git a/springboot-dfa/src/main/java/com/example/dfa/service/SensitiveWordFilter.java b/springboot-dfa/src/main/java/com/example/dfa/service/SensitiveWordFilter.java new file mode 100644 index 0000000..397aaab --- /dev/null +++ b/springboot-dfa/src/main/java/com/example/dfa/service/SensitiveWordFilter.java @@ -0,0 +1,202 @@ +package com.example.dfa.service; + +import com.example.dfa.entity.SensitiveWordResult; +import com.example.dfa.entity.TrieNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * DFA 敏感词过滤器 + * 基于 Trie 树实现的高效敏感词过滤算法 + */ +@Slf4j +@Service +public class SensitiveWordFilter { + + private TrieNode root; + private int minWordLength = 1; + + /** + * 构造函数 + * @param sensitiveWords 敏感词列表 + */ + public SensitiveWordFilter(List sensitiveWords) { + this.root = buildTrie(sensitiveWords); + this.minWordLength = sensitiveWords.stream() + .mapToInt(String::length) + .min() + .orElse(1); + + log.info("DFA敏感词过滤器初始化完成,加载敏感词 {} 个", sensitiveWords.size()); + } + + /** + * 默认构造函数,初始化基础敏感词库 + */ + public SensitiveWordFilter() { + List defaultWords = Arrays.asList( + "apple", "app", "application", "apply", "orange" + ); + this.root = buildTrie(defaultWords); + this.minWordLength = defaultWords.stream() + .mapToInt(String::length) + .min() + .orElse(1); + + log.info("DFA敏感词过滤器初始化完成,加载默认敏感词 {} 个", defaultWords.size()); + } + + /** + * 构建 Trie 树 + */ + private TrieNode buildTrie(List words) { + TrieNode root = new TrieNode(); + for (String word : words) { + if (word == null || word.trim().isEmpty()) { + continue; + } + + word = word.trim().toLowerCase(); + TrieNode node = root; + + for (char c : word.toCharArray()) { + node = node.addChild(c); + } + + node.setEnd(true); + node.setKeyword(word); + } + return root; + } + + /** + * 检查是否包含敏感词 - 核心 DFA 匹配算法 + */ + public boolean containsSensitiveWord(String text) { + if (text == null || text.length() < minWordLength) { + return false; + } + + char[] chars = text.toLowerCase().toCharArray(); + for (int i = 0; i < chars.length; i++) { + if (dfaMatch(chars, i)) { + return true; + } + } + return false; + } + + /** + * DFA 状态转移匹配 + */ + private boolean dfaMatch(char[] chars, int start) { + TrieNode node = root; + + for (int i = start; i < chars.length; i++) { + char c = chars[i]; + + if (!node.hasChild(c)) { + break; // 状态转移失败 + } + + node = node.getChild(c); + + if (node.isEnd()) { + return true; // 到达接受状态 + } + } + return false; + } + + /** + * 查找并替换敏感词 + */ + public String filter(String text, String replacement) { + if (text == null || text.length() < minWordLength) { + return text; + } + + List words = findAllWords(text); + if (words.isEmpty()) { + return text; + } + + // 从后往前替换,避免索引变化问题 + StringBuilder result = new StringBuilder(text); + for (int i = words.size() - 1; i >= 0; i--) { + SensitiveWordResult word = words.get(i); + String stars = String.valueOf(replacement != null ? replacement : "*") + .repeat(word.getEnd() - word.getStart() + 1); + result.replace(word.getStart(), word.getEnd() + 1, stars); + } + return result.toString(); + } + + /** + * 查找所有敏感词 + */ + public List findAllWords(String text) { + List results = new ArrayList<>(); + + if (text == null || text.length() < minWordLength) { + return results; + } + + char[] chars = text.toLowerCase().toCharArray(); + for (int i = 0; i < chars.length; i++) { + TrieNode node = root; + int j = i; + + while (j < chars.length && node.hasChild(chars[j])) { + node = node.getChild(chars[j]); + j++; + + if (node.isEnd()) { + // 获取原始文本中的敏感词 + String originalWord = text.substring(i, j); + results.add(new SensitiveWordResult(originalWord, i, j - 1)); + } + } + } + return results; + } + + /** + * 重新加载敏感词库 + */ + public void reloadWords(List words) { + this.root = buildTrie(words); + this.minWordLength = words.stream() + .mapToInt(String::length) + .min() + .orElse(1); + + log.info("敏感词库重新加载完成,当前词数:{}", words.size()); + } + + /** + * 添加单个敏感词 + */ + public void addWord(String word) { + if (word == null || word.trim().isEmpty()) { + return; + } + + word = word.trim().toLowerCase(); + TrieNode node = root; + + for (char c : word.toCharArray()) { + node = node.addChild(c); + } + + node.setEnd(true); + node.setKeyword(word); + + // 更新最小词长度 + this.minWordLength = Math.min(this.minWordLength, word.length()); + } +} \ No newline at end of file diff --git a/springboot-dfa/src/main/java/com/example/dfa/service/SensitiveWordService.java b/springboot-dfa/src/main/java/com/example/dfa/service/SensitiveWordService.java new file mode 100644 index 0000000..bc4b01f --- /dev/null +++ b/springboot-dfa/src/main/java/com/example/dfa/service/SensitiveWordService.java @@ -0,0 +1,148 @@ +package com.example.dfa.service; + +import com.example.dfa.entity.SensitiveWordResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +/** + * 敏感词服务 + * 提供敏感词过滤相关的业务逻辑 + */ +@Slf4j +@Service +public class SensitiveWordService { + + @Autowired + private SensitiveWordFilter sensitiveWordFilter; + + /** + * 检查文本是否包含敏感词 + */ + public boolean containsSensitiveWord(String text) { + return sensitiveWordFilter.containsSensitiveWord(text); + } + + /** + * 过滤文本中的敏感词 + */ + public String filterText(String text, String replacement) { + return sensitiveWordFilter.filter(text, replacement); + } + + /** + * 查找文本中的所有敏感词 + */ + public List findAllSensitiveWords(String text) { + return sensitiveWordFilter.findAllWords(text); + } + + /** + * 添加敏感词到词库 + */ + public void addSensitiveWord(String word) { + sensitiveWordFilter.addWord(word); + log.info("添加敏感词到词库: {}", word); + } + + /** + * 批量添加敏感词 + */ + public void addSensitiveWords(List words) { + for (String word : words) { + addSensitiveWord(word); + } + } + + /** + * 重新加载敏感词库 + */ + public void reloadSensitiveWords(List words) { + sensitiveWordFilter.reloadWords(words); + log.info("重新加载敏感词库,共 {} 个词", words.size()); + } + + /** + * 获取完整的过滤结果 + */ + public FilterResult getFilterResult(String text, String replacement) { + boolean hasSensitive = containsSensitiveWord(text); + String filteredText = filterText(text, replacement); + List sensitiveWords = findAllSensitiveWords(text); + + return new FilterResult( + text, + filteredText, + hasSensitive, + sensitiveWords, + sensitiveWords.size() + ); + } + + /** + * 过滤结果封装类 + */ + public static class FilterResult { + private String originalText; // 原始文本 + private String filteredText; // 过滤后文本 + private boolean hasSensitive; // 是否包含敏感词 + private List sensitiveWords; // 敏感词列表 + private int sensitiveWordCount; // 敏感词数量 + + public FilterResult() {} + + public FilterResult(String originalText, String filteredText, + boolean hasSensitive, List sensitiveWords, + int sensitiveWordCount) { + this.originalText = originalText; + this.filteredText = filteredText; + this.hasSensitive = hasSensitive; + this.sensitiveWords = sensitiveWords; + this.sensitiveWordCount = sensitiveWordCount; + } + + // Getters and Setters + public String getOriginalText() { + return originalText; + } + + public void setOriginalText(String originalText) { + this.originalText = originalText; + } + + public String getFilteredText() { + return filteredText; + } + + public void setFilteredText(String filteredText) { + this.filteredText = filteredText; + } + + public boolean isHasSensitive() { + return hasSensitive; + } + + public void setHasSensitive(boolean hasSensitive) { + this.hasSensitive = hasSensitive; + } + + public List getSensitiveWords() { + return sensitiveWords; + } + + public void setSensitiveWords(List sensitiveWords) { + this.sensitiveWords = sensitiveWords; + } + + public int getSensitiveWordCount() { + return sensitiveWordCount; + } + + public void setSensitiveWordCount(int sensitiveWordCount) { + this.sensitiveWordCount = sensitiveWordCount; + } + } +} \ No newline at end of file diff --git a/springboot-dfa/src/main/resources/application.properties b/springboot-dfa/src/main/resources/application.properties new file mode 100644 index 0000000..3c1d2aa --- /dev/null +++ b/springboot-dfa/src/main/resources/application.properties @@ -0,0 +1,10 @@ +server.port=8080 + +spring.application.name=dfa-sensitive-word-filter + +logging.level.com.example.dfa=INFO +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n + +server.servlet.encoding.charset=UTF-8 +server.servlet.encoding.enabled=true +server.servlet.encoding.force=true \ No newline at end of file diff --git a/springboot-dfa/src/main/resources/static/app.js b/springboot-dfa/src/main/resources/static/app.js new file mode 100644 index 0000000..ebafc2f --- /dev/null +++ b/springboot-dfa/src/main/resources/static/app.js @@ -0,0 +1,612 @@ +// API 基础 URL +const API_BASE_URL = '/api/sensitive-word'; + +// 全局变量 +let currentResult = null; + +/** + * 显示/隐藏加载提示 + */ +function showLoading(show = true) { + const overlay = document.getElementById('loadingOverlay'); + if (show) { + overlay.classList.remove('hidden'); + } else { + overlay.classList.add('hidden'); + } +} + +/** + * 显示提示消息 + */ +function showToast(title, message, type = 'success') { + const toast = document.getElementById('toast'); + const toastIcon = document.getElementById('toastIcon'); + const toastTitle = document.getElementById('toastTitle'); + const toastMessage = document.getElementById('toastMessage'); + + // 设置图标和颜色 + toastIcon.className = 'fas text-2xl'; + if (type === 'success') { + toastIcon.classList.add('fa-check-circle', 'text-green-500'); + } else if (type === 'error') { + toastIcon.classList.add('fa-exclamation-circle', 'text-red-500'); + } else if (type === 'warning') { + toastIcon.classList.add('fa-exclamation-triangle', 'text-yellow-500'); + } else { + toastIcon.classList.add('fa-info-circle', 'text-blue-500'); + } + + toastTitle.textContent = title; + toastMessage.textContent = message; + + toast.classList.remove('hidden'); + + // 3秒后自动隐藏 + setTimeout(() => { + toast.classList.add('hidden'); + }, 3000); +} + +/** + * 检查敏感词 + */ +async function checkSensitiveWord() { + const text = document.getElementById('textInput').value.trim(); + + if (!text) { + showToast('输入错误', '请输入要检测的文本', 'warning'); + return; + } + + showLoading(true); + + try { + const response = await fetch(`${API_BASE_URL}/check?text=${encodeURIComponent(text)}`); + const result = await response.json(); + + if (result.success) { + displayCheckResult(result); + showToast('检测完成', + result.hasSensitive ? '发现敏感词' : '未发现敏感词', + result.hasSensitive ? 'warning' : 'success' + ); + } else { + showToast('检测失败', result.error, 'error'); + } + } catch (error) { + console.error('检查敏感词失败:', error); + showToast('网络错误', '请检查网络连接后重试', 'error'); + } finally { + showLoading(false); + } +} + +/** + * 过滤文本 + */ +async function filterText() { + const text = document.getElementById('textInput').value.trim(); + const replacement = document.getElementById('replacement').value || '*'; + + if (!text) { + showToast('输入错误', '请输入要过滤的文本', 'warning'); + return; + } + + showLoading(true); + + try { + const response = await fetch( + `${API_BASE_URL}/filter?text=${encodeURIComponent(text)}&replacement=${encodeURIComponent(replacement)}` + ); + const result = await response.json(); + + if (result.success) { + currentResult = result; + displayFilterResult(result); + showToast('过滤完成', `发现 ${result.sensitiveWordCount} 个敏感词`, + result.hasSensitive ? 'warning' : 'success'); + } else { + showToast('过滤失败', result.error, 'error'); + } + } catch (error) { + console.error('过滤文本失败:', error); + showToast('网络错误', '请检查网络连接后重试', 'error'); + } finally { + showLoading(false); + } +} + +/** + * 查找所有敏感词 + */ +async function findAllSensitiveWords() { + const text = document.getElementById('textInput').value.trim(); + + if (!text) { + showToast('输入错误', '请输入要检测的文本', 'warning'); + return; + } + + showLoading(true); + + try { + const response = await fetch(`${API_BASE_URL}/find-all?text=${encodeURIComponent(text)}`); + const result = await response.json(); + + if (result.success) { + displayAllSensitiveWords(result); + showToast('查找完成', `发现 ${result.count} 个敏感词`, + result.count > 0 ? 'warning' : 'success'); + } else { + showToast('查找失败', result.error, 'error'); + } + } catch (error) { + console.error('查找敏感词失败:', error); + showToast('网络错误', '请检查网络连接后重试', 'error'); + } finally { + showLoading(false); + } +} + +/** + * 显示检查结果 + */ +function displayCheckResult(result) { + const resultArea = document.getElementById('resultArea'); + const resultStats = document.getElementById('resultStats'); + const textComparison = document.getElementById('textComparison'); + const sensitiveWordsList = document.getElementById('sensitiveWordsList'); + + resultArea.classList.remove('hidden'); + + // 显示统计信息 + resultStats.innerHTML = ` +
+
+ ${result.hasSensitive ? '是' : '否'} +
+
是否包含敏感词
+
+
+
+ ${result.text ? result.text.length : 0} +
+
文本长度
+
+
+
+ ${result.text ? result.text.split(' ').length : 0} +
+
词语数量
+
+
+
+ 1 +
+
检测次数
+
+ `; + + // 显示文本对比 + textComparison.innerHTML = ` +
+

原始文本:

+
+
${escapeHtml(result.text || '')}
+
+
+ `; + + sensitiveWordsList.classList.add('hidden'); +} + +/** + * 显示过滤结果 + */ +function displayFilterResult(result) { + const resultArea = document.getElementById('resultArea'); + const resultStats = document.getElementById('resultStats'); + const textComparison = document.getElementById('textComparison'); + const sensitiveWordsList = document.getElementById('sensitiveWordsList'); + + resultArea.classList.remove('hidden'); + + // 显示统计信息 + resultStats.innerHTML = ` +
+
+ ${result.sensitiveWordCount} +
+
敏感词数量
+
+
+
+ ${result.originalText ? result.originalText.length : 0} +
+
原始长度
+
+
+
+ ${result.filteredText ? result.filteredText.length : 0} +
+
过滤后长度
+
+
+
+ ${result.replacement || '*'} +
+
替换字符
+
+ `; + + // 显示文本对比 + textComparison.innerHTML = ` +
+

原始文本:

+
+
${escapeHtml(result.originalText || '')}
+
+
+
+

过滤后文本:

+
+
${escapeHtml(result.filteredText || '')}
+
+
+ `; + + // 显示敏感词列表 + if (result.sensitiveWords && result.sensitiveWords.length > 0) { + sensitiveWordsList.classList.remove('hidden'); + const sensitiveWordsContainer = document.getElementById('sensitiveWordsContainer'); + + let html = '
'; + result.sensitiveWords.forEach((word, index) => { + html += ` +
+
+ ${index + 1}. + ${escapeHtml(word.word)} + 位置: ${word.start}-${word.end} +
+ 敏感词 +
+ `; + }); + html += '
'; + + sensitiveWordsContainer.innerHTML = html; + } else { + sensitiveWordsList.classList.add('hidden'); + } +} + +/** + * 显示所有敏感词 + */ +function displayAllSensitiveWords(result) { + const resultArea = document.getElementById('resultArea'); + const resultStats = document.getElementById('resultStats'); + const textComparison = document.getElementById('textComparison'); + const sensitiveWordsList = document.getElementById('sensitiveWordsList'); + + resultArea.classList.remove('hidden'); + + // 显示统计信息 + resultStats.innerHTML = ` +
+
+ ${result.count} +
+
敏感词数量
+
+
+
+ ${result.text ? result.text.length : 0} +
+
文本长度
+
+
+
+ ${result.count > 0 ? Math.round((result.count / (result.text.length / 10)) * 100) : 0}% +
+
敏感词密度
+
+
+
+ 1 +
+
检测次数
+
+ `; + + // 显示原始文本 + textComparison.innerHTML = ` +
+

原始文本(高亮敏感词):

+
+
${highlightSensitiveWords(result.text || '', result.sensitiveWords || [])}
+
+
+ `; + + // 显示敏感词列表 + if (result.sensitiveWords && result.sensitiveWords.length > 0) { + sensitiveWordsList.classList.remove('hidden'); + const sensitiveWordsContainer = document.getElementById('sensitiveWordsContainer'); + + let html = '
'; + result.sensitiveWords.forEach((word, index) => { + html += ` +
+
+ ${index + 1}. + ${escapeHtml(word.word)} + 位置: ${word.start}-${word.end} + 长度: ${word.word.length} +
+ 敏感词 +
+ `; + }); + html += '
'; + + sensitiveWordsContainer.innerHTML = html; + } else { + sensitiveWordsList.classList.add('hidden'); + } +} + +/** + * 高亮敏感词 + */ +function highlightSensitiveWords(text, sensitiveWords) { + if (!sensitiveWords || sensitiveWords.length === 0) { + return escapeHtml(text); + } + + let result = escapeHtml(text); + + // 按位置排序敏感词(从后往前处理,避免位置偏移) + const sortedWords = [...sensitiveWords].sort((a, b) => b.start - a.start); + + sortedWords.forEach(word => { + const before = result.substring(0, word.start); + const highlighted = `${escapeHtml(word.word)}`; + const after = result.substring(word.end + 1); + result = before + highlighted + after; + }); + + return result; +} + +/** + * 清空结果 + */ +function clearResults() { + document.getElementById('resultArea').classList.add('hidden'); + document.getElementById('textInput').value = ''; + currentResult = null; + showToast('清空完成', '所有结果已清空', 'info'); +} + +/** + * 设置替换字符 + */ +function setReplacement(char) { + document.getElementById('replacement').value = char; +} + +/** + * 添加敏感词 + */ +async function addSensitiveWord() { + const word = document.getElementById('newSensitiveWord').value.trim(); + + if (!word) { + showToast('输入错误', '请输入敏感词', 'warning'); + return; + } + + showLoading(true); + + try { + const response = await fetch(`${API_BASE_URL}/add`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ word: word }) + }); + + const result = await response.json(); + + if (result.success) { + document.getElementById('newSensitiveWord').value = ''; + showToast('添加成功', `敏感词 "${word}" 已添加到词库`, 'success'); + } else { + showToast('添加失败', result.error, 'error'); + } + } catch (error) { + console.error('添加敏感词失败:', error); + showToast('网络错误', '请检查网络连接后重试', 'error'); + } finally { + showLoading(false); + } +} + +/** + * 批量添加敏感词 + */ +async function addBatchSensitiveWords() { + const batchText = document.getElementById('batchSensitiveWords').value.trim(); + + if (!batchText) { + showToast('输入错误', '请输入敏感词列表', 'warning'); + return; + } + + const words = batchText.split('\n') + .map(word => word.trim()) + .filter(word => word.length > 0); + + if (words.length === 0) { + showToast('输入错误', '没有有效的敏感词', 'warning'); + return; + } + + showLoading(true); + + try { + const response = await fetch(`${API_BASE_URL}/add-batch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ words: words }) + }); + + const result = await response.json(); + + if (result.success) { + document.getElementById('batchSensitiveWords').value = ''; + showToast('批量添加成功', `成功添加 ${result.count} 个敏感词`, 'success'); + } else { + showToast('批量添加失败', result.error, 'error'); + } + } catch (error) { + console.error('批量添加敏感词失败:', error); + showToast('网络错误', '请检查网络连接后重试', 'error'); + } finally { + showLoading(false); + } +} + +/** + * 检查系统状态 + */ +async function checkSystemStatus() { + showLoading(true); + + try { + const response = await fetch(`${API_BASE_URL}/status`); + const result = await response.json(); + + if (result.success) { + displaySystemStatus(result); + document.getElementById('systemStatus').scrollIntoView({ behavior: 'smooth' }); + showToast('状态查询成功', '系统运行正常', 'success'); + } else { + showToast('状态查询失败', result.error, 'error'); + } + } catch (error) { + console.error('检查系统状态失败:', error); + showToast('网络错误', '请检查网络连接后重试', 'error'); + } finally { + showLoading(false); + } +} + +/** + * 显示系统状态 + */ +function displaySystemStatus(status) { + const systemStatusContent = document.getElementById('systemStatusContent'); + const systemStatus = document.getElementById('systemStatus'); + + systemStatus.classList.remove('hidden'); + + let featuresHtml = ''; + if (status.features && status.features.length > 0) { + featuresHtml = '

功能特性:

    '; + status.features.forEach(feature => { + featuresHtml += `
  • ${escapeHtml(feature)}
  • `; + }); + featuresHtml += '
'; + } + + systemStatusContent.innerHTML = ` +
+
+

基本信息

+
+
+ 系统状态: + ${status.status || 'Unknown'} +
+
+ 算法: + ${status.algorithm || 'Unknown'} +
+
+ 数据结构: + ${status.dataStructure || 'Unknown'} +
+
+
+
+

性能指标

+
+
+ 时间复杂度: + O(n) +
+
+ 空间效率: + 前缀共享 +
+
+ 响应时间: + 毫秒级 +
+
+
+
+ ${featuresHtml} + `; +} + +/** + * HTML 转义 + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * 页面加载完成后的初始化 + */ +document.addEventListener('DOMContentLoaded', function() { + // 绑定回车键事件 + document.getElementById('textInput').addEventListener('keypress', function(e) { + if (e.key === 'Enter' && e.ctrlKey) { + filterText(); + } + }); + + document.getElementById('newSensitiveWord').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + addSensitiveWord(); + } + }); + + // 初始化示例文本 + const sampleText = '这是一个包含app内容的测试文本,还有orange和apple等敏感词汇。'; + document.getElementById('textInput').placeholder = sampleText; + + // 自动检查系统健康状态 + fetch(`${API_BASE_URL}/health`) + .then(response => response.json()) + .then(result => { + if (result.status === 'UP') { + console.log('系统健康检查通过'); + } + }) + .catch(error => { + console.warn('系统健康检查失败:', error); + }); +}); \ No newline at end of file diff --git a/springboot-dfa/src/main/resources/static/index.html b/springboot-dfa/src/main/resources/static/index.html new file mode 100644 index 0000000..70c6344 --- /dev/null +++ b/springboot-dfa/src/main/resources/static/index.html @@ -0,0 +1,344 @@ + + + + + + DFA 敏感词过滤系统 + + + + + + + + + + + + + +
+ +
+
+ +

DFA 算法简介

+
+
+
+

什么是 DFA 算法?

+

+ DFA (Deterministic Finite Automaton) 是一种有限状态自动机,通过构建 Trie 树数据结构实现高效的多模式字符串匹配。 + 时间复杂度从传统的 O(n×m) 优化到 O(n),其中 n 是文本长度,m 是敏感词数量。 +

+
+
+

核心优势

+
    +
  • + + 线性时间复杂度 - 只需遍历文本一次 +
  • +
  • + + 前缀共享优化 - 减少重复存储 +
  • +
  • + + 确定性匹配 - 无需回溯 +
  • +
+
+
+
+ + +
+
+ +

敏感词过滤测试

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

敏感词管理

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

关于本项目

+
+ +
+

+ 本项目基于 DFA (Deterministic Finite Automaton) 算法实现高效敏感词过滤系统, + 通过 Trie 树数据结构优化存储和查找效率。 +

+ +
+
+

技术特点

+
    +
  • + + 基于 Spring Boot + Java 17 +
  • +
  • + + DFA 算法 + Trie 树数据结构 +
  • +
  • + + 前后端分离架构 +
  • +
  • + + RESTful API 接口设计 +
  • +
+
+
+

性能指标

+
    +
  • + + 时间复杂度:O(n) +
  • +
  • + + 空间效率:前缀共享优化 +
  • +
  • + + 支持大规模敏感词库 +
  • +
  • + + 毫秒级响应时间 +
  • +
+
+
+
+
+
+ + +
+
+

+ DFA 敏感词过滤系统 - 基于 Trie 树的高效多模式匹配算法 +

+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/springboot-mutual-cert/README.md b/springboot-mutual-cert/README.md new file mode 100644 index 0000000..3c57afc --- /dev/null +++ b/springboot-mutual-cert/README.md @@ -0,0 +1,61 @@ +# Spring Boot HTTPS双向认证演示项目 + +这是一个Spring Boot HTTPS双向认证演示项目,展示了如何在Spring Boot应用中实现客户端和服务器的双向SSL认证。 + +## 🚀 快速开始 + +### 1. 环境准备 + +确保你的环境中已安装: +- JDK 17+ +- Maven 3.6+ +- OpenSSL (用于生成证书) + +### 2. 启动应用 + +```bash +# 编译项目 +mvn clean compile + +# 启动应用 +mvn spring-boot:run +``` + +应用启动后将在以下端口提供服务: +- HTTP: http://localhost:8080 (自动重定向到HTTPS) +- HTTPS: https://localhost:8443 (需要客户端证书) + +## 🧪 测试验证 + +### 1. 公共接口测试 (无需客户端证书) + +```bash +# 使用curl测试公共接口 +curl -k https://localhost:8443/api/public/info +``` + +### 2. 需要认证的接口测试 (需要客户端证书) + +```bash +# 使用客户端证书测试安全接口 +curl -k --cert certs/client.p12:changeit \ + https://localhost:8443/api/secure/data + +# 获取证书信息 +curl -k --cert certs/client.p12:changeit \ + https://localhost:8443/api/certificate/info + +# 获取用户配置文件 +curl -k --cert certs/client.p12:changeit \ + https://localhost:8443/api/user/profile +``` + +### 3. POST请求测试 + +```bash +# 提交数据 (需要客户端证书) +curl -k --cert certs/client.p12:changeit \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello Server", "data": [1, 2, 3]}' \ + https://localhost:8443/api/secure/submit +``` \ No newline at end of file diff --git a/springboot-mutual-cert/pom.xml b/springboot-mutual-cert/pom.xml new file mode 100644 index 0000000..b7fd2be --- /dev/null +++ b/springboot-mutual-cert/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + com.example + springboot-mutual-cert + 1.0.0 + Spring Boot Mutual Certificate Authentication + Spring Boot HTTPS双向认证演示项目 + + 17 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3 + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/springboot-mutual-cert/src/main/java/com/example/mutualcert/MutualCertApplication.java b/springboot-mutual-cert/src/main/java/com/example/mutualcert/MutualCertApplication.java new file mode 100644 index 0000000..50a56ed --- /dev/null +++ b/springboot-mutual-cert/src/main/java/com/example/mutualcert/MutualCertApplication.java @@ -0,0 +1,11 @@ +package com.example.mutualcert; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MutualCertApplication { + public static void main(String[] args) { + SpringApplication.run(MutualCertApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-mutual-cert/src/main/java/com/example/mutualcert/client/SecureHttpClient.java b/springboot-mutual-cert/src/main/java/com/example/mutualcert/client/SecureHttpClient.java new file mode 100644 index 0000000..c196371 --- /dev/null +++ b/springboot-mutual-cert/src/main/java/com/example/mutualcert/client/SecureHttpClient.java @@ -0,0 +1,321 @@ +package com.example.mutualcert.client; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import java.io.IOException; +import java.io.InputStream; +import java.net.Proxy; +import java.net.URL; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * HTTPS双向认证客户端示例 + * + * 该类演示了如何在Java客户端中配置SSL双向认证, + * 用于调用启用了双向认证的Spring Boot服务。 + */ +public class SecureHttpClient { + + private static final Logger logger = LoggerFactory.getLogger(SecureHttpClient.class); + + private final String serverUrl; + private final RestTemplate restTemplate; + + /** + * 构造函数 + * @param serverUrl 服务器地址,如: https://localhost:8443 + * @param keyStorePath 密钥库路径 (classpath资源) + * @param keyStorePassword 密钥库密码 + * @param trustStorePath 信任库路径 (classpath资源) + * @param trustStorePassword 信任库密码 + */ + public SecureHttpClient(String serverUrl, String keyStorePath, String keyStorePassword, + String trustStorePath, String trustStorePassword) { + this.serverUrl = serverUrl; + this.restTemplate = createRestTemplate(keyStorePath, keyStorePassword, trustStorePath, trustStorePassword); + } + + /** + * 创建配置了双向认证的RestTemplate + */ + private RestTemplate createRestTemplate(String keyStorePath, String keyStorePassword, + String trustStorePath, String trustStorePassword) { + try { + // 创建SSL上下文 + SSLContext sslContext = createSSLContext(keyStorePath, keyStorePassword, trustStorePath, trustStorePassword); + + // 创建自定义的RestTemplate + RestTemplate template = new RestTemplate(); + + // 使用SimpleClientHttpRequestFactory并配置SSL上下文 + // 这种方式不需要额外的Apache HttpClient依赖 + template.setRequestFactory(new org.springframework.http.client.SimpleClientHttpRequestFactory() { + @Override + protected java.net.HttpURLConnection openConnection(URL uri, Proxy proxy) throws IOException { + java.net.HttpURLConnection connection = super.openConnection(uri, proxy); + + // 如果是HTTPS连接,配置SSL上下文 + if (connection instanceof javax.net.ssl.HttpsURLConnection) { + javax.net.ssl.HttpsURLConnection httpsConnection = (javax.net.ssl.HttpsURLConnection) connection; + httpsConnection.setSSLSocketFactory(sslContext.getSocketFactory()); + httpsConnection.setHostnameVerifier((hostname, session) -> { + // 在生产环境中应该严格验证主机名,这里为了演示放宽限制 + logger.warn("主机名验证已禁用,生产环境请启用: {}", hostname); + return true; + }); + } + + return connection; + } + }); + + return template; + + } catch (Exception e) { + logger.error("创建RestTemplate失败", e); + throw new RuntimeException("创建RestTemplate失败", e); + } + } + + /** + * 创建SSL上下文 + */ + private SSLContext createSSLContext(String keyStorePath, String keyStorePassword, + String trustStorePath, String trustStorePassword) throws Exception { + + // 创建并初始化KeyManagerFactory + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + KeyStore keyStore = loadKeyStore(keyStorePath, keyStorePassword); + kmf.init(keyStore, keyStorePassword.toCharArray()); + + // 创建并初始化TrustManagerFactory + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + KeyStore trustStore = loadKeyStore(trustStorePath, trustStorePassword); + tmf.init(trustStore); + + // 创建SSL上下文 + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom()); + + logger.info("SSL上下文创建成功"); + return sslContext; + } + + /** + * 加载密钥库 + */ + private KeyStore loadKeyStore(String path, String password) throws Exception { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) { + if (is == null) { + throw new RuntimeException("找不到密钥库文件: " + path); + } + + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(is, password.toCharArray()); + + logger.info("密钥库加载成功: " + path); + return keyStore; + } + } + + /** + * 调用公共接口 (无需客户端证书) + */ + public Map getPublicInfo() { + try { + logger.info("调用公共接口: {}", serverUrl + "/api/public/info"); + + ResponseEntity response = restTemplate.getForEntity( + serverUrl + "/api/public/info", Map.class); + + logger.info("公共接口调用成功,状态码: {}", response.getStatusCode()); + return response.getBody(); + + } catch (HttpClientErrorException e) { + logger.error("公共接口调用失败,状态码: {}, 响应: {}", + e.getStatusCode(), e.getResponseBodyAsString()); + return Map.of("error", "请求失败", "status", e.getStatusCode().value()); + + } catch (ResourceAccessException e) { + logger.error("连接服务器失败: {}", e.getMessage()); + return Map.of("error", "连接服务器失败", "message", e.getMessage()); + + } catch (Exception e) { + logger.error("公共接口调用异常", e); + return Map.of("error", "系统异常", "message", e.getMessage()); + } + } + + /** + * 调用需要认证的安全接口 + */ + public Map getSecureData() { + try { + logger.info("调用安全接口: {}", serverUrl + "/api/secure/data"); + + ResponseEntity response = restTemplate.getForEntity( + serverUrl + "/api/secure/data", Map.class); + + logger.info("安全接口调用成功,状态码: {}", response.getStatusCode()); + return response.getBody(); + + } catch (HttpClientErrorException e) { + logger.error("安全接口调用失败,状态码: {}, 响应: {}", + e.getStatusCode(), e.getResponseBodyAsString()); + return Map.of("error", "认证失败", "status", e.getStatusCode().value()); + + } catch (Exception e) { + logger.error("安全接口调用异常", e); + return Map.of("error", "系统异常", "message", e.getMessage()); + } + } + + /** + * 获取客户端证书信息 + */ + public Map getCertificateInfo() { + try { + logger.info("调用证书信息接口: {}", serverUrl + "/api/certificate/info"); + + ResponseEntity response = restTemplate.getForEntity( + serverUrl + "/api/certificate/info", Map.class); + + logger.info("证书信息获取成功,状态码: {}", response.getStatusCode()); + return response.getBody(); + + } catch (Exception e) { + logger.error("获取证书信息失败", e); + return Map.of("error", "获取证书信息失败", "message", e.getMessage()); + } + } + + /** + * 获取用户配置文件 + */ + public Map getUserProfile() { + try { + logger.info("调用用户配置接口: {}", serverUrl + "/api/user/profile"); + + ResponseEntity response = restTemplate.getForEntity( + serverUrl + "/api/user/profile", Map.class); + + logger.info("用户配置获取成功,状态码: {}", response.getStatusCode()); + return response.getBody(); + + } catch (Exception e) { + logger.error("获取用户配置失败", e); + return Map.of("error", "获取用户配置失败", "message", e.getMessage()); + } + } + + /** + * 提交数据到安全接口 + */ + public Map submitData(Map data) { + try { + logger.info("调用数据提交接口: {}", serverUrl + "/api/secure/submit"); + + // 添加时间戳 + Map request = new HashMap<>(data); + request.put("timestamp", Instant.now().toString()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(request, headers); + + ResponseEntity response = restTemplate.postForEntity( + serverUrl + "/api/secure/submit", entity, Map.class); + + logger.info("数据提交成功,状态码: {}", response.getStatusCode()); + return response.getBody(); + + } catch (Exception e) { + logger.error("数据提交失败", e); + return Map.of("error", "数据提交失败", "message", e.getMessage()); + } + } + + /** + * 执行完整的测试流程 + */ + public void runCompleteTest() { + logger.info("=== 开始HTTPS双向认证客户端测试 ==="); + + // 1. 测试公共接口 + System.out.println("\n🔓 1. 测试公共接口 (无需客户端证书)"); + Map publicInfo = getPublicInfo(); + System.out.println("响应: " + publicInfo); + + // 2. 测试安全接口 + System.out.println("\n🔐 2. 测试安全接口 (需要客户端证书)"); + Map secureData = getSecureData(); + System.out.println("响应: " + secureData); + + // 3. 获取证书信息 + System.out.println("\n📋 3. 获取客户端证书信息"); + Map certInfo = getCertificateInfo(); + System.out.println("响应: " + certInfo); + + // 4. 获取用户配置 + System.out.println("\n👤 4. 获取用户配置文件"); + Map userProfile = getUserProfile(); + System.out.println("响应: " + userProfile); + + // 5. 提交数据 + System.out.println("\n📤 5. 提交数据"); + Map dataToSubmit = Map.of( + "message", "Hello from SecureHttpClient", + "clientType", "Java", + "version", "1.0.0" + ); + Map submitResult = submitData(dataToSubmit); + System.out.println("响应: " + submitResult); + + System.out.println("\n=== 测试完成 ==="); + } + + /** + * 主方法 - 演示客户端调用 + */ + public static void main(String[] args) { + // 配置参数 + String serverUrl = "https://localhost:8443"; + String keyStorePath = "client.jks"; + String keyStorePassword = "changeit"; + String trustStorePath = "truststore.jks"; + String trustStorePassword = "changeit"; + + try { + // 创建安全HTTP客户端 + SecureHttpClient client = new SecureHttpClient( + serverUrl, keyStorePath, keyStorePassword, trustStorePath, trustStorePassword); + + // 运行完整测试 + client.runCompleteTest(); + + } catch (Exception e) { + logger.error("客户端启动失败", e); + System.err.println("错误: " + e.getMessage()); + System.err.println("请确保:"); + System.err.println("1. Spring Boot服务器正在运行 (https://localhost:8443)"); + System.err.println("2. 客户端证书文件存在且可访问"); + System.err.println("3. 证书配置正确"); + } + } +} \ No newline at end of file diff --git a/springboot-mutual-cert/src/main/java/com/example/mutualcert/config/SslConfig.java b/springboot-mutual-cert/src/main/java/com/example/mutualcert/config/SslConfig.java new file mode 100644 index 0000000..652bc2d --- /dev/null +++ b/springboot-mutual-cert/src/main/java/com/example/mutualcert/config/SslConfig.java @@ -0,0 +1,38 @@ +package com.example.mutualcert.config; + +import org.apache.catalina.Context; +import org.apache.catalina.connector.Connector; +import org.apache.tomcat.util.descriptor.web.SecurityCollection; +import org.apache.tomcat.util.descriptor.web.SecurityConstraint; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SslConfig { + + @Bean + public WebServerFactoryCustomizer servletContainerCustomizer() { + return factory -> { + factory.addAdditionalTomcatConnectors(redirectConnector()); + factory.addContextCustomizers(context -> { + SecurityConstraint securityConstraint = new SecurityConstraint(); + securityConstraint.setUserConstraint("CONFIDENTIAL"); + SecurityCollection collection = new SecurityCollection(); + collection.addPattern("/*"); + securityConstraint.addCollection(collection); + context.addConstraint(securityConstraint); + }); + }; + } + + private Connector redirectConnector() { + Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol"); + connector.setScheme("http"); + connector.setPort(8080); + connector.setSecure(false); + connector.setRedirectPort(8443); + return connector; + } +} \ No newline at end of file diff --git a/springboot-mutual-cert/src/main/java/com/example/mutualcert/controller/ApiController.java b/springboot-mutual-cert/src/main/java/com/example/mutualcert/controller/ApiController.java new file mode 100644 index 0000000..67d899f --- /dev/null +++ b/springboot-mutual-cert/src/main/java/com/example/mutualcert/controller/ApiController.java @@ -0,0 +1,300 @@ +package com.example.mutualcert.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import com.example.mutualcert.security.CustomX509Validator; + +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api") +public class ApiController { + + private final CustomX509Validator customX509Validator; + + public ApiController(CustomX509Validator customX509Validator) { + this.customX509Validator = customX509Validator; + } + + @GetMapping("/public/info") + public ResponseEntity> publicInfo() { + Map response = new HashMap<>(); + response.put("message", "这是公开信息,无需认证即可访问"); + response.put("status", "success"); + response.put("timestamp", System.currentTimeMillis()); + return ResponseEntity.ok(response); + } + + @GetMapping("/secure/data") + public ResponseEntity> secureData(Authentication authentication) { + Map response = new HashMap<>(); + + try { + // 使用自定义证书验证器验证客户端证书 + Object credentials = authentication.getCredentials(); + if (credentials instanceof X509Certificate) { + X509Certificate cert = (X509Certificate) credentials; + + // 执行自定义证书验证 + CustomX509Validator.ValidationResult validationResult = + customX509Validator.validateCertificate(cert); + + // 添加验证结果到响应中 + response.put("certificateValidation", Map.of( + "valid", validationResult.isValid(), + "hasErrors", validationResult.hasErrors(), + "hasWarnings", validationResult.hasWarnings(), + "errors", validationResult.getErrors(), + "warnings", validationResult.getWarnings(), + "info", validationResult.getInfo() + )); + + // 如果验证失败,返回详细的错误信息 + if (!validationResult.isValid()) { + response.put("message", "❌ 证书验证失败,访问被拒绝"); + response.put("error", "CERTIFICATE_VALIDATION_FAILED"); + response.put("errorDetails", validationResult.getErrors()); + response.put("status", "error"); + return ResponseEntity.status(403).body(response); + } + + // 验证成功,显示详细信息 + response.put("message", "✅ 证书验证成功!" + authentication.getName() + ",您通过了自定义安全验证"); + response.put("certificateStatus", "VALIDATED"); + response.put("validationTimestamp", System.currentTimeMillis()); + + } else { + response.put("message", "⚠️ 无法获取客户端证书进行验证"); + response.put("certificateStatus", "NOT_AVAILABLE"); + } + + } catch (Exception e) { + response.put("message", "❌ 证书验证过程中发生错误: " + e.getMessage()); + response.put("error", "VALIDATION_EXCEPTION"); + response.put("status", "error"); + return ResponseEntity.status(500).body(response); + } + + // 添加基础用户信息 + response.put("username", authentication.getName()); + response.put("authorities", authentication.getAuthorities()); + response.put("status", "success"); + response.put("timestamp", System.currentTimeMillis()); + response.put("accessLevel", "CUSTOM_VALIDATED_SECURE"); + + return ResponseEntity.ok(response); + } + + @GetMapping("/certificate/info") + public ResponseEntity> getCertificateInfo(Authentication authentication) { + Map response = new HashMap<>(); + + // 从认证对象中获取证书信息 + Object credentials = authentication.getCredentials(); + + if (credentials instanceof X509Certificate) { + X509Certificate cert = (X509Certificate) credentials; + response.put("subject", cert.getSubjectX500Principal().getName()); + response.put("issuer", cert.getIssuerX500Principal().getName()); + response.put("serialNumber", cert.getSerialNumber().toString()); + response.put("validFrom", cert.getNotBefore()); + response.put("validTo", cert.getNotAfter()); + response.put("sigAlgName", cert.getSigAlgName()); + response.put("type", cert.getType()); + } else { + response.put("error", "无法获取客户端证书信息"); + } + + response.put("username", authentication.getName()); + response.put("status", "success"); + return ResponseEntity.ok(response); + } + + @GetMapping("/user/profile") + public ResponseEntity> getUserProfile(Authentication authentication) { + Map profile = new HashMap<>(); + profile.put("username", authentication.getName()); + profile.put("authorities", authentication.getAuthorities()); + + // 根据不同的用户类型返回不同的配置文件 + if ("DemoClient".equals(authentication.getName())) { + profile.put("role", "API_CLIENT"); + profile.put("permissions", new String[]{"READ_SENSITIVE_DATA", "WRITE_DATA"}); + } else if ("localhost".equals(authentication.getName())) { + profile.put("role", "SERVER"); + profile.put("permissions", new String[]{"ADMIN_ACCESS", "SYSTEM_CONFIG"}); + } + + return ResponseEntity.ok(profile); + } + + @PostMapping("/secure/submit") + public ResponseEntity> submitData( + @RequestBody Map requestData, + Authentication authentication) { + + Map response = new HashMap<>(); + response.put("message", "数据提交成功"); + response.put("submittedBy", authentication.getName()); + response.put("receivedData", requestData); + response.put("timestamp", System.currentTimeMillis()); + response.put("status", "success"); + + return ResponseEntity.ok(response); + } + + /** + * 获取详细的证书验证结果 (包含自定义验证) + */ + @GetMapping("/certificate/validation") + public ResponseEntity> getCertificateValidation(Authentication authentication) { + Map response = new HashMap<>(); + + try { + // 获取客户端证书 + Object credentials = authentication.getCredentials(); + + if (credentials instanceof X509Certificate) { + X509Certificate cert = (X509Certificate) credentials; + + // 执行自定义证书验证 + CustomX509Validator.ValidationResult validationResult = + customX509Validator.validateCertificate(cert); + + // 基本证书信息 + response.put("subject", cert.getSubjectX500Principal().getName()); + response.put("issuer", cert.getIssuerX500Principal().getName()); + response.put("serialNumber", cert.getSerialNumber().toString()); + response.put("validFrom", cert.getNotBefore()); + response.put("validTo", cert.getNotAfter()); + response.put("signatureAlgorithm", cert.getSigAlgName()); + + // 自定义验证结果 + response.put("customValidation", Map.of( + "valid", validationResult.isValid(), + "hasErrors", validationResult.hasErrors(), + "hasWarnings", validationResult.hasWarnings(), + "errors", validationResult.getErrors(), + "warnings", validationResult.getWarnings(), + "info", validationResult.getInfo() + )); + + // 提取证书的详细信息 + String subject = cert.getSubjectX500Principal().getName(); + response.put("extractedFields", Map.of( + "commonName", extractCN(subject), + "organization", extractOrganization(subject), + "organizationalUnit", extractOU(subject), + "country", extractCountry(subject) + )); + + response.put("username", authentication.getName()); + response.put("status", "success"); + + } else { + response.put("error", "无法获取客户端证书信息"); + response.put("status", "error"); + } + + } catch (Exception e) { + response.put("error", "证书验证过程中发生错误: " + e.getMessage()); + response.put("status", "error"); + } + + return ResponseEntity.ok(response); + } + + /** + * 验证任意客户端证书 (不需要认证,但需要证书) + */ + @PostMapping("/certificate/validate") + public ResponseEntity> validateCertificate( + @RequestBody Map certificateData, + Authentication authentication) { + + Map response = new HashMap<>(); + + try { + // 获取客户端证书 + if (authentication.getCredentials() instanceof X509Certificate) { + X509Certificate cert = (X509Certificate) authentication.getCredentials(); + + // 执行详细的证书验证 + CustomX509Validator.ValidationResult validationResult = + customX509Validator.validateCertificate(cert); + + // 验证结果摘要 + response.put("validationSummary", validationResult.isValid() ? "PASSED" : "FAILED"); + response.put("certificateDetails", Map.of( + "subject", cert.getSubjectX500Principal().getName(), + "issuer", cert.getIssuerX500Principal().getName(), + "serialNumber", cert.getSerialNumber().toString(), + "validFrom", cert.getNotBefore(), + "validTo", cert.getNotAfter() + )); + + // 验证详情 + response.put("validationDetails", validationResult); + response.put("timestamp", System.currentTimeMillis()); + response.put("status", "success"); + + } else { + response.put("error", "请求中没有有效的客户端证书"); + response.put("status", "error"); + } + + } catch (Exception e) { + response.put("error", "证书验证失败: " + e.getMessage()); + response.put("status", "error"); + } + + return ResponseEntity.ok(response); + } + + // 辅助方法:提取CN字段 + private String extractCN(String subject) { + String[] parts = subject.split(","); + for (String part : parts) { + if (part.trim().startsWith("CN=")) { + return part.trim().substring(3); + } + } + return "Unknown"; + } + + // 辅助方法:提取组织信息 + private String extractOrganization(String subject) { + String[] parts = subject.split(","); + for (String part : parts) { + if (part.trim().startsWith("O=")) { + return part.trim().substring(2); + } + } + return "Unknown"; + } + + // 辅助方法:提取组织部门 + private String extractOU(String subject) { + String[] parts = subject.split(","); + for (String part : parts) { + if (part.trim().startsWith("OU=")) { + return part.trim().substring(3); + } + } + return "Unknown"; + } + + // 辅助方法:提取国家信息 + private String extractCountry(String subject) { + String[] parts = subject.split(","); + for (String part : parts) { + if (part.trim().startsWith("C=")) { + return part.trim().substring(2); + } + } + return "Unknown"; + } +} \ No newline at end of file diff --git a/springboot-mutual-cert/src/main/java/com/example/mutualcert/security/CustomX509Validator.java b/springboot-mutual-cert/src/main/java/com/example/mutualcert/security/CustomX509Validator.java new file mode 100644 index 0000000..ef59459 --- /dev/null +++ b/springboot-mutual-cert/src/main/java/com/example/mutualcert/security/CustomX509Validator.java @@ -0,0 +1,434 @@ +package com.example.mutualcert.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.security.*; +import java.security.cert.*; +import java.security.interfaces.RSAPublicKey; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.stream.Collectors; + +@Component +public class CustomX509Validator { + + private static final Logger logger = LoggerFactory.getLogger(CustomX509Validator.class); + + // 黑名单存储(实际项目中应该从数据库或配置文件读取) + private final Set blacklistedSerialNumbers = new HashSet<>(Arrays.asList( + "1234567890ABCDEF1234567890ABCDEF", + "FEDCBA0987654321FEDCBA0987654321" + )); + + private final Set blacklistedSubjectDNs = new HashSet<>(Arrays.asList( + "CN=BlacklistedClient, OU=Blacklisted Dept, O=Blacklisted Corp, C=US", + "CN=RevokedClient, OU=Revoked Dept, O=Revoked Corp, C=CN" + )); + + // 允许的组织列表(白名单) + private final Set allowedOrganizations = new HashSet<>(Arrays.asList( + "DemoCompany" + )); + + // 允许的CA序列号(用于验证证书链) + private final Set trustedRootCASerials = new HashSet<>(Arrays.asList( + "3EE5FAF49D1073C87F738E69DF71A8E1CBA0752" // 我们的根CA序列号 + )); + + // 吊销证书的CRL文件路径(生产环境中应该配置) + private final String crlPath = null; // 配置为null,使用在线检查替代 + + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + /** + * 验证客户端证书 - 优化版本 + * 实现真正的证书链验证、黑名单检查和签名验证 + */ + public ValidationResult validateCertificate(X509Certificate cert) { + ValidationResult result = new ValidationResult(); + + try { + logger.info("开始证书验证: {}", cert.getSubjectX500Principal().getName()); + + // 1. 证书有效期验证 + validateCertificateValidity(cert, result); + + // 2. 证书黑名单检查 + validateBlacklist(cert, result); + + // 3. 证书链验证(真正的签名验证) + validateCertificateChain(cert, result); + + // 4. 证书组织验证 + validateOrganization(cert, result); + + // 5. 证书密钥强度验证 + validateKeyStrength(cert, result); + + // 6. 证书扩展验证 + //validateExtensions(cert, result); + + // 7. 总体验证结果 + if (!result.hasErrors()) { + result.setValid(true); + result.addInfo("证书验证通过 - 所有检查均通过"); + } + + } catch (Exception e) { + result.addError("证书验证过程中发生异常: " + e.getMessage()); + logger.error("证书验证异常", e); + } + + // 记录验证结果 + logValidationResult(cert, result); + + return result; + } + + /** + * 1. 验证证书有效期 + */ + private void validateCertificateValidity(X509Certificate cert, ValidationResult result) { + try { + cert.checkValidity(); + Date now = new Date(); + Date notAfter = cert.getNotAfter(); + long daysUntilExpiry = (notAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + + result.addInfo(String.format("证书有效期验证通过 - 剩余%d天", daysUntilExpiry)); + + if (daysUntilExpiry < 30) { + result.addWarning(String.format("证书将在%d天后过期,请及时更新", daysUntilExpiry)); + } + } catch (CertificateExpiredException e) { + result.addError("证书已过期: " + e.getMessage()); + } catch (CertificateNotYetValidException e) { + result.addError("证书尚未生效: " + e.getMessage()); + } + } + + /** + * 2. 验证证书黑名单 + */ + private void validateBlacklist(X509Certificate cert, ValidationResult result) { + // 自定义黑名单规则校验 + String serialNumber = cert.getSerialNumber().toString(16).toUpperCase(); + if (blacklistedSerialNumbers.contains(serialNumber)) { + result.addError("证书序列号在黑名单中: " + serialNumber); + return; + } + + String subjectDN = cert.getSubjectX500Principal().getName(); + if (blacklistedSubjectDNs.contains(subjectDN)) { + result.addError("证书主题在黑名单中: " + subjectDN); + return; + } + + //todo 基于CRL、OCSP等进行在线检查 + + result.addInfo("证书黑名单验证通过"); + } + + /** + * 3. 验证证书链 + */ + private void validateCertificateChain(X509Certificate cert, ValidationResult result) { + try { + // 从classpath加载根CA证书 + X509Certificate rootCACert = null; + try (InputStream caCertStream = getClass().getClassLoader().getResourceAsStream("root-ca-cert.pem")) { + if (caCertStream == null) { + throw new RuntimeException("找不到根CA证书文件 root-ca-cert.pem"); + } + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + rootCACert = (X509Certificate) cf.generateCertificate(caCertStream); + } + + // 验证客户端证书是否由根CA签署 + try { + cert.verify(rootCACert.getPublicKey()); + result.addInfo("客户端证书签名验证通过 - 使用根CA公钥验证成功"); + } catch (SignatureException e) { + result.addError("客户端证书签名验证失败: 证书不是由可信CA签发的"); + return; + } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchProviderException e) { + result.addError("客户端证书签名验证失败: 技术错误 - " + e.getMessage()); + return; + } + + // 验证根CA证书的有效性 + try { + rootCACert.checkValidity(); + result.addInfo("根CA证书本身有效"); + } catch (CertificateExpiredException | CertificateNotYetValidException e) { + result.addError("根CA证书无效: " + e.getMessage()); + return; + } + + /*// 检查根CA序列号 + String rootCASerial = rootCACert.getSerialNumber().toString(16).toUpperCase(); + if (!trustedRootCASerials.contains(rootCASerial)) { + result.addError("根CA序列号不在信任列表中: " + rootCASerial); + return; + }*/ + + // 更智能的颁发者信息检查 - 解析DN而不是简单的字符串比较 + String issuerDN = cert.getIssuerX500Principal().getName(); + String rootCAIssuerDN = rootCACert.getSubjectX500Principal().getName(); + + // 比较颁发者和根CA的DN字段 + boolean isIssuerValid = compareDistinguishedNames(issuerDN, rootCAIssuerDN); + + if (isIssuerValid) { + result.addInfo("证书颁发者DN与根CA完全匹配"); + } else { + result.addWarning("证书颁发者DN与根CA基本匹配但不完全一致"); + } + + result.addInfo("证书链验证通过"); + + } catch (Exception e) { + result.addError("证书链验证失败: " + e.getMessage()); + logger.error("证书链验证异常", e); + } + } + + /** + * 智能比较两个DN是否相等,忽略顺序和格式差异 + */ + private boolean compareDistinguishedNames(String dn1, String dn2) { + if (dn1 == null || dn2 == null) { + return false; + } + + if (dn1.equals(dn2)) { + return true; + } + + // 解析DN为字段映射 + Map dnFields1 = parseDN(dn1); + Map dnFields2 = parseDN(dn2); + + // 比较字段是否相等 + return dnFields1.equals(dnFields2); + } + + /** + * 解析DN字符串为字段映射 + */ + private Map parseDN(String dn) { + Map fields = new TreeMap<>(); // 使用TreeMap保持顺序一致 + + String[] parts = dn.split(","); + for (String part : parts) { + String trimmedPart = part.trim(); + if (trimmedPart.isEmpty()) { + continue; + } + + int equalsIndex = trimmedPart.indexOf('='); + if (equalsIndex != -1) { + String key = trimmedPart.substring(0, equalsIndex).trim().toUpperCase(); + String value = trimmedPart.substring(equalsIndex + 1).trim(); + fields.put(key, value); + } + } + + return fields; + } + + /** + * 4. 验证证书组织 + */ + private void validateOrganization(X509Certificate cert, ValidationResult result) { + String subject = cert.getSubjectX500Principal().getName(); + String organization = extractOrganization(subject); + + if (!allowedOrganizations.contains(organization)) { + result.addError("证书组织不在允许列表中: " + organization); + return; + } + + result.addInfo("证书组织验证通过: " + organization); + } + + /** + * 5. 验证证书密钥强度 + */ + private void validateKeyStrength(X509Certificate cert, ValidationResult result) { + try { + PublicKey publicKey = cert.getPublicKey(); + + if (publicKey instanceof RSAPublicKey) { + RSAPublicKey rsaPublicKey = (RSAPublicKey) publicKey; + int keyLength = rsaPublicKey.getModulus().bitLength(); + + if (keyLength < 2048) { + result.addWarning("RSA密钥长度较短: " + keyLength + "位,建议使用2048位或更高"); + } else if (keyLength >= 4096) { + result.addInfo("RSA密钥强度高: " + keyLength + "位"); + } else { + result.addInfo("RSA密钥强度适中: " + keyLength + "位"); + } + + result.addInfo("密钥强度验证通过"); + } else { + result.addWarning("非RSA密钥,使用其他算法: " + publicKey.getAlgorithm()); + } + } catch (Exception e) { + result.addError("密钥强度验证失败: " + e.getMessage()); + } + } + + /** + * 6. 验证证书扩展 + */ + private void validateExtensions(X509Certificate cert, ValidationResult result) { + try { + boolean[] keyUsage = cert.getKeyUsage(); + if (keyUsage != null) { + List usages = new ArrayList<>(); + if (keyUsage[0]) usages.add("digitalSignature"); + if (keyUsage[1]) usages.add("nonRepudiation"); + if (keyUsage[2]) usages.add("keyEncipherment"); + if (keyUsage[3]) usages.add("dataEncipherment"); + if (keyUsage[4]) usages.add("keyAgreement"); + if (keyUsage[5]) usages.add("keyCertSign"); + if (keyUsage[6]) usages.add("cRLSign"); + if (keyUsage[7]) usages.add("encipherOnly"); + if (keyUsage[8]) usages.add("decipherOnly"); + + result.addInfo("密钥用途: " + String.join(", ", usages)); + } + + List extendedKeyUsage = cert.getExtendedKeyUsage(); + if (extendedKeyUsage != null) { + result.addInfo("扩展密钥用途: " + String.join(", ", extendedKeyUsage)); + + if (!extendedKeyUsage.contains("clientAuth")) { + result.addWarning("证书缺少客户端认证用途 (clientAuth)"); + } + } + + result.addInfo("证书扩展验证通过"); + + } catch (Exception e) { + result.addError("证书扩展验证失败: " + e.getMessage()); + } + } + + /** + * 计算SHA-256指纹 + */ + private String calculateSHA256Fingerprint(X509Certificate cert) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] derEncoded = cert.getEncoded(); + byte[] digest = md.digest(derEncoded); + + StringBuilder hexString = new StringBuilder(); + for (byte b : digest) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString().toUpperCase(); + } + + /** + * 从证书主题中提取组织信息 + */ + private String extractOrganization(String subject) { + Map dnFields = parseDN(subject); + return dnFields.getOrDefault("O", "Unknown"); + } + + /** + * 从证书主题中提取通用名(CN) + */ + private String extractCN(String subject) { + Map dnFields = parseDN(subject); + return dnFields.getOrDefault("CN", "Unknown"); + } + + /** + * 记录验证结果 + */ + private void logValidationResult(X509Certificate cert, ValidationResult result) { + try { + String subject = cert.getSubjectX500Principal().getName(); + String issuer = cert.getIssuerX500Principal().getName(); + String serialNumber = cert.getSerialNumber().toString(16); + + logger.info("证书验证完成 - 主题: {}, 颁发者: {}, 序列号: {}", subject, issuer, serialNumber); + logger.info("验证结果: {}", result.isValid() ? "通过" : "失败"); + + if (result.hasErrors()) { + logger.error("证书验证错误: {}", result.getErrors()); + } + + if (result.hasWarnings()) { + logger.warn("证书验证警告: {}", result.getWarnings()); + } + + } catch (Exception e) { + logger.error("记录验证结果失败", e); + } + } + + /** + * 验证结果类 + */ + public static class ValidationResult { + private boolean valid = false; + private List errors = new ArrayList<>(); + private List warnings = new ArrayList<>(); + private List info = new ArrayList<>(); + + public boolean isValid() { + return valid; + } + + public void setValid(boolean valid) { + this.valid = valid; + } + + public List getErrors() { + return errors; + } + + public void addError(String error) { + this.errors.add(error); + } + + public List getWarnings() { + return warnings; + } + + public void addWarning(String warning) { + this.warnings.add(warning); + } + + public List getInfo() { + return info; + } + + public void addInfo(String info) { + this.info.add(info); + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } + + public boolean hasWarnings() { + return !warnings.isEmpty(); + } + } +} \ No newline at end of file diff --git a/springboot-mutual-cert/src/main/java/com/example/mutualcert/security/SecurityConfig.java b/springboot-mutual-cert/src/main/java/com/example/mutualcert/security/SecurityConfig.java new file mode 100644 index 0000000..3a00a01 --- /dev/null +++ b/springboot-mutual-cert/src/main/java/com/example/mutualcert/security/SecurityConfig.java @@ -0,0 +1,90 @@ +package com.example.mutualcert.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final CustomX509Validator customX509Validator; + + public SecurityConfig() { + this.customX509Validator = new CustomX509Validator(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authz -> authz + .requestMatchers("/public/**", "/actuator/health", "/actuator/info").permitAll() + .anyRequest().authenticated() + ) + .x509(x509 -> x509 + .subjectPrincipalRegex("CN=(.*?)(?:,|$)") + .userDetailsService(userDetailsService()) + ) + .csrf(csrf -> csrf.disable()); + + return http.build(); + } + + @Bean + public UserDetailsService userDetailsService() { + return username -> { + // 首先检查证书中的CN字段是否在允许的列表中 + if (isAllowedCertificateUser(username)) { + if ("DemoClient".equals(username)) { + return new User(username, "", Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))); + } else if ("localhost".equals(username)) { + return new User(username, "", Collections.singletonList(new SimpleGrantedAuthority("ROLE_SERVER"))); + } + } + throw new UsernameNotFoundException("User not found: " + username); + }; + } + + /** + * 检查证书用户是否在允许列表中 + */ + private boolean isAllowedCertificateUser(String username) { + // 这里可以结合 CustomX509Validator 来进行更严格的验证 + // 目前暂时允许 DemoClient 和 localhost + return "DemoClient".equals(username) || "localhost".equals(username); + } + + /** + * 提供自定义证书验证器给其他组件使用 + */ + @Bean + public CustomX509Validator customX509Validator() { + return customX509Validator; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/springboot-mutual-cert/src/main/resources/application.yml b/springboot-mutual-cert/src/main/resources/application.yml new file mode 100644 index 0000000..7241a16 --- /dev/null +++ b/springboot-mutual-cert/src/main/resources/application.yml @@ -0,0 +1,27 @@ +server: + port: 8443 + ssl: + enabled: true + key-store: classpath:server.jks + key-store-password: changeit + key-store-type: JKS + key-alias: server + trust-store: classpath:truststore.jks + trust-store-password: changeit + trust-store-type: JKS + client-auth: need # 需要客户端证书 + +spring: + application: + name: springboot-mutual-cert + output: + ansi: + enabled: always + banner: + location: classpath:banner.txt + +logging: + level: + org.springframework.security: DEBUG + com.example.mutualcert: DEBUG + org.apache.catalina: DEBUG diff --git a/springboot-mutual-cert/src/main/resources/client.jks b/springboot-mutual-cert/src/main/resources/client.jks new file mode 100644 index 0000000..40fd610 Binary files /dev/null and b/springboot-mutual-cert/src/main/resources/client.jks differ diff --git a/springboot-mutual-cert/src/main/resources/root-ca-cert.pem b/springboot-mutual-cert/src/main/resources/root-ca-cert.pem new file mode 100644 index 0000000..3c737aa --- /dev/null +++ b/springboot-mutual-cert/src/main/resources/root-ca-cert.pem @@ -0,0 +1,34 @@ +-----BEGIN CERTIFICATE----- +MIIF9TCCA92gAwIBAgIUAQbGmDElUrXOvxPmG7eSts8Ib1gwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAkNOMRAwDgYDVQQIDAdCZWlqaW5nMRAwDgYDVQQHDAdC +ZWlqaW5nMRMwEQYDVQQKDApEZW1vUm9vdENBMSMwIQYDVQQLDBpSb290IENlcnRp +ZmljYXRlIEF1dGhvcml0eTETMBEGA1UEAwwKRGVtb1Jvb3RDQTAgFw0yNTExMjkw +ODI4MjBaGA8yMTI1MTEwNTA4MjgyMFowgYAxCzAJBgNVBAYTAkNOMRAwDgYDVQQI +DAdCZWlqaW5nMRAwDgYDVQQHDAdCZWlqaW5nMRMwEQYDVQQKDApEZW1vUm9vdENB +MSMwIQYDVQQLDBpSb290IENlcnRpZmljYXRlIEF1dGhvcml0eTETMBEGA1UEAwwK +RGVtb1Jvb3RDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANgLt5eA +tC1KPzxsmUAF6xREUolbAAhCS3xFBBvu0wL5sshH7dZFvQaZNQ0QwXoAR+ktnz8v +i6SRvu+gtAHL5EcfqPkCAR4vprZ9UKBhkGSNgfxXQwMRqY7DYXK9cZaP5MGEMMt9 +yC/q+uOtHPyn1yXQ9sga6q08pWkCUumf4apVRo+Dm1CNJIJH6cp9Q02WysKkxoXj +5V64SkJZSqRymzL2YoVQJ69Q4sFH6JKTQ8wql/CUqaruUzzKOyIEk8bJtkYrssxk +GrQj459+Hc3rBcPI0EYmiS4VHe4H1q3EttIqyBtiPliVZH7HKa5cpeSDSTcAA17p +O7YwIJewnYcEEpFVT6lm3NNHO8+/cSDviozVvlidNPttlHXUMCrySMYANjHj/rha +xbZ7vWWTPj9OE1ZQwdzrwPcOuCJRPbDekul3GGTedO26nzLZMYwEJuGAFGk0kIjx +cfd0c1Kn9xd83D88pMoZ/OcMCiPgZCk59Q+p4lIPQHmyw3jOAypU67N1T4GV7X7P +5gfX+wyt9OkEKsroDMpFXfivJmBUJ3AVT4ApfwWuTaeRpqKHIRbaieO5jT8gFCG8 +B0fvouzKJvt7eu/pa9AnqDjTFozDh5NYQ3KGIKUc0BBGUa5f025jm3TwSPUrgg1m +Y9NxSq/zsLjHCqw5HREQl0UnxAUbqJJDsCHlAgMBAAGjYzBhMB0GA1UdDgQWBBQV +MpiG5fYk5ntQYGnUNtS69w2L3DAfBgNVHSMEGDAWgBQVMpiG5fYk5ntQYGnUNtS6 +9w2L3DAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B +AQsFAAOCAgEAtUJ4sG2WWadTb5MzFp5GOgL7GuFg+6ekBaeC7CEJDYloL3bbo02K +/sdq+JMRnAAMP4bIqPON54o2EUkr25/X9qftDeUdR2grzuOmczD3bH4M5k+hVBPw +r68WNSOfXz0Rq0MrTZSaCpmTDf92RfSpuiLr+NFKWON2ARQ+KSk1pb1bd9WcTJ+7 +uAGAcb55mjW0+/CqjCmQWuejawS6aRHKpBI5sox1GFVha3XWd/cyHSlWROb4BO2U +fD/z5X+isjwuvMcvroYC7VqF+wihvXVKbP7ymh/apQnwnH9nqYOS8xRiw59MlVlS +ebsH4U+zlxF27o7BeHJSJgppoLZl+IMjysCb+35Yd5l9fLzrKTukhXN3s3AqBRHC +YkGYOJk10x6bsf6JRpU5rMKin5+bVwvHt/UXBXlvplk0bCkDdagrerWwyOF9t+dQ +NPcM34VBlV8l4rzDwxfKyfx+2jaQkiBDslFlPpYyBUtZfl5YCaXAHcVvin5sfSbG +X24tStUqWFWHZ3p7COGaZerTb3HApBu/NPlPzkTkxbcHEyF3mqA+/zgnhYeuaqs/ +uABYUaUZk9ylA3rzHh0PA+ugJDSsoicCEWCmgsv9v8liqXVEogRmW2R09vH9TVIp +PhU0l09GYDzhnNMdOQ7Jqsno5+bztmTpBGuyyDVi3hx1yOF8er8YYt8= +-----END CERTIFICATE----- diff --git a/springboot-mutual-cert/src/main/resources/root-ca-key.pem b/springboot-mutual-cert/src/main/resources/root-ca-key.pem new file mode 100644 index 0000000..e39f5b2 --- /dev/null +++ b/springboot-mutual-cert/src/main/resources/root-ca-key.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKAIBAAKCAgEA2Au3l4C0LUo/PGyZQAXrFERSiVsACEJLfEUEG+7TAvmyyEft +1kW9Bpk1DRDBegBH6S2fPy+LpJG+76C0AcvkRx+o+QIBHi+mtn1QoGGQZI2B/FdD +AxGpjsNhcr1xlo/kwYQwy33IL+r6460c/KfXJdD2yBrqrTylaQJS6Z/hqlVGj4Ob +UI0kgkfpyn1DTZbKwqTGhePlXrhKQllKpHKbMvZihVAnr1DiwUfokpNDzCqX8JSp +qu5TPMo7IgSTxsm2RiuyzGQatCPjn34dzesFw8jQRiaJLhUd7gfWrcS20irIG2I+ +WJVkfscprlyl5INJNwADXuk7tjAgl7CdhwQSkVVPqWbc00c7z79xIO+KjNW+WJ00 ++22UddQwKvJIxgA2MeP+uFrFtnu9ZZM+P04TVlDB3OvA9w64IlE9sN6S6XcYZN50 +7bqfMtkxjAQm4YAUaTSQiPFx93RzUqf3F3zcPzykyhn85wwKI+BkKTn1D6niUg9A +ebLDeM4DKlTrs3VPgZXtfs/mB9f7DK306QQqyugMykVd+K8mYFQncBVPgCl/Ba5N +p5GmoochFtqJ47mNPyAUIbwHR++i7Mom+3t67+lr0CeoONMWjMOHk1hDcoYgpRzQ +EEZRrl/TbmObdPBI9SuCDWZj03FKr/OwuMcKrDkdERCXRSfEBRuokkOwIeUCAwEA +AQKCAgBIPZLELBsTUdJXSBDuYYw7mKTonO8j09cd1I4NMQyJ4Cix46tZjLQqMqyU +k9e+Db398G1hWWqeOsXXpqrKNv078xAzQ0JQb6qVNs3w8u6vUMn4MM2NhyhlPlul +XEdRCwh41NTkFkkMDMybuNUKfqzoTjlWq/lwt+ivdkF3MSjqJd2UO3OBudBNZ/J6 +7OvGU/e0ohhnyM53n7Pk/6p/1nqizdQfs6+xZaCM9JiF+owfBtcLcQpSx6I5n32q +YFFxlR1H1XDR+18agS2ptSgOJNomn01VR6lzKUh6wVA9hpuDJx8GWRFz2XBjHAGJ +9hzajjO7GlwGLoLy5qDfWAU0kl4KcWW3gMONQPYWob1F1/OSBkDWAxZLE1yg0712 +SGSr+oMRYeOHAMJkqmfq4oQQn50l0++ibvJzcETlKm3YMyhecBnTgRVdJLiRtbYw +J6Iry+uptp0cSz1Z7Cu7VqIesQsuehw1Lk2Eq/lCdeVvOUmPXsg1x9Jac+AFhH0K +g2kIiH48/DXsPsEEEzKVFjdx3lJveH9ubiQewcfQpJf8xb9KlzqMgVJPQVqXSYd2 +mdtqRwGSEqaObg42a6cxYvDofRgJvYAtnlU25ue3u2XZ2v8+DFbJFq+ZeaCGzX3l +E+HUet2Wy8zVZssDeJwlnhKlqDZYpMeXTEhNbe38Wo1zJ76IwQKCAQEA8GkZzV5A +uH1+TwL34whj4eXzxv0pqwxSz8ja2PpsR86FLGJbpxiNxLNAdYI+yP00UK9hdMq0 +juT6Bio1YcwY6DUhkItm4qNEV4rV93M3BOZJtdiiTVidVq72YsVeDaVs0q5n6e1z +npvMllD95TxEdUPItTuYv6q2TBzo9b2oS5KsncoRT7cfv/Ne5gdBcFOme1yxVVmm +zTvoYfJ40qpruKZnitldplhHfeyEe4fi8gjiwEMo+uznYKOhfdyqRsOtwgEKqRX/ +Wgw3RfRzg8ZRiXRrffJz4OxUsEYgQEem/iDwrpgrkMVqwaGjCj8JgC+MrJkRrWDI +x9O4Z42TwqMLiQKCAQEA5g4nCjC5JiZ03HGHlsKrHaIvqT0ZBjiOfNGMixnqzByt +YjRbt5vOZ1CMlytvbszAuRk08fahC5WxcryJkHFImOJtKYA45nMnRyAcwpZTzKgu +X+o3bgnrDJM7RqYtwCAYI/qk4tt6U2d9f9w0M2s9o4KQLG8omsBtaNi9PiX70YGE +Q7vfdnL02SBTGIE4k9w29vgeMxC3NqyMgHCfKFYPA6cQz5FYzlYg26j4UQM3Zk0L +phyEuKNC+zNiQ2iUkIHQqCFxJOnfPUqN5tWshLwDP9upKrEHyUz0B0yafo2sSyYy +91h5POgMotx+1R1AB8vbRBWUAf/pJ7VNU/ztkxeAfQKCAQEA4ltzCeS2t36hUK+Q +ytj5gpbK4w83DnA6AJ4zQJz5GtselN2/QiNiSFQmWv3ZM9EEUvvunNLHEswRhYB0 +ZrKOuQRdqAU5SCdFj8+PCsAWi6xwtqFUn9LRwe5W2kTO+7ZIMk44VQ9YD3zOMrHr +fM0z/91kuw90EPMhVaTay5ZZQV7G3IKHrjDT2h3BuoAWYza/x+NMrPoOjarccGym +ymPfrSowz5E+FgOEvNHXI6CcVBt9tF0H1sr8SAeJZEJCqQJRNhtY+D9YAGcEG//A +S9CMsQlGtH12Ec8zJg3BDATq/NfzBdENI/BdRhd0tY8I2QLsRw6QkFhSc6OrOwUY +nOh7UQKCAQAmVSVcJVI0cSP3t1MIY9dvUJ7wbCXHS5UyZxgr9V2SNRUOz/qYVXXG +8Tz701j19VgHf5O63YVoEMFIhPHHB5k5IEFgMOVKQNXCnC8unS3JZByWDsi9pRlt +Nvshgn8NDEv5csIWqstvKkdXDrID/1J99DthrAPwBTA10Cd4O4wCFLqdLqjFa9Iw +e5pc8usieAcQj7c4ewiMK6QdoqZiajSGP0glzeomN2OyNi1qEkcg3KWcQBQ9T7bR +dHZjFQHsMjU6TpgztmRkKhAK7n+YfltsQIWnf2f0usXOkY0MmT6kJvFHFY7d/yxb +1rGrgPwyUF1wsse+rY2D+EmyPOq5H6lhAoIBAD7f7ZXmWI4y1Z93Pvdk8/tFweui +AYw8UmvZp/KCDEmJQ2mZeoo2dYcproyCpbFlZaOXE3h3lrCYYMeUt2OFxCqQIDYb +8o0gVrsZs5kBcs5w8yH7fERjadxD8UQ/um/K7+dvsOwZ0L10q2F9hrDXN5qGwgic +y1BZYA6XZKdGJ9jE+PsXXHUTSewy3qUNFQjKi6W24qlZd0WjeEJ9MyIh/D84pVM5 +Ltq3EqBC9yIInXCYRR+W25bmjdg6G3+l7+vInt0wU83VN2R1ZiZyWuGmM/pPwGwi +S01ETxfzhZ2ys9U9BDcC9SkiR0nki9leuqArvHuZblJngBckZY8JdxPHhNA= +-----END RSA PRIVATE KEY----- diff --git a/springboot-mutual-cert/src/main/resources/server.jks b/springboot-mutual-cert/src/main/resources/server.jks new file mode 100644 index 0000000..46ff9f1 Binary files /dev/null and b/springboot-mutual-cert/src/main/resources/server.jks differ diff --git a/springboot-mutual-cert/src/main/resources/truststore.jks b/springboot-mutual-cert/src/main/resources/truststore.jks new file mode 100644 index 0000000..e55c984 Binary files /dev/null and b/springboot-mutual-cert/src/main/resources/truststore.jks differ 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接口的响应限速
  • +
+
+
+
+
+ + + + 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" +} 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 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 配置比对

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