需求:前台壁纸站支持切换到开源壁纸平台,并能展示后台上传的自家图库, 保留原有「浏览器实时生成」能力(扩展而非替换),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 不变)。
219 lines
7.1 KiB
Go
219 lines
7.1 KiB
Go
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
|
||
}
|