需求:前台壁纸站支持切换到开源壁纸平台,并能展示后台上传的自家图库, 保留原有「浏览器实时生成」能力(扩展而非替换),PC / 移动双端一致。 设计要点(受服务器 5M 出口带宽约束): - 开源平台图片一律走官方 CDN 直链,后端只代理元数据,零带宽消耗。 - 自建图库落盘到 /data/www/wallpaper,由 nginx 的 /wallpaper/ 直出, Go 服务不参与传图;三档尺寸(480 缩略 / 1920 预览 / 原图仅供下载)。 - 内存缓存平台响应(Bing 1h、搜索类 10min),避免撞第三方配额。 内容: - 平台插件:Bing 每日一图、Picsum、Unsplash、Pexels、Wallhaven。 新增平台 = 写一个 provider_xxx.go 并在 allProviders() 加一行, 刻意不用 init() 自注册,避免隐式副作用。所有适配器支持 config.baseUrl 覆盖,用于绕开 DNS 污染(实测 wallhaven.cc 解析到境外无关 IP)。 - 自建图库:上传(MD5 秒传去重 / 尺寸前置校验 / 失败清理孤儿文件)、 元信息编辑、删除、统计;随机取图用「数总数 → 随机偏移」而非 ORDER BY RAND()。 - 接口:open 组 4 个(sources / list / random / download-track), admin 组 8 个(list / upload / save / delete / stats / source.list|save|test)。 全部 POST 且 URL 无参数,符合工作空间接口规范;上传走 multipart 例外。 - 安全:purity 默认锁死 SFW;apiKey 只回传布尔不回传明文; 保存时留空表示保留旧值;open 组错误信息对外脱敏。 - 配置:clientMaxBodySize 提到 64M(gf 默认 8M 会截断 20MB 原图); wallpaper.root / baseUrl 支持环境变量注入并在占位符未替换时兜底。 依赖:golang.org/x/image@v0.23.0(仅用于缩略图缩放,保持 go 1.23.0 不变)。
196 lines
5.9 KiB
Go
196 lines
5.9 KiB
Go
package wallpaper
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/jpeg"
|
||
_ "image/gif" // 注册 GIF 解码器
|
||
_ "image/png" // 注册 PNG 解码器
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
"golang.org/x/image/draw"
|
||
_ "golang.org/x/image/webp" // 注册 WebP 解码器(只解不编,够用)
|
||
)
|
||
|
||
// 派生图的尺寸上限。
|
||
//
|
||
// 这两个数字直接决定服务器出口带宽的消耗方式,是本模块最重要的性能参数:
|
||
// - 缩略图 480px:列表网格一屏可能要几十张,必须小(单张约 30~60KB);
|
||
// - 预览图 1920px:全屏展示用,单张约 300~600KB,一次只看一张,可以接受;
|
||
// - 原图:只在用户点「下载」时访问,不参与浏览。
|
||
//
|
||
// 5M 出口(约 625KB/s)下,如果不分档、列表直接拉原图(动辄 3~8MB),
|
||
// 一屏就能把带宽占满十几秒。
|
||
const (
|
||
thumbMaxEdge = 480
|
||
previewMaxEdge = 1920
|
||
|
||
thumbQuality = 82
|
||
previewQuality = 88
|
||
|
||
// MaxUploadBytes 单张原图上限。4K 无损壁纸一般也不超过这个数。
|
||
MaxUploadBytes = 20 << 20 // 20MB
|
||
)
|
||
|
||
// Storage 负责自建图库的文件落盘与派生图生成。
|
||
type Storage struct {
|
||
root string // 存储根目录(绝对路径)
|
||
baseURL string // 对外访问前缀,如 https://xpcool.com/wallpaper
|
||
}
|
||
|
||
// NewStorage 从配置构造存储。
|
||
//
|
||
// 配置项(支持环境变量注入,见 cmd.injectEnv):
|
||
//
|
||
// wallpaper.root 存储根目录,生产为 /data/www/wallpaper
|
||
// wallpaper.baseUrl 对外访问前缀,生产为 https://xpcool.com/wallpaper
|
||
func NewStorage(ctx context.Context) *Storage {
|
||
root := g.Cfg().MustGet(ctx, "wallpaper.root", "./data/wallpaper").String()
|
||
base := g.Cfg().MustGet(ctx, "wallpaper.baseUrl", "/wallpaper").String()
|
||
return &Storage{
|
||
root: root,
|
||
baseURL: strings.TrimRight(base, "/"),
|
||
}
|
||
}
|
||
|
||
// Root 返回存储根目录。
|
||
func (st *Storage) Root() string { return st.root }
|
||
|
||
// URL 把库里的相对路径拼成对外可访问的完整地址。
|
||
// 由 nginx 直出,Go 服务不参与传图。
|
||
func (st *Storage) URL(rel string) string {
|
||
if rel == "" {
|
||
return ""
|
||
}
|
||
return st.baseURL + "/" + strings.TrimLeft(rel, "/")
|
||
}
|
||
|
||
// relPath 生成相对路径:按年月分目录,文件名用内容 MD5。
|
||
//
|
||
// 用 MD5 作文件名有两个好处:天然幂等(同一张图重复上传覆盖同一个文件,
|
||
// 不会堆垃圾);URL 不可枚举。
|
||
func relPath(hash, ext string, t *gtime.Time) string {
|
||
return path.Join(t.Format("Y"), t.Format("m"), hash+ext)
|
||
}
|
||
|
||
// derivedRelPath 派生图的相对路径(在原名后加 _t / _p 后缀)。
|
||
func derivedRelPath(hash, suffix string, t *gtime.Time) string {
|
||
return path.Join(t.Format("Y"), t.Format("m"), hash+suffix+".jpg")
|
||
}
|
||
|
||
// SaveOriginal 把原图写到磁盘,返回相对路径。
|
||
func (st *Storage) SaveOriginal(hash, ext string, data []byte) (string, error) {
|
||
t := gtime.Now()
|
||
rel := relPath(hash, ext, t)
|
||
if err := st.writeFile(rel, data); err != nil {
|
||
return "", err
|
||
}
|
||
return rel, nil
|
||
}
|
||
|
||
// SaveDerived 生成并写入派生图(缩略图 / 预览图),返回相对路径。
|
||
//
|
||
// src 是已经解码好的原图。缩放走 CatmullRom(质量与速度的平衡点),
|
||
// 且**不放大**:原图本来就比上限小的时候直接沿用,避免糊图还白占空间。
|
||
func (st *Storage) SaveDerived(src image.Image, hash, suffix string, maxEdge, quality int) (string, error) {
|
||
resized := resizeTo(src, maxEdge)
|
||
t := gtime.Now()
|
||
rel := derivedRelPath(hash, suffix, t)
|
||
|
||
var b bytes.Buffer
|
||
if err := jpeg.Encode(&b, resized, &jpeg.Options{Quality: quality}); err != nil {
|
||
return "", fmt.Errorf("编码派生图失败: %w", err)
|
||
}
|
||
if err := st.writeFile(rel, b.Bytes()); err != nil {
|
||
return "", err
|
||
}
|
||
return rel, nil
|
||
}
|
||
|
||
// Remove 删除一组文件(原图 + 派生图)。文件不存在不算错误。
|
||
func (st *Storage) Remove(rels ...string) error {
|
||
for _, rel := range rels {
|
||
if rel == "" {
|
||
continue
|
||
}
|
||
full := filepath.Join(st.root, filepath.FromSlash(rel))
|
||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||
return fmt.Errorf("删除文件 %s 失败: %w", rel, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// writeFile 写文件,自动建目录。
|
||
func (st *Storage) writeFile(rel string, data []byte) error {
|
||
full := filepath.Join(st.root, filepath.FromSlash(rel))
|
||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||
return fmt.Errorf("创建目录失败: %w", err)
|
||
}
|
||
if err := os.WriteFile(full, data, 0o644); err != nil {
|
||
return fmt.Errorf("写入文件失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 图像处理
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// resizeTo 等比缩放到「长边不超过 maxEdge」,且不放大。
|
||
func resizeTo(src image.Image, maxEdge int) image.Image {
|
||
b := src.Bounds()
|
||
sw, sh := b.Dx(), b.Dy()
|
||
if sw <= 0 || sh <= 0 {
|
||
return src
|
||
}
|
||
longest := sw
|
||
if sh > longest {
|
||
longest = sh
|
||
}
|
||
if longest <= maxEdge {
|
||
return src
|
||
}
|
||
|
||
scale := float64(maxEdge) / float64(longest)
|
||
dw := int(float64(sw)*scale + 0.5)
|
||
dh := int(float64(sh)*scale + 0.5)
|
||
if dw < 1 {
|
||
dw = 1
|
||
}
|
||
if dh < 1 {
|
||
dh = 1
|
||
}
|
||
|
||
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
||
// 先铺白底再叠加:原图若带透明通道(PNG),直接编成 JPEG 会变成黑底。
|
||
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
|
||
return dst
|
||
}
|
||
|
||
// normalizeExt 把解码得到的格式名归一成文件扩展名。
|
||
// 未知格式一律按 .jpg 存(我们只接受能解码的格式,见 Upload 的校验)。
|
||
func normalizeExt(format string) string {
|
||
switch strings.ToLower(format) {
|
||
case "jpeg", "jpg":
|
||
return ".jpg"
|
||
case "png":
|
||
return ".png"
|
||
case "gif":
|
||
return ".gif"
|
||
case "webp":
|
||
return ".webp"
|
||
default:
|
||
return ".jpg"
|
||
}
|
||
}
|