From 736579572a116fea1d48c46f84ecb871b75efd68 Mon Sep 17 00:00:00 2001 From: yuoon Date: Sat, 27 Sep 2025 16:57:02 +0800 Subject: [PATCH 01/22] exception group --- springboot-exception-group/pom.xml | 80 ++++ .../ExceptionGroupApplication.java | 12 + .../appender/EnhancedLoggingEvent.java | 145 ++++++ .../appender/ErrorFingerprintAppender.java | 161 +++++++ .../cache/ErrorFingerprintCache.java | 154 +++++++ .../controller/ErrorStatsController.java | 178 ++++++++ .../ErrorFingerprintGenerator.java | 127 ++++++ .../exceptiongroup/service/TestService.java | 77 ++++ .../exceptiongroup/util/TraceIdGenerator.java | 43 ++ .../src/main/resources/application.properties | 12 + .../src/main/resources/logback-spring.xml | 54 +++ .../src/main/resources/static/css/styles.css | 414 ++++++++++++++++++ .../src/main/resources/static/index.html | 94 ++++ .../src/main/resources/static/js/app.js | 387 ++++++++++++++++ 14 files changed, 1938 insertions(+) create mode 100644 springboot-exception-group/pom.xml create mode 100644 springboot-exception-group/src/main/java/com/example/exceptiongroup/ExceptionGroupApplication.java create mode 100644 springboot-exception-group/src/main/java/com/example/exceptiongroup/appender/EnhancedLoggingEvent.java create mode 100644 springboot-exception-group/src/main/java/com/example/exceptiongroup/appender/ErrorFingerprintAppender.java create mode 100644 springboot-exception-group/src/main/java/com/example/exceptiongroup/cache/ErrorFingerprintCache.java create mode 100644 springboot-exception-group/src/main/java/com/example/exceptiongroup/controller/ErrorStatsController.java create mode 100644 springboot-exception-group/src/main/java/com/example/exceptiongroup/fingerprint/ErrorFingerprintGenerator.java create mode 100644 springboot-exception-group/src/main/java/com/example/exceptiongroup/service/TestService.java create mode 100644 springboot-exception-group/src/main/java/com/example/exceptiongroup/util/TraceIdGenerator.java create mode 100644 springboot-exception-group/src/main/resources/application.properties create mode 100644 springboot-exception-group/src/main/resources/logback-spring.xml create mode 100644 springboot-exception-group/src/main/resources/static/css/styles.css create mode 100644 springboot-exception-group/src/main/resources/static/index.html create mode 100644 springboot-exception-group/src/main/resources/static/js/app.js diff --git a/springboot-exception-group/pom.xml b/springboot-exception-group/pom.xml new file mode 100644 index 0000000..88ef5cb --- /dev/null +++ b/springboot-exception-group/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + com.example + exception-group + 1.0.0 + jar + + Spring Boot Exception Group Demo + Error fingerprint clustering system demo + + + 17 + 3.2.0 + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + + + ch.qos.logback + logback-classic + 1.4.11 + + + + + org.apache.commons + commons-lang3 + 3.12.0 + + + + commons-codec + commons-codec + 1.15 + + + + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + UTF-8 + true + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + \ No newline at end of file diff --git a/springboot-exception-group/src/main/java/com/example/exceptiongroup/ExceptionGroupApplication.java b/springboot-exception-group/src/main/java/com/example/exceptiongroup/ExceptionGroupApplication.java new file mode 100644 index 0000000..57cebd3 --- /dev/null +++ b/springboot-exception-group/src/main/java/com/example/exceptiongroup/ExceptionGroupApplication.java @@ -0,0 +1,12 @@ +package com.example.exceptiongroup; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ExceptionGroupApplication { + + public static void main(String[] args) { + SpringApplication.run(ExceptionGroupApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/java/com/example/exceptiongroup/appender/EnhancedLoggingEvent.java b/springboot-exception-group/src/main/java/com/example/exceptiongroup/appender/EnhancedLoggingEvent.java new file mode 100644 index 0000000..f6cb6af --- /dev/null +++ b/springboot-exception-group/src/main/java/com/example/exceptiongroup/appender/EnhancedLoggingEvent.java @@ -0,0 +1,145 @@ +package com.example.exceptiongroup.appender; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.LoggerContextVO; +import com.example.exceptiongroup.cache.ErrorFingerprintCache; +import org.slf4j.Marker; +import org.slf4j.event.KeyValuePair; + +import java.util.List; +import java.util.Map; + +/** + * 增强的日志事件,包含指纹信息 + */ +public class EnhancedLoggingEvent implements ILoggingEvent { + + private final ILoggingEvent originalEvent; + private final String fingerprint; + private final ErrorFingerprintCache.ErrorFingerprint fpInfo; + private final String traceId; + private final String enhancedMessage; + + public EnhancedLoggingEvent(ILoggingEvent originalEvent, String fingerprint, + ErrorFingerprintCache.ErrorFingerprint fpInfo, String traceId) { + this.originalEvent = originalEvent; + this.fingerprint = fingerprint; + this.fpInfo = fpInfo; + this.traceId = traceId; + this.enhancedMessage = buildEnhancedMessage(); + } + + private String buildEnhancedMessage() { + StringBuilder sb = new StringBuilder(); + sb.append("[FINGERPRINT:").append(fingerprint.substring(0, 8)).append("]"); + sb.append("[COUNT:").append(fpInfo.getCount()).append("]"); + sb.append("[TRACE:").append(traceId).append("]"); + sb.append(" ").append(originalEvent.getFormattedMessage()); + + if (fpInfo.getCount() > 1) { + sb.append(" [SIMILAR_ERRORS:").append(fpInfo.getCount()).append("]"); + sb.append("[FIRST_SEEN:").append(fpInfo.getFirstOccurrence()).append("]"); + } + + return sb.toString(); + } + + @Override + public String getFormattedMessage() { + return enhancedMessage; + } + + @Override + public String getMessage() { + return enhancedMessage; + } + + // 委托给原始事件的方法 + @Override + public String getThreadName() { + return originalEvent.getThreadName(); + } + + @Override + public Level getLevel() { + return originalEvent.getLevel(); + } + + @Override + public String getLoggerName() { + return originalEvent.getLoggerName(); + } + + @Override + public LoggerContextVO getLoggerContextVO() { + return originalEvent.getLoggerContextVO(); + } + + @Override + public IThrowableProxy getThrowableProxy() { + return originalEvent.getThrowableProxy(); + } + + @Override + public Object[] getArgumentArray() { + return originalEvent.getArgumentArray(); + } + + @Override + public long getTimeStamp() { + return originalEvent.getTimeStamp(); + } + + @Override + public Marker getMarker() { + return originalEvent.getMarker(); + } + + @Override + public List getMarkerList() { + return originalEvent.getMarkerList(); + } + + @Override + public Map getMDCPropertyMap() { + return originalEvent.getMDCPropertyMap(); + } + + @Override + public Map getMdc() { + return originalEvent.getMdc(); + } + + @Override + public void prepareForDeferredProcessing() { + originalEvent.prepareForDeferredProcessing(); + } + + // 新版本Logback接口的额外方法实现 + @Override + public List getKeyValuePairs() { + return originalEvent.getKeyValuePairs(); + } + + @Override + public int getNanoseconds() { + return originalEvent.getNanoseconds(); + } + + @Override + public long getSequenceNumber() { + return originalEvent.getSequenceNumber(); + } + + @Override + public boolean hasCallerData() { + return originalEvent.hasCallerData(); + } + + @Override + public StackTraceElement[] getCallerData() { + return originalEvent.getCallerData(); + } +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/java/com/example/exceptiongroup/appender/ErrorFingerprintAppender.java b/springboot-exception-group/src/main/java/com/example/exceptiongroup/appender/ErrorFingerprintAppender.java new file mode 100644 index 0000000..6eca039 --- /dev/null +++ b/springboot-exception-group/src/main/java/com/example/exceptiongroup/appender/ErrorFingerprintAppender.java @@ -0,0 +1,161 @@ +package com.example.exceptiongroup.appender; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import com.example.exceptiongroup.cache.ErrorFingerprintCache; +import com.example.exceptiongroup.fingerprint.ErrorFingerprintGenerator; +import com.example.exceptiongroup.util.TraceIdGenerator; + +/** + * 自定义Logback Appender + * 实现错误指纹聚类和智能日志输出 + */ +public class ErrorFingerprintAppender extends AppenderBase { + + private ErrorFingerprintGenerator fingerprintGenerator; + private ErrorFingerprintCache fingerprintCache; + private TraceIdGenerator traceIdGenerator; + + // 委托给控制台输出 + private ConsoleAppender consoleAppender; + private Encoder encoder; + + @Override + public void start() { + // 使用单例实例,确保与Spring容器中的Bean是同一个 + this.fingerprintGenerator = new ErrorFingerprintGenerator(); + this.fingerprintCache = ErrorFingerprintCache.getInstance(); + this.traceIdGenerator = new TraceIdGenerator(); + + System.out.println("=== ErrorFingerprintAppender 启动 ==="); + System.out.println("Cache instance: " + this.fingerprintCache); + + // 初始化控制台输出 + this.consoleAppender = new ConsoleAppender<>(); + this.consoleAppender.setContext(getContext()); + this.consoleAppender.setName("console-delegate"); + + if (encoder != null) { + this.consoleAppender.setEncoder(encoder); + } + + this.consoleAppender.start(); + super.start(); + } + + @Override + protected void append(ILoggingEvent event) { + if (!isStarted()) { + return; + } + + // 生成或获取TraceId + String traceId = traceIdGenerator.getCurrentTraceId(); + + System.out.println("=== Appender处理日志事件 ==="); + System.out.println("Level: " + event.getLevel()); + System.out.println("Message: " + event.getMessage()); + System.out.println("HasException: " + (event.getThrowableProxy() != null)); + + if (isErrorEvent(event)) { + System.out.println("处理错误事件..."); + handleErrorEvent(event, traceId); + } else { + // 非错误日志直接输出 + consoleAppender.doAppend(event); + } + } + + /** + * 处理错误事件 + */ + private void handleErrorEvent(ILoggingEvent event, String traceId) { + IThrowableProxy throwableProxy = event.getThrowableProxy(); + if (throwableProxy == null) { + consoleAppender.doAppend(event); + return; + } + + // 转换为Throwable对象用于指纹生成 + Throwable throwable = convertToThrowable(throwableProxy); + if (throwable == null) { + consoleAppender.doAppend(event); + return; + } + + // 生成错误指纹 + String fingerprint = fingerprintGenerator.generateFingerprint(throwable); + + // 检查是否应该输出日志 + if (fingerprintCache.shouldLog(fingerprint, traceId)) { + // 创建增强的日志事件,包含指纹和统计信息 + ErrorFingerprintCache.ErrorFingerprint fpInfo = fingerprintCache.getFingerprint(fingerprint); + ILoggingEvent enhancedEvent = createEnhancedEvent(event, fingerprint, fpInfo, traceId); + consoleAppender.doAppend(enhancedEvent); + } + // 不输出的情况:已经记录过且未达到阈值 + } + + /** + * 判断是否为错误事件 + */ + private boolean isErrorEvent(ILoggingEvent event) { + return event.getThrowableProxy() != null; + } + + /** + * 转换IThrowableProxy为Throwable + */ + private Throwable convertToThrowable(IThrowableProxy throwableProxy) { + try { + String className = throwableProxy.getClassName(); + String message = throwableProxy.getMessage(); + + // 创建异常实例 + Class exceptionClass = Class.forName(className); + Throwable throwable = (Throwable) exceptionClass.getDeclaredConstructor(String.class).newInstance(message); + + // 设置堆栈轨迹 + StackTraceElementProxy[] proxyArray = throwableProxy.getStackTraceElementProxyArray(); + if (proxyArray != null) { + StackTraceElement[] stackTrace = new StackTraceElement[proxyArray.length]; + for (int i = 0; i < proxyArray.length; i++) { + stackTrace[i] = proxyArray[i].getStackTraceElement(); + } + throwable.setStackTrace(stackTrace); + } + + return throwable; + } catch (Exception e) { + // 如果转换失败,创建一个通用异常 + RuntimeException genericException = new RuntimeException(throwableProxy.getMessage()); + return genericException; + } + } + + /** + * 创建增强的日志事件 + */ + private ILoggingEvent createEnhancedEvent(ILoggingEvent originalEvent, String fingerprint, + ErrorFingerprintCache.ErrorFingerprint fpInfo, String traceId) { + // 创建包装的日志事件,添加指纹信息 + return new EnhancedLoggingEvent(originalEvent, fingerprint, fpInfo, traceId); + } + + // Setter for encoder + public void setEncoder(Encoder encoder) { + this.encoder = encoder; + } + + @Override + public void stop() { + if (consoleAppender != null) { + consoleAppender.stop(); + } + super.stop(); + } +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/java/com/example/exceptiongroup/cache/ErrorFingerprintCache.java b/springboot-exception-group/src/main/java/com/example/exceptiongroup/cache/ErrorFingerprintCache.java new file mode 100644 index 0000000..7ba9642 --- /dev/null +++ b/springboot-exception-group/src/main/java/com/example/exceptiongroup/cache/ErrorFingerprintCache.java @@ -0,0 +1,154 @@ +package com.example.exceptiongroup.cache; + +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * LRU指纹缓存系统 - 单例模式 + * 确保Logback Appender和Spring Controller使用同一个缓存实例 + */ +@Component +public class ErrorFingerprintCache { + + private static ErrorFingerprintCache INSTANCE; + + private static final int DEFAULT_CAPACITY = 1000; + private static final int DEFAULT_LOG_THRESHOLD = 10; + private static final int MAX_RECENT_TRACES = 5; + + private final int capacity; + private final int logThreshold; + private final Map cache; + + public ErrorFingerprintCache() { + this.capacity = DEFAULT_CAPACITY; + this.logThreshold = DEFAULT_LOG_THRESHOLD; + this.cache = Collections.synchronizedMap(new LinkedHashMap(capacity, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > capacity; + } + }); + // 设置单例实例 + INSTANCE = this; + } + + /** + * 获取单例实例(供Logback Appender使用) + */ + public static ErrorFingerprintCache getInstance() { + if (INSTANCE == null) { + synchronized (ErrorFingerprintCache.class) { + if (INSTANCE == null) { + INSTANCE = new ErrorFingerprintCache(); + } + } + } + return INSTANCE; + } + + /** + * 检查是否应该记录日志 + */ + public boolean shouldLog(String fingerprint, String traceId) { + return shouldLog(fingerprint, traceId, null, null); + } + + /** + * 检查是否应该记录日志(重载方法,支持异常信息) + */ + public boolean shouldLog(String fingerprint, String traceId, String exceptionType, String stackTrace) { + ErrorFingerprint errorInfo = cache.computeIfAbsent(fingerprint, + k -> new ErrorFingerprint(fingerprint, traceId, exceptionType, stackTrace)); + + long count = errorInfo.incrementAndGet(); + errorInfo.setLastOccurrence(LocalDateTime.now()); + errorInfo.addRecentTraceId(traceId); + + // 首次出现或达到阈值时记录日志 + return count == 1 || count % logThreshold == 0; + } + + /** + * 获取所有错误指纹统计 + */ + public List getAllFingerprints() { + synchronized (cache) { + return new ArrayList<>(cache.values()); + } + } + + /** + * 根据指纹获取详细信息 + */ + public ErrorFingerprint getFingerprint(String fingerprint) { + return cache.get(fingerprint); + } + + /** + * 清空缓存 + */ + public void clear() { + cache.clear(); + } + + /** + * 获取缓存大小 + */ + public int size() { + return cache.size(); + } + + /** + * 错误指纹信息类 + */ + public static class ErrorFingerprint { + private final String fingerprint; + private final LocalDateTime firstOccurrence; + private volatile LocalDateTime lastOccurrence; + private final AtomicLong count = new AtomicLong(0); + private final String sampleTraceId; + private final String exceptionType; + private final String stackTrace; + private final Queue recentTraceIds = new ArrayDeque<>(MAX_RECENT_TRACES); + + public ErrorFingerprint(String fingerprint, String sampleTraceId) { + this(fingerprint, sampleTraceId, null, null); + } + + public ErrorFingerprint(String fingerprint, String sampleTraceId, String exceptionType, String stackTrace) { + this.fingerprint = fingerprint; + this.sampleTraceId = sampleTraceId; + this.exceptionType = exceptionType; + this.stackTrace = stackTrace; + this.firstOccurrence = LocalDateTime.now(); + this.lastOccurrence = this.firstOccurrence; + } + + public long incrementAndGet() { + return count.incrementAndGet(); + } + + public synchronized void addRecentTraceId(String traceId) { + if (recentTraceIds.size() >= MAX_RECENT_TRACES) { + recentTraceIds.poll(); + } + recentTraceIds.offer(traceId); + } + + // Getters + public String getFingerprint() { return fingerprint; } + public LocalDateTime getFirstOccurrence() { return firstOccurrence; } + public LocalDateTime getLastOccurrence() { return lastOccurrence; } + public void setLastOccurrence(LocalDateTime lastOccurrence) { this.lastOccurrence = lastOccurrence; } + public long getCount() { return count.get(); } + public String getSampleTraceId() { return sampleTraceId; } + public String getExceptionType() { return exceptionType; } + public String getStackTrace() { return stackTrace; } + public synchronized List getRecentTraceIds() { return new ArrayList<>(recentTraceIds); } + } +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/java/com/example/exceptiongroup/controller/ErrorStatsController.java b/springboot-exception-group/src/main/java/com/example/exceptiongroup/controller/ErrorStatsController.java new file mode 100644 index 0000000..8cc1558 --- /dev/null +++ b/springboot-exception-group/src/main/java/com/example/exceptiongroup/controller/ErrorStatsController.java @@ -0,0 +1,178 @@ +package com.example.exceptiongroup.controller; + +import com.example.exceptiongroup.cache.ErrorFingerprintCache; +import com.example.exceptiongroup.fingerprint.ErrorFingerprintGenerator; +import com.example.exceptiongroup.util.TraceIdGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 错误统计REST API控制器 + */ +@RestController +@RequestMapping("/api/error-stats") +@CrossOrigin(origins = "*") +public class ErrorStatsController { + + private static final Logger logger = LoggerFactory.getLogger(ErrorStatsController.class); + + @Autowired + private ErrorFingerprintCache fingerprintCache; + + @Autowired + private TraceIdGenerator traceIdGenerator; + + /** + * 获取所有错误指纹统计 + */ + @GetMapping("/fingerprints") + public ResponseEntity> getAllFingerprints() { + logger.info("Getting all fingerprints, cache size: {}", fingerprintCache.size()); + List fingerprints = fingerprintCache.getAllFingerprints(); + logger.info("Returning {} fingerprints", fingerprints.size()); + return ResponseEntity.ok(fingerprints); + } + + /** + * 根据指纹ID获取详细信息 + */ + @GetMapping("/fingerprints/{fingerprint}") + public ResponseEntity getFingerprint(@PathVariable("fingerprint") String fingerprint) { + ErrorFingerprintCache.ErrorFingerprint fp = fingerprintCache.getFingerprint(fingerprint); + if (fp != null) { + return ResponseEntity.ok(fp); + } else { + return ResponseEntity.notFound().build(); + } + } + + /** + * 获取错误统计概览 + */ + @GetMapping("/overview") + public ResponseEntity> getOverview() { + List fingerprints = fingerprintCache.getAllFingerprints(); + + Map overview = new HashMap<>(); + overview.put("totalFingerprints", fingerprints.size()); + overview.put("totalErrors", fingerprints.stream().mapToLong(fp -> fp.getCount()).sum()); + overview.put("cacheCapacity", fingerprintCache.size()); + overview.put("timestamp", LocalDateTime.now()); + + logger.info("Overview: {} fingerprints, {} total errors", fingerprints.size(), + fingerprints.stream().mapToLong(fp -> fp.getCount()).sum()); + + // 统计最频繁的错误 + ErrorFingerprintCache.ErrorFingerprint mostFrequent = fingerprints.stream() + .max((a, b) -> Long.compare(a.getCount(), b.getCount())) + .orElse(null); + + if (mostFrequent != null) { + Map mostFrequentInfo = new HashMap<>(); + mostFrequentInfo.put("fingerprint", mostFrequent.getFingerprint()); + mostFrequentInfo.put("count", mostFrequent.getCount()); + overview.put("mostFrequentError", mostFrequentInfo); + } + + return ResponseEntity.ok(overview); + } + + /** + * 清空错误统计缓存 + */ + @DeleteMapping("/clear") + public ResponseEntity> clearCache() { + fingerprintCache.clear(); + Map result = new HashMap<>(); + result.put("status", "success"); + result.put("message", "Error fingerprint cache cleared"); + return ResponseEntity.ok(result); + } + + @PostMapping("/simulate/{errorType}") + public ResponseEntity> simulateError(@PathVariable("errorType") String errorType) { + String traceId = traceIdGenerator.generateTraceId(); + Map result = new HashMap<>(); + + logger.info("Simulating error type: {} with traceId: {}", errorType, traceId); + + try { + switch (errorType.toLowerCase()) { + case "npe": + simulateNullPointerException(); + break; + case "iae": + simulateIllegalArgumentException(); + break; + case "ioexception": + simulateIOException(); + break; + default: + simulateGenericException(); + } + } catch (Exception e) { + // 既记录日志又直接处理,确保数据被添加 + logger.error("Simulated error occurred for type: " + errorType, e); + + // 直接调用指纹处理,绕过Logback问题 + try { + ErrorFingerprintGenerator generator = new ErrorFingerprintGenerator(); + String fingerprintStr = generator.generateFingerprint(e); + + // 获取堆栈跟踪信息 + String stackTrace = getStackTraceString(e); + + ErrorFingerprintCache.getInstance().shouldLog( + fingerprintStr, traceId, e.getClass().getSimpleName(), stackTrace); + } catch (Exception ex) { + logger.warn("Failed to process fingerprint directly: " + ex.getMessage()); + } + + result.put("status", "error_simulated"); + result.put("errorType", e.getClass().getSimpleName()); + result.put("traceId", traceId); + result.put("message", e.getMessage()); + result.put("cacheSize", String.valueOf(fingerprintCache.size())); + return ResponseEntity.ok(result); + } + + result.put("status", "no_error_occurred"); + result.put("traceId", traceId); + return ResponseEntity.ok(result); + } + + private void simulateNullPointerException() { + String nullString = null; + nullString.length(); // 故意触发NPE + } + + private void simulateIllegalArgumentException() { + throw new IllegalArgumentException("Invalid argument provided for simulation"); + } + + private void simulateIOException() throws java.io.IOException { + throw new java.io.IOException("Simulated IO operation failed"); + } + + private void simulateGenericException() { + throw new RuntimeException("Generic runtime exception for testing"); + } + + /** + * 获取异常的堆栈跟踪字符串 + */ + private String getStackTraceString(Throwable e) { + java.io.StringWriter sw = new java.io.StringWriter(); + java.io.PrintWriter pw = new java.io.PrintWriter(sw); + e.printStackTrace(pw); + return sw.toString(); + } +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/java/com/example/exceptiongroup/fingerprint/ErrorFingerprintGenerator.java b/springboot-exception-group/src/main/java/com/example/exceptiongroup/fingerprint/ErrorFingerprintGenerator.java new file mode 100644 index 0000000..efc2289 --- /dev/null +++ b/springboot-exception-group/src/main/java/com/example/exceptiongroup/fingerprint/ErrorFingerprintGenerator.java @@ -0,0 +1,127 @@ +package com.example.exceptiongroup.fingerprint; + +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +/** + * 错误指纹生成器 + * 基于异常类型、发生位置、调用链生成唯一指纹 + */ +@Component +public class ErrorFingerprintGenerator { + + /** + * 生成错误指纹 + */ + public String generateFingerprint(Throwable throwable) { + if (throwable == null) { + return ""; + } + + // 获取异常发生的根本位置 + StackTraceElement rootCause = getRootCauseLocation(throwable); + if (rootCause == null) { + return DigestUtils.md5Hex(throwable.getClass().getSimpleName()); + } + + StringBuilder fingerprint = new StringBuilder() + .append(throwable.getClass().getSimpleName()) + .append("|") + .append(rootCause.getClassName()) + .append("#") + .append(rootCause.getMethodName()) + .append(":") + .append(rootCause.getLineNumber()); + + // 过滤异常消息中的动态值,保留结构特征 + String filteredMessage = filterDynamicValues(throwable.getMessage()); + if (StringUtils.isNotBlank(filteredMessage)) { + fingerprint.append("|").append(filteredMessage); + } + + // 注释掉调用链哈希,避免相同错误产生不同指纹 + // String callChainHash = getCallChainHash(throwable.getStackTrace(), 3); + // if (StringUtils.isNotBlank(callChainHash)) { + // fingerprint.append("|").append(callChainHash); + // } + + return DigestUtils.md5Hex(fingerprint.toString()); + } + + /** + * 获取异常的根本发生位置 + */ + private StackTraceElement getRootCauseLocation(Throwable throwable) { + StackTraceElement[] stackTrace = throwable.getStackTrace(); + if (stackTrace == null || stackTrace.length == 0) { + return null; + } + + // 找到第一个非框架代码的位置 + for (StackTraceElement element : stackTrace) { + String className = element.getClassName(); + if (!isFrameworkClass(className)) { + return element; + } + } + + // 如果都是框架代码,返回第一个 + return stackTrace[0]; + } + + /** + * 判断是否为框架代码 + */ + private boolean isFrameworkClass(String className) { + return className.startsWith("java.") || + className.startsWith("javax.") || + className.startsWith("org.springframework.") || + className.startsWith("org.apache.") || + className.startsWith("com.sun."); + } + + /** + * 过滤消息中的动态值 + */ + private String filterDynamicValues(String message) { + if (message == null) { + return ""; + } + + return message + // 过滤数字ID + .replaceAll("\\b\\d{4,}\\b", "NUM") + // 过滤UUID + .replaceAll("\\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\b", "UUID") + // 过滤时间戳 + .replaceAll("\\b\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2}\\b", "TIMESTAMP") + // 过滤IP地址 + .replaceAll("\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b", "IP") + // 限制长度 + .substring(0, Math.min(message.length(), 100)); + } + + /** + * 生成调用链特征哈希 + */ + private String getCallChainHash(StackTraceElement[] stackTrace, int depth) { + if (stackTrace == null || stackTrace.length == 0) { + return ""; + } + + StringBuilder callChain = new StringBuilder(); + int actualDepth = Math.min(depth, stackTrace.length); + + for (int i = 0; i < actualDepth; i++) { + StackTraceElement element = stackTrace[i]; + callChain.append(element.getClassName()) + .append("#") + .append(element.getMethodName()) + .append("|"); + } + + return callChain.length() > 0 ? + DigestUtils.md5Hex(callChain.toString()).substring(0, 8) : ""; + } +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/java/com/example/exceptiongroup/service/TestService.java b/springboot-exception-group/src/main/java/com/example/exceptiongroup/service/TestService.java new file mode 100644 index 0000000..da46593 --- /dev/null +++ b/springboot-exception-group/src/main/java/com/example/exceptiongroup/service/TestService.java @@ -0,0 +1,77 @@ +package com.example.exceptiongroup.service; + +import com.example.exceptiongroup.util.TraceIdGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 用于测试错误指纹功能的服务类 + */ +@Service +public class TestService { + + private static final Logger logger = LoggerFactory.getLogger(TestService.class); + + @Autowired + private TraceIdGenerator traceIdGenerator; + + /** + * 模拟用户服务中的空指针异常 + */ + public void simulateUserServiceError() { + String traceId = traceIdGenerator.getCurrentTraceId(); + logger.info("Starting user service operation with traceId: {}", traceId); + + try { + // 模拟空指针异常 + String nullString = null; + int length = nullString.length(); // 第45行 + } catch (Exception e) { + logger.error("Error in user service operation", e); + throw e; + } + } + + /** + * 模拟订单服务中的参数异常 + */ + public void simulateOrderServiceError() { + String traceId = traceIdGenerator.getCurrentTraceId(); + logger.info("Starting order calculation with traceId: {}", traceId); + + try { + // 模拟参数异常 + calculateOrderTotal(-100); // 第59行 + } catch (Exception e) { + logger.error("Error in order calculation", e); + throw e; + } + } + + private void calculateOrderTotal(double amount) { + if (amount < 0) { + throw new IllegalArgumentException("Order amount cannot be negative: " + amount); + } + } + + /** + * 模拟数据访问层的IO异常 + */ + public void simulateDataAccessError() { + String traceId = traceIdGenerator.getCurrentTraceId(); + logger.info("Starting data access operation with traceId: {}", traceId); + + try { + // 模拟IO异常 + accessDatabase(); // 第77行 + } catch (Exception e) { + logger.error("Error in data access operation", e); + } + } + + private void accessDatabase() throws java.io.IOException { + throw new java.io.IOException("Database connection failed - connection timeout"); + } +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/java/com/example/exceptiongroup/util/TraceIdGenerator.java b/springboot-exception-group/src/main/java/com/example/exceptiongroup/util/TraceIdGenerator.java new file mode 100644 index 0000000..41efd1f --- /dev/null +++ b/springboot-exception-group/src/main/java/com/example/exceptiongroup/util/TraceIdGenerator.java @@ -0,0 +1,43 @@ +package com.example.exceptiongroup.util; + +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * TraceId生成器 + * 用于生成和管理链路追踪ID + */ +@Component +public class TraceIdGenerator { + + private static final String TRACE_ID_KEY = "traceId"; + + /** + * 生成新的TraceId + */ + public String generateTraceId() { + String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + MDC.put(TRACE_ID_KEY, traceId); + return traceId; + } + + /** + * 获取当前TraceId,如果不存在则生成新的 + */ + public String getCurrentTraceId() { + String traceId = MDC.get(TRACE_ID_KEY); + if (traceId == null || traceId.isEmpty()) { + traceId = generateTraceId(); + } + return traceId; + } + + /** + * 清除当前TraceId + */ + public void clearTraceId() { + MDC.remove(TRACE_ID_KEY); + } +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/resources/application.properties b/springboot-exception-group/src/main/resources/application.properties new file mode 100644 index 0000000..ce3df83 --- /dev/null +++ b/springboot-exception-group/src/main/resources/application.properties @@ -0,0 +1,12 @@ +server.port=8080 +server.servlet.context-path=/ + +logging.level.com.example.exceptiongroup=DEBUG +logging.level.org.springframework.web=INFO + +error-fingerprint.cache-size=1000 +error-fingerprint.log-threshold=10 +error-fingerprint.stack-depth=5 +error-fingerprint.enable-call-chain-hash=true + +spring.web.cors.enabled=true \ No newline at end of file diff --git a/springboot-exception-group/src/main/resources/logback-spring.xml b/springboot-exception-group/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..c0a5aaf --- /dev/null +++ b/springboot-exception-group/src/main/resources/logback-spring.xml @@ -0,0 +1,54 @@ + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{50} - %msg%n + UTF-8 + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{50} - %msg%n%ex + UTF-8 + + + + + + logs/application.log + + logs/application.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{50} - %msg%n%ex + UTF-8 + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/springboot-exception-group/src/main/resources/static/css/styles.css b/springboot-exception-group/src/main/resources/static/css/styles.css new file mode 100644 index 0000000..326bd05 --- /dev/null +++ b/springboot-exception-group/src/main/resources/static/css/styles.css @@ -0,0 +1,414 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px 30px; + border-radius: 12px; + margin-bottom: 30px; + box-shadow: 0 4px 15px rgba(0,0,0,0.1); +} + +.header h1 { + font-size: 2rem; + font-weight: 600; +} + +.header-controls { + display: flex; + gap: 12px; +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + outline: none; +} + +.btn-primary { + background-color: #007bff; + color: white; +} + +.btn-primary:hover { + background-color: #0056b3; + transform: translateY(-2px); +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; + transform: translateY(-2px); +} + +.btn-warning { + background-color: #ffc107; + color: #212529; +} + +.btn-warning:hover { + background-color: #e0a800; + transform: translateY(-2px); +} + +.btn-info { + background-color: #17a2b8; + color: white; + padding: 6px 12px; + font-size: 12px; +} + +.btn-info:hover { + background-color: #138496; +} + +/* Section styles */ +section { + background: white; + border-radius: 12px; + padding: 25px; + margin-bottom: 25px; + box-shadow: 0 2px 10px rgba(0,0,0,0.08); +} + +section h2 { + margin-bottom: 20px; + color: #2c3e50; + font-size: 1.5rem; + font-weight: 600; +} + +/* Overview Grid */ +.overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; +} + +.overview-card { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + color: white; + padding: 20px; + border-radius: 10px; + text-align: center; + box-shadow: 0 4px 15px rgba(240, 147, 251, 0.3); +} + +.card-title { + font-size: 14px; + opacity: 0.9; + margin-bottom: 8px; +} + +.card-value { + font-size: 2rem; + font-weight: bold; +} + +/* Simulation Controls */ +.simulation-controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 20px; +} + +.simulation-result { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 15px; + min-height: 60px; + display: none; +} + +.simulation-result.show { + display: block; +} + +.simulation-result.success { + background-color: #d4edda; + border-color: #c3e6cb; + color: #155724; +} + +.simulation-result.error { + background-color: #f8d7da; + border-color: #f5c6cb; + color: #721c24; +} + +/* Table Styles */ +.table-container { + overflow-x: auto; + border-radius: 8px; + border: 1px solid #dee2e6; +} + +.fingerprints-table { + width: 100%; + border-collapse: collapse; + background: white; +} + +.fingerprints-table th, +.fingerprints-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #dee2e6; +} + +.fingerprints-table th { + background-color: #f8f9fa; + font-weight: 600; + color: #495057; + font-size: 14px; +} + +.fingerprints-table td { + font-size: 13px; +} + +.fingerprints-table tbody tr:hover { + background-color: #f8f9fa; +} + +.fingerprint-short { + font-family: 'Courier New', monospace; + background-color: #e9ecef; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; +} + +.count-badge { + background-color: #28a745; + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.trace-id { + font-family: 'Courier New', monospace; + font-size: 11px; + background-color: #f1f3f4; + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + margin: 1px; +} + +.recent-traces { + display: flex; + flex-wrap: wrap; + gap: 2px; +} + +.datetime { + font-size: 11px; + color: #666; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); +} + +.modal-content { + background-color: white; + margin: 5% auto; + padding: 0; + border-radius: 12px; + width: 80%; + max-width: 600px; + max-height: 80vh; /* 限制最大高度为视窗高度的80% */ + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + animation: modalSlideIn 0.3s ease; + display: flex; + flex-direction: column; /* 使用flex布局 */ +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 30px; + border-bottom: 1px solid #dee2e6; + flex-shrink: 0; /* 头部不缩小 */ +} + +.modal-header h3 { + color: #2c3e50; + font-size: 1.3rem; +} + +.close { + font-size: 28px; + font-weight: bold; + cursor: pointer; + color: #aaa; + transition: color 0.3s; +} + +.close:hover { + color: #000; +} + +.modal-body { + padding: 30px; + overflow-y: auto; /* 添加垂直滚动条 */ + flex: 1; /* 占据剩余空间 */ +} + +.detail-item { + margin-bottom: 15px; +} + +.detail-label { + font-weight: 600; + color: #495057; + margin-bottom: 5px; +} + +.detail-value { + background-color: #f8f9fa; + padding: 10px; + border-radius: 6px; + font-family: 'Courier New', monospace; + font-size: 13px; + border: 1px solid #dee2e6; + word-break: break-word; /* 长单词自动换行 */ +} + +/* 堆栈跟踪特殊样式 */ +.detail-value.stack-trace { + white-space: pre-wrap; /* 保留换行和空格 */ + font-size: 12px; + max-height: 300px; /* 限制最大高度 */ + overflow-y: auto; /* 添加滚动条 */ + background-color: #2d3748; /* 深色背景,像IDE */ + color: #e2e8f0; /* 浅色文字 */ + border: 1px solid #4a5568; +} + +/* Loading Animation */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 15px; + text-align: center; + } + + .header-controls { + justify-content: center; + } + + .overview-grid { + grid-template-columns: 1fr; + } + + .simulation-controls { + justify-content: center; + } + + .modal-content { + width: 95%; + margin: 5% auto; + max-height: 85vh; /* 移动设备上稍微增加高度 */ + } + + .container { + padding: 10px; + } +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 40px; + color: #6c757d; +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 15px; + opacity: 0.5; +} + +/* Status indicators */ +.status-online { + color: #28a745; + font-weight: 600; +} + +.status-offline { + color: #dc3545; + font-weight: 600; +} \ No newline at end of file diff --git a/springboot-exception-group/src/main/resources/static/index.html b/springboot-exception-group/src/main/resources/static/index.html new file mode 100644 index 0000000..8673280 --- /dev/null +++ b/springboot-exception-group/src/main/resources/static/index.html @@ -0,0 +1,94 @@ + + + + + + Error Fingerprint Monitor + + + +
+ +
+

🔍 Error Fingerprint Monitor

+
+ + +
+
+ + +
+

📊 统计概览

+
+
+
总指纹数
+
-
+
+
+
总错误数
+
-
+
+
+
缓存使用
+
-
+
+
+
最高频错误
+
-
+
+
+
+ + +
+

🧪 错误模拟器

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

🔐 异常列表

+
+ + + + + + + + + + + + + + + +
指纹 (短)错误计数首次出现最后出现样本TraceId最近TraceIds操作
+
+
+ + + +
+ + + + \ No newline at end of file diff --git a/springboot-exception-group/src/main/resources/static/js/app.js b/springboot-exception-group/src/main/resources/static/js/app.js new file mode 100644 index 0000000..52099ff --- /dev/null +++ b/springboot-exception-group/src/main/resources/static/js/app.js @@ -0,0 +1,387 @@ +/** + * Error Fingerprint Monitor - JavaScript Application + * 前端管理界面的核心逻辑 + */ + +class ErrorMonitorApp { + constructor() { + this.apiBaseUrl = '/api/error-stats'; // 使用相对路径,避免CORS问题 + this.refreshInterval = null; + this.init(); + } + + /** + * 初始化应用 + */ + init() { + this.bindEvents(); + this.loadData(); + this.startAutoRefresh(); + } + + /** + * 绑定事件处理器 + */ + bindEvents() { + // 刷新按钮 + document.getElementById('refreshBtn').addEventListener('click', () => { + this.loadData(); + }); + + // 清空缓存按钮 + document.getElementById('clearCacheBtn').addEventListener('click', () => { + this.clearCache(); + }); + + // 错误模拟按钮 + document.querySelectorAll('.simulate-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const errorType = e.target.dataset.type; + this.simulateError(errorType); + }); + }); + + // 模态框关闭 + document.querySelector('.close').addEventListener('click', () => { + this.closeModal(); + }); + + // 点击模态框外部关闭 + document.getElementById('detailModal').addEventListener('click', (e) => { + if (e.target.id === 'detailModal') { + this.closeModal(); + } + }); + } + + /** + * 加载所有数据 + */ + async loadData() { + try { + await Promise.all([ + this.loadOverview(), + this.loadFingerprints() + ]); + } catch (error) { + console.error('Failed to load data:', error); + this.showError('加载数据失败,请检查后端服务是否正常运行'); + } + } + + /** + * 加载概览数据 + */ + async loadOverview() { + try { + const response = await fetch(`${this.apiBaseUrl}/overview`); + if (!response.ok) throw new Error('Failed to fetch overview'); + + const data = await response.json(); + + document.getElementById('totalFingerprints').textContent = data.totalFingerprints || 0; + document.getElementById('totalErrors').textContent = this.formatNumber(data.totalErrors || 0); + document.getElementById('cacheUsage').textContent = `${data.cacheCapacity || 0}/1000`; + + if (data.mostFrequentError) { + const shortFingerprint = data.mostFrequentError.fingerprint.substring(0, 8); + document.getElementById('mostFrequentError').textContent = + `${shortFingerprint} (${data.mostFrequentError.count})`; + } else { + document.getElementById('mostFrequentError').textContent = '无'; + } + } catch (error) { + console.error('Failed to load overview:', error); + } + } + + /** + * 加载指纹列表 + */ + async loadFingerprints() { + try { + const response = await fetch(`${this.apiBaseUrl}/fingerprints`); + if (!response.ok) throw new Error('Failed to fetch fingerprints'); + + const fingerprints = await response.json(); + this.renderFingerprintsTable(fingerprints); + } catch (error) { + console.error('Failed to load fingerprints:', error); + this.renderEmptyTable(); + } + } + + /** + * 渲染指纹表格 + */ + renderFingerprintsTable(fingerprints) { + const tbody = document.getElementById('fingerprintsTableBody'); + + if (!fingerprints || fingerprints.length === 0) { + this.renderEmptyTable(); + return; + } + + // 按错误计数排序 + fingerprints.sort((a, b) => b.count - a.count); + + tbody.innerHTML = fingerprints.map(fp => ` + + + + ${fp.fingerprint.substring(0, 12)}... + + + ${fp.count} + ${this.formatDateTime(fp.firstOccurrence)} + ${this.formatDateTime(fp.lastOccurrence)} + + + ${fp.sampleTraceId || 'N/A'} + + + +
+ ${(fp.recentTraceIds || []).map(traceId => + `${traceId.substring(0, 8)}...` + ).join('')} +
+ + + + + + `).join(''); + } + + /** + * 渲染空表格 + */ + renderEmptyTable() { + const tbody = document.getElementById('fingerprintsTableBody'); + tbody.innerHTML = ` + + +
+
📭
+
暂无错误指纹数据
+ 尝试使用错误模拟器生成一些测试数据 +
+ + + `; + } + + /** + * 显示指纹详情 + */ + async showFingerprintDetail(fingerprint) { + try { + const response = await fetch(`${this.apiBaseUrl}/fingerprints/${fingerprint}`); + if (!response.ok) throw new Error('Failed to fetch fingerprint detail'); + + const detail = await response.json(); + + const modalBody = document.getElementById('modalBody'); + modalBody.innerHTML = ` +
+
完整指纹
+
${detail.fingerprint}
+
+ ${detail.exceptionType ? ` +
+
异常类型
+
${detail.exceptionType}
+
+ ` : ''} +
+
错误计数
+
${detail.count}
+
+
+
首次出现时间
+
${this.formatDateTime(detail.firstOccurrence)}
+
+
+
最后出现时间
+
${this.formatDateTime(detail.lastOccurrence)}
+
+
+
样本链路ID
+
${detail.sampleTraceId || 'N/A'}
+
+
+
最近链路IDs (最多5个)
+
+ ${(detail.recentTraceIds || []).join('
') || '无'} +
+
+ ${detail.stackTrace ? ` +
+
异常堆栈跟踪
+
${this.escapeHtml(detail.stackTrace)}
+
+ ` : ''} + `; + + document.getElementById('detailModal').style.display = 'block'; + } catch (error) { + console.error('Failed to load fingerprint detail:', error); + alert('加载详情失败'); + } + } + + /** + * 关闭模态框 + */ + closeModal() { + document.getElementById('detailModal').style.display = 'none'; + } + + /** + * 模拟错误 + */ + async simulateError(errorType) { + const resultDiv = document.getElementById('simulationResult'); + resultDiv.innerHTML = '
正在模拟错误...'; + resultDiv.className = 'simulation-result show'; + + try { + const response = await fetch(`${this.apiBaseUrl}/simulate/${errorType}`, { + method: 'POST' + }); + + if (!response.ok) throw new Error('Simulation failed'); + + const result = await response.json(); + + resultDiv.className = 'simulation-result show success'; + resultDiv.innerHTML = ` + ✅ 模拟成功
+ 错误类型: ${result.errorType || errorType}
+ 链路ID: ${result.traceId}
+ ${result.message ? `消息: ${result.message}` : ''} + `; + + // 自动刷新数据 + setTimeout(() => { + this.loadData(); + }, 1000); + + } catch (error) { + console.error('Simulation failed:', error); + resultDiv.className = 'simulation-result show error'; + resultDiv.innerHTML = ` + ❌ 模拟失败
+ 请检查后端服务是否正常运行 + `; + } + } + + /** + * 清空缓存 + */ + async clearCache() { + if (!confirm('确定要清空所有错误指纹缓存吗?')) { + return; + } + + try { + const response = await fetch(`${this.apiBaseUrl}/clear`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Clear cache failed'); + + alert('✅ 缓存清空成功'); + this.loadData(); + } catch (error) { + console.error('Failed to clear cache:', error); + alert('❌ 清空缓存失败'); + } + } + + /** + * 开始自动刷新 + */ + startAutoRefresh() { + // 每30秒自动刷新一次 + this.refreshInterval = setInterval(() => { + this.loadData(); + }, 30000); + } + + /** + * 停止自动刷新 + */ + stopAutoRefresh() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + /** + * 显示错误信息 + */ + showError(message) { + const resultDiv = document.getElementById('simulationResult'); + resultDiv.className = 'simulation-result show error'; + resultDiv.innerHTML = `❌ 错误
${message}`; + } + + /** + * HTML转义防止XSS + */ + escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * 格式化数字 + */ + formatNumber(num) { + return num.toLocaleString(); + } + + /** + * 格式化日期时间 + */ + formatDateTime(dateTimeStr) { + if (!dateTimeStr) return 'N/A'; + + try { + const date = new Date(dateTimeStr); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } catch (error) { + return dateTimeStr; + } + } +} + +// 全局应用实例 +let app; + +// DOM加载完成后初始化应用 +document.addEventListener('DOMContentLoaded', () => { + app = new ErrorMonitorApp(); +}); + +// 页面卸载时清理定时器 +window.addEventListener('beforeunload', () => { + if (app) { + app.stopAutoRefresh(); + } +}); \ No newline at end of file From 7b637f5ffd9d286279f9aedbf652868be58923f0 Mon Sep 17 00:00:00 2001 From: yuoon Date: Wed, 1 Oct 2025 22:00:44 +0800 Subject: [PATCH 02/22] springboot-lic --- springboot-lic/README.md | 121 ++++++++ springboot-lic/pom.xml | 60 ++++ .../java/com/license/LicenseApplication.java | 12 + .../license/annotation/RequireFeature.java | 23 ++ .../license/aspect/LicenseFeatureAspect.java | 55 ++++ .../java/com/license/config/AppConfig.java | 23 ++ .../com/license/context/LicenseContext.java | 61 ++++ .../license/controller/LicenseController.java | 165 ++++++++++ .../license/controller/ReportController.java | 108 +++++++ .../main/java/com/license/entity/License.java | 94 ++++++ .../license/exception/LicenseException.java | 16 + .../license/service/KeyManagementService.java | 87 ++++++ .../com/license/service/LicenseService.java | 179 +++++++++++ .../service/LicenseStartupValidator.java | 94 ++++++ .../java/com/license/util/HardwareUtil.java | 136 +++++++++ .../main/java/com/license/util/RSAUtil.java | 113 +++++++ .../src/main/resources/application.yml | 15 + .../src/main/resources/static/index.html | 156 ++++++++++ .../src/main/resources/static/js/app.js | 281 ++++++++++++++++++ 19 files changed, 1799 insertions(+) create mode 100644 springboot-lic/README.md create mode 100644 springboot-lic/pom.xml create mode 100644 springboot-lic/src/main/java/com/license/LicenseApplication.java create mode 100644 springboot-lic/src/main/java/com/license/annotation/RequireFeature.java create mode 100644 springboot-lic/src/main/java/com/license/aspect/LicenseFeatureAspect.java create mode 100644 springboot-lic/src/main/java/com/license/config/AppConfig.java create mode 100644 springboot-lic/src/main/java/com/license/context/LicenseContext.java create mode 100644 springboot-lic/src/main/java/com/license/controller/LicenseController.java create mode 100644 springboot-lic/src/main/java/com/license/controller/ReportController.java create mode 100644 springboot-lic/src/main/java/com/license/entity/License.java create mode 100644 springboot-lic/src/main/java/com/license/exception/LicenseException.java create mode 100644 springboot-lic/src/main/java/com/license/service/KeyManagementService.java create mode 100644 springboot-lic/src/main/java/com/license/service/LicenseService.java create mode 100644 springboot-lic/src/main/java/com/license/service/LicenseStartupValidator.java create mode 100644 springboot-lic/src/main/java/com/license/util/HardwareUtil.java create mode 100644 springboot-lic/src/main/java/com/license/util/RSAUtil.java create mode 100644 springboot-lic/src/main/resources/application.yml create mode 100644 springboot-lic/src/main/resources/static/index.html create mode 100644 springboot-lic/src/main/resources/static/js/app.js diff --git a/springboot-lic/README.md b/springboot-lic/README.md new file mode 100644 index 0000000..1b6a428 --- /dev/null +++ b/springboot-lic/README.md @@ -0,0 +1,121 @@ +# 许可证控制系统 + +基于RSA2048的Spring Boot许可证控制系统,支持Windows和Linux主板序列号绑定。 + +## 功能特性 + +- ✅ RSA2048公私钥签名验签 +- ✅ 主板序列号硬件绑定(Windows/Linux) +- ✅ 许可证有效期控制 +- ✅ 功能权限管理 +- ✅ Web演示界面(前后端分离) +- ✅ REST API接口 + +## 快速开始 + +### 1. 环境要求 + +- Java 17+ +- Maven 3.6+ +- Windows或Linux操作系统 + +### 2. 启动应用 + +```bash +# 编译项目 +mvn clean package + +# 启动应用 +mvn spring-boot:run + +# 或者直接运行jar包 +java -jar target/springboot-lic-1.0.0.jar +``` + +### 3. 访问演示界面 + +打开浏览器访问:`http://localhost:8080` + +## API接口 + +### 密钥管理 + +- `POST /api/keys/generate` - 生成新密钥对 +- `POST /api/keys/load` - 加载密钥 +- `GET /api/keys/status` - 检查密钥状态 + +### 许可证操作 + +- `POST /api/license/generate` - 生成许可证 +- `POST /api/license/verify` - 验证许可证 + +### 硬件信息 + +- `GET /api/hardware/info` - 获取硬件信息 + +## 使用说明 + +### 1. 生成密钥对 + +首先在Web界面点击"生成新密钥对"按钮,系统会自动生成RSA2048密钥对。 + +### 2. 创建许可证 + +填写许可证信息: +- 软件名称 +- 授权给(公司名称) +- 到期时间 +- 功能权限(逗号分隔) + +点击"生成许可证"即可创建签名的许可证文件。 + +### 3. 验证许可证 + +将许可证JSON内容粘贴到验证区域,点击"验证许可证"进行验证。 + +系统会检查: +- 签名有效性 +- 硬件指纹匹配 +- 有效期 + +## 项目结构 + +``` +src/main/java/com/license/ +├── controller/ # REST控制器 +├── entity/ # 实体类 +├── service/ # 业务服务 +├── util/ # 工具类 +└── config/ # 配置类 + +src/main/resources/ +├── static/ # 前端静态文件 +└── application.yml # 应用配置 +``` + +## 技术栈 + +- **后端**: Spring Boot 3.x, Java Security API +- **前端**: HTML, JavaScript, TailwindCSS +- **加密**: RSA2048, SHA256withRSA +- **序列化**: Jackson JSON + +## 注意事项 + +1. **密钥安全**: 私钥应妥善保管,不要泄露 +2. **硬件绑定**: 更换主板后需要重新生成许可证 +3. **生产环境**: 建议将密钥存储在安全的密钥管理系统中 +4. **Linux权限**: Linux下获取硬件信息可能需要sudo权限 + +## 许可证格式 + +```json +{ + "subject": "MyApp", + "issuedTo": "Company Name", + "hardwareId": "MOTHERBOARD_SERIAL", + "expireAt": "2025-12-31", + "features": ["BASIC", "EXPORT", "REPORT"], + "signature": "Base64_Signature" +} +``` \ No newline at end of file diff --git a/springboot-lic/pom.xml b/springboot-lic/pom.xml new file mode 100644 index 0000000..0f5c985 --- /dev/null +++ b/springboot-lic/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.license + springboot-lic + 1.0.0 + License Control System + 基于RSA2048的许可证控制系统 + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + com.fasterxml.jackson.core + jackson-databind + + + + org.apache.commons + commons-lang3 + + + + org.springframework.boot + spring-boot-starter-aop + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/LicenseApplication.java b/springboot-lic/src/main/java/com/license/LicenseApplication.java new file mode 100644 index 0000000..f1fd692 --- /dev/null +++ b/springboot-lic/src/main/java/com/license/LicenseApplication.java @@ -0,0 +1,12 @@ +package com.license; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LicenseApplication { + + public static void main(String[] args) { + SpringApplication.run(LicenseApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/annotation/RequireFeature.java b/springboot-lic/src/main/java/com/license/annotation/RequireFeature.java new file mode 100644 index 0000000..b2fdf8d --- /dev/null +++ b/springboot-lic/src/main/java/com/license/annotation/RequireFeature.java @@ -0,0 +1,23 @@ +package com.license.annotation; + +import java.lang.annotation.*; + +/** + * 功能权限注解 + * 用于标记需要特定功能权限的方法或类 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequireFeature { + + /** + * 需要的功能权限 + */ + String value(); + + /** + * 权限不足时的提示信息 + */ + String message() default "功能未授权"; +} diff --git a/springboot-lic/src/main/java/com/license/aspect/LicenseFeatureAspect.java b/springboot-lic/src/main/java/com/license/aspect/LicenseFeatureAspect.java new file mode 100644 index 0000000..ef5e93d --- /dev/null +++ b/springboot-lic/src/main/java/com/license/aspect/LicenseFeatureAspect.java @@ -0,0 +1,55 @@ +package com.license.aspect; + +import com.license.annotation.RequireFeature; +import com.license.context.LicenseContext; +import com.license.entity.License; +import com.license.exception.LicenseException; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * 许可证功能权限AOP切面 + * 用于拦截带有@RequireFeature注解的方法,检查功能权限 + */ +@Component +@Aspect +@Order(1) +public class LicenseFeatureAspect { + + private static final Logger logger = LoggerFactory.getLogger(LicenseFeatureAspect.class); + + /** + * 环绕通知:在方法执行前检查功能权限 + */ + @Around("@annotation(requireFeature)") + public Object checkFeaturePermission(ProceedingJoinPoint joinPoint, RequireFeature requireFeature) throws Throwable { + + // 获取当前许可证信息 + License currentLicense = LicenseContext.getCurrentLicense(); + + if (currentLicense == null) { + logger.warn("访问需要授权的功能,但未找到有效许可证: {}", requireFeature.value()); + throw new LicenseException("系统未找到有效许可证,请联系管理员"); + } + + // 检查功能权限 + if (currentLicense.getFeatures() == null || + !currentLicense.getFeatures().contains(requireFeature.value())) { + + logger.warn("功能权限不足 - 用户: {}, 需要权限: {}, 拥有权限: {}", + currentLicense.getIssuedTo(), + requireFeature.value(), + currentLicense.getFeatures()); + + throw new LicenseException(requireFeature.message() + ": " + requireFeature.value()); + } + + logger.debug("功能权限验证通过: {}", requireFeature.value()); + return joinPoint.proceed(); + } +} diff --git a/springboot-lic/src/main/java/com/license/config/AppConfig.java b/springboot-lic/src/main/java/com/license/config/AppConfig.java new file mode 100644 index 0000000..96a2479 --- /dev/null +++ b/springboot-lic/src/main/java/com/license/config/AppConfig.java @@ -0,0 +1,23 @@ +package com.license.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 应用程序配置 + */ +@Configuration +public class AppConfig { + + /** + * 配置Jackson ObjectMapper + */ + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + return mapper; + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/context/LicenseContext.java b/springboot-lic/src/main/java/com/license/context/LicenseContext.java new file mode 100644 index 0000000..38fa987 --- /dev/null +++ b/springboot-lic/src/main/java/com/license/context/LicenseContext.java @@ -0,0 +1,61 @@ +package com.license.context; + +import com.license.entity.License; + +/** + * 许可证上下文类 + * 用于在应用运行期间存储和访问当前有效的许可证信息 + */ +public class LicenseContext { + + private static final ThreadLocal licenseHolder = new ThreadLocal<>(); + private static License globalLicense; + + /** + * 设置全局许可证(通常在应用启动时设置) + */ + public static void setCurrentLicense(License license) { + globalLicense = license; + } + + /** + * 获取当前许可证 + * 优先从ThreadLocal获取,如果没有则返回全局许可证 + */ + public static License getCurrentLicense() { + License license = licenseHolder.get(); + return license != null ? license : globalLicense; + } + + /** + * 为当前线程设置许可证(用于特殊场景) + */ + public static void setThreadLocalLicense(License license) { + licenseHolder.set(license); + } + + /** + * 清除当前线程的许可证 + */ + public static void clearThreadLocalLicense() { + licenseHolder.remove(); + } + + /** + * 检查是否有指定的功能权限 + */ + public static boolean hasFeature(String feature) { + License license = getCurrentLicense(); + if (license == null || license.getFeatures() == null) { + return false; + } + return license.getFeatures().contains(feature); + } + + /** + * 清空全局许可证(谨慎使用) + */ + public static void clearGlobalLicense() { + globalLicense = null; + } +} diff --git a/springboot-lic/src/main/java/com/license/controller/LicenseController.java b/springboot-lic/src/main/java/com/license/controller/LicenseController.java new file mode 100644 index 0000000..1c88a55 --- /dev/null +++ b/springboot-lic/src/main/java/com/license/controller/LicenseController.java @@ -0,0 +1,165 @@ +package com.license.controller; + +import com.license.entity.License; +import com.license.service.KeyManagementService; +import com.license.service.LicenseService; +import com.license.util.HardwareUtil; +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.Map; + +/** + * 许可证控制器 + */ +@RestController +@RequestMapping("/api") +@CrossOrigin(origins = "*") +public class LicenseController { + + @Autowired + private LicenseService licenseService; + + @Autowired + private KeyManagementService keyManagementService; + + @Autowired + private HardwareUtil hardwareUtil; + + /** + * 生成新的密钥对 + */ + @PostMapping("/keys/generate") + public ResponseEntity> generateKeys() { + try { + Map keys = keyManagementService.generateKeyPair(); + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", keys); + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "密钥生成失败: " + e.getMessage()); + return ResponseEntity.badRequest().body(response); + } + } + + /** + * 加载密钥 + */ + @PostMapping("/keys/load") + public ResponseEntity> loadKeys(@RequestBody Map request) { + try { + String privateKey = request.get("privateKey"); + String publicKey = request.get("publicKey"); + + if (privateKey != null && !privateKey.trim().isEmpty()) { + keyManagementService.loadPrivateKey(privateKey); + } + + if (publicKey != null && !publicKey.trim().isEmpty()) { + keyManagementService.loadPublicKey(publicKey); + } + + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "密钥加载成功"); + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "密钥加载失败: " + e.getMessage()); + return ResponseEntity.badRequest().body(response); + } + } + + /** + * 生成许可证 + */ + @PostMapping("/license/generate") + public ResponseEntity> generateLicense(@RequestBody License license) { + try { + if (!keyManagementService.isKeysLoaded()) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "请先加载或生成密钥"); + return ResponseEntity.badRequest().body(response); + } + + String licenseJson = licenseService.generateLicense(license, keyManagementService.getCachedPrivateKey()); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", licenseJson); + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "许可证生成失败: " + e.getMessage()); + return ResponseEntity.badRequest().body(response); + } + } + + /** + * 验证许可证 + */ + @PostMapping("/license/verify") + public ResponseEntity> verifyLicense(@RequestBody Map request) { + try { + String licenseJson = request.get("licenseJson"); + + if (!keyManagementService.isKeysLoaded()) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "请先加载公钥"); + return ResponseEntity.badRequest().body(response); + } + + LicenseService.LicenseVerifyResult result = licenseService.verifyLicense( + licenseJson, keyManagementService.getCachedPublicKey()); + + Map response = new HashMap<>(); + response.put("success", result.isValid()); + response.put("message", result.getMessage()); + if (result.getLicense() != null) { + response.put("license", result.getLicense()); + } + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", "许可证验证失败: " + e.getMessage()); + return ResponseEntity.badRequest().body(response); + } + } + + /** + * 获取当前硬件信息 + */ + @GetMapping("/hardware/info") + public ResponseEntity> getHardwareInfo() { + Map response = new HashMap<>(); + response.put("success", true); + + Map hardwareInfo = new HashMap<>(); + hardwareInfo.put("motherboardSerial", hardwareUtil.getMotherboardSerial()); + hardwareInfo.put("systemInfo", hardwareUtil.getSystemInfo()); + + response.put("data", hardwareInfo); + return ResponseEntity.ok(response); + } + + /** + * 检查密钥状态 + */ + @GetMapping("/keys/status") + public ResponseEntity> getKeysStatus() { + Map response = new HashMap<>(); + response.put("success", true); + response.put("keysLoaded", keyManagementService.isKeysLoaded()); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/controller/ReportController.java b/springboot-lic/src/main/java/com/license/controller/ReportController.java new file mode 100644 index 0000000..de5095a --- /dev/null +++ b/springboot-lic/src/main/java/com/license/controller/ReportController.java @@ -0,0 +1,108 @@ +package com.license.controller; + +import com.license.annotation.RequireFeature; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 报表控制器 + * 演示功能权限控制的使用 + */ +@RestController +@RequestMapping("/api/report") +@CrossOrigin(origins = "*") +public class ReportController { + + /** + * 导出报表功能 - 需要REPORT_EXPORT权限 + */ + @GetMapping("/export") + @RequireFeature(value = "REPORT_EXPORT", message = "报表导出功能未授权") + public ResponseEntity> exportReport(@RequestParam(defaultValue = "pdf") String format) { + // 模拟报表导出功能 + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "报表导出成功"); + response.put("format", format); + response.put("fileName", "report_" + System.currentTimeMillis() + "." + format); + response.put("size", "2.5 MB"); + + return ResponseEntity.ok(response); + } + + /** + * 定时报表功能 - 需要REPORT_SCHEDULE权限 + */ + @PostMapping("/schedule") + @RequireFeature(value = "REPORT_SCHEDULE", message = "定时报表功能未授权") + public ResponseEntity> scheduleReport(@RequestBody Map request) { + // 模拟定时报表创建 + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "定时报表创建成功"); + response.put("scheduleId", "SCH-" + System.currentTimeMillis()); + response.put("cron", request.getOrDefault("cron", "0 0 9 * * ?")); + + return ResponseEntity.ok(response); + } + + /** + * 数据分析功能 - 需要DATA_ANALYSIS权限 + */ + @GetMapping("/analysis") + @RequireFeature(value = "DATA_ANALYSIS", message = "数据分析功能未授权") + public ResponseEntity> dataAnalysis(@RequestParam(required = false) String dimension) { + // 模拟数据分析 + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "数据分析完成"); + response.put("dimension", dimension != null ? dimension : "default"); + + Map analysisResult = new HashMap<>(); + analysisResult.put("totalRecords", 15234); + analysisResult.put("avgValue", 456.78); + analysisResult.put("maxValue", 1234.56); + analysisResult.put("minValue", 12.34); + + response.put("result", analysisResult); + + return ResponseEntity.ok(response); + } + + /** + * 用户管理功能 - 需要USER_MANAGEMENT权限 + */ + @GetMapping("/users") + @RequireFeature(value = "USER_MANAGEMENT", message = "用户管理功能未授权") + public ResponseEntity> getUserList() { + // 模拟用户列表查询 + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "用户列表查询成功"); + response.put("totalCount", 245); + response.put("currentPage", 1); + response.put("pageSize", 20); + + return ResponseEntity.ok(response); + } + + /** + * 查看报表 - 无需特定权限 + */ + @GetMapping("/view") + public ResponseEntity> viewReport(@RequestParam(defaultValue = "1") String reportId) { + // 基础查看功能,不需要额外权限 + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "报表查看成功"); + response.put("reportId", reportId); + response.put("reportName", "月度销售报表"); + response.put("createDate", "2025-10-01"); + + return ResponseEntity.ok(response); + } +} diff --git a/springboot-lic/src/main/java/com/license/entity/License.java b/springboot-lic/src/main/java/com/license/entity/License.java new file mode 100644 index 0000000..8968e62 --- /dev/null +++ b/springboot-lic/src/main/java/com/license/entity/License.java @@ -0,0 +1,94 @@ +package com.license.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import java.time.LocalDate; +import java.util.List; + +/** + * 许可证实体类 + */ +@JsonPropertyOrder({"subject", "issuedTo", "hardwareId", "expireAt", "features"}) +public class License { + + private String subject; // 软件名称 + private String issuedTo; // 授权给谁 + private String hardwareId; // 硬件指纹(主板序列号) + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate expireAt; // 到期时间 + + private List features; // 功能权限列表 + private String signature; // 签名(JSON序列化时忽略) + + public License() {} + + public License(String subject, String issuedTo, String hardwareId, LocalDate expireAt, List features) { + this.subject = subject; + this.issuedTo = issuedTo; + this.hardwareId = hardwareId; + this.expireAt = expireAt; + this.features = features; + } + + // Getters and Setters + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getIssuedTo() { + return issuedTo; + } + + public void setIssuedTo(String issuedTo) { + this.issuedTo = issuedTo; + } + + public String getHardwareId() { + return hardwareId; + } + + public void setHardwareId(String hardwareId) { + this.hardwareId = hardwareId; + } + + public LocalDate getExpireAt() { + return expireAt; + } + + public void setExpireAt(LocalDate expireAt) { + this.expireAt = expireAt; + } + + public List getFeatures() { + return features; + } + + public void setFeatures(List features) { + this.features = features; + } + + public String getSignature() { + return signature; + } + + public void setSignature(String signature) { + this.signature = signature; + } + + @Override + public String toString() { + return "License{" + + "subject='" + subject + '\'' + + ", issuedTo='" + issuedTo + '\'' + + ", hardwareId='" + hardwareId + '\'' + + ", expireAt=" + expireAt + + ", features=" + features + + '}'; + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/exception/LicenseException.java b/springboot-lic/src/main/java/com/license/exception/LicenseException.java new file mode 100644 index 0000000..edfda1a --- /dev/null +++ b/springboot-lic/src/main/java/com/license/exception/LicenseException.java @@ -0,0 +1,16 @@ +package com.license.exception; + +/** + * 许可证异常类 + * 用于处理许可证相关的业务异常 + */ +public class LicenseException extends RuntimeException { + + public LicenseException(String message) { + super(message); + } + + public LicenseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/springboot-lic/src/main/java/com/license/service/KeyManagementService.java b/springboot-lic/src/main/java/com/license/service/KeyManagementService.java new file mode 100644 index 0000000..70175df --- /dev/null +++ b/springboot-lic/src/main/java/com/license/service/KeyManagementService.java @@ -0,0 +1,87 @@ +package com.license.service; + +import com.license.util.RSAUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.HashMap; +import java.util.Map; + +/** + * 密钥管理服务 + */ +@Service +public class KeyManagementService { + + private static final Logger logger = LoggerFactory.getLogger(KeyManagementService.class); + + @Autowired + private RSAUtil rsaUtil; + + // 内存中存储密钥(实际项目中应该从配置文件或密钥库加载) + private PrivateKey cachedPrivateKey; + private PublicKey cachedPublicKey; + + /** + * 生成新的密钥对 + */ + public Map generateKeyPair() throws Exception { + KeyPair keyPair = rsaUtil.generateKeyPair(); + + String privateKeyPem = rsaUtil.privateKeyToPem(keyPair.getPrivate()); + String publicKeyPem = rsaUtil.publicKeyToPem(keyPair.getPublic()); + + // 缓存密钥 + this.cachedPrivateKey = keyPair.getPrivate(); + this.cachedPublicKey = keyPair.getPublic(); + + Map result = new HashMap<>(); + result.put("privateKey", privateKeyPem); + result.put("publicKey", publicKeyPem); + + logger.info("新密钥对生成成功"); + return result; + } + + /** + * 加载私钥 + */ + public void loadPrivateKey(String privateKeyPem) throws Exception { + this.cachedPrivateKey = rsaUtil.loadPrivateKeyFromPem(privateKeyPem); + logger.info("私钥加载成功"); + } + + /** + * 加载公钥 + */ + public void loadPublicKey(String publicKeyPem) throws Exception { + this.cachedPublicKey = rsaUtil.loadPublicKeyFromPem(publicKeyPem); + logger.info("公钥加载成功"); + } + + /** + * 获取缓存的私钥 + */ + public PrivateKey getCachedPrivateKey() { + return cachedPrivateKey; + } + + /** + * 获取缓存的公钥 + */ + public PublicKey getCachedPublicKey() { + return cachedPublicKey; + } + + /** + * 检查密钥是否已加载 + */ + public boolean isKeysLoaded() { + return cachedPrivateKey != null && cachedPublicKey != null; + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/service/LicenseService.java b/springboot-lic/src/main/java/com/license/service/LicenseService.java new file mode 100644 index 0000000..1e17c99 --- /dev/null +++ b/springboot-lic/src/main/java/com/license/service/LicenseService.java @@ -0,0 +1,179 @@ +package com.license.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.license.entity.License; +import com.license.util.HardwareUtil; +import com.license.util.RSAUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.security.PrivateKey; +import java.security.PublicKey; +import java.time.LocalDate; + +/** + * 许可证服务类 + */ +@Service +public class LicenseService { + + private static final Logger logger = LoggerFactory.getLogger(LicenseService.class); + + @Autowired + private RSAUtil rsaUtil; + + @Autowired + private HardwareUtil hardwareUtil; + + @Autowired + private ObjectMapper objectMapper; + + /** + * 生成许可证 + */ + public String generateLicense(License license, PrivateKey privateKey) throws Exception { + // 自动填充硬件指纹 + if (license.getHardwareId() == null || license.getHardwareId().isEmpty()) { + license.setHardwareId(hardwareUtil.getMotherboardSerial()); + } + + // 创建标准化的JSON数据(按固定顺序) + String licenseData = createStandardizedLicenseJson(license); + logger.debug("用于签名的许可证数据: {}", licenseData); + + // 使用私钥签名 + String signature = rsaUtil.sign(licenseData, privateKey); + + // 创建包含签名的完整许可证 + JsonNode jsonNode = objectMapper.readTree(licenseData); + ((com.fasterxml.jackson.databind.node.ObjectNode) jsonNode).put("signature", signature); + + String result = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); + logger.info("许可证生成成功,授权给: {}", license.getIssuedTo()); + return result; + } + + /** + * 验证许可证 + */ + public LicenseVerifyResult verifyLicense(String licenseJson, PublicKey publicKey) { + try { + JsonNode jsonNode = objectMapper.readTree(licenseJson); + + // 提取签名 + if (!jsonNode.has("signature")) { + return new LicenseVerifyResult(false, "许可证缺少签名信息"); + } + String signature = jsonNode.get("signature").asText(); + + // 移除签名字段,获取原始数据 + ((com.fasterxml.jackson.databind.node.ObjectNode) jsonNode).remove("signature"); + + // 解析许可证对象 + License license = objectMapper.readValue(jsonNode.toString(), License.class); + + // 重新生成标准化的JSON数据用于验证 + String licenseData = createStandardizedLicenseJson(license); + logger.debug("用于验证的许可证数据: {}", licenseData); + + // 验证签名 + boolean signatureValid = rsaUtil.verify(licenseData, signature, publicKey); + if (!signatureValid) { + logger.warn("签名验证失败 - 原始数据: {}", licenseData); + return new LicenseVerifyResult(false, "许可证签名验证失败"); + } + + // 验证硬件指纹 + String currentHardwareId = hardwareUtil.getMotherboardSerial(); + if (!currentHardwareId.equals(license.getHardwareId())) { + return new LicenseVerifyResult(false, + String.format("硬件指纹不匹配。期望: %s, 实际: %s", + license.getHardwareId(), currentHardwareId)); + } + + // 验证有效期 + if (license.getExpireAt().isBefore(LocalDate.now())) { + return new LicenseVerifyResult(false, + String.format("许可证已过期。到期时间: %s", license.getExpireAt())); + } + + logger.info("许可证验证成功: {}", license.getIssuedTo()); + return new LicenseVerifyResult(true, "许可证验证成功", license); + + } catch (Exception e) { + logger.error("许可证验证失败", e); + return new LicenseVerifyResult(false, "许可证格式错误: " + e.getMessage()); + } + } + + /** + * 验证结果类 + */ + public static class LicenseVerifyResult { + private final boolean valid; + private final String message; + private final License license; + + public LicenseVerifyResult(boolean valid, String message) { + this(valid, message, null); + } + + public LicenseVerifyResult(boolean valid, String message, License license) { + this.valid = valid; + this.message = message; + this.license = license; + } + + public boolean isValid() { + return valid; + } + + public String getMessage() { + return message; + } + + public License getLicense() { + return license; + } + } + + /** + * 创建标准化的许可证JSON数据 + * 确保字段顺序一致,用于签名和验证 + */ + private String createStandardizedLicenseJson(License license) throws Exception { + // 手动构建JSON以确保字段顺序一致 + StringBuilder json = new StringBuilder(); + json.append("{"); + json.append("\"subject\":\"").append(escapeJson(license.getSubject())).append("\","); + json.append("\"issuedTo\":\"").append(escapeJson(license.getIssuedTo())).append("\","); + json.append("\"hardwareId\":\"").append(escapeJson(license.getHardwareId())).append("\","); + json.append("\"expireAt\":\"").append(license.getExpireAt().toString()).append("\","); + json.append("\"features\":["); + + if (license.getFeatures() != null && !license.getFeatures().isEmpty()) { + for (int i = 0; i < license.getFeatures().size(); i++) { + if (i > 0) json.append(","); + json.append("\"").append(escapeJson(license.getFeatures().get(i))).append("\""); + } + } + + json.append("]}"); + return json.toString(); + } + + /** + * 转义JSON字符串中的特殊字符 + */ + private String escapeJson(String str) { + if (str == null) return ""; + return str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/service/LicenseStartupValidator.java b/springboot-lic/src/main/java/com/license/service/LicenseStartupValidator.java new file mode 100644 index 0000000..8b1ed5c --- /dev/null +++ b/springboot-lic/src/main/java/com/license/service/LicenseStartupValidator.java @@ -0,0 +1,94 @@ +package com.license.service; + +import com.license.context.LicenseContext; +import com.license.entity.License; +import com.license.util.HardwareUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.util.Arrays; + +/** + * 许可证启动验证器 + * 在应用启动时验证许可证并保存到上下文 + */ +//@Component +public class LicenseStartupValidator implements ApplicationRunner { + + private static final Logger logger = LoggerFactory.getLogger(LicenseStartupValidator.class); + + @Autowired + private LicenseService licenseService; + + @Autowired + private KeyManagementService keyManagementService; + + @Value("${license.file.path:license.json}") + private String licenseFilePath; + + @Value("${license.public.key.path:public.pem}") + private String publicKeyPath; + + @Override + public void run(ApplicationArguments args) throws Exception { + logger.info("开始执行许可证启动验证..."); + + try { + // 检查许可证文件是否存在 + Path licensePath = Paths.get(licenseFilePath); + if (!Files.exists(licensePath)) { + throw new RuntimeException("许可证文件不存在: " + licenseFilePath); + } + + // 读取许可证文件内容 + String licenseJson = Files.readString(licensePath, StandardCharsets.UTF_8); + logger.debug("许可证文件读取成功,文件大小: {} 字节", licenseJson.length()); + + // 加载公钥 + Path publicKeyFilePath = Paths.get(publicKeyPath); + if (Files.exists(publicKeyFilePath)) { + String publicKeyContent = Files.readString(publicKeyFilePath, StandardCharsets.UTF_8); + keyManagementService.loadPublicKey(publicKeyContent); + logger.info("公钥文件加载成功"); + } else { + throw new RuntimeException("公钥文件不存在: " + publicKeyPath); + } + + // 验证许可证 + LicenseService.LicenseVerifyResult result = licenseService.verifyLicense( + licenseJson, keyManagementService.getCachedPublicKey()); + + if (!result.isValid()) { + logger.error("许可证验证失败: {}", result.getMessage()); + throw new RuntimeException("许可证验证失败,应用启动终止: " + result.getMessage()); + } + + // 验证通过,记录许可证信息 + License license = result.getLicense(); + logger.info("====== 许可证验证通过 ======"); + logger.info("软件产品: {}", license.getSubject()); + logger.info("授权对象: {}", license.getIssuedTo()); + logger.info("到期时间: {}", license.getExpireAt()); + logger.info("授权功能: {}", String.join(", ", license.getFeatures())); + logger.info("绑定硬件: {}", license.getHardwareId()); + logger.info("============================"); + + // 将许可证信息保存到应用上下文,供其他组件使用 + LicenseContext.setCurrentLicense(license); + + } catch (Exception e) { + logger.error("许可证验证过程发生异常,应用启动失败", e); + throw new RuntimeException("许可证验证失败: " + e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/util/HardwareUtil.java b/springboot-lic/src/main/java/com/license/util/HardwareUtil.java new file mode 100644 index 0000000..72f0c0d --- /dev/null +++ b/springboot-lic/src/main/java/com/license/util/HardwareUtil.java @@ -0,0 +1,136 @@ +package com.license.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * 硬件指纹获取工具类 + * 支持Windows和Linux系统的主板序列号获取 + */ +@Component +public class HardwareUtil { + + private static final Logger logger = LoggerFactory.getLogger(HardwareUtil.class); + + /** + * 获取主板序列号 + * @return 主板序列号,获取失败返回"UNKNOWN" + */ + public String getMotherboardSerial() { + String os = System.getProperty("os.name").toLowerCase(); + + try { + if (os.contains("windows")) { + return getWindowsMotherboardSerial(); + } else if (os.contains("linux")) { + return getLinuxMotherboardSerial(); + } else { + logger.warn("不支持的操作系统: {}", os); + return "UNKNOWN"; + } + } catch (Exception e) { + logger.error("获取主板序列号失败", e); + return "UNKNOWN"; + } + } + + /** + * 获取Windows系统主板序列号 + */ + private String getWindowsMotherboardSerial() { + try { + Process process = Runtime.getRuntime().exec("wmic baseboard get serialnumber"); + BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8) + ); + + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (!line.isEmpty() && !line.equals("SerialNumber")) { + logger.debug("Windows主板序列号: {}", line); + return line; + } + } + + reader.close(); + process.waitFor(); + + } catch (Exception e) { + logger.error("获取Windows主板序列号失败", e); + } + + return "UNKNOWN"; + } + + /** + * 获取Linux系统主板序列号 + */ + private String getLinuxMotherboardSerial() { + try { + // 尝试通过dmidecode命令获取 + Process process = Runtime.getRuntime().exec("sudo dmidecode -s baseboard-serial-number"); + BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8) + ); + + String line = reader.readLine(); + reader.close(); + process.waitFor(); + + if (line != null && !line.trim().isEmpty() && !line.contains("Not Specified")) { + logger.debug("Linux主板序列号: {}", line.trim()); + return line.trim(); + } + + // 如果dmidecode失败,尝试读取/sys/class/dmi/id/board_serial + return getLinuxMotherboardFromSys(); + + } catch (Exception e) { + logger.error("获取Linux主板序列号失败", e); + return getLinuxMotherboardFromSys(); + } + } + + /** + * 从/sys/class/dmi/id/board_serial读取主板序列号 + */ + private String getLinuxMotherboardFromSys() { + try { + Process process = Runtime.getRuntime().exec("cat /sys/class/dmi/id/board_serial"); + BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8) + ); + + String line = reader.readLine(); + reader.close(); + process.waitFor(); + + if (line != null && !line.trim().isEmpty()) { + logger.debug("Linux主板序列号(从sys读取): {}", line.trim()); + return line.trim(); + } + + } catch (Exception e) { + logger.warn("从/sys/class/dmi/id/board_serial读取失败", e); + } + + return "UNKNOWN"; + } + + /** + * 获取系统信息摘要(用于调试) + */ + public String getSystemInfo() { + return String.format("OS: %s, Arch: %s, Motherboard: %s", + System.getProperty("os.name"), + System.getProperty("os.arch"), + getMotherboardSerial() + ); + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/java/com/license/util/RSAUtil.java b/springboot-lic/src/main/java/com/license/util/RSAUtil.java new file mode 100644 index 0000000..8d81bff --- /dev/null +++ b/springboot-lic/src/main/java/com/license/util/RSAUtil.java @@ -0,0 +1,113 @@ +package com.license.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +/** + * RSA密钥管理工具类 + */ +@Component +public class RSAUtil { + + private static final Logger logger = LoggerFactory.getLogger(RSAUtil.class); + private static final String ALGORITHM = "RSA"; + private static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; + private static final int KEY_SIZE = 2048; + + /** + * 生成RSA密钥对 + */ + public KeyPair generateKeyPair() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM); + keyGen.initialize(KEY_SIZE); + return keyGen.generateKeyPair(); + } + + /** + * 将私钥转换为PEM格式字符串 + */ + public String privateKeyToPem(PrivateKey privateKey) { + String encoded = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + return "-----BEGIN PRIVATE KEY-----\n" + + formatBase64String(encoded) + + "\n-----END PRIVATE KEY-----"; + } + + /** + * 将公钥转换为PEM格式字符串 + */ + public String publicKeyToPem(PublicKey publicKey) { + String encoded = Base64.getEncoder().encodeToString(publicKey.getEncoded()); + return "-----BEGIN PUBLIC KEY-----\n" + + formatBase64String(encoded) + + "\n-----END PUBLIC KEY-----"; + } + + /** + * 从PEM格式字符串加载私钥 + */ + public PrivateKey loadPrivateKeyFromPem(String pemContent) throws Exception { + String privateKeyPEM = pemContent + .replaceAll("-----\\w+ PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(privateKeyPEM); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM); + return keyFactory.generatePrivate(spec); + } + + /** + * 从PEM格式字符串加载公钥 + */ + public PublicKey loadPublicKeyFromPem(String pemContent) throws Exception { + String publicKeyPEM = pemContent + .replaceAll("-----\\w+ PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(publicKeyPEM); + X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM); + return keyFactory.generatePublic(spec); + } + + /** + * 使用私钥对数据进行签名 + */ + public String sign(String data, PrivateKey privateKey) throws Exception { + Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); + signature.initSign(privateKey); + signature.update(data.getBytes(StandardCharsets.UTF_8)); + byte[] signed = signature.sign(); + return Base64.getEncoder().encodeToString(signed); + } + + /** + * 使用公钥验证签名 + */ + public boolean verify(String data, String signatureBase64, PublicKey publicKey) throws Exception { + Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); + signature.initVerify(publicKey); + signature.update(data.getBytes(StandardCharsets.UTF_8)); + byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64); + return signature.verify(signatureBytes); + } + + /** + * 格式化Base64字符串,每64个字符换行 + */ + private String formatBase64String(String base64) { + StringBuilder formatted = new StringBuilder(); + for (int i = 0; i < base64.length(); i += 64) { + formatted.append(base64, i, Math.min(i + 64, base64.length())).append("\n"); + } + return formatted.toString().trim(); + } +} \ No newline at end of file diff --git a/springboot-lic/src/main/resources/application.yml b/springboot-lic/src/main/resources/application.yml new file mode 100644 index 0000000..189a944 --- /dev/null +++ b/springboot-lic/src/main/resources/application.yml @@ -0,0 +1,15 @@ +server: + port: 8080 + +spring: + application: + name: license-control-system + +license: + keys: + private-key: classpath:keys/private.pem + public-key: classpath:keys/public.pem + +logging: + level: + com.license: DEBUG \ No newline at end of file diff --git a/springboot-lic/src/main/resources/static/index.html b/springboot-lic/src/main/resources/static/index.html new file mode 100644 index 0000000..5b14e01 --- /dev/null +++ b/springboot-lic/src/main/resources/static/index.html @@ -0,0 +1,156 @@ + + + + + + 许可证控制系统演示 + + + + +
+ +
+

许可证控制系统

+

基于RSA2048的许可证生成与验证演示

+
+ + +
+

🖥️ 当前硬件信息

+
+
+ + 加载中... +
+
+ + 加载中... +
+
+ +
+ + +
+

🔐 密钥管理

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

📝 许可证生成

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

✅ 许可证验证

+ +
+ + +
+ + + +
+
+
+ + +
+
+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/springboot-lic/src/main/resources/static/js/app.js b/springboot-lic/src/main/resources/static/js/app.js new file mode 100644 index 0000000..831fd46 --- /dev/null +++ b/springboot-lic/src/main/resources/static/js/app.js @@ -0,0 +1,281 @@ +// API 基础配置 +const API_BASE = 'http://localhost:8080/api'; + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', function() { + loadHardwareInfo(); + checkKeyStatus(); + setDefaultExpireDate(); +}); + +// 设置默认到期时间(一年后) +function setDefaultExpireDate() { + const tomorrow = new Date(); + tomorrow.setFullYear(tomorrow.getFullYear() + 1); + document.getElementById('expireAt').value = tomorrow.toISOString().split('T')[0]; +} + +// 显示消息提示 +function showToast(title, message, type = 'info') { + const toast = document.getElementById('toast'); + const icon = document.getElementById('toastIcon'); + const titleEl = document.getElementById('toastTitle'); + const messageEl = document.getElementById('toastMessage'); + + // 设置图标和颜色 + const types = { + success: { icon: '✅', color: 'text-green-600' }, + error: { icon: '❌', color: 'text-red-600' }, + warning: { icon: '⚠️', color: 'text-yellow-600' }, + info: { icon: 'ℹ️', color: 'text-blue-600' } + }; + + const config = types[type] || types.info; + icon.textContent = config.icon; + titleEl.textContent = title; + titleEl.className = `font-medium ${config.color}`; + messageEl.textContent = message; + + // 显示动画 + toast.classList.remove('translate-x-full'); + toast.classList.add('translate-x-0'); + + // 3秒后自动隐藏 + setTimeout(() => { + toast.classList.remove('translate-x-0'); + toast.classList.add('translate-x-full'); + }, 3000); +} + +// 复制到剪贴板 +function copyToClipboard(elementId) { + const element = document.getElementById(elementId); + element.select(); + document.execCommand('copy'); + showToast('复制成功', '内容已复制到剪贴板', 'success'); +} + +// 加载硬件信息 +async function loadHardwareInfo() { + try { + const response = await fetch(`${API_BASE}/hardware/info`); + const result = await response.json(); + + if (result.success) { + document.getElementById('motherboardSerial').textContent = result.data.motherboardSerial; + document.getElementById('systemInfo').textContent = result.data.systemInfo; + } else { + showToast('错误', '获取硬件信息失败', 'error'); + } + } catch (error) { + console.error('获取硬件信息失败:', error); + showToast('错误', '无法连接到服务器', 'error'); + } +} + +// 检查密钥状态 +async function checkKeyStatus() { + try { + const response = await fetch(`${API_BASE}/keys/status`); + const result = await response.json(); + + const statusEl = document.getElementById('keyStatus'); + if (result.keysLoaded) { + statusEl.textContent = '✅ 密钥已加载'; + statusEl.className = 'text-sm text-green-600'; + } else { + statusEl.textContent = '❌ 密钥未加载'; + statusEl.className = 'text-sm text-red-600'; + } + } catch (error) { + console.error('检查密钥状态失败:', error); + } +} + +// 生成密钥对 +async function generateKeys() { + try { + showToast('处理中', '正在生成密钥对...', 'info'); + + const response = await fetch(`${API_BASE}/keys/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (result.success) { + document.getElementById('privateKey').value = result.data.privateKey; + document.getElementById('publicKey').value = result.data.publicKey; + showToast('成功', '密钥对生成成功', 'success'); + checkKeyStatus(); + } else { + showToast('错误', result.message, 'error'); + } + } catch (error) { + console.error('生成密钥失败:', error); + showToast('错误', '生成密钥失败', 'error'); + } +} + +// 加载密钥 +async function loadKeys() { + try { + const privateKey = document.getElementById('privateKey').value.trim(); + const publicKey = document.getElementById('publicKey').value.trim(); + + if (!privateKey && !publicKey) { + showToast('警告', '请输入至少一个密钥', 'warning'); + return; + } + + const response = await fetch(`${API_BASE}/keys/load`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + privateKey: privateKey, + publicKey: publicKey + }) + }); + + const result = await response.json(); + + if (result.success) { + showToast('成功', result.message, 'success'); + checkKeyStatus(); + } else { + showToast('错误', result.message, 'error'); + } + } catch (error) { + console.error('加载密钥失败:', error); + showToast('错误', '加载密钥失败', 'error'); + } +} + +// 生成许可证 +async function generateLicense() { + try { + // 收集表单数据 + const subject = document.getElementById('subject').value.trim(); + const issuedTo = document.getElementById('issuedTo').value.trim(); + const expireAt = document.getElementById('expireAt').value; + const featuresStr = document.getElementById('features').value.trim(); + + // 验证必填字段 + if (!subject || !issuedTo || !expireAt) { + showToast('警告', '请填写所有必填字段', 'warning'); + return; + } + + // 解析功能权限 + const features = featuresStr.split(',').map(f => f.trim()).filter(f => f); + + const licenseData = { + subject: subject, + issuedTo: issuedTo, + expireAt: expireAt, + features: features + }; + + showToast('处理中', '正在生成许可证...', 'info'); + + const response = await fetch(`${API_BASE}/license/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(licenseData) + }); + + const result = await response.json(); + + if (result.success) { + document.getElementById('generatedLicense').value = result.data; + showToast('成功', '许可证生成成功', 'success'); + } else { + showToast('错误', result.message, 'error'); + } + } catch (error) { + console.error('生成许可证失败:', error); + showToast('错误', '生成许可证失败', 'error'); + } +} + +// 验证许可证 +async function verifyLicense() { + try { + const licenseJson = document.getElementById('licenseToVerify').value.trim(); + + if (!licenseJson) { + showToast('警告', '请输入许可证内容', 'warning'); + return; + } + + showToast('处理中', '正在验证许可证...', 'info'); + + const response = await fetch(`${API_BASE}/license/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + licenseJson: licenseJson + }) + }); + + const result = await response.json(); + const resultDiv = document.getElementById('verifyResult'); + + if (result.success) { + resultDiv.innerHTML = ` +
+
+ +

许可证验证成功

+
+

${result.message}

+ ${result.license ? ` +
+
+
软件名称: ${result.license.subject}
+
授权给: ${result.license.issuedTo}
+
硬件ID: ${result.license.hardwareId}
+
到期时间: ${result.license.expireAt}
+
功能权限: ${result.license.features.join(', ')}
+
+
+ ` : ''} +
+ `; + showToast('成功', '许可证验证通过', 'success'); + } else { + resultDiv.innerHTML = ` +
+
+ +

许可证验证失败

+
+

${result.message}

+
+ `; + showToast('失败', '许可证验证失败', 'error'); + } + } catch (error) { + console.error('验证许可证失败:', error); + showToast('错误', '验证许可证失败', 'error'); + + document.getElementById('verifyResult').innerHTML = ` +
+
+ +

验证过程出错

+
+

无法连接到服务器或请求格式错误

+
+ `; + } +} \ No newline at end of file From 8fd07f27a6ed872058c7742691864496e075c246 Mon Sep 17 00:00:00 2001 From: yuoon Date: Wed, 1 Oct 2025 22:13:00 +0800 Subject: [PATCH 03/22] shamir --- springboot-shamir/.gitignore | 6 + springboot-shamir/README.md | 129 ++++++++ springboot-shamir/pom.xml | 63 ++++ .../com/demo/shamir/ShamirApplication.java | 11 + .../com/demo/shamir/config/CorsConfig.java | 21 ++ .../shamir/controller/ShamirController.java | 65 ++++ .../com/demo/shamir/dto/CombineRequest.java | 17 + .../com/demo/shamir/dto/CombineResponse.java | 17 + .../com/demo/shamir/dto/SplitRequest.java | 19 ++ .../com/demo/shamir/dto/SplitResponse.java | 19 ++ .../demo/shamir/service/ShamirService.java | 150 +++++++++ .../com/demo/shamir/util/ShamirUtils.java | 174 +++++++++++ .../src/main/resources/application.properties | 5 + .../src/main/resources/static/app.js | 291 ++++++++++++++++++ .../src/main/resources/static/index.html | 218 +++++++++++++ 15 files changed, 1205 insertions(+) create mode 100644 springboot-shamir/.gitignore create mode 100644 springboot-shamir/README.md create mode 100644 springboot-shamir/pom.xml create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/ShamirApplication.java create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/config/CorsConfig.java create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/controller/ShamirController.java create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/dto/CombineRequest.java create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/dto/CombineResponse.java create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/dto/SplitRequest.java create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/dto/SplitResponse.java create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/service/ShamirService.java create mode 100644 springboot-shamir/src/main/java/com/demo/shamir/util/ShamirUtils.java create mode 100644 springboot-shamir/src/main/resources/application.properties create mode 100644 springboot-shamir/src/main/resources/static/app.js create mode 100644 springboot-shamir/src/main/resources/static/index.html diff --git a/springboot-shamir/.gitignore b/springboot-shamir/.gitignore new file mode 100644 index 0000000..0c6a7e2 --- /dev/null +++ b/springboot-shamir/.gitignore @@ -0,0 +1,6 @@ +.idea/ +target/ +*.iml +.DS_Store +.vscode/ +*.log \ No newline at end of file diff --git a/springboot-shamir/README.md b/springboot-shamir/README.md new file mode 100644 index 0000000..b9c2a69 --- /dev/null +++ b/springboot-shamir/README.md @@ -0,0 +1,129 @@ +# Shamir Secret Sharing - 五门三限密钥共享系统 + +基于 Shamir Secret Sharing (SSS) 算法的门限密钥共享演示系统,实现了"五门三限"的经典案例:将密钥拆分为 5 份,任意 3 份即可恢复。 + +## 项目特点 + +- ✅ **纯 Java 实现** - 基于拉格朗日插值和有限域运算,无需第三方算法库 +- ✅ **前后端分离** - Spring Boot RESTful API + HTML/JS/TailwindCSS +- ✅ **交互友好** - 现代化 UI 设计,支持一键复制、实时验证 +- ✅ **演示性强** - 内存存储(Map),适合学习和演示使用 + +⚠️ **注意**:本项目使用 `ConcurrentHashMap` 存储会话信息,仅用于演示。生产环境应使用数据库或分布式存储(如 Redis、MySQL)。 + +## 技术栈 + +### 后端 +- Spring Boot 3.2.0 +- Java 17 +- Maven +- Lombok + +### 前端 +- HTML5 +- TailwindCSS 3.x(通过 CDN) +- Vanilla JavaScript + +## 快速开始 + +### 1. 克隆项目 +```bash +cd springboot-shamir +``` + +### 2. 构建并运行 +```bash +# 使用 Maven 构建 +mvn clean package + +# 运行 Spring Boot 应用 +mvn spring-boot:run + +# 或者直接运行 JAR +java -jar target/springboot-shamir-1.0.0.jar +``` + +### 3. 访问前端 +打开浏览器访问:http://localhost:8080 + +## API 接口 + +### 1. 拆分密钥 +**POST** `/api/shamir/split` + +请求体: +```json +{ + "secret": "my-super-secret-key", + "totalShares": 5, + "threshold": 3 +} +``` + +响应: +```json +{ + "sessionId": "uuid-xxx", + "shares": [ + "1:a1b2c3d4...", + "2:e5f6g7h8...", + "3:i9j0k1l2...", + "4:m3n4o5p6...", + "5:q7r8s9t0..." + ], + "message": "密钥已拆分为 5 份,任意 3 份可恢复原始密钥" +} +``` + +### 2. 恢复密钥 +**POST** `/api/shamir/combine` + +请求体: +```json +{ + "shares": [ + "1:a1b2c3d4...", + "3:i9j0k1l2...", + "5:q7r8s9t0..." + ] +} +``` + +响应: +```json +{ + "secret": "my-super-secret-key", + "message": "成功使用 3 个份额恢复密钥", + "success": true +} +``` + +### 3. 健康检查 +**GET** `/api/shamir/health` + +响应:`Shamir Secret Sharing Service is running` + +## 算法原理 + +### Shamir Secret Sharing (SSS) + +基于**拉格朗日插值定理**: +- 构造 t-1 次多项式:f(x) = a₀ + a₁x + a₂x² + ... + a_{t-1}x^{t-1} +- 其中 a₀ = secret(密钥) +- 生成 n 个点:(x₁, f(x₁)), (x₂, f(x₂)), ..., (xₙ, f(xₙ)) +- 恢复时用拉格朗日公式计算 f(0) = a₀ + +### 数学保证 +- 任意 t 个点可唯一确定多项式 +- 少于 t 个点则有无穷多个可能的多项式 +- 因此少于 t 份无法推导出密钥 + +## 应用场景 + +1. **金融安全** - 银行大额转账多人审批 +2. **区块链多签** - 数字货币钱包多重签名 +3. **云密钥管理** - 云服务商与用户共同持有密钥碎片 +4. **企业权限控制** - 高危操作需多人参与 +5. **数据备份** - 分布式密钥存储,防止单点故障 + + diff --git a/springboot-shamir/pom.xml b/springboot-shamir/pom.xml new file mode 100644 index 0000000..598589d --- /dev/null +++ b/springboot-shamir/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.demo + springboot-shamir + 1.0.0 + springboot-shamir + Shamir Secret Sharing Demo with Spring Boot + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + 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-shamir/src/main/java/com/demo/shamir/ShamirApplication.java b/springboot-shamir/src/main/java/com/demo/shamir/ShamirApplication.java new file mode 100644 index 0000000..840560f --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/ShamirApplication.java @@ -0,0 +1,11 @@ +package com.demo.shamir; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ShamirApplication { + public static void main(String[] args) { + SpringApplication.run(ShamirApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-shamir/src/main/java/com/demo/shamir/config/CorsConfig.java b/springboot-shamir/src/main/java/com/demo/shamir/config/CorsConfig.java new file mode 100644 index 0000000..c10a5ba --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/config/CorsConfig.java @@ -0,0 +1,21 @@ +package com.demo.shamir.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * CORS 跨域配置 + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("*") // 生产环境应配置具体域名 + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .maxAge(3600); + } +} \ No newline at end of file diff --git a/springboot-shamir/src/main/java/com/demo/shamir/controller/ShamirController.java b/springboot-shamir/src/main/java/com/demo/shamir/controller/ShamirController.java new file mode 100644 index 0000000..1f7d1e0 --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/controller/ShamirController.java @@ -0,0 +1,65 @@ +package com.demo.shamir.controller; + +import com.demo.shamir.dto.*; +import com.demo.shamir.service.ShamirService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Shamir Secret Sharing REST API + */ +@RestController +@RequestMapping("/api/shamir") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") // 允许跨域(生产环境应配置具体域名) +public class ShamirController { + + private final ShamirService shamirService; + + /** + * 拆分密钥 + * POST /api/shamir/split + */ + @PostMapping("/split") + public ResponseEntity split(@RequestBody SplitRequest request) { + try { + SplitResponse response = shamirService.split(request); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest() + .body(new SplitResponse(null, null, e.getMessage())); + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(new SplitResponse(null, null, "服务器错误:" + e.getMessage())); + } + } + + /** + * 恢复密钥 + * POST /api/shamir/combine + */ + @PostMapping("/combine") + public ResponseEntity combine(@RequestBody CombineRequest request) { + try { + CombineResponse response = shamirService.combine(request); + if (response.isSuccess()) { + return ResponseEntity.ok(response); + } else { + return ResponseEntity.badRequest().body(response); + } + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(new CombineResponse(null, "服务器错误:" + e.getMessage(), false)); + } + } + + /** + * 健康检查 + * GET /api/shamir/health + */ + @GetMapping("/health") + public ResponseEntity health() { + return ResponseEntity.ok("Shamir Secret Sharing Service is running"); + } +} \ No newline at end of file diff --git a/springboot-shamir/src/main/java/com/demo/shamir/dto/CombineRequest.java b/springboot-shamir/src/main/java/com/demo/shamir/dto/CombineRequest.java new file mode 100644 index 0000000..84542d6 --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/dto/CombineRequest.java @@ -0,0 +1,17 @@ +package com.demo.shamir.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 恢复请求 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CombineRequest { + private List shares; // 密钥份额列表 +} \ No newline at end of file diff --git a/springboot-shamir/src/main/java/com/demo/shamir/dto/CombineResponse.java b/springboot-shamir/src/main/java/com/demo/shamir/dto/CombineResponse.java new file mode 100644 index 0000000..2acf470 --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/dto/CombineResponse.java @@ -0,0 +1,17 @@ +package com.demo.shamir.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 恢复响应 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CombineResponse { + private String secret; // 恢复的密钥 + private String message; // 提示信息 + private boolean success; // 是否成功 +} \ No newline at end of file diff --git a/springboot-shamir/src/main/java/com/demo/shamir/dto/SplitRequest.java b/springboot-shamir/src/main/java/com/demo/shamir/dto/SplitRequest.java new file mode 100644 index 0000000..3da1354 --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/dto/SplitRequest.java @@ -0,0 +1,19 @@ +package com.demo.shamir.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 拆分请求 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SplitRequest { + private String secret; // 原始密钥 + private Integer totalShares; // 总份额数 (n) + private Integer threshold; // 门限值 (t) +} \ No newline at end of file diff --git a/springboot-shamir/src/main/java/com/demo/shamir/dto/SplitResponse.java b/springboot-shamir/src/main/java/com/demo/shamir/dto/SplitResponse.java new file mode 100644 index 0000000..1cd7280 --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/dto/SplitResponse.java @@ -0,0 +1,19 @@ +package com.demo.shamir.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 拆分响应 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SplitResponse { + private String sessionId; // 会话 ID + private List shares; // 密钥份额列表 + private String message; // 提示信息 +} \ No newline at end of file diff --git a/springboot-shamir/src/main/java/com/demo/shamir/service/ShamirService.java b/springboot-shamir/src/main/java/com/demo/shamir/service/ShamirService.java new file mode 100644 index 0000000..8728bb8 --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/service/ShamirService.java @@ -0,0 +1,150 @@ +package com.demo.shamir.service; + +import com.demo.shamir.dto.*; +import com.demo.shamir.util.ShamirUtils; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Shamir 密钥共享服务 + * 注意:此处使用 Map 存储仅用于演示,实际生产环境应存储到数据库或其他持久化存储 + */ +@Service +public class ShamirService { + + /** + * 存储会话信息(演示用,生产环境应使用数据库) + * key: sessionId, value: 会话元数据 + */ + private final Map sessionStore = new ConcurrentHashMap<>(); + + /** + * 会话元数据 + */ + private static class SessionMetadata { + String sessionId; + int totalShares; + int threshold; + long createTime; + + SessionMetadata(String sessionId, int totalShares, int threshold) { + this.sessionId = sessionId; + this.totalShares = totalShares; + this.threshold = threshold; + this.createTime = System.currentTimeMillis(); + } + } + + /** + * 拆分密钥 + */ + public SplitResponse split(SplitRequest request) { + // 参数校验 + if (request.getSecret() == null || request.getSecret().isEmpty()) { + throw new IllegalArgumentException("Secret cannot be empty"); + } + if (request.getTotalShares() == null || request.getTotalShares() < 2) { + throw new IllegalArgumentException("Total shares must be at least 2"); + } + if (request.getThreshold() == null || request.getThreshold() < 2) { + throw new IllegalArgumentException("Threshold must be at least 2"); + } + if (request.getThreshold() > request.getTotalShares()) { + throw new IllegalArgumentException("Threshold cannot exceed total shares"); + } + + // 将密钥转换为字节数组 + byte[] secretBytes = request.getSecret().getBytes(StandardCharsets.UTF_8); + + // 调用 Shamir 算法拆分 + List shares = ShamirUtils.split( + secretBytes, + request.getTotalShares(), + request.getThreshold() + ); + + // 编码份额为字符串 + List encodedShares = shares.stream() + .map(ShamirUtils.Share::encode) + .collect(Collectors.toList()); + + // 生成会话 ID + String sessionId = UUID.randomUUID().toString(); + + // 存储会话元数据(演示用) + sessionStore.put(sessionId, new SessionMetadata( + sessionId, + request.getTotalShares(), + request.getThreshold() + )); + + return new SplitResponse( + sessionId, + encodedShares, + String.format("密钥已拆分为 %d 份,任意 %d 份可恢复原始密钥", + request.getTotalShares(), request.getThreshold()) + ); + } + + /** + * 恢复密钥 + */ + public CombineResponse combine(CombineRequest request) { + try { + // 参数校验 + if (request.getShares() == null || request.getShares().isEmpty()) { + return new CombineResponse(null, "密钥份额列表不能为空", false); + } + + // 解码份额 + List shares = request.getShares().stream() + .map(ShamirUtils.Share::decode) + .collect(Collectors.toList()); + + // 调用 Shamir 算法恢复 + byte[] secretBytes = ShamirUtils.combine(shares); + + // 转换为字符串(处理可能的多余字节) + String secret = new String(secretBytes, StandardCharsets.UTF_8).trim(); + + // 移除可能的前导零字节 + if (secret.charAt(0) == '\0') { + secret = secret.substring(1); + } + + return new CombineResponse( + secret, + String.format("成功使用 %d 个份额恢复密钥", shares.size()), + true + ); + + } catch (Exception e) { + return new CombineResponse( + null, + "恢复失败:" + e.getMessage(), + false + ); + } + } + + /** + * 获取所有会话(演示用) + */ + public Map getAllSessions() { + return new HashMap<>(sessionStore); + } + + /** + * 清理过期会话(演示用,生产环境应使用定时任务) + */ + public void cleanExpiredSessions(long maxAgeMillis) { + long now = System.currentTimeMillis(); + sessionStore.entrySet().removeIf(entry -> + now - entry.getValue().createTime > maxAgeMillis + ); + } +} \ No newline at end of file diff --git a/springboot-shamir/src/main/java/com/demo/shamir/util/ShamirUtils.java b/springboot-shamir/src/main/java/com/demo/shamir/util/ShamirUtils.java new file mode 100644 index 0000000..4c8fa20 --- /dev/null +++ b/springboot-shamir/src/main/java/com/demo/shamir/util/ShamirUtils.java @@ -0,0 +1,174 @@ +package com.demo.shamir.util; + +import java.math.BigInteger; +import java.security.SecureRandom; +import java.util.*; + +/** + * Shamir Secret Sharing 算法实现 + * 基于拉格朗日插值和有限域运算 + */ +public class ShamirUtils { + + private static final SecureRandom RANDOM = new SecureRandom(); + // 使用一个大素数作为有限域的模 + private static final BigInteger PRIME = new BigInteger( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16); + + /** + * 密钥份额 + */ + public static class Share { + private final int x; // 份额的 x 坐标 + private final BigInteger y; // 份额的 y 坐标 + + public Share(int x, BigInteger y) { + this.x = x; + this.y = y; + } + + public int getX() { + return x; + } + + public BigInteger getY() { + return y; + } + + /** + * 编码为 Base64 字符串,格式:x:y(hex) + */ + public String encode() { + return x + ":" + y.toString(16); + } + + /** + * 从编码字符串解码 + */ + public static Share decode(String encoded) { + String[] parts = encoded.split(":"); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid share format"); + } + int x = Integer.parseInt(parts[0]); + BigInteger y = new BigInteger(parts[1], 16); + return new Share(x, y); + } + + @Override + public String toString() { + return "Share{x=" + x + ", y=" + y.toString(16) + "}"; + } + } + + /** + * 拆分密钥 + * + * @param secret 原始密钥(字节数组) + * @param n 总份额数 + * @param threshold 门限值(至少需要多少份才能恢复) + * @return 密钥份额列表 + */ + public static List split(byte[] secret, int n, int threshold) { + if (threshold > n) { + throw new IllegalArgumentException("Threshold cannot be greater than total shares"); + } + if (threshold < 2) { + throw new IllegalArgumentException("Threshold must be at least 2"); + } + + // 将密钥转换为 BigInteger + BigInteger secretInt = new BigInteger(1, secret); + + // 确保密钥小于素数 + if (secretInt.compareTo(PRIME) >= 0) { + throw new IllegalArgumentException("Secret is too large"); + } + + // 生成随机多项式系数:f(x) = a0 + a1*x + a2*x^2 + ... + a(t-1)*x^(t-1) + // 其中 a0 = secret + BigInteger[] coefficients = new BigInteger[threshold]; + coefficients[0] = secretInt; + for (int i = 1; i < threshold; i++) { + coefficients[i] = new BigInteger(PRIME.bitLength(), RANDOM).mod(PRIME); + } + + // 生成 n 个份额 + List shares = new ArrayList<>(); + for (int x = 1; x <= n; x++) { + BigInteger y = evaluatePolynomial(coefficients, x); + shares.add(new Share(x, y)); + } + + return shares; + } + + /** + * 恢复密钥 + * + * @param shares 至少 threshold 个份额 + * @return 原始密钥(字节数组) + */ + public static byte[] combine(List shares) { + if (shares == null || shares.isEmpty()) { + throw new IllegalArgumentException("Shares list cannot be empty"); + } + + // 使用拉格朗日插值恢复多项式在 x=0 处的值(即 a0,也就是密钥) + BigInteger secret = lagrangeInterpolate(shares); + + return secret.toByteArray(); + } + + /** + * 计算多项式在 x 处的值 + */ + private static BigInteger evaluatePolynomial(BigInteger[] coefficients, int x) { + BigInteger result = BigInteger.ZERO; + BigInteger xPower = BigInteger.ONE; + BigInteger xBig = BigInteger.valueOf(x); + + for (BigInteger coefficient : coefficients) { + result = result.add(coefficient.multiply(xPower)).mod(PRIME); + xPower = xPower.multiply(xBig).mod(PRIME); + } + + return result; + } + + /** + * 拉格朗日插值,计算 f(0) 的值 + */ + private static BigInteger lagrangeInterpolate(List shares) { + BigInteger result = BigInteger.ZERO; + + for (int i = 0; i < shares.size(); i++) { + Share share = shares.get(i); + BigInteger numerator = BigInteger.ONE; + BigInteger denominator = BigInteger.ONE; + + for (int j = 0; j < shares.size(); j++) { + if (i == j) continue; + + Share otherShare = shares.get(j); + // 计算拉格朗日基础多项式 + // numerator *= (0 - x_j) + numerator = numerator.multiply(BigInteger.valueOf(-otherShare.getX())).mod(PRIME); + // denominator *= (x_i - x_j) + denominator = denominator.multiply( + BigInteger.valueOf(share.getX() - otherShare.getX()) + ).mod(PRIME); + } + + // 计算 y_i * numerator / denominator + BigInteger term = share.getY() + .multiply(numerator) + .multiply(denominator.modInverse(PRIME)) + .mod(PRIME); + + result = result.add(term).mod(PRIME); + } + + return result; + } +} \ No newline at end of file diff --git a/springboot-shamir/src/main/resources/application.properties b/springboot-shamir/src/main/resources/application.properties new file mode 100644 index 0000000..fbd2a31 --- /dev/null +++ b/springboot-shamir/src/main/resources/application.properties @@ -0,0 +1,5 @@ +server.port=8080 +spring.application.name=shamir-secret-sharing + +logging.level.com.demo.shamir=INFO +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n \ No newline at end of file diff --git a/springboot-shamir/src/main/resources/static/app.js b/springboot-shamir/src/main/resources/static/app.js new file mode 100644 index 0000000..fc5da57 --- /dev/null +++ b/springboot-shamir/src/main/resources/static/app.js @@ -0,0 +1,291 @@ +// API 基础地址 +const API_BASE_URL = 'http://localhost:8080/api/shamir'; + +// DOM 元素 +const splitForm = document.getElementById('splitForm'); +const combineForm = document.getElementById('combineForm'); +const secretInput = document.getElementById('secretInput'); +const totalSharesInput = document.getElementById('totalShares'); +const thresholdInput = document.getElementById('threshold'); +const sharesInput = document.getElementById('sharesInput'); +const splitResult = document.getElementById('splitResult'); +const combineResult = document.getElementById('combineResult'); +const sharesContainer = document.getElementById('sharesContainer'); +const splitMessage = document.getElementById('splitMessage'); +const sessionIdElement = document.getElementById('sessionId'); +const combineMessage = document.getElementById('combineMessage'); +const combineStatus = document.getElementById('combineStatus'); +const recoveredSecret = document.getElementById('recoveredSecret'); +const recoveredSecretContainer = document.getElementById('recoveredSecretContainer'); +const requiredShares = document.getElementById('requiredShares'); + +// 更新门限值显示 +thresholdInput.addEventListener('input', () => { + requiredShares.textContent = thresholdInput.value; +}); + +// 拆分密钥表单提交 +splitForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const secret = secretInput.value.trim(); + const totalShares = parseInt(totalSharesInput.value); + const threshold = parseInt(thresholdInput.value); + + // 参数校验 + if (!secret) { + showError('请输入原始密钥'); + return; + } + + if (threshold > totalShares) { + showError('门限值不能大于总份额数'); + return; + } + + if (threshold < 2 || totalShares < 2) { + showError('门限值和总份额数必须至少为 2'); + return; + } + + try { + // 显示加载状态 + const submitButton = splitForm.querySelector('button[type="submit"]'); + const originalText = submitButton.textContent; + submitButton.textContent = '🔄 处理中...'; + submitButton.disabled = true; + + // 调用后端 API + const response = await fetch(`${API_BASE_URL}/split`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + secret: secret, + totalShares: totalShares, + threshold: threshold + }) + }); + + const data = await response.json(); + + if (response.ok && data.shares) { + // 显示拆分结果 + displaySplitResult(data); + + // 自动填充门限值到右侧提示 + requiredShares.textContent = threshold; + } else { + showError(data.message || '拆分失败,请检查输入参数'); + } + + // 恢复按钮状态 + submitButton.textContent = originalText; + submitButton.disabled = false; + + } catch (error) { + console.error('Error:', error); + showError('网络错误,请确保后端服务已启动(端口 8080)'); + + // 恢复按钮状态 + const submitButton = splitForm.querySelector('button[type="submit"]'); + submitButton.textContent = '🚀 开始拆分'; + submitButton.disabled = false; + } +}); + +// 恢复密钥表单提交 +combineForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const sharesText = sharesInput.value.trim(); + + if (!sharesText) { + showCombineError('请输入密钥份额'); + return; + } + + // 解析份额(每行一个) + const shares = sharesText + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + if (shares.length < 2) { + showCombineError('至少需要 2 个密钥份额'); + return; + } + + try { + // 显示加载状态 + const submitButton = combineForm.querySelector('button[type="submit"]'); + const originalText = submitButton.textContent; + submitButton.textContent = '🔄 处理中...'; + submitButton.disabled = true; + + // 调用后端 API + const response = await fetch(`${API_BASE_URL}/combine`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + shares: shares + }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + // 显示恢复成功结果 + displayCombineSuccess(data); + } else { + showCombineError(data.message || '恢复失败,请检查份额格式'); + } + + // 恢复按钮状态 + submitButton.textContent = originalText; + submitButton.disabled = false; + + } catch (error) { + console.error('Error:', error); + showCombineError('网络错误,请确保后端服务已启动(端口 8080)'); + + // 恢复按钮状态 + const submitButton = combineForm.querySelector('button[type="submit"]'); + submitButton.textContent = '🔓 恢复密钥'; + submitButton.disabled = false; + } +}); + +// 显示拆分结果 +function displaySplitResult(data) { + splitMessage.textContent = data.message; + sessionIdElement.textContent = `会话 ID: ${data.sessionId}`; + + // 清空之前的份额 + sharesContainer.innerHTML = ''; + + // 显示每个份额 + data.shares.forEach((share, index) => { + const shareCard = document.createElement('div'); + shareCard.className = 'share-card bg-gray-50 border border-gray-300 rounded-lg p-4 hover:bg-gray-100'; + + shareCard.innerHTML = ` +
+ 份额 ${index + 1} + +
+
+ ${share} +
+ `; + + sharesContainer.appendChild(shareCard); + }); + + splitResult.classList.remove('hidden'); + + // 平滑滚动到结果区域 + splitResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +// 显示恢复成功结果 +function displayCombineSuccess(data) { + combineStatus.className = 'bg-green-50 border border-green-200 rounded-lg p-4 mb-4'; + combineMessage.className = 'text-green-800 font-medium'; + combineMessage.textContent = data.message; + + recoveredSecret.textContent = data.secret; + recoveredSecretContainer.classList.remove('hidden'); + + combineResult.classList.remove('hidden'); + combineResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +// 显示拆分错误 +function showError(message) { + alert('❌ 错误:' + message); +} + +// 显示恢复错误 +function showCombineError(message) { + combineStatus.className = 'bg-red-50 border border-red-200 rounded-lg p-4 mb-4'; + combineMessage.className = 'text-red-800 font-medium'; + combineMessage.textContent = message; + + recoveredSecretContainer.classList.add('hidden'); + combineResult.classList.remove('hidden'); + combineResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +// 复制份额到剪贴板 +function copyShare(index) { + const shareElement = document.getElementById(`share-${index}`); + const shareText = shareElement.textContent; + + // 使用现代 Clipboard API + if (navigator.clipboard) { + navigator.clipboard.writeText(shareText).then(() => { + // 显示复制成功提示 + showToast('✅ 份额已复制到剪贴板'); + }).catch(err => { + console.error('复制失败:', err); + fallbackCopy(shareText); + }); + } else { + fallbackCopy(shareText); + } +} + +// 降级复制方案 +function fallbackCopy(text) { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + document.execCommand('copy'); + showToast('✅ 份额已复制到剪贴板'); + } catch (err) { + console.error('复制失败:', err); + alert('复制失败,请手动复制'); + } + + document.body.removeChild(textarea); +} + +// 显示临时提示 +function showToast(message) { + const toast = document.createElement('div'); + toast.className = 'fixed bottom-4 right-4 bg-gray-800 text-white px-6 py-3 rounded-lg shadow-lg z-50 animate-fade-in'; + toast.textContent = message; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transition = 'opacity 0.3s ease'; + setTimeout(() => document.body.removeChild(toast), 300); + }, 2000); +} + +// 自动连接测试 +window.addEventListener('load', async () => { + try { + const response = await fetch(`${API_BASE_URL}/health`); + if (response.ok) { + console.log('✅ 后端服务连接成功'); + } + } catch (error) { + console.warn('⚠️ 后端服务未启动,请先启动 Spring Boot 应用'); + showToast('⚠️ 后端服务未启动,请先启动 Spring Boot 应用'); + } +}); \ No newline at end of file diff --git a/springboot-shamir/src/main/resources/static/index.html b/springboot-shamir/src/main/resources/static/index.html new file mode 100644 index 0000000..cb70d8e --- /dev/null +++ b/springboot-shamir/src/main/resources/static/index.html @@ -0,0 +1,218 @@ + + + + + + Shamir 门限密钥共享系统 - 五门三限演示 + + + + + + +
+
+

🔐 Shamir 门限密钥共享系统

+

基于拉格朗日插值的五门三限算法演示

+
+
+ + +
+ + +
+

💡 什么是五门三限?

+

+ 五门三限是门限密码学的经典应用:将一个密钥拆分为 5 份密钥碎片, + 任意 3 份即可恢复原始密钥,少于 3 份则完全无法推算。 +

+

+ ⚠️ 注意:本演示使用内存 Map 存储,实际生产环境应存储到数据库或分布式存储系统。 +

+
+ + +
+ + +
+

+ 1 + 拆分密钥 +

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

+ 2 + 恢复密钥 +

+ +
+ +
+

+ ℹ️ 请输入至少 3 个密钥份额(每行一个) +

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

🔬 技术原理

+
+
+

数学基础

+

+ 基于 Shamir Secret Sharing (SSS),利用拉格朗日插值和有限域运算 +

+
+
+

安全性保证

+

+ 任何少于门限值的份额组合,数学上完全无法推导出原始密钥 +

+
+
+

应用场景

+

+ 金融安全、区块链多签、云密钥管理、企业权限控制 +

+
+
+
+ +
+ + +
+
+

+ 🚀 Spring Boot + Shamir Secret Sharing Demo | + GitHub +

+

+ ⚠️ 演示系统 - 生产环境请将数据持久化到数据库 +

+
+
+ + + + + + \ No newline at end of file From 6ec78996f54a65a5af2811eef62e463a5409a2ab Mon Sep 17 00:00:00 2001 From: yuoon Date: Wed, 8 Oct 2025 20:32:03 +0800 Subject: [PATCH 04/22] permission --- springboot-permission/README.md | 85 +++++ springboot-permission/pom.xml | 116 +++++++ .../permission/PermissionApplication.java | 15 + .../annotation/CheckPermission.java | 25 ++ .../permission/aspect/PermissionAspect.java | 149 +++++++++ .../common/AccessDeniedException.java | 15 + .../com/example/permission/common/Result.java | 34 ++ .../controller/DocumentController.java | 89 +++++ .../controller/PolicyController.java | 75 +++++ .../example/permission/entity/Document.java | 59 ++++ .../com/example/permission/entity/User.java | 50 +++ .../permission/service/DocumentService.java | 103 ++++++ .../permission/service/EnforcerService.java | 108 +++++++ .../src/main/resources/application.yml | 11 + .../src/main/resources/casbin/model.conf | 11 + .../src/main/resources/casbin/policy.csv | 3 + .../src/main/resources/static/index.html | 126 ++++++++ .../src/main/resources/static/js/app.js | 306 ++++++++++++++++++ 18 files changed, 1380 insertions(+) create mode 100644 springboot-permission/README.md create mode 100644 springboot-permission/pom.xml create mode 100644 springboot-permission/src/main/java/com/example/permission/PermissionApplication.java create mode 100644 springboot-permission/src/main/java/com/example/permission/annotation/CheckPermission.java create mode 100644 springboot-permission/src/main/java/com/example/permission/aspect/PermissionAspect.java create mode 100644 springboot-permission/src/main/java/com/example/permission/common/AccessDeniedException.java create mode 100644 springboot-permission/src/main/java/com/example/permission/common/Result.java create mode 100644 springboot-permission/src/main/java/com/example/permission/controller/DocumentController.java create mode 100644 springboot-permission/src/main/java/com/example/permission/controller/PolicyController.java create mode 100644 springboot-permission/src/main/java/com/example/permission/entity/Document.java create mode 100644 springboot-permission/src/main/java/com/example/permission/entity/User.java create mode 100644 springboot-permission/src/main/java/com/example/permission/service/DocumentService.java create mode 100644 springboot-permission/src/main/java/com/example/permission/service/EnforcerService.java create mode 100644 springboot-permission/src/main/resources/application.yml create mode 100644 springboot-permission/src/main/resources/casbin/model.conf create mode 100644 springboot-permission/src/main/resources/casbin/policy.csv create mode 100644 springboot-permission/src/main/resources/static/index.html create mode 100644 springboot-permission/src/main/resources/static/js/app.js diff --git a/springboot-permission/README.md b/springboot-permission/README.md new file mode 100644 index 0000000..af7c021 --- /dev/null +++ b/springboot-permission/README.md @@ -0,0 +1,85 @@ +# ABAC 权限管理系统 + +基于 **JCasbin** 的属性访问控制(ABAC)演示项目,实现业务逻辑与权限逻辑的完全解耦。 + +## 🚀 快速开始 + +### 1. 启动后端 + +```bash +cd springboot-permission +mvn spring-boot:run +``` + +### 2. 访问前端 + +打开浏览器访问:http://localhost:8080 + +## 🎯 功能特性 + +### ✅ 核心功能 + +- **ABAC 权限模型**:基于用户和资源属性的动态授权 +- **AOP 切面拦截**:通过 `@CheckPermission` 注解实现无侵入式权限控制 +- **策略动态配置**:支持运行时添加/删除策略规则 +- **前后端分离**:HTML + Tailwind CSS + Axios + +### 📋 权限规则示例 + +| 规则 | 主体条件 | 资源条件 | 操作 | +|------|---------|---------|------| +| 同部门可编辑 | `r.sub.dept == r.obj.dept && r.sub.id == r.obj.ownerId` | `true` | edit | +| 同部门可读 | `r.sub.dept == r.obj.dept` | `true` | read | +| 所有者可删除 | `r.sub.id == r.obj.ownerId` | `true` | delete | +| 公开文档可读 | `true` | `r.obj.type == "public"` | read | + +## 🧪 测试场景 + +### 场景 1:部门隔离 +- 张三(研发部)只能编辑研发部的文档 +- 李四(销售部)无法访问研发部文档 + +### 场景 2:所有权控制 +- 文档创建者可以删除自己的文档 +- 其他用户无法删除 + +### 场景 3:公开资源 +- 所有用户都可以阅读 `type=public` 的文档 + +## 🔧 技术栈 + +- **后端**:Spring Boot 3.2.0 + JCasbin 1.55.0 +- **前端**:HTML + Tailwind CSS + Axios +- **权限引擎**:JCasbin(支持 RBAC/ABAC/RBAC with domains) + +## 📝 使用说明 + +### 1. 在业务代码中使用权限注解 + +```java +@CheckPermission(action = "edit") +@PutMapping("/{id}") +public Result update(@PathVariable String id, @RequestBody Document doc) { + return Result.success(documentService.update(doc)); +} +``` + +### 2. 策略配置 + +**模型文件(model.conf)**: +``` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub_rule, obj_rule, act + +[matchers] +m = eval(p.sub_rule) && eval(p.obj_rule) && r.act == p.act +``` + +**策略文件(policy.csv)**: +``` +p, r.sub.dept == r.obj.dept, r.sub.id == r.obj.ownerId, edit +p, r.sub.dept == r.obj.dept, true, read +``` \ No newline at end of file diff --git a/springboot-permission/pom.xml b/springboot-permission/pom.xml new file mode 100644 index 0000000..b36a864 --- /dev/null +++ b/springboot-permission/pom.xml @@ -0,0 +1,116 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.example + springboot-permission + 1.0.0 + springboot-permission + ABAC Permission System with JCasbin + + + 17 + 1.84.0 + 2.6.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.casbin + jcasbin + ${jcasbin.version} + + + + + org.casbin + jdbc-adapter + ${jdbc-adapter.version} + + + + + com.mysql + mysql-connector-j + runtime + + + + + com.zaxxer + HikariCP + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + com.github.ben-manes.caffeine + caffeine + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/springboot-permission/src/main/java/com/example/permission/PermissionApplication.java b/springboot-permission/src/main/java/com/example/permission/PermissionApplication.java new file mode 100644 index 0000000..36f8811 --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/PermissionApplication.java @@ -0,0 +1,15 @@ +package com.example.permission; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 应用启动类 + */ +@SpringBootApplication +public class PermissionApplication { + + public static void main(String[] args) { + SpringApplication.run(PermissionApplication.class, args); + } +} diff --git a/springboot-permission/src/main/java/com/example/permission/annotation/CheckPermission.java b/springboot-permission/src/main/java/com/example/permission/annotation/CheckPermission.java new file mode 100644 index 0000000..26d75d7 --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/annotation/CheckPermission.java @@ -0,0 +1,25 @@ +package com.example.permission.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 权限检查注解 + * 用于方法级别的权限控制 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface CheckPermission { + + /** + * 操作类型:read, edit, delete 等 + */ + String action(); + + /** + * 资源参数名称(默认从方法参数中获取第一个 Document 类型) + */ + String resourceParam() default ""; +} diff --git a/springboot-permission/src/main/java/com/example/permission/aspect/PermissionAspect.java b/springboot-permission/src/main/java/com/example/permission/aspect/PermissionAspect.java new file mode 100644 index 0000000..25ce141 --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/aspect/PermissionAspect.java @@ -0,0 +1,149 @@ +package com.example.permission.aspect; + +import com.example.permission.annotation.CheckPermission; +import com.example.permission.common.AccessDeniedException; +import com.example.permission.entity.Document; +import com.example.permission.entity.User; +import com.example.permission.service.DocumentService; +import com.example.permission.service.EnforcerService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 权限检查切面 + */ +@Slf4j +@Aspect +@Component +public class PermissionAspect { + + @Autowired + private EnforcerService enforcerService; + + @Autowired + private DocumentService documentService; + + @Before("@annotation(checkPermission)") + public void checkAuth(JoinPoint joinPoint, CheckPermission checkPermission) { + log.debug("权限检查开始:action={}", checkPermission.action()); + + // 1. 获取当前用户 + User user = getCurrentUser(); + if (user == null) { + throw new AccessDeniedException("未登录"); + } + + // 2. 获取资源对象 + Document resource = getResource(joinPoint, checkPermission); + if (resource == null) { + throw new AccessDeniedException("资源对象不存在"); + } + + // 3. 执行权限检查 + String action = checkPermission.action(); + boolean allowed = enforcerService.enforce(user, resource, action); + + log.info("权限检查结果:user={}, resource={}, action={}, allowed={}", + user.getId(), resource.getId(), action, allowed); + + if (!allowed) { + throw new AccessDeniedException( + String.format("无权限执行操作:%s (用户=%s, 资源=%s)", action, user.getId(), resource.getId()) + ); + } + } + + /** + * 从请求上下文获取当前用户(简化版,实际应从 Session/JWT 获取) + */ + private User getCurrentUser() { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return null; + } + + HttpServletRequest request = attributes.getRequest(); + + // 从 Header 中获取用户信息(演示用) + String userId = request.getHeader("X-User-Id"); + String userName = request.getHeader("X-User-Name"); + String userDept = request.getHeader("X-User-Dept"); + + if (userId == null) { + return null; + } + + return new User(userId, userName, userDept); + } + + /** + * 从方法参数中提取资源对象 + */ + private Document getResource(JoinPoint joinPoint, CheckPermission checkPermission) { + Object[] args = joinPoint.getArgs(); + + // 如果指定了参数名,则按名称匹配 + if (!checkPermission.resourceParam().isEmpty()) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] paramNames = signature.getParameterNames(); + for (int i = 0; i < paramNames.length; i++) { + if (paramNames[i].equals(checkPermission.resourceParam())) { + return convertToDocument(args[i]); + } + } + } + + // 查找ID参数并尝试通过ID获取资源 + String resourceId = getResourceIdFromParams(args); + if (resourceId != null) { + return documentService.getDocument(resourceId); + } + + // 否则查找第一个 Document 类型参数 + for (Object arg : args) { + if (arg instanceof Document) { + return (Document) arg; + } + } + + return null; + } + + /** + * 从参数中提取资源ID + */ + private String getResourceIdFromParams(Object[] args) { + for (Object arg : args) { + if (arg instanceof String) { + // 简单判断:假设String类型的参数是资源ID + return (String) arg; + } + } + return null; + } + + /** + * 尝试将参数转换为 Document + */ + private Document convertToDocument(Object obj) { + if (obj instanceof Document) { + return (Document) obj; + } + // 支持从 ID 查询 Document + if (obj instanceof String) { + return documentService.getDocument((String) obj); + } + return null; + } + + +} diff --git a/springboot-permission/src/main/java/com/example/permission/common/AccessDeniedException.java b/springboot-permission/src/main/java/com/example/permission/common/AccessDeniedException.java new file mode 100644 index 0000000..c0c5ff9 --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/common/AccessDeniedException.java @@ -0,0 +1,15 @@ +package com.example.permission.common; + +/** + * 权限拒绝异常 + */ +public class AccessDeniedException extends RuntimeException { + + public AccessDeniedException(String message) { + super(message); + } + + public AccessDeniedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/springboot-permission/src/main/java/com/example/permission/common/Result.java b/springboot-permission/src/main/java/com/example/permission/common/Result.java new file mode 100644 index 0000000..cfdcd95 --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/common/Result.java @@ -0,0 +1,34 @@ +package com.example.permission.common; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 统一响应结果 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Result { + + private Integer code; + private String message; + private T data; + + public static Result success(T data) { + return new Result<>(200, "success", data); + } + + public static Result success() { + return new Result<>(200, "success", null); + } + + public static Result error(String message) { + return new Result<>(500, message, null); + } + + public static Result error(Integer code, String message) { + return new Result<>(code, message, null); + } +} diff --git a/springboot-permission/src/main/java/com/example/permission/controller/DocumentController.java b/springboot-permission/src/main/java/com/example/permission/controller/DocumentController.java new file mode 100644 index 0000000..e602d8c --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/controller/DocumentController.java @@ -0,0 +1,89 @@ +package com.example.permission.controller; + +import com.example.permission.annotation.CheckPermission; +import com.example.permission.common.AccessDeniedException; +import com.example.permission.common.Result; +import com.example.permission.entity.Document; +import com.example.permission.service.DocumentService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 文档管理控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/documents") +@CrossOrigin(origins = "*") +public class DocumentController { + + @Autowired + private DocumentService documentService; + + /** + * 查询所有文档(无权限限制) + */ + @GetMapping + public Result> list() { + return Result.success(documentService.getAllDocuments()); + } + + /** + * 查询单个文档(需要 read 权限) + */ + @CheckPermission(action = "read") + @GetMapping("/{id}") + public Result get(@PathVariable String id) { + Document doc = documentService.getDocument(id); + return Result.success(doc); + } + + /** + * 创建文档(无权限限制) + */ + @PostMapping + public Result create(@RequestBody Document doc) { + Document created = documentService.createDocument(doc); + return Result.success(created); + } + + /** + * 更新文档(需要 edit 权限) + */ + @CheckPermission(action = "edit") + @PutMapping("/{id}") + public Result update(@PathVariable String id, @RequestBody Document doc) { + doc.setId(id); + Document updated = documentService.updateDocument(doc); + return Result.success(updated); + } + + /** + * 删除文档(需要 delete 权限) + */ + @CheckPermission(action = "delete") + @DeleteMapping("/{id}") + public Result delete(@PathVariable String id) { + Document doc = documentService.getDocument(id); + documentService.deleteDocument(id); + return Result.success(); + } + + /** + * 全局异常处理 + */ + @ExceptionHandler(AccessDeniedException.class) + public Result handleAccessDenied(AccessDeniedException e) { + log.warn("权限拒绝:{}", e.getMessage()); + return Result.error(403, e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public Result handleException(Exception e) { + log.error("服务异常", e); + return Result.error(e.getMessage()); + } +} diff --git a/springboot-permission/src/main/java/com/example/permission/controller/PolicyController.java b/springboot-permission/src/main/java/com/example/permission/controller/PolicyController.java new file mode 100644 index 0000000..eb407bb --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/controller/PolicyController.java @@ -0,0 +1,75 @@ +package com.example.permission.controller; + +import com.example.permission.common.Result; +import com.example.permission.service.EnforcerService; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 策略管理控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/policies") +@CrossOrigin(origins = "*") +public class PolicyController { + + @Autowired + private EnforcerService enforcerService; + + /** + * 获取所有策略 + */ + @GetMapping + public Result>> list() { + List> policies = enforcerService.getAllPolicy(); + return Result.success(policies); + } + + /** + * 添加策略 + */ + @PostMapping + public Result add(@RequestBody PolicyRequest request) { + boolean success = enforcerService.addPolicy( + request.getSubRule(), + request.getObjRule(), + request.getAction() + ); + + if (success) { + enforcerService.savePolicy(); + } + + return Result.success(success); + } + + /** + * 删除策略 + */ + @DeleteMapping + public Result remove(@RequestBody PolicyRequest request) { + boolean success = enforcerService.removePolicy( + request.getSubRule(), + request.getObjRule(), + request.getAction() + ); + + if (success) { + enforcerService.savePolicy(); + } + + return Result.success(success); + } + + @Data + public static class PolicyRequest { + private String subRule; + private String objRule; + private String action; + } +} diff --git a/springboot-permission/src/main/java/com/example/permission/entity/Document.java b/springboot-permission/src/main/java/com/example/permission/entity/Document.java new file mode 100644 index 0000000..7310775 --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/entity/Document.java @@ -0,0 +1,59 @@ +package com.example.permission.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 文档实体 - ABAC 资源对象(Object) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Document { + + private String id; + private String title; + private String content; + private String ownerId; // 所有者ID + private String dept; // 所属部门 + private String type; // 文档类型:contract(合同)、report(报告)、public(公开) + private Integer securityLevel; // 安全级别:1-公开 2-内部 3-机密 + private LocalDateTime createTime; + private LocalDateTime updateTime; + + /** + * 转换为 ABAC 属性 Map,用于策略匹配 + */ + public Map toAttributes() { + Map attrs = new HashMap<>(); + attrs.put("id", this.id); + attrs.put("ownerId", this.ownerId); + attrs.put("dept", this.dept); + attrs.put("type", this.type); + attrs.put("securityLevel", this.securityLevel); + return attrs; + } + + /** + * 简化构造器 + */ + public Document(String id, String ownerId, String dept) { + this.id = id; + this.ownerId = ownerId; + this.dept = dept; + } + + public Document(String id, String title, String ownerId, String dept, String type) { + this.id = id; + this.title = title; + this.ownerId = ownerId; + this.dept = dept; + this.type = type; + this.createTime = LocalDateTime.now(); + } +} diff --git a/springboot-permission/src/main/java/com/example/permission/entity/User.java b/springboot-permission/src/main/java/com/example/permission/entity/User.java new file mode 100644 index 0000000..c8767ab --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/entity/User.java @@ -0,0 +1,50 @@ +package com.example.permission.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +/** + * 用户实体 - ABAC 主体(Subject) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class User { + + private String id; + private String name; + private String dept; // 部门 + private String role; // 角色(可选) + private Integer level; // 级别 + + /** + * 转换为 ABAC 属性 Map,用于策略匹配 + */ + public Map toAttributes() { + Map attrs = new HashMap<>(); + attrs.put("id", this.id); + attrs.put("name", this.name); + attrs.put("dept", this.dept); + attrs.put("role", this.role); + attrs.put("level", this.level); + return attrs; + } + + /** + * 简化构造器 + */ + public User(String id, String dept) { + this.id = id; + this.dept = dept; + } + + public User(String id, String name, String dept) { + this.id = id; + this.name = name; + this.dept = dept; + } +} diff --git a/springboot-permission/src/main/java/com/example/permission/service/DocumentService.java b/springboot-permission/src/main/java/com/example/permission/service/DocumentService.java new file mode 100644 index 0000000..a60b9a2 --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/service/DocumentService.java @@ -0,0 +1,103 @@ +package com.example.permission.service; + +import com.example.permission.entity.Document; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 文档服务(业务逻辑层) + */ +@Slf4j +@Service +public class DocumentService { + + // 模拟数据存储 + private final Map documentStore = new ConcurrentHashMap<>(); + private final AtomicInteger idGenerator = new AtomicInteger(4); + + public DocumentService() { + // 初始化测试数据 + initTestData(); + } + + private void initTestData() { + createDocument(new Document("1", "研发部周报", "user1", "yfb", "report")); + createDocument(new Document("2", "销售合同", "user2", "xsb", "contract")); + createDocument(new Document("3", "公司公告", "admin", "xzb", "public")); + } + + /** + * 创建文档 + */ + public Document createDocument(Document doc) { + if (doc.getId() == null || doc.getId().isEmpty()) { + doc.setId(String.valueOf(idGenerator.getAndIncrement())); + } + doc.setCreateTime(LocalDateTime.now()); + doc.setUpdateTime(LocalDateTime.now()); + documentStore.put(doc.getId(), doc); + log.info("创建文档:{}", doc.getId()); + return doc; + } + + /** + * 更新文档 + */ + public Document updateDocument(Document doc) { + Document existing = documentStore.get(doc.getId()); + if (existing == null) { + throw new RuntimeException("文档不存在:" + doc.getId()); + } + + existing.setTitle(doc.getTitle()); + existing.setContent(doc.getContent()); + existing.setType(doc.getType()); + existing.setUpdateTime(LocalDateTime.now()); + + log.info("更新文档:{}", doc.getId()); + return existing; + } + + /** + * 删除文档 + */ + public void deleteDocument(String id) { + Document removed = documentStore.remove(id); + if (removed == null) { + throw new RuntimeException("文档不存在:" + id); + } + log.info("删除文档:{}", id); + } + + /** + * 查询文档 + */ + public Document getDocument(String id) { + Document doc = documentStore.get(id); + if (doc == null) { + throw new RuntimeException("文档不存在:" + id); + } + return doc; + } + + /** + * 查询所有文档 + */ + public List getAllDocuments() { + return new ArrayList<>(documentStore.values()); + } + + /** + * 按部门查询文档 + */ + public List getDocumentsByDept(String dept) { + return documentStore.values().stream() + .filter(doc -> dept.equals(doc.getDept())) + .toList(); + } +} diff --git a/springboot-permission/src/main/java/com/example/permission/service/EnforcerService.java b/springboot-permission/src/main/java/com/example/permission/service/EnforcerService.java new file mode 100644 index 0000000..8e89e37 --- /dev/null +++ b/springboot-permission/src/main/java/com/example/permission/service/EnforcerService.java @@ -0,0 +1,108 @@ +package com.example.permission.service; + +import com.example.permission.entity.Document; +import com.example.permission.entity.User; +import lombok.extern.slf4j.Slf4j; +import org.casbin.jcasbin.main.Enforcer; +import org.casbin.jcasbin.model.Model; +import org.casbin.jcasbin.persist.file_adapter.FileAdapter; +import org.casbin.jcasbin.util.function.CustomFunction; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Casbin 权限决策服务 + */ +@Slf4j +@Service +public class EnforcerService { + + private Enforcer enforcer; + + @PostConstruct + public void init() { + try { + String modelPath = "src/main/resources/casbin/model.conf"; + String policyPath = "src/main/resources/casbin/policy.csv"; + + Model model = new Model(); + model.loadModel(modelPath); + + enforcer = new Enforcer(model, new FileAdapter(policyPath)); + + log.info("Casbin Enforcer 初始化成功"); + } catch (Exception e) { + log.error("Casbin Enforcer 初始化失败", e); + throw new RuntimeException("权限引擎初始化失败", e); + } + } + + /** + * 权限判断 + */ + public boolean enforce(User user, Document doc, String action) { + try { + // 构建请求上下文 + RequestContext context = new RequestContext(user, doc, action); + + // 执行权限检查 + boolean result = enforcer.enforce(user,doc,action); + + log.debug("权限检查:user={}, doc={}, action={}, result={}", + user.getId(), doc.getId(), action, result); + + return result; + } catch (Exception e) { + log.error("权限检查异常", e); + return false; + } + } + + /** + * 添加策略 + */ + public boolean addPolicy(String subRule, String objRule, String act) { + return enforcer.addPolicy(subRule, objRule, act); + } + + /** + * 删除策略 + */ + public boolean removePolicy(String subRule, String objRule, String act) { + return enforcer.removePolicy(subRule, objRule, act); + } + + /** + * 获取所有策略 + */ + public List> getAllPolicy() { + return enforcer.getPolicy(); + } + + /** + * 保存策略到文件 + */ + public void savePolicy() { + enforcer.savePolicy(); + } + + /** + * 请求上下文(封装 ABAC 属性) + */ + public static class RequestContext { + public final Map sub; + public final Map obj; + public final String act; + + public RequestContext(User user, Document doc, String action) { + this.sub = user.toAttributes(); + this.obj = doc.toAttributes(); + this.act = action; + } + } + +} diff --git a/springboot-permission/src/main/resources/application.yml b/springboot-permission/src/main/resources/application.yml new file mode 100644 index 0000000..0467efb --- /dev/null +++ b/springboot-permission/src/main/resources/application.yml @@ -0,0 +1,11 @@ +server: + port: 8080 + +spring: + application: + name: springboot-permission + +logging: + level: + com.example.permission: DEBUG + org.casbin: DEBUG diff --git a/springboot-permission/src/main/resources/casbin/model.conf b/springboot-permission/src/main/resources/casbin/model.conf new file mode 100644 index 0000000..23023bd --- /dev/null +++ b/springboot-permission/src/main/resources/casbin/model.conf @@ -0,0 +1,11 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub_rule, obj_rule, act + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = eval(p.sub_rule) && eval(p.obj_rule) && r.act == p.act \ No newline at end of file diff --git a/springboot-permission/src/main/resources/casbin/policy.csv b/springboot-permission/src/main/resources/casbin/policy.csv new file mode 100644 index 0000000..c080a7d --- /dev/null +++ b/springboot-permission/src/main/resources/casbin/policy.csv @@ -0,0 +1,3 @@ +p, r.sub.dept == r.obj.dept && r.sub.id == r.obj.ownerId, true, edit +p, r.sub.dept == r.obj.dept, true, read +p, true, r.obj.type == "public", read \ No newline at end of file diff --git a/springboot-permission/src/main/resources/static/index.html b/springboot-permission/src/main/resources/static/index.html new file mode 100644 index 0000000..4327b72 --- /dev/null +++ b/springboot-permission/src/main/resources/static/index.html @@ -0,0 +1,126 @@ + + + + + + ABAC 权限管理系统 + + + + +
+ +
+

🧭 ABAC 权限演示系统

+

基于 JCasbin 的属性访问控制演示

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

创建文档

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

文档列表

+ + + + + + + + + + + + +
ID标题所有者部门类型操作
+
+
+ + + +
+ + + +
+ + + + diff --git a/springboot-permission/src/main/resources/static/js/app.js b/springboot-permission/src/main/resources/static/js/app.js new file mode 100644 index 0000000..387145f --- /dev/null +++ b/springboot-permission/src/main/resources/static/js/app.js @@ -0,0 +1,306 @@ +// API 配置 +const API_BASE = 'http://localhost:8080/api'; + +// 当前用户信息 +let currentUser = { + id: '', + name: '', + dept: '' +}; + +// 页面加载完成 +document.addEventListener('DOMContentLoaded', () => { + loadDocuments(); + loadPolicies(); +}); + +// 切换用户 +function switchUser() { + const select = document.getElementById('userSelect'); + const value = select.value; + + if (!value) { + showToast('请选择用户', 'error'); + return; + } + + const [id, name, dept] = value.split(','); + currentUser = { id, name, dept }; + + document.getElementById('currentUser').textContent = `已登录:${name} (${dept})`; + showToast(`已切换到用户:${name}`, 'success'); + + loadDocuments(); +} + +// 切换标签页 +function switchTab(tab) { + // 切换标签样式 + document.querySelectorAll('[id^="tab-"]').forEach(el => { + el.classList.remove('border-blue-500', 'text-blue-500'); + el.classList.add('text-gray-500'); + }); + document.getElementById(`tab-${tab}`).classList.add('border-blue-500', 'text-blue-500'); + document.getElementById(`tab-${tab}`).classList.remove('text-gray-500'); + + // 切换内容 + document.querySelectorAll('[id^="content-"]').forEach(el => el.classList.add('hidden')); + document.getElementById(`content-${tab}`).classList.remove('hidden'); + + // 加载数据 + if (tab === 'policies') { + loadPolicies(); + } +} + +// 加载文档列表 +async function loadDocuments() { + try { + const response = await axios.get(`${API_BASE}/documents`); + const documents = response.data.data || []; + + const tbody = document.getElementById('documentList'); + tbody.innerHTML = documents.map(doc => ` + + ${doc.id} + ${doc.title || '-'} + ${doc.ownerId} + ${doc.dept} + + + ${doc.type} + + + + + + + + `).join(''); + } catch (error) { + console.error('加载文档失败', error); + showToast('加载文档失败', 'error'); + } +} + +// 创建文档 +async function createDocument() { + const title = document.getElementById('docTitle').value; + const ownerId = document.getElementById('docOwnerId').value; + const dept = document.getElementById('docDept').value; + const type = document.getElementById('docType').value; + + if (!title || !ownerId || !dept) { + showToast('请填写完整信息', 'error'); + return; + } + + try { + await axios.post(`${API_BASE}/documents`, { + title, ownerId, dept, type + }, getHeaders()); + + showToast('创建成功', 'success'); + clearDocumentForm(); + loadDocuments(); + } catch (error) { + showToast(error.response?.data?.message || '创建失败', 'error'); + } +} + +// 编辑文档 +async function editDocument(id) { + if (!currentUser.id) { + showToast('请先选择用户', 'error'); + return; + } + + const newTitle = prompt('请输入新标题:'); + if (!newTitle) return; + + try { + // 先获取文档信息 + const getResp = await axios.get(`${API_BASE}/documents/${id}`, getHeaders()); + const doc = getResp.data.data; + + // 更新文档 + const response = await axios.put(`${API_BASE}/documents/${id}`, { + ...doc, + title: newTitle + }, getHeaders()); + + if(response.data.code != 200){ + showToast(response.data.message,'error'); + return; + } + + showToast('编辑成功', 'success'); + loadDocuments(); + } catch (error) { + showToast(error.response?.data?.message || '编辑失败', 'error'); + } +} + +// 删除文档 +async function deleteDocument(id) { + if (!currentUser.id) { + showToast('请先选择用户', 'error'); + return; + } + + if (!confirm('确认删除?')) return; + + try { + // 先获取文档信息(用于权限检查) + const getResp = await axios.get(`${API_BASE}/documents/${id}`, getHeaders()); + const doc = getResp.data.data; + + const response = await axios.delete(`${API_BASE}/documents/${id}`, { + headers: getHeaders().headers, + data: doc + }); + + if(response.data.code != 200){ + showToast(response.data.message,'error'); + return; + } + + showToast('删除成功', 'success'); + loadDocuments(); + } catch (error) { + showToast(error.response?.data?.message || '删除失败', 'error'); + } +} + +// 加载策略列表 +async function loadPolicies() { + try { + const response = await axios.get(`${API_BASE}/policies`); + const policies = response.data.data || []; + + const tbody = document.getElementById('policyList'); + tbody.innerHTML = policies.map((policy, index) => ` + + ${policy[0]} + ${policy[1]} + + + ${policy[2]} + + + + + + + `).join(''); + } catch (error) { + console.error('加载策略失败', error); + showToast('加载策略失败', 'error'); + } +} + +function escHtml(str) { + return str.replace(/[&<>"']/g, m => ({ + '&':'&','<':'<','>':'>','"':'"',"'":''' + })[m]); +} + +// 添加策略 +async function addPolicy() { + const subRule = document.getElementById('subRule').value; + const objRule = document.getElementById('objRule').value; + const action = document.getElementById('policyAction').value; + + if (!subRule || !objRule) { + showToast('请填写完整信息', 'error'); + return; + } + + try { + await axios.post(`${API_BASE}/policies`, { + subRule, objRule, action + }); + + showToast('添加成功', 'success'); + clearPolicyForm(); + loadPolicies(); + } catch (error) { + showToast(error.response?.data?.message || '添加失败', 'error'); + } +} + +// 删除策略 +async function removePolicy(subRule, objRule, action) { + if (!confirm('确认删除此策略?')) return; + + try { + await axios.delete(`${API_BASE}/policies`, { + data: { subRule, objRule, action } + }); + + showToast('删除成功', 'success'); + loadPolicies(); + } catch (error) { + showToast(error.response?.data?.message || '删除失败', 'error'); + } +} + +// 获取请求头(携带用户信息) +function getHeaders() { + return { + headers: { + 'X-User-Id': currentUser.id, + 'X-User-Name': currentUser.name, + 'X-User-Dept': currentUser.dept + } + }; +} + +// 清空文档表单 +function clearDocumentForm() { + document.getElementById('docTitle').value = ''; + document.getElementById('docOwnerId').value = ''; + document.getElementById('docDept').value = ''; +} + +// 清空策略表单 +function clearPolicyForm() { + document.getElementById('subRule').value = ''; + document.getElementById('objRule').value = ''; +} + +// 显示提示消息 +function showToast(message, type = 'info') { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.classList.remove('hidden', 'bg-blue-500', 'bg-green-500', 'bg-red-500'); + + if (type === 'success') toast.classList.add('bg-green-500'); + else if (type === 'error') toast.classList.add('bg-red-500'); + else toast.classList.add('bg-blue-500'); + + setTimeout(() => toast.classList.add('hidden'), 3000); +} + +// 获取类型颜色 +function getTypeColor(type) { + const colors = { + 'report': 'bg-blue-100 text-blue-800', + 'contract': 'bg-green-100 text-green-800', + 'public': 'bg-gray-100 text-gray-800' + }; + return colors[type] || 'bg-gray-100 text-gray-800'; +} + +// 获取操作颜色 +function getActionColor(action) { + const colors = { + 'read': 'bg-blue-100 text-blue-800', + 'edit': 'bg-yellow-100 text-yellow-800', + 'delete': 'bg-red-100 text-red-800' + }; + return colors[action] || 'bg-gray-100 text-gray-800'; +} From cb2d52523ddc355820a7817607a942be5224c3c8 Mon Sep 17 00:00:00 2001 From: yuoon Date: Sat, 11 Oct 2025 23:04:56 +0800 Subject: [PATCH 05/22] object-change --- springboot-object-version/README.md | 27 ++ springboot-object-version/pom.xml | 64 +++ .../SpringbootObjectVersionApplication.java | 12 + .../objectversion/annotation/Audit.java | 46 ++ .../objectversion/aspect/AuditAspect.java | 275 +++++++++++ .../objectversion/config/JaversConfig.java | 17 + .../objectversion/config/WebConfig.java | 18 + .../controller/ProductController.java | 77 +++ .../controller/RestExceptionHandler.java | 27 ++ .../objectversion/dto/ProductRequest.java | 17 + .../example/objectversion/model/AuditLog.java | 14 + .../example/objectversion/model/Product.java | 11 + .../objectversion/service/ProductService.java | 90 ++++ .../src/main/resources/application.yml | 8 + .../src/main/resources/static/index.html | 443 ++++++++++++++++++ 15 files changed, 1146 insertions(+) create mode 100644 springboot-object-version/README.md create mode 100644 springboot-object-version/pom.xml create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/SpringbootObjectVersionApplication.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/annotation/Audit.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/aspect/AuditAspect.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/config/JaversConfig.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/config/WebConfig.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/controller/ProductController.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/controller/RestExceptionHandler.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/dto/ProductRequest.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/model/AuditLog.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/model/Product.java create mode 100644 springboot-object-version/src/main/java/com/example/objectversion/service/ProductService.java create mode 100644 springboot-object-version/src/main/resources/application.yml create mode 100644 springboot-object-version/src/main/resources/static/index.html diff --git a/springboot-object-version/README.md b/springboot-object-version/README.md new file mode 100644 index 0000000..9463e47 --- /dev/null +++ b/springboot-object-version/README.md @@ -0,0 +1,27 @@ +# Spring Boot 对象审计 + +极简版对象审计示例,使用 Spring Boot 3 与 Javers 在内存中记录实体变更,并通过 REST API 暴露。前端页面使用纯 HTML + Tailwind CSS,通过 Fetch API 调用接口,界面与提示均为中文。 + +## 后端 + +- 技术栈:Java 17、Spring Boot 3、Spring Web、Javers Core。 +- 数据存储:`ConcurrentHashMap` 保存商品与审计日志,进程重启后数据会清空。 +- 审计逻辑:拦截商品的新增、更新、删除操作,将 `javers.compare` 的 Diff JSON 直接写入审计流。 +- 主要接口(前缀 `/api/products`): + - `GET /api/products` - 查询全部商品。 + - `GET /api/products/{id}` - 根据编号获取商品。 + - `PUT /api/products/{id}` - 新建或更新商品(可携带 `X-User` 头作为操作人)。 + - `DELETE /api/products/{id}` - 删除商品。 + - `GET /api/products/{id}/audits` - 查看指定商品的审计记录。 + - `GET /api/products/audits` - 查看全部审计记录。 + +## 前端 + +- 页面位置:`src/main/resources/static/index.html`(Spring Boot 会自动以静态资源目录提供服务)。 +- 访问地址:后端启动后打开 `http://localhost:8080/index.html`;也可以直接双击文件本地预览。 +- 功能说明: + 1. 表单区用于创建或更新商品,可选填操作人。 + 2. 商品列表支持查看审计与删除操作。 + 3. 审计时间线支持筛选单个商品或回看全部记录,Diff JSON 会解析成中文说明。 + +如需修改后端地址,请在页面顶部脚本中调整 `API_BASE` 常量。 diff --git a/springboot-object-version/pom.xml b/springboot-object-version/pom.xml new file mode 100644 index 0000000..6b5ce06 --- /dev/null +++ b/springboot-object-version/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + com.example + springboot-object-version + 0.0.1-SNAPSHOT + jar + + springboot-object-version + Minimal object audit MVP with Spring Boot 3 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.javers + javers-core + 7.3.1 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-aop + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/springboot-object-version/src/main/java/com/example/objectversion/SpringbootObjectVersionApplication.java b/springboot-object-version/src/main/java/com/example/objectversion/SpringbootObjectVersionApplication.java new file mode 100644 index 0000000..ddaad58 --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/SpringbootObjectVersionApplication.java @@ -0,0 +1,12 @@ +package com.example.objectversion; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringbootObjectVersionApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringbootObjectVersionApplication.class, args); + } +} diff --git a/springboot-object-version/src/main/java/com/example/objectversion/annotation/Audit.java b/springboot-object-version/src/main/java/com/example/objectversion/annotation/Audit.java new file mode 100644 index 0000000..57c8f45 --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/annotation/Audit.java @@ -0,0 +1,46 @@ +package com.example.objectversion.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 审计注解,用于标记需要进行变更审计的方法 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Audit { + + /** + * ID字段名,用于从实体中提取ID + */ + String idField() default "id"; + + /** + * ID参数名,如果指定则直接从方法参数中获取ID + */ + String idParam() default ""; + + /** + * 操作类型,如果未指定则根据方法名自动推断 + */ + ActionType action() default ActionType.AUTO; + + /** + * 操作人参数名 + */ + String actorParam() default ""; + + /** + * 实体参数位置 + */ + int entityIndex() default 0; + + /** + * 操作类型枚举 + */ + enum ActionType { + CREATE, UPDATE, DELETE, AUTO + } +} \ No newline at end of file diff --git a/springboot-object-version/src/main/java/com/example/objectversion/aspect/AuditAspect.java b/springboot-object-version/src/main/java/com/example/objectversion/aspect/AuditAspect.java new file mode 100644 index 0000000..b847536 --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/aspect/AuditAspect.java @@ -0,0 +1,275 @@ +package com.example.objectversion.aspect; + +import com.example.objectversion.annotation.Audit; +import com.example.objectversion.model.AuditLog; +import com.example.objectversion.model.Product; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.javers.core.Javers; +import org.javers.core.diff.Diff; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 审计切面,自包含的审计逻辑,无需额外服务类 + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class AuditAspect { + + private final Javers javers; + + // 内存存储审计日志(生成环境需要存放到数据库中) + private final List auditTimeline = new CopyOnWriteArrayList<>(); + private final Map> auditByEntity = new ConcurrentHashMap<>(); + private final AtomicLong auditSequence = new AtomicLong(0); + + // 数据存储,用于快照对比 + private final Map dataStore = new ConcurrentHashMap<>(); + + @Around("@annotation(auditAnnotation)") + public Object auditMethod(ProceedingJoinPoint joinPoint, Audit auditAnnotation) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + String[] paramNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + // 提取实体ID + String entityId = extractEntityId(null, args, paramNames, auditAnnotation); + log.info("审计方法: {}, 提取到ID: {}", method.getName(), entityId); + if (entityId == null) { + log.error("无法提取实体ID,跳过审计: {}, 参数名: {}", method.getName(), paramNames != null ? String.join(",", paramNames) : "null"); + return joinPoint.proceed(); + } + + // 提取实体对象(DELETE操作可能不需要) + Object entity = null; + if (auditAnnotation.entityIndex() >= 0 && auditAnnotation.entityIndex() < args.length) { + entity = args[auditAnnotation.entityIndex()]; + } + + // 提取操作人 + String actor = extractActor(args, paramNames, auditAnnotation); + + // 获取操作类型 + Audit.ActionType actionType = determineActionType(auditAnnotation, method.getName()); + + // 执行前快照 + Object beforeSnapshot = null; + if (actionType == Audit.ActionType.DELETE) { + // 删除操作:需要知道要删除什么,从数据存储中获取当前实体 + beforeSnapshot = dataStore.get(buildKey(Product.class, entityId)); // 假设是Product,实际应该动态确定 + } else if (entity != null) { + // 其他操作:从存储中获取历史快照 + beforeSnapshot = dataStore.get(buildKey(entity.getClass(), entityId)); + } + + // 执行原方法 + Object result = joinPoint.proceed(); + + // 执行后快照 - 这里需要通过业务逻辑获取最新状态 + // 简化处理:对于CREATE和DELETE,直接使用null或新对象 + // 对于UPDATE,这里应该重新查询,但为了演示简化处理 + Object afterSnapshot = determineAfterSnapshot(entity, actionType, entityId); + + // 比较差异并记录审计日志 + Diff diff = javers.compare(beforeSnapshot, afterSnapshot); + + // 确定实体类型 + String entityType = entity != null ? entity.getClass().getSimpleName() : "Product"; + + // DELETE操作或有变更时记录审计 + if (diff.hasChanges() || beforeSnapshot == null || actionType == Audit.ActionType.DELETE) { + recordAudit( + entityType, + entityId, + actionType.name(), + actor, + javers.getJsonConverter().toJson(diff) + ); + } + + // 更新数据存储 + if (actionType != Audit.ActionType.DELETE) { + Class entityClass = entity != null ? entity.getClass() : Product.class; + dataStore.put(buildKey(entityClass, entityId), afterSnapshot); + } else { + Class entityClass = entity != null ? entity.getClass() : Product.class; + dataStore.remove(buildKey(entityClass, entityId)); + } + + return result; + } + + /** + * 提取操作人 + */ + private String extractActor(Object[] args, String[] paramNames, Audit audit) { + if (paramNames == null) { + return "anonymous"; + } + + // 根据参数名查找 + if (!audit.actorParam().isEmpty()) { + for (int i = 0; i < paramNames.length; i++) { + if (audit.actorParam().equals(paramNames[i])) { + Object actor = args[i]; + return actor != null ? actor.toString() : "anonymous"; + } + } + } + + return "anonymous"; + } + + /** + * 从实体中提取ID - 支持多种方式 + */ + private String extractEntityId(Object entity, Object[] args, String[] paramNames, Audit audit) { + // 1. 优先从方法参数中直接获取ID + if (!audit.idParam().isEmpty() && paramNames != null) { + for (int i = 0; i < paramNames.length; i++) { + if (audit.idParam().equals(paramNames[i])) { + Object idValue = args[i]; + if (idValue != null) { + return idValue.toString(); + } + } + } + } + + // 2. 从实体对象中提取ID + if (entity != null) { + return extractIdFromEntity(entity, audit.idField()); + } + + return null; + } + + /** + * 从实体对象中提取ID + */ + private String extractIdFromEntity(Object entity, String idField) { + try { + // 处理Record类型 + if (entity.getClass().isRecord()) { + var components = entity.getClass().getRecordComponents(); + for (var component : components) { + if (component.getName().equals(idField)) { + Object idValue = component.getAccessor().invoke(entity); + return idValue != null ? idValue.toString() : null; + } + } + log.debug("Record类型中未找到字段: {}", idField); + return null; + } + + // 处理普通类 - 先尝试get方法 + try { + String getterName = "get" + idField.substring(0, 1).toUpperCase() + idField.substring(1); + var getter = entity.getClass().getMethod(getterName); + Object idValue = getter.invoke(entity); + return idValue != null ? idValue.toString() : null; + } catch (NoSuchMethodException e) { + // 直接访问字段 + Field field = entity.getClass().getDeclaredField(idField); + field.setAccessible(true); + Object idValue = field.get(entity); + return idValue != null ? idValue.toString() : null; + } + } catch (Exception e) { + log.debug("从实体提取ID失败: {} for {}", idField, entity.getClass().getName()); + return null; + } + } + + /** + * 确定操作类型 + */ + private Audit.ActionType determineActionType(Audit audit, String methodName) { + if (audit.action() != Audit.ActionType.AUTO) { + return audit.action(); + } + + String lowerMethodName = methodName.toLowerCase(); + if (lowerMethodName.contains("create") || lowerMethodName.contains("save")) { + return Audit.ActionType.CREATE; + } else if (lowerMethodName.contains("delete") || lowerMethodName.contains("remove")) { + return Audit.ActionType.DELETE; + } else { + return Audit.ActionType.UPDATE; + } + } + + /** + * 确定执行后的快照 + */ + private Object determineAfterSnapshot(Object entity, Audit.ActionType actionType, String entityId) { + switch (actionType) { + case CREATE: + return entity; // 创建操作,新实体就是最终状态 + case DELETE: + return null; // 删除操作,最终状态为null + case UPDATE: + default: + return entity; // 更新操作,使用传入的新实体 + } + } + + /** + * 构建存储键 + */ + private String buildKey(Class entityClass, String entityId) { + return entityClass.getSimpleName() + ":" + entityId; + } + + /** + * 记录审计日志 + */ + private void recordAudit(String entityType, String entityId, String action, + String actor, String diffJson) { + AuditLog auditLog = new AuditLog( + Long.toString(auditSequence.incrementAndGet()), + entityType, + entityId, + action, + actor, + Instant.now(), + diffJson + ); + + auditTimeline.add(auditLog); + auditByEntity.computeIfAbsent(entityId, key -> new CopyOnWriteArrayList<>()) + .add(auditLog); + + log.debug("记录审计日志: {} {} by {}", action, entityType + ":" + entityId, actor); + } + + /** + * 查询指定实体的审计日志 + */ + public List findAuditByEntityId(String entityId) { + return List.copyOf(auditByEntity.getOrDefault(entityId, List.of())); + } + + /** + * 查询所有审计日志 + */ + public List findAllAudits() { + return List.copyOf(auditTimeline); + } +} \ No newline at end of file diff --git a/springboot-object-version/src/main/java/com/example/objectversion/config/JaversConfig.java b/springboot-object-version/src/main/java/com/example/objectversion/config/JaversConfig.java new file mode 100644 index 0000000..190c55d --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/config/JaversConfig.java @@ -0,0 +1,17 @@ +package com.example.objectversion.config; + +import org.javers.core.Javers; +import org.javers.core.JaversBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JaversConfig { + + @Bean + public Javers javers() { + return JaversBuilder.javers() + .withPrettyPrint(true) + .build(); + } +} diff --git a/springboot-object-version/src/main/java/com/example/objectversion/config/WebConfig.java b/springboot-object-version/src/main/java/com/example/objectversion/config/WebConfig.java new file mode 100644 index 0000000..3c33c9d --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/config/WebConfig.java @@ -0,0 +1,18 @@ +package com.example.objectversion.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(false); + } +} diff --git a/springboot-object-version/src/main/java/com/example/objectversion/controller/ProductController.java b/springboot-object-version/src/main/java/com/example/objectversion/controller/ProductController.java new file mode 100644 index 0000000..91181a6 --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/controller/ProductController.java @@ -0,0 +1,77 @@ +package com.example.objectversion.controller; + +import com.example.objectversion.dto.ProductRequest; +import com.example.objectversion.model.AuditLog; +import com.example.objectversion.model.Product; +import com.example.objectversion.service.ProductService; +import com.example.objectversion.aspect.AuditAspect; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collection; +import java.util.List; + +@RestController +@RequestMapping("/api/products") +public class ProductController { + + private final ProductService productService; + private final AuditAspect auditAspect; + + public ProductController(ProductService productService, AuditAspect auditAspect) { + this.productService = productService; + this.auditAspect = auditAspect; + } + + @GetMapping + public Collection findAll() { + return productService.findAll(); + } + + @GetMapping("/{id}") + public ResponseEntity findById(@PathVariable String id) { + return productService.findById(id) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PutMapping("/{id}") + public ResponseEntity upsert(@PathVariable String id, + @Valid @RequestBody ProductRequest request, + @RequestHeader(name = "X-User", required = false) String actor) { + boolean existed = productService.findById(id).isPresent(); + Product result = productService.upsert(id, request, normalizeActor(actor)); + HttpStatus status = existed ? HttpStatus.OK : HttpStatus.CREATED; + return new ResponseEntity<>(result, status); + } + + @DeleteMapping("/{id}") + public ResponseEntity remove(@PathVariable String id, + @RequestHeader(name = "X-User", required = false) String actor) { + boolean removed = productService.delete(id, normalizeActor(actor)); + return removed ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); + } + + @GetMapping("/{id}/audits") + public List findAudits(@PathVariable String id) { + return auditAspect.findAuditByEntityId(id); + } + + @GetMapping("/audits") + public List findAllAudits() { + return auditAspect.findAllAudits(); + } + + private String normalizeActor(String actor) { + return actor == null || actor.isBlank() ? "anonymous" : actor.trim(); + } +} diff --git a/springboot-object-version/src/main/java/com/example/objectversion/controller/RestExceptionHandler.java b/springboot-object-version/src/main/java/com/example/objectversion/controller/RestExceptionHandler.java new file mode 100644 index 0000000..19200b8 --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/controller/RestExceptionHandler.java @@ -0,0 +1,27 @@ +package com.example.objectversion.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class RestExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + Map response = new HashMap<>(); + response.put("message", "参数校验失败"); + Map errors = new HashMap<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + response.put("errors", errors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } +} diff --git a/springboot-object-version/src/main/java/com/example/objectversion/dto/ProductRequest.java b/springboot-object-version/src/main/java/com/example/objectversion/dto/ProductRequest.java new file mode 100644 index 0000000..adfba45 --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/dto/ProductRequest.java @@ -0,0 +1,17 @@ +package com.example.objectversion.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +import java.math.BigDecimal; + +public record ProductRequest( + @NotBlank(message = "name is required") + String name, + @NotNull(message = "price is required") + @PositiveOrZero(message = "price must be >= 0") + BigDecimal price, + String description +) { +} diff --git a/springboot-object-version/src/main/java/com/example/objectversion/model/AuditLog.java b/springboot-object-version/src/main/java/com/example/objectversion/model/AuditLog.java new file mode 100644 index 0000000..715c5eb --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/model/AuditLog.java @@ -0,0 +1,14 @@ +package com.example.objectversion.model; + +import java.time.Instant; + +public record AuditLog( + String id, + String entityType, + String entityId, + String action, + String actor, + Instant occurredAt, + String diffJson +) { +} diff --git a/springboot-object-version/src/main/java/com/example/objectversion/model/Product.java b/springboot-object-version/src/main/java/com/example/objectversion/model/Product.java new file mode 100644 index 0000000..76677d6 --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/model/Product.java @@ -0,0 +1,11 @@ +package com.example.objectversion.model; + +import java.math.BigDecimal; + +public record Product( + String id, + String name, + BigDecimal price, + String description +) { +} diff --git a/springboot-object-version/src/main/java/com/example/objectversion/service/ProductService.java b/springboot-object-version/src/main/java/com/example/objectversion/service/ProductService.java new file mode 100644 index 0000000..78b619d --- /dev/null +++ b/springboot-object-version/src/main/java/com/example/objectversion/service/ProductService.java @@ -0,0 +1,90 @@ +package com.example.objectversion.service; + +import com.example.objectversion.annotation.Audit; +import com.example.objectversion.dto.ProductRequest; +import com.example.objectversion.model.Product; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + +@Service +public class ProductService { + + private final Map products = new ConcurrentHashMap<>(); + + public Collection findAll() { + return products.values(); + } + + public Optional findById(String id) { + return Optional.ofNullable(products.get(id)); + } + + @Audit( + action = Audit.ActionType.CREATE, + idParam = "id", + actorParam = "actor", + entityIndex = 1 + ) + public Product create(String id, ProductRequest request, String actor) { + Product newProduct = new Product( + id, + request.name(), + request.price(), + request.description() + ); + return products.put(id, newProduct); + } + + @Audit( + action = Audit.ActionType.UPDATE, + idParam = "id", + actorParam = "actor", + entityIndex = 1 + ) + public Product update(String id, ProductRequest request, String actor) { + Product existingProduct = products.get(id); + if (existingProduct == null) { + throw new IllegalArgumentException("产品不存在: " + id); + } + + Product updatedProduct = new Product( + id, + request.name(), + request.price(), + request.description() + ); + return products.put(id, updatedProduct); + } + + @Audit( + action = Audit.ActionType.DELETE, + idParam = "id", + actorParam = "actor", + entityIndex = -1 // 删除操作不需要实体对象 + ) + public boolean delete(String id, String actor) { + return products.remove(id) != null; + } + + /** + * 通用的 upsert 方法,支持创建和更新 + */ + @Audit( + idParam = "id", // ID来自第一个参数 + actorParam = "actor", // 操作人来自第三个参数 + entityIndex = 1 // 实体对象是ProductRequest + ) + public Product upsert(String id, ProductRequest request, String actor) { + Product newProduct = new Product( + id, + request.name(), + request.price(), + request.description() + ); + return products.put(id, newProduct); + } +} diff --git a/springboot-object-version/src/main/resources/application.yml b/springboot-object-version/src/main/resources/application.yml new file mode 100644 index 0000000..b59308b --- /dev/null +++ b/springboot-object-version/src/main/resources/application.yml @@ -0,0 +1,8 @@ +spring: + jackson: + serialization: + write-dates-as-timestamps: false + +app: + audit: + enabled: true diff --git a/springboot-object-version/src/main/resources/static/index.html b/springboot-object-version/src/main/resources/static/index.html new file mode 100644 index 0000000..ef2b6e1 --- /dev/null +++ b/springboot-object-version/src/main/resources/static/index.html @@ -0,0 +1,443 @@ + + + + + + 对象变更审计 + + + + +
+
+

对象变更审计

+

Spring Boot 3 · Javers Diff

+
+ + +
+

创建或更新商品

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

商品列表

+

点击「查看审计」可只查看指定商品的历史

+
+ +
+
+ + + + + + + + + + + +
编号名称价格描述操作
+
+
+ + +
+
+
+

审计时间线

+

当前:全部记录

+
+
+ + +
+
+
+
+
+ + + + + + + From 047f2131e4e96064360d2f439f55a29247c8052d Mon Sep 17 00:00:00 2001 From: yuoon Date: Mon, 13 Oct 2025 23:24:54 +0800 Subject: [PATCH 06/22] jwt-rotation --- springboot-jwt-rotation/README.md | 155 ++++++ springboot-jwt-rotation/pom.xml | 76 +++ .../example/jwt/JwtRotationApplication.java | 13 + .../jwt/controller/AuthController.java | 232 ++++++++ .../jwt/controller/DemoController.java | 173 ++++++ .../com/example/jwt/model/ApiResponse.java | 59 +++ .../java/com/example/jwt/model/KeyInfo.java | 43 ++ .../example/jwt/service/DynamicKeyStore.java | 184 +++++++ .../example/jwt/service/JwtTokenService.java | 228 ++++++++ .../jwt/service/KeyRotationScheduler.java | 190 +++++++ .../src/main/resources/application.yml | 25 + .../src/main/resources/static/app.js | 494 ++++++++++++++++++ .../src/main/resources/static/index.html | 224 ++++++++ 13 files changed, 2096 insertions(+) create mode 100644 springboot-jwt-rotation/README.md create mode 100644 springboot-jwt-rotation/pom.xml create mode 100644 springboot-jwt-rotation/src/main/java/com/example/jwt/JwtRotationApplication.java create mode 100644 springboot-jwt-rotation/src/main/java/com/example/jwt/controller/AuthController.java create mode 100644 springboot-jwt-rotation/src/main/java/com/example/jwt/controller/DemoController.java create mode 100644 springboot-jwt-rotation/src/main/java/com/example/jwt/model/ApiResponse.java create mode 100644 springboot-jwt-rotation/src/main/java/com/example/jwt/model/KeyInfo.java create mode 100644 springboot-jwt-rotation/src/main/java/com/example/jwt/service/DynamicKeyStore.java create mode 100644 springboot-jwt-rotation/src/main/java/com/example/jwt/service/JwtTokenService.java create mode 100644 springboot-jwt-rotation/src/main/java/com/example/jwt/service/KeyRotationScheduler.java create mode 100644 springboot-jwt-rotation/src/main/resources/application.yml create mode 100644 springboot-jwt-rotation/src/main/resources/static/app.js create mode 100644 springboot-jwt-rotation/src/main/resources/static/index.html diff --git a/springboot-jwt-rotation/README.md b/springboot-jwt-rotation/README.md new file mode 100644 index 0000000..8f79bbf --- /dev/null +++ b/springboot-jwt-rotation/README.md @@ -0,0 +1,155 @@ +# JWT 动态密钥轮换演示系统 + +这是一个Spring Boot JWT 动态密钥轮换演示系统,实现了 **RSA 2048 + KID + 定时轮换** 的安全生产实践。 + +## 🎯 功能特性 + +### 核心功能 +- ✅ **动态密钥轮换**: 定时自动生成新的 RSA 密钥对 +- ✅ **多版本密钥共存**: 新 Token 用新密钥,旧 Token 仍可验证 +- ✅ **用户无感知**: 密钥轮换不影响已登录用户 +- ✅ **KID 标识**: JWT Header 包含密钥 ID,支持多版本验证 +- ✅ **定时清理**: 自动清理过期的密钥 + +### 演示功能 +- 🔐 **用户认证**: 登录/退出、Token 生成和验证 +- 🛡️ **受保护资源**: 需要有效 Token 才能访问 +- 🔑 **密钥管理**: 查看密钥状态、手动轮换、清理过期密钥 +- 🔍 **Token 解析**: 解析 JWT Header、Payload 和验证状态 +- 📊 **系统监控**: 实时显示密钥存储和系统状态 + +## 🏗️ 技术架构 + +### 后端技术栈 +- **Spring Boot 3.2.0** - 应用框架 +- **JJWT 0.12.3** - JWT 处理库 +- **RSA 2048** - 非对称加密算法 +- **Spring Scheduling** - 定时任务 + +### 前端技术栈 +- **HTML5 + CSS3** - 页面结构 +- **JavaScript ES6** - 交互逻辑 +- **Tailwind CSS** - UI 样式框架 +- **Font Awesome** - 图标库 + +## 🚀 快速开始 + +### 环境要求 +- JDK 17+ +- Maven 3.6+ + +### 运行步骤 + +1. **克隆项目** +```bash +cd springboot-jwt-rotation +``` + +2. **编译运行** +```bash +mvn clean spring-boot:run +``` + +3. **访问应用** +``` +浏览器打开: http://localhost:8080 +``` + +### 测试账户 +| 用户名 | 密码 | 角色 | +|--------|------|------| +| admin | password | 管理员 | +| user | 123456 | 普通用户 | +| test | test | 测试用户 | + +## 🔧 核心组件 + +### 1. DynamicKeyStore - 动态密钥存储 +- 管理 RSA 密钥对的生成、存储和获取 +- 支持多版本密钥共存 +- 线程安全的 ConcurrentHashMap 存储 +- 自动密钥清理机制 + +### 2. JwtTokenService - JWT 服务 +- Token 生成、验证和解析 +- 支持多版本密钥验证 +- Token 刷新功能 +- KID (Key ID) 管理 + +### 3. KeyRotationScheduler - 密钥轮换调度 +- 定时检查和轮换密钥 +- 过期密钥清理 +- 监控和日志记录 +- 支持手动触发 + +## 📋 API 接口 + +### 认证相关 +- `POST /api/auth/login` - 用户登录 +- `POST /api/auth/validate` - Token 验证 +- `POST /api/auth/refresh` - Token 刷新 +- `GET /api/auth/me` - 获取当前用户信息 + +### 管理功能 +- `POST /api/auth/admin/rotate-keys` - 手动轮换密钥 +- `POST /api/auth/admin/cleanup-keys` - 清理过期密钥 + +### 演示功能 +- `GET /api/demo/key-stats` - 获取密钥统计 +- `POST /api/demo/parse-token` - 解析 Token +- `POST /api/demo/generate-test-token` - 生成测试 Token +- `GET /api/demo/protected` - 受保护资源 +- `GET /api/demo/system-info` - 系统信息 + +## ⚙️ 配置说明 + +### application.yml +```yaml +jwt: + token-expiration: 24 # Token过期时间(小时) + rotation-period-days: 7 # 密钥轮换周期(天) + grace-period-days: 14 # 旧密钥保留时间(天) + key-size: 2048 # RSA密钥长度 +``` + +### 定时任务 +- **每天凌晨2点**: 检查是否需要轮换密钥 +- **每天凌晨3点**: 清理过期密钥 +- **每小时**: 密钥状态监控 + +## 🔄 密钥轮换流程 + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ 第1天 │ │ 第7天 │ │ 第21天 │ +├─────────────────┤ ├──────────────────┤ ├─────────────────┤ +│ 生成 key-001 │ │ 生成 key-002 │ │ 清理 key-001 │ +│ 新Token使用 │→ │ 新Token使用 │→ │ 保留 key-002 │ +│ key-001签名 │ │ key-002签名 │ │ key-002继续使用 │ +│ key-001验证 │ │ key-001仍可验证 │ │ │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ +``` + +## 🎨 前端界面 + +### 功能页面 +1. **用户登录** - 登录认证和状态显示 +2. **受保护资源** - 演示 Token 保护 +3. **密钥信息** - 实时密钥存储状态 +4. **Token 解析** - JWT 结构分析工具 +5. **管理功能** - 密钥轮换和清理 + +## 🧪 测试演示 + +### 基础流程 +1. 打开 http://localhost:8080 +2. 使用测试账户登录 +3. 查看生成的 JWT Token 和 KID +4. 访问受保护资源验证 Token +5. 查看密钥存储状态 + +### 密钥轮换演示 +1. 在管理页面手动轮换密钥 +2. 观察新 Token 使用新密钥 +3. 旧 Token 仍可正常验证 +4. 查看密钥统计信息变化 diff --git a/springboot-jwt-rotation/pom.xml b/springboot-jwt-rotation/pom.xml new file mode 100644 index 0000000..bff715a --- /dev/null +++ b/springboot-jwt-rotation/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + com.example + jwt-rotation + 1.0.0 + jar + + JWT Key Rotation Demo + 动态密钥轮换JWT认证系统演示 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + + 17 + 0.12.3 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/java/com/example/jwt/JwtRotationApplication.java b/springboot-jwt-rotation/src/main/java/com/example/jwt/JwtRotationApplication.java new file mode 100644 index 0000000..0450d10 --- /dev/null +++ b/springboot-jwt-rotation/src/main/java/com/example/jwt/JwtRotationApplication.java @@ -0,0 +1,13 @@ +package com.example.jwt; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class JwtRotationApplication { + public static void main(String[] args) { + SpringApplication.run(JwtRotationApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/java/com/example/jwt/controller/AuthController.java b/springboot-jwt-rotation/src/main/java/com/example/jwt/controller/AuthController.java new file mode 100644 index 0000000..d6aea11 --- /dev/null +++ b/springboot-jwt-rotation/src/main/java/com/example/jwt/controller/AuthController.java @@ -0,0 +1,232 @@ +package com.example.jwt.controller; + +import com.example.jwt.model.ApiResponse; +import com.example.jwt.service.JwtTokenService; +import com.example.jwt.service.KeyRotationScheduler; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 认证控制器 + * 提供登录、Token验证、刷新等功能 + */ +@RestController +@RequestMapping("/api/auth") +@CrossOrigin(origins = "*") +public class AuthController { + + private static final Logger logger = LoggerFactory.getLogger(AuthController.class); + + @Autowired + private JwtTokenService jwtTokenService; + + @Autowired + private KeyRotationScheduler keyRotationScheduler; + + /** + * 用户登录 + */ + @PostMapping("/login") + public ResponseEntity>> login(@RequestBody LoginRequest request) { + logger.info("用户登录请求: {}", request.getUsername()); + + // 简化的用户验证(实际项目中应该查询数据库) + if (!isValidUser(request.getUsername(), request.getPassword())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("用户名或密码错误")); + } + + try { + // 生成Token + Map claims = new HashMap<>(); + claims.put("role", "USER"); + claims.put("loginTime", System.currentTimeMillis()); + + String token = jwtTokenService.generateToken(request.getUsername(), claims); + + // 构建响应数据 + Map response = new HashMap<>(); + response.put("token", token); + response.put("username", request.getUsername()); + response.put("expiresIn", 24 * 60 * 60); // 24小时,单位秒 + response.put("tokenType", "Bearer"); + + logger.info("用户 {} 登录成功", request.getUsername()); + + return ResponseEntity.ok(ApiResponse.success("登录成功", response)); + + } catch (Exception e) { + logger.error("生成Token失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("系统错误,请稍后重试")); + } + } + + /** + * 验证Token + */ + @PostMapping("/validate") + public ResponseEntity>> validateToken( + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("缺少有效的Authorization头")); + } + + String token = authHeader.substring(7); // 移除 "Bearer " 前缀 + + try { + Claims claims = jwtTokenService.validateToken(token); + + // 构建用户信息 + Map userInfo = new HashMap<>(); + userInfo.put("username", claims.getSubject()); + userInfo.put("role", claims.get("role")); + userInfo.put("loginTime", claims.get("loginTime")); + userInfo.put("issuedAt", claims.getIssuedAt()); + userInfo.put("expiresAt", claims.getExpiration()); + userInfo.put("keyId", jwtTokenService.extractKeyId(token)); + + return ResponseEntity.ok(ApiResponse.success("Token有效", userInfo)); + + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("Token无效或已过期: " + e.getMessage())); + } + } + + /** + * 刷新Token + */ + @PostMapping("/refresh") + public ResponseEntity>> refreshToken( + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("缺少有效的Authorization头")); + } + + String token = authHeader.substring(7); + + try { + String newToken = jwtTokenService.refreshToken(token); + + Map response = new HashMap<>(); + response.put("token", newToken); + response.put("expiresIn", 24 * 60 * 60); + response.put("tokenType", "Bearer"); + + return ResponseEntity.ok(ApiResponse.success("Token刷新成功", response)); + + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("Token刷新失败: " + e.getMessage())); + } + } + + /** + * 获取当前用户信息 + */ + @GetMapping("/me") + public ResponseEntity>> getCurrentUser( + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("缺少有效的Authorization头")); + } + + String token = authHeader.substring(7); + + try { + Claims claims = jwtTokenService.validateToken(token); + + Map userInfo = new HashMap<>(); + userInfo.put("username", claims.getSubject()); + userInfo.put("role", claims.get("role")); + userInfo.put("loginTime", claims.get("loginTime")); + + return ResponseEntity.ok(ApiResponse.success(userInfo)); + + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("Token无效: " + e.getMessage())); + } + } + + /** + * 手动触发密钥轮换(管理员功能) + */ + @PostMapping("/admin/rotate-keys") + public ResponseEntity>> rotateKeys() { + try { + String newKeyId = keyRotationScheduler.forceKeyRotation(); + + Map result = new HashMap<>(); + result.put("newKeyId", newKeyId); + result.put("message", "密钥轮换成功"); + result.put("timestamp", System.currentTimeMillis()); + + return ResponseEntity.ok(ApiResponse.success("密钥轮换成功", result)); + } catch (Exception e) { + logger.error("手动密钥轮换失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("密钥轮换失败: " + e.getMessage())); + } + } + + /** + * 手动清理过期密钥(管理员功能) + */ + @PostMapping("/admin/cleanup-keys") + public ResponseEntity>> cleanupKeys() { + try { + List removedKeys = keyRotationScheduler.forceKeyCleanup(); + return ResponseEntity.ok(ApiResponse.success("密钥清理完成", removedKeys)); + } catch (Exception e) { + logger.error("手动密钥清理失败", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("密钥清理失败: " + e.getMessage())); + } + } + + /** + * 简化的用户验证逻辑 + * 实际项目中应该查询数据库进行验证 + */ + private boolean isValidUser(String username, String password) { + // 简化的验证逻辑,仅用于演示 + // 实际项目中应该使用数据库查询和密码加密验证 + return "admin".equals(username) && "password".equals(password) || + "user".equals(username) && "123456".equals(password) || + "test".equals(username) && "test".equals(password); + } + + /** + * 登录请求对象 + */ + public static class LoginRequest { + private String username; + private String password; + + // Getters and Setters + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + } +} \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/java/com/example/jwt/controller/DemoController.java b/springboot-jwt-rotation/src/main/java/com/example/jwt/controller/DemoController.java new file mode 100644 index 0000000..f3f4eea --- /dev/null +++ b/springboot-jwt-rotation/src/main/java/com/example/jwt/controller/DemoController.java @@ -0,0 +1,173 @@ +package com.example.jwt.controller; + +import com.example.jwt.model.ApiResponse; +import com.example.jwt.service.DynamicKeyStore; +import com.example.jwt.service.JwtTokenService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.lang.management.ManagementFactory; +import java.util.HashMap; +import java.util.Map; + +/** + * 演示控制器 + * 展示JWT密钥轮换的功能和状态 + */ +@RestController +@RequestMapping("/api/demo") +@CrossOrigin(origins = "*") +public class DemoController { + + private static final Logger logger = LoggerFactory.getLogger(DemoController.class); + + @Autowired + private DynamicKeyStore keyStore; + + @Autowired + private JwtTokenService jwtTokenService; + + /** + * 获取密钥存储统计信息 + */ + @GetMapping("/key-stats") + public ResponseEntity>> getKeyStats() { + Map stats = keyStore.getStatistics(); + return ResponseEntity.ok(ApiResponse.success("获取密钥统计信息成功", stats)); + } + + /** + * 解析Token(不验证签名) + */ + @PostMapping("/parse-token") + public ResponseEntity>> parseToken( + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return ResponseEntity.badRequest().body(ApiResponse.error("请提供有效的Token")); + } + + String token = authHeader.substring(7); + + try { + Map result = new HashMap<>(); + + // 提取Header信息 + String keyId = jwtTokenService.extractKeyId(token); + String username = jwtTokenService.extractUsername(token); + + result.put("keyId", keyId); + result.put("username", username); + result.put("tokenLength", token.length()); + result.put("tokenPreview", token.substring(0, Math.min(50, token.length())) + "..."); + + // 尝试验证Token + try { + Claims claims = jwtTokenService.validateToken(token); + result.put("valid", true); + result.put("subject", claims.getSubject()); + result.put("issuedAt", claims.getIssuedAt()); + result.put("expiresAt", claims.getExpiration()); + result.put("claims", claims); + } catch (JwtException e) { + result.put("valid", false); + result.put("error", e.getMessage()); + } + + return ResponseEntity.ok(ApiResponse.success("Token解析完成", result)); + + } catch (Exception e) { + logger.error("解析Token失败", e); + return ResponseEntity.badRequest().body(ApiResponse.error("Token解析失败: " + e.getMessage())); + } + } + + /** + * 演示生成测试Token + */ + @PostMapping("/generate-test-token") + public ResponseEntity>> generateTestToken( + @RequestParam(defaultValue = "demoUser") String username) { + + try { + Map claims = new HashMap<>(); + claims.put("role", "DEMO"); + claims.put("purpose", "演示测试"); + claims.put("generatedAt", System.currentTimeMillis()); + + String token = jwtTokenService.generateToken(username, claims); + + Map result = new HashMap<>(); + result.put("token", token); + result.put("username", username); + result.put("keyId", jwtTokenService.extractKeyId(token)); + result.put("claims", claims); + + return ResponseEntity.ok(ApiResponse.success("测试Token生成成功", result)); + + } catch (Exception e) { + logger.error("生成测试Token失败", e); + return ResponseEntity.internalServerError().body(ApiResponse.error("生成Token失败: " + e.getMessage())); + } + } + + /** + * 模拟保护资源的API + */ + @GetMapping("/protected") + public ResponseEntity>> protectedResource( + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return ResponseEntity.status(401).body(ApiResponse.error("需要提供有效的Token")); + } + + String token = authHeader.substring(7); + + try { + Claims claims = jwtTokenService.validateToken(token); + String username = claims.getSubject(); + String keyId = jwtTokenService.extractKeyId(token); + + Map result = new HashMap<>(); + result.put("message", "恭喜!您访问了受保护的资源"); + result.put("username", username); + result.put("keyId", keyId); + result.put("accessTime", System.currentTimeMillis()); + result.put("serverTime", java.time.LocalDateTime.now().toString()); + + return ResponseEntity.ok(ApiResponse.success("访问成功", result)); + + } catch (JwtException e) { + return ResponseEntity.status(401).body(ApiResponse.error("Token验证失败: " + e.getMessage())); + } + } + + /** + * 获取系统信息 + */ + @GetMapping("/system-info") + public ResponseEntity>> getSystemInfo() { + Map info = new HashMap<>(); + + info.put("application", "JWT密钥轮换演示系统"); + info.put("version", "1.0.0"); + info.put("serverTime", java.time.LocalDateTime.now().toString()); + info.put("uptime", ManagementFactory.getRuntimeMXBean().getUptime() + " ms"); + + Runtime runtime = Runtime.getRuntime(); + Map memory = new HashMap<>(); + memory.put("total", runtime.totalMemory() / 1024 / 1024 + " MB"); + memory.put("free", runtime.freeMemory() / 1024 / 1024 + " MB"); + memory.put("used", (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024 + " MB"); + memory.put("max", runtime.maxMemory() / 1024 / 1024 + " MB"); + info.put("memory", memory); + + return ResponseEntity.ok(ApiResponse.success("系统信息获取成功", info)); + } +} \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/java/com/example/jwt/model/ApiResponse.java b/springboot-jwt-rotation/src/main/java/com/example/jwt/model/ApiResponse.java new file mode 100644 index 0000000..5cdb046 --- /dev/null +++ b/springboot-jwt-rotation/src/main/java/com/example/jwt/model/ApiResponse.java @@ -0,0 +1,59 @@ +package com.example.jwt.model; + +/** + * 统一API响应格式 + */ +public class ApiResponse { + private boolean success; + private String message; + private T data; + private long timestamp; + + public ApiResponse() { + this.timestamp = System.currentTimeMillis(); + } + + public ApiResponse(boolean success, String message) { + this(); + this.success = success; + this.message = message; + } + + public ApiResponse(boolean success, String message, T data) { + this(success, message); + this.data = data; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, "操作成功", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(true, message, data); + } + + public static ApiResponse success(String message) { + return new ApiResponse<>(true, message, null); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(false, message, null); + } + + public static ApiResponse error(String message, T data) { + return new ApiResponse<>(false, message, data); + } + + // Getters and Setters + 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 T getData() { return data; } + public void setData(T data) { this.data = data; } + + public long getTimestamp() { return timestamp; } + public void setTimestamp(long timestamp) { this.timestamp = timestamp; } +} \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/java/com/example/jwt/model/KeyInfo.java b/springboot-jwt-rotation/src/main/java/com/example/jwt/model/KeyInfo.java new file mode 100644 index 0000000..e859e8b --- /dev/null +++ b/springboot-jwt-rotation/src/main/java/com/example/jwt/model/KeyInfo.java @@ -0,0 +1,43 @@ +package com.example.jwt.model; + +import java.security.KeyPair; +import java.time.LocalDateTime; + +/** + * 密钥信息封装类 + */ +public class KeyInfo { + private final String keyId; + private final KeyPair keyPair; + private final LocalDateTime createdAt; + private LocalDateTime lastUsed; + private boolean isActive; + + public KeyInfo(String keyId, KeyPair keyPair) { + this.keyId = keyId; + this.keyPair = keyPair; + this.createdAt = LocalDateTime.now(); + this.lastUsed = LocalDateTime.now(); + this.isActive = true; + } + + // Getters + public String getKeyId() { return keyId; } + public KeyPair getKeyPair() { return keyPair; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getLastUsed() { return lastUsed; } + public boolean isActive() { return isActive; } + + public void setLastUsed(LocalDateTime lastUsed) { this.lastUsed = lastUsed; } + public void setActive(boolean active) { isActive = active; } + + @Override + public String toString() { + return "KeyInfo{" + + "keyId='" + keyId + '\'' + + ", createdAt=" + createdAt + + ", lastUsed=" + lastUsed + + ", isActive=" + isActive + + '}'; + } +} \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/java/com/example/jwt/service/DynamicKeyStore.java b/springboot-jwt-rotation/src/main/java/com/example/jwt/service/DynamicKeyStore.java new file mode 100644 index 0000000..d3c0550 --- /dev/null +++ b/springboot-jwt-rotation/src/main/java/com/example/jwt/service/DynamicKeyStore.java @@ -0,0 +1,184 @@ +package com.example.jwt.service; + +import com.example.jwt.model.KeyInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 动态密钥存储管理器 + * 负责密钥的生成、存储、获取和清理 + */ +@Service +public class DynamicKeyStore { + + private static final Logger logger = LoggerFactory.getLogger(DynamicKeyStore.class); + private static final String KEY_ID_PREFIX = "key-"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Value("${jwt.key-size:2048}") + private int keySize; + + // 线程安全的密钥存储 + private final Map keyStore = new ConcurrentHashMap<>(); + + // 当前活跃密钥ID + private volatile String currentKeyId; + + /** + * 初始化密钥存储,创建一个初始密钥 + */ + public void initialize() { + logger.info("初始化动态密钥存储..."); + generateNewKeyPair(); + logger.info("密钥存储初始化完成,当前密钥ID: {}", currentKeyId); + } + + /** + * 生成新的RSA密钥对 + */ + public String generateNewKeyPair() { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(keySize, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + String keyId = generateKeyId(); + KeyInfo keyInfo = new KeyInfo(keyId, keyPair); + + // 如果是第一个密钥,直接设为当前密钥 + if (currentKeyId == null) { + currentKeyId = keyId; + } else { + // 将旧密钥标记为非活跃,但保留用于验证 + KeyInfo oldKeyInfo = keyStore.get(currentKeyId); + if (oldKeyInfo != null) { + oldKeyInfo.setActive(false); + } + currentKeyId = keyId; + } + + keyStore.put(keyId, keyInfo); + + logger.info("生成新密钥对: {} (密钥长度: {} bits)", keyId, keySize); + return keyId; + + } catch (NoSuchAlgorithmException e) { + logger.error("生成RSA密钥对失败", e); + throw new RuntimeException("无法生成RSA密钥对", e); + } + } + + /** + * 获取当前活跃的密钥信息 + */ + public KeyInfo getCurrentKey() { + // 检查是否已初始化 + if (currentKeyId == null) { + logger.error("密钥存储未初始化!正在初始化..."); + initialize(); + } + + KeyInfo keyInfo = keyStore.get(currentKeyId); + if (keyInfo == null) { + logger.error("当前密钥不存在!生成新密钥..."); + generateNewKeyPair(); + keyInfo = keyStore.get(currentKeyId); + } + + if (keyInfo != null) { + // 更新最后使用时间 + keyInfo.setLastUsed(LocalDateTime.now()); + } + + return keyInfo; + } + + /** + * 根据密钥ID获取密钥信息 + */ + public KeyInfo getKey(String keyId) { + KeyInfo keyInfo = keyStore.get(keyId); + if (keyInfo != null) { + // 更新最后使用时间 + keyInfo.setLastUsed(LocalDateTime.now()); + } + return keyInfo; + } + + /** + * 清理过期的密钥 + * @param gracePeriodDays 宽限期天数 + */ + public List cleanupExpiredKeys(int gracePeriodDays) { + LocalDateTime cutoffTime = LocalDateTime.now().minusDays(gracePeriodDays); + List removedKeys = new ArrayList<>(); + + keyStore.entrySet().removeIf(entry -> { + KeyInfo keyInfo = entry.getValue(); + boolean isExpired = keyInfo.getCreatedAt().isBefore(cutoffTime); + + // 不能删除当前正在使用的密钥 + if (isExpired && !keyInfo.getKeyId().equals(currentKeyId)) { + removedKeys.add(entry.getKey()); + logger.info("清理过期密钥: {} (创建时间: {})", + keyInfo.getKeyId(), keyInfo.getCreatedAt()); + return true; + } + return false; + }); + + return removedKeys; + } + + /** + * 获取所有密钥信息(用于监控和调试) + */ + public Map getAllKeys() { + return new HashMap<>(keyStore); + } + + /** + * 获取密钥存储统计信息 + */ + public Map getStatistics() { + Map stats = new HashMap<>(); + stats.put("totalKeys", keyStore.size()); + stats.put("currentKeyId", currentKeyId); + + List> keyDetails = keyStore.values().stream() + .map(keyInfo -> { + Map detail = new HashMap<>(); + detail.put("keyId", keyInfo.getKeyId()); + detail.put("createdAt", keyInfo.getCreatedAt()); + detail.put("lastUsed", keyInfo.getLastUsed()); + detail.put("isActive", keyInfo.isActive()); + detail.put("isCurrent", keyInfo.getKeyId().equals(currentKeyId)); + return detail; + }) + .collect(Collectors.toList()); + + stats.put("keys", keyDetails); + return stats; + } + + /** + * 生成密钥ID + */ + private String generateKeyId() { + String dateStr = LocalDateTime.now().format(DATE_FORMATTER); + String timestamp = String.valueOf(System.currentTimeMillis() % 10000); + return KEY_ID_PREFIX + dateStr + "-" + timestamp; + } +} \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/java/com/example/jwt/service/JwtTokenService.java b/springboot-jwt-rotation/src/main/java/com/example/jwt/service/JwtTokenService.java new file mode 100644 index 0000000..0d502c9 --- /dev/null +++ b/springboot-jwt-rotation/src/main/java/com/example/jwt/service/JwtTokenService.java @@ -0,0 +1,228 @@ +package com.example.jwt.service; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.SignatureException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.PublicKey; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Base64; + +/** + * JWT Token 服务 + * 负责Token的生成、验证和解析 + */ +@Service +public class JwtTokenService { + + private static final Logger logger = LoggerFactory.getLogger(JwtTokenService.class); + + @Autowired + private DynamicKeyStore keyStore; + + @Value("${jwt.token-expiration:24}") + private int tokenExpirationHours; + + /** + * 生成JWT Token + * @param username 用户名 + * @param claims 额外的声明信息 + * @return JWT Token字符串 + */ + public String generateToken(String username, Map claims) { + try { + // 获取当前活跃密钥 + var currentKey = keyStore.getCurrentKey(); + String keyId = currentKey.getKeyId(); + + // 构建JWT + JwtBuilder builder = Jwts.builder() + .subject(username) + .issuedAt(new Date()) + .expiration(Date.from(Instant.now().plus(tokenExpirationHours, ChronoUnit.HOURS))) + .header().keyId(keyId).and() + .signWith(currentKey.getKeyPair().getPrivate(), Jwts.SIG.RS256); + + // 添加额外声明 + if (claims != null && !claims.isEmpty()) { + builder.claims().add(claims); + } + + String token = builder.compact(); + + logger.info("为用户 {} 生成JWT Token, 使用密钥: {}", username, keyId); + return token; + + } catch (Exception e) { + logger.error("生成JWT Token失败", e); + throw new RuntimeException("JWT Token生成失败", e); + } + } + + /** + * 生成JWT Token(简化版本) + * @param username 用户名 + * @return JWT Token字符串 + */ + public String generateToken(String username) { + return generateToken(username, null); + } + + /** + * 验证并解析JWT Token + * @param token JWT Token字符串 + * @return 解析后的Claims + * @throws JwtException Token无效或过期 + */ + public Claims validateToken(String token) throws JwtException { + try { + // 首先解析Header获取密钥ID + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new JwtException("Token格式错误"); + } + + // 解析Header部分 + String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map headerMap = mapper.readValue(headerJson, Map.class); + String keyId = (String) headerMap.get("kid"); + + if (keyId == null) { + throw new JwtException("Token缺少密钥ID (kid)"); + } + + // 获取对应的公钥 + var keyInfo = keyStore.getKey(keyId); + if (keyInfo == null) { + throw new JwtException("找不到对应的密钥: " + keyId); + } + + PublicKey publicKey = keyInfo.getKeyPair().getPublic(); + + // 使用公钥验证Token + Jws jws = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token); + + Claims claims = jws.getPayload(); + logger.info("Token验证成功, 用户: {}, 密钥: {}", claims.getSubject(), keyId); + + return claims; + + } catch (ExpiredJwtException e) { + logger.warn("Token已过期: {}", e.getMessage()); + throw new JwtException("Token已过期", e); + } catch (UnsupportedJwtException e) { + logger.warn("不支持的Token格式: {}", e.getMessage()); + throw new JwtException("不支持的Token格式", e); + } catch (MalformedJwtException e) { + logger.warn("Token格式错误: {}", e.getMessage()); + throw new JwtException("Token格式错误", e); + } catch (SignatureException e) { + logger.warn("Token签名验证失败: {}", e.getMessage()); + throw new JwtException("Token签名验证失败", e); + } catch (IllegalArgumentException e) { + logger.warn("Token参数错误: {}", e.getMessage()); + throw new JwtException("Token参数错误", e); + } catch (Exception e) { + logger.error("Token验证过程中发生未知错误", e); + throw new JwtException("Token验证失败", e); + } + } + + /** + * 检查Token是否即将过期(1小时内) + * @param token JWT Token字符串 + * @return true如果即将过期 + */ + public boolean isTokenExpiringSoon(String token) { + try { + Claims claims = validateToken(token); + Date expiration = claims.getExpiration(); + Instant oneHourFromNow = Instant.now().plus(1, ChronoUnit.HOURS); + return expiration.toInstant().isBefore(oneHourFromNow); + } catch (JwtException e) { + // 如果Token无效,也认为需要刷新 + return true; + } + } + + /** + * 刷新Token(生成新的Token) + * @param token 旧Token + * @return 新Token + * @throws JwtException 如果旧Token无效 + */ + public String refreshToken(String token) throws JwtException { + Claims claims = validateToken(token); + String username = claims.getSubject(); + + // 保留原有的非标准声明(除了时间相关的) + Map newClaims = new HashMap<>(); + for (Map.Entry entry : claims.entrySet()) { + String key = entry.getKey(); + if (!"exp".equals(key) && !"iat".equals(key) && !"nbf".equals(key)) { + newClaims.put(key, entry.getValue()); + } + } + + logger.info("为用户 {} 刷新Token", username); + return generateToken(username, newClaims); + } + + /** + * 从Token中提取用户名(不验证签名) + * @param token JWT Token字符串 + * @return 用户名 + */ + public String extractUsername(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length != 3) { + return null; + } + + // 解析Payload部分 + String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1])); + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map payloadMap = mapper.readValue(payloadJson, Map.class); + return (String) payloadMap.get("sub"); + } catch (Exception e) { + logger.warn("提取用户名失败: {}", e.getMessage()); + return null; + } + } + + /** + * 获取Token的密钥ID(不验证签名) + * @param token JWT Token字符串 + * @return 密钥ID + */ + public String extractKeyId(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length != 3) { + return null; + } + + // 解析Header部分 + String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map headerMap = mapper.readValue(headerJson, Map.class); + return (String) headerMap.get("kid"); + } catch (Exception e) { + logger.warn("提取密钥ID失败: {}", e.getMessage()); + return null; + } + } +} \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/java/com/example/jwt/service/KeyRotationScheduler.java b/springboot-jwt-rotation/src/main/java/com/example/jwt/service/KeyRotationScheduler.java new file mode 100644 index 0000000..b249df4 --- /dev/null +++ b/springboot-jwt-rotation/src/main/java/com/example/jwt/service/KeyRotationScheduler.java @@ -0,0 +1,190 @@ +package com.example.jwt.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 密钥轮换调度器 + * 负责定期生成新密钥和清理过期密钥 + */ +@Service +public class KeyRotationScheduler { + + private static final Logger logger = LoggerFactory.getLogger(KeyRotationScheduler.class); + + @Autowired + private DynamicKeyStore keyStore; + + @Value("${jwt.rotation-period-days:7}") + private int rotationPeriodDays; + + @Value("${jwt.grace-period-days:14}") + private int gracePeriodDays; + + /** + * 应用启动后初始化密钥存储 + */ + @EventListener(ApplicationReadyEvent.class) + public void initialize() { + logger.info("应用启动完成,开始初始化密钥轮换调度器..."); + keyStore.initialize(); + logger.info("密钥轮换调度器初始化完成"); + logger.info("配置信息 - 轮换周期: {} 天, 宽限期: {} 天", rotationPeriodDays, gracePeriodDays); + } + + /** + * 定时轮换密钥 - 每天凌晨2点检查是否需要轮换 + */ + @Scheduled(cron = "0 0 2 * * ?") + public void scheduledKeyRotation() { + logger.info("执行定时密钥轮换检查..."); + + try { + // 获取当前密钥统计信息 + var stats = keyStore.getStatistics(); + String currentKeyId = (String) stats.get("currentKeyId"); + + // 检查当前密钥的创建时间 + var currentKey = keyStore.getCurrentKey(); + long daysSinceCreation = java.time.temporal.ChronoUnit.DAYS.between( + currentKey.getCreatedAt(), + java.time.LocalDateTime.now() + ); + + if (daysSinceCreation >= rotationPeriodDays) { + logger.info("当前密钥 {} 已使用 {} 天,开始轮换", currentKeyId, daysSinceCreation); + + // 生成新密钥 + String newKeyId = keyStore.generateNewKeyPair(); + logger.info("密钥轮换完成: {} -> {}", currentKeyId, newKeyId); + + // 记录轮换事件 + logRotationEvent(currentKeyId, newKeyId, daysSinceCreation); + } else { + logger.debug("当前密钥 {} 仅使用 {} 天,暂不需要轮换", currentKeyId, daysSinceCreation); + } + + } catch (Exception e) { + logger.error("密钥轮换过程中发生错误", e); + } + } + + /** + * 清理过期密钥 - 每天凌晨3点执行 + */ + @Scheduled(cron = "0 0 3 * * ?") + public void scheduledKeyCleanup() { + logger.info("执行定时密钥清理..."); + + try { + List removedKeys = keyStore.cleanupExpiredKeys(gracePeriodDays); + + if (!removedKeys.isEmpty()) { + logger.info("清理完成,删除了 {} 个过期密钥: {}", removedKeys.size(), removedKeys); + } else { + logger.debug("没有需要清理的过期密钥"); + } + + // 输出清理后的统计信息 + var stats = keyStore.getStatistics(); + logger.info("清理后密钥存储状态: 总密钥数={}, 当前密钥={}", + stats.get("totalKeys"), stats.get("currentKeyId")); + + } catch (Exception e) { + logger.error("密钥清理过程中发生错误", e); + } + } + + /** + * 密钥状态监控 - 每小时执行一次 + */ + @Scheduled(fixedRate = 3600000) // 1小时 + public void monitorKeyStatus() { + try { + var stats = keyStore.getStatistics(); + int totalKeys = (Integer) stats.get("totalKeys"); + String currentKeyId = (String) stats.get("currentKeyId"); + + // 检查密钥存储是否已初始化 + if (currentKeyId == null) { + logger.warn("密钥存储尚未初始化,跳过监控"); + return; + } + + // 检查密钥数量是否异常 + if (totalKeys > 5) { + logger.warn("密钥数量较多: {},建议检查清理策略", totalKeys); + } + + // 检查当前密钥状态 + var currentKey = keyStore.getCurrentKey(); + if (currentKey == null) { + logger.error("无法获取当前密钥,尝试重新初始化"); + keyStore.initialize(); + return; + } + + long daysSinceCreation = java.time.temporal.ChronoUnit.DAYS.between( + currentKey.getCreatedAt(), + java.time.LocalDateTime.now() + ); + + if (daysSinceCreation >= rotationPeriodDays - 1) { + logger.info("当前密钥 {} 即将到达轮换时间(已使用 {} 天)", + currentKeyId, daysSinceCreation); + } + + } catch (Exception e) { + logger.error("密钥状态监控发生错误", e); + } + } + + /** + * 手动触发密钥轮换(用于测试或紧急情况) + */ + public String forceKeyRotation() { + logger.warn("手动触发密钥轮换..."); + + var oldKey = keyStore.getCurrentKey(); + String oldKeyId = oldKey.getKeyId(); + + String newKeyId = keyStore.generateNewKeyPair(); + + logger.warn("手动密钥轮换完成: {} -> {}", oldKeyId, newKeyId); + + return newKeyId; + } + + /** + * 手动触发密钥清理(用于测试或紧急情况) + */ + public List forceKeyCleanup() { + logger.warn("手动触发密钥清理..."); + + List removedKeys = keyStore.cleanupExpiredKeys(gracePeriodDays); + + logger.warn("手动密钥清理完成,删除了 {} 个密钥: {}", removedKeys.size(), removedKeys); + + return removedKeys; + } + + /** + * 记录密钥轮换事件 + */ + private void logRotationEvent(String oldKeyId, String newKeyId, long daysUsed) { + logger.info("=== 密钥轮换事件 ==="); + logger.info("旧密钥: {} (使用天数: {})", oldKeyId, daysUsed); + logger.info("新密钥: {}", newKeyId); + logger.info("轮换时间: {}", java.time.LocalDateTime.now()); + logger.info("轮换周期: {} 天", rotationPeriodDays); + logger.info("=================="); + } +} \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/resources/application.yml b/springboot-jwt-rotation/src/main/resources/application.yml new file mode 100644 index 0000000..64c3f10 --- /dev/null +++ b/springboot-jwt-rotation/src/main/resources/application.yml @@ -0,0 +1,25 @@ +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: jwt-rotation-demo + +# JWT配置 +jwt: + # Token过期时间(小时) + token-expiration: 24 + # 密钥轮换周期(天) + rotation-period-days: 7 + # 旧密钥保留时间(天) + grace-period-days: 14 + # RSA密钥长度 + key-size: 2048 + +# 日志配置 +logging: + level: + com.example.jwt: DEBUG + io.jsonwebtoken: DEBUG \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/resources/static/app.js b/springboot-jwt-rotation/src/main/resources/static/app.js new file mode 100644 index 0000000..71e20c3 --- /dev/null +++ b/springboot-jwt-rotation/src/main/resources/static/app.js @@ -0,0 +1,494 @@ +// JWT 密钥轮换演示系统 - 前端脚本 + +class JwtDemoApp { + constructor() { + this.apiBase = ''; + this.currentToken = localStorage.getItem('jwtToken') || null; + this.currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null'); + + this.init(); + } + + init() { + this.bindEvents(); + this.loadSystemInfo(); + this.updateLoginStatus(); + this.setupTabNavigation(); + + // 定期刷新系统状态 + setInterval(() => this.loadSystemInfo(), 30000); + } + + setupTabNavigation() { + const tabBtns = document.querySelectorAll('.tab-btn'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + const targetTab = btn.dataset.tab; + + // 更新按钮样式 + tabBtns.forEach(b => { + b.classList.remove('text-blue-600', 'border-b-2', 'border-blue-600'); + b.classList.add('text-gray-600'); + }); + btn.classList.remove('text-gray-600'); + btn.classList.add('text-blue-600', 'border-b-2', 'border-blue-600'); + + // 切换内容显示 + tabContents.forEach(content => { + content.classList.remove('active'); + }); + document.getElementById(targetTab).classList.add('active'); + }); + }); + } + + bindEvents() { + // 登录表单 + document.getElementById('loginForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.login(); + }); + + // 受保护资源访问 + document.getElementById('accessProtected').addEventListener('click', () => { + this.accessProtectedResource(); + }); + + // 密钥信息刷新 + document.getElementById('refreshKeyInfo').addEventListener('click', () => { + this.loadKeyInfo(); + }); + + // Token解析 + document.getElementById('parseToken').addEventListener('click', () => { + this.parseToken(); + }); + + // 管理功能 + document.getElementById('rotateKeys').addEventListener('click', () => { + this.rotateKeys(); + }); + + document.getElementById('cleanupKeys').addEventListener('click', () => { + this.cleanupKeys(); + }); + + // 测试工具 + document.getElementById('generateTestToken').addEventListener('click', () => { + this.generateTestToken(); + }); + + // 状态刷新 + document.getElementById('refreshStatus').addEventListener('click', () => { + this.loadSystemInfo(); + this.loadKeyInfo(); + }); + + // Token输入框回车解析 + document.getElementById('tokenInput').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this.parseToken(); + } + }); + } + + async apiCall(url, options = {}) { + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + if (this.currentToken) { + defaultOptions.headers.Authorization = `Bearer ${this.currentToken}`; + } + + const finalOptions = { ...defaultOptions, ...options }; + + try { + const response = await fetch(this.apiBase + url, finalOptions); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || `HTTP ${response.status}`); + } + + return data; + } catch (error) { + console.error('API调用失败:', error); + throw error; + } + } + + async login() { + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + if (!username || !password) { + this.showMessage('请输入用户名和密码', 'error'); + return; + } + + const loginBtn = document.querySelector('#loginForm button[type="submit"]'); + loginBtn.disabled = true; + loginBtn.innerHTML = '登录中...'; + + try { + const response = await this.apiCall('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); + + if (response.success) { + this.currentToken = response.data.token; + this.currentUser = { + username: response.data.username, + expiresIn: response.data.expiresIn, + tokenType: response.data.tokenType + }; + + localStorage.setItem('jwtToken', this.currentToken); + localStorage.setItem('currentUser', JSON.stringify(this.currentUser)); + + this.updateLoginStatus(); + this.showMessage('登录成功!', 'success'); + + // 清空表单 + document.getElementById('username').value = ''; + document.getElementById('password').value = ''; + } else { + this.showMessage(response.message || '登录失败', 'error'); + } + } catch (error) { + this.showMessage(error.message || '登录失败', 'error'); + } finally { + loginBtn.disabled = false; + loginBtn.innerHTML = '登录'; + } + } + + logout() { + this.currentToken = null; + this.currentUser = null; + localStorage.removeItem('jwtToken'); + localStorage.removeItem('currentUser'); + this.updateLoginStatus(); + this.showMessage('已退出登录', 'info'); + } + + updateLoginStatus() { + const statusDiv = document.getElementById('loginStatus'); + + if (this.currentToken && this.currentUser) { + const keyId = this.extractKeyIdFromToken(this.currentToken); + statusDiv.innerHTML = ` +
+
+

+ 已登录 +

+ +
+
+
用户名: ${this.currentUser.username}
+
密钥ID: ${keyId}
+
Token类型: ${this.currentUser.tokenType}
+
Token预览:
+
+ ${this.currentToken} +
+
+ 长度: ${this.currentToken.length} 字符 +
+
+
+ `; + } else { + statusDiv.innerHTML = ` +
+ +

尚未登录

+
+ `; + } + } + + extractKeyIdFromToken(token) { + try { + const parts = token.split('.'); + if (parts.length !== 3) return 'N/A'; + + const header = JSON.parse(atob(parts[0])); + return header.kid || 'N/A'; + } catch (error) { + return 'Parse Error'; + } + } + + async accessProtectedResource() { + if (!this.currentToken) { + this.showMessage('请先登录', 'warning'); + return; + } + + const btn = document.getElementById('accessProtected'); + btn.disabled = true; + btn.innerHTML = '访问中...'; + + try { + const response = await this.apiCall('/api/demo/protected'); + document.getElementById('protectedResult').textContent = JSON.stringify(response.data, null, 2); + } catch (error) { + document.getElementById('protectedResult').textContent = `错误: ${error.message}`; + } finally { + btn.disabled = false; + btn.innerHTML = '访问受保护资源'; + } + } + + async loadKeyInfo() { + const btn = document.getElementById('refreshKeyInfo'); + btn.disabled = true; + btn.innerHTML = '刷新中...'; + + try { + const response = await this.apiCall('/api/demo/key-stats'); + document.getElementById('keyInfoResult').textContent = JSON.stringify(response.data, null, 2); + } catch (error) { + document.getElementById('keyInfoResult').textContent = `错误: ${error.message}`; + } finally { + btn.disabled = false; + btn.innerHTML = '刷新密钥信息'; + } + } + + async parseToken() { + let tokenInput = document.getElementById('tokenInput').value.trim(); + + if (!tokenInput) { + this.showMessage('请输入Token', 'warning'); + return; + } + + // 如果输入的是完整的Authorization header,提取token部分 + if (tokenInput.startsWith('Bearer ')) { + tokenInput = tokenInput.substring(7); + } + + const btn = document.getElementById('parseToken'); + btn.disabled = true; + btn.innerHTML = '解析中...'; + + try { + const response = await this.apiCall('/api/demo/parse-token', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${tokenInput}`, + } + }); + document.getElementById('tokenResult').textContent = JSON.stringify(response.data, null, 2); + } catch (error) { + document.getElementById('tokenResult').textContent = `错误: ${error.message}`; + } finally { + btn.disabled = false; + btn.innerHTML = '解析'; + } + } + + async rotateKeys() { + if (!confirm('确定要手动轮换密钥吗?这将创建新的密钥对。')) { + return; + } + + const btn = document.getElementById('rotateKeys'); + btn.disabled = true; + btn.innerHTML = '轮换中...'; + + try { + const response = await this.apiCall('/api/auth/admin/rotate-keys', { + method: 'POST' + }); + document.getElementById('adminResult').textContent = JSON.stringify(response.data, null, 2); + this.showMessage('密钥轮换成功', 'success'); + + // 刷新密钥信息 + this.loadKeyInfo(); + } catch (error) { + document.getElementById('adminResult').textContent = `错误: ${error.message}`; + } finally { + btn.disabled = false; + btn.innerHTML = '手动轮换密钥'; + } + } + + async cleanupKeys() { + if (!confirm('确定要清理过期密钥吗?')) { + return; + } + + const btn = document.getElementById('cleanupKeys'); + btn.disabled = true; + btn.innerHTML = '清理中...'; + + try { + const response = await this.apiCall('/api/auth/admin/cleanup-keys', { + method: 'POST' + }); + document.getElementById('adminResult').textContent = JSON.stringify(response.data, null, 2); + this.showMessage('密钥清理完成', 'success'); + + // 刷新密钥信息 + this.loadKeyInfo(); + } catch (error) { + document.getElementById('adminResult').textContent = `错误: ${error.message}`; + } finally { + btn.disabled = false; + btn.innerHTML = '清理过期密钥'; + } + } + + async generateTestToken() { + const username = document.getElementById('testUsername').value.trim() || 'demoUser'; + + const btn = document.getElementById('generateTestToken'); + btn.disabled = true; + btn.innerHTML = '生成中...'; + + try { + const response = await this.apiCall(`/api/demo/generate-test-token?username=${encodeURIComponent(username)}`, { + method: 'POST' + }); + document.getElementById('testResult').textContent = JSON.stringify(response.data, null, 2); + + // 自动填充到Token输入框 + document.getElementById('tokenInput').value = response.data.token; + + this.showMessage('测试Token生成成功', 'success'); + } catch (error) { + document.getElementById('testResult').textContent = `错误: ${error.message}`; + } finally { + btn.disabled = false; + btn.innerHTML = '生成测试Token'; + } + } + + async loadSystemInfo() { + try { + const response = await this.apiCall('/api/demo/system-info'); + this.displaySystemInfo(response.data); + + // 更新连接状态 + const statusEl = document.getElementById('connectionStatus'); + statusEl.innerHTML = ` + + 已连接 + `; + } catch (error) { + // 更新连接状态 + const statusEl = document.getElementById('connectionStatus'); + statusEl.innerHTML = ` + + 连接失败 + `; + + document.getElementById('systemInfo').innerHTML = ` +
+ +

无法获取系统信息: ${error.message}

+
+ `; + } + } + + displaySystemInfo(info) { + const systemInfoDiv = document.getElementById('systemInfo'); + systemInfoDiv.innerHTML = ` +
+
+ +

应用信息

+
+
+
名称: ${info.application}
+
版本: ${info.version}
+
运行时间: ${info.uptime}
+
+
+ +
+
+ +

时间信息

+
+
+
服务器时间:
+
${info.serverTime}
+
+
+ +
+
+ +

内存使用

+
+
+
总计: ${info.memory.total}
+
已用: ${info.memory.used}
+
空闲: ${info.memory.free}
+
最大: ${info.memory.max}
+
+
+ `; + } + + showMessage(message, type = 'info') { + // 创建消息元素 + const messageDiv = document.createElement('div'); + messageDiv.className = `fixed top-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all duration-300 transform translate-x-0`; + + // 根据类型设置样式 + const styles = { + success: 'bg-green-500 text-white', + error: 'bg-red-500 text-white', + warning: 'bg-yellow-500 text-white', + info: 'bg-blue-500 text-white' + }; + + messageDiv.className += ' ' + (styles[type] || styles.info); + + // 设置图标 + const icons = { + success: 'fas fa-check-circle', + error: 'fas fa-exclamation-circle', + warning: 'fas fa-exclamation-triangle', + info: 'fas fa-info-circle' + }; + + messageDiv.innerHTML = ` +
+ + ${message} +
+ `; + + // 添加到页面 + document.body.appendChild(messageDiv); + + // 3秒后自动移除 + setTimeout(() => { + messageDiv.style.transform = 'translateX(400px)'; + setTimeout(() => { + if (messageDiv.parentNode) { + messageDiv.parentNode.removeChild(messageDiv); + } + }, 300); + }, 3000); + } +} + +// 初始化应用 +const app = new JwtDemoApp(); \ No newline at end of file diff --git a/springboot-jwt-rotation/src/main/resources/static/index.html b/springboot-jwt-rotation/src/main/resources/static/index.html new file mode 100644 index 0000000..e2d075e --- /dev/null +++ b/springboot-jwt-rotation/src/main/resources/static/index.html @@ -0,0 +1,224 @@ + + + + + + JWT 密钥轮换演示系统 + + + + + + +
+
+
+
+ +
+

JWT 动态密钥轮换演示

+

RSA 2048 + KID + 定时轮换

+
+
+
+ + + 已连接 + + +
+
+
+
+ + +
+ +
+
+ + + + + +
+
+ + +
+ +
+
+
+

+ 用户登录 +

+
+
+ + +

测试用户: admin, user, test

+
+
+ + +

对应密码: password, 123456, test

+
+ +
+
+ +
+

+ 登录状态 +

+
+
+ +

尚未登录

+
+
+
+
+
+ + +
+
+

+ 受保护资源访问 +

+
+ +
+
点击按钮访问受保护资源...
+
+
+ + +
+
+

+ 密钥存储信息 +

+
+ +
+
点击按钮获取密钥信息...
+
+
+ + +
+
+

+ Token 解析工具 +

+
+ +
+ + +
+

输入完整的 Authorization Header 或仅 Token

+
+
输入Token进行解析...
+
+
+ + +
+
+
+

+ 密钥轮换管理 +

+
+ + +
+
点击按钮执行管理操作...
+
+ +
+

+ 测试工具 +

+
+
+ + +
+ +
+
点击按钮生成测试Token...
+
+
+
+
+ + +
+

+ 系统信息 +

+
+
+ +

加载中...

+
+
+
+
+ + +
+
+

JWT 密钥轮换演示系统 - RSA 2048 + Spring Boot

+

展示动态密钥轮换、多版本密钥共存、用户无感知的安全升级

+
+
+ + + + \ No newline at end of file From 54d42c58f9996df5366cd29f3e7943fb9da7197c Mon Sep 17 00:00:00 2001 From: yuoon Date: Thu, 16 Oct 2025 21:08:20 +0800 Subject: [PATCH 07/22] ASN.1 Parse --- springboot-asn1/README.md | 21 + springboot-asn1/pom.xml | 167 ++ .../example/asn1/Asn1ParserApplication.java | 18 + .../asn1/controller/Asn1Controller.java | 198 +++ .../asn1/controller/HomeController.java | 24 + .../example/asn1/dto/Asn1ParseRequest.java | 34 + .../example/asn1/dto/Asn1ParseResponse.java | 97 ++ .../asn1/exception/Asn1ParseException.java | 56 + .../asn1/service/Asn1ParserService.java | 1449 +++++++++++++++++ .../src/main/resources/application.yml | 51 + .../src/main/resources/static/index.html | 201 +++ .../src/main/resources/static/script.js | 843 ++++++++++ .../src/main/resources/static/style.css | 1119 +++++++++++++ 13 files changed, 4278 insertions(+) create mode 100644 springboot-asn1/README.md create mode 100644 springboot-asn1/pom.xml create mode 100644 springboot-asn1/src/main/java/com/example/asn1/Asn1ParserApplication.java create mode 100644 springboot-asn1/src/main/java/com/example/asn1/controller/Asn1Controller.java create mode 100644 springboot-asn1/src/main/java/com/example/asn1/controller/HomeController.java create mode 100644 springboot-asn1/src/main/java/com/example/asn1/dto/Asn1ParseRequest.java create mode 100644 springboot-asn1/src/main/java/com/example/asn1/dto/Asn1ParseResponse.java create mode 100644 springboot-asn1/src/main/java/com/example/asn1/exception/Asn1ParseException.java create mode 100644 springboot-asn1/src/main/java/com/example/asn1/service/Asn1ParserService.java create mode 100644 springboot-asn1/src/main/resources/application.yml create mode 100644 springboot-asn1/src/main/resources/static/index.html create mode 100644 springboot-asn1/src/main/resources/static/script.js create mode 100644 springboot-asn1/src/main/resources/static/style.css diff --git a/springboot-asn1/README.md b/springboot-asn1/README.md new file mode 100644 index 0000000..1f4afdf --- /dev/null +++ b/springboot-asn1/README.md @@ -0,0 +1,21 @@ +# SpringBoot ASN.1在线解析工具 + +基于SpringBoot和BouncyCastle实现的ASN.1在线解析工具,支持多种ASN.1编码格式的解析和可视化展示。 + + +# 📋 支持的ASN.1类型 + +| 类型 | 描述 | 示例 | +|------|------|------| +| SEQUENCE | 序列类型 | `3009...` | +| SET | 集合类型 | `3109...` | +| INTEGER | 整数类型 | `020101` | +| OCTET STRING | 八位字节串 | `04048899aabb` | +| UTF8String | UTF-8字符串 | `0c0548656c6c6f` | +| PrintableString | 可打印字符串 | `130548656c6c6f` | +| OBJECT IDENTIFIER | 对象标识符 | `06032a0304` | +| BIT STRING | 位串 | `030200ff` | +| BOOLEAN | 布尔值 | `0101ff` | +| NULL | 空值 | `0500` | +| UTCTime | UTC时间 | `170d32333031303132303539305a` | +| GeneralizedTime | 通用时间 | `18113332333031303132303539305a` | diff --git a/springboot-asn1/pom.xml b/springboot-asn1/pom.xml new file mode 100644 index 0000000..5a8bc58 --- /dev/null +++ b/springboot-asn1/pom.xml @@ -0,0 +1,167 @@ + + + 4.0.0 + + com.example + springboot-asn1 + 1.0.0 + jar + + SpringBoot ASN.1 Parser + 在线ASN.1解析工具 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + + 11 + 11 + 11 + UTF-8 + 1.70 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.bouncycastle + bcpkix-jdk15on + ${bouncycastle.version} + + + + org.bouncycastle + bcprov-jdk15on + ${bouncycastle.version} + + + + + org.projectlombok + lombok + true + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 11 + 11 + UTF-8 + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + false + + + + + org.apache.maven.plugins + maven-clean-plugin + 3.2.0 + + + + logs + + + + + + + + + + + dev + + true + + + dev + + + + + prod + + prod + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + + + + \ No newline at end of file diff --git a/springboot-asn1/src/main/java/com/example/asn1/Asn1ParserApplication.java b/springboot-asn1/src/main/java/com/example/asn1/Asn1ParserApplication.java new file mode 100644 index 0000000..b55aa51 --- /dev/null +++ b/springboot-asn1/src/main/java/com/example/asn1/Asn1ParserApplication.java @@ -0,0 +1,18 @@ +package com.example.asn1; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * SpringBoot ASN.1解析应用主类 + * + * + * @version 1.0.0 + */ +@SpringBootApplication +public class Asn1ParserApplication { + + public static void main(String[] args) { + SpringApplication.run(Asn1ParserApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-asn1/src/main/java/com/example/asn1/controller/Asn1Controller.java b/springboot-asn1/src/main/java/com/example/asn1/controller/Asn1Controller.java new file mode 100644 index 0000000..df9b44f --- /dev/null +++ b/springboot-asn1/src/main/java/com/example/asn1/controller/Asn1Controller.java @@ -0,0 +1,198 @@ +package com.example.asn1.controller; + +import com.example.asn1.dto.Asn1ParseRequest; +import com.example.asn1.dto.Asn1ParseResponse; +import com.example.asn1.service.Asn1ParserService; +import com.example.asn1.exception.Asn1ParseException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.*; + +/** + * ASN.1解析控制器 + * + * @author SpringBoot + * @version 1.0.0 + */ +@Slf4j +@RestController +@RequestMapping("/api/asn1") +@Validated +public class Asn1Controller { + + private final Asn1ParserService asn1ParserService; + + /** + * 构造函数 + * + * @param asn1ParserService ASN.1解析服务 + */ + public Asn1Controller(Asn1ParserService asn1ParserService) { + this.asn1ParserService = asn1ParserService; + } + + /** + * 解析ASN.1数据 + * + * @param request 解析请求 + * @return 解析结果 + */ + @PostMapping("/parse") + public ResponseEntity parseAsn1( + @Valid @RequestBody Asn1ParseRequest request) { + + log.info("收到ASN.1解析请求,编码类型: {}, 详细输出: {}", + request.getEncodingType(), request.isVerbose()); + + try { + Asn1ParseResponse response = asn1ParserService.parseAsn1Data( + request.getAsn1Data(), + request.getEncodingType(), + request.isVerbose() + ); + + log.info("ASN.1解析成功"); + return ResponseEntity.ok(response); + + } catch (Asn1ParseException e) { + log.warn("ASN.1解析失败: {}", e.getMessage()); + Asn1ParseResponse errorResponse = createErrorResponse(e.getMessage(), e.getErrorCode()); + return ResponseEntity.badRequest().body(errorResponse); + + } catch (Exception e) { + log.error("ASN.1解析异常: ", e); + Asn1ParseResponse errorResponse = createErrorResponse("服务器内部错误", "INTERNAL_ERROR"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } + } + + /** + * 获取ASN.1解析器信息 + * + * @return 解析器信息 + */ + @GetMapping("/info") + public ResponseEntity> getAsn1Info() { + Map info = new HashMap<>(); + info.put("application", "SpringBoot ASN.1在线解析工具"); + info.put("version", "1.0.0"); + info.put("description", "支持多种ASN.1编码格式的在线解析工具"); + info.put("supportedEncodings", Arrays.asList("HEX", "BASE64", "RAW")); + info.put("supportedTypes", Arrays.asList( + "SEQUENCE", "SET", "INTEGER", "OCTET STRING", + "UTF8String", "PrintableString", "OBJECT IDENTIFIER", + "BIT STRING", "BOOLEAN", "NULL", "IA5String", + "UTCTime", "GeneralizedTime", "TAGGED" + )); + info.put("encodingRules", Arrays.asList("BER", "DER")); + info.put("library", "Bouncy Castle"); + info.put("bouncyCastleVersion", "1.75"); + + return ResponseEntity.ok(info); + } + + /** + * 健康检查接口 + * + * @return 健康状态 + */ + @GetMapping("/health") + public ResponseEntity> healthCheck() { + Map health = new HashMap<>(); + health.put("status", "UP"); + health.put("timestamp", System.currentTimeMillis()); + health.put("application", "SpringBoot ASN.1 Parser"); + return ResponseEntity.ok(health); + } + + /** + * 获取支持的编码类型 + * + * @return 支持的编码类型列表 + */ + @GetMapping("/encodings") + public ResponseEntity> getSupportedEncodings() { + Map encodings = new HashMap<>(); + + Map hexEncoding = new HashMap<>(); + hexEncoding.put("name", "HEX"); + hexEncoding.put("description", "十六进制编码"); + + Map base64Encoding = new HashMap<>(); + base64Encoding.put("name", "BASE64"); + base64Encoding.put("description", "Base64编码"); + + Map rawEncoding = new HashMap<>(); + rawEncoding.put("name", "RAW"); + rawEncoding.put("description", "原始字符串编码"); + + encodings.put("encodings", Arrays.asList(hexEncoding, base64Encoding, rawEncoding)); + encodings.put("default", "HEX"); + return ResponseEntity.ok(encodings); + } + + /** + * 获取示例数据 + * + * @return 示例数据列表 + */ + @GetMapping("/samples") + public ResponseEntity> getSampleData() { + Map samples = new HashMap<>(); + + Map integerSample = new HashMap<>(); + integerSample.put("name", "整数"); + integerSample.put("data", "020101"); + integerSample.put("encoding", "HEX"); + integerSample.put("description", "简单的ASN.1整数,值为1"); + samples.put("integer", integerSample); + + Map sequenceSample = new HashMap<>(); + sequenceSample.put("name", "序列"); + sequenceSample.put("data", "3009020101020101020101"); + sequenceSample.put("encoding", "HEX"); + sequenceSample.put("description", "包含三个整数的序列"); + samples.put("sequence", sequenceSample); + + Map utf8Sample = new HashMap<>(); + utf8Sample.put("name", "UTF8字符串"); + utf8Sample.put("data", "0c0548656c6c6f"); + utf8Sample.put("encoding", "HEX"); + utf8Sample.put("description", "UTF8编码的字符串'Hello'"); + samples.put("utf8string", utf8Sample); + + Map oidSample = new HashMap<>(); + oidSample.put("name", "对象标识符"); + oidSample.put("data", "06032a0304"); + oidSample.put("encoding", "HEX"); + oidSample.put("description", "OID 1.2.3.4"); + samples.put("oid", oidSample); + + return ResponseEntity.ok(samples); + } + + /** + * 创建错误响应 + * + * @param message 错误消息 + * @param errorCode 错误代码 + * @return 错误响应对象 + */ + private Asn1ParseResponse createErrorResponse(String message, String errorCode) { + Asn1ParseResponse errorResponse = new Asn1ParseResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(message); + errorResponse.setRootStructure(null); + errorResponse.setWarnings(null); + errorResponse.setMetadata(Map.of( + "errorCode", errorCode, + "timestamp", System.currentTimeMillis() + )); + return errorResponse; + } +} \ No newline at end of file diff --git a/springboot-asn1/src/main/java/com/example/asn1/controller/HomeController.java b/springboot-asn1/src/main/java/com/example/asn1/controller/HomeController.java new file mode 100644 index 0000000..b5a48eb --- /dev/null +++ b/springboot-asn1/src/main/java/com/example/asn1/controller/HomeController.java @@ -0,0 +1,24 @@ +package com.example.asn1.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * 首页控制器 + * + * + * @version 1.0.0 + */ +@Controller +public class HomeController { + + /** + * 首页 + * + * @return 首页视图 + */ + @GetMapping("/") + public String index() { + return "forward:/index.html"; + } +} \ No newline at end of file diff --git a/springboot-asn1/src/main/java/com/example/asn1/dto/Asn1ParseRequest.java b/springboot-asn1/src/main/java/com/example/asn1/dto/Asn1ParseRequest.java new file mode 100644 index 0000000..035cf00 --- /dev/null +++ b/springboot-asn1/src/main/java/com/example/asn1/dto/Asn1ParseRequest.java @@ -0,0 +1,34 @@ +package com.example.asn1.dto; + +import javax.validation.constraints.NotBlank; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +/** + * ASN.1解析请求DTO + * + * + * @version 1.0.0 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Asn1ParseRequest { + + /** + * ASN.1数据,不能为空 + */ + @NotBlank(message = "ASN.1数据不能为空") + private String asn1Data; + + /** + * 编码类型,默认为HEX + */ + private String encodingType = "HEX"; + + /** + * 是否输出详细信息 + */ + private boolean verbose = false; +} \ No newline at end of file diff --git a/springboot-asn1/src/main/java/com/example/asn1/dto/Asn1ParseResponse.java b/springboot-asn1/src/main/java/com/example/asn1/dto/Asn1ParseResponse.java new file mode 100644 index 0000000..ffafc7a --- /dev/null +++ b/springboot-asn1/src/main/java/com/example/asn1/dto/Asn1ParseResponse.java @@ -0,0 +1,97 @@ +package com.example.asn1.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import java.util.List; +import java.util.Map; + +/** + * ASN.1解析响应DTO + * + * + * @version 1.0.0 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Asn1ParseResponse { + + /** + * 解析是否成功 + */ + private boolean success; + + /** + * 响应消息 + */ + private String message; + + /** + * ASN.1结构树 + */ + private Asn1Structure rootStructure; + + /** + * 警告信息列表 + */ + private List warnings; + + /** + * 元数据信息 + */ + private Map metadata; + + /** + * ASN.1结构数据类 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Asn1Structure { + /** + * 标签名称 + */ + private String tag; + + /** + * 标签编号 + */ + private int tagNumber; + + /** + * 标签类别 + */ + private String tagClass; + + /** + * 数据类型 + */ + private String type; + + /** + * 数据值 + */ + private String value; + + /** + * 数据长度 + */ + private int length; + + /** + * 偏移量 + */ + private int offset; + + /** + * 子结构列表 + */ + private List children; + + /** + * 属性信息 + */ + private Map properties; + } +} \ No newline at end of file diff --git a/springboot-asn1/src/main/java/com/example/asn1/exception/Asn1ParseException.java b/springboot-asn1/src/main/java/com/example/asn1/exception/Asn1ParseException.java new file mode 100644 index 0000000..6d55316 --- /dev/null +++ b/springboot-asn1/src/main/java/com/example/asn1/exception/Asn1ParseException.java @@ -0,0 +1,56 @@ +package com.example.asn1.exception; + +/** + * ASN.1解析异常类 + * + * + * @version 1.0.0 + */ +public class Asn1ParseException extends RuntimeException { + + /** + * 错误代码 + */ + private final String errorCode; + + /** + * 默认构造函数 + * + * @param message 错误消息 + */ + public Asn1ParseException(String message) { + super(message); + this.errorCode = "ASN1_PARSE_ERROR"; + } + + /** + * 带异常原因的构造函数 + * + * @param message 错误消息 + * @param cause 异常原因 + */ + public Asn1ParseException(String message, Throwable cause) { + super(message, cause); + this.errorCode = "ASN1_PARSE_ERROR"; + } + + /** + * 自定义错误代码的构造函数 + * + * @param errorCode 错误代码 + * @param message 错误消息 + */ + public Asn1ParseException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + /** + * 获取错误代码 + * + * @return 错误代码 + */ + public String getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/springboot-asn1/src/main/java/com/example/asn1/service/Asn1ParserService.java b/springboot-asn1/src/main/java/com/example/asn1/service/Asn1ParserService.java new file mode 100644 index 0000000..bf5ac8f --- /dev/null +++ b/springboot-asn1/src/main/java/com/example/asn1/service/Asn1ParserService.java @@ -0,0 +1,1449 @@ +package com.example.asn1.service; + +import com.example.asn1.dto.Asn1ParseResponse; +import com.example.asn1.exception.Asn1ParseException; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.asn1.*; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +/** + * ASN.1解析服务类 + * + * + * @version 1.0.0 + */ +@Slf4j +@Service +public class Asn1ParserService { + + /** + * 解析ASN.1数据 + * + * @param data ASN.1数据 + * @param encodingType 编码类型(HEX、BASE64、RAW) + * @param verbose 是否输出详细信息 + * @return 解析结果 + */ + public Asn1ParseResponse parseAsn1Data(String data, String encodingType, boolean verbose) { + try { + log.debug("开始解析ASN.1数据,编码类型: {}, 数据长度: {}", encodingType, data.length()); + + byte[] asn1Bytes = decodeAsn1Data(data, encodingType); + + // 尝试多种解析策略 + Asn1ParseResponse.Asn1Structure rootStructure = tryMultipleParsingStrategies(asn1Bytes, verbose); + + List warnings = new ArrayList<>(); + Map metadata = createMetadata(asn1Bytes, encodingType); + + // 添加解析统计信息 + metadata.put("totalObjects", countTotalObjects(rootStructure)); + metadata.put("maxDepth", calculateMaxDepth(rootStructure)); + metadata.put("parsingStrategy", "multi-strategy"); + + log.debug("ASN.1数据解析成功,使用多策略解析"); + return new Asn1ParseResponse( + true, + "ASN.1数据解析成功,使用智能解析策略", + rootStructure, + warnings, + metadata + ); + + } catch (Asn1ParseException e) { + log.error("ASN.1解析失败: {}", e.getMessage()); + throw e; + } catch (Exception e) { + log.error("ASN.1解析异常: ", e); + throw new Asn1ParseException("ASN.1解析失败: " + e.getMessage(), e); + } + } + + /** + * 尝试多种解析策略 + */ + private Asn1ParseResponse.Asn1Structure tryMultipleParsingStrategies(byte[] asn1Bytes, boolean verbose) { + List errors = new ArrayList<>(); + + // 策略1: 标准ASN.1解析 - 优先使用,对于完整的ASN.1数据效果最好 + try { + Asn1ParseResponse.Asn1Structure result = parseWithStandardStrategy(asn1Bytes, verbose); + // 检查是否为容器结构(多对象),如果是且数据看起来像单一ASN.1结构,则尝试重解析 + if ("CONTAINER".equals(result.getTag()) && looksLikeSingleAsn1Structure(asn1Bytes)) { + log.debug("检测到多对象容器,但数据像单一ASN.1结构,尝试单对象解析"); + return parseAsSingleObject(asn1Bytes, verbose); + } + return result; + } catch (Exception e) { + errors.add("标准解析失败: " + e.getMessage()); + log.debug("标准解析策略失败: {}", e.getMessage()); + } + + // 策略2: 容错解析 + try { + return parseWithFaultTolerantStrategy(asn1Bytes, verbose); + } catch (Exception e) { + errors.add("容错解析失败: " + e.getMessage()); + log.debug("容错解析策略失败: {}", e.getMessage()); + } + + // 策略3: 分段解析 + try { + return parseWithSegmentedStrategy(asn1Bytes, verbose); + } catch (Exception e) { + errors.add("分段解析失败: " + e.getMessage()); + log.debug("分段解析策略失败: {}", e.getMessage()); + } + + // 所有策略都失败,抛出异常 + throw new Asn1ParseException("所有解析策略都失败。错误详情: " + String.join("; ", errors)); + } + + /** + * 标准解析策略 - 改进版 + */ + private Asn1ParseResponse.Asn1Structure parseWithStandardStrategy(byte[] asn1Bytes, boolean verbose) throws IOException { + List structures = new ArrayList<>(); + + try (ASN1InputStream asn1InputStream = new ASN1InputStream(new ByteArrayInputStream(asn1Bytes))) { + ASN1Primitive asn1Primitive; + int offset = 0; + + while ((asn1Primitive = asn1InputStream.readObject()) != null) { + Asn1ParseResponse.Asn1Structure structure = parseStructureWithOffset( + asn1Primitive, asn1Bytes, offset, verbose); + structures.add(structure); + + // 精确计算下一个对象的偏移量 + offset = calculateNextObjectOffset(asn1Bytes, offset, asn1Primitive); + + log.debug("解析ASN.1对象: tag={}, offset={}, nextOffset={}", + asn1Primitive.getClass().getSimpleName(), offset, offset); + } + } + + if (structures.isEmpty()) { + throw new RuntimeException("标准解析未找到有效的ASN.1结构"); + } + + return structures.size() == 1 ? structures.get(0) : createContainerStructure(structures, verbose); + } + + /** + * 精确解析ASN.1结构并计算偏移量 + */ + private Asn1ParseResponse.Asn1Structure parseStructureWithOffset( + ASN1Primitive asn1, byte[] originalData, int currentOffset, boolean verbose) { + + Asn1ParseResponse.Asn1Structure structure = new Asn1ParseResponse.Asn1Structure(); + + // 计算当前对象的实际长度 + int objectLength = calculateActualLength(asn1, originalData, currentOffset); + + // 基本属性设置 + structure.setOffset(currentOffset); + structure.setLength(objectLength); + + // 根据类型解析 + if (asn1 instanceof ASN1TaggedObject) { + parseTaggedObject((ASN1TaggedObject) asn1, structure, currentOffset, verbose); + } else if (asn1 instanceof ASN1Sequence) { + parseSequenceWithOffset((ASN1Sequence) asn1, structure, originalData, currentOffset, verbose); + } else if (asn1 instanceof ASN1Set) { + parseSetWithOffset((ASN1Set) asn1, structure, originalData, currentOffset, verbose); + } else if (asn1 instanceof ASN1Integer) { + parseInteger((ASN1Integer) asn1, structure, currentOffset); + } else if (asn1 instanceof ASN1OctetString) { + parseOctetString((ASN1OctetString) asn1, structure, currentOffset); + } else if (asn1 instanceof DERUTF8String) { + parseUTF8String((DERUTF8String) asn1, structure, currentOffset); + } else if (asn1 instanceof DERPrintableString) { + parsePrintableString((DERPrintableString) asn1, structure, currentOffset); + } else if (asn1 instanceof ASN1ObjectIdentifier) { + parseObjectIdentifier((ASN1ObjectIdentifier) asn1, structure, currentOffset); + } else if (asn1 instanceof ASN1BitString) { + parseBitString((ASN1BitString) asn1, structure, currentOffset); + } else if (asn1 instanceof ASN1Boolean) { + parseBoolean((ASN1Boolean) asn1, structure, currentOffset); + } else if (asn1 instanceof ASN1Null) { + parseNull(structure, currentOffset); + } else if (asn1 instanceof DERIA5String) { + parseIA5String((DERIA5String) asn1, structure, currentOffset); + } else if (asn1 instanceof ASN1UTCTime) { + parseUTCTime((ASN1UTCTime) asn1, structure, currentOffset); + } else if (asn1 instanceof ASN1GeneralizedTime) { + parseGeneralizedTime((ASN1GeneralizedTime) asn1, structure, currentOffset); + } else { + parseUnknown(asn1, structure, currentOffset); + } + + // 添加详细属性 + if (verbose) { + addVerboseProperties(asn1, structure); + } + + return structure; + } + + /** + * 精确计算ASN.1对象的实际长度 + */ + private int calculateActualLength(ASN1Primitive asn1, byte[] data, int offset) { + try { + if (offset >= data.length) return 0; + + // 获取标签字节 + int tagByte = data[offset] & 0xFF; + int lengthStart = offset + 1; + + // 检查是否为长格式长度编码 + if (lengthStart >= data.length) return 1; + + int lengthByte = data[lengthStart] & 0xFF; + + if ((lengthByte & 0x80) == 0) { + // 短格式长度 + return 1 + 1 + lengthByte; // tag + length + content + } else { + // 长格式长度 + int lengthBytes = lengthByte & 0x7F; + if (lengthBytes == 0 || lengthBytes > 4) { + // 不定长或长度字节过多,使用估算 + return estimateLength(asn1); + } + + if (lengthStart + lengthBytes >= data.length) { + return estimateLength(asn1); + } + + // 读取长度值 + int contentLength = 0; + for (int i = 0; i < lengthBytes; i++) { + contentLength = (contentLength << 8) | (data[lengthStart + 1 + i] & 0xFF); + } + + return 1 + 1 + lengthBytes + contentLength; // tag + length_bytes + content + } + } catch (Exception e) { + log.debug("计算长度失败,使用估算: {}", e.getMessage()); + return estimateLength(asn1); + } + } + + /** + * 计算下一个对象的偏移量 + */ + private int calculateNextObjectOffset(byte[] data, int currentOffset, ASN1Primitive currentObject) { + int objectLength = calculateActualLength(currentObject, data, currentOffset); + int nextOffset = currentOffset + objectLength; + + // 确保不越界 + return Math.min(nextOffset, data.length); + } + + /** + * 改进的序列解析 - 精确计算子对象偏移量 + */ + private void parseSequenceWithOffset(ASN1Sequence sequence, Asn1ParseResponse.Asn1Structure structure, + byte[] originalData, int sequenceOffset, boolean verbose) { + structure.setTag("SEQUENCE"); + structure.setTagNumber(16); + structure.setTagClass("UNIVERSAL"); + structure.setType("SEQUENCE"); + structure.setLength(sequence.size()); + structure.setOffset(sequenceOffset); + + List children = new ArrayList<>(); + + try { + // 获取序列的原始字节数据用于精确偏移计算 + byte[] sequenceBytes = extractSequenceBytes(originalData, sequenceOffset); + int childOffset = sequenceOffset + getSequenceHeaderLength(originalData, sequenceOffset); + + for (Enumeration e = sequence.getObjects(); e.hasMoreElements(); ) { + ASN1Primitive element = (ASN1Primitive) e.nextElement(); + + Asn1ParseResponse.Asn1Structure childStructure = parseStructureWithOffset( + element, originalData, childOffset, verbose); + children.add(childStructure); + + // 更新子对象偏移量 + childOffset = calculateNextObjectOffset(originalData, childOffset, element); + } + } catch (Exception ex) { + log.warn("序列偏移量计算失败,使用简化方式: {}", ex.getMessage()); + // 降级到简化方式 + int childOffset = sequenceOffset + 2; + for (Enumeration e = sequence.getObjects(); e.hasMoreElements(); ) { + ASN1Primitive element = (ASN1Primitive) e.nextElement(); + children.add(parseStructure(element, childOffset, verbose)); + childOffset += estimateLength(element); + } + } + + structure.setChildren(children); + structure.setValue(sequence.size() + " 个元素"); + } + + /** + * 改进的集合解析 + */ + private void parseSetWithOffset(ASN1Set set, Asn1ParseResponse.Asn1Structure structure, + byte[] originalData, int setOffset, boolean verbose) { + structure.setTag("SET"); + structure.setTagNumber(17); + structure.setTagClass("UNIVERSAL"); + structure.setType("SET"); + structure.setLength(set.size()); + structure.setOffset(setOffset); + structure.setValue(set.size() + " 个元素"); + + List children = new ArrayList<>(); + + try { + byte[] setBytes = extractSetBytes(originalData, setOffset); + int childOffset = setOffset + getSetHeaderLength(originalData, setOffset); + + for (Enumeration e = set.getObjects(); e.hasMoreElements(); ) { + ASN1Primitive element = (ASN1Primitive) e.nextElement(); + Asn1ParseResponse.Asn1Structure childStructure = parseStructureWithOffset( + element, originalData, childOffset, verbose); + children.add(childStructure); + + childOffset = calculateNextObjectOffset(originalData, childOffset, element); + } + } catch (Exception ex) { + log.warn("集合偏移量计算失败,使用简化方式: {}", ex.getMessage()); + // 降级到简化方式 + int childOffset = setOffset + 2; + for (Enumeration e = set.getObjects(); e.hasMoreElements(); ) { + ASN1Primitive element = (ASN1Primitive) e.nextElement(); + children.add(parseStructure(element, childOffset, verbose)); + childOffset += estimateLength(element); + } + } + + structure.setChildren(children); + } + + /** + * 提取序列的字节数据 + */ + private byte[] extractSequenceBytes(byte[] data, int offset) { + if (offset >= data.length) return new byte[0]; + + try { + int tagByte = data[offset] & 0xFF; + int lengthStart = offset + 1; + + if (lengthStart >= data.length) return new byte[0]; + + int lengthByte = data[lengthStart] & 0xFF; + int contentLength; + int headerLength; + + if ((lengthByte & 0x80) == 0) { + contentLength = lengthByte; + headerLength = 2; + } else { + int lengthBytes = lengthByte & 0x7F; + if (lengthBytes > 4 || lengthStart + lengthBytes >= data.length) { + return new byte[0]; + } + + contentLength = 0; + for (int i = 0; i < lengthBytes; i++) { + contentLength = (contentLength << 8) | (data[lengthStart + 1 + i] & 0xFF); + } + headerLength = 1 + 1 + lengthBytes; + } + + int totalLength = Math.min(headerLength + contentLength, data.length - offset); + byte[] result = new byte[totalLength]; + System.arraycopy(data, offset, result, 0, totalLength); + return result; + + } catch (Exception e) { + log.debug("提取序列字节失败: {}", e.getMessage()); + return new byte[0]; + } + } + + /** + * 提取集合的字节数据 + */ + private byte[] extractSetBytes(byte[] data, int offset) { + return extractSequenceBytes(data, offset); // SET和SEQUENCE的头部格式相同 + } + + /** + * 获取序列头部长度 + */ + private int getSequenceHeaderLength(byte[] data, int offset) { + if (offset >= data.length) return 2; + + int lengthByte = data[offset + 1] & 0xFF; + if ((lengthByte & 0x80) == 0) { + return 2; // tag + length + } else { + int lengthBytes = lengthByte & 0x7F; + return 1 + 1 + Math.min(lengthBytes, 4); + } + } + + /** + * 获取集合头部长度 + */ + private int getSetHeaderLength(byte[] data, int offset) { + return getSequenceHeaderLength(data, offset); + } + + /** + * 容错解析策略 + */ + private Asn1ParseResponse.Asn1Structure parseWithFaultTolerantStrategy(byte[] asn1Bytes, boolean verbose) { + List structures = new ArrayList<>(); + int position = 0; + int maxAttempts = asn1Bytes.length; + int attempts = 0; + + while (position < asn1Bytes.length && attempts < maxAttempts) { + attempts++; + + try { + // 寻找有效的ASN.1标签起始位置 + position = findNextValidTagStart(asn1Bytes, position); + if (position >= asn1Bytes.length) break; + + // 检查剩余数据是否足够 + if (asn1Bytes.length - position < 2) { + break; + } + + // 尝试从当前位置开始解析 + int remainingLength = asn1Bytes.length - position; + byte[] segment = new byte[Math.min(remainingLength, 2000)]; // 增大片段大小 + System.arraycopy(asn1Bytes, position, segment, 0, segment.length); + + ASN1InputStream tempStream = new ASN1InputStream(new ByteArrayInputStream(segment)); + ASN1Primitive asn1Primitive = tempStream.readObject(); + + if (asn1Primitive != null) { + // 使用精确解析 + Asn1ParseResponse.Asn1Structure structure = parseStructureWithOffset( + asn1Primitive, asn1Bytes, position, verbose); + structures.add(structure); + + // 精确计算下一个位置 + position = calculateNextObjectOffset(asn1Bytes, position, asn1Primitive); + + log.debug("容错解析成功: type={}, offset={}", asn1Primitive.getClass().getSimpleName(), position); + } else { + position++; + } + } catch (Exception e) { + log.debug("容错解析失败,位置 {}: {}", position, e.getMessage()); + position++; + } + } + + if (structures.isEmpty()) { + throw new RuntimeException("容错解析未找到有效的ASN.1结构"); + } + + return structures.size() == 1 ? structures.get(0) : createContainerStructure(structures, verbose); + } + + /** + * 寻找下一个有效的ASN.1标签起始位置 + */ + private int findNextValidTagStart(byte[] data, int startPosition) { + for (int i = startPosition; i < data.length - 1; i++) { + int currentByte = data[i] & 0xFF; + + // 检查是否为有效的ASN.1标签 + if (isValidAsn1Tag(currentByte)) { + // 进一步检查长度字节 + if (i + 1 < data.length) { + int lengthByte = data[i + 1] & 0xFF; + if (isValidLengthByte(lengthByte)) { + return i; + } + } + } + } + return data.length; + } + + /** + * 检查是否为有效的ASN.1标签 + */ + private boolean isValidAsn1Tag(int tagByte) { + // 检查高位是否为0(universal标签)或其他有效标签格式 + return (tagByte & 0x1F) != 0x1F || // 不是长格式标签 + (tagByte & 0xC0) != 0xC0; // 不是保留标签 + } + + /** + * 检查是否为有效的长度字节 + */ + private boolean isValidLengthByte(int lengthByte) { + if ((lengthByte & 0x80) == 0) { + // 短格式长度 + return true; + } else { + // 长格式长度,检查长度字节数是否合理 + int lengthBytes = lengthByte & 0x7F; + return lengthBytes > 0 && lengthBytes <= 4; + } + } + + /** + * 分段解析策略 - 改进版 + */ + private Asn1ParseResponse.Asn1Structure parseWithSegmentedStrategy(byte[] asn1Bytes, boolean verbose) { + // 使用更精确的标签查找策略 + List structures = new ArrayList<>(); + Set processedPositions = new HashSet<>(); + + // 扩展的ASN.1标签值,包含更多常见类型 + int[] commonTags = { + 0x30, // SEQUENCE + 0x31, // SET + 0x02, // INTEGER + 0x04, // OCTET STRING + 0x05, // NULL + 0x06, // OBJECT IDENTIFIER + 0x13, // PrintableString + 0x14, // T61String + 0x16, // IA5String + 0x17, // UTCTime + 0x18, // GeneralizedTime + 0x03, // BIT STRING + 0x01, // BOOLEAN + 0x0C, // UTF8String + 0x0A, // ENUMERATED + 0x19, // VisibleString + 0x1A, // BMPString + 0x1B, // UniversalString + 0x1E // NUMERIC STRING + }; + + // 首先尝试在数据中查找常见的ASN.1结构起始点 + List candidates = findStructureCandidates(asn1Bytes, commonTags); + + // 按优先级排序:SEQUENCE和SET优先 + candidates.sort((a, b) -> { + if ((a.tagByte & 0xFF) == 0x30) return -1; // SEQUENCE + if ((b.tagByte & 0xFF) == 0x30) return 1; + if ((a.tagByte & 0xFF) == 0x31) return -1; // SET + if ((b.tagByte & 0xFF) == 0x31) return 1; + return Integer.compare(a.position, b.position); + }); + + // 处理找到的候选结构 + for (StructureCandidate candidate : candidates) { + if (processedPositions.contains(candidate.position)) { + continue; // 跳过已处理的位置 + } + + try { + // 计算合理的解析长度 + int parseLength = calculateParseLength(asn1Bytes, candidate.position); + parseLength = Math.min(parseLength, Math.min(5000, asn1Bytes.length - candidate.position)); + + byte[] segment = new byte[parseLength]; + System.arraycopy(asn1Bytes, candidate.position, segment, 0, parseLength); + + ASN1InputStream tempStream = new ASN1InputStream(new ByteArrayInputStream(segment)); + ASN1Primitive asn1Primitive = tempStream.readObject(); + + if (asn1Primitive != null) { + // 使用精确偏移量解析 + Asn1ParseResponse.Asn1Structure structure = parseStructureWithOffset( + asn1Primitive, asn1Bytes, candidate.position, verbose); + structures.add(structure); + + // 标记处理过的位置范围 + int structureLength = calculateActualLength(asn1Primitive, asn1Bytes, candidate.position); + for (int i = candidate.position; i < candidate.position + structureLength && i < asn1Bytes.length; i++) { + processedPositions.add(i); + } + + log.debug("分段解析成功: type={}, offset={}, length={}", + asn1Primitive.getClass().getSimpleName(), candidate.position, structureLength); + } + } catch (Exception e) { + log.debug("分段解析失败,位置 {}: {}", candidate.position, e.getMessage()); + } + } + + // 如果没有找到有效结构,尝试逐字节扫描 + if (structures.isEmpty()) { + structures.addAll(scanByByte(asn1Bytes, verbose)); + } + + if (structures.isEmpty()) { + throw new RuntimeException("分段解析未找到有效的ASN.1结构"); + } + + return structures.size() == 1 ? structures.get(0) : createContainerStructure(structures, verbose); + } + + /** + * 结构候选信息 + */ + private static class StructureCandidate { + int position; + int tagByte; + int confidence; + + StructureCandidate(int position, int tagByte, int confidence) { + this.position = position; + this.tagByte = tagByte; + this.confidence = confidence; + } + } + + /** + * 查找结构候选 + */ + private List findStructureCandidates(byte[] data, int[] commonTags) { + List candidates = new ArrayList<>(); + + for (int tag : commonTags) { + List positions = findTagPositions(data, tag); + + for (int pos : positions) { + // 验证候选位置的有效性 + int confidence = validateCandidate(data, pos, tag); + if (confidence > 0) { + candidates.add(new StructureCandidate(pos, tag, confidence)); + } + } + } + + return candidates; + } + + /** + * 验证候选位置的有效性 + */ + private int validateCandidate(byte[] data, int position, int tagByte) { + try { + if (position + 1 >= data.length) { + return 0; + } + + int lengthByte = data[position + 1] & 0xFF; + int confidence = 1; + + // 检查长度字节的合理性 + if ((lengthByte & 0x80) == 0) { + // 短格式长度 + confidence += lengthByte > 0 ? 2 : 0; + } else { + // 长格式长度 + int lengthBytes = lengthByte & 0x7F; + if (lengthBytes > 0 && lengthBytes <= 4) { + confidence += 2; + if (position + 1 + lengthBytes < data.length) { + // 检查长度值的合理性 + int contentLength = 0; + for (int i = 0; i < lengthBytes; i++) { + contentLength = (contentLength << 8) | (data[position + 2 + i] & 0xFF); + } + if (contentLength > 0 && contentLength < 10000) { + confidence += 1; + } + } + } + } + + // 特殊标签的额外检查 + if (tagByte == 0x30 || tagByte == 0x31) { // SEQUENCE or SET + confidence += 2; // 高优先级 + } else if (tagByte == 0x02) { // INTEGER + confidence += 1; + } + + return confidence; + } catch (Exception e) { + return 0; + } + } + + /** + * 计算合理的解析长度 + */ + private int calculateParseLength(byte[] data, int startPosition) { + try { + if (startPosition >= data.length) return 100; + + int lengthByte = data[startPosition + 1] & 0xFF; + int headerLength = 2; + + if ((lengthByte & 0x80) != 0) { + int lengthBytes = lengthByte & 0x7F; + if (lengthBytes > 0 && lengthBytes <= 4 && startPosition + 1 + lengthBytes < data.length) { + int contentLength = 0; + for (int i = 0; i < lengthBytes; i++) { + contentLength = (contentLength << 8) | (data[startPosition + 2 + i] & 0xFF); + } + headerLength = 1 + 1 + lengthBytes; + return headerLength + contentLength; + } + } + + return headerLength + Math.min(lengthByte, 1000); + } catch (Exception e) { + return 100; + } + } + + /** + * 逐字节扫描最后手段 + */ + private List scanByByte(byte[] data, boolean verbose) { + List structures = new ArrayList<>(); + + for (int i = 0; i < Math.min(data.length - 2, 1000); i++) { + try { + byte[] segment = Arrays.copyOfRange(data, i, Math.min(i + 100, data.length)); + ASN1InputStream tempStream = new ASN1InputStream(new ByteArrayInputStream(segment)); + ASN1Primitive asn1Primitive = tempStream.readObject(); + + if (asn1Primitive != null) { + Asn1ParseResponse.Asn1Structure structure = parseStructureWithOffset( + asn1Primitive, data, i, verbose); + structures.add(structure); + break; // 找到一个就停止 + } + } catch (Exception e) { + // 继续尝试 + } + } + + return structures; + } + + /** + * 查找标签位置 + */ + private List findTagPositions(byte[] data, int tag) { + List positions = new ArrayList<>(); + + for (int i = 0; i < data.length - 1; i++) { + if ((data[i] & 0xFF) == tag) { + positions.add(i); + } + } + + return positions; + } + + /** + * 计算总对象数 + */ + private int countTotalObjects(Asn1ParseResponse.Asn1Structure structure) { + int count = 1; + + if (structure.getChildren() != null) { + for (Asn1ParseResponse.Asn1Structure child : structure.getChildren()) { + count += countTotalObjects(child); + } + } + + return count; + } + + /** + * 解码ASN.1数据 + * + * @param data 编码后的数据 + * @param encodingType 编码类型 + * @return 解码后的字节数组 + * @throws IOException 解码失败异常 + */ + private byte[] decodeAsn1Data(String data, String encodingType) throws IOException { + if (data == null || data.trim().isEmpty()) { + throw new IllegalArgumentException("输入数据不能为空"); + } + + data = data.trim().replaceAll("\\s+", ""); + + try { + switch (encodingType.toUpperCase()) { + case "HEX": + return hexStringToByteArray(data); + case "BASE64": + return Base64.getDecoder().decode(data); + case "RAW": + return data.getBytes("UTF-8"); + default: + throw new IllegalArgumentException("不支持的编码类型: " + encodingType + ",支持的类型: HEX、BASE64、RAW"); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("数据解码失败: " + e.getMessage(), e); + } + } + + /** + * 十六进制字符串转字节数组 + * + * @param hex 十六进制字符串 + * @return 字节数组 + */ + private byte[] hexStringToByteArray(String hex) { + if (hex.length() % 2 != 0) { + hex = "0" + hex; + } + + byte[] bytes = new byte[hex.length() / 2]; + try { + for (int i = 0; i < hex.length(); i += 2) { + bytes[i / 2] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("无效的十六进制格式: " + e.getMessage()); + } + return bytes; + } + + /** + * 解析ASN.1结构 + * + * @param asn1 ASN.1对象 + * @param offset 偏移量 + * @param verbose 是否输出详细信息 + * @return 解析后的结构 + */ + private Asn1ParseResponse.Asn1Structure parseStructure(ASN1Primitive asn1, int offset, boolean verbose) { + Asn1ParseResponse.Asn1Structure structure = new Asn1ParseResponse.Asn1Structure(); + + if (asn1 instanceof ASN1TaggedObject) { + parseTaggedObject((ASN1TaggedObject) asn1, structure, offset, verbose); + } else if (asn1 instanceof ASN1Sequence) { + parseSequence((ASN1Sequence) asn1, structure, offset, verbose); + } else if (asn1 instanceof ASN1Set) { + parseSet((ASN1Set) asn1, structure, offset, verbose); + } else if (asn1 instanceof ASN1Integer) { + parseInteger((ASN1Integer) asn1, structure, offset); + } else if (asn1 instanceof ASN1OctetString) { + parseOctetString((ASN1OctetString) asn1, structure, offset); + } else if (asn1 instanceof DERUTF8String) { + parseUTF8String((DERUTF8String) asn1, structure, offset); + } else if (asn1 instanceof DERPrintableString) { + parsePrintableString((DERPrintableString) asn1, structure, offset); + } else if (asn1 instanceof ASN1ObjectIdentifier) { + parseObjectIdentifier((ASN1ObjectIdentifier) asn1, structure, offset); + } else if (asn1 instanceof ASN1BitString) { + parseBitString((ASN1BitString) asn1, structure, offset); + } else if (asn1 instanceof ASN1Boolean) { + parseBoolean((ASN1Boolean) asn1, structure, offset); + } else if (asn1 instanceof ASN1Null) { + parseNull(structure, offset); + } else if (asn1 instanceof DERIA5String) { + parseIA5String((DERIA5String) asn1, structure, offset); + } else if (asn1 instanceof ASN1UTCTime) { + parseUTCTime((ASN1UTCTime) asn1, structure, offset); + } else if (asn1 instanceof ASN1GeneralizedTime) { + parseGeneralizedTime((ASN1GeneralizedTime) asn1, structure, offset); + } else { + parseUnknown(asn1, structure, offset); + } + + // 添加详细属性 + if (verbose) { + addVerboseProperties(asn1, structure); + } + + return structure; + } + + /** + * 解析标记对象 + */ + private void parseTaggedObject(ASN1TaggedObject tagged, Asn1ParseResponse.Asn1Structure structure, int offset, boolean verbose) { + structure.setTag("TAGGED"); + structure.setTagNumber(tagged.getTagNo()); + structure.setTagClass(getTagClass(tagged.getTagClass())); + structure.setOffset(offset); + + ASN1Primitive baseObject = tagged.getObject(); + if (baseObject instanceof ASN1OctetString && !tagged.isExplicit()) { + structure.setType("IMPLICIT OCTET STRING"); + structure.setValue("0x" + bytesToHex(((ASN1OctetString) baseObject).getOctets())); + } else { + Asn1ParseResponse.Asn1Structure childStructure = parseStructure(baseObject, offset, verbose); + structure.setType(childStructure.getType()); + structure.setValue(childStructure.getValue()); + structure.setChildren(childStructure.getChildren()); + } + } + + /** + * 解析序列 + */ + private void parseSequence(ASN1Sequence sequence, Asn1ParseResponse.Asn1Structure structure, int offset, boolean verbose) { + structure.setTag("SEQUENCE"); + structure.setTagNumber(16); + structure.setTagClass("UNIVERSAL"); + structure.setType("SEQUENCE"); + structure.setLength(sequence.size()); + structure.setOffset(offset); + + List children = new ArrayList<>(); + int childOffset = offset + 2; // 简化的偏移计算 + + for (Enumeration e = sequence.getObjects(); e.hasMoreElements(); ) { + ASN1Primitive element = (ASN1Primitive) e.nextElement(); + children.add(parseStructure(element, childOffset, verbose)); + childOffset += 10; // 简化的长度计算 + } + structure.setChildren(children); + structure.setValue(sequence.size() + " 个元素"); + } + + /** + * 解析集合 + */ + private void parseSet(ASN1Set set, Asn1ParseResponse.Asn1Structure structure, int offset, boolean verbose) { + structure.setTag("SET"); + structure.setTagNumber(17); + structure.setTagClass("UNIVERSAL"); + structure.setType("SET"); + structure.setLength(set.size()); + structure.setOffset(offset); + structure.setValue(set.size() + " 个元素"); + + List children = new ArrayList<>(); + for (Enumeration e = set.getObjects(); e.hasMoreElements(); ) { + children.add(parseStructure((ASN1Primitive) e.nextElement(), offset, verbose)); + } + structure.setChildren(children); + } + + /** + * 解析整数 + */ + private void parseInteger(ASN1Integer integer, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("INTEGER"); + structure.setTagNumber(2); + structure.setTagClass("UNIVERSAL"); + structure.setType("INTEGER"); + structure.setValue(integer.getValue().toString()); + structure.setOffset(offset); + structure.setLength(integer.getValue().toByteArray().length); + } + + /** + * 解析八位字节字符串 - 增强版,支持嵌套ASN.1解析 + */ + private void parseOctetString(ASN1OctetString octetString, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("OCTET STRING"); + structure.setTagNumber(4); + structure.setTagClass("UNIVERSAL"); + structure.setType("OCTET STRING"); + structure.setValue("0x" + bytesToHex(octetString.getOctets())); + structure.setLength(octetString.getOctets().length); + structure.setOffset(offset); + + // 尝试解析嵌套的ASN.1数据 + try { + byte[] octetBytes = octetString.getOctets(); + if (octetBytes.length > 0) { + // 检查是否可能是有效的ASN.1数据 + if (looksLikeAsn1Data(octetBytes)) { + List nestedStructures = new ArrayList<>(); + + try (ASN1InputStream nestedStream = new ASN1InputStream(new ByteArrayInputStream(octetBytes))) { + ASN1Primitive nestedAsn1; + int nestedOffset = 0; + + while ((nestedAsn1 = nestedStream.readObject()) != null) { + Asn1ParseResponse.Asn1Structure nestedStructure = parseStructureWithOffset( + nestedAsn1, octetBytes, nestedOffset, false); + nestedStructures.add(nestedStructure); + + // 计算下一个嵌套对象的偏移量 + nestedOffset = calculateNextObjectOffset(octetBytes, nestedOffset, nestedAsn1); + + // 防止无限循环 + if (nestedOffset >= octetBytes.length) break; + } + } + + if (!nestedStructures.isEmpty()) { + structure.setChildren(nestedStructures); + structure.setValue(nestedStructures.size() + " 个嵌套对象"); + log.debug("OCTET STRING中解析出 {} 个嵌套ASN.1对象", nestedStructures.size()); + } + } + } + } catch (Exception e) { + log.debug("OCTET STRING嵌套解析失败: {}", e.getMessage()); + // 保持原始的十六进制值 + } + } + + /** + * 解析UTF8字符串 + */ + private void parseUTF8String(DERUTF8String utf8String, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("UTF8String"); + structure.setTagNumber(12); + structure.setTagClass("UNIVERSAL"); + structure.setType("UTF8String"); + structure.setValue(utf8String.getString()); + structure.setOffset(offset); + structure.setLength(utf8String.getString().getBytes().length); + } + + /** + * 解析可打印字符串 + */ + private void parsePrintableString(DERPrintableString printableString, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("PrintableString"); + structure.setTagNumber(19); + structure.setTagClass("UNIVERSAL"); + structure.setType("PrintableString"); + structure.setValue(printableString.getString()); + structure.setOffset(offset); + structure.setLength(printableString.getString().getBytes().length); + } + + /** + * 解析对象标识符 + */ + private void parseObjectIdentifier(ASN1ObjectIdentifier oid, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("OBJECT IDENTIFIER"); + structure.setTagNumber(6); + structure.setTagClass("UNIVERSAL"); + structure.setType("OBJECT IDENTIFIER"); + structure.setValue(oid.getId()); + structure.setOffset(offset); + } + + /** + * 解析位字符串 - 增强版,支持嵌套ASN.1解析 + */ + private void parseBitString(ASN1BitString bitString, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("BIT STRING"); + structure.setTagNumber(3); + structure.setTagClass("UNIVERSAL"); + structure.setType("BIT STRING"); + structure.setValue("0x" + bytesToHex(bitString.getBytes())); + structure.setLength(bitString.getBytes().length); + structure.setOffset(offset); + + // 尝试解析嵌套的ASN.1数据(如公钥、签名等) + try { + byte[] bitBytes = bitString.getBytes(); + if (bitBytes.length > 0) { + // 根据BIT STRING的第一个字节判断是否跳过 + int unusedBits = bitBytes[0] & 0x07; // 最低3位表示未使用的位数 + byte[] contentBytes; + + if (unusedBits == 0) { + // 没有未使用的位数,解析整个字节序列 + contentBytes = bitBytes; + } else { + // 跳过未使用的位数 + contentBytes = Arrays.copyOfRange(bitBytes, 1, bitBytes.length); + } + + if (contentBytes.length > 0 && looksLikeAsn1Data(contentBytes)) { + List nestedStructures = new ArrayList<>(); + + try (ASN1InputStream nestedStream = new ASN1InputStream(new ByteArrayInputStream(contentBytes))) { + ASN1Primitive nestedAsn1; + int nestedOffset = 0; + + while ((nestedAsn1 = nestedStream.readObject()) != null) { + Asn1ParseResponse.Asn1Structure nestedStructure = parseStructureWithOffset( + nestedAsn1, contentBytes, nestedOffset, false); + nestedStructures.add(nestedStructure); + + nestedOffset = calculateNextObjectOffset(contentBytes, nestedOffset, nestedAsn1); + if (nestedOffset >= contentBytes.length) break; + } + } + + if (!nestedStructures.isEmpty()) { + structure.setChildren(nestedStructures); + structure.setValue(nestedStructures.size() + " 个嵌套对象"); + log.debug("BIT STRING中解析出 {} 个嵌套ASN.1对象", nestedStructures.size()); + } + } + } + } catch (Exception e) { + log.debug("BIT STRING嵌套解析失败: {}", e.getMessage()); + // 保持原始的十六进制值 + } + } + + /** + * 解析布尔值 + */ + private void parseBoolean(ASN1Boolean booleanObj, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("BOOLEAN"); + structure.setTagNumber(1); + structure.setTagClass("UNIVERSAL"); + structure.setType("BOOLEAN"); + structure.setValue(booleanObj.isTrue() ? "TRUE" : "FALSE"); + structure.setOffset(offset); + structure.setLength(1); + } + + /** + * 解析空值 + */ + private void parseNull(Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("NULL"); + structure.setTagNumber(5); + structure.setTagClass("UNIVERSAL"); + structure.setType("NULL"); + structure.setValue("NULL"); + structure.setOffset(offset); + structure.setLength(0); + } + + /** + * 解析IA5字符串 + */ + private void parseIA5String(DERIA5String ia5String, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("IA5String"); + structure.setTagNumber(22); + structure.setTagClass("UNIVERSAL"); + structure.setType("IA5String"); + structure.setValue(ia5String.getString()); + structure.setOffset(offset); + structure.setLength(ia5String.getString().getBytes().length); + } + + /** + * 解析UTC时间 + */ + private void parseUTCTime(ASN1UTCTime utcTime, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("UTCTime"); + structure.setTagNumber(23); + structure.setTagClass("UNIVERSAL"); + structure.setType("UTCTime"); + structure.setValue(utcTime.getTime()); + structure.setOffset(offset); + structure.setLength(utcTime.getTime().getBytes().length); + } + + /** + * 解析通用时间 + */ + private void parseGeneralizedTime(ASN1GeneralizedTime generalizedTime, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("GeneralizedTime"); + structure.setTagNumber(24); + structure.setTagClass("UNIVERSAL"); + structure.setType("GeneralizedTime"); + structure.setValue(generalizedTime.getTime()); + structure.setOffset(offset); + structure.setLength(generalizedTime.getTime().getBytes().length); + } + + /** + * 解析未知类型 + */ + private void parseUnknown(ASN1Primitive asn1, Asn1ParseResponse.Asn1Structure structure, int offset) { + structure.setTag("UNKNOWN"); + structure.setTagNumber(-1); + structure.setTagClass("UNKNOWN"); + structure.setType("UNKNOWN"); + structure.setValue(asn1.toString()); + structure.setOffset(offset); + } + + /** + * 获取标签类别 + */ + private String getTagClass(int tagClass) { + switch (tagClass) { + case 0: + return "UNIVERSAL"; + case 1: + return "APPLICATION"; + case 2: + return "CONTEXT_SPECIFIC"; + case 3: + return "PRIVATE"; + default: + return "UNKNOWN"; + } + } + + /** + * 字节数组转十六进制字符串 + */ + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } + + /** + * 添加详细属性 + */ + private void addVerboseProperties(ASN1Primitive asn1, Asn1ParseResponse.Asn1Structure structure) { + Map properties = new HashMap<>(); + properties.put("className", asn1.getClass().getSimpleName()); + properties.put("hashCode", asn1.hashCode()); + structure.setProperties(properties); + } + + /** + * 创建元数据 + */ + private Map createMetadata(byte[] data, String encodingType) { + Map metadata = new HashMap<>(); + metadata.put("originalLength", data.length); + metadata.put("encodingType", encodingType); + metadata.put("encodingTimestamp", System.currentTimeMillis()); + + // 检测可能的编码规则 + String probableEncoding = detectEncodingRule(data); + metadata.put("probableEncoding", probableEncoding); + + return metadata; + } + + /** + * 检测编码规则 + */ + private String detectEncodingRule(byte[] data) { + if (data.length > 0) { + byte firstByte = data[0]; + if ((firstByte & 0x1F) == 0x10) { // SEQUENCE tag + if (isDerCompliant(data)) { + return "DER (Distinguished Encoding Rules)"; + } else { + return "BER (Basic Encoding Rules)"; + } + } + } + return "Unknown"; + } + + /** + * 检查是否符合DER规范 + */ + private boolean isDerCompliant(byte[] data) { + // 简化的DER合规性检查 + if (data.length >= 2) { + byte lengthByte = data[1]; + if ((lengthByte & 0x80) != 0) { + int lengthBytes = lengthByte & 0x7F; + if (lengthBytes == 1) { + return (data[2] & 0x80) != 0; + } + } + } + return true; + } + + /** + * 估算ASN.1对象的长度 + */ + private int estimateLength(ASN1Primitive asn1) { + if (asn1 instanceof ASN1Sequence) { + return ((ASN1Sequence) asn1).size() * 10 + 4; // 简化估算 + } else if (asn1 instanceof ASN1OctetString) { + return ((ASN1OctetString) asn1).getOctets().length + 2; + } else if (asn1 instanceof ASN1Integer) { + return ((ASN1Integer) asn1).getValue().toByteArray().length + 2; + } else { + return 10; // 默认估算长度 + } + } + + /** + * 创建容器结构包含多个ASN.1对象 + */ + private Asn1ParseResponse.Asn1Structure createContainerStructure(List structures, boolean verbose) { + Asn1ParseResponse.Asn1Structure container = new Asn1ParseResponse.Asn1Structure(); + container.setTag("CONTAINER"); + container.setTagNumber(-1); + container.setTagClass("CONTAINER"); + container.setType("MULTIPLE_OBJECTS"); + container.setValue(structures.size() + " 个顶级对象"); + container.setOffset(0); + container.setLength(structures.size()); + container.setChildren(structures); + + if (verbose) { + Map properties = new HashMap<>(); + properties.put("className", "Container"); + properties.put("objectCount", structures.size()); + container.setProperties(properties); + } + + return container; + } + + /** + * 检查字节数组是否看起来像ASN.1数据 + */ + private boolean looksLikeAsn1Data(byte[] data) { + if (data == null || data.length < 2) { + return false; + } + + try { + // 检查第一个字节是否为有效的ASN.1标签 + int firstByte = data[0] & 0xFF; + + // 常见的ASN.1标签值 + int[] commonTags = { + 0x30, // SEQUENCE + 0x31, // SET + 0x02, // INTEGER + 0x04, // OCTET STRING + 0x05, // NULL + 0x06, // OBJECT IDENTIFIER + 0x13, // PrintableString + 0x16, // IA5String + 0x17, // UTCTime + 0x18, // GeneralizedTime + 0x03, // BIT STRING + 0x01, // BOOLEAN + 0x0C, // UTF8String + }; + + boolean isValidTag = false; + for (int tag : commonTags) { + if (firstByte == tag) { + isValidTag = true; + break; + } + } + + if (!isValidTag) { + return false; + } + + // 检查长度字节是否合理 + int lengthByte = data[1] & 0xFF; + + if ((lengthByte & 0x80) == 0) { + // 短格式长度 + return lengthByte <= data.length - 2; + } else { + // 长格式长度 + int lengthBytes = lengthByte & 0x7F; + if (lengthBytes == 0 || lengthBytes > 4) { + return false; + } + + if (2 + lengthBytes >= data.length) { + return false; + } + + // 读取长度值 + int contentLength = 0; + for (int i = 0; i < lengthBytes; i++) { + contentLength = (contentLength << 8) | (data[2 + i] & 0xFF); + } + + // 检查内容长度是否合理 + return contentLength > 0 && contentLength <= 10000 && + (2 + lengthBytes + contentLength) <= data.length; + } + } catch (Exception e) { + return false; + } + } + + /** + * 计算结构的最大深度 + */ + private int calculateMaxDepth(Asn1ParseResponse.Asn1Structure structure) { + if (structure.getChildren() == null || structure.getChildren().isEmpty()) { + return 1; + } + + int maxChildDepth = 0; + for (Asn1ParseResponse.Asn1Structure child : structure.getChildren()) { + int childDepth = calculateMaxDepth(child); + if (childDepth > maxChildDepth) { + maxChildDepth = childDepth; + } + } + + return maxChildDepth + 1; + } + + /** + * 检查数据是否看起来像单一ASN.1结构 + */ + private boolean looksLikeSingleAsn1Structure(byte[] data) { + if (data == null || data.length < 2) { + return false; + } + + try { + // 检查是否以常见的ASN.1结构标签开头 + int firstByte = data[0] & 0xFF; + + // 单一ASN.1结构通常以这些标签开头 + if (firstByte == 0x30) { // SEQUENCE + // 检查长度是否匹配整个数据 + int lengthByte = data[1] & 0xFF; + int expectedLength; + + if ((lengthByte & 0x80) == 0) { + expectedLength = 2 + lengthByte; // tag + length + content + } else { + int lengthBytes = lengthByte & 0x7F; + if (lengthBytes == 0 || lengthBytes > 4 || 2 + lengthBytes >= data.length) { + return false; + } + + int contentLength = 0; + for (int i = 0; i < lengthBytes; i++) { + contentLength = (contentLength << 8) | (data[2 + i] & 0xFF); + } + expectedLength = 2 + lengthBytes + contentLength; + } + + return expectedLength == data.length; + } + + // 其他单一结构检查... + return false; + } catch (Exception e) { + return false; + } + } + + /** + * 将数据作为单一ASN.1对象解析 + */ + private Asn1ParseResponse.Asn1Structure parseAsSingleObject(byte[] asn1Bytes, boolean verbose) throws IOException { + try (ASN1InputStream asn1InputStream = new ASN1InputStream(new ByteArrayInputStream(asn1Bytes))) { + ASN1Primitive asn1Primitive = asn1InputStream.readObject(); + + if (asn1Primitive == null) { + throw new RuntimeException("无法解析ASN.1对象"); + } + + Asn1ParseResponse.Asn1Structure structure = parseStructureWithOffset( + asn1Primitive, asn1Bytes, 0, verbose); + + log.debug("单对象解析成功: {}", asn1Primitive.getClass().getSimpleName()); + return structure; + } + } +} \ No newline at end of file diff --git a/springboot-asn1/src/main/resources/application.yml b/springboot-asn1/src/main/resources/application.yml new file mode 100644 index 0000000..3576a66 --- /dev/null +++ b/springboot-asn1/src/main/resources/application.yml @@ -0,0 +1,51 @@ +spring: + application: + name: springboot-asn1-parser + +server: + port: 8080 + servlet: + context-path: / + +# 日志配置 +logging: + level: + 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" + file: + name: logs/springboot-asn1-parser.log + max-size: 10MB + max-history: 30 + +# 应用信息 +info: + app: + name: SpringBoot ASN.1解析工具 + version: 1.0.0 + description: 基于SpringBoot和BouncyCastle的ASN.1在线解析工具 + author: + build: + timestamp: ${maven.build.timestamp} + java: + version: ${java.version} + +# 自定义配置 +asn1: + parser: + # 最大输入数据大小(字节) + max-input-size: 1048576 # 1MB + # 解析超时时间(毫秒) + parse-timeout: 30000 + # 是否启用详细日志 + verbose-logging: false + # 支持的编码类型 + supported-encodings: + - BASE64 + - HEX + # 缓存配置 + cache: + enabled: true + max-size: 1000 + ttl: 3600 \ No newline at end of file diff --git a/springboot-asn1/src/main/resources/static/index.html b/springboot-asn1/src/main/resources/static/index.html new file mode 100644 index 0000000..c957c11 --- /dev/null +++ b/springboot-asn1/src/main/resources/static/index.html @@ -0,0 +1,201 @@ + + + + + + ASN.1在线解析工具 - SpringBoot实现 + + + + +
+
+
+

ASN.1在线解析工具

+

基于SpringBoot和BouncyCastle实现 | 支持BER、DER等编码规则

+
+ +
+ +
+
+
+

输入ASN.1数据

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

示例数据:

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

解析结果

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

解析结果将在这里显示...

+

请在左侧输入ASN.1数据并点击解析按钮

+
+
+
+
+
+ +
+

使用文档

+ +
+
+

支持的ASN.1类型

+
    +
  • SEQUENCE - 序列类型
  • +
  • SET - 集合类型
  • +
  • INTEGER - 整数类型
  • +
  • OCTET STRING - 八位字节串
  • +
  • UTF8String - UTF-8字符串
  • +
  • PrintableString - 可打印字符串
  • +
  • OBJECT IDENTIFIER - 对象标识符
  • +
  • BIT STRING - 位串
  • +
  • BOOLEAN - 布尔值
  • +
  • NULL - 空值
  • +
  • UTCTime - UTC时间
  • +
  • GeneralizedTime - 通用时间
  • +
+
+ +
+

支持的编码格式

+
    +
  • HEX - 十六进制编码(推荐)
  • +
  • Base64 - Base64编码
  • +
  • RAW - 原始字符串编码
  • +
+

HEX格式示例:020101(表示整数1)

+
+ +
+

编码规则

+
    +
  • BER - 基本编码规则
  • +
  • DER - 唯一编码规则
  • +
  • 工具会自动检测可能的编码规则
  • +
+
+ +
+

快捷键

+
    +
  • Ctrl + Enter - 快速解析
  • +
  • Ctrl + V - 粘贴数据
  • +
  • Ctrl + L - 清空输入
  • +
+
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/springboot-asn1/src/main/resources/static/script.js b/springboot-asn1/src/main/resources/static/script.js new file mode 100644 index 0000000..9d6202c --- /dev/null +++ b/springboot-asn1/src/main/resources/static/script.js @@ -0,0 +1,843 @@ +// 全局变量 +let currentResult = null; +let parseStartTime = null; + +// DOM元素 +const elements = { + asn1Input: document.getElementById('asn1Input'), + encodingType: document.getElementById('encodingType'), + verboseCheckbox: document.getElementById('verbose'), + parseBtn: document.getElementById('parseBtn'), + resultContainer: document.getElementById('resultContainer'), + resultStats: document.getElementById('resultStats'), + parseTime: document.getElementById('parseTime'), + dataSize: document.getElementById('dataSize'), + clearBtn: document.getElementById('clearBtn'), + pasteBtn: document.getElementById('pasteBtn'), + expandAllBtn: document.getElementById('expandAllBtn'), + collapseAllBtn: document.getElementById('collapseAllBtn'), + copyBtn: document.getElementById('copyBtn'), + downloadBtn: document.getElementById('downloadBtn') +}; + +// 示例数据 +const sampleData = { + cert: { + name: 'CERT', + data: 'MIICFjCCAbugAwIBAgIJAOWoGwJCnlzJMAoGCCqBHM9VAYN1MGcxCzAJBgNVBAYTAkNOMRAwDgYDVQQIDAdCZWlqaW5nMRAwDgYDVQQHDAdIYWlEaWFuMRMwEQYDVQQKDApHTUNlcnQub3JnMR8wHQYDVQQDDBZHTUNlcnQgR00gUm9vdCBDQSAtIDAxMB4XDTI1MTAxNTE1MzQ0NloXDTI2MTAxNTE1MzQ0NlowKjELMAkGA1UEBhMCQ04xGzAZBgNVBAMMEkNOPVNQUklOR0JPT1QtQVNOMTBZMBMGByqGSM49AgEGCCqBHM9VAYItA0IABELWZpP7zz8BfGaF1KBAXPnz6vLlsQbyTRiQGCutV4gfRCHyWWv3UDfuZnsb23Gpkl9tP3I+vLUEbAgSHlBu8UqjgYwwgYkwDAYDVR0TAQH/BAIwADALBgNVHQ8EBAMCBsAwLAYJYIZIAYb4QgENBB8WHUdNQ2VydC5vcmcgU2lnbmVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBQ9PH/j70J99kkwDlX3iBG1hr8PbDAfBgNVHSMEGDAWgBR/Wl47AIRZKg+YvqEObzmVQxBNBzAKBggqgRzPVQGDdQNJADBGAiEAu8CIkPzNuYYCXCJ1amp+mgPKLIdwqkWfw0bkkols8o8CIQDKuntS24AVDeffU3OFrgQThOOhOuVzm7QXdw2jfFzClg==', + description: '证书数据' + }, + integer: { + name: 'INTEGER', + data: 'AgEB', + description: '简单的ASN.1整数,值为1' + }, + sequence: { + name: 'SEQUENCE', + data: 'MAkCAQECAQECAQE=', + description: '包含三个整数的序列' + }, + utf8string: { + name: 'UTF8String', + data: 'DAVIZWxsbw==', + description: 'UTF8编码的字符串"Hello"' + }, + oid: { + name: 'OID', + data: 'BgMqAwQ=', + description: '对象标识符 1.2.3.4' + }, + boolean: { + name: 'BOOLEAN', + data: 'AQH/', + description: '布尔值 TRUE' + }, + null: { + name: 'NULL', + data: 'BQA=', + description: '空值 NULL' + } +}; + +// 初始化 +document.addEventListener('DOMContentLoaded', function() { + initializeEventListeners(); + loadSampleDataOnLoad(); + setupKeyboardShortcuts(); + initializeTooltips(); +}); + +// 初始化事件监听器 +function initializeEventListeners() { + // 解析按钮 + elements.parseBtn.addEventListener('click', parseAsn1); + + // 示例数据按钮 + document.querySelectorAll('.sample-btn').forEach(btn => { + btn.addEventListener('click', function() { + const sampleType = this.dataset.sample; + loadSampleData(sampleType); + }); + }); + + // 清空按钮 + elements.clearBtn.addEventListener('click', clearInput); + + // 粘贴按钮 + elements.pasteBtn.addEventListener('click', pasteFromClipboard); + + // 输入框事件 + elements.asn1Input.addEventListener('input', handleInputChange); + elements.asn1Input.addEventListener('keydown', handleInputKeydown); + + // 编码类型改变 + elements.encodingType.addEventListener('change', handleEncodingChange); + + // 结果操作按钮 + elements.expandAllBtn.addEventListener('click', expandAll); + elements.collapseAllBtn.addEventListener('click', collapseAll); + elements.copyBtn.addEventListener('click', copyResult); + elements.downloadBtn.addEventListener('click', downloadResult); + + // 拖拽文件 + setupDragAndDrop(); +} + +// 加载示例数据 +function loadSampleData(sampleType) { + const sample = sampleData[sampleType]; + if (sample) { + elements.asn1Input.value = sample.data; + elements.asn1Input.focus(); + + // 显示提示信息 + showNotification(`已加载${sample.name}示例:${sample.description}`, 'info'); + + // 自动解析 + setTimeout(() => parseAsn1(), 100); + } +} + +// 页面加载时显示默认示例 +function loadSampleDataOnLoad() { + // 可以选择在页面加载时显示一个默认示例 + // loadSampleData('integer'); +} + +// 解析ASN.1数据 +async function parseAsn1() { + const data = elements.asn1Input.value.trim(); + + if (!data) { + showNotification('请输入ASN.1数据', 'warning'); + elements.asn1Input.focus(); + return; + } + + // 显示加载状态 + setLoading(true); + parseStartTime = performance.now(); + + try { + const response = await fetch('/api/asn1/parse', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + asn1Data: data, + encodingType: elements.encodingType.value, + verbose: elements.verboseCheckbox.checked + }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + currentResult = result; + + if (result.success) { + showSuccess(result); + updateStats(result, data.length); + } else { + showError(result.message); + } + + } catch (error) { + console.error('解析错误:', error); + showError(`网络错误或服务器异常: ${error.message}`); + } finally { + setLoading(false); + } +} + +// 显示加载状态 +function setLoading(loading) { + if (loading) { + elements.parseBtn.innerHTML = '解析中...'; + elements.parseBtn.disabled = true; + } else { + elements.parseBtn.innerHTML = '解析ASN.1'; + elements.parseBtn.disabled = false; + } +} + +// 显示成功结果 +function showSuccess(result) { + let html = `
+ + ${result.message} +
`; + + if (result.rootStructure) { + html += renderStructure(result.rootStructure); + } + + if (result.metadata) { + html += renderMetadata(result.metadata); + } + + if (result.warnings && result.warnings.length > 0) { + html += renderWarnings(result.warnings); + } + + elements.resultContainer.innerHTML = html; + elements.resultContainer.classList.add('fade-in'); + + // 添加交互功能 + addStructureInteraction(); +} + +// 显示错误信息 +function showError(message) { + elements.resultContainer.innerHTML = ` +
+ + ${message} +
+ `; + elements.resultStats.style.display = 'none'; +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 创建通知元素 + const notification = document.createElement('div'); + notification.className = `${type}-message fade-in`; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + max-width: 400px; + z-index: 9999; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + `; + + // 添加图标 + const icon = getIconForType(type); + notification.innerHTML = `${message}`; + + // 添加到页面 + document.body.appendChild(notification); + + // 自动移除 + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + }, 3000); +} + +// 获取类型对应的图标 +function getIconForType(type) { + const icons = { + 'success': 'fa-check-circle', + 'error': 'fa-exclamation-triangle', + 'warning': 'fa-exclamation-circle', + 'info': 'fa-info-circle' + }; + return icons[type] || icons['info']; +} + +// 渲染ASN.1结构 +function renderStructure(structure, level = 0) { + const tagClass = getTagClass(structure.tagClass); + const hasChildren = structure.children && structure.children.length > 0; + + // 使用表格布局,避免折行 + let html = ` +
+
+
+ ${hasChildren ? '' : ''} +
+
+ ${structure.tagClass} + ${structure.tag} + ${structure.tagNumber >= 0 ? `[${structure.tagNumber}]` : ''} +
+
+ ${structure.type} +
+
+ ${formatValue(structure.value, structure.type)} +
+
+ ${structure.length ? `L:${structure.length}` : ''} + ${structure.offset ? `@${structure.offset.toString(16).toUpperCase()}` : ''} +
+
+ `; + + // 详细属性(仅在详细模式下显示) + if (structure.properties && Object.keys(structure.properties).length > 0) { + html += '
'; + html += '
'; + html += '
'; + for (const [key, value] of Object.entries(structure.properties)) { + html += `${key}: ${value}`; + } + html += '
'; + html += '
'; + } + + // 添加子结构 + if (hasChildren) { + html += '
'; + structure.children.forEach(child => { + html += renderStructure(child, level + 1); + }); + html += '
'; + } + + return html; +} + +// 格式化值显示 +function formatValue(value, type) { + if (!value) return ''; + + // 对于不同类型的值进行特殊格式化 + switch (type) { + case 'OCTET STRING': + case 'BIT STRING': + // 对于长数据,提供展开/折叠功能,但保留完整值用于复制 + if (value.length > 50) { + return ` +
+ ${value.substring(0, 47)}... + + + +
+ `; + } + return ` +
+ ${value} + + + +
+ `; + + case 'INTEGER': + return ` +
+ ${value} + + + +
+ `; + + case 'OBJECT IDENTIFIER': + return ` +
+ ${value} + + + +
+ `; + + case 'BOOLEAN': + return ` +
+ ${value} + + + +
+ `; + + case 'NULL': + return ` +
+ ${value} + + + +
+ `; + + default: + return ` +
+ ${escapeHtml(value)} + + + +
+ `; + } +} + +// 渲染属性 +function renderProperties(properties) { + let html = '
'; + for (const [key, value] of Object.entries(properties)) { + html += `
${key}: ${value}
`; + } + html += '
'; + return html; +} + +// 渲染元数据 +function renderMetadata(metadata) { + let html = '

元数据

'; + for (const [key, value] of Object.entries(metadata)) { + html += `
${formatMetadataKey(key)}: ${formatMetadataValue(key, value)}
`; + } + html += '
'; + return html; +} + +// 格式化元数据键 +function formatMetadataKey(key) { + const keyMap = { + 'originalLength': '原始长度', + 'encodingType': '编码类型', + 'encodingTimestamp': '编码时间戳', + 'probableEncoding': '可能编码规则' + }; + return keyMap[key] || key; +} + +// 格式化元数据值 +function formatMetadataValue(key, value) { + switch (key) { + case 'encodingTimestamp': + return new Date(value).toLocaleString(); + case 'originalLength': + return `${value} 字节`; + default: + return value; + } +} + +// 渲染警告信息 +function renderWarnings(warnings) { + let html = '

警告

'; + warnings.forEach(warning => { + html += `
${warning}
`; + }); + html += '
'; + return html; +} + +// 获取标签类样式 +function getTagClass(tagClass) { + const classMap = { + 'UNIVERSAL': 'universal', + 'APPLICATION': 'application', + 'CONTEXT_SPECIFIC': 'context', + 'PRIVATE': 'private' + }; + return classMap[tagClass] || 'unknown'; +} + +// 添加结构交互功能 +function addStructureInteraction() { + // 添加展开/折叠功能 + document.querySelectorAll('.collapsible').forEach(element => { + element.addEventListener('click', function() { + const structureRow = this.closest('.structure-row'); + let childrenWrapper = structureRow.nextElementSibling; + + // 查找子元素容器 + while (childrenWrapper && !childrenWrapper.classList.contains('structure-children-wrapper')) { + childrenWrapper = childrenWrapper.nextElementSibling; + } + + if (childrenWrapper && childrenWrapper.classList.contains('structure-children-wrapper')) { + const isHidden = childrenWrapper.style.display === 'none'; + childrenWrapper.style.display = isHidden ? 'block' : 'none'; + this.textContent = isHidden ? '▼' : '▶'; + this.classList.toggle('collapsed'); + } + }); + }); + + // 添加长值展开/折叠功能(只针对长值) + document.querySelectorAll('.long-value').forEach(element => { + element.style.cursor = 'pointer'; + element.title = '点击展开/折叠完整值'; + element.addEventListener('click', function(e) { + e.stopPropagation(); + toggleLongValue(this); + }); + }); + + // 添加复制按钮功能 + document.querySelectorAll('.copy-btn').forEach(element => { + element.style.cursor = 'pointer'; + element.addEventListener('click', function(e) { + e.stopPropagation(); + const valueToCopy = this.getAttribute('data-value'); + copyToClipboard(valueToCopy); + + // 显示复制成功的反馈 + const originalIcon = this.innerHTML; + this.innerHTML = ''; + this.style.color = '#27ae60'; + + setTimeout(() => { + this.innerHTML = originalIcon; + this.style.color = ''; + }, 2000); + + showNotification('已复制到剪贴板', 'success'); + }); + }); + + // 为类型名添加复制功能 + document.querySelectorAll('.type-name').forEach(element => { + element.style.cursor = 'pointer'; + element.addEventListener('click', function(e) { + e.stopPropagation(); + copyToClipboard(this.textContent); + showNotification('已复制到剪贴板', 'success'); + }); + }); + + + // 添加双击展开/折叠所有子节点 + document.querySelectorAll('.structure-row').forEach(row => { + row.addEventListener('dblclick', function(e) { + if (e.target.classList.contains('collapsible')) return; + + const collapsible = this.querySelector('.collapsible'); + if (collapsible) { + collapsible.click(); + } + }); + }); +} + +// 更新统计信息 +function updateStats(result, inputSize) { + if (parseStartTime) { + const parseTime = (performance.now() - parseStartTime).toFixed(2); + elements.parseTime.textContent = `解析时间: ${parseTime}ms`; + elements.dataSize.textContent = `数据大小: ${inputSize} 字节`; + elements.resultStats.style.display = 'flex'; + } +} + +// 清空输入 +function clearInput() { + elements.asn1Input.value = ''; + elements.resultContainer.innerHTML = '

解析结果将在这里显示...

请在左侧输入ASN.1数据并点击解析按钮

'; + elements.resultStats.style.display = 'none'; + currentResult = null; + elements.asn1Input.focus(); +} + +// 从剪贴板粘贴 +async function pasteFromClipboard() { + try { + const text = await navigator.clipboard.readText(); + elements.asn1Input.value = text; + showNotification('已从剪贴板粘贴', 'success'); + elements.asn1Input.focus(); + } catch (error) { + showNotification('无法访问剪贴板', 'error'); + } +} + +// 复制到剪贴板 +async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + } catch (error) { + // 降级方案 + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } +} + +// 展开所有 +function expandAll() { + document.querySelectorAll('.structure-children-wrapper').forEach(element => { + element.style.display = 'block'; + }); + document.querySelectorAll('.collapsible').forEach(element => { + element.classList.remove('collapsed'); + element.textContent = '▼'; + }); +} + +// 折叠所有 +function collapseAll() { + document.querySelectorAll('.structure-children-wrapper').forEach(element => { + element.style.display = 'none'; + }); + document.querySelectorAll('.collapsible').forEach(element => { + element.classList.add('collapsed'); + element.textContent = '▶'; + }); +} + +// 复制结果 +function copyResult() { + if (!currentResult) { + showNotification('没有可复制的结果', 'warning'); + return; + } + + const resultText = formatResultForCopy(currentResult); + copyToClipboard(resultText); + showNotification('结果已复制到剪贴板', 'success'); +} + +// 格式化结果用于复制 +function formatResultForCopy(result) { + let text = `ASN.1解析结果\n`; + text += `状态: ${result.success ? '成功' : '失败'}\n`; + text += `消息: ${result.message}\n\n`; + + if (result.rootStructure) { + text += formatStructureForCopy(result.rootStructure, 0); + } + + if (result.metadata) { + text += '\n元数据:\n'; + for (const [key, value] of Object.entries(result.metadata)) { + text += ` ${key}: ${value}\n`; + } + } + + return text; +} + +// 格式化结构用于复制 +function formatStructureForCopy(structure, level) { + const indent = ' '.repeat(level); + let text = `${indent}${structure.tag} [${structure.tagNumber}] (${structure.tagClass})\n`; + text += `${indent} 类型: ${structure.type}\n`; + text += `${indent} 值: ${structure.value}\n`; + + if (structure.children) { + structure.children.forEach(child => { + text += formatStructureForCopy(child, level + 1); + }); + } + + return text; +} + +// 下载结果 +function downloadResult() { + if (!currentResult) { + showNotification('没有可下载的结果', 'warning'); + return; + } + + const resultText = formatResultForCopy(currentResult); + const blob = new Blob([resultText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `asn1_parse_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + showNotification('结果已下载', 'success'); +} + +// 处理输入变化 +function handleInputChange() { + // 可以在这里添加实时验证或格式化 + const data = elements.asn1Input.value; + + // 简单的十六进制验证 + if (elements.encodingType.value === 'HEX' && data) { + const hexPattern = /^[0-9a-fA-F\s]*$/; + if (!hexPattern.test(data)) { + showNotification('输入包含非十六进制字符', 'warning'); + } + } +} + +// 处理输入键盘事件 +function handleInputKeydown(e) { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + parseAsn1(); + } else if (e.key === 'Escape') { + clearInput(); + } +} + +// 处理编码类型变化 +function handleEncodingChange() { + // 可以在这里添加编码类型变化时的处理逻辑 + showNotification(`已切换到 ${elements.encodingType.value} 编码`, 'info'); +} + +// 设置键盘快捷键 +function setupKeyboardShortcuts() { + document.addEventListener('keydown', function(e) { + // Ctrl+Enter: 解析 + if (e.ctrlKey && e.key === 'Enter') { + e.preventDefault(); + parseAsn1(); + } + + // Ctrl+L: 清空 + if (e.ctrlKey && e.key === 'l') { + e.preventDefault(); + clearInput(); + } + + // Ctrl+V: 粘贴(当输入框没有焦点时) + if (e.ctrlKey && e.key === 'v' && document.activeElement !== elements.asn1Input) { + e.preventDefault(); + pasteFromClipboard(); + } + }); +} + +// 设置拖拽上传 +function setupDragAndDrop() { + const dropZone = elements.asn1Input; + + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + dropZone.addEventListener(eventName, preventDefaults, false); + document.body.addEventListener(eventName, preventDefaults, false); + }); + + ['dragenter', 'dragover'].forEach(eventName => { + dropZone.addEventListener(eventName, highlight, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + dropZone.addEventListener(eventName, unhighlight, false); + }); + + dropZone.addEventListener('drop', handleDrop, false); +} + +function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); +} + +function highlight(e) { + elements.asn1Input.style.borderColor = 'var(--primary-color)'; + elements.asn1Input.style.backgroundColor = '#f0f8ff'; +} + +function unhighlight(e) { + elements.asn1Input.style.borderColor = ''; + elements.asn1Input.style.backgroundColor = ''; +} + +async function handleDrop(e) { + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + try { + const text = await file.text(); + elements.asn1Input.value = text; + showNotification(`已加载文件: ${file.name}`, 'success'); + } catch (error) { + showNotification('文件读取失败', 'error'); + } + } +} + +// 展开/折叠长值 +function toggleLongValue(element) { + const fullValue = element.getAttribute('data-full-value'); + const isExpanded = element.classList.contains('expanded'); + + if (isExpanded) { + // 折叠 + if (fullValue.length > 50) { + element.innerHTML = fullValue.substring(0, 47) + '...'; + } + element.classList.remove('expanded'); + element.title = '点击展开完整值'; + } else { + // 展开 + element.innerHTML = `${fullValue}`; + element.classList.add('expanded'); + element.title = '点击折叠值'; + } +} + +// 初始化工具提示 +function initializeTooltips() { + // 为按钮添加工具提示 + const tooltips = { + clearBtn: '清空输入 (Ctrl+L)', + pasteBtn: '从剪贴板粘贴 (Ctrl+V)', + expandAllBtn: '展开所有节点', + collapseAllBtn: '折叠所有节点', + copyBtn: '复制结果到剪贴板', + downloadBtn: '下载结果为文件' + }; + + Object.entries(tooltips).forEach(([id, tooltip]) => { + const element = elements[id]; + if (element) { + element.title = tooltip; + element.classList.add('tooltip'); + } + }); +} + +// HTML转义 +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); +} + +// API调用示例(用于开发者) +window.Asn1ParserAPI = { + parse: parseAsn1, + loadSample: loadSampleData, + clear: clearInput, + getResult: () => currentResult, + getVersion: () => '1.0.0' +}; \ No newline at end of file diff --git a/springboot-asn1/src/main/resources/static/style.css b/springboot-asn1/src/main/resources/static/style.css new file mode 100644 index 0000000..38292c2 --- /dev/null +++ b/springboot-asn1/src/main/resources/static/style.css @@ -0,0 +1,1119 @@ +/* 全局样式重置 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* 根元素样式 */ +:root { + --primary-color: #667eea; + --secondary-color: #764ba2; + --success-color: #27ae60; + --error-color: #e74c3c; + --warning-color: #f39c12; + --info-color: #3498db; + --dark-bg: #2c3e50; + --light-bg: #ecf0f1; + --border-color: #bdc3c7; + --text-primary: #2c3e50; + --text-secondary: #7f8c8d; + --shadow-light: 0 2px 10px rgba(0,0,0,0.1); + --shadow-medium: 0 5px 20px rgba(0,0,0,0.15); + --shadow-heavy: 0 10px 30px rgba(0,0,0,0.2); + --border-radius: 8px; + --transition: all 0.3s ease; +} + +/* 基础样式 */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: var(--text-primary); + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + min-height: 100vh; +} + +/* 容器 */ +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +/* 头部样式 */ +header { + text-align: center; + margin-bottom: 40px; + color: white; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.header-content h1 { + font-size: 2.5rem; + margin-bottom: 10px; + text-shadow: 2px 2px 4px rgba(0,0,0,0.3); + font-weight: 700; +} + +.header-content p { + font-size: 1.1rem; + opacity: 0.9; + font-weight: 300; +} + +.header-links { + display: flex; + gap: 15px; +} + +/* 网格布局 */ +.grid-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + margin-bottom: 40px; +} + +/* 区块样式 */ +section { + background: white; + border-radius: var(--border-radius); + padding: 30px; + box-shadow: var(--shadow-medium); + transition: var(--transition); +} + +section:hover { + box-shadow: var(--shadow-heavy); + transform: translateY(-2px); +} + +h2 { + color: var(--text-primary); + margin-bottom: 25px; + font-size: 1.5rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; +} + +h2 i { + color: var(--primary-color); +} + +/* 按钮样式 */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: none; + border-radius: var(--border-radius); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: var(--transition); + white-space: nowrap; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + color: white; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-light); +} + +.btn-outline { + background: transparent; + color: white; + border: 2px solid white; +} + +.btn-outline:hover { + background: white; + color: var(--primary-color); +} + +.btn-secondary { + background: var(--light-bg); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--border-color); +} + +.btn-small { + padding: 5px 10px; + font-size: 0.9rem; +} + +/* 输入区域样式 */ +.encoding-selector { + margin-bottom: 20px; +} + +.encoding-selector label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--text-primary); +} + +.encoding-selector select { + width: 100%; + padding: 12px; + border: 2px solid var(--border-color); + border-radius: var(--border-radius); + font-size: 1rem; + background: white; + transition: var(--transition); +} + +.encoding-selector select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* 文本区域样式 */ +.textarea-container { + margin-bottom: 20px; +} + +.textarea-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.textarea-header label { + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.input-actions { + display: flex; + gap: 5px; +} + +#asn1Input { + width: 100%; + height: 200px; + padding: 15px; + border: 2px solid var(--border-color); + border-radius: var(--border-radius); + font-family: 'Courier New', monospace; + font-size: 14px; + resize: vertical; + background: #f8f9fa; + transition: var(--transition); +} + +#asn1Input:focus { + outline: none; + border-color: var(--primary-color); + background: white; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* 示例数据样式 */ +.sample-data { + margin-top: 20px; + padding: 15px; + background: #f8f9fa; + border-radius: var(--border-radius); + border-left: 4px solid var(--primary-color); +} + +.sample-data h3 { + font-size: 1rem; + margin-bottom: 15px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.sample-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.sample-btn { + background: white; + border: 1px solid var(--border-color); + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: var(--transition); + display: flex; + align-items: center; + gap: 6px; +} + +.sample-btn:hover { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); + transform: translateY(-1px); +} + +/* 选项样式 */ +.options { + margin-bottom: 25px; +} + +.checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + font-size: 1rem; + gap: 10px; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +/* 解析按钮样式 */ +.parse-btn { + width: 100%; + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + color: white; + border: none; + padding: 18px 30px; + border-radius: var(--border-radius); + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + position: relative; + overflow: hidden; +} + +.parse-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4); +} + +.parse-btn:active { + transform: translateY(0); +} + +.parse-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +/* 结果区域样式 */ +.result-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding: 15px; + background: #f8f9fa; + border-radius: var(--border-radius); + border: 1px solid var(--border-color); +} + +.result-actions { + display: flex; + gap: 5px; +} + +.result-stats { + display: flex; + gap: 15px; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.result-container { + min-height: 400px; + max-height: 600px; + overflow-y: auto; + border: 2px solid var(--border-color); + border-radius: var(--border-radius); + padding: 20px; + background: #f8f9fa; +} + +.placeholder { + text-align: center; + color: var(--text-secondary); + padding: 50px 20px; +} + +.placeholder i { + font-size: 3rem; + margin-bottom: 20px; + opacity: 0.5; +} + +.placeholder p { + margin-bottom: 10px; +} + +/* ASN.1结构树样式 */ +.structure-tree { + font-family: 'Courier New', monospace; + font-size: 14px; +} + +.structure-item { + margin: 8px 0; + padding: 12px 15px; + border-radius: var(--border-radius); + background: white; + border-left: 4px solid var(--primary-color); + transition: var(--transition); + position: relative; +} + +.structure-item:hover { + box-shadow: var(--shadow-light); +} + +.structure-item.error { + border-left-color: var(--error-color); + background: #fdf2f2; +} + +.structure-item.success { + border-left-color: var(--success-color); + background: #f2fdf5; +} + +.structure-header { + font-weight: bold; + color: var(--text-primary); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.structure-details { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.structure-details div { + margin-bottom: 4px; + display: flex; + align-items: flex-start; + gap: 5px; +} + +.structure-details strong { + color: var(--text-primary); + min-width: 60px; +} + +/* 标签样式 */ +.tag-info { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.tag-universal { + background: #e3f2fd; + color: #1976d2; +} + +.tag-application { + background: #f3e5f5; + color: #7b1fa2; +} + +.tag-context { + background: #e8f5e8; + color: #388e3c; +} + +.tag-private { + background: #fff3e0; + color: #f57c00; +} + +/* 消息样式 */ +.error-message, .success-message, .warning-message, .info-message { + padding: 15px 20px; + border-radius: var(--border-radius); + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; +} + +.error-message { + background: #fdf2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +.success-message { + background: #f2fdf5; + border: 1px solid #bbf7d0; + color: #166534; +} + +.warning-message { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; +} + +.info-message { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1e40af; +} + +/* 文档区域样式 */ +.docs-section { + background: white; + border-radius: var(--border-radius); + padding: 40px; + margin-bottom: 40px; + box-shadow: var(--shadow-medium); +} + +.docs-section h2 { + text-align: center; + margin-bottom: 30px; + font-size: 1.8rem; +} + +.docs-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 30px; +} + +.doc-card { + padding: 25px; + background: #f8f9fa; + border-radius: var(--border-radius); + border-left: 4px solid var(--primary-color); + transition: var(--transition); +} + +.doc-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-light); +} + +.doc-card h3 { + color: var(--text-primary); + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; +} + +.doc-card ul { + list-style: none; + padding: 0; +} + +.doc-card li { + margin-bottom: 8px; + padding-left: 20px; + position: relative; +} + +.doc-card li::before { + content: "•"; + color: var(--primary-color); + font-weight: bold; + position: absolute; + left: 0; +} + +.doc-card code { + background: var(--text-primary); + color: white; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9rem; +} + +/* 底部样式 */ +footer { + text-align: center; + color: white; + padding: 30px 20px; + opacity: 0.9; +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; +} + +.footer-info p { + margin-bottom: 5px; +} + +.footer-links { + display: flex; + gap: 20px; +} + +.footer-links a { + color: white; + text-decoration: none; + transition: var(--transition); +} + +.footer-links a:hover { + text-decoration: underline; +} + +/* 加载动画 */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255,255,255,0.3); + border-top: 3px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 10px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 响应式设计 */ +@media (max-width: 1024px) { + .grid-container { + grid-template-columns: 1fr; + } + + .docs-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .container { + padding: 15px; + } + + header h1 { + font-size: 2rem; + } + + header p { + font-size: 1rem; + } + + .header-links { + flex-wrap: wrap; + justify-content: center; + } + + section { + padding: 20px; + } + + .sample-buttons { + justify-content: center; + } + + .result-toolbar { + flex-direction: column; + gap: 15px; + align-items: stretch; + } + + .result-actions { + justify-content: center; + } + + .result-stats { + justify-content: center; + } + + .footer-content { + flex-direction: column; + gap: 20px; + text-align: center; + } + + .footer-links { + justify-content: center; + } +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: var(--primary-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--secondary-color); +} + +/* 工具提示样式 */ +.tooltip { + position: relative; + cursor: help; +} + +.tooltip::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: var(--dark-bg); + color: white; + padding: 5px 10px; + border-radius: 4px; + font-size: 0.8rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; +} + +.tooltip:hover::after { + opacity: 1; +} + +/* 展开折叠动画 */ +.collapsible { + cursor: pointer; + user-select: none; + color: var(--primary-color); + font-weight: bold; + min-width: 20px; + display: inline-block; + transition: all 0.3s ease; +} + +.collapsible.collapsed { + transform: rotate(-90deg); +} + +/* 表格式布局 - 新的紧凑样式 */ +.structure-row { + display: flex; + align-items: center; + min-height: 22px; + border-bottom: 1px solid #f0f0f0; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 11px; + white-space: nowrap; +} + +.structure-row:hover { + background: #f8f8ff; +} + +.structure-indent { + flex-shrink: 0; +} + +.structure-toggle { + flex-shrink: 0; + width: 20px; + text-align: center; +} + +.structure-tag-info { + flex-shrink: 0; + min-width: 120px; + display: flex; + align-items: center; + gap: 4px; +} + +.structure-type { + flex-shrink: 0; + min-width: 100px; +} + +.structure-value { + flex: 1; + min-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 8px; +} + +.structure-meta { + flex-shrink: 0; + min-width: 80px; + text-align: right; + padding-right: 8px; +} + +/* 子元素容器 */ +.structure-children-wrapper { + background: #fafafa; + border-left: 2px solid #e0e0e0; + margin-left: 20px; + padding-left: 15px; + margin-top: 10px; +} + +/* 属性行样式 */ +.structure-properties-row { + display: flex; + background: #f5f5f5; + border-bottom: 1px solid #e8e8e8; + min-height: 18px; +} + +.properties-content { + flex: 1; + padding: 2px 8px; + display: flex; + align-items: center; + gap: 12px; + font-size: 10px; +} + +/* 紧凑标签样式 */ +.tag-mini { + display: inline-block; + padding: 1px 4px; + border-radius: 2px; + font-size: 9px; + font-weight: bold; + text-transform: uppercase; + min-width: 50px; + text-align: center; +} + +.tag-name { + color: #333; + font-weight: bold; + font-size: 10px; +} + +.tag-num { + color: #666; + font-size: 9px; +} + +.type-name { + color: #0066cc; + font-weight: bold; + font-size: 11px; +} + +.value-text { + color: #008800; + font-family: 'Consolas', monospace; +} + +.len-info, .off-info { + font-size: 9px; + color: #888; + margin-left: 4px; +} + +.off-info { + color: #aa6600; +} + +.prop-item { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.prop-item em { + color: #666; + font-style: normal; + font-size: 9px; +} + +.prop-item code { + color: #333; + background: #e8e8e8; + padding: 1px 3px; + border-radius: 2px; + font-size: 9px; +} + +/* 保持旧的紧凑样式作为备用 */ +.structure-item.compact { + margin: 1px 0; + padding: 3px 8px; + background: #fafafa; + border-radius: 2px; + border-left: 2px solid var(--primary-color); + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; + line-height: 1.3; + min-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.type-separator, .value-separator { + color: #666; + font-weight: normal; +} + +.type-value { + color: #0066cc; + font-weight: bold; +} + +.value-display { + color: #008800; + word-break: break-all; +} + +.length-info { + color: #888; + font-size: 11px; + margin-left: 5px; +} + +.offset-info { + color: #aa6600; + font-size: 11px; + margin-left: 5px; +} + +.no-children { + display: inline-block; + width: 20px; + margin-right: 8px; +} + +.structure-properties.compact { + margin-top: 5px; + padding: 4px 8px; + background: #f5f5f5; + border-radius: 3px; + font-size: 11px; + color: #666; +} + +.property-item { + margin-right: 10px; + white-space: nowrap; +} + +.property-item em { + color: #888; + font-style: normal; +} + +.property-item code { + color: #333; + background: #eee; + padding: 1px 4px; + border-radius: 2px; +} + +/* 标签数字样式 */ +.tag-number { + color: #9966cc; + font-weight: bold; + font-size: 11px; +} + +/* 代码块样式 */ +code { + background: #f8f8f8; + padding: 2px 4px; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 12px; + color: #333; +} + +/* 布尔值样式 */ +.boolean-value { + font-weight: bold; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; +} + +.boolean-value.true { + background: #d4edda; + color: #155724; +} + +.boolean-value.false { + background: #f8d7da; + color: #721c24; +} + +/* NULL值样式 */ +.null-value { + color: #888; + font-style: italic; +} + +/* 高亮搜索结果 */ +.highlight { + background: yellow; + padding: 2px 4px; + border-radius: 3px; +} + +/* 值容器 */ +.value-container { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +/* 长值展开状态 */ +.long-value { + color: #0066cc; + text-decoration: underline dotted; + transition: all 0.2s ease; + cursor: pointer; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.long-value:hover { + color: #004499; + text-decoration: underline solid; +} + +.long-value.expanded { + color: #008800; + text-decoration: none; + word-break: break-all; + white-space: pre-wrap; +} + +.long-value.expanded code { + background: #f0f8ff; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid #d0e8ff; + display: block; + margin: 2px 0; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 11px; + line-height: 1.4; +} + +/* 复制按钮 */ +.copy-btn { + color: #666; + cursor: pointer; + opacity: 0.6; + transition: all 0.2s ease; + font-size: 12px; + padding: 2px 4px; + border-radius: 3px; + background: transparent; + border: 1px solid transparent; + flex-shrink: 0; +} + +.copy-btn:hover { + opacity: 1; + color: var(--primary-color); + background: rgba(102, 126, 234, 0.1); + border-color: rgba(102, 126, 234, 0.3); +} + +.copy-btn:active { + transform: scale(0.95); +} + +.copy-btn.success { + color: #27ae60; + opacity: 1; +} + +/* 键盘快捷键显示 */ +.kbd { + background: var(--light-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 2px 6px; + font-family: monospace; + font-size: 0.9em; + box-shadow: 0 2px 0 var(--border-color); +} + +/* 动画效果 */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.fade-in { + animation: fadeIn 0.5s ease-out; +} + +@keyframes slideIn { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} + +.slide-in { + animation: slideIn 0.3s ease-out; +} \ No newline at end of file From dd0427d9fae2e53a7ac4ca8e8f6a8c777153d7bd Mon Sep 17 00:00:00 2001 From: yuoon Date: Mon, 20 Oct 2025 21:43:34 +0800 Subject: [PATCH 08/22] multi-port --- springboot-multi-port/README.md | 162 ++++++ springboot-multi-port/pom.xml | 74 +++ .../multiport/MultiPortApplication.java | 16 + .../multiport/annotation/AdminApi.java | 15 + .../example/multiport/annotation/UserApi.java | 15 + .../example/multiport/config/CorsConfig.java | 72 +++ .../config/DualPortConfiguration.java | 56 ++ .../multiport/config/DualPortProperties.java | 24 + .../config/WebInterceptorConfig.java | 38 ++ .../multiport/config/WebMvcConfig.java | 26 + .../controller/AdminProductController.java | 164 ++++++ .../controller/AdminStatisticsController.java | 88 +++ .../controller/HealthController.java | 92 +++ .../controller/UserCartController.java | 136 +++++ .../controller/UserProductController.java | 70 +++ .../exception/GlobalExceptionHandler.java | 120 ++++ .../interceptor/PortAwareInterceptor.java | 98 ++++ .../multiport/logging/PortAwareLogger.java | 136 +++++ .../example/multiport/model/ApiResponse.java | 42 ++ .../com/example/multiport/model/CartItem.java | 31 + .../multiport/model/ErrorResponse.java | 30 + .../com/example/multiport/model/Product.java | 35 ++ .../monitoring/DualPortHealthIndicator.java | 80 +++ .../multiport/service/CartService.java | 122 ++++ .../multiport/service/PortService.java | 145 +++++ .../multiport/service/ProductService.java | 188 +++++++ .../src/main/resources/application.yml | 37 ++ .../src/main/resources/static/index.html | 264 +++++++++ .../src/main/resources/static/js/api.js | 267 +++++++++ .../src/main/resources/static/js/app.js | 173 ++++++ .../static/js/pages/admin-products.js | 530 ++++++++++++++++++ .../static/js/pages/admin-statistics.js | 210 +++++++ .../resources/static/js/pages/admin-system.js | 400 +++++++++++++ .../resources/static/js/pages/user-cart.js | 430 ++++++++++++++ .../static/js/pages/user-products.js | 440 +++++++++++++++ .../resources/static/js/pages/user-search.js | 203 +++++++ .../src/main/resources/static/js/state.js | 261 +++++++++ .../src/main/resources/static/js/ui.js | 342 +++++++++++ 38 files changed, 5632 insertions(+) create mode 100644 springboot-multi-port/README.md create mode 100644 springboot-multi-port/pom.xml create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/MultiPortApplication.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/annotation/AdminApi.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/annotation/UserApi.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/config/CorsConfig.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/config/DualPortConfiguration.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/config/DualPortProperties.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/config/WebInterceptorConfig.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/config/WebMvcConfig.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/controller/AdminProductController.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/controller/AdminStatisticsController.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/controller/HealthController.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/controller/UserCartController.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/controller/UserProductController.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/exception/GlobalExceptionHandler.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/interceptor/PortAwareInterceptor.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/logging/PortAwareLogger.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/model/ApiResponse.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/model/CartItem.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/model/ErrorResponse.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/model/Product.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/monitoring/DualPortHealthIndicator.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/service/CartService.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/service/PortService.java create mode 100644 springboot-multi-port/src/main/java/com/example/multiport/service/ProductService.java create mode 100644 springboot-multi-port/src/main/resources/application.yml create mode 100644 springboot-multi-port/src/main/resources/static/index.html create mode 100644 springboot-multi-port/src/main/resources/static/js/api.js create mode 100644 springboot-multi-port/src/main/resources/static/js/app.js create mode 100644 springboot-multi-port/src/main/resources/static/js/pages/admin-products.js create mode 100644 springboot-multi-port/src/main/resources/static/js/pages/admin-statistics.js create mode 100644 springboot-multi-port/src/main/resources/static/js/pages/admin-system.js create mode 100644 springboot-multi-port/src/main/resources/static/js/pages/user-cart.js create mode 100644 springboot-multi-port/src/main/resources/static/js/pages/user-products.js create mode 100644 springboot-multi-port/src/main/resources/static/js/pages/user-search.js create mode 100644 springboot-multi-port/src/main/resources/static/js/state.js create mode 100644 springboot-multi-port/src/main/resources/static/js/ui.js diff --git a/springboot-multi-port/README.md b/springboot-multi-port/README.md new file mode 100644 index 0000000..a156796 --- /dev/null +++ b/springboot-multi-port/README.md @@ -0,0 +1,162 @@ +# Spring Boot 双端口应用示例 + +这是一个实现双端口监听的 Spring Boot 应用,能够同时提供用户端和管理端服务。 + +## 功能特性 + +- ✅ 双端口监听(8082用户端 + 8083管理端) +- ✅ 基于注解的API路由分离 +- ✅ 端口感知的拦截器和异常处理 +- ✅ 分端口的日志记录 +- ✅ 健康检查和监控 + +## 项目结构 + +``` +springboot-multi-port/ +├── src/ +│ ├── main/ +│ │ ├── java/com/example/multiport/ +│ │ │ ├── annotation/ # API注解 +│ │ │ ├── config/ # 配置类 +│ │ │ ├── controller/ # 控制器 +│ │ │ ├── exception/ # 异常处理 +│ │ │ ├── interceptor/ # 拦截器 +│ │ │ ├── logging/ # 日志记录 +│ │ │ ├── model/ # 数据模型 +│ │ │ ├── monitoring/ # 监控组件 +│ │ │ ├── service/ # 服务层 +│ │ │ └── MultiPortApplication.java +│ │ └── resources/ +│ │ └── application.yml +│ └── test/ # 测试类 +├── docker-compose.yml +├── Dockerfile +├── nginx/ +│ └── conf.d/ +├── k8s/ +│ └── deployment.yaml +└── pom.xml +``` + +## 快速开始 + +### 本地运行 + +1. **克隆项目** + ```bash + git clone + cd springboot-multi-port + ``` + +2. **构建项目** + ```bash + mvn clean package + ``` + +3. **运行应用** + ```bash + java -jar target/springboot-multi-port-1.0.0.jar + ``` + +4. **测试接口** + + **用户端接口 (端口8082):** + ```bash + # 获取商品列表 + curl http://localhost:8082/api/user/products + + # 搜索商品 + curl http://localhost:8082/api/user/products/search?keyword=iPhone + + # 健康检查 + curl http://localhost:8082/health/user + ``` + + **管理端接口 (端口8083):** + ```bash + # 获取所有商品(包括下架的) + curl http://localhost:8083/api/admin/products + + # 获取统计信息 + curl http://localhost:8083/api/admin/statistics/products + + # 健康检查 + curl http://localhost:8083/health/admin + ``` + +## API接口文档 + +### 用户端API (端口8082) + +#### 商品相关 + +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | `/api/user/products` | 获取所有上架商品 | +| GET | `/api/user/products/{id}` | 获取商品详情 | +| GET | `/api/user/products/category/{category}` | 按分类获取商品 | +| GET | `/api/user/products/search` | 搜索商品 | + +#### 购物车相关 + +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | `/api/user/cart/{userId}` | 获取用户购物车 | +| POST | `/api/user/cart/{userId}/items` | 添加商品到购物车 | +| PUT | `/api/user/cart/{userId}/items/{cartItemId}` | 更新购物车商品数量 | +| DELETE | `/api/user/cart/{userId}/items/{cartItemId}` | 从购物车移除商品 | +| DELETE | `/api/user/cart/{userId}` | 清空购物车 | +| GET | `/api/user/cart/{userId}/summary` | 获取购物车统计信息 | + +### 管理端API (端口8083) + +#### 商品管理 + +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | `/api/admin/products` | 获取所有商品 | +| GET | `/api/admin/products/{id}` | 获取商品详情 | +| POST | `/api/admin/products` | 创建商品 | +| PUT | `/api/admin/products/{id}` | 更新商品 | +| DELETE | `/api/admin/products/{id}` | 删除商品 | +| PATCH | `/api/admin/products/{id}/status` | 更新商品状态 | +| PATCH | `/api/admin/products/batch/status` | 批量更新商品状态 | + +#### 统计分析 + +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | `/api/admin/statistics/products` | 商品统计信息 | +| GET | `/api/admin/statistics/overview` | 系统概览 | +| GET | `/api/admin/statistics/ports` | 端口状态 | + +## 配置说明 + +### application.yml + +```yaml +# 双端口配置 +dual: + port: + user-port: 8082 # 用户端端口 + admin-port: 8083 # 管理端端口 + +# Spring配置 +spring: + application: + name: multi-port-application + +# 日志配置 +logging: + level: + com.example.multiport: DEBUG +``` + +## 核心技术 + +- **双端口配置**: 通过`TomcatServletWebServerFactory`配置多个Connector +- **API路由分离**: 使用自定义注解`@UserApi`和`@AdminApi`配合`WebMvcConfigurer` +- **端口感知拦截器**: 根据请求端口执行不同的处理逻辑 +- **分端口日志**: 为不同端口使用独立的Logger实例 +- **健康检查**: 为每个端口提供独立的健康检查端点 \ No newline at end of file diff --git a/springboot-multi-port/pom.xml b/springboot-multi-port/pom.xml new file mode 100644 index 0000000..4b8040c --- /dev/null +++ b/springboot-multi-port/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + com.example + springboot-multi-port + 1.0.0 + jar + + Spring Boot Multi-Port Application + 双端口Spring Boot应用示例 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + + 11 + 11 + 11 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.projectlombok + lombok + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/MultiPortApplication.java b/springboot-multi-port/src/main/java/com/example/multiport/MultiPortApplication.java new file mode 100644 index 0000000..dd40f9f --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/MultiPortApplication.java @@ -0,0 +1,16 @@ +package com.example.multiport; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 多端口Spring Boot应用主类 + * 同时监听8082(用户端)和8083(管理端)端口 + */ +@SpringBootApplication +public class MultiPortApplication { + + public static void main(String[] args) { + SpringApplication.run(MultiPortApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/annotation/AdminApi.java b/springboot-multi-port/src/main/java/com/example/multiport/annotation/AdminApi.java new file mode 100644 index 0000000..97a4f53 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/annotation/AdminApi.java @@ -0,0 +1,15 @@ +package com.example.multiport.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 管理端API注解 + * 标记该Controller为管理端API + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface AdminApi { +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/annotation/UserApi.java b/springboot-multi-port/src/main/java/com/example/multiport/annotation/UserApi.java new file mode 100644 index 0000000..5867ae7 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/annotation/UserApi.java @@ -0,0 +1,15 @@ +package com.example.multiport.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 用户端API注解 + * 标记该Controller为用户端API + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserApi { +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/config/CorsConfig.java b/springboot-multi-port/src/main/java/com/example/multiport/config/CorsConfig.java new file mode 100644 index 0000000..d10b524 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/config/CorsConfig.java @@ -0,0 +1,72 @@ +package com.example.multiport.config; + +import com.example.multiport.service.PortService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.Arrays; +import java.util.Collections; + +/** + * CORS跨域配置 + * 允许前端跨域访问不同端口的API + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Autowired + private PortService portService; + + @Override + public void addCorsMappings(CorsRegistry registry) { + String[] allowedOrigins = portService.getAllowedOrigins(); + + registry.addMapping("/api/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + + registry.addMapping("/health/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 允许的源 + configuration.setAllowedOrigins(Arrays.asList(portService.getAllowedOrigins())); + + // 允许的HTTP方法 + configuration.setAllowedMethods(Arrays.asList( + "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" + )); + + // 允许的请求头 + configuration.setAllowedHeaders(Collections.singletonList("*")); + + // 允许发送Cookie + configuration.setAllowCredentials(true); + + // 预检请求的缓存时间 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + source.registerCorsConfiguration("/health/**", configuration); + + return source; + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/config/DualPortConfiguration.java b/springboot-multi-port/src/main/java/com/example/multiport/config/DualPortConfiguration.java new file mode 100644 index 0000000..9b9bc43 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/config/DualPortConfiguration.java @@ -0,0 +1,56 @@ +package com.example.multiport.config; + +import lombok.extern.slf4j.Slf4j; +import org.apache.catalina.connector.Connector; +import org.apache.coyote.http11.Http11NioProtocol; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 双端口配置类 + * 配置Tomcat监听两个不同端口 + */ +@Slf4j +@Configuration +public class DualPortConfiguration { + + @Bean + public WebServerFactoryCustomizer + webServerFactoryCustomizer(DualPortProperties properties) { + + return factory -> { + if (factory instanceof TomcatServletWebServerFactory) { + TomcatServletWebServerFactory tomcatFactory = (TomcatServletWebServerFactory)factory; + // 添加管理端端口连接器 + tomcatFactory.addAdditionalTomcatConnectors( + createAdminConnector(properties.getAdminPort()) + ); + log.info("配置双端口:用户端端口={}, 管理端端口={}", + properties.getUserPort(), properties.getAdminPort()); + } + }; + } + + /** + * 创建管理端端口连接器 + */ + private Connector createAdminConnector(int port) { + Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol"); + connector.setPort(port); + connector.setProperty("connectionTimeout", "20000"); + connector.setProperty("maxThreads", "200"); + connector.setProperty("minSpareThreads", "10"); + + // 设置协议处理器 + Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler(); + protocol.setConnectionTimeout(20000); + protocol.setMaxThreads(200); + protocol.setMinSpareThreads(10); + + log.info("创建管理端连接器,端口: {}", port); + return connector; + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/config/DualPortProperties.java b/springboot-multi-port/src/main/java/com/example/multiport/config/DualPortProperties.java new file mode 100644 index 0000000..085a1c0 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/config/DualPortProperties.java @@ -0,0 +1,24 @@ +package com.example.multiport.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 双端口配置属性类 + */ +@Data +@Component +@ConfigurationProperties(prefix = "dual.port") +public class DualPortProperties { + + /** + * 用户端端口,默认8082 + */ + private int userPort = 8082; + + /** + * 管理端端口,默认8083 + */ + private int adminPort = 8083; +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/config/WebInterceptorConfig.java b/springboot-multi-port/src/main/java/com/example/multiport/config/WebInterceptorConfig.java new file mode 100644 index 0000000..fe2a536 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/config/WebInterceptorConfig.java @@ -0,0 +1,38 @@ +package com.example.multiport.config; + +import com.example.multiport.interceptor.PortAwareInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web拦截器配置 + */ +@Configuration +@RequiredArgsConstructor +public class WebInterceptorConfig implements WebMvcConfigurer { + + private final PortAwareInterceptor portAwareInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(portAwareInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/actuator/**", + "/error", + "/favicon.ico", + "/css/**", + "/js/**", + "/images/**", + "/static/**", + "/", + "/index.html", + "/test", + "/admin", + "/shop", + "/dashboard" + ); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/config/WebMvcConfig.java b/springboot-multi-port/src/main/java/com/example/multiport/config/WebMvcConfig.java new file mode 100644 index 0000000..1b07ba7 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/config/WebMvcConfig.java @@ -0,0 +1,26 @@ +package com.example.multiport.config; + +import com.example.multiport.annotation.AdminApi; +import com.example.multiport.annotation.UserApi; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC配置类 + * 为不同API类型配置路径前缀 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + // 为用户端Controller添加/api/user前缀 + configurer.addPathPrefix("/api/user", + cls -> cls.isAnnotationPresent(UserApi.class)); + + // 为管理端Controller添加/api/admin前缀 + configurer.addPathPrefix("/api/admin", + cls -> cls.isAnnotationPresent(AdminApi.class)); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/controller/AdminProductController.java b/springboot-multi-port/src/main/java/com/example/multiport/controller/AdminProductController.java new file mode 100644 index 0000000..1737c11 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/controller/AdminProductController.java @@ -0,0 +1,164 @@ +package com.example.multiport.controller; + +import com.example.multiport.annotation.AdminApi; +import com.example.multiport.model.ApiResponse; +import com.example.multiport.model.Product; +import com.example.multiport.service.ProductService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +/** + * 管理端商品控制器 + * 提供商品管理功能 + */ +@Slf4j +@RestController +@RequestMapping("/products") +@RequiredArgsConstructor +@AdminApi +public class AdminProductController { + + private final ProductService productService; + + /** + * 获取所有商品(包括上架和下架) + */ + @GetMapping + public ApiResponse> getAllProducts() { + log.info("管理端获取所有商品列表"); + List products = productService.getAllProductsForAdmin(); + return ApiResponse.success("获取商品列表成功", products); + } + + /** + * 根据ID获取商品详情 + */ + @GetMapping("/{id}") + public ApiResponse getProductById(@PathVariable Long id) { + log.info("管理端获取商品详情,ID: {}", id); + Product product = productService.getProductById(id); + + if (product == null) { + return ApiResponse.error(404, "商品不存在"); + } + + return ApiResponse.success("获取商品详情成功", product); + } + + /** + * 创建商品 + */ + @PostMapping + public ApiResponse createProduct(@Valid @RequestBody Product product) { + log.info("管理端创建商品: {}", product.getName()); + + // 基本验证 + if (product.getName() == null || product.getName().trim().isEmpty()) { + return ApiResponse.error(400, "商品名称不能为空"); + } + + if (product.getPrice() == null || product.getPrice().doubleValue() <= 0) { + return ApiResponse.error(400, "商品价格必须大于0"); + } + + if (product.getStock() == null || product.getStock() < 0) { + return ApiResponse.error(400, "商品库存不能为负数"); + } + + Product createdProduct = productService.createProduct(product); + return ApiResponse.success("创建商品成功", createdProduct); + } + + /** + * 更新商品 + */ + @PutMapping("/{id}") + public ApiResponse updateProduct(@PathVariable Long id, + @Valid @RequestBody Product product) { + log.info("管理端更新商品,ID: {}, 名称: {}", id, product.getName()); + + // 基本验证 + if (product.getName() != null && product.getName().trim().isEmpty()) { + return ApiResponse.error(400, "商品名称不能为空"); + } + + if (product.getPrice() != null && product.getPrice().doubleValue() <= 0) { + return ApiResponse.error(400, "商品价格必须大于0"); + } + + if (product.getStock() != null && product.getStock() < 0) { + return ApiResponse.error(400, "商品库存不能为负数"); + } + + Product updatedProduct = productService.updateProduct(id, product); + if (updatedProduct == null) { + return ApiResponse.error(404, "商品不存在"); + } + + return ApiResponse.success("更新商品成功", updatedProduct); + } + + /** + * 删除商品 + */ + @DeleteMapping("/{id}") + public ApiResponse deleteProduct(@PathVariable Long id) { + log.info("管理端删除商品,ID: {}", id); + + boolean deleted = productService.deleteProduct(id); + if (!deleted) { + return ApiResponse.error(404, "商品不存在"); + } + + return ApiResponse.success("删除商品成功"); + } + + /** + * 商品上架/下架 + */ + @PatchMapping("/{id}/status") + public ApiResponse updateProductStatus(@PathVariable Long id, + @RequestBody Map request) { + Boolean status = request.get("status"); + log.info("管理端更新商品状态,ID: {}, 状态: {}", id, status ? "上架" : "下架"); + + Product product = productService.getProductById(id); + if (product == null) { + return ApiResponse.error(404, "商品不存在"); + } + + product.setStatus(status); + Product updatedProduct = productService.updateProduct(id, product); + + return ApiResponse.success("更新商品状态成功", updatedProduct); + } + + /** + * 批量更新商品状态 + */ + @PatchMapping("/batch/status") + public ApiResponse batchUpdateStatus(@RequestBody Map request) { + @SuppressWarnings("unchecked") + List ids = (List) request.get("ids"); + Boolean status = (Boolean) request.get("status"); + + log.info("管理端批量更新商品状态,商品数量: {}, 状态: {}", ids.size(), status ? "上架" : "下架"); + + int successCount = 0; + for (Long id : ids) { + Product product = productService.getProductById(id); + if (product != null) { + product.setStatus(status); + productService.updateProduct(id, product); + successCount++; + } + } + + return ApiResponse.success(String.format("批量更新完成,成功更新 %d 个商品", successCount)); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/controller/AdminStatisticsController.java b/springboot-multi-port/src/main/java/com/example/multiport/controller/AdminStatisticsController.java new file mode 100644 index 0000000..6e48ab0 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/controller/AdminStatisticsController.java @@ -0,0 +1,88 @@ +package com.example.multiport.controller; + +import com.example.multiport.annotation.AdminApi; +import com.example.multiport.model.ApiResponse; +import com.example.multiport.service.ProductService; +import com.example.multiport.service.PortService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 管理端统计控制器 + * 提供数据统计功能 + */ +@Slf4j +@RestController +@RequestMapping("/statistics") +@RequiredArgsConstructor +@AdminApi +public class AdminStatisticsController { + + private final ProductService productService; + private final PortService portService; + + /** + * 获取商品统计信息 + */ + @GetMapping("/products") + public ApiResponse> getProductStatistics() { + log.info("管理端获取商品统计信息"); + Map statistics = productService.getStatistics(); + return ApiResponse.success("获取商品统计成功", statistics); + } + + /** + * 获取系统概览信息 + */ + @GetMapping("/overview") + public ApiResponse> getSystemOverview() { + log.info("管理端获取系统概览信息"); + + Map productStats = productService.getStatistics(); + + Map overview = new HashMap<>(); + overview.put("products", productStats); + overview.put("system", getSystemInfo()); + overview.put("timestamp", System.currentTimeMillis()); + + return ApiResponse.success("获取系统概览成功", overview); + } + + /** + * 获取系统信息 + */ + private Map getSystemInfo() { + Runtime runtime = Runtime.getRuntime(); + + Map systemInfo = new HashMap<>(); + systemInfo.put("javaVersion", System.getProperty("java.version")); + systemInfo.put("osName", System.getProperty("os.name")); + systemInfo.put("osVersion", System.getProperty("os.version")); + systemInfo.put("availableProcessors", runtime.availableProcessors()); + systemInfo.put("totalMemory", runtime.totalMemory()); + systemInfo.put("freeMemory", runtime.freeMemory()); + systemInfo.put("maxMemory", runtime.maxMemory()); + + return systemInfo; + } + + /** + * 获取端口状态信息 + */ + @GetMapping("/ports") + public ApiResponse> getPortStatus() { + log.info("管理端获取端口状态信息"); + + Map portStatus = new HashMap<>(); + portStatus.put("userPort", portService.getUserPort()); + portStatus.put("adminPort", portService.getAdminPort()); + portStatus.put("userPortStatus", "ACTIVE"); + portStatus.put("adminPortStatus", "ACTIVE"); + + return ApiResponse.success("获取端口状态成功", portStatus); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/controller/HealthController.java b/springboot-multi-port/src/main/java/com/example/multiport/controller/HealthController.java new file mode 100644 index 0000000..65147f1 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/controller/HealthController.java @@ -0,0 +1,92 @@ +package com.example.multiport.controller; + +import com.example.multiport.logging.PortAwareLogger; +import com.example.multiport.model.ApiResponse; +import com.example.multiport.service.ProductService; +import com.example.multiport.service.PortService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; + +/** + * 健康检查控制器 + * 为不同端口提供独立的健康检查端点 + */ +@Slf4j +@RestController +@RequestMapping("/health") +@RequiredArgsConstructor +public class HealthController { + + private final ProductService productService; + private final PortAwareLogger portAwareLogger; + private final PortService portService; + + /** + * 用户端健康检查 + */ + @GetMapping("/user") + public ApiResponse> userHealth(HttpServletRequest request) { + int port = request.getLocalPort(); + portAwareLogger.logBusinessOperation(port, "HEALTH_CHECK", "用户端健康检查"); + + Map health = new HashMap<>(); + health.put("port", port); + health.put("status", "UP"); + health.put("service", "user-api"); + health.put("timestamp", System.currentTimeMillis()); + health.put("productCount", productService.getAllProductsForUser().size()); + + return ApiResponse.success("用户端服务健康", health); + } + + /** + * 管理端健康检查 + */ + @GetMapping("/admin") + public ApiResponse> adminHealth(HttpServletRequest request) { + int port = request.getLocalPort(); + portAwareLogger.logBusinessOperation(port, "HEALTH_CHECK", "管理端健康检查"); + + Map health = new HashMap<>(); + health.put("port", port); + health.put("status", "UP"); + health.put("service", "admin-api"); + health.put("timestamp", System.currentTimeMillis()); + health.put("productCount", productService.getAllProductsForAdmin().size()); + + Map statistics = productService.getStatistics(); + health.put("statistics", statistics); + + return ApiResponse.success("管理端服务健康", health); + } + + /** + * 通用健康检查(兼容性端点) + */ + @GetMapping + public ApiResponse> generalHealth(HttpServletRequest request) { + int port = request.getLocalPort(); + String serviceType = portService.getServiceTypePrefix(port); + + Map health = new HashMap<>(); + health.put("port", port); + health.put("status", "UP"); + health.put("service", serviceType + "-api"); + health.put("timestamp", System.currentTimeMillis()); + + if (portService.isUserPort(port)) { + health.put("endpoint", "用户端健康检查端点"); + } else { + health.put("endpoint", "管理端健康检查端点"); + } + + return ApiResponse.success("服务健康", health); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/controller/UserCartController.java b/springboot-multi-port/src/main/java/com/example/multiport/controller/UserCartController.java new file mode 100644 index 0000000..9c49272 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/controller/UserCartController.java @@ -0,0 +1,136 @@ +package com.example.multiport.controller; + +import com.example.multiport.annotation.UserApi; +import com.example.multiport.model.ApiResponse; +import com.example.multiport.model.CartItem; +import com.example.multiport.service.CartService; +import com.example.multiport.service.ProductService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 用户端购物车控制器 + * 提供购物车管理功能 + */ +@Slf4j +@RestController +@RequestMapping("/cart") +@RequiredArgsConstructor +@UserApi +public class UserCartController { + + private final CartService cartService; + private final ProductService productService; + + /** + * 获取用户购物车 + */ + @GetMapping("/{userId}") + public ApiResponse> getUserCart(@PathVariable Long userId) { + log.info("用户端获取购物车,用户ID: {}", userId); + List cartItems = cartService.getUserCart(userId); + return ApiResponse.success("获取购物车成功", cartItems); + } + + /** + * 添加商品到购物车 + */ + @PostMapping("/{userId}/items") + public ApiResponse addToCart(@PathVariable Long userId, + @RequestBody Map request) { + Long productId = Long.valueOf(request.get("productId").toString()); + Integer quantity = Integer.valueOf(request.get("quantity").toString()); + + log.info("用户端添加商品到购物车,用户ID: {}, 商品ID: {}, 数量: {}", userId, productId, quantity); + + // 验证商品是否存在 + var product = productService.getProductById(productId); + if (product == null || !Boolean.TRUE.equals(product.getStatus())) { + return ApiResponse.error(404, "商品不存在或已下架"); + } + + // 检查库存 + if (product.getStock() < quantity) { + return ApiResponse.error(400, "商品库存不足"); + } + + CartItem cartItem = cartService.addToCart( + userId, productId, product.getName(), quantity, product.getPrice() + ); + + return ApiResponse.success("添加到购物车成功", cartItem); + } + + /** + * 更新购物车商品数量 + */ + @PutMapping("/{userId}/items/{cartItemId}") + public ApiResponse updateCartItem(@PathVariable Long userId, + @PathVariable Long cartItemId, + @RequestBody Map request) { + Integer quantity = request.get("quantity"); + log.info("用户端更新购物车商品数量,用户ID: {}, 购物车项ID: {}, 新数量: {}", userId, cartItemId, quantity); + + if (quantity <= 0) { + return ApiResponse.error(400, "商品数量必须大于0"); + } + + CartItem cartItem = cartService.updateCartItem(userId, cartItemId, quantity); + if (cartItem == null) { + return ApiResponse.error(404, "购物车项不存在"); + } + + return ApiResponse.success("更新购物车成功", cartItem); + } + + /** + * 从购物车移除商品 + */ + @DeleteMapping("/{userId}/items/{cartItemId}") + public ApiResponse removeFromCart(@PathVariable Long userId, + @PathVariable Long cartItemId) { + log.info("用户端从购物车移除商品,用户ID: {}, 购物车项ID: {}", userId, cartItemId); + + boolean removed = cartService.removeFromCart(userId, cartItemId); + if (!removed) { + return ApiResponse.error(404, "购物车项不存在"); + } + + return ApiResponse.success("移除商品成功"); + } + + /** + * 清空购物车 + */ + @DeleteMapping("/{userId}") + public ApiResponse clearCart(@PathVariable Long userId) { + log.info("用户端清空购物车,用户ID: {}", userId); + cartService.clearCart(userId); + return ApiResponse.success("清空购物车成功"); + } + + /** + * 获取购物车统计信息 + */ + @GetMapping("/{userId}/summary") + public ApiResponse> getCartSummary(@PathVariable Long userId) { + log.info("用户端获取购物车统计信息,用户ID: {}", userId); + + List cartItems = cartService.getUserCart(userId); + BigDecimal totalAmount = cartService.getCartTotal(userId); + int itemCount = cartService.getCartItemCount(userId); + + Map summary = new HashMap<>(); + summary.put("items", cartItems); + summary.put("totalAmount", totalAmount); + summary.put("itemCount", itemCount); + + return ApiResponse.success("获取购物车统计成功", summary); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/controller/UserProductController.java b/springboot-multi-port/src/main/java/com/example/multiport/controller/UserProductController.java new file mode 100644 index 0000000..3a8bc64 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/controller/UserProductController.java @@ -0,0 +1,70 @@ +package com.example.multiport.controller; + +import com.example.multiport.annotation.UserApi; +import com.example.multiport.model.ApiResponse; +import com.example.multiport.model.Product; +import com.example.multiport.service.ProductService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 用户端商品控制器 + * 提供用户商品浏览相关功能 + */ +@Slf4j +@RestController +@RequestMapping("/products") +@RequiredArgsConstructor +@UserApi +public class UserProductController { + + private final ProductService productService; + + /** + * 获取所有上架商品 + */ + @GetMapping + public ApiResponse> getAllProducts() { + log.info("用户端获取所有商品列表"); + List products = productService.getAllProductsForUser(); + return ApiResponse.success("获取商品列表成功", products); + } + + /** + * 根据ID获取商品详情 + */ + @GetMapping("/{id}") + public ApiResponse getProductById(@PathVariable Long id) { + log.info("用户端获取商品详情,ID: {}", id); + Product product = productService.getProductById(id); + + if (product == null || !Boolean.TRUE.equals(product.getStatus())) { + return ApiResponse.error(404, "商品不存在或已下架"); + } + + return ApiResponse.success("获取商品详情成功", product); + } + + /** + * 根据分类获取商品 + */ + @GetMapping("/category/{category}") + public ApiResponse> getProductsByCategory(@PathVariable String category) { + log.info("用户端根据分类获取商品,分类: {}", category); + List products = productService.getProductsByCategory(category); + return ApiResponse.success("获取分类商品成功", products); + } + + /** + * 搜索商品 + */ + @GetMapping("/search") + public ApiResponse> searchProducts(@RequestParam String keyword) { + log.info("用户端搜索商品,关键词: {}", keyword); + List products = productService.searchProducts(keyword); + return ApiResponse.success("搜索商品成功", products); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/exception/GlobalExceptionHandler.java b/springboot-multi-port/src/main/java/com/example/multiport/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..271aae3 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/exception/GlobalExceptionHandler.java @@ -0,0 +1,120 @@ +package com.example.multiport.exception; + +import com.example.multiport.model.ErrorResponse; +import com.example.multiport.service.PortService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +import javax.servlet.http.HttpServletRequest; + +/** + * 全局异常处理器 + * 根据不同端口提供不同的错误响应格式 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @Autowired + private PortService portService; + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException( + Exception e, HttpServletRequest request) { + + int port = request.getLocalPort(); + String serviceType = portService.getServiceType(port); + String path = request.getRequestURI(); + + log.error("全局异常处理 - 端口: {}, 服务类型: {}, 路径: {}, 异常: {}", + port, serviceType, path, e.getMessage(), e); + + ErrorResponse error = new ErrorResponse(); + + if (portService.isUserPort(port)) { + // 用户端错误响应 + error.setCode("USER_ERROR_" + Math.abs(e.hashCode() % 1000)); + error.setMessage("用户服务暂时繁忙,请稍后重试"); + error.setServiceType("USER"); + error.setPort(portService.getUserPort()); + } else if (portService.isAdminPort(port)) { + // 管理端错误响应 + error.setCode("ADMIN_ERROR_" + Math.abs(e.hashCode() % 1000)); + error.setMessage("管理服务异常: " + e.getMessage()); + error.setServiceType("ADMIN"); + error.setPort(portService.getAdminPort()); + } + + error.setPath(path); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(error); + } + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNotFound( + NoHandlerFoundException e, HttpServletRequest request) { + + int port = request.getLocalPort(); + String serviceType = portService.getServiceType(port); + String path = request.getRequestURI(); + + log.warn("404错误 - 端口: {}, 服务类型: {}, 路径: {}", port, serviceType, path); + + ErrorResponse error = new ErrorResponse(); + + if (portService.isUserPort(port)) { + error.setCode("USER_NOT_FOUND"); + error.setMessage("用户端接口不存在: " + path); + error.setServiceType("USER"); + error.setPort(portService.getUserPort()); + } else if (portService.isAdminPort(port)) { + error.setCode("ADMIN_NOT_FOUND"); + error.setMessage("管理端接口不存在: " + path); + error.setServiceType("ADMIN"); + error.setPort(portService.getAdminPort()); + } + + error.setPath(path); + + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(error); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException( + IllegalArgumentException e, HttpServletRequest request) { + + int port = request.getLocalPort(); + String serviceType = portService.getServiceType(port); + String path = request.getRequestURI(); + + log.warn("参数错误 - 端口: {}, 服务类型: {}, 路径: {}, 错误: {}", + port, serviceType, path, e.getMessage()); + + ErrorResponse error = new ErrorResponse(); + + if (portService.isUserPort(port)) { + error.setCode("USER_BAD_REQUEST"); + error.setMessage("请求参数错误: " + e.getMessage()); + error.setServiceType("USER"); + error.setPort(portService.getUserPort()); + } else if (portService.isAdminPort(port)) { + error.setCode("ADMIN_BAD_REQUEST"); + error.setMessage("请求参数错误: " + e.getMessage()); + error.setServiceType("ADMIN"); + error.setPort(portService.getAdminPort()); + } + + error.setPath(path); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(error); + } + + } \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/interceptor/PortAwareInterceptor.java b/springboot-multi-port/src/main/java/com/example/multiport/interceptor/PortAwareInterceptor.java new file mode 100644 index 0000000..cba562f --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/interceptor/PortAwareInterceptor.java @@ -0,0 +1,98 @@ +package com.example.multiport.interceptor; + +import com.example.multiport.service.PortService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * 端口感知拦截器 + * 根据不同端口进行不同的请求处理逻辑 + */ +@Slf4j +@Component +public class PortAwareInterceptor implements HandlerInterceptor { + + @Autowired + private PortService portService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + + int port = request.getLocalPort(); + String uri = request.getRequestURI(); + String method = request.getMethod(); + + // 只记录API请求,避免静态资源日志干扰 + if (uri.startsWith("/api/")) { + log.info("端口感知拦截器 - 端口: {}, 方法: {}, URI: {}", port, method, uri); + } + + if (portService.isUserPort(port)) { + // 用户端逻辑 + return handleUserRequest(request, response); + } else if (portService.isAdminPort(port)) { + // 管理端逻辑 + return handleAdminRequest(request, response); + } + + return true; + } + + /** + * 处理用户端请求 + */ + private boolean handleUserRequest(HttpServletRequest request, HttpServletResponse response) { + // 用户端请求验证逻辑 + String userAgent = request.getHeader("User-Agent"); + + // 可以根据需要添加用户端特定的验证逻辑 + if (userAgent != null && userAgent.contains("bot")) { + log.warn("检测到可能的机器人访问用户端: {}", userAgent); + // 可以选择拒绝或记录机器人访问 + } + + // 设置用户端响应头 + response.setHeader("X-Service-Type", "USER"); + response.setHeader("X-Port", portService.getUserPortString()); + + return true; + } + + /** + * 处理管理端请求 + */ + private boolean handleAdminRequest(HttpServletRequest request, HttpServletResponse response) { + // 管理端请求处理逻辑(已移除认证检查) + log.info("管理端请求处理: {}", request.getRequestURI()); + + // 设置管理端响应头 + response.setHeader("X-Service-Type", "ADMIN"); + response.setHeader("X-Port", portService.getAdminPortString()); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) throws Exception { + + int port = request.getLocalPort(); + String serviceType = portService.getServiceType(port); + String uri = request.getRequestURI(); + + // 只记录API请求的完成状态 + if (uri.startsWith("/api/")) { + if (ex != null) { + log.error("{}端口请求处理完成,但发生异常: {}", serviceType, ex.getMessage()); + } else { + log.debug("{}端口请求处理完成,状态码: {}", serviceType, response.getStatus()); + } + } + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/logging/PortAwareLogger.java b/springboot-multi-port/src/main/java/com/example/multiport/logging/PortAwareLogger.java new file mode 100644 index 0000000..4361ff1 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/logging/PortAwareLogger.java @@ -0,0 +1,136 @@ +package com.example.multiport.logging; + +import com.example.multiport.service.PortService; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; + +/** + * 端口感知日志记录器 + * 根据不同端口使用不同的日志记录策略 + */ +@Component +@Slf4j +public class PortAwareLogger { + + @Autowired + private PortService portService; + + private static final Logger userLogger = LoggerFactory.getLogger("USER-PORT"); + private static final Logger adminLogger = LoggerFactory.getLogger("ADMIN-PORT"); + private static final Logger systemLogger = LoggerFactory.getLogger("SYSTEM"); + + /** + * 记录请求信息 + */ + public void logRequest(HttpServletRequest request) { + int port = request.getLocalPort(); + String uri = request.getRequestURI(); + String method = request.getMethod(); + String userAgent = request.getHeader("User-Agent"); + String clientIp = getClientIpAddress(request); + + if (portService.isUserPort(port)) { + userLogger.info("用户端请求 - IP: {}, 方法: {}, URI: {}, UserAgent: {}", + clientIp, method, uri, userAgent); + } else if (portService.isAdminPort(port)) { + adminLogger.info("管理端请求 - IP: {}, 方法: {}, URI: {}, UserAgent: {}", + clientIp, method, uri, userAgent); + } + } + + /** + * 记录响应信息 + */ + public void logResponse(HttpServletRequest request, int status, long duration) { + int port = request.getLocalPort(); + String uri = request.getRequestURI(); + String method = request.getMethod(); + + if (portService.isUserPort(port)) { + userLogger.info("用户端响应 - 方法: {}, URI: {}, 状态码: {}, 耗时: {}ms", + method, uri, status, duration); + } else if (portService.isAdminPort(port)) { + adminLogger.info("管理端响应 - 方法: {}, URI: {}, 状态码: {}, 耗时: {}ms", + method, uri, status, duration); + } + } + + /** + * 记录业务操作 + */ + public void logBusinessOperation(int port, String operation, String details) { + if (portService.isUserPort(port)) { + userLogger.info("用户端业务操作 - 操作: {}, 详情: {}", operation, details); + } else if (portService.isAdminPort(port)) { + adminLogger.info("管理端业务操作 - 操作: {}, 详情: {}", operation, details); + } + } + + /** + * 记录安全事件 + */ + public void logSecurityEvent(int port, String event, String details) { + String serviceType = portService.getServiceTypeChinese(port); + systemLogger.warn("安全事件 - 服务类型: {}, 事件: {}, 详情: {}", serviceType, event, details); + } + + /** + * 记录性能指标 + */ + public void logPerformanceMetrics(int port, String endpoint, long responseTime) { + if (portService.isUserPort(port)) { + userLogger.info("性能指标 - 端点: {}, 响应时间: {}ms", endpoint, responseTime); + } else if (portService.isAdminPort(port)) { + adminLogger.info("性能指标 - 端点: {}, 响应时间: {}ms", endpoint, responseTime); + } + } + + /** + * 记录错误信息 + */ + public void logError(int port, String error, Throwable throwable) { + if (portService.isUserPort(port)) { + userLogger.error("用户端错误 - 错误: {}", error, throwable); + } else if (portService.isAdminPort(port)) { + adminLogger.error("管理端错误 - 错误: {}", error, throwable); + } + } + + /** + * 记录系统启动信息 + */ + public void logSystemStartup() { + systemLogger.info("多端口应用启动完成 - 用户端端口: {}, 管理端端口: {}", + portService.getUserPort(), portService.getAdminPort()); + } + + /** + * 记录端口状态变化 + */ + public void logPortStatusChange(int port, String status) { + String serviceType = portService.getServiceTypeChinese(port); + systemLogger.info("端口状态变化 - 服务类型: {}, 端口: {}, 状态: {}", serviceType, port, status); + } + + /** + * 获取客户端真实IP地址 + */ + private String getClientIpAddress(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty() && !"unknown".equalsIgnoreCase(xForwardedFor)) { + return xForwardedFor.split(",")[0].trim(); + } + + String xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isEmpty() && !"unknown".equalsIgnoreCase(xRealIp)) { + return xRealIp; + } + + return request.getRemoteAddr(); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/model/ApiResponse.java b/springboot-multi-port/src/main/java/com/example/multiport/model/ApiResponse.java new file mode 100644 index 0000000..88d0459 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/model/ApiResponse.java @@ -0,0 +1,42 @@ +package com.example.multiport.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 统一API响应格式 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private int code; + private String message; + private T data; + private long timestamp; + + public ApiResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "success", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(500, message, null); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/model/CartItem.java b/springboot-multi-port/src/main/java/com/example/multiport/model/CartItem.java new file mode 100644 index 0000000..6294f73 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/model/CartItem.java @@ -0,0 +1,31 @@ +package com.example.multiport.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 购物车项模型 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CartItem { + + private Long id; + + private Long userId; + + private Long productId; + + private String productName; + + private Integer quantity; + + private BigDecimal price; + + private LocalDateTime addTime; +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/model/ErrorResponse.java b/springboot-multi-port/src/main/java/com/example/multiport/model/ErrorResponse.java new file mode 100644 index 0000000..6aba587 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/model/ErrorResponse.java @@ -0,0 +1,30 @@ +package com.example.multiport.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 错误响应模型 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + + private String code; + private String message; + private String serviceType; + private int port; + private long timestamp; + private String path; + + public ErrorResponse(String code, String message, String serviceType, int port, String path) { + this.code = code; + this.message = message; + this.serviceType = serviceType; + this.port = port; + this.path = path; + this.timestamp = System.currentTimeMillis(); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/model/Product.java b/springboot-multi-port/src/main/java/com/example/multiport/model/Product.java new file mode 100644 index 0000000..89910e1 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/model/Product.java @@ -0,0 +1,35 @@ +package com.example.multiport.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 商品模型 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Product { + + private Long id; + + private String name; + + private String description; + + private BigDecimal price; + + private Integer stock; + + private String category; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + private Boolean status; // true: 上架, false: 下架 +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/monitoring/DualPortHealthIndicator.java b/springboot-multi-port/src/main/java/com/example/multiport/monitoring/DualPortHealthIndicator.java new file mode 100644 index 0000000..ddfb9ac --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/monitoring/DualPortHealthIndicator.java @@ -0,0 +1,80 @@ +package com.example.multiport.monitoring; + +import com.example.multiport.service.PortService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * 双端口健康检查指示器 + * 检查两个端口的服务状态 + */ +@Component +public class DualPortHealthIndicator implements HealthIndicator { + + @Autowired + private PortService portService; + + @Override + public Health health() { + Health.Builder builder = Health.up(); + + // 构建动态URL + String userPortUrl = portService.getUserUrlPrefix() + "/api/user/products"; + String adminPortUrl = portService.getAdminUrlPrefix() + "/api/admin/products"; + + // 检查用户端端口 + boolean userPortHealthy = checkPortHealth(userPortUrl, "用户端"); + if (!userPortHealthy) { + builder = Health.down(); + } + + // 检查管理端端口 + boolean adminPortHealthy = checkPortHealth(adminPortUrl, "管理端"); + if (!adminPortHealthy) { + builder = Health.down(); + } + + return builder + .withDetail("user-port", userPortHealthy ? "UP" : "DOWN") + .withDetail("admin-port", adminPortHealthy ? "UP" : "DOWN") + .withDetail("user-port-url", userPortUrl) + .withDetail("admin-port-url", adminPortUrl) + .withDetail("status", userPortHealthy && adminPortHealthy ? + "Both ports are active" : "One or more ports are down") + .build(); + } + + /** + * 检查指定端口URL的健康状态 + */ + private boolean checkPortHealth(String url, String portName) { + try { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + int responseCode = connection.getResponseCode(); + boolean isHealthy = responseCode >= 200 && responseCode < 300; + + if (!isHealthy) { + System.err.printf("健康检查失败 - %s: HTTP %d%n", portName, responseCode); + } + + return isHealthy; + + } catch (ConnectException e) { + System.err.printf("健康检查失败 - %s: 连接被拒绝%n", portName); + return false; + } catch (Exception e) { + System.err.printf("健康检查失败 - %s: %s%n", portName, e.getMessage()); + return false; + } + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/service/CartService.java b/springboot-multi-port/src/main/java/com/example/multiport/service/CartService.java new file mode 100644 index 0000000..eee4482 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/service/CartService.java @@ -0,0 +1,122 @@ +package com.example.multiport.service; + +import com.example.multiport.model.CartItem; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 购物车服务类 + * 模拟购物车相关的业务逻辑 + */ +@Service +public class CartService { + + private final Map> userCartMap = new ConcurrentHashMap<>(); + private final AtomicLong cartItemIdGenerator = new AtomicLong(1); + + /** + * 添加商品到购物车 + */ + public CartItem addToCart(Long userId, Long productId, String productName, + Integer quantity, BigDecimal price) { + List cartItems = userCartMap.computeIfAbsent(userId, k -> new ArrayList<>()); + + // 检查购物车中是否已存在该商品 + Optional existingItem = cartItems.stream() + .filter(item -> item.getProductId().equals(productId)) + .findFirst(); + + if (existingItem.isPresent()) { + // 更新数量 + CartItem item = existingItem.get(); + item.setQuantity(item.getQuantity() + quantity); + return item; + } else { + // 添加新商品 + CartItem cartItem = new CartItem(); + cartItem.setId(cartItemIdGenerator.getAndIncrement()); + cartItem.setUserId(userId); + cartItem.setProductId(productId); + cartItem.setProductName(productName); + cartItem.setQuantity(quantity); + cartItem.setPrice(price); + cartItem.setAddTime(LocalDateTime.now()); + + cartItems.add(cartItem); + return cartItem; + } + } + + /** + * 获取用户购物车 + */ + public List getUserCart(Long userId) { + return userCartMap.getOrDefault(userId, new ArrayList<>()); + } + + /** + * 更新购物车商品数量 + */ + public CartItem updateCartItem(Long userId, Long cartItemId, Integer quantity) { + List cartItems = userCartMap.get(userId); + if (cartItems == null) { + return null; + } + + Optional itemOptional = cartItems.stream() + .filter(item -> item.getId().equals(cartItemId)) + .findFirst(); + + if (itemOptional.isPresent()) { + CartItem item = itemOptional.get(); + item.setQuantity(quantity); + return item; + } + + return null; + } + + /** + * 从购物车移除商品 + */ + public boolean removeFromCart(Long userId, Long cartItemId) { + List cartItems = userCartMap.get(userId); + if (cartItems == null) { + return false; + } + + return cartItems.removeIf(item -> item.getId().equals(cartItemId)); + } + + /** + * 清空购物车 + */ + public void clearCart(Long userId) { + userCartMap.remove(userId); + } + + /** + * 获取购物车总金额 + */ + public BigDecimal getCartTotal(Long userId) { + List cartItems = getUserCart(userId); + return cartItems.stream() + .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 获取购物车商品总数 + */ + public int getCartItemCount(Long userId) { + List cartItems = getUserCart(userId); + return cartItems.stream() + .mapToInt(CartItem::getQuantity) + .sum(); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/service/PortService.java b/springboot-multi-port/src/main/java/com/example/multiport/service/PortService.java new file mode 100644 index 0000000..410c6d7 --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/service/PortService.java @@ -0,0 +1,145 @@ +package com.example.multiport.service; + +import com.example.multiport.config.DualPortProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 端口服务类 + * 统一管理端口配置,避免硬编码 + */ +@Slf4j +@Service +public class PortService { + + @Autowired + private DualPortProperties portProperties; + + /** + * 获取用户端端口 + */ + public int getUserPort() { + return portProperties.getUserPort(); + } + + /** + * 获取管理端端口 + */ + public int getAdminPort() { + return portProperties.getAdminPort(); + } + + /** + * 获取用户端端口号(字符串) + */ + public String getUserPortString() { + return String.valueOf(getUserPort()); + } + + /** + * 获取管理端端口号(字符串) + */ + public String getAdminPortString() { + return String.valueOf(getAdminPort()); + } + + /** + * 判断是否为用户端端口 + */ + public boolean isUserPort(int port) { + return port == getUserPort(); + } + + /** + * 判断是否为管理端端口 + */ + public boolean isAdminPort(int port) { + return port == getAdminPort(); + } + + /** + * 判断是否为用户端端口(字符串比较) + */ + public boolean isUserPort(String port) { + return getUserPortString().equals(port); + } + + /** + * 判断是否为管理端端口(字符串比较) + */ + public boolean isAdminPort(String port) { + return getAdminPortString().equals(port); + } + + /** + * 根据端口获取服务类型 + */ + public String getServiceType(int port) { + return isUserPort(port) ? "USER" : "ADMIN"; + } + + /** + * 根据端口获取服务类型(中文) + */ + public String getServiceTypeChinese(int port) { + return isUserPort(port) ? "用户端" : "管理端"; + } + + /** + * 根据端口获取服务类型标识 + */ + public String getServiceTypePrefix(int port) { + return isUserPort(port) ? "user" : "admin"; + } + + /** + * 获取用户端URL前缀 + */ + public String getUserUrlPrefix() { + return "http://localhost:" + getUserPort(); + } + + /** + * 获取管理端URL前缀 + */ + public String getAdminUrlPrefix() { + return "http://localhost:" + getAdminPort(); + } + + /** + * 获取用户端健康检查URL + */ + public String getUserHealthUrl() { + return getUserUrlPrefix() + "/health/user"; + } + + /** + * 获取管理端健康检查URL + */ + public String getAdminHealthUrl() { + return getAdminUrlPrefix() + "/health/admin"; + } + + /** + * 获取允许的CORS源地址 + */ + public String[] getAllowedOrigins() { + return new String[]{ + getUserUrlPrefix(), + getAdminUrlPrefix(), + "http://127.0.0.1:" + getUserPort(), + "http://127.0.0.1:" + getAdminPort() + }; + } + + /** + * 记录端口配置信息 + */ + public void logPortConfiguration() { + log.info("端口服务配置 - 用户端端口: {}, 管理端端口: {}", + getUserPort(), getAdminPort()); + log.info("用户端URL: {}", getUserUrlPrefix()); + log.info("管理端URL: {}", getAdminUrlPrefix()); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/java/com/example/multiport/service/ProductService.java b/springboot-multi-port/src/main/java/com/example/multiport/service/ProductService.java new file mode 100644 index 0000000..c7f260f --- /dev/null +++ b/springboot-multi-port/src/main/java/com/example/multiport/service/ProductService.java @@ -0,0 +1,188 @@ +package com.example.multiport.service; + +import com.example.multiport.model.Product; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * 商品服务类 + * 模拟商品相关的业务逻辑 + */ +@Service +public class ProductService { + + private final Map productMap = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + public ProductService() { + // 初始化一些测试数据 + initTestData(); + } + + /** + * 获取所有商品(用户端 - 只返回上架商品) + */ + public List getAllProductsForUser() { + return productMap.values().stream() + .filter(product -> Boolean.TRUE.equals(product.getStatus())) + .sorted(Comparator.comparing(Product::getCreateTime).reversed()) + .collect(Collectors.toList()); + } + + /** + * 获取所有商品(管理端 - 返回所有商品) + */ + public List getAllProductsForAdmin() { + return new ArrayList<>(productMap.values()) + .stream() + .sorted(Comparator.comparing(Product::getCreateTime).reversed()) + .collect(Collectors.toList()); + } + + /** + * 根据ID获取商品 + */ + public Product getProductById(Long id) { + return productMap.get(id); + } + + /** + * 创建商品 + */ + public Product createProduct(Product product) { + long id = idGenerator.getAndIncrement(); + product.setId(id); + product.setCreateTime(LocalDateTime.now()); + product.setUpdateTime(LocalDateTime.now()); + + if (product.getStatus() == null) { + product.setStatus(true); + } + + productMap.put(id, product); + return product; + } + + /** + * 更新商品 + */ + public Product updateProduct(Long id, Product product) { + Product existingProduct = productMap.get(id); + if (existingProduct == null) { + return null; + } + + existingProduct.setName(product.getName()); + existingProduct.setDescription(product.getDescription()); + existingProduct.setPrice(product.getPrice()); + existingProduct.setStock(product.getStock()); + existingProduct.setCategory(product.getCategory()); + existingProduct.setStatus(product.getStatus()); + existingProduct.setUpdateTime(LocalDateTime.now()); + + return existingProduct; + } + + /** + * 删除商品 + */ + public boolean deleteProduct(Long id) { + return productMap.remove(id) != null; + } + + /** + * 根据分类获取商品 + */ + public List getProductsByCategory(String category) { + return productMap.values().stream() + .filter(product -> category.equals(product.getCategory())) + .filter(product -> Boolean.TRUE.equals(product.getStatus())) + .collect(Collectors.toList()); + } + + /** + * 搜索商品 + */ + public List searchProducts(String keyword) { + String lowerKeyword = keyword.toLowerCase(); + return productMap.values().stream() + .filter(product -> Boolean.TRUE.equals(product.getStatus())) + .filter(product -> + product.getName().toLowerCase().contains(lowerKeyword) || + product.getDescription().toLowerCase().contains(lowerKeyword) + ) + .collect(Collectors.toList()); + } + + /** + * 获取统计信息 + */ + public Map getStatistics() { + long totalProducts = productMap.size(); + long activeProducts = productMap.values().stream() + .filter(p -> Boolean.TRUE.equals(p.getStatus())) + .count(); + long totalStock = productMap.values().stream() + .mapToInt(Product::getStock) + .sum(); + + Map stats = new HashMap<>(); + stats.put("totalProducts", totalProducts); + stats.put("activeProducts", activeProducts); + stats.put("inactiveProducts", totalProducts - activeProducts); + stats.put("totalStock", totalStock); + stats.put("categories", productMap.values().stream() + .map(Product::getCategory) + .distinct() + .collect(Collectors.toList())); + + return stats; + } + + /** + * 初始化测试数据 + */ + private void initTestData() { + Product product1 = new Product(); + product1.setName("iPhone 15 Pro"); + product1.setDescription("最新款苹果手机,钛金属机身"); + product1.setPrice(new BigDecimal("7999.00")); + product1.setStock(100); + product1.setCategory("手机"); + product1.setStatus(true); + + Product product2 = new Product(); + product2.setName("MacBook Pro 14"); + product2.setDescription("专业级笔记本电脑,M3芯片"); + product2.setPrice(new BigDecimal("14999.00")); + product2.setStock(50); + product2.setCategory("电脑"); + product2.setStatus(true); + + Product product3 = new Product(); + product3.setName("AirPods Pro"); + product3.setDescription("主动降噪无线耳机"); + product3.setPrice(new BigDecimal("1999.00")); + product3.setStock(200); + product3.setCategory("耳机"); + product3.setStatus(true); + + Product product4 = new Product(); + product4.setName("iPad Air"); + product4.setDescription("轻薄平板电脑"); + product4.setPrice(new BigDecimal("4799.00")); + product4.setStock(0); + product4.setCategory("平板"); + product4.setStatus(false); // 下架 + + createProduct(product1); + createProduct(product2); + createProduct(product3); + createProduct(product4); + } +} \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/application.yml b/springboot-multi-port/src/main/resources/application.yml new file mode 100644 index 0000000..95dbd26 --- /dev/null +++ b/springboot-multi-port/src/main/resources/application.yml @@ -0,0 +1,37 @@ +# 多端口应用配置 +server: + port: 8082 # 主端口(用户端) + +# 双端口自定义配置 +dual: + port: + user-port: 8082 + admin-port: 8083 + +# Spring配置 +spring: + application: + name: multi-port-application + profiles: + active: default + +# Actuator配置 +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +# 日志配置 +logging: + level: + com.example.multiport: DEBUG + org.springframework.web: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: logs/multi-port-app.log \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/index.html b/springboot-multi-port/src/main/resources/static/index.html new file mode 100644 index 0000000..9d3d9ea --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/index.html @@ -0,0 +1,264 @@ + + + + + + 双端口应用测试平台 + + + + + + + + + +
+ +
+
+ +
+
+ + +
+ +
+
+ +
+
+ + +
+ +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/api.js b/springboot-multi-port/src/main/resources/static/js/api.js new file mode 100644 index 0000000..0ebbbd0 --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/api.js @@ -0,0 +1,267 @@ +/** + * API 调用模块 + * 处理与后端的所有HTTP请求 + */ + +class ApiClient { + constructor() { + this.baseUrls = { + user: 'http://localhost:8082', + admin: 'http://localhost:8083' + }; + this.defaultHeaders = { + 'Content-Type': 'application/json' + }; + } + + /** + * 通用HTTP请求方法 + */ + async request(service, endpoint, options = {}) { + const url = `${this.baseUrls[service]}${endpoint}`; + const config = { + headers: { ...this.defaultHeaders, ...options.headers }, + ...options + }; + + try { + const response = await fetch(url, config); + + // 处理HTTP错误状态 + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // 尝试解析JSON响应 + const data = await response.json(); + return data; + } catch (error) { + console.error('API请求失败:', error); + throw error; + } + } + + /** + * GET请求 + */ + async get(service, endpoint, params = {}) { + const queryString = new URLSearchParams(params).toString(); + const url = queryString ? `${endpoint}?${queryString}` : endpoint; + return this.request(service, url); + } + + /** + * POST请求 + */ + async post(service, endpoint, data = {}) { + return this.request(service, endpoint, { + method: 'POST', + body: JSON.stringify(data) + }); + } + + /** + * PUT请求 + */ + async put(service, endpoint, data = {}) { + return this.request(service, endpoint, { + method: 'PUT', + body: JSON.stringify(data) + }); + } + + /** + * DELETE请求 + */ + async delete(service, endpoint) { + return this.request(service, endpoint, { + method: 'DELETE' + }); + } + + /** + * PATCH请求 + */ + async patch(service, endpoint, data = {}) { + return this.request(service, endpoint, { + method: 'PATCH', + body: JSON.stringify(data) + }); + } +} + +// 用户端API +class UserApi extends ApiClient { + /** + * 获取所有商品 + */ + async getProducts() { + return this.get('user', '/api/user/products'); + } + + /** + * 根据ID获取商品 + */ + async getProduct(id) { + return this.get('user', `/api/user/products/${id}`); + } + + /** + * 根据分类获取商品 + */ + async getProductsByCategory(category) { + return this.get('user', `/api/user/products/category/${category}`); + } + + /** + * 搜索商品 + */ + async searchProducts(keyword) { + return this.get('user', '/api/user/products/search', { keyword }); + } + + /** + * 获取用户购物车 + */ + async getCart(userId) { + return this.get('user', `/api/user/cart/${userId}`); + } + + /** + * 添加商品到购物车 + */ + async addToCart(userId, productId, quantity) { + return this.post('user', `/api/user/cart/${userId}/items`, { + productId, + quantity + }); + } + + /** + * 更新购物车商品数量 + */ + async updateCartItem(userId, cartItemId, quantity) { + return this.put('user', `/api/user/cart/${userId}/items/${cartItemId}`, { + quantity + }); + } + + /** + * 从购物车移除商品 + */ + async removeFromCart(userId, cartItemId) { + return this.delete('user', `/api/user/cart/${userId}/items/${cartItemId}`); + } + + /** + * 清空购物车 + */ + async clearCart(userId) { + return this.delete('user', `/api/user/cart/${userId}`); + } + + /** + * 获取购物车统计 + */ + async getCartSummary(userId) { + return this.get('user', `/api/user/cart/${userId}/summary`); + } + + /** + * 健康检查 + */ + async healthCheck() { + return this.get('user', '/health/user'); + } +} + +// 管理端API +class AdminApi extends ApiClient { + /** + * 获取所有商品(包括下架的) + */ + async getAllProducts() { + return this.get('admin', '/api/admin/products'); + } + + /** + * 根据ID获取商品 + */ + async getProduct(id) { + return this.get('admin', `/api/admin/products/${id}`); + } + + /** + * 创建商品 + */ + async createProduct(product) { + return this.post('admin', '/api/admin/products', product); + } + + /** + * 更新商品 + */ + async updateProduct(id, product) { + return this.put('admin', `/api/admin/products/${id}`, product); + } + + /** + * 删除商品 + */ + async deleteProduct(id) { + return this.delete('admin', `/api/admin/products/${id}`); + } + + /** + * 更新商品状态 + */ + async updateProductStatus(id, status) { + return this.patch('admin', `/api/admin/products/${id}/status`, { status }); + } + + /** + * 批量更新商品状态 + */ + async batchUpdateStatus(ids, status) { + return this.patch('admin', '/api/admin/products/batch/status', { + ids, + status + }); + } + + /** + * 获取商品统计 + */ + async getProductStatistics() { + return this.get('admin', '/api/admin/statistics/products'); + } + + /** + * 获取系统概览 + */ + async getSystemOverview() { + return this.get('admin', '/api/admin/statistics/overview'); + } + + /** + * 获取端口状态 + */ + async getPortStatus() { + return this.get('admin', '/api/admin/statistics/ports'); + } + + /** + * 健康检查 + */ + async healthCheck() { + return this.get('admin', '/health/admin'); + } +} + +// 创建全局API实例 +const userApi = new UserApi(); +const adminApi = new AdminApi(); + +// 导出API实例 +window.userApi = userApi; +window.adminApi = adminApi; \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/app.js b/springboot-multi-port/src/main/resources/static/js/app.js new file mode 100644 index 0000000..8b7b831 --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/app.js @@ -0,0 +1,173 @@ +/** + * 主应用入口 + */ + +class App { + constructor() { + this.initialized = false; + } + + /** + * 初始化应用 + */ + async init() { + if (this.initialized) { + return; + } + + try { + console.log('正在初始化多端口测试平台...'); + + // 设置全局错误处理 + this.setupErrorHandling(); + + // 初始化主题 + this.initTheme(); + + // 检查后端连接 + await this.checkBackendConnection(); + + // 订阅状态变化 + this.subscribeToStateChanges(); + + // 加载初始页面 + this.loadInitialPage(); + + this.initialized = true; + console.log('应用初始化完成'); + + } catch (error) { + console.error('应用初始化失败:', error); + uiManager.showNotification('初始化失败', '应用初始化过程中出现错误', 'error'); + } + } + + /** + * 设置全局错误处理 + */ + setupErrorHandling() { + // 捕获未处理的Promise错误 + window.addEventListener('unhandledrejection', (event) => { + console.error('未处理的Promise错误:', event.reason); + uiManager.showNotification('系统错误', '发生未预期的错误', 'error'); + }); + + // 捕获全局JavaScript错误 + window.addEventListener('error', (event) => { + console.error('全局JavaScript错误:', event.error); + uiManager.showNotification('系统错误', '脚本执行出错', 'error'); + }); + } + + /** + * 初始化主题 + */ + initTheme() { + const savedTheme = localStorage.getItem('theme') || 'light'; + if (savedTheme === 'dark') { + document.body.classList.add('dark'); + const themeBtn = document.getElementById('themeToggle'); + if (themeBtn) { + themeBtn.innerHTML = ''; + } + } + appState.updateState('ui.theme', savedTheme); + } + + /** + * 检查后端连接 + */ + async checkBackendConnection() { + try { + // 检查用户端连接 + const userResponse = await fetch('http://localhost:8082/health/user'); + const userConnected = userResponse.ok; + + // 检查管理端连接 + const adminResponse = await fetch('http://localhost:8083/health/admin'); + const adminConnected = adminResponse.ok; + + if (userConnected && adminConnected) { + uiManager.showNotification('连接成功', '已连接到后端服务', 'success'); + uiManager.updateConnectionStatus(true); + } else if (userConnected) { + uiManager.showNotification('部分连接', '用户端服务可用,管理端服务不可用', 'warning'); + uiManager.updateConnectionStatus(true); + } else { + uiManager.showNotification('连接失败', '无法连接到后端服务,请确保服务正在运行', 'error'); + uiManager.updateConnectionStatus(false); + } + + } catch (error) { + console.error('检查后端连接失败:', error); + uiManager.showNotification('连接失败', '无法连接到后端服务', 'error'); + uiManager.updateConnectionStatus(false); + } + } + + /** + * 订阅状态变化 + */ + subscribeToStateChanges() { + appState.subscribe((state) => { + // 这里可以响应状态变化 + console.log('状态更新:', state); + }); + } + + /** + * 加载初始页面 + */ + loadInitialPage() { + // 默认加载用户端商品页面 + uiManager.switchUserPage('products'); + } + + /** + * 重启应用 + */ + restart() { + this.initialized = false; + this.init(); + } + + /** + * 获取应用信息 + */ + getInfo() { + return { + name: '双端口应用测试平台', + version: '1.0.0', + description: '用于测试多端口Spring Boot应用的前端界面', + features: [ + '用户端商品浏览和购物车', + '管理端商品管理和统计', + '实时系统监控', + '响应式设计' + ], + apis: { + user: 'http://localhost:8082', + admin: 'http://localhost:8083' + } + }; + } +} + +// 创建全局应用实例 +const app = new App(); + +// 页面卸载时清理资源 +window.addEventListener('beforeunload', () => { + if (AdminSystemPage && AdminSystemPage.destroy) { + AdminSystemPage.destroy(); + } +}); + +// 导出到全局作用域 +window.App = App; // 导出类 +window.app = app; // 导出实例(小写) +window.AppClass = App; // 导出类(兼容性) + +console.log('App类已导出到全局作用域:', typeof window.App); +console.log('app实例已导出到全局作用域:', typeof window.app); +console.log('app实例是否有init方法:', typeof window.app?.init); \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/pages/admin-products.js b/springboot-multi-port/src/main/resources/static/js/pages/admin-products.js new file mode 100644 index 0000000..4a0b6b8 --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/pages/admin-products.js @@ -0,0 +1,530 @@ +/** + * 管理端商品管理页面 + */ + +const AdminProductsPage = { + currentPage: 1, + pageSize: 10, + totalItems: 0, + allProducts: [], + + /** + * 渲染页面 + */ + async render(container) { + container.innerHTML = ` +
+ +
+

+ + 商品管理 +

+
+ +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+ + + + + + + + + + + + + + +
+ 商品信息 + + 分类 + + 价格 + + 库存 + + 状态 + + 操作 +
+
+
+ + + + + + +
+ `; + + // 加载数据 + await this.loadProducts(); + this.setupEventListeners(); + }, + + /** + * 设置事件监听器 + */ + setupEventListeners() { + // 搜索框回车事件 + document.getElementById('searchInput').addEventListener('keyup', (e) => { + if (e.key === 'Enter') { + this.filterProducts(); + } + }); + + // 防抖搜索 + document.getElementById('searchInput').addEventListener('input', + uiManager.debounce(() => this.filterProducts(), 500) + ); + }, + + /** + * 加载商品数据 + */ + async loadProducts() { + try { + uiManager.showLoading(); + + const response = await adminApi.getAllProducts(); + + if (response.code === 200) { + this.allProducts = response.data; + this.totalItems = this.allProducts.length; + this.renderProducts(this.allProducts); + this.updateCategories(this.allProducts); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('加载商品失败:', error); + this.showError(); + uiManager.showNotification('加载失败', '无法加载商品数据', 'error'); + } finally { + uiManager.hideLoading(); + } + }, + + /** + * 渲染商品表格 + */ + renderProducts(products) { + const tbody = document.getElementById('productsTableBody'); + const emptyState = document.getElementById('emptyState'); + const errorState = document.getElementById('errorState'); + + // 隐藏状态提示 + emptyState.classList.add('hidden'); + errorState.classList.add('hidden'); + + if (products.length === 0) { + tbody.innerHTML = ''; + emptyState.classList.remove('hidden'); + return; + } + + tbody.innerHTML = products.map(product => this.createProductRow(product)).join(''); + }, + + /** + * 创建商品行 + */ + createProductRow(product) { + const statusBadge = product.status ? + '上架' : + '下架'; + + const stockStatus = product.stock > 20 ? 'text-green-600' : + product.stock > 0 ? 'text-yellow-600' : 'text-red-600'; + + return ` + + +
+ ${product.name} +
+
${product.name}
+
ID: ${product.id}
+
+
+ + + ${product.category || '-'} + + + ${uiManager.formatPrice(product.price)} + + + ${product.stock} + + + ${statusBadge} + + +
+ + + + +
+ + + `; + }, + + /** + * 更新分类过滤器 + */ + updateCategories(products) { + const categories = [...new Set(products.map(p => p.category).filter(Boolean))]; + const select = document.getElementById('categoryFilter'); + + select.innerHTML = ` + + ${categories.map(category => ` + + `).join('')} + `; + }, + + /** + * 过滤商品 + */ + filterProducts() { + const keyword = document.getElementById('searchInput').value.toLowerCase().trim(); + const category = document.getElementById('categoryFilter').value; + const status = document.getElementById('statusFilter').value; + + let filtered = this.allProducts; + + if (keyword) { + filtered = filtered.filter(p => + p.name.toLowerCase().includes(keyword) || + p.description.toLowerCase().includes(keyword) + ); + } + + if (category) { + filtered = filtered.filter(p => p.category === category); + } + + if (status !== '') { + filtered = filtered.filter(p => p.status.toString() === status); + } + + this.renderProducts(filtered); + }, + + /** + * 查看商品详情 + */ + async viewProduct(id) { + try { + const response = await adminApi.getProduct(id); + + if (response.code === 200) { + this.showProductModal(response.data, 'view'); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('获取商品详情失败:', error); + uiManager.showNotification('失败', '无法获取商品详情', 'error'); + } + }, + + /** + * 编辑商品 + */ + async editProduct(id) { + try { + const response = await adminApi.getProduct(id); + + if (response.code === 200) { + this.showProductModal(response.data, 'edit'); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('获取商品信息失败:', error); + uiManager.showNotification('失败', '无法获取商品信息', 'error'); + } + }, + + /** + * 切换商品状态 + */ + async toggleStatus(id) { + const product = this.allProducts.find(p => p.id === id); + if (!product) return; + + const action = product.status ? '下架' : '上架'; + if (!confirm(`确定要${action}商品 "${product.name}" 吗?`)) { + return; + } + + try { + const response = await adminApi.updateProductStatus(id, !product.status); + + if (response.code === 200) { + uiManager.showNotification('成功', `商品已${action}`, 'success'); + await this.loadProducts(); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('更新状态失败:', error); + uiManager.showNotification('失败', '更新状态失败', 'error'); + } + }, + + /** + * 删除商品 + */ + async deleteProduct(id) { + const product = this.allProducts.find(p => p.id === id); + if (!product) return; + + if (!confirm(`确定要删除商品 "${product.name}" 吗?此操作不可恢复!`)) { + return; + } + + try { + const response = await adminApi.deleteProduct(id); + + if (response.code === 200) { + uiManager.showNotification('成功', '商品已删除', 'success'); + await this.loadProducts(); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('删除商品失败:', error); + uiManager.showNotification('失败', '删除商品失败', 'error'); + } + }, + + /** + * 显示创建商品模态框 + */ + showCreateModal() { + this.showProductModal(null, 'create'); + }, + + /** + * 显示商品模态框 + */ + showProductModal(product, mode) { + const isCreate = mode === 'create'; + const isEdit = mode === 'edit'; + const isView = mode === 'view'; + + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'; + + const title = isCreate ? '添加商品' : isEdit ? '编辑商品' : '商品详情'; + const disabled = isView ? 'disabled' : ''; + + modal.innerHTML = ` +
+
+
+

${title}

+ +
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + ${!isView ? ` +
+ + +
+ ` : ''} +
+
+
+ `; + + document.body.appendChild(modal); + + // 表单提交处理 + if (!isView) { + const form = document.getElementById('productForm'); + form.addEventListener('submit', (e) => { + e.preventDefault(); + this.saveProduct(product?.id, mode); + }); + } + + // 点击背景关闭模态框 + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.remove(); + } + }); + }, + + /** + * 保存商品 + */ + async saveProduct(id, mode) { + const formData = { + name: document.getElementById('productName').value, + category: document.getElementById('productCategory').value, + description: document.getElementById('productDescription').value, + price: parseFloat(document.getElementById('productPrice').value), + stock: parseInt(document.getElementById('productStock').value), + status: document.getElementById('productStatus').checked + }; + + // 基本验证 + if (!formData.name || formData.price <= 0 || formData.stock < 0) { + uiManager.showNotification('验证失败', '请填写完整的商品信息', 'error'); + return; + } + + try { + let response; + if (mode === 'create') { + response = await adminApi.createProduct(formData); + } else { + response = await adminApi.updateProduct(id, formData); + } + + if (response.code === 200) { + const action = mode === 'create' ? '创建' : '更新'; + uiManager.showNotification('成功', `商品${action}成功`, 'success'); + document.querySelector('.fixed').remove(); + await this.loadProducts(); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('保存商品失败:', error); + uiManager.showNotification('失败', '保存商品失败', 'error'); + } + }, + + /** + * 显示错误状态 + */ + showError() { + document.getElementById('productsTableBody').innerHTML = ''; + document.getElementById('emptyState').classList.add('hidden'); + document.getElementById('errorState').classList.remove('hidden'); + } +}; + +// 导出到全局作用域 +window.AdminProductsPage = AdminProductsPage; \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/pages/admin-statistics.js b/springboot-multi-port/src/main/resources/static/js/pages/admin-statistics.js new file mode 100644 index 0000000..ae990ac --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/pages/admin-statistics.js @@ -0,0 +1,210 @@ +/** + * 管理端统计分析页面 + */ + +const AdminStatisticsPage = { + /** + * 渲染页面 + */ + async render(container) { + container.innerHTML = ` +
+ +
+

+ + 统计分析 +

+
+ +
+
+ + +
+
+
+
+

商品总数

+

0

+
+
+ +
+
+
+ +
+
+
+

上架商品

+

0

+
+
+ +
+
+
+ +
+
+
+

总库存

+

0

+
+
+ +
+
+
+ +
+
+
+

商品分类

+

0

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

+ + 分类详细信息 +

+
+
+ + + + + + + + + + + + +
+ 分类名称 + + 商品数量 + + 上架商品 + + 总库存 +
+
+
+ + + + + + +
+ `; + + // 加载数据 + await this.loadData(); + }, + + /** + * 加载统计数据 + */ + async loadData() { + try { + uiManager.showLoading(); + + const response = await adminApi.getProductStatistics(); + + if (response.code === 200) { + const statistics = response.data; + this.renderOverviewCards(statistics); + this.renderCategoryTable(statistics); + appState.updateAdminData('statistics', statistics); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('加载统计数据失败:', error); + this.showError(); + uiManager.showNotification('加载失败', '无法加载统计数据', 'error'); + } finally { + uiManager.hideLoading(); + } + }, + + /** + * 渲染概览卡片 + */ + renderOverviewCards(statistics) { + document.getElementById('totalProducts').textContent = statistics.totalProducts || 0; + document.getElementById('activeProducts').textContent = statistics.activeProducts || 0; + document.getElementById('totalStock').textContent = statistics.totalStock || 0; + document.getElementById('totalCategories').textContent = statistics.categories?.length || 0; + }, + + /** + * 渲染分类表格 + */ + renderCategoryTable(statistics) { + const tbody = document.getElementById('categoryTableBody'); + const categories = statistics.categories || []; + + if (categories.length === 0) { + tbody.innerHTML = '暂无分类数据'; + return; + } + + tbody.innerHTML = categories.map(category => ` + + + ${category.name} + + + ${category.count || 0} + + + ${category.activeCount || 0} + + + ${category.totalStock || 0} + + + `).join(''); + }, + + /** + * 刷新数据 + */ + async refreshData() { + await this.loadData(); + uiManager.showNotification('成功', '统计数据已刷新', 'success'); + }, + + /** + * 显示错误状态 + */ + showError() { + document.getElementById('emptyState').classList.add('hidden'); + document.getElementById('errorState').classList.remove('hidden'); + } +}; + +// 导出到全局作用域 +window.AdminStatisticsPage = AdminStatisticsPage; \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/pages/admin-system.js b/springboot-multi-port/src/main/resources/static/js/pages/admin-system.js new file mode 100644 index 0000000..407e77c --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/pages/admin-system.js @@ -0,0 +1,400 @@ +/** + * 管理端系统监控页面 + */ + +const AdminSystemPage = { + updateInterval: null, + + /** + * 渲染页面 + */ + async render(container) { + container.innerHTML = ` +
+ +
+

+ + 系统监控 +

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

用户端服务

+ + + 检测中... + +
+
+
+ 端口: + 8082 +
+
+ 响应时间: + --ms +
+
+ 健康检查: + -- +
+
+ 最后检查: + -- +
+
+
+ + +
+
+

管理端服务

+ + + 检测中... + +
+
+
+ 端口: + 8083 +
+
+ 响应时间: + --ms +
+
+ 健康检查: + -- +
+
+ 最后检查: + -- +
+
+
+ + +
+
+

系统信息

+ +
+
+
+ Java版本: + -- +
+
+ 操作系统: + -- +
+
+ CPU核心: + -- +
+
+ 运行时间: + -- +
+
+
+
+ + +
+

+ + 性能监控 +

+
+
+
+ 内存使用 + --% +
+
+
+
+
+ 已用: --MB / 总计: --MB +
+
+ +
+
+ CPU使用率 + --% +
+
+
+
+
+ 当前负载: -- +
+
+ +
+
+ 活跃线程 + -- +
+
+
+
+
+ 最大: -- +
+
+ +
+
+ 请求QPS + -- +
+
+
+
+
+ 总请求: -- +
+
+
+
+
+ `; + + // 加载初始数据 + await this.loadSystemData(); + }, + + /** + * 加载系统数据 + */ + async loadSystemData() { + try { + // 获取系统概览数据 + const overviewResponse = await adminApi.getSystemOverview(); + if (overviewResponse.code === 200) { + this.updateSystemInfo(overviewResponse.data); + } + + // 检查健康状态 + await this.checkHealthStatus(); + + // 更新性能数据 + this.updatePerformanceMetrics(); + + } catch (error) { + console.error('加载系统数据失败:', error); + uiManager.showNotification('加载失败', '无法获取系统数据', 'error'); + } + }, + + /** + * 更新系统信息 + */ + updateSystemInfo(systemData) { + if (systemData.system) { + document.getElementById('javaVersion').textContent = systemData.system.javaVersion || '--'; + document.getElementById('osName').textContent = systemData.system.osName || '--'; + document.getElementById('cpuCores').textContent = systemData.system.availableProcessors || '--'; + } + + // 计算运行时间(这里使用模拟数据) + const uptime = this.calculateUptime(); + document.getElementById('uptime').textContent = uptime; + }, + + /** + * 检查健康状态 + */ + async checkHealthStatus() { + const now = new Date().toLocaleTimeString(); + + // 检查用户端健康状态 + try { + const startTime = Date.now(); + const response = await fetch('http://localhost:8082/health/user'); + const responseTime = Date.now() - startTime; + + document.getElementById('userResponseTime').textContent = `${responseTime}ms`; + document.getElementById('userHealthCheck').textContent = response.ok ? '正常' : '异常'; + document.getElementById('userHealthCheck').className = response.ok ? 'font-medium text-green-600' : 'font-medium text-red-600'; + document.getElementById('userLastCheck').textContent = now; + + const userStatus = document.getElementById('userPortStatus'); + userStatus.className = response.ok ? + 'px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800' : + 'px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800'; + userStatus.innerHTML = response.ok ? + '正常' : + '异常'; + + uiManager.updateConnectionStatus(true); + + } catch (error) { + document.getElementById('userResponseTime').textContent = '--ms'; + document.getElementById('userHealthCheck').textContent = '连接失败'; + document.getElementById('userHealthCheck').className = 'font-medium text-red-600'; + document.getElementById('userLastCheck').textContent = now; + + const userStatus = document.getElementById('userPortStatus'); + userStatus.className = 'px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800'; + userStatus.innerHTML = '异常'; + + uiManager.updateConnectionStatus(false); + } + + // 检查管理端健康状态 + try { + const startTime = Date.now(); + const response = await fetch('http://localhost:8083/health/admin'); + const responseTime = Date.now() - startTime; + + document.getElementById('adminResponseTime').textContent = `${responseTime}ms`; + document.getElementById('adminHealthCheck').textContent = response.ok ? '正常' : '异常'; + document.getElementById('adminHealthCheck').className = response.ok ? 'font-medium text-green-600' : 'font-medium text-red-600'; + document.getElementById('adminLastCheck').textContent = now; + + const adminStatus = document.getElementById('adminPortStatus'); + adminStatus.className = response.ok ? + 'px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800' : + 'px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800'; + adminStatus.innerHTML = response.ok ? + '正常' : + '异常'; + + } catch (error) { + document.getElementById('adminResponseTime').textContent = '--ms'; + document.getElementById('adminHealthCheck').textContent = '连接失败'; + document.getElementById('adminHealthCheck').className = 'font-medium text-red-600'; + document.getElementById('adminLastCheck').textContent = now; + + const adminStatus = document.getElementById('adminPortStatus'); + adminStatus.className = 'px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800'; + adminStatus.innerHTML = '异常'; + } + }, + + /** + * 更新性能指标 + */ + updatePerformanceMetrics() { + // 模拟性能数据(实际项目中应该从真实的监控系统获取) + const memoryUsed = Math.floor(Math.random() * 300) + 200; // 200-500MB + const memoryTotal = 1024; // 1GB + const memoryPercent = Math.round((memoryUsed / memoryTotal) * 100); + + const cpuPercent = Math.floor(Math.random() * 40) + 10; // 10-50% + const threads = Math.floor(Math.random() * 50) + 30; // 30-80 + const maxThreads = 200; + const qps = Math.floor(Math.random() * 100) + 20; // 20-120 + + // 更新内存使用 + document.getElementById('memoryPercent').textContent = `${memoryPercent}%`; + document.getElementById('memoryUsed').textContent = `${memoryUsed}MB`; + document.getElementById('memoryTotal').textContent = `${memoryTotal}MB`; + document.getElementById('memoryBar').style.width = `${memoryPercent}%`; + + // 更新CPU使用率 + document.getElementById('cpuPercent').textContent = `${cpuPercent}%`; + document.getElementById('cpuLoad').textContent = `${cpuPercent}%`; + document.getElementById('cpuBar').style.width = `${cpuPercent}%`; + + // 更新线程数 + document.getElementById('threadsCount').textContent = threads; + document.getElementById('maxThreads').textContent = maxThreads; + document.getElementById('threadsBar').style.width = `${Math.round((threads / maxThreads) * 100)}%`; + + // 更新QPS + document.getElementById('qpsCount').textContent = qps; + document.getElementById('totalRequests').textContent = Math.floor(Math.random() * 10000) + 5000; + document.getElementById('qpsBar').style.width = `${Math.min(Math.round((qps / 200) * 100), 100)}%`; + }, + + /** + * 切换自动刷新 + */ + toggleAutoRefresh() { + const btn = document.getElementById('autoRefreshBtn'); + const text = document.getElementById('autoRefreshText'); + + if (this.updateInterval) { + // 停止自动刷新 + clearInterval(this.updateInterval); + this.updateInterval = null; + btn.className = 'bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg transition'; + text.textContent = '开始自动刷新'; + uiManager.showNotification('提示', '自动刷新已停止', 'info'); + } else { + // 开始自动刷新 + this.updateInterval = setInterval(() => { + this.refreshSystemData(); + }, 10000); // 每10秒刷新一次 + + btn.className = 'bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg transition'; + text.textContent = '停止自动刷新'; + uiManager.showNotification('提示', '自动刷新已开启 (10秒间隔)', 'info'); + } + }, + + /** + * 手动刷新系统数据 + */ + async refreshSystemData() { + await this.loadSystemData(); + }, + + /** + * 计算运行时间 + */ + calculateUptime() { + // 模拟运行时间(实际项目中应该从服务器获取) + const now = Date.now(); + const startTime = now - (Math.random() * 86400000 * 7); // 随机1-7天前 + const uptime = now - startTime; + + const days = Math.floor(uptime / (1000 * 60 * 60 * 24)); + const hours = Math.floor((uptime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((uptime % (1000 * 60 * 60)) / (1000 * 60)); + + if (days > 0) { + return `${days}天 ${hours}小时 ${minutes}分钟`; + } else if (hours > 0) { + return `${hours}小时 ${minutes}分钟`; + } else { + return `${minutes}分钟`; + } + }, + + /** + * 清理资源 + */ + destroy() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } +}; + +// 导出到全局作用域 +window.AdminSystemPage = AdminSystemPage; \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/pages/user-cart.js b/springboot-multi-port/src/main/resources/static/js/pages/user-cart.js new file mode 100644 index 0000000..4fa7236 --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/pages/user-cart.js @@ -0,0 +1,430 @@ +/** + * 用户端购物车页面 + */ + +const UserCartPage = { + /** + * 渲染页面 + */ + async render(container) { + container.innerHTML = ` +
+ +
+

+ + 我的购物车 +

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

购物车商品

+
+
+ +
+
+
+ + +
+
+

订单摘要

+ +
+
+ 商品数量: + 0 +
+
+ 商品总价: + ¥0.00 +
+
+ 运费: + ¥0.00 +
+
+
+ 总计: + ¥0.00 +
+
+
+ + + +
+ +
+
+
+
+ + + + + + +
+ `; + + // 加载购物车数据 + await this.loadCart(); + }, + + /** + * 加载购物车数据 + */ + async loadCart() { + try { + uiManager.showLoading(); + + const userId = appState.get('currentUser.id'); + const response = await userApi.getCart(userId); + + if (response.code === 200) { + const cartData = { + items: response.data, + summary: { + totalAmount: 0, + itemCount: 0 + } + }; + + // 获取购物车统计信息 + try { + const summaryResponse = await userApi.getCartSummary(userId); + if (summaryResponse.code === 200) { + cartData.summary = summaryResponse.data; + } + } catch (error) { + console.error('获取购物车统计失败:', error); + } + + appState.updateCart(cartData); + this.renderCartItems(cartData.items); + this.updateOrderSummary(cartData.summary); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('加载购物车失败:', error); + this.showError(); + uiManager.showNotification('加载失败', '无法加载购物车数据', 'error'); + } finally { + uiManager.hideLoading(); + } + }, + + /** + * 渲染购物车商品列表 + */ + renderCartItems(items) { + const container = document.getElementById('cartItemsContainer'); + const emptyState = document.getElementById('emptyState'); + const errorState = document.getElementById('errorState'); + + // 隐藏状态提示 + emptyState.classList.add('hidden'); + errorState.classList.add('hidden'); + + if (items.length === 0) { + container.innerHTML = ` +
+ +

购物车为空

+ +
+ `; + return; + } + + container.innerHTML = items.map(item => this.createCartItem(item)).join(''); + + // 添加事件监听 + this.attachCartItemEvents(); + }, + + /** + * 创建购物车商品项 + */ + createCartItem(item) { + return ` +
+
+ + ${item.productName} + + +
+

${item.productName}

+

商品ID: ${item.productId}

+

${uiManager.formatPrice(item.price)}

+
+ + +
+ + ${item.quantity} + +
+ + +
+

${uiManager.formatPrice(item.price * item.quantity)}

+ +
+
+
+ `; + }, + + /** + * 更新订单摘要 + */ + updateOrderSummary(summary) { + document.getElementById('totalItems').textContent = summary.itemCount || 0; + document.getElementById('subtotal').textContent = uiManager.formatPrice(summary.totalAmount || 0); + + // 计算运费(满99免运费) + const shipping = (summary.totalAmount || 0) >= 99 ? 0 : 10; + document.getElementById('shipping').textContent = uiManager.formatPrice(shipping); + + // 计算总计 + const total = (summary.totalAmount || 0) + shipping; + document.getElementById('totalAmount').textContent = uiManager.formatPrice(total); + }, + + /** + * 更新商品数量 + */ + async updateQuantity(cartItemId, newQuantity) { + if (newQuantity <= 0) { + this.removeItem(cartItemId); + return; + } + + try { + const userId = appState.get('currentUser.id'); + const response = await userApi.updateCartItem(userId, cartItemId, newQuantity); + + if (response.code === 200) { + uiManager.showNotification('成功', '数量已更新', 'success'); + await this.loadCart(); // 重新加载购物车 + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('更新数量失败:', error); + uiManager.showNotification('失败', '更新数量失败', 'error'); + } + }, + + /** + * 移除购物车商品 + */ + async removeItem(cartItemId) { + if (!confirm('确定要删除这个商品吗?')) { + return; + } + + try { + const userId = appState.get('currentUser.id'); + const response = await userApi.removeFromCart(userId, cartItemId); + + if (response.code === 200) { + uiManager.showNotification('成功', '商品已从购物车移除', 'success'); + await this.loadCart(); // 重新加载购物车 + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('移除商品失败:', error); + uiManager.showNotification('失败', '移除商品失败', 'error'); + } + }, + + /** + * 清空购物车 + */ + async clearCart() { + if (!confirm('确定要清空购物车吗?此操作不可恢复。')) { + return; + } + + try { + const userId = appState.get('currentUser.id'); + const response = await userApi.clearCart(userId); + + if (response.code === 200) { + uiManager.showNotification('成功', '购物车已清空', 'success'); + await this.loadCart(); // 重新加载购物车 + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('清空购物车失败:', error); + uiManager.showNotification('失败', '清空购物车失败', 'error'); + } + }, + + /** + * 结算 + */ + checkout() { + const items = appState.get('cart.items'); + + if (items.length === 0) { + uiManager.showNotification('提示', '购物车为空,无法结算', 'warning'); + return; + } + + // 模拟结算流程 + this.showCheckoutModal(); + }, + + /** + * 显示结算模态框 + */ + showCheckoutModal() { + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'; + modal.innerHTML = ` +
+
+
+

确认订单

+ +
+ +
+
+

+ + 这是一个演示应用,不会进行真实的支付处理。 +

+
+ +
+

收货信息

+
+ + + +
+
+ + +
+
+
+ `; + + document.body.appendChild(modal); + + // 点击背景关闭模态框 + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.remove(); + } + }); + }, + + /** + * 处理结算 + */ + async processCheckout() { + try { + uiManager.showLoading(); + + // 模拟支付处理 + await new Promise(resolve => setTimeout(resolve, 2000)); + + // 清空购物车 + const userId = appState.get('currentUser.id'); + await userApi.clearCart(userId); + + uiManager.showNotification('支付成功', '订单已成功创建!', 'success'); + + // 跳转到商品页面 + setTimeout(() => { + uiManager.switchUserPage('products'); + }, 2000); + + } catch (error) { + console.error('支付处理失败:', error); + uiManager.showNotification('支付失败', '支付处理过程中出现错误', 'error'); + } finally { + uiManager.hideLoading(); + } + }, + + /** + * 显示错误状态 + */ + showError() { + document.getElementById('cartItemsContainer').innerHTML = ''; + document.getElementById('emptyState').classList.add('hidden'); + document.getElementById('errorState').classList.remove('hidden'); + }, + + /** + * 添加购物车商品事件监听 + */ + attachCartItemEvents() { + // 购物车商品事件已通过内联onclick处理 + } +}; + +// 导出到全局作用域 +window.UserCartPage = UserCartPage; \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/pages/user-products.js b/springboot-multi-port/src/main/resources/static/js/pages/user-products.js new file mode 100644 index 0000000..e461c00 --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/pages/user-products.js @@ -0,0 +1,440 @@ +/** + * 用户端商品页面 + */ + +const UserProductsPage = { + /** + * 渲染页面 + */ + async render(container) { + container.innerHTML = ` +
+ +
+

+ + 商品浏览 +

+
+ + +
+
+ + +
+
+ 分类筛选: +
+ +
+
+
+ + +
+
+
+ 排序: + +
+
+ 共 0 件商品 +
+
+
+ + +
+ +
+ + + + + + +
+ `; + + // 加载商品数据 + await this.loadProducts(); + this.updateCartBadge(); + }, + + /** + * 加载商品数据 + */ + async loadProducts() { + try { + uiManager.showLoading(); + + const response = await userApi.getProducts(); + + if (response.code === 200) { + appState.updateProducts(response.data); + this.renderProducts(response.data); + this.renderCategories(response.data); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('加载商品失败:', error); + this.showError(); + uiManager.showNotification('加载失败', '无法加载商品数据', 'error'); + } finally { + uiManager.hideLoading(); + } + }, + + /** + * 渲染商品列表 + */ + renderProducts(products) { + const container = document.getElementById('productsContainer'); + const emptyState = document.getElementById('emptyState'); + const errorState = document.getElementById('errorState'); + const productCount = document.getElementById('productCount'); + + // 隐藏状态提示 + emptyState.classList.add('hidden'); + errorState.classList.add('hidden'); + + if (products.length === 0) { + container.innerHTML = ''; + emptyState.classList.remove('hidden'); + productCount.textContent = '0'; + return; + } + + productCount.textContent = products.length; + + container.innerHTML = products.map(product => this.createProductCard(product)).join(''); + + // 添加商品卡片事件监听 + this.attachProductEvents(); + }, + + /** + * 创建商品卡片 + */ + createProductCard(product) { + const isOutOfStock = product.stock <= 0; + const statusBadge = product.status ? + '在售' : + '下架'; + + return ` +
+
+ ${product.name} +
+ ${statusBadge} +
+ ${isOutOfStock ? '
已售罄
' : ''} +
+
+

${product.name}

+

${product.description}

+
+ ${uiManager.formatPrice(product.price)} + 库存: ${product.stock} +
+
+ + +
+
+
+ `; + }, + + /** + * 渲染分类过滤器 + */ + renderCategories(products) { + const container = document.getElementById('categoryFilters'); + const categories = [...new Set(products.map(p => p.category).filter(Boolean))]; + + container.innerHTML = ` + + ${categories.map(category => ` + + `).join('')} + `; + }, + + /** + * 按分类筛选 + */ + filterByCategory(category) { + // 更新按钮状态 + document.querySelectorAll('.category-btn').forEach(btn => { + if (btn.textContent.trim() === category || (category === '' && btn.textContent.trim() === '全部')) { + btn.className = 'category-btn px-3 py-1 rounded-full bg-blue-500 text-white text-sm'; + } else { + btn.className = 'category-btn px-3 py-1 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 text-sm transition'; + } + }); + + // 筛选商品 + const allProducts = appState.get('products'); + const filtered = category ? allProducts.filter(p => p.category === category) : allProducts; + + this.renderProducts(filtered); + }, + + /** + * 排序商品 + */ + sortProducts() { + const sortValue = document.getElementById('sortSelect').value; + const [sortBy, sortOrder] = sortValue.split('-'); + + const allProducts = appState.get('products'); + const sorted = [...allProducts].sort((a, b) => { + let aVal, bVal; + + switch (sortBy) { + case 'name': + aVal = a.name || ''; + bVal = b.name || ''; + break; + case 'price': + aVal = a.price || 0; + bVal = b.price || 0; + break; + case 'time': + aVal = new Date(a.createTime || 0); + bVal = new Date(b.createTime || 0); + break; + default: + return 0; + } + + if (sortOrder === 'asc') { + return aVal > bVal ? 1 : -1; + } else { + return aVal < bVal ? 1 : -1; + } + }); + + this.renderProducts(sorted); + }, + + /** + * 添加到购物车 + */ + async addToCart(productId) { + try { + const product = appState.get('products').find(p => p.id === productId); + if (!product) { + uiManager.showNotification('错误', '商品不存在', 'error'); + return; + } + + if (product.stock <= 0 || !product.status) { + uiManager.showNotification('提示', '商品已售罄或已下架', 'warning'); + return; + } + + const userId = appState.get('currentUser.id'); + await userApi.addToCart(userId, productId, 1); + + uiManager.showNotification('成功', '商品已添加到购物车', 'success'); + this.updateCartBadge(); + + } catch (error) { + console.error('添加到购物车失败:', error); + uiManager.showNotification('失败', '添加到购物车失败', 'error'); + } + }, + + /** + * 查看商品详情 + */ + async viewProduct(productId) { + try { + const response = await userApi.getProduct(productId); + + if (response.code === 200) { + const product = response.data; + this.showProductModal(product); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('获取商品详情失败:', error); + uiManager.showNotification('失败', '无法获取商品详情', 'error'); + } + }, + + /** + * 显示商品详情模态框 + */ + showProductModal(product) { + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'; + modal.innerHTML = ` +
+
+
+

${product.name}

+ +
+ +
+
+ ${product.name} +
+ +
+
+ ${uiManager.formatPrice(product.price)} +
+ +
+
+ 库存: + ${product.stock} +
+
+ 分类: + ${product.category} +
+
+ 状态: + + ${product.status ? '在售' : '下架'} + +
+
+ +
+

商品描述

+

${product.description}

+
+ + +
+
+
+
+ `; + + document.body.appendChild(modal); + + // 点击背景关闭模态框 + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.remove(); + } + }); + }, + + /** + * 刷新商品列表 + */ + refreshProducts() { + this.loadProducts(); + uiManager.showNotification('提示', '商品列表已刷新', 'info'); + }, + + /** + * 切换到购物车页面 + */ + switchToCart() { + uiManager.switchUserPage('cart'); + }, + + /** + * 更新购物车徽章 + */ + async updateCartBadge() { + try { + const userId = appState.get('currentUser.id'); + const response = await userApi.getCartSummary(userId); + + if (response.code === 200) { + const itemCount = response.data.itemCount || 0; + const badge = document.getElementById('cartBadge'); + + if (itemCount > 0) { + badge.textContent = itemCount > 99 ? '99+' : itemCount; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + } + } catch (error) { + console.error('获取购物车信息失败:', error); + } + }, + + /** + * 显示错误状态 + */ + showError() { + document.getElementById('productsContainer').innerHTML = ''; + document.getElementById('emptyState').classList.add('hidden'); + document.getElementById('errorState').classList.remove('hidden'); + document.getElementById('productCount').textContent = '0'; + }, + + /** + * 添加商品卡片事件监听 + */ + attachProductEvents() { + // 商品卡片事件已通过内联onclick处理,这里可以添加其他事件 + } +}; + +// 导出到全局作用域 +window.UserProductsPage = UserProductsPage; \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/pages/user-search.js b/springboot-multi-port/src/main/resources/static/js/pages/user-search.js new file mode 100644 index 0000000..910d5d5 --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/pages/user-search.js @@ -0,0 +1,203 @@ +/** + * 用户端商品搜索页面 + */ + +const UserSearchPage = { + /** + * 渲染页面 + */ + async render(container) { + container.innerHTML = ` +
+ +
+

+ + 商品搜索 +

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

请输入关键词搜索商品

+
+
+
+ `; + + this.setupSearchInput(); + }, + + /** + * 设置搜索输入框 + */ + setupSearchInput() { + const searchInput = document.getElementById('searchInput'); + searchInput.focus(); + }, + + /** + * 处理搜索按键事件 + */ + handleSearchKeyup(event) { + if (event.key === 'Enter') { + this.performSearch(); + } + }, + + /** + * 执行搜索 + */ + async performSearch() { + const searchInput = document.getElementById('searchInput'); + const keyword = searchInput.value.trim(); + + if (!keyword) { + uiManager.showNotification('提示', '请输入搜索关键词', 'warning'); + return; + } + + await this.searchTerm(keyword); + }, + + /** + * 搜索指定关键词 + */ + async searchTerm(keyword) { + try { + uiManager.showLoading(); + + // 更新搜索框 + document.getElementById('searchInput').value = keyword; + + // 执行API搜索 + const response = await userApi.searchProducts(keyword); + + if (response.code === 200) { + const products = response.data; + this.renderSearchResults(products, keyword); + + if (products.length === 0) { + this.showNoResults(keyword); + } + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('搜索失败:', error); + this.showSearchError(); + uiManager.showNotification('搜索失败', '无法完成搜索,请重试', 'error'); + } finally { + uiManager.hideLoading(); + } + }, + + /** + * 渲染搜索结果 + */ + renderSearchResults(products, keyword) { + const container = document.getElementById('searchResultsContainer'); + + container.innerHTML = ` +
+
+
+

+ 搜索结果: "${keyword}" +

+ 找到 ${products.length} 个商品 +
+
+
+
+ ${products.map(product => this.createSearchResultCard(product)).join('')} +
+
+
+ `; + }, + + /** + * 创建搜索结果卡片 + */ + createSearchResultCard(product) { + const isOutOfStock = product.stock <= 0; + const statusBadge = product.status ? + '在售' : + '下架'; + + return ` +
+
+ ${product.name} +
+

${product.name}

+

${product.description}

+
+ ${uiManager.formatPrice(product.price)} +
+ ${statusBadge} + 库存: ${product.stock} +
+
+
+
+
+ `; + }, + + /** + * 显示无结果状态 + */ + showNoResults(keyword) { + document.getElementById('searchResultsContainer').innerHTML = ` +
+ +

未找到与 "${keyword}" 相关的商品

+

建议尝试其他关键词

+
+ `; + }, + + /** + * 显示搜索错误 + */ + showSearchError() { + document.getElementById('searchResultsContainer').innerHTML = ` + ${uiManager.createErrorState('搜索失败,请重试', 'UserSearchPage.performSearch()')} + `; + } +}; + +// 导出到全局作用域 +window.UserSearchPage = UserSearchPage; \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/state.js b/springboot-multi-port/src/main/resources/static/js/state.js new file mode 100644 index 0000000..1d23f28 --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/state.js @@ -0,0 +1,261 @@ +/** + * 状态管理模块 + * 管理应用的全局状态 + */ + +class AppState { + constructor() { + this.state = { + // 当前服务类型 + currentService: 'user', + + // 当前页面 + currentPage: { + user: 'products', + admin: 'products' + }, + + // 用户数据 + currentUser: { + id: 1, // 模拟用户ID + name: '测试用户' + }, + + // 商品数据 + products: [], + categories: [], + + // 购物车数据 + cart: { + items: [], + summary: { + totalAmount: 0, + itemCount: 0 + } + }, + + // 管理端数据 + adminData: { + allProducts: [], + statistics: {}, + systemInfo: {}, + portStatus: {} + }, + + // UI状态 + ui: { + loading: false, + notifications: [], + theme: 'light' + }, + + // 搜索和过滤 + search: { + keyword: '', + category: '', + sortBy: 'name', + sortOrder: 'asc' + } + }; + + // 订阅者列表 + this.subscribers = []; + } + + /** + * 获取状态 + */ + getState() { + return this.state; + } + + /** + * 获取特定状态 + */ + get(path) { + return this.getNestedValue(this.state, path); + } + + /** + * 设置状态 + */ + setState(updates) { + this.state = { ...this.state, ...updates }; + this.notify(); + } + + /** + * 更新嵌套状态 + */ + updateState(path, value) { + this.setNestedValue(this.state, path, value); + this.notify(); + } + + /** + * 获取嵌套对象的值 + */ + getNestedValue(obj, path) { + return path.split('.').reduce((current, key) => { + return current && current[key] !== undefined ? current[key] : null; + }, obj); + } + + /** + * 设置嵌套对象的值 + */ + setNestedValue(obj, path, value) { + const keys = path.split('.'); + const lastKey = keys.pop(); + const target = keys.reduce((current, key) => { + if (!current[key]) current[key] = {}; + return current[key]; + }, obj); + target[lastKey] = value; + } + + /** + * 订阅状态变化 + */ + subscribe(callback) { + this.subscribers.push(callback); + return () => { + this.subscribers = this.subscribers.filter(sub => sub !== callback); + }; + } + + /** + * 通知所有订阅者 + */ + notify() { + this.subscribers.forEach(callback => callback(this.state)); + } + + /** + * 重置状态 + */ + reset() { + this.state = { + currentService: 'user', + currentPage: { + user: 'products', + admin: 'products' + }, + currentUser: { + id: 1, + name: '测试用户' + }, + products: [], + categories: [], + cart: { + items: [], + summary: { + totalAmount: 0, + itemCount: 0 + } + }, + adminData: { + allProducts: [], + statistics: {}, + systemInfo: {}, + portStatus: {} + }, + ui: { + loading: false, + notifications: [], + theme: 'light' + }, + search: { + keyword: '', + category: '', + sortBy: 'name', + sortOrder: 'asc' + } + }; + this.notify(); + } + + /** + * 切换服务类型 + */ + switchService(service) { + this.updateState('currentService', service); + } + + /** + * 切换页面 + */ + switchPage(service, page) { + this.updateState(`currentPage.${service}`, page); + } + + /** + * 设置加载状态 + */ + setLoading(loading) { + this.updateState('ui.loading', loading); + } + + /** + * 添加通知 + */ + addNotification(notification) { + const notifications = [...this.state.ui.notifications, { + id: Date.now(), + timestamp: new Date(), + ...notification + }]; + this.updateState('ui.notifications', notifications); + + // 自动移除通知 + setTimeout(() => { + this.removeNotification(notification.id); + }, 5000); + } + + /** + * 移除通知 + */ + removeNotification(id) { + const notifications = this.state.ui.notifications.filter(n => n.id !== id); + this.updateState('ui.notifications', notifications); + } + + /** + * 更新商品列表 + */ + updateProducts(products) { + this.updateState('products', products); + + // 提取分类 + const categories = [...new Set(products.map(p => p.category).filter(Boolean))]; + this.updateState('categories', categories); + } + + /** + * 更新购物车 + */ + updateCart(cartData) { + this.updateState('cart', cartData); + } + + /** + * 更新管理端数据 + */ + updateAdminData(key, data) { + this.updateState(`adminData.${key}`, data); + } + + /** + * 更新搜索参数 + */ + updateSearchParams(params) { + this.updateState('search', { ...this.state.search, ...params }); + } +} + +// 创建全局状态实例 +const appState = new AppState(); + +// 导出状态实例 +window.appState = appState; \ No newline at end of file diff --git a/springboot-multi-port/src/main/resources/static/js/ui.js b/springboot-multi-port/src/main/resources/static/js/ui.js new file mode 100644 index 0000000..fa8eac4 --- /dev/null +++ b/springboot-multi-port/src/main/resources/static/js/ui.js @@ -0,0 +1,342 @@ +/** + * UI 交互模块 + * 处理用户界面的交互和显示 + */ + +class UIManager { + constructor() { + this.initEventListeners(); + this.connectButtonEvents(); + } + + /** + * 初始化事件监听器 + */ + initEventListeners() { + // 服务切换标签 + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + this.switchService(e.target.dataset.service); + }); + }); + + // 用户端页面标签 + document.querySelectorAll('.user-tab').forEach(btn => { + btn.addEventListener('click', (e) => { + this.switchUserPage(e.target.dataset.page); + }); + }); + + // 管理端页面标签 + document.querySelectorAll('.admin-tab').forEach(btn => { + btn.addEventListener('click', (e) => { + this.switchAdminPage(e.target.dataset.page); + }); + }); + + // 主题切换 + document.getElementById('themeToggle').addEventListener('click', () => { + this.toggleTheme(); + }); + } + + /** + * 连接按钮事件到页面模块 + */ + connectButtonEvents() { + // 这里会由各个页面模块自己处理按钮事件 + } + + /** + * 切换服务类型 + */ + switchService(service) { + // 更新标签状态 + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('tab-active'); + if (btn.dataset.service === service) { + btn.classList.add('tab-active'); + } + }); + + // 切换内容显示 + document.getElementById('userContent').classList.toggle('hidden', service !== 'user'); + document.getElementById('adminContent').classList.toggle('hidden', service !== 'admin'); + + // 更新状态 + appState.switchService(service); + + // 加载对应页面 + if (service === 'user') { + const currentPage = appState.get('currentPage.user'); + this.switchUserPage(currentPage); + } else { + const currentPage = appState.get('currentPage.admin'); + this.switchAdminPage(currentPage); + } + } + + /** + * 切换用户端页面 + */ + switchUserPage(page) { + // 更新标签状态 + document.querySelectorAll('.user-tab').forEach(btn => { + btn.classList.remove('tab-active'); + if (btn.dataset.page === page) { + btn.classList.add('tab-active'); + } + }); + + // 更新状态 + appState.switchPage('user', page); + + // 加载页面内容 + const container = document.getElementById('userPageContainer'); + this.loadPage('user', page, container); + } + + /** + * 切换管理端页面 + */ + switchAdminPage(page) { + // 更新标签状态 + document.querySelectorAll('.admin-tab').forEach(btn => { + btn.classList.remove('tab-active'); + if (btn.dataset.page === page) { + btn.classList.add('tab-active'); + } + }); + + // 更新状态 + appState.switchPage('admin', page); + + // 加载页面内容 + const container = document.getElementById('adminPageContainer'); + this.loadPage('admin', page, container); + } + + /** + * 加载页面内容 + */ + loadPage(service, page, container) { + // 显示加载状态 + this.showLoading(); + + // 调用对应的页面模块 + if (service === 'user') { + switch (page) { + case 'products': + UserProductsPage.render(container); + break; + case 'cart': + UserCartPage.render(container); + break; + case 'search': + UserSearchPage.render(container); + break; + } + } else if (service === 'admin') { + switch (page) { + case 'products': + AdminProductsPage.render(container); + break; + case 'statistics': + AdminStatisticsPage.render(container); + break; + case 'system': + AdminSystemPage.render(container); + break; + } + } + + // 隐藏加载状态 + this.hideLoading(); + } + + /** + * 显示加载状态 + */ + showLoading() { + document.getElementById('loadingOverlay').classList.remove('hidden'); + appState.setLoading(true); + } + + /** + * 隐藏加载状态 + */ + hideLoading() { + document.getElementById('loadingOverlay').classList.add('hidden'); + appState.setLoading(false); + } + + /** + * 显示通知 + */ + showNotification(title, message, type = 'info') { + const notification = { + title, + message, + type + }; + + appState.addNotification(notification); + + // 显示通知UI + const notificationEl = document.getElementById('notification'); + const iconEl = document.getElementById('notificationIcon'); + const titleEl = document.getElementById('notificationTitle'); + const messageEl = document.getElementById('notificationMessage'); + + // 设置图标 + const icons = { + success: '', + error: '', + warning: '', + info: '' + }; + + iconEl.innerHTML = icons[type] || icons.info; + titleEl.textContent = title; + messageEl.textContent = message; + + // 显示通知 + notificationEl.classList.remove('hidden'); + notificationEl.classList.add('fade-in'); + + // 自动隐藏 + setTimeout(() => { + notificationEl.classList.add('hidden'); + }, 5000); + } + + /** + * 更新连接状态 + */ + updateConnectionStatus(connected) { + const statusEl = document.getElementById('connectionStatus'); + + if (connected) { + statusEl.className = 'text-sm px-3 py-1 rounded-full bg-green-100 text-green-800'; + statusEl.innerHTML = '已连接'; + } else { + statusEl.className = 'text-sm px-3 py-1 rounded-full bg-red-100 text-red-800'; + statusEl.innerHTML = '连接断开'; + } + } + + /** + * 切换主题 + */ + toggleTheme() { + const currentTheme = appState.get('ui.theme'); + const newTheme = currentTheme === 'light' ? 'dark' : 'light'; + + // 更新主题类 + document.body.classList.toggle('dark'); + + // 更新主题图标 + const themeBtn = document.getElementById('themeToggle'); + themeBtn.innerHTML = newTheme === 'light' ? + '' : + ''; + + // 更新状态 + appState.updateState('ui.theme', newTheme); + + // 保存到本地存储 + localStorage.setItem('theme', newTheme); + } + + /** + * 格式化价格 + */ + formatPrice(price) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY' + }).format(price || 0); + } + + /** + * 格式化日期时间 + */ + formatDateTime(dateString) { + if (!dateString) return '-'; + + const date = new Date(dateString); + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }).format(date); + } + + /** + * 创建空状态提示 + */ + createEmptyState(message, icon = 'fa-inbox') { + return ` +
+ +

${message}

+
+ `; + } + + /** + * 创建错误状态提示 + */ + createErrorState(message, onRetry) { + return ` +
+ +

${message}

+ ${onRetry ? ` + + ` : ''} +
+ `; + } + + /** + * 防抖函数 + */ + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + /** + * 节流函数 + */ + throttle(func, limit) { + let inThrottle; + return function() { + const args = arguments; + const context = this; + if (!inThrottle) { + func.apply(context, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; + } +} + +// 创建全局UI管理器实例 +const uiManager = new UIManager(); + +// 导出UI管理器 +window.uiManager = uiManager; \ No newline at end of file From 87adeb5a3dd6a0aeb7c5f69fedfe0604d59dd0a4 Mon Sep 17 00:00:00 2001 From: yuoon Date: Thu, 23 Oct 2025 21:30:08 +0800 Subject: [PATCH 09/22] springboot-form --- springboot-form/.gitignore | 60 ++ springboot-form/README.md | 121 +++ springboot-form/pom.xml | 76 ++ .../dynamicform/DynamicFormApplication.java | 13 + .../dynamicform/config/CorsConfig.java | 21 + .../controller/DynamicFormController.java | 360 +++++++++ .../dynamicform/model/ApiResponse.java | 51 ++ .../example/dynamicform/model/FormConfig.java | 24 + .../example/dynamicform/model/FormField.java | 28 + .../example/dynamicform/model/FormSchema.java | 27 + .../dynamicform/model/FormSubmission.java | 24 + .../dynamicform/model/ValidationError.java | 19 + .../dynamicform/model/ValidationResult.java | 38 + .../service/FormSchemaService.java | 269 +++++++ .../service/FormSubmissionService.java | 131 ++++ .../service/JsonSchemaValidator.java | 391 ++++++++++ .../src/main/resources/application.yml | 19 + .../src/main/resources/static/app.js | 702 ++++++++++++++++++ .../src/main/resources/static/index.html | 136 ++++ 19 files changed, 2510 insertions(+) create mode 100644 springboot-form/.gitignore create mode 100644 springboot-form/README.md create mode 100644 springboot-form/pom.xml create mode 100644 springboot-form/src/main/java/com/example/dynamicform/DynamicFormApplication.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/config/CorsConfig.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/controller/DynamicFormController.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/model/ApiResponse.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/model/FormConfig.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/model/FormField.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/model/FormSchema.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/model/FormSubmission.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/model/ValidationError.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/model/ValidationResult.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/service/FormSchemaService.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/service/FormSubmissionService.java create mode 100644 springboot-form/src/main/java/com/example/dynamicform/service/JsonSchemaValidator.java create mode 100644 springboot-form/src/main/resources/application.yml create mode 100644 springboot-form/src/main/resources/static/app.js create mode 100644 springboot-form/src/main/resources/static/index.html diff --git a/springboot-form/.gitignore b/springboot-form/.gitignore new file mode 100644 index 0000000..bf89600 --- /dev/null +++ b/springboot-form/.gitignore @@ -0,0 +1,60 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iws +*.iml +*.ipr +.vscode/ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Application logs +logs/ +application.log + +# Temporary files +*.tmp +*.temp \ No newline at end of file diff --git a/springboot-form/README.md b/springboot-form/README.md new file mode 100644 index 0000000..7d99542 --- /dev/null +++ b/springboot-form/README.md @@ -0,0 +1,121 @@ +# 动态表单系统 - SpringBoot + JSON Schema + +## 🎯 项目简介 + +这是一个基于 SpringBoot + JSON Schema 构建的动态表单验证系统,实现了"配置驱动开发"的理念。通过 JSON Schema 定义表单结构,系统自动生成前端表单界面和后端验证逻辑,大幅提升开发效率。 + +## ✨ 核心特性 + +- 🔧 **配置驱动**:只需定义 JSON Schema,自动生成完整表单系统 +- ✅ **统一验证**:前后端共享同一套验证逻辑,确保数据一致性 + +## 🏗️ 技术架构 + +### 后端技术栈 +- **SpringBoot 3.2.1** - 核心框架 +- **JSON Schema Validator** - Schema验证引擎 +- **Jackson** - JSON数据处理 + + +## 🚀 快速开始 + +### 环境要求 +- Java 17+ +- Maven 3.6+ + +### 启动步骤 + +1. **克隆项目** +```bash +git clone +cd springboot-form +``` + +2. **编译运行** +```bash +mvn clean spring-boot:run +``` + +3. **访问应用** +``` +浏览器打开: http://localhost:8080 +``` + +## 📝 API接口 + +### 表单管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/forms/schemas` | 获取所有可用表单 | +| GET | `/api/forms/{schemaId}/config` | 获取表单配置 | +| POST | `/api/forms/{schemaId}/submit` | 提交表单数据 | +| POST | `/api/forms/{schemaId}/validate-field` | 实时字段验证 | +| GET | `/api/forms/{schemaId}/submissions` | 获取表单提交数据 | +| GET | `/api/forms/statistics` | 获取系统统计信息 | + +### 请求示例 + +#### 获取表单配置 +```bash +curl -X GET "http://localhost:8080/api/forms/user-registration/config" +``` + +#### 提交表单数据 +```bash +curl -X POST "http://localhost:8080/api/forms/user-registration/submit" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "email": "test@example.com", + "password": "Test123456", + "confirmPassword": "Test123456" + }' +``` + +## 🎨 预置表单 + +系统预置了两个示例表单: + +### 1. 用户注册表单 (`user-registration`) +- 基础信息:用户名、邮箱、密码 +- 个人信息:姓名、手机号、生日 +- 兴趣偏好:多选兴趣标签 +- 订阅设置:新闻通讯订阅 + +### 2. 满意度调查表单 (`satisfaction-survey`) +- 总体满意度评分 +- 服务体验评价 +- 推荐意愿 +- 详细反馈 + +## 🔧 JSON Schema 示例 + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": "用户注册表单", + "required": ["username", "email", "password"], + "properties": { + "username": { + "type": "string", + "title": "用户名", + "minLength": 3, + "maxLength": 20, + "pattern": "^[a-zA-Z0-9_]+$" + }, + "email": { + "type": "string", + "title": "邮箱地址", + "format": "email" + }, + "password": { + "type": "string", + "title": "密码", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,}$" + } + } +} +``` \ No newline at end of file diff --git a/springboot-form/pom.xml b/springboot-form/pom.xml new file mode 100644 index 0000000..1d39d14 --- /dev/null +++ b/springboot-form/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.1 + + + + com.example + dynamic-form + 1.0.0 + Dynamic Form System + SpringBoot + JSON Schema Dynamic Form System + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.networknt + json-schema-validator + 1.0.87 + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.projectlombok + lombok + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/DynamicFormApplication.java b/springboot-form/src/main/java/com/example/dynamicform/DynamicFormApplication.java new file mode 100644 index 0000000..a6308d0 --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/DynamicFormApplication.java @@ -0,0 +1,13 @@ +package com.example.dynamicform; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DynamicFormApplication { + public static void main(String[] args) { + SpringApplication.run(DynamicFormApplication.class, args); + System.out.println("🚀 Dynamic Form System started successfully!"); + System.out.println("📝 Visit: http://localhost:8080"); + } +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/config/CorsConfig.java b/springboot-form/src/main/java/com/example/dynamicform/config/CorsConfig.java new file mode 100644 index 0000000..bbe64b1 --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/config/CorsConfig.java @@ -0,0 +1,21 @@ +package com.example.dynamicform.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * CORS配置 + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .maxAge(3600); + } +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/controller/DynamicFormController.java b/springboot-form/src/main/java/com/example/dynamicform/controller/DynamicFormController.java new file mode 100644 index 0000000..5a24e92 --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/controller/DynamicFormController.java @@ -0,0 +1,360 @@ +package com.example.dynamicform.controller; + +import com.example.dynamicform.model.*; +import com.example.dynamicform.service.FormSchemaService; +import com.example.dynamicform.service.FormSubmissionService; +import com.example.dynamicform.service.JsonSchemaValidator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.*; + +/** + * 动态表单管理控制器 + */ +@RestController +@RequestMapping("/api/forms") +@Slf4j +@CrossOrigin(origins = "*") +public class DynamicFormController { + + private final FormSchemaService schemaService; + private final JsonSchemaValidator validator; + private final FormSubmissionService submissionService; + private final ObjectMapper objectMapper; + + @Autowired + public DynamicFormController(FormSchemaService schemaService, + JsonSchemaValidator validator, + FormSubmissionService submissionService, + ObjectMapper objectMapper) { + this.schemaService = schemaService; + this.validator = validator; + this.submissionService = submissionService; + this.objectMapper = objectMapper; + } + + /** + * 获取表单配置(用于前端渲染) + */ + @GetMapping("/{schemaId}/config") + public ResponseEntity> getFormConfig(@PathVariable String schemaId) { + try { + FormSchema schema = schemaService.getActiveSchema(schemaId); + if (schema == null) { + return ResponseEntity.ok(ApiResponse.error("表单不存在或已禁用")); + } + + FormConfig config = buildFormConfig(schema); + return ResponseEntity.ok(ApiResponse.success(config)); + } catch (Exception e) { + log.error("Failed to get form config for schemaId: {}", schemaId, e); + return ResponseEntity.ok(ApiResponse.error("获取表单配置失败: " + e.getMessage())); + } + } + + /** + * 提交表单数据 + */ + @PostMapping("/{schemaId}/submit") + public ResponseEntity> submitForm( + @PathVariable String schemaId, + @RequestBody JsonNode formData) { + + try { + // 验证表单数据 + ValidationResult result = validator.validate(schemaId, formData); + if (!result.isValid()) { + return ResponseEntity.ok(ApiResponse.error("表单验证失败", result.getErrors())); + } + + // 保存提交数据 + FormSubmission submission = submissionService.saveSubmission( + schemaId, formData.toString(), "anonymous"); + + Map responseData = new HashMap<>(); + responseData.put("submissionId", submission.getId()); + responseData.put("status", submission.getStatus()); + responseData.put("submittedAt", submission.getSubmittedAt()); + + return ResponseEntity.ok(ApiResponse.success("提交成功", responseData)); + } catch (Exception e) { + log.error("Failed to submit form for schemaId: {}", schemaId, e); + return ResponseEntity.ok(ApiResponse.error("提交失败: " + e.getMessage())); + } + } + + /** + * 实时验证单个字段 + */ + @PostMapping("/{schemaId}/validate-field") + public ResponseEntity> validateField( + @PathVariable String schemaId, + @RequestBody FieldValidationRequest request) { + + try { + ValidationResult result = validator.validateField( + schemaId, request.getFieldName(), request.getFieldValue()); + + return ResponseEntity.ok(ApiResponse.success(result)); + } catch (Exception e) { + log.error("Failed to validate field for schemaId: {}, field: {}", schemaId, request.getFieldName(), e); + return ResponseEntity.ok(ApiResponse.error("字段验证失败: " + e.getMessage())); + } + } + + /** + * 获取所有可用的表单Schema + */ + @GetMapping("/schemas") + public ResponseEntity>> getAllSchemas() { + try { + List schemas = schemaService.getAllSchemas(); + List schemaInfos = schemas.stream() + .filter(schema -> Boolean.TRUE.equals(schema.getActive())) + .map(schema -> SchemaInfo.builder() + .schemaId(schema.getSchemaId()) + .name(schema.getName()) + .description(schema.getDescription()) + .category(schema.getCategory()) + .version(schema.getVersion()) + .build()) + .toList(); + + return ResponseEntity.ok(ApiResponse.success(schemaInfos)); + } catch (Exception e) { + log.error("Failed to get all schemas", e); + return ResponseEntity.ok(ApiResponse.error("获取表单列表失败: " + e.getMessage())); + } + } + + /** + * 创建新的表单Schema + */ + @PostMapping("/schemas") + public ResponseEntity> createSchema(@RequestBody CreateSchemaRequest request) { + try { + // 验证Schema定义的格式有效性 + try { + objectMapper.readTree(request.getSchemaDefinition()); + } catch (Exception e) { + return ResponseEntity.ok(ApiResponse.error("JSON Schema格式无效: " + e.getMessage())); + } + + FormSchema schema = FormSchema.builder() + .schemaId(request.getSchemaId()) + .name(request.getName()) + .description(request.getDescription()) + .schemaDefinition(request.getSchemaDefinition()) + .category(request.getCategory()) + .build(); + + FormSchema savedSchema = schemaService.saveSchema(schema); + return ResponseEntity.ok(ApiResponse.success("创建成功", savedSchema)); + } catch (Exception e) { + log.error("Failed to create schema", e); + return ResponseEntity.ok(ApiResponse.error("创建表单失败: " + e.getMessage())); + } + } + + /** + * 获取表单提交数据 + */ + @GetMapping("/{schemaId}/submissions") + public ResponseEntity>> getSubmissions(@PathVariable String schemaId) { + try { + List submissions = submissionService.getSubmissionsBySchema(schemaId); + return ResponseEntity.ok(ApiResponse.success(submissions)); + } catch (Exception e) { + log.error("Failed to get submissions for schemaId: {}", schemaId, e); + return ResponseEntity.ok(ApiResponse.error("获取提交数据失败: " + e.getMessage())); + } + } + + /** + * 获取系统统计信息 + */ + @GetMapping("/statistics") + public ResponseEntity>> getStatistics() { + try { + Map statistics = submissionService.getStatistics(); + return ResponseEntity.ok(ApiResponse.success(statistics)); + } catch (Exception e) { + log.error("Failed to get statistics", e); + return ResponseEntity.ok(ApiResponse.error("获取统计信息失败: " + e.getMessage())); + } + } + + /** + * 构建表单配置 + */ + private FormConfig buildFormConfig(FormSchema schema) { + try { + log.debug("Building form config for schema: {}", schema.getSchemaId()); + JsonNode schemaNode = objectMapper.readTree(schema.getSchemaDefinition()); + List fields = parseFields(schemaNode); + + return FormConfig.builder() + .schemaId(schema.getSchemaId()) + .name(schema.getName()) + .description(schema.getDescription()) + .schema(schemaNode) + .fields(fields) + .build(); + } catch (Exception e) { + log.error("Failed to build form config for schema: {}", schema.getSchemaId(), e); + throw new RuntimeException("Failed to build form config: " + e.getMessage(), e); + } + } + + /** + * 解析Schema字段(支持嵌套对象) + */ + private List parseFields(JsonNode schemaNode) { + List fields = new ArrayList<>(); + parseFieldsRecursive(schemaNode, fields, new ArrayList<>()); + return fields; + } + + /** + * 递归解析Schema字段 + */ + private void parseFieldsRecursive(JsonNode schemaNode, List fields, List parentPath) { + JsonNode properties = schemaNode.get("properties"); + JsonNode required = schemaNode.get("required"); + + if (properties != null) { + Iterator> fieldIterator = properties.fields(); + while (fieldIterator.hasNext()) { + Map.Entry entry = fieldIterator.next(); + String fieldName = entry.getKey(); + JsonNode fieldSchema = entry.getValue(); + + // 构建完整的字段路径 + String fullPath = parentPath.isEmpty() ? fieldName : String.join(".", parentPath) + "." + fieldName; + + boolean isRequired = false; + try { + isRequired = required != null && required.isArray() && + Arrays.asList(objectMapper.treeToValue(required, String[].class)).contains(fullPath); + } catch (JsonProcessingException e) { + log.warn("Failed to parse required fields: {}", fullPath, e); + } + + FormField field = FormField.builder() + .name(fullPath) + .type(fieldSchema.has("type") ? fieldSchema.get("type").asText() : "string") + .title(fieldSchema.has("title") ? fieldSchema.get("title").asText() : fieldName) + .description(fieldSchema.has("description") ? fieldSchema.get("description").asText() : "") + .required(isRequired) + .build(); + + // 如果字段是对象类型,递归解析其属性,但不添加对象字段本身 + if ("object".equals(field.getType())) { + List childPath = new ArrayList<>(parentPath); + childPath.add(fieldName); + parseFieldsRecursive(fieldSchema, fields, childPath); + } else { + // 只添加非对象类型的字段到字段列表 + parseValidationRules(field, fieldSchema); + fields.add(field); + } + } + } + } + + /** + * 解析验证规则 + */ + private void parseValidationRules(FormField field, JsonNode fieldSchema) { + if (fieldSchema.has("minLength")) { + field.setMinLength(fieldSchema.get("minLength").asInt()); + } + if (fieldSchema.has("maxLength")) { + field.setMaxLength(fieldSchema.get("maxLength").asInt()); + } + if (fieldSchema.has("minimum")) { + field.setMinimum(fieldSchema.get("minimum").asDouble()); + } + if (fieldSchema.has("maximum")) { + field.setMaximum(fieldSchema.get("maximum").asDouble()); + } + if (fieldSchema.has("pattern")) { + field.setPattern(fieldSchema.get("pattern").asText()); + } + if (fieldSchema.has("format")) { + field.setFormat(fieldSchema.get("format").asText()); + } + if (fieldSchema.has("enum")) { + try { + String[] enumValues = objectMapper.treeToValue(fieldSchema.get("enum"), String[].class); + field.setEnumValues(enumValues); + } catch (Exception e) { + log.warn("Failed to parse enum values for field: {}", field.getName(), e); + } + } else if ("array".equals(field.getType()) && fieldSchema.has("items") && fieldSchema.get("items").has("enum")) { + // 处理数组类型字段的枚举值(在items中定义) + try { + String[] enumValues = objectMapper.treeToValue(fieldSchema.get("items").get("enum"), String[].class); + field.setEnumValues(enumValues); + } catch (Exception e) { + log.warn("Failed to parse array enum values for field: {}", field.getName(), e); + } + } + } + + /** + * 字段验证请求 + */ + public static class FieldValidationRequest { + private String fieldName; + private JsonNode fieldValue; + + public String getFieldName() { return fieldName; } + public void setFieldName(String fieldName) { this.fieldName = fieldName; } + public JsonNode getFieldValue() { return fieldValue; } + public void setFieldValue(JsonNode fieldValue) { this.fieldValue = fieldValue; } + } + + /** + * Schema信息 + */ + @lombok.Data + @lombok.Builder + public static class SchemaInfo { + private String schemaId; + private String name; + private String description; + private String category; + private Integer version; + } + + /** + * 创建Schema请求 + */ + public static class CreateSchemaRequest { + private String schemaId; + private String name; + private String description; + private String schemaDefinition; + private String category; + + // Getters and Setters + public String getSchemaId() { return schemaId; } + public void setSchemaId(String schemaId) { this.schemaId = schemaId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getSchemaDefinition() { return schemaDefinition; } + public void setSchemaDefinition(String schemaDefinition) { this.schemaDefinition = schemaDefinition; } + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + } +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/model/ApiResponse.java b/springboot-form/src/main/java/com/example/dynamicform/model/ApiResponse.java new file mode 100644 index 0000000..3fb155d --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/model/ApiResponse.java @@ -0,0 +1,51 @@ +package com.example.dynamicform.model; + +import lombok.Data; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +/** + * 统一API响应格式 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ApiResponse { + private boolean success; + private String message; + private T data; + private Object errors; + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse success(T data) { + return ApiResponse.builder() + .success(true) + .message("操作成功") + .data(data) + .build(); + } + + public static ApiResponse error(String message, Object errors) { + return ApiResponse.builder() + .success(false) + .message(message) + .errors(errors) + .build(); + } + + public static ApiResponse error(String message) { + return ApiResponse.builder() + .success(false) + .message(message) + .build(); + } +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/model/FormConfig.java b/springboot-form/src/main/java/com/example/dynamicform/model/FormConfig.java new file mode 100644 index 0000000..fff8fb6 --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/model/FormConfig.java @@ -0,0 +1,24 @@ +package com.example.dynamicform.model; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 表单配置信息(用于前端渲染) + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class FormConfig { + private String schemaId; + private String name; + private String description; + private JsonNode schema; + private List fields; +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/model/FormField.java b/springboot-form/src/main/java/com/example/dynamicform/model/FormField.java new file mode 100644 index 0000000..2467a1a --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/model/FormField.java @@ -0,0 +1,28 @@ +package com.example.dynamicform.model; + +import lombok.Data; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +/** + * 表单字段信息 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class FormField { + private String name; // 字段名 + private String type; // 字段类型 + private String title; // 字段标题 + private String description; // 字段描述 + private boolean required; // 是否必填 + private Integer minLength; // 最小长度 + private Integer maxLength; // 最大长度 + private Double minimum; // 最小值 + private Double maximum; // 最大值 + private String pattern; // 正则表达式 + private String format; // 格式(email, date等) + private String[] enumValues; // 枚举值 +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/model/FormSchema.java b/springboot-form/src/main/java/com/example/dynamicform/model/FormSchema.java new file mode 100644 index 0000000..f405e83 --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/model/FormSchema.java @@ -0,0 +1,27 @@ +package com.example.dynamicform.model; + +import lombok.Data; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 表单Schema定义实体 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class FormSchema { + private String schemaId; // 表单唯一标识 + private String name; // 表单名称 + private String description; // 表单描述 + private String schemaDefinition; // JSON Schema定义 + private String category; // 表单分类 + private Integer version; // 版本号 + private Boolean active; // 是否启用 + private LocalDateTime createdAt; // 创建时间 + private LocalDateTime updatedAt; // 更新时间 +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/model/FormSubmission.java b/springboot-form/src/main/java/com/example/dynamicform/model/FormSubmission.java new file mode 100644 index 0000000..0a40400 --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/model/FormSubmission.java @@ -0,0 +1,24 @@ +package com.example.dynamicform.model; + +import lombok.Data; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 表单提交数据实体 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class FormSubmission { + private Long id; // 提交ID + private String schemaId; // 关联的表单Schema + private String formData; // 用户提交的JSON数据 + private String submitterId; // 提交者ID + private String status; // 提交状态:pending, approved, rejected + private LocalDateTime submittedAt; // 提交时间 +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/model/ValidationError.java b/springboot-form/src/main/java/com/example/dynamicform/model/ValidationError.java new file mode 100644 index 0000000..e6b186f --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/model/ValidationError.java @@ -0,0 +1,19 @@ +package com.example.dynamicform.model; + +import lombok.Data; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +/** + * 验证错误信息 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ValidationError { + private String field; + private String message; + private String code; +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/model/ValidationResult.java b/springboot-form/src/main/java/com/example/dynamicform/model/ValidationResult.java new file mode 100644 index 0000000..588ebca --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/model/ValidationResult.java @@ -0,0 +1,38 @@ +package com.example.dynamicform.model; + +import lombok.Data; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 验证结果封装类 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ValidationResult { + private boolean valid; + private List errors; + + public static ValidationResult failed(String message) { + return ValidationResult.builder() + .valid(false) + .errors(List.of(ValidationError.builder() + .field("_global") + .message(message) + .code("VALIDATION_FAILED") + .build())) + .build(); + } + + public static ValidationResult success() { + return ValidationResult.builder() + .valid(true) + .errors(List.of()) + .build(); + } +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/service/FormSchemaService.java b/springboot-form/src/main/java/com/example/dynamicform/service/FormSchemaService.java new file mode 100644 index 0000000..bd81fc4 --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/service/FormSchemaService.java @@ -0,0 +1,269 @@ +package com.example.dynamicform.service; + +import com.example.dynamicform.model.FormSchema; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 表单Schema管理服务(基于内存Map存储) + */ +@Service +@Slf4j +public class FormSchemaService { + + // 使用内存Map存储Schema定义 + private final Map schemaStore = new ConcurrentHashMap<>(); + private final Map> categoryStore = new ConcurrentHashMap<>(); + + public FormSchemaService() { + // 初始化一些示例Schema + initializeDefaultSchemas(); + } + + /** + * 获取Schema + */ + public FormSchema getSchema(String schemaId) { + return schemaStore.get(schemaId); + } + + /** + * 获取激活的Schema + */ + public FormSchema getActiveSchema(String schemaId) { + FormSchema schema = schemaStore.get(schemaId); + return (schema != null && Boolean.TRUE.equals(schema.getActive())) ? schema : null; + } + + /** + * 创建或更新Schema + */ + public FormSchema saveSchema(FormSchema schema) { + if (schema.getSchemaId() == null || schema.getSchemaId().trim().isEmpty()) { + throw new IllegalArgumentException("Schema ID cannot be null or empty"); + } + + // 设置时间戳 + LocalDateTime now = LocalDateTime.now(); + if (schema.getCreatedAt() == null) { + schema.setCreatedAt(now); + } + schema.setUpdatedAt(now); + + // 默认值设置 + if (schema.getActive() == null) { + schema.setActive(true); + } + if (schema.getVersion() == null) { + schema.setVersion(1); + } + + // 保存到存储 + schemaStore.put(schema.getSchemaId(), schema); + + // 更新分类索引 + if (schema.getCategory() != null) { + categoryStore.computeIfAbsent(schema.getCategory(), k -> new ArrayList<>()).add(schema); + } + + log.info("Saved schema: {} - {}", schema.getSchemaId(), schema.getName()); + return schema; + } + + /** + * 删除Schema + */ + public boolean deleteSchema(String schemaId) { + FormSchema schema = schemaStore.remove(schemaId); + if (schema != null && schema.getCategory() != null) { + List schemas = categoryStore.get(schema.getCategory()); + if (schemas != null) { + schemas.removeIf(s -> s.getSchemaId().equals(schemaId)); + } + } + log.info("Deleted schema: {}", schemaId); + return schema != null; + } + + /** + * 获取所有Schema + */ + public List getAllSchemas() { + return new ArrayList<>(schemaStore.values()); + } + + /** + * 根据分类获取Schema + */ + public List getSchemasByCategory(String category) { + return categoryStore.getOrDefault(category, new ArrayList<>()); + } + + /** + * 获取所有分类 + */ + public Set getAllCategories() { + return categoryStore.keySet(); + } + + /** + * 初始化默认Schema + */ + private void initializeDefaultSchemas() { + // 用户注册表单Schema + String userRegistrationSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": "用户注册表单", + "description": "新用户注册信息收集表单", + "required": ["username", "email", "password", "confirmPassword"], + "properties": { + "username": { + "type": "string", + "title": "用户名", + "description": "3-20位字母、数字或下划线", + "minLength": 3, + "maxLength": 20, + "pattern": "^[a-zA-Z0-9_]+$" + }, + "email": { + "type": "string", + "title": "邮箱地址", + "description": "请输入有效的邮箱地址", + "format": "email" + }, + "password": { + "type": "string", + "title": "密码", + "description": "至少8位,包含大小写字母和数字", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\\\d)[a-zA-Z\\\\d@$!%*?&]{8,}$" + }, + "confirmPassword": { + "type": "string", + "title": "确认密码", + "description": "请再次输入密码", + "minLength": 8 + }, + "profile": { + "type": "object", + "title": "个人信息", + "properties": { + "firstName": { + "type": "string", + "title": "名字", + "maxLength": 50 + }, + "lastName": { + "type": "string", + "title": "姓氏", + "maxLength": 50 + }, + "phone": { + "type": "string", + "title": "手机号码", + "description": "请输入11位手机号码", + "pattern": "^1[3-9]\\\\d{9}$" + }, + "birthDate": { + "type": "string", + "format": "date", + "title": "出生日期" + } + } + }, + "preferences": { + "type": "array", + "title": "兴趣偏好", + "description": "请选择您的兴趣爱好", + "items": { + "type": "string", + "enum": ["technology", "sports", "music", "reading", "travel", "food"] + }, + "uniqueItems": true + }, + "newsletter": { + "type": "boolean", + "title": "订阅新闻通讯", + "description": "是否接收我们的最新资讯", + "default": false + } + } + } + """; + + FormSchema userRegSchema = FormSchema.builder() + .schemaId("user-registration") + .name("用户注册表单") + .description("新用户注册信息收集表单") + .schemaDefinition(userRegistrationSchema) + .category("用户管理") + .version(1) + .active(true) + .build(); + + saveSchema(userRegSchema); + + // 满意度调查表单Schema + String satisfactionSurveySchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": "客户满意度调查", + "description": "请对我们的服务进行评价", + "required": ["overallRating", "service"], + "properties": { + "overallRating": { + "type": "integer", + "title": "总体满意度", + "description": "请为我们的整体服务打分(1-5分)", + "minimum": 1, + "maximum": 5 + }, + "service": { + "type": "string", + "title": "服务体验", + "description": "您对我们服务的整体评价如何?", + "enum": ["非常满意", "满意", "一般", "不满意", "非常不满意"] + }, + "recommendation": { + "type": "boolean", + "title": "推荐意愿", + "description": "您是否愿意向朋友推荐我们的服务?" + }, + "feedback": { + "type": "string", + "title": "详细反馈", + "description": "请告诉我们您的想法和建议", + "maxLength": 1000 + }, + "contactEmail": { + "type": "string", + "title": "联系邮箱(可选)", + "description": "如需我们回复,请留下邮箱", + "format": "email" + } + } + } + """; + + FormSchema surveySchema = FormSchema.builder() + .schemaId("satisfaction-survey") + .name("客户满意度调查") + .description("客户满意度调查表单") + .schemaDefinition(satisfactionSurveySchema) + .category("问卷调查") + .version(1) + .active(true) + .build(); + + saveSchema(surveySchema); + + log.info("Initialized {} default schemas", schemaStore.size()); + } +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/service/FormSubmissionService.java b/springboot-form/src/main/java/com/example/dynamicform/service/FormSubmissionService.java new file mode 100644 index 0000000..d40a520 --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/service/FormSubmissionService.java @@ -0,0 +1,131 @@ +package com.example.dynamicform.service; + +import com.example.dynamicform.model.FormSubmission; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 表单提交管理服务(基于内存Map存储) + */ +@Service +@Slf4j +public class FormSubmissionService { + + // 使用内存Map存储提交数据 + private final Map> submissionStore = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + /** + * 保存表单提交数据 + */ + public FormSubmission saveSubmission(String schemaId, String formData, String submitterId) { + FormSubmission submission = FormSubmission.builder() + .id(idGenerator.getAndIncrement()) + .schemaId(schemaId) + .formData(formData) + .submitterId(submitterId != null ? submitterId : "anonymous") + .status("pending") + .submittedAt(LocalDateTime.now()) + .build(); + + // 保存到存储 + submissionStore.computeIfAbsent(schemaId, k -> new ArrayList<>()).add(submission); + + log.info("Saved submission for schema: {}, submission ID: {}", schemaId, submission.getId()); + return submission; + } + + /** + * 获取表单的所有提交数据 + */ + public List getSubmissionsBySchema(String schemaId) { + return submissionStore.getOrDefault(schemaId, new ArrayList<>()); + } + + /** + * 根据ID获取提交数据 + */ + public FormSubmission getSubmissionById(Long id) { + for (List submissions : submissionStore.values()) { + for (FormSubmission submission : submissions) { + if (submission.getId().equals(id)) { + return submission; + } + } + } + return null; + } + + /** + * 获取所有提交数据 + */ + public List getAllSubmissions() { + List allSubmissions = new ArrayList<>(); + for (List submissions : submissionStore.values()) { + allSubmissions.addAll(submissions); + } + return allSubmissions; + } + + /** + * 删除提交数据 + */ + public boolean deleteSubmission(Long id) { + for (Map.Entry> entry : submissionStore.entrySet()) { + List submissions = entry.getValue(); + boolean removed = submissions.removeIf(submission -> submission.getId().equals(id)); + if (removed) { + log.info("Deleted submission: {}", id); + return true; + } + } + return false; + } + + /** + * 更新提交状态 + */ + public boolean updateSubmissionStatus(Long id, String status) { + FormSubmission submission = getSubmissionById(id); + if (submission != null) { + submission.setStatus(status); + log.info("Updated submission {} status to: {}", id, status); + return true; + } + return false; + } + + /** + * 获取统计信息 + */ + public Map getStatistics() { + Map stats = new HashMap<>(); + + int totalSubmissions = getAllSubmissions().size(); + int totalSchemas = submissionStore.size(); + + Map statusCount = new HashMap<>(); + Map schemaCount = new HashMap<>(); + + for (FormSubmission submission : getAllSubmissions()) { + statusCount.put(submission.getStatus(), + statusCount.getOrDefault(submission.getStatus(), 0L) + 1); + } + + for (Map.Entry> entry : submissionStore.entrySet()) { + schemaCount.put(entry.getKey(), entry.getValue().size()); + } + + stats.put("totalSubmissions", totalSubmissions); + stats.put("totalSchemas", totalSchemas); + stats.put("statusCount", statusCount); + stats.put("schemaCount", schemaCount); + + return stats; + } +} \ No newline at end of file diff --git a/springboot-form/src/main/java/com/example/dynamicform/service/JsonSchemaValidator.java b/springboot-form/src/main/java/com/example/dynamicform/service/JsonSchemaValidator.java new file mode 100644 index 0000000..8c1927e --- /dev/null +++ b/springboot-form/src/main/java/com/example/dynamicform/service/JsonSchemaValidator.java @@ -0,0 +1,391 @@ +package com.example.dynamicform.service; + +import com.example.dynamicform.model.ValidationResult; +import com.example.dynamicform.model.ValidationError; +import com.example.dynamicform.model.FormSchema; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * JSON Schema验证核心组件 + */ +@Service +@Slf4j +public class JsonSchemaValidator { + + private final ObjectMapper objectMapper; + private final Map schemaCache = new ConcurrentHashMap<>(); + private final FormSchemaService schemaService; + + @Autowired + public JsonSchemaValidator(ObjectMapper objectMapper, FormSchemaService schemaService) { + this.objectMapper = objectMapper; + this.schemaService = schemaService; + } + + /** + * 验证表单数据 + */ + public ValidationResult validate(String schemaId, JsonNode data) { + try { + // 获取schema定义 + FormSchema formSchema = schemaService.getSchema(schemaId); + if (formSchema == null) { + return ValidationResult.failed("Schema not found: " + schemaId); + } + + JsonNode schemaNode = objectMapper.readTree(formSchema.getSchemaDefinition()); + + // 过滤掉空的可选字段,只保留有值的字段和必填字段 + ObjectNode filteredData = filterOptionalFields(data, schemaNode); + + JsonSchema schema = getSchema(schemaId); + Set validationMessages = schema.validate(filteredData); + + if (validationMessages.isEmpty()) { + return ValidationResult.success(); + } + + List errors = new ArrayList<>(); + for (ValidationMessage msg : validationMessages) { + // 过滤掉由于可选字段为空导致的验证错误 + String path = msg.getPath(); + String fieldName = extractFieldName(path); + + if (isEmptyOptionalFieldError(fieldName, data, schemaNode)) { + continue; // 跳过空的可选字段错误 + } + + errors.add(ValidationError.builder() + .field(path) + .message(msg.getMessage()) + .code(msg.getType()) + .build()); + } + + return ValidationResult.builder() + .valid(errors.isEmpty()) + .errors(errors) + .build(); + + } catch (Exception e) { + log.error("Schema validation failed for schemaId: {}", schemaId, e); + return ValidationResult.failed("Schema验证失败: " + e.getMessage()); + } + } + + /** + * 过滤掉空的可选字段 + */ + private ObjectNode filterOptionalFields(JsonNode data, JsonNode schemaNode) { + ObjectNode filtered = objectMapper.createObjectNode(); + JsonNode required = schemaNode.get("required"); + JsonNode properties = schemaNode.get("properties"); + + if (properties == null) { + return filtered; + } + + // 获取必填字段列表 + Set requiredFields = new HashSet<>(); + if (required != null && required.isArray()) { + for (JsonNode field : required) { + requiredFields.add(field.asText()); + } + } + + // 复制数据 + Iterator> fields = data.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String fieldName = entry.getKey(); + JsonNode fieldValue = entry.getValue(); + + // 特殊处理对象类型字段 + if (fieldValue.isObject()) { + JsonNode fieldSchema = properties.get(fieldName); + if (fieldSchema != null && fieldSchema.has("type") && "object".equals(fieldSchema.get("type").asText())) { + // 对于对象类型字段,递归过滤其子字段 + ObjectNode filteredObject = filterObjectFields(fieldValue, fieldSchema, requiredFields.contains(fieldName)); + if (!filteredObject.isEmpty() || requiredFields.contains(fieldName)) { + filtered.set(fieldName, filteredObject); + } + continue; + } + } + + // 如果是必填字段或者字段有值,则保留 + if (requiredFields.contains(fieldName) || !isEmptyValue(fieldValue)) { + filtered.set(fieldName, fieldValue); + } + } + + return filtered; + } + + /** + * 过滤对象字段的子字段 + */ + private ObjectNode filterObjectFields(JsonNode objectData, JsonNode objectSchema, boolean isRequired) { + ObjectNode filteredObject = objectMapper.createObjectNode(); + JsonNode properties = objectSchema.get("properties"); + + if (properties == null || !objectData.isObject()) { + return filteredObject; + } + + // 对象字段没有子字段的必填要求(因为对象本身不是必填的) + // 但需要检查子字段是否有值 + + Iterator> subFields = objectData.fields(); + while (subFields.hasNext()) { + Map.Entry subEntry = subFields.next(); + String subFieldName = subEntry.getKey(); + JsonNode subFieldValue = subEntry.getValue(); + + // 保留有值的子字段 + if (!isEmptyValue(subFieldValue)) { + filteredObject.set(subFieldName, subFieldValue); + } + } + + return filteredObject; + } + + /** + * 检查是否为空值 + */ + private boolean isEmptyValue(JsonNode value) { + if (value == null || value.isNull()) { + return true; + } + if (value.isTextual() && value.asText().trim().isEmpty()) { + return true; + } + if (value.isArray() && value.size() == 0) { + return true; + } + if (value.isObject() && value.size() == 0) { + return true; + } + return false; + } + + /** + * 从路径中提取字段名 + */ + private String extractFieldName(String path) { + if (path.startsWith("$.") || path.startsWith("/")) { + path = path.substring(2); + } + return path.split("\\.")[0].split("/")[0]; + } + + /** + * 检查是否是空的可选字段错误 + */ + private boolean isEmptyOptionalFieldError(String fieldName, JsonNode originalData, JsonNode schemaNode) { + // 检查字段是否为必填 + JsonNode required = schemaNode.get("required"); + if (required != null && required.isArray()) { + for (JsonNode reqField : required) { + if (reqField.asText().equals(fieldName)) { + return false; // 是必填字段,不是空可选字段错误 + } + } + } + + // 处理嵌套对象字段路径(如 profile.firstName) + if (fieldName.contains(".")) { + String[] pathParts = fieldName.split("\\."); + String parentField = pathParts[0]; + String childField = pathParts[1]; + + // 检查父对象字段是否为必填 + if (required != null && required.isArray()) { + for (JsonNode reqField : required) { + if (reqField.asText().equals(parentField)) { + return false; // 父字段是必填的 + } + } + } + + // 检查子字段是否为空 + JsonNode parentValue = originalData.get(parentField); + if (parentValue != null && parentValue.isObject()) { + JsonNode childValue = parentValue.get(childField); + return isEmptyValue(childValue); + } + return true; // 父对象不存在或为空 + } + + // 检查字段在原数据中是否为空 + JsonNode fieldValue = originalData.get(fieldName); + return isEmptyValue(fieldValue); + } + + /** + * 验证单个字段 + */ + public ValidationResult validateField(String schemaId, String fieldName, JsonNode fieldValue) { + try { + FormSchema formSchema = schemaService.getSchema(schemaId); + if (formSchema == null) { + return ValidationResult.failed("Schema not found: " + schemaId); + } + + JsonNode schemaNode = objectMapper.readTree(formSchema.getSchemaDefinition()); + JsonNode fieldDefinition = getFieldDefinition(schemaNode.get("properties"), fieldName); + + if (fieldDefinition == null) { + return ValidationResult.success(); // 字段不存在,视为通过 + } + + // 跳过对象类型字段的验证,只验证叶子节点字段 + if (fieldDefinition.has("type") && "object".equals(fieldDefinition.get("type").asText())) { + return ValidationResult.success(); // 对象类型字段不需要单独验证 + } + + // 创建简单的字段验证 schema + ObjectNode fieldSchema = createSimpleFieldSchema(fieldDefinition); + + // 构建临时数据对象,字段名简化为 "field" + ObjectNode data = objectMapper.createObjectNode(); + data.set("field", fieldValue); + + // 使用字段专用的 schema 进行验证 + JsonSchema jsonSchema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(fieldSchema); + + Set validationMessages = jsonSchema.validate(data); + + if (validationMessages.isEmpty()) { + return ValidationResult.success(); + } + + List errors = new ArrayList<>(); + for (ValidationMessage msg : validationMessages) { + // 将路径中的 "field" 替换为实际的字段名 + String path = msg.getPath().replace("$.field", "$." + fieldName); + String message = msg.getMessage(); + + // 特殊处理邮箱格式错误,简化错误信息 + if (message.contains("email") || message.contains("RFC 5321")) { + message = "邮箱格式不正确"; + } + + errors.add(ValidationError.builder() + .field(path) + .message(message) + .code(msg.getType()) + .build()); + } + + return ValidationResult.builder() + .valid(false) + .errors(errors) + .build(); + + } catch (Exception e) { + log.error("Field validation failed for schemaId: {}, field: {}", schemaId, fieldName, e); + return ValidationResult.failed("字段验证失败: " + e.getMessage()); + } + } + + /** + * 创建简单的字段验证 schema + */ + private ObjectNode createSimpleFieldSchema(JsonNode fieldDefinition) { + ObjectNode schema = objectMapper.createObjectNode(); + schema.put("$schema", "https://json-schema.org/draft/2020-12/schema"); + schema.put("type", "object"); + + ObjectNode properties = objectMapper.createObjectNode(); + properties.set("field", fieldDefinition); + + schema.set("properties", properties); + + // 如果字段是必填的,添加到 required 数组 + ArrayNode required = objectMapper.createArrayNode(); + required.add("field"); + schema.set("required", required); + + return schema; + } + + /** + * 获取字段定义(支持嵌套路径) + */ + private JsonNode getFieldDefinition(JsonNode properties, String fieldName) { + if (properties == null) { + return null; + } + + String[] pathParts = fieldName.split("\\."); + JsonNode current = properties; + + for (int i = 0; i < pathParts.length - 1; i++) { + if (current.has(pathParts[i])) { + JsonNode node = current.get(pathParts[i]); + if (node.has("properties")) { + current = node.get("properties"); + } else { + return null; + } + } else { + return null; + } + } + + String finalFieldName = pathParts[pathParts.length - 1]; + return current.has(finalFieldName) ? current.get(finalFieldName) : null; + } + + /** + * 获取缓存的JsonSchema + */ + private JsonSchema getSchema(String schemaId) throws Exception { + return schemaCache.computeIfAbsent(schemaId, k -> { + try { + FormSchema formSchema = schemaService.getSchema(k); + if (formSchema == null) { + throw new IllegalArgumentException("Schema not found: " + k); + } + + JsonNode schemaNode = objectMapper.readTree(formSchema.getSchemaDefinition()); + return JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schemaNode); + } catch (Exception e) { + throw new RuntimeException("Failed to parse schema", e); + } + }); + } + + /** + * 清除缓存 + */ + public void clearCache(String schemaId) { + schemaCache.remove(schemaId); + log.info("Cleared cache for schema: {}", schemaId); + } + + /** + * 清除所有缓存 + */ + public void clearAllCache() { + schemaCache.clear(); + log.info("Cleared all schema cache"); + } +} \ No newline at end of file diff --git a/springboot-form/src/main/resources/application.yml b/springboot-form/src/main/resources/application.yml new file mode 100644 index 0000000..6feeb59 --- /dev/null +++ b/springboot-form/src/main/resources/application.yml @@ -0,0 +1,19 @@ +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: dynamic-form-system + +# 自定义配置 +form: + cache: + max-size: 1000 + expire-minutes: 30 + +logging: + level: + com.example.dynamicform: DEBUG + org.springframework.web: DEBUG \ No newline at end of file diff --git a/springboot-form/src/main/resources/static/app.js b/springboot-form/src/main/resources/static/app.js new file mode 100644 index 0000000..6588303 --- /dev/null +++ b/springboot-form/src/main/resources/static/app.js @@ -0,0 +1,702 @@ +// 全局变量 +const API_BASE_URL = '/api/forms'; +let currentFormSchema = null; +let formFields = new Map(); + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', function() { + console.log('🚀 动态表单系统启动'); + loadFormList(); + + // 设置表单提交事件 + document.getElementById('dynamicForm').addEventListener('submit', function(e) { + e.preventDefault(); + submitForm(); + }); +}); + +/** + * 加载表单列表 + */ +async function loadFormList() { + try { + const response = await fetch(`${API_BASE_URL}/schemas`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + if (result.success) { + renderFormList(result.data); + } else { + showError('formList', result.message); + } + } catch (error) { + console.error('加载表单列表失败:', error); + showError('formList', '网络错误,请稍后重试'); + } +} + +/** + * 渲染表单列表 + */ +function renderFormList(schemas) { + const formListContainer = document.getElementById('formList'); + + if (schemas.length === 0) { + formListContainer.innerHTML = ` +
+ +

暂无可用表单

+
+ `; + return; + } + + formListContainer.innerHTML = schemas.map(schema => ` +
+
+
+ +
+
+

${schema.name}

+

${schema.description}

+
+ + ${schema.category} + + + v${schema.version} + +
+
+
+
+ `).join(''); +} + +/** + * 加载表单配置 + */ +async function loadForm(schemaId) { + try { + console.log('Loading form for schema:', schemaId); + + const response = await fetch(`${API_BASE_URL}/${schemaId}/config`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + console.log('Form config response:', result); + + if (result.success) { + currentFormSchema = result.data; + showForm(); + renderForm(result.data); + } else { + alert('加载表单失败: ' + result.message); + } + } catch (error) { + console.error('加载表单失败:', error); + alert('网络错误,请稍后重试'); + } +} + +/** + * 显示表单容器 + */ +function showForm() { + const formContainer = document.getElementById('formContainer'); + formContainer.classList.remove('hidden'); + formContainer.scrollIntoView({ behavior: 'smooth' }); +} + +/** + * 渲染表单 + */ +function renderForm(formConfig) { + console.log('Rendering form with data:', formConfig); + + // 设置表单标题和描述 + document.getElementById('formTitle').textContent = formConfig.name || '表单'; + document.getElementById('formDescription').textContent = formConfig.description || ''; + + // 清空之前的内容 + const dynamicForm = document.getElementById('dynamicForm'); + dynamicForm.innerHTML = ''; + + // 清空字段映射 + formFields.clear(); + + // 生成表单字段 + formConfig.fields.forEach(field => { + console.log('Creating field:', field.name, field.type, field.enumValues); // 调试信息 + const fieldElement = createFormField(field); + dynamicForm.appendChild(fieldElement); + formFields.set(field.name, field); + }); + + console.log('Form rendered successfully'); + console.log('All fields in formFields:', Array.from(formFields.keys())); // 调试信息 +} + +/** + * 创建表单字段 + */ +function createFormField(field) { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'space-y-2'; + + let html = ''; + + // 字段标签 + const requiredMark = field.required ? '*' : ''; + + if (field.type !== 'boolean') { + html += ` + + `; + } + + // 根据字段类型创建输入控件 + const inputHtml = createInputHtml(field); + html += inputHtml; + + // 字段描述 + if (field.type !== 'boolean' && field.description) { + html += ` +

${field.description}

+ `; + } + + // 错误提示容器 + html += ` + + `; + + fieldDiv.innerHTML = html; + + // 添加实时验证事件 + const input = fieldDiv.querySelector('input, select, textarea'); + if (input) { + input.addEventListener('blur', () => validateField(field.name)); + input.addEventListener('input', () => clearFieldError(field.name)); + } + + return fieldDiv; +} + +/** + * 创建输入控件HTML + */ +function createInputHtml(field) { + const attributes = { + name: field.name, + id: field.name, + class: 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent' + }; + + if (field.required) { + attributes.required = true; + } + + const buildAttrs = (attrs) => { + return Object.entries(attrs) + .map(([key, value]) => `${key}="${value}"`) + .join(' '); + }; + + switch (field.type) { + case 'string': + if (field.format === 'email') { + return ``; + } else if (field.format === 'date') { + return ``; + } else if (field.pattern && field.pattern.includes('\\d')) { + return ``; + } else if (field.name.toLowerCase().includes('password')) { + return ``; + } else { + return ``; + } + + case 'integer': + case 'number': + return ``; + + case 'boolean': + return ` +
+ + +
+ `; + + case 'array': + if (field.enumValues && field.enumValues.length > 0) { + return ` + +

按住 Ctrl/Cmd 键可多选

+ `; + } + break; + + default: + return ``; + } + + return ''; +} + +/** + * 验证单个字段 + */ +async function validateField(fieldName) { + const field = formFields.get(fieldName); + if (!field) return true; + + const inputElement = document.getElementById(fieldName); + if (!inputElement) { + console.warn('Input element not found for field:', fieldName); + return true; + } + + let value; + + if (inputElement.type === 'checkbox') { + value = inputElement.checked; + } else if (inputElement.type === 'number') { + value = inputElement.value ? Number(inputElement.value) : null; + } else if (inputElement.type === 'select-multiple') { + value = Array.from(inputElement.selectedOptions).map(option => option.value); + } else { + value = inputElement.value; + } + + // 前端基础验证 + let isValid = true; + let errorMessage = ''; + + // 必填验证 + if (field.required && (value === null || value === '' || (Array.isArray(value) && value.length === 0))) { + isValid = false; + errorMessage = `${field.title}不能为空`; + } + + // 数组类型特殊验证 + if (isValid && Array.isArray(value) && field.type === 'array') { + if (field.enumValues && value.length > 0) { + // 验证数组中的每个值是否都在枚举范围内 + const invalidValues = value.filter(v => !field.enumValues.includes(v)); + if (invalidValues.length > 0) { + isValid = false; + errorMessage = `${field.title}包含了无效选项`; + } + } + } + + // 长度验证 + if (isValid && typeof value === 'string') { + if (field.minLength && value.length < field.minLength) { + isValid = false; + errorMessage = `${field.title}至少需要${field.minLength}个字符`; + } + if (field.maxLength && value.length > field.maxLength) { + isValid = false; + errorMessage = `${field.title}不能超过${field.maxLength}个字符`; + } + } + + // 数值范围验证 + if (isValid && typeof value === 'number') { + if (field.minimum !== null && value < field.minimum) { + isValid = false; + errorMessage = `${field.title}不能小于${field.minimum}`; + } + if (field.maximum !== null && value > field.maximum) { + isValid = false; + errorMessage = `${field.title}不能大于${field.maximum}`; + } + } + + // 正则表达式验证 - 只对非空值进行验证 + if (isValid && field.pattern && typeof value === 'string' && value.trim() !== '') { + try { + const regex = new RegExp(field.pattern); + if (!regex.test(value)) { + isValid = false; + errorMessage = `${field.title}格式不正确`; + } + } catch (e) { + console.warn('正则表达式无效:', field.pattern); + } + } + + // 后端验证 - 只对非空值或必填字段进行验证 + if (isValid && currentFormSchema && (field.required || (value !== null && value !== '' && !(Array.isArray(value) && value.length === 0)))) { + try { + const response = await fetch(`${API_BASE_URL}/${currentFormSchema.schemaId}/validate-field`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fieldName: fieldName, + fieldValue: value + }) + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && !result.data.valid) { + isValid = false; + errorMessage = result.data.errors[0]?.message || `${field.title}验证失败`; + } + } + } catch (error) { + console.warn('后端验证失败:', error); + } + } + + // 显示或清除错误 + if (!isValid) { + showFieldError(fieldName, errorMessage); + } else { + clearFieldError(fieldName); + } + + return isValid; +} + +/** + * 显示字段错误 + */ +function showFieldError(fieldName, message) { + const errorElement = document.getElementById(`error-${fieldName}`); + const inputElement = document.getElementById(fieldName); + + if (errorElement) { + errorElement.querySelector('.error-message').textContent = message; + errorElement.classList.remove('hidden'); + } + + if (inputElement) { + inputElement.classList.add('border-red-500'); + } +} + +/** + * 清除字段错误 + */ +function clearFieldError(fieldName) { + const errorElement = document.getElementById(`error-${fieldName}`); + const inputElement = document.getElementById(fieldName); + + if (errorElement) { + errorElement.classList.add('hidden'); + } + + if (inputElement) { + inputElement.classList.remove('border-red-500'); + } +} + +/** + * 提交表单 + */ +async function submitForm(event) { + if (event) { + event.preventDefault(); + } + + if (!currentFormSchema) return; + + // 验证所有字段 + let isValid = true; + for (const fieldName of formFields.keys()) { + const fieldValid = await validateField(fieldName); + if (!fieldValid) { + isValid = false; + } + } + + if (!isValid) { + alert('请修正表单中的错误后再提交'); + return; + } + + // 收集表单数据 + const formData = collectFormData(); + + // 禁用提交按钮 + const submitButton = document.querySelector('button[type="submit"]'); + const originalText = submitButton.innerHTML; + submitButton.disabled = true; + submitButton.innerHTML = '
提交中...'; + + try { + const response = await fetch(`${API_BASE_URL}/${currentFormSchema.schemaId}/submit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + if (result.success) { + showSuccessMessage('表单提交成功!提交ID: ' + result.data.submissionId); + resetForm(); + } else { + alert('提交失败: ' + result.message); + } + } catch (error) { + console.error('提交表单失败:', error); + alert('网络错误,请稍后重试'); + } finally { + // 恢复提交按钮 + submitButton.disabled = false; + submitButton.innerHTML = originalText; + } +} + +/** + * 收集表单数据 + */ +function collectFormData() { + const data = {}; + + formFields.forEach((field, fieldName) => { + const inputElement = document.getElementById(fieldName); + + console.log(`Processing field: ${fieldName}, element found:`, !!inputElement); // 调试信息 + if (!inputElement) { + console.warn(`Input element not found for field: ${fieldName}`); // 调试信息 + return; + } + + let value; + if (inputElement.type === 'checkbox') { + value = inputElement.checked; + } else if (inputElement.type === 'number') { + value = inputElement.value ? Number(inputElement.value) : null; + } else if (inputElement.type === 'select-multiple') { + value = Array.from(inputElement.selectedOptions).map(option => option.value); + } else { + value = inputElement.value ? inputElement.value.trim() : null; + } + + // 空值处理 - 对于可选字段,如果值为空字符串则设为null + // 但数组类型字段除外,空数组应该保留 + if (value === '' && !Array.isArray(value)) { + value = null; + } + + // 处理嵌套对象 + if (fieldName.includes('.')) { + const parts = fieldName.split('.'); + let current = data; + + // 创建嵌套对象结构 + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]]) { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + + current[parts[parts.length - 1]] = value; + } else { + data[fieldName] = value; + } + + console.log(`Field ${fieldName}:`, value, 'Type:', typeof value, 'Is Array:', Array.isArray(value)); // 调试输出 + }); + + console.log('Collected form data:', data); // 调试输出 + return data; +} + +/** + * 重置表单 + */ +function resetForm() { + const form = document.getElementById('dynamicForm'); + if (form) { + form.reset(); + } + + // 清除所有错误提示 + formFields.forEach((field, fieldName) => { + clearFieldError(fieldName); + }); + + hideSuccessMessage(); +} + +/** + * 关闭表单 + */ +function closeForm() { + const formContainer = document.getElementById('formContainer'); + formContainer.classList.add('hidden'); + resetForm(); + hideSuccessMessage(); + currentFormSchema = null; +} + +/** + * 显示成功消息 + */ +function showSuccessMessage(message) { + const successElement = document.getElementById('successMessage'); + const successText = document.getElementById('successText'); + + successText.textContent = message; + successElement.classList.remove('hidden'); + + // 3秒后自动隐藏 + setTimeout(() => { + hideSuccessMessage(); + }, 3000); +} + +/** + * 隐藏成功消息 + */ +function hideSuccessMessage() { + document.getElementById('successMessage').classList.add('hidden'); +} + +/** + * 显示统计信息 + */ +async function showStatistics() { + try { + const response = await fetch(`${API_BASE_URL}/statistics`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + if (result.success) { + renderStatistics(result.data); + document.getElementById('statisticsModal').classList.remove('hidden'); + } else { + alert('获取统计信息失败: ' + result.message); + } + } catch (error) { + console.error('获取统计信息失败:', error); + alert('网络错误,请稍后重试'); + } +} + +/** + * 渲染统计信息 + */ +function renderStatistics(stats) { + const content = document.getElementById('statisticsContent'); + + content.innerHTML = ` +
+
+
+
+ +
+
+

总提交数

+

${stats.totalSubmissions}

+
+
+
+ +
+
+
+ +
+
+

可用表单

+

${stats.totalSchemas}

+
+
+
+
+ +
+

提交状态分布

+
+ ${Object.entries(stats.statusCount).map(([status, count]) => ` +
+ ${getStatusText(status)} + + ${count} + +
+ `).join('')} +
+
+ `; +} + +/** + * 获取状态文本 + */ +function getStatusText(status) { + const statusMap = { + 'pending': '待处理', + 'approved': '已通过', + 'rejected': '已拒绝' + }; + return statusMap[status] || status; +} + +/** + * 关闭统计信息 + */ +function closeStatistics() { + document.getElementById('statisticsModal').classList.add('hidden'); +} + +/** + * 显示错误信息 + */ +function showError(containerId, message) { + const container = document.getElementById(containerId); + container.innerHTML = ` +
+ +

${message}

+ +
+ `; +} \ No newline at end of file diff --git a/springboot-form/src/main/resources/static/index.html b/springboot-form/src/main/resources/static/index.html new file mode 100644 index 0000000..e5900dd --- /dev/null +++ b/springboot-form/src/main/resources/static/index.html @@ -0,0 +1,136 @@ + + + + + + 动态表单系统 - SpringBoot + JSON Schema + + + + + + + + +
+
+
+
+ +
+

动态表单系统

+

基于 SpringBoot + JSON Schema

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

+ + 选择表单 +

+
+
+
+

正在加载表单列表...

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

+ + 动态表单系统 - 基于 SpringBoot + JSON Schema 构建 +

+
+
+
+ + + + \ No newline at end of file From dac970ac9de718cb11baf45503baed6a6ff112c6 Mon Sep 17 00:00:00 2001 From: yuoon Date: Wed, 29 Oct 2025 21:29:06 +0800 Subject: [PATCH 10/22] timingwheel --- springboot-timingwheel/README.md | 86 ++ springboot-timingwheel/pom.xml | 89 ++ .../SpringbootTimingwheelApplication.java | 20 + .../timingwheel/config/MetricsConfig.java | 19 + .../timingwheel/config/TimingWheelConfig.java | 24 + .../example/timingwheel/config/WebConfig.java | 22 + .../controller/TimingWheelController.java | 246 +++++ .../com/example/timingwheel/model/Slot.java | 99 ++ .../example/timingwheel/model/TimerTask.java | 33 + .../timingwheel/model/TimerTaskWrapper.java | 92 ++ .../model/TimingWheelProperties.java | 56 + .../service/TimingWheelService.java | 218 ++++ .../example/timingwheel/util/TimingWheel.java | 341 ++++++ .../src/main/resources/application.yml | 23 + .../src/main/resources/static/app.js | 994 ++++++++++++++++++ .../src/main/resources/static/index.html | 495 +++++++++ .../src/main/resources/static/styles.css | 143 +++ 17 files changed, 3000 insertions(+) create mode 100644 springboot-timingwheel/README.md create mode 100644 springboot-timingwheel/pom.xml create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/SpringbootTimingwheelApplication.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/config/MetricsConfig.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/config/TimingWheelConfig.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/config/WebConfig.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/controller/TimingWheelController.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/model/Slot.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimerTask.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimerTaskWrapper.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimingWheelProperties.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/service/TimingWheelService.java create mode 100644 springboot-timingwheel/src/main/java/com/example/timingwheel/util/TimingWheel.java create mode 100644 springboot-timingwheel/src/main/resources/application.yml create mode 100644 springboot-timingwheel/src/main/resources/static/app.js create mode 100644 springboot-timingwheel/src/main/resources/static/index.html create mode 100644 springboot-timingwheel/src/main/resources/static/styles.css diff --git a/springboot-timingwheel/README.md b/springboot-timingwheel/README.md new file mode 100644 index 0000000..c394fa9 --- /dev/null +++ b/springboot-timingwheel/README.md @@ -0,0 +1,86 @@ +# Spring Boot 时间轮 + +这是一个基于Spring Boot的时间轮(Timing Wheel)实现项目,提供了完整的时间轮功能和可视化的监控界面。 + +## 🚀 项目特性 + +- ✅ **高效时间轮算法**:O(1)时间复杂度的任务调度 +- ✅ **前后端分离架构**:独立的前端页面,纯静态部署 +- ✅ **实时可视化监控**:动态时间轮图表、任务状态分布、性能统计 +- ✅ **完整的RESTful API**:支持任务的CRUD操作 + +## 🚀 快速启动 + +### 1. 启动后端服务 + +```bash +cd springboot-timingwheel +mvn spring-boot:run -Dmaven.test.skip=true +``` + +后端服务将启动在:http://localhost:8080 + +### 2. 访问前端页面 + +直接打开 `http://localhost:8080/index.html` 文件即可访问监控页面。 + +## 📊 功能特性 + +### 时间轮核心功能 +- **高效调度**: O(1)时间复杂度的任务添加和删除 +- **批量处理**: 同一槽位的多个任务可以批量触发 +- **多层支持**: 理论支持多层时间轮处理不同精度需求 + +### 监控界面功能 +- **实时统计**: 总任务数、已完成、失败、活跃任务数 +- **时间轮可视化**: 圆形时间轮实时动画展示 +- **任务管理**: 创建、取消、查看任务详情 +- **状态监控**: 任务状态分布饼图 +- **配置管理**: API地址配置、刷新间隔调整 + +### API接口 +- `GET /api/timingwheel/stats` - 获取时间轮统计信息 +- `GET /api/timingwheel/execution-stats` - 获取执行统计 +- `GET /api/timingwheel/tasks` - 获取活跃任务列表 +- `POST /api/timingwheel/tasks/sample` - 创建示例任务 +- `POST /api/timingwheel/tasks/batch` - 批量创建任务 +- `DELETE /api/timingwheel/tasks/{taskId}` - 取消任务 +- `POST /api/timingwheel/cleanup` - 清理已完成任务 + +## ⚙️ 配置说明 + +### 后端配置 (application.yml) +```yaml +timingwheel: + config: + slot-size: 512 # 槽位数量 + tick-duration: 100 # 时间间隔(毫秒) + worker-threads: 4 # 工作线程数 + enable-multi-wheel: true # 启用多层时间轮 + enable-metrics: true # 启用监控指标 + task-timeout: 30000 # 任务超时时间(毫秒) +``` + +## 🎯 使用示例 + +### 创建单个任务 +```javascript +// 前端调用 +const result = await window.apiManager.createSampleTask('simple', 2000); + +// 后端API调用 +curl -X POST http://localhost:8080/api/timingwheel/tasks/sample \ + -H "Content-Type: application/json" \ + -d '{"type": "simple", "delay": 2000}' +``` + +### 批量创建任务 +```javascript +// 前端调用 +const result = await window.apiManager.createBatchTasks(10, 1000, 5000); + +// 后端API调用 +curl -X POST http://localhost:8080/api/timingwheel/tasks/batch \ + -H "Content-Type: application/json" \ + -d '{"count": 10, "minDelay": 1000, "maxDelay": 5000}' +``` \ No newline at end of file diff --git a/springboot-timingwheel/pom.xml b/springboot-timingwheel/pom.xml new file mode 100644 index 0000000..9eb1685 --- /dev/null +++ b/springboot-timingwheel/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.example + springboot-timingwheel + 1.0.0 + Spring Boot Timing Wheel + Timing Wheel implementation with Spring Boot + + + 17 + 1.12.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-validation + + + + + io.micrometer + micrometer-registry-prometheus + + + + + org.projectlombok + lombok + true + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/SpringbootTimingwheelApplication.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/SpringbootTimingwheelApplication.java new file mode 100644 index 0000000..9b0cee1 --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/SpringbootTimingwheelApplication.java @@ -0,0 +1,20 @@ +package com.example.timingwheel; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Spring Boot应用程序主类 + */ +@SpringBootApplication +public class SpringbootTimingwheelApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringbootTimingwheelApplication.class, args); + System.out.println("========================================="); + System.out.println("Timing Wheel Application Started!"); + System.out.println("访问地址: http://localhost:8080"); + System.out.println("API文档: http://localhost:8080/api/timingwheel/stats"); + System.out.println("========================================="); + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/config/MetricsConfig.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/config/MetricsConfig.java new file mode 100644 index 0000000..6b9ff82 --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/config/MetricsConfig.java @@ -0,0 +1,19 @@ +package com.example.timingwheel.config; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 监控指标配置 + */ +@Configuration +public class MetricsConfig { + + @Bean + public MeterRegistryCustomizer metricsCommonTags() { + return registry -> registry.config().commonTags("application", "springboot-timingwheel"); + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/config/TimingWheelConfig.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/config/TimingWheelConfig.java new file mode 100644 index 0000000..5b9bcd6 --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/config/TimingWheelConfig.java @@ -0,0 +1,24 @@ +package com.example.timingwheel.config; + +import com.example.timingwheel.model.TimingWheelProperties; +import com.example.timingwheel.util.TimingWheel; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 时间轮配置类 + */ +@Slf4j +@Configuration +@EnableConfigurationProperties(TimingWheelProperties.class) +public class TimingWheelConfig { + + @Bean + public TimingWheel timingWheel(TimingWheelProperties properties, MeterRegistry meterRegistry) { + log.info("Creating timing wheel with properties: {}", properties); + return new TimingWheel(properties, meterRegistry); + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/config/WebConfig.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/config/WebConfig.java new file mode 100644 index 0000000..ca53068 --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/config/WebConfig.java @@ -0,0 +1,22 @@ +package com.example.timingwheel.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web配置类 - 配置CORS跨域支持 + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns("*") // 允许所有来源 + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的HTTP方法 + .allowedHeaders("*") // 允许所有请求头 + .allowCredentials(false) // 不允许携带凭证 + .maxAge(3600); // 预检请求缓存时间 + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/controller/TimingWheelController.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/controller/TimingWheelController.java new file mode 100644 index 0000000..ae59fec --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/controller/TimingWheelController.java @@ -0,0 +1,246 @@ +package com.example.timingwheel.controller; + +import com.example.timingwheel.model.TimerTaskWrapper; +import com.example.timingwheel.service.TimingWheelService; +import com.example.timingwheel.util.TimingWheel; +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; + +/** + * 时间轮控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/timingwheel") +@CrossOrigin(origins = "*") +public class TimingWheelController { + + @Autowired + private TimingWheelService timingWheelService; + + /** + * 获取时间轮统计信息 + */ + @GetMapping("/stats") + public ResponseEntity getStats() { + TimingWheel.TimingWheelStats stats = timingWheelService.getStats(); + return ResponseEntity.ok(stats); + } + + /** + * 获取任务执行统计 + */ + @GetMapping("/execution-stats") + public ResponseEntity getExecutionStats() { + TimingWheelService.TaskExecutionStats stats = timingWheelService.getExecutionStats(); + return ResponseEntity.ok(stats); + } + + /** + * 获取所有活跃任务 + */ + @GetMapping("/tasks") + public ResponseEntity> getActiveTasks() { + List tasks = timingWheelService.getActiveTasks(); + return ResponseEntity.ok(tasks); + } + + /** + * 获取特定任务信息 + */ + @GetMapping("/tasks/{taskId}") + public ResponseEntity getTask(@PathVariable String taskId) { + TimerTaskWrapper task = timingWheelService.getTaskInfo(taskId); + if (task != null) { + return ResponseEntity.ok(task); + } else { + return ResponseEntity.notFound().build(); + } + } + + /** + * 创建示例任务 + */ + @PostMapping("/tasks/sample") + public ResponseEntity> createSampleTask(@RequestBody Map request) { + try { + String type = (String) request.getOrDefault("type", "simple"); + long delay = ((Number) request.getOrDefault("delay", 1000)).longValue(); + + String taskId = timingWheelService.createSampleTask(type, delay); + + Map response = new HashMap<>(); + response.put("taskId", taskId); + response.put("type", type); + response.put("delay", delay); + response.put("message", "Task created successfully"); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("Failed to create sample task", e); + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + /** + * 批量创建任务 + */ + @PostMapping("/tasks/batch") + public ResponseEntity> createBatchTasks(@RequestBody Map request) { + try { + int count = (Integer) request.getOrDefault("count", 10); + long minDelay = ((Number) request.getOrDefault("minDelay", 1000)).longValue(); + long maxDelay = ((Number) request.getOrDefault("maxDelay", 10000)).longValue(); + + List taskIds = timingWheelService.createBatchTasks(count, minDelay, maxDelay); + + Map response = new HashMap<>(); + response.put("taskIds", taskIds); + response.put("count", taskIds.size()); + response.put("message", "Batch tasks created successfully"); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("Failed to create batch tasks", e); + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + /** + * 取消任务 + */ + @DeleteMapping("/tasks/{taskId}") + public ResponseEntity> cancelTask(@PathVariable String taskId) { + boolean cancelled = timingWheelService.cancelTask(taskId); + Map response = new HashMap<>(); + response.put("taskId", taskId); + response.put("cancelled", cancelled); + response.put("message", cancelled ? "Task cancelled successfully" : "Task not found or already completed"); + + if (cancelled) { + return ResponseEntity.ok(response); + } else { + return ResponseEntity.notFound().build(); + } + } + + /** + * 清理已完成的任务 + */ + @PostMapping("/cleanup") + public ResponseEntity> cleanupTasks() { + int removedCount = timingWheelService.cleanupCompletedTasks(); + Map response = new HashMap<>(); + response.put("removedCount", removedCount); + response.put("message", "Cleaned up " + removedCount + " completed tasks"); + + return ResponseEntity.ok(response); + } + + /** + * 创建自定义任务 + */ + @PostMapping("/tasks/custom") + public ResponseEntity> createCustomTask(@RequestBody Map request) { + try { + String description = (String) request.getOrDefault("description", "Custom task"); + long delay = ((Number) request.getOrDefault("delay", 1000)).longValue(); + String action = (String) request.getOrDefault("action", "log"); + + String taskId = timingWheelService.scheduleTask(() -> { + switch (action.toLowerCase()) { + case "log": + log.info("Custom task executed: {} at {}", description, java.time.LocalDateTime.now()); + break; + case "calc": + performCalculation(description); + break; + case "sleep": + performSleep(description); + break; + default: + log.info("Unknown action: {} for task: {}", action, description); + } + }, delay, description); + + Map response = new HashMap<>(); + response.put("taskId", taskId); + response.put("description", description); + response.put("delay", delay); + response.put("action", action); + response.put("message", "Custom task created successfully"); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("Failed to create custom task", e); + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + /** + * 压力测试 + */ + @PostMapping("/stress-test") + public ResponseEntity> stressTest(@RequestBody Map request) { + try { + int taskCount = (Integer) request.getOrDefault("taskCount", 1000); + long minDelay = ((Number) request.getOrDefault("minDelay", 100)).longValue(); + long maxDelay = ((Number) request.getOrDefault("maxDelay", 5000)).longValue(); + + long startTime = System.currentTimeMillis(); + List taskIds = timingWheelService.createBatchTasks(taskCount, minDelay, maxDelay); + long endTime = System.currentTimeMillis(); + + Map response = new HashMap<>(); + response.put("taskCount", taskIds.size()); + response.put("creationTime", endTime - startTime); + response.put("throughput", taskIds.size() * 1000.0 / (endTime - startTime)); + response.put("message", "Stress test completed successfully"); + + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("Failed to perform stress test", e); + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + /** + * 获取系统信息 + */ + @GetMapping("/system-info") + public ResponseEntity> getSystemInfo() { + Runtime runtime = Runtime.getRuntime(); + Map info = new HashMap<>(); + info.put("availableProcessors", runtime.availableProcessors()); + info.put("freeMemory", runtime.freeMemory()); + info.put("totalMemory", runtime.totalMemory()); + info.put("maxMemory", runtime.maxMemory()); + info.put("usedMemory", runtime.totalMemory() - runtime.freeMemory()); + info.put("currentTime", java.time.LocalDateTime.now()); + + return ResponseEntity.ok(info); + } + + private void performCalculation(String description) { + long result = 0; + for (int i = 0; i < 1000000; i++) { + result += i; + } + log.info("Calculation task '{}' completed, result: {}", description, result); + } + + private void performSleep(String description) { + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + log.info("Sleep task '{}' completed", description); + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/model/Slot.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/model/Slot.java new file mode 100644 index 0000000..95617a9 --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/model/Slot.java @@ -0,0 +1,99 @@ +package com.example.timingwheel.model; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +/** + * 时间轮槽位,存储任务列表 + */ +@Data +@Slf4j +public class Slot { + private final int index; + private final ConcurrentLinkedQueue tasks; + private final AtomicInteger taskCount; + private volatile long lastAccessTime; + + public Slot(int index) { + this.index = index; + this.tasks = new ConcurrentLinkedQueue<>(); + this.taskCount = new AtomicInteger(0); + this.lastAccessTime = System.currentTimeMillis(); + } + + public void addTask(TimerTaskWrapper task) { + tasks.offer(task); + taskCount.incrementAndGet(); + lastAccessTime = System.currentTimeMillis(); + + log.debug("Added task {} to slot {}", task.getTaskId(), index); + } + + public boolean removeTask(String taskId) { + boolean removed = tasks.removeIf(task -> task.getTaskId().equals(taskId)); + if (removed) { + taskCount.decrementAndGet(); + lastAccessTime = System.currentTimeMillis(); + } + return removed; + } + + public void clear() { + int removedCount = taskCount.get(); + tasks.clear(); + taskCount.set(0); + lastAccessTime = System.currentTimeMillis(); + + log.debug("Cleared slot {}, removed {} tasks", index, removedCount); + } + + public boolean isEmpty() { + return tasks.isEmpty(); + } + + public int getTaskCount() { + return taskCount.get(); + } + + public java.util.List getTasks() { + return tasks.stream().collect(Collectors.toList()); + } + + public java.util.List drainTasks() { + java.util.List result = new java.util.ArrayList<>(); + TimerTaskWrapper task; + while ((task = tasks.poll()) != null) { + result.add(task); + } + taskCount.set(0); + lastAccessTime = System.currentTimeMillis(); + return result; + } + + public SlotInfo getSlotInfo() { + return new SlotInfo( + index, + taskCount.get(), + lastAccessTime, + tasks.stream() + .map(wrapper -> wrapper.getStatus()) + .collect(Collectors.groupingBy( + status -> status, + java.util.stream.Collectors.counting() + )) + ); + } + + @Data + @lombok.AllArgsConstructor + public static class SlotInfo { + private int index; + private int taskCount; + private long lastAccessTime; + private java.util.Map statusCounts; + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimerTask.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimerTask.java new file mode 100644 index 0000000..5a7f902 --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimerTask.java @@ -0,0 +1,33 @@ +package com.example.timingwheel.model; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 定时任务接口 + */ +public interface TimerTask { + /** + * 任务执行方法 + */ + void run(); + + /** + * 任务描述 + */ + String getDescription(); + + /** + * 任务ID + */ + default String getTaskId() { + return UUID.randomUUID().toString(); + } + + /** + * 任务创建时间 + */ + default LocalDateTime getCreateTime() { + return LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimerTaskWrapper.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimerTaskWrapper.java new file mode 100644 index 0000000..4d5e06a --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimerTaskWrapper.java @@ -0,0 +1,92 @@ +package com.example.timingwheel.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 时间轮任务包装类 + */ +@Data +@Slf4j +public class TimerTaskWrapper { + private final String taskId; + private final TimerTask task; + private final long delayMs; + private final long createTime; + private final long expireTime; + private final AtomicInteger rounds; + + private volatile TaskStatus status; + private volatile LocalDateTime executeTime; + private volatile String errorMessage; + + public enum TaskStatus { + PENDING, // 等待执行 + RUNNING, // 正在执行 + COMPLETED, // 执行完成 + FAILED, // 执行失败 + CANCELLED // 已取消 + } + + public TimerTaskWrapper(TimerTask task, long delayMs) { + this.taskId = UUID.randomUUID().toString(); + this.task = task; + this.delayMs = delayMs; + this.createTime = System.currentTimeMillis(); + this.expireTime = this.createTime + delayMs; + this.rounds = new AtomicInteger(0); + this.status = TaskStatus.PENDING; + } + + public void markAsRunning() { + this.status = TaskStatus.RUNNING; + this.executeTime = LocalDateTime.now(); + } + + public void markAsCompleted() { + this.status = TaskStatus.COMPLETED; + } + + public void markAsFailed(String errorMessage) { + this.status = TaskStatus.FAILED; + this.errorMessage = errorMessage; + log.error("Task {} failed: {}", taskId, errorMessage); + } + + public void markAsCancelled() { + this.status = TaskStatus.CANCELLED; + } + + public boolean isExpired() { + return rounds.get() <= 0; + } + + public void decrementRounds() { + rounds.decrementAndGet(); + } + + public void setRounds(int rounds) { + this.rounds.set(rounds); + } + + @JsonProperty("rounds") + public int getRounds() { + return rounds.get(); + } + + public long getRemainingDelay() { + long currentTime = System.currentTimeMillis(); + return Math.max(0, expireTime - currentTime); + } + + public double getProgressPercentage() { + long totalDuration = delayMs; + long elapsed = System.currentTimeMillis() - createTime; + return Math.min(100.0, (elapsed * 100.0) / totalDuration); + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimingWheelProperties.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimingWheelProperties.java new file mode 100644 index 0000000..7c9b42e --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/model/TimingWheelProperties.java @@ -0,0 +1,56 @@ +package com.example.timingwheel.model; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 时间轮配置 + */ +@Data +@ConfigurationProperties(prefix = "timingwheel.config") +public class TimingWheelProperties { + /** + * 槽位数量,默认为512 + */ + private int slotSize = 512; + + /** + * 每个槽位的时间间隔(毫秒),默认为100ms + */ + private long tickDuration = 100; + + /** + * 工作线程数量 + */ + private int workerThreads = 4; + + /** + * 是否启用多层时间轮 + */ + private boolean enableMultiWheel = true; + + /** + * 最大时间轮层级 + */ + private int maxWheelLevels = 3; + + /** + * 是否启用指标监控 + */ + private boolean enableMetrics = true; + + /** + * 任务队列最大容量 + */ + private int maxQueueSize = 10000; + + /** + * 是否启用任务持久化 + */ + private boolean enablePersistence = false; + + /** + * 任务执行超时时间(毫秒) + */ + private long taskTimeout = 30000; +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/service/TimingWheelService.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/service/TimingWheelService.java new file mode 100644 index 0000000..53c0cdc --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/service/TimingWheelService.java @@ -0,0 +1,218 @@ +package com.example.timingwheel.service; + +import com.example.timingwheel.model.TimerTask; +import com.example.timingwheel.model.TimerTaskWrapper; +import com.example.timingwheel.util.TimingWheel; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 时间轮服务类 + */ +@Slf4j +@Service +public class TimingWheelService { + + private final TimingWheel timingWheel; + private final Map activeTasks = new ConcurrentHashMap<>(); + private final AtomicLong taskSequence = new AtomicLong(0); + + @Autowired + public TimingWheelService(TimingWheel timingWheel) { + this.timingWheel = timingWheel; + } + + /** + * 添加定时任务 + */ + public String scheduleTask(TimerTask task, long delayMs) { + try { + TimerTaskWrapper wrapper = timingWheel.schedule(task, delayMs); + if (wrapper != null) { + activeTasks.put(wrapper.getTaskId(), wrapper); + log.info("Scheduled task: {}, delay: {}ms", wrapper.getTaskId(), delayMs); + return wrapper.getTaskId(); + } + return null; + } catch (Exception e) { + log.error("Failed to schedule task", e); + throw new RuntimeException("Failed to schedule task", e); + } + } + + /** + * 添加延迟任务(使用Lambda) + */ + public String scheduleTask(Runnable task, long delayMs, String description) { + TimerTask timerTask = new TimerTask() { + @Override + public void run() { + task.run(); + } + + @Override + public String getDescription() { + return description; + } + }; + return scheduleTask(timerTask, delayMs); + } + + /** + * 取消任务 + */ + public boolean cancelTask(String taskId) { + boolean cancelled = timingWheel.cancelTask(taskId); + if (cancelled) { + activeTasks.remove(taskId); + log.info("Cancelled task: {}", taskId); + } + return cancelled; + } + + /** + * 获取任务信息 + */ + public TimerTaskWrapper getTaskInfo(String taskId) { + return activeTasks.get(taskId); + } + + /** + * 获取所有活跃任务 + */ + public List getActiveTasks() { + return List.copyOf(activeTasks.values()); + } + + /** + * 获取时间轮统计信息 + */ + public TimingWheel.TimingWheelStats getStats() { + return timingWheel.getStats(); + } + + /** + * 清理已完成的任务 + */ + public int cleanupCompletedTasks() { + final AtomicInteger removedCount = new AtomicInteger(0); + activeTasks.entrySet().removeIf(entry -> { + TimerTaskWrapper wrapper = entry.getValue(); + boolean shouldRemove = wrapper.getStatus() == TimerTaskWrapper.TaskStatus.COMPLETED || + wrapper.getStatus() == TimerTaskWrapper.TaskStatus.FAILED || + wrapper.getStatus() == TimerTaskWrapper.TaskStatus.CANCELLED; + if (shouldRemove) { + removedCount.incrementAndGet(); + } + return shouldRemove; + }); + + int count = removedCount.get(); + if (count > 0) { + log.info("Cleaned up {} completed tasks", count); + } + + return count; + } + + /** + * 获取任务执行统计 + */ + public TaskExecutionStats getExecutionStats() { + Map statusCounts = activeTasks.values() + .stream() + .collect(java.util.stream.Collectors.groupingBy( + wrapper -> wrapper.getStatus(), + java.util.stream.Collectors.counting() + )); + + return new TaskExecutionStats( + activeTasks.size(), + statusCounts.getOrDefault(TimerTaskWrapper.TaskStatus.PENDING, 0L), + statusCounts.getOrDefault(TimerTaskWrapper.TaskStatus.RUNNING, 0L), + statusCounts.getOrDefault(TimerTaskWrapper.TaskStatus.COMPLETED, 0L), + statusCounts.getOrDefault(TimerTaskWrapper.TaskStatus.FAILED, 0L), + statusCounts.getOrDefault(TimerTaskWrapper.TaskStatus.CANCELLED, 0L), + LocalDateTime.now() + ); + } + + /** + * 创建示例任务 + */ + public String createSampleTask(String type, long delayMs) { + return scheduleTask(() -> { + switch (type.toLowerCase()) { + case "simple": + log.info("Simple task executed at {}", LocalDateTime.now()); + break; + case "calculation": + performCalculation(); + break; + case "io": + performIOOperation(); + break; + default: + log.info("Unknown task type: {} executed at {}", type, LocalDateTime.now()); + } + }, delayMs, "Sample " + type + " task"); + } + + private void performCalculation() { + // 模拟CPU密集型任务 + long result = 0; + for (int i = 0; i < 1000000; i++) { + result += i; + } + log.info("Calculation task completed, result: {}", result); + } + + private void performIOOperation() { + // 模拟IO操作 + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + log.info("IO operation task completed"); + } + + /** + * 批量创建测试任务 + */ + public List createBatchTasks(int count, long minDelay, long maxDelay) { + List taskIds = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + long delay = minDelay + (long) (Math.random() * (maxDelay - minDelay)); + String taskId = createSampleTask("simple", delay); + if (taskId != null) { + taskIds.add(taskId); + } + } + log.info("Created {} batch tasks", taskIds.size()); + return taskIds; + } + + /** + * 任务执行统计信息 + */ + @lombok.Data + @lombok.AllArgsConstructor + public static class TaskExecutionStats { + private int totalTasks; + private long pendingTasks; + private long runningTasks; + private long completedTasks; + private long failedTasks; + private long cancelledTasks; + private LocalDateTime timestamp; + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/java/com/example/timingwheel/util/TimingWheel.java b/springboot-timingwheel/src/main/java/com/example/timingwheel/util/TimingWheel.java new file mode 100644 index 0000000..ffdf826 --- /dev/null +++ b/springboot-timingwheel/src/main/java/com/example/timingwheel/util/TimingWheel.java @@ -0,0 +1,341 @@ +package com.example.timingwheel.util; + +import com.example.timingwheel.model.*; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 时间轮核心实现 + */ +@Slf4j +public class TimingWheel implements InitializingBean, DisposableBean { + + private final TimingWheelProperties properties; + private final Slot[] slots; + private final AtomicInteger currentSlot = new AtomicInteger(0); + private final AtomicLong totalTasks = new AtomicLong(0); + private final AtomicLong completedTasks = new AtomicLong(0); + private final AtomicLong failedTasks = new AtomicLong(0); + + private final ScheduledExecutorService tickerExecutor; + private final ExecutorService taskExecutor; + private final MeterRegistry meterRegistry; + + // Micrometer metrics + private Timer scheduleTimer; + private Timer executionTimer; + private Timer taskDurationTimer; + + public TimingWheel(TimingWheelProperties properties, MeterRegistry meterRegistry) { + this.properties = properties; + this.meterRegistry = meterRegistry; + this.slots = new Slot[properties.getSlotSize()]; + + // 创建执行器 + this.tickerExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "timing-wheel-ticker"); + t.setDaemon(true); + return t; + }); + + this.taskExecutor = Executors.newFixedThreadPool( + properties.getWorkerThreads(), + r -> { + Thread t = new Thread(r, "timing-wheel-worker"); + t.setDaemon(true); + return t; + } + ); + + // 初始化槽位 + for (int i = 0; i < slots.length; i++) { + slots[i] = new Slot(i); + } + } + + @Override + public void afterPropertiesSet() { + initializeMetrics(); + start(); + } + + private void initializeMetrics() { + if (properties.isEnableMetrics() && meterRegistry != null) { + scheduleTimer = Timer.builder("timingwheel.schedule.duration") + .description("Time taken to schedule tasks") + .register(meterRegistry); + + executionTimer = Timer.builder("timingwheel.execution.duration") + .description("Time taken to execute tasks") + .register(meterRegistry); + + taskDurationTimer = Timer.builder("timingwheel.task.duration") + .description("Actual task execution duration") + .register(meterRegistry); + } + } + + /** + * 启动时间轮 + */ + public void start() { + log.info("Starting timing wheel with {} slots, {}ms tick duration, {} worker threads", + properties.getSlotSize(), properties.getTickDuration(), properties.getWorkerThreads()); + + tickerExecutor.scheduleAtFixedRate( + this::tick, + properties.getTickDuration(), + properties.getTickDuration(), + TimeUnit.MILLISECONDS + ); + } + + /** + * 添加定时任务 + */ + public TimerTaskWrapper schedule(TimerTask task, long delayMs) { + Timer.Sample sample = Timer.start(meterRegistry); + + try { + if (delayMs <= 0) { + // 立即执行 + taskExecutor.submit(() -> { + long startTime = System.currentTimeMillis(); + try { + task.run(); + recordTaskSuccess(startTime); + } catch (Exception e) { + recordTaskFailure(e); + } + }); + return null; + } + + TimerTaskWrapper wrapper = new TimerTaskWrapper(task, delayMs); + + // 计算目标槽位 + int targetSlot = calculateTargetSlot(delayMs); + int rounds = calculateRounds(delayMs); + wrapper.setRounds(rounds); + + // 添加任务到目标槽位 + slots[targetSlot].addTask(wrapper); + totalTasks.incrementAndGet(); + + log.debug("Scheduled task {} to slot {} with {} rounds, delay: {}ms", + wrapper.getTaskId(), targetSlot, rounds, delayMs); + + return wrapper; + } finally { + if (scheduleTimer != null) { + sample.stop(scheduleTimer); + } + } + } + + /** + * 取消任务 + */ + public boolean cancelTask(String taskId) { + for (Slot slot : slots) { + boolean removed = slot.removeTask(taskId); + if (removed) { + log.debug("Cancelled task: {}", taskId); + return true; + } + } + return false; + } + + /** + * 时间轮指针移动 + */ + private void tick() { + try { + int slotIndex = currentSlot.getAndIncrement() % properties.getSlotSize(); + Slot currentSlot = slots[slotIndex]; + + if (!currentSlot.isEmpty()) { + Timer.Sample sample = Timer.start(meterRegistry); + + try { + processSlot(currentSlot); + } finally { + if (executionTimer != null) { + sample.stop(executionTimer); + } + } + } + } catch (Exception e) { + log.error("Error during tick processing", e); + } + } + + /** + * 处理槽位任务 + */ + private void processSlot(Slot slot) { + java.util.List tasksToExecute = new java.util.ArrayList<>(); + java.util.List tasksToRequeue = new java.util.ArrayList<>(); + + // 处理当前槽位的任务 + for (TimerTaskWrapper wrapper : slot.getTasks()) { + if (wrapper.isExpired()) { + tasksToExecute.add(wrapper); + } else { + wrapper.decrementRounds(); + tasksToRequeue.add(wrapper); + } + } + + // 清空当前槽位 + slot.clear(); + + // 重新排队未到期的任务 + for (TimerTaskWrapper wrapper : tasksToRequeue) { + slot.addTask(wrapper); + } + + // 执行到期的任务 + for (TimerTaskWrapper wrapper : tasksToExecute) { + executeTask(wrapper); + } + + log.debug("Processed slot {}: {} tasks executed, {} tasks requeued", + slot.getIndex(), tasksToExecute.size(), tasksToRequeue.size()); + } + + /** + * 执行任务 + */ + private void executeTask(TimerTaskWrapper wrapper) { + taskExecutor.submit(() -> { + long startTime = System.currentTimeMillis(); + Timer.Sample sample = Timer.start(meterRegistry); + + try { + wrapper.markAsRunning(); + + // 直接执行任务,不再创建额外的Future + wrapper.getTask().run(); + + wrapper.markAsCompleted(); + completedTasks.incrementAndGet(); + recordTaskSuccess(startTime); + + log.debug("Task {} executed successfully", wrapper.getTaskId()); + + } catch (Exception e) { + wrapper.markAsFailed(e.getMessage()); + failedTasks.incrementAndGet(); + recordTaskFailure(e); + + log.error("Error executing task: " + wrapper.getTaskId(), e); + + } finally { + if (taskDurationTimer != null) { + sample.stop(taskDurationTimer); + } + } + }); + } + + private void recordTaskSuccess(long startTime) { + // 记录成功指标 + } + + private void recordTaskFailure(Exception e) { + // 记录失败指标 + } + + /** + * 计算目标槽位 + */ + private int calculateTargetSlot(long delayMs) { + int ticks = (int) (delayMs / properties.getTickDuration()); + return (currentSlot.get() + ticks) % properties.getSlotSize(); + } + + /** + * 计算需要经过的轮数 + */ + private int calculateRounds(long delayMs) { + int ticks = (int) (delayMs / properties.getTickDuration()); + return ticks / properties.getSlotSize(); + } + + /** + * 获取时间轮统计信息 + */ + public TimingWheelStats getStats() { + return new TimingWheelStats( + properties.getSlotSize(), + properties.getTickDuration(), + currentSlot.get(), + totalTasks.get(), + completedTasks.get(), + failedTasks.get(), + getActiveTaskCount(), + getSlotInfos() + ); + } + + private int getActiveTaskCount() { + return java.util.Arrays.stream(slots) + .mapToInt(Slot::getTaskCount) + .sum(); + } + + private java.util.List getSlotInfos() { + return java.util.Arrays.stream(slots) + .map(Slot::getSlotInfo) + .collect(java.util.stream.Collectors.toList()); + } + + @Override + public void destroy() { + log.info("Stopping timing wheel"); + + // 关闭执行器 + tickerExecutor.shutdown(); + taskExecutor.shutdown(); + + try { + if (!tickerExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + tickerExecutor.shutdownNow(); + } + if (!taskExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + taskExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + tickerExecutor.shutdownNow(); + taskExecutor.shutdownNow(); + } + + log.info("Timing wheel stopped"); + } + + /** + * 时间轮统计信息 + */ + @lombok.Data + @lombok.AllArgsConstructor + public static class TimingWheelStats { + private int slotSize; + private long tickDuration; + private int currentSlot; + private long totalTasks; + private long completedTasks; + private long failedTasks; + private int activeTaskCount; + private java.util.List slotInfos; + } +} \ No newline at end of file diff --git a/springboot-timingwheel/src/main/resources/application.yml b/springboot-timingwheel/src/main/resources/application.yml new file mode 100644 index 0000000..b5762f6 --- /dev/null +++ b/springboot-timingwheel/src/main/resources/application.yml @@ -0,0 +1,23 @@ +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: springboot-timingwheel +# Timing Wheel Configuration +timingwheel: + config: + slot-size: 512 + tick-duration: 100 + worker-threads: 4 + enable-multi-wheel: true + enable-metrics: true + +logging: + level: + com.example.timingwheel: DEBUG + org.springframework.web: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" \ No newline at end of file diff --git a/springboot-timingwheel/src/main/resources/static/app.js b/springboot-timingwheel/src/main/resources/static/app.js new file mode 100644 index 0000000..5ac9bad --- /dev/null +++ b/springboot-timingwheel/src/main/resources/static/app.js @@ -0,0 +1,994 @@ +// 修复404错误的取消任务功能版本 + +// 全局变量 +let autoRefresh = true; +let refreshInterval; +let performanceChart; +let performanceData = { + labels: [], + activeTasks: [], + completedTasks: [], + failedTasks: [], + completionRate: [] +}; + +// API基础URL +const API_BASE = '/api/timingwheel'; + +// 初始化应用 +document.addEventListener('DOMContentLoaded', function() { + initializeApp(); +}); + +function initializeApp() { + initializeTimingWheel(); + initializePerformanceChart(); + startAutoRefresh(); + updateCurrentTime(); + setInterval(updateCurrentTime, 1000); + + // 初始加载数据 + loadAllData(); +} + +// 初始化时间轮可视化 +function initializeTimingWheel() { + const wheel = document.getElementById('timingWheel'); + const numSlots = 512; + + // 清空现有槽位 + wheel.querySelectorAll('.slot').forEach(slot => slot.remove()); + + // 创建槽位元素 + for (let i = 0; i < numSlots; i++) { + const slot = document.createElement('div'); + slot.className = 'slot'; + slot.id = `slot-${i}`; + + // 计算槽位位置(圆形分布)- 适配400px尺寸 + const angle = (i * 360 / numSlots) - 90; + const radius = 190; // 适应新的400px尺寸 + const centerX = 200; + const centerY = 200; + const slotSize = 6; + + const x = centerX + radius * Math.cos(angle * Math.PI / 180) - slotSize/2; + const y = centerY + radius * Math.sin(angle * Math.PI / 180) - slotSize/2; + + slot.style.left = x + 'px'; + slot.style.top = y + 'px'; + slot.title = `槽位 ${i}`; + + wheel.appendChild(slot); + } +} + +// 初始化性能图表 +function initializePerformanceChart() { + const ctx = document.getElementById('performanceChart').getContext('2d'); + performanceChart = new Chart(ctx, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: '活跃任务数', + data: [], + borderColor: 'rgb(245, 158, 11)', + backgroundColor: 'rgba(245, 158, 11, 0.1)', + tension: 0.4 + }, + { + label: '已完成任务', + data: [], + borderColor: 'rgb(16, 185, 129)', + backgroundColor: 'rgba(16, 185, 129, 0.1)', + tension: 0.4 + }, + { + label: '失败任务数', + data: [], + borderColor: 'rgb(239, 68, 68)', + backgroundColor: 'rgba(239, 68, 68, 0.1)', + tension: 0.4 + }, + { + label: '任务完成率(%)', + data: [], + borderColor: 'rgb(139, 92, 246)', + backgroundColor: 'rgba(139, 92, 246, 0.1)', + tension: 0.4, + yAxisID: 'y1', + borderDash: [5, 5] + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 0 + }, + interaction: { + mode: 'index', + intersect: false, + }, + layout: { + padding: { + top: 10, + right: 10, + bottom: 10, + left: 10 + } + }, + scales: { + x: { + display: true, + title: { + display: false + }, + ticks: { + maxRotation: 45, + minRotation: 45, + autoSkip: true, + maxTicksLimit: 8 + } + }, + y: { + type: 'linear', + display: true, + position: 'left', + beginAtZero: true, + max: 100, + title: { + display: true, + text: '任务数量' + }, + ticks: { + stepSize: 10 + } + }, + y1: { + type: 'linear', + display: true, + position: 'right', + beginAtZero: true, + max: 100, + title: { + display: true, + text: '完成率(%)' + }, + grid: { + drawOnChartArea: false, + }, + ticks: { + stepSize: 20 + } + } + }, + plugins: { + legend: { + position: 'top', + }, + tooltip: { + callbacks: { + label: function(context) { + let label = context.dataset.label || ''; + if (label) { + label += ': '; + } + if (context.datasetIndex === 3) { + label += context.parsed.y.toFixed(1) + '%'; + } else { + label += context.parsed.y; + } + return label; + } + } + } + } + } + }); +} + +// 开始自动刷新 +function startAutoRefresh() { + if (autoRefresh && !refreshInterval) { + refreshInterval = setInterval(loadAllData, 2000); + } +} + +// 停止自动刷新 +function stopAutoRefresh() { + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } +} + +// 切换自动刷新状态 +function toggleAutoRefresh() { + autoRefresh = !autoRefresh; + const statusElement = document.getElementById('refreshStatus'); + + if (autoRefresh) { + startAutoRefresh(); + statusElement.textContent = '自动刷新: 开启'; + showNotification('自动刷新已开启', 'success'); + } else { + stopAutoRefresh(); + statusElement.textContent = '自动刷新: 关闭'; + showNotification('自动刷新已关闭', 'info'); + } +} + +// 加载所有数据 +async function loadAllData() { + try { + await Promise.all([ + loadStats(), + loadExecutionStats(), + loadActiveTasks(), + loadSystemInfo() + ]); + } catch (error) { + console.error('加载数据失败:', error); + showNotification('加载数据失败: ' + error.message, 'error'); + } +} + +// 加载统计信息 +async function loadStats() { + try { + const response = await fetch(`${API_BASE}/stats`); + if (!response.ok) throw new Error('获取统计信息失败'); + + const stats = await response.json(); + updateStatsDisplay(stats); + updateTimingWheel(stats); + updatePerformanceChart(stats); + + } catch (error) { + console.error('加载统计信息失败:', error); + } +} + +// 更新统计信息显示 +function updateStatsDisplay(stats) { + document.getElementById('totalTasks').textContent = stats.totalTasks || 0; + document.getElementById('completedTasks').textContent = stats.completedTasks || 0; + document.getElementById('failedTasks').textContent = stats.failedTasks || 0; + document.getElementById('activeTasks').textContent = stats.activeTaskCount || 0; + document.getElementById('currentSlot').textContent = stats.currentSlot || 0; + document.getElementById('totalSlots').textContent = stats.slotSize || 512; + document.getElementById('tickDuration').textContent = stats.tickDuration || 100; +} + +// 更新时间轮可视化 +function updateTimingWheel(stats) { + const currentSlot = stats.currentSlot || 0; + const slotInfos = stats.slotInfos || []; + + // 清除所有状态 + document.querySelectorAll('.slot').forEach(slot => { + slot.classList.remove('current', 'has-tasks', 'active'); + }); + + // 设置当前槽位 + const currentSlotElement = document.getElementById(`slot-${currentSlot}`); + if (currentSlotElement) { + currentSlotElement.classList.add('current'); + } + + // 设置有任务的槽位 + slotInfos.forEach(slotInfo => { + if (slotInfo.taskCount > 0) { + const slotElement = document.getElementById(`slot-${slotInfo.slotIndex}`); + if (slotElement && slotInfo.slotIndex !== currentSlot) { + slotElement.classList.add('has-tasks'); + } + } + }); +} + +// 更新性能图表 +function updatePerformanceChart(stats) { + if (!performanceChart || performanceChart.destroyed) { + return; + } + + const now = new Date(); + const timeLabel = now.toLocaleTimeString(); + + const totalTasks = stats.totalTasks || 0; + const completedTasks = stats.completedTasks || 0; + const failedTasks = stats.failedTasks || 0; + const activeTasks = stats.activeTaskCount || 0; + const completionRate = totalTasks > 0 ? (completedTasks / totalTasks * 100) : 0; + + const maxDataPoints = 15; + if (performanceData.labels.length >= maxDataPoints) { + performanceData.labels.shift(); + performanceData.activeTasks.shift(); + performanceData.completedTasks.shift(); + performanceData.failedTasks.shift(); + performanceData.completionRate.shift(); + } + + performanceData.labels.push(timeLabel); + performanceData.activeTasks.push(activeTasks); + performanceData.completedTasks.push(completedTasks); + performanceData.failedTasks.push(failedTasks); + performanceData.completionRate.push(completionRate); + + try { + performanceChart.data.labels = performanceData.labels; + performanceChart.data.datasets[0].data = performanceData.activeTasks; + performanceChart.data.datasets[1].data = performanceData.completedTasks; + performanceChart.data.datasets[2].data = performanceData.failedTasks; + performanceChart.data.datasets[3].data = performanceData.completionRate; + performanceChart.update('none'); + } catch (error) { + console.error('图表更新失败:', error); + initializePerformanceChart(); + } +} + +// 加载执行统计 +async function loadExecutionStats() { + try { + const response = await fetch(`${API_BASE}/execution-stats`); + if (!response.ok) throw new Error('获取执行统计失败'); + + const stats = await response.json(); + updateExecutionStatsDisplay(stats); + + } catch (error) { + console.error('加载执行统计失败:', error); + } +} + +// 更新执行统计显示 +function updateExecutionStatsDisplay(stats) { + const container = document.getElementById('executionStats'); + + const statsHtml = ` +
+
+

平均调度时间

+

${(stats.averageScheduleTime || 0).toFixed(2)} ms

+
+
+

平均执行时间

+

${(stats.averageExecutionTime || 0).toFixed(2)} ms

+
+
+

总执行次数

+

${stats.totalExecutions || 0}

+
+
+

成功率

+

${(stats.successRate || 0).toFixed(1)}%

+
+
+ `; + + container.innerHTML = statsHtml; +} + +// 加载活跃任务 +async function loadActiveTasks() { + try { + const response = await fetch(`${API_BASE}/tasks`); + if (!response.ok) throw new Error('获取活跃任务失败'); + + const tasks = await response.json(); + + // 调试:输出任务数据结构 + if (tasks && tasks.length > 0) { + console.log('任务数据结构示例:', tasks[0]); + console.log('所有任务状态:', tasks.map(t => ({ id: t.taskId, status: t.status }))); + } + + updateActiveTasksList(tasks); + + } catch (error) { + console.error('加载活跃任务失败:', error); + } +} + +// 更新活跃任务列表(网格布局版本) +function updateActiveTasksList(tasks) { + const container = document.getElementById('activeTasksList'); + const countElement = document.getElementById('activeTasksCount'); + const emptyState = document.getElementById('emptyState'); + const gridContainer = container.querySelector('.grid'); + + // 过滤出真正的活跃任务(只显示未完成的任务) + const activeTasks = tasks ? tasks.filter(task => { + const status = task.status || 'PENDING'; + return status !== 'COMPLETED' && status !== 'FAILED' && status !== 'CANCELLED'; + }) : []; + + // 更新任务数量 + if (countElement) { + countElement.textContent = activeTasks.length; + } + + if (!activeTasks || activeTasks.length === 0) { + gridContainer.style.display = 'none'; + emptyState.style.display = 'flex'; + return; + } + + // 显示网格,隐藏空状态 + gridContainer.style.display = 'grid'; + emptyState.style.display = 'none'; + + // 创建任务卡片网格 + const tasksHtml = activeTasks.map(task => { + // 安全获取任务描述 + let taskDescription = '未命名任务'; + if (task.task && task.task.description) { + taskDescription = task.task.description; + } else if (task.description) { + taskDescription = task.description; + } + + // 安全获取过期时间 + let expireTimeStr = '未知时间'; + const expireTime = task.expireTime || task.expiryTime; + if (expireTime && typeof expireTime === 'number') { + try { + const expireDate = new Date(expireTime); + const now = new Date(); + const timeDiff = expireDate - now; + const isExpired = timeDiff < 0; + + expireTimeStr = expireDate.toLocaleString(); + + // 添加过期状态样式 + if (isExpired) { + expireTimeStr += ' ⚠️ 已过期'; + } + } catch (e) { + expireTimeStr = '时间格式错误'; + } + } else if (expireTime) { + expireTimeStr = String(expireTime); + } + + // 安全获取轮次信息 + let roundsStr = '未知'; + if (task.rounds !== undefined && task.rounds !== null) { + if (typeof task.rounds === 'object') { + roundsStr = task.rounds.value || task.rounds.get ? task.rounds.get() : '对象'; + } else if (typeof task.rounds === 'number') { + roundsStr = task.rounds; + } else { + roundsStr = String(task.rounds); + } + } else if (task.remainingRounds !== undefined) { + roundsStr = task.remainingRounds; + } + + // 任务状态 + const status = task.status || 'PENDING'; + const statusColor = { + 'PENDING': 'bg-yellow-100 text-yellow-800 border-yellow-200', + 'RUNNING': 'bg-blue-100 text-blue-800 border-blue-200', + 'COMPLETED': 'bg-green-100 text-green-800 border-green-200', + 'FAILED': 'bg-red-100 text-red-800 border-red-200', + 'CANCELLED': 'bg-gray-100 text-gray-800 border-gray-200' + }[status] || 'bg-gray-100 text-gray-800 border-gray-200'; + + const statusIcon = { + 'PENDING': 'fa-clock', + 'RUNNING': 'fa-spinner fa-spin', + 'COMPLETED': 'fa-check-circle', + 'FAILED': 'fa-times-circle', + 'CANCELLED': 'fa-ban' + }[status] || 'fa-question-circle'; + + const statusText = { + 'PENDING': '等待中', + 'RUNNING': '执行中', + 'COMPLETED': '已完成', + 'FAILED': '失败', + 'CANCELLED': '已取消' + }[status] || status; + + return ` +
+ +
+ + + ${statusText} + +
+ + +
+

${taskDescription}

+

${task.taskId}

+
+ + +
+
+ + ${expireTimeStr} +
+
+ + 轮次: ${roundsStr} +
+ ${task.delayMs ? ` +
+ + 延迟: ${task.delayMs}ms +
+ ` : ''} + ${task.createTime ? ` +
+ + 创建: ${new Date(task.createTime).toLocaleTimeString()} +
+ ` : ''} + ${task.errorMessage ? ` +
+ + ${task.errorMessage} +
+ ` : ''} +
+ + +
+ +
+
+ `; + }).join(''); + + gridContainer.innerHTML = tasksHtml; +} + +// 加载系统信息 +async function loadSystemInfo() { + try { + const response = await fetch(`${API_BASE}/system-info`); + if (!response.ok) throw new Error('获取系统信息失败'); + + const info = await response.json(); + updateSystemInfoDisplay(info); + + } catch (error) { + console.error('加载系统信息失败:', error); + } +} + +// 更新系统信息显示 +function updateSystemInfoDisplay(info) { + const container = document.getElementById('systemInfo'); + + const formatBytes = (bytes) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const memoryUsagePercent = ((info.usedMemory / info.totalMemory) * 100).toFixed(1); + + const infoHtml = ` +
+
+ CPU核心数 + ${info.availableProcessors} +
+
+
+ 内存使用率 + ${memoryUsagePercent}% +
+
+
+
+
+
已用: ${formatBytes(info.usedMemory)}
+
总计: ${formatBytes(info.totalMemory)}
+
最大: ${formatBytes(info.maxMemory)}
+
+
+
+
+ 当前时间 + ${new Date(info.currentTime).toLocaleTimeString()} +
+
+
+ `; + + container.innerHTML = infoHtml; +} + +// 创建示例任务 +async function createSampleTask() { + const type = document.getElementById('taskType').value; + const delay = parseInt(document.getElementById('taskDelay').value); + + try { + const response = await fetch(`${API_BASE}/tasks/sample`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ type, delay }) + }); + + if (!response.ok) throw new Error('创建任务失败'); + + const result = await response.json(); + showNotification(`任务创建成功: ${result.taskId}`, 'success'); + + loadAllData(); + + } catch (error) { + console.error('创建任务失败:', error); + showNotification('创建任务失败: ' + error.message, 'error'); + } +} + +// 批量创建任务 +async function createBatchTasks() { + const count = parseInt(document.getElementById('batchCount').value); + const minDelay = parseInt(document.getElementById('minDelay').value); + const maxDelay = parseInt(document.getElementById('maxDelay').value); + + try { + const response = await fetch(`${API_BASE}/tasks/batch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ count, minDelay, maxDelay }) + }); + + if (!response.ok) throw new Error('批量创建任务失败'); + + const result = await response.json(); + showNotification(`成功创建 ${result.count} 个任务`, 'success'); + + loadAllData(); + + } catch (error) { + console.error('批量创建任务失败:', error); + showNotification('批量创建任务失败: ' + error.message, 'error'); + } +} + +// 创建自定义任务 +async function createCustomTask() { + const description = document.getElementById('customDescription').value || '自定义任务'; + const delay = parseInt(document.getElementById('customDelay').value); + const action = document.getElementById('customAction').value; + + try { + const response = await fetch(`${API_BASE}/tasks/custom`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ description, delay, action }) + }); + + if (!response.ok) throw new Error('创建自定义任务失败'); + + const result = await response.json(); + showNotification(`自定义任务创建成功: ${result.taskId}`, 'success'); + + document.getElementById('customDescription').value = ''; + + loadAllData(); + + } catch (error) { + console.error('创建自定义任务失败:', error); + showNotification('创建自定义任务失败: ' + error.message, 'error'); + } +} + +// 取消任务(修复404错误版本) +async function cancelTask(taskId) { + const cancelButton = document.querySelector(`[data-task-id="${taskId}"]`); + if (cancelButton) { + cancelButton.disabled = true; + cancelButton.textContent = '取消中...'; + } + + try { + // 先检查任务是否存在以及状态 + console.log(`检查任务状态: ${taskId}`); + const checkResponse = await fetch(`${API_BASE}/tasks/${taskId}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + + if (checkResponse.status === 404) { + showNotification(`任务不存在或已自动完成: ${taskId}`, 'info'); + if (cancelButton) { + const taskRow = cancelButton.closest('div'); + if (taskRow) { + taskRow.style.opacity = '0.5'; + cancelButton.textContent = '已完成'; + } + } + refreshTasks(); // 刷新任务列表 + return; + } + + if (!checkResponse.ok) { + const errorText = await checkResponse.text(); + showNotification(`检查任务状态失败: ${errorText}`, 'error'); + return; + } + + const taskData = await checkResponse.json(); + console.log(`任务当前状态:`, taskData); + + // 检查任务是否已经完成或失败 + if (taskData.status === 'COMPLETED') { + showNotification(`任务已完成,无需取消: ${taskId}`, 'info'); + if (cancelButton) { + const taskRow = cancelButton.closest('div'); + if (taskRow) { + taskRow.style.opacity = '0.5'; + cancelButton.textContent = '已完成'; + } + } + refreshTasks(); + return; + } + + if (taskData.status === 'CANCELLED') { + showNotification(`任务已被取消: ${taskId}`, 'info'); + if (cancelButton) { + const taskRow = cancelButton.closest('div'); + if (taskRow) { + taskRow.style.opacity = '0.5'; + cancelButton.textContent = '已取消'; + } + } + refreshTasks(); + return; + } + + if (taskData.status === 'FAILED') { + showNotification(`任务已失败,无需取消: ${taskId}`, 'info'); + if (cancelButton) { + const taskRow = cancelButton.closest('div'); + if (taskRow) { + taskRow.style.opacity = '0.5'; + cancelButton.textContent = '已失败'; + } + } + refreshTasks(); + return; + } + + // 尝试取消任务 + console.log(`尝试取消任务: ${taskId}`); + const deleteResponse = await fetch(`${API_BASE}/tasks/${taskId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' } + }); + + if (deleteResponse.ok) { + showNotification('任务取消成功', 'success'); + refreshTasks(); + } else if (deleteResponse.status === 404) { + showNotification(`取消失败: 任务不存在或已完成 (${taskId})`, 'info'); + refreshTasks(); + } else { + const errorText = await deleteResponse.text(); + console.error(`取消任务失败:`, { + status: deleteResponse.status, + statusText: deleteResponse.statusText, + errorText: errorText, + taskId: taskId + }); + showNotification(`取消任务失败: ${errorText}`, 'error'); + } + } catch (error) { + console.error(`取消任务异常:`, error); + showNotification('取消任务失败: ' + error.message, 'error'); + } finally { + if (cancelButton) { + cancelButton.disabled = false; + // 根据最新的任务状态来更新按钮文本 + const buttonText = cancelButton.textContent; + if (buttonText === '取消中...') { + cancelButton.textContent = '取消任务'; + } + } + } +} + +// 清理已完成的任务 +async function cleanupTasks() { + try { + const response = await fetch(`${API_BASE}/cleanup`, { + method: 'POST' + }); + + if (!response.ok) throw new Error('清理任务失败'); + + const result = await response.json(); + showNotification(`清理完成,移除了 ${result.removedCount} 个任务`, 'success'); + + loadAllData(); + + } catch (error) { + console.error('清理任务失败:', error); + showNotification('清理任务失败: ' + error.message, 'error'); + } +} + +// 执行压力测试 +async function performStressTest() { + const taskCount = parseInt(document.getElementById('stressTaskCount').value); + const minDelay = parseInt(document.getElementById('stressMinDelay').value); + const maxDelay = parseInt(document.getElementById('stressMaxDelay').value); + + const resultsContainer = document.getElementById('stressTestResults'); + resultsContainer.innerHTML = '
测试进行中...
'; + + try { + const startTime = performance.now(); + + const response = await fetch(`${API_BASE}/stress-test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ taskCount, minDelay, maxDelay }) + }); + + if (!response.ok) throw new Error('压力测试失败'); + + const result = await response.json(); + const endTime = performance.now(); + + resultsContainer.innerHTML = ` +
+
测试完成
+
+ 创建 ${result.taskCount} 个任务
+ 创建时间: ${result.creationTime}ms
+ 吞吐量: ${result.throughput.toFixed(2)} 任务/秒 +
+
+ `; + + showNotification(`压力测试完成,创建了 ${result.taskCount} 个任务`, 'success'); + + loadAllData(); + + } catch (error) { + console.error('压力测试失败:', error); + resultsContainer.innerHTML = '
测试失败
'; + showNotification('压力测试失败: ' + error.message, 'error'); + } +} + +// 刷新任务列表 +function refreshTasks() { + loadActiveTasks(); + showNotification('任务列表已刷新', 'info'); +} + +// 更新当前时间 +function updateCurrentTime() { + const now = new Date(); + document.getElementById('currentTime').textContent = now.toLocaleString(); +} + +// 显示通知 +function showNotification(message, type = 'success') { + const notification = document.getElementById('notification'); + const notificationText = document.getElementById('notificationText'); + + notificationText.textContent = message; + + notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg transform transition-transform duration-300 z-50'; + + switch (type) { + case 'success': + notification.classList.add('bg-green-500', 'text-white'); + break; + case 'error': + notification.classList.add('bg-red-500', 'text-white'); + break; + case 'info': + notification.classList.add('bg-blue-500', 'text-white'); + break; + case 'warning': + notification.classList.add('bg-yellow-500', 'text-white'); + break; + default: + notification.classList.add('bg-gray-500', 'text-white'); + } + + notification.style.transform = 'translateX(0)'; + + setTimeout(() => { + notification.style.transform = 'translateX(100%)'; + }, 3000); +} + +// 图表控制函数 +function resetChartData() { + if (performanceChart) { + performanceChart.destroy(); + } + + performanceData = { + labels: [], + activeTasks: [], + completedTasks: [], + failedTasks: [], + completionRate: [] + }; + + initializePerformanceChart(); + + showNotification('图表数据已重置', 'info'); +} + +function changeChartType() { + const chartType = document.getElementById('chartType').value; + performanceChart.config.type = chartType; + performanceChart.update(); +} + +function toggleChartDataset(datasetIndex, isVisible) { + const dataset = performanceChart.data.datasets[datasetIndex]; + dataset.hidden = !isVisible; + performanceChart.update(); +} + +// 清理图表资源 +function cleanupChart() { + if (performanceChart && !performanceChart.destroyed) { + performanceChart.destroy(); + performanceChart = null; + } +} + +// 页面卸载时清理资源 +window.addEventListener('beforeunload', function() { + cleanupChart(); + stopAutoRefresh(); +}); + +// 错误处理 +window.addEventListener('error', function(event) { + console.error('全局错误:', event.error); + showNotification('发生未知错误,请刷新页面重试', 'error'); +}); + +// 网络错误处理 +window.addEventListener('unhandledrejection', function(event) { + console.error('未处理的Promise拒绝:', event.reason); + showNotification('网络请求失败,请检查网络连接', 'error'); +}); + +console.log('404错误修复版本已加载 - 改进了任务状态检查和错误处理'); \ No newline at end of file diff --git a/springboot-timingwheel/src/main/resources/static/index.html b/springboot-timingwheel/src/main/resources/static/index.html new file mode 100644 index 0000000..9eb3661 --- /dev/null +++ b/springboot-timingwheel/src/main/resources/static/index.html @@ -0,0 +1,495 @@ + + + + + + Timing Wheel 时间轮监控面板 + + + + + + + + +
+
+
+
+ +

Timing Wheel 时间轮监控面板

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

+ + 时间轮可视化 +

+
+
+
+
+
0
+
当前槽位
+
+
总槽位: 512
+
间隔: 100ms
+
+
+
+
+
+ + +
+
+
+ 空闲 +
+
+
+ 有任务 +
+
+
+ 当前 +
+
+
+
+ + +
+
+

+ + 实时统计与性能趋势 +

+ + +
+
+
+
+

总任务数

+

0

+
+ +
+
+ +
+
+
+

已完成

+

0

+
+ +
+
+ +
+
+
+

失败任务

+

0

+
+ +
+
+ +
+
+
+

活跃任务

+

0

+
+ +
+
+
+ + +
+

执行统计

+
+ +
+
+ + +
+
+

性能趋势

+
+ + +
+
+
+ + + + +
+
+ +
+
+ 显示最近15个时间点的数据 • 每2秒更新一次 +
+
+
+
+ + +
+
+

+ + 系统信息与任务管理 +

+ + +
+

系统信息

+
+ +
+
+ + +
+

任务管理

+ + +
+

创建示例任务

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

批量创建任务

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

自定义任务

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

+ + 活跃任务列表 +

+

+ 当前共有 0 个活跃任务 +

+
+
+ + +
+ + 实时更新 + + + 只显示未完成任务 + +
+
+
+ + +
+
+ +
+
+ +

暂无活跃任务

+

创建一些任务开始监控时间轮的运行状态

+
+
+
+ + +
+
+

+ + 压力测试工具 +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+ + +
+
+ + 操作成功 +
+
+ + + + \ No newline at end of file diff --git a/springboot-timingwheel/src/main/resources/static/styles.css b/springboot-timingwheel/src/main/resources/static/styles.css new file mode 100644 index 0000000..fb917fd --- /dev/null +++ b/springboot-timingwheel/src/main/resources/static/styles.css @@ -0,0 +1,143 @@ +/* 自定义样式补充 */ + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* 动画效果 */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.slide-in { + animation: slideIn 0.3s ease-out; +} + +/* 加载动画 */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading { + animation: spin 1s linear infinite; +} + +/* 悬浮效果 */ +.hover-lift { + transition: transform 0.2s ease; +} + +.hover-lift:hover { + transform: translateY(-2px); +} + +/* 状态指示器 */ +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; +} + +.status-indicator.online { + background-color: #10b981; + box-shadow: 0 0 6px #10b981; +} + +.status-indicator.offline { + background-color: #ef4444; +} + +.status-indicator.warning { + background-color: #f59e0b; + box-shadow: 0 0 6px #f59e0b; +} + +/* 脉冲动画 */ +@keyframes ping { + 75%, 100% { + transform: scale(2); + opacity: 0; + } +} + +.ping { + animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .timing-wheel { + width: 300px; + height: 300px; + } + + .slot { + width: 6px; + height: 6px; + } + + .metric-card { + margin-bottom: 1rem; + } +} + +@media (max-width: 640px) { + .timing-wheel { + width: 250px; + height: 250px; + } + + .container { + padding-left: 1rem; + padding-right: 1rem; + } +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + .bg-white { + background-color: #1f2937 !important; + } + + .text-gray-800 { + color: #f3f4f6 !important; + } + + .text-gray-600 { + color: #9ca3af !important; + } + + .bg-gray-50 { + background-color: #374151 !important; + } + + .border-gray-300 { + border-color: #4b5563 !important; + } +} \ No newline at end of file From 4c64dfa7975cc87d20417a44600d132ffcb90c1b Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 2 Nov 2025 21:04:38 +0800 Subject: [PATCH 11/22] report --- springboot-report/README.md | 59 + springboot-report/pom.xml | 61 + springboot-report/setup_test_data.sql | 149 ++ .../com/example/report/ReportApplication.java | 11 + .../com/example/report/config/CorsConfig.java | 25 + .../report/controller/ReportController.java | 53 + .../com/example/report/entity/ColumnInfo.java | 59 + .../example/report/entity/QueryRequest.java | 45 + .../com/example/report/entity/TableInfo.java | 39 + .../report/service/MetaDataService.java | 76 + .../example/report/service/QueryService.java | 159 ++ .../src/main/resources/application.yml | 16 + .../src/main/resources/static/app.js | 1442 +++++++++++++++++ .../src/main/resources/static/index.html | 372 +++++ 14 files changed, 2566 insertions(+) create mode 100644 springboot-report/README.md create mode 100644 springboot-report/pom.xml create mode 100644 springboot-report/setup_test_data.sql create mode 100644 springboot-report/src/main/java/com/example/report/ReportApplication.java create mode 100644 springboot-report/src/main/java/com/example/report/config/CorsConfig.java create mode 100644 springboot-report/src/main/java/com/example/report/controller/ReportController.java create mode 100644 springboot-report/src/main/java/com/example/report/entity/ColumnInfo.java create mode 100644 springboot-report/src/main/java/com/example/report/entity/QueryRequest.java create mode 100644 springboot-report/src/main/java/com/example/report/entity/TableInfo.java create mode 100644 springboot-report/src/main/java/com/example/report/service/MetaDataService.java create mode 100644 springboot-report/src/main/java/com/example/report/service/QueryService.java create mode 100644 springboot-report/src/main/resources/application.yml create mode 100644 springboot-report/src/main/resources/static/app.js create mode 100644 springboot-report/src/main/resources/static/index.html diff --git a/springboot-report/README.md b/springboot-report/README.md new file mode 100644 index 0000000..ff2840e --- /dev/null +++ b/springboot-report/README.md @@ -0,0 +1,59 @@ +# 轻量级BI报表自助分析平台 + +一个基于Spring Boot 3 + 纯HTML/JS的轻量级报表系统,支持拖拽字段选择、动态SQL生成和多种图表展示。 + +## 功能特性 + +- 🔗 **数据源连接**: 支持MySQL数据库连接 +- 📊 **元数据查询**: 自动获取数据库、表、字段信息 +- 🎯 **拖拽操作**: 直观的拖拽界面选择维度和指标 +- 📈 **多种图表**: 支持表格、柱状图、折线图、饼图 +- 🔄 **动态SQL**: 根据用户选择自动生成SQL查询 + +## 技术栈 + +### 后端 +- Spring Boot 3.2.0 +- MySQL Connector +- Maven + +### 前端 +- HTML5 + JavaScript (ES6+) +- Tailwind CSS +- ECharts 5 +- SortableJS (拖拽功能) + +## 快速开始 + +### 1. 数据库准备 + +确保MySQL服务运行,执行测试数据脚本: + +```bash +mysql -u root -p < setup_test_data.sql +``` + +或者手动执行SQL文件中的内容创建测试数据。 + +### 2. 配置数据库连接 + +修改 `src/main/resources/application.yml` 中的数据库连接信息: + +```yaml +spring: + datasource: + url: jdbc:mysql://localhost:3306/information_schema?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC + username: your_username + password: your_password +``` + +### 3. 启动应用 + +```bash +mvn clean install +mvn spring-boot:run +``` + +### 4. 访问应用 + +启动后,访问:http://localhost:8080/index.html diff --git a/springboot-report/pom.xml b/springboot-report/pom.xml new file mode 100644 index 0000000..e739699 --- /dev/null +++ b/springboot-report/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.example + report-platform + 1.0.0 + report-platform + Lightweight BI Report Platform + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + mysql + mysql-connector-java + 8.0.33 + + + + org.springframework.boot + spring-boot-starter-validation + + + + com.fasterxml.jackson.core + jackson-databind + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/springboot-report/setup_test_data.sql b/springboot-report/setup_test_data.sql new file mode 100644 index 0000000..429ae8e --- /dev/null +++ b/springboot-report/setup_test_data.sql @@ -0,0 +1,149 @@ +-- 测试数据创建脚本 +-- 用于轻量级BI报表平台演示 + +-- 创建测试数据库,指定字符集 +CREATE DATABASE IF NOT EXISTS test_db +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; + +USE test_db; + +-- 创建销售数据表,指定字符集 +CREATE TABLE sales_data ( + id INT AUTO_INCREMENT PRIMARY KEY, + region VARCHAR(50) NOT NULL COMMENT '销售区域', + product_category VARCHAR(50) NOT NULL COMMENT '产品分类', + product_name VARCHAR(100) NOT NULL COMMENT '产品名称', + sales_amount DECIMAL(10,2) NOT NULL COMMENT '销售金额', + quantity INT NOT NULL COMMENT '销售数量', + sale_date DATE NOT NULL COMMENT '销售日期', + sales_person VARCHAR(50) NOT NULL COMMENT '销售人员', + customer_type VARCHAR(30) NOT NULL COMMENT '客户类型', + payment_method VARCHAR(30) NOT NULL COMMENT '支付方式' +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 插入测试数据 +INSERT INTO sales_data (region, product_category, product_name, sales_amount, quantity, sale_date, sales_person, customer_type, payment_method) VALUES +-- 华北区域数据 +('华北', '电子产品', 'iPhone 15', 6999.00, 8, '2024-01-15', '张三', '个人', '支付宝'), +('华北', '电子产品', 'MacBook Pro', 12999.00, 3, '2024-01-16', '张三', '企业', '银行转账'), +('华北', '电子产品', 'iPad Air', 4799.00, 5, '2024-01-17', '李四', '个人', '微信支付'), +('华北', '服装', '商务西装', 1299.00, 12, '2024-01-18', '李四', '个人', '信用卡'), +('华北', '服装', '休闲T恤', 199.00, 30, '2024-01-19', '王五', '个人', '现金'), +('华北', '食品', '进口巧克力', 89.00, 45, '2024-01-20', '王五', '个人', '支付宝'), + +-- 华东区域数据 +('华东', '电子产品', 'iPhone 15', 6899.00, 12, '2024-01-21', '赵六', '个人', '微信支付'), +('华东', '电子产品', '小米笔记本', 4999.00, 8, '2024-01-22', '赵六', '企业', '银行转账'), +('华东', '电子产品', 'AirPods', 1299.00, 15, '2024-01-23', '钱七', '个人', '支付宝'), +('华东', '服装', '连衣裙', 599.00, 25, '2024-01-24', '钱七', '个人', '信用卡'), +('华东', '服装', '运动鞋', 899.00, 18, '2024-01-25', '孙八', '个人', '微信支付'), +('华东', '食品', '有机蔬菜', 45.00, 60, '2024-01-26', '孙八', '个人', '现金'), + +-- 华南区域数据 +('华南', '电子产品', '华为手机', 5499.00, 10, '2024-01-27', '周九', '个人', '支付宝'), +('华南', '电子产品', '联想电脑', 6299.00, 6, '2024-01-28', '周九', '企业', '银行转账'), +('华南', '电子产品', '智能手表', 1999.00, 20, '2024-01-29', '吴十', '个人', '微信支付'), +('华南', '服装', '防晒衣', 299.00, 35, '2024-01-30', '吴十', '个人', '信用卡'), +('华南', '服装', '凉鞋', 399.00, 22, '2024-01-31', '郑一', '个人', '支付宝'), +('华南', '食品', '热带水果', 68.00, 80, '2024-02-01', '郑一', '个人', '现金'), + +-- 华西区域数据 +('华西', '电子产品', 'OPPO手机', 2999.00, 15, '2024-02-02', '王二', '个人', '微信支付'), +('华西', '电子产品', '蓝牙音箱', 399.00, 25, '2024-02-03', '王二', '个人', '支付宝'), +('华西', '电子产品', '移动硬盘', 599.00, 18, '2024-02-04', '李三', '企业', '银行转账'), +('华西', '服装', '羽绒服', 899.00, 28, '2024-02-05', '李三', '个人', '信用卡'), +('华西', '服装', '保暖内衣', 199.00, 40, '2024-02-06', '赵四', '个人', '现金'), +('华西', '食品', '牛肉干', 128.00, 55, '2024-02-07', '赵四', '个人', '支付宝'), + +-- 添加更多历史数据用于时间序列分析 +('华北', '电子产品', 'iPhone 15', 6799.00, 6, '2024-02-08', '张三', '个人', '微信支付'), +('华北', '电子产品', 'MacBook Pro', 12499.00, 2, '2024-02-09', '张三', '企业', '银行转账'), +('华东', '电子产品', 'iPhone 15', 7099.00, 9, '2024-02-10', '赵六', '个人', '支付宝'), +('华东', '服装', '连衣裙', 579.00, 20, '2024-02-11', '钱七', '个人', '信用卡'), +('华南', '电子产品', '华为手机', 5399.00, 8, '2024-02-12', '周九', '个人', '微信支付'), +('华西', '电子产品', 'OPPO手机', 2899.00, 12, '2024-02-13', '王二', '个人', '支付宝'); + +-- 创建用户信息表用于多表关联演示 +CREATE TABLE user_info ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_name VARCHAR(50) NOT NULL COMMENT '用户名', + region VARCHAR(50) NOT NULL COMMENT '所属区域', + level VARCHAR(20) NOT NULL COMMENT '用户等级', + register_date DATE NOT NULL COMMENT '注册日期' +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +INSERT INTO user_info (user_name, region, level, register_date) VALUES +('张三', '华北', 'VIP', '2023-06-15'), +('李四', '华北', '普通', '2023-08-20'), +('王五', '华北', 'VIP', '2023-09-10'), +('赵六', '华东', '金牌', '2023-07-05'), +('钱七', '华东', '普通', '2023-10-12'), +('孙八', '华东', 'VIP', '2023-11-03'), +('周九', '华南', '金牌', '2023-05-28'), +('吴十', '华南', '普通', '2023-09-15'), +('郑一', '华南', 'VIP', '2023-12-01'), +('王二', '华西', '普通', '2023-08-10'), +('李三', '华西', '金牌', '2023-10-20'), +('赵四', '华西', 'VIP', '2023-11-15'); + +-- 创建产品信息表 +CREATE TABLE product_info ( + id INT AUTO_INCREMENT PRIMARY KEY, + product_name VARCHAR(100) NOT NULL COMMENT '产品名称', + category VARCHAR(50) NOT NULL COMMENT '产品分类', + brand VARCHAR(50) NOT NULL COMMENT '品牌', + cost_price DECIMAL(10,2) NOT NULL COMMENT '成本价', + created_date DATE NOT NULL COMMENT '创建日期' +)CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +INSERT INTO product_info (product_name, category, brand, cost_price, created_date) VALUES +('iPhone 15', '电子产品', 'Apple', 4500.00, '2023-09-15'), +('MacBook Pro', '电子产品', 'Apple', 8000.00, '2023-06-20'), +('iPad Air', '电子产品', 'Apple', 2800.00, '2023-08-10'), +('AirPods', '电子产品', 'Apple', 800.00, '2023-07-12'), +('华为手机', '电子产品', '华为', 3500.00, '2023-09-01'), +('小米笔记本', '电子产品', '小米', 3200.00, '2023-05-15'), +('联想电脑', '电子产品', '联想', 4200.00, '2023-08-25'), +('OPPO手机', '电子产品', 'OPPO', 1800.00, '2023-10-10'), +('蓝牙音箱', '电子产品', '小米', 200.00, '2023-11-05'), +('智能手表', '电子产品', '华为', 1200.00, '2023-09-20'), +('移动硬盘', '电子产品', '西部数据', 350.00, '2023-07-18'), +('商务西装', '服装', '雅戈尔', 800.00, '2023-04-12'), +('休闲T恤', '服装', '优衣库', 80.00, '2023-06-25'), +('连衣裙', '服装', 'ZARA', 350.00, '2023-08-15'), +('运动鞋', '服装', '耐克', 550.00, '2023-09-10'), +('防晒衣', '服装', '迪卡侬', 150.00, '2023-10-20'), +('凉鞋', '服装', '百丽', 220.00, '2023-11-12'), +('羽绒服', '服装', '波司登', 550.00, '2023-12-05'), +('保暖内衣', '服装', '三枪', 120.00, '2023-10-15'), +('进口巧克力', '食品', '费列罗', 60.00, '2023-08-20'), +('有机蔬菜', '食品', '有机农场', 25.00, '2023-09-01'), +('热带水果', '食品', '果园直供', 45.00, '2023-10-10'), +('牛肉干', '食品', '草原牧歌', 85.00, '2023-11-20'); + +-- 创建视图用于统计查询 +CREATE VIEW sales_summary AS +SELECT + region, + product_category, + COUNT(*) as order_count, + SUM(quantity) as total_quantity, + SUM(sales_amount) as total_sales, + AVG(sales_amount) as avg_order_amount, + MIN(sales_amount) as min_order_amount, + MAX(sales_amount) as max_order_amount +FROM sales_data +GROUP BY region, product_category; + +-- 添加索引提高查询性能 +CREATE INDEX idx_sales_region ON sales_data(region); +CREATE INDEX idx_sales_category ON sales_data(product_category); +CREATE INDEX idx_sales_date ON sales_data(sale_date); +CREATE INDEX idx_sales_person ON sales_data(sales_person); + +-- 查询测试数据 +SELECT '数据库初始化完成' as message; +SELECT COUNT(*) as total_records FROM sales_data; +SELECT DISTINCT region FROM sales_data ORDER BY region; +SELECT DISTINCT product_category FROM sales_data ORDER BY product_category; \ No newline at end of file diff --git a/springboot-report/src/main/java/com/example/report/ReportApplication.java b/springboot-report/src/main/java/com/example/report/ReportApplication.java new file mode 100644 index 0000000..d9c2913 --- /dev/null +++ b/springboot-report/src/main/java/com/example/report/ReportApplication.java @@ -0,0 +1,11 @@ +package com.example.report; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ReportApplication { + public static void main(String[] args) { + SpringApplication.run(ReportApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-report/src/main/java/com/example/report/config/CorsConfig.java b/springboot-report/src/main/java/com/example/report/config/CorsConfig.java new file mode 100644 index 0000000..c216d9a --- /dev/null +++ b/springboot-report/src/main/java/com/example/report/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.example.report.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} \ No newline at end of file diff --git a/springboot-report/src/main/java/com/example/report/controller/ReportController.java b/springboot-report/src/main/java/com/example/report/controller/ReportController.java new file mode 100644 index 0000000..d7c73c9 --- /dev/null +++ b/springboot-report/src/main/java/com/example/report/controller/ReportController.java @@ -0,0 +1,53 @@ +package com.example.report.controller; + +import com.example.report.entity.ColumnInfo; +import com.example.report.entity.QueryRequest; +import com.example.report.entity.TableInfo; +import com.example.report.service.MetaDataService; +import com.example.report.service.QueryService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/report") +@CrossOrigin(origins = "*") +public class ReportController { + + @Autowired + private MetaDataService metaDataService; + + @Autowired + private QueryService queryService; + + @GetMapping("/databases") + public ResponseEntity> getDatabases() { + List databases = metaDataService.getDatabases(); + return ResponseEntity.ok(databases); + } + + @GetMapping("/tables/{database}") + public ResponseEntity> getTables(@PathVariable String database) { + List tables = metaDataService.getDatabaseTables(database); + return ResponseEntity.ok(tables); + } + + @GetMapping("/columns/{database}/{table}") + public ResponseEntity> getColumns( + @PathVariable String database, + @PathVariable String table) { + List columns = metaDataService.getTableColumns(database, table); + return ResponseEntity.ok(columns); + } + + @PostMapping("/query/{database}") + public ResponseEntity>> executeQuery( + @PathVariable String database, + @RequestBody QueryRequest request) { + List> result = queryService.executeQuery(request, database); + return ResponseEntity.ok(result); + } +} \ No newline at end of file diff --git a/springboot-report/src/main/java/com/example/report/entity/ColumnInfo.java b/springboot-report/src/main/java/com/example/report/entity/ColumnInfo.java new file mode 100644 index 0000000..cc370fb --- /dev/null +++ b/springboot-report/src/main/java/com/example/report/entity/ColumnInfo.java @@ -0,0 +1,59 @@ +package com.example.report.entity; + +public class ColumnInfo { + private String columnName; + private String dataType; + private String columnComment; + private boolean isNullable; + private String columnType; + + public ColumnInfo() {} + + public ColumnInfo(String columnName, String dataType, String columnComment, boolean isNullable, String columnType) { + this.columnName = columnName; + this.dataType = dataType; + this.columnComment = columnComment; + this.isNullable = isNullable; + this.columnType = columnType; + } + + public String getColumnName() { + return columnName; + } + + public void setColumnName(String columnName) { + this.columnName = columnName; + } + + public String getDataType() { + return dataType; + } + + public void setDataType(String dataType) { + this.dataType = dataType; + } + + public String getColumnComment() { + return columnComment; + } + + public void setColumnComment(String columnComment) { + this.columnComment = columnComment; + } + + public boolean isNullable() { + return isNullable; + } + + public void setNullable(boolean nullable) { + isNullable = nullable; + } + + public String getColumnType() { + return columnType; + } + + public void setColumnType(String columnType) { + this.columnType = columnType; + } +} \ No newline at end of file diff --git a/springboot-report/src/main/java/com/example/report/entity/QueryRequest.java b/springboot-report/src/main/java/com/example/report/entity/QueryRequest.java new file mode 100644 index 0000000..ef9eec2 --- /dev/null +++ b/springboot-report/src/main/java/com/example/report/entity/QueryRequest.java @@ -0,0 +1,45 @@ +package com.example.report.entity; + +import java.util.List; + +public class QueryRequest { + private String tableName; + private List dimensions; + private List metrics; + private List filters; + private int limit = 100; + + public static class FilterCondition { + private String field; + private String operator; + private String value; + + public FilterCondition() {} + + public FilterCondition(String field, String operator, String value) { + this.field = field; + this.operator = operator; + this.value = value; + } + + public String getField() { return field; } + public void setField(String field) { this.field = field; } + public String getOperator() { return operator; } + public void setOperator(String operator) { this.operator = operator; } + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + } + + public QueryRequest() {} + + public String getTableName() { return tableName; } + public void setTableName(String tableName) { this.tableName = tableName; } + public List getDimensions() { return dimensions; } + public void setDimensions(List dimensions) { this.dimensions = dimensions; } + public List getMetrics() { return metrics; } + public void setMetrics(List metrics) { this.metrics = metrics; } + public List getFilters() { return filters; } + public void setFilters(List filters) { this.filters = filters; } + public int getLimit() { return limit; } + public void setLimit(int limit) { this.limit = limit; } +} \ No newline at end of file diff --git a/springboot-report/src/main/java/com/example/report/entity/TableInfo.java b/springboot-report/src/main/java/com/example/report/entity/TableInfo.java new file mode 100644 index 0000000..c10edb7 --- /dev/null +++ b/springboot-report/src/main/java/com/example/report/entity/TableInfo.java @@ -0,0 +1,39 @@ +package com.example.report.entity; + +public class TableInfo { + private String tableName; + private String tableComment; + private int columnCount; + + public TableInfo() {} + + public TableInfo(String tableName, String tableComment, int columnCount) { + this.tableName = tableName; + this.tableComment = tableComment; + this.columnCount = columnCount; + } + + public String getTableName() { + return tableName; + } + + public void setTableName(String tableName) { + this.tableName = tableName; + } + + public String getTableComment() { + return tableComment; + } + + public void setTableComment(String tableComment) { + this.tableComment = tableComment; + } + + public int getColumnCount() { + return columnCount; + } + + public void setColumnCount(int columnCount) { + this.columnCount = columnCount; + } +} \ No newline at end of file diff --git a/springboot-report/src/main/java/com/example/report/service/MetaDataService.java b/springboot-report/src/main/java/com/example/report/service/MetaDataService.java new file mode 100644 index 0000000..59c2414 --- /dev/null +++ b/springboot-report/src/main/java/com/example/report/service/MetaDataService.java @@ -0,0 +1,76 @@ +package com.example.report.service; + +import com.example.report.entity.TableInfo; +import com.example.report.entity.ColumnInfo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class MetaDataService { + + @Autowired + private JdbcTemplate jdbcTemplate; + + public List getDatabaseTables(String databaseName) { + String sql = "SELECT TABLE_NAME, TABLE_COMMENT, " + + "(SELECT COUNT(*) FROM information_schema.COLUMNS C WHERE C.TABLE_NAME = T.TABLE_NAME AND C.TABLE_SCHEMA = T.TABLE_SCHEMA) as COLUMN_COUNT " + + "FROM information_schema.TABLES T " + + "WHERE T.TABLE_SCHEMA = ? AND T.TABLE_TYPE = 'BASE TABLE' " + + "ORDER BY TABLE_NAME"; + + List> rows = jdbcTemplate.queryForList(sql, databaseName); + List tables = new ArrayList<>(); + + for (Map row : rows) { + TableInfo table = new TableInfo(); + table.setTableName((String) row.get("TABLE_NAME")); + table.setTableComment((String) row.get("TABLE_COMMENT")); + table.setColumnCount(((Number) row.get("COLUMN_COUNT")).intValue()); + tables.add(table); + } + + return tables; + } + + public List getTableColumns(String databaseName, String tableName) { + String sql = "SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_TYPE " + + "FROM information_schema.COLUMNS " + + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? " + + "ORDER BY ORDINAL_POSITION"; + + List> rows = jdbcTemplate.queryForList(sql, databaseName, tableName); + List columns = new ArrayList<>(); + + for (Map row : rows) { + ColumnInfo column = new ColumnInfo(); + column.setColumnName((String) row.get("COLUMN_NAME")); + column.setDataType((String) row.get("DATA_TYPE")); + column.setColumnComment((String) row.get("COLUMN_COMMENT")); + column.setNullable("YES".equals(row.get("IS_NULLABLE"))); + column.setColumnType((String) row.get("COLUMN_TYPE")); + columns.add(column); + } + + return columns; + } + + public List getDatabases() { + String sql = "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA " + + "WHERE SCHEMA_NAME NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') " + + "ORDER BY SCHEMA_NAME"; + + List> rows = jdbcTemplate.queryForList(sql); + List databases = new ArrayList<>(); + + for (Map row : rows) { + databases.add((String) row.get("SCHEMA_NAME")); + } + + return databases; + } +} \ No newline at end of file diff --git a/springboot-report/src/main/java/com/example/report/service/QueryService.java b/springboot-report/src/main/java/com/example/report/service/QueryService.java new file mode 100644 index 0000000..60ed97e --- /dev/null +++ b/springboot-report/src/main/java/com/example/report/service/QueryService.java @@ -0,0 +1,159 @@ +package com.example.report.service; + +import com.example.report.entity.QueryRequest; +import com.example.report.entity.ColumnInfo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class QueryService { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MetaDataService metaDataService; + + public List> executeQuery(QueryRequest request, String databaseName) { + String sql = buildSQL(request, databaseName); + System.out.println("Generated SQL: " + sql); + + if (request.getLimit() > 0) { + sql += " LIMIT " + request.getLimit(); + } + + return jdbcTemplate.queryForList(sql); + } + + private String buildSQL(QueryRequest request, String databaseName) { + StringBuilder sql = new StringBuilder(); + + // SELECT clause + sql.append("SELECT "); + + List selectFields = new ArrayList<>(); + boolean hasDimensions = request.getDimensions() != null && !request.getDimensions().isEmpty(); + boolean hasMetrics = request.getMetrics() != null && !request.getMetrics().isEmpty(); + + // Add dimensions + if (hasDimensions) { + selectFields.addAll(request.getDimensions()); + } + + // Add metrics with aggregation + if (hasMetrics) { + List columns = metaDataService.getTableColumns(databaseName, request.getTableName()); + + for (String metric : request.getMetrics()) { + String dataType = getDataTypeForColumn(columns, metric); + String aggFunction = getAggregationFunction(dataType); + selectFields.add(aggFunction + "(" + metric + ") as " + metric); + } + } else if (hasDimensions) { + // 智能优化:如果只有维度没有指标,自动添加 COUNT(*) 作为默认指标 + // 这样前端就能看到每个维度的记录数,而不是重复数据 + selectFields.add("COUNT(*) as record_count"); + } + + // If no dimensions and no metrics, select all + if (selectFields.isEmpty()) { + sql.append("*"); + } else { + sql.append(String.join(", ", selectFields)); + } + + // FROM clause + sql.append(" FROM ").append(databaseName).append(".").append(request.getTableName()); + + // WHERE clause + if (request.getFilters() != null && !request.getFilters().isEmpty()) { + sql.append(" WHERE "); + List conditions = new ArrayList<>(); + + for (QueryRequest.FilterCondition filter : request.getFilters()) { + String condition = buildCondition(filter); + if (condition != null) { + conditions.add(condition); + } + } + + if (!conditions.isEmpty()) { + sql.append(String.join(" AND ", conditions)); + } + } + + // GROUP BY clause - 修复:只要有多个维度就应该分组 + // 多个维度时,必须分组以避免数据重复 + if (hasDimensions) { + // 重要:只要有多个维度就生成 GROUP BY + sql.append(" GROUP BY ").append(String.join(", ", request.getDimensions())); + } + + return sql.toString(); + } + + private String buildCondition(QueryRequest.FilterCondition filter) { + if (filter.getField() == null || filter.getOperator() == null || filter.getValue() == null) { + return null; + } + + String field = filter.getField(); + String operator = filter.getOperator(); + String value = filter.getValue(); + + switch (operator.toLowerCase()) { + case "=": + case "eq": + return field + " = '" + escapeSql(value) + "'"; + case "!=": + case "ne": + return field + " != '" + escapeSql(value) + "'"; + case ">": + case "gt": + return field + " > " + value; + case ">=": + case "gte": + return field + " >= " + value; + case "<": + case "lt": + return field + " < " + value; + case "<=": + case "lte": + return field + " <= " + value; + case "like": + return field + " LIKE '%" + escapeSql(value) + "%'"; + case "in": + return field + " IN (" + value + ")"; + default: + return null; + } + } + + private String escapeSql(String value) { + return value.replace("'", "''"); + } + + private String getDataTypeForColumn(List columns, String columnName) { + for (ColumnInfo column : columns) { + if (column.getColumnName().equals(columnName)) { + return column.getDataType(); + } + } + return "varchar"; + } + + private String getAggregationFunction(String dataType) { + if (dataType != null && (dataType.toLowerCase().contains("int") || + dataType.toLowerCase().contains("decimal") || + dataType.toLowerCase().contains("float") || + dataType.toLowerCase().contains("double"))) { + return "SUM"; + } + return "COUNT"; + } +} diff --git a/springboot-report/src/main/resources/application.yml b/springboot-report/src/main/resources/application.yml new file mode 100644 index 0000000..a0cd14d --- /dev/null +++ b/springboot-report/src/main/resources/application.yml @@ -0,0 +1,16 @@ +server: + port: 8080 + +spring: + application: + name: report-platform + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/test_db?useSSL=false + username: root + password: root + +logging: + level: + com.example.report: INFO \ No newline at end of file diff --git a/springboot-report/src/main/resources/static/app.js b/springboot-report/src/main/resources/static/app.js new file mode 100644 index 0000000..d522612 --- /dev/null +++ b/springboot-report/src/main/resources/static/app.js @@ -0,0 +1,1442 @@ +class BIReportPlatform { + constructor() { + this.API_BASE = 'http://localhost:8080/report'; + this.currentDatabase = ''; + this.currentTable = ''; + this.chartType = 'table'; + this.chartInstance = null; + this.currentData = []; + this.currentLimit = 100; + this.xAxisDimension = ''; + this.groupDimensions = []; + this.enableTableGrouping = false; + + this.init(); + } + + init() { + this.setupEventListeners(); + this.setupDragAndDrop(); + this.loadDatabases(); + this.updateConnectionStatus(); + + // 默认选中表格图表类型 + setTimeout(() => { + this.selectChartType('table'); + }, 100); + } + + setupEventListeners() { + // Database selection + document.getElementById('databaseSelect').addEventListener('change', (e) => { + this.currentDatabase = e.target.value; + if (this.currentDatabase) { + this.loadTables(this.currentDatabase); + } else { + this.clearTables(); + } + }); + + // Table selection + document.getElementById('tableSelect').addEventListener('change', (e) => { + this.currentTable = e.target.value; + if (this.currentTable) { + this.loadColumns(this.currentDatabase, this.currentTable); + } else { + this.clearColumns(); + } + }); + + // Chart type buttons + document.querySelectorAll('.chart-type-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + this.selectChartType(e.target.dataset.type); + }); + }); + + // Query button + document.getElementById('queryBtn').addEventListener('click', () => { + this.executeQuery(); + }); + } + + setupDragAndDrop() { + const availableFields = document.getElementById('availableFields'); + const dimensions = document.getElementById('dimensions'); + const metrics = document.getElementById('metrics'); + + // Make containers sortable + [availableFields, dimensions, metrics].forEach(container => { + new Sortable(container, { + group: 'fields', + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + onEnd: (evt) => { + this.handleFieldDrop(evt); + } + }); + }); + } + + handleFieldDrop(evt) { + const field = evt.item; + const fromContainer = evt.from; + const toContainer = evt.to; + + // 添加拖拽动画效果 + field.classList.add('slide-in'); + + // Remove placeholder text if this is the first item + if (toContainer.children.length === 1) { + const placeholder = toContainer.querySelector('.text-gray-400, .text-gray-500'); + if (placeholder) { + placeholder.remove(); + } + } + + // Add placeholder back if container is empty + if (fromContainer.children.length === 0) { + const placeholderText = fromContainer.id === 'dimensions' ? + '拖拽维度字段到此处' : '拖拽指标字段到此处'; + const placeholder = document.createElement('p'); + placeholder.className = 'text-gray-400 text-sm text-center'; + placeholder.textContent = placeholderText; + fromContainer.appendChild(placeholder); + } + + // Style field items with enhanced design + if (toContainer.id !== 'availableFields') { + // 根据容器类型设置不同的样式 + const isDimension = toContainer.id === 'dimensions'; + const colorClass = isDimension ? 'blue' : 'green'; + + field.className = `field-tag bg-${colorClass}-100 border border-${colorClass}-300 rounded-lg px-3 py-2 text-sm cursor-move flex justify-between items-center hover:shadow-md`; + + // Add remove button with better styling + if (!field.querySelector('.remove-btn')) { + const removeBtn = document.createElement('button'); + removeBtn.className = `remove-btn ml-2 w-5 h-5 rounded-full bg-${colorClass}-500 text-white hover:bg-${colorClass}-600 flex items-center justify-center transition-colors`; + removeBtn.innerHTML = ` + + + + `; + removeBtn.title = '移除字段'; + removeBtn.onclick = (e) => { + e.stopPropagation(); + this.removeFieldWithAnimation(field, fromContainer); + }; + field.appendChild(removeBtn); + } + } else { + // 返回到可用字段区域的样式 + field.className = 'field-tag bg-gray-100 border border-gray-300 rounded-lg px-3 py-2 text-sm cursor-move hover:bg-gray-200'; + const removeBtn = field.querySelector('.remove-btn'); + if (removeBtn) { + removeBtn.remove(); + } + } + + // 移除动画类 + setTimeout(() => { + field.classList.remove('slide-in'); + }, 300); + } + + checkEmptyContainers() { + ['dimensions', 'metrics'].forEach(containerId => { + const container = document.getElementById(containerId); + // 排除占位符元素 + const fields = Array.from(container.children).filter(child => + !child.classList.contains('text-gray-400') && !child.classList.contains('text-gray-500') + ); + + if (fields.length === 0) { + const placeholderText = containerId === 'dimensions' ? + '拖拽维度字段到此处' : '拖拽指标字段到此处'; + const placeholder = document.createElement('p'); + placeholder.className = 'text-gray-400 text-sm text-center'; + placeholder.textContent = placeholderText; + container.appendChild(placeholder); + } + }); + } + + removeFieldWithAnimation(field, targetContainer) { + // 添加移除动画 + field.style.transform = 'scale(0.8)'; + field.style.opacity = '0'; + field.style.transition = 'all 0.2s ease-out'; + + setTimeout(() => { + field.remove(); + this.checkEmptyContainers(); + }, 200); + } + + async loadDatabases() { + try { + const response = await fetch(`${this.API_BASE}/databases`); + const databases = await response.json(); + + const select = document.getElementById('databaseSelect'); + select.innerHTML = ''; + + databases.forEach(db => { + const option = document.createElement('option'); + option.value = db; + option.textContent = db; + select.appendChild(option); + }); + } catch (error) { + console.error('Error loading databases:', error); + this.showError('加载数据库列表失败'); + } + } + + async loadTables(database) { + try { + this.showLoading(true); + const response = await fetch(`${this.API_BASE}/tables/${database}`); + const tables = await response.json(); + + const select = document.getElementById('tableSelect'); + select.innerHTML = ''; + select.disabled = false; + + tables.forEach(table => { + const option = document.createElement('option'); + option.value = table.tableName; + option.textContent = `${table.tableName} (${table.columnCount}列)`; + select.appendChild(option); + }); + } catch (error) { + console.error('Error loading tables:', error); + this.showError('加载数据表列表失败'); + } finally { + this.showLoading(false); + } + } + + async loadColumns(database, table) { + try { + this.showLoading(true); + const response = await fetch(`${this.API_BASE}/columns/${database}/${table}`); + const columns = await response.json(); + + const container = document.getElementById('availableFields'); + container.innerHTML = ''; + + columns.forEach((column, index) => { + const field = document.createElement('div'); + field.className = 'field-tag bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm cursor-move hover:shadow-md hover:border-blue-300'; + field.draggable = true; + field.dataset.field = column.columnName; + field.dataset.type = column.dataType; + + // 根据数据类型设置图标 + const typeIcon = this.getTypeIcon(column.dataType); + const typeColor = this.getTypeColor(column.dataType); + + field.innerHTML = ` +
+
+ ${typeIcon} + ${column.columnName} +
+ ${column.dataType} +
+ ${column.columnComment ? `
${column.columnComment}
` : ''} + `; + + // 添加动画延迟 + field.style.animationDelay = `${index * 50}ms`; + field.classList.add('slide-in'); + + container.appendChild(field); + }); + } catch (error) { + console.error('Error loading columns:', error); + this.showError('加载字段列表失败'); + } finally { + this.showLoading(false); + } + } + + clearTables() { + const tableSelect = document.getElementById('tableSelect'); + tableSelect.innerHTML = ''; + tableSelect.disabled = true; + this.clearColumns(); + } + + clearColumns() { + const container = document.getElementById('availableFields'); + container.innerHTML = ` +
+
+ + + +

请先选择数据表

+
+
+ `; + this.clearQueryFields(); + } + + clearQueryFields() { + ['dimensions', 'metrics'].forEach(containerId => { + const container = document.getElementById(containerId); + const placeholderText = containerId === 'dimensions' ? + '拖拽维度字段到此处' : '拖拽指标字段到此处'; + container.innerHTML = `

${placeholderText}

`; + }); + } + + selectChartType(type) { + this.chartType = type; + + // Update button styles - 修复文字颜色问题 + document.querySelectorAll('.chart-type-btn').forEach(btn => { + btn.classList.remove('bg-blue-600', 'text-white', 'border-blue-600'); + btn.classList.add('border-gray-200', 'text-gray-700', 'bg-white'); + }); + + const selectedBtn = document.querySelector(`[data-type="${type}"]`); + if (selectedBtn) { + selectedBtn.classList.remove('border-gray-200', 'text-gray-700', 'bg-white'); + selectedBtn.classList.add('bg-blue-600', 'text-white', 'border-blue-600'); + } + + // Show/hide dimension control panel and table controls + const dimensionControl = document.getElementById('dimensionControl'); + const tableControls = document.getElementById('tableControls'); + + if (type === 'bar' || type === 'line' || type === 'pie') { + dimensionControl.classList.remove('hidden'); + tableControls.classList.add('hidden'); + this.updateDimensionControl(); + } else if (type === 'table') { + dimensionControl.classList.add('hidden'); + tableControls.classList.remove('hidden'); + // Setup table controls after the element is shown + setTimeout(() => this.setupTableControls(), 0); + } + } + + updateDimensionControl() { + const dimensions = this.getSelectedFields('dimensions'); + const xAxisSelect = document.getElementById('xAxisDimension'); + + // Update X-axis dimension options + xAxisSelect.innerHTML = ''; + dimensions.forEach(dim => { + const option = document.createElement('option'); + option.value = dim; + option.textContent = dim; + xAxisSelect.appendChild(option); + }); + + // If dimensions changed, reset selections + if (dimensions.length > 0) { + if (!dimensions.includes(this.xAxisDimension)) { + this.xAxisDimension = dimensions[0]; + xAxisSelect.value = this.xAxisDimension; + this.updateGroupDimensions(); + } + } else { + this.xAxisDimension = ''; + this.groupDimensions = []; + document.getElementById('groupDimensions').innerHTML = '

请先选择X轴维度

'; + } + + // Bind X-axis selection event + xAxisSelect.onchange = (e) => { + this.xAxisDimension = e.target.value; + this.updateGroupDimensions(); + if (this.currentData.length > 0) { + this.renderChart(this.currentData); + } + }; + } + + updateGroupDimensions() { + const dimensions = this.getSelectedFields('dimensions'); + const groupContainer = document.getElementById('groupDimensions'); + this.groupDimensions = dimensions.filter(d => d !== this.xAxisDimension); + + groupContainer.innerHTML = ''; + if (this.groupDimensions.length === 0) { + groupContainer.innerHTML = '

无其他维度

'; + return; + } + + this.groupDimensions.forEach(dim => { + const label = document.createElement('label'); + label.className = 'flex items-center text-sm text-gray-700'; + label.innerHTML = ` + + ${dim} + `; + groupContainer.appendChild(label); + + // Bind change event + const checkbox = label.querySelector('input'); + checkbox.onchange = () => { + const checkedDims = Array.from(groupContainer.querySelectorAll('input:checked')) + .map(cb => cb.value); + this.groupDimensions = checkedDims; + if (this.currentData.length > 0) { + this.renderChart(this.currentData); + } + }; + }); + } + + async executeQuery() { + if (!this.currentDatabase || !this.currentTable) { + this.showError('请先选择数据库和数据表'); + return; + } + + const dimensions = this.getSelectedFields('dimensions'); + const metrics = this.getSelectedFields('metrics'); + + if (dimensions.length === 0 && metrics.length === 0) { + this.showError('请至少选择一个维度或指标字段'); + return; + } + + const queryRequest = { + tableName: this.currentTable, + dimensions: dimensions, + metrics: metrics, + filters: [], + limit: this.currentLimit + }; + + try { + this.showLoading(true); + const response = await fetch(`${this.API_BASE}/query/${this.currentDatabase}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(queryRequest) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + this.currentData = data; + + // Update dimension control when data changes + this.updateDimensionControl(); + + this.renderChart(data); + + // 显示成功提示 + if (data && data.length > 0) { + this.showSuccess(`查询成功,返回 ${data.length} 条数据`); + } else { + this.showSuccess('查询成功,但无数据返回'); + } + } catch (error) { + console.error('Error executing query:', error); + this.showError('查询执行失败: ' + error.message); + } finally { + this.showLoading(false); + } + } + + getSelectedFields(containerId) { + const container = document.getElementById(containerId); + const fields = Array.from(container.querySelectorAll('[data-field]')); + return fields.map(field => field.dataset.field); + } + + renderChart(data) { + const container = document.getElementById('chartContainer'); + + // 清空容器内容 + container.innerHTML = ''; + container.className = 'w-full'; + + if (this.chartType === 'table') { + this.renderTable(container, data); + } else { + this.renderEChart(container, data); + } + } + + renderTable(container, data) { + if (data.length === 0) { + container.innerHTML = ` +
+
+ + + +

查询结果为空

+

请调整查询条件后重试

+
+
+ `; + return; + } + + const rowCount = data.length; + const isLimited = this.currentLimit <= 500 && rowCount === this.currentLimit; + const dimensions = this.getSelectedFields('dimensions'); + const metrics = this.getSelectedFields('metrics'); + + // Prepare grouped data if enabled + let displayData = data; + let grouped = false; + + if (this.enableTableGrouping && dimensions.length >= 1) { + grouped = true; + displayData = this.groupDataForTable(data, dimensions, metrics); + + // If grouping resulted in empty data, fall back to normal view + if (displayData.length === 0) { + grouped = false; + displayData = data; + } + } + + // If still empty after grouping, show empty message + if (!displayData || displayData.length === 0) { + container.innerHTML = ` +
+
+

查询结果为空

+

请调整查询条件后重试

+
+
+ `; + return; + } + + // Determine columns based on data structure + let columns = []; + if (grouped) { + // For grouped view, show all original columns + // We'll handle grouping in the rendering logic + if (displayData.length > 0 && displayData[0].details.length > 0) { + columns = Object.keys(displayData[0].details[0]); + } + } else { + // For normal view, show all columns from first row + columns = Object.keys(displayData[0]); + } + + // Create main result container + const mainDiv = document.createElement('div'); + mainDiv.className = 'space-y-4'; + + // Create table container with proper height + const tableContainer = document.createElement('div'); + tableContainer.className = 'border border-gray-200 rounded-lg overflow-hidden bg-white'; + tableContainer.style.maxHeight = '500px'; + + const table = document.createElement('table'); + table.className = 'min-w-full divide-y divide-gray-200'; + + // Create header + const thead = document.createElement('thead'); + thead.className = 'bg-gray-50 sticky top-0 z-10'; + const headerRow = document.createElement('tr'); + + columns.forEach(key => { + const th = document.createElement('th'); + th.className = 'px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-200'; + th.textContent = key; + headerRow.appendChild(th); + }); + + thead.appendChild(headerRow); + table.appendChild(thead); + + // Create body + const tbody = document.createElement('tbody'); + tbody.className = 'bg-white divide-y divide-gray-200'; + + if (grouped) { + // Render merged cell table + this.renderMergedCellTable(tbody, displayData, dimensions, metrics, columns); + } else { + // Render normal rows + displayData.forEach((row, index) => { + const rowClass = index % 2 === 0 ? 'bg-white' : 'bg-gray-50'; + const tr = document.createElement('tr'); + tr.className = `${rowClass} hover:bg-blue-50 transition-colors`; + + columns.forEach(key => { + const td = document.createElement('td'); + td.className = 'px-6 py-3 whitespace-nowrap text-sm text-gray-900 border-b border-gray-100'; + const value = row[key]; + td.textContent = value !== null && value !== undefined ? value : ''; + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + } + + table.appendChild(tbody); + tableContainer.appendChild(table); + mainDiv.appendChild(tableContainer); + + // Create control bar + const controlBar = document.createElement('div'); + controlBar.className = 'flex justify-between items-center pt-4 border-t border-gray-200'; + const detailCount = grouped ? this.countAllProcessedRows(displayData) : rowCount; + + let groupInfo = ''; + if (grouped) { + const primaryGroups = displayData.length; + const secondaryGroups = this.countAllSubGroups(displayData); + + if (dimensions.length === 1) { + groupInfo = `按${dimensions[0]}分组合并,共${primaryGroups}个分组`; + } else if (dimensions.length === 2) { + groupInfo = `按${dimensions[0]}、${dimensions[1]}多级分组合并,共${primaryGroups}个${dimensions[0]}分组,${secondaryGroups}个${dimensions[1]}分组`; + } else { + groupInfo = `按${dimensions.join('、')}多级分组合并,共${primaryGroups}个顶级分组`; + } + } + + controlBar.innerHTML = ` +
+ ${grouped ? + ` + + + + 多级单元格合并模式 + + ${groupInfo} + 总计${detailCount.toLocaleString()}条明细数据` : + `显示 ${rowCount.toLocaleString()} 条数据` + } + ${isLimited ? `⚠️ 可能还有更多数据` : ''} +
+
+ + ${isLimited ? `` : ''} +
+ `; + + mainDiv.appendChild(controlBar); + container.appendChild(mainDiv); + + // 绑定事件监听器 + this.setupTableControls(); + + // 对于合并单元格模式,不需要展开/折叠功能 + // 所有数据都通过合并单元格可见 + } + + groupDataForTable(data, dimensions, metrics) { + if (!data || data.length === 0) { + return []; + } + + // Debug: Log input data + console.log('Input data for grouping:', data.length, 'rows'); + console.log('Dimensions:', dimensions); + console.log('Metrics:', metrics); + + // Create hierarchical grouping structure + const hierarchicalData = this.createHierarchicalGroups(data, dimensions, metrics); + + // Debug: Log grouped data + console.log('Grouped data structure:', hierarchicalData); + const totalProcessed = this.countAllProcessedRows(hierarchicalData); + console.log('Total processed rows:', totalProcessed, '(should match input rows)'); + + return hierarchicalData; + } + + countAllProcessedRows(groups) { + return groups.reduce((count, group) => { + if (group.subGroups && group.subGroups.length > 0) { + return count + this.countAllProcessedRows(group.subGroups); + } + return count + (group.details ? group.details.length : 0); + }, 0); + } + + createHierarchicalGroups(data, dimensions, metrics) { + if (dimensions.length === 0) { + // No dimensions, return flat data + return data.map(row => ({ + level: 0, + groupValues: {}, + details: [row], + metrics: metrics.reduce((acc, metric) => { + acc[metric] = parseFloat(row[metric]) || 0; + return acc; + }, {}), + subGroups: [] + })); + } + + // Create nested groups for each dimension level + const createNestedGroups = (data, dimIndex) => { + if (dimIndex >= dimensions.length) { + // Last level: create leaf groups for each unique row + const leafMap = new Map(); + data.forEach(row => { + // Create a unique key for this row based on all dimension values + const rowKey = dimensions.map(dim => row[dim] || '').join('|'); + if (!leafMap.has(rowKey)) { + leafMap.set(rowKey, { + level: dimIndex, + groupValues: dimensions.reduce((acc, dim) => { + acc[dim] = row[dim] || ''; + return acc; + }, {}), + details: [], + metrics: metrics.reduce((acc, metric) => { + acc[metric] = 0; + return acc; + }, {}), + subGroups: [] + }); + } + const leafGroup = leafMap.get(rowKey); + leafGroup.details.push(row); + + // Add metrics for this row + metrics.forEach(metric => { + leafGroup.metrics[metric] += parseFloat(row[metric]) || 0; + }); + }); + return Array.from(leafMap.values()); + } + + const currentDim = dimensions[dimIndex]; + const groupedMap = new Map(); + + data.forEach(row => { + const dimValue = row[currentDim] || '未知'; + if (!groupedMap.has(dimValue)) { + groupedMap.set(dimValue, []); + } + groupedMap.get(dimValue).push(row); + }); + + // Convert map to array with nested structure + return Array.from(groupedMap.entries()).map(([dimValue, groupData]) => { + const subGroups = createNestedGroups(groupData, dimIndex + 1); + + // Calculate metrics for this group by summing all sub-group metrics + const groupMetrics = {}; + metrics.forEach(metric => { + groupMetrics[metric] = subGroups.reduce((sum, group) => { + return sum + (group.metrics[metric] || 0); + }, 0); + }); + + return { + level: dimIndex, + groupValues: { + [currentDim]: dimValue + }, + groupKey: dimValue, + details: groupData, + metrics: groupMetrics, + subGroups: subGroups + }; + }); + }; + + return createNestedGroups(data, 0); + } + + calculateGroupMetrics(details, metrics) { + const result = {}; + if (!metrics || metrics.length === 0) { + return result; + } + + // Calculate SUM for each metric + metrics.forEach(metric => { + const sum = details.reduce((acc, row) => { + const val = parseFloat(row[metric]) || 0; + return acc + val; + }, 0); + result[metric] = sum; + }); + + return result; + } + + renderMergedCellTable(tbody, groupedData, dimensions, metrics, columns) { + // Calculate other columns (non-dimension, non-metric) + const otherColumns = columns.filter(col => + !dimensions.includes(col) && !metrics.includes(col) + ); + + // Flatten grouped data for simple rendering + const flatData = this.flattenGroupedData(groupedData, dimensions, metrics); + + // Render simple merged cell table + this.renderSimpleMergedTable(tbody, flatData, dimensions, metrics, otherColumns); + + // Add summary row at the end + this.addSummaryRow(tbody, dimensions, metrics, otherColumns, groupedData); + } + + flattenGroupedData(groupedData, dimensions, metrics) { + const flatRows = []; + + const processGroup = (group, dimIndex, parentValues = {}) => { + const currentValues = { ...parentValues, ...group.groupValues }; + + if (group.subGroups && group.subGroups.length > 0) { + // This is a parent group + group.subGroups.forEach(subGroup => { + processGroup(subGroup, dimIndex + 1, currentValues); + }); + } else { + // This is a leaf group, add each detail row + if (group.details) { + group.details.forEach(detailRow => { + const flatRow = { + ...detailRow, + _groupValues: currentValues, + _groupMetrics: group.metrics, + _isGroupHeader: false + }; + + // Add a group header row if needed + if (dimensions.length > 1 && dimIndex < dimensions.length - 1) { + const headerRow = { + ...detailRow, + _groupValues: currentValues, + _groupMetrics: group.metrics, + _isGroupHeader: true, + _level: dimIndex + }; + flatRows.push(headerRow); + } + + flatRows.push(flatRow); + }); + } + } + }; + + groupedData.forEach(group => processGroup(group, 0)); + + return flatRows; + } + + renderSimpleMergedTable(tbody, flatData, dimensions, metrics, otherColumns) { + if (flatData.length === 0) return; + + // Track rowspan for each dimension + const dimensionSpans = {}; + const dimensionValues = {}; + + dimensions.forEach(dim => { + dimensionSpans[dim] = {}; + dimensionValues[dim] = null; + }); + + let currentRow = 0; + + flatData.forEach((row, rowIndex) => { + const tr = document.createElement('tr'); + tr.className = currentRow % 2 === 0 ? 'bg-white hover:bg-gray-50' : 'bg-gray-50 hover:bg-gray-100'; + tr.className += ' transition-colors'; + + // Add dimension cells with rowspan logic + dimensions.forEach((dim, dimIndex) => { + const td = document.createElement('td'); + const currentValue = row._groupValues[dim] || row[dim] || ''; + + // Check if this value should have a rowspan + let spanCount = 1; + if (rowIndex === 0 || currentValue !== dimensionValues[dim]) { + // Calculate rowspan for this value + spanCount = this.calculateRowSpanForValue(flatData, rowIndex, dim); + dimensionSpans[dim][currentValue] = spanCount - 1; // Remaining rows after current one + + // Set rowspan + if (spanCount > 1) { + td.rowSpan = spanCount; + td.style.verticalAlign = 'middle'; + } + + // Style the cell + const level = Object.keys(row._groupValues).indexOf(dim); + td.className = `px-6 py-3 text-sm font-medium border-b border-gray-200 ${this.getLevelBackgroundColor(level)}`; + + const icon = this.getLevelIcon(level); + td.innerHTML = ` +
+ + ${icon} + + ${currentValue} +
+ `; + } else if (dimensionSpans[dim][currentValue] > 0) { + // This cell is covered by a rowspan from above + dimensionSpans[dim][currentValue]--; + return; // Don't add this cell + } + + dimensionValues[dim] = currentValue; + tr.appendChild(td); + }); + + // Add metric cells + metrics.forEach(metric => { + const td = document.createElement('td'); + td.className = 'px-6 py-3 text-sm text-gray-900 border-b border-gray-200 text-right'; + + if (row._isGroupHeader) { + // Show group aggregated value for headers + const value = row._groupMetrics[metric] || 0; + td.innerHTML = `${typeof value === 'number' ? value.toLocaleString() : value}`; + } else { + // Show actual value for detail rows + const value = row[metric]; + td.textContent = value !== null && value !== undefined ? value : '0'; + } + + tr.appendChild(td); + }); + + // Add other columns + otherColumns.forEach(col => { + const td = document.createElement('td'); + td.className = 'px-6 py-3 text-sm text-gray-600 border-b border-gray-200'; + + if (row._isGroupHeader) { + td.textContent = '—'; + } else { + const value = row[col]; + td.textContent = value !== null && value !== undefined ? value : ''; + } + + tr.appendChild(td); + }); + + tbody.appendChild(tr); + currentRow++; + }); + } + + calculateRowSpanForValue(data, startIndex, dimension) { + if (startIndex >= data.length) return 1; + + const startValue = data[startIndex]._groupValues[dimension] || data[startIndex][dimension] || ''; + let spanCount = 1; + + for (let i = startIndex + 1; i < data.length; i++) { + const currentValue = data[i]._groupValues[dimension] || data[i][dimension] || ''; + if (currentValue === startValue) { + spanCount++; + } else { + break; + } + } + + return spanCount; + } + + + calculateTotalRows(group) { + if (!group.subGroups || group.subGroups.length === 0) { + // Leaf node: count actual data rows + return (group.details && group.details.length > 0) ? group.details.length : 1; + } + // Parent node: sum of all sub-group rows + return group.subGroups.reduce((total, subGroup) => total + this.calculateTotalRows(subGroup), 0); + } + + getLevelBackgroundColor(level) { + const colors = ['bg-blue-50', 'bg-green-50', 'bg-yellow-50', 'bg-purple-50']; + return colors[level % colors.length]; + } + + getLevelIconColor(level) { + const colors = ['text-blue-500', 'text-green-500', 'text-yellow-600', 'text-purple-500']; + return colors[level % colors.length]; + } + + getLevelTextColor(level) { + const colors = ['text-blue-600', 'text-green-600', 'text-yellow-700', 'text-purple-600']; + return colors[level % colors.length]; + } + + getLevelIcon(level) { + const icons = [ + '', // 文件夹 + '', // 文件 + '', // 分类 + '' // 数据 + ]; + return icons[level % icons.length]; + } + + addSummaryRow(tbody, dimensions, metrics, otherColumns, groupedData) { + const summaryRow = document.createElement('tr'); + summaryRow.className = 'bg-blue-100 font-semibold'; + + // Create summary cells for each dimension + dimensions.forEach((dim, index) => { + const td = document.createElement('td'); + td.className = 'px-6 py-3 text-sm text-blue-900 font-bold border-b border-blue-200'; + td.textContent = index === 0 ? '总计' : '—'; + summaryRow.appendChild(td); + }); + + // Calculate and add total metrics + metrics.forEach(metric => { + const total = this.calculateTotalMetric(groupedData, metric); + const td = document.createElement('td'); + td.className = 'px-6 py-3 text-sm text-blue-900 font-bold border-b border-blue-200 text-right'; + td.textContent = typeof total === 'number' ? total.toLocaleString() : total; + summaryRow.appendChild(td); + }); + + // Add empty cells for other columns + otherColumns.forEach(col => { + const td = document.createElement('td'); + td.className = 'px-6 py-3 text-sm text-blue-900 border-b border-blue-200'; + td.textContent = '—'; + summaryRow.appendChild(td); + }); + + tbody.appendChild(summaryRow); + } + + calculateTotalMetric(groups, metric) { + return groups.reduce((total, group) => { + return total + (group.metrics[metric] || 0); + }, 0); + } + + countAllSubGroups(groups) { + return groups.reduce((count, group) => { + if (group.subGroups && group.subGroups.length > 0) { + return count + group.subGroups.length + this.countAllSubGroups(group.subGroups); + } + return count; + }, 0); + } + + setupGroupToggle() { + // For merged cells, we don't need expand/collapse functionality + // The data is all visible with merged cells showing groupings + } + + setupTableControls() { + // 限制数量选择 + const limitSelect = document.getElementById('limitSelect'); + if (limitSelect) { + limitSelect.addEventListener('change', (e) => { + this.currentLimit = parseInt(e.target.value); + this.executeQuery(); + }); + } + + // 加载更多按钮 + const loadMoreBtn = document.getElementById('loadMoreBtn'); + if (loadMoreBtn) { + loadMoreBtn.addEventListener('click', () => { + this.currentLimit = Math.min(this.currentLimit * 2, 1000); + this.executeQuery(); + }); + } + + // 绑定表格分组开关事件 + const enableGrouping = document.getElementById('enableGrouping'); + if (enableGrouping) { + enableGrouping.onchange = (e) => { + this.enableTableGrouping = e.target.checked; + if (this.currentData.length > 0 && this.chartType === 'table') { + this.renderChart(this.currentData); + } + }; + } + } + + renderEChart(container, data) { + if (data.length === 0) { + container.innerHTML = ` +
+
+ + + + +

查询结果为空

+

请调整查询条件后重试

+
+
+ `; + return; + } + + const rowCount = data.length; + const isLimited = this.currentLimit <= 500 && rowCount === this.currentLimit; + + // Create main container + const mainDiv = document.createElement('div'); + mainDiv.className = 'space-y-4'; + + // Create chart container + const chartContainer = document.createElement('div'); + chartContainer.className = 'border border-gray-200 rounded-lg bg-white'; + chartContainer.style.height = '450px'; + + const echartDiv = document.createElement('div'); + echartDiv.id = 'echartContainer'; + echartDiv.className = 'w-full h-full'; + chartContainer.appendChild(echartDiv); + mainDiv.appendChild(chartContainer); + + // Create control bar + const controlBar = document.createElement('div'); + controlBar.className = 'flex justify-between items-center pt-4 border-t border-gray-200'; + controlBar.innerHTML = ` +
+ 显示 ${rowCount.toLocaleString()} 条数据 + ${isLimited ? `⚠️ 可能还有更多数据` : ''} +
+
+ + ${isLimited ? `` : ''} +
+ `; + + mainDiv.appendChild(controlBar); + container.appendChild(mainDiv); + + // Dispose existing chart + if (this.chartInstance) { + this.chartInstance.dispose(); + } + + // Create new chart in the dedicated container + this.chartInstance = echarts.init(echartDiv); + + const option = this.generateEChartOption(data); + this.chartInstance.setOption(option); + + // Handle window resize + window.addEventListener('resize', () => { + this.chartInstance && this.chartInstance.resize(); + }); + + // 绑定图表控制事件 + this.setupChartControls(); + } + + setupChartControls() { + // 限制数量选择 + const limitSelect = document.getElementById('chartLimitSelect'); + if (limitSelect) { + limitSelect.addEventListener('change', (e) => { + this.currentLimit = parseInt(e.target.value); + this.executeQuery(); + }); + } + + // 加载更多按钮 + const loadMoreBtn = document.getElementById('chartLoadMoreBtn'); + if (loadMoreBtn) { + loadMoreBtn.addEventListener('click', () => { + this.currentLimit = Math.min(this.currentLimit * 2, 1000); + this.executeQuery(); + }); + } + } + + generateEChartOption(data) { + const dimensions = this.getSelectedFields('dimensions'); + const metrics = this.getSelectedFields('metrics'); + + // Ensure we have an X-axis dimension selected + if (!this.xAxisDimension && dimensions.length > 0) { + this.xAxisDimension = dimensions[0]; + } + + if (this.chartType === 'pie' && dimensions.length >= 1 && metrics.length >= 1) { + // Pie chart - support multiple dimensions by combining them + let pieData; + + if (this.groupDimensions.length > 0) { + // Multiple dimensions: group by selected dimensions + const groupByDims = [this.xAxisDimension, ...this.groupDimensions]; + const groupedMap = new Map(); + + data.forEach(row => { + const key = groupByDims.map(d => row[d]).join(' / '); + const value = parseFloat(row[metrics[0]]) || 0; + if (!groupedMap.has(key)) { + groupedMap.set(key, 0); + } + groupedMap.set(key, groupedMap.get(key) + value); + }); + + pieData = Array.from(groupedMap.entries()).map(([name, value]) => ({ name, value })); + } else { + // Single dimension + pieData = data.map(row => ({ + name: row[this.xAxisDimension] || '', + value: parseFloat(row[metrics[0]]) || 0 + })); + } + + return { + tooltip: { + trigger: 'item', + formatter: '{a}
{b}: {c} ({d}%)' + }, + legend: { + orient: 'vertical', + left: 'left', + type: 'scroll' + }, + series: [{ + name: metrics[0], + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + data: pieData, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)' + } + }, + label: { + show: false, + position: 'center' + }, + labelLine: { + show: false + } + }] + }; + } else { + // Bar or Line chart - support multiple dimensions as series + let series = []; + + if (this.groupDimensions.length > 0) { + // Multiple dimensions: create series for each group + const groupByDims = this.groupDimensions; + const uniqueGroups = [...new Set(data.map(row => groupByDims.map(d => row[d]).join(' / ')))]; + + uniqueGroups.forEach(group => { + const groupValues = group.split(' / '); + const filteredData = data.filter(row => { + const rowValues = groupByDims.map(d => row[d]); + return rowValues.every((v, i) => v === groupValues[i]); + }); + + const dataPoints = filteredData.map(row => ({ + name: row[this.xAxisDimension], + value: parseFloat(row[metrics[0]]) || 0 + })); + + series.push({ + name: group, + type: this.chartType, + data: dataPoints.map(d => d.value), + emphasis: { + focus: 'series' + } + }); + }); + } else { + // Single dimension or no grouping + const xAxisData = [...new Set(data.map(row => row[this.xAxisDimension] || ''))]; + series = metrics.map(metric => ({ + name: metric, + type: this.chartType, + data: data.map(row => parseFloat(row[metric]) || 0), + emphasis: { + focus: 'series' + } + })); + } + + const xAxisData = [...new Set(data.map(row => row[this.xAxisDimension] || ''))]; + + return { + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + legend: { + data: series.map(s => s.name), + type: 'scroll', + top: 10 + }, + grid: { + left: '2%', + right: '2%', + bottom: '8%', + top: '15%', + containLabel: true + }, + xAxis: { + type: 'category', + data: xAxisData, + axisLabel: { + rotate: xAxisData.length > 10 ? 45 : 0, + interval: xAxisData.length > 50 ? 'auto' : 0 + } + }, + yAxis: { + type: 'value' + }, + series: series, + dataZoom: xAxisData.length > 20 ? [ + { + type: 'inside' + }, + { + type: 'slider', + height: 20, + bottom: 30 + } + ] : undefined + }; + } + } + + showLoading(show) { + const overlay = document.getElementById('loadingOverlay'); + if (show) { + overlay.classList.remove('hidden'); + } else { + overlay.classList.add('hidden'); + } + } + + + getTypeIcon(dataType) { + if (!dataType) return '📝'; + const type = dataType.toLowerCase(); + if (type.includes('int') || type.includes('decimal') || type.includes('float') || type.includes('double')) { + return '🔢'; + } else if (type.includes('varchar') || type.includes('text') || type.includes('char')) { + return '📝'; + } else if (type.includes('date') || type.includes('time')) { + return '📅'; + } else if (type.includes('bool')) { + return '✅'; + } + return '📝'; + } + + getTypeColor(dataType) { + if (!dataType) return 'gray'; + const type = dataType.toLowerCase(); + if (type.includes('int') || type.includes('decimal') || type.includes('float') || type.includes('double')) { + return 'blue'; + } else if (type.includes('varchar') || type.includes('text') || type.includes('char')) { + return 'green'; + } else if (type.includes('date') || type.includes('time')) { + return 'purple'; + } else if (type.includes('bool')) { + return 'orange'; + } + return 'gray'; + } + + updateConnectionStatus() { + const status = document.getElementById('connectionStatus'); + // 在HTML中已经设置为已连接状态,这里可以添加实际的连接检查逻辑 + console.log('Connection status updated'); + } + + showError(message) { + // 创建更好的错误提示 + const toast = document.createElement('div'); + toast.className = 'fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 flex items-center space-x-2 slide-in'; + toast.innerHTML = ` + + + + ${message} + `; + + document.body.appendChild(toast); + + // 3秒后自动移除 + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + toast.style.transition = 'all 0.3s ease-out'; + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + showSuccess(message) { + // 创建成功提示 + const toast = document.createElement('div'); + toast.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 flex items-center space-x-2 slide-in'; + toast.innerHTML = ` + + + + ${message} + `; + + document.body.appendChild(toast); + + // 2秒后自动移除 + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + toast.style.transition = 'all 0.3s ease-out'; + setTimeout(() => toast.remove(), 300); + }, 2000); + } +} + +// Initialize the application +document.addEventListener('DOMContentLoaded', () => { + new BIReportPlatform(); +}); \ No newline at end of file diff --git a/springboot-report/src/main/resources/static/index.html b/springboot-report/src/main/resources/static/index.html new file mode 100644 index 0000000..7809c49 --- /dev/null +++ b/springboot-report/src/main/resources/static/index.html @@ -0,0 +1,372 @@ + + + + + + 轻量级BI报表平台 + + + + + + + +
+
+
+
+
+ + + +
+

轻量级BI报表平台

+
+
+
+
+ 已连接 +
+
+
+
+
+ + +
+
+ +
+
+

+ + + + 数据源配置 +

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

+ + + + 可用字段 +

+ 拖拽到下方维度或指标区域 +
+
+
+

+ + + + 请先选择数据表 +

+
+
+
+
+
+
+ + +
+
+

+ + + + 查询配置 +

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

维度字段

+
+
+

拖拽维度字段到此处

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

指标字段

+
+
+

拖拽指标字段到此处

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

图表类型

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

+ + + + 查询结果 +

+
+
+
+
+ + + +

配置查询条件后点击执行查询

+

选择数据源、配置维度和指标,然后选择图表类型

+
+
+
+
+
+
+ + + + + + + \ No newline at end of file From 4e56876a49f650c1dcb44118630adff3f30805d3 Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 16 Nov 2025 20:06:55 +0800 Subject: [PATCH 12/22] column-encryption --- springboot-column-encryption/README.md | 226 +++++ springboot-column-encryption/pom.xml | 91 ++ .../ColumnEncryptionApplication.java | 15 + .../encryption/annotation/Encrypted.java | 31 + .../encryption/config/DataInitializer.java | 100 +++ .../config/EncryptionAutoConfiguration.java | 117 +++ .../config/EncryptionTestRunner.java | 132 +++ .../encryption/controller/UserController.java | 446 ++++++++++ .../com/example/encryption/entity/User.java | 191 ++++ .../handler/EncryptTypeHandler.java | 168 ++++ .../interceptor/EncryptionInterceptor.java | 275 ++++++ .../example/encryption/mapper/UserMapper.java | 181 ++++ .../encryption/service/UserService.java | 281 ++++++ .../example/encryption/util/CryptoUtil.java | 156 ++++ .../src/main/resources/application.yml | 78 ++ .../src/main/resources/sql/data.sql | 32 + .../src/main/resources/sql/schema.sql | 28 + .../src/main/resources/static/index.html | 840 ++++++++++++++++++ 18 files changed, 3388 insertions(+) create mode 100644 springboot-column-encryption/README.md create mode 100644 springboot-column-encryption/pom.xml create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/ColumnEncryptionApplication.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/annotation/Encrypted.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/config/DataInitializer.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/config/EncryptionAutoConfiguration.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/config/EncryptionTestRunner.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/controller/UserController.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/entity/User.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/handler/EncryptTypeHandler.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/interceptor/EncryptionInterceptor.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/mapper/UserMapper.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/service/UserService.java create mode 100644 springboot-column-encryption/src/main/java/com/example/encryption/util/CryptoUtil.java create mode 100644 springboot-column-encryption/src/main/resources/application.yml create mode 100644 springboot-column-encryption/src/main/resources/sql/data.sql create mode 100644 springboot-column-encryption/src/main/resources/sql/schema.sql create mode 100644 springboot-column-encryption/src/main/resources/static/index.html diff --git a/springboot-column-encryption/README.md b/springboot-column-encryption/README.md new file mode 100644 index 0000000..81d0704 --- /dev/null +++ b/springboot-column-encryption/README.md @@ -0,0 +1,226 @@ +# 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 (已自动解密) +``` + +### 支持的加密字段 + +- ✅ 手机号 +- ✅ 身份证号 +- ✅ 邮箱 +- ✅ 银行卡号 +- ✅ 家庭住址 +- ✅ 其他字符串类型敏感信息 + +## 🔧 核心原理 + +### 1. 注解机制 + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Encrypted { + Algorithm algorithm() default Algorithm.AES_GCM; + boolean searchable() default false; +} +``` + +### 2. TypeHandler 自动处理 + +```java +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(String.class) +public class EncryptTypeHandler extends BaseTypeHandler { + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, String value, JdbcType jdbcType) { + // 写入数据库时自动加密 + ps.setString(i, CryptoUtil.encrypt(value)); + } + + @Override + public String getNullableResult(ResultSet rs, String columnName) { + // 从数据库读取时自动解密 + return CryptoUtil.decrypt(rs.getString(columnName)); + } +} +``` + +### 3. 自动注册机制 + +通过 `ObjectWrapperFactory` 自动识别标记了 `@Encrypted` 注解的字段,并注册相应的 TypeHandler。 + +## 🔐 安全特性 + +### 加密算法 +- **算法名称**:AES-GCM +- **密钥长度**:256位 +- **IV长度**:12字节(随机生成) +- **认证标签**:128位 + +### 密钥管理 +```java +// 默认密钥(生产环境请使用安全的密钥管理系统) +String secretKey = "MySecretKey12345MySecretKey12345"; + +// 支持运行时更新密钥 +CryptoUtil.updateKey(newSecretKey); + +// 生成随机密钥 +String randomKey = CryptoUtil.generateRandomKey(); +``` + +## 📊 API 接口 + +### 用户管理接口 + +| 方法 | 路径 | 描述 | +|------|------|------| +| GET | `/api/users` | 获取所有用户 | +| GET | `/api/users/{id}` | 获取用户详情 | +| POST | `/api/users` | 创建用户 | +| PUT | `/api/users/{id}` | 更新用户 | +| DELETE | `/api/users/{id}` | 删除用户 | +| GET | `/api/users/search` | 搜索用户 | +| GET | `/api/users/stats` | 获取统计信息 | + +### 请求示例 + +```json +POST /api/users +{ + "username": "张三", + "phone": "13812345678", + "idCard": "110101199001011234", + "email": "zhangsan@example.com", + "bankCard": "6222021234567890123", + "address": "北京市朝阳区建国路88号", + "age": 34, + "gender": "男", + "occupation": "软件工程师" +} +``` + +```json +{ + "success": true, + "message": "用户创建成功", + "data": { + "id": 1, + "username": "张三", + "phone": "13812345678", // 已自动解密 + "idCard": "110101199001011234", // 已自动解密 + "email": "zhangsan@example.com", + "bankCard": "6222021234567890123", + "address": "北京市朝阳区建国路88号", + "age": 34, + "gender": "男", + "occupation": "软件工程师", + "enabled": true, + "createTime": "2024-01-01 10:00:00", + "updateTime": "2024-01-01 10:00:00" + } +} +``` + +## 🔍 查看加密效果 + +### 数据库中的存储格式 + +在 H2 控制台中查看 users 表,可以看到加密字段存储的是加密后的密文: + +```sql +SELECT phone, id_card, email FROM users WHERE username = '张三'; +``` + +输出示例: +``` +phone | "AbCdEfGhIjKlMnOp:sDeFgHiJkLmNoPqRsTuVwXyZ123456789" +id_card | "XyZaBcDeFgHiJkLm:PqRsTuVwXyZ1234567890AbCdEfGhIj" +email | "MnOpQrStUvWxYzA:bCdEfGhIjKlMnOpQrStUvWxYzA123456" +``` + +### 应用中的显示格式 + +通过 API 查询时,自动返回解密后的明文: + +```json +{ + "phone": "13812345678", + "idCard": "110101199001011234", + "email": "zhangsan@example.com" +} +``` \ 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 From 319871e09713740a4e542235db4e171fc9d8661a Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 16 Nov 2025 20:09:26 +0800 Subject: [PATCH 13/22] update --- springboot-column-encryption/README.md | 149 ------------------------- 1 file changed, 149 deletions(-) diff --git a/springboot-column-encryption/README.md b/springboot-column-encryption/README.md index 81d0704..94ea81e 100644 --- a/springboot-column-encryption/README.md +++ b/springboot-column-encryption/README.md @@ -74,153 +74,4 @@ userMapper.insert(user); // 查询数据 - 自动解密敏感字段 User result = userMapper.findById(user.getId()); System.out.println(result.getPhone()); // 输出: 13812345678 (已自动解密) -``` - -### 支持的加密字段 - -- ✅ 手机号 -- ✅ 身份证号 -- ✅ 邮箱 -- ✅ 银行卡号 -- ✅ 家庭住址 -- ✅ 其他字符串类型敏感信息 - -## 🔧 核心原理 - -### 1. 注解机制 - -```java -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Encrypted { - Algorithm algorithm() default Algorithm.AES_GCM; - boolean searchable() default false; -} -``` - -### 2. TypeHandler 自动处理 - -```java -@MappedJdbcTypes(JdbcType.VARCHAR) -@MappedTypes(String.class) -public class EncryptTypeHandler extends BaseTypeHandler { - - @Override - public void setNonNullParameter(PreparedStatement ps, int i, String value, JdbcType jdbcType) { - // 写入数据库时自动加密 - ps.setString(i, CryptoUtil.encrypt(value)); - } - - @Override - public String getNullableResult(ResultSet rs, String columnName) { - // 从数据库读取时自动解密 - return CryptoUtil.decrypt(rs.getString(columnName)); - } -} -``` - -### 3. 自动注册机制 - -通过 `ObjectWrapperFactory` 自动识别标记了 `@Encrypted` 注解的字段,并注册相应的 TypeHandler。 - -## 🔐 安全特性 - -### 加密算法 -- **算法名称**:AES-GCM -- **密钥长度**:256位 -- **IV长度**:12字节(随机生成) -- **认证标签**:128位 - -### 密钥管理 -```java -// 默认密钥(生产环境请使用安全的密钥管理系统) -String secretKey = "MySecretKey12345MySecretKey12345"; - -// 支持运行时更新密钥 -CryptoUtil.updateKey(newSecretKey); - -// 生成随机密钥 -String randomKey = CryptoUtil.generateRandomKey(); -``` - -## 📊 API 接口 - -### 用户管理接口 - -| 方法 | 路径 | 描述 | -|------|------|------| -| GET | `/api/users` | 获取所有用户 | -| GET | `/api/users/{id}` | 获取用户详情 | -| POST | `/api/users` | 创建用户 | -| PUT | `/api/users/{id}` | 更新用户 | -| DELETE | `/api/users/{id}` | 删除用户 | -| GET | `/api/users/search` | 搜索用户 | -| GET | `/api/users/stats` | 获取统计信息 | - -### 请求示例 - -```json -POST /api/users -{ - "username": "张三", - "phone": "13812345678", - "idCard": "110101199001011234", - "email": "zhangsan@example.com", - "bankCard": "6222021234567890123", - "address": "北京市朝阳区建国路88号", - "age": 34, - "gender": "男", - "occupation": "软件工程师" -} -``` - -```json -{ - "success": true, - "message": "用户创建成功", - "data": { - "id": 1, - "username": "张三", - "phone": "13812345678", // 已自动解密 - "idCard": "110101199001011234", // 已自动解密 - "email": "zhangsan@example.com", - "bankCard": "6222021234567890123", - "address": "北京市朝阳区建国路88号", - "age": 34, - "gender": "男", - "occupation": "软件工程师", - "enabled": true, - "createTime": "2024-01-01 10:00:00", - "updateTime": "2024-01-01 10:00:00" - } -} -``` - -## 🔍 查看加密效果 - -### 数据库中的存储格式 - -在 H2 控制台中查看 users 表,可以看到加密字段存储的是加密后的密文: - -```sql -SELECT phone, id_card, email FROM users WHERE username = '张三'; -``` - -输出示例: -``` -phone | "AbCdEfGhIjKlMnOp:sDeFgHiJkLmNoPqRsTuVwXyZ123456789" -id_card | "XyZaBcDeFgHiJkLm:PqRsTuVwXyZ1234567890AbCdEfGhIj" -email | "MnOpQrStUvWxYzA:bCdEfGhIjKlMnOpQrStUvWxYzA123456" -``` - -### 应用中的显示格式 - -通过 API 查询时,自动返回解密后的明文: - -```json -{ - "phone": "13812345678", - "idCard": "110101199001011234", - "email": "zhangsan@example.com" -} ``` \ No newline at end of file From 1438e4f39cf82b6655f35bf4c3d7a2e3f5732587 Mon Sep 17 00:00:00 2001 From: yuoon Date: Wed, 19 Nov 2025 20:30:13 +0800 Subject: [PATCH 14/22] DFA -> Trie --- springboot-dfa/README.md | 21 + springboot-dfa/pom.xml | 85 +++ .../java/com/example/dfa/DfaApplication.java | 29 + .../dfa/controller/IndexController.java | 19 + .../controller/SensitiveWordController.java | 243 +++++++ .../dfa/entity/SensitiveWordResult.java | 33 + .../java/com/example/dfa/entity/TrieNode.java | 50 ++ .../dfa/service/SensitiveWordFilter.java | 202 ++++++ .../dfa/service/SensitiveWordService.java | 148 +++++ .../src/main/resources/application.properties | 10 + .../src/main/resources/static/app.js | 612 ++++++++++++++++++ .../src/main/resources/static/index.html | 344 ++++++++++ 12 files changed, 1796 insertions(+) create mode 100644 springboot-dfa/README.md create mode 100644 springboot-dfa/pom.xml create mode 100644 springboot-dfa/src/main/java/com/example/dfa/DfaApplication.java create mode 100644 springboot-dfa/src/main/java/com/example/dfa/controller/IndexController.java create mode 100644 springboot-dfa/src/main/java/com/example/dfa/controller/SensitiveWordController.java create mode 100644 springboot-dfa/src/main/java/com/example/dfa/entity/SensitiveWordResult.java create mode 100644 springboot-dfa/src/main/java/com/example/dfa/entity/TrieNode.java create mode 100644 springboot-dfa/src/main/java/com/example/dfa/service/SensitiveWordFilter.java create mode 100644 springboot-dfa/src/main/java/com/example/dfa/service/SensitiveWordService.java create mode 100644 springboot-dfa/src/main/resources/application.properties create mode 100644 springboot-dfa/src/main/resources/static/app.js create mode 100644 springboot-dfa/src/main/resources/static/index.html 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 From ac006a977f0e42bd9069a2c5d62f99350229a799 Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 23 Nov 2025 21:51:30 +0800 Subject: [PATCH 15/22] api-signature --- springboot-api-signature/README.md | 124 +++++++++++ springboot-api-signature/pom.xml | 103 +++++++++ .../example/sign/ApiSignatureApplication.java | 24 ++ .../com/example/sign/client/ApiClient.java | 206 ++++++++++++++++++ .../sign/config/ApiSecurityProperties.java | 88 ++++++++ .../com/example/sign/config/WebMvcConfig.java | 42 ++++ .../sign/controller/TestController.java | 158 ++++++++++++++ .../SignatureValidationInterceptor.java | 150 +++++++++++++ .../com/example/sign/util/SignatureUtil.java | 160 ++++++++++++++ .../src/main/resources/application.yml | 67 ++++++ 10 files changed, 1122 insertions(+) create mode 100644 springboot-api-signature/README.md create mode 100644 springboot-api-signature/pom.xml create mode 100644 springboot-api-signature/src/main/java/com/example/sign/ApiSignatureApplication.java create mode 100644 springboot-api-signature/src/main/java/com/example/sign/client/ApiClient.java create mode 100644 springboot-api-signature/src/main/java/com/example/sign/config/ApiSecurityProperties.java create mode 100644 springboot-api-signature/src/main/java/com/example/sign/config/WebMvcConfig.java create mode 100644 springboot-api-signature/src/main/java/com/example/sign/controller/TestController.java create mode 100644 springboot-api-signature/src/main/java/com/example/sign/interceptor/SignatureValidationInterceptor.java create mode 100644 springboot-api-signature/src/main/java/com/example/sign/util/SignatureUtil.java create mode 100644 springboot-api-signature/src/main/resources/application.yml 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 From e7b10d4d007efcbcf8b3d3bf1207e1dafd3c7a74 Mon Sep 17 00:00:00 2001 From: yuoon Date: Sun, 30 Nov 2025 21:21:48 +0800 Subject: [PATCH 16/22] mutual-cert --- springboot-mutual-cert/README.md | 61 +++ springboot-mutual-cert/pom.xml | 57 +++ .../mutualcert/MutualCertApplication.java | 11 + .../mutualcert/client/SecureHttpClient.java | 321 +++++++++++++ .../example/mutualcert/config/SslConfig.java | 38 ++ .../mutualcert/controller/ApiController.java | 300 ++++++++++++ .../security/CustomX509Validator.java | 434 ++++++++++++++++++ .../mutualcert/security/SecurityConfig.java | 90 ++++ .../src/main/resources/application.yml | 27 ++ .../src/main/resources/client.jks | Bin 0 -> 6456 bytes .../src/main/resources/root-ca-cert.pem | 34 ++ .../src/main/resources/root-ca-key.pem | 51 ++ .../src/main/resources/server.jks | Bin 0 -> 6504 bytes .../src/main/resources/truststore.jks | Bin 0 -> 1910 bytes 14 files changed, 1424 insertions(+) create mode 100644 springboot-mutual-cert/README.md create mode 100644 springboot-mutual-cert/pom.xml create mode 100644 springboot-mutual-cert/src/main/java/com/example/mutualcert/MutualCertApplication.java create mode 100644 springboot-mutual-cert/src/main/java/com/example/mutualcert/client/SecureHttpClient.java create mode 100644 springboot-mutual-cert/src/main/java/com/example/mutualcert/config/SslConfig.java create mode 100644 springboot-mutual-cert/src/main/java/com/example/mutualcert/controller/ApiController.java create mode 100644 springboot-mutual-cert/src/main/java/com/example/mutualcert/security/CustomX509Validator.java create mode 100644 springboot-mutual-cert/src/main/java/com/example/mutualcert/security/SecurityConfig.java create mode 100644 springboot-mutual-cert/src/main/resources/application.yml create mode 100644 springboot-mutual-cert/src/main/resources/client.jks create mode 100644 springboot-mutual-cert/src/main/resources/root-ca-cert.pem create mode 100644 springboot-mutual-cert/src/main/resources/root-ca-key.pem create mode 100644 springboot-mutual-cert/src/main/resources/server.jks create mode 100644 springboot-mutual-cert/src/main/resources/truststore.jks 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 0000000000000000000000000000000000000000..40fd610c43b7b9de849719d5e9fa9aaf3cd46a90 GIT binary patch literal 6456 zcma)hRZtuZlkEV5JA=Es43^+7LkR8@++Bi%;FjR7!F90UPH>kH+}(AM06~K6ck6C_ zf9=!mLswUy)2E-h&O0%Cp$veZAI7})w>1u{0+04(&M5(~@$B>Y5Ey+{`S zeqxTb;bZ~t6M6Gj*|&&@EO>|rv|ub0wEsU55gi!-rb0nC{R%;_LINVN1M%9|xAzRT zk@LAxc$JQ}8WMpaz^O6!87h^fUItU*ebg6&#%bL_y2x|GM*1N{$tuu{p#aZ`{@pUk%b&XqH`HUu zQ0)$V3^{=YXvHCb#=c0OjQLPUMv&Mx2E{oqY0~eV<*9#@IUaqH3eYK{v-53bORmA& z`uN+cv4tyjdcRXU{>(&RCzMY z(^JRs3*^TgB&DU!rIJQl&2<@idK0A^+U}U!@A*;1-NX&%KozGX zO6*w6Z0_rOtLNjb288=9_iS94tSLrSj4Ot@&8`sQ9*{#79^kEw+U0<7&rB*b+>n|!6~>7N$GJWY;kyW_d3w3E$w{kfX)X4v#>v@Ia{P88sPQg22iju7 z6PrV$Xu8y3X9v^=oR?mfjW3n-^@<;9IJkudB=o+`ol*8;Ds6=^s+=W! zGa<%yD^Eo05fbkH=7sT-ZG zr99rJE(Vnl5FsBkuvVcYpjFupwIhaR{iPT0wD9MOw55>lum+^wSBD~IJtg$8eJH9K z>`~Z>;uQ!-AYBQIKp=%lhb%-c+4;T2*eWP{jZ2j0FeMfD7r$6d=`$MlWf zpZ>w72W-d6&g_-L`$FJDLSd~&y2jIkHjnR{!_~LC2@j*-qX}9RPUV@z7=%Rb#wZ&v z1g`pg16Q^fcks-+HXhW|P&9u2k0``DbKM=c@9;2LlXN8j+^laVbhSKIw3C(H5R}E;0Uk-SOJ^?9$>ovkVL$MKmu($Co5Xsw{HXlzyiWTf^T@i;1Cf0 zzfGtp`5_=ga0tN*A_Cx_boUHGAsTDvBMn zAAI?1NE^_gGkx|HJgq&V)1^ar1hRkqbM_Y(B3L-JKNFOj&$}0w%5Kf{j87Q`IlbKb zLW;UWw)1T}fU?l9v6ApdRF-ZjpS|hARi$+OL&0voxmm=#1kUeO@%JC_X=Wd-mvUo^ zl%!V=(-dYROXrRGf{&Dfa&Tykc1FaoO3%j5c{^4(!E(&?<;QR&8>;9TGVdILZr^mZv$+%cd8N$ais3 z4Fjr=n(vmuBi4<~L`$IzRMVjus|2qiU9FxG39vNUbe>rmwNs%Ud4!2X;(W3Yvjjk! zBw!x|xt~_Gj5wNfsgt~J2&ORt`l-T*{=UB!t7XJ#W4;epbkc@)YkUD1)`I3^=qDn4 zAo*s8Ka1=x0>q7H7&;ylHpJ7$yw++7B}^VFXb&aVqUtG&gBwdt9PtwAS=NV%L+QO_ z$m(64!T!;gzBw}yrX5~4ei5N@~es+rRPK17r znRwa8H8*&nZ8+bLVrQ}Bv{RED_wVRz+pGSi2~>S%(olKJ1H4%AvVcuK%_aaO9)r9# z&nmVfpd)eO&6j$jJ)&beC;Cb4d?haw^+67O2-6`y&h?c%u!YtB0KD(VR(K7e&D`9g z4vV*Tbi^7TF2mFElo?f?rY%c6YE{_fzw^uNP?>g2gh-1H1@9q8IYwSysH!>kA2saV zq|HtuDx=NgDG4+-H2L}R}SIdS4 z0lh;kU1$qI37u|n*4AZi8>w5~)ZH2^afYSs!)I!P z^iVrkBj4@t#BL|<>cHQ>fRM5aj~V^owWHJ8x9Do8C{mdl36Xi2G5%X}a~tvTH3y@7Cp==7i&l23W!GtVX7Wc05$^VCg%sE%VO3Jx&-^j-HojonmzR3h9 z8&yP0mEf|sbl63Ir%)BM-yfd=jZMjnas43|l0*Bbw$T9_(U!Qaq;QxxEp6 zTIRxE)FsARr&4PfWCO#ODIz==_rh>Q!D3QBbb}y%=WLCU^UdUwSO>ts-XKo6)qvxW z{spp@%dd=7j~S+0E3si+aSGkSQvLi&gS8+lm;6tCm*ref&)yZrT@XHp;I}6Q`{>9W zB1W=?olY#Oot7Qn16xNL$q|)&2*hn&-hC&tFhvS0&uMWVGFz)F^Ww(}6ms zD0;lA{G{i`rx4S=^|7P>h`GRO*#`1>X(qit|f&muuifiTHp87>%Tfe%C(tu zC%}`M$TqP868W_qSb;Sh9$)jAI9T6L_*zuBzWehh+B+W3XkY>8Z?%EgG`qC7@NU4w z;Fn2}Da2Ln8BKcZ?P535CJ@7q6IouOAKM9~tM`DX$NNI*st|Qydxdjm+4|9<+bC$S zF6|pMBVFdqpWPz3Nywihdl}uM)3Qp9EyNQJxks z7PzQKLX9ovFTrJ|M_0mRV%lKEH3b#Od7oj;eLUeDT%?3#Yprt!STq`+$}+%8%{S;( zh{a}6mDOOj|Me*aSOGU>D5Lf4-A0nm~Q zUg6QiS7m(lJ+4uG+|l!ss-_zw-YCbcoTo=%k22MgDg5F`N^43|;3NrNve?^~E|fLi zKq<2|XXDBD0Osu(4U#1!N#w6HX0&iv%4%(-K?jIr*v(B9xU!Maa6%R92O|W2Tg33y z5n&Iwj%JaLHjFuIV}Z;oNMEP-_s=j77$&9rVP4nK#{@qWzDvk`Y>R3Y^EY`ufL_iw6f6Alv9gaYE)$#h{tz55oir-h&W@dluB0@*LE-I8*ymU?h z%}w2S66}Ui8L2Mt^HX!b2wY(FUcL|nZz)+8RVa`ciNbcu3!>56+ZUQVD4p@mre2zJ z!UaPpx1wh8Qq>ztMAj>bEaTj^$2?mCTHkD9KOj8En3M1mX91dvBrCbH(SF#}N?8 z0tNY&Wqv%Br1fm-m1NH@G_RhN@Bb5&1;^pG!01QxZ;;@Ai$R(6yN_(3)Q-x%~G4kRC1?JB>HRI!Q)G2mu|rPS)~SrPsiRkK&WKrPv%(qB=#A4>MHG z1qUYNCTHO{i9JF#M|o2o+>>nfsZGbp*zT<7KIb_UhKVI5n!YMPK42!lq+m-~ig(D- zucHCb{E$@0=0x@fg)w^;y_*c@0MUawGx|jcO>IdO&&1AR7L~?NCBuZG17o$+zCsuE zdgW6(bx|$vt?Kc(RaBam@P+|Jqqpgn&Qnor?-!0#kE|E{0Kvtl7|#gfck4Xu3Rs7z z{Z&wVCRXcymIdOA=v`&X@W2*yLQ9f1Uh-tOUiTO)6Mo?(PO41y*;ZpK-#R}VQNf`U z*nB8g?g=+DbTC3MR~&RCw%F`(@|HPe1}8yMD;R5A`Enb;B^jrT#yM(pN#{nvH_brYY(k7I0A;!fxLjVk+d)5$sNX8qIo@313p-?V5tP0vTy z!jb8@bv&DbPst$3SPTL&BNI+h__ku}n@5z(!eK0gaM?JS?XC+wcO<8RH9kx(Usn(# z1idVKW@ab8KrAPb#(j(MrgoSLXMRVP7@lNbudj7}1Gn*Jtnr>~#TJJG6PCTSyNJJ^ zsrTB`yI&2xLkkVX^7`?sC9+?N1|!zY^G#?@R&MW(4awgj1ne=$Id8y9RlD1*TKfj0 z2r{ZSiAVI!l_pJx_&C{VbZ9EBWV6PMF1auiJgNB^54a3vsMphKzT!+Lzp!#C3zB~> zpqayBhm5<<%qWaU+t=4&1`4UPhs?h?@)b*i2PELB!U}$-A4v9_Sz_|$+v*eS)*z}o z!m$7meFO(0zF0QFFjdBksCZ|s8&{u*np_E~iS$X*Io0jhMN1M0MgHVoW_U->5mtU~ zT{~ax(cs=C;Y)X;g%kwP-6BU@UWGPL+VKdCwcO$}4dool>n7{;a!x+NdseF=^fDSH zDKF}Q!ft|o60AE<&-_u2cU7Y1H3=6G56+O8$L)?G+$1$F=k?0&yMUHnquClFs784* zJK~yCdF$qtg2iXmtdq^;?`R&2KL+ubePg{yCF7$;3tyuF$wkvasI`>i-&|mYcdeD~ zrSJ0wX`V(JR^8)t8J}ZcuE-@8Y{B$DVfoCoDb>(v4MzM2e&L+>TfXd>#Z=NkTJrgMwM_E-=t(x@FdagJ#bU+=|$3rqB{|O_~Y6ktHg^>1vhX-&Jx%ZpbC_u*e~OdhhG^$nLKjU?@c39CR>+ZG$xVMUNGQk=vm8mlp% zUn!UNjErjpeV9xbc<|&>c_+G9e^2Vqqk{#7j>g-gN39%r3d2;++Z2{EE}gF{sY!|G z0yY@D%NYRdf%FGFwdWew)Ft;*kIl=BWRa}fsAv>Cn{)d2?Z?$q8?Q&ziTl6xoEBA) zE23ia3A!a%HK<|!EDV1+`#iF%z$#!~F!sOS1VjX60FYL`Sw*jWBQmHU5Is}okZl46j|GyL1e*pl*9Jv4h literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..46ff9f1b2146af9f09a4dd8fedbeeecafcbd4ce0 GIT binary patch literal 6504 zcma)BWl$X4lEoR^U4jqp9yEiy1(%?~9RdU$+}#H!xDyBjcLonmaCdj#@6~R-SNm)C zN8heKr%(U7b*k=dPB4iD94tI1m;@6Eg*i+v>>dLa88#nG{Eh%7e&z%d-*bWyYyKC7 zn1=vH%>D;X|0j}>(Edk-jtUEx4@Q*u2NdD7`nLiRgHw-F@INIwCkq08TLd&Zw%agd zmHd>@QFTdFV0Fq{-fC||x$0_#Xfjf-EW6d23z zc!;YoPZZVRT`-r@;ntA7PqpqfW#O%U8tK&1To$AFL8(j$vt$s(CRu7a_m2XWEwMB6 zNxWMG0TguP&RAE??DLyh>E=f$CVb~qKJMRw>`*v)chky1sM;wAQyovk9Jerhim0<$ z3;W#n6U2Iij^TTB3%44JHnyv3p)1<;<_|7!?>_NHH344XtiF(e;zItLk)bX9TixfikLKumwR^aSjFyczY?HN5C|_mYer!0)wT z3*%HR?4>S$LV5mNfZM_60q{j{3O4Y3ehckA=ZXi$)DVtO`K&B-x|UH~R?l9I&{K(l z#v;RWJELK4sPa|i$wxpPN*@gbf>svvX+vEXptRnVec|Fo{yVOc+UPv9eJB8xBQrRP` zD7}%W>m5N-cM7=9HF)fy1;b9qYq5%w*NlM|%p&IJ(EZNfQghe=i)<>kuwpmjJ z2316$f4&*zu==s>W5{7H8UarNXOX@>g7Cn|WuP^=k-NcUSLY5{d)2?A=rEJ04_|1ajr({%6%pGo27?YT- zuR&Z4wlRj^?{c2<08L(!4~tPfNby}!PZh;+!*TfOPHOuJ@$YG~tY0T8>Mm8MsJ|*a zDp$5Xc787}Zl}$Rw*af+k;e^atfYU8w!88BIGCNX?LZX|rkMHzlie-#bs=hmuHOu` zVBE1(dyvpC{+gFgKXb2>GB2yt_3;^6^zOfuBu(w#x8?UGNvA*--kFOo)NC@dnl~Ho zJz6SWt9VYsbtuoerEV?~fQ){LzMMZdl@>-Vl|6fvGRyG=MYM&-qHT^z9ENMH$fhv0 z>&}(9bJ=C94e(14f-;JCFh;3YP}1`#<=;IMUg?vkUNnPgZ&bpOP@_|Isl>Fl3JECw z5D+kx_4vf&Ipe7I_k!ef+rbn>ag?IZKyuxVy`#_pOccluq;0Lz1GH;+uv<05=kdqs zxqMMy0xi{1>#dR0vsB|J=+crA?88E$xj=~CeuxlB`&H`82EV6xq8SU5#c3K`QZ5Nj zvVYqp3O*MmB|3~7j3taKjOV{jPU`=V1YGzCxLP(2mQ-8<{JgxJyh6PE{QNweU_8lx zHz6bCgYmfkf!N_-Vg5`X@HrC^i*dj0LMS?9$6eHm4rw zfYLGoq8e6+%dK2nSEJeNQ@U-@d2#_ofAzKv8EIzL3QhZ!(~>bEcpWNm-CX{g6MmUz z6VN=1j6B2DBubi&SWSxeCu2@+?rqsV)NyJ&5hO;fKa%o0A=>bnS?>!)kC=iL^5n2`Tf=G@?r=Za>QK9jToIN_v9R@_ltorvL-jqn&%k z^ybKqhUS-krNFJ*`k*kDw_!LmFe;$MNUSM)1_f;dEcW~jM-;Q=_>7lBRe@I`#ddF1 z-b#D%LCcd>p#B&82F;*Qk8La$zu?bUe8%k&ndDmUpZE7B&gm12Uq^E7FOG}|TNb9M z!ig%II|xrAJ^E%gRZUHeE&Dx-MW3Xi-g-yxPOT@vCfg3YOpA0jlc8Tz^S=m&W-j0K z7m7*L11h`)1m()MdNYgfW})l_MM3KB9aSnGSk2~^|&_L3;J z@{WQ$oW;L(R1H)7p0%n*OyTvpmWoaHo?KNt0zSgyV&v;_Dx^v;VKu&q^~9VLeUW81 zL0S?p6^#yP4WOdBIjKCEeBQ^dS*ZzHO!Lw$nnz?5osOUt&5XLIi%6*RXmM1sv;BZWhTYSx^57Kv<$^+aMoAfsAS!(@=T%Cw;CdT_L(TX!A=twbdX-;J@6 zx3fg6DPFHnQsUgIq%ua!0$>e5HT3DKZ;pl;*a!Gx2M^ z^&jYSX^sB^>6|Qi4bYBw1#8S!saW_5BvJAmL*`9ere8wIcpXFA;t}w%j)a1q?aV9jBgaAkYn)fsglP*D2k-F zWKCnm7h$VO$k9f$C0Ocn1pK%KS>NgmaT|?7fS-JU5JEI%LI7?KA9wnrwiYAZV!%|N z0)~g1?$%@W3Tek9b|fdxdK107_qdF*`U5P@CCqPf^t8b}wrUbU=WZz#-q;Y48XgGoE7Nyw&d-3JchdSI6 zEo(SQ4^=DE9rJ_(ZbU zY*nEK)oaans`oCG6CX*H$Dhi{JGN0Lz;Rnb!%X9Q#Le|QpVSfiGCe;jqjB+iEwIaw ztQzLPI~3AkqJ69diRA>JLa;Bokb9=RU?;N^{<=(d#DcHjq^5~$_b6Lxf`Wgb!9a6R z4W)*MC6a!Xsk7=V>i0|tgoICQ)d?EB)$8I2aHYn_$+cBNV&CO0Y6JG<52nq}CvhfQ zxs?6{E>_EbzHd?xd&ao&bOk^8VfkSeek;@ZR2LpkV>pk^F1qkTMj(9!Wh>#<>%b6k znB*Wt(9$wUmz#QXfU42JBqZYCMzUQyqQGo_+Wo%YPi&jWjF1YFGLE&hz9?X4n(N>= z6UUp4ZIi76ZeWAB%f0*(5kN1>C(%eL2&i-)Y7fNeyn1gQ&~H8H7VI(p%lS(gi@Y(L zY;ZZ0P+|&jKhU=fP!CF_AVYnsGPJI3W%}U@fJKgPwIHY!6H);Bf`}h_gi{;8q>T}$ z{`K>gz3+#$Yx1z-M%IUp1+G$kN-xR(csXi=qu2aRo7FI91?;w+i?qtu)=Dw4ik>o7 zGkECA#23g5(a#w+_v12>aQGA-L}rZH$PDb{?uTAqevup~G%30qIlwee`sgfK_s85Z zSu6Or%DUd0q6~suU1=+X5~VJ*POzu|_gK`^japZQsZ8oUC`W2)3yfqu)8ui!+P43Q ztEggs0~ch&>zR!11{vSX<`;Vg4ygFo3o(-Dh0Di=ssj=OrLfv<@a)hu4+#}6$*@+-tu#MT*8=cOM64i1Y~#*{*#>tY>sv@-*UkB#@{oqv#HkYPa>s6 zz%;6p^WS~LgMf4F&XL%IcENoZ zI&1ULhSoH|6`@b#d|LFTf4P-`osiS&?%xLFJcQ;iZx-X(C(=ILWVF+}3$=dw;oJ%; zAGc|MGA}eeE^#Q;=J=T(_O9wsz%B7iN5_|DO&h_`{Zmu1T{+0_@C@k4>$W^z-bP6- z^y|H8Q7*agy;jq1zu3)7jF)scNpzU2!KadF5)7oY;Ha#)jOM@x_Oivx?R<)pG}r+F z82KpiQ&}qnk@d>PlSF=+KPLI$2OJ!uMcS|l!LNjD=Dd2C=^d{YxV1j(s0RkIfAtH| z<7rQ8(b1z>G8qIC7|~= zoC;zi?@|t!S@k3YIxj<%Wrg;TG5t`%aC0Ieq{S>HX=}W6Ww7(jS#XhjYhI758Kz?0 zfyjFB0hj)%A}*Df#VPA@mF&zE-wXPOa z9+}T;>@XKOXST+IhlF=0H)E_tiT-n$<|B`e|Mg|{S`|TW0NTN^%l#cP)z1kjk8{)$ zc64toW`Ptli4R7hbFt@Bp{(SlHRaiYA-eNfMgq!q19c| z6%jo|p-f|(#UOLpC^AnodF=ZRsy%=ZXM{d}L00lmgUBi3T>2s-k}~aG=w7AJ60bsU zd?IgW_j*6Ewhr2Dudj|T(Os#k*skG9r!Z#~7|-(AA9PQ4J6)iVeW0*27V5&{eZc&> zQwyD`=D@E>#{!K4-VBPthL%co+RW4nT{o2|KTj*IOtGhY2t;4a7rkq>0Zi4_8^e5T zyEFT^`+Xu#Sl;;ED}d?+z*Kv@I71rjfp8?p=8zQyyS%q#pZ|xYU&udZck)x`XWDE; zI}m4HU+p}{ko90Cq?fGv2pFF39EgoGkudNTs{0+J)E%$}?iJA*rUIB`elp#4iAG`J zYf35=Ykm@PsvWE_t!0RT+C$+Zg>MIjjsU7E3`+C+IBNRtvU9k^Y#*v*q&~>R3~Vy0 z_)u`%WNGyA(4knY6niX&QUZzF4xj@q*P_Bfwy#uhYVm9BjhQg`F?eB2l$p%(pUse7NNa4FWp*pmXY z{_5Msp6SYE=<1AjVHz9W;Db$%R9P772O4_c;T!b$`8ZIdYMQwVoa=d67 z{7eOR?)anH;>0ZR*_m)ax@(eL@KuNKkx4p@560$Q@RNz9*Y7s4|5~r`IcaS63#Q0& z&zIQs+<@3O&^v*<^rIYwm&e52V!X9ay>Kgtv2xo;Ibm5eNp~9)Xue1b=naTI^To&u zUV+9T+eF&{vU>Ecm0Ew}$r?6{$ac)usPJmNFp0N{buv(Y&7xw&(Tdo|uK`3>V8|C>3!%mMu1tS1kZtnF1 zGC&Rk5_l39){=ry1%7tAP4ja@&O(}wHFEdP_eycEyoDMX#jE1IOLiS@OYS4G{L~N2AE&PPIPzS-6Z62@l4H~Ae`fZB z^Q8n{HA?gxf%e6GR(Y{+Y^uckzz2lf)kl(ysuBI_o}a&-Sn06)IR^%?gH$-I-c}zc z61WZJsu=;B_yJn-=xxQ3C19sWykYLEwha+E*u$;w>9;k^;%<|Fd^n7N_L(JIlk8h2 zJ;V~%ip^el?2@LDuuwkM4s+&-wT$Wo71xo(P?VmTk1TmASNR7YRFf7zN;zVEjXY3H zrC?DRr|wTNdm1?wESq)`9eJK5h8eZQ?t8wU{*lPvevDh64O}l%OQ04F78%&+-t)N> zqX;#q7A%-LhAGoO%`TJe0=oRDm+{+(g;yrOeN;#>IS&jSjj{{n^zvs`2f{Ur6$X7# zqkU_xAM}Cv)W3~0TxlXr< zXq+y-vDwJ;Q3b4{Ze7?#ZprdzPI)|6{8x_bUwE;NhN`!s}a~Aq)d+ zwQY;-JR6G$$r5Ns;DUT!5Kdg-C3$d2rU#L~7pC~+#=-zUyelKxDpplN?7$j?X|2xr zw^7<260Q*OB5cXlb-PdBv;irKNo^~;su*)pHq&jX&g~2Mhn-La+Tm>ZKE6ratel0& z5bnev&xyhrT^9T!K@>V>g|latPs@8!+ADyR!e{W#b}^-BU3DQGS?FW}8qTM$!-Foi zf)m_8I{(kB3g3L|Ij|5)L|D;4&walC%ho|*P#Pa=eh;#?@=!C(;WjmVTwWmWz2I>O z?3E99R*lb04{<=Hwo>LQ>0!2T6)858E;d6c8B+U2dq%bv_8TWeKcG2un=XREgxoKy zgx=Bv_{T{B9^*yUv4W{wdt66bB3EV~j7R^&K$IVigT)mnve({2l@tZi?BAT?QCixHr{QB)kGdF9wz z>*7y`D)z*~{+)8L;Zi)OD^tHY$LCgEvY5PJGNs?a#eqObi66R4Ut6PE>#@2 zR|~Z|+Yf|s*VvX+OOVS+$Q|`sFmXQoE(=DqMuSoE$&4rNMx(@;Ib}TW1h~%egTHYd zWmP*G&G@JGXo`9JKG;lXGoetj^;Gb)a}hL*BtE{Z(8h;KV|_a3maWk^yDe9;zORC5 z9;uIL=yELnVP*8T!Kuv2#fkCn?E@SvA`Al6x&LNQU+thDZTr0|Wso1Q1=KAwgdj`S1^!S3x=i1!RDN25?^D6&fD;K^jZPP#%vQ+XB8I zCm?`rRC}hAjphjC$D)y@p855ff52)5K->^26+?C}+xs{|&F*dxCeC_BSqu`w)~wyz zT_Kqa4;tVWMFwg>h9%`@Ef6dRm*l`Ok5l@r#cnxt3 z0f;WC{AS&spRnSnlnCk-{rKr}cdcbdG@GnfyG~f4tn7bqP`U2D2p^;0 zIAtv&KnAYW*v~-R!u^TUJR3NmN)Jc%D%zT&fvmzg%HD6_2%4oHGJCB2+N_hE5~Xb(tU->TA63v&** zW9m#m=Yik3LB}0tD!m1MR$k*NGYBcm8o>{gevIJN#?s{B~ShWTV&{crpKLr(J0kwl)q@kWTgC4iS%Lh&-h3u$tTm?!aoj8W;zu zQ7p+=c)>c;T9-ERC7p{BX!Bc2aq>J+^w2XM+(f{ZOe08eXZn(C3L3@p_g`+ey(k0cGW=Z7c={C1Z4*CGMSO=(ssCC>oN|7x)Htp8cbNWtr}DZcko z;}|JYeJ<&t*R{zF2Ll`qX@b$8Ch$XG*?tT0O9=3&`Y?1gvuMgAixa%J#$#BbaKS`Ew7zBxvdV|b<`jp_G0a~@bBt@1WxPFxy{C^D% z!2d!NG6@zYPl&UFOKvF~a~glV9B;F!AH?jtVO3co;dWf8N1J`BN>1-=>OVZ2o9c-O z&gGlcz5~sl79N{kWHt-_+3Eerwp6Iodqfl;DF=22#b+x+uO5rIrDIZ?8z4hOlbz}8 zwd4d#x&`!*ZP@E4DD`Ybf>G3Tsvfb!f$@1I*nb_#BaN-weT#G|U#?JmCNv$Y;^Xa_ zm7yaAR-hhnbg3Pc5A$~fZxR%`4B>yCIq_>~u@4p=^pXv4j{sTdbjnn%JE~YQP%m~H zxd~_^r+wu{4teXIb;a~XD6k_Sh5>~{5R`{AcStt^@yN>BW_`6pzhkDbt!6^7mo=}^^qbOlN_BJaQ(Z^o+X=Xfd zEdFK9mJUKvrR7>saybVV|UTCWx;WOo62tcHNy| zh$ahpce5?;fE5ykZ0yWO(yZ5{#Hpt1wiItQ*zCqOXFBo+U3bj06NphP8?Hc=;j4(k zfD8zbxG%8K-R0mo6+0N$z#xBpX6XxAD0BdP#vjuNVzIX&tqFc{9G{*$lR~1*Rv&E| zWj?%Y{-d)e7}nTc3l_QhoiRdw=|xrI6)L@zw|LFat#=}?0Ev+_{-Pj3K2ND#ypSZ- zHBpJzHx$!wL>Xs0agzW>*~-^3;Z_a!UuWOUTcEzt?A+^EZ9<6HrKmL zqLfaLZi-Y3wd|Db=Ssbl-X3u`Yj2SK=OZH@zp_tgexd}`4hc9UbHGSo^=Q}GV6VQD zU(c=b$J|I!p@>Nk&CP{Aak`&|;i-^K-f-Mqs-Ff?hlwke9GPkRO5}NC9O71OfpC00bZ;tbbmw--_vqi=TvE wcFD(8KC=}%4!s!>iY_t`3&C6j6g<-cx~-r Date: Wed, 10 Dec 2025 21:10:01 +0800 Subject: [PATCH 17/22] single-login --- springboot-single-login/.gitignore | 44 ++ springboot-single-login/pom.xml | 78 ++++ .../com/example/login/LoginApplication.java | 11 + .../example/login/config/LoginProperties.java | 54 +++ .../com/example/login/config/WebConfig.java | 37 ++ .../login/controller/AuthController.java | 156 +++++++ .../login/controller/PageController.java | 22 + .../login/interceptor/LoginInterceptor.java | 92 ++++ .../com/example/login/model/ApiResponse.java | 31 ++ .../com/example/login/model/LoginInfo.java | 18 + .../com/example/login/model/LoginRequest.java | 12 + .../com/example/login/model/TokenInfo.java | 16 + .../example/login/service/SessionManager.java | 58 +++ .../login/service/impl/MapSessionManager.java | 148 ++++++ .../src/main/resources/application.yml | 37 ++ .../src/main/resources/static/admin.html | 362 +++++++++++++++ .../src/main/resources/static/index.html | 429 ++++++++++++++++++ .../src/main/resources/static/js/api.js | 118 +++++ .../src/main/resources/static/login.html | 201 ++++++++ 19 files changed, 1924 insertions(+) create mode 100644 springboot-single-login/.gitignore create mode 100644 springboot-single-login/pom.xml create mode 100644 springboot-single-login/src/main/java/com/example/login/LoginApplication.java create mode 100644 springboot-single-login/src/main/java/com/example/login/config/LoginProperties.java create mode 100644 springboot-single-login/src/main/java/com/example/login/config/WebConfig.java create mode 100644 springboot-single-login/src/main/java/com/example/login/controller/AuthController.java create mode 100644 springboot-single-login/src/main/java/com/example/login/controller/PageController.java create mode 100644 springboot-single-login/src/main/java/com/example/login/interceptor/LoginInterceptor.java create mode 100644 springboot-single-login/src/main/java/com/example/login/model/ApiResponse.java create mode 100644 springboot-single-login/src/main/java/com/example/login/model/LoginInfo.java create mode 100644 springboot-single-login/src/main/java/com/example/login/model/LoginRequest.java create mode 100644 springboot-single-login/src/main/java/com/example/login/model/TokenInfo.java create mode 100644 springboot-single-login/src/main/java/com/example/login/service/SessionManager.java create mode 100644 springboot-single-login/src/main/java/com/example/login/service/impl/MapSessionManager.java create mode 100644 springboot-single-login/src/main/resources/application.yml create mode 100644 springboot-single-login/src/main/resources/static/admin.html create mode 100644 springboot-single-login/src/main/resources/static/index.html create mode 100644 springboot-single-login/src/main/resources/static/js/api.js create mode 100644 springboot-single-login/src/main/resources/static/login.html diff --git a/springboot-single-login/.gitignore b/springboot-single-login/.gitignore new file mode 100644 index 0000000..ee99290 --- /dev/null +++ b/springboot-single-login/.gitignore @@ -0,0 +1,44 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +###logs### +logs/ +*.log + +###env### +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/springboot-single-login/pom.xml b/springboot-single-login/pom.xml new file mode 100644 index 0000000..2bb5d07 --- /dev/null +++ b/springboot-single-login/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + com.example + springboot-single-login + 1.0.0 + jar + + Spring Boot Single Login + 基于Token的单点登录实现 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + + 8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.apache.commons + commons-lang3 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/LoginApplication.java b/springboot-single-login/src/main/java/com/example/login/LoginApplication.java new file mode 100644 index 0000000..650ea43 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/LoginApplication.java @@ -0,0 +1,11 @@ +package com.example.login; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LoginApplication { + public static void main(String[] args) { + SpringApplication.run(LoginApplication.class, args); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/config/LoginProperties.java b/springboot-single-login/src/main/java/com/example/login/config/LoginProperties.java new file mode 100644 index 0000000..f587527 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/config/LoginProperties.java @@ -0,0 +1,54 @@ +package com.example.login.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 登录配置类 + */ +@Data +@Component +@ConfigurationProperties(prefix = "app.login") +public class LoginProperties { + + /** + * 登录模式:SINGLE-单用户单登录,MULTIPLE-单用户多登录 + */ + private LoginMode mode = LoginMode.SINGLE; + + /** + * Token有效期(秒) + */ + private long tokenExpireTime = 30 * 60; + + /** + * Token前缀 + */ + private String tokenPrefix = "TOKEN_"; + + /** + * Token请求头名称 + */ + private String tokenHeader = "Authorization"; + + /** + * 是否启用自动清理过期Token + */ + private boolean enableAutoClean = true; + + /** + * 清理间隔(分钟) + */ + private int cleanInterval = 5; + + /** + * 登录模式枚举 + */ + public enum LoginMode { + // 单用户单登录(新登录踢出旧登录) + SINGLE, + // 单用户多登录(允许多个设备同时登录) + MULTIPLE + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/config/WebConfig.java b/springboot-single-login/src/main/java/com/example/login/config/WebConfig.java new file mode 100644 index 0000000..ae1af2b --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/config/WebConfig.java @@ -0,0 +1,37 @@ +package com.example.login.config; + +import com.example.login.interceptor.LoginInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web配置类 + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Autowired + private LoginInterceptor loginInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(loginInterceptor) + .addPathPatterns("/**") // 拦截所有请求 + .excludePathPatterns( + "/", // 首页 + "/login.html", // 登录页面 + "/index.html", // 主页 + "/admin.html", // 管理页面 + "/error", // 错误页面 + "/favicon.ico", // 图标 + "/css/**", // CSS文件 + "/js/**", // JS文件 + "/images/**", // 图片文件 + "/api/auth/login", // 登录API + "/api/auth/register", // 注册API(如果有) + "/api/status" // 状态检查API + ); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/controller/AuthController.java b/springboot-single-login/src/main/java/com/example/login/controller/AuthController.java new file mode 100644 index 0000000..924a229 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/controller/AuthController.java @@ -0,0 +1,156 @@ +package com.example.login.controller; + +import com.example.login.config.LoginProperties; +import com.example.login.model.ApiResponse; +import com.example.login.model.LoginInfo; +import com.example.login.model.LoginRequest; +import com.example.login.model.TokenInfo; +import com.example.login.service.SessionManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.*; + +/** + * 认证控制器(API接口) + */ +@RestController +@RequestMapping("/api/auth") +@Slf4j +public class AuthController { + + @Autowired + private SessionManager sessionManager; + + @Autowired + private LoginProperties loginProperties; + + // 模拟用户数据库 + private static final Map USER_DB = new HashMap<>(); + static { + USER_DB.put("admin", "admin123"); + USER_DB.put("user1", "user123"); + USER_DB.put("user2", "user123"); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request, + HttpServletRequest httpRequest) { + String username = request.getUsername(); + String password = request.getPassword(); + + // 1. 验证用户名密码 + if (!USER_DB.containsKey(username) || + !USER_DB.get(username).equals(password)) { + return ResponseEntity.status(401) + .body(ApiResponse.fail("用户名或密码错误")); + } + + // 2. 创建登录信息 + LoginInfo loginInfo = new LoginInfo( + getClientIp(httpRequest), + getClientDevice(httpRequest), + httpRequest.getHeader("User-Agent"), + System.currentTimeMillis() + ); + + // 3. 执行登录 + String token = sessionManager.login(username, loginInfo); + + // 4. 返回登录结果 + Map data = new HashMap<>(); + data.put("token", token); + data.put("username", username); + data.put("expireTime", System.currentTimeMillis() + + loginProperties.getTokenExpireTime() * 1000); + data.put("loginMode", loginProperties.getMode()); + + return ResponseEntity.ok(ApiResponse.success("登录成功", data)); + } + + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader(value = "${app.login.token-header:Authorization}", required = false) String token) { + if (org.apache.commons.lang3.StringUtils.isNotBlank(token)) { + sessionManager.logout(token); + } + return ResponseEntity.ok(ApiResponse.success("退出登录成功")); + } + + @PostMapping("/kickout") + public ResponseEntity kickout(@RequestParam String username) { + sessionManager.kickoutUser(username); + return ResponseEntity.ok(ApiResponse.success("已踢出用户:" + username)); + } + + @GetMapping("/online") + public ResponseEntity getOnlineUsers() { + Set users = sessionManager.getOnlineUsers(); + return ResponseEntity.ok(ApiResponse.success("获取成功", users)); + } + + /** + * 获取当前用户信息 + */ + @GetMapping("/current") + public ResponseEntity getCurrentUser(HttpServletRequest request) { + TokenInfo tokenInfo = (TokenInfo) request.getAttribute("tokenInfo"); + if (tokenInfo == null) { + return ResponseEntity.status(401) + .body(ApiResponse.fail(401, "未登录")); + } + + Map data = new HashMap<>(); + data.put("username", tokenInfo.getUsername()); + data.put("loginTime", tokenInfo.getLoginInfo().getLoginTime()); + data.put("ip", tokenInfo.getLoginInfo().getIp()); + data.put("device", tokenInfo.getLoginInfo().getDevice()); + data.put("userAgent", tokenInfo.getLoginInfo().getUserAgent()); + data.put("expireTime", tokenInfo.getExpireTime()); + + return ResponseEntity.ok(ApiResponse.success("获取成功", data)); + } + + @GetMapping("/tokens") + public ResponseEntity getUserTokens(@RequestParam String username) { + List tokens = sessionManager.getUserTokens(username); + return ResponseEntity.ok(ApiResponse.success("获取成功", tokens)); + } + + /** + * 获取客户端IP + */ + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; + } + + /** + * 获取客户端设备信息 + */ + private String getClientDevice(HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + if (userAgent == null) { + return "Unknown"; + } + + if (userAgent.contains("Mobile")) { + return "Mobile"; + } else if (userAgent.contains("Tablet")) { + return "Tablet"; + } else { + return "PC"; + } + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/controller/PageController.java b/springboot-single-login/src/main/java/com/example/login/controller/PageController.java new file mode 100644 index 0000000..ea93492 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/controller/PageController.java @@ -0,0 +1,22 @@ +package com.example.login.controller; + +import com.example.login.model.ApiResponse; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +/** + * 页面控制器 + */ +@Controller +public class PageController { + + /** + * API首页 - 检查登录状态 + */ + @GetMapping({"/api", "/api/status"}) + @ResponseBody + public ApiResponse apiStatus() { + return ApiResponse.success("API服务正常"); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/interceptor/LoginInterceptor.java b/springboot-single-login/src/main/java/com/example/login/interceptor/LoginInterceptor.java new file mode 100644 index 0000000..d1c6931 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/interceptor/LoginInterceptor.java @@ -0,0 +1,92 @@ +package com.example.login.interceptor; + +import com.example.login.model.ApiResponse; +import com.example.login.model.TokenInfo; +import com.example.login.service.SessionManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.lang.invoke.MethodHandle; +import java.util.Arrays; +import java.util.List; + +/** + * 登录验证拦截器 + */ +@Slf4j +@Component +public class LoginInterceptor implements HandlerInterceptor { + + @Autowired + private SessionManager sessionManager; + + @Value("${app.login.token-header:Authorization}") + private String tokenHeader; + + @Override + public boolean preHandle(HttpServletRequest request, + HttpServletResponse response, + Object handler) throws Exception { + + String requestURI = request.getRequestURI(); + + if(!(handler instanceof HandlerMethod)){ + return true; + } + + // 获取Token + String token = getTokenFromRequest(request); + if (token == null) { + return handleUnauthorized(response, "请先登录"); + } + + // 验证Token + TokenInfo tokenInfo = sessionManager.validateToken(token); + if (tokenInfo == null) { + return handleUnauthorized(response, "登录已过期,请重新登录"); + } + + // 将Token信息存入请求属性 + request.setAttribute("tokenInfo", tokenInfo); + request.setAttribute("username", tokenInfo.getUsername()); + + return true; + } + + /** + * 从请求中获取Token + */ + private String getTokenFromRequest(HttpServletRequest request) { + // 从Header中获取 + String token = request.getHeader(tokenHeader); + if (StringUtils.isNotBlank(token)) { + return token; + } + return null; + } + + /** + * 处理未授权请求 + */ + private boolean handleUnauthorized(HttpServletResponse response, String message) + throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(401); + + ApiResponse result = ApiResponse.fail(401, message); + response.getWriter().write( + new ObjectMapper().writeValueAsString(result) + ); + + return false; + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/model/ApiResponse.java b/springboot-single-login/src/main/java/com/example/login/model/ApiResponse.java new file mode 100644 index 0000000..a79ee3d --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/model/ApiResponse.java @@ -0,0 +1,31 @@ +package com.example.login.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 统一响应格式 + */ +@Data +@AllArgsConstructor +public class ApiResponse { + private int code; + private String message; + private T data; + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + public static ApiResponse success(String message) { + return success(message, null); + } + + public static ApiResponse fail(String message) { + return new ApiResponse<>(500, message, null); + } + + public static ApiResponse fail(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/model/LoginInfo.java b/springboot-single-login/src/main/java/com/example/login/model/LoginInfo.java new file mode 100644 index 0000000..5bd153f --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/model/LoginInfo.java @@ -0,0 +1,18 @@ +package com.example.login.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 登录信息 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class LoginInfo { + private String ip; // IP地址 + private String device; // 设备信息 + private String userAgent; // User-Agent + private long loginTime; // 登录时间 +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/model/LoginRequest.java b/springboot-single-login/src/main/java/com/example/login/model/LoginRequest.java new file mode 100644 index 0000000..ffbea8f --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/model/LoginRequest.java @@ -0,0 +1,12 @@ +package com.example.login.model; + +import lombok.Data; + +/** + * 登录请求 + */ +@Data +public class LoginRequest { + private String username; + private String password; +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/model/TokenInfo.java b/springboot-single-login/src/main/java/com/example/login/model/TokenInfo.java new file mode 100644 index 0000000..b406158 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/model/TokenInfo.java @@ -0,0 +1,16 @@ +package com.example.login.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * Token信息 + */ +@Data +@AllArgsConstructor +public class TokenInfo { + private String token; // Token值 + private String username; // 用户名 + private LoginInfo loginInfo; // 登录信息 + private long expireTime; // 过期时间 +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/service/SessionManager.java b/springboot-single-login/src/main/java/com/example/login/service/SessionManager.java new file mode 100644 index 0000000..884963d --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/service/SessionManager.java @@ -0,0 +1,58 @@ +package com.example.login.service; + +import com.example.login.model.LoginInfo; +import com.example.login.model.TokenInfo; + +import java.util.List; +import java.util.Set; + +/** + * 会话管理接口 + */ +public interface SessionManager { + + /** + * 用户登录 + * @param username 用户名 + * @param loginInfo 登录信息(IP、设备等) + * @return 登录Token + */ + String login(String username, LoginInfo loginInfo); + + /** + * 用户登出 + * @param token 登录Token + */ + void logout(String token); + + /** + * 验证Token是否有效 + * @param token 登录Token + * @return Token信息 + */ + TokenInfo validateToken(String token); + + /** + * 获取用户的所有Token + * @param username 用户名 + * @return Token列表 + */ + List getUserTokens(String username); + + /** + * 踢出用户的所有会话 + * @param username 用户名 + */ + void kickoutUser(String username); + + /** + * 获取所有在线用户 + * @return 在线用户列表 + */ + Set getOnlineUsers(); + + /** + * 清理过期Token + */ + void cleanExpiredTokens(); +} \ No newline at end of file diff --git a/springboot-single-login/src/main/java/com/example/login/service/impl/MapSessionManager.java b/springboot-single-login/src/main/java/com/example/login/service/impl/MapSessionManager.java new file mode 100644 index 0000000..ed465d2 --- /dev/null +++ b/springboot-single-login/src/main/java/com/example/login/service/impl/MapSessionManager.java @@ -0,0 +1,148 @@ +package com.example.login.service.impl; + +import com.example.login.config.LoginProperties; +import com.example.login.model.LoginInfo; +import com.example.login.model.TokenInfo; +import com.example.login.service.SessionManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 基于Map的会话管理器实现 + */ +@Slf4j +@Service +public class MapSessionManager implements SessionManager { + + private final LoginProperties properties; + + // 用户名 -> Token列表 + private final Map> userTokenMap = new ConcurrentHashMap<>(); + + // Token -> Token信息 + private final Map tokenMap = new ConcurrentHashMap<>(); + + public MapSessionManager(LoginProperties properties) { + this.properties = properties; + } + + @Override + public String login(String username, LoginInfo loginInfo) { + // 生成Token + String token = generateToken(); + + // 设置登录时间 + if (loginInfo.getLoginTime() == 0) { + loginInfo.setLoginTime(System.currentTimeMillis()); + } + + // 创建Token信息 + TokenInfo tokenInfo = new TokenInfo( + token, + username, + loginInfo, + System.currentTimeMillis() + properties.getTokenExpireTime() * 1000 + ); + + // 根据登录模式处理 + if (properties.getMode() == LoginProperties.LoginMode.SINGLE) { + // 单用户单登录:先踢出旧Token + kickoutUser(username); + } + + // 保存Token + tokenMap.put(token, tokenInfo); + userTokenMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()) + .add(token); + + log.info("用户登录成功: username={}, token={}, mode={}", + username, token, properties.getMode()); + + return token; + } + + @Override + public void logout(String token) { + TokenInfo tokenInfo = tokenMap.remove(token); + if (tokenInfo != null) { + Set tokens = userTokenMap.get(tokenInfo.getUsername()); + if (tokens != null) { + tokens.remove(token); + if (tokens.isEmpty()) { + userTokenMap.remove(tokenInfo.getUsername()); + } + } + log.info("用户登出: username={}, token={}", + tokenInfo.getUsername(), token); + } + } + + @Override + public TokenInfo validateToken(String token) { + TokenInfo tokenInfo = tokenMap.get(token); + if (tokenInfo == null) { + return null; + } + + // 检查是否过期 + if (System.currentTimeMillis() > tokenInfo.getExpireTime()) { + logout(token); + return null; + } + + // 更新过期时间(续期) + tokenInfo.setExpireTime( + System.currentTimeMillis() + properties.getTokenExpireTime() * 1000 + ); + + return tokenInfo; + } + + @Override + public List getUserTokens(String username) { + Set tokens = userTokenMap.get(username); + return tokens != null ? new ArrayList<>(tokens) : Collections.emptyList(); + } + + @Override + public void kickoutUser(String username) { + Set tokens = userTokenMap.remove(username); + if (tokens != null) { + for (String token : tokens) { + tokenMap.remove(token); + } + log.info("踢出用户所有会话: username={}", username); + } + } + + @Override + public Set getOnlineUsers() { + return new HashSet<>(userTokenMap.keySet()); + } + + @Override + public void cleanExpiredTokens() { + long now = System.currentTimeMillis(); + List expiredTokens = new ArrayList<>(); + + tokenMap.forEach((token, info) -> { + if (now > info.getExpireTime()) { + expiredTokens.add(token); + } + }); + + expiredTokens.forEach(this::logout); + log.info("清理过期Token: {}个", expiredTokens.size()); + } + + /** + * 生成Token + */ + private String generateToken() { + return properties.getTokenPrefix() + + UUID.randomUUID().toString().replace("-", ""); + } +} \ No newline at end of file diff --git a/springboot-single-login/src/main/resources/application.yml b/springboot-single-login/src/main/resources/application.yml new file mode 100644 index 0000000..f688beb --- /dev/null +++ b/springboot-single-login/src/main/resources/application.yml @@ -0,0 +1,37 @@ +server: + port: 8080 + +app: + login: + # 登录模式:SINGLE-单用户单登录,MULTIPLE-单用户多登录 + mode: MULTIPLE + # Token有效期(秒) + token-expire-time: 1800 + # Token前缀 + token-prefix: TOKEN_ + # Token请求头名称 + token-header: Authorization + # 是否启用自动清理 + enable-auto-clean: true + # 清理间隔(分钟) + clean-interval: 5 + +# Redis配置(可选,用于分布式部署) +# spring: +# redis: +# host: localhost +# port: 6379 +# database: 0 +# timeout: 3000ms +# lettuce: +# pool: +# max-active: 8 +# max-idle: 8 +# min-idle: 0 + +# 日志配置 +logging: + level: + com.example.login: DEBUG + org.springframework.web: DEBUG + root: INFO \ No newline at end of file diff --git a/springboot-single-login/src/main/resources/static/admin.html b/springboot-single-login/src/main/resources/static/admin.html new file mode 100644 index 0000000..8b68b0d --- /dev/null +++ b/springboot-single-login/src/main/resources/static/admin.html @@ -0,0 +1,362 @@ + + + + + + 管理页面 - 登录系统 + + + + + + + + +
+
+ +
+

系统管理

+

管理在线用户和系统配置

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

在线用户列表

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

系统配置

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

欢迎回来!

+

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

+

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

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

用户信息

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

+ 登录您的账户 +

+

+ 单用户多登录模式 +

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

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

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

配置差异比对工具

+

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

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

+ Spring Boot 网络带宽限速 +

+

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

+
+ + +
+

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

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

全局限速

+ 200 KB/s +
+

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

+ + +
+ + +
+
+

API维度限速

+ 500 KB/s +
+

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

+ + +
+ + +
+
+

用户维度限速

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

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

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

IP维度限速

+ 300 KB/s +
+

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

+ + +
+
+ + +
+

+ + + + + 控制面板 +

+
+ + + +
+
+ + +
+

关于限速算法

+
+
+

令牌桶算法原理

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

应用场景

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

Spring Boot 流式聊天

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

👋 欢迎体验 AI 流式响应

+

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

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