573 lines
16 KiB
Go
573 lines
16 KiB
Go
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"`
|
||
}
|