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