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

618 lines
17 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 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
}
// 使用 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
}