update
This commit is contained in:
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 签名。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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,说明首次删除未生效")
|
||||
}
|
||||
}
|
||||
@ -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 不存在时按成功处理(幂等)。
|
||||
|
||||
@ -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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user