需求:前台壁纸站支持切换到开源壁纸平台,并能展示后台上传的自家图库, 保留原有「浏览器实时生成」能力(扩展而非替换),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 不变)。
202 lines
6.5 KiB
Go
202 lines
6.5 KiB
Go
package wallpaper
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/net/ghttp"
|
|
|
|
wallpaperv1 "service.xpcool.com/api/wallpaper"
|
|
"service.xpcool.com/internal/model/dto"
|
|
wallpapersvc "service.xpcool.com/internal/service/wallpaper"
|
|
)
|
|
|
|
// Controller 后台管理接口(走 RBAC 权限)。
|
|
type Controller struct{}
|
|
|
|
// New 构造后台控制器。
|
|
func New() *Controller { return &Controller{} }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 自建图库
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// List 图库列表(含已停用项,便于重新启用)。
|
|
func (c *Controller) List(ctx context.Context, req *wallpaperv1.AdminListReq) (res *wallpaperv1.AdminListRes, err error) {
|
|
q := dto.WallpaperQuery{
|
|
Query: req.Query,
|
|
Orientation: req.Orientation,
|
|
Page: req.Page,
|
|
Size: req.Size,
|
|
}
|
|
items, total, err := wallpapersvc.Wallpaper().LibraryAdminList(ctx, q, req.Enabled)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wallpaperv1.AdminListRes{List: toAPIItems(items), Total: total}, nil
|
|
}
|
|
|
|
// Upload 批量上传壁纸。
|
|
//
|
|
// 刻意「逐文件返回结果」而不是整体成功/失败:一次传 20 张,其中 1 张格式不对时,
|
|
// 用户希望的是「19 张成功、1 张告诉我为什么失败」,而不是全部回滚重来。
|
|
func (c *Controller) Upload(ctx context.Context, req *wallpaperv1.AdminUploadReq) (res *wallpaperv1.AdminUploadRes, err error) {
|
|
if len(req.Files) == 0 {
|
|
return nil, gerror.New("没有收到文件")
|
|
}
|
|
|
|
out := make([]*wallpaperv1.UploadItem, 0, len(req.Files))
|
|
okCount, failCount := 0, 0
|
|
for _, f := range req.Files {
|
|
row := &wallpaperv1.UploadItem{FileName: f.Filename}
|
|
data, readErr := readUpload(f)
|
|
if readErr != nil {
|
|
row.Message = readErr.Error()
|
|
failCount++
|
|
out = append(out, row)
|
|
continue
|
|
}
|
|
item, duplicated, upErr := wallpapersvc.Wallpaper().Upload(ctx, f.Filename, data, dto.WallpaperSaveInput{
|
|
Title: req.Title,
|
|
Tags: req.Tags,
|
|
Category: req.Category,
|
|
Sort: req.Sort,
|
|
Enabled: 1,
|
|
})
|
|
if upErr != nil {
|
|
row.Message = upErr.Error()
|
|
failCount++
|
|
out = append(out, row)
|
|
continue
|
|
}
|
|
row.Ok = true
|
|
row.Duplicated = duplicated
|
|
row.Item = toAPIItem(item)
|
|
if duplicated {
|
|
row.Message = "内容重复,已指向图库中已有的图片"
|
|
} else {
|
|
row.Message = "上传成功"
|
|
}
|
|
okCount++
|
|
out = append(out, row)
|
|
}
|
|
return &wallpaperv1.AdminUploadRes{List: out, OkCount: okCount, FailCount: failCount}, nil
|
|
}
|
|
|
|
// readUpload 把一个上传文件读成字节切片。
|
|
//
|
|
// 先看 Size 再读:超限的文件直接拒绝,避免把一个 200MB 的文件读进内存才发现。
|
|
func readUpload(f *ghttp.UploadFile) ([]byte, error) {
|
|
if f == nil {
|
|
return nil, gerror.New("文件为空")
|
|
}
|
|
if f.Size <= 0 {
|
|
return nil, gerror.New("文件为空")
|
|
}
|
|
if f.Size > wallpapersvc.MaxUploadBytes {
|
|
return nil, gerror.Newf("文件超过 %d MB 上限", wallpapersvc.MaxUploadBytes>>20)
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, gerror.New("读取上传文件失败")
|
|
}
|
|
defer func() { _ = rc.Close() }()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(rc, wallpapersvc.MaxUploadBytes+1))
|
|
if err != nil {
|
|
return nil, gerror.New("读取上传文件失败")
|
|
}
|
|
if int64(len(data)) > wallpapersvc.MaxUploadBytes {
|
|
return nil, gerror.Newf("文件超过 %d MB 上限", wallpapersvc.MaxUploadBytes>>20)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Save 保存壁纸元数据。
|
|
func (c *Controller) Save(ctx context.Context, req *wallpaperv1.AdminSaveReq) (res *wallpaperv1.AdminSaveRes, err error) {
|
|
err = wallpapersvc.Wallpaper().SaveMeta(ctx, dto.WallpaperSaveInput{
|
|
Id: req.Id,
|
|
Title: req.Title,
|
|
Tags: req.Tags,
|
|
Category: req.Category,
|
|
Enabled: req.Enabled,
|
|
Sort: req.Sort,
|
|
Remark: req.Remark,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wallpaperv1.AdminSaveRes{Message: "保存成功"}, nil
|
|
}
|
|
|
|
// Delete 删除壁纸。
|
|
func (c *Controller) Delete(ctx context.Context, req *wallpaperv1.AdminDeleteReq) (res *wallpaperv1.AdminDeleteRes, err error) {
|
|
if err = wallpapersvc.Wallpaper().Delete(ctx, req.Id); err != nil {
|
|
return nil, err
|
|
}
|
|
return &wallpaperv1.AdminDeleteRes{Message: "删除成功"}, nil
|
|
}
|
|
|
|
// Stats 图库概览。
|
|
func (c *Controller) Stats(ctx context.Context, req *wallpaperv1.AdminStatsReq) (res *wallpaperv1.AdminStatsRes, err error) {
|
|
st, err := wallpapersvc.Wallpaper().Stat(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wallpaperv1.AdminStatsRes{
|
|
Total: st.Total,
|
|
Enabled: st.Enabled,
|
|
Disabled: st.Disabled,
|
|
Portrait: st.Portrait,
|
|
Landscape: st.Landscape,
|
|
TotalBytes: st.TotalBytes,
|
|
}, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 开源平台配置
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SourceList 平台配置列表。
|
|
func (c *Controller) SourceList(ctx context.Context, req *wallpaperv1.AdminSourceListReq) (res *wallpaperv1.AdminSourceListRes, err error) {
|
|
list, err := wallpapersvc.Wallpaper().Sources(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]*wallpaperv1.SourceInfo, 0, len(list))
|
|
for i := range list {
|
|
out = append(out, toAPISource(&list[i]))
|
|
}
|
|
return &wallpaperv1.AdminSourceListRes{List: out}, nil
|
|
}
|
|
|
|
// SourceSave 保存平台配置。
|
|
func (c *Controller) SourceSave(ctx context.Context, req *wallpaperv1.AdminSourceSaveReq) (res *wallpaperv1.AdminSourceSaveRes, err error) {
|
|
err = wallpapersvc.Wallpaper().SaveSource(ctx, dto.WallpaperSourceSaveInput{
|
|
Code: req.Code,
|
|
Name: req.Name,
|
|
Enabled: req.Enabled,
|
|
Sort: req.Sort,
|
|
Remark: req.Remark,
|
|
Config: req.Config,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wallpaperv1.AdminSourceSaveRes{Message: "保存成功"}, nil
|
|
}
|
|
|
|
// SourceTest 测试平台连通性。
|
|
func (c *Controller) SourceTest(ctx context.Context, req *wallpaperv1.AdminSourceTestReq) (res *wallpaperv1.AdminSourceTestRes, err error) {
|
|
msg, err := wallpapersvc.Wallpaper().TestSource(ctx, req.Code)
|
|
if err != nil {
|
|
// 测试失败是「预期内的结果」而不是接口异常,故把原因作为数据返回,
|
|
// 让前端能直接在界面上展示,而不是弹一个通用错误。
|
|
g.Log().Infof(ctx, "平台连通性测试未通过 code=%s: %v", req.Code, err)
|
|
return &wallpaperv1.AdminSourceTestRes{Ok: false, Message: err.Error()}, nil
|
|
}
|
|
return &wallpaperv1.AdminSourceTestRes{Ok: true, Message: msg}, nil
|
|
}
|