tokenFactory/service/oss_upload.go

198 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 将表单文件上传到已配置的阿里云 OSSREST 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:SignatureHMAC-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 单次 PUThttpStatus 在传输失败时为 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
}