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" } }