Private
Public Access
1
0
Files
u-desk/internal/oss/aliyun/client.go

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