From f2e56aa8503dc53ebdee2409349ecde8ceb8df8b Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:40:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(storage):=20=E5=85=BC=E5=AE=B9=20COS=20?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=20Range=20=E7=9A=84=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=93=8D=E5=BA=94=20(#383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/storage_location.go | 39 ++++++++++++++++--- .../internal/service/storage_location_test.go | 33 ++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/storage_location.go b/backend/internal/service/storage_location.go index 8af898c58..1a897f2aa 100644 --- a/backend/internal/service/storage_location.go +++ b/backend/internal/service/storage_location.go @@ -26,6 +26,8 @@ type OSSConnectionTestResult struct { TestedDigest string `json:"testedDigest"` } +var errOSSConnectionReadMismatch = errors.New("对象存储读取内容不一致") + func (s *Service) TestAdminOSSSetting(actor *model.User, req OSSSettingRequest) (*OSSConnectionTestResult, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err @@ -110,11 +112,7 @@ func verifyOSSConnection(value ossSettingValue, testKey string) error { if err != nil { rangeErr = err } else { - data, readErr := io.ReadAll(io.LimitReader(stream.body, 5)) - closeErr := stream.body.Close() - if readErr != nil || closeErr != nil || string(data) != "ying" { - rangeErr = errors.New("对象存储 Range 读取测试失败") - } + rangeErr = verifyOSSConnectionRead(stream, payload) } if err := deleteOSSObject(testValue, testKey); err != nil { return storageConnectionTestError(testValue.Provider, "删除", err) @@ -125,6 +123,34 @@ func verifyOSSConnection(value ossSettingValue, testKey string) error { return nil } +func verifyOSSConnectionRead(stream *ossObjectStream, payload []byte) error { + if stream == nil || stream.body == nil { + return fmt.Errorf("%w:响应为空", errOSSConnectionReadMismatch) + } + data, readErr := io.ReadAll(io.LimitReader(stream.body, int64(len(payload)+1))) + closeErr := stream.body.Close() + if readErr != nil { + return fmt.Errorf("对象存储响应读取失败:%w", readErr) + } + if closeErr != nil { + return fmt.Errorf("对象存储响应关闭失败:%w", closeErr) + } + + // HTTP 允许服务端忽略 Range 并返回 200 + 完整对象。应用的资源代理也会 + // 原样透传这种响应,因此连接测试应同时接受完整读取与标准的 206 分段读取。 + switch stream.statusCode { + case http.StatusPartialContent: + if len(payload) >= 4 && bytes.Equal(data, payload[:4]) { + return nil + } + case http.StatusOK: + if bytes.Equal(data, payload) { + return nil + } + } + return fmt.Errorf("%w(HTTP %d,读取 %d 字节)", errOSSConnectionReadMismatch, stream.statusCode, len(data)) +} + func storageConnectionTestError(provider string, operation string, cause error) error { provider = strings.ToLower(strings.TrimSpace(provider)) providerName := "对象存储" @@ -146,6 +172,9 @@ func storageConnectionTestError(provider string, operation string, cause error) if errors.As(cause, &networkErr) && networkErr.Timeout() { return WrapAppError(http.StatusGatewayTimeout, fmt.Sprintf("%s %s测试超时,请检查 Endpoint 和服务端网络", providerName, operation), cause) } + if operation == "Range 读取" && errors.Is(cause, errOSSConnectionReadMismatch) { + return WrapAppError(http.StatusBadGateway, fmt.Sprintf("%s Range 读取返回的数据与刚写入对象不一致,请检查 Endpoint 或出网代理是否忽略、改写了 Range 请求", providerName), cause) + } if provider == tencentCOSProvider { var responseErr *cos.ErrorResponse diff --git a/backend/internal/service/storage_location_test.go b/backend/internal/service/storage_location_test.go index 5b730ffe2..7b0c843e0 100644 --- a/backend/internal/service/storage_location_test.go +++ b/backend/internal/service/storage_location_test.go @@ -77,6 +77,39 @@ func TestTencentCOSConnectionTestUsesStorageEndpointInsteadOfCDN(t *testing.T) { } } +func TestOSSConnectionReadAcceptsFullObjectWhenRangeIsIgnored(t *testing.T) { + payload := []byte("yingce-storage-test") + stream := &ossObjectStream{ + body: io.NopCloser(strings.NewReader(string(payload))), + statusCode: http.StatusOK, + contentLength: int64(len(payload)), + } + if err := verifyOSSConnectionRead(stream, payload); err != nil { + t.Fatal(err) + } +} + +func TestOSSConnectionReadRejectsUnexpectedContent(t *testing.T) { + payload := []byte("yingce-storage-test") + stream := &ossObjectStream{ + body: io.NopCloser(strings.NewReader("error page")), + statusCode: http.StatusOK, + contentLength: 10, + } + err := verifyOSSConnectionRead(stream, payload) + if err == nil { + t.Fatal("verifyOSSConnectionRead() error = nil") + } + if !errors.Is(err, errOSSConnectionReadMismatch) { + t.Fatalf("verifyOSSConnectionRead() error = %v", err) + } + publicErr := storageConnectionTestError(tencentCOSProvider, "Range 读取", err) + var appErr *AppError + if !errors.As(publicErr, &appErr) || !strings.Contains(appErr.Message, "忽略、改写了 Range 请求") { + t.Fatalf("storageConnectionTestError() = %#v", publicErr) + } +} + func TestTencentCOSConnectionTestReturnsActionableAuthError(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {