Repository: GopeedLab/gopeed

Issue:
[Bug] 下载多个重名文件时，已下载的文件会被覆盖
我使用 Gopeed 1.8.2 Windows桌面端从某数据集网站下载多份数据集文件，如下图所示，这些文件名都是一样的
<img width="561" height="873" alt="Image" src="https://github.com/user-attachments/assets/7ee4b63e-deea-4eee-867f-9dfa6713f475" />

下图是已经下载完成的列表
<img width="553" height="721" alt="Image" src="https://github.com/user-attachments/assets/f4053894-06e7-44ad-93db-c4c5aa99723f" />

下图是本地下载路径，可以看到`datasetdatadown`文件只保存了一份，已下载的文件疑似会被重名的文件覆盖
<img width="713" height="165" alt="Image" src="https://github.com/user-attachments/assets/0ad06fee-e683-458d-b4ef-513bfe0a3b6c" />

下面提供一些测试数据，可以使用HTTP直接下载
```
https://www.plantplus.cn/cn/datasetdatadown?guid=d3911fbe-4ffa-4971-ba1c-651a34df8a4b
https://www.plantplus.cn/cn/datasetdatadown?guid=d0b39fc8-5398-401d-b9b7-1bd3a429f41e
https://www.plantplus.cn/cn/datasetdatadown?guid=e6b870fc-7f54-493e-8e0d-5d92e4fa5549
https://www.plantplus.cn/cn/datasetdatadown?guid=c8835999-a726-4978-80e1-dac90324bbb0
https://www.plantplus.cn/cn/datasetdatadown?guid=30df149f-4f0c-4de9-9b62-b016342a2949
```

Discussion:
@Little-King2022: 另外，从**下载中**列表删除一个`datasetdatadown`任务时，本地**已下载完成**的`datasetdatadown`文件也会被删除，疑似是采用了文件名匹配，这样在多个重名文件下载时可能存在严重的逻辑问题@Little-King2022: 建议将下载中的文件名加入**uuid**和**临时文件扩展名**进行区分，下载完成后自动进行重命名，以下issue也有提到过类似的问题：#870 #1154 @monkeyWie: 如果文件名是带后缀的话是有做自动重命名处理的，估计这种没有后缀的文件名有问题，我看看怎么修复下

Solution patch:
diff --git a/pkg/util/path.go b/pkg/util/path.go
index a6a78a9..9855118 100644
--- a/pkg/util/path.go
+++ b/pkg/util/path.go
@@ -50,12 +50,17 @@ func CheckDuplicateAndRename(path string) (string, error) {
 		return "", err
 	}
 
-	index := strings.LastIndex(name, ".")
+	ext := syspath.Ext(name)
 	var nameTpl string
-	if index == -1 {
+	// Special case: if the extension is the entire filename (like .gitignore),
+	// or if index of last dot is 0 (starts with dot), treat it as no extension
+	if ext == "" || ext == name || (len(ext) > 0 && strings.LastIndex(name, ".") == 0) {
+		// No extension or hidden file without extension
 		nameTpl = name + " (%d)"
 	} else {
-		nameTpl = name[:index] + " (%d)" + name[index:]
+		// Has extension
+		nameWithoutExt := name[:len(name)-len(ext)]
+		nameTpl = nameWithoutExt + " (%d)" + ext
 	}
 	for i := 1; ; i++ {
 		newName := fmt.Sprintf(nameTpl, i)



Test patch:
diff --git a/pkg/util/path_test.go b/pkg/util/path_test.go
index 13d558d..3f5cf96 100644
--- a/pkg/util/path_test.go
+++ b/pkg/util/path_test.go
@@ -106,13 +106,28 @@ func TestSafeRemove(t *testing.T) {
 }
 
 func TestCheckDuplicateAndRename(t *testing.T) {
+	// Test with extension
 	doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt")
 	doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt")
 	doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt")
 
+	// Test without extension
 	doCheckDuplicateAndRename(t, []string{}, "a", "a")
 	doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)")
 	doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)")
+
+	// Test hidden files (starting with dot)
+	doCheckDuplicateAndRename(t, []string{}, ".gitignore", ".gitignore")
+	doCheckDuplicateAndRename(t, []string{".gitignore"}, ".gitignore", ".gitignore (1)")
+	doCheckDuplicateAndRename(t, []string{".gitignore", ".gitignore (1)"}, ".gitignore", ".gitignore (2)")
+
+	// Test hidden files with extension
+	doCheckDuplicateAndRename(t, []string{}, ".config.json", ".config.json")
+	doCheckDuplicateAndRename(t, []string{".config.json"}, ".config.json", ".config (1).json")
+
+	// Test multiple dots
+	doCheckDuplicateAndRename(t, []string{}, "test.tar.gz", "test.tar.gz")
+	doCheckDuplicateAndRename(t, []string{"test.tar.gz"}, "test.tar.gz", "test.tar (1).gz")
 }
 
 func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {