fix(desktop): 原生下载失败会在用户选定路径上留半截文件

桌面端 Go 侧此前零测试。补 5 个(download/ReadLocalFile/Ping),
逮到一个真 bug:io.Copy 中途断开时,用户选定的路径上会留下一个半截的
.docx —— 带着用户自己起的名字躺在那儿,虽然前端会弹错误,但以后双击打不开,
而用户会以为是导出功能坏了。现在失败一律 os.Remove 不留残file。

同批修的两处(同一段代码,都没测试盖到):
- Close 的错误被 defer 吞掉。写文件时 io.Copy 成功不代表数据落盘,
  flush 失败只在 Close 上报——吞掉就是静默截断,且 download 返回 nil(成功)。
- http.Get 用默认 client,没有超时。上游卡住的话「另存为」会永远转,
  用户只能强杀 app。改用带 3 分钟超时的 client。

顺带核实过一个可疑点、结论是不用改:download 走裸 http.Get 不带鉴权头,
但报告导出路由 `/reports/:id/export` 是故意公开的(router.go 注释:
"EventSource/下载无法带 Bearer"),所以能通。

对话框本身(application.Get().Dialog)要真窗口,自动化盖不到,仍需手点。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-16 12:06:21 +08:00
parent d52af12f3d
commit bf3b0048fd
2 changed files with 109 additions and 3 deletions
+20 -3
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
goruntime "runtime" goruntime "runtime"
"time"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
) )
@@ -68,8 +69,14 @@ func (a *App) Notify(title, body string) {
} }
} }
func download(url, dst string) error { // downloadClient 给下载加超时:默认 http.Get 用的 client 没有超时,
resp, err := http.Get(url) // 上游卡住的话「另存为」会永远转下去,用户只能强杀 app。
var downloadClient = &http.Client{Timeout: 3 * time.Minute}
// download 把 url 下载到 dst。失败一律不留残file:
// 中途断开会在用户选定的路径上留个半截 .docx,带着用户起的名字,以后双击打不开。
func download(url, dst string) (err error) {
resp, err := downloadClient.Get(url)
if err != nil { if err != nil {
return err return err
} }
@@ -81,7 +88,17 @@ func download(url, dst string) error {
if err != nil { if err != nil {
return err return err
} }
defer f.Close() defer func() {
// Close 的错误不能吞:写文件时 io.Copy 成功不代表数据落了盘,
// flush 失败只会在 Close 上报出来——吞掉就是静默截断。
cerr := f.Close()
if err == nil {
err = cerr
}
if err != nil {
_ = os.Remove(dst) // 失败不留残file
}
}()
_, err = io.Copy(f, resp.Body) _, err = io.Copy(f, resp.Body)
return err return err
} }
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// download 是原生「另存为 / 系统打开」真正落盘的那一段,之前零测试。
// 对话框本身要真窗口没法自动化,但下载这段是纯 HTTP + 文件 IO,必须钉住。
func TestDownloadOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("PK\x03\x04fake docx"))
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "r.docx")
if err := download(srv.URL, dst); err != nil {
t.Fatalf("下载应成功: %v", err)
}
b, err := os.ReadFile(dst)
if err != nil || string(b) != "PK\x03\x04fake docx" {
t.Fatalf("落盘内容不对: %q err=%v", b, err)
}
}
func TestDownloadHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "r.docx")
err := download(srv.URL, dst)
if err == nil {
t.Fatal("404 应该报错")
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("错误里应带状态码,便于用户判断: %v", err)
}
// 失败时不该在用户选定的路径上留一个空的/半截的 .docx ——
// 用户会以为导出成功,双击却打不开。
if _, statErr := os.Stat(dst); statErr == nil {
t.Error("下载失败却留下了文件(用户会当成导出成功的空文档)")
}
}
// io.Copy 中途断开:用户选定路径上不该留半截文件。
func TestDownloadTruncatedLeavesNoFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "999999") // 声明很长,实际写一点就断
_, _ = w.Write([]byte("half"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
panic(http.ErrAbortHandler) // 掐断连接
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "r.docx")
if err := download(srv.URL, dst); err == nil {
t.Fatal("连接中断应该报错")
}
if _, statErr := os.Stat(dst); statErr == nil {
t.Error("中断却留下了半截文件")
}
}
func TestReadLocalFile(t *testing.T) {
p := filepath.Join(t.TempDir(), "a.txt")
_ = os.WriteFile(p, []byte("你好"), 0o600)
got, err := (&App{}).ReadLocalFile(p)
if err != nil || got != "你好" {
t.Fatalf("读文件: got=%q err=%v", got, err)
}
if _, err := (&App{}).ReadLocalFile(filepath.Join(t.TempDir(), "nope")); err == nil {
t.Error("读不存在的文件应报错")
}
}
func TestPing(t *testing.T) {
if (&App{}).Ping() == "" {
t.Error("Ping 是前端探活 Go 桥的唯一手段,不能返回空")
}
}