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