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 // 注意前缀是 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 }