需求:前台壁纸站支持切换到开源壁纸平台,并能展示后台上传的自家图库, 保留原有「浏览器实时生成」能力(扩展而非替换),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 不变)。
125 lines
3.9 KiB
Go
125 lines
3.9 KiB
Go
package wallpaper
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"service.xpcool.com/internal/model/dto"
|
||
)
|
||
|
||
// BingProvider 微软必应「每日一图」。
|
||
//
|
||
// 特点:无需 API Key、每天只换一张、画质高(官方提供到 UHD)。
|
||
// 接口是公开的 HPImageArchive,返回最近若干天的图,天然带版权信息。
|
||
//
|
||
// 局限:只有横版(1920x1080 / UHD),所以筛选竖版时返回空 —— 这是事实,
|
||
// 不做「用别的图凑数」这种欺骗性的降级。
|
||
type BingProvider struct{}
|
||
|
||
// bingBase 图片 CDN 前缀(接口返回的是相对路径)。
|
||
const bingBase = "https://www.bing.com"
|
||
|
||
func (p *BingProvider) Code() string { return "bing" }
|
||
func (p *BingProvider) Name() string { return "Bing 每日一图" }
|
||
func (p *BingProvider) RequiresKey() bool { return false }
|
||
|
||
func (p *BingProvider) List(ctx context.Context, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
|
||
// 本平台一页固定 8 张,size 参数无意义,故用 _ 接住
|
||
page, _ := normalizePage(q.Page, q.Size)
|
||
// HPImageArchive 的 idx 是「从今天往前推几天」,n 最多 8。
|
||
// 故一页 8 张,最多取两页(16 天),再往前没有意义。
|
||
if page > 2 {
|
||
return []dto.WallpaperItem{}, 0, nil
|
||
}
|
||
idx := (page - 1) * 8
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
base := baseOf(cfg.BaseUrl, bingBase)
|
||
url := fmt.Sprintf("%s/HPImageArchive.aspx?format=js&idx=%d&n=8&mkt=zh-CN", base, idx)
|
||
j, err := httpGetJSON(ctx, url, nil, 10*time.Second)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("获取 Bing 每日一图失败: %w", err)
|
||
}
|
||
|
||
var out []dto.WallpaperItem
|
||
for _, img := range j.GetJsons("images") {
|
||
urlbase := img.Get("urlbase").String()
|
||
if urlbase == "" {
|
||
// 少数情况下只给完整 url,退而求其次从 url 里截掉尺寸后缀
|
||
raw := img.Get("url").String()
|
||
if raw == "" {
|
||
continue
|
||
}
|
||
urlbase = strings.SplitN(raw, "_", 2)[0]
|
||
}
|
||
title, author := splitBingCopyright(img.Get("copyright").String())
|
||
start := img.Get("startdate").String()
|
||
if t := img.Get("title").String(); t != "" {
|
||
title = t
|
||
}
|
||
if title == "" {
|
||
title = "Bing 每日一图 " + start
|
||
}
|
||
|
||
// Bing 的图固定 1920x1080 比例(UHD 同比例),故朝向直接判定为横版。
|
||
if !matchOrientation(16, 9, q.Orientation) {
|
||
continue
|
||
}
|
||
out = append(out, dto.WallpaperItem{
|
||
Id: firstNonEmpty(img.Get("hsh").String(), start),
|
||
Title: title,
|
||
Width: 1920,
|
||
Height: 1080,
|
||
Orientation: 1,
|
||
ThumbUrl: base + urlbase + "_400x240.jpg",
|
||
PreviewUrl: base + urlbase + "_1920x1080.jpg",
|
||
FullUrl: base + urlbase + "_UHD.jpg",
|
||
Source: p.Code(),
|
||
SourceName: p.Name(),
|
||
FromOpen: true,
|
||
Author: author,
|
||
PageUrl: img.Get("copyrightlink").String(),
|
||
License: "Bing 每日一图(版权归原作者)",
|
||
})
|
||
}
|
||
_ = openCache.Set(ctx, key, out, cacheTTLDaily)
|
||
return out, len(out), nil
|
||
}
|
||
|
||
// splitBingCopyright 把 Bing 的版权串拆成「地点」与「作者」。
|
||
//
|
||
// 原始格式形如:`某地风光 (© 张三/Getty Images)`。
|
||
// 括号可能出现在地名里,所以从**最后一个** "(©" 开始切。
|
||
func splitBingCopyright(s string) (title, author string) {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return "", ""
|
||
}
|
||
i := strings.LastIndex(s, "(©")
|
||
if i < 0 {
|
||
return s, ""
|
||
}
|
||
title = strings.TrimSpace(s[:i])
|
||
author = strings.TrimSpace(s[i+len("(©"):])
|
||
author = strings.TrimSuffix(author, ")")
|
||
return strings.TrimSpace(title), strings.TrimSpace(author)
|
||
}
|
||
|
||
// firstNonEmpty 返回第一个非空字符串(用于给可选字段挑兜底值)。
|
||
func firstNonEmpty(vals ...string) string {
|
||
for _, v := range vals {
|
||
if v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|