需求:前台壁纸站支持切换到开源壁纸平台,并能展示后台上传的自家图库, 保留原有「浏览器实时生成」能力(扩展而非替换),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 不变)。
369 lines
12 KiB
Go
369 lines
12 KiB
Go
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"
|
||
}
|
||
}
|