package service import ( "bytes" "context" "crypto/hmac" "crypto/sha1" "encoding/base64" "errors" "fmt" "io" "mime/multipart" "net" "net/http" "path" "strings" "time" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/google/uuid" ) const ( ossPutMaxAttempts = 3 ossPutBackoffBase = 80 * time.Millisecond ) // ErrOssNotConfigured OSS 未启用或必填项未配置完整。 var ErrOssNotConfigured = errors.New("未配置阿里云 OSS,请先在运营设置中启用并填写 Endpoint、Bucket、AccessKey 等参数") // OssUploadMultipartFile 将表单文件上传到已配置的阿里云 OSS(REST PutObject + 签名版本 1),返回对外访问 URL。 // 需 Bucket/对象可读(公共读、CDN 或已授权访问)。 func OssUploadMultipartFile(file *multipart.FileHeader, userID int) (string, error) { if !operation_setting.IsOssUploadReady() { return "", ErrOssNotConfigured } cfg := operation_setting.GetOssSetting() maxBytes := int64(cfg.MaxFileSizeMB) * 1024 * 1024 if cfg.MaxFileSizeMB <= 0 { maxBytes = 20 * 1024 * 1024 } if file.Size > maxBytes { return "", fmt.Errorf("文件超过大小限制(最大 %d MB)", cfg.MaxFileSizeMB) } f, err := file.Open() if err != nil { return "", err } defer f.Close() data, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) if err != nil { return "", err } if int64(len(data)) > maxBytes { return "", fmt.Errorf("文件超过大小限制(最大 %d MB)", cfg.MaxFileSizeMB) } orig := strings.TrimSpace(file.Filename) ext := path.Ext(orig) if ext != "" && len(ext) > 16 { ext = "" } ext = strings.ToLower(ext) objectKey := ossObjectKey(cfg.ObjectKeyPrefix, userID, ext) contentType := file.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } if err := ossPutObject(cfg, objectKey, contentType, data); err != nil { return "", err } return publicObjectURL(cfg, objectKey), nil } func ossObjectKey(prefix string, userID int, ext string) string { p := strings.Trim(prefix, "/") if p != "" { p += "/" } id := uuid.NewString() if ext != "" && !strings.HasPrefix(ext, ".") { ext = "." + ext } return fmt.Sprintf("%s%d/%s%s", p, userID, id, ext) } func publicObjectURL(cfg *operation_setting.OssSetting, objectKey string) string { base := strings.TrimSpace(cfg.PublicBaseURL) if base != "" { base = strings.TrimRight(base, "/") return base + "/" + strings.TrimLeft(objectKey, "/") } ep := strings.TrimSpace(cfg.Endpoint) ep = strings.TrimPrefix(ep, "https://") ep = strings.TrimPrefix(ep, "http://") bkt := strings.TrimSpace(cfg.Bucket) return fmt.Sprintf("https://%s.%s/%s", bkt, ep, strings.TrimLeft(objectKey, "/")) } // ossPutObject 使用 OSS 兼容的 Authorization: OSS AccessKeyId:Signature(HMAC-SHA1),带有限次指数退避重试。 func ossPutObject(cfg *operation_setting.OssSetting, objectKey, contentType string, body []byte) error { backoff := ossPutBackoffBase for attempt := 0; attempt < ossPutMaxAttempts; attempt++ { if attempt > 0 { time.Sleep(backoff) backoff *= 2 } httpStatus, err := ossPutObjectOnce(cfg, objectKey, contentType, body) if err == nil { return nil } if !ossPutShouldRetry(httpStatus, err) || attempt == ossPutMaxAttempts-1 { return err } } return errors.New("OSS Put: 内部错误(不应到达)") } func ossPutShouldRetry(httpStatus int, err error) bool { if err == nil { return false } if httpStatus == http.StatusTooManyRequests { return true } if httpStatus >= 500 && httpStatus <= 599 { return true } if httpStatus != 0 { return false } return isTransientOssNetErr(err) } func isTransientOssNetErr(err error) bool { if errors.Is(err, context.DeadlineExceeded) { return true } var ne net.Error if errors.As(err, &ne) && ne.Timeout() { return true } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "use of closed network connection") || strings.Contains(msg, "connection reset by peer") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "broken pipe") || strings.Contains(msg, "unexpected eof") } // ossPutObjectOnce 单次 PUT;httpStatus 在传输失败时为 0。 func ossPutObjectOnce(cfg *operation_setting.OssSetting, objectKey, contentType string, body []byte) (httpStatus int, err error) { endpoint := strings.TrimSpace(cfg.Endpoint) endpoint = strings.TrimPrefix(endpoint, "https://") endpoint = strings.TrimPrefix(endpoint, "http://") bucket := strings.TrimSpace(cfg.Bucket) ak := strings.TrimSpace(cfg.AccessKeyID) sk := strings.TrimSpace(cfg.AccessKeySecret) objectKey = strings.TrimLeft(objectKey, "/") canonicalResource := "/" + bucket + "/" + objectKey date := time.Now().UTC().Format(http.TimeFormat) // 与 OSS 文档一致:Verb、Content-MD5(空)、Content-Type、Date、CanonicalizedResource;无 x-oss-* 头时不在 Date 与 Resource 之间插入额外行。 stringToSign := fmt.Sprintf("PUT\n\n%s\n%s\n%s", contentType, date, canonicalResource) mac := hmac.New(sha1.New, []byte(sk)) _, _ = mac.Write([]byte(stringToSign)) sig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) auth := "OSS " + ak + ":" + sig host := bucket + "." + endpoint target := "https://" + host + "/" + objectKey req, err := http.NewRequest(http.MethodPut, target, bytes.NewReader(body)) if err != nil { return 0, err } req.Header.Set("Date", date) req.Header.Set("Content-Type", contentType) req.Header.Set("Authorization", auth) req.ContentLength = int64(len(body)) resp, err := GetOssHttpClient().Do(req) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return resp.StatusCode, fmt.Errorf("OSS 上传失败: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(b))) } return resp.StatusCode, nil }