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

219 lines
7.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"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// Sources 返回所有开源平台的可用性信息。
//
// 合并两个来源:数据库里的配置行 + 代码里注册的适配器。
// 这样「新写了一个适配器但库里还没记录」的平台也会出现在后台列表里,
// 用户可以直接开启,不需要先手工插数据。
func (s *service) Sources(ctx context.Context) ([]dto.WallpaperSourceInfo, error) {
rows, err := dao.WallpaperSource.Ctx(ctx).OrderAsc("sort").OrderAsc("id").All()
if err != nil {
return nil, gerror.Wrap(err, "查询壁纸平台配置失败")
}
// 库里已有的配置,按 code 索引
byCode := make(map[string]entity.WallpaperSource, len(rows))
for _, r := range rows {
var e entity.WallpaperSource
if err = r.Struct(&e); err != nil {
continue
}
byCode[e.Code] = e
}
out := make([]dto.WallpaperSourceInfo, 0, len(providerMap))
seen := make(map[string]bool, len(providerMap))
for _, p := range ListProviders() {
seen[p.Code()] = true
e, ok := byCode[p.Code()]
if !ok {
// 库里没有:给出可用但「未启用」的默认态,提示去后台开启
out = append(out, dto.WallpaperSourceInfo{
Code: p.Code(),
Name: p.Name(),
Enabled: 0,
Configured: !p.RequiresKey(),
Available: false,
Hint: "尚未在后台启用",
})
continue
}
cfg := ParseSourceConfig(e.Config)
configured := !p.RequiresKey() || cfg.ApiKey != ""
info := dto.WallpaperSourceInfo{
Code: p.Code(),
Name: firstNonEmpty(e.Name, p.Name()),
Enabled: e.Enabled,
Sort: e.Sort,
Configured: configured,
Available: e.Enabled == 1 && configured,
Remark: e.Remark,
HasApiKey: cfg.ApiKey != "",
}
switch {
case e.Enabled != 1:
info.Hint = "已停用"
case !configured:
info.Hint = "需要配置 API Key"
}
out = append(out, info)
}
// 库里配了但代码里没有适配器的(比如后端降级回滚过)也列出来,避免「看不到所以查不到」
for code, e := range byCode {
if seen[code] {
continue
}
out = append(out, dto.WallpaperSourceInfo{
Code: code,
Name: e.Name,
Enabled: e.Enabled,
Sort: e.Sort,
Available: false,
Hint: "后端未注册该平台的适配器",
Remark: e.Remark,
})
}
return out, nil
}
// AvailableSourceCodes 返回当前可用(已启用且已配置)的平台编码集合。
// 供前台「随机一张」在前端未指定来源时挑一个用。
func (s *service) AvailableSourceCodes(ctx context.Context) ([]string, error) {
infos, err := s.Sources(ctx)
if err != nil {
return nil, err
}
codes := make([]string, 0, len(infos))
for _, i := range infos {
if i.Available {
codes = append(codes, i.Code)
}
}
return codes, nil
}
// LoadSourceConfig 读取某平台的配置。
// 平台不存在时返回错误;平台存在但未启用时也照常返回配置(调用方自行判断)。
func (s *service) LoadSourceConfig(ctx context.Context, code string) (dto.WallpaperSourceConfig, entity.WallpaperSource, error) {
var e entity.WallpaperSource
one, err := dao.WallpaperSource.Ctx(ctx).Where(do.WallpaperSource{Code: code}).One()
if err != nil {
return dto.WallpaperSourceConfig{}, e, gerror.Wrap(err, "查询壁纸平台配置失败")
}
if one.IsEmpty() {
return dto.WallpaperSourceConfig{}, e, gerror.Newf("平台 %s 未配置", code)
}
if err = one.Struct(&e); err != nil {
return dto.WallpaperSourceConfig{}, e, gerror.Wrap(err, "解析壁纸平台配置失败")
}
return ParseSourceConfig(e.Config), e, nil
}
// SaveSource 保存(新增或更新)平台配置。
//
// 关键处理:入参里的 apiKey 若为空,**保留库里已有的值**。
// 后台列表只回传「是否已设置」而不回传明文,用户只想改开关或排序时
// 不必重新粘贴一遍密钥,也不会因为一次误操作把 Key 清掉。
func (s *service) SaveSource(ctx context.Context, in dto.WallpaperSourceSaveInput) error {
if in.Code == "" {
return gerror.New("平台编码不能为空")
}
if _, ok := GetProvider(in.Code); !ok {
return gerror.Newf("未知平台:%s", in.Code)
}
incoming := ParseSourceConfig(in.Config)
old, _, err := s.LoadSourceConfig(ctx, in.Code)
if err == nil && incoming.ApiKey == "" {
incoming.ApiKey = old.ApiKey
}
if err == nil && incoming.ApiSecret == "" {
incoming.ApiSecret = old.ApiSecret
}
encoded, err := gjson.Encode(incoming)
if err != nil {
return gerror.Wrap(err, "平台配置序列化失败")
}
data := do.WallpaperSource{
Code: in.Code,
Name: in.Name,
Enabled: in.Enabled,
Sort: in.Sort,
Config: string(encoded),
Remark: in.Remark,
}
// 先按 code 更新,没有受影响行再插入 —— 避免依赖「主键自增 + 唯一键冲突」的写法。
n, err := dao.WallpaperSource.Ctx(ctx).Where(do.WallpaperSource{Code: in.Code}).Count()
if err != nil {
return gerror.Wrap(err, "检查平台配置是否存在失败")
}
if n > 0 {
if _, err = dao.WallpaperSource.Ctx(ctx).Where(do.WallpaperSource{Code: in.Code}).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新平台配置失败")
}
return nil
}
if _, err = dao.WallpaperSource.Ctx(ctx).Data(data).Insert(); err != nil {
return gerror.Wrap(err, "新增平台配置失败")
}
return nil
}
// TestSource 试拉一条,用于后台的「测试连通性」按钮。
// 返回一句给人看的结果描述。
func (s *service) TestSource(ctx context.Context, code string) (string, error) {
p, ok := GetProvider(code)
if !ok {
return "", gerror.Newf("未知平台:%s", code)
}
cfg, e, err := s.LoadSourceConfig(ctx, code)
if err != nil {
// 库里没有配置也允许测:用空配置试一次,能通就说明零配置可用
cfg = dto.WallpaperSourceConfig{}
} else if e.Enabled != 1 {
return "", gerror.Newf("平台「%s」当前是停用状态请先启用再测试", firstNonEmpty(e.Name, p.Name()))
}
if p.RequiresKey() && cfg.ApiKey == "" {
return "", gerror.Newf("平台「%s」需要先填写 API Key", p.Name())
}
items, _, err := p.List(ctx, cfg, dto.WallpaperQuery{Page: 1, Size: 1})
if err != nil {
return "", err
}
if len(items) == 0 {
return fmt.Sprintf("接口连通,但未返回任何图片(可能是筛选条件把结果过滤空了):%s", p.Name()), nil
}
return fmt.Sprintf("连通正常,取到示例:%s", firstNonEmpty(items[0].Title, items[0].Id)), nil
}
// ParseSourceConfig 解析 wallpaper_source.config 的 JSON。
//
// **解析失败不报错,回退全默认值** —— 一个平台的脏配置不应该让整个来源列表挂掉,
// 那种「一处配置写错导致整站壁纸不可用」的故障非常难排查。
func ParseSourceConfig(raw string) dto.WallpaperSourceConfig {
var cfg dto.WallpaperSourceConfig
if raw == "" {
return cfg
}
if err := gjson.DecodeTo(raw, &cfg); err != nil {
return dto.WallpaperSourceConfig{}
}
return cfg
}