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

124 lines
4.1 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"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"service.xpcool.com/internal/model/dto"
)
// PicsumProvider Lorem Picsum 随机摄影图库。
//
// 特点:无需 API Key、量极大、支持按 id 取任意尺寸。
// 图片本身来自 Unsplash故作者信息与许可沿用 Unsplash。
//
// 局限:接口不支持关键词搜索、也不支持朝向筛选,两者都只能取回后在本地过滤。
// 为了过滤后仍有足够条目,这里会一次多取一些(接口上限 100 条)。
type PicsumProvider struct{}
const picsumBase = "https://picsum.photos"
func (p *PicsumProvider) Code() string { return "picsum" }
func (p *PicsumProvider) Name() string { return "Lorem Picsum" }
func (p *PicsumProvider) RequiresKey() bool { return false }
func (p *PicsumProvider) 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
}
}
// 需要本地过滤(搜索词 / 朝向)时必须多取:该接口的列表是按 id 顺序返回的,
// 前几页几乎全是横图,只多取三四倍的话筛竖版经常一条都不剩(实测踩过)。
// 故只要带过滤条件就直接取满接口上限 100 条。
fetch := size
if q.Query != "" || q.Orientation != 0 {
fetch = 100
}
base := baseOf(cfg.BaseUrl, picsumBase)
url := fmt.Sprintf("%s/v2/list?page=%d&limit=%d", base, page, fetch)
j, err := httpGetJSON(ctx, url, nil, 12*time.Second)
if err != nil {
return nil, 0, fmt.Errorf("获取 Picsum 列表失败: %w", err)
}
// 该接口的响应体是**根数组**(没有外层对象),故用 Array() 逐项包装成 gjson。
// 注意 gjson.Json 自身没有 IsArray(),判类型要走 Var().IsSlice()。
if !j.Var().IsSlice() {
return nil, 0, fmt.Errorf("Picsum 响应格式异常:期望数组")
}
out := make([]dto.WallpaperItem, 0, size)
for _, raw := range j.Array() {
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
}
author := item.Get("author").String()
// Picsum 不提供标题,用「作者 (#id)」拼一个可辨识的标题
title := author
if title == "" {
title = "Picsum #" + id
} else {
title = fmt.Sprintf("%s (#%s)", author, id)
}
// 关键词只能本地匹配(作者名 / 标题),该平台没有搜索能力
if q.Query != "" && !containsFold(author, q.Query) && !containsFold(title, q.Query) {
continue
}
if len(out) >= size {
break
}
out = append(out, dto.WallpaperItem{
Id: id,
Title: title,
Width: w,
Height: h,
Orientation: orientationOf(w, h),
// 按原图比例缩出目标宽度,避免被接口裁剪改变构图
ThumbUrl: picsumScaleURL(base, id, 480, w, h),
PreviewUrl: picsumScaleURL(base, id, 1600, w, h),
FullUrl: item.Get("download_url").String(),
Source: p.Code(),
SourceName: p.Name(),
FromOpen: true,
Author: author,
AuthorUrl: item.Get("url").String(),
PageUrl: item.Get("url").String(),
License: "Unsplash License来自 Lorem Picsum",
})
}
_ = openCache.Set(ctx, key, out, cacheTTLSearch)
return out, len(out), nil
}
// picsumScaleURL 生成 Picsum 的等比缩放地址。
//
// Picsum 的 /id/{id}/{w}/{h} 会把图**裁剪**到给定尺寸,
// 直接传 16:9 会切掉竖图的两头,所以这里按原图比例算出高度 —— 纯缩放、不裁剪。
func picsumScaleURL(base, id string, targetW, w, h int) string {
if w <= 0 || h <= 0 {
return fmt.Sprintf("%s/id/%s/%d/%d", base, id, targetW, targetW*9/16)
}
if targetW >= w {
// 原图比目标还小就别放大了,直接用原尺寸
return fmt.Sprintf("%s/id/%s/%d/%d", base, id, w, h)
}
targetH := h * targetW / w
return fmt.Sprintf("%s/id/%s/%d/%d", base, id, targetW, targetH)
}