update
This commit is contained in:
196
internal/pkg/storage/qiniu.go
Normal file
196
internal/pkg/storage/qiniu.go
Normal file
@ -0,0 +1,196 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QiniuUploader 通过七牛云表单直传(标准库实现,无第三方 SDK 依赖)。
|
||||
//
|
||||
// 上传凭证算法(与官方 SDK 一致):
|
||||
//
|
||||
// putPolicy = {"scope":bucket,"deadline":unix+3600,"insertOnly":1}
|
||||
// encPolicy = base64.URLEncoding(putPolicy) // urlsafe,保留 '=' 填充
|
||||
// sign = HMAC-SHA1(SK, encPolicy)
|
||||
// encSign = base64.URLEncoding(sign) // 同样保留填充
|
||||
// token = AK + ":" + encSign + ":" + encPolicy
|
||||
// 注意:必须用 base64.URLEncoding(带 '=' 填充),不能用 RawURLEncoding,否则服务端报 "bad token"。
|
||||
//
|
||||
// 然后以 multipart/form-data POST 到对应区域的上传域名,表单含 token / key / file。
|
||||
type QiniuUploader struct {
|
||||
ak, sk, bucket, baseURL string
|
||||
upHost string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewQiniuUploader 构造七牛上传器。
|
||||
// - zone: z0=华东 z1=华北 z2=华南 na0=北美 as0=新加坡
|
||||
// - baseURL: bucket 绑定的公开访问域名(如 https://cdn.toom-studio.com),用于拼出图片 URL;
|
||||
// 为空时仅返回对象 key(调用方需另行拼接域名)。
|
||||
func NewQiniuUploader(ak, sk, bucket, zone, baseURL string) *QiniuUploader {
|
||||
return &QiniuUploader{
|
||||
ak: ak,
|
||||
sk: sk,
|
||||
bucket: bucket,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
upHost: upHostByZone(zone),
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// upHostByZone 把区域映射为七牛上传域名。
|
||||
func upHostByZone(zone string) string {
|
||||
switch zone {
|
||||
case "z0":
|
||||
return "https://up-z0.qiniup.com"
|
||||
case "z1":
|
||||
return "https://up-z1.qiniup.com"
|
||||
case "z2":
|
||||
return "https://up-z2.qiniup.com"
|
||||
case "na0":
|
||||
return "https://up-na0.qiniup.com"
|
||||
case "as0":
|
||||
return "https://up-as0.qiniup.com"
|
||||
default:
|
||||
return "https://up-z1.qiniup.com"
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled 任一凭证缺失即视为未启用。
|
||||
func (q *QiniuUploader) Enabled() bool {
|
||||
return q.ak != "" && q.sk != "" && q.bucket != ""
|
||||
}
|
||||
|
||||
// makeToken 生成七牛上传凭证。
|
||||
func (q *QiniuUploader) makeToken() (string, error) {
|
||||
putPolicy := map[string]any{
|
||||
"scope": q.bucket,
|
||||
"deadline": time.Now().Add(time.Hour).Unix(),
|
||||
"insertOnly": 1,
|
||||
}
|
||||
b, err := json.Marshal(putPolicy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 注意:七牛 token 使用 urlsafe base64 但保留 '=' 填充(base64.URLEncoding),
|
||||
// 与官方 SDK 一致;不能用 RawURLEncoding,否则服务端校验签名失败报 "bad token"。
|
||||
encPolicy := base64.URLEncoding.EncodeToString(b)
|
||||
mac := hmac.New(sha1.New, []byte(q.sk))
|
||||
mac.Write([]byte(encPolicy))
|
||||
encSign := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return q.ak + ":" + encSign + ":" + encPolicy, nil
|
||||
}
|
||||
|
||||
// Upload 把 data 直传到七牛 bucket 的 key,返回可访问 URL。
|
||||
func (q *QiniuUploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
|
||||
token, err := q.makeToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
if err := mw.WriteField("token", token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.WriteField("key", key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fw, err := mw.CreateFormFile("file", path.Base(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.upHost, &buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("qiniu upload failed: status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 成功返回 {"hash":"...","key":"..."};失败返回 {"error":"..."}
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err == nil && out.Error != "" {
|
||||
return "", fmt.Errorf("qiniu error: %s", out.Error)
|
||||
}
|
||||
|
||||
if q.baseURL != "" {
|
||||
return q.baseURL + "/" + strings.TrimLeft(key, "/"), nil
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// rsHost 七牛管理 API 域名(全局,不分区域)。删除对象走此端点。
|
||||
const rsHost = "rs.qiniu.com"
|
||||
|
||||
// Delete 删除七牛 bucket 中的对象(按 key)。仅当引用计数归零时由调用方触发,
|
||||
// 避免误删仍被其他图集引用的文件(七牛 key 为 sha1 内容寻址,同名图跨图集共享 key)。
|
||||
//
|
||||
// 管理凭证(QBox 方案,与官方 SDK 一致):
|
||||
//
|
||||
// entry = base64.URLEncoding(bucket + ":" + key) // 带 '=' 填充
|
||||
// path = "/delete/" + entry
|
||||
// signing = path + "\n" // 仅 Path + 换行,不含 Method/Host/Content-Type
|
||||
// sign = HMAC-SHA1(SK, signing)
|
||||
// token = AK + ":" + base64.URLEncoding(sign)
|
||||
// Authorization: QBox <token> // 注意前缀是 QBox(下载凭证才是 Qiniu)
|
||||
func (q *QiniuUploader) Delete(ctx context.Context, key string) error {
|
||||
if !q.Enabled() {
|
||||
return nil
|
||||
}
|
||||
// 七牛管理凭证(QBox)签名串仅为 Path + "\n",前缀 "QBox";与下载凭证(imgurl.sign,前缀 "Qiniu")不同。
|
||||
entry := base64.URLEncoding.EncodeToString([]byte(q.bucket + ":" + key))
|
||||
path := "/delete/" + entry
|
||||
signing := path + "\n"
|
||||
mac := hmac.New(sha1.New, []byte(q.sk))
|
||||
mac.Write([]byte(signing))
|
||||
token := q.ak + ":" + base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
url := "https://" + rsHost + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "QBox "+token)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("qiniu delete failed: status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user