feat(attachment): 下载进度显示(含无 Content-Length 的按 MiB 步进模式)

This commit is contained in:
Taoyouce 2026-07-07 15:56:08 +00:00
parent b068b4a775
commit 19209c5cea
2 changed files with 71 additions and 17 deletions

View File

@ -17,10 +17,11 @@ import (
// reported on stderr.
const progressThreshold = 1 << 20 // 1 MiB
// progressReporter prints upload progress to stderr at 10% steps for files
// progressReporter prints transfer progress to stderr at 10% steps for files
// larger than progressThreshold. It implements io.Writer so it can sit on
// the tee side of the upload stream.
// the tee side of the transfer stream.
type progressReporter struct {
verb string
name string
total int64
done int64
@ -29,34 +30,54 @@ type progressReporter struct {
out io.Writer
}
func newProgressReporter(name string, total int64) *progressReporter {
return &progressReporter{name: name, total: total, lastPct: -1, out: os.Stderr}
func newProgressReporter(verb, name string, total int64) *progressReporter {
return &progressReporter{verb: verb, name: name, total: total, lastPct: -1, out: os.Stderr}
}
func newProgressReporterTo(name string, total int64, out io.Writer) *progressReporter {
return &progressReporter{name: name, total: total, lastPct: -1, out: out}
func newProgressReporterTo(verb, name string, total int64, out io.Writer) *progressReporter {
return &progressReporter{verb: verb, name: name, total: total, lastPct: -1, out: out}
}
func (p *progressReporter) Write(b []byte) (int, error) {
p.done += int64(len(b))
if p.total >= progressThreshold {
switch {
case p.total >= progressThreshold:
pct := p.done * 100 / p.total
if pct/10 > p.lastPct/10 || (pct == 100 && p.lastPct != 100) {
line := fmt.Sprintf("uploading %s: %d%% (%s / %s)", p.name, pct, formatBytes(p.done), formatBytes(p.total))
if pad := p.lastLine - len(line); pad > 0 {
line += strings.Repeat(" ", pad)
}
fmt.Fprintf(p.out, "\r%s", line)
p.lastLine = len(line)
p.print(fmt.Sprintf("%s %s: %d%% (%s / %s)", p.verb, p.name, pct, formatBytes(p.done), formatBytes(p.total)))
if pct >= 100 {
fmt.Fprintln(p.out)
}
p.lastPct = pct
}
case p.total <= 0:
// Unknown total (e.g. chunked downloads without Content-Length):
// report transferred bytes at every MiB boundary.
if step := p.done / progressThreshold; step > 0 && step > p.lastPct {
p.print(fmt.Sprintf("%s %s: %s", p.verb, p.name, formatBytes(p.done)))
p.lastPct = step
}
}
return len(b), nil
}
// Close finishes an unknown-total progress line with the final byte count.
func (p *progressReporter) Close() error {
if p.total <= 0 && p.done >= progressThreshold {
p.print(fmt.Sprintf("%s %s: %s", p.verb, p.name, formatBytes(p.done)))
fmt.Fprintln(p.out)
}
return nil
}
func (p *progressReporter) print(line string) {
if pad := p.lastLine - len(line); pad > 0 {
line += strings.Repeat(" ", pad)
}
fmt.Fprintf(p.out, "\r%s", line)
p.lastLine = len(line)
}
func formatBytes(n int64) string {
switch {
case n >= 1<<30:
@ -89,7 +110,7 @@ func (c *Client) PostMultipartFile(path, filePath, fileField string, fields map[
// are never buffered in memory.
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
progress := newProgressReporter(filepath.Base(filePath), info.Size())
progress := newProgressReporter("uploading", filepath.Base(filePath), info.Size())
go func() {
part, err := writer.CreateFormFile(fileField, filepath.Base(filePath))
if err != nil {
@ -222,7 +243,9 @@ func (c *Client) DownloadFile(path, destPath string) (int64, error) {
}
defer out.Close()
n, err := io.Copy(out, resp.Body)
progress := newProgressReporter("downloading", filepath.Base(destPath), resp.ContentLength)
n, err := io.Copy(out, io.TeeReader(resp.Body, progress))
progress.Close()
if err != nil {
return n, fmt.Errorf("write output file: %w", err)
}

View File

@ -9,7 +9,7 @@ import (
func TestProgressReporterLargeFile(t *testing.T) {
var buf bytes.Buffer
total := int64(4 << 20)
p := newProgressReporterTo("big.bin", total, &buf)
p := newProgressReporterTo("uploading", "big.bin", total, &buf)
chunk := make([]byte, 1<<20)
for i := 0; i < 4; i++ {
@ -31,7 +31,7 @@ func TestProgressReporterLargeFile(t *testing.T) {
func TestProgressReporterSmallFileSilent(t *testing.T) {
var buf bytes.Buffer
p := newProgressReporterTo("small.txt", 1024, &buf)
p := newProgressReporterTo("uploading", "small.txt", 1024, &buf)
if _, err := p.Write(make([]byte, 1024)); err != nil {
t.Fatal(err)
}
@ -40,6 +40,37 @@ func TestProgressReporterSmallFileSilent(t *testing.T) {
}
}
func TestProgressReporterUnknownTotal(t *testing.T) {
var buf bytes.Buffer
p := newProgressReporterTo("downloading", "chunked.bin", -1, &buf)
chunk := make([]byte, 1<<20)
for i := 0; i < 3; i++ {
if _, err := p.Write(chunk); err != nil {
t.Fatal(err)
}
}
p.Close()
out := buf.String()
if !strings.Contains(out, "downloading chunked.bin") {
t.Fatalf("missing progress prefix: %q", out)
}
if !strings.Contains(out, "3.0 MiB") {
t.Fatalf("missing final byte count: %q", out)
}
}
func TestProgressReporterUnknownTotalSmallSilent(t *testing.T) {
var buf bytes.Buffer
p := newProgressReporterTo("downloading", "small.bin", -1, &buf)
if _, err := p.Write(make([]byte, 1024)); err != nil {
t.Fatal(err)
}
p.Close()
if buf.Len() != 0 {
t.Fatalf("expected no progress output for small unknown-total transfer, got %q", buf.String())
}
}
func TestFormatBytes(t *testing.T) {
cases := map[int64]string{
512: "512 B",