621 lines
17 KiB
Go
621 lines
17 KiB
Go
package qiniu
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha1"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"u-desk/internal/oss"
|
||
)
|
||
|
||
// Config 七牛云配置
|
||
type Config struct {
|
||
AccessKey string // 访问密钥
|
||
SecretKey string // 秘钥
|
||
Bucket string // 存储空间名称
|
||
Region string // 区域 z0=华东, z2=华南, as0=亚太0区
|
||
UseHTTPS bool // 是否使用 HTTPS
|
||
DownloadDomain string // 缓存的下载域名(由 resolveDownloadDomain 自动设置)
|
||
}
|
||
|
||
// Client 七牛云客户端
|
||
type Client struct {
|
||
config *Config
|
||
httpClient *http.Client
|
||
rsAPI string // 资源管理 API
|
||
rsfAPI string // 资源列举 API (RSF)
|
||
apiAPI string // API 服务
|
||
}
|
||
|
||
// NewClient 创建七牛云客户端
|
||
func NewClient(config *Config) (*Client, error) {
|
||
if config == nil {
|
||
return nil, oss.NewError("INVALID_CONFIG", "config cannot be nil", nil)
|
||
}
|
||
if config.AccessKey == "" || config.SecretKey == "" {
|
||
return nil, oss.NewError("INVALID_CONFIG", "access key and secret key are required", nil)
|
||
}
|
||
if config.Bucket == "" {
|
||
return nil, oss.NewError("INVALID_CONFIG", "bucket name is required", nil)
|
||
}
|
||
|
||
// 设置默认区域
|
||
if config.Region == "" {
|
||
config.Region = "z0" // 华东
|
||
}
|
||
|
||
return &Client{
|
||
config: config,
|
||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||
rsAPI: "http://rs.qiniu.com",
|
||
rsfAPI: "http://rsf.qbox.me", // 资源列举 API
|
||
apiAPI: "http://api.qiniu.com",
|
||
}, nil
|
||
}
|
||
|
||
// generateAuthToken 生成管理认证 Token
|
||
func (c *Client) generateAuthToken(method, path, host, contentType string, body []byte) string {
|
||
return c.generateAuthTokenWithQuery(method, path, "", host, contentType, body)
|
||
}
|
||
|
||
// generateAuthTokenWithQuery 生成管理认证 Token(支持 query string)
|
||
// https://developer.qiniu.com/kodo/1201/access-token
|
||
func (c *Client) generateAuthTokenWithQuery(method, path, query, host, contentType string, body []byte) string {
|
||
var signingStr string
|
||
|
||
signingStr = method + " " + path
|
||
if query != "" {
|
||
signingStr += "?" + query
|
||
}
|
||
signingStr += "\nHost: " + host
|
||
if contentType != "" {
|
||
signingStr += "\nContent-Type: " + contentType
|
||
}
|
||
signingStr += "\n\n"
|
||
if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 {
|
||
signingStr += string(body)
|
||
}
|
||
|
||
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
|
||
h.Write([]byte(signingStr))
|
||
signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||
|
||
return "Qiniu " + c.config.AccessKey + ":" + signature
|
||
}
|
||
|
||
// encodeEntry 编码 EntryURI (bucket:key)
|
||
func (c *Client) encodeEntry(key string) string {
|
||
entry := c.config.Bucket + ":" + key
|
||
return base64.URLEncoding.EncodeToString([]byte(entry))
|
||
}
|
||
|
||
// getUploadDomain 获取上传域名
|
||
func (c *Client) getUploadDomain() string {
|
||
if c.config.DownloadDomain != "" {
|
||
if c.config.UseHTTPS {
|
||
return "https://" + c.config.DownloadDomain
|
||
}
|
||
return "http://" + c.config.DownloadDomain
|
||
}
|
||
|
||
// 根据区域选择默认上传域名
|
||
// 七牛云上传域名格式: up-<region>.qiniup.com 或 upload-<region>.qbox.me
|
||
scheme := "https://"
|
||
if !c.config.UseHTTPS {
|
||
scheme = "http://"
|
||
}
|
||
|
||
// 根据区域返回上传域名
|
||
switch c.config.Region {
|
||
case "z0": // 华东
|
||
return scheme + "up-z0.qiniup.com"
|
||
case "z1": // 华北
|
||
return scheme + "up-z1.qiniup.com"
|
||
case "z2": // 华南
|
||
return scheme + "up-z2.qiniup.com"
|
||
case "na0": // 北美
|
||
return scheme + "up-na0.qiniup.com"
|
||
case "as0": // 亚太
|
||
return scheme + "up-as0.qiniup.com"
|
||
default:
|
||
// 默认使用华东
|
||
return scheme + "up-z0.qiniup.com"
|
||
}
|
||
}
|
||
|
||
// doRequest 执行 HTTP 请求
|
||
func (c *Client) doRequest(method, path string, body io.Reader) (*http.Response, error) {
|
||
url := c.rsAPI + path
|
||
|
||
// 解析 path 和 query string
|
||
signPath := path
|
||
queryString := ""
|
||
if idx := strings.Index(path, "?"); idx > 0 {
|
||
signPath = path[:idx]
|
||
queryString = path[idx+1:] // 去掉问号
|
||
}
|
||
|
||
// 读取 body 用于签名
|
||
var bodyBytes []byte
|
||
var err error
|
||
if body != nil {
|
||
bodyBytes, err = io.ReadAll(body)
|
||
if err != nil {
|
||
return nil, oss.NewError("REQUEST_ERROR", "failed to read request body", err)
|
||
}
|
||
}
|
||
|
||
req, err := http.NewRequest(method, url, bytes.NewReader(bodyBytes))
|
||
if err != nil {
|
||
return nil, oss.NewError("REQUEST_ERROR", "failed to create request", err)
|
||
}
|
||
|
||
// 设置 Content-Type
|
||
contentType := ""
|
||
if method == "POST" || method == "PUT" {
|
||
contentType = "application/x-www-form-urlencoded"
|
||
req.Header.Set("Content-Type", contentType)
|
||
}
|
||
|
||
// 设置管理认证头(使用新的签名算法,包含 query string)
|
||
host := "rs.qiniu.com"
|
||
authToken := c.generateAuthTokenWithQuery(method, signPath, queryString, host, contentType, bodyBytes)
|
||
req.Header.Set("Authorization", authToken)
|
||
|
||
return c.httpClient.Do(req)
|
||
}
|
||
|
||
// doRSFRequest 执行 RSF (资源列举) API 请求
|
||
// RSF API 使用不同的 host (rsf.qbox.me)
|
||
func (c *Client) doRSFRequest(method, path string) (*http.Response, error) {
|
||
url := c.rsfAPI + path
|
||
|
||
// 解析 path 和 query string
|
||
signPath := path
|
||
queryString := ""
|
||
if idx := strings.Index(path, "?"); idx > 0 {
|
||
signPath = path[:idx]
|
||
queryString = path[idx+1:] // 去掉问号
|
||
}
|
||
|
||
req, err := http.NewRequest(method, url, nil)
|
||
if err != nil {
|
||
return nil, oss.NewError("REQUEST_ERROR", "failed to create request", err)
|
||
}
|
||
|
||
// 设置 Content-Type
|
||
contentType := "application/x-www-form-urlencoded"
|
||
req.Header.Set("Content-Type", contentType)
|
||
|
||
// 设置管理认证头(使用 RSF host)
|
||
host := "rsf.qbox.me"
|
||
authToken := c.generateAuthTokenWithQuery(method, signPath, queryString, host, contentType, nil)
|
||
req.Header.Set("Authorization", authToken)
|
||
|
||
return c.httpClient.Do(req)
|
||
}
|
||
|
||
// Upload 上传文件 (使用表单上传)
|
||
func (c *Client) Upload(ctx context.Context, key string, reader io.Reader, options *oss.UploadOptions) (*oss.UploadResult, error) {
|
||
// 使用 UploadClient 进行上传
|
||
uploadClient := NewUploadClient(c.config)
|
||
return uploadClient.Upload(ctx, key, reader)
|
||
}
|
||
|
||
// generateToken 生成上传凭证
|
||
func (c *Client) generateToken(scope string) string {
|
||
putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`, scope, time.Now().Add(1*time.Hour).Unix())
|
||
encoded := base64.URLEncoding.EncodeToString([]byte(putPolicy))
|
||
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
|
||
h.Write([]byte(encoded))
|
||
sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||
return c.config.AccessKey + ":" + sign + ":" + encoded
|
||
}
|
||
|
||
func (c *Client) generateUploadToken(key string) string {
|
||
return c.generateToken(c.config.Bucket + ":" + key)
|
||
}
|
||
|
||
func (c *Client) generateBucketToken() string {
|
||
return c.generateToken(c.config.Bucket)
|
||
}
|
||
|
||
// 七牛云临时域名后缀(平台分配的 CDN 域名,稳定性高)
|
||
var qiniuTempSuffixes = []string{
|
||
".qiniudns.com", ".clouddn.com", ".qbox.me",
|
||
".qnssl.com", ".qnybgz.cn", ".qiniudns.com.cn",
|
||
}
|
||
|
||
// extractHost 从 URL 提取主机名(去掉 scheme、path、port)
|
||
func extractHost(domainURL string) string {
|
||
host := strings.TrimPrefix(domainURL, "http://")
|
||
host = strings.TrimPrefix(host, "https://")
|
||
if idx := strings.Index(host, "/"); idx >= 0 {
|
||
host = host[:idx]
|
||
}
|
||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||
return h
|
||
}
|
||
return host
|
||
}
|
||
|
||
// isTempDomain 判断是否为七牛平台分配的临时域名(后缀匹配)
|
||
func (c *Client) isTempDomain(domain string) bool {
|
||
host := strings.ToLower(extractHost(domain))
|
||
for _, s := range qiniuTempSuffixes {
|
||
if strings.HasSuffix(host, s) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// classifyDomains 将域名列表分为临时域名和自定义域名
|
||
func (c *Client) classifyDomains(domains []string) (tempDomains, customDomains []string) {
|
||
for _, d := range domains {
|
||
if !strings.HasPrefix(d, "http://") && !strings.HasPrefix(d, "https://") {
|
||
d = "http://" + d
|
||
}
|
||
if c.isTempDomain(d) {
|
||
tempDomains = append(tempDomains, d)
|
||
} else {
|
||
customDomains = append(customDomains, d)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// resolveDownloadDomain 解析并缓存下载域名
|
||
// 策略:API 域名列表(临时优先→自定义)→ 兜底默认 CDN
|
||
// 不做 HTTP 探测:Download 使用签名 URL,即使有防盗链也能通过
|
||
func (c *Client) resolveDownloadDomain() (string, error) {
|
||
if c.config.DownloadDomain != "" {
|
||
return c.config.DownloadDomain, nil
|
||
}
|
||
|
||
domains, err := c.GetBucketDomains(context.Background())
|
||
|
||
if err == nil && len(domains) > 0 {
|
||
tempDomains, customDomains := c.classifyDomains(domains)
|
||
|
||
// 精准获取桶的真实区域
|
||
c.resolveRegion(tempDomains)
|
||
|
||
// 优先使用临时域名(平台分配,稳定性高)
|
||
if len(tempDomains) > 0 {
|
||
d := tempDomains[0]
|
||
c.config.DownloadDomain = d
|
||
return d, nil
|
||
}
|
||
// 降级到自定义域名
|
||
if len(customDomains) > 0 {
|
||
d := customDomains[0]
|
||
c.config.DownloadDomain = d
|
||
return d, nil
|
||
}
|
||
}
|
||
|
||
// 无域名 → 兜底默认 CDN(可能不存在,但给一个机会)
|
||
fallback := c.defaultCDNDomain()
|
||
c.config.DownloadDomain = fallback
|
||
return fallback, nil
|
||
}
|
||
|
||
// defaultCDNDomain 构造七牛默认 CDN 域名
|
||
func (c *Client) defaultCDNDomain() string {
|
||
return fmt.Sprintf("http://%s-%s.qiniudns.com", c.config.Bucket, c.config.Region)
|
||
}
|
||
|
||
// ClearDownloadDomain 清除缓存的下载域名(下载失败时调用,下次重新解析)
|
||
func (c *Client) ClearDownloadDomain() {
|
||
c.config.DownloadDomain = ""
|
||
}
|
||
|
||
// resolveRegion 精准获取桶的真实区域
|
||
// 优先从临时域名提取 → 查询 API → 使用配置值兜底
|
||
func (c *Client) resolveRegion(tempDomains []string) {
|
||
// 1. 从临时域名提取
|
||
bucketLower := strings.ToLower(c.config.Bucket)
|
||
for _, d := range tempDomains {
|
||
host := extractHost(d)
|
||
host = strings.ToLower(host)
|
||
if !strings.HasPrefix(host, bucketLower+"-") {
|
||
continue
|
||
}
|
||
rest := host[len(bucketLower)+1:]
|
||
if idx := strings.Index(rest, "."); idx > 0 {
|
||
c.config.Region = rest[:idx]
|
||
return
|
||
}
|
||
}
|
||
|
||
// 2. 查询七牛 API
|
||
if region, err := c.GetBucketRegion(context.Background()); err == nil && region != "" {
|
||
c.config.Region = region
|
||
}
|
||
}
|
||
|
||
// Download 下载文件(使用签名 URL,绕过防盗链)
|
||
func (c *Client) Download(ctx context.Context, key string, writer io.Writer) error {
|
||
signedURL, err := c.GetSignedURL(ctx, key, 1*time.Hour)
|
||
if err != nil {
|
||
return oss.NewError("DOWNLOAD_ERROR", err.Error(), err)
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, "GET", signedURL, nil)
|
||
if err != nil {
|
||
return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err)
|
||
}
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
c.ClearDownloadDomain()
|
||
return oss.NewError("DOWNLOAD_ERROR", "failed to download file", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != 200 {
|
||
c.ClearDownloadDomain()
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return oss.NewError("DOWNLOAD_ERROR",
|
||
fmt.Sprintf("download failed with status %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])), nil)
|
||
}
|
||
|
||
_, err = io.Copy(writer, resp.Body)
|
||
if err != nil {
|
||
c.ClearDownloadDomain()
|
||
}
|
||
return err
|
||
}
|
||
|
||
// Delete 删除文件
|
||
func (c *Client) Delete(ctx context.Context, key string) error {
|
||
encodedEntry := c.encodeEntry(key)
|
||
path := "/delete/" + encodedEntry
|
||
|
||
resp, err := c.doRequest("POST", path, nil)
|
||
if err != nil {
|
||
return oss.NewError("DELETE_ERROR", "failed to delete file", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 200 || resp.StatusCode == 612 {
|
||
return nil
|
||
}
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return oss.NewError("DELETE_ERROR", fmt.Sprintf("delete failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// GetFileInfo 获取文件信息
|
||
func (c *Client) GetFileInfo(ctx context.Context, key string) (*oss.FileInfo, error) {
|
||
encodedEntry := c.encodeEntry(key)
|
||
path := "/stat/" + encodedEntry
|
||
|
||
resp, err := c.doRequest("GET", path, nil)
|
||
if err != nil {
|
||
return nil, oss.NewError("STAT_ERROR", "failed to get file info", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, oss.NewError("STAT_ERROR", "failed to read response", err)
|
||
}
|
||
|
||
if resp.StatusCode == 612 {
|
||
return nil, oss.ErrFileNotFound
|
||
}
|
||
|
||
if resp.StatusCode != 200 {
|
||
return nil, oss.NewError("STAT_ERROR", fmt.Sprintf("stat failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||
}
|
||
|
||
var statResp struct {
|
||
Hash string `json:"hash"`
|
||
Fsize int64 `json:"fsize"`
|
||
MimeType string `json:"mimeType"`
|
||
PutTime int64 `json:"putTime"`
|
||
}
|
||
if err := json.Unmarshal(body, &statResp); err != nil {
|
||
return nil, oss.NewError("STAT_ERROR", "failed to parse response", err)
|
||
}
|
||
|
||
var modTime time.Time
|
||
if statResp.PutTime > 0 {
|
||
modTime = time.Unix(0, statResp.PutTime)
|
||
}
|
||
|
||
return &oss.FileInfo{
|
||
Key: key,
|
||
Size: statResp.Fsize,
|
||
ETag: statResp.Hash,
|
||
ContentType: statResp.MimeType,
|
||
LastModified: modTime,
|
||
}, 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
|
||
}
|
||
|
||
// 构建查询参数
|
||
path := fmt.Sprintf("/list?bucket=%s&limit=%d", c.config.Bucket, options.MaxKeys)
|
||
if options.Prefix != "" {
|
||
path += "&prefix=" + options.Prefix
|
||
}
|
||
if options.Marker != "" {
|
||
path += "&marker=" + options.Marker
|
||
}
|
||
if options.Delimiter != "" {
|
||
path += "&delimiter=" + options.Delimiter
|
||
}
|
||
|
||
// 使用 GET 方法和 RSF API
|
||
resp, err := c.doRSFRequest("GET", path)
|
||
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)
|
||
}
|
||
|
||
// 解析响应
|
||
// 响应格式: {"marker":"","commonPrefixes":[],"items":[{"key":"xxx","hash":"xxx","fsize":123,...}]}
|
||
var listResp struct {
|
||
Marker string `json:"marker"`
|
||
CommonPrefixes []string `json:"commonPrefixes"`
|
||
Items []struct {
|
||
Key string `json:"key"`
|
||
Hash string `json:"hash"`
|
||
Fsize int64 `json:"fsize"`
|
||
MimeType string `json:"mimeType"`
|
||
PutTime int64 `json:"putTime"`
|
||
} `json:"items"`
|
||
}
|
||
|
||
if err := json.Unmarshal(body, &listResp); err != nil {
|
||
return nil, oss.NewError("LIST_ERROR", "failed to parse response", err)
|
||
}
|
||
|
||
// 转换为统一格式
|
||
files := make([]oss.FileInfo, 0, len(listResp.Items))
|
||
for _, item := range listResp.Items {
|
||
var modTime time.Time
|
||
if item.PutTime > 0 {
|
||
modTime = time.Unix(0, item.PutTime)
|
||
}
|
||
files = append(files, oss.FileInfo{
|
||
Key: item.Key,
|
||
Size: item.Fsize,
|
||
ETag: item.Hash,
|
||
ContentType: item.MimeType,
|
||
LastModified: modTime,
|
||
})
|
||
}
|
||
|
||
return &oss.ListResult{
|
||
Files: files,
|
||
IsTruncated: listResp.Marker != "",
|
||
NextMarker: listResp.Marker,
|
||
Prefixes: listResp.CommonPrefixes,
|
||
}, nil
|
||
}
|
||
|
||
// GetSignedURL 获取预签名URL
|
||
// 签名格式: hmac_sha1(SecretKey, "<downloadURL>?e=<deadline>")
|
||
func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) {
|
||
deadline := time.Now().Add(expiresIn).Unix()
|
||
|
||
baseURL, err := c.resolveDownloadDomain()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// 签名字符串 = 完整 URL + ?e=deadline
|
||
urlToSign := fmt.Sprintf("%s/%s?e=%d", baseURL, key, deadline)
|
||
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
|
||
h.Write([]byte(urlToSign))
|
||
sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||
|
||
return fmt.Sprintf("%s&token=%s:%s", urlToSign, c.config.AccessKey, sign), nil
|
||
}
|
||
|
||
// Copy 复制文件
|
||
func (c *Client) Copy(ctx context.Context, sourceKey, targetKey string) error {
|
||
sourceEntry := c.encodeEntry(sourceKey)
|
||
targetEntry := c.encodeEntry(targetKey)
|
||
path := "/copy/" + sourceEntry + "/" + targetEntry
|
||
|
||
resp, err := c.doRequest("POST", path, nil)
|
||
if err != nil {
|
||
return oss.NewError("COPY_ERROR", "failed to copy file", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 200 {
|
||
return nil
|
||
}
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return oss.NewError("COPY_ERROR", fmt.Sprintf("copy failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
||
}
|
||
|
||
// Move 移动/重命名文件
|
||
func (c *Client) Move(ctx context.Context, sourceKey, targetKey string) error {
|
||
sourceEntry := c.encodeEntry(sourceKey)
|
||
targetEntry := c.encodeEntry(targetKey)
|
||
path := "/move/" + sourceEntry + "/" + targetEntry
|
||
|
||
resp, err := c.doRequest("POST", path, nil)
|
||
if err != nil {
|
||
return oss.NewError("MOVE_ERROR", "failed to move file", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 200 {
|
||
return nil
|
||
}
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return oss.NewError("MOVE_ERROR", fmt.Sprintf("move failed with status %d: %s", resp.StatusCode, string(body)), 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
|
||
}
|