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: 每个 直接包含 Key/Size 等字段,无 包裹 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"` }