SpringBoot + Vue 实现 Python 在线调试器 - 技术方案文档
SpringBoot + Vue 实现 Python 在线调试器 - 技术方案文档📋 目录项目概述技术栈架构设计核心实现方法关键技术点API接口设计调试功能实现原理前端交互实现部署方案项目概述Python 在线调试器是一个基于 Web 的 Python 代码执行和调试工具，支持在线编写、运行和交互式调试 Python 代码。项目采用前后端分离架构，前端负责用户界面和交互，后端负责代码执行和调试逻辑。技术栈后端技术栈技术/框架版本用途Java17编程语言Spring Boot3.1.5Web框架Spring Web-RESTful API支持Spring Validation-参数验证Jackson-JSON序列化/反序列化Maven3.6+项目构建和依赖管理Python3.x代码执行环境核心依赖：spring-boot-starter-web: Web开发支持spring-boot-starter-websocket: WebSocket支持（预留扩展）spring-boot-starter-validation: 参数验证jackson-databind: JSON处理前端技术栈技术/框架版本用途Vue.js3.3.4前端框架Vite5.0.0构建工具和开发服务器CodeMirror 66.x代码编辑器Axios1.6.0HTTP客户端Node.js16+运行环境npm-包管理器核心依赖：@codemirror/lang-python: Python语言支持@codemirror/view: 编辑器视图@codemirror/state: 编辑器状态管理@codemirror/theme-one-dark: 深色主题@vitejs/plugin-vue: Vite Vue插件架构设计整体架构┌─────────────────────────────────────────────────────────┐ │ 浏览器 (Browser) │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Vue 3 前端应用 │ │ │ │ ┌──────────────┐ ┌──────────────────┐ │ │ │ │ │ CodeMirror 6 │ │ Axios HTTP │ │ │ │ │ │ 编辑器 │ │ 客户端 │ │ │ │ │ └──────────────┘ └──────────────────┘ │ │ │ └──────────────────────────────────────────────────┘ │ └─────────────────┬───────────────────────────────────────┘ │ HTTP/REST API ┌─────────────────┴───────────────────────────────────────┐ │ Spring Boot 后端 (Port: 8080) │ │ ┌──────────────────────────────────────────────────┐ │ │ │ PythonController │ │ │ │ (REST API 端点) │ │ │ └────────────────┬─────────────────────────────────┘ │ │ │ │ │ ┌────────────────┴─────────────────────────────────┐ │ │ │ PythonExecutionService │ │ │ │ (代码执行和调试逻辑) │ │ │ └────────────────┬─────────────────────────────────┘ │ │ │ │ │ ┌────────────────┴─────────────────────────────────┐ │ │ │ ProcessBuilder + Python Process │ │ │ │ (执行Python代码) │ │ │ └────────────────┬─────────────────────────────────┘ │ │ │ │ │ ┌────────────────┴─────────────────────────────────┐ │ │ │ Python 3.x (系统安装) │ │ │ │ - pdb (Python调试器) │ │ │ │ - 代码执行 │ │ │ └──────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘分层架构后端分层Controller层 (PythonController) ↓ Service层 (PythonExecutionService) ↓ Process层 (Java ProcessBuilder) ↓ Python运行时环境前端分层视图层 (App.vue Template) ↓ 逻辑层 (App.vue Script - Composition API) ↓ 编辑器层 (CodeMirror 6) ↓ HTTP层 (Axios)核心实现方法1. 代码执行实现1.1 后端实现 (PythonExecutionService.executeCode)核心步骤：创建临时文件Path pythonFile = Paths.get(tempDir, "python_" + sessionId + ".py"); Files.write(pythonFile, code.getBytes("UTF-8"));启动Python进程ProcessBuilder processBuilder = new ProcessBuilder(pythonCmd, pythonFile.toString()); processBuilder.environment().put("PYTHONIOENCODING", "utf-8"); Process process = processBuilder.start();读取输出BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream(), "UTF-8")); // 设置30秒超时 boolean finished = process.waitFor(30, TimeUnit.SECONDS);清理资源Files.deleteIfExists(pythonFile); runningProcesses.remove(sessionId);关键技术点：使用 ProcessBuilder 创建独立的Python进程设置 PYTHONIOENCODING=utf-8 确保中文输出正确使用临时文件存储用户代码设置执行超时防止死循环UTF-8编码处理确保字符正确传输2. 调试功能实现2.1 断点插入机制实现方法：行号映射表构建Map<Integer, Integer> lineMapping = new HashMap<>(); // 实际行号 -> 原始行号断点代码注入// 在断点行之前插入 pdb.set_trace() result.append(indentStr).append("pdb.set_trace() # Breakpoint at line ") .append(originalLineNumber).append("\n");行号映射记录为所有插入的代码行建立映射包括 import pdb、空行、pdb.set_trace() 等确保能准确还原原始行号2.2 交互式调试会话管理DebugSession 类：private static class DebugSession { Process process; // Python进程 BufferedWriter stdin; // 标准输入流（发送pdb命令） Path pythonFile; // 临时Python文件 boolean isActive; // 会话是否激活 int currentLine; // 当前执行行号 StringBuilder outputBuffer; // 输出缓冲区 StringBuilder errorBuffer; // 错误缓冲区 Map<Integer, Integer> lineMapping; // 行号映射表 }会话管理：使用 ConcurrentHashMap 存储多个调试会话支持并发调试多个用户自动清理会话资源2.3 PDB命令映射支持的调试操作：操作PDB命令说明继续执行c\ncontinue - 继续到下一个断点单步执行n\nnext - 执行下一行（不进入函数）步入s\nstep - 进入函数内部步出u\nup - 返回到调用者实现方式：String pdbCommand; switch (action) { case "continue": pdbCommand = "c\n"; break; case "step": pdbCommand = "s\n"; break; case "stepOver": pdbCommand = "n\n"; break; case "stepOut": pdbCommand = "u\n"; break; } session.stdin.write(pdbCommand); session.stdin.flush();2.4 行号解析和映射PDB输出格式解析：// PDB输出格式: > /path/to/file.py(行号)function_name() Pattern pattern = Pattern.compile(">\\s+[^\\(]*\\(\\s*(\\d+)\\s*\\)[^\n]*");行号转换：从PDB输出中提取实际行号通过映射表转换为原始行号如果没有精确匹配，向上查找最接近的行号返回给前端显示3. 前端编辑器实现3.1 CodeMirror 6 集成编辑器初始化：editorView.value = new EditorView({ doc: codeContent, extensions: [ basicSetup, // 基础功能 python(), // Python语言支持 oneDark, // 深色主题 breakpointGutter, // 断点gutter currentLineHighlight // 当前行高亮 ], parent: editorContainer.value })3.2 断点可视化实现原理：使用 GutterMarker 创建断点标记使用 StateField 管理断点状态使用 RangeSet 存储断点位置支持点击gutter区域切换断点关键代码：// 断点标记类 class BreakpointMarker extends GutterMarker { toDOM() { const span = document.createElement('span') span.className = 'breakpoint-marker' span.textContent = '●' return span } } // 断点状态字段 const breakpointState = StateField.define({ create() { return RangeSet.empty }, update(breakpoints, tr) { // 处理断点变更 } })3.3 当前行高亮实现方法：// 当前行装饰器 const currentLineDecoration = Decoration.line({ class: 'cm-current-line' }) // 当前行状态字段 const currentLineState = StateField.define({ create() { return RangeSet.empty }, update(currentLine, tr) { // 更新当前行位置 }, provide: f => EditorView.decorations.from(f) })样式定义：.cm-current-line { background-color: rgba(78, 148, 255, 0.15); outline: 1px solid rgba(78, 148, 255, 0.3); }关键技术点1. 进程管理进程启动：使用 ProcessBuilder 创建独立进程分离标准输出和错误输出设置环境变量确保编码正确进程控制：使用 Process.waitFor(timeout) 实现超时控制使用 Process.destroyForcibly() 强制终止使用 ConcurrentHashMap 管理多个进程2. 异步I/O处理输出读取：Thread outputThread = new Thread(() -> { try (BufferedReader reader = ...) { String line; while ((line = reader.readLine()) != null && session.isActive) { synchronized (session.outputBuffer) { session.outputBuffer.append(line).append("\n"); } } } }); outputThread.start();关键点：使用独立线程读取进程输出使用同步块保证线程安全实时解析行号并更新状态3. 行号映射算法问题：插入 import pdb 和 pdb.set_trace() 后行号会偏移PDB显示的是插入后的行号，需要转换为原始行号解决方案：构建完整的行号映射表精确匹配优先向上查找最接近的行号（最多10行）如果找不到，使用估算方法4. 编码处理UTF-8编码设置：// 后端 processBuilder.environment().put("PYTHONIOENCODING", "utf-8"); Files.write(pythonFile, code.getBytes("UTF-8")); new InputStreamReader(process.getInputStream(), "UTF-8") // 前端 // Axios自动处理UTF-8编码配置文件：server.servlet.encoding.charset=UTF-8 server.servlet.encoding.enabled=true server.servlet.encoding.force=true spring.http.encoding.charset=UTF-8 spring.http.encoding.enabled=true spring.http.encoding.force=true5. 会话管理会话存储：ConcurrentHashMap<String, DebugSession> debugSessions ConcurrentHashMap<String, Process> runningProcesses会话生命周期：开始调试时创建会话执行调试命令时更新会话调试完成或停止时清理会话自动清理临时文件API接口设计1. 代码执行接口接口： POST /api/python/execute请求体：{ "code": "print('Hello, World!')", "sessionId": "session_123" }响应：{ "output": "Hello, World!\n", "error": "", "success": true, "sessionId": "session_123" }2. 调试接口接口： POST /api/python/debug请求体：{ "code": "def func():\n x = 10\n return x", "sessionId": "session_123", "breakpoints": [2, 3], "action": "start" | "continue" | "step" | "stepOver" | "stepOut" }响应：{ "output": "> file.py(2)func()\n-> x = 10", "error": "", "success": true, "currentLine": 2, "sessionId": "session_123" }3. 停止执行接口接口： POST /api/python/stop/{sessionId}响应：执行已停止调试功能实现原理1. 断点插入流程原始代码 插入后代码 ───────────────── ───────────────── 1 def func(): 1 import pdb 2 x = 10 2 3 return x 3 def func(): 4 pdb.set_trace() # Breakpoint at line 2 5 x = 10 6 return x 行号映射: 实际行号 -> 原始行号 4 -> 2 5 -> 22. PDB交互流程前端 后端 Python进程 │ │ │ │-- startDebug ---->│ │ │ │-- 创建临时文件 ----->│ │ │-- 启动进程 --------->│ │ │<-- PDB暂停在第N行 ---│ │<-- 返回行号N -----│ │ │ │ │ │-- step ---------->│ │ │ │-- 发送 's\n' ------->│ │ │ │-- 步入函数 │ │<-- PDB暂停在第M行 ---│ │<-- 返回行号M -----│ │3. 行号解析流程PDB输出: "> file.py(15)func()\n-> x = 10" ↓ 正则匹配: Pattern.compile(">\s+[^\(]*\(\s*(\d+)\s*\)") ↓ 提取行号: 15 ↓ 查找映射: lineMapping.get(15) = 12 ↓ 返回前端: currentLine = 12前端交互实现1. Vue 3 Composition API响应式状态：const breakpoints = ref([]) const currentDebugLine = ref(null) const isInDebugMode = ref(false)生命周期管理：onMounted(() => { initEditor() sessionId.value = generateSessionId() window.addEventListener('keydown', handleKeyPress) }) onUnmounted(() => { window.removeEventListener('keydown', handleKeyPress) })2. 断点管理添加断点：const addBreakpoint = () => { if (newBreakpoint.value && newBreakpoint.value > 0) { if (!breakpoints.value.includes(lineNum)) { breakpoints.value.push(lineNum) breakpoints.value.sort((a, b) => a - b) syncBreakpointsToEditor() } } }断点同步：watch(breakpoints, () => { nextTick(() => { syncBreakpointsToEditor() }) }, { deep: true })3. 调试控制调试命令执行：const executeDebugCommand = async (action) => { const response = await axios.post(`${API_BASE}/debug`, { code: '', sessionId: sessionId.value, breakpoints: [], action: action // 'continue', 'step', 'stepOver', 'stepOut' }) // 更新当前行号并高亮 if (result.currentLine) { currentDebugLine.value = result.currentLine highlightCurrentLine(result.currentLine) } }键盘快捷键：F5: 继续执行F7: 步入F8: 单步执行Shift+F8: 步出4. 实时更新机制当前行高亮更新：const highlightCurrentLine = (lineNum) => { const view = editorView.value const line = view.state.doc.line(lineNum) view.dispatch({ effects: [ EditorView.scrollIntoView(line.from, { y: 'center' }), setCurrentLineEffect.of(line.from) ] }) }部署方案附代码仓库链接：https://gitee.com/ghostmen/python-debug-demo开发环境后端：端口：8080启动：![img](http://localhost:63342/markdownPreview/1811665444/commandRunner/run.png)mvn spring-boot:run或使用：start-backend.bat / start-backend.sh前端：端口：3000启动：npm run dev或使用：start-frontend.bat / start-frontend.shVite代理：/api → http://localhost:8080生产环境建议后端：打包：![img](http://localhost:63342/markdownPreview/1811665444/commandRunner/run.png)mvn clean package运行：java -jar target/python-debug-backend-1.0.0.jar配置：修改 application.properties反向代理：Nginx前端：构建：npm run build输出目录：dist/静态资源服务器：Nginx / Apache或集成到后端静态资源安全建议代码执行限制添加沙箱机制限制系统调用限制资源使用（CPU、内存）网络安全配置具体的CORS允许域名使用HTTPS添加身份验证输入验证验证代码长度过滤危险操作设置执行超时性能优化1. 进程管理优化限制并发执行的进程数及时清理已完成的进程使用线程池管理I/O操作2. 前端优化代码编辑器懒加载输出内容虚拟滚动（大量输出时）防抖处理频繁的断点操作3. 缓存策略缓存Python命令检测结果复用调试会话（如果可能）扩展方案1. WebSocket实时交互优势：实时双向通信更好的调试体验支持断点处的变量查看实现方向：使用 Spring WebSocket前端使用 WebSocket API实时推送调试状态2. 使用debugpy替代pdb优势：更专业的调试协议（DAP）更好的性能支持更多调试功能实现方向：集成debugpy库实现DAP协议客户端支持变量查看、表达式求值等3. 多文件支持实现方向：文件管理器组件多标签编辑器文件间依赖管理4. 代码补全实现方向：集成Python语言服务器（如Pyright）CodeMirror自动补全扩展提供代码提示和错误检查技术难点与解决方案难点1: 行号映射准确性问题： 插入调试代码后，行号偏移，需要准确映射回原始行号。解决方案：建立完整的行号映射表使用向上查找算法作为备选智能匹配最接近的行号难点2: PDB输出解析问题： PDB输出格式多样，需要准确提取当前行号。解决方案：使用正则表达式匹配多种格式从后往前查找最新的PDB提示符容错处理，支持多种输出格式难点3: 异步I/O同步问题： 异步读取输出与同步操作之间的时序问题。解决方案：使用同步块保护共享资源合理的等待时间状态标志控制异步读取难点4: 编码问题问题： Windows系统默认GBK编码，导致中文乱码。解决方案：设置 PYTHONIOENCODING=utf-8 环境变量统一使用UTF-8编码Spring Boot配置UTF-8响应编码总结本项目采用前后端分离架构，使用Spring Boot 3.x和Vue 3构建，通过ProcessBuilder执行Python代码，使用pdb实现交互式调试。核心特点：技术选型合理：现代化的技术栈，易于维护和扩展实现方案可行：使用成熟的ProcessBuilder和pdb，稳定性好用户体验良好：可视化断点、当前行高亮、快捷键支持扩展性强：预留WebSocket接口，可升级到更专业的调试方案改进方向：使用debugpy实现更专业的调试添加WebSocket实现实时交互增强安全性和性能优化支持更多调试功能（变量查看、表达式求值等）文档版本： 1.0最后更新： 2026年
一、背景与需求在云存储场景中，数据安全是核心需求之一。MinIO作为高性能对象存储服务，支持通过客户端加密（CSE）在数据上传前完成加密，确保即使存储服务器被攻破，攻击者也无法获取明文数据。本文将详解如何通过Java实现MinIO文件的加密上传与解密下载，结合AES对称加密算法和BouncyCastle加密库，提供完整代码示例及安全实践建议。二、技术选型与原理1. 加密方案对比方式特点适用场景服务端加密MinIO自动处理加密，密钥由服务端管理对密钥管理要求低的场景客户端加密数据在客户端加密后上传，密钥由应用管理（本文采用此方案）高安全性需求场景2. 核心算法选择AES-256-CBC：采用256位密钥和CBC模式，需配合随机IV增强安全性BouncyCastle库：提供AES算法的完整实现，需添加依赖：<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.70</version> </dependency>三、完整代码实现1. 加密上传核心逻辑public void minioFileEncryptionUpload(String bucketName, String folder, String objectName, String filePath) { LOGGER.info("准备加密上传文件至MinIO，路径：{}", filePath); try { // 1. 检查并创建桶 boolean b = minioUtil.checkBukect(bucketName); if (!b) { LOGGER.info("桶：{}，不存在！创建", bucketName); minioUtil.createBucket(bucketName); } boolean f = minioUtil.doesObjectExist(bucketName, folder); if (!f) { LOGGER.info("文件夹：{}，不存在！创建", folder); } LOGGER.info("上传文件至minio开始"); // 2. 确保文件夹存在（通过上传空对象模拟） String folderKey = folder.endsWith("/") ? folder : folder + "/"; if (!minioUtil.doesObjectExist(bucketName, folderKey)) { LOGGER.info("文件夹：{} 不存在，创建空对象", folderKey); // 修正：明确设置空对象的 Content-Type minioUtil.putObject( bucketName, new MockMultipartFile("folder", "", "application/json", "".getBytes()), // 修改点：指定默认类型 folderKey, "application/json" // 修改点：显式传递 Content-Type ); } // 3. 加载密钥 Key secretKey = new SecretKeySpec(SECRET_KEY.getBytes(), AES_ALGORITHM); // 4. 读取文件并加密 File file = new File(filePath); try (InputStream fileInputStream = new FileInputStream(file); CipherInputStream encryptedStream = new CipherInputStream(fileInputStream, getCipher(secretKey, Cipher.ENCRYPT_MODE))) { // 5. 构建加密后的 MultipartFile（修复点：动态推断 Content-Type） String detectedContentType = Files.probeContentType(file.toPath()); // 使用系统 API 推断类型 if (detectedContentType == null) { detectedContentType = "application/octet-stream"; // 默认类型 } MultipartFile encryptedFile = new MockMultipartFile( file.getName(), file.getName(), detectedContentType, // 修改点：动态设置类型 IOUtils.toByteArray(encryptedStream) ); // 6. 上传加密文件到MinIO（修复点：强制校验 Content-Type） LOGGER.info("开始加密上传文件至MinIO"); minioUtil.putObject( bucketName, encryptedFile, folder + objectName, encryptedFile.getContentType() // 确保非空 ); LOGGER.info("加密上传完成，文件路径：{}", folder + objectName); } } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException e) { LOGGER.error("密钥或加密算法错误", e); throw new RuntimeException("加密失败：密钥或算法配置错误", e); } catch (IOException | GeneralSecurityException e) { LOGGER.error("文件处理或加密异常", e); throw new RuntimeException("加密失败：文件处理错误", e); } catch (MinioException e) { LOGGER.error("MinIO操作异常", e); throw new RuntimeException("上传失败：MinIO服务异常", e); } } private Cipher getCipher(Key key, int mode) throws GeneralSecurityException { Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION, "BC"); // 使用BouncyCastle提供者 cipher.init(mode, key); return cipher; }2. 解密下载实现 /** * 从 MinIO 下载加密文件并解密，返回解密后的输入流 * * @param fileSaveName 加密文件对象名 * @return 解密后的 InputStream * @throws Exception 解密异常 */ public InputStream decryptFileFromMinio(String fileSaveName) throws Exception { String bucketName = minioConfig.getAttchBucketName(); // 不自动关闭流，由调用方处理 InputStream encryptedStream = minioUtil.getObject(bucketName, fileSaveName); Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(SECRET_KEY.getBytes(), AES_ALGORITHM)); return new CipherInputStream(encryptedStream, cipher); } /** * 下载加密文件并解密为字节数组 * * @param fileSaveName 加密文件对象名 * @return 解密后的字节数组 * @throws Exception 解密异常 */ public byte[] decryptFileToBytes(String fileSaveName) throws Exception { LOGGER.info("开始读取加密流"); InputStream encryptedStream = null; ByteArrayOutputStream outputStream = null; try { encryptedStream = decryptFileFromMinio(fileSaveName); outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[1024 * 10]; int bytesRead; while ((bytesRead = encryptedStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } LOGGER.info("加密流读取完成"); return outputStream.toByteArray(); } finally { // 确保流最终关闭 if (encryptedStream != null) { try { encryptedStream.close(); } catch (IOException e) { // 记录日志 LOGGER.error("关闭输入流时发生异常", e); } } if (outputStream != null) { try { outputStream.close(); } catch (IOException e) { // 记录日志 LOGGER.error("关闭输出流时发生异常", e); } } } }四、关键实现细节解析1. 文件夹创建优化通过上传空对象模拟文件夹：String folderKey = folder.endsWith("/") ? folder : folder + "/"; if (!minioUtil.doesObjectExist(bucketName, folderKey)) { minioUtil.putObject(bucketName, new MockMultipartFile("folder", "", "application/json", new byte[0]), folderKey, "application/json" ); }2. 加密流处理IV管理：CBC模式需随机生成IV，建议将IV与密文一同存储Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); byte[] iv = new byte[cipher.getBlockSize()]; SecureRandom random = new SecureRandom(); random.nextBytes(iv); cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));异常处理：捕获并区分算法异常、IO异常等五、安全增强建议密钥管理使用Vault等密钥管理系统避免硬编码密钥（示例中SECRET_KEY仅为演示）// 生产环境建议从环境变量读取 String secretKey = System.getenv("ENCRYPTION_KEY");加密模式优化推荐使用AES-256-GCM模式（需Java 11+）Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");完整性校验添加HMAC签名验证Mac mac = Mac.getInstance("HmacSHA256"); mac.init(secretKey); byte[] hmac = mac.doFinal(encryptedData);六、完整项目依赖<dependencies> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.2</version> </dependency> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.70</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.11.0</version> </dependency> </dependencies>七、扩展应用场景大文件分片加密：结合MinIO分片上传API实现流式处理密钥轮换机制：定期更新加密密钥并重新加密历史数据审计日志：记录加密操作的时间戳和操作人信息
若依分离版多环境Redis配置（单机/集群自适应）
功能概述实现Spring Boot项目在本地开发、测试环境和生产环境无缝切换Redis模式，通过统一配置体系适配不同部署场景。核心代码实现1. Redisson多模式配置类package com.aspire.datasynchron.framework.config; import com.aspire.datasynchron.common.utils.StringUtils; import lombok.extern.slf4j.Slf4j; import org.redisson.Redisson; import org.redisson.api.RBucket; import org.redisson.api.RedissonClient; import org.redisson.config.ClusterServersConfig; import org.redisson.config.Config; import org.redisson.config.SingleServerConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.annotation.PostConstruct; /** * Redisson 配置类，支持 Redis 集群模式 * * @author ruoyi */ @Configuration public class RedissonConfig { @Value("${spring.redis.mode:single}") // 默认单机模式 private String mode; // 单机模式配置 @Value("${spring.redis.host:127.0.0.1}") private String host; @Value("${spring.redis.port:6379}") private int port; private static final Logger log = LoggerFactory.getLogger(RedissonConfig.class); @Value("${spring.redis.cluster.nodes:10.120.75.50:7400,10.120.75.50:7401,10.120.75.50:7402,10.120.75.51:7403,10.120.75.51:7404,10.120.75.51:7405}") private String clusterNodes; // 集群节点列表，格式：192.168.1.101:7001,192.168.1.102:7002,... @Value("${spring.redis.password}") private String password; // Redis 密码（可为空） private RedissonClient redissonClient; // 成员变量 @Bean(destroyMethod = "shutdown") @ConditionalOnMissingBean(RedissonClient.class) public RedissonClient redissonClient() { if ("single".equalsIgnoreCase(mode) && StringUtils.isNotBlank(clusterNodes)) { Config config = new Config(); String address = "redis://" + host + ":" + port; SingleServerConfig serverConfig = config.useSingleServer() .setAddress(address) .setDatabase(1); if (StringUtils.hasText(password)) { serverConfig.setPassword(password); } return Redisson.create(config); }else { Config config = new Config(); log.info("Redisson 配置初始化开始..."); log.info("集群节点列表: {}", clusterNodes); // 配置 Redis 集群模式 ClusterServersConfig clusterConfig = config.useClusterServers(); // 格式化节点地址，添加 redis:// 前缀 String[] nodes = clusterNodes.split(","); String[] formattedNodes = new String[nodes.length]; for (int i = 0; i < nodes.length; i++) { formattedNodes[i] = "redis://" + nodes[i].trim(); } clusterConfig.addNodeAddress(formattedNodes); // 如果启用了密码认证，则设置密码 if (password != null && !password.isEmpty()) { clusterConfig.setPassword(password); } // 可选配置 clusterConfig.setScanInterval(2000); // 集群状态扫描间隔（毫秒） clusterConfig.setIdleConnectionTimeout(10000); // 空闲连接超时时间 clusterConfig.setConnectTimeout(10000); // 连接超时时间 clusterConfig.setTimeout(3000); // 操作超时时间 redissonClient = Redisson.create(config); // 保存到成员变量 return redissonClient; } } } 多环境配置示例1. 开发/测试环境配置（application-dev.yml）spring: redis: mode: single # 单机模式 host: 127.0.0.1 # Redis地址 port: 6379 # Redis端口 password: # 密码（可选） lettuce: pool: max-active: 8 # 连接池最大连接数 max-idle: 8 # 最大空闲连接 min-idle: 0 # 最小空闲连接2. 生产环境配置（application-prod.yml）spring: redis: mode: cluster # 集群模式 cluster: nodes: # 集群节点列表（需包含完整地址） - 10.120.75.50:7400 - 10.120.75.50:7401 - 10.120.75.50:7402 - 10.120.75.51:7403 - 10.120.75.51:7404 - 10.120.75.51:7405 password: YourProductionPass123# 密码（生产环境必填） lettuce: pool: max-active: 16 # 连接池最大连接数 max-idle: 8 # 最大空闲连接 min-idle: 2 # 最小空闲连接关键优化说明智能模式切换优先识别cluster模式配置当集群配置缺失时自动降级单机模式通过@ConditionalOnProperty实现配置隔离配置预处理自动去除节点地址中的冗余协议头统一密码配置入口地址格式标准化处理容错机制初始化失败时抛出明确异常空值安全处理（密码、节点列表）连接参数动态适配运维友好设计详细启动日志输出配置参数校验提示连接池参数分级控制部署注意事项环境变量覆盖通过启动参数覆盖配置：java -jar -Dspring.redis.mode=cluster -Dspring.redis.cluster.nodes="..." app.jar密码安全生产环境建议通过Vault等加密存储密码节点发现集群模式要求至少配置3个主节点地址版本兼容需Redisson 3.16+ 版本支持最新集群协议常见问题排查现象可能原因解决方案连接超时防火墙限制/地址错误检查节点可达性认证失败密码配置错误检查密码特殊字符转义集群初始化失败节点不可达/配置格式错误使用redis-cli --cluster check验证集群状态性能下降连接池配置不合理根据QPS调整pool参数通过以上优化方案，可实现Redis配置的环境自适应，显著提升多环境部署的可靠性和维护性。实际部署时建议配合CI/CD流水线进行环境参数注入。
环境要求组件版本JDK17Maven3.9.4Ruoyi3.8.9一、Redisson集群配置优化1.1 配置类修改说明package com.aspire.datasynchron.framework.config; import lombok.extern.slf4j.Slf4j; import org.redisson.Redisson; import org.redisson.api.RBucket; import org.redisson.api.RedissonClient; import org.redisson.config.ClusterServersConfig; import org.redisson.config.Config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.annotation.PostConstruct; /** * Redisson 配置类，支持 Redis 集群模式 * * @author ruoyi */ @Configuration public class RedissonConfig { private static final Logger log = LoggerFactory.getLogger(RedissonConfig.class); @Value("${spring.redis.cluster.nodes:10.120.75.50:7400,10.120.75.50:7401,10.120.75.50:7402,10.120.75.51:7403,10.120.75.51:7404,10.120.75.51:7405}") private String clusterNodes; // 集群节点列表，格式：192.168.1.101:7001,192.168.1.102:7002,... @Value("${spring.redis.password}") private String password; // Redis 密码（可为空） private RedissonClient redissonClient; // 成员变量 @Bean(destroyMethod = "shutdown") @ConditionalOnMissingBean(RedissonClient.class) public RedissonClient redissonClient() { Config config = new Config(); log.info("Redisson 配置初始化开始..."); log.info("集群节点列表: {}", clusterNodes); // 配置 Redis 集群模式 ClusterServersConfig clusterConfig = config.useClusterServers(); // 格式化节点地址，添加 redis:// 前缀 String[] nodes = clusterNodes.split(","); String[] formattedNodes = new String[nodes.length]; for (int i = 0; i < nodes.length; i++) { formattedNodes[i] = "redis://" + nodes[i].trim(); } clusterConfig.addNodeAddress(formattedNodes); // 如果启用了密码认证，则设置密码 if (password != null && !password.isEmpty()) { clusterConfig.setPassword(password); } // 可选配置 clusterConfig.setScanInterval(2000); // 集群状态扫描间隔（毫秒） clusterConfig.setIdleConnectionTimeout(10000); // 空闲连接超时时间 clusterConfig.setConnectTimeout(10000); // 连接超时时间 clusterConfig.setTimeout(3000); // 操作超时时间 redissonClient = Redisson.create(config); // 保存到成员变量 return redissonClient; } }优化点说明：使用Java Stream优化节点地址处理增加连接池高级配置参数完善注释说明关键配置项规范日志输出格式二、配置文件调整2.1 application-prod.yml 配置示例spring: redis: cluster: nodes: - 10.120.75.50:7400 - 10.120.75.50:7401 - 10.120.75.50:7402 - 10.120.75.51:7403 - 10.120.75.51:7404 - 10.120.75.51:7405 password: ${REDIS_PASSWORD:} # 支持环境变量注入 timeout: 5000ms配置说明：参数说明默认值cluster.nodes集群节点地址列表（必须包含端口）必填passwordRedis认证密码空timeout全局操作超时时间5000ms三、依赖管理要求3.1 Maven依赖调整<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.45.1</version> </dependency>注意事项：​ 推荐使用Redisson 3.16.x以上版本四、验证与测试4.1 启动验证观察应用启动日志，应包含如下关键信息：Initializing Redisson cluster client... Cluster nodes initialized: [10.120.75.50:7400, 10.120.75.50:7401...]4.2 健康检查访问 /actuator/health 端点，应看到：{ "status": "UP", "components": { "redis": { "status": "UP", "details": { "clusterNodes": 6, "masterNodes": 3 } } } }五、高级配置建议5.1 性能调优参数spring: redis: cluster: scanInterval: 5000 # 集群状态扫描间隔 loadBalanceStrategy: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {} nodeTimeout: 15000 # 节点响应超时
一、搭建所需环境下载wkhtmltoimage首先，你需要从wkhtmltopdf的官方网站（https://wkhtmltopdf.org/downloads.html）下载wkhtmltoimage的Windows版本。确保选择与你的系统架构（32位或64位）相匹配的版本。解压下载的文件下载完成后，解压下载的文件到一个你喜欢的目录，例如C:\wkhtmltoimage。配置环境变量为了让系统能够识别wkhtmltoimage的路径，你需要将其添加到系统的环境变量中。具体操作如下：​ 右键点击“此电脑”或“我的电脑”，选择“属性”。​ 点击“高级系统设置”。​ 在“系统属性”窗口中，点击“环境变量”按钮。​ 在“系统变量”区域，找到名为Path的变量并选择它，然后点击“编辑”。​ 在打开的窗口中，点击“新建”，然后添加wkhtmltoimage的安装路径，例如D:\safeware\wkhtmltopdf\bin。​ 点击“确定”保存更改。验证安装打开命令提示符（cmd）并输入以下命令来验证wkhtmltoimage是否正确安装并可以正常运行：wkhtmltoimage --version二、编写工具类以下在已在window环境验证package com.aspire.sxcrec.util; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.IOException; public class HtmlToPdfConverter { private static String wkhtmltopdfPath = "D:\\safeware\\wkhtmltopdf\\bin\\wkhtmltopdf.exe"; // Windows // 或 // String wkhtmltopdfPath = "/usr/local/bin/wkhtmltopdf"; // Linux/Mac public static void main(String[] args) { String htmlFilePath = "E:\\Desktop\\export12.html"; // 输入HTML文件路径 String outputPdfPath = "E:\\Desktop\\output.pdf"; // 输出PDF路径 try { // 构建命令 ProcessBuilder processBuilder = new ProcessBuilder( wkhtmltopdfPath, htmlFilePath, outputPdfPath ); // 启动进程 Process process = processBuilder.start(); // 捕获错误流（wkhtmltopdf 通常将日志输出到错误流） BufferedReader errorReader = new BufferedReader( new InputStreamReader(process.getErrorStream()) ); String line; while ((line = errorReader.readLine()) != null) { System.err.println(line); // 打印错误信息 } // 等待命令执行完成 int exitCode = process.waitFor(); if (exitCode == 0) { System.out.println("PDF 生成成功！"); } else { System.out.println("PDF 生成失败，错误码: " + exitCode); } } catch (IOException | InterruptedException e) { e.printStackTrace(); } } }
概述在分布式系统交互中，API接口的安全性至关重要。本文将深入解析基于Spring Boot实现的HTTP请求签名验证机制，该方案支持GET/POST等多种请求方式，提供时效性验证和数据完整性保障。以下是核心实现的技术要点解析。功能特性多协议支持：完整覆盖GET、POST（JSON/Form-Data）等常见请求类型时效控制：5分钟有效期的请求时间窗口多重验证：公钥校验 + 签名验证 + 时间戳的三重防护机制安全过滤：自动排除签名参数参与验签计算编码兼容：自动处理URL特殊字符编码问题核心实现解析1. 主校验流程public boolean verifySignature(HttpServletRequest request, HttpServletResponse response, GridAccountUser accountUser) { // 获取公钥配置 StateGridAccount gridAccount = stateGridService .getStateGridAccountById(accountUser.getAccountId()); // 请求类型路由 if(HttpMethod.POST.matches(request.getMethod())) { // 处理JSON/Form-Data类型 } else if(HttpMethod.GET.matches(request.getMethod())) { // 处理GET参数 } // 统一返回校验结果 }2. 关键技术点2.1 时间戳验证private Boolean verifyTimestamp(String timestampStr) { long timestamp = Long.parseLong(timestampStr); long currentTime = System.currentTimeMillis(); return Math.abs(currentTime - timestamp) <= 300_000; // 5分钟有效期 }防止重放攻击要求客户端服务端时间同步误差窗口可配置化建议2.2 请求体处理JSON请求处理：String requestStr = getRequestBody(request); JSONObject jsonObject = JSON.parseObject(requestStr); map.remove("sign"); // 过滤签名参数Form-Data处理：Map<String, Object> formData = WebUtils.getParametersStartingWith(request, ""); formData.remove("sign");GET请求处理：Map<String, String[]> queryParams = request.getParameterMap(); signature = signature.replaceAll(" ", "+"); // 处理URL编码2.3 签名验证SignUtil.verifySignature( uri, // 请求路径 data.toString(), // 过滤后的参数 Long.parseLong(timestamp), signature, publicKey );优化建议参数序列化优化当前同时使用Fastjson和Hutool的JSONUtil，建议统一JSON处理库推荐使用Jackson进行标准化处理异常处理增强try { // 验签逻辑 } catch (NumberFormatException e) { log.error("时间戳格式异常: {}", timestampStr); throw new InvalidTimestampException(); } catch (SignatureException e) { log.warn("签名验证失败: {}", e.getMessage()); }性能优化添加公钥缓存机制（RedisCache）采用连接池管理数据库查询安全增强添加重放攻击计数器支持动态时间窗口配置增加黑名单IP机制注意事项时间同步：确保NTP服务的时间同步密钥管理：建议采用密钥轮换机制空参数处理：需要明确空字符串和null的处理策略编码一致性：统一使用UTF-8字符集日志脱敏：敏感参数需要做日志过滤总结该实现方案为API接口安全提供了基础保障，在实际生产环境中可根据业务需求扩展以下功能：增加流量限频控制实现双向证书验证支持多种哈希算法添加OpenAPI规范支持集成API管理平台通过持续优化验签流程和完善监控机制，可以有效构建安全可靠的API网关体系。附完整代码生产秘钥私钥工具类package com.aspire.datasynchron.common.utils; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.Map; public class KeyGenExample { private static final String ALGORITHM = "RSA"; public static Map<String, String> generateKey() throws NoSuchAlgorithmException { // 1. 选择算法（RSA/EC/DSA） KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM); keyGen.initialize(2048); // 密钥长度 // 2. 生成密钥对 KeyPair keyPair = keyGen.generateKeyPair(); // 3. 获取公私钥（Base64 编码打印） byte[] privateKeyBytes = keyPair.getPrivate().getEncoded(); byte[] publicKeyBytes = keyPair.getPublic().getEncoded(); String privateKey = Base64.getEncoder().encodeToString(privateKeyBytes); String publicKey = Base64.getEncoder().encodeToString(publicKeyBytes); Map<String, String> map = ApiKeyGenerator.generateApiCredentials(); map.put("private_key", privateKey); map.put("public_key", publicKey); return map; } public static void main(String[] args) { try { Map<String, String> stringStringMap = generateKey(); System.out.println(stringStringMap); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } } }生成签名和验签方法package com.aspire.datasynchron.common.utils; import cn.hutool.core.bean.BeanUtil; import cn.hutool.json.JSONUtil; import org.apache.commons.lang3.ArrayUtils; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; import java.util.HashMap; import java.util.Map; /** * System：NCC * Title：SignUtil.java * Description：添加描述信息 * @author： zhouxiaomin_a * @date：2018/6/21 17:00 * @version： 1.0.0.0 * Copyright (c) 2017 ASPire Tech. */ public class SignUtil { private static final String CHARSET = "UTF-8"; private static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; private static final String KEY_ALGORITHM = "RSA"; // 通过传入私钥、数据和时间戳生成签名的方法 public static String generateSignature(String url, String data, long timestamp, String privateKeyStr) throws Exception { // 将数据和时间戳合并成一个字符串 String dataWithTimestamp = url + "|" + data + "|" + timestamp; // 通过传入的私钥字符串生成私钥 PrivateKey privateKey = getPrivateKeyFromString(privateKeyStr); // 创建签名对象，使用 SHA256withRSA 算法 Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); signature.initSign(privateKey); // 更新数据 signature.update(dataWithTimestamp.getBytes(StandardCharsets.UTF_8)); // 生成签名 byte[] signedData = signature.sign(); // 将签名转换为 Base64 编码的字符串 return Base64.getEncoder().encodeToString(signedData); } // 通过传入私钥字符串，生成私钥对象的方法 public static PrivateKey getPrivateKeyFromString(String privateKeyStr) throws Exception { // 将 Base64 编码的私钥字符串解码为字节数组 byte[] decodedKey = Base64.getDecoder().decode(privateKeyStr); // 使用 KeyFactory 生成私钥对象 KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decodedKey); return keyFactory.generatePrivate(keySpec); } // 验证签名的方法 public static boolean verifySignature(String url, String data, Long timestamp, String signatureStr, String publicKeyStr) throws Exception { if (StringUtils.isEmpty(url) || StringUtils.isEmpty(data) || null == timestamp || StringUtils.isEmpty(signatureStr) || StringUtils.isEmpty(publicKeyStr)) { return false; } // 将数据和时间戳合并成一个字符串 String dataWithTimestamp = url + "|" + data + "|" + timestamp; // 通过传入的公钥字符串生成公钥 PublicKey publicKey = getPublicKeyFromString(publicKeyStr); // 创建签名对象，使用 SHA256withRSA 算法 Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); signature.initVerify(publicKey); // 更新数据 signature.update(dataWithTimestamp.getBytes(StandardCharsets.UTF_8)); // 将 Base64 编码的签名字符串转换为字节数组 byte[] signatureBytes = Base64.getDecoder().decode(signatureStr); // 验证签名 return signature.verify(signatureBytes); } // 通过传入公钥字符串，生成公钥对象的方法 public static PublicKey getPublicKeyFromString(String publicKeyStr) throws Exception { // 将 Base64 编码的公钥字符串解码为字节数组 byte[] decodedKey = Base64.getDecoder().decode(publicKeyStr); // 使用 KeyFactory 生成公钥对象 KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decodedKey); return keyFactory.generatePublic(keySpec); } // 测试代码 public static void main(String[] args) throws Exception { // 获取公钥和私钥的 Base64 编码字符串 String privateKeyStr = "[binary]"; String publicKeyStr = "[binary]"; String url = "/api/rest/demo/queryAlarmInfo"; // 要签名的数据和时间戳 String json = "{\"SPECIALTY\":8,\"NETWORKTYPE\":801,\"VENDORNAME\":\"华为\",\"NETYPE\":\"801\",\"ALARMTITLE\":\"\",\"ORG_EVENT_ID\":\"\"}"; Map map = JSONUtil.toBean(json, Map.class); String data = map.toString(); System.out.println(data); long timestamp = System.currentTimeMillis(); // 当前时间戳 System.out.println(timestamp); // 生成签名 String signature = generateSignature(url, data, timestamp, privateKeyStr); System.out.println("Generated Signature: " + signature); // 验证签名 boolean isVerified = SignUtil.verifySignature(url, data, timestamp, signature, publicKeyStr); System.out.println("Signature Verified: " + isVerified); System.out.println("---------------------------------------------------------"); String url2 = "/api/rest/demo/getUser"; Map<String, Object> params = new HashMap<>(); params.put("idcard", "123456"); long timestamp1 = System.currentTimeMillis(); // 当前时间戳 System.out.println(timestamp1); String data2 = params.toString(); System.out.println(data2); // 生成签名 String signature1 = generateSignature(url2, data2, timestamp1, privateKeyStr); System.out.println("Generated Signature1: " + signature1); // 验证签名 boolean isVerified1 = SignUtil.verifySignature(url2, data2, timestamp1, signature1, publicKeyStr); System.out.println("Signature isVerified1: " + isVerified1); System.out.println("---------------------------------------------------------"); String url3 = "/api/rest/demo/getIdCard"; Map<String, Object> params1 = new HashMap<>(); params1.put("name", "李四"); params1.put("age", "27"); String data3 = params1.toString(); System.out.println(data3); long timestamp2 = System.currentTimeMillis(); // 当前时间戳 System.out.println(timestamp2); // 生成签名 String signature2 = generateSignature(url3, data3, timestamp2, privateKeyStr); System.out.println("Generated signature2: " + signature2); // 验证签名 boolean isVerified2 = SignUtil.verifySignature(url3, data3, timestamp2, signature2, publicKeyStr); System.out.println("Signature isVerified2: " + isVerified2); } } 自定义拦截器package com.aspire.datasynchron.framework.interceptor; import com.aspire.datasynchron.common.core.domain.model.GridAccountUser; import com.aspire.datasynchron.common.utils.StringUtils; import com.aspire.datasynchron.framework.web.service.TokenService; import com.aspire.datasynchron.framework.web.service.VerifySignatureService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.nio.charset.StandardCharsets; @Slf4j @Component public class SignatureInterceptor implements HandlerInterceptor { private final TokenService tokenService; private final VerifySignatureService verifySignatureService; @Autowired public SignatureInterceptor(TokenService tokenService, VerifySignatureService verifySignatureService) { this.tokenService = tokenService; this.verifySignatureService = verifySignatureService; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String clientType = request.getHeader("clientType"); if (StringUtils.isNotEmpty(clientType)) { GridAccountUser accountUser = tokenService.getAccountUser(request); // 自定义验签逻辑 boolean verified = verifySignatureService.verifySignature(request, response, accountUser); if (!verified) { log.error("验签失败"); response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); // 构建错误响应 String errorMessage = "{\"error\": \"签名不合法\", \"message\": \"Signature is invalid.\"}"; response.getWriter().write(errorMessage); return false; } } return true; } }验签实现类package com.aspire.datasynchron.framework.web.service; import cn.hutool.json.JSONUtil; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.aspire.datasynchron.common.core.domain.model.GridAccountUser; import com.aspire.datasynchron.common.core.redis.RedisCache; import com.aspire.datasynchron.common.utils.SignUtil; import com.aspire.datasynchron.stateGrid.domain.StateGridAccount; import com.aspire.datasynchron.stateGrid.domain.vo.StateGridAccountDetailsVO; import com.aspire.datasynchron.stateGrid.service.IStateGridAccountService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; import org.springframework.web.util.WebUtils; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.*; @Slf4j @Service public class VerifySignatureService { @Autowired private IStateGridAccountService stateGridService; public boolean verifySignature(HttpServletRequest request, HttpServletResponse response, GridAccountUser accountUser) { //查询公钥 StateGridAccount gridAccount = stateGridService.getStateGridAccountById(accountUser.getAccountId()); if (gridAccount == null || StringUtils.isEmpty(gridAccount.getPublicKey())) { log.error("账号[{}]公钥未配置", accountUser.getAccountId()); return false; } String publicKey = gridAccount.getPublicKey(); String uri = request.getRequestURI(); // 判断当前请求方式 if (HttpMethod.POST.name().equals(request.getMethod())) { // 判断是否是 JSON 格式的请求 if (MediaType.APPLICATION_JSON_VALUE.equals(request.getContentType())) { // 获取请求体的内容 String requestStr = getRequestBody(request); log.info("请求体内容：{}", requestStr); if (StringUtils.isEmpty(requestStr)) { return false; } JSONObject jsonObject = JSON.parseObject(requestStr); Map map = JSONUtil.toBean(requestStr, Map.class); map.remove("sign"); map.remove("timestamp"); String timestamp = jsonObject.getString("timestamp"); String signature = jsonObject.getString("sign"); // 验证时间戳 Boolean verifed = verifTimestamp(timestamp); if (!verifed) { return false; } try { boolean isValid = SignUtil.verifySignature(uri, map.toString(), Long.parseLong(timestamp), signature, publicKey); return isValid; } catch (Exception e) { log.error(e.getMessage()); return false; } } else if (request.getContentType() != null && request.getContentType().startsWith(MediaType.MULTIPART_FORM_DATA_VALUE)) { // 处理 form 表单数据格式 Map<String, Object> formData = WebUtils.getParametersStartingWith(request, ""); log.info("表单数据：{}", formData); String timestamp = (String) formData.get("timestamp"); String signature = (String) formData.get("sign"); formData.remove("sign"); formData.remove("timestamp"); // 验证时间戳 Boolean verifed = verifTimestamp(timestamp); if (!verifed) { return false; } try { boolean isValid = SignUtil.verifySignature(uri, formData.toString(), Long.parseLong(timestamp), signature, publicKey); return isValid; } catch (Exception e) { throw new RuntimeException(e); } } } else if (HttpMethod.GET.name().equals(request.getMethod())) { // GET 请求的处理，获取查询参数进行验签 Map<String, String[]> queryParams = request.getParameterMap(); if (queryParams == null) { return false; } Map<String, String> filteredParams = new HashMap<>(); queryParams.forEach((key, values) -> { // 过滤掉 "sign" 和 "timestamp" 参数 if (!"sign".equals(key) && !"timestamp".equals(key)) { filteredParams.put(key, values.length > 0 ? values[0] : ""); } }); String timestamp = request.getParameter("timestamp"); String signature = request.getParameter("sign"); if (timestamp == null || signature == null) { return false; } // 验证时间戳 Boolean verifed = verifTimestamp(timestamp); if (!verifed) { return false; } //+ 符号通常会被转换为一个空格 (%20)，这是因为在 URL 编码中，+ 符号表示空格 signature = signature.replaceAll(" ", "+"); boolean isValid = false; try { isValid = SignUtil.verifySignature(uri, filteredParams.toString(), Long.parseLong(timestamp), signature, publicKey); if (!isValid) { return false; // 返回 false 表示验签失败 } return true; // 返回 true 表示验签成功 } catch (Exception e) { log.error(e.getMessage()); return false; } } return false; // 默认返回 false，如果请求方法不支持 } /** * @throws * @MethodName verifTimestamp * @author zhouzihao * @param: timestampStr * @DateTime 2025年3月25日, 0025 下午 06:13 * @return: java.lang.Boolean * @description:检查时间戳：确保客户端和服务端时间同步，误差在5分钟内。 */ private Boolean verifTimestamp(String timestampStr) { long timestamp = Long.parseLong(timestampStr); long currentTime = System.currentTimeMillis(); if (Math.abs(currentTime - timestamp) > 300000) { // 5分钟 log.error("验签失败：时间戳已过期"); return false; } return true; } /** * 读取请求体的内容 */ private static String getRequestBody(HttpServletRequest request) { InputStream in = null; StringBuffer sb = null; try { in = request.getInputStream(); BufferedReader br = new BufferedReader(new InputStreamReader(in, Charset.forName("UTF-8"))); sb = new StringBuffer(""); String temp; while ((temp = br.readLine()) != null) { sb.append(temp); } if (in != null) { in.close(); } if (br != null) { br.close(); } } catch (IOException e) { throw new RuntimeException(e); } return sb.toString(); } }
java 17若依：3.8.9maven仓库链接：https://central.sonatype.com/artifact/org.redisson/redisson一、引入maven依赖<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.45.1</version> </dependency>二.添加工具类RedisLock.javapackage com.ruoyi.common.core.redis; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; /** * redis锁工具类 * * @author ruoyi */ @Component public class RedisLock { @Autowired private RedissonClient redissonClient; /** * 获取锁 * * @param lockKey 锁实例key * @return 锁信息 */ public RLock getRLock(String lockKey) { return redissonClient.getLock(lockKey); } /** * 加锁 * * @param lockKey 锁实例key * @return 锁信息 */ public RLock lock(String lockKey) { RLock lock = getRLock(lockKey); lock.lock(); return lock; } /** * 加锁 * * @param lockKey 锁实例key * @param leaseTime 上锁后自动释放锁时间 * @return true=成功；false=失败 */ public Boolean tryLock(String lockKey, long leaseTime) { return tryLock(lockKey, 0, leaseTime, TimeUnit.SECONDS); } /** * 加锁 * * @param lockKey 锁实例key * @param leaseTime 上锁后自动释放锁时间 * @param unit 时间颗粒度 * @return true=加锁成功；false=加锁失败 */ public Boolean tryLock(String lockKey, long leaseTime, TimeUnit unit) { return tryLock(lockKey, 0, leaseTime, unit); } /** * 加锁 * * @param lockKey 锁实例key * @param waitTime 最多等待时间 * @param leaseTime 上锁后自动释放锁时间 * @param unit 时间颗粒度 * @return true=加锁成功；false=加锁失败 */ public Boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit) { RLock rLock = getRLock(lockKey); boolean tryLock = false; try { tryLock = rLock.tryLock(waitTime, leaseTime, unit); } catch (InterruptedException e) { return false; } return tryLock; } /** * 释放锁 * * @param lockKey 锁实例key */ public void unlock(String lockKey) { RLock lock = getRLock(lockKey); lock.unlock(); } /** * 释放锁 * * @param lock 锁信息 */ public void unlock(RLock lock) { lock.unlock(); } } 3、新增配置RedissonConfig.javapackage com.aspire.datasynchron.framework.config; import com.aspire.datasynchron.common.utils.StringUtils; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.redisson.config.SingleServerConfig; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * redisson配置 * * @author ruoyi */ @Configuration public class RedissonConfig { @Value("${spring.redis.host}") private String host; @Value("${spring.redis.port}") private String port; @Value("${spring.redis.password:}") // 允许密码为空 private String password; @Bean(destroyMethod = "shutdown") @ConditionalOnMissingBean(RedissonClient.class) public RedissonClient redissonClient() { Config config = new Config(); String address = "redis://" + host + ":" + port; SingleServerConfig serverConfig = config.useSingleServer() .setAddress(address) .setDatabase(1); if (StringUtils.hasText(password)) { serverConfig.setPassword(password); } return Redisson.create(config); } } 4、示例@Slf4j @Component("statisticalDataTask") public class StatisticalDataTask { @Autowired private IStatisticalDataService statisticalDataService; // 引入RedisLock @Autowired private RedisLock redisLock; /* * 轮询所有上游接口获取数据并保存到数据库中并加密 */ public void rollPoling() { String lockKey = "statisticalDataLock:" + "rollPoling"; RLock rLock = redisLock.getRLock(lockKey); try { // 尝试获取锁，等待时间为 3 秒，锁超时时间为 100 秒 if (rLock.tryLock(3, 100, TimeUnit.SECONDS)) { try { log.info("轮询所有上游接口定时任务开始执行"); statisticalDataService.rollPoling(); log.info("轮询所有上游接口定时任务执行结束"); } finally { // 确保在完成后释放锁 if (rLock.isHeldByCurrentThread()) { rLock.unlock(); } } } else { log.error("轮询所有上游接口定时任务, 获取锁失败"); } } catch (Exception e) { log.error("轮询所有上游接口定时任务执行异常：{}", e); } } }参考文献：https://blog.csdn.net/weixin_56404155/article/details/145880379
Java 使用freemarker生成html技术方案java 1.8 + Freemarkermaven依赖<dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.31</version> </dependency>创建模板文件，保存至src/main/resources/templates/，文件名为template.html ${name}的简历 ${name}的简历 联系信息 邮箱: ${email} 电话: ${phone} 工作经历 <#list experience as job> ${job.position} 在 ${job.company}（${job.startYear} - ${job.endYear}） ${job.description} </#list> 教育背景 <#list education as degree> ${degree.degree} ${degree.school}（${degree.year}年） </#list> 实现代码package com.ruoyi.system.utils; import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateException; import java.io.*; import java.util.Map; /** * @Program: RuoYi-master * @ClassName: GenerateHtmlUtil * @author: zhouzihao * @date: 2025年2月20日, 0020 下午 03:33 * @version: 1.0.0 * @Description: * @Time： 2025-02-20 15:33 */ public class GenerateHtmlUtil { // 模板路径 private static final String templatesPath = "/templates"; // 模板文件 private static final String templatesFile = "template.html"; public static void generateHtml(Map<String, Object> data, String filePName) { // 配置 Freemarker Configuration cfg = new Configuration(Configuration.VERSION_2_3_30); cfg.setClassForTemplateLoading(GenerateHtmlUtil.class, templatesPath); // 获取 HTML 模板 try { Template template = cfg.getTemplate(templatesFile); // 5. 创建输出流 try (Writer writer = new FileWriter(new File(filePName))) { // 6. 合并模板和数据模型，生成 HTML 文件 template.process(data, writer); System.out.println("HTML file generated successfully!"); } catch (IOException | TemplateException e) { e.printStackTrace(); } } catch (IOException e) { throw new RuntimeException(e); } } }
Java 使用docx4j+ Freemarker生成word文档技术方案java 1.8 + docx4j + Freemarkermaven依赖<dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.31</version> </dependency> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j</artifactId> <version>6.1.2</version> </dependency> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-ImportXHTML</artifactId> <version>6.1.0</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>1.7.36</version> </dependency>创建模板文件，保存至src/main/resources/templates/，文件名为template.html ${name}的简历 ${name}的简历 联系信息 邮箱: ${email} 电话: ${phone} 工作经历 <#list experience as job> ${job.position} 在 ${job.company}（${job.startYear} - ${job.endYear}） ${job.description} </#list> 教育背景 <#list education as degree> ${degree.degree} ${degree.school}（${degree.year}年） </#list> 实现代码package com.ruoyi.system.utils; import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateException; import org.docx4j.Docx4J; import org.docx4j.convert.in.xhtml.XHTMLImporterImpl; import org.docx4j.openpackaging.exceptions.Docx4JException; import org.docx4j.openpackaging.exceptions.InvalidFormatException; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart; import java.io.File; import java.io.IOException; import java.io.StringWriter; import java.util.Map; /** * @Program: RuoYi-master * @ClassName: GenerateWord * @author: zhouzihao * @date: 2025年2月20日, 0020 下午 03:25 * @version: 1.0.0 * @Description: * @Time： 2025-02-20 15:25 */ public class GenerateWordUtil { // 模板路径 private static final String templatesPath = "/templates"; // 模板文件 private static final String templatesFile = "template.html"; public static void generateWord(Map<String, Object> data, String filePName) { // 配置 Freemarker Configuration cfg = new Configuration(Configuration.VERSION_2_3_30); cfg.setClassForTemplateLoading(GenerateWordUtil.class, templatesPath); // 获取 HTML 模板 try { Template template = cfg.getTemplate(templatesFile); // 使用 Freemarker 填充模板 StringWriter writer = new StringWriter(); template.process(data, writer); String htmlContent = writer.toString(); // 创建 Word 文档包 WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.createPackage(); MainDocumentPart mainDocumentPart = wordMLPackage.getMainDocumentPart(); // 创建 XHTML 导入器实现类实例 XHTMLImporterImpl xhtmlImporter = new XHTMLImporterImpl(wordMLPackage); // 将 HTML 内容导入到 Word 文档中 java.util.List<Object> convertedXHTML = xhtmlImporter.convert(htmlContent, null); // 将转换后的内容添加到文档主体中 mainDocumentPart.getContent().addAll(convertedXHTML); // 保存生成的 Word 文档 File outputFile = new File(filePName); Docx4J.save(wordMLPackage, outputFile, Docx4J.FLAG_NONE); System.out.println("Word 文档生成成功：" + outputFile.getAbsolutePath()); } catch (IOException e) { throw new RuntimeException(e); } catch (TemplateException e) { throw new RuntimeException(e); } catch (InvalidFormatException e) { throw new RuntimeException(e); } catch (Docx4JException e) { throw new RuntimeException(e); } } }
编写单元测试工具：idea插件：Squaretest一、安装Squaretest插件File——>Settings——>Plugins，搜索Squaretest，然后install就好了，插件安装完成后需要重启一下二、激活​ 免费试用三十天三、使用教程1. 打开你的类，光标定位到代码里，右击鼠标选择Generate…2. 选择生成单元测试3. 选择第二项后就会弹出一个框看下面这里它自动会识别出当前类需要Mock的成员变量，直接点ok生成后效果4. 生成后文件路径5. 查询覆盖率