Files
toom1996 10d8a96e8c update
2026-09-07 00:04:01 +08:00

197 lines
6.1 KiB
Go
Raw Permalink 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 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
}