This commit is contained in:
toom1996
2026-09-07 00:04:01 +08:00
parent 6a5a4378ab
commit 10d8a96e8c
29 changed files with 5349 additions and 0 deletions

60
internal/pkg/hmac/hmac.go Normal file
View 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)))
}

View 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:])
}

View 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)
}

View 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")
}
}

View 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
}

View 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)
}
}
}

View 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
}

View 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,说明首次删除未生效")
}
}

View 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
}