Skip to content

Conversation

@TwoOnefour
Copy link
Contributor

@TwoOnefour TwoOnefour commented Aug 25, 2025

流式上传 /api/fs/put 添加X-File-Size请求头 与Content-Length 一样用于传递文件大小,如果两个请求头同时存在优先采用Content-LengthX-File-Size可用于无法传递Content-Length请求头时使用,或者 仅使用X-File-Size(推荐)
close #1100

@xrgzs xrgzs requested a review from j2rong4cn August 25, 2025 15:54
@TwoOnefour
Copy link
Contributor Author

问题出在分块上传的时候,有些sdk的实现不会传入Content-Length, 导致f.GetSize()返回0

buf = make([]byte, bufSize)

下面的bufSize也就直接变成0了,而这个commit之前是所有cache都直接存入硬盘中,即utils.CreateTempFile,然后file会变成tmpF,之后GetSize()在 http chunk和非http chunk都会获取到正确的文件大小

@TwoOnefour
Copy link
Contributor Author

原来代码有些地方看着有点跟不上@j2rong4cn的思路,不太会改😢

虽然看起来像爆改,但我已经尽量保留原来的思路了

@j2rong4cn
Copy link
Member

我明天有空再改

@j2rong4cn
Copy link
Member

问题出在分块上传的时候,有些sdk的实现不会传入Content-Length, 导致f.GetSize()返回0

有个疑问,有些云盘在上传前要上报文件大小的,如果是流式上传用不到缓存,这种情况以前不也是会直接失败吗?

@TwoOnefour
Copy link
Contributor Author

问题出在分块上传的时候,有些sdk的实现不会传入Content-Length, 导致f.GetSize()返回0

有个疑问,有些云盘在上传前要上报文件大小的,如果是流式上传用不到缓存,这种情况以前不也是会直接失败吗?

有点没理解,是指的是原issue复现代码的情况吗

@TwoOnefour
Copy link
Contributor Author

问题出在分块上传的时候,有些sdk的实现不会传入Content-Length, 导致f.GetSize()返回0

有个疑问,有些云盘在上传前要上报文件大小的,如果是流式上传用不到缓存,这种情况以前不也是会直接失败吗?

b0dbbeb 之前流式上传也是会默认缓存到硬盘上的

你可以git revert 8c244a9

然后断点在

sizeStr = "0"

image

然后再git pull,再走一次上传流程,再断点在

return f.cache(f.GetSize())

跟着往下走就大概理解怎么回事了

复现代码可以用这个

点击展开/折叠代码
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"strings"
	"time"
)

func encodeFilePath(p string) string {
	p = strings.ReplaceAll(p, "\\", "/")
	parts := strings.Split(p, "/")
	for i, s := range parts {
		if s == "" {
			continue
		}
		parts[i] = url.PathEscape(s)
	}
	return strings.Join(parts, "/")
}

func normalizeDestPath(destPath, localFile string) string {
	destPath = strings.ReplaceAll(destPath, "\\", "/")
	if strings.HasSuffix(destPath, "/") {
		return path.Join(destPath, filepath.Base(localFile))
	}
	return destPath
}

func uploadFileChunked(filePath, putURL, token, destPath string, blockSize int, useChunked bool) error {
	f, err := os.Open(filePath)
	if err != nil {
		return printJSON(map[string]any{
			"ok":    false,
			"error": fmt.Sprintf("open file: %v", err),
		})
	}
	defer f.Close()

	fi, err := f.Stat()
	if err != nil {
		return printJSON(map[string]any{
			"ok":    false,
			"error": fmt.Sprintf("stat file: %v", err),
		})
	}
	size := fi.Size()

	destFull := normalizeDestPath(destPath, filePath)
	filePathHeader := encodeFilePath(destFull)

	pr, pw := io.Pipe()
	// 分块写入:边读文件边写到请求体
	go func() {
		defer pw.Close()
		buf := make([]byte, blockSize)
		for {
			n, rerr := f.Read(buf)
			if n > 0 {
				if _, werr := pw.Write(buf[:n]); werr != nil {
					_ = pw.CloseWithError(werr)
					return
				}
			}
			if rerr == io.EOF {
				return
			}
			if rerr != nil {
				_ = pw.CloseWithError(rerr)
				return
			}
		}
	}()

	req, err := http.NewRequest(http.MethodPut, putURL, pr)
	if err != nil {
		return printJSON(map[string]any{
			"ok":    false,
			"error": fmt.Sprintf("new request: %v", err),
		})
	}

	// 头部
	req.Header.Set("Authorization", token)
	req.Header.Set("As-Task", "true")
	req.Header.Set("File-Path", filePathHeader)
	req.Header.Set("Content-Type", "application/octet-stream")

	// 是否使用 HTTP chunked 传输:
	// - useChunked=false(默认):设置 ContentLength,避免启用 Transfer-Encoding: chunked
	// - useChunked=true:不设置 ContentLength,Go 会自动使用 chunked

	// 模拟不带content-length的情况
	if !useChunked {
		req.ContentLength = size
		req.Header.Set("Content-Length", fmt.Sprint(size))
	}

	//req.ContentLength = size
	//req.Header.Set("Content-Length", fmt.Sprint(size))

	client := &http.Client{Timeout: 2 * time.Minute}
	resp, err := client.Do(req)
	if err != nil {
		return printJSON(map[string]any{
			"ok":    false,
			"error": fmt.Sprintf("request failed: %v", err),
		})
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
		// 成功:JSON 输出
		out := map[string]any{
			"ok":         true,
			"status":     resp.StatusCode,
			"statusText": resp.Status,
		}
		if len(body) > 0 {
			if json.Valid(body) {
				var v any
				_ = json.Unmarshal(body, &v)
				out["body"] = v
			} else {
				out["body"] = string(body)
			}
		}
		return printJSON(out)
	}

	// 失败:也输出 JSON
	out := map[string]any{
		"ok":         false,
		"status":     resp.StatusCode,
		"statusText": resp.Status,
		"body":       string(body),
	}
	return printJSON(out)
}

func printJSON(v any) error {
	enc := json.NewEncoder(os.Stdout)
	enc.SetEscapeHTML(false)
	return enc.Encode(v)
}

func main() {
	filePath := "openlist.exe"
	putURL := "http://127.0.0.1:5244/api/fs/put"
	token := "xxx" // 填入token
	destPath := "/qaq/openlist.exe"

	const blockSize = 50 * 1024 * 1024 // 50MB
	useChunked := true                 // 需要 HTTP chunked 时改为 true

	_ = uploadFileChunked(filePath, putURL, token, destPath, blockSize, useChunked)
}

@j2rong4cn
Copy link
Member

j2rong4cn commented Aug 27, 2025

你试一下以前的没问题的版本,分块流式上传到本地存储应该也是0kb的
搞错了

@TwoOnefour
Copy link
Contributor Author

你试一下以前的没问题的版本,分块流式上传到本地存储应该也是0kb的

4.1.0吗,我试试

@TwoOnefour
Copy link
Contributor Author

你试一下以前的没问题的版本,分块流式上传到本地存储应该也是0kb的

直接网页端传吗,还是用代码,我复现不了

@j2rong4cn
Copy link
Member

j2rong4cn commented Aug 28, 2025

治标不治本,例如在chunk驱动中 请求头As-Task不是true时 无法分片

我觉得应该添加 File-Size 请求头

sizeStr := c.GetHeader("Content-Length")
if sizeStr == "" {
sizeStr = "0"
}
size, err := strconv.ParseInt(sizeStr, 10, 64)
if err != nil {
common.ErrorResp(c, err, 400)
return
}

@TwoOnefour
Copy link
Contributor Author

TwoOnefour commented Aug 28, 2025

治标不治本,例如在chunk驱动中还是0kb

我觉得应该添加 File-Size 请求头

sizeStr := c.GetHeader("Content-Length")
if sizeStr == "" {
sizeStr = "0"
}
size, err := strconv.ParseInt(sizeStr, 10, 64)
if err != nil {
common.ErrorResp(c, err, 400)
return
}

我试了一下chunk分支merge过来流式上传是没问题

image image

@j2rong4cn
Copy link
Member

我试了一下chunk分支merge过来流式上传是没问题

搞错了,As-Task请求头为false,是不能分片

@xrgzs
Copy link
Member

xrgzs commented Aug 30, 2025

#1100 的代码,合并这个 PR 后能上传

image image

就是服务端内存占用有点大 正常

image

完整测试代码:

src\main\java\com\example\FileUploader.java

package com.example;

import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.http.Header;
import cn.hutool.http.HttpConfig;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;

import java.io.File;

public class FileUploader {
    
    public static void main(String[] args) {
        try {
            // 配置HTTP参数
            HttpConfig httpConfig = new HttpConfig()
                    // 分块模式,每块50MB
                    .setBlockSize(1024 * 1024 * 50);
            
            // 要上传的文件路径(请根据实际情况修改)
            String filePath = "e:\\xxx\\2GB.bin";
            
            // 检查文件是否存在
            File file = new File(filePath);
            if (!file.exists()) {
                System.err.println("错误:文件不存在 - " + filePath);
                return;
            }
            
            System.out.println("开始上传文件: " + filePath);
            System.out.println("文件大小: " + file.length() + " 字节");
            
            // 发送HTTP PUT请求上传文件
            HttpResponse response = HttpRequest.put("http://10.0.1.111:5244/api/fs/put")
                    .timeout(1000 * 60 * 2)  // 超时时间2分钟
                    .setConfig(httpConfig)
                    .header(Header.AUTHORIZATION, "eyJxxx.xxx.xxx")  // 请替换为实际的授权token
                    .header("As-Task", "true")
                    .header("File-Path", "/123/Temp")  // 服务器上的目标路径
                    .header(Header.CONTENT_LENGTH, String.valueOf(file.length()))
                    .contentType("application/octet-stream")
                    .body(ResourceUtil.getResourceObj(filePath))
                    .execute();
                    
            // 检查响应结果
            if (response.isOk()) {
                System.out.println("文件上传成功!");
                System.out.println("响应状态码: " + response.getStatus());
                System.out.println("响应内容: " + response.body());
            } else {
                System.err.println("文件上传失败!");
                System.err.println("响应状态码: " + response.getStatus());
                System.err.println("错误信息: " + response.body());
            }
            
        } catch (Exception e) {
            System.err.println("上传过程中发生错误: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <groupId>com.example</groupId>
    <artifactId>file-upload</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    
    <dependencies>
        <!-- Hutool HTTP工具 -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-http</artifactId>
            <version>5.8.22</version>
        </dependency>
        
        <!-- Hutool 核心工具 -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-core</artifactId>
            <version>5.8.22</version>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                </configuration>
            </plugin>
            
            <!-- 创建可执行JAR -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.2.4</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>com.example.FileUploader</mainClass>
                                </transformer>
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

测试命令:

# 生成随机文件
dd if=/dev/urandom of=2GB.bin bs=1G count=2

# Maven 打包
mvn clean package

# 运行
java -jar target/file-upload-1.0.0.jar

@j2rong4cn
Copy link
Member

试试 请求头As-Task设置为false,

@xrgzs
Copy link
Member

xrgzs commented Aug 30, 2025

试试 请求头As-Task设置为false,

As-Task: false

合入这个 PR

image

内存占用还是一样,应该正常?

image
INFO[2025-08-30 10:53:46] max buffer limit: 1629MB
INFO[2025-08-30 10:53:46] mmap threshold: 4MB

data/temp 底下

image

如果不合,文件大小为 0,123 服务器直接秒传:

image

确保文件不会秒传,日志如下:

DEBU[2025-08-30 10:50:47]D:/temp/OpenList/server/middlewares/auth.go:73 github.com/OpenListTeam/OpenList/v4/server.Init.Auth.func10() use login token: &{ID:1 Username:admin PwdHash:xxxx PwdTS:0 Salt:xxxx Password: BasePath:/ Role:2 Disabled:false Permission:29183 OtpSecret: SsoID: Authn:[]}
DEBU[2025-08-30 10:50:47]D:/temp/OpenList/internal/op/path.go:27 github.com/OpenListTeam/OpenList/v4/internal/op.GetStorageAndActualPath() use storage:  /123
DEBU[2025-08-30 10:50:47]D:/temp/OpenList/internal/op/fs.go:179 github.com/OpenListTeam/OpenList/v4/internal/op.Get() op.Get /Temp
DEBU[2025-08-30 10:50:47]D:/temp/OpenList/internal/op/fs.go:122 github.com/OpenListTeam/OpenList/v4/internal/op.List() op.List /
DEBU[2025-08-30 10:50:47]D:/temp/OpenList/internal/op/fs.go:126 github.com/OpenListTeam/OpenList/v4/internal/op.List() use cache when list /
DEBU[2025-08-30 10:50:47]D:/temp/OpenList/internal/op/fs.go:179 github.com/OpenListTeam/OpenList/v4/internal/op.Get() op.Get /
DEBU[2025-08-30 10:50:47]D:/temp/OpenList/internal/op/fs.go:179 github.com/OpenListTeam/OpenList/v4/internal/op.Get() op.Get /
DEBU[2025-08-30 10:50:47]D:/temp/OpenList/drivers/123/driver.go:206 github.com/OpenListTeam/OpenList/v4/drivers/123.(*Pan123).Put() upload request res:  {"code":0,"message":"ok","data":{"AccessKeyId":null,"SecretAccessKey":null,"SessionToken":null,"Expiration":null,"Key":"xxx/xxx-0/xxx","Bucket":"","FileId":0,"Reuse":true,"Info":{"FileId":xxx,"FileName":"Temp","Type":0,"Size":0,"ContentType":"0","S3KeyFlag":"xxx-0","CreateAt":"2025-08-30T10:50:47.694252458+08:00","UpdateAt":"2025-08-30T10:50:47.69425258+08:00","Hidden":false,"Etag":"xxx","Status":2,"ParentFileId":0,"Category":0,"PunishFlag":0,"ParentName":"","DownloadUrl":"","AbnormalAlert":1,"Trashed":false,"TrashedExpire":"","TrashedAt":"","StorageNode":"m0","DirectLink":0,"AbsPath":"","PinYin":"temp","PreviewType":0,"BusinessType":0,"Thumbnail":"","Operable":false,"StarredStatus":0,"HighLight":"","LiveSize":0},"UploadId":"","DownloadUrl":"","StorageNode":"m0","EndPoint":"","UploadFileStatus":0,"SliceSize":"16777216","liveUploadInfo":null}}
DEBU[2025-08-30 10:50:47]D:/temp/OpenList/internal/op/fs.go:646 github.com/OpenListTeam/OpenList/v4/internal/op.Put() put file [Temp] done
[GIN] 2025/08/30 - 10:50:50 | 200 |    2.4614985s |      10.0.1.111 | PUT      "/api/fs/put"

@j2rong4cn
Copy link
Member

j2rong4cn commented Aug 30, 2025

那是因为这个PR只是修复了 As-Task为true时无法缓存的问题,,缓存之后是可以获取文件大小,
As-Task不是true时不缓存的话,部分驱动无法正常上传,这是另一个问题
#1152 (comment)

@xrgzs
Copy link
Member

xrgzs commented Aug 30, 2025

那是因为这个PR只是修复了 As-Task为true时无法缓存的问题,,缓存之后是可以获取文件大小, As-Task不是true时不缓存的话,部分驱动无法正常上次,这是另一个问题 #1152 (comment)

不合这个 PR,直接改成这样

	sizeStr := c.GetHeader("File-Size")
	if sizeStr == "" {
		sizeStr := c.GetHeader("Content-Length")
		if sizeStr == "" {
			sizeStr = "0"
		}
	}

然后 JAVA 客户端加上

.header("As-Task", "false")
.header("File-Size", String.valueOf(file.length()))
image image
.header("As-Task", "true")
.header("File-Size", String.valueOf(file.length()))
image image

也可以

内存占用还小

image

@j2rong4cn
Copy link
Member

要不要在客户端没有文件大小的请求头时报错呢?

j2rong4cn
j2rong4cn previously approved these changes Sep 15, 2025
Copy link
Member

@xrgzs xrgzs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM,记得更新一下文档和 Apifox

@j2rong4cn j2rong4cn merged commit cbbb5ad into OpenListTeam:main Sep 15, 2025
12 checks passed
ForSourceCodeAnalysis pushed a commit to ForSourceCodeAnalysis/OpenList that referenced this pull request Oct 4, 2025
* fix(stream): http chucked upload issue

* fix(stream): use MmapThreshold

* fix(stream): improve caching mechanism and handle size=0 case

* fix bug

* fix(buffer): optimize ReadAt method for improved performance

* fix(upload): handle Content-Length and File-Size headers for better size management

* fix(189pc): 移除重复限速

* fix(upload): handle negative file size during streaming uploads

* fix(upload): update header key from File-Size to X-File-Size for size retrieval

---------

Co-authored-by: j2rong4cn <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] 流式上传对分段式的支持出现了问题

4 participants