update
This commit is contained in:
60
internal/pkg/hmac/hmac.go
Normal file
60
internal/pkg/hmac/hmac.go
Normal file
@ -0,0 +1,60 @@
|
||||
// Package hmac 提供爬虫 → 后台 ingest 接口的 HMAC-SHA256 请求签名与校验。
|
||||
//
|
||||
// 设计目标(与公开接口「前端 JS 签名」区分):
|
||||
// - 这里面向「服务端到服务端」的爬虫上报,密钥绝不下发到任何前端 bundle,
|
||||
// 只存在于爬虫配置与后台 INGEST_SECRET 环境变量,安全性高得多。
|
||||
// - 通过 X-Signature / X-Timestamp / X-Nonce 三头防篡改 + 防重放:
|
||||
// - X-Timestamp 容忍窗口(默认 ±5 分钟)挡掉过期请求;
|
||||
// - X-Nonce 一次性随机串,由后台入库去重挡掉重放(见 repository.IngestRepository 的 nonce 表)。
|
||||
//
|
||||
// 签名串拼接:HMAC_SHA256(secret, timestamp + "." + nonce + "." + bodyRaw)
|
||||
// bodyRaw 是请求体的原始字节(未编码),保证签名与服务端收到的字节严格一致。
|
||||
package hmac
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultTTL 签名时间戳默认容忍窗口(秒)。
|
||||
const DefaultTTL = 300
|
||||
|
||||
// Sign 生成签名:对 timestamp.nonce.body 做 HMAC-SHA256,返回十六进制串。
|
||||
func Sign(secret, timestamp, nonce, body string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(timestamp))
|
||||
mac.Write([]byte("."))
|
||||
mac.Write([]byte(nonce))
|
||||
mac.Write([]byte("."))
|
||||
mac.Write([]byte(body))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify 校验请求签名,同时检查时间戳窗口。
|
||||
//
|
||||
// 返回 (ok, error):ok=false 表示签名或时间戳不通过;error 仅用于内部异常(理论上不会返回)。
|
||||
// 注:nonce 防重放不在此处判断,交由调用方(中间件 / 仓储)查库,
|
||||
// 因为 nonce 是否重复依赖持久化状态,且失败时应返回 409 而非 401。
|
||||
func Verify(secret, body, sigHeader, tsHeader, nonceHeader string, ttl int) bool {
|
||||
if sigHeader == "" || tsHeader == "" || nonceHeader == "" {
|
||||
return false
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultTTL
|
||||
}
|
||||
ts, err := strconv.ParseInt(tsHeader, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if diff := now - ts; diff > int64(ttl) || diff < -int64(ttl) {
|
||||
return false
|
||||
}
|
||||
expected := Sign(secret, tsHeader, nonceHeader, body)
|
||||
// 定长比较防时序侧信道
|
||||
return hmac.Equal([]byte(expected), []byte(strings.TrimSpace(sigHeader)))
|
||||
}
|
||||
78
internal/pkg/hmac/hmac_test.go
Normal file
78
internal/pkg/hmac/hmac_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package hmac
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
secret := "topsecret"
|
||||
body := `{"brand_uid":"001DESke","source_url":"https://vogue.com/x"}`
|
||||
ts := tsNow()
|
||||
nonce := "abc123nonce"
|
||||
|
||||
sig := Sign(secret, ts, nonce, body)
|
||||
if !Verify(secret, body, sig, ts, nonce, DefaultTTL) {
|
||||
t.Fatal("valid signature should verify")
|
||||
}
|
||||
// 篡改 body → 失败
|
||||
if Verify(secret, body+"x", sig, ts, nonce, DefaultTTL) {
|
||||
t.Fatal("tampered body should fail")
|
||||
}
|
||||
// 错误密钥 → 失败
|
||||
if Verify("wrong", body, sig, ts, nonce, DefaultTTL) {
|
||||
t.Fatal("wrong secret should fail")
|
||||
}
|
||||
// 缺头 → 失败
|
||||
if Verify(secret, body, "", ts, nonce, DefaultTTL) {
|
||||
t.Fatal("missing header should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsStaleTimestamp(t *testing.T) {
|
||||
secret := "s"
|
||||
body := "{}"
|
||||
// 早于窗口
|
||||
old := timeNowMinus(600)
|
||||
nonce := "n1"
|
||||
sig := Sign(secret, old, nonce, body)
|
||||
if Verify(secret, body, sig, old, nonce, DefaultTTL) {
|
||||
t.Fatal("stale timestamp should be rejected")
|
||||
}
|
||||
// 晚于窗口(未来太多)
|
||||
future := timeNowPlus(600)
|
||||
sig2 := Sign(secret, future, nonce, body)
|
||||
if Verify(secret, body, sig2, future, nonce, DefaultTTL) {
|
||||
t.Fatal("future timestamp beyond window should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// 简易时间助手(避免直接依赖 time.Now 的不可控)
|
||||
func tsNow() string { return itoa(int64(time.Now().Unix())) }
|
||||
func timeNowMinus(sec int) string {
|
||||
return itoa(int64(time.Now().Unix()) - int64(sec))
|
||||
}
|
||||
func timeNowPlus(sec int) string {
|
||||
return itoa(int64(time.Now().Unix()) + int64(sec))
|
||||
}
|
||||
func itoa(v int64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
buf := [20]byte{}
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
133
internal/pkg/imgurl/imgurl.go
Normal file
133
internal/pkg/imgurl/imgurl.go
Normal file
@ -0,0 +1,133 @@
|
||||
// Package imgurl 把数据库里存储的图片存储值拼装为对外可访问的完整 URL。
|
||||
//
|
||||
// 存储约定(见迁移 007):库里只存「七牛 key」(如 runway/abc.jpg)或「本地相对路径」
|
||||
// (如 /uploads/runway/x.jpg),绝不存完整 URL。这样做两个好处:
|
||||
// 1. 换 CDN 域名只改配置,无需迁移全表;
|
||||
// 2. 做「普通 / 高清」分级时无需从 URL 反向解析 key。
|
||||
//
|
||||
// 分级与安全(付费墙):
|
||||
// - 高清(hd=true,仅 VIP):私有空间下载签名 URL(base_url/key-StyleVip,如 key-vip),限时有效;
|
||||
// 免费/未登录用户拿不到 VIP 样式 key,即使从别处得到 key,因无签名也会被七牛拒绝(403)。
|
||||
// - 普通(hd=false,所有人):base_url/key-StyleNormal(如 key-free)的私有空间签名 URL,长过期(供 SSG 静态页与 CDN 缓存)。
|
||||
// - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)。
|
||||
// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套七牛样式,签名无意义)。
|
||||
//
|
||||
// 七牛私有空间下载签名:对 "base_url/key?e=<deadline>" 做 HMAC-SHA1(SK),urlsafe base64 后拼
|
||||
// "AK:sign" 作为 token。签名覆盖完整 path(含 -StyleNormal / -StyleVip 后缀),七牛校验通过才返回资源。
|
||||
// 前置:bucket 须在七牛控制台设为「私有」,并把 base_url 改为自定义 HTTPS 域名。
|
||||
package imgurl
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Composer 图片 URL 拼装器。
|
||||
type Composer struct {
|
||||
// BaseURL 七牛 bucket 绑定域名,如 https://cdn.toom-studio.com(末尾无斜杠)。
|
||||
BaseURL string
|
||||
// StyleNormal 普通图七牛命名样式名(对应七牛控制台创建的样式),如 "free"。
|
||||
// 访问即 base_url/key-StyleNormal。务必与控制台样式名一致(如 free 或 free.webp)。
|
||||
StyleNormal string
|
||||
// StyleVip VIP 高清七牛命名样式名(对应七牛控制台创建的样式),如 "vip"。
|
||||
// 访问即 base_url/key-StyleVip。VIP 用户走此样式;务必与控制台样式名一致。
|
||||
StyleVip string
|
||||
// LocalBase 本地兜底图片前缀;通常空——本地 /uploads 路径由前端 toAbs 拼 host。
|
||||
LocalBase string
|
||||
// AK/SK 七牛 AccessKey/SecretKey,用于私有空间下载签名。未配置时回落为公开 URL。
|
||||
AK string
|
||||
SK string
|
||||
// SignTTLNormal 普通/压缩图(含命名样式 -free)签名过期秒;长过期供 SSG 静态页与 CDN 缓存。
|
||||
SignTTLNormal int64
|
||||
// SignTTLHD VIP 高清原图签名过期秒;短过期使泄漏窗口小。
|
||||
SignTTLHD int64
|
||||
}
|
||||
|
||||
// New 构造 Composer。styleNormal 缺省回落 "free";styleVip 缺省回落 "vip";两个 TTL 缺省回落 31536000 / 3600。
|
||||
func New(baseURL, styleNormal, styleVip, localBase, ak, sk string, ttlNormal, ttlHD int64) *Composer {
|
||||
if styleNormal == "" {
|
||||
styleNormal = "free"
|
||||
}
|
||||
if styleVip == "" {
|
||||
styleVip = "vip"
|
||||
}
|
||||
if ttlNormal <= 0 {
|
||||
ttlNormal = 31536000
|
||||
}
|
||||
if ttlHD <= 0 {
|
||||
ttlHD = 3600
|
||||
}
|
||||
return &Composer{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
StyleNormal: styleNormal,
|
||||
StyleVip: styleVip,
|
||||
LocalBase: strings.TrimRight(localBase, "/"),
|
||||
AK: ak,
|
||||
SK: sk,
|
||||
SignTTLNormal: ttlNormal,
|
||||
SignTTLHD: ttlHD,
|
||||
}
|
||||
}
|
||||
|
||||
// sign 生成七牛私有空间下载签名 URL。
|
||||
//
|
||||
// rawKey 是待访问的对象路径(可能含 -StyleNormal 后缀)。签名字符串为
|
||||
// "base_url/rawKey?e=<deadline>",对其做 HMAC-SHA1(SK) 后 urlsafe base64 得到 token。
|
||||
//
|
||||
// 无 AK/SK 或 BaseURL 时回落为公开 URL(兼容未配置七牛的开发态:公开空间会忽略 token 直接返回)。
|
||||
func (c *Composer) sign(rawKey string, ttl int64) string {
|
||||
if c.BaseURL == "" {
|
||||
return rawKey
|
||||
}
|
||||
if c.AK == "" || c.SK == "" {
|
||||
return c.BaseURL + "/" + rawKey
|
||||
}
|
||||
deadline := time.Now().Unix() + ttl
|
||||
toSign := fmt.Sprintf("%s/%s?e=%d", c.BaseURL, rawKey, deadline)
|
||||
mac := hmac.New(sha1.New, []byte(c.SK))
|
||||
mac.Write([]byte(toSign))
|
||||
enc := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return fmt.Sprintf("%s&token=%s:%s", toSign, c.AK, enc)
|
||||
}
|
||||
|
||||
// Compose 把存储值拼成可访问 URL。
|
||||
//
|
||||
// - stored 为空:返回 ""
|
||||
// - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)
|
||||
// - 已是完整 http(s) URL(历史遗留 / 外链图,如 vogue):hd 原样返回;
|
||||
// normal 尝试从 base_url 还原 key 再套样式签名,还原失败原样返回(外链无法套样式)
|
||||
// - 七牛 key:hd → 原图签名 URL(短过期);normal → 普通图(命名样式)签名 URL(长过期)
|
||||
//
|
||||
// 这样免费/未登录用户永远拿不到「不带签名的原图 URL」;VIP 拿到的是限时签名原图 URL。
|
||||
func (c *Composer) Compose(stored string, hd bool) string {
|
||||
stored = strings.TrimSpace(stored)
|
||||
if stored == "" {
|
||||
return ""
|
||||
}
|
||||
// 本地兜底路径:由前端拼绝对地址,原样返回。
|
||||
if strings.HasPrefix(stored, "/uploads/") {
|
||||
return stored
|
||||
}
|
||||
// 已是完整 URL(历史遗留 / 外链图)。
|
||||
if strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") {
|
||||
if hd {
|
||||
return stored
|
||||
}
|
||||
// 同域七牛 URL:剥掉 base_url 还原 key,再套普通样式签名。
|
||||
if c.BaseURL != "" {
|
||||
if key, ok := strings.CutPrefix(stored, c.BaseURL+"/"); ok {
|
||||
return c.sign(key+"-"+c.StyleNormal, c.SignTTLNormal)
|
||||
}
|
||||
}
|
||||
return stored // 外链图无法套样式,原样返回
|
||||
}
|
||||
// 七牛 key:VIP 高清(-StyleVip 命名样式)签名(短过期);免费图(-StyleNormal 命名样式)签名(长过期)。
|
||||
if hd {
|
||||
return c.sign(stored+"-"+c.StyleVip, c.SignTTLHD)
|
||||
}
|
||||
return c.sign(stored+"-"+c.StyleNormal, c.SignTTLNormal)
|
||||
}
|
||||
33
internal/pkg/imgurl/vip_check_test.go
Normal file
33
internal/pkg/imgurl/vip_check_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package imgurl
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVipStyle 验证:免费(free)用 -free 命名样式、VIP 用 -vip 命名样式,且均带私有空间签名。
|
||||
func TestVipStyle(t *testing.T) {
|
||||
c := New("http://toom-studio.23cm.cn", "free", "vip", "", "AK_TEST", "SK_TEST", 31536000, 3600)
|
||||
|
||||
free := c.Compose("runway/abc123.jpg", false)
|
||||
vip := c.Compose("runway/abc123.jpg", true)
|
||||
|
||||
t.Logf("free URL = %s", free)
|
||||
t.Logf("vip URL = %s", vip)
|
||||
|
||||
if free == "" || vip == "" {
|
||||
t.Fatal("URL 不应为空")
|
||||
}
|
||||
if !strings.Contains(free, "-free") {
|
||||
t.Fatalf("免费图应拼 -free 命名样式,实际: %s", free)
|
||||
}
|
||||
if !strings.Contains(vip, "-vip") {
|
||||
t.Fatalf("VIP 图应拼 -vip 命名样式,实际: %s", vip)
|
||||
}
|
||||
if !strings.Contains(free, "token=") || !strings.Contains(vip, "token=") {
|
||||
t.Fatal("两种图都必须带私有空间下载签名 token=")
|
||||
}
|
||||
if strings.Contains(vip, "-free") {
|
||||
t.Fatal("VIP 图不应混入 -free")
|
||||
}
|
||||
}
|
||||
71
internal/pkg/season/season.go
Normal file
71
internal/pkg/season/season.go
Normal file
@ -0,0 +1,71 @@
|
||||
// Package season 走秀季节 / 年份 → season_code 的推导与解析辅助。
|
||||
//
|
||||
// season_code 是对外与排序共用的紧凑编码:SS26 / FW25 / RES26 / PF25,
|
||||
// 规则:前缀由 collection_type(rtw/menswear→SS/FW;resort→RES;pre_fall→PF)
|
||||
// 或 season(spring→SS / fall→FW)决定,后缀为两位年份。
|
||||
// 历史代码 vogue.go 漏填 season_code 是已知 bug,这里集中推导逻辑,worker 与回填脚本复用。
|
||||
package season
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CollectionType / Season 取值白名单。
|
||||
const (
|
||||
ColRTW = "rtw"
|
||||
ColMenswear = "menswear"
|
||||
ColCouture = "couture"
|
||||
ColResort = "resort"
|
||||
ColPreFall = "pre_fall"
|
||||
|
||||
SeasonSpring = "spring"
|
||||
SeasonFall = "fall"
|
||||
)
|
||||
|
||||
// Derive 由年份 + 系列/季节推导 season_code(如 2026 + spring → "SS26")。
|
||||
//
|
||||
// 优先级:collection_type 先在(resort→RES、pre_fall→PF);否则用 season
|
||||
// (spring→SS、fall→FW);二者皆空或无法识别时回落 "SS"。
|
||||
// year 为 0 时返回空串(未知年份无法编码)。
|
||||
func Derive(year uint16, collectionType, season string) string {
|
||||
if year == 0 {
|
||||
return ""
|
||||
}
|
||||
prefix := prefixOf(collectionType, season)
|
||||
return prefix + twoDigit(year)
|
||||
}
|
||||
|
||||
func prefixOf(collectionType, season string) string {
|
||||
switch collectionType {
|
||||
case ColResort:
|
||||
return "RES"
|
||||
case ColPreFall:
|
||||
return "PF"
|
||||
case ColCouture:
|
||||
// 高定沿用秋冬编码(FW),与排序优先级一致
|
||||
return "FW"
|
||||
case ColMenswear:
|
||||
return "FW"
|
||||
case ColRTW:
|
||||
return "SS"
|
||||
}
|
||||
switch season {
|
||||
case SeasonFall:
|
||||
return "FW"
|
||||
case SeasonSpring:
|
||||
return "SS"
|
||||
}
|
||||
return "SS"
|
||||
}
|
||||
|
||||
// twoDigit 取年份后两位(2026 → "26")。
|
||||
func twoDigit(year uint16) string {
|
||||
if year >= 2000 {
|
||||
year -= 2000
|
||||
}
|
||||
s := strconv.Itoa(int(year))
|
||||
if len(s) > 2 {
|
||||
s = s[len(s)-2:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
28
internal/pkg/season/season_test.go
Normal file
28
internal/pkg/season/season_test.go
Normal file
@ -0,0 +1,28 @@
|
||||
package season
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDerive(t *testing.T) {
|
||||
cases := []struct {
|
||||
year uint16
|
||||
col string
|
||||
season string
|
||||
want string
|
||||
}{
|
||||
{2026, ColRTW, SeasonSpring, "SS26"},
|
||||
{2025, "", SeasonFall, "FW25"},
|
||||
{2026, ColResort, "", "RES26"},
|
||||
{2026, ColPreFall, "", "PF26"},
|
||||
{2025, ColMenswear, "", "FW25"},
|
||||
{2025, ColCouture, "", "FW25"},
|
||||
{2026, "", "", "SS26"}, // 二者皆空回落 SS
|
||||
{0, ColRTW, SeasonSpring, ""}, // 未知年份无法编码
|
||||
{2099, ColRTW, SeasonSpring, "SS99"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Derive(c.year, c.col, c.season)
|
||||
if got != c.want {
|
||||
t.Fatalf("Derive(%d,%q,%q)=%q want %q", c.year, c.col, c.season, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
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