This commit is contained in:
toom1996
2026-09-13 00:52:35 +08:00
parent 10d8a96e8c
commit dbee704c95
44 changed files with 755 additions and 1148 deletions

View File

@ -1,109 +1,71 @@
// Package imgurl 把数据库里存储的图片存储值拼装为对外可访问的完整 URL。
//
// 存储约定(见迁移 007):库里只存「七牛 key」(如 runway/abc.jpg)或「本地相对路径」
// 存储约定(见迁移 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 缓存)。
// 质量模型(2026-09-11 调整):VIP 与免费用户现在看到同一质量,高清样式名为 `high`,
// 由缤纷云 CoreIX 公开样式提供(base_url/key!style:high),匿名可访问、免 token。
// 旧「免费低清 / VIP 原图预签名」付费墙已取消,故 Composer 不再区分 tier。
// - 列表缩略图:另走 `thumb` 公开样式(ComposeThumb),仅用于列表/卡片等小尺寸场景,省流量。
// - 本地兜底 /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 域名。
// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套样式)。
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 对外访问域名,如 https://toomstudio.s3.bitiful.net(末尾无斜杠)。
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
// StyleDisplay 全量展示图 CoreIX 样式名(如 "high",对应控制台建的公开样式);
// 若值含 "=" 则视为查询串(如 "w=1080&q=80&fmt=webp"),拼成 base_url/key?查询串。
StyleDisplay string
// StyleThumb 列表缩略图 CoreIX 样式名(如 "thumb");仅用于列表/卡片等小尺寸场景。
// 留空时回落 StyleDisplay(无独立缩略图样式时行为与旧版一致)。
StyleThumb string
}
// 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"
// New 构造 Composer。
// - styleDisplay 缺省回落 "high"(高清公开样式,VIP 与免费用户同质量);
// - styleThumb 缺省回落 styleDisplay(未单独配置缩略图样式时,列表与详情同图)。
func New(baseURL, styleDisplay, styleThumb string) *Composer {
if styleDisplay == "" {
styleDisplay = "high"
}
if styleVip == "" {
styleVip = "vip"
}
if ttlNormal <= 0 {
ttlNormal = 31536000
}
if ttlHD <= 0 {
ttlHD = 3600
if styleThumb == "" {
styleThumb = styleDisplay
}
return &Composer{
BaseURL: strings.TrimRight(baseURL, "/"),
StyleNormal: styleNormal,
StyleVip: styleVip,
LocalBase: strings.TrimRight(localBase, "/"),
AK: ak,
SK: sk,
SignTTLNormal: ttlNormal,
SignTTLHD: ttlHD,
BaseURL: strings.TrimRight(baseURL, "/"),
StyleDisplay: styleDisplay,
StyleThumb: styleThumb,
}
}
// 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。
// Compose 把存储值拼成可访问 URL(详情/大图场景,统一走 StyleDisplay)。
//
// - 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 {
// - 已是完整 http(s) URL(历史遗留 / 外链图):原样返回(无法套样式)
// - key:base_url/key!style:high(或查询串模式)
func (c *Composer) Compose(stored string) string {
return c.compose(stored, c.StyleDisplay)
}
// ComposeThumb 把存储值拼成「列表缩略图」URL(卡片 / 列表封面 / 图条等小尺寸场景)。
// 与 Compose 共用同一套兜底逻辑,仅样式名换成 StyleThumb(未配置时回落 StyleDisplay)。
// 注意:仅适用于同一 base_url 下的对象 key;外链与 /uploads 路径不做处理(无法套样式)。
func (c *Composer) ComposeThumb(stored string) string {
return c.compose(stored, c.StyleThumb)
}
// compose 应用指定样式拼装 URL。
func (c *Composer) compose(stored, style string) string {
stored = strings.TrimSpace(stored)
if stored == "" {
return ""
@ -112,22 +74,22 @@ func (c *Composer) Compose(stored string, hd bool) string {
if strings.HasPrefix(stored, "/uploads/") {
return stored
}
// 已是完整 URL(历史遗留 / 外链图)。
// 已是完整 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 // 外链图无法套样式,原样返回
return stored
}
// 七牛 key:VIP 高清(-StyleVip 命名样式)签名(短过期);免费图(-StyleNormal 命名样式)签名(长过期)。
if hd {
return c.sign(stored+"-"+c.StyleVip, c.SignTTLHD)
}
return c.sign(stored+"-"+c.StyleNormal, c.SignTTLNormal)
// 对象 key:按指定样式拼装(命名样式 key!style:xxx 或查询串 key?w=..&q=..)。
return c.styledURL(stored, style)
}
// styledURL 按样式名生成地址:命名样式(key!style:high)或查询串(key?w=..&q=..)两种模式。
func (c *Composer) styledURL(key, style string) string {
if strings.Contains(style, "=") {
sep := "?"
if strings.Contains(style, "?") {
sep = "&"
}
return fmt.Sprintf("%s/%s%s%s", c.BaseURL, key, sep, style)
}
return fmt.Sprintf("%s/%s!style:%s", c.BaseURL, key, style)
}

View File

@ -5,29 +5,78 @@ import (
"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)
// TestDisplayStyle 验证新模型(2026-09-11):VIP 与免费用户同质量,统一走展示样式。
// - 命名样式(默认 high):base_url/key!style:high
// - 查询串模式(style 含 =):base_url/key?w=..&q=..
// - /uploads/ 与 http(s) 原样返回
func TestDisplayStyle(t *testing.T) {
c := New("https://mybucket.s3.bitiful.net", "high", "thumb", "")
got := c.Compose("runway/abc123.jpg")
want := "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high"
if got != want {
t.Fatalf("命名样式拼装错误: 期望 %s 实际 %s", want, got)
}
free := c.Compose("runway/abc123.jpg", false)
vip := c.Compose("runway/abc123.jpg", true)
// 缺省回落 high
c0 := New("https://mybucket.s3.bitiful.net", "", "", "")
if c0.Compose("runway/abc123.jpg") != "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high" {
t.Fatal("style 缺省应回落 high")
}
t.Logf("free URL = %s", free)
t.Logf("vip URL = %s", vip)
// 查询串模式
cq := New("https://mybucket.s3.bitiful.net", "w=1080&q=80&fmt=webp", "", "")
gq := cq.Compose("runway/abc123.jpg")
wq := "https://mybucket.s3.bitiful.net/runway/abc123.jpg?w=1080&q=80&fmt=webp"
if gq != wq {
t.Fatalf("查询串模式拼装错误: 期望 %s 实际 %s", wq, gq)
}
if free == "" || vip == "" {
t.Fatal("URL 不应为空")
// 本地兜底与外链原样返回
if c.Compose("/uploads/runway/x.jpg") != "/uploads/runway/x.jpg" {
t.Fatal("/uploads/ 应原样返回")
}
if !strings.Contains(free, "-free") {
t.Fatalf("免费图应拼 -free 命名样式,实际: %s", free)
if c.Compose("https://vogue.com/foo.jpg") != "https://vogue.com/foo.jpg" {
t.Fatal("http(s) 应原样返回")
}
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")
if c.Compose("") != "" {
t.Fatal("空串应返回空")
}
}
// TestComposeThumb 验证列表缩略图拼装:走 StyleThumb,兜底逻辑与 Compose 一致。
func TestComposeThumb(t *testing.T) {
c := New("https://mybucket.s3.bitiful.net", "high", "thumb", "")
got := c.ComposeThumb("runway/abc123.jpg")
want := "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:thumb"
if got != want {
t.Fatalf("缩略图样式拼装错误: 期望 %s 实际 %s", want, got)
}
// 未配置 styleThumb 时应回落展示样式,不得拼出空样式(key!style:)
c0 := New("https://mybucket.s3.bitiful.net", "high", "", "")
if got := c0.ComposeThumb("runway/abc123.jpg"); got != "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high" {
t.Fatalf("styleThumb 缺省应回落 styleDisplay,实际 %s", got)
}
// 兜底:本地路径 / 外链 / 空串
if c.ComposeThumb("/uploads/runway/x.jpg") != "/uploads/runway/x.jpg" {
t.Fatal("/uploads/ 应原样返回")
}
if c.ComposeThumb("https://vogue.com/foo.jpg") != "https://vogue.com/foo.jpg" {
t.Fatal("http(s) 应原样返回")
}
if c.ComposeThumb("") != "" {
t.Fatal("空串应返回空")
}
}
// TestDisplayStyleURLSafe 确认展示 URL 不含任何签名/过期参数(匿名可访问)。
func TestDisplayStyleURLSafe(t *testing.T) {
c := New("https://mybucket.s3.bitiful.net", "high", "thumb", "")
u := c.Compose("runway/abc123.jpg")
for _, bad := range []string{"X-Amz", "sign", "e="} {
if strings.Contains(u, bad) {
t.Fatalf("展示 URL 不应含签名参数 %q,实际: %s", bad, u)
}
}
}

View File

@ -41,11 +41,6 @@ func NewManager(secret string, expireHours int) *Manager {
}
}
// 注:Generate(签发令牌)已随注册 / 登录接口一并移除——当前服务没有任何签发入口,
// 只保留 Parse 供 middleware.Auth 校验既有令牌。
// 将来接入登录时恢复:构造 jwtlib.MapClaims{"uid","username","email","exp"},
// 再用 jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims).SignedString(m.secret) 签名即可。
// Generate 签发令牌(登录成功后调用)。
//
// 构造与 Parse 对称的 Claims(uid / username / email / tier / exp),用 HS256 签名。

View File

@ -1,14 +1,21 @@
// Package response 统一 HTTP 响应结构。
//
// 响应形状严格沿用原项目,前端 (src/lib/api.ts、data.ts) 依赖这些字段名:
// - 列表:{ data, last_page } // last_page 供前端真分页(页码导航)使用;total/per_page/current_page 前端无消费点,已移除以精简
// - 详情:{ data }
// 响应形状严格沿用原项目约定,前端依赖这些字段名:
// - 公开列表(SSG):Page 返回 { data, last_page },last_page 供前端页码导航;
// 运行期不回传 total / current_page / per_page(这些由 SSG 在构建期消费),故此处精简。
// - 「我的资源」列表(history/favorite):经 Data 返回 { items, total, last_page },
// total 供前端显示总数(如「共 N 条」)。
// - 详情:{ data }(可选 preview / image_total 门禁标记,见 DataPreview)
// - 错误:{ error }
//
// 集中在此处定义,避免各 handler 手写 gin.H 时出现字段拼写漂移。
package response
import "github.com/gin-gonic/gin"
import (
"github.com/gin-gonic/gin"
"fashionapi/internal/pkg/pagination"
)
// Data 返回单个对象:{ "data": v }。
func Data(c *gin.Context, status int, v any) {
@ -30,10 +37,11 @@ func DataPreview(c *gin.Context, status int, v any, preview bool, imageTotal int
c.JSON(status, m)
}
// Page 返回分页列表:{ data, last_page }。
// Page 返回分页列表:{ data, last_page },用于 SSG 公开列表(前端仅消费 data 与 last_page)。
//
// 只保留前端真正消费的字段:data(本页数据)与 last_page(总页数,供页码导航)。
// total / current_page / per_page 前端从无读取,移除以精简协议;last_page 仍由后端用 total 与固定 size 计算。
// 运行期不回传 total / current_page / per_page——这些由 SSG 在构建期消费,调用方无需关心;
// last_page 由后端用 total 与固定 size 计算。
// 注意:history/favorite 这类「我的资源」列表需返回 total,请用 response.Data 自行构造。
func Page(c *gin.Context, status int, data any, total int64, size int) {
c.JSON(status, gin.H{
"data": data,
@ -41,21 +49,6 @@ func Page(c *gin.Context, status int, data any, total int64, size int) {
})
}
// PagePreview 同 Page,但可附加 preview 标记(未登录截断预览时 preview=true)。
//
// 列表接口对未登录请求只返回预览上限(PreviewLimit)条数据,并打 preview=true,
// 前端据此弹出「登录查看全部」门禁;已登录或总量本就不超上限时不打标记。
func PagePreview(c *gin.Context, status int, data any, total int64, size int, preview bool) {
m := gin.H{
"data": data,
"last_page": LastPage(total, size),
}
if preview {
m["preview"] = true
}
c.JSON(status, m)
}
// Error 返回错误:{ "error": msg }。
func Error(c *gin.Context, status int, msg string) {
c.JSON(status, gin.H{"error": msg})
@ -67,16 +60,7 @@ func AbortError(c *gin.Context, status int, msg string) {
}
// LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。
// 实现委托给 pagination.LastPage,避免分页数学在多处重复。
func LastPage(total int64, size int) int {
if size <= 0 {
return 1
}
last := int(total) / size
if int(total)%size > 0 {
last++
}
if last == 0 {
last = 1
}
return last
return pagination.LastPage(total, size)
}

View File

@ -1,8 +1,9 @@
// 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)决定,后缀为两位年份。
// 规则:前缀由 collection_type 决定(resort→RES;pre_fall→PF;couture→FW;
// menswear→FW);rtw 无独立前缀,落入 season 分支(spring→SS / fall→FW),
// 后缀为两位年份。
// 历史代码 vogue.go 漏填 season_code 是已知 bug,这里集中推导逻辑,worker 与回填脚本复用。
package season
@ -46,8 +47,6 @@ func prefixOf(collectionType, season string) string {
return "FW"
case ColMenswear:
return "FW"
case ColRTW:
return "SS"
}
switch season {
case SeasonFall:

View File

@ -1,196 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -2,9 +2,9 @@
//
// 两种实现:
// - LocalUploader:落到本地磁盘,对外以静态路径暴露(兜底/默认实现)。
// - QiniuUploader:直传七牛云对象存储(标准库实现,无第三方 SDK 依赖)。
// - S4Uploader:直传缤纷云 S4 对象存储(兼容 S3,基于 aws-sdk-go-v2)。
//
// 入库 worker 主用 Qiniu(若启用),单张失败时兜底写本地,避免图片完全丢失。
// 入库 worker 主用 S4(若启用),单张失败时兜底写本地,避免图片完全丢失。
package storage
import (
@ -16,7 +16,7 @@ import (
// Uploader 抽象图片上传。
// Upload 返回写入数据库的图片访问地址:
// - Qiniu 返回完整 https URL(base_url + key)
// - S4 返回对象 key(base_url 由 imgurl.Composer 在渲染时拼)
// - 本地实现返回 Upload.URLPrefix + "/" + key 的相对路径
type Uploader interface {
// Upload 上传 data 到 key 指定的对象,返回访问 URL。
@ -26,7 +26,7 @@ type Uploader interface {
}
// Deleter 抽象图片删除(清理孤儿文件用)。
// QiniuUploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中
// S4Uploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中
// 引用计数归零时才由调用方触发,避免误删仍被引用的文件。
type Deleter interface {
// Delete 删除 key 指定的对象/文件。key 不存在时按成功处理(幂等)。

View File

@ -1,13 +0,0 @@
// Package textutil 提供文本处理辅助函数。
package textutil
// Summarize 按 rune 截断字符串,避免中文被按字节切断;超出部分追加省略号。
//
// 行为与原项目 handlers.summarize 完全一致(含省略号字符 U+2026)。
func Summarize(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "…"
}