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
|
||||
}
|
||||
55
internal/pkg/storage/qiniu_purge_verify_test.go
Normal file
55
internal/pkg/storage/qiniu_purge_verify_test.go
Normal file
@ -0,0 +1,55 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/config"
|
||||
)
|
||||
|
||||
// TestQiniuPurgeRoundTrip 真实验证七牛删除能力(管理凭证签名是否正确):
|
||||
// 上传一个临时 key → 删除(期望 200)→ 再删一次(期望 612 资源不存在,证明已被删)。
|
||||
// 若管理 token 签名错误,七牛会返回 401(invalid token),测试即失败。
|
||||
// 仅在七牛启用且能连通时运行;否则跳过。
|
||||
func TestQiniuPurgeRoundTrip(t *testing.T) {
|
||||
cfg, err := config.Load("../../../configs/config.yml")
|
||||
if err != nil {
|
||||
t.Skipf("加载配置失败,跳过: %v", err)
|
||||
}
|
||||
if !cfg.Qiniu.Enabled {
|
||||
t.Skip("七牛未启用,跳过删除验证")
|
||||
}
|
||||
|
||||
up := NewQiniuUploader(cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.Bucket, cfg.Qiniu.Zone, "")
|
||||
key := fmt.Sprintf("runway/_purge_verify_%d.bin", time.Now().UnixNano())
|
||||
|
||||
ctx := context.Background()
|
||||
// 1) 上传临时对象(任意字节即可,七牛不校验内容格式)
|
||||
if _, err := up.Upload(ctx, key, []byte("purge-verify-test-content"), "application/octet-stream"); err != nil {
|
||||
t.Fatalf("上传临时对象失败(无法继续验证删除): %v", err)
|
||||
}
|
||||
t.Logf("已上传临时 key=%s", key)
|
||||
|
||||
// 2) 删除(期望 200 / nil)
|
||||
if err := up.Delete(ctx, key); err != nil {
|
||||
// 401 表示管理 token 签名错误,是本次验证的核心目标
|
||||
if strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("七牛删除返回 401,管理凭证签名错误: %v", err)
|
||||
}
|
||||
t.Fatalf("首次删除失败: %v", err)
|
||||
}
|
||||
t.Logf("首次删除成功(200),key=%s", key)
|
||||
|
||||
// 3) 再删一次(期望 612 资源不存在,证明确实被删除)
|
||||
if err := up.Delete(ctx, key); err != nil {
|
||||
if !strings.Contains(err.Error(), "612") {
|
||||
t.Fatalf("二次删除期望 612(资源不存在),实际: %v", err)
|
||||
}
|
||||
t.Logf("二次删除返回 612,确认对象已删除: %s", key)
|
||||
} else {
|
||||
t.Fatalf("二次删除应返回 612(资源不存在),却返回 200,说明首次删除未生效")
|
||||
}
|
||||
}
|
||||
71
internal/pkg/storage/storage.go
Normal file
71
internal/pkg/storage/storage.go
Normal file
@ -0,0 +1,71 @@
|
||||
// Package storage 抽象「把一段图片字节存成可访问地址」的实现。
|
||||
//
|
||||
// 两种实现:
|
||||
// - LocalUploader:落到本地磁盘,对外以静态路径暴露(兜底/默认实现)。
|
||||
// - QiniuUploader:直传七牛云对象存储(标准库实现,无第三方 SDK 依赖)。
|
||||
//
|
||||
// 入库 worker 主用 Qiniu(若启用),单张失败时兜底写本地,避免图片完全丢失。
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Uploader 抽象图片上传。
|
||||
// Upload 返回写入数据库的图片访问地址:
|
||||
// - Qiniu 返回完整 https URL(base_url + key)
|
||||
// - 本地实现返回 Upload.URLPrefix + "/" + key 的相对路径
|
||||
type Uploader interface {
|
||||
// Upload 上传 data 到 key 指定的对象,返回访问 URL。
|
||||
Upload(ctx context.Context, key string, data []byte, contentType string) (string, error)
|
||||
// Enabled 表示该实现是否真正启用(用于日志/兜底判断)。
|
||||
Enabled() bool
|
||||
}
|
||||
|
||||
// Deleter 抽象图片删除(清理孤儿文件用)。
|
||||
// QiniuUploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中
|
||||
// 引用计数归零时才由调用方触发,避免误删仍被引用的文件。
|
||||
type Deleter interface {
|
||||
// Delete 删除 key 指定的对象/文件。key 不存在时按成功处理(幂等)。
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// LocalUploader 把文件落到本地磁盘并对外以静态路径暴露(兜底/默认实现)。
|
||||
type LocalUploader struct {
|
||||
Dir string
|
||||
URLPrefix string
|
||||
}
|
||||
|
||||
// NewLocalUploader 构造本地上传器。
|
||||
func NewLocalUploader(dir, urlPrefix string) *LocalUploader {
|
||||
return &LocalUploader{Dir: dir, URLPrefix: strings.TrimRight(urlPrefix, "/")}
|
||||
}
|
||||
|
||||
// Enabled 本地上传始终可用。
|
||||
func (l *LocalUploader) Enabled() bool { return true }
|
||||
|
||||
// Upload 写本地文件并返回相对静态路径(如 /uploads/runway/xxx.jpg)。
|
||||
func (l *LocalUploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
|
||||
abs := filepath.Join(l.Dir, filepath.FromSlash(key))
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(abs, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return l.URLPrefix + "/" + strings.TrimLeft(key, "/"), nil
|
||||
}
|
||||
|
||||
// Delete 删除本地文件(清理孤儿图用)。key 可能带 URLPrefix 前缀,先剥掉再定位磁盘文件。
|
||||
func (l *LocalUploader) Delete(ctx context.Context, key string) error {
|
||||
rel := strings.TrimPrefix(key, l.URLPrefix)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
abs := filepath.Join(l.Dir, filepath.FromSlash(rel))
|
||||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user