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)); + } +}