Files
backend_v2/internal/pkg/imgurl/imgurl.go
toom1996 10d8a96e8c update
2026-09-07 00:04:01 +08:00

134 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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