摘要本文介绍了一种基于 Java 的 Silk V3 音频编解码实现方案。该方案通过封装腾讯官方 Silk V3 编解码工具，实现了纯 Java 环境下的 WAV、PCM 和 Silk 格式音频的相互转换。方案采用完全对称的API设计，支持跨平台部署，并提供了统一的命名规范，可应用于即时通讯、语音消息处理等场景。一、背景介绍1.1 Silk 编解码器简介Silk 是由 Skype 开发、后被腾讯广泛应用的语音编解码器，具有以下特点：高压缩比：相比 PCM 格式可实现 20-30 倍的压缩；语音优化：专门针对人声频率范围进行优化；低延迟：适合实时语音通信场景；自适应码率：根据网络状况动态调整比特率。1.2 技术挑战Silk V3 官方实现为 C/C++ 代码，在 Java 环境中直接使用面临以下挑战：JNI 集成复杂：需要针对不同平台编译原生库；跨平台部署困难：Windows、Linux、macOS 需要分别处理；依赖管理复杂：第三方 Java 封装库存在版本兼容性问题；API 不统一：现有方案缺乏清晰的命名规范。二、技术方案2.1 整体架构本方案采用外部工具调用方式，通过 Java 进程管理机制调用 Silk V3 编解码工具，避免了 JNI 集成的复杂性。架构分为三层：工具层：Silk V3 编解码可执行文件；编解码层：SilkCodec 类封装编解码逻辑；转换层：AudioConverter 类提供高级音频转换接口。2.2 核心技术方案使用的主要技术包括：Java NIO：文件读写和临时文件管理；ProcessBuilder：外部进程调用和输出流管理；Java Sound API：WAV 音频文件解析和生成；自定义WAV解析器：处理大文件和特殊格式；ClassLoader：从 JAR 包中提取工具文件；平台检测：自动识别操作系统并选择对应工具。2.3 API 设计原则本方案采用以下API设计原则：完全对称：每种转换都支持 File→File、File→Bytes、Bytes→File、Bytes→Bytes 四种组合；统一命名：采用 {源格式}{源类型}To{目标格式}{目标类型} 格式；无重载：每个方法名唯一，避免调用歧义；固定采样率：统一使用 24kHz（微信标准）；明确后缀：时长计算方法使用 ByFile 和 ByBytes 后缀。三、实现细节3.1 工具管理3.1.1 工具提取机制系统启动时，从 JAR 包的 classpath 中提取 Silk 编解码工具到临时目录：private static File extractToolFromClasspath(String resourcePath, String executable) { InputStream is = SilkCodec.class.getClassLoader().getResourceAsStream(resourcePath); if (is == null) { return null; } if (toolsExtractDir == null) { synchronized (SilkCodec.class) { if (toolsExtractDir == null) { toolsExtractDir = Files.createTempDirectory("silk_tools_").toFile(); toolsExtractDir.deleteOnExit(); } } } File toolFile = new File(toolsExtractDir, executable); if (!toolFile.exists()) { Files.copy(is, toolFile.toPath(), StandardCopyOption.REPLACE_EXISTING); toolFile.setExecutable(true); toolFile.deleteOnExit(); } is.close(); return toolFile; }3.1.2 平台自适应根据操作系统类型选择对应的工具文件：private static String getToolPath(String tool) throws IOException { String os = System.getProperty("os.name").toLowerCase(); String toolsDir; String executable; if (os.contains("win")) { toolsDir = "windows"; executable = tool.equals("encoder") ? "silk_v3_encoder.exe" : "silk_v3_decoder.exe"; } else if (os.contains("linux")) { toolsDir = "linux"; executable = tool; } else if (os.contains("mac")) { toolsDir = "macos"; executable = tool; } else { throw new IOException("不支持的操作系统！"); } String resourcePath = "tools/" + toolsDir + "/" + executable; File extractedTool = extractToolFromClasspath(resourcePath, executable); if (extractedTool != null && extractedTool.exists()) { return extractedTool.getAbsolutePath(); } throw new IOException("找不到工具: " + executable); }3.2 Silk 编码实现3.2.1 编码流程PCM 数据编码为 Silk 格式的核心流程如下：public static byte[] encode(byte[] pcmData, int sampleRate) throws IOException { Path tempPcm = null; Path tempSilk = null; try { // 创建临时文件 tempPcm = Files.createTempFile("silk_encode_", ".pcm"); tempSilk = Files.createTempFile("silk_encode_", ".silk"); // 写入 PCM 数据 Files.write(tempPcm, pcmData); // 调用编码器 String encoderPath = getToolPath("encoder"); ProcessBuilder pb = new ProcessBuilder( encoderPath, tempPcm.toAbsolutePath().toString(), tempSilk.toAbsolutePath().toString(), "-Fs_API", String.valueOf(sampleRate), "-rate", "25000", "-tencent" ); pb.redirectErrorStream(true); Process process = pb.start(); // 后台线程读取输出，避免死锁 Thread outputReader = new Thread(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { // 静默处理输出 } } catch (IOException e) { // 忽略 } }); outputReader.start(); int exitCode = process.waitFor(); outputReader.join(1000); if (exitCode != 0) { throw new IOException("Silk 编码失败！"); } // 读取结果 return Files.readAllBytes(tempSilk); } finally { // 清理临时文件 if (tempPcm != null) Files.deleteIfExists(tempPcm); if (tempSilk != null) Files.deleteIfExists(tempSilk); } }3.2.2 进程死锁问题修复早期实现中遇到进程死锁问题，原因是编码器输出缓冲区满导致进程阻塞。解决方案：在调用 waitFor() 之前启动后台线程读取输出流；持续读取直到流结束；确保主线程等待时输出缓冲区不会满。3.3 Silk 解码实现3.3.1 解码流程Silk 数据解码为 PCM 格式的核心流程：public static byte[] decode(byte[] silkData, int sampleRate) throws IOException { Path tempSilk = null; Path tempPcm = null; try { // 创建临时文件 tempSilk = Files.createTempFile("silk_decode_", ".silk"); tempPcm = Files.createTempFile("silk_decode_", ".pcm"); // 写入 Silk 数据 Files.write(tempSilk, silkData); // 调用解码器 String decoderPath = getToolPath("decoder"); ProcessBuilder pb = new ProcessBuilder( decoderPath, tempSilk.toAbsolutePath().toString(), tempPcm.toAbsolutePath().toString(), "-Fs_API", String.valueOf(sampleRate) ); pb.redirectErrorStream(true); Process process = pb.start(); // 后台线程读取输出 Thread outputReader = new Thread(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { // 静默处理输出 } } catch (IOException e) { // 忽略 } }); outputReader.start(); int exitCode = process.waitFor(); outputReader.join(1000); if (exitCode != 0) { throw new IOException("Silk 解码失败！"); } // 读取结果 return Files.readAllBytes(tempPcm); } finally { // 清理临时文件 if (tempSilk != null) Files.deleteIfExists(tempSilk); if (tempPcm != null) Files.deleteIfExists(tempPcm); } }3.4 WAV 格式转换3.4.1 自定义 WAV 解析器为了处理大文件和特殊格式（如 0xFFFFFFFF chunk size），实现了自定义 WAV 解析器：private static byte[] extractPcmFromWav(String wavFile, int targetSampleRate) throws IOException { try (RandomAccessFile raf = new RandomAccessFile(wavFile, "r")) { long fileLength = raf.length(); // 读取 RIFF 头 byte[] riffHeader = new byte[12]; if (raf.read(riffHeader) < 12) { throw new IOException("文件太小，不是有效的WAV文件！"); } // 验证 RIFF 和 WAVE 标识 String riff = new String(riffHeader, 0, 4, "ASCII"); String wave = new String(riffHeader, 8, 4, "ASCII"); if (!"RIFF".equals(riff) || !"WAVE".equals(wave)) { throw new IOException("不是有效的WAV文件！"); } // 查找 fmt chunk 和 data chunk int channels = 0; int sourceSampleRate = 0; int bitsPerSample = 0; long dataChunkPos = 0; long dataChunkSize = 0; while (raf.getFilePointer() < fileLength - 8) { long currentPos = raf.getFilePointer(); byte[] chunkHeader = new byte[8]; int bytesRead = raf.read(chunkHeader); if (bytesRead < 8) break; String chunkId = new String(chunkHeader, 0, 4, "ASCII"); long chunkSize = ((chunkHeader[4] & 0xFFL)) | ((chunkHeader[5] & 0xFFL) << 8) | ((chunkHeader[6] & 0xFFL) << 16) | ((chunkHeader[7] & 0xFFL) << 24); // 处理特殊的 chunk 大小值 if (chunkSize == 0xFFFFFFFFL) { chunkSize = fileLength - currentPos - 8; } if ("fmt ".equals(chunkId)) { // 读取格式信息 byte[] fmtData = new byte[16]; raf.readFully(fmtData); channels = ((fmtData[2] & 0xFF)) | ((fmtData[3] & 0xFF) << 8); sourceSampleRate = ((fmtData[4] & 0xFF)) | ((fmtData[5] & 0xFF) << 8) | ((fmtData[6] & 0xFF) << 16) | ((fmtData[7] & 0xFF) << 24); bitsPerSample = ((fmtData[14] & 0xFF)) | ((fmtData[15] & 0xFF) << 8); if (chunkSize > 16) { raf.skipBytes((int)(chunkSize - 16)); } } else if ("data".equals(chunkId)) { dataChunkPos = raf.getFilePointer(); dataChunkSize = chunkSize; if (dataChunkSize > fileLength - dataChunkPos) { dataChunkSize = fileLength - dataChunkPos; } break; } else { long skipSize = chunkSize; if (currentPos + 8 + skipSize > fileLength) { skipSize = fileLength - currentPos - 8; } if (skipSize > 0) { raf.skipBytes((int)skipSize); } } } // 读取 PCM 数据 raf.seek(dataChunkPos); byte[] pcmData = new byte[(int)dataChunkSize]; raf.readFully(pcmData); // 转换为目标格式：16位单声道 return convertPcmFormat(pcmData, channels, sourceSampleRate, bitsPerSample, targetSampleRate); } }3.4.2 格式转换支持多种格式自动转换：private static byte[] convertPcmFormat(byte[] data, int channels, int sourceSampleRate, int bitsPerSample, int targetSampleRate) { // 转换为单声道 if (channels == 2) { data = stereoToMono(data, bitsPerSample); } // 转换位深度为 16 位 if (bitsPerSample == 8) { data = convert8to16bit(data); } else if (bitsPerSample == 24) { data = convert24to16bit(data); } else if (bitsPerSample == 32) { data = convert32to16bit(data); } // 重采样 if (sourceSampleRate != targetSampleRate) { data = resample(data, sourceSampleRate, targetSampleRate); } return data; }3.5 统一API接口3.5.1 命名规范所有转换方法遵循统一命名规范：{源格式}{源类型}To{目标格式}{目标类型}示例：wavFileToPcmFile - WAV文件转PCM文件；wavFileToPcmBytes - WAV文件转PCM字节；wavBytesToPcmFile - WAV字节转PCM文件；wavBytesToPcmBytes - WAV字节转PCM字节。3.5.2 完整API列表转换方法（24个）：WAV → PCM (4个)public static void wavFileToPcmFile(String wavFile, String pcmFile); public static byte[] wavFileToPcmBytes(String wavFile); public static void wavBytesToPcmFile(byte[] wavData, String pcmFile); public static byte[] wavBytesToPcmBytes(byte[] wavData);WAV → Silk (4个)public static void wavFileToSilkFile(String wavFile, String silkFile); public static byte[] wavFileToSilkBytes(String wavFile); public static void wavBytesToSilkFile(byte[] wavData, String silkFile); public static byte[] wavBytesToSilkBytes(byte[] wavData);PCM → WAV (4个)public static void pcmFileToWavFile(String pcmFile, String wavFile); public static byte[] pcmFileToWavBytes(String pcmFile); public static void pcmBytesToWavFile(byte[] pcmData, String wavFile); public static byte[] pcmBytesToWavBytes(byte[] pcmData);PCM → Silk (4个)public static void pcmFileToSilkFile(String pcmFile, String silkFile); public static byte[] pcmFileToSilkBytes(String pcmFile); public static void pcmBytesToSilkFile(byte[] pcmData, String silkFile); public static byte[] pcmBytesToSilkBytes(byte[] pcmData);Silk → WAV (4个)public static void silkFileToWavFile(String silkFile, String wavFile); public static byte[] silkFileToWavBytes(String silkFile); public static void silkBytesToWavFile(byte[] silkData, String wavFile); public static byte[] silkBytesToWavBytes(byte[] silkData);Silk → PCM (4个)public static void silkFileToPcmFile(String silkFile, String pcmFile); public static byte[] silkFileToPcmBytes(String silkFile); public static void silkBytesToPcmFile(byte[] silkData, String pcmFile); public static byte[] silkBytesToPcmBytes(byte[] silkData);时长计算方法（6个）：public static int getPcmDurationByFile(String pcmFile); public static int getPcmDurationByBytes(byte[] pcmData); public static int getWavDurationByFile(String wavFile); public static int getWavDurationByBytes(byte[] wavData); public static int getSilkDurationByFile(String silkFile); public static int getSilkDurationByBytes(byte[] silkData);3.5.3 API完整性矩阵转换类型File→FileFile→BytesBytes→FileBytes→BytesWAV→PCM支持支持支持支持WAV→Silk支持支持支持支持PCM→WAV支持支持支持支持PCM→Silk支持支持支持支持Silk→WAV支持支持支持支持Silk→PCM支持支持支持支持四、性能与优化4.1 压缩效果实际测试数据显示：原始 WAV 文件：127 KB，约 2.6 秒音频，24000 Hz；转换后 Silk 文件：7.5 KB；压缩比：17 倍；压缩率：约 96%。压缩比分析：Silk 编码压缩：PCM 到 Silk 压缩约 17 倍；音质损失：有损压缩，但语音质量满足通信需求；带宽节省：显著降低网络传输和存储成本。4.2 性能指标基于标准测试（127 KB WAV 文件，约 2.6 秒音频，24000 Hz）：WAV → PCM 提取：~50 ms；PCM → Silk 编码：~700 ms；Silk → PCM 解码：~350 ms；PCM → WAV 转换：~20 ms；完整 WAV → Silk：~750 ms；完整 Silk → WAV：~370 ms；内存占用：< 10 MB；4.3 优化措施系统采用的优化策略包括：后台线程读取输出：解决进程死锁问题，提升编码速度；自定义WAV解析器：支持大文件和特殊格式，避免 Java AudioSystem 限制；临时文件复用：同步机制确保工具提取目录唯一性；资源自动清理：使用 deleteOnExit 确保临时文件清理；固定采样率：统一使用 24kHz，简化参数管理；完全对称API：提供一致的接口设计，降低学习成本。五、跨平台支持5.1 支持的平台方案已实现以下平台支持：Windows：x86、x64 架构；Linux：x64、ARM64 架构；macOS：Intel、Apple Silicon 架构。5.2 部署方式工具文件按平台组织：tools/ ├── windows/ │ ├── silk_v3_encoder.exe │ └── silk_v3_decoder.exe ├── linux/ │ ├── encoder │ └── decoder └── macos/ ├── encoder └── decoderMaven 打包配置：<resource> <directory>tools</directory> <targetPath>tools</targetPath> <includes> <include>**/*.exe</include> <include>**/*</include> </includes> </resource>5.3 自动化部署工具文件打包到 JAR 后，运行时自动处理：自动检测操作系统类型；从 JAR 包中提取对应平台的工具；设置可执行权限（Unix 系列系统）；缓存到临时目录避免重复提取。六、使用示例6.1 文件格式转换WAV 转 Silk：// 基本转换 AudioConverter.wavFileToSilkFile("input.wav", "output.silk"); // 获取字节数组 byte[] silkData = AudioConverter.wavFileToSilkBytes("input.wav");Silk 转 WAV：// 基本转换 AudioConverter.silkFileToWavFile("input.silk", "output.wav"); // 从字节数组 byte[] silkData = Files.readAllBytes(Paths.get("input.silk")); AudioConverter.silkBytesToWavFile(silkData, "output.wav");6.2 字节数组操作（网络传输）TTS API 返回 WAV，转换为 Silk 发送到微信：// TTS API 返回 WAV 字节 byte[] ttsWavData = ttsAPI.synthesize("你好"); // 转换为 Silk byte[] silkData = AudioConverter.wavBytesToSilkBytes(ttsWavData); // Base64 编码用于传输 String base64Silk = Base64.getEncoder().encodeToString(silkData); // 获取时长 int duration = AudioConverter.getSilkDurationByBytes(silkData); System.out.println("时长: " + duration + " 秒");从微信接收 Silk，转换为 WAV 播放：// 从微信接收 Base64 String base64Silk = wechatMessage.getVoiceData(); // 解码 byte[] silkData = Base64.getDecoder().decode(base64Silk); // 转换为 WAV byte[] wavData = AudioConverter.silkBytesToWavBytes(silkData); // 播放或保存 Files.write(Paths.get("received.wav"), wavData);6.3 混合使用从文件读取，转换为字节数组用于网络传输：// 读取本地文件 byte[] silkData = AudioConverter.wavFileToSilkBytes("local.wav"); // 发送到服务器 sendToServer(silkData);从网络接收字节数组，保存为文件：// 从服务器接收 byte[] receivedSilk = receiveFromServer(); // 保存为文件 AudioConverter.silkBytesToWavFile(receivedSilk, "received.wav");6.4 获取音频信息计算音频时长：// 文件时长 int wavDuration = AudioConverter.getWavDurationByFile("audio.wav"); int silkDuration = AudioConverter.getSilkDurationByFile("voice.silk"); int pcmDuration = AudioConverter.getPcmDurationByFile("audio.pcm"); System.out.println("WAV 时长: " + wavDuration + " 秒"); System.out.println("Silk 时长: " + silkDuration + " 秒"); System.out.println("PCM 时长: " + pcmDuration + " 秒"); // 字节数组时长 byte[] pcmData = AudioConverter.wavFileToPcmBytes("audio.wav"); int duration = AudioConverter.getPcmDurationByBytes(pcmData); System.out.println("音频时长: " + duration + " 秒");6.5 批量转换Path inputDir = Paths.get("input"); Path outputDir = Paths.get("output"); Files.createDirectories(outputDir); Files.list(inputDir) .filter(p -> p.toString().endsWith(".wav")) .forEach(wavPath -> { try { String wavFile = wavPath.toString(); String silkFile = outputDir.resolve( wavPath.getFileName().toString().replace(".wav", ".silk") ).toString(); AudioConverter.wavFileToSilkFile(wavFile, silkFile); int duration = AudioConverter.getSilkDurationByFile(silkFile); System.out.println("已转换: " + wavPath.getFileName() + " (时长: " + duration + "秒)"); } catch (IOException e) { System.err.println("转换失败: " + wavPath.getFileName()); e.printStackTrace(); } });七、技术对比7.1 与其他方案对比本方案与常见实现方式的对比：方案跨平台性API设计依赖管理音质大文件支持纯 Java 简化实现优秀一般无差有限JNI 集成官方库困难一般复杂优秀良好第三方 Maven 库优秀不统一可能失效优秀有限本方案 v1.2.0良好优秀无优秀优秀7.2 方案优势本方案的主要优势：音质保证：使用腾讯官方 Silk V3 工具，确保编解码质量；API统一：完全对称的设计，清晰的命名规范；部署简单：工具打包到 JAR，无需额外配置；依赖独立：不依赖外部 Maven 仓库，避免版本冲突；大文件支持：自定义WAV解析器，支持特殊格式；易于维护：架构清晰，调试和问题定位容易；固定采样率：统一使用24kHz，简化参数管理。7.3 适用场景方案特别适合以下场景：即时通讯应用的语音消息处理；与微信等平台的语音格式对接；语音数据存储和传输；TTS语音合成结果转换；需要跨平台部署的 Java 应用；需要统一API接口的项目。八、总结与展望8.1 技术总结本文介绍的 Java Silk 音频编解码方案 v1.2.0，通过封装官方工具实现了高质量的语音编解码功能。方案具有以下特点：技术成熟：基于腾讯官方 Silk V3 工具；API优秀：完全对称的设计，统一的命名规范；接口简洁：30个方法覆盖所有转换组合；性能优秀：压缩比达到 17 倍，编解码速度满足实时需求；跨平台：支持 Windows、Linux、macOS 主流平台；大文件支持：自定义WAV解析器，处理特殊格式。8.2 v1.2.0 核心改进相比早期版本，v1.2.0 的核心改进包括：完全重构API：统一命名规范，去除所有重载方法；完全对称设计：每种转换都支持4种组合；固定采样率：统一使用24kHz，简化参数；时长方法重命名：添加 ByFile 和 ByBytes 后缀；修复进程死锁：后台线程读取输出；自定义WAV解析器：支持大文件和特殊格式；自动格式转换：支持8/16/24/32位、单/双声道。8.3 未来优化方向后续可考虑的优化方向包括：性能优化：实现工具进程池，减少进程创建开销；缓存机制：对常用转换结果进行缓存；流式处理：支持音频流的实时编解码；格式扩展：增加对 MP3、AAC 等格式的支持；监控指标：添加编解码时间、成功率等监控；异步API：提供异步转换接口。8.4 应用前景随着语音交互技术的发展，高效的音频编解码技术将有更广泛的应用场景。本方案为 Java 开发者提供了一个可靠、易用的 Silk 编解码解决方案，可应用于智能客服、语音助手、在线教育、远程会议等多个领域。统一的API设计和完全对称的接口，使得开发者能够快速上手并高效开发，显著降低了集成成本和维护难度。参考资源Silk V3 Decoder 项目：https://github.com/kn007/silk-v3-decoder；Java Sound API 文档：https://docs.oracle.com/javase/8/docs/technotes/guides/sound/；ProcessBuilder 官方文档：https://docs.oracle.com/javase/8/docs/api/java/lang/ProcessBuilder.html；
泛微OA对接飞书多维表格HTTP请求工具类
目前有个需求是把泛微OA的需求台账同步到飞书多维表格里面，虽然飞书官方封装了对应的SDK，但是泛微OA只能通过本地jar包的形式导入，多有不便，并且SDK的文档也不全过于分散（可能是我没找到具体的点）。只能自己造一个轮子了，把常用的接口都封装了一下：[collapse status="true" title="FeiShuHttpService.java"]package weaver.interfaces.push.feishu; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import com.alibaba.fastjson.JSONObject; import java.util.HashMap; import java.util.Map; /** * @Author zF. * @ClassName FeiShuHttpService.java * @ProjectName ecology * @Description 飞书多维表格HTTP请求工具类 - 基于HTTP API实现多维表格的增删改查操作 */ public class FeiShuHttpService { // 飞书API基础URL private static final String FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"; // 缓存访问令牌 private static volatile String cachedAccessToken; private static volatile long tokenExpireTime = 0; /** * 获取访问令牌 * * @return 访问令牌 */ public static String getAccessToken() { // 检查缓存是否有效 if (StrUtil.isNotBlank(cachedAccessToken) && System.currentTimeMillis() < tokenExpireTime) { return cachedAccessToken; } try { Map<String, String> config = FeiShuConfig.getAppConfig(); String appId = config.get("app_id"); String appSecret = config.get("app_secret"); if (StrUtil.isBlank(appId) || StrUtil.isBlank(appSecret)) { throw new RuntimeException("飞书应用配置不完整"); } String url = FEISHU_BASE_URL + "/auth/v3/tenant_access_token/internal"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("app_id", appId); requestBody.put("app_secret", appSecret); HttpResponse response = HttpRequest.post(url) .header("Content-Type", "application/json; charset=utf-8") .body(JSONObject.toJSONString(requestBody)) .timeout(30000) .execute(); if (response.getStatus() == 200) { JSONObject result = JSONObject.parseObject(response.body()); if (result.getIntValue("code") == 0) { cachedAccessToken = result.getString("tenant_access_token"); // 设置过期时间（提前5分钟过期） int expire = result.getIntValue("expire"); tokenExpireTime = System.currentTimeMillis() + (expire - 300) * 1000L; System.out.println("飞书访问令牌获取成功"); return cachedAccessToken; } else { throw new RuntimeException("获取访问令牌失败: " + result.getString("msg")); } } else { throw new RuntimeException("获取访问令牌HTTP请求失败: " + response.getStatus()); } } catch (Exception e) { System.out.println("获取飞书访问令牌异常: " + e.getMessage()); e.printStackTrace(); throw new RuntimeException("获取访问令牌异常: " + e.getMessage(), e); } } /** * 发送HTTP请求的通用方法 * * @param method 请求方法 * @param url 请求URL * @param requestBody 请求体 * @return 响应结果 */ private static JSONObject sendHttpRequest(String method, String url, Object requestBody) { try { HttpRequest request; if ("GET".equalsIgnoreCase(method)) { request = HttpRequest.get(url); } else if ("POST".equalsIgnoreCase(method)) { request = HttpRequest.post(url); } else if ("PUT".equalsIgnoreCase(method)) { request = HttpRequest.put(url); } else if ("DELETE".equalsIgnoreCase(method)) { request = HttpRequest.delete(url); } else if ("PATCH".equalsIgnoreCase(method)) { request = HttpRequest.patch(url); } else { throw new IllegalArgumentException("不支持的HTTP方法: " + method); } // 添加请求头 request.header("Authorization", "Bearer " + getAccessToken()) .header("Content-Type", "application/json; charset=utf-8") .timeout(30000); // 添加请求体 if (requestBody != null) { request.body(JSONObject.toJSONString(requestBody)); } HttpResponse response = request.execute(); JSONObject result = new JSONObject(); result.put("httpStatus", response.getStatus()); result.put("body", response.body()); if (response.getStatus() == 200) { JSONObject responseJson = JSONObject.parseObject(response.body()); result.put("code", responseJson.getIntValue("code")); result.put("msg", responseJson.getString("msg")); result.put("data", responseJson.get("data")); result.put("success", responseJson.getIntValue("code") == 0); } else { result.put("success", false); result.put("msg", "HTTP请求失败: " + response.getStatus()); } return result; } catch (Exception e) { System.out.println("HTTP请求异常: " + e.getMessage()); e.printStackTrace(); JSONObject result = new JSONObject(); result.put("success", false); result.put("msg", "请求异常: " + e.getMessage()); return result; } } // ==================== 多维表格应用管理 ==================== /** * 创建多维表格应用 * * @param name 应用名称 * @return 创建结果 */ public static JSONObject createApp(String name) { try { if (StrUtil.isBlank(name)) { return createErrorResult(-1, "应用名称不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("name", name); System.out.println("创建飞书多维表格应用: " + name); return sendHttpRequest("POST", url, requestBody); } catch (Exception e) { System.out.println("创建飞书多维表格应用异常: " + e.getMessage()); return createErrorResult(-1, "创建应用异常: " + e.getMessage()); } } /** * 复制多维表格应用 * * @param appToken 源应用Token * @param name 新应用名称 * @return 复制结果 */ public static JSONObject copyApp(String appToken, String name) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(name)) { return createErrorResult(-1, "应用Token和名称不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/copy"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("name", name); System.out.println("复制飞书多维表格应用: " + appToken + " -> " + name); return sendHttpRequest("POST", url, requestBody); } catch (Exception e) { System.out.println("复制飞书多维表格应用异常: " + e.getMessage()); return createErrorResult(-1, "复制应用异常: " + e.getMessage()); } } /** * 获取多维表格应用信息 * * @param appToken 应用Token * @return 应用信息 */ public static JSONObject getApp(String appToken) { try { if (StrUtil.isBlank(appToken)) { return createErrorResult(-1, "应用Token不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken; System.out.println("获取飞书多维表格应用信息: " + appToken); return sendHttpRequest("GET", url, null); } catch (Exception e) { System.out.println("获取飞书多维表格应用信息异常: " + e.getMessage()); return createErrorResult(-1, "获取应用信息异常: " + e.getMessage()); } } /** * 更新多维表格应用 * * @param appToken 应用Token * @param name 新名称 * @return 更新结果 */ public static JSONObject updateApp(String appToken, String name) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(name)) { return createErrorResult(-1, "应用Token和名称不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("name", name); System.out.println("更新飞书多维表格应用: " + appToken + " -> " + name); return sendHttpRequest("PUT", url, requestBody); } catch (Exception e) { System.out.println("更新飞书多维表格应用异常: " + e.getMessage()); return createErrorResult(-1, "更新应用异常: " + e.getMessage()); } } // ==================== 多维表格管理 ==================== /** * 创建多维表格 * * @param appToken 应用Token * @param tableName 表格名称 * @return 创建结果 */ public static JSONObject createTable(String appToken, String tableName) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableName)) { return createErrorResult(-1, "应用Token和表格名称不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("table", new HashMap<String, Object>() {{ put("name", tableName); }}); System.out.println("创建飞书多维表格: " + appToken + " -> " + tableName); return sendHttpRequest("POST", url, requestBody); } catch (Exception e) { System.out.println("创建飞书多维表格异常: " + e.getMessage()); return createErrorResult(-1, "创建表格异常: " + e.getMessage()); } } /** * 批量创建多维表格 * * @param appToken 应用Token * @param tables 表格列表 * @return 创建结果 */ public static JSONObject batchCreateTables(String appToken, Object[] tables) { try { if (StrUtil.isBlank(appToken) || tables == null || tables.length == 0) { return createErrorResult(-1, "应用Token和表格列表不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/batch_create"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("tables", tables); System.out.println("批量创建飞书多维表格: " + appToken + " -> " + tables.length + "个表格"); return sendHttpRequest("POST", url, requestBody); } catch (Exception e) { System.out.println("批量创建飞书多维表格异常: " + e.getMessage()); return createErrorResult(-1, "批量创建表格异常: " + e.getMessage()); } } /** * 更新多维表格 * * @param appToken 应用Token * @param tableId 表格ID * @param tableName 新表格名称 * @return 更新结果 */ public static JSONObject updateTable(String appToken, String tableId, String tableName) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || StrUtil.isBlank(tableName)) { return createErrorResult(-1, "应用Token、表格ID和名称不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("table", new HashMap<String, Object>() {{ put("name", tableName); }}); System.out.println("更新飞书多维表格: " + appToken + " -> " + tableId + " -> " + tableName); return sendHttpRequest("PATCH", url, requestBody); } catch (Exception e) { System.out.println("更新飞书多维表格异常: " + e.getMessage()); return createErrorResult(-1, "更新表格异常: " + e.getMessage()); } } /** * 获取多维表格列表 * * @param appToken 应用Token * @param pageSize 每页大小 * @param pageToken 分页Token * @return 表格列表 */ public static JSONObject listTables(String appToken, Integer pageSize, String pageToken) { try { if (StrUtil.isBlank(appToken)) { return createErrorResult(-1, "应用Token不能为空"); } StringBuilder url = new StringBuilder(FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables"); boolean hasParam = false; if (pageSize != null && pageSize > 0) { url.append(hasParam ? "&" : "?").append("page_size=").append(pageSize); hasParam = true; } if (StrUtil.isNotBlank(pageToken)) { url.append(hasParam ? "&" : "?").append("page_token=").append(pageToken); } System.out.println("获取飞书多维表格列表: " + appToken); return sendHttpRequest("GET", url.toString(), null); } catch (Exception e) { System.out.println("获取飞书多维表格列表异常: " + e.getMessage()); return createErrorResult(-1, "获取表格列表异常: " + e.getMessage()); } } /** * 删除多维表格 * * @param appToken 应用Token * @param tableId 表格ID * @return 删除结果 */ public static JSONObject deleteTable(String appToken, String tableId) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId)) { return createErrorResult(-1, "应用Token和表格ID不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId; System.out.println("删除飞书多维表格: " + appToken + " -> " + tableId); return sendHttpRequest("DELETE", url, null); } catch (Exception e) { System.out.println("删除飞书多维表格异常: " + e.getMessage()); return createErrorResult(-1, "删除表格异常: " + e.getMessage()); } } /** * 批量删除多维表格 * * @param appToken 应用Token * @param tableIds 表格ID列表 * @return 删除结果 */ public static JSONObject batchDeleteTables(String appToken, String[] tableIds) { try { if (StrUtil.isBlank(appToken) || tableIds == null || tableIds.length == 0) { return createErrorResult(-1, "应用Token和表格ID列表不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/batch_delete"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("table_ids", tableIds); System.out.println("批量删除飞书多维表格: " + appToken + " -> " + tableIds.length + "个表格"); return sendHttpRequest("POST", url, requestBody); } catch (Exception e) { System.out.println("批量删除飞书多维表格异常: " + e.getMessage()); return createErrorResult(-1, "批量删除表格异常: " + e.getMessage()); } } // ==================== 记录管理 ==================== /** * 创建记录 * * @param appToken 应用Token * @param tableId 表格ID * @param fields 字段数据 * @return 创建结果 */ public static JSONObject createRecord(String appToken, String tableId, Map<String, Object> fields) { return createRecord(appToken, tableId, fields, null); } /** * 创建记录（带user_id_type参数） * * @param appToken 应用Token * @param tableId 表格ID * @param fields 字段数据 * @param userIdType 用户ID类型 * @return 创建结果 */ public static JSONObject createRecord(String appToken, String tableId, Map<String, Object> fields, String userIdType) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || fields == null) { return createErrorResult(-1, "应用Token、表格ID和字段数据不能为空"); } StringBuilder url = new StringBuilder(FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records"); if (StrUtil.isNotBlank(userIdType)) { url.append("?user_id_type=").append(userIdType); } Map<String, Object> requestBody = new HashMap<>(); requestBody.put("fields", fields); System.out.println("创建飞书多维表格记录: " + appToken + " -> " + tableId + (StrUtil.isNotBlank(userIdType) ? " -> user_id_type=" + userIdType : "")); return sendHttpRequest("POST", url.toString(), requestBody); } catch (Exception e) { System.out.println("创建飞书多维表格记录异常: " + e.getMessage()); return createErrorResult(-1, "创建记录异常: " + e.getMessage()); } } /** * 更新记录 * * @param appToken 应用Token * @param tableId 表格ID * @param recordId 记录ID * @param fields 字段数据 * @return 更新结果 */ public static JSONObject updateRecord(String appToken, String tableId, String recordId, Map<String, Object> fields) { return updateRecord(appToken, tableId, recordId, fields, null); } /** * 更新记录（带user_id_type参数） * * @param appToken 应用Token * @param tableId 表格ID * @param recordId 记录ID * @param fields 字段数据 * @param userIdType 用户ID类型 * @return 更新结果 */ public static JSONObject updateRecord(String appToken, String tableId, String recordId, Map<String, Object> fields, String userIdType) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || StrUtil.isBlank(recordId) || fields == null) { return createErrorResult(-1, "应用Token、表格ID、记录ID和字段数据不能为空"); } StringBuilder url = new StringBuilder(FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records/" + recordId); if (StrUtil.isNotBlank(userIdType)) { url.append("?user_id_type=").append(userIdType); } Map<String, Object> requestBody = new HashMap<>(); requestBody.put("fields", fields); System.out.println("更新飞书多维表格记录: " + appToken + " -> " + tableId + " -> " + recordId + (StrUtil.isNotBlank(userIdType) ? " -> user_id_type=" + userIdType : "")); return sendHttpRequest("PUT", url.toString(), requestBody); } catch (Exception e) { System.out.println("更新飞书多维表格记录异常: " + e.getMessage()); return createErrorResult(-1, "更新记录异常: " + e.getMessage()); } } /** * 搜索记录 * * @param appToken 应用Token * @param tableId 表格ID * @param searchKey 搜索关键字（暂未使用，查询所有记录时传递null即可） * @param pageSize 每页大小 * @param pageToken 分页Token * @param userIdType 用户ID类型 * @return 搜索结果 */ public static JSONObject searchRecords(String appToken, String tableId, String searchKey, Integer pageSize, String pageToken, String userIdType) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId)) { return createErrorResult(-1, "应用Token和表格ID不能为空"); } StringBuilder url = new StringBuilder(FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records/search"); boolean hasParam = false; if (pageSize != null && pageSize > 0) { url.append(hasParam ? "&" : "?").append("page_size=").append(pageSize); hasParam = true; } if (StrUtil.isNotBlank(pageToken)) { url.append(hasParam ? "&" : "?").append("page_token=").append(pageToken); hasParam = true; } if (StrUtil.isNotBlank(userIdType)) { url.append(hasParam ? "&" : "?").append("user_id_type=").append(userIdType); } // 构建请求体 - 根据飞书官方文档，查询所有记录时传递空对象 Map<String, Object> requestBody = new HashMap<>(); // 注意：飞书官方文档中没有search_key参数，查询所有记录时传递空请求体即可 System.out.println("搜索飞书多维表格记录: " + appToken + " -> " + tableId + " -> " + searchKey + (StrUtil.isNotBlank(userIdType) ? " -> user_id_type=" + userIdType : "")); return sendHttpRequest("POST", url.toString(), requestBody); } catch (Exception e) { System.out.println("搜索飞书多维表格记录异常: " + e.getMessage()); return createErrorResult(-1, "搜索记录异常: " + e.getMessage()); } } /** * 获取记录详情 * * @param appToken 应用Token * @param tableId 表格ID * @param recordId 记录ID * @return 记录详情 */ public static JSONObject getRecord(String appToken, String tableId, String recordId) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || StrUtil.isBlank(recordId)) { return createErrorResult(-1, "应用Token、表格ID和记录ID不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records/" + recordId; System.out.println("获取飞书多维表格记录详情: " + appToken + " -> " + tableId + " -> " + recordId); return sendHttpRequest("GET", url, null); } catch (Exception e) { System.out.println("获取飞书多维表格记录详情异常: " + e.getMessage()); return createErrorResult(-1, "获取记录详情异常: " + e.getMessage()); } } /** * 删除记录 * * @param appToken 应用Token * @param tableId 表格ID * @param recordId 记录ID * @return 删除结果 */ public static JSONObject deleteRecord(String appToken, String tableId, String recordId) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || StrUtil.isBlank(recordId)) { return createErrorResult(-1, "应用Token、表格ID和记录ID不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records/" + recordId; System.out.println("删除飞书多维表格记录: " + appToken + " -> " + tableId + " -> " + recordId); return sendHttpRequest("DELETE", url, null); } catch (Exception e) { System.out.println("删除飞书多维表格记录异常: " + e.getMessage()); return createErrorResult(-1, "删除记录异常: " + e.getMessage()); } } /** * 批量创建记录 * * @param appToken 应用Token * @param tableId 表格ID * @param records 记录列表 * @return 创建结果 */ public static JSONObject batchCreateRecords(String appToken, String tableId, Object[] records) { return batchCreateRecords(appToken, tableId, records, null); } /** * 批量创建记录（带user_id_type参数） * * @param appToken 应用Token * @param tableId 表格ID * @param records 记录列表 * @param userIdType 用户ID类型 * @return 创建结果 */ public static JSONObject batchCreateRecords(String appToken, String tableId, Object[] records, String userIdType) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || records == null || records.length == 0) { return createErrorResult(-1, "应用Token、表格ID和记录列表不能为空"); } StringBuilder url = new StringBuilder(FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records/batch_create"); if (StrUtil.isNotBlank(userIdType)) { url.append("?user_id_type=").append(userIdType); } Map<String, Object> requestBody = new HashMap<>(); requestBody.put("records", records); System.out.println("批量创建飞书多维表格记录: " + appToken + " -> " + tableId + " -> " + records.length + "条记录" + (StrUtil.isNotBlank(userIdType) ? " -> user_id_type=" + userIdType : "")); return sendHttpRequest("POST", url.toString(), requestBody); } catch (Exception e) { System.out.println("批量创建飞书多维表格记录异常: " + e.getMessage()); return createErrorResult(-1, "批量创建记录异常: " + e.getMessage()); } } /** * 批量更新记录 * * @param appToken 应用Token * @param tableId 表格ID * @param records 记录列表 * @return 更新结果 */ public static JSONObject batchUpdateRecords(String appToken, String tableId, Object[] records) { return batchUpdateRecords(appToken, tableId, records, null); } /** * 批量更新记录（带user_id_type参数） * * @param appToken 应用Token * @param tableId 表格ID * @param records 记录列表 * @param userIdType 用户ID类型 * @return 更新结果 */ public static JSONObject batchUpdateRecords(String appToken, String tableId, Object[] records, String userIdType) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || records == null || records.length == 0) { return createErrorResult(-1, "应用Token、表格ID和记录列表不能为空"); } StringBuilder url = new StringBuilder(FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records/batch_update"); if (StrUtil.isNotBlank(userIdType)) { url.append("?user_id_type=").append(userIdType); } Map<String, Object> requestBody = new HashMap<>(); requestBody.put("records", records); System.out.println("批量更新飞书多维表格记录: " + appToken + " -> " + tableId + " -> " + records.length + "条记录" + (StrUtil.isNotBlank(userIdType) ? " -> user_id_type=" + userIdType : "")); return sendHttpRequest("POST", url.toString(), requestBody); } catch (Exception e) { System.out.println("批量更新飞书多维表格记录异常: " + e.getMessage()); return createErrorResult(-1, "批量更新记录异常: " + e.getMessage()); } } /** * 批量获取记录 * * @param appToken 应用Token * @param tableId 表格ID * @param recordIds 记录ID列表 * @return 记录列表 */ public static JSONObject batchGetRecords(String appToken, String tableId, String[] recordIds) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || recordIds == null || recordIds.length == 0) { return createErrorResult(-1, "应用Token、表格ID和记录ID列表不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records/batch_get"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("records", recordIds); System.out.println("批量获取飞书多维表格记录: " + appToken + " -> " + tableId + " -> " + recordIds.length + "条记录"); return sendHttpRequest("POST", url, requestBody); } catch (Exception e) { System.out.println("批量获取飞书多维表格记录异常: " + e.getMessage()); return createErrorResult(-1, "批量获取记录异常: " + e.getMessage()); } } /** * 批量删除记录 * * @param appToken 应用Token * @param tableId 表格ID * @param recordIds 记录ID列表 * @return 删除结果 */ public static JSONObject batchDeleteRecords(String appToken, String tableId, String[] recordIds) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || recordIds == null || recordIds.length == 0) { return createErrorResult(-1, "应用Token、表格ID和记录ID列表不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/records/batch_delete"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("records", recordIds); System.out.println("批量删除飞书多维表格记录: " + appToken + " -> " + tableId + " -> " + recordIds.length + "条记录"); return sendHttpRequest("POST", url, requestBody); } catch (Exception e) { System.out.println("批量删除飞书多维表格记录异常: " + e.getMessage()); return createErrorResult(-1, "批量删除记录异常: " + e.getMessage()); } } // ==================== 文件管理 ==================== /** * 上传文件到飞书 * * @param fileUrl 文件下载URL * @param fileName 文件名 * @param accessToken 访问令牌 * @param appToken 应用令牌 * @return 上传结果 */ public static JSONObject uploadFile(String fileUrl, String fileName, String accessToken, String appToken) { try { if (StrUtil.isBlank(fileUrl) || StrUtil.isBlank(fileName) || StrUtil.isBlank(accessToken)) { return createErrorResult(-1, "文件URL、文件名和访问令牌不能为空"); } // 1. 先下载文件 byte[] fileBytes = downloadFileFromUrl(fileUrl); if (fileBytes == null || fileBytes.length == 0) { return createErrorResult(-1, "文件下载失败: " + fileName); } // 2. 上传文件到飞书 String url = FEISHU_BASE_URL + "/drive/v1/medias/upload_all"; // 构建multipart/form-data请求 HttpRequest request = HttpRequest.post(url); request.header("Authorization", "Bearer " + accessToken) .timeout(60000); // 文件上传超时时间设置为60秒 // 添加文件参数 - 按照飞书官方curl示例格式 // 必需参数：file_name, file // 可选参数：parent_type, size, extra, parent_node request.form("file_name", fileName); request.form("file", fileBytes, fileName); // 根据官方示例添加可选参数 request.form("parent_type", "bitable_file"); // 用于多维表格 request.form("size", String.valueOf(fileBytes.length)); // 文件大小 // extra参数（可选，用于多维表格） String extra = "{\"drive_route_token\":\"\"}"; request.form("extra", extra); // parent_node参数：指定上传到哪个多维表格（使用app_token） String parentNode = appToken; if (StrUtil.isNotBlank(parentNode)) { request.form("parent_node", parentNode); } System.out.println("上传文件到飞书: " + fileName + ", 大小: " + fileBytes.length + " 字节"); System.out.println("请求URL: " + url); System.out.println("请求头: Authorization=Bearer " + accessToken.substring(0, Math.min(20, accessToken.length())) + "..."); System.out.println("Content-Type: multipart/form-data (自动设置)"); System.out.println("请求参数: file_name=" + fileName + ", file=字节数组, parent_type=bitable_file, size=" + fileBytes.length + ", parent_node=" + parentNode); HttpResponse response = request.execute(); System.out.println("飞书文件上传响应状态: " + response.getStatus()); System.out.println("飞书文件上传响应体: " + response.body()); JSONObject result = new JSONObject(); result.put("httpStatus", response.getStatus()); result.put("body", response.body()); if (response.getStatus() == 200) { try { JSONObject responseBody = JSONObject.parseObject(response.body()); System.out.println("飞书API响应解析: " + responseBody); // 检查飞书API的标准响应格式 if (responseBody.containsKey("code") && responseBody.getIntValue("code") == 0) { // 飞书API成功响应 result.put("code", 0); result.put("data", responseBody.get("data")); result.put("msg", "文件上传成功"); } else { // 飞书API错误响应 result.put("code", responseBody.getIntValue("code")); result.put("data", responseBody.get("data")); result.put("msg", responseBody.getString("msg")); } } catch (Exception e) { System.out.println("解析飞书API响应异常: " + e.getMessage()); result.put("code", -1); result.put("msg", "解析响应异常: " + e.getMessage()); } } else { result.put("code", -1); result.put("msg", "文件上传失败: HTTP " + response.getStatus()); } return result; } catch (Exception e) { System.out.println("上传文件到飞书异常: " + e.getMessage()); return createErrorResult(-1, "上传文件异常: " + e.getMessage()); } } /** * 从URL下载文件 * * @param fileUrl 文件URL * @return 文件字节数组 */ private static byte[] downloadFileFromUrl(String fileUrl) { try { HttpRequest request = HttpRequest.get(fileUrl); request.timeout(30000); // 30秒超时 HttpResponse response = request.execute(); if (response.getStatus() == 200) { return response.bodyBytes(); } else { System.out.println("文件下载失败: HTTP " + response.getStatus() + ", URL: " + fileUrl); return null; } } catch (Exception e) { System.out.println("文件下载异常: " + e.getMessage() + ", URL: " + fileUrl); return null; } } // ==================== 字段管理 ==================== /** * 创建字段 * * @param appToken 应用Token * @param tableId 表格ID * @param fieldName 字段名称 * @param fieldType 字段类型 * @return 创建结果 */ public static JSONObject createField(String appToken, String tableId, String fieldName, String fieldType) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || StrUtil.isBlank(fieldName) || StrUtil.isBlank(fieldType)) { return createErrorResult(-1, "应用Token、表格ID、字段名称和类型不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/fields"; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("field_name", fieldName); requestBody.put("type", fieldType); System.out.println("创建飞书多维表格字段: " + appToken + " -> " + tableId + " -> " + fieldName); return sendHttpRequest("POST", url, requestBody); } catch (Exception e) { System.out.println("创建飞书多维表格字段异常: " + e.getMessage()); return createErrorResult(-1, "创建字段异常: " + e.getMessage()); } } /** * 更新字段 * * @param appToken 应用Token * @param tableId 表格ID * @param fieldId 字段ID * @param fieldName 新字段名称 * @return 更新结果 */ public static JSONObject updateField(String appToken, String tableId, String fieldId, String fieldName) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || StrUtil.isBlank(fieldId) || StrUtil.isBlank(fieldName)) { return createErrorResult(-1, "应用Token、表格ID、字段ID和名称不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/fields/" + fieldId; Map<String, Object> requestBody = new HashMap<>(); requestBody.put("field_name", fieldName); System.out.println("更新飞书多维表格字段: " + appToken + " -> " + tableId + " -> " + fieldId + " -> " + fieldName); return sendHttpRequest("PUT", url, requestBody); } catch (Exception e) { System.out.println("更新飞书多维表格字段异常: " + e.getMessage()); return createErrorResult(-1, "更新字段异常: " + e.getMessage()); } } /** * 获取字段列表 * * @param appToken 应用Token * @param tableId 表格ID * @param pageSize 每页大小 * @param pageToken 分页Token * @return 字段列表 */ public static JSONObject listFields(String appToken, String tableId, Integer pageSize, String pageToken) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId)) { return createErrorResult(-1, "应用Token和表格ID不能为空"); } StringBuilder url = new StringBuilder(FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/fields"); boolean hasParam = false; if (pageSize != null && pageSize > 0) { url.append(hasParam ? "&" : "?").append("page_size=").append(pageSize); hasParam = true; } if (StrUtil.isNotBlank(pageToken)) { url.append(hasParam ? "&" : "?").append("page_token=").append(pageToken); } System.out.println("获取飞书多维表格字段列表: " + appToken + " -> " + tableId); return sendHttpRequest("GET", url.toString(), null); } catch (Exception e) { System.out.println("获取飞书多维表格字段列表异常: " + e.getMessage()); return createErrorResult(-1, "获取字段列表异常: " + e.getMessage()); } } /** * 删除字段 * * @param appToken 应用Token * @param tableId 表格ID * @param fieldId 字段ID * @return 删除结果 */ public static JSONObject deleteField(String appToken, String tableId, String fieldId) { try { if (StrUtil.isBlank(appToken) || StrUtil.isBlank(tableId) || StrUtil.isBlank(fieldId)) { return createErrorResult(-1, "应用Token、表格ID和字段ID不能为空"); } String url = FEISHU_BASE_URL + "/bitable/v1/apps/" + appToken + "/tables/" + tableId + "/fields/" + fieldId; System.out.println("删除飞书多维表格字段: " + appToken + " -> " + tableId + " -> " + fieldId); return sendHttpRequest("DELETE", url, null); } catch (Exception e) { System.out.println("删除飞书多维表格字段异常: " + e.getMessage()); return createErrorResult(-1, "删除字段异常: " + e.getMessage()); } } // ==================== 工具方法 ==================== /** * 处理飞书API响应 * * @param response 响应JSON * @return 处理结果 */ public static Map<String, Object> processFeiShuResponse(JSONObject response) { Map<String, Object> result = new HashMap<>(); if (response == null) { result.put("TYPE", "E"); result.put("MESSAGE", "飞书API响应为空"); return result; } int code = response.getIntValue("code"); String msg = response.getString("msg"); // 飞书API：code=0表示成功 if (code == 0) { result.put("TYPE", "S"); result.put("MESSAGE", "操作成功"); result.put("DATA", response.get("data")); } else { result.put("TYPE", "E"); result.put("MESSAGE", "操作失败: " + msg); result.put("ERROR_CODE", code); } return result; } /** * 创建错误结果对象 * * @param code 错误码 * @param message 错误信息 * @return 错误结果JSONObject */ private static JSONObject createErrorResult(int code, String message) { JSONObject result = new JSONObject(); result.put("success", false); result.put("code", code); result.put("msg", message); return result; } /** * 清除访问令牌缓存 */ public static void clearTokenCache() { cachedAccessToken = null; tokenExpireTime = 0; System.out.println("飞书访问令牌缓存已清除"); } // ==================== 用户管理 ==================== /** * 批量获取用户信息 * * @param userIds 用户ID列表 * @param departmentIdType 部门ID类型，可选值：department_id, open_department_id * @param userIdType 用户ID类型，可选值：user_id, open_id, union_id * @return 用户信息列表 */ public static JSONObject batchGetUsers(String[] userIds, String departmentIdType, String userIdType) { try { if (userIds == null || userIds.length == 0) { return createErrorResult(-1, "用户ID列表不能为空"); } // 获取访问令牌 String accessToken = getAccessToken(); if (StrUtil.isBlank(accessToken)) { return createErrorResult(-1, "获取访问令牌失败"); } // 构建URL StringBuilder url = new StringBuilder(FEISHU_BASE_URL + "/contact/v3/users/batch"); boolean hasParam = false; // 添加department_id_type参数 if (StrUtil.isNotBlank(departmentIdType)) { url.append(hasParam ? "&" : "?").append("department_id_type=").append(departmentIdType); hasParam = true; } // 添加user_id_type参数 if (StrUtil.isNotBlank(userIdType)) { url.append(hasParam ? "&" : "?").append("user_id_type=").append(userIdType); hasParam = true; } // 添加user_ids参数（多个用户ID） for (String userId : userIds) { if (StrUtil.isNotBlank(userId)) { url.append(hasParam ? "&" : "?").append("user_ids=").append(userId); hasParam = true; } } System.out.println("批量获取飞书用户信息: " + userIds.length + "个用户, department_id_type=" + departmentIdType + ", user_id_type=" + userIdType); System.out.println("请求URL: " + url.toString()); // 发送GET请求 HttpRequest request = HttpRequest.get(url.toString()); request.header("Authorization", "Bearer " + accessToken) .timeout(30000); HttpResponse response = request.execute(); System.out.println("飞书用户信息查询响应状态: " + response.getStatus()); System.out.println("飞书用户信息查询响应体: " + response.body()); JSONObject result = new JSONObject(); result.put("httpStatus", response.getStatus()); result.put("body", response.body()); if (response.getStatus() == 200) { try { JSONObject responseBody = JSONObject.parseObject(response.body()); System.out.println("飞书用户信息API响应解析: " + responseBody); if (responseBody.containsKey("code") && responseBody.getIntValue("code") == 0) { result.put("code", 0); result.put("data", responseBody.get("data")); result.put("msg", "获取用户信息成功"); } else { result.put("code", responseBody.getIntValue("code")); result.put("data", responseBody.get("data")); result.put("msg", responseBody.getString("msg")); } } catch (Exception e) { System.out.println("解析飞书用户信息API响应异常: " + e.getMessage()); result.put("code", -1); result.put("msg", "解析响应异常: " + e.getMessage()); } } else { result.put("code", -1); result.put("msg", "获取用户信息失败: HTTP " + response.getStatus()); } return result; } catch (Exception e) { System.out.println("批量获取飞书用户信息异常: " + e.getMessage()); return createErrorResult(-1, "批量获取用户信息异常: " + e.getMessage()); } } /** * 批量获取用户信息（使用默认参数） * * @param userIds 用户ID列表 * @return 用户信息列表 */ public static JSONObject batchGetUsers(String[] userIds) { return batchGetUsers(userIds, "department_id", "user_id"); } /** * 批量获取用户信息（单个用户ID） * * @param userId 用户ID * @param departmentIdType 部门ID类型 * @param userIdType 用户ID类型 * @return 用户信息 */ public static JSONObject batchGetUsers(String userId, String departmentIdType, String userIdType) { return batchGetUsers(new String[]{userId}, departmentIdType, userIdType); } /** * 批量获取用户信息（单个用户ID，使用默认参数） * * @param userId 用户ID * @return 用户信息 */ public static JSONObject batchGetUsers(String userId) { return batchGetUsers(new String[]{userId}, "department_id", "user_id"); } } [/collapse]
2025年泛微OA专用接口数据附件入库更新工具类-多主键配置（原创）
在之前的两个工具类的基础上又封装了一个通用文件上传并入库工具类，完美解决了附件上传的问题。注意此工具类操作有点危险，原因是泛微OA的一些底层机制影响的。具体看代码吧：package com.api.zp.interfaces.util; import com.alibaba.fastjson.JSONObject; import com.api.doc.detail.service.DocAccService; import com.api.doc.detail.service.DocSaveService; import weaver.conn.RecordSet; import weaver.general.BaseBean; import weaver.hrm.User; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.util.*; /** * 支持： * 1. 附件上传到文档中心，返回docId * 2. 业务表数据插入/更新（唯一字段判断） * 3. 字段映射+自定义字段优先合并 * 4. 自动删除旧附件记录和文件 * * @Author zF. * @ClassName FileUploadProcessorUtil.java * @ProjectName 通用文件上传并入库工具类 */ public class FileUploadProcessorUtil { private static final BaseBean baseBean = new BaseBean(); /** * 之前的那些人写文件上传并入库都要重新了解一遍泛微OA的底层逻辑，还需要单独更新对应的两张文件表中的数据。 * 需要编写大量代码来实现这些操作，且极容易出错。在写附件上传的时候，由于没有文档，我阅读了本项目中大量前人留下的坑。 * 例如：docCategoryId这个字段原有的位置，因为泛微命名其为var1，所以不明其意。最后连线泛微的官方工作人员才知道那个位置是什么东西。 * 我在仔细了解了当前项目的交互逻辑之后，遂封装了这个工具类。从此以后，继任的开发者再也不需要关心泛微OA的底层逻辑。 * 只需要按照我要求的传参，即可直接实现附件上传功能。实在是太优雅啦，当然，如有兴趣你也可以直接阅读本工具类代码，注释非常详细。 */ /** * 通用文件上传+入库（支持自动上传文件并入库） * * @param body json数据体 * @param updateFields 字段映射 * @param uniqueFields 唯一字段 * @param tableName 业务表 * @param attachmentFieldName 附件字段名 * @param fileList 附件列表（每个map含fileUrl、fileName） * @param docCategoryId 文档目录ID * @param userId 用户ID * @param customFields 自定义字段 */ public static Map<String, Object> processDbDataAndUpsert(JSONObject body, Map<String, String> updateFields, List<String> uniqueFields, String tableName, String attachmentFieldName, List<Map<String, String>> fileList, int docCategoryId, int userId, Map<String, Object> customFields) { // 环境判断：只允许在生产环境（Linux）运行 String osName = System.getProperty("os.name").toLowerCase(); if (osName.contains("windows")) { // 原因是本地运行该工具类后，由于共用数据库的缘故。返回的imageFileId和docId并不是你上传的，而是线上生产环境其他业务真实上传的附件信息。 // 这是泛微OA的特性（获取线上最后一个文件的ID），无解。执行本方法之后，就会把线上其他业务的附件给删除掉。 // 如果非要运行，那就把这个判断注释掉。但一定不能在本地运行deleteOldAttachments方法，这样就不会删掉线上的文件数据及记录。 // 测试完方法，记得手动删掉其对应的IMAGEFILEID表和DOCIMAGEFILE表的数据库记录。 // 另外其他代码中使用的删除附件的方法是错的，需要使用：docAccService.deleteDocAcc，至于原因，有兴趣可以读一下这个方法的代码。 throw new RuntimeException("严禁在开发环境运行本工具类，只能在生产环境（Linux）环境下运行！当前操作系统：" + osName); } // 1. 上传文件到文档中心，收集docId List<Integer> docIds = new ArrayList<>(); if (fileList != null) { for (Map<String, String> file : fileList) { String fileUrl = file.get("fileUrl"); String fileName = file.get("fileName"); if (fileUrl != null && fileName != null) { int docId = uploadFileToDocCenter(fileUrl, fileName, docCategoryId, userId); if (docId > 0) { docIds.add(docId); } } } } // 2. 字段映射 Map<String, Object> dbData = mapJsonToDbFields(body, updateFields); // 3. 合并自定义字段（优先级高），并保证所有key为大写 if (customFields != null) { for (Map.Entry<String, Object> entry : customFields.entrySet()) { dbData.put(entry.getKey(), entry.getValue()); } } // 4. 删除旧附件（如果存在）——用合并后的 dbData if (attachmentFieldName != null && !attachmentFieldName.isEmpty()) { Map<String, Object> whereMap = buildWhereClause(uniqueFields, dbData); String whereClause = (String) whereMap.get("whereClause"); List<Object> whereParams = (List<Object>) whereMap.get("whereParams"); if (whereClause.length() > 0) { deleteOldAttachments(tableName, whereClause, whereParams, attachmentFieldName, new User(userId)); } } // 5. 组装附件字段 putAttachmentField(dbData, attachmentFieldName, docIds); // 6. 保证所有key为大写（包括customFields） Map<String, Object> upperDbData = new HashMap<>(); for (Map.Entry<String, Object> entry : dbData.entrySet()) { upperDbData.put(entry.getKey().toUpperCase(), entry.getValue()); } // 7. 唯一字段大写 List<String> upperUniqueFields = new ArrayList<>(); for (String f : uniqueFields) upperUniqueFields.add(f.toUpperCase()); // 8. 数据库操作，始终用同一个RecordSet RecordSet rs = new RecordSet(); upsertByDbFieldOrder(rs, tableName, upperDbData, upperUniqueFields); // 返回最终数据 Map<String, Object> result = new HashMap<>(); result.put("upperDbData", upperDbData); result.put("upperUniqueFields", upperUniqueFields); result.put("docIds", docIds); return result; } /** * 通用字段映射：将 body 的 json 字段按映射关系转为 db 字段 */ public static Map<String, Object> mapJsonToDbFields(JSONObject body, Map<String, String> updateFields) { Map<String, Object> dbData = new HashMap<>(); for (Map.Entry<String, String> entry : updateFields.entrySet()) { String jsonField = entry.getKey(); String dbField = entry.getValue(); if (body.containsKey(jsonField)) { dbData.put(dbField, body.get(jsonField)); } } return dbData; } /** * 构建 where 条件和参数（唯一字段大写，值从 dataMap 取） */ public static Map<String, Object> buildWhereClause(List<String> uniqueFields, Map<String, Object> dataMap) { StringBuilder whereClause = new StringBuilder(); List<Object> whereParams = new ArrayList<>(); for (String field : uniqueFields) { if (whereClause.length() > 0) whereClause.append(" AND "); whereClause.append(field.toUpperCase()).append(" = ?"); whereParams.add(dataMap.get(field.toUpperCase())); } Map<String, Object> result = new HashMap<>(); result.put("whereClause", whereClause.toString()); result.put("whereParams", whereParams); return result; } /** * 删除业务表中的旧附件记录和文件 */ public static void deleteOldAttachments(String tableName, String whereClause, List<Object> whereParams, String attachmentFieldName, User user) { // 环境判断：只允许在生产环境（Linux）运行删除逻辑 String osName = System.getProperty("os.name").toLowerCase(); if (osName.contains("windows")) { // 原因是本地运行该工具类后，由于共用数据库的缘故。返回的imageFileId和docId并不是你上传的，而是线上生产环境其他业务真实上传的附件信息。 // 这是泛微OA的特性（获取线上最后一个文件的ID），无解。执行本方法之后，就会把线上其他业务的附件给删除掉。 baseBean.writeLog("开发环境下不执行 deleteOldAttachments 方法，当前操作系统：" + osName); return; } RecordSet rs = new RecordSet(); DocAccService docAccService = new DocAccService(); try { // 查询业务表中现有的附件docId String querySql = "SELECT " + attachmentFieldName.toUpperCase() + " FROM " + tableName + " WHERE " + whereClause; baseBean.writeLog("查询旧附件SQL: " + querySql + " 参数: " + whereParams); rs.executeQuery(querySql, whereParams.toArray()); if (rs.next()) { String attachmentValue = rs.getString(attachmentFieldName.toUpperCase()); if (attachmentValue != null && !attachmentValue.trim().isEmpty()) { baseBean.writeLog("发现旧附件记录: " + attachmentValue); // 处理多个附件的情况（逗号分隔） String[] docIds = attachmentValue.split(","); for (String docIdStr : docIds) { String docId = docIdStr.trim(); if (!docId.isEmpty()) { try { int docIdInt = Integer.parseInt(docId); deleteSingleAttachment(rs, docAccService, docIdInt, user); } catch (NumberFormatException e) { baseBean.writeLog("无效的docId格式: " + docId); } } } } } } catch (Exception e) { baseBean.writeLog("删除旧附件异常: " + e.getMessage()); } } /** * 删除单个附件记录和文件 * * @param rs 数据库连接 * @param docAccService 附件服务 * @param docId 文档ID * @param user 用户对象 */ private static void deleteSingleAttachment(RecordSet rs, DocAccService docAccService, int docId, User user) { try { // 查询DOCIMAGEFILE表获取IMAGEFILEID String imageFileQuery = "SELECT IMAGEFILEID FROM DOCIMAGEFILE WHERE DOCID = ?"; baseBean.writeLog("查询IMAGEFILEID SQL: " + imageFileQuery + " 参数: [" + docId + "]"); rs.executeQuery(imageFileQuery, docId); if (rs.next()) { int imageFileId = rs.getInt("IMAGEFILEID"); baseBean.writeLog("找到IMAGEFILEID: " + imageFileId + " 对应DOCID: " + docId); // 调用泛微底层方法删除文档附件（包含权限检查和引用计数） baseBean.writeLog("开始删除文档附件，IMAGEFILEID: " + imageFileId + ", DOCID: " + docId); boolean deleteResult = docAccService.deleteDocAcc(imageFileId, docId, user); if (deleteResult) { baseBean.writeLog("成功删除文档附件，DOCID: " + docId + ", IMAGEFILEID: " + imageFileId); } else { baseBean.writeLog("删除文档附件失败，可能是权限不足，DOCID: " + docId + ", IMAGEFILEID: " + imageFileId); } } else { baseBean.writeLog("未找到DOCID对应的IMAGEFILEID: " + docId); } } catch (Exception e) { baseBean.writeLog("删除单个附件异常，DOCID: " + docId + ", 错误: " + e.getMessage()); } } /** * 上传文件到文档中心，返回docId */ public static int uploadFileToDocCenter(String fileUrl, String fileName, int docCategoryId, int userId) { try { baseBean.writeLog("开始上传文件: " + fileName + ", URL: " + fileUrl); String base64Content = getFileBase64FromUrl(fileUrl); if (base64Content == null) { baseBean.writeLog("文件下载失败: " + fileName); return 0; } if (base64Content != null) { byte[] fileBytes = Base64.getDecoder().decode(base64Content); baseBean.writeLog("下载的文件大小: " + fileBytes.length + " 字节"); } DocAccService docAccService = new DocAccService(); DocSaveService docSaveService = new DocSaveService(); User user = new User(userId); // 备用方案，直接url入参 // int annexId = docAccService.getFileByUrl(fileUrl, fileName); // 获取附件ID int annexId = docAccService.getFileByBase64(base64Content, fileName); // 获取文档ID int docId = docSaveService.accForDoc(docCategoryId, annexId, user); baseBean.writeLog("文件上传成功，文档ID: " + docId + ", 文件名: " + fileName); baseBean.writeLog("文件URL: " + fileUrl); baseBean.writeLog("文件名: " + fileName); baseBean.writeLog("Base64内容长度: " + (base64Content != null ? base64Content.length() : 0)); baseBean.writeLog("获取到的annexId: " + annexId); baseBean.writeLog("获取到的docId: " + docId); return docId; } catch (Exception e) { baseBean.writeLog("文件上传异常: " + e.getMessage()); return 0; } } /** * 下载文件并转为Base64 */ public static String getFileBase64FromUrl(String fileUrl) { InputStream is = null; ByteArrayOutputStream outStream = null; HttpURLConnection httpUrl = null; try { URL url = new URL(fileUrl); httpUrl = (HttpURLConnection) url.openConnection(); httpUrl.setRequestMethod("GET"); httpUrl.connect(); is = httpUrl.getInputStream(); outStream = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int len; while ((len = is.read(buffer)) != -1) { outStream.write(buffer, 0, len); } return Base64.getEncoder().encodeToString(outStream.toByteArray()); } catch (Exception e) { baseBean.writeLog("文件下载异常: " + e.getMessage()); return null; } finally { try { if (is != null) is.close(); if (outStream != null) outStream.close(); if (httpUrl != null) httpUrl.disconnect(); } catch (Exception e) { baseBean.writeLog("流关闭异常: " + e.getMessage()); } } } /** * 按数据库字段顺序将数据写入或更新指定表 * * @param rs 数据库连接 * @param tableName 目标表 * @param data 数据Map * @param uniqueFields 唯一字段（大写） */ public static void upsertByDbFieldOrder(RecordSet rs, String tableName, Map<String, Object> data, List<String> uniqueFields) { // 1. 构建where条件 StringBuilder whereClause = new StringBuilder(); List<Object> whereParams = new ArrayList<>(); for (String field : uniqueFields) { if (whereClause.length() > 0) whereClause.append(" AND "); whereClause.append(field).append(" = ?"); whereParams.add(data.get(field)); } // 2. 检查记录是否存在 String checkSql = "SELECT COUNT(1) AS CNT FROM " + tableName + " WHERE " + whereClause; boolean exists = false; try { rs.executeQuery(checkSql, whereParams.toArray()); if (rs.next()) exists = rs.getInt("CNT") > 0; } catch (Exception e) { baseBean.writeLog("检查记录存在性失败！表[" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("记录检查异常！", e); } // 3. 获取表字段 List<String> dbFields = getTableFields(rs, tableName, data); if (dbFields.isEmpty()) { baseBean.writeLog("警告：表 [" + tableName + "] 无有效字段信息，操作终止！"); return; } if (exists) { // 更新 executeUpdate(rs, tableName, data, dbFields, whereClause.toString(), whereParams); } else { // 插入 executeInsert(rs, tableName, data, dbFields); } } /** * 更新操作 */ private static void executeUpdate(RecordSet rs, String tableName, Map<String, Object> data, List<String> dbFields, String whereClause, List<Object> whereParams) { StringBuilder setClause = new StringBuilder(); List<Object> setParams = new ArrayList<>(); for (String field : dbFields) { // 跳过唯一字段，它们用于WHERE条件 if (whereClause.contains(field + " = ?")) { continue; } if (setClause.length() > 0) { setClause.append(", "); } setClause.append(field).append(" = ?"); setParams.add(data.get(field)); } String sql = "UPDATE " + tableName + " SET " + setClause + " WHERE " + whereClause; List<Object> allParams = new ArrayList<>(); allParams.addAll(setParams); allParams.addAll(whereParams); try { baseBean.writeLog("执行SQL(更新)：" + sql + " 参数：" + allParams); baseBean.writeLog("可直接执行SQL(更新)：" + buildFullSql(sql, allParams)); rs.executeUpdate(sql, allParams.toArray()); baseBean.writeLog("成功更新表 [" + tableName + "]，数据：" + data); } catch (Exception e) { baseBean.writeLog("更新失败！表 [" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("数据库更新异常", e); } } /** * 插入操作 */ private static void executeInsert(RecordSet rs, String tableName, Map<String, Object> data, List<String> dbFields) { List<Object> params = new ArrayList<>(); for (String field : dbFields) { params.add(data.get(field)); } String fieldsClause = String.join(", ", dbFields); String placeholders = String.join(", ", Collections.nCopies(dbFields.size(), "?")); String sql = "INSERT INTO " + tableName + "(" + fieldsClause + ") VALUES(" + placeholders + ")"; try { baseBean.writeLog("执行SQL(插入)：" + sql + " 参数：" + params); baseBean.writeLog("可直接执行SQL(插入)：" + buildFullSql(sql, params)); rs.executeUpdate(sql, params.toArray()); baseBean.writeLog("成功插入表 [" + tableName + "]，数据：" + data); } catch (Exception e) { baseBean.writeLog("插入失败！表 [" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("数据库插入异常", e); } } // 构造可直接执行的SQL（将?替换为实际参数值，字符串加单引号） private static String buildFullSql(String sql, List<Object> params) { StringBuilder sb = new StringBuilder(); int paramIdx = 0; for (int i = 0; i < sql.length(); i++) { char c = sql.charAt(i); if (c == '?' && paramIdx < params.size()) { Object val = params.get(paramIdx++); if (val == null) { sb.append("NULL"); } else if (val instanceof Number) { sb.append(val.toString()); } else { sb.append("'").append(val.toString().replace("'", "''")).append("'"); } } else { sb.append(c); } } return sb.toString(); } /** * 获取数据库表字段 */ private static List<String> getTableFields(RecordSet metaRs, String tableName, Map<String, Object> data) { List<String> fields = new ArrayList<>(); String metaSql = "SELECT COLUMN_NAME FROM ALL_TAB_COLUMNS WHERE TABLE_NAME = '" + tableName + "' AND OWNER = USER ORDER BY COLUMN_ID"; try { metaRs.execute(metaSql); while (metaRs.next()) { String field = metaRs.getString("COLUMN_NAME"); if (data.containsKey(field)) { fields.add(field); } } if (fields.isEmpty()) { baseBean.writeLog("警告：表 [" + tableName + "] 无数据中存在的有效字段，操作终止！"); } } catch (Exception e) { baseBean.writeLog("获取表字段失败！表 [" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("元数据查询异常！", e); } return fields; } /** * 组装附件字段（单/多附件） */ public static void putAttachmentField(Map<String, Object> dbData, String attachmentFieldName, List<Integer> docIds) { if (!docIds.isEmpty() && attachmentFieldName != null && !attachmentFieldName.isEmpty()) { if (docIds.size() == 1) { dbData.put(attachmentFieldName, docIds.get(0)); } else { String attachmentIdsStr = String.join(",", docIds.stream().map(String::valueOf).toArray(String[]::new)); dbData.put(attachmentFieldName, attachmentIdsStr); } } } }当然，使用方式也非常的优雅： /** * @Description 保存银行交易回单附件到数据库 */ public static List<JSONObject> bankTransactionFileUpload() { RecordSet rs = new RecordSet(); List<JSONObject> resultList = new ArrayList<>(); String today = DateUtil.today(); int docCategoryId = 100000; String randomUUID = IdUtil.randomUUID(); int userId = 1; String tableName = "UF_ZJJSYQ"; // 业务表名 String attachmentFieldName = "ANNEX"; // 业务表中存放docId的字段名 List<String> serialIds = queryTodaySerialIds(rs, today); baseBean.writeLog(randomUUID + "-当前xx银行待请求回单数据：" + JSON.toJSONString(serialIds)); for (String serialId : serialIds) { String downloadNo = getDownloadNoBySerialId(randomUUID, serialId); if (StrUtil.isEmpty(downloadNo)) { continue; } List<String> downloadUrls = getDownloadUrlsByDownloadNo(randomUUID, downloadNo); baseBean.writeLog(randomUUID + "-当前xx银行回单下载编号列表：" + JSON.toJSONString(downloadUrls)); for (String downloadUrl : downloadUrls) { // 组装 fileList List<Map<String, String>> fileList = new ArrayList<>(); Map<String, String> fileMap = new HashMap<>(); String fileName = "NB" + serialId + ".pdf"; fileMap.put("fileUrl", downloadUrl); fileMap.put("fileName", fileName); fileList.add(fileMap); // 组装 body JSONObject body = new JSONObject(); // SERIALID 在上面定义为唯一字段，数据库不会更新它。 body.put("SERIALID", serialId); // 请求入参和数据库字段关系映射 Map<String, String> updateFields = new HashMap<>(); updateFields.put("SERIALID", "SERIALID"); // 自定义字段（优先级高于入参） Map<String, Object> customFields = new HashMap<>(); customFields.put("SERIALID", serialId); // 唯一字段（数据库字段名，需大写） List<String> uniqueFields = new ArrayList<>(); uniqueFields.add("SERIALID"); baseBean.writeLog(randomUUID + "-当前xx银行回单附件入库：" + JSON.toJSONString(fileList)); // 调用工具类 Map<String, Object> utilResult = FileUploadProcessorUtil.processDbDataAndUpsert( body, updateFields, uniqueFields, tableName, attachmentFieldName, fileList, docCategoryId, userId, customFields ); baseBean.writeLog(randomUUID + "-当前xx银行回单附件入库结果：" + JSON.toJSONString(utilResult)); List<Integer> docIds = (List<Integer>) utilResult.get("docIds"); JSONObject result = new JSONObject(); result.put("serialId", serialId); result.put("fileName", fileName); result.put("downloadUrl", downloadUrl); result.put("docIds", docIds); resultList.add(result); } } return resultList; }
2025年泛微OA专用接口数据入库更新工具类-多主键配置（原创）
这个工具类是：2025年泛微OA专用接口数据入库工具类（原创） 的升级版，其实也不能算是升级版，主要是应对不同的业务。之前的是数据如果存在则直接跳过，这个版本是数据不存在新增，存在则按照主键更新，并且支持多个主键。直接上代码：package com.api.zp.interfaces.util; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import weaver.conn.RecordSet; import weaver.general.BaseBean; import java.util.*; import java.util.stream.Collectors; /** * @Author zF. * @ClassName JsonToDbUpdateUtil.java * @ProjectName 通用json数据入库/更新处理工具类 */ public class JsonToDbUpdateUtil { private static BaseBean base = new BaseBean(); /** * 按数据库字段顺序将JSONObject写入或更新指定表 * @param rs 数据库连接 * @param tableName 目标数据库表名 * @param json 待写入的JSON对象 * @param customJson 自定义JSON参数，优先级高于原始json * @param uniqueFields 唯一字段列表，数据去重使用，避免重复落库 */ public static void upsertByDbFieldOrder(RecordSet rs, String tableName, JSONObject json, JSONObject customJson, List<String> uniqueFields) { // 合并原始JSON与自定义JSON（自定义优先级更高） JSONObject mergedJson = new JSONObject(json); // 把json的所有key都转换成大写 JSONObject convertedJson = convertKeysToUpperCase(mergedJson); if(customJson != null) { convertedJson.putAll(customJson); } // 转换唯一字段为大写 List<String> upperUniqueFields = uniqueFields.stream() .map(String::toUpperCase) .collect(Collectors.toList()); // 验证所有唯一字段在JSON中存在 for (String field : upperUniqueFields) { if (!convertedJson.containsKey(field)) { base.writeLog("错误：唯一字段[" + field + "]在JSON中不存在，操作终止！"); return; } } // 构建WHERE条件 StringBuilder whereClause = new StringBuilder(); List<Object> whereParams = new ArrayList<>(); for (String field : upperUniqueFields) { if (whereClause.length() > 0) { whereClause.append(" AND "); } whereClause.append(field).append(" = ?"); whereParams.add(convertedJson.get(field)); } // 检查记录是否存在 String checkSql = "SELECT COUNT(1) AS CNT FROM " + tableName + " WHERE " + whereClause; boolean exists = false; try { rs.executeQuery(checkSql, whereParams.toArray()); if (rs.next()) { exists = rs.getInt("CNT") > 0; } } catch (Exception e) { base.writeLog("检查记录存在性失败！表[" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("记录检查异常！", e); } // 获取数据库字段列表 List<String> dbFields = getTableFields(rs, tableName, convertedJson); if (dbFields.isEmpty()) { base.writeLog("警告：表 [" + tableName + "] 无有效字段信息，操作终止！"); return; } if (exists) { // 更新操作 executeUpdate(rs, tableName, convertedJson, dbFields, whereClause.toString(), whereParams); } else { // 插入操作 executeInsert(rs, tableName, convertedJson, dbFields); } } /** * 执行更新操作 */ private static void executeUpdate(RecordSet rs, String tableName, JSONObject json, List<String> dbFields, String whereClause, List<Object> whereParams) { StringBuilder setClause = new StringBuilder(); List<Object> setParams = new ArrayList<>(); for (String field : dbFields) { // 跳过唯一字段，它们用于WHERE条件 if (whereClause.contains(field + " = ?")) { continue; } if (setClause.length() > 0) { setClause.append(", "); } setClause.append(field).append(" = ?"); setParams.add(json.get(field)); } String sql = "UPDATE " + tableName + " SET " + setClause + " WHERE " + whereClause; List<Object> allParams = new ArrayList<>(); allParams.addAll(setParams); allParams.addAll(whereParams); try { rs.executeUpdate(sql, allParams.toArray()); base.writeLog("成功更新表 [" + tableName + "]，数据：" + json.toJSONString()); } catch (Exception e) { base.writeLog("更新失败！表 [" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("数据库更新异常", e); } } /** * 执行插入操作 */ private static void executeInsert(RecordSet rs, String tableName, JSONObject json, List<String> dbFields) { List<Object> params = new ArrayList<>(); for (String field : dbFields) { params.add(json.get(field)); } String fieldsClause = String.join(", ", dbFields); String placeholders = String.join(", ", Collections.nCopies(dbFields.size(), "?")); String sql = "INSERT INTO " + tableName + "(" + fieldsClause + ") VALUES(" + placeholders + ")"; try { rs.executeUpdate(sql, params.toArray()); base.writeLog("成功插入表 [" + tableName + "]，数据：" + json.toJSONString()); } catch (Exception e) { base.writeLog("插入失败！表 [" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("数据库插入异常", e); } } /** * 获取数据库表的字段列表（按定义顺序） */ private static List<String> getTableFields(RecordSet metaRs, String tableName, JSONObject json) { List<String> fields = new ArrayList<>(); String metaSql = "SELECT COLUMN_NAME FROM ALL_TAB_COLUMNS " + "WHERE TABLE_NAME = '" + tableName + "' AND OWNER = USER " + "ORDER BY COLUMN_ID"; try { metaRs.execute(metaSql); while (metaRs.next()) { String field = metaRs.getString("COLUMN_NAME"); if(json.containsKey(field)) { fields.add(field); } } if(fields.isEmpty()) { base.writeLog("警告：表 [" + tableName + "] 无JSON中存在的有效字段，操作终止！"); } } catch (Exception e) { base.writeLog("获取表字段失败！表 [" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("元数据查询异常！", e); } return fields; } /** * 递归转换JSON对象的所有key为大写 */ public static JSONObject convertKeysToUpperCase(JSONObject json) { if (json == null) { return null; } JSONObject result = new JSONObject(); for (Object ikey : json.keySet()) { String key = StrUtil.toString(ikey); Object value = json.get(key); String upperKey = key.toUpperCase(); result.put(upperKey, processValue(value)); } return result; } private static Object processValue(Object value) { if (value instanceof JSONObject) { return convertKeysToUpperCase((JSONObject) value); } else if (value instanceof JSONArray) { JSONArray array = (JSONArray) value; JSONArray newArray = new JSONArray(); array.forEach(item -> newArray.add(processValue(item))); return newArray; } return value; } } 使用方式示例： /** * 更新联系人列表 * @param contactList 单个联系人对象 */ public static Map<String, Object> UpdateContactList(RecordSet rs, JSONObject contactList, Map<String, Object> result, String date, String time) { // 参数校验 if (ObjectUtil.isEmpty(contactList)) { result.put("TYPE", "S"); result.put("MESSAGE", "contactList为空！"); return result; } // UUID String randomUUID = IdUtil.randomUUID(); // 需要插入字段的表名 String tableName = "UF_BG_KSLXR"; // 查询指定字段并重新赋值 String ksmc = contactList.getString("ksmc"); String theKS = fieldQueryBy193(rs, "ID", ksmc); // 新建自定义json（存入自定义字段） JSONObject customJson = new JSONObject(); customJson.put("FORMMODEID", 2004); customJson.put("MODEDATACREATER", 1); customJson.put("MODEDATACREATERTYPE", 0); customJson.put("MODEDATACREATEDATE", date); customJson.put("MODEDATACREATETIME", time); customJson.put("MODEUUID", randomUUID); // 重新赋值字段 customJson.put("KSMC", theKS); // 唯一字段（数据去重使用） List<String> uniqueFields = Arrays.asList("KSMC", "LXR"); // 数据入库 JsonToDbUpdateUtil.upsertByDbFieldOrder(rs, tableName, contactList, customJson, uniqueFields); result.put("TYPE", "S"); result.put("MESSAGE", "数据推送成功"); result.put("DATA", null); return result;
2025年泛微OA专用接口数据入库工具类（原创）
最近刚刚接手了泛微OA的项目，项目代码简直是一座庞大的屎山。不得不说，习惯了Spring的生态以后，再去适应其他的框架简直是一种折磨。尤其是这种连JavaBean都不用的项目，所有的参数处理全都是用json和map进行接收和响应……最让我难以忍受的是，所有和数据库交互的地方全都是在Java代码里面直接嵌入的，代码读起来太阳穴突突跳动，咖啡在胃里翻涌成酸。所以被逼无奈，只能亲手封装了一个工具类，简直是优雅极了！[scode type="blue"]20250609 补充：这两天本地跑项目老是自动终止，通过debug发现是方法在再次new RecordSet()的时候会突然结束掉，个人推断是数据库连接池满了。网上搜了一下：数据库操作类 RecordSet 能复用该类进行查询、插入、更改操作，尽量选择复用。尽量选择少建立新的 RecordSet 类进行数据库操作。所以修改了一下工具类，减少不必要的资源消耗，把能从上游传入的都改成传入。再次启动，即解决了这个问题。[/scode]package com.api.zp.interfaces.util; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import weaver.conn.RecordSet; import weaver.general.BaseBean; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Vector; /** * @Author zF. * @ClassName JsonToDbUtil.java * @ProjectName 通用json数据入库处理工具类 */ public class JsonToDbUtil { private static BaseBean base = new BaseBean(); /** * 按数据库字段顺序将JSONObject写入指定表 * @param rs 数据库连接 * @param tableName 目标数据库表名 * @param json 待写入的JSON对象 * @param customJson 增自定义JSON参数，优先级高于原始json * @param uniqueField 唯一字段，数据去重使用，避免重复落库 */ public static void insertByDbFieldOrder(RecordSet rs, String tableName, JSONObject json, JSONObject customJson, String uniqueField) { // 获取数据库字段列表（按表定义顺序） // 合并原始JSON与自定义JSON（自定义优先级更高） JSONObject mergedJson = new JSONObject(json); // 把json的所有key都转换成大写 JSONObject convertedJson = convertKeysToUpperCase(mergedJson); if(customJson != null) { convertedJson.putAll(customJson); // 自定义JSON的键会覆盖原始JSON的相同键 } // 获取唯一字段值（转换为大写匹配数据库字段） String upperUniqueField = uniqueField.toUpperCase(); Object uniqueValue = convertedJson.get(upperUniqueField); if(uniqueValue == null) { base.writeLog("错误：唯一字段[" + upperUniqueField + "]在JSON中不存在，插入终止！"); return; } // 检查唯一字段是否已存在 String checkSql = "SELECT " + upperUniqueField + " FROM " + tableName + " WHERE " + upperUniqueField + " IS NOT NULL"; try { rs.execute(checkSql); // 数据库中是否已经存在当的json Vector array = rs.getArray(); List<String> newMergeList = mergeList(array); if (newMergeList.contains(uniqueValue.toString())) { base.writeLog("唯一字段[" + upperUniqueField + "]值[" + uniqueValue + "]已存在，不重复插入！"); return; } } catch (Exception e) { base.writeLog("唯一字段检查失败！表[" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("唯一字段检查异常！", e); } List<String> dbFields = getTableFields(rs, tableName, convertedJson); if (dbFields.isEmpty()) { base.writeLog("警告：表 [" + tableName + "] 无有效字段信息，插入终止！"); return; } // 按字段顺序收集参数值（过滤JSON中不存在的字段） List<Object> params = new ArrayList<>(); for (String field : dbFields) { Object value = convertedJson.get(field); // 从合并后的JSON获取值（包含自定义字段） // 处理日期类型自动转换（示例） // if(value instanceof String && field.contains("DATE")){ // try{ // value = new java.sql.Date(new java.text.SimpleDateFormat("yyyy-MM-dd").parse((String)value).getTime()); // }catch (Exception ignored){} // } params.add(value != null ? value : null); // 显式处理null } // 构造预编译SQL String fieldsClause = String.join(", ", dbFields); String placeholders = String.join(", ", Collections.nCopies(dbFields.size(), "?")); String sql = "INSERT INTO " + tableName + "(" + fieldsClause + ") VALUES(" + placeholders + ")"; // 执行数据库插入 try { rs.executeUpdate(sql, params.toArray()); base.writeLog("成功插入表 [" + tableName + "]，数据：" + json.toJSONString()); } catch (Exception e) { base.writeLog("插入失败！表 [" + tableName + "]，错误信息：" + e.getMessage()); throw new RuntimeException("数据库插入异常", e); } } /** * 获取列表内部所有字符串并合并成新的列表 * @param vector 目标列表 * @return 新的列表 */ public static List<String> mergeList(Vector vector) { List<String> stringList = new ArrayList<>(); if (vector == null) { System.out.println("输入Vector为null，返回空列表"); return stringList; } System.out.println("原始Vector内容: " + vector); for (Object item : vector) { // 检查元素是否为Object[]类型 if (!(item instanceof Object[])) { // 跳过非Object[]类型元素 continue; } Object[] itemArray = (Object[]) item; // 检查数组非空 if (itemArray.length == 0) { // 跳过空数组元素 continue; } Object firstElement = itemArray[0]; // 检查首位元素是否为String类型 if (firstElement instanceof String) { String strElement = (String) firstElement; stringList.add(strElement); System.out.println("提取字符串内容: " + strElement); } else { System.out.println("数组首位非String类型，跳过: " + firstElement); } } System.out.println("合并后的字符串列表: " + stringList); return stringList; } /** * 获取数据库表的字段列表（按定义顺序） * @param metaRs 数据库连接 * @param tableName 目标表名 * @param json 待处理json * @return 字段列表（有序） */ private static List<String> getTableFields(RecordSet metaRs, String tableName, JSONObject json ) { List<String> fields = new ArrayList<>(); // Oracle专用元数据查询（使用ALL_TAB_COLUMNS视图） String metaSql = "SELECT COLUMN_NAME FROM ALL_TAB_COLUMNS " + "WHERE TABLE_NAME = '" + tableName + "' AND OWNER = USER " + "ORDER BY COLUMN_ID"; try { metaRs.execute(metaSql); // Oracle表名默认大写存储 while (metaRs.next()) { String field = metaRs.getString("COLUMN_NAME"); if(json.containsKey(field)){ // 检查JSON中key转换为全大写后是否存在该字段 fields.add(field); } } if(fields.isEmpty()){ base.writeLog("警告：表 [" + tableName + "] 无JSON中存在的有效字段，插入终止！"); } } catch (Exception e) { base.writeLog("获取表字段失败！表 [" + tableName + "]，错误：" + e.getMessage()); throw new RuntimeException("元数据查询异常！", e); } return fields; } /** * 递归转换JSON对象的所有key为大写 * @param json 原始JSON对象 * @return 所有key大写的JSON对象（其他内容不变） */ public static JSONObject convertKeysToUpperCase(JSONObject json) { if (json == null) { return null; } JSONObject result = new JSONObject(); // 遍历原始JSON的所有键 for (Object ikey : json.keySet()) { String key = StrUtil.toString(ikey); Object value = json.get(key); String upperKey = key.toUpperCase(); // 递归处理不同类型的值 result.put(upperKey, processValue(value)); } return result; } private static Object processValue(Object value) { if (value instanceof JSONObject) { // 递归处理嵌套JSON对象 return convertKeysToUpperCase((JSONObject) value); } else if (value instanceof JSONArray) { // 处理JSON数组 JSONArray array = (JSONArray) value; JSONArray newArray = new JSONArray(); array.forEach(item -> newArray.add(processValue(item)) ); return newArray; } // 基础类型直接返回 return value; } }使用前（例如）： private static void saveToUF_JYYQZL(String requestId, String[] fields) { RecordSet rs = new RecordSet(); try { String rzh = getField(fields, 5); String querySelect = "select rzh from uf_jyyqzl where rzh ='" + rzh + "'"; baseBean.writeLog("查询明细是否存在："); baseBean.writeLog(querySelect); rs.execute(querySelect); if (rs.next()) { return; } // SQL（表结构为43个字段） String sql = "INSERT INTO UF_JYYQZL(" + // 按数据库字段顺序严格排列（共43个字段） "ID,REQUESTID,FORMMODEID,MODEDATACREATER,MODEDATACREATERTYPE," + "MODEDATACREATEDATE,MODEDATACREATETIME,MODEDATAMODIFIER,MODEDATAMODIFYDATETIME,MODEUUID," + "SSDM,ZH,HBM,JYRQ,JYSJ,RZH,JYLX,JYXH,HM,FSEBZ," + "DFZHSSDM,DFZH,DFZHHBM,DFZHHM,DFZHKHX,XZBZ,CZRQ,CZCPH," + "JYJE,ZHYE,SBYE,SXFZE,PZZL,PZSSDH,PZPCH,PZH," + "KHCKH,JYM,GYH,CPH,ZY,FY,JYLY) " + "VALUES(" + "uf_jyyqzl_Id.nextval,?,?,?,?,TO_CHAR(SYSDATE,'YYYYMMDD'),TO_CHAR(SYSDATE,'HH24MISS'),?,?,?," + // ID(1)由序列生成 "?,?,?,?,?,?,?,?,?,?," + // 10-19 "?,?,?,?,?,?,?,?,?,?," + // 20-29 "?,?,?,?,?,?,?,?,?,?," + // 30-39 "?,?,?)"; // 40-43 Object[] params = new Object[]{ // REQUESTID（字段2） requestId, // 系统字段（3个） 154512, // FORMMODEID（字段3） 0, // MODEDATACREATER（字段4） 1, // MODEDATACREATERTYPE（字段5） // 修改信息（2个） 0, // MODEDATAMODIFIER（字段8） "", // MODEDATAMODIFYDATETIME（字段9） "", // MODEUUID（字段10） // 业务字段（按数据库顺序） getField(fields, 0), // SSDM（字段11） getField(fields, 1), // ZH（字段12） getField(fields, 2), // HBM（字段13） getField(fields, 3), // JYRQ（字段14） getField(fields, 4), // JYSJ（字段15） getField(fields, 5), // RZH（字段16） getField(fields, 6), // JYLX（字段17） getField(fields, 7), // JYXH（字段18） getField(fields, 8), // HM（字段19） getField(fields, 9), // FSEBZ（字段20） getField(fields, 10),// DFZHSSDM（字段21） getField(fields, 11),// DFZH（字段22） getField(fields, 12),// DFZHHBM（字段23） getField(fields, 13),// DFZHHM（字段24） getField(fields, 14),// DFZHKHX（字段25） getField(fields, 15),// XZBZ（字段26） getField(fields, 16),// CZRQ（字段27） getField(fields, 17),// CZCPH（字段28） parseDecimal(getField(fields, 18)), // JYJE（字段29） parseDecimal(getField(fields, 19)), // ZHYE（字段30） parseDecimal(getField(fields, 20)), // SBYE（字段31） parseDecimal(getField(fields, 21)), // SXFZE（字段32） getField(fields, 22),// PZZL（字段33） getField(fields, 23),// PZSSDH（字段34） getField(fields, 24),// PZPCH（字段35） getField(fields, 25),// PZH（字段36） getField(fields, 26),// KHCKH（字段37） getField(fields, 27),// JYM（字段38） getField(fields, 28),// GYH（字段39） getField(fields, 29),// CPH（字段40） getField(fields, 30),// ZY（字段41） parseDecimal(getField(fields, 31)), // FY（字段42） getField(fields, 32) // JYLY（字段43） }; baseBean.writeLog("参数数量验证：" + params.length); // 必须输出42（ID由序列生成，占位符少1个） // 在executeUpdate之前 baseBean.writeLog("===== 参数详情 ====="); for (int i = 0; i < params.length; i++) { System.out.printf("参数 %2d: %s%n", i + 1, params[i]); } System.out.println("字段映射关系："); String[] fieldNames = new String[]{"SSDM", "ZH", "HBM", "JYRQ", "JYSJ", "RZH", "JYLX", "JYXH", "HM", "FSEBZ", "DFZHSSDM", "DFZH", "DFZHHBM", "DFZHHM", "DFZHKHX", "XZBZ", "CZRQ", "CZCPH", "JYJE", "ZHYE", "SBYE", "SXFZE", "PZZL", "PZSSDH", "PZPCH", "PZH", "KHCKH", "JYM", "GYH", "CPH", "ZY", "FY", "JYLY"}; for (int i = 0; i < fields.length; i++) { System.out.printf("%s(%d) => %s%n", (i < fieldNames.length) ? fieldNames[i] : "未知字段", i, fields[i]); } rs.executeUpdate(sql, params); } catch (Exception e) { baseBean.writeLog("保存失败：" + e.getMessage()); e.printStackTrace(); } }每个类都要单独创建一遍数据库映射关系，然后手动加很多占位符。这是历史遗留代码，虽然一眼Ai，但是依然需要人工核对修正。使用后（例如）： /** * 银行数据入库 */ public static void bankSataStorage(RecordSet rs, String res, String requestId) { // 组装日期 String[] split = DateUtil.now().split(" "); // 获取当前日期 String date = split[0]; // 获取当前时间 String time = split[1]; JSONObject object = JSONObject.parseObject(res); // 实际数据 JSONObject data = object.getJSONObject("Data"); // 实际条数 Integer totalSize = data.getInteger("totalSize"); JSONArray list = data.getJSONArray("list"); if (ObjectUtil.isEmpty(list)) { InsterLogZPUtil.insertPushResponseLogToReuse(rs, requestId, "xx银行交易明细数据为空入库失败", NBCBConfig.APIGW_URL_ADDRESS, res); return; } list.forEach(e-> { JSONObject obj = JSONObject.parseObject(e.toString()); // UUID String randomUUID = IdUtil.randomUUID(); // 需要插入字段的表名 String tableName = "UF_ZJJSYQ"; // 新建自定义json（存入自定义字段） JSONObject customJson = new JSONObject(); customJson.put("REQUESTID", requestId); customJson.put("FORMMODEID", 158512); customJson.put("MODEDATACREATER", 1); customJson.put("MODEDATACREATERTYPE", 0); customJson.put("MODEDATACREATEDATE", date); customJson.put("MODEDATACREATETIME", time); customJson.put("MODEUUID", randomUUID); // 唯一字段（数据去重使用） String uniqueField = "serialId"; // 数据入库 JsonToDbUtil.insertByDbFieldOrder(rs, tableName, obj, customJson, uniqueField); }); }由于工具类直接从数据库获取字段列表，优雅的省掉了映射关系配置和手动给SQL设置数量占位符，你不需要再关注数据入库的底层逻辑，只需要确认想要入库的数据。如果数据库字段和接收的字段不匹配，则可以通过自定义json来实现无缝替换。还可以手动指定唯一字段（主键），避免数据重复入库。
昨天，同事拿到一个需求，要在已有的配置上新增一个SQLserver数据库，实现多数据源。找到我的时候，我寻思这不有手就行嘛？直接用mybatis-plus的dynamic-datasource就好了啊。然后通过@DS注解在mapper接口上面指定对应的数据库就……结果很打脸，按照mybatis-plus官方的配置指定默认数据库以后一直报错：Description: Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured. Reason: Failed to determine a suitable driver class Action: Consider the following: If you want an embedded database (H2, HSQL or Derby), please put it on the classpath. If you have database settings to be loaded from a particular profile you may need to activate it (the profiles db,redis,mq,config,sap,pre are currently active). 进程已结束，退出代码为 1不得不说，这还是我职业生涯中第一次遇到这种问题。我首先考虑到的是：会不会是阿里巴巴的连接池配置影响到的？所以我尝试把阿里巴巴的连接池配置迁移到MySQL数据库的配置下面，还是报错。网上搜了一堆教程，给新的SQLserver也加了连接池配置，依旧无效。可能是驱动没有生效？把SQLserver的驱动换成jTDS试试……就这么折腾了一个多小时，期间我还使用了最近非常火的trae，希望它可以解决我的燃眉之急。结果它在获取了项目文件列表和报错信息的情况下还是一通乱答，排查只能是靠自己了……毕竟帮人帮到底嘛，虽然这不是我自己的工作~就在我最终打算放弃的时候，突然想到会不会是优先级的问题，可能在某个地方定义了一个配置，导致项目启动的时候默认走到了那个配置文件，导致我们后面新增的配置没有被扫描到……就这样从下午五点多折腾到了晚上八点半，除去中间吃饭的半个小时。终于有点头绪了。。。就是这个配置类，项目里面定义了一个MysqlDataSourceConfig：package cn.com.demo.infras.config; import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.config.GlobalConfig; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.session.SqlSessionFactory; import org.mybatis.spring.SqlSessionTemplate; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Scope; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import javax.sql.DataSource; @Slf4j @Configuration @MapperScan(basePackages = {"cn.com.demo.**.mapper"}, sqlSessionFactoryRef = "mysqlSqlSessionFactory") public class MysqlDataSourceConfig { @Bean(name = "mysqlDataSource") @ConfigurationProperties(prefix = "spring.datasource.druid.mysql") @Primary public DataSource dataSource() { return DruidDataSourceBuilder.create().build(); } @Bean @Scope(value = "prototype") @ConfigurationProperties(prefix = "mybatis-plus.configuration") public MybatisConfiguration getCfg() { return new MybatisConfiguration(); } @Bean(name = "mysqlSqlSessionFactory") @Primary public SqlSessionFactory sqlSessionFactory(@Qualifier("mysqlDataSource") DataSource dataSource, @Qualifier("mybatisPlusInterceptor") MybatisPlusInterceptor interceptor, BaseEntityMetaObjectHandler baseEntityMetaObjectHandler ) throws Exception { MybatisSqlSessionFactoryBean mysqlBean = new MybatisSqlSessionFactoryBean(); mysqlBean.setDataSource(dataSource); mysqlBean.setConfiguration(getCfg()); mysqlBean.setPlugins(interceptor); mysqlBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:/mapper/**/*.xml")); mysqlBean.setGlobalConfig(new GlobalConfig().setBanner(false).setMetaObjectHandler(baseEntityMetaObjectHandler)); return mysqlBean.getObject(); } @Bean(name = "mysqlTransactionManager") @Primary public DataSourceTransactionManager transactionManager(@Qualifier("mysqlDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } @Bean(name = "mysqlSqlSessionTemplate") @Primary public SqlSessionTemplate sqlSessionTemplate(@Qualifier("mysqlSqlSessionFactory") SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); } }这一行：@ConfigurationProperties(prefix = "spring.datasource.druid.mysql")它每次都是读取yml里面的阿里巴巴连接池配置下面的mysql。因为@Primary注解的缘故，在自动装配规则中Spring会优先注入带有 @Primary 的 Bean。所以使用@DS注解才会不生效，因为它依然取的是默认数据源下面的数据。怎么解决？由于项目10号就要上线，为了避免改出bug来，所以我想尽可能的不要影响现有的业务。所以按照它之前的配置又写了一个SqlserverDataSourceConfig：package cn.com.demo.infras.config; import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.session.SqlSessionFactory; import org.mybatis.spring.SqlSessionTemplate; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import javax.sql.DataSource; /** * @Author zF. * @ClassName SqlserverDataSourceConfig.java * @ProjectName app-bx-inventory * @Description Sqlserver自定义数据源配置 */ @Slf4j @Configuration @MapperScan(basePackages = "cn.com.demo.**.sqlserver", sqlSessionFactoryRef = "sqlserverSqlSessionFactory") public class SqlserverDataSourceConfig { @Bean(name = "sqlserverDataSource") @ConfigurationProperties(prefix = "spring.datasource.dynamic.datasource.sqlserver") public DataSource sqlserverDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean(name = "sqlserverSqlSessionFactory") public SqlSessionFactory sqlserverSqlSessionFactory(@Qualifier("sqlserverDataSource") DataSource dataSource, @Qualifier("mybatisPlusInterceptor") MybatisPlusInterceptor interceptor) throws Exception { MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setConfiguration(new MybatisConfiguration()); bean.setPlugins(interceptor); // 注释掉这行，如果不使用SQL Server的Mapper XML文件 // bean.setMapperLocations(new PathMatchingResourcePatternResolver() // .getResources("classpath:/sqlserver/**/*.xml")); return bean.getObject(); } @Bean(name = "sqlserverTransactionManager") public DataSourceTransactionManager sqlserverTransactionManager(@Qualifier("sqlserverDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } @Bean(name = "sqlserverSqlSessionTemplate") public SqlSessionTemplate sqlserverSqlSessionTemplate(@Qualifier("sqlserverSqlSessionFactory") SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); } }在不影响当前规则的情况下新定义一套数据连接的规则：因为之前是按照功能划分的，每个功能一个包，然后在功能包的下面创建entity、mapper、service、controller。使用以上Sqlserver自定义数据源配置，指定basePackages = "cn.com.demo.**.sqlserver"，在功能包下面新建一个sqlserver包，在里面写Mapper接口即可。这样在接口被请求的时候，上面的配置就能自动将其识别为SQLserver数据源。然后用@ConfigurationProperties(prefix = "spring.datasource.dynamic.datasource.sqlserver")指定该SQLserver数据源在yml中的具体位置。如果想要使用xml编写sql语句，则需要解开上面配置中的注释，并且在resources目录下新建sqlserver文件夹，然后再按照模块名称创建文件夹，最后在模块文件夹内创建对应的Mapper.xml，其他的和之前正常开发同理。此时就不再需要@DS注解了，同样不需要在yml里面的多数据源配置中指定primary，因为这是无效操作！对应的yml配置文件：spring: datasource: dynamic: datasource: sqlserver: url: jdbc:sqlserver://10.10.92.210:1433;databaseName=​**​* username: ​**​* password: ​**​* driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver type: com.alibaba.druid.pool.DruidDataSource druid: mysql: database: ​**​* username: ​**​* password: ​**​* address: 172.29.20.133:3306 initialSize: 10 minIdle: 10 maxActive: 30 maxPoolPreparedStatementPerConnectionSize: 30 test-while-idle: true test-on-borrow: false test-on-return: false web-stat-filter: enabled: true profile-enable: true session-stat-enable: true stat-view-servlet: enabled: true login-username: ​**​* login-password: ​**​* reset-enable: false filters: stat,wall,log4j2,config filter: stat: enabled: true log-slow-sql: true slow-sql-millis: 6000 merge-sql: false wall: config: multi-statement-allow: true config: enabled: true至于我为什么要使用dynamic的方式新增sqlserver的配置，而不是像mysql那样将配置写在druid下面，这大概是其不支持自定义数据库驱动的原因，具体的底层逻辑你可以自行研究。按照dynamic的方式进行分库其实是一种暂时性的妥协，可能未来会有更好的方法，但是截止当前，为了不影响现有的这套业务正常运行，只能如此。一套下来折腾了两三个小时，回过头来，梳理清楚，发现也没有想象中的那么难……附上当前所需的maven依赖： <dependency> <groupId>com.microsoft.sqlserver</groupId> <artifactId>mssql-jdbc</artifactId> <version>9.2.1.jre8</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.5.1</version> </dependency>
这个框架其实最早从2023年初就开始写了，以前纯粹是自己用的，所有的东西都一股脑的揉在一起。后来在群里聊天，群友希望我能把东西共享出来。本来是想直接发的，但是第一写的太乱了，其次也不容易理解。于是趁着不上班，从上个月中旬开始准备重构一下。这段时间平均每天写十六个小时，毛都要写秃了。当然也遇到了很多问题，什么奇葩问题都遇到了。也用到了一些新东西，比如框架的开发加配套工具一共用到了：Java、易语言、bat、PHP、vue……算起来这是我第一次自己去做一个完整的开源方案。文档地址：https://10ke.cn/ （有兴趣的可以看一下）这本来是我离职前计划的一部分，完成了这件事后其他的事也得陆续提上日程了。
在很多项目的开发文档上面我们都能看到很漂亮的目录树。如果纯手敲的话那得需要很久，可能还不标准。最近还是在写机器人框架，涉及到插件开发部分的说明，需要向用户展示插件的结构。手敲目录树太麻烦了，于是我搞了个工具类：package com.chaizhou; /** * @Author zF. * @ClassName ProjectStructureGenerator.java * @ProjectName NewBot-FrameWork * @Description 项目结构生成器 */ import java.io.File; import java.io.FileWriter; import java.io.IOException; public class ProjectStructureGenerator { public static void main(String[] args) { File rootDir = new File("src"); try { FileWriter writer = new FileWriter("project_structure.txt"); generateProjectStructure(rootDir, writer, 0); writer.close(); System.out.println("Project structure file created successfully."); } catch (IOException e) { e.printStackTrace(); } } private static void generateProjectStructure(File dir, FileWriter writer, int level) throws IOException { if (dir.isDirectory()) { File[] files = dir.listFiles(); if (files != null) { for (int i = 0; i < files.length; i++) { File file = files[i]; String prefix = (i == files.length - 1) ? "└── " : "├── "; writer.write(getIndentation(level) + prefix + file.getName() + "\n"); if (file.isDirectory()) { generateProjectStructure(file, writer, level + 1); } } } } } private static String getIndentation(int level) { StringBuilder indentation = new StringBuilder(); for (int i = 0; i < level; i++) { indentation.append(" "); } return indentation.toString(); } } 使用方法，直接把上面的工具类放在项目的java文件夹下面，然后运行main方法即可。文件会生成在项目根目录下，效果：└── main ├── java └── com └── chaizhou ├── event ├── EventAbstract.java └── RequestResultVO.java ├── plugin ├── PluginStatus.java └── QueryPhonePlugin.java └── resources怎么样？是不是很标准？
这个命令用于使用 Maven 构建和安装项目，同时跳过测试阶段。具体来说：命令解析mvn install：功能：编译项目、运行测试、打包（如生成 JAR 或 WAR 文件），然后将生成的包安装到本地 Maven 仓库中。这样，其他本地项目可以引用这个包作为依赖。生命周期阶段：install 是 Maven 生命周期中的一个阶段，位于 package 之后，确保项目的构建产物被正确安装到本地仓库。-Dmaven.test.skip=true：功能：通过设置 Maven 的属性 maven.test.skip 为 true，指示 Maven 在构建过程中跳过运行测试代码。这意味着测试编译和执行步骤都会被跳过。使用场景：当你确定代码已经经过测试，或者在某些情况下（如快速构建、测试不相关的改动等）希望节省时间时，可以使用这个参数。综合作用整个命令 mvn install -Dmaven.test.skip=true 的作用是：编译项目：将源代码编译成可执行的字节码。打包：根据项目配置生成相应的包（如 JAR、WAR）。安装：将生成的包安装到本地 Maven 仓库，供其他本地项目引用。跳过测试：在上述过程中不执行任何测试相关的步骤，节省构建时间。注意事项跳过测试的风险：虽然跳过测试可以加快构建速度，但可能会导致未发现的代码问题被忽略。因此，建议在确保代码稳定性和测试通过的情况下使用。示例mvn install -Dmaven.test.skip=true上述命令会执行项目的编译、打包和安装步骤，但不会运行任何测试。总结使用 mvn install -Dmaven.test.skip=true 可以快速完成项目的构建和安装过程，适用于需要快速部署或在确认代码已经通过测试的情况下。然而，为了保证代码质量，建议在开发过程中尽量执行测试，只有在特定需求下才跳过测试步骤。
最近在写微信机器人框架，经过一周左右的时间，终于搞定了框架的插件开发模式（独立创建插件项目、热拔插、解耦合）。但是在导入service的时候出现了问题，正常来说直接通过@Autowired即可导入我们需要的service接口。 @Autowired private PluginOperate pluginOperate; // 自动注入插件管理器但是运行插件的时候出现了空指针异常，排查了很久，包括问GPT。然后网上搜了几个小时，一度想要放弃……好在最后终于是解决了。[scode type="blue"]出现的原因： Spring管理的都是单例（singleton），和多对象相冲突。框架项目启动时初始化，会初始化插件管理器，Spring同时会为其注入service，该对象的service不是null，被成功注入。但是，由于Spring默认管理的是单例，所以只会注入一次service。当插件载入框架并启动，框架端会加载插件提供的服务，但是Spring管理的都是单例，不会给新的对象注入service，所以导致只要是创建新的对象，都不能再注入了。像controller里面有service，service里面有dao。因为controller，service，dao都有是单例，所以注入时不会报null。但是插件不是单例，所以使用Spring注入一次后，后面的对象就不会再注入了，会报NullException。[/scode]解决方案： private static PluginOperate pluginOperate; @Autowired public void setPluginOperate(PluginOperate pluginOperate) { SystemPlugin.pluginOperate = pluginOperate; }此时即可在插件中调pluginOperate提供的方法，而不再报空指针异常。