198 lines
5.8 KiB
Go
198 lines
5.8 KiB
Go
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
|
||
}
|