新增: 云OSS存储集成(七牛云+阿里云)+多桶导航+GBK编码自动转换
This commit is contained in:
73
internal/oss/aliyun/bucket.go
Normal file
73
internal/oss/aliyun/bucket.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/oss"
|
||||
)
|
||||
|
||||
// ListBuckets 列出所有存储桶
|
||||
func ListBuckets(accessKeyID, accessKeySecret, endpoint string) ([]oss.BucketEntry, error) {
|
||||
if endpoint == "" {
|
||||
endpoint = "oss-cn-hangzhou.aliyuncs.com"
|
||||
}
|
||||
url := "https://" + endpoint + "/"
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")
|
||||
req.Header.Set("Date", date)
|
||||
|
||||
stringToSign := "GET\n\n\n" + date + "\n/"
|
||||
signature := sign(accessKeySecret, stringToSign)
|
||||
req.Header.Set("Authorization", "OSS "+accessKeyID+":"+signature)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("列举存储桶失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("列举存储桶失败: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result listAllMyBucketsResult
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析存储桶列表失败: %w", err)
|
||||
}
|
||||
|
||||
entries := make([]oss.BucketEntry, len(result.Buckets.Bucket))
|
||||
for i, b := range result.Buckets.Bucket {
|
||||
entries[i] = oss.BucketEntry{
|
||||
Name: b.Name,
|
||||
Region: b.Location,
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func sign(secretKey, data string) string {
|
||||
mac := hmac.New(sha1.New, []byte(secretKey))
|
||||
mac.Write([]byte(data))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
type listAllMyBucketsResult struct {
|
||||
XMLName xml.Name `xml:"ListAllMyBucketsResult"`
|
||||
Buckets struct {
|
||||
Bucket []bucketEntryXML `xml:"Bucket"`
|
||||
} `xml:"Buckets"`
|
||||
}
|
||||
|
||||
type bucketEntryXML struct {
|
||||
Name string `xml:"Name"`
|
||||
Location string `xml:"Location"`
|
||||
}
|
||||
572
internal/oss/aliyun/client.go
Normal file
572
internal/oss/aliyun/client.go
Normal file
@@ -0,0 +1,572 @@
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/oss"
|
||||
)
|
||||
|
||||
// Config 阿里云 OSS 配置
|
||||
type Config struct {
|
||||
AccessKeyID string // 访问密钥 ID
|
||||
AccessKeySecret string // 访问密钥 Secret
|
||||
Bucket string // 存储空间名称
|
||||
Region string // 区域,如 oss-cn-hangzhou
|
||||
Endpoint string // 自定义 Endpoint(可选)
|
||||
UseHTTPS bool // 是否使用 HTTPS
|
||||
}
|
||||
|
||||
// Client 阿里云 OSS 客户端
|
||||
type Client struct {
|
||||
config *Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient 创建阿里云 OSS 客户端
|
||||
func NewClient(config *Config) (*Client, error) {
|
||||
if config == nil {
|
||||
return nil, oss.NewError("INVALID_CONFIG", "config cannot be nil", nil)
|
||||
}
|
||||
if config.AccessKeyID == "" || config.AccessKeySecret == "" {
|
||||
return nil, oss.NewError("INVALID_CONFIG", "access key id and secret are required", nil)
|
||||
}
|
||||
if config.Bucket == "" {
|
||||
return nil, oss.NewError("INVALID_CONFIG", "bucket name is required", nil)
|
||||
}
|
||||
|
||||
// 设置默认区域
|
||||
if config.Region == "" {
|
||||
config.Region = "oss-cn-hangzhou" // 默认华东1(杭州)
|
||||
}
|
||||
|
||||
// 构建 Endpoint
|
||||
if config.Endpoint == "" {
|
||||
config.Endpoint = config.Region + ".aliyuncs.com"
|
||||
}
|
||||
|
||||
return &Client{
|
||||
config: config,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateSignature 生成阿里云 OSS 签名
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/signature-detail
|
||||
func (c *Client) generateSignature(method, path, contentType, date string, contentMD5 string) string {
|
||||
// 构建待签名字符串
|
||||
// StringToSign = HTTP-Verb + "\n" + Content-MD5 + "\n" + Content-Type + "\n" + Date + "\n" + CanonicalizedResource
|
||||
stringToSign := method + "\n"
|
||||
stringToSign += contentMD5 + "\n"
|
||||
stringToSign += contentType + "\n"
|
||||
stringToSign += date + "\n"
|
||||
stringToSign += path
|
||||
|
||||
// 使用 HMAC-SHA1 签名
|
||||
h := hmac.New(sha1.New, []byte(c.config.AccessKeySecret))
|
||||
h.Write([]byte(stringToSign))
|
||||
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return signature
|
||||
}
|
||||
|
||||
// generateSignatureWithHeaders 生成包含自定义头的签名
|
||||
// 用于需要包含 x-oss-* 头的请求(如 CopyObject)
|
||||
func (c *Client) generateSignatureWithHeaders(method, path, contentType, date string, headers map[string]string) string {
|
||||
// 构建待签名字符串
|
||||
stringToSign := method + "\n"
|
||||
stringToSign += "\n" // Content-MD5 (空)
|
||||
stringToSign += contentType + "\n"
|
||||
stringToSign += date + "\n"
|
||||
|
||||
// 添加 CanonicalizedOSSHeaders (以 x-oss- 开头的头)
|
||||
ossHeaders := c.canonicalizeOSSHeaders(headers)
|
||||
stringToSign += ossHeaders
|
||||
|
||||
// 添加 CanonicalizedResource
|
||||
stringToSign += path
|
||||
|
||||
// 使用 HMAC-SHA1 签名
|
||||
h := hmac.New(sha1.New, []byte(c.config.AccessKeySecret))
|
||||
h.Write([]byte(stringToSign))
|
||||
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return signature
|
||||
}
|
||||
|
||||
// canonicalizeOSSHeaders 规范化 OSS 自定义头
|
||||
// 将所有以 x-oss- 开头的头按字典序排序,并转换为小写
|
||||
func (c *Client) canonicalizeOSSHeaders(headers map[string]string) string {
|
||||
if len(headers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 提取以 x-oss- 开头的头
|
||||
var ossHeaders []string
|
||||
for k, v := range headers {
|
||||
if strings.HasPrefix(strings.ToLower(k), "x-oss-") {
|
||||
// 转换为小写并添加到列表
|
||||
lowerKey := strings.ToLower(k)
|
||||
ossHeaders = append(ossHeaders, lowerKey+":"+v)
|
||||
}
|
||||
}
|
||||
|
||||
// 按字典序排序
|
||||
// 这里简单处理,实际应该用排序算法
|
||||
for i := 0; i < len(ossHeaders); i++ {
|
||||
for j := i + 1; j < len(ossHeaders); j++ {
|
||||
if ossHeaders[i] > ossHeaders[j] {
|
||||
ossHeaders[i], ossHeaders[j] = ossHeaders[j], ossHeaders[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接结果
|
||||
if len(ossHeaders) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := ""
|
||||
for _, h := range ossHeaders {
|
||||
result += h + "\n"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
func (c *Client) Upload(ctx context.Context, key string, reader io.Reader, options *oss.UploadOptions) (*oss.UploadResult, error) {
|
||||
// 读取数据
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("UPLOAD_ERROR", "failed to read data", err)
|
||||
}
|
||||
|
||||
// 计算 Content-MD5
|
||||
hash := md5.Sum(data)
|
||||
contentMD5 := base64.StdEncoding.EncodeToString(hash[:])
|
||||
|
||||
// 设置 Content-Type
|
||||
contentType := "application/octet-stream"
|
||||
if options != nil && options.ContentType != "" {
|
||||
contentType = options.ContentType
|
||||
}
|
||||
|
||||
// 构建请求
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
path := "/" + c.config.Bucket + "/" + key
|
||||
signature := c.generateSignature("PUT", path, contentType, date, contentMD5)
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", url, strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return nil, oss.NewError("UPLOAD_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Content-MD5", contentMD5)
|
||||
|
||||
// 发送请求
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("UPLOAD_ERROR", "failed to upload file", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, oss.NewError("UPLOAD_ERROR",
|
||||
fmt.Sprintf("upload failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
// 获取 ETag
|
||||
etag := resp.Header.Get("ETag")
|
||||
// 去掉 ETag 的引号
|
||||
etag = strings.Trim(etag, "\"")
|
||||
|
||||
return &oss.UploadResult{
|
||||
Key: key,
|
||||
ETag: etag,
|
||||
Size: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Download 下载文件
|
||||
func (c *Client) Download(ctx context.Context, key string, writer io.Writer) error {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
path := "/" + c.config.Bucket + "/" + key
|
||||
signature := c.generateSignature("GET", path, "", date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return oss.NewError("DOWNLOAD_ERROR", "failed to download file", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return oss.NewError("DOWNLOAD_ERROR", fmt.Sprintf("download failed with status %d", resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
_, err = io.Copy(writer, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete 删除文件
|
||||
func (c *Client) Delete(ctx context.Context, key string) error {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
path := "/" + c.config.Bucket + "/" + key
|
||||
signature := c.generateSignature("DELETE", path, "", date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
||||
if err != nil {
|
||||
return oss.NewError("DELETE_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return oss.NewError("DELETE_ERROR", "failed to delete file", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 204 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return oss.NewError("DELETE_ERROR",
|
||||
fmt.Sprintf("delete failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileInfo 获取文件信息
|
||||
func (c *Client) GetFileInfo(ctx context.Context, key string) (*oss.FileInfo, error) {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
path := "/" + c.config.Bucket + "/" + key
|
||||
signature := c.generateSignature("HEAD", path, "", date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("STAT_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("STAT_ERROR", "failed to get file info", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, oss.ErrFileNotFound
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, oss.NewError("STAT_ERROR", fmt.Sprintf("stat failed with status %d", resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
// 解析响应头
|
||||
size := resp.ContentLength
|
||||
etag := resp.Header.Get("ETag")
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
_ = resp.Header.Get("Last-Modified") // 预留,可能需要解析时间
|
||||
|
||||
return &oss.FileInfo{
|
||||
Key: key,
|
||||
Size: size,
|
||||
ETag: strings.Trim(etag, "\""),
|
||||
ContentType: contentType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListFiles 列举文件
|
||||
func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.ListResult, error) {
|
||||
if options == nil {
|
||||
options = &oss.ListOptions{}
|
||||
}
|
||||
|
||||
if options.MaxKeys == 0 {
|
||||
options.MaxKeys = 100
|
||||
}
|
||||
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
|
||||
// 构建查询参数(URL 编码)
|
||||
query := url.Values{}
|
||||
query.Set("max-keys", fmt.Sprintf("%d", options.MaxKeys))
|
||||
if options.Prefix != "" {
|
||||
query.Set("prefix", options.Prefix)
|
||||
}
|
||||
if options.Marker != "" {
|
||||
query.Set("marker", options.Marker)
|
||||
}
|
||||
if options.Delimiter != "" {
|
||||
query.Set("delimiter", options.Delimiter)
|
||||
}
|
||||
|
||||
// CanonicalizedResource: list 参数(prefix/delimiter/marker/max-keys)不是子资源,不参与签名
|
||||
signPath := "/" + c.config.Bucket + "/"
|
||||
signature := c.generateSignature("GET", signPath, "", date, "")
|
||||
|
||||
requestURL := scheme + c.config.Bucket + "." + c.config.Endpoint + "/?" + query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("LIST_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("LIST_ERROR", "failed to list files", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("LIST_ERROR", "failed to read response", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, oss.NewError("LIST_ERROR",
|
||||
fmt.Sprintf("list failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
// 解析 XML 响应
|
||||
var result ListBucketResult
|
||||
if err := xml.Unmarshal(body, &result); err != nil {
|
||||
return nil, oss.NewError("LIST_ERROR", "failed to parse response", err)
|
||||
}
|
||||
|
||||
// 转换为统一格式
|
||||
files := make([]oss.FileInfo, 0, len(result.Contents))
|
||||
for _, obj := range result.Contents {
|
||||
lastMod := parseAliyunTime(obj.LastModified)
|
||||
files = append(files, oss.FileInfo{
|
||||
Key: obj.Key,
|
||||
Size: obj.Size,
|
||||
ETag: strings.Trim(obj.ETag, "\""),
|
||||
LastModified: lastMod,
|
||||
})
|
||||
}
|
||||
|
||||
prefixes := make([]string, 0)
|
||||
for _, p := range result.CommonPrefixes.Prefix {
|
||||
prefixes = append(prefixes, p)
|
||||
}
|
||||
|
||||
return &oss.ListResult{
|
||||
Files: files,
|
||||
IsTruncated: result.IsTruncated,
|
||||
NextMarker: result.NextMarker,
|
||||
Prefixes: prefixes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Copy 复制文件
|
||||
func (c *Client) Copy(ctx context.Context, sourceKey, targetKey string) error {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
path := "/" + c.config.Bucket + "/" + targetKey
|
||||
|
||||
// 设置自定义头
|
||||
headers := map[string]string{
|
||||
"x-oss-copy-source": "/" + c.config.Bucket + "/" + sourceKey,
|
||||
}
|
||||
signature := c.generateSignatureWithHeaders("PUT", path, "", date, headers)
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + targetKey
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", url, nil)
|
||||
if err != nil {
|
||||
return oss.NewError("COPY_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
// 设置头
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
req.Header.Set("x-oss-copy-source", "/"+c.config.Bucket+"/"+sourceKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return oss.NewError("COPY_ERROR", "failed to copy file", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return oss.NewError("COPY_ERROR",
|
||||
fmt.Sprintf("copy failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move 移动/重命名文件
|
||||
func (c *Client) Move(ctx context.Context, sourceKey, targetKey string) error {
|
||||
// 阿里云 OSS 通过复制 + 删除实现移动
|
||||
if err := c.Copy(ctx, sourceKey, targetKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Delete(ctx, sourceKey)
|
||||
}
|
||||
|
||||
// DeleteMultiple 批量删除文件
|
||||
func (c *Client) DeleteMultiple(ctx context.Context, keys []string) (*oss.DeleteResult, error) {
|
||||
result := &oss.DeleteResult{
|
||||
Deleted: make([]string, 0),
|
||||
Errors: make([]string, 0),
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if err := c.Delete(ctx, key); err != nil {
|
||||
result.Errors = append(result.Errors, key)
|
||||
} else {
|
||||
result.Deleted = append(result.Deleted, key)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetSignedURL 获取预签名URL
|
||||
func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) {
|
||||
// 阿里云 OSS 使用签名 URL
|
||||
// 格式: ?OSSAccessKeyId=xxx&Expires=xxx&Signature=xxx
|
||||
expiration := time.Now().Add(expiresIn).Unix()
|
||||
|
||||
// 构建签名
|
||||
path := "/" + c.config.Bucket + "/" + key
|
||||
stringToSign := "GET\n\n\n" + fmt.Sprintf("%d", expiration) + "\n" + path
|
||||
h := hmac.New(sha1.New, []byte(c.config.AccessKeySecret))
|
||||
h.Write([]byte(stringToSign))
|
||||
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
signature = strings.TrimRight(signature, "=") // URL Safe
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
baseURL := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key
|
||||
|
||||
signedURL := fmt.Sprintf("%s?OSSAccessKeyId=%s&Expires=%d&Signature=%s",
|
||||
baseURL, c.config.AccessKeyID, expiration, signature)
|
||||
|
||||
return signedURL, nil
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
|
||||
_, err := c.GetFileInfo(ctx, key)
|
||||
if err == oss.ErrFileNotFound {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *Client) Close() error {
|
||||
c.httpClient.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseAliyunTime 宽容解析阿里云时间格式
|
||||
func parseAliyunTime(s string) time.Time {
|
||||
for _, layout := range []string{
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05.000000Z",
|
||||
time.RFC3339,
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// ============ XML 数据结构 ============
|
||||
|
||||
// ListBucketResult 列举 Bucket 响应
|
||||
// 阿里云 XML: 每个 <Contents> 直接包含 Key/Size 等字段,无 <Object> 包裹
|
||||
type ListBucketResult struct {
|
||||
XMLName xml.Name `xml:"ListBucketResult"`
|
||||
Name string `xml:"Name"`
|
||||
Prefix string `xml:"Prefix"`
|
||||
Marker string `xml:"Marker"`
|
||||
MaxKeys int `xml:"MaxKeys"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
NextMarker string `xml:"NextMarker"`
|
||||
Delimiter string `xml:"Delimiter"`
|
||||
Contents []Object `xml:"Contents"`
|
||||
CommonPrefixes struct {
|
||||
Prefix []string `xml:"Prefix"`
|
||||
} `xml:"CommonPrefixes"`
|
||||
}
|
||||
|
||||
type Object struct {
|
||||
Key string `xml:"Key"`
|
||||
LastModified string `xml:"LastModified"`
|
||||
ETag string `xml:"ETag"`
|
||||
Size int64 `xml:"Size"`
|
||||
StorageClass string `xml:"StorageClass"`
|
||||
}
|
||||
521
internal/oss/aliyun/lifecycle.go
Normal file
521
internal/oss/aliyun/lifecycle.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/oss"
|
||||
)
|
||||
|
||||
// ============ 生命周期相关数据结构 ============
|
||||
|
||||
// LifecycleStorageClass 存储类型枚举
|
||||
type LifecycleStorageClass string
|
||||
|
||||
const (
|
||||
// StorageClassStandard 标准存储
|
||||
StorageClassStandard LifecycleStorageClass = "Standard"
|
||||
// StorageClassIA 低频存储 (Infrequent Access)
|
||||
StorageClassIA LifecycleStorageClass = "IA"
|
||||
// StorageClassArchive 归档存储
|
||||
StorageClassArchive LifecycleStorageClass = "Archive"
|
||||
// StorageClassColdArchive 冷归档存储
|
||||
StorageClassColdArchive LifecycleStorageClass = "ColdArchive"
|
||||
)
|
||||
|
||||
// LifecycleRule 生命周期规则
|
||||
type LifecycleRule struct {
|
||||
ID string `xml:"ID"` // 规则 ID
|
||||
Prefix string `xml:"Prefix"` // 前缀(应用于匹配的文件)
|
||||
Status string `xml:"Status"` // 状态:Enabled 或 Disabled
|
||||
|
||||
// Expiration 过期删除配置
|
||||
Expiration *LifecycleExpiration `xml:"Expiration,omitempty"`
|
||||
|
||||
// Transition 存储类型转换配置(可以有多个)
|
||||
Transitions []LifecycleTransition `xml:"Transition,omitempty"`
|
||||
|
||||
// AbortMultipartUpload 中止未完成的分片上传
|
||||
AbortMultipartUpload *LifecycleAbortMultipartUpload `xml:"AbortMultipartUpload,omitempty"`
|
||||
|
||||
// Filter 过滤器(与 Prefix 二选一)
|
||||
Filter *LifecycleFilter `xml:"Filter,omitempty"`
|
||||
}
|
||||
|
||||
// LifecycleExpiration 过期删除配置
|
||||
type LifecycleExpiration struct {
|
||||
Days int `xml:"Days,omitempty"` // 多少天后过期
|
||||
CreatedBeforeDate string `xml:"CreatedBeforeDate,omitempty"` // 指定日期之前创建的文件过期(格式:2023-01-01T00:00:00.000Z)
|
||||
ExpiredObjectDeleteMarker bool `xml:"ExpiredObjectDeleteMarker,omitempty"` // 删除过期删除标记
|
||||
}
|
||||
|
||||
// LifecycleTransition 存储类型转换配置
|
||||
type LifecycleTransition struct {
|
||||
Days int `xml:"Days,omitempty"` // 多少天后转换
|
||||
CreatedBeforeDate string `xml:"CreatedBeforeDate,omitempty"` // 指定日期之前创建的文件转换
|
||||
StorageClass LifecycleStorageClass `xml:"StorageClass"` // 目标存储类型
|
||||
}
|
||||
|
||||
// LifecycleAbortMultipartUpload 中止分片上传配置
|
||||
type LifecycleAbortMultipartUpload struct {
|
||||
Days int `xml:"Days,omitempty"` // 多少天后中止
|
||||
CreatedBeforeDate string `xml:"CreatedBeforeDate,omitempty"` // 指定日期之前创建的分片上传中止
|
||||
}
|
||||
|
||||
// LifecycleFilter 过滤器(用于更精细的规则匹配)
|
||||
type LifecycleFilter struct {
|
||||
// Prefix 前缀
|
||||
Prefix string `xml:"Prefix,omitempty"`
|
||||
// Tag 标签(可以有多个)
|
||||
Tag []LifecycleTag `xml:"Tag,omitempty"`
|
||||
// Not 非匹配条件
|
||||
Not *LifecycleNotFilter `xml:"Not,omitempty"`
|
||||
}
|
||||
|
||||
// LifecycleTag 标签
|
||||
type LifecycleTag struct {
|
||||
Key string `xml:"Key"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
// LifecycleNotFilter 非匹配条件
|
||||
type LifecycleNotFilter struct {
|
||||
Prefix string `xml:"Prefix,omitempty"`
|
||||
Tag LifecycleTag `xml:"Tag,omitempty"`
|
||||
}
|
||||
|
||||
// LifecycleConfiguration 生命周期配置
|
||||
type LifecycleConfiguration struct {
|
||||
XMLName xml.Name `xml:"LifecycleConfiguration"`
|
||||
Rules []LifecycleRule `xml:"Rule"`
|
||||
}
|
||||
|
||||
// ============ 生命周期管理方法 ============
|
||||
|
||||
// SetBucketLifecycle 设置生命周期规则
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/put-bucket-lifecycle
|
||||
func (c *Client) SetBucketLifecycle(ctx context.Context, rules []LifecycleRule) error {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建请求体
|
||||
config := LifecycleConfiguration{
|
||||
Rules: rules,
|
||||
}
|
||||
bodyBytes, err := xml.Marshal(config)
|
||||
if err != nil {
|
||||
return oss.NewError("LIFECYCLE_ERROR", "failed to marshal lifecycle config", err)
|
||||
}
|
||||
|
||||
// 添加 XML 声明
|
||||
bodyWithHeader := []byte(xml.Header + string(bodyBytes))
|
||||
|
||||
// 构建签名字符串 - 对于 bucket 级别操作,需要计算 Content-MD5
|
||||
contentType := "application/xml"
|
||||
path := "/" + c.config.Bucket + "/?lifecycle"
|
||||
|
||||
// 计算 Content-MD5
|
||||
hash := md5.Sum(bodyWithHeader)
|
||||
contentMD5 := base64.StdEncoding.EncodeToString(hash[:])
|
||||
|
||||
signature := c.generateSignature("PUT", path, contentType, date, contentMD5)
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/?lifecycle"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(bodyWithHeader))
|
||||
if err != nil {
|
||||
return oss.NewError("LIFECYCLE_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Content-MD5", contentMD5)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return oss.NewError("LIFECYCLE_ERROR", "failed to set lifecycle", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return oss.NewError("LIFECYCLE_ERROR",
|
||||
fmt.Sprintf("set lifecycle failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBucketLifecycle 获取生命周期规则
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/get-bucket-lifecycle
|
||||
func (c *Client) GetBucketLifecycle(ctx context.Context) ([]LifecycleRule, error) {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建签名字符串 - 使用 bucket/ 前缀
|
||||
path := "/" + c.config.Bucket + "/?lifecycle"
|
||||
signature := c.generateSignature("GET", path, "", date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/?lifecycle"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("LIFECYCLE_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("LIFECYCLE_ERROR", "failed to get lifecycle", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("LIFECYCLE_ERROR", "failed to read response", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
// 没有设置生命周期规则
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, oss.NewError("LIFECYCLE_ERROR",
|
||||
fmt.Sprintf("get lifecycle failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
// 解析 XML 响应
|
||||
var config LifecycleConfiguration
|
||||
if err := xml.Unmarshal(body, &config); err != nil {
|
||||
return nil, oss.NewError("LIFECYCLE_ERROR", "failed to parse response", err)
|
||||
}
|
||||
|
||||
return config.Rules, nil
|
||||
}
|
||||
|
||||
// DeleteBucketLifecycle 删除生命周期规则
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/delete-bucket-lifecycle
|
||||
func (c *Client) DeleteBucketLifecycle(ctx context.Context) error {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建签名字符串 - 使用 bucket/ 前缀
|
||||
path := "/" + c.config.Bucket + "/?lifecycle"
|
||||
signature := c.generateSignature("DELETE", path, "", date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/?lifecycle"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
||||
if err != nil {
|
||||
return oss.NewError("LIFECYCLE_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return oss.NewError("LIFECYCLE_ERROR", "failed to delete lifecycle", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 204 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return oss.NewError("LIFECYCLE_ERROR",
|
||||
fmt.Sprintf("delete lifecycle failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============ 便捷方法 ============
|
||||
|
||||
// SetExpirationRule 设置过期删除规则
|
||||
// 为指定前缀的文件设置过期删除天数
|
||||
func (c *Client) SetExpirationRule(ctx context.Context, ruleID, prefix string, days int) error {
|
||||
// 获取现有规则
|
||||
rules, err := c.GetBucketLifecycle(ctx)
|
||||
if err != nil {
|
||||
// 如果没有现有规则,创建新的规则列表
|
||||
rules = []LifecycleRule{}
|
||||
}
|
||||
|
||||
// 检查是否已存在相同 ID 的规则,如果存在则更新,否则添加
|
||||
found := false
|
||||
for i, r := range rules {
|
||||
if r.ID == ruleID {
|
||||
// 更新现有规则
|
||||
rules[i].Prefix = prefix
|
||||
rules[i].Status = "Enabled"
|
||||
rules[i].Expiration = &LifecycleExpiration{Days: days}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// 添加新规则
|
||||
rule := LifecycleRule{
|
||||
ID: ruleID,
|
||||
Prefix: prefix,
|
||||
Status: "Enabled",
|
||||
Expiration: &LifecycleExpiration{
|
||||
Days: days,
|
||||
},
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
return c.SetBucketLifecycle(ctx, rules)
|
||||
}
|
||||
|
||||
// SetTransitionRule 设置存储类型转换规则
|
||||
// 为指定前缀的文件设置存储类型转换
|
||||
func (c *Client) SetTransitionRule(ctx context.Context, ruleID, prefix string, days int, storageClass LifecycleStorageClass) error {
|
||||
// 获取现有规则
|
||||
rules, err := c.GetBucketLifecycle(ctx)
|
||||
if err != nil {
|
||||
// 如果没有现有规则,创建新的规则列表
|
||||
rules = []LifecycleRule{}
|
||||
}
|
||||
|
||||
// 检查是否已存在相同 ID 的规则,如果存在则更新,否则添加
|
||||
found := false
|
||||
for i, r := range rules {
|
||||
if r.ID == ruleID {
|
||||
// 更新现有规则
|
||||
rules[i].Prefix = prefix
|
||||
rules[i].Status = "Enabled"
|
||||
rules[i].Transitions = []LifecycleTransition{
|
||||
{Days: days, StorageClass: storageClass},
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// 添加新规则
|
||||
rule := LifecycleRule{
|
||||
ID: ruleID,
|
||||
Prefix: prefix,
|
||||
Status: "Enabled",
|
||||
Transitions: []LifecycleTransition{
|
||||
{
|
||||
Days: days,
|
||||
StorageClass: storageClass,
|
||||
},
|
||||
},
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
return c.SetBucketLifecycle(ctx, rules)
|
||||
}
|
||||
|
||||
// SetAbortMultipartUploadRule 设置中止分片上传规则
|
||||
// 为指定前缀的文件设置中止未完成的分片上传
|
||||
func (c *Client) SetAbortMultipartUploadRule(ctx context.Context, ruleID, prefix string, days int) error {
|
||||
rule := LifecycleRule{
|
||||
ID: ruleID,
|
||||
Prefix: prefix,
|
||||
Status: "Enabled",
|
||||
AbortMultipartUpload: &LifecycleAbortMultipartUpload{
|
||||
Days: days,
|
||||
},
|
||||
}
|
||||
return c.SetBucketLifecycle(ctx, []LifecycleRule{rule})
|
||||
}
|
||||
|
||||
// SetCombinedRule 设置组合生命周期规则
|
||||
// 同时支持过期删除和存储类型转换
|
||||
func (c *Client) SetCombinedRule(ctx context.Context, ruleID, prefix string, expirationDays int, transitionDays int, storageClass LifecycleStorageClass) error {
|
||||
rule := LifecycleRule{
|
||||
ID: ruleID,
|
||||
Prefix: prefix,
|
||||
Status: "Enabled",
|
||||
}
|
||||
|
||||
// 设置过期删除
|
||||
if expirationDays > 0 {
|
||||
rule.Expiration = &LifecycleExpiration{
|
||||
Days: expirationDays,
|
||||
}
|
||||
}
|
||||
|
||||
// 设置存储类型转换
|
||||
if transitionDays > 0 && storageClass != "" {
|
||||
rule.Transitions = []LifecycleTransition{
|
||||
{
|
||||
Days: transitionDays,
|
||||
StorageClass: storageClass,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return c.SetBucketLifecycle(ctx, []LifecycleRule{rule})
|
||||
}
|
||||
|
||||
// SetTempFileRule 设置临时文件规则
|
||||
// 为临时文件目录设置规则:先转为低频存储,然后删除
|
||||
func (c *Client) SetTempFileRule(ctx context.Context, prefix string, toIADays int, deleteDays int) error {
|
||||
ruleID := fmt.Sprintf("temp-files-%s", strings.ReplaceAll(prefix, "/", "-"))
|
||||
|
||||
// 获取现有规则
|
||||
rules, err := c.GetBucketLifecycle(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建新规则
|
||||
rule := LifecycleRule{
|
||||
ID: ruleID,
|
||||
Prefix: prefix,
|
||||
Status: "Enabled",
|
||||
}
|
||||
|
||||
// 设置存储类型转换
|
||||
if toIADays > 0 {
|
||||
rule.Transitions = []LifecycleTransition{
|
||||
{
|
||||
Days: toIADays,
|
||||
StorageClass: StorageClassIA,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 设置过期删除
|
||||
if deleteDays > 0 {
|
||||
rule.Expiration = &LifecycleExpiration{
|
||||
Days: deleteDays,
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到现有规则
|
||||
rules = append(rules, rule)
|
||||
|
||||
return c.SetBucketLifecycle(ctx, rules)
|
||||
}
|
||||
|
||||
// ClearTempFileRule 清除临时文件规则
|
||||
func (c *Client) ClearTempFileRule(ctx context.Context, prefix string) error {
|
||||
ruleID := fmt.Sprintf("temp-files-%s", strings.ReplaceAll(prefix, "/", "-"))
|
||||
|
||||
// 获取现有规则
|
||||
rules, err := c.GetBucketLifecycle(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 过滤掉要删除的规则
|
||||
newRules := make([]LifecycleRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule.ID != ruleID {
|
||||
newRules = append(newRules, rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新规则
|
||||
if len(newRules) == 0 {
|
||||
return c.DeleteBucketLifecycle(ctx)
|
||||
}
|
||||
|
||||
return c.SetBucketLifecycle(ctx, newRules)
|
||||
}
|
||||
|
||||
// ListLifecycleRules 列出所有生命周期规则(带详细信息)
|
||||
func (c *Client) ListLifecycleRules(ctx context.Context) ([]LifecycleRule, error) {
|
||||
return c.GetBucketLifecycle(ctx)
|
||||
}
|
||||
|
||||
// DisableLifecycleRule 禁用生命周期规则
|
||||
func (c *Client) DisableLifecycleRule(ctx context.Context, ruleID string) error {
|
||||
rules, err := c.GetBucketLifecycle(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 找到并禁用规则
|
||||
found := false
|
||||
for i := range rules {
|
||||
if rules[i].ID == ruleID {
|
||||
rules[i].Status = "Disabled"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return oss.NewError("LIFECYCLE_ERROR", "rule not found: "+ruleID, nil)
|
||||
}
|
||||
|
||||
return c.SetBucketLifecycle(ctx, rules)
|
||||
}
|
||||
|
||||
// EnableLifecycleRule 启用生命周期规则
|
||||
func (c *Client) EnableLifecycleRule(ctx context.Context, ruleID string) error {
|
||||
rules, err := c.GetBucketLifecycle(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 找到并启用规则
|
||||
found := false
|
||||
for i := range rules {
|
||||
if rules[i].ID == ruleID {
|
||||
rules[i].Status = "Enabled"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return oss.NewError("LIFECYCLE_ERROR", "rule not found: "+ruleID, nil)
|
||||
}
|
||||
|
||||
return c.SetBucketLifecycle(ctx, rules)
|
||||
}
|
||||
|
||||
// DeleteLifecycleRule 删除生命周期规则
|
||||
func (c *Client) DeleteLifecycleRule(ctx context.Context, ruleID string) error {
|
||||
rules, err := c.GetBucketLifecycle(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 过滤掉要删除的规则
|
||||
newRules := make([]LifecycleRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule.ID != ruleID {
|
||||
newRules = append(newRules, rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新规则
|
||||
if len(newRules) == 0 {
|
||||
return c.DeleteBucketLifecycle(ctx)
|
||||
}
|
||||
|
||||
return c.SetBucketLifecycle(ctx, newRules)
|
||||
}
|
||||
584
internal/oss/aliyun/multipart.go
Normal file
584
internal/oss/aliyun/multipart.go
Normal file
@@ -0,0 +1,584 @@
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/oss"
|
||||
)
|
||||
|
||||
// ============ 分片上传相关数据结构 ============
|
||||
|
||||
// PartInfo 分片信息
|
||||
type PartInfo struct {
|
||||
PartNumber int `xml:"PartNumber"` // 分片编号 (1-10000)
|
||||
ETag string `xml:"ETag"` // 分片的 ETag
|
||||
Size int64 `xml:"Size"` // 分片大小
|
||||
}
|
||||
|
||||
// InitiateMultipartUploadResult 初始化分片上传的响应
|
||||
type InitiateMultipartUploadResult struct {
|
||||
XMLName xml.Name `xml:"InitiateMultipartUploadResult"`
|
||||
Bucket string `xml:"Bucket"`
|
||||
Key string `xml:"Key"`
|
||||
UploadID string `xml:"UploadId"`
|
||||
}
|
||||
|
||||
// CompleteMultipartUploadRequest 完成分片上传的请求
|
||||
type CompleteMultipartUploadRequest struct {
|
||||
XMLName xml.Name `xml:"CompleteMultipartUploadRequest"`
|
||||
Parts []PartInfo `xml:"Part"`
|
||||
}
|
||||
|
||||
// CompleteMultipartUploadResult 完成分片上传的响应
|
||||
type CompleteMultipartUploadResult struct {
|
||||
XMLName xml.Name `xml:"CompleteMultipartUploadResult"`
|
||||
Location string `xml:"Location"`
|
||||
Bucket string `xml:"Bucket"`
|
||||
Key string `xml:"Key"`
|
||||
ETag string `xml:"ETag"`
|
||||
}
|
||||
|
||||
// ListPartsResult 列举分片的响应
|
||||
type ListPartsResult struct {
|
||||
XMLName xml.Name `xml:"ListPartsResult"`
|
||||
Bucket string `xml:"Bucket"`
|
||||
Key string `xml:"Key"`
|
||||
UploadID string `xml:"UploadId"`
|
||||
NextPartNumberMarker int `xml:"NextPartNumberMarker"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
MaxParts int `xml:"MaxParts"`
|
||||
PartNumberMarker int `xml:"PartNumberMarker"`
|
||||
StorageClass string `xml:"StorageClass"`
|
||||
Parts []PartInfo `xml:"Part"`
|
||||
}
|
||||
|
||||
// ListMultipartUploadsResult 列举分片上传任务的响应
|
||||
type ListMultipartUploadsResult struct {
|
||||
XMLName xml.Name `xml:"ListMultipartUploadsResult"`
|
||||
Bucket string `xml:"Bucket"`
|
||||
KeyMarker string `xml:"KeyMarker"`
|
||||
UploadIDMarker string `xml:"UploadIdMarker"`
|
||||
NextKeyMarker string `xml:"NextKeyMarker"`
|
||||
NextUploadIDMarker string `xml:"NextUploadIdMarker"`
|
||||
Delimiter string `xml:"Delimiter"`
|
||||
Prefix string `xml:"Prefix"`
|
||||
MaxUploads int `xml:"MaxUploads"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Uploads []struct {
|
||||
Key string `xml:"Key"`
|
||||
UploadID string `xml:"UploadId"`
|
||||
Initiated string `xml:"Initiated"`
|
||||
StorageClass string `xml:"StorageClass"`
|
||||
} `xml:"Upload"`
|
||||
}
|
||||
|
||||
// ============ 分片上传核心方法 ============
|
||||
|
||||
// InitiateMultipartUpload 初始化分片上传任务
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/initiate-multipart-upload
|
||||
func (c *Client) InitiateMultipartUpload(ctx context.Context, key string, contentType string) (string, error) {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建签名字符串(包含 ?uploads 参数)
|
||||
path := "/" + c.config.Bucket + "/" + key + "?uploads"
|
||||
signature := c.generateSignature("POST", path, contentType, date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key + "?uploads"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
|
||||
if err != nil {
|
||||
return "", oss.NewError("MULTIPART_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", oss.NewError("MULTIPART_ERROR", "failed to initiate multipart upload", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", oss.NewError("MULTIPART_ERROR", "failed to read response", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", oss.NewError("MULTIPART_ERROR",
|
||||
fmt.Sprintf("initiate multipart upload failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
// 解析 XML 响应
|
||||
var result InitiateMultipartUploadResult
|
||||
if err := xml.Unmarshal(body, &result); err != nil {
|
||||
return "", oss.NewError("MULTIPART_ERROR", "failed to parse response", err)
|
||||
}
|
||||
|
||||
return result.UploadID, nil
|
||||
}
|
||||
|
||||
// UploadPart 上传分片
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/upload-part
|
||||
func (c *Client) UploadPart(ctx context.Context, key, uploadID string, partNumber int, reader io.Reader) (string, error) {
|
||||
// 读取数据
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", oss.NewError("MULTIPART_ERROR", "failed to read part data", err)
|
||||
}
|
||||
|
||||
// 计算 Content-MD5
|
||||
hash := md5.Sum(data)
|
||||
contentMD5 := base64.StdEncoding.EncodeToString(hash[:])
|
||||
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建签名字符串(包含查询参数)
|
||||
path := fmt.Sprintf("/%s/%s?partNumber=%d&uploadId=%s",
|
||||
c.config.Bucket, key, partNumber, uploadID)
|
||||
signature := c.generateSignature("PUT", path, "application/octet-stream", date, contentMD5)
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := fmt.Sprintf("%s%s.%s/%s?partNumber=%d&uploadId=%s",
|
||||
scheme, c.config.Bucket, c.config.Endpoint, key, partNumber, uploadID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", oss.NewError("MULTIPART_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-MD5", contentMD5)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", oss.NewError("MULTIPART_ERROR", "failed to upload part", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", oss.NewError("MULTIPART_ERROR",
|
||||
fmt.Sprintf("upload part failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
// 从响应头获取 ETag
|
||||
etag := resp.Header.Get("ETag")
|
||||
etag = strings.Trim(etag, "\"")
|
||||
|
||||
return etag, nil
|
||||
}
|
||||
|
||||
// CompleteMultipartUpload 完成分片上传
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/complete-multipart-upload
|
||||
func (c *Client) CompleteMultipartUpload(ctx context.Context, key, uploadID string, parts []PartInfo) (*oss.UploadResult, error) {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建请求体 - 手动构建 XML 以确保格式正确
|
||||
// 阿里云要求的 XML 格式:
|
||||
// <?xml version="1.0" encoding="UTF-8"?>
|
||||
// <CompleteMultipartUpload>
|
||||
// <Part>
|
||||
// <PartNumber>1</PartNumber>
|
||||
// <ETag>"etag"</ETag> <!-- 注意:ETag 需要带引号 -->
|
||||
// </Part>
|
||||
// ...
|
||||
// </CompleteMultipartUpload>
|
||||
var xmlBuilder strings.Builder
|
||||
xmlBuilder.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||
xmlBuilder.WriteString("<CompleteMultipartUpload>\n")
|
||||
for _, part := range parts {
|
||||
// ETag 需要带引号
|
||||
etag := part.ETag
|
||||
if !strings.HasPrefix(etag, "\"") {
|
||||
etag = "\"" + etag
|
||||
}
|
||||
if !strings.HasSuffix(etag, "\"") {
|
||||
etag = etag + "\""
|
||||
}
|
||||
xmlBuilder.WriteString(fmt.Sprintf(" <Part>\n <PartNumber>%d</PartNumber>\n <ETag>%s</ETag>\n </Part>\n",
|
||||
part.PartNumber, etag))
|
||||
}
|
||||
xmlBuilder.WriteString("</CompleteMultipartUpload>")
|
||||
bodyBytes := []byte(xmlBuilder.String())
|
||||
|
||||
// 构建签名字符串
|
||||
contentType := "application/xml"
|
||||
path := fmt.Sprintf("/%s/%s?uploadId=%s", c.config.Bucket, key, uploadID)
|
||||
signature := c.generateSignature("POST", path, contentType, date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := fmt.Sprintf("%s%s.%s/%s?uploadId=%s",
|
||||
scheme, c.config.Bucket, c.config.Endpoint, key, uploadID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to complete multipart upload", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, oss.NewError("MULTIPART_ERROR",
|
||||
fmt.Sprintf("complete multipart upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil)
|
||||
}
|
||||
|
||||
// 解析 XML 响应
|
||||
var result CompleteMultipartUploadResult
|
||||
if err := xml.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err)
|
||||
}
|
||||
|
||||
return &oss.UploadResult{
|
||||
Key: result.Key,
|
||||
ETag: strings.Trim(result.ETag, "\""),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AbortMultipartUpload 中止分片上传任务
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/abort-multipart-upload
|
||||
func (c *Client) AbortMultipartUpload(ctx context.Context, key, uploadID string) error {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建签名字符串
|
||||
path := fmt.Sprintf("/%s/%s?uploadId=%s", c.config.Bucket, key, uploadID)
|
||||
signature := c.generateSignature("DELETE", path, "", date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
url := fmt.Sprintf("%s%s.%s/%s?uploadId=%s",
|
||||
scheme, c.config.Bucket, c.config.Endpoint, key, uploadID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
||||
if err != nil {
|
||||
return oss.NewError("MULTIPART_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return oss.NewError("MULTIPART_ERROR", "failed to abort multipart upload", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 204 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return oss.NewError("MULTIPART_ERROR",
|
||||
fmt.Sprintf("abort multipart upload failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListParts 列举已上传的分片
|
||||
// 参考: https://help.aliyun.com/zh/oss/developer-reference/list-parts
|
||||
func (c *Client) ListParts(ctx context.Context, key, uploadID string, maxParts int, partNumberMarker int) ([]PartInfo, error) {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建签名字符串
|
||||
path := fmt.Sprintf("/%s/%s?uploadId=%s", c.config.Bucket, key, uploadID)
|
||||
signature := c.generateSignature("GET", path, "", date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
|
||||
// 构建查询参数
|
||||
params := []string{
|
||||
fmt.Sprintf("uploadId=%s", uploadID),
|
||||
}
|
||||
if maxParts > 0 {
|
||||
params = append(params, fmt.Sprintf("max-parts=%d", maxParts))
|
||||
}
|
||||
if partNumberMarker > 0 {
|
||||
params = append(params, fmt.Sprintf("part-number-marker=%d", partNumberMarker))
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s%s.%s/%s?%s",
|
||||
scheme, c.config.Bucket, c.config.Endpoint, key, strings.Join(params, "&"))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to list parts", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, oss.NewError("MULTIPART_ERROR",
|
||||
fmt.Sprintf("list parts failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
// 解析 XML 响应
|
||||
var result ListPartsResult
|
||||
if err := xml.Unmarshal(body, &result); err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err)
|
||||
}
|
||||
|
||||
return result.Parts, nil
|
||||
}
|
||||
|
||||
// ListMultipartUploads 列举所有进行中的分片上传任务
|
||||
// 参考: 阿里云 OSS API 文档
|
||||
func (c *Client) ListMultipartUploads(ctx context.Context, prefix string, maxUploads int) ([]struct {
|
||||
Key string
|
||||
UploadID string
|
||||
Initiated string
|
||||
StorageClass string
|
||||
}, error) {
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
|
||||
// 构建签名字符串
|
||||
path := "/" + c.config.Bucket + "?uploads"
|
||||
signature := c.generateSignature("GET", path, "", date, "")
|
||||
|
||||
scheme := "https://"
|
||||
if !c.config.UseHTTPS {
|
||||
scheme = "http://"
|
||||
}
|
||||
|
||||
// 构建查询参数
|
||||
params := []string{"uploads"}
|
||||
if prefix != "" {
|
||||
params = append(params, "prefix="+prefix)
|
||||
}
|
||||
if maxUploads > 0 {
|
||||
params = append(params, fmt.Sprintf("max-uploads=%d", maxUploads))
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s%s.%s/?%s",
|
||||
scheme, c.config.Bucket, c.config.Endpoint, strings.Join(params, "&"))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to list multipart uploads", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, oss.NewError("MULTIPART_ERROR",
|
||||
fmt.Sprintf("list multipart uploads failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||||
}
|
||||
|
||||
// 解析 XML 响应
|
||||
var result ListMultipartUploadsResult
|
||||
if err := xml.Unmarshal(body, &result); err != nil {
|
||||
return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err)
|
||||
}
|
||||
|
||||
// 转换返回结果
|
||||
uploads := make([]struct {
|
||||
Key string
|
||||
UploadID string
|
||||
Initiated string
|
||||
StorageClass string
|
||||
}, 0, len(result.Uploads))
|
||||
for _, u := range result.Uploads {
|
||||
uploads = append(uploads, struct {
|
||||
Key string
|
||||
UploadID string
|
||||
Initiated string
|
||||
StorageClass string
|
||||
}{
|
||||
Key: u.Key,
|
||||
UploadID: u.UploadID,
|
||||
Initiated: u.Initiated,
|
||||
StorageClass: u.StorageClass,
|
||||
})
|
||||
}
|
||||
|
||||
return uploads, nil
|
||||
}
|
||||
|
||||
// ============ 高级辅助方法 ============
|
||||
|
||||
// UploadMultipart 使用分片上传方式上传文件
|
||||
// 自动将文件分片并上传,适用于大文件
|
||||
// 阿里云 OSS 要求:每个分片大小 100KB ~ 5GB,除最后一个分片外
|
||||
func (c *Client) UploadMultipart(ctx context.Context, key string, reader io.Reader, partSize int64, contentType string) (*oss.UploadResult, error) {
|
||||
// 默认分片大小为 10MB
|
||||
if partSize <= 0 {
|
||||
partSize = 10 * 1024 * 1024
|
||||
}
|
||||
|
||||
// 阿里云 OSS 要求:每个分片大小至少 100KB
|
||||
const minPartSize = 100 * 1024 // 100KB
|
||||
if partSize < minPartSize {
|
||||
partSize = minPartSize
|
||||
}
|
||||
|
||||
// 1. 初始化上传任务
|
||||
uploadID, err := c.InitiateMultipartUpload(ctx, key, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initiate multipart upload: %w", err)
|
||||
}
|
||||
|
||||
// 确保在失败时中止任务
|
||||
defer func() {
|
||||
if err != nil {
|
||||
c.AbortMultipartUpload(context.Background(), key, uploadID)
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. 读取所有数据并分片
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read data: %w", err)
|
||||
}
|
||||
|
||||
totalSize := int64(len(data))
|
||||
|
||||
// 如果文件太小,使用普通上传
|
||||
if totalSize < minPartSize {
|
||||
options := &oss.UploadOptions{ContentType: contentType}
|
||||
return c.Upload(ctx, key, bytes.NewReader(data), options)
|
||||
}
|
||||
|
||||
partCount := int((totalSize + partSize - 1) / partSize) // 向上取整
|
||||
|
||||
// 阿里云限制:最多 10000 个分片
|
||||
if partCount > 10000 {
|
||||
// 重新计算分片大小
|
||||
partSize = (totalSize + 9999) / 10000
|
||||
if partSize < minPartSize {
|
||||
partSize = minPartSize
|
||||
}
|
||||
partCount = int((totalSize + partSize - 1) / partSize)
|
||||
}
|
||||
|
||||
// 3. 上传各个分片
|
||||
parts := make([]PartInfo, 0, partCount)
|
||||
for i := 0; i < partCount; i++ {
|
||||
partNumber := i + 1
|
||||
start := i * int(partSize)
|
||||
end := start + int(partSize)
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
|
||||
partData := data[start:end]
|
||||
currentPartSize := int64(len(partData))
|
||||
|
||||
// 验证分片大小(除最后一个分片外,其他分片必须 >= 100KB)
|
||||
if i < partCount-1 && currentPartSize < minPartSize {
|
||||
return nil, fmt.Errorf("part %d size (%d bytes) is less than minimum required size (%d bytes)",
|
||||
partNumber, currentPartSize, minPartSize)
|
||||
}
|
||||
|
||||
etag, err := c.UploadPart(ctx, key, uploadID, partNumber, bytes.NewReader(partData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload part %d: %w", partNumber, err)
|
||||
}
|
||||
|
||||
parts = append(parts, PartInfo{
|
||||
PartNumber: partNumber,
|
||||
ETag: etag,
|
||||
Size: currentPartSize,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 完成上传
|
||||
result, err := c.CompleteMultipartUpload(ctx, key, uploadID, parts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to complete multipart upload: %w", err)
|
||||
}
|
||||
|
||||
// 成功,取消 defer 中的中止操作
|
||||
err = nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UploadWithRetry 带重试的分片上传
|
||||
// 支持失败重试,适用于不稳定的网络环境
|
||||
func (c *Client) UploadWithRetry(ctx context.Context, key string, reader io.Reader, partSize int64, maxRetries int, contentType string) (*oss.UploadResult, error) {
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 3
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// 每次重试需要重新读取数据
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := c.UploadMultipart(ctx, key, bytes.NewReader(data), partSize, contentType)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
// 等待一段时间后重试
|
||||
time.Sleep(time.Second * time.Duration(attempt+1))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
Reference in New Issue
Block a user