service.xpcool.com/internal/service/wallpaper/library.go
夏犀麟 d0a084b80e
Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 28s
feat(wallpaper): 平台配置脱敏回显 + 图库元字段补齐
后台做完界面才发现的两个接口缺口:

1) 平台配置不回显 → 用户只改一个开关,defaultQuery / purity / baseUrl
   会被一起清空(SaveSource 只对 apiKey/apiSecret 做了"留空即保留")。
   现在 Sources() 返回脱敏后的 config(凭据一律置空,是否已设置由
   HasApiKey 表达),前台接口不下发该字段 —— toAPISource 加 withConfig
   开关,公开接口没必要泄露自建反代地址等内部信息。

2) Item 缺 enabled / sort / remark → 后台列表无法显示启用状态,
   编辑抽屉也拿不到当前值。补齐字段并在 library.toItem 中填充,
   开源平台条目保持默认值(这三个字段对前台无意义)。

另抽出 sanitizeSourceConfig 统一处理凭据抹除,避免以后再加配置项时漏抹。
2026-09-14 01:48:23 +08:00

372 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,
Enabled: e.Enabled,
Sort: e.Sort,
Remark: e.Remark,
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"
}
}