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

219 lines
7.2 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 提供壁纸模块的领域服务。
//
// 模块有两种来源,对外输出完全同构(见 dto.WallpaperItem
//
// ① 开源平台wallpaper_source 表配置 + provider_*.go 适配器)
// 后端只代理「元数据」,图片本体走各平台官方 CDN 直链 —— 零带宽消耗。
//
// ② 自建图库wallpaper 表 + 本机磁盘)
// 用户后台上传的真实图片,落盘后由 nginx 直出Go 服务不参与传图。
//
// 新增一个开源平台 = 写一个 provider_xxx.go + 在 allProviders() 里加一行,
// 表结构与前端都不用动。
package wallpaper
import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"service.xpcool.com/internal/model/dto"
)
// Provider 是单个开源壁纸平台的适配器。
//
// 实现约定:
// - 一律按 dto.WallpaperQuery 里的 Page/Size/Orientation 做归一化,
// 平台不支持的能力(比如 Bing 没有竖版、Picsum 不支持搜索)就地降级,
// 不要把平台差异抛给调用方;
// - 网络请求必须带超时(用 httpGetJSON 即可),单平台失败不能影响其它来源。
type Provider interface {
// Code 平台编码,与 wallpaper_source.code 一致。
Code() string
// Name 平台显示名(兜底用,正常取库里配置的名称)。
Name() string
// RequiresKey 是否必须配置 API Key 才能使用。
// 为 true 时config.apiKey 为空即视为「未配置」,前端置灰。
RequiresKey() bool
// List 拉取一批壁纸。
// total 为平台返回的总数,平台不给则返回 0表示「未知」而非「没有」
List(ctx context.Context, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) (items []dto.WallpaperItem, total int, err error)
}
// downloadTracker 是可选能力:某些平台(如 Unsplash要求下载前回调一次接口
// 既是许可要求也是给摄影师的统计。实现该接口的平台会在用户点下载时被调用。
type downloadTracker interface {
TrackDownload(ctx context.Context, cfg dto.WallpaperSourceConfig, id string) error
}
// allProviders 返回全部内置平台适配器。
//
// 刻意用显式列表而不是 init() 自注册:这样「到底支持哪些平台」一眼可见,
// 也不会出现 import 顺序导致的隐式副作用。
func allProviders() []Provider {
return []Provider{
&BingProvider{},
&PicsumProvider{},
&UnsplashProvider{},
&PexelsProvider{},
&WallhavenProvider{},
}
}
// providerMap 是 code → Provider 的索引,进程启动时由 buildProviderMap 构建。
var providerMap = buildProviderMap()
func buildProviderMap() map[string]Provider {
all := allProviders()
m := make(map[string]Provider, len(all))
for _, p := range all {
m[p.Code()] = p
}
return m
}
// GetProvider 按编码取平台适配器。
func GetProvider(code string) (Provider, bool) {
p, ok := providerMap[code]
return p, ok
}
// ListProviders 返回全部已注册的适配器(不区分是否启用)。
// 供后台「平台配置」页展示:库里没有记录的平台也要能列出来,否则无从开启。
func ListProviders() []Provider {
return allProviders()
}
// ---------------------------------------------------------------------------
// 缓存
// ---------------------------------------------------------------------------
// openCache 缓存开源平台的列表响应。
//
// 为什么要缓存:这些平台都有速率限制(免费额度通常每小时几十到几百次),
// 而壁纸站的访问是「多人反复刷新」的模式,不缓存会很快触发 429。
// 只缓存元数据(几 KB不缓存图片本体。
var openCache = gcache.New()
// Bing 是「每日一图」,本身一天只变一次,缓存久一点没问题;
// 其余平台用短缓存,兼顾新鲜度与配额。
const (
cacheTTLDaily = time.Hour
cacheTTLSearch = 10 * time.Minute
)
// cacheKey 组装缓存键(含来源、关键词、分页、朝向,任一不同即为不同结果)。
func cacheKey(code string, q dto.WallpaperQuery) string {
return fmt.Sprintf("wallpaper:%s:%s:%d:%d:%d", code, q.Query, q.Page, q.Size, q.Orientation)
}
// ---------------------------------------------------------------------------
// 统一的 JSON 拉取工具
// ---------------------------------------------------------------------------
// httpGetJSON 发起带超时的 GET 并把响应体解析为 gjson。
//
// headers 用于传各平台的鉴权头Unsplash 用 Authorization: Client-ID xxx
// Pexels 用 Authorization: xxx。任何非 2xx 都会转成带状态码的错误,
// 便于上层区分「没配额」和「平台挂了」。
func httpGetJSON(ctx context.Context, url string, headers map[string]string, timeout time.Duration) (*gjson.Json, error) {
c := g.Client().Timeout(timeout)
if len(headers) > 0 {
c = c.Header(headers)
}
resp, err := c.Get(ctx, url)
if err != nil {
return nil, err
}
defer func() { _ = resp.Close() }()
body := resp.ReadAllString()
if resp.StatusCode != 200 {
// 只截前 200 字符,避免把整页 HTML 错误页塞进日志
head := body
if len(head) > 200 {
head = head[:200]
}
return nil, fmt.Errorf("平台返回 HTTP %d: %s", resp.StatusCode, head)
}
j, err := gjson.DecodeToJson(body)
if err != nil {
return nil, fmt.Errorf("解析平台响应失败: %w", err)
}
return j, nil
}
// ---------------------------------------------------------------------------
// 小工具
// ---------------------------------------------------------------------------
// orientationOf 由宽高判断朝向,与库里的 orientation 字段语义一致。
func orientationOf(w, h int) int {
switch {
case w == 0 || h == 0:
return 0
case w > h:
return 1 // 横版
case w < h:
return 2 // 竖版
default:
return 3 // 方形
}
}
// matchOrientation 判断尺寸是否符合筛选条件。want=0 表示不限。
func matchOrientation(w, h, want int) bool {
if want == 0 {
return true
}
return orientationOf(w, h) == want
}
// normalizePage 兜底分页参数:页码从 1 起,每页 1~60 条。
// 上限 60 是权衡结果:太小翻页烦,太大容易撞平台配额且首屏变重。
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 {
size = 24
}
if size > 60 {
size = 60
}
return page, size
}
// baseOf 返回平台接口基址:配置里覆盖了就用配置的,否则用内置默认值。
//
// 为什么要留这个口子:部分平台在境内不可直连(实测 wallhaven.cc 遭 DNS 污染,
// 解析到境外无关 IP此时把 BaseUrl 指向自建反代即可继续使用,
// 不必改代码、也不必给整个服务挂全局代理。
func baseOf(cfgBase, fallback string) string {
if cfgBase != "" {
return strings.TrimRight(cfgBase, "/")
}
return fallback
}
// containsFold 不区分大小写的子串判断,用于本地关键词过滤。
// 关键词可能为空的情况由调用方先判断,这里不额外兜底。
func containsFold(s, sub string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
}
// clamp 把 v 夹在 [lo, hi] 区间内。
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}