diff --git a/INFO.md b/INFO.md
index 1123e79..f62c029 100644
--- a/INFO.md
+++ b/INFO.md
@@ -1,4 +1,4 @@
-1.8-SNAPSHOT-Java21
+1.9-SNAPSHOT-Java21
此版本包含:
1. 压缩/解压 (`compress`)
@@ -13,22 +13,31 @@
4.1 AES-256/GCM 加密
4.2 RSA-2048/OAEP 混合加密
4.3 DES 加密(已废弃,仅兼容旧版本)
-5. 随机生成器 (`random`)
-6. 硬件标识生成器 (`hwid`)
-7. 版本信息 (`version`)
-8. 运行时信息 (`runtime`)
- 8.1 获取运行时临时目录
- 8.2 新建临时目录
- 8.3 获取当前运行的 JAR 文件路径
+5. 文件下载 (`file/download`)
+ 5.1 单线程下载
+ 5.2 多线程下载(HTTP Range 分块并行下载,支持断点续传)
+6. 随机生成器 (`random`)
+7. 硬件标识生成器 (`hwid`)
+8. 版本信息 (`version`)
+9. 运行时信息 (`runtime`)
+ 9.1 获取运行时临时目录
+ 9.2 新建临时目录
+ 9.3 获取当前运行的 JAR 文件路径
-1.8 更新内容:
+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 实例
-- 构建验证通过:mvn clean verify(53 个测试用例 + checkstyle + SpotBugs 0 问题)
- 与 mvn javadoc:javadoc 均成功
1.7 更新内容(历史):
- 安全:修复 Zip-Slip 路径穿越漏洞,解压时校验条目路径并拒绝符号/硬链接条目
@@ -43,4 +52,4 @@
版本号统一由 pom / MANIFEST Implementation-Version 提供
- 兼容性:向下降级为 Java 21
-在 1.7-SNAPSHOT-Java21 的基础上进行版本升级与质量插件修复。
\ No newline at end of file
+在 1.8-SNAPSHOT-Java21 的基础上新增文件下载模块并同步 1.9 版本号。
diff --git a/README.md b/README.md
index a89060d..f784dff 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 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)
> 原项目/仓库链接:[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 # 压缩/解压工具类
├── file/
│ ├── CreateFile.java # 文件创建/保存工具类
- │ └── EncryptionAndDecryption.java # 文件加密/解密工具类
+ │ ├── EncryptionAndDecryption.java # 文件加密/解密工具类
+ │ └── download/
+ │ └── file.java # 文件下载工具类(单线程/多线程)
├── print/
│ ├── echo.java # 终端彩色输出工具类
│ └── ColorNameEnum.java # 颜色名称枚举
@@ -273,7 +275,40 @@ File keyFile = keyDir.resolve("data.txt.key").toFile();
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`。
-### 6. 硬件标识生成器 (`hwid`)
+### 7. 硬件标识生成器 (`hwid`)
组合 CPU、主板、MAC 地址、硬盘序列号等信息,通过 SHA-256 生成唯一的硬件标识符(HWID)。支持 Windows、macOS、Linux 及 Android 平台。
@@ -307,7 +342,7 @@ System.out.println("HWID: " + hwid);
> 单项硬件信息获取失败时自动跳过该组件(如无序列号的硬件);全部失败时返回 `"UNKNOWN_HWID"`。
> MAC 地址优先取非回环、非虚拟网卡,避免虚拟网卡导致 HWID 漂移。
-### 7. 版本信息 (`version`)
+### 8. 版本信息 (`version`)
提供项目版本信息及编译时 Java/OpenJDK 版本常量。
@@ -338,7 +373,7 @@ main.version.getOpenJDKRuntimeEnvironment(); // OpenJDK 运行时环境
main.version.getOpenJDK64BitServerVM(); // OpenJDK 64-Bit Server VM
```
-### 8. 运行时信息 (`runtime`)
+### 9. 运行时信息 (`runtime`)
获取当前 Java 运行时环境信息,包括 JAR 路径、JVM 版本、位数等。
@@ -406,17 +441,17 @@ mvn package
## 测试
-项目使用 JUnit 5 编写测试(共 53 个用例),运行:
+项目使用 JUnit 5 编写测试(共 58 个用例),运行:
```bash
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.8-SNAPSHOT-Java21.jar` 放入项目依赖中,或在 Releases 中下载最新版 JAR 包。
+编译成功后,将 `target/gnu_java_utils_library-1.9-SNAPSHOT-Java21.jar` 放入项目依赖中,或在 Releases 中下载最新版 JAR 包。
**pom.xml 引入本地依赖:**
@@ -425,9 +460,9 @@ mvn test
top.gtb520.java.gnu
gnu_java_utils_library
- 1.8-SNAPSHOT-Java21
+ 1.9-SNAPSHOT-Java21
system
- ${project.basedir}/lib/gnu_java_utils_library-1.8-SNAPSHOT-Java21.jar
+ ${project.basedir}/lib/gnu_java_utils_library-1.9-SNAPSHOT-Java21.jar
```
diff --git a/pom.xml b/pom.xml
index 24ba5b0..7d70d11 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
top.gtb520.java.gnu
gnu_java_utils_library
- 1.8-SNAPSHOT-Java21
+ 1.9-SNAPSHOT-Java21
jar
GNU Java Utils Library
diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml
index cb20c5b..4c57952 100644
--- a/spotbugs-exclude.xml
+++ b/spotbugs-exclude.xml
@@ -8,11 +8,11 @@
重命名属于破坏性变更,暂不处理,待后续大版本统一规范后移除本排除。
-->
-
+
-
+
diff --git a/src/main/java/top/gtb520/java/gnu/java/utils/library/main.java b/src/main/java/top/gtb520/java/gnu/java/utils/library/main.java
index 3d5e950..d51793a 100644
--- a/src/main/java/top/gtb520/java/gnu/java/utils/library/main.java
+++ b/src/main/java/top/gtb520/java/gnu/java/utils/library/main.java
@@ -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.compress.CompressPNG.PNGThreshold;
+import top.gtb520.java.gnu.java.utils.library.utils.file.download.file.ThreadNum;
import java.io.File;
import java.nio.file.Path;
@@ -153,6 +154,16 @@ public interface main {
static boolean StreamSaveFile(File file, Path TargePath, String 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);
+ }
}
// 文件加解密操作
diff --git a/src/main/java/top/gtb520/java/gnu/java/utils/library/utils/file/download/file.java b/src/main/java/top/gtb520/java/gnu/java/utils/library/utils/file/download/file.java
new file mode 100644
index 0000000..fbe58c8
--- /dev/null
+++ b/src/main/java/top/gtb520/java/gnu/java/utils/library/utils/file/download/file.java
@@ -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 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 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> 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 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;
+ }
+ }
+}
diff --git a/src/main/java/top/gtb520/java/gnu/java/utils/library/version.java b/src/main/java/top/gtb520/java/gnu/java/utils/library/version.java
index 809b0ac..99791bb 100644
--- a/src/main/java/top/gtb520/java/gnu/java/utils/library/version.java
+++ b/src/main/java/top/gtb520/java/gnu/java/utils/library/version.java
@@ -3,7 +3,7 @@ package top.gtb520.java.gnu.java.utils.library;
public class version {
// 项目的版本信息,不代表实时运行环境
// 与 pom.xml 保持一致;构建 JAR 时优先读取 MANIFEST 的 Implementation-Version
- public static final String VERSION = "1.8";
+ public static final String VERSION = "1.9";
public static final String JAVA_VERSION = "21";
public static final String OpenJDK_VERSION = "21";
public static final String OpenJDK_Runtime_Environment = "21+35-2513";
diff --git a/src/test/java/top/gtb520/java/gnu/java/utils/library/utils/file/DownloadFileTest.java b/src/test/java/top/gtb520/java/gnu/java/utils/library/utils/file/DownloadFileTest.java
new file mode 100644
index 0000000..8f4d91b
--- /dev/null
+++ b/src/test/java/top/gtb520/java/gnu/java/utils/library/utils/file/DownloadFileTest.java
@@ -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));
+ }
+}