service.xpcool.com/internal/service/wallpaper/library.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

369 lines
12 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"
"crypto/md5"
"encoding/hex"
"image"
"math/rand"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// ---------------------------------------------------------------------------
// 查询
// ---------------------------------------------------------------------------
// LibraryList 分页查询自建图库。
func (s *service) LibraryList(ctx context.Context, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
page, size := normalizePage(q.Page, q.Size)
m := dao.Wallpaper.Ctx(ctx).Where(do.Wallpaper{Enabled: 1})
if q.Orientation != 0 {
m = m.Where(do.Wallpaper{Orientation: q.Orientation})
}
if q.Tag != "" {
m = m.WhereLike(dao.Wallpaper.Columns().Tags, "%"+q.Tag+"%")
}
if q.Query != "" {
kw := "%" + q.Query + "%"
m = m.Where("title LIKE ? OR tags LIKE ? OR category LIKE ?", kw, kw, kw)
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "统计图库总数失败")
}
rows, err := m.Clone().OrderAsc("sort").OrderDesc("id").Page(page, size).All()
if err != nil {
return nil, 0, gerror.Wrap(err, "查询图库列表失败")
}
out := make([]dto.WallpaperItem, 0, len(rows))
for _, r := range rows {
var e entity.Wallpaper
if err = r.Struct(&e); err != nil {
continue
}
out = append(out, s.toItem(&e))
}
return out, total, nil
}
// LibraryAdminList 后台列表:与前台的区别是**包含已停用的图**。
func (s *service) LibraryAdminList(ctx context.Context, q dto.WallpaperQuery, enabled int) ([]dto.WallpaperItem, int, error) {
page, size := normalizePage(q.Page, q.Size)
m := dao.Wallpaper.Ctx(ctx)
if enabled >= 0 {
m = m.Where(do.Wallpaper{Enabled: enabled})
}
if q.Orientation != 0 {
m = m.Where(do.Wallpaper{Orientation: q.Orientation})
}
if q.Query != "" {
kw := "%" + q.Query + "%"
m = m.Where("title LIKE ? OR tags LIKE ? OR category LIKE ?", kw, kw, kw)
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "统计图库总数失败")
}
rows, err := m.Clone().OrderAsc("sort").OrderDesc("id").Page(page, size).All()
if err != nil {
return nil, 0, gerror.Wrap(err, "查询图库列表失败")
}
out := make([]dto.WallpaperItem, 0, len(rows))
for _, r := range rows {
var e entity.Wallpaper
if err = r.Struct(&e); err != nil {
continue
}
item := s.toItem(&e)
item.Source = "admin" // 后台列表用不到来源字段,留个标识避免被当成前台数据
out = append(out, item)
}
return out, total, nil
}
// MineCount 对外暴露「已启用的自建图片数量」。
func (s *service) MineCount(ctx context.Context) (int, error) {
return s.libraryCount(ctx, 0)
}
// libraryCount 统计启用的图库数量orientation=0 表示不限)。
func (s *service) libraryCount(ctx context.Context, orientation int) (int, error) {
m := dao.Wallpaper.Ctx(ctx).Where(do.Wallpaper{Enabled: 1})
if orientation != 0 {
m = m.Where(do.Wallpaper{Orientation: orientation})
}
return m.Count()
}
// libraryRandom 在自建图库里随机取一张。
//
// 刻意不用 ORDER BY RAND():那会对全表排序,图库上万张以后明显变慢。
// 改为「先数总数 → 随机取偏移量」,无论多大都是常数级开销。
func (s *service) libraryRandom(ctx context.Context, orientation int) (*dto.WallpaperItem, error) {
total, err := s.libraryCount(ctx, orientation)
if err != nil {
return nil, gerror.Wrap(err, "统计图库总数失败")
}
if total == 0 {
return nil, gerror.New("图库里还没有图片")
}
m := dao.Wallpaper.Ctx(ctx).Where(do.Wallpaper{Enabled: 1})
if orientation != 0 {
m = m.Where(do.Wallpaper{Orientation: orientation})
}
row, err := m.OrderAsc("sort").OrderAsc("id").Limit(1).Offset(rand.Intn(total)).One()
if err != nil {
return nil, gerror.Wrap(err, "随机取图失败")
}
if row.IsEmpty() {
return nil, gerror.New("图库里还没有图片")
}
var e entity.Wallpaper
if err = row.Struct(&e); err != nil {
return nil, gerror.Wrap(err, "解析图库记录失败")
}
item := s.toItem(&e)
return &item, nil
}
// toItem 把库内记录转成统一输出形状,并补齐三档 URL。
//
// URL 拼接只在这里发生 —— 换 CDN、换域名、加签名都只改这一处。
func (s *service) toItem(e *entity.Wallpaper) dto.WallpaperItem {
return dto.WallpaperItem{
Id: g.NewVar(e.Id).String(),
Title: e.Title,
Width: e.Width,
Height: e.Height,
Orientation: e.Orientation,
ThumbUrl: s.store.URL(firstNonEmpty(e.ThumbPath, e.Path)),
PreviewUrl: s.store.URL(firstNonEmpty(e.PreviewPath, e.Path)),
FullUrl: s.store.URL(e.Path),
Source: MineSourceCode,
SourceName: "我的图库",
FromOpen: false,
Tags: e.Tags,
Category: e.Category,
Filesize: e.Filesize,
CreatedAt: e.CreatedAt,
}
}
// ---------------------------------------------------------------------------
// 上传
// ---------------------------------------------------------------------------
// Upload 处理一张上传的原图:校验 → 去重 → 落盘 → 生成派生图 → 入库。
//
// 去重按内容 MD5同一张图重复上传不会产生第二份文件直接返回已有记录
// (第二个返回值为 true。这对「批量传一个已经传过的文件夹」很实用。
func (s *service) Upload(ctx context.Context, filename string, data []byte, meta dto.WallpaperSaveInput) (*dto.WallpaperItem, bool, error) {
if len(data) == 0 {
return nil, false, gerror.New("上传内容为空")
}
if len(data) > MaxUploadBytes {
return nil, false, gerror.Newf("图片超过 %d MB 上限", MaxUploadBytes>>20)
}
sum := md5.Sum(data)
hash := hex.EncodeToString(sum[:])
// 已存在同内容的图:直接返回,不重复落盘
exist, err := dao.Wallpaper.Ctx(ctx).Where(do.Wallpaper{Hash: hash}).One()
if err != nil {
return nil, false, gerror.Wrap(err, "查询重复图片失败")
}
if !exist.IsEmpty() {
var e entity.Wallpaper
if err = exist.Struct(&e); err == nil {
item := s.toItem(&e)
return &item, true, nil
}
}
// 先只解元信息:能在完整解码前挡掉超大图与非法格式,避免把内存打爆
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false, gerror.New("无法识别的图片格式(仅支持 JPEG/PNG/GIF/WebP")
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, false, gerror.New("图片尺寸异常")
}
const maxEdgeLimit = 16384
if cfg.Width > maxEdgeLimit || cfg.Height > maxEdgeLimit {
return nil, false, gerror.Newf("图片尺寸过大(最大支持 %d 像素边长)", maxEdgeLimit)
}
// 完整解码一次,供生成缩略图与预览图
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, false, gerror.New("图片解码失败,文件可能已损坏")
}
origRel, err := s.store.SaveOriginal(hash, normalizeExt(format), data)
if err != nil {
return nil, false, err
}
thumbRel, err := s.store.SaveDerived(src, hash, "_t", thumbMaxEdge, thumbQuality)
if err != nil {
// 派生图失败就把已落盘的原图清掉,避免留下孤儿文件
_ = s.store.Remove(origRel)
return nil, false, err
}
previewRel, err := s.store.SaveDerived(src, hash, "_p", previewMaxEdge, previewQuality)
if err != nil {
_ = s.store.Remove(origRel, thumbRel)
return nil, false, err
}
if meta.Title == "" {
meta.Title = strings.TrimSuffix(filename, "."+strings.TrimPrefix(normalizeExt(format), "."))
}
id, err := dao.Wallpaper.Ctx(ctx).Data(do.Wallpaper{
Title: meta.Title,
Tags: meta.Tags,
Category: meta.Category,
Orientation: orientationOf(cfg.Width, cfg.Height),
Width: cfg.Width,
Height: cfg.Height,
Filesize: len(data),
Mime: formatToMime(format),
Path: origRel,
ThumbPath: thumbRel,
PreviewPath: previewRel,
Hash: hash,
Enabled: 1,
Sort: meta.Sort,
Remark: meta.Remark,
}).InsertAndGetId()
if err != nil {
_ = s.store.Remove(origRel, thumbRel, previewRel)
return nil, false, gerror.Wrap(err, "写入图库记录失败")
}
row, err := dao.Wallpaper.Ctx(ctx).WherePri(id).One()
if err != nil || row.IsEmpty() {
return nil, false, gerror.New("图片已入库,但读取记录失败")
}
var e entity.Wallpaper
if err = row.Struct(&e); err != nil {
return nil, false, gerror.Wrap(err, "解析新纪录失败")
}
item := s.toItem(&e)
return &item, false, nil
}
// SaveMeta 更新壁纸的可编辑元数据(不涉及文件本身)。
func (s *service) SaveMeta(ctx context.Context, in dto.WallpaperSaveInput) error {
if in.Id == 0 {
return gerror.New("缺少壁纸 ID")
}
_, err := dao.Wallpaper.Ctx(ctx).WherePri(in.Id).Data(do.Wallpaper{
Title: in.Title,
Tags: in.Tags,
Category: in.Category,
Enabled: in.Enabled,
Sort: in.Sort,
Remark: in.Remark,
}).Update()
if err != nil {
return gerror.Wrap(err, "保存壁纸信息失败")
}
return nil
}
// Delete 删除一条壁纸记录及其三份文件。
//
// 先删记录再删文件:即便文件删除失败(比如权限问题),也只是留下无用文件,
// 不会出现「记录还在但图已没了」的破图。
func (s *service) Delete(ctx context.Context, id uint64) error {
if id == 0 {
return gerror.New("缺少壁纸 ID")
}
row, err := dao.Wallpaper.Ctx(ctx).WherePri(id).One()
if err != nil {
return gerror.Wrap(err, "查询待删除壁纸失败")
}
if row.IsEmpty() {
return gerror.New("壁纸不存在或已被删除")
}
var e entity.Wallpaper
if err = row.Struct(&e); err != nil {
return gerror.Wrap(err, "解析待删除壁纸失败")
}
if _, err = dao.Wallpaper.Ctx(ctx).WherePri(id).Delete(); err != nil {
return gerror.Wrap(err, "删除壁纸记录失败")
}
if err = s.store.Remove(e.Path, e.ThumbPath, e.PreviewPath); err != nil {
g.Log().Warningf(ctx, "壁纸记录已删除,但文件清理失败(可手工清理): %v", err)
}
return nil
}
// Stat 图库概览统计。
func (s *service) Stat(ctx context.Context) (*dto.WallpaperStat, error) {
m := dao.Wallpaper.Ctx(ctx)
total, err := m.Clone().Count()
if err != nil {
return nil, gerror.Wrap(err, "统计总数失败")
}
enabled, err := m.Clone().Where(do.Wallpaper{Enabled: 1}).Count()
if err != nil {
return nil, gerror.Wrap(err, "统计启用数失败")
}
portrait, err := m.Clone().Where(do.Wallpaper{Enabled: 1, Orientation: 2}).Count()
if err != nil {
return nil, gerror.Wrap(err, "统计竖版数失败")
}
landscape, err := m.Clone().Where(do.Wallpaper{Enabled: 1, Orientation: 1}).Count()
if err != nil {
return nil, gerror.Wrap(err, "统计横版数失败")
}
// 原图占用空间SUM 返回的是单值,用 Value() 取(返回 *gvar.Var自动转 int64
var totalBytes int64
if v, verr := m.Clone().Fields("IFNULL(SUM(filesize),0)").Value(); verr == nil && v != nil {
totalBytes = v.Int64()
}
return &dto.WallpaperStat{
Total: total,
Enabled: enabled,
Disabled: total - enabled,
Portrait: portrait,
Landscape: landscape,
TotalBytes: totalBytes,
}, nil
}
// formatToMime 把 image.Decode 返回的格式名转成 MIME。
func formatToMime(format string) string {
switch strings.ToLower(format) {
case "jpeg", "jpg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "webp":
return "image/webp"
default:
return "application/octet-stream"
}
}