Private
Public Access
1
0

新增: 云OSS存储集成(七牛云+阿里云)+多桶导航+GBK编码自动转换

This commit is contained in:
2026-05-05 03:18:47 +08:00
parent eb5b85e007
commit b4f4b4627d
34 changed files with 5225 additions and 48 deletions

View File

@@ -0,0 +1,299 @@
package qiniu
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"u-desk/internal/oss"
)
// BucketAccessControl 空间访问控制类型
type BucketAccessControl int
const (
// BucketAccessControlPublic 公开空间 (0)
BucketAccessControlPublic BucketAccessControl = 0
// BucketAccessControlPrivate 私有空间 (1)
BucketAccessControlPrivate BucketAccessControl = 1
)
// String 返回访问控制的字符串表示
func (a BucketAccessControl) String() string {
switch a {
case BucketAccessControlPublic:
return "公开"
case BucketAccessControlPrivate:
return "私有"
default:
return "未知"
}
}
// GetBucketDomains 获取空间绑定的域名列表
// 根据: https://developer.qiniu.com/kodo/api/3949/get-the-bucket-space-domain
//
// 返回:
// - []string: 域名列表
// - error: 错误信息
//
// 注意:
// - 返回的域名包括七牛云提供的默认域名和用户绑定的自定义域名
// - 默认域名格式: <bucket>.<region>.qiniudns.com 或 <bucket>.<region>.clouddn.com
func (c *Client) GetBucketDomains(ctx context.Context) ([]string, error) {
// 构建查询参数
params := url.Values{}
params.Set("tbl", c.config.Bucket)
// 构建 URL
// 格式: GET /v6/domain/list?tbl=<bucketName>
apiURL := fmt.Sprintf("%s/v6/domain/list?%s", c.apiAPI, params.Encode())
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return nil, oss.NewError("BUCKET_ERROR", "failed to create request", err)
}
// 使用 API 服务的 host 生成认证
// API 接口使用简单的查询字符串认证
path := "/v6/domain/list"
queryString := params.Encode()
host := "api.qiniu.com"
authToken := c.generateAuthTokenWithQuery("GET", path, queryString, host, "application/x-www-form-urlencoded", nil)
req.Header.Set("Host", host)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", authToken)
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, oss.NewError("BUCKET_ERROR", "failed to get bucket domains", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, oss.NewError("BUCKET_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return nil, oss.NewError("BUCKET_ERROR",
fmt.Sprintf("get bucket domains failed with status %d: %s", resp.StatusCode, string(body)), nil)
}
// 解析响应JSON 数组)
var domains []string
if err := json.Unmarshal(body, &domains); err != nil {
return nil, oss.NewError("BUCKET_ERROR", "failed to parse response", err)
}
return domains, nil
}
// SetBucketAccess 设置空间访问权限(公开/私有)
// 根据: https://developer.qiniu.com/kodo/api/3946/set-bucket-private
//
// 参数:
// - access: BucketAccessControlPublic(公开) 或 BucketAccessControlPrivate(私有)
//
// 注意:
// - 公开空间:文件可通过 URL 直接访问
// - 私有空间:文件访问需要下载凭证
// - 修改权限会影响该空间下所有文件的访问方式
func (c *Client) SetBucketAccess(ctx context.Context, access BucketAccessControl) error {
// 构建查询参数
params := url.Values{}
params.Set("bucket", c.config.Bucket)
params.Set("private", fmt.Sprintf("%d", access))
// 构建 URL
// 格式: POST /private?bucket=<bucketName>&private=<0|1>
apiURL := fmt.Sprintf("%s/private?%s", c.apiAPI, params.Encode())
// 创建请求
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, nil)
if err != nil {
return oss.NewError("BUCKET_ERROR", "failed to create request", err)
}
// 使用 API 服务的 host 生成认证
path := "/private"
queryString := params.Encode()
host := "api.qiniu.com"
authToken := c.generateAuthTokenWithQuery("POST", path, queryString, host, "application/x-www-form-urlencoded", nil)
req.Header.Set("Host", host)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", authToken)
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return oss.NewError("BUCKET_ERROR", "failed to set bucket access", err)
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
return nil
}
body, _ := io.ReadAll(resp.Body)
return oss.NewError("BUCKET_ERROR",
fmt.Sprintf("set bucket access failed with status %d: %s", resp.StatusCode, string(body)), nil)
}
// SetBucketPublic 设置空间为公开空间
// 便捷方法:将空间设置为公开访问
func (c *Client) SetBucketPublic(ctx context.Context) error {
return c.SetBucketAccess(ctx, BucketAccessControlPublic)
}
// SetBucketPrivate 设置空间为私有空间
// 便捷方法:将空间设置为私有访问
func (c *Client) SetBucketPrivate(ctx context.Context) error {
return c.SetBucketAccess(ctx, BucketAccessControlPrivate)
}
// BucketInfo 空间信息
type BucketInfo struct {
Name string // 空间名称
Region string // 区域
Domains []string // 绑定的域名列表
IsPrivate bool // 是否为私有空间
}
// GetBucketInfo 获取空间信息
// 组合方法:获取空间的域名列表和访问权限等信息
//
// 注意:
// - 该方法会调用多个 API 接口获取完整信息
// - IsPrivate 字段无法通过 API 直接获取,需要通过测试文件访问来确定
func (c *Client) GetBucketInfo(ctx context.Context) (*BucketInfo, error) {
// 获取域名列表
domains, err := c.GetBucketDomains(ctx)
if err != nil {
return nil, err
}
// 构建基本信息
info := &BucketInfo{
Name: c.config.Bucket,
Region: c.config.Region,
Domains: domains,
// IsPrivate 需要通过其他方式确定
}
return info, nil
}
// CheckBucketAccess 检查空间访问权限
// 通过尝试访问一个不存在的文件来判断空间是否为私有
//
// 返回:
// - bool: true=私有空间, false=公开空间
// - error: 错误信息
//
// 注意:
// - 该方法会发送一个测试请求来判断空间权限
// - 如果空间内没有文件,可能无法准确判断
func (c *Client) CheckBucketAccess(ctx context.Context) (bool, error) {
// 尝试获取一个不存在的文件的信息
// 如果是公开空间,会返回明确的"文件不存在"错误
// 如果是私有空间,会返回认证错误
testKey := fmt.Sprintf("__qiniu_test_access_check_%d__", time.Now().UnixNano())
_, err := c.GetFileInfo(ctx, testKey)
if err == oss.ErrFileNotFound {
// 返回文件不存在,说明是公开空间
return false, nil
}
// 其他错误情况,可能需要根据错误信息判断
if err != nil {
// 检查错误信息中是否包含认证相关的内容
errStr := err.Error()
if contains(errStr, "permission") || contains(errStr, "unauthorized") || contains(errStr, "token") {
return true, nil // 私有空间
}
}
// 默认假设为公开空间
return false, nil
}
// contains 辅助函数:检查字符串是否包含子串(忽略大小写)
func contains(str, substr string) bool {
return len(str) >= len(substr) && (str == substr || len(str) > len(substr) && containsIgnoreCase(str, substr))
}
func containsIgnoreCase(str, substr string) bool {
// 简化实现,实际使用时可以使用 strings.ToLower
for i := 0; i <= len(str)-len(substr); i++ {
match := true
for j := 0; j < len(substr); j++ {
c1 := str[i+j]
c2 := substr[j]
if c1 >= 'A' && c1 <= 'Z' {
c1 += 32
}
if c2 >= 'A' && c2 <= 'Z' {
c2 += 32
}
if c1 != c2 {
match = false
break
}
}
if match {
return true
}
}
return false
}
// ListBuckets 列出所有存储桶
func ListBuckets(accessKey, secretKey string) ([]oss.BucketEntry, error) {
signingStr := "/buckets\n"
token := accessKey + ":" + signHmacSha1(secretKey, signingStr)
req, _ := http.NewRequest("POST", "https://rs.qbox.me/buckets", nil)
req.Header.Set("Authorization", "QBox "+token)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("列举存储桶失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("列举存储桶失败: HTTP %d: %s", resp.StatusCode, string(body))
}
var names []string
if err := json.NewDecoder(resp.Body).Decode(&names); err != nil {
return nil, fmt.Errorf("解析存储桶列表失败: %w", err)
}
entries := make([]oss.BucketEntry, len(names))
for i, name := range names {
entries[i] = oss.BucketEntry{Name: name}
}
return entries, nil
}
func signHmacSha1(secretKey, data string) string {
mac := hmac.New(sha1.New, []byte(secretKey))
mac.Write([]byte(data))
return base64.URLEncoding.EncodeToString(mac.Sum(nil))
}

View File

@@ -0,0 +1,570 @@
package qiniu
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"u-desk/internal/oss"
)
// Config 七牛云配置
type Config struct {
AccessKey string // 访问密钥
SecretKey string // 秘钥
Bucket string // 存储空间名称
Region string // 区域 z0=华东, as0=亚太0区
UseHTTPS bool // 是否使用 HTTPS
UploadDomain string // 上传域名(可选,默认根据 Region 自动选择)
}
// 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
}
// generateSignature 生成七牛云管理凭证签名
// 根据官方文档https://developer.qiniu.com/kodo/1201/access-token
func (c *Client) generateSignature(method, path, host, contentType string, body []byte) string {
// 七牛云管理凭证签名格式:
// signingStr = Method + " " + Path + "\nHost: " + Host + "\n" + [Content-Type] + "\n\n" + [body]
var signingStr string
// 1. Method + " " + Path
signingStr = method + " " + path
// 2. Host header
signingStr += "\nHost: " + host
// 3. Content-Type header (如果设置了)
if contentType != "" {
signingStr += "\nContent-Type: " + contentType
}
// 4. 两个连续换行符
signingStr += "\n\n"
// 5. Body (如果设置了 Content-Type 且不是 application/octet-stream)
if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 {
signingStr += string(body)
}
// 使用 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(signingStr))
// Base64 URL 安全编码
signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
return signature
}
// generateAuthToken 生成管理认证 Token
func (c *Client) generateAuthToken(method, path, host, contentType string, body []byte) string {
signature := c.generateSignature(method, path, host, contentType, body)
return "Qiniu " + c.config.AccessKey + ":" + signature
}
// generateAuthTokenWithQuery 生成管理认证 Token支持 query string
func (c *Client) generateAuthTokenWithQuery(method, path, query, host, contentType string, body []byte) string {
// 七牛云管理凭证签名格式:
// 如果 query 为非空字符串: signingStr = Method + " " + Path + "?" + query + "\nHost: " + Host + ...
// 如果 query 为空: signingStr = Method + " " + Path + "\nHost: " + Host + ...
var signingStr string
// 1. Method + " " + Path
signingStr = method + " " + path
// 2. Query string (如果有)
if query != "" {
signingStr += "?" + query
}
// 3. Host header
signingStr += "\nHost: " + host
// 4. Content-Type header (如果设置了)
if contentType != "" {
signingStr += "\nContent-Type: " + contentType
}
// 5. 两个连续换行符
signingStr += "\n\n"
// 6. Body (如果设置了 Content-Type 且不是 application/octet-stream)
if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 {
signingStr += string(body)
}
// 使用 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(signingStr))
// Base64 URL 安全编码
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.UploadDomain != "" {
if c.config.UseHTTPS {
return "https://" + c.config.UploadDomain
}
return "http://" + c.config.UploadDomain
}
// 根据区域选择默认上传域名
// 七牛云上传域名格式: 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)
}
// generateUploadToken 生成上传凭证
func (c *Client) generateUploadToken(key string) string {
// 七牛云上传凭证的生成
// 1. 创建 putPolicy
putPolicy := fmt.Sprintf(`{"scope":"%s:%s","deadline":%d}`,
c.config.Bucket, key, time.Now().Add(1*time.Hour).Unix())
// 2. 对 putPolicy 进行 base64 URL 编码
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy))
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(encodedPutPolicy))
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 4. 组合 token
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
}
// generateBucketToken 生成 bucket 级别的上传凭证(用于分片上传 v2
func (c *Client) generateBucketToken() string {
// 分片上传 v2 需要 bucket 级别的 token
// 1. 创建 putPolicy
putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`,
c.config.Bucket, time.Now().Add(1*time.Hour).Unix())
// 2. 对 putPolicy 进行 base64 URL 编码
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy))
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(encodedPutPolicy))
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 4. 组合 token
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
}
// resolveDownloadDomain 解析并缓存下载域名
func (c *Client) resolveDownloadDomain() (string, error) {
if c.config.UploadDomain != "" {
return c.config.UploadDomain, nil
}
domains, err := c.GetBucketDomains(context.Background())
if err != nil || len(domains) == 0 {
return "", fmt.Errorf("无法获取桶 %s 的下载域名: %v", c.config.Bucket, err)
}
domain := domains[0]
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
domain = "http://" + domain
}
c.config.UploadDomain = domain
return domain, nil
}
// Download 下载文件
func (c *Client) Download(ctx context.Context, key string, writer io.Writer) error {
baseURL, err := c.resolveDownloadDomain()
if err != nil {
return oss.NewError("DOWNLOAD_ERROR", err.Error(), err)
}
url := fmt.Sprintf("%s/%s", baseURL, key)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err)
}
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 {
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)
}
// 解析响应 (简化实现)
// 实际响应格式: {"hash":"xxx","fsize":123,"mimeType":"xxx","putTime":123}
// 这里返回一个简化的 FileInfo
return &oss.FileInfo{
Key: key,
}, 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 {
files = append(files, oss.FileInfo{
Key: item.Key,
Size: item.Fsize,
ETag: item.Hash,
ContentType: item.MimeType,
})
}
return &oss.ListResult{
Files: files,
IsTruncated: listResp.Marker != "",
NextMarker: listResp.Marker,
Prefixes: listResp.CommonPrefixes,
}, nil
}
// GetSignedURL 获取预签名URL
func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) {
// 七牛云私有空间下载需要生成私有下载 URL
deadline := time.Now().Add(expiresIn).Unix()
// 构建 download URL
baseURL, err := c.resolveDownloadDomain()
if err != nil {
return "", err
}
downloadURL := fmt.Sprintf("%s/%s", baseURL, key)
// 生成签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
signStr := fmt.Sprintf("%s\n%d", downloadURL, deadline)
h.Write([]byte(signStr))
sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 构建最终 URL
signedURL := fmt.Sprintf("%s?e=%d&token=%s:%s", downloadURL, deadline, c.config.AccessKey, sign)
return signedURL, 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
}

View File

@@ -0,0 +1,235 @@
package qiniu
import (
"context"
"fmt"
"io"
"u-desk/internal/oss"
)
// StorageType 存储类型枚举
type StorageType int
const (
// StorageTypeStandard 标准存储 (0)
StorageTypeStandard StorageType = 0
// StorageTypeIA 低频存储 (1) - Infrequent Access
StorageTypeIA StorageType = 1
// StorageTypeArchive 归档存储 (2) - Archive
StorageTypeArchive StorageType = 2
// StorageTypeDeepArchive 深度归档存储 (3) - Deep Archive
StorageTypeDeepArchive StorageType = 3
// StorageTypeIntelligentTiering 智能分层存储 (4)
StorageTypeIntelligentTiering StorageType = 4
// StorageTypeArchiveIR 归档直读存储 (5) - Archive Immediate Retrieval
StorageTypeArchiveIR StorageType = 5
)
// String 返回存储类型的字符串表示
func (t StorageType) String() string {
switch t {
case StorageTypeStandard:
return "标准存储"
case StorageTypeIA:
return "低频存储"
case StorageTypeArchive:
return "归档存储"
case StorageTypeDeepArchive:
return "深度归档存储"
case StorageTypeIntelligentTiering:
return "智能分层存储"
case StorageTypeArchiveIR:
return "归档直读存储"
default:
return "未知"
}
}
// LifecycleConfig 文件生命周期配置
type LifecycleConfig struct {
// ToIAAfterDays 转换到低频存储的天数,-1 表示取消
ToIAAfterDays int
// ToIntelligentTieringAfterDays 转换到智能分层存储的天数,-1 表示取消
ToIntelligentTieringAfterDays int
// ToArchiveIRAfterDays 转换到归档直读存储的天数,-1 表示取消
ToArchiveIRAfterDays int
// ToArchiveAfterDays 转换到归档存储的天数,-1 表示取消
ToArchiveAfterDays int
// ToDeepArchiveAfterDays 转换到深度归档存储的天数,-1 表示取消
ToDeepArchiveAfterDays int
// DeleteAfterDays 过期删除的天数,-1 表示取消0 表示不设置
DeleteAfterDays int
}
// ChangeStorageType 修改文件存储类型
// 根据: https://developer.qiniu.com/kodo/api/3710/chtype
func (c *Client) ChangeStorageType(ctx context.Context, key string, storageType StorageType) error {
encodedEntry := c.encodeEntry(key)
path := fmt.Sprintf("/chtype/%s/type/%d", encodedEntry, storageType)
resp, err := c.doRequest("POST", path, nil)
if err != nil {
return oss.NewError("STYPE_ERROR", "failed to change storage type", err)
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
return nil
}
body, _ := io.ReadAll(resp.Body)
return oss.NewError("STYPE_ERROR",
fmt.Sprintf("change storage type failed with status %d: %s", resp.StatusCode, string(body)), nil)
}
// SetDeleteAfterDays 设置文件过期删除时间
// 根据: https://developer.qiniu.com/kodo/api/update-file-lifecycle
//
// 参数:
// - key: 文件 key
// - days: 过期天数0 表示取消过期删除设置
//
// 注意:
// - 文件在设置的天数之后被删除,删除后不可恢复
// - 设置为 0 表示取消过期删除设置
func (c *Client) SetDeleteAfterDays(ctx context.Context, key string, days int) error {
encodedEntry := c.encodeEntry(key)
path := fmt.Sprintf("/deleteAfterDays/%s/%d", encodedEntry, days)
resp, err := c.doRequest("POST", path, nil)
if err != nil {
return oss.NewError("LIFECYCLE_ERROR", "failed to set delete after days", err)
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
return nil
}
body, _ := io.ReadAll(resp.Body)
return oss.NewError("LIFECYCLE_ERROR",
fmt.Sprintf("set delete after days failed with status %d: %s", resp.StatusCode, string(body)), nil)
}
// SetLifecycle 设置文件生命周期
// 根据: https://developer.qiniu.com/kodo/api/8062/modify-object-life-cycle
//
// 参数说明:
// - ToIAAfterDays: 转换到低频存储的天数,设置为 -1 表示取消
// - ToIntelligentTieringAfterDays: 转换到智能分层存储的天数,设置为 -1 表示取消
// - ToArchiveIRAfterDays: 转换到归档直读存储的天数,设置为 -1 表示取消
// - ToArchiveAfterDays: 转换到归档存储的天数,设置为 -1 表示取消
// - ToDeepArchiveAfterDays: 转换到深度归档存储的天数,设置为 -1 表示取消
// - DeleteAfterDays: 过期删除的天数,设置为 -1 表示取消0 表示不设置
//
// 注意:
// - 所有参数都是可选的,只设置需要的参数即可
// - 文件删除后不可恢复
func (c *Client) SetLifecycle(ctx context.Context, key string, config *LifecycleConfig) error {
encodedEntry := c.encodeEntry(key)
path := fmt.Sprintf("/lifecycle/%s", encodedEntry)
// 添加各个生命周期参数
if config.ToIAAfterDays != 0 {
path += fmt.Sprintf("/toIAAfterDays/%d", config.ToIAAfterDays)
}
if config.ToIntelligentTieringAfterDays != 0 {
path += fmt.Sprintf("/toIntelligentTieringAfterDays/%d", config.ToIntelligentTieringAfterDays)
}
if config.ToArchiveIRAfterDays != 0 {
path += fmt.Sprintf("/toArchiveIRAfterDays/%d", config.ToArchiveIRAfterDays)
}
if config.ToArchiveAfterDays != 0 {
path += fmt.Sprintf("/toArchiveAfterDays/%d", config.ToArchiveAfterDays)
}
if config.ToDeepArchiveAfterDays != 0 {
path += fmt.Sprintf("/toDeepArchiveAfterDays/%d", config.ToDeepArchiveAfterDays)
}
if config.DeleteAfterDays != 0 {
path += fmt.Sprintf("/deleteAfterDays/%d", config.DeleteAfterDays)
}
// 如果没有设置任何参数,返回错误
if path == fmt.Sprintf("/lifecycle/%s", encodedEntry) {
return oss.NewError("LIFECYCLE_ERROR", "no lifecycle parameters specified", nil)
}
resp, err := c.doRequest("POST", path, nil)
if err != nil {
return oss.NewError("LIFECYCLE_ERROR", "failed to set lifecycle", err)
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
return nil
}
body, _ := io.ReadAll(resp.Body)
return oss.NewError("LIFECYCLE_ERROR",
fmt.Sprintf("set lifecycle failed with status %d: %s", resp.StatusCode, string(body)), nil)
}
// CancelIAConversion 取消转低频存储的生命周期规则
func (c *Client) CancelIAConversion(ctx context.Context, key string) error {
config := &LifecycleConfig{
ToIAAfterDays: -1,
}
return c.SetLifecycle(ctx, key, config)
}
// CancelArchiveConversion 取消转归档存储的生命周期规则
func (c *Client) CancelArchiveConversion(ctx context.Context, key string) error {
config := &LifecycleConfig{
ToArchiveAfterDays: -1,
}
return c.SetLifecycle(ctx, key, config)
}
// CancelDeleteAfterDays 取消过期删除的生命周期规则
func (c *Client) CancelDeleteAfterDays(ctx context.Context, key string) error {
config := &LifecycleConfig{
DeleteAfterDays: -1,
}
return c.SetLifecycle(ctx, key, config)
}
// SetToIAAfterDays 设置文件转低频存储的天数
func (c *Client) SetToIAAfterDays(ctx context.Context, key string, days int) error {
config := &LifecycleConfig{
ToIAAfterDays: days,
}
return c.SetLifecycle(ctx, key, config)
}
// SetToArchiveAfterDays 设置文件转归档存储的天数
func (c *Client) SetToArchiveAfterDays(ctx context.Context, key string, days int) error {
config := &LifecycleConfig{
ToArchiveAfterDays: days,
}
return c.SetLifecycle(ctx, key, config)
}
// SetToDeepArchiveAfterDays 设置文件转深度归档存储的天数
func (c *Client) SetToDeepArchiveAfterDays(ctx context.Context, key string, days int) error {
config := &LifecycleConfig{
ToDeepArchiveAfterDays: days,
}
return c.SetLifecycle(ctx, key, config)
}
// SetToIntelligentTieringAfterDays 设置文件转智能分层存储的天数
func (c *Client) SetToIntelligentTieringAfterDays(ctx context.Context, key string, days int) error {
config := &LifecycleConfig{
ToIntelligentTieringAfterDays: days,
}
return c.SetLifecycle(ctx, key, config)
}
// SetToArchiveIRAfterDays 设置文件转归档直读存储的天数
func (c *Client) SetToArchiveIRAfterDays(ctx context.Context, key string, days int) error {
config := &LifecycleConfig{
ToArchiveIRAfterDays: days,
}
return c.SetLifecycle(ctx, key, config)
}

View File

@@ -0,0 +1,427 @@
package qiniu
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"u-desk/internal/oss"
)
// PartInfo 分片信息
type PartInfo struct {
PartNumber int `json:"partNumber"` // 分片编号 (1-10000)
ETag string `json:"etag"` // 分片的 ETag
}
// InitiateMultipartUploadResult 初始化分片上传任务的结果
type InitiateMultipartUploadResult struct {
UploadId string `json:"uploadId"` // 上传任务 ID
}
// UploadPartResult 上传分片的结果
type UploadPartResult struct {
ETag string `json:"etag"` // 分片的 ETag
MD5 string `json:"md5"` // 分片的 MD5
}
// CompleteMultipartUploadResult 完成分片上传的结果
type CompleteMultipartUploadResult struct {
Key string `json:"key"` // 文件 key
Hash string `json:"hash"` // 文件 hash (ETag)
}
// InitiateMultipartUpload 初始化分片上传任务
// 根据: https://developer.qiniu.com/kodo/api/1502/initiate-multipart-upload
func (c *Client) InitiateMultipartUpload(ctx context.Context, key string) (string, error) {
// 生成上传 token
// 注意:分片上传 v2 需要 bucket 级别的 token不包含 key
token := c.generateBucketToken()
// 构建 URL
// 格式: POST /buckets/<BucketName>/objects/<EncodedObjectName>/uploads
encodedKey := base64.URLEncoding.EncodeToString([]byte(key))
url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads",
c.getUploadDomain(), c.config.Bucket, encodedKey)
// 构建请求体
requestBody := map[string]string{
"fname": key,
}
bodyBytes, _ := json.Marshal(requestBody)
// 创建请求
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to create request", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "UpToken "+token)
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to initiate multipart upload", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return "", oss.NewError("MULTIPART_ERROR",
fmt.Sprintf("initiate multipart upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil)
}
// 解析响应
var result InitiateMultipartUploadResult
if err := json.Unmarshal(respBody, &result); err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to parse response", err)
}
return result.UploadId, nil
}
// UploadPart 上传分片
// 根据: https://developer.qiniu.com/kodo/api/6366/upload-part
func (c *Client) UploadPart(ctx context.Context, key, uploadId string, partNumber int, reader io.Reader) (string, error) {
// 生成上传 token分片上传 v2 使用 bucket 级别 token
token := c.generateBucketToken()
// 读取数据
data, err := io.ReadAll(reader)
if err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to read part data", err)
}
// 计算 MD5
hash := md5.New()
hash.Write(data)
md5Sum := hash.Sum(nil)
md5Base64 := base64.StdEncoding.EncodeToString(md5Sum)
// 构建 URL
// 格式: PUT /buckets/<BucketName>/objects/<EncodedObjectName>/uploads/<UploadId>/<PartNumber>
encodedKey := base64.URLEncoding.EncodeToString([]byte(key))
url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads/%s/%d",
c.getUploadDomain(), c.config.Bucket, encodedKey, uploadId, partNumber)
// 创建请求
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(data))
if err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to create request", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-MD5", md5Base64)
req.Header.Set("Authorization", "UpToken "+token)
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(data)))
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to upload part", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return "", oss.NewError("MULTIPART_ERROR",
fmt.Sprintf("upload part failed with status %d: %s", resp.StatusCode, string(respBody)), nil)
}
// 解析响应
var result UploadPartResult
if err := json.Unmarshal(respBody, &result); err != nil {
return "", oss.NewError("MULTIPART_ERROR", "failed to parse response", err)
}
return result.ETag, nil
}
// CompleteMultipartUpload 完成分片上传
// 根据: https://developer.qiniu.com/kodo/api/6368/complete-multipart-upload
func (c *Client) CompleteMultipartUpload(ctx context.Context, key, uploadId string, parts []PartInfo) (*oss.UploadResult, error) {
// 生成上传 token分片上传 v2 使用 bucket 级别 token
token := c.generateBucketToken()
// 构建 URL
// 格式: POST /buckets/<BucketName>/objects/<EncodedObjectName>/uploads/<UploadId>
encodedKey := base64.URLEncoding.EncodeToString([]byte(key))
url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads/%s",
c.getUploadDomain(), c.config.Bucket, encodedKey, uploadId)
// 构建请求体
requestBody := map[string]interface{}{
"parts": parts,
"fname": key,
"mimeType": "",
}
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to marshal request", err)
}
// 创建请求
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "UpToken "+token)
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to complete multipart upload", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return nil, oss.NewError("MULTIPART_ERROR",
fmt.Sprintf("complete multipart upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil)
}
// 解析响应
var result CompleteMultipartUploadResult
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err)
}
return &oss.UploadResult{
Key: result.Key,
ETag: result.Hash,
}, nil
}
// AbortMultipartUpload 中止分片上传任务
// 根据: https://developer.qiniu.com/kodo/api/1503/abort-multipart-upload
func (c *Client) AbortMultipartUpload(ctx context.Context, key, uploadId string) error {
// 生成上传 token分片上传 v2 使用 bucket 级别 token
token := c.generateBucketToken()
// 构建 URL
// 格式: DELETE /buckets/<BucketName>/objects/<EncodedObjectName>/uploads/<UploadId>
encodedKey := base64.URLEncoding.EncodeToString([]byte(key))
url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads/%s",
c.getUploadDomain(), c.config.Bucket, encodedKey, uploadId)
// 创建请求
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
if err != nil {
return oss.NewError("MULTIPART_ERROR", "failed to create request", err)
}
req.Header.Set("Authorization", "UpToken "+token)
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return oss.NewError("MULTIPART_ERROR", "failed to abort multipart upload", err)
}
defer resp.Body.Close()
// 200 或 204 都表示成功
if resp.StatusCode != 200 && resp.StatusCode != 204 {
respBody, _ := io.ReadAll(resp.Body)
return oss.NewError("MULTIPART_ERROR",
fmt.Sprintf("abort multipart upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil)
}
return nil
}
// ListParts 列举已上传的分片
// 根据: https://developer.qiniu.com/kodo/api/1504/list-parts
func (c *Client) ListParts(ctx context.Context, key, uploadId string) ([]PartInfo, error) {
// 生成上传 token分片上传 v2 使用 bucket 级别 token
token := c.generateBucketToken()
// 构建 URL
// 格式: GET /buckets/<BucketName>/objects/<EncodedObjectName>/uploads/<UploadId>?partNumberMarker=<Marker>&maxParts=<MaxParts>
encodedKey := base64.URLEncoding.EncodeToString([]byte(key))
url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads/%s",
c.getUploadDomain(), c.config.Bucket, encodedKey, uploadId)
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err)
}
req.Header.Set("Authorization", "UpToken "+token)
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to list parts", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return nil, oss.NewError("MULTIPART_ERROR",
fmt.Sprintf("list parts failed with status %d: %s", resp.StatusCode, string(respBody)), nil)
}
// 解析响应
var result struct {
Parts []struct {
PartNumber int `json:"partNumber"`
ETag string `json:"etag"`
Size int64 `json:"size"`
} `json:"parts"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err)
}
// 转换为 PartInfo
parts := make([]PartInfo, 0, len(result.Parts))
for _, p := range result.Parts {
parts = append(parts, PartInfo{
PartNumber: p.PartNumber,
ETag: p.ETag,
})
}
return parts, nil
}
// UploadMultipart 使用分片上传方式上传文件
// 自动将文件分片并上传,适用于大文件
// 注意:七牛云要求每个分片大小至少为 1MB除最后一个分片外
func (c *Client) UploadMultipart(ctx context.Context, key string, reader io.Reader, partSize int64) (*oss.UploadResult, error) {
// 默认分片大小为 4MB
if partSize <= 0 {
partSize = 4 * 1024 * 1024
}
// 七牛云要求:每个分片至少 1MB除最后一个分片外
const minPartSize = 1024 * 1024 // 1MB
if partSize < minPartSize {
partSize = minPartSize
}
// 1. 初始化上传任务
uploadId, err := c.InitiateMultipartUpload(ctx, key)
if err != nil {
return nil, fmt.Errorf("failed to initiate multipart upload: %w", err)
}
// 确保在失败时中止任务
defer func() {
if err != nil {
c.AbortMultipartUpload(context.Background(), key, uploadId)
}
}()
// 2. 读取所有数据并分片
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read data: %w", err)
}
totalSize := int64(len(data))
// 如果文件太小,使用普通上传
if totalSize < minPartSize {
// 文件小于 1MB使用普通上传
uploadClient := NewUploadClient(c.config)
return uploadClient.Upload(ctx, key, bytes.NewReader(data))
}
partCount := int((totalSize + partSize - 1) / partSize) // 向上取整
// 3. 上传各个分片
parts := make([]PartInfo, 0, partCount)
for i := 0; i < partCount; i++ {
partNumber := i + 1
start := i * int(partSize)
end := start + int(partSize)
if end > len(data) {
end = len(data)
}
partData := data[start:end]
currentPartSize := int64(len(partData))
// 验证分片大小(除最后一个分片外,其他分片必须 >= 1MB
if i < partCount-1 && currentPartSize < minPartSize {
return nil, fmt.Errorf("part %d size (%d bytes) is less than minimum required size (%d bytes)",
partNumber, currentPartSize, minPartSize)
}
etag, err := c.UploadPart(ctx, key, uploadId, partNumber, bytes.NewReader(partData))
if err != nil {
return nil, fmt.Errorf("failed to upload part %d: %w", partNumber, err)
}
parts = append(parts, PartInfo{
PartNumber: partNumber,
ETag: etag,
})
}
// 4. 完成上传
result, err := c.CompleteMultipartUpload(ctx, key, uploadId, parts)
if err != nil {
return nil, fmt.Errorf("failed to complete multipart upload: %w", err)
}
// 成功,取消 defer 中的中止操作
err = nil
return result, nil
}
// UploadWithRetry 带重试的分片上传
// 支持失败重试,适用于不稳定的网络环境
func (c *Client) UploadWithRetry(ctx context.Context, key string, reader io.Reader, partSize int64, maxRetries int) (*oss.UploadResult, error) {
if maxRetries <= 0 {
maxRetries = 3
}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
// 每次重试需要重新读取数据
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
result, err := c.UploadMultipart(ctx, key, bytes.NewReader(data), partSize)
if err == nil {
return result, nil
}
lastErr = err
// 等待一段时间后重试
time.Sleep(time.Second * time.Duration(attempt+1))
}
return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
}

View File

@@ -0,0 +1,235 @@
package qiniu
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"time"
"u-desk/internal/oss"
)
// UploadResult 七牛云上传结果
type qiniuUploadResult struct {
Key string `json:"key"`
Hash string `json:"hash"`
Size int64 `json:"fsize"`
Bucket string `json:"bucket"`
}
// UploadWithUploader 使用表单上传文件
func (c *Client) UploadWithUploader(ctx context.Context, key string, reader io.Reader, options *oss.UploadOptions) (*oss.UploadResult, error) {
// 生成上传 token
token := c.generateUploadToken(key)
// 创建 multipart form
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 添加字段
_ = writer.WriteField("token", token)
_ = writer.WriteField("key", key)
// 添加文件
part, err := writer.CreateFormFile("file", key)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to create form file", err)
}
// 读取数据并写入
// 为了获取文件大小,先读取到内存
data, err := io.ReadAll(reader)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to read file data", err)
}
if _, err := part.Write(data); err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to write file data", err)
}
// 设置 Content-Type
if options != nil && options.ContentType != "" {
_ = writer.WriteField("mimeType", options.ContentType)
}
if err := writer.Close(); err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to close multipart writer", err)
}
// 上传 URL - 根据配置或区域选择
uploadURL := c.getUploadDomain()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, body)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to create request", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to upload file", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return nil, oss.NewError("UPLOAD_ERROR", fmt.Sprintf("upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil)
}
// 解析结果
var result qiniuUploadResult
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to parse response", err)
}
return &oss.UploadResult{
Key: result.Key,
ETag: result.Hash,
Size: result.Size,
}, nil
}
// UploadClient 专用的上传客户端
type UploadClient struct {
config *Config
client *http.Client
}
// NewUploadClient 创建上传客户端
func NewUploadClient(config *Config) *UploadClient {
return &UploadClient{
config: config,
client: &http.Client{
Timeout: 5 * time.Minute,
},
}
}
// Upload 上传文件
func (uc *UploadClient) Upload(ctx context.Context, key string, reader io.Reader) (*oss.UploadResult, error) {
token := uc.generateUploadToken()
// 创建 multipart form
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("token", token)
_ = writer.WriteField("key", key)
part, err := writer.CreateFormFile("file", key)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to create form file", err)
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to read file data", err)
}
if _, err := part.Write(data); err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to write file data", err)
}
if err := writer.Close(); err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to close multipart writer", err)
}
// 上传 URL - 根据配置或区域选择
scheme := "https://"
if !uc.config.UseHTTPS {
scheme = "http://"
}
var uploadURL string
if uc.config.UploadDomain != "" {
uploadURL = scheme + uc.config.UploadDomain
} else {
// 根据区域选择
switch uc.config.Region {
case "z0":
uploadURL = scheme + "up-z0.qiniup.com"
case "z1":
uploadURL = scheme + "up-z1.qiniup.com"
case "z2":
uploadURL = scheme + "up-z2.qiniup.com"
case "na0":
uploadURL = scheme + "up-na0.qiniup.com"
case "as0":
uploadURL = scheme + "up-as0.qiniup.com"
default:
uploadURL = scheme + "up-z0.qiniup.com"
}
}
req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, body)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to create request", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := uc.client.Do(req)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to upload file", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return nil, oss.NewError("UPLOAD_ERROR", fmt.Sprintf("upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil)
}
var result qiniuUploadResult
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, oss.NewError("UPLOAD_ERROR", "failed to parse response", err)
}
return &oss.UploadResult{
Key: result.Key,
ETag: result.Hash,
Size: result.Size,
}, nil
}
// generateUploadToken 生成上传 token
func (uc *UploadClient) generateUploadToken() string {
// 1. 创建 putPolicy
putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`,
uc.config.Bucket, time.Now().Add(1*time.Hour).Unix())
// 2. 对 putPolicy 进行 base64 URL 编码
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy))
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名
h := uc.hmacSHA1([]byte(encodedPutPolicy))
encodedSign := base64.URLEncoding.EncodeToString(h)
// 4. 组合 token
return uc.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
}
// hmacSHA1 HMAC-SHA1 签名
func (uc *UploadClient) hmacSHA1(data []byte) []byte {
h := hmac.New(sha1.New, []byte(uc.config.SecretKey))
h.Write(data)
return h.Sum(nil)
}