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