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-.qiniup.com 或 upload-.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 } // 使用 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, "?e=") 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 }