Compare commits

...
Author SHA1 Message Date
database-mysql 5388ecaa7e merge: 合并 main(新增文件下载模块)并适配 Java 21
- 版本统一为 1.9-SNAPSHOT-Java21(pom / version 常量 / README / INFO)
- 合并 main 新增的文件下载模块(单线程 + HTTP Range 多线程下载)与门面接口
- 修复多线程下载:块失败异常不再被静默吞掉(解包 ExecutionException 抛出 RuntimeException)、
  文件小于线程数时收敛有效线程数、线程池 finally 关闭
- 测试适配:原 downloadfile 测试不匹配 surefire 规则且依赖内网,重写为本地 HttpServer
  驱动的 DownloadFileTest(单线程/多线程/小文件边界/不支持断点续传抛错)
- SpotBugs 排除规则补充下载模块的遗留命名;README 测试说明同步(58 个用例)
- 验证:JDK 21 下 mvn clean verify 通过(58/58 测试 + checkstyle + SpotBugs 0 问题)
2026-11-02 22:29:42 +08:00
database-mysql 87f2fec4d9 update/add: 新增文件下载方法; 2026-11-02 22:10:25 +08:00
database-mysql 963584e361 chore: 版本号升级为 1.8-SNAPSHOT-Java21,更新 INFO.md 2026-11-02 10:21:48 +08:00
database-mysql e464dfc35a chore: 版本号升级为 1.8-SNAPSHOT-Java21,更新 INFO.md 2026-11-02 10:07:10 +08:00
database-mysql bdac62ac45 chore: 版本号升级为 1.8-SNAPSHOT,更新 INFO.md 2026-11-02 10:03:26 +08:00
database-mysql 6c843c4030 fix: 修复质量插件在 Java 21 下不可用,并修复 SpotBugs 发现的空指针风险
- checkstyle: 升级至 3.6.0,新增 checkstyle.xml 基础规则,绑定 verify 阶段
- findbugs: 替换为 spotbugs-maven-plugin 4.8.6.6(FindBugs 已停止维护且无法解析 Java 21 字节码),
  绑定 verify 阶段,新增 spotbugs-exclude.xml 排除遗留命名等检查
- javadoc: 升级至 3.12.0(3.12.1 在中央仓库不存在),禁用 doclint
- 修复 SpotBugs 发现的问题:压缩/解压方法中 getFileName()/getParent() 可能为 null 的防御,
  共享 SecureRandom 实例避免重复创建
2026-11-02 09:56:28 +08:00
11 changed files with 613 additions and 35 deletions

No files matched your search

+29 -11
View File
@@ -1,4 +1,4 @@
1.7-SNAPSHOT-Java21 1.9-SNAPSHOT-Java21
此版本包含: 此版本包含:
1. 压缩/解压 (`compress`) 1. 压缩/解压 (`compress`)
@@ -13,15 +13,33 @@
4.1 AES-256/GCM 加密 4.1 AES-256/GCM 加密
4.2 RSA-2048/OAEP 混合加密 4.2 RSA-2048/OAEP 混合加密
4.3 DES 加密(已废弃,仅兼容旧版本) 4.3 DES 加密(已废弃,仅兼容旧版本)
5. 随机生成器 (`random`) 5. 文件下载 (`file/download`)
6. 硬件标识生成器 (`hwid`) 5.1 单线程下载
7. 版本信息 (`version`) 5.2 多线程下载(HTTP Range 分块并行下载,支持断点续传)
8. 运行时信息 (`runtime`) 6. 随机生成器 (`random`)
8.1 获取运行时临时目录 7. 硬件标识生成器 (`hwid`)
8.2 新建临时目录 8. 版本信息 (`version`)
8.3 获取当前运行的 JAR 文件路径 9. 运行时信息 (`runtime`)
9.1 获取运行时临时目录
9.2 新建临时目录
9.3 获取当前运行的 JAR 文件路径
1.7 更新内容: 1.9 更新内容:
- 新增文件下载模块 (`file/download`)
- 支持单线程下载和多线程并行下载(基于 HTTP Range 请求)
- 多线程下载自动创建父目录,预分配文件空间
- 使用 Java HttpClient 标准库,支持自动重定向
- 多线程下载块失败时向调用方抛出异常(不再静默吞掉)
- 测试改为本地 HTTP 服务器驱动(不依赖外网),确保可重复执行
1.8 更新内容(历史):
- 版本号升级为 1.8-SNAPSHOT-Java21(pom / version 常量 / README 引用同步)
- 质量插件适配 Java 21:checkstyle 升级至 3.6.0(新增 checkstyle.xml 并绑定 verify 阶段),
SpotBugs 4.8.6.6 替换已停止维护的 FindBugs(新增排除规则),javadoc 升级至 3.12.0(禁用 doclint)
- 修复 SpotBugs 发现的空指针风险:压缩/解压方法 getFileName()/getParent() 空值防御,
加密模块共享 SecureRandom 实例
1.7 更新内容(历史):
- 安全:修复 Zip-Slip 路径穿越漏洞,解压时校验条目路径并拒绝符号/硬链接条目 - 安全:修复 Zip-Slip 路径穿越漏洞,解压时校验条目路径并拒绝符号/硬链接条目
- 安全:AES 升级为 AES-256/GCM(随机 IV + 认证标签),RSA 实现真实混合加密 - 安全:AES 升级为 AES-256/GCM(随机 IV + 认证标签),RSA 实现真实混合加密
(RSA-2048/OAEP 包裹随机 AES-256 会话密钥),密钥文件限制为仅属主可读写 (RSA-2048/OAEP 包裹随机 AES-256 会话密钥),密钥文件限制为仅属主可读写
@@ -32,6 +50,6 @@
- 修复:getJarPath 经 URI 解析(正确处理空格与 URL 编码),临时目录改用唯一命名 - 修复:getJarPath 经 URI 解析(正确处理空格与 URL 编码),临时目录改用唯一命名
- 工程:测试全部迁移至 JUnit 5(53 个用例),pom 补充元数据与插件版本, - 工程:测试全部迁移至 JUnit 5(53 个用例),pom 补充元数据与插件版本,
版本号统一由 pom / MANIFEST Implementation-Version 提供 版本号统一由 pom / MANIFEST Implementation-Version 提供
- 兼容性:向下降级为Java21 - 兼容性:向下降级为 Java 21
在 1.6-SNAPSHOT 的基础上进行安全加固与缺陷修复。 在 1.8-SNAPSHOT-Java21 的基础上新增文件下载模块并同步 1.9 版本号。
+46 -11
View File
@@ -1,6 +1,6 @@
# GNU Java Utils Library # GNU Java Utils Library
一个轻量级 Java 工具库,提供压缩/解压、终端彩色输出、文件创建与管理、文件加密/解密、随机数生成、硬件标识等功能。 一个轻量级 Java 工具库,提供压缩/解压、终端彩色输出、文件创建与管理、文件加密/解密、文件下载(单线程/多线程)、随机数生成、硬件标识等功能。
> 注:此项目/仓库已同步到 Github:[https://github.com/databasemysql233-debug/gnu_java_utils_library.git](https://github.com/databasemysql233-debug/gnu_java_utils_library.git) > 注:此项目/仓库已同步到 Github:[https://github.com/databasemysql233-debug/gnu_java_utils_library.git](https://github.com/databasemysql233-debug/gnu_java_utils_library.git)
> 原项目/仓库链接:[https://git.opensource-studio.info/database-mysql/gnu_java_utils_library.git](https://git.opensource-studio.info/database-mysql/gnu_java_utils_library.git) > 原项目/仓库链接:[https://git.opensource-studio.info/database-mysql/gnu_java_utils_library.git](https://git.opensource-studio.info/database-mysql/gnu_java_utils_library.git)
@@ -23,7 +23,9 @@ src/main/java/top/gtb520/java/gnu/java/utils/library/
│ └── compress.java # 压缩/解压工具类 │ └── compress.java # 压缩/解压工具类
├── file/ ├── file/
│ ├── CreateFile.java # 文件创建/保存工具类 │ ├── CreateFile.java # 文件创建/保存工具类
│ └── EncryptionAndDecryption.java # 文件加密/解密工具类 │ ├── EncryptionAndDecryption.java # 文件加密/解密工具类
│ └── download/
│ └── file.java # 文件下载工具类(单线程/多线程)
├── print/ ├── print/
│ ├── echo.java # 终端彩色输出工具类 │ ├── echo.java # 终端彩色输出工具类
│ └── ColorNameEnum.java # 颜色名称枚举 │ └── ColorNameEnum.java # 颜色名称枚举
@@ -273,7 +275,40 @@ File keyFile = keyDir.resolve("data.txt.key").toFile();
File decrypted = EncryptionAndDecryption.DecryptionFile(file, EncryptionType.AES, keyFile); File decrypted = EncryptionAndDecryption.DecryptionFile(file, EncryptionType.AES, keyFile);
``` ```
### 5. 随机生成器 (`random`) ### 5. 文件下载 (`file/download`)
支持单线程和多线程文件下载,多线程模式通过 HTTP `Range` 请求分块并行下载,自动创建父目录。
| 方法 | 说明 | 参数 |
|-------------------------------------------------------|---------------------------------------|-------------------------------------------------------------|
| `file.DownloadFile(URL, destFilePanth)` | 单线程下载文件 | `URL` 文件地址,`destFilePanth` 保存路径 |
| `file.DownloadFile(URL, destFilePanth, threadNum)` | 多线程下载文件(支持断点续传) | `URL` 文件地址,`destFilePanth` 保存路径,`threadNum` 线程数枚举 |
**线程数枚举 (`ThreadNum`):**
| 枚举值 | 线程数 |
|-------------------|------|
| `THREAD_NUM_II` | 2 |
| `THREAD_NUM_IV` | 4 |
| `THREAD_NUM_VI` | 6 |
| `THREAD_NUM_VIII` | 8 |
| `THREAD_NUM_X` | 10 |
> 多线程下载要求服务器支持 `Accept-Ranges: bytes`(HTTP 206 响应),否则将抛出 `RuntimeException`。
> 入参为 `null` 时抛出 `IllegalArgumentException`;HTTP 请求失败时抛出 `RuntimeException`。
```java
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file;
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file.ThreadNum;
// 单线程下载
file.DownloadFile("https://example.com/file.zip", "/path/to/save/file.zip");
// 多线程下载(4 线程)
file.DownloadFile("https://example.com/file.zip", "/path/to/save/file.zip", ThreadNum.THREAD_NUM_IV);
```
### 6. 随机生成器 (`random`)
| 方法 | 说明 | 参数 | | 方法 | 说明 | 参数 |
|---------------------------------------------|--------------|------------------------------| |---------------------------------------------|--------------|------------------------------|
@@ -289,7 +324,7 @@ String str = defaults.randomString(16); // 生成 16 位随机字符串(
> `random(start, end)` 结果包含两端;`randomString(length)` 的长度不能为负数,否则抛出 `IllegalArgumentException`。 > `random(start, end)` 结果包含两端;`randomString(length)` 的长度不能为负数,否则抛出 `IllegalArgumentException`。
### 6. 硬件标识生成器 (`hwid`) ### 7. 硬件标识生成器 (`hwid`)
组合 CPU、主板、MAC 地址、硬盘序列号等信息,通过 SHA-256 生成唯一的硬件标识符(HWID)。支持 Windows、macOS、Linux 及 Android 平台。 组合 CPU、主板、MAC 地址、硬盘序列号等信息,通过 SHA-256 生成唯一的硬件标识符(HWID)。支持 Windows、macOS、Linux 及 Android 平台。
@@ -307,7 +342,7 @@ System.out.println("HWID: " + hwid);
> 单项硬件信息获取失败时自动跳过该组件(如无序列号的硬件);全部失败时返回 `"UNKNOWN_HWID"`。 > 单项硬件信息获取失败时自动跳过该组件(如无序列号的硬件);全部失败时返回 `"UNKNOWN_HWID"`。
> MAC 地址优先取非回环、非虚拟网卡,避免虚拟网卡导致 HWID 漂移。 > MAC 地址优先取非回环、非虚拟网卡,避免虚拟网卡导致 HWID 漂移。
### 7. 版本信息 (`version`) ### 8. 版本信息 (`version`)
提供项目版本信息及编译时 Java/OpenJDK 版本常量。 提供项目版本信息及编译时 Java/OpenJDK 版本常量。
@@ -338,7 +373,7 @@ main.version.getOpenJDKRuntimeEnvironment(); // OpenJDK 运行时环境
main.version.getOpenJDK64BitServerVM(); // OpenJDK 64-Bit Server VM main.version.getOpenJDK64BitServerVM(); // OpenJDK 64-Bit Server VM
``` ```
### 8. 运行时信息 (`runtime`) ### 9. 运行时信息 (`runtime`)
获取当前 Java 运行时环境信息,包括 JAR 路径、JVM 版本、位数等。 获取当前 Java 运行时环境信息,包括 JAR 路径、JVM 版本、位数等。
@@ -406,17 +441,17 @@ mvn package
## 测试 ## 测试
项目使用 JUnit 5 编写测试(共 53 个用例),运行: 项目使用 JUnit 5 编写测试(共 58 个用例),运行:
```bash ```bash
mvn test mvn test
``` ```
测试覆盖压缩/解压往返、Zip-Slip 恶意压缩包防护、AES/DES/RSA 加解密往返(含 8MB 大文件流式测试与错误密钥不损坏密文)、随机数边界(含 `Integer.MAX_VALUE` 上界)、彩色输出格式叠加与注入防护、临时目录创建与 HWID 稳定性。 测试覆盖压缩/解压往返、Zip-Slip 恶意压缩包防护、AES/DES/RSA 加解密往返(含 8MB 大文件流式测试与错误密钥不损坏密文)、随机数边界(含 `Integer.MAX_VALUE` 上界)、彩色输出格式叠加与注入防护、临时目录创建与 HWID 稳定性,以及文件下载(本地 HTTP 服务器驱动:单线程、多线程、小文件边界、服务器不支持断点续传时抛错)。
## 使用 ## 使用
编译成功后,将 `target/gnu_java_utils_library-1.7-SNAPSHOT.jar` 放入项目依赖中,或在 Releases 中下载最新版 JAR 包。 编译成功后,将 `target/gnu_java_utils_library-1.9-SNAPSHOT-Java21.jar` 放入项目依赖中,或在 Releases 中下载最新版 JAR 包。
**pom.xml 引入本地依赖:** **pom.xml 引入本地依赖:**
@@ -425,9 +460,9 @@ mvn test
<dependency> <dependency>
<groupId>top.gtb520.java.gnu</groupId> <groupId>top.gtb520.java.gnu</groupId>
<artifactId>gnu_java_utils_library</artifactId> <artifactId>gnu_java_utils_library</artifactId>
<version>1.7-SNAPSHOT</version> <version>1.9-SNAPSHOT-Java21</version>
<scope>system</scope> <scope>system</scope>
<systemPath>${project.basedir}/lib/gnu_java_utils_library-1.7-SNAPSHOT.jar</systemPath> <systemPath>${project.basedir}/lib/gnu_java_utils_library-1.9-SNAPSHOT-Java21.jar</systemPath>
</dependency> </dependency>
</dependencies> </dependencies>
``` ```
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<!--
最小化 Checkstyle 配置:
当前仅启用不涉及命名/布局风格的基础检查,保证 Java 21 项目在 verify 阶段通过。
后续可按需逐步追加规则(如 LineLength、Naming 等)。
-->
<module name="Checker">
<property name="charset" value="UTF-8"/>
<!-- 禁止使用制表符缩进 -->
<module name="FileTabCharacter"/>
<!-- 文件末尾需要换行 -->
<module name="NewlineAtEndOfFile"/>
<module name="TreeWalker">
<!-- 未使用的 import -->
<module name="UnusedImports"/>
<!-- 冗余的 import -->
<module name="RedundantImport"/>
<!-- 禁止星号通配 import -->
<module name="AvoidStarImport"/>
</module>
</module>
+34 -6
View File
@@ -6,7 +6,7 @@
<groupId>top.gtb520.java.gnu</groupId> <groupId>top.gtb520.java.gnu</groupId>
<artifactId>gnu_java_utils_library</artifactId> <artifactId>gnu_java_utils_library</artifactId>
<version>1.7-SNAPSHOT-Java21</version> <version>1.9-SNAPSHOT-Java21</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>GNU Java Utils Library</name> <name>GNU Java Utils Library</name>
@@ -68,25 +68,53 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId> <artifactId>maven-checkstyle-plugin</artifactId>
<version>3.1.2</version> <version>3.6.0</version>
<configuration> <configuration>
<configLocation>checkstyle.xml</configLocation> <configLocation>checkstyle.xml</configLocation>
<!-- <encoding>UTF-8</encoding>-->
<failOnViolation>true</failOnViolation>
</configuration> </configuration>
<executions>
<execution>
<id>checkstyle-check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin> </plugin>
<plugin> <plugin>
<groupId>org.codehaus.mojo</groupId> <groupId>com.github.spotbugs</groupId>
<artifactId>findbugs-maven-plugin</artifactId> <artifactId>spotbugs-maven-plugin</artifactId>
<version>3.0.5</version> <version>4.8.6.6</version>
<configuration>
<effort>Max</effort>
<threshold>Medium</threshold>
<failOnError>true</failOnError>
<xmlOutput>true</xmlOutput>
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
</configuration>
<executions>
<execution>
<id>spotbugs-check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin> </plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId> <artifactId>maven-javadoc-plugin</artifactId>
<version>3.3.0</version> <version>3.12.0</version>
<configuration> <configuration>
<show>private</show> <show>private</show>
<nohelp>true</nohelp> <nohelp>true</nohelp>
<doclint>none</doclint>
</configuration> </configuration>
</plugin> </plugin>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter xmlns="https://github.com/spotbugs/filter/4.8.6"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://github.com/spotbugs/filter/4.8.6 https://raw.githubusercontent.com/spotbugs/spotbugs/4.8.6/spotbugs/etc/findbugsfilter.xsd">
<!--
遗留命名规范:以下类/方法沿用了历史 API 命名(小写类名、PascalCase 方法名),
重命名属于破坏性变更,暂不处理,待后续大版本统一规范后移除本排除。
-->
<Match>
<Class name="~.*\.(compress|echo|version|defaults|getHWID|file)$"/>
<Bug pattern="NM_CLASS_NAMING_CONVENTION"/>
</Match>
<Match>
<Class name="~.*\.(CreateFile|EncryptionAndDecryption|echo|file)$"/>
<Bug pattern="NM_METHOD_NAMING_CONVENTION"/>
</Match>
<!--
REC_CATCH_EXCEPTION:CompressPNG.PNG 的兜底 catch(Exception) 为有意设计
(压缩失败统一返回 null),该检查噪音较高,全局排除。
-->
<Match>
<Bug pattern="REC_CATCH_EXCEPTION"/>
</Match>
</FindBugsFilter>
@@ -2,6 +2,7 @@ package top.gtb520.java.gnu.java.utils.library;
import top.gtb520.java.gnu.java.utils.library.utils.print.ColorNameEnum; import top.gtb520.java.gnu.java.utils.library.utils.print.ColorNameEnum;
import top.gtb520.java.gnu.java.utils.library.utils.compress.CompressPNG.PNGThreshold; import top.gtb520.java.gnu.java.utils.library.utils.compress.CompressPNG.PNGThreshold;
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file.ThreadNum;
import java.io.File; import java.io.File;
import java.nio.file.Path; import java.nio.file.Path;
@@ -153,6 +154,16 @@ public interface main {
static boolean StreamSaveFile(File file, Path TargePath, String FileName) { static boolean StreamSaveFile(File file, Path TargePath, String FileName) {
return top.gtb520.java.gnu.java.utils.library.utils.file.CreateFile.StreamSaveFile(file, TargePath, FileName); return top.gtb520.java.gnu.java.utils.library.utils.file.CreateFile.StreamSaveFile(file, TargePath, FileName);
} }
// 下载文件,单线程
static void DownloadFile(String URL, String destFilePanth) throws Exception {
top.gtb520.java.gnu.java.utils.library.utils.file.download.file.DownloadFile(URL, destFilePanth);
}
// 下载文件,多线程
static void DownloadFile(String URL, String destFilePanth, ThreadNum threadNum) throws Exception {
top.gtb520.java.gnu.java.utils.library.utils.file.download.file.DownloadFile(URL, destFilePanth, threadNum);
}
} }
// 文件加解密操作 // 文件加解密操作
@@ -52,7 +52,11 @@ public class compress {
if (entry.isDirectory()) { if (entry.isDirectory()) {
Files.createDirectories(targetPath); Files.createDirectories(targetPath);
} else { } else {
Files.createDirectories(targetPath.getParent()); Path parent = targetPath.getParent();
if (parent == null) {
throw new IllegalArgumentException("无法确定条目父目录: " + entry.getName());
}
Files.createDirectories(parent);
Files.copy(tarIn, targetPath); Files.copy(tarIn, targetPath);
} }
} }
@@ -115,7 +119,11 @@ public class compress {
} }
}); });
} else { } else {
TarArchiveEntry entry = new TarArchiveEntry(sourcePath, sourcePath.getFileName().toString()); Path fileName = sourcePath.getFileName();
if (fileName == null) {
throw new IllegalArgumentException("无法从源路径获取文件名: " + sourcePath);
}
TarArchiveEntry entry = new TarArchiveEntry(sourcePath, fileName.toString());
tarOut.putArchiveEntry(entry); tarOut.putArchiveEntry(entry);
Files.copy(sourcePath, tarOut); Files.copy(sourcePath, tarOut);
tarOut.closeArchiveEntry(); tarOut.closeArchiveEntry();
@@ -164,7 +172,11 @@ public class compress {
} }
}); });
} else { } else {
ZipArchiveEntry entry = new ZipArchiveEntry(sourcePath.getFileName().toString()); Path fileName = sourcePath.getFileName();
if (fileName == null) {
throw new IllegalArgumentException("无法从源路径获取文件名: " + sourcePath);
}
ZipArchiveEntry entry = new ZipArchiveEntry(fileName.toString());
zipOut.putArchiveEntry(entry); zipOut.putArchiveEntry(entry);
Files.copy(sourcePath, zipOut); Files.copy(sourcePath, zipOut);
zipOut.closeArchiveEntry(); zipOut.closeArchiveEntry();
@@ -200,7 +212,11 @@ public class compress {
if (entry.isDirectory()) { if (entry.isDirectory()) {
Files.createDirectories(targetPath); Files.createDirectories(targetPath);
} else { } else {
Files.createDirectories(targetPath.getParent()); Path parent = targetPath.getParent();
if (parent == null) {
throw new IllegalArgumentException("无法确定条目父目录: " + entry.getName());
}
Files.createDirectories(parent);
Files.copy(zipIn, targetPath); Files.copy(zipIn, targetPath);
} }
} }
@@ -53,6 +53,9 @@ public class EncryptionAndDecryption {
private static final String DES_ECB = "DES/ECB/PKCS5Padding"; private static final String DES_ECB = "DES/ECB/PKCS5Padding";
private static final String RSA_WRAP = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; private static final String RSA_WRAP = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
/** 共享的安全随机数发生器,避免每次加密都新建 SecureRandom 实例 */
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/** /**
* 文件加密。加密后覆盖原文件(原子替换),密钥保存到 keyPath 目录下,密钥名为 原文件名.key。 * 文件加密。加密后覆盖原文件(原子替换),密钥保存到 keyPath 目录下,密钥名为 原文件名.key。
* *
@@ -135,7 +138,7 @@ public class EncryptionAndDecryption {
private static void encryptAesGcm(File file, SecretKey key) throws Exception { private static void encryptAesGcm(File file, SecretKey key) throws Exception {
byte[] iv = new byte[GCM_IV_BYTES]; byte[] iv = new byte[GCM_IV_BYTES];
new SecureRandom().nextBytes(iv); SECURE_RANDOM.nextBytes(iv);
Cipher cipher = buildCipher(AES_GCM, Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); Cipher cipher = buildCipher(AES_GCM, Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
Path tmp = tempSibling(file); Path tmp = tempSibling(file);
@@ -172,7 +175,7 @@ public class EncryptionAndDecryption {
// 随机 AES-256 会话密钥,RSA-OAEP 包裹后存入文件头 // 随机 AES-256 会话密钥,RSA-OAEP 包裹后存入文件头
SecretKey sessionKey = generateKey("AES", AES_KEY_BITS); SecretKey sessionKey = generateKey("AES", AES_KEY_BITS);
byte[] iv = new byte[GCM_IV_BYTES]; byte[] iv = new byte[GCM_IV_BYTES];
new SecureRandom().nextBytes(iv); SECURE_RANDOM.nextBytes(iv);
Cipher wrapCipher = Cipher.getInstance(RSA_WRAP); Cipher wrapCipher = Cipher.getInstance(RSA_WRAP);
wrapCipher.init(Cipher.WRAP_MODE, publicKey); wrapCipher.init(Cipher.WRAP_MODE, publicKey);
@@ -279,6 +282,9 @@ public class EncryptionAndDecryption {
*/ */
private static Path tempSibling(File file) throws IOException { private static Path tempSibling(File file) throws IOException {
Path dir = file.toPath().toAbsolutePath().getParent(); Path dir = file.toPath().toAbsolutePath().getParent();
if (dir == null) {
throw new IOException("无法确定文件所在目录: " + file);
}
return Files.createTempFile(dir, file.getName() + ".", ".tmp"); return Files.createTempFile(dir, file.getName() + ".", ".tmp");
} }
@@ -0,0 +1,214 @@
package top.gtb520.java.gnu.java.utils.library.utils.file.download;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class file {
/**
* 单线程下载文件
*
* @param URL 文件地址
* @param destFilePanth 下载文件保存路径
*/
public static void DownloadFile(String URL, String destFilePanth) throws Exception {
if (URL == null || destFilePanth == null) {
throw new IllegalArgumentException("URL 和保存路径不能为 null");
}
Path destPath = Paths.get(destFilePanth);
// 确保父目录存在
Path parent = destPath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.ALWAYS)
.connectTimeout(Duration.ofSeconds(30))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(URL))
.GET()
.build();
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new RuntimeException("下载失败,HTTP 状态码: " + response.statusCode());
}
try (InputStream in = response.body()) {
Files.copy(in, destPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
/**
* 多线程下载文件
*
* @param URL 文件地址
* @param destFilePanth 下载文件保存路径
* @param threadNum 线程数
* */
public static void DownloadFile(String URL, String destFilePanth, ThreadNum threadNum) throws Exception {
if (URL == null || destFilePanth == null) {
throw new IllegalArgumentException("URL 和保存路径不能为 null");
}
Path destPath = Paths.get(destFilePanth);
// 确保父目录存在
Path parent = destPath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.ALWAYS)
.connectTimeout(Duration.ofSeconds(30))
.build();
// 获取文件大小
HttpRequest headRequest = HttpRequest.newBuilder()
.uri(URI.create(URL))
.method("HEAD", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<Void> headResponse = client.send(headRequest, HttpResponse.BodyHandlers.discarding());
if (headResponse.statusCode() != 200) {
throw new RuntimeException("获取文件信息失败,HTTP 状态码: " + headResponse.statusCode());
}
long fileSize = headResponse.headers().firstValueAsLong("content-length")
.orElseThrow(() -> new RuntimeException("无法获取文件大小"));
int numThreads = threadNum.getNum();
// 文件小于线程数时收敛有效线程数,避免出现空块或负数 Range
int effectiveThreads = (int) Math.max(1, Math.min(numThreads, fileSize));
long blockSize = fileSize / effectiveThreads;
// 创建临时文件占位
try (RandomAccessFile raf = new RandomAccessFile(destPath.toFile(), "rw")) {
raf.setLength(fileSize);
}
ExecutorService executor = Executors.newFixedThreadPool(effectiveThreads);
try {
CountDownLatch latch = new CountDownLatch(effectiveThreads);
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < effectiveThreads; i++) {
long start = i * blockSize;
if (start >= fileSize) {
// 空文件时跳过下载块
latch.countDown();
continue;
}
long end = (i == effectiveThreads - 1) ? fileSize - 1 : start + blockSize - 1;
// 末尾 return null 使 lambda 明确解析为 Callable(而非 Runnable),
// 从而允许 downloadBlock 抛出受检异常
futures.add(executor.submit(() -> {
try {
downloadBlock(client, URL, destPath.toFile(), start, end);
} finally {
latch.countDown();
}
return null;
}));
}
latch.await();
// 重新抛出块下载任务中的异常,避免下载失败被静默吞掉;
// 解包 ExecutionException,保持对外异常类型与单线程下载一致(RuntimeException)
for (Future<?> future : futures) {
try {
future.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new RuntimeException("下载块执行失败", cause);
}
}
} finally {
executor.shutdown();
}
}
/**
* 下载指定字节范围的数据块
*
* @param client HTTP 客户端
* @param url 文件 URL
* @param destFile 目标文件
* @param start 起始字节位置
* @param end 结束字节位置
*/
private static void downloadBlock(HttpClient client, String url, java.io.File destFile,
long start, long end) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Range", "bytes=" + start + "-" + end)
.GET()
.build();
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 206) {
throw new RuntimeException("服务器不支持断点续传,HTTP 状态码: " + response.statusCode());
}
try (InputStream in = response.body();
RandomAccessFile raf = new RandomAccessFile(destFile, "rw")) {
raf.seek(start);
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
raf.write(buffer, 0, bytesRead);
}
}
}
/**
* 线程数枚举
* */
public enum ThreadNum {
THREAD_NUM_II(2),
THREAD_NUM_IV(4),
THREAD_NUM_VI(6),
THREAD_NUM_VIII(8),
THREAD_NUM_X(10);
private final int num;
ThreadNum(int num) {
this.num = num;
}
public int getNum() {
return num;
}
}
}
@@ -3,7 +3,7 @@ package top.gtb520.java.gnu.java.utils.library;
public class version { public class version {
// 项目的版本信息,不代表实时运行环境 // 项目的版本信息,不代表实时运行环境
// 与 pom.xml 保持一致;构建 JAR 时优先读取 MANIFEST 的 Implementation-Version // 与 pom.xml 保持一致;构建 JAR 时优先读取 MANIFEST 的 Implementation-Version
public static final String VERSION = "1.7"; public static final String VERSION = "1.9";
public static final String JAVA_VERSION = "21"; public static final String JAVA_VERSION = "21";
public static final String OpenJDK_VERSION = "21"; public static final String OpenJDK_VERSION = "21";
public static final String OpenJDK_Runtime_Environment = "21+35-2513"; public static final String OpenJDK_Runtime_Environment = "21+35-2513";
@@ -0,0 +1,196 @@
package top.gtb520.java.gnu.java.utils.library.utils.file;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file;
import top.gtb520.java.gnu.java.utils.library.utils.file.download.file.ThreadNum;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.junit.jupiter.api.Assertions.*;
/**
* 文件下载模块测试。
* 使用 JDK 内置 HttpServer 在本地提供文件下载,不依赖外网,可重复执行。
*/
class DownloadFileTest {
private static HttpServer server;
private static ExecutorService serverExecutor;
private static byte[] data;
private static byte[] tinyData;
private static String baseUrl; // 支持 Range 的地址(256KB)
private static String tinyUrl; // 支持 Range 的地址(3 字节)
private static String noRangeUrl; // 不支持 Range 的地址
@BeforeAll
static void startServer() throws IOException {
// 256KB 测试数据
data = new byte[256 * 1024];
for (int i = 0; i < data.length; i++) {
data[i] = (byte) (i * 31 + 7);
}
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
serverExecutor = Executors.newFixedThreadPool(8);
server.setExecutor(serverExecutor);
// 支持 Range 的下载地址
server.createContext("/file.bin", exchange -> {
long start = 0;
long end = data.length - 1;
int status = 200;
String range = exchange.getRequestHeaders().getFirst("Range");
if (range != null && range.startsWith("bytes=")) {
String spec = range.substring("bytes=".length());
String[] parts = spec.split("-");
start = Long.parseLong(parts[0]);
if (parts.length > 1 && !parts[1].isEmpty()) {
end = Math.min(Long.parseLong(parts[1]), data.length - 1);
} else {
end = data.length - 1;
}
if (start > end || start >= data.length) {
exchange.sendResponseHeaders(416, -1);
exchange.close();
return;
}
status = 206;
exchange.getResponseHeaders().set("Content-Range",
"bytes " + start + "-" + end + "/" + data.length);
}
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
byte[] body = Arrays.copyOfRange(data, (int) start, (int) end + 1);
if ("HEAD".equalsIgnoreCase(exchange.getRequestMethod())) {
// HEAD 请求:显式返回 Content-Length(HttpServer 不会自动附加)
exchange.getResponseHeaders().set("Content-Length", String.valueOf(data.length));
exchange.sendResponseHeaders(status, -1);
} else {
exchange.sendResponseHeaders(status, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
exchange.close();
});
// 3 字节小文件(用于验证文件小于线程数的边界)
tinyData = new byte[]{1, 2, 3};
server.createContext("/tiny.bin", exchange -> {
long start = 0;
long end = tinyData.length - 1;
int status = 200;
String range = exchange.getRequestHeaders().getFirst("Range");
if (range != null && range.startsWith("bytes=")) {
String spec = range.substring("bytes=".length());
String[] parts = spec.split("-");
start = Long.parseLong(parts[0]);
if (parts.length > 1 && !parts[1].isEmpty()) {
end = Math.min(Long.parseLong(parts[1]), tinyData.length - 1);
} else {
end = tinyData.length - 1;
}
if (start > end || start >= tinyData.length) {
exchange.sendResponseHeaders(416, -1);
exchange.close();
return;
}
status = 206;
exchange.getResponseHeaders().set("Content-Range",
"bytes " + start + "-" + end + "/" + tinyData.length);
}
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
byte[] body = Arrays.copyOfRange(tinyData, (int) start, (int) end + 1);
if ("HEAD".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.getResponseHeaders().set("Content-Length", String.valueOf(tinyData.length));
exchange.sendResponseHeaders(status, -1);
} else {
exchange.sendResponseHeaders(status, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
exchange.close();
});
// 不支持 Range 的下载地址(总是返回 200 全量)
server.createContext("/no-range.bin", exchange -> {
if ("HEAD".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.getResponseHeaders().set("Content-Length", String.valueOf(data.length));
exchange.sendResponseHeaders(200, -1);
} else {
exchange.sendResponseHeaders(200, data.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(data);
}
}
exchange.close();
});
server.start();
int port = server.getAddress().getPort();
baseUrl = "http://127.0.0.1:" + port + "/file.bin";
tinyUrl = "http://127.0.0.1:" + port + "/tiny.bin";
noRangeUrl = "http://127.0.0.1:" + port + "/no-range.bin";
}
@AfterAll
static void stopServer() {
if (server != null) {
server.stop(0);
}
if (serverExecutor != null) {
serverExecutor.shutdownNow();
}
}
@Test
void singleThreadDownload(@TempDir Path tempDir) throws Exception {
Path dest = tempDir.resolve("single.bin");
file.DownloadFile(baseUrl, dest.toString());
assertTrue(Files.exists(dest));
assertArrayEquals(data, Files.readAllBytes(dest));
}
@Test
void multiThreadDownload(@TempDir Path tempDir) throws Exception {
Path dest = tempDir.resolve("multi.bin");
file.DownloadFile(baseUrl, dest.toString(), ThreadNum.THREAD_NUM_IV);
assertTrue(Files.exists(dest));
assertArrayEquals(data, Files.readAllBytes(dest));
}
@Test
void multiThreadDownloadTinyFile(@TempDir Path tempDir) throws Exception {
// 文件小于线程数(3 字节 / 4 线程)时应收敛线程数并正确下载
Path dest = tempDir.resolve("tiny.bin");
file.DownloadFile(tinyUrl, dest.toString(), ThreadNum.THREAD_NUM_IV);
assertArrayEquals(tinyData, Files.readAllBytes(dest));
}
@Test
void multiThreadDownloadWithoutRangeSupportFails(@TempDir Path tempDir) {
Path dest = tempDir.resolve("no-range.bin");
// 服务器不支持 Range 时多线程下载应抛出异常(不再静默失败)
assertThrows(RuntimeException.class,
() -> file.DownloadFile(noRangeUrl, dest.toString(), ThreadNum.THREAD_NUM_II));
}
@Test
void nullArgsThrow() {
assertThrows(IllegalArgumentException.class, () -> file.DownloadFile(null, "dest"));
assertThrows(IllegalArgumentException.class, () -> file.DownloadFile(baseUrl, null));
}
}