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

202 lines
6.6 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 (
"context"
"math/rand"
"github.com/gogf/gf/v2/errors/gerror"
"service.xpcool.com/internal/model/dto"
)
// MineSourceCode 自建图库在来源参数里的固定编码。
// 与平台编码区分开:它不是「某个开源平台」,而是用户自己上传的图。
const MineSourceCode = "mine"
// IWallpaper 壁纸领域服务接口。
type IWallpaper interface {
// ---- 前台open 组,免鉴权)----
Sources(ctx context.Context) ([]dto.WallpaperSourceInfo, error)
List(ctx context.Context, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error)
Random(ctx context.Context, sources []string, orientation int) (*dto.WallpaperItem, error)
TrackDownload(ctx context.Context, source, id string) error
// ---- 后台:自建图库 ----
// LibraryAdminList 与前台 LibraryList 的区别是「包含已停用的图」,
// 后台需要看得到停用项才能重新启用。
LibraryAdminList(ctx context.Context, q dto.WallpaperQuery, enabled int) ([]dto.WallpaperItem, int, error)
// Upload 的第二个返回值表示「内容重复,命中了已有图片(秒传)」,不是错误。
Upload(ctx context.Context, filename string, data []byte, meta dto.WallpaperSaveInput) (*dto.WallpaperItem, bool, error)
SaveMeta(ctx context.Context, in dto.WallpaperSaveInput) error
Delete(ctx context.Context, id uint64) error
Stat(ctx context.Context) (*dto.WallpaperStat, error)
// MineCount 返回自建图库中「已启用」的图片数量。
// 前台据此决定要不要显示「我的图库」入口。
MineCount(ctx context.Context) (int, error)
// ---- 后台:平台配置 ----
SaveSource(ctx context.Context, in dto.WallpaperSourceSaveInput) error
TestSource(ctx context.Context, code string) (string, error)
}
type service struct {
store *Storage
}
var localWallpaper IWallpaper
// New 构造壁纸服务。
func New(ctx context.Context) IWallpaper {
return &service{store: NewStorage(ctx)}
}
// Wallpaper 返回已注册的壁纸服务实现。
func Wallpaper() IWallpaper {
if localWallpaper == nil {
panic("Wallpaper 实现未注册")
}
return localWallpaper
}
// RegisterWallpaper 注册壁纸服务实现。
func RegisterWallpaper(i IWallpaper) { localWallpaper = i }
// List 按来源分派mine 走本地库,其余走对应的平台适配器。
//
// 对外的输出形状完全一致,前端不需要为不同来源写两套渲染逻辑。
func (s *service) List(ctx context.Context, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
if q.Source == "" || q.Source == MineSourceCode {
return s.LibraryList(ctx, q)
}
p, ok := GetProvider(q.Source)
if !ok {
return nil, 0, gerror.Newf("未知的来源:%s", q.Source)
}
cfg, row, err := s.LoadSourceConfig(ctx, q.Source)
if err != nil {
return nil, 0, err
}
if row.Enabled != 1 {
return nil, 0, gerror.Newf("来源「%s」已停用", row.Name)
}
if p.RequiresKey() && cfg.ApiKey == "" {
return nil, 0, gerror.Newf("来源「%s」尚未配置 API Key", firstNonEmpty(row.Name, p.Name()))
}
return p.List(ctx, cfg, q)
}
// Random 跨来源随机取一张,供前台「换一张 / 每次进入都不同」使用。
//
// sources 为空表示「在所有可用来源里随机」;仅指定 mine 时只在自建图库里随机。
// 单个来源失败不视为整体失败 —— 换下一个可用来源重试,最多试 3 次。
// 壁纸站的核心体验就是「每次都能出图」,一个平台抽风不该让整页空白。
func (s *service) Random(ctx context.Context, sources []string, orientation int) (*dto.WallpaperItem, error) {
candidates := make([]string, 0, len(sources))
for _, code := range sources {
if code == "" {
continue
}
candidates = append(candidates, code)
}
if len(candidates) == 0 {
available, err := s.AvailableSourceCodes(ctx)
if err != nil {
return nil, err
}
candidates = append(candidates, available...)
// 自建图库有图的话也纳入候选(不占平台配额,优先给它一点权重)
if n, err := s.libraryCount(ctx, 0); err == nil && n > 0 {
candidates = append(candidates, MineSourceCode)
}
}
if len(candidates) == 0 {
return nil, gerror.New("当前没有任何可用的壁纸来源")
}
// 打乱候选顺序后依次尝试,避免总是卡在同一个坏来源上
rand.Shuffle(len(candidates), func(i, j int) {
candidates[i], candidates[j] = candidates[j], candidates[i]
})
tries := len(candidates)
if tries > 3 {
tries = 3
}
var lastErr error
for i := 0; i < tries; i++ {
item, err := s.randomOne(ctx, candidates[i], orientation)
if err == nil && item != nil {
return item, nil
}
if err != nil {
lastErr = err
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, gerror.New("没能取到任何壁纸,请稍后重试")
}
// randomOne 在单个来源里随机取一张。
func (s *service) randomOne(ctx context.Context, code string, orientation int) (*dto.WallpaperItem, error) {
if code == MineSourceCode {
return s.libraryRandom(ctx, orientation)
}
p, ok := GetProvider(code)
if !ok {
return nil, gerror.Newf("未知的来源:%s", code)
}
cfg, row, err := s.LoadSourceConfig(ctx, code)
if err != nil {
return nil, err
}
if row.Enabled != 1 || (p.RequiresKey() && cfg.ApiKey == "") {
return nil, gerror.Newf("来源「%s」不可用", firstNonEmpty(row.Name, p.Name()))
}
// 随机翻一页再随机取一张。平台的分页深度未知,故页数限制在前 3 页,
// 避免请求到空页Bing 那种只有两页的来源会直接被 normalizePage 兜住)。
q := dto.WallpaperQuery{
Page: 1 + rand.Intn(3),
Size: 24,
Orientation: orientation,
}
items, _, err := p.List(ctx, cfg, q)
if err != nil {
return nil, err
}
if len(items) == 0 && q.Page > 1 {
// 该页没有(多半是越界),退回第一页再试一次
items, _, err = p.List(ctx, cfg, dto.WallpaperQuery{Page: 1, Size: 24, Orientation: orientation})
if err != nil {
return nil, err
}
}
if len(items) == 0 {
return nil, gerror.Newf("来源「%s」没有符合条件的壁纸", firstNonEmpty(row.Name, p.Name()))
}
pick := items[rand.Intn(len(items))]
return &pick, nil
}
// TrackDownload 通知平台「这张图被下载了」。
// 只有实现了 downloadTracker 的平台需要(目前是 Unsplash属其 API 许可要求)。
// 失败只记日志、不阻断用户下载。
func (s *service) TrackDownload(ctx context.Context, source, id string) error {
p, ok := GetProvider(source)
if !ok {
return nil
}
tracker, ok := p.(downloadTracker)
if !ok {
return nil
}
cfg, _, err := s.LoadSourceConfig(ctx, source)
if err != nil {
return nil
}
return tracker.TrackDownload(ctx, cfg, id)
}