需求:前台壁纸站支持切换到开源壁纸平台,并能展示后台上传的自家图库, 保留原有「浏览器实时生成」能力(扩展而非替换),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 不变)。
145 lines
4.7 KiB
Go
145 lines
4.7 KiB
Go
package wallpaper
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/url"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/encoding/gjson"
|
||
|
||
"service.xpcool.com/internal/model/dto"
|
||
)
|
||
|
||
// UnsplashProvider Unsplash 官方 API。
|
||
//
|
||
// 特点:画质与内容质量最好,支持关键词搜索与朝向筛选,接口给三档尺寸。
|
||
// 鉴权:需要在 unsplash.com/developers 申请 Access Key,走 Client-ID 头。
|
||
//
|
||
// 许可要求(必须遵守,不是可选项):
|
||
// - 图片必须走 Unsplash 的 CDN 直链,**不得转存到自己服务器再分发**;
|
||
// - 用户下载前要回调一次 /photos/{id}/download,这是给摄影师的统计口径。
|
||
// 故本适配器实现了 downloadTracker。
|
||
type UnsplashProvider struct{}
|
||
|
||
const unsplashBase = "https://api.unsplash.com"
|
||
|
||
func (p *UnsplashProvider) Code() string { return "unsplash" }
|
||
func (p *UnsplashProvider) Name() string { return "Unsplash" }
|
||
func (p *UnsplashProvider) RequiresKey() bool { return true }
|
||
|
||
func (p *UnsplashProvider) List(ctx context.Context, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
|
||
if cfg.ApiKey == "" {
|
||
return nil, 0, fmt.Errorf("Unsplash 未配置 Access Key")
|
||
}
|
||
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
|
||
}
|
||
}
|
||
|
||
headers := map[string]string{"Authorization": "Client-ID " + cfg.ApiKey}
|
||
orient := unsplashOrientation(q.Orientation)
|
||
keyword := firstNonEmpty(q.Query, cfg.DefaultQuery)
|
||
base := baseOf(cfg.BaseUrl, unsplashBase)
|
||
|
||
var (
|
||
j *gjson.Json
|
||
total int
|
||
err error
|
||
items []interface{}
|
||
)
|
||
if keyword == "" {
|
||
// 没有关键词就走「最新照片」列表;该端点响应体是根数组。
|
||
u := fmt.Sprintf("%s/photos?page=%d&per_page=%d", base, page, size)
|
||
if orient != "" {
|
||
u += "&orientation=" + url.QueryEscape(orient)
|
||
}
|
||
if j, err = httpGetJSON(ctx, u, headers, 12*time.Second); err != nil {
|
||
return nil, 0, fmt.Errorf("获取 Unsplash 列表失败: %w", err)
|
||
}
|
||
// gjson.Json 没有 IsArray(),判数组要走 Var().IsSlice()
|
||
if !j.Var().IsSlice() {
|
||
return nil, 0, fmt.Errorf("Unsplash 响应格式异常:期望数组")
|
||
}
|
||
items = j.Array()
|
||
} else {
|
||
u := fmt.Sprintf("%s/search/photos?query=%s&page=%d&per_page=%d",
|
||
base, url.QueryEscape(keyword), page, size)
|
||
if orient != "" {
|
||
u += "&orientation=" + url.QueryEscape(orient)
|
||
}
|
||
if j, err = httpGetJSON(ctx, u, headers, 12*time.Second); err != nil {
|
||
return nil, 0, fmt.Errorf("搜索 Unsplash 失败: %w", err)
|
||
}
|
||
items = j.Get("results").Array()
|
||
total = j.Get("total").Int()
|
||
}
|
||
|
||
out := make([]dto.WallpaperItem, 0, len(items))
|
||
for _, raw := range items {
|
||
item := gjson.New(raw)
|
||
id := item.Get("id").String()
|
||
if id == "" {
|
||
continue
|
||
}
|
||
w := item.Get("width").Int()
|
||
h := item.Get("height").Int()
|
||
if !matchOrientation(w, h, q.Orientation) {
|
||
continue
|
||
}
|
||
out = append(out, dto.WallpaperItem{
|
||
Id: id,
|
||
Title: firstNonEmpty(item.Get("alt_description").String(), item.Get("description").String(), "Unsplash "+id),
|
||
Width: w,
|
||
Height: h,
|
||
Orientation: orientationOf(w, h),
|
||
ThumbUrl: item.Get("urls.small").String(),
|
||
PreviewUrl: item.Get("urls.regular").String(),
|
||
FullUrl: firstNonEmpty(item.Get("urls.full").String(), item.Get("urls.regular").String()),
|
||
Source: p.Code(),
|
||
SourceName: p.Name(),
|
||
FromOpen: true,
|
||
Author: item.Get("user.name").String(),
|
||
AuthorUrl: item.Get("user.links.html").String(),
|
||
PageUrl: item.Get("links.html").String(),
|
||
License: "Unsplash License",
|
||
})
|
||
}
|
||
|
||
_ = openCache.Set(ctx, key, out, cacheTTLSearch)
|
||
return out, total, nil
|
||
}
|
||
|
||
// TrackDownload 回调 Unsplash 的下载端点。
|
||
// 这是其 API 指南的硬性要求;失败只记日志,不能阻断用户下载。
|
||
func (p *UnsplashProvider) TrackDownload(ctx context.Context, cfg dto.WallpaperSourceConfig, id string) error {
|
||
if cfg.ApiKey == "" || id == "" {
|
||
return nil
|
||
}
|
||
headers := map[string]string{"Authorization": "Client-ID " + cfg.ApiKey}
|
||
_, err := httpGetJSON(ctx, fmt.Sprintf("%s/photos/%s/download", baseOf(cfg.BaseUrl, unsplashBase), url.PathEscape(id)), headers, 8*time.Second)
|
||
if err != nil {
|
||
return fmt.Errorf("Unsplash 下载回调失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// unsplashOrientation 把内部朝向编码翻译成 Unsplash 的取值。
|
||
// 无对应值(方形除外)时返回空串,表示不加该参数。
|
||
func unsplashOrientation(o int) string {
|
||
switch o {
|
||
case 1:
|
||
return "landscape"
|
||
case 2:
|
||
return "portrait"
|
||
case 3:
|
||
return "squarish"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|