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

135 lines
4.4 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"
"fmt"
"net/url"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"service.xpcool.com/internal/model/dto"
)
// WallhavenProvider Wallhaven 壁纸站。
//
// 特点:专门做壁纸,分辨率与横竖版筛选最贴合本需求,缩略图与预览图质量都高。
//
// 鉴权:**纯 SFW 检索不需要 API Key**purity=100 匿名可用),故本适配器
// RequiresKey 为 false配置了 Key 之后才允许放开更高分级。
//
// 安全约束purity 默认锁死 SFW。只有「配置了 Key」且「后台显式改成
// sketchy/nsfw」两个条件同时满足才会放开过滤 —— 避免误把成人内容
// 推到公网首页。
type WallhavenProvider struct{}
const wallhavenBase = "https://wallhaven.cc/api/v1"
func (p *WallhavenProvider) Code() string { return "wallhaven" }
func (p *WallhavenProvider) Name() string { return "Wallhaven" }
func (p *WallhavenProvider) RequiresKey() bool { return false }
func (p *WallhavenProvider) List(ctx context.Context, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
page, size := normalizePage(q.Page, q.Size)
key := cacheKey(p.Code(), q)
if v, err := openCache.Get(ctx, key); err == nil && !v.IsNil() {
if cached, ok := v.Val().([]dto.WallpaperItem); ok {
return cached, len(cached), nil
}
}
// 该站单页上限 24需要本地按朝向过滤时就直接取满免得过滤后不够一页。
perPage := size
if q.Orientation != 0 || perPage > 24 {
perPage = 24
}
params := url.Values{}
params.Set("page", fmt.Sprint(page))
params.Set("per_page", fmt.Sprint(perPage))
params.Set("purity", wallhavenPurity(cfg))
params.Set("categories", firstNonEmpty(cfg.Categories, "111"))
params.Set("atleast", "1920x1080")
// 有搜索词按相关度排,否则取月榜 —— 对壁纸站来说「热门」比「最新」更耐看
if keyword := firstNonEmpty(q.Query, cfg.DefaultQuery); keyword != "" {
params.Set("q", keyword)
params.Set("sorting", "relevance")
} else {
params.Set("sorting", "toplist")
params.Set("topRange", "1M")
}
if cfg.ApiKey != "" {
params.Set("apikey", cfg.ApiKey)
}
// 超时给得比其它平台短wallhaven.cc 在境内遭 DNS 污染(实测解析到境外无关 IP
// 直连必然超时。这里快速失败,避免一个不可达的平台拖慢整个来源列表的响应。
// 需要使用时把后台配置里的「接口地址」指向自建反代即可。
base := baseOf(cfg.BaseUrl, wallhavenBase)
j, err := httpGetJSON(ctx, base+"/search?"+params.Encode(), nil, 8*time.Second)
if err != nil {
return nil, 0, fmt.Errorf("获取 Wallhaven 列表失败(该站境内多不可直连,可在平台配置里填写反代地址): %w", err)
}
if errMsg := j.Get("error").String(); errMsg != "" {
return nil, 0, fmt.Errorf("Wallhaven 返回错误: %s", errMsg)
}
out := make([]dto.WallpaperItem, 0, size)
for _, raw := range j.Get("data").Array() {
item := gjson.New(raw)
id := item.Get("id").String()
if id == "" {
continue
}
w := item.Get("dimension_x").Int()
h := item.Get("dimension_y").Int()
if !matchOrientation(w, h, q.Orientation) {
continue
}
out = append(out, dto.WallpaperItem{
Id: id,
Title: firstNonEmpty(item.Get("source").String(), "Wallhaven "+id),
Width: w,
Height: h,
Orientation: orientationOf(w, h),
ThumbUrl: item.Get("thumbs.small").String(),
PreviewUrl: firstNonEmpty(item.Get("thumbs.original").String(), item.Get("thumbs.large").String()),
FullUrl: item.Get("path").String(),
Source: p.Code(),
SourceName: p.Name(),
FromOpen: true,
Author: "Wallhaven 用户投稿",
AuthorUrl: item.Get("url").String(),
PageUrl: item.Get("url").String(),
License: "Wallhaven版权归上传者请遵守站点条款",
})
if len(out) >= size {
break
}
}
_ = openCache.Set(ctx, key, out, cacheTTLSearch)
return out, j.Get("meta.total").Int(), nil
}
// wallhavenPurity 计算分级过滤参数。
//
// 编码规则:三位分别代表 SFW / Sketchy / NSFW1 为放开、0 为过滤。
// 默认 "100"(只要 SFW。未配置 Key 时无论后台怎么填都强制 "100"。
func wallhavenPurity(cfg dto.WallpaperSourceConfig) string {
if cfg.ApiKey == "" {
return "100"
}
switch cfg.Purity {
case "sketchy":
return "010"
case "nsfw":
return "001"
case "all":
return "111"
default:
return "100"
}
}