Private
Public Access
1
0

新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放

This commit is contained in:
2026-05-12 11:06:28 +08:00
parent 545d7a864d
commit 2a363fd729
62 changed files with 6687 additions and 660 deletions

View File

@@ -99,6 +99,55 @@ func (c *Client) GetBucketDomains(ctx context.Context) ([]string, error) {
return domains, nil
}
// GetBucketRegion 查询桶的真实区域
// API: POST https://uc.qbox.me/v2/buckets → 遍历匹配桶名获取 region
func (c *Client) GetBucketRegion(ctx context.Context) (string, error) {
// 使用 UC API 获取所有桶列表(含 region
req, err := http.NewRequestWithContext(ctx, "POST", "https://uc.qbox.me/v2/buckets", nil)
if err != nil {
return "", oss.NewError("BUCKET_ERROR", "failed to create request", err)
}
path := "/v2/buckets"
host := "uc.qbox.me"
authToken := c.generateAuthTokenWithQuery("POST", path, "", 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 query bucket region", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", oss.NewError("BUCKET_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return "", oss.NewError("BUCKET_ERROR",
fmt.Sprintf("query bucket region failed with status %d: %s", resp.StatusCode, string(body)), nil)
}
var buckets []struct {
ID string `json:"id"`
Region string `json:"region"`
}
if err := json.Unmarshal(body, &buckets); err != nil {
return "", oss.NewError("BUCKET_ERROR", "failed to parse response", err)
}
for _, b := range buckets {
if b.ID == c.config.Bucket {
return b.Region, nil
}
}
return "", oss.NewError("BUCKET_ERROR", fmt.Sprintf("bucket %s not found in account", c.config.Bucket), nil)
}
// SetBucketAccess 设置空间访问权限(公开/私有)
// 根据: https://developer.qiniu.com/kodo/api/3946/set-bucket-private
//

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
@@ -18,12 +19,12 @@ import (
// Config 七牛云配置
type Config struct {
AccessKey string // 访问密钥
SecretKey string // 秘钥
Bucket string // 存储空间名称
Region string // 区域 z0=华东, as0=亚太0区
UseHTTPS bool // 是否使用 HTTPS
UploadDomain string // 上传域名(可选,默认根据 Region 自动选择
AccessKey string // 访问密钥
SecretKey string // 秘钥
Bucket string // 存储空间名称
Region string // 区域 z0=华东, z2=华南, as0=亚太0区
UseHTTPS bool // 是否使用 HTTPS
DownloadDomain string // 缓存的下载域名(由 resolveDownloadDomain 自动设置
}
// Client 七牛云客户端
@@ -61,84 +62,31 @@ func NewClient(config *Config) (*Client, error) {
}, 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
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 {
// 七牛云管理凭证签名格式:
// 如果 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
@@ -152,12 +100,11 @@ func (c *Client) encodeEntry(key string) string {
// getUploadDomain 获取上传域名
func (c *Client) getUploadDomain() string {
// 如果配置了自定义上传域名,使用自定义的
if c.config.UploadDomain != "" {
if c.config.DownloadDomain != "" {
if c.config.UseHTTPS {
return "https://" + c.config.UploadDomain
return "https://" + c.config.DownloadDomain
}
return "http://" + c.config.UploadDomain
return "http://" + c.config.DownloadDomain
}
// 根据区域选择默认上传域名
@@ -264,85 +211,169 @@ func (c *Client) Upload(ctx context.Context, key string, reader io.Reader, optio
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 签名
// 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(encodedPutPolicy))
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 4. 组合 token
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
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)
}
// 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())
return c.generateToken(c.config.Bucket)
}
// 2. 对 putPolicy 进行 base64 URL 编码
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy))
// 七牛云临时域名后缀(平台分配的 CDN 域名,稳定性高)
var qiniuTempSuffixes = []string{
".qiniudns.com", ".clouddn.com", ".qbox.me",
".qnssl.com", ".qnybgz.cn", ".qiniudns.com.cn",
}
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(encodedPutPolicy))
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 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
}
// 4. 组合 token
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
// 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.UploadDomain != "" {
return c.config.UploadDomain, nil
if c.config.DownloadDomain != "" {
return c.config.DownloadDomain, nil
}
domains, err := c.GetBucketDomains(context.Background())
if err != nil || len(domains) == 0 {
return "", fmt.Errorf("无法获取桶 %s 的下载域名: %v", c.config.Bucket, err)
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
}
}
domain := domains[0]
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
domain = "http://" + domain
}
c.config.UploadDomain = domain
return domain, nil
// 无域名 → 兜底默认 CDN可能不存在但给一个机会
fallback := c.defaultCDNDomain()
c.config.DownloadDomain = fallback
return fallback, nil
}
// Download 下载文件
// 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 {
baseURL, err := c.resolveDownloadDomain()
signedURL, err := c.GetSignedURL(ctx, key, 1*time.Hour)
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)
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 {
return oss.NewError("DOWNLOAD_ERROR", fmt.Sprintf("download failed with status %d", resp.StatusCode), nil)
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
}
@@ -407,11 +438,27 @@ func (c *Client) GetFileInfo(ctx context.Context, key string) (*oss.FileInfo, er
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
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,
Key: key,
Size: statResp.Fsize,
ETag: statResp.Hash,
ContentType: statResp.MimeType,
LastModified: modTime,
}, nil
}
@@ -471,11 +518,16 @@ func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.
// 转换为统一格式
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,
Key: item.Key,
Size: item.Fsize,
ETag: item.Hash,
ContentType: item.MimeType,
LastModified: modTime,
})
}
@@ -488,27 +540,22 @@ func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.
}
// GetSignedURL 获取预签名URL
// 签名格式: hmac_sha1(SecretKey, "<downloadURL>?e=<deadline>")
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)
// 生成签名
// 签名字符串 = 完整 URL + ?e=deadline
urlToSign := fmt.Sprintf("%s/%s?e=%d", baseURL, key, deadline)
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
signStr := fmt.Sprintf("%s\n%d", downloadURL, deadline)
h.Write([]byte(signStr))
h.Write([]byte(urlToSign))
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
return fmt.Sprintf("%s&token=%s:%s", urlToSign, c.config.AccessKey, sign), nil
}
// Copy 复制文件

View File

@@ -0,0 +1,190 @@
package qiniu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"u-desk/internal/oss"
)
// 临时测试配置 — 提交前删除此文件
func testConfig() *Config {
return &Config{
AccessKey: "eUjiDJGy9CkRb3-Ad3jCubPrm49xeBTesHYckIwc",
SecretKey: "LE8XL-LmoMkpy0jNK-kDhgL_w7A6MRXD1Msqd1Y4",
Bucket: "u-res",
Region: "as0",
UseHTTPS: true,
}
}
const testKey = "music/03.一人一首成名曲【特调音源】/001.雨一直下-张宇.mp3"
// TestListBuckets 列举桶
func TestListBuckets(t *testing.T) {
buckets, err := ListBuckets("eUjiDJGy9CkRb3-Ad3jCubPrm49xeBTesHYckIwc", "LE8XL-LmoMkpy0jNK-kDhgL_w7A6MRXD1Msqd1Y4")
if err != nil {
t.Fatalf("ListBuckets 失败: %v", err)
}
for _, b := range buckets {
t.Logf("桶: %s 区域: %s", b.Name, b.Region)
}
}
// TestGetBucketDomains 获取桶域名
func TestGetBucketDomains(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
domains, err := c.GetBucketDomains(context.Background())
if err != nil {
t.Fatalf("获取域名失败: %v", err)
}
t.Logf("桶域名: %v", domains)
}
// TestDownloadDirect 裸 URL 下载(测试桶公开/私有)
func TestDownloadDirect(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
domain, err := c.resolveDownloadDomain()
if err != nil {
t.Fatalf("获取下载域名失败: %v", err)
}
t.Logf("下载域名: %s", domain)
rawURL := fmt.Sprintf("%s/%s", domain, testKey)
t.Logf("裸 URL: %s", rawURL)
httpResp, err := http.Get(rawURL)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer httpResp.Body.Close()
t.Logf("裸 URL 状态码: %d", httpResp.StatusCode)
var buf bytes.Buffer
buf.ReadFrom(httpResp.Body)
t.Logf("响应大小: %d bytes", buf.Len())
}
// TestDownloadSigned 签名 URL 下载
func TestDownloadSigned(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
signedURL, err := c.GetSignedURL(context.Background(), testKey, 1*time.Hour)
if err != nil {
t.Fatalf("生成签名 URL 失败: %v", err)
}
t.Logf("签名 URL: %s...", signedURL[:min(120, len(signedURL))])
httpResp, err := http.Get(signedURL)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer httpResp.Body.Close()
t.Logf("签名 URL 状态码: %d", httpResp.StatusCode)
var buf bytes.Buffer
buf.ReadFrom(httpResp.Body)
t.Logf("下载大小: %d bytes", buf.Len())
if httpResp.StatusCode != 200 {
t.Errorf("下载失败: %d, body: %s", httpResp.StatusCode, buf.String()[:min(200, buf.Len())])
}
}
// TestDownloadViaClient 通过 Client.Download 方法下载
func TestDownloadViaClient(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
var buf bytes.Buffer
err = c.Download(context.Background(), testKey, &buf)
if err != nil {
t.Errorf("Client.Download 失败: %v", err)
} else {
t.Logf("Client.Download 成功,大小: %d bytes (预期 ~7MB)", buf.Len())
}
}
// TestGetFileInfo 获取文件信息
func TestGetFileInfo(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
info, err := c.GetFileInfo(context.Background(), testKey)
if err != nil {
t.Errorf("GetFileInfo 失败: %v", err)
} else {
t.Logf("GetFileInfo: key=%s size=%d", info.Key, info.Size)
}
}
// TestListFiles 列举文件
func TestListFiles(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
result, err := c.ListFiles(context.Background(), &oss.ListOptions{Prefix: "music/", MaxKeys: 10})
if err != nil {
t.Fatalf("ListFiles 失败: %v", err)
}
for _, f := range result.Files {
t.Logf("文件: %-80s size: %d", f.Key, f.Size)
}
}
// TestListFilesRaw 原始 RSF 请求查看响应结构
func TestListFilesRaw(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
resp, err := c.doRSFRequest("GET", fmt.Sprintf("/list?bucket=%s&limit=3&prefix=music/", testConfig().Bucket))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var buf bytes.Buffer
buf.ReadFrom(resp.Body)
var pretty bytes.Buffer
json.Indent(&pretty, buf.Bytes(), "", " ")
t.Logf("原始响应:\n%s", pretty.String())
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -156,8 +156,8 @@ func (uc *UploadClient) Upload(ctx context.Context, key string, reader io.Reader
}
var uploadURL string
if uc.config.UploadDomain != "" {
uploadURL = scheme + uc.config.UploadDomain
if uc.config.DownloadDomain != "" {
uploadURL = scheme + uc.config.DownloadDomain
} else {
// 根据区域选择
switch uc.config.Region {