service.xpcool.com/internal/service/wallpaper/storage.go
夏犀麟 582485ba2b feat(wallpaper): 壁纸模块后端(开源平台插件 + 自建图库 + 前后台接口)
需求:前台壁纸站支持切换到开源壁纸平台,并能展示后台上传的自家图库,
保留原有「浏览器实时生成」能力(扩展而非替换),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 不变)。
2026-09-14 01:39:09 +08:00

196 lines
5.9 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 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"
}
}