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 不变)。
This commit is contained in:
夏犀麟 2026-09-14 01:39:09 +08:00
parent ad4567fc01
commit 582485ba2b
31 changed files with 3181 additions and 2 deletions

236
api/wallpaper/wallpaper.go Normal file
View File

@ -0,0 +1,236 @@
// Package wallpaper_v1 壁纸模块接口契约。
//
// 分成两组:
// - open 组(/api/service/open/wallpaper/*):给 xpcool.com 前台调用,免鉴权;
// - admin 组(/api/service/admin/wallpaper/*):后台管理,走 RBAC 权限。
//
// 规范2026-08-27 起):全部 POSTURL 不含参数;入参一律 body。
// 唯一例外是「上传」——文件必须走 multipart其余字段随之走表单而不是 JSON。
package wallpaper
import "github.com/gogf/gf/v2/frame/g"
import "github.com/gogf/gf/v2/net/ghttp"
// ---------------------------------------------------------------------------
// 公共数据结构
// ---------------------------------------------------------------------------
// Item 是壁纸的统一输出条目。开源平台与自建图库填的是同一个结构,
// 前端因此只需要一套渲染逻辑。
type Item struct {
Id string `json:"id"`
Title string `json:"title"`
Width int `json:"width"`
Height int `json:"height"`
Orientation int `json:"orientation"` // 0未知 1横版 2竖版 3方形
ThumbUrl string `json:"thumbUrl"` // 列表网格用480px
PreviewUrl string `json:"previewUrl"` // 全屏展示用(长边 1920
FullUrl string `json:"fullUrl"` // 下载用(原图)
Source string `json:"source"`
SourceName string `json:"sourceName"`
FromOpen bool `json:"fromOpen"`
Author string `json:"author"`
AuthorUrl string `json:"authorUrl"`
PageUrl string `json:"pageUrl"`
License string `json:"license"`
Tags string `json:"tags"`
Category string `json:"category"`
Filesize int64 `json:"filesize"`
CreatedAt string `json:"createdAt"`
}
// SourceInfo 开源平台的可用性描述。
type SourceInfo struct {
Code string `json:"code"`
Name string `json:"name"`
Enabled int `json:"enabled"`
Sort int `json:"sort"`
Configured bool `json:"configured"` // 凭据是否齐备
Available bool `json:"available"` // 启用 且 凭据齐备
Hint string `json:"hint"` // 不可用原因
Remark string `json:"remark"`
HasApiKey bool `json:"hasApiKey"` // 只回传「是否已设置」,不回传明文
}
// ---------------------------------------------------------------------------
// open 组:前台
// ---------------------------------------------------------------------------
// OpenSourceListReq 取全部来源清单(含开源平台 + 自建图库计数)。
type OpenSourceListReq struct {
g.Meta `path:"/wallpaper/sources" method:"post" tags:"Open/Wallpaper" summary:"壁纸来源清单"`
}
type OpenSourceListRes struct {
List []*SourceInfo `json:"list"`
// MineCount 只统计「启用」的自建图片数量,为 0 时前台隐藏「我的图库」入口
MineCount int `json:"mineCount"`
}
// OpenListReq 按来源分页取图。
type OpenListReq struct {
g.Meta `path:"/wallpaper/list" method:"post" tags:"Open/Wallpaper" summary:"壁纸列表(按来源)"`
Source string `json:"source" d:"mine" dc:"来源编码mine=自建图库,其余为平台编码"`
Query string `json:"query" dc:"搜索词(仅部分平台与自建图库支持)"`
Orientation int `json:"orientation" dc:"0不限 1横版 2竖版 3方形"`
Tag string `json:"tag" dc:"标签(仅自建图库)"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"24"`
}
type OpenListRes struct {
List []*Item `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// OpenRandomReq 跨来源随机取一张。
type OpenRandomReq struct {
g.Meta `path:"/wallpaper/random" method:"post" tags:"Open/Wallpaper" summary:"随机取一张壁纸"`
Sources []string `json:"sources" dc:"限定来源;为空则在所有可用来源中随机"`
Orientation int `json:"orientation" dc:"0不限 1横版 2竖版 3方形"`
}
type OpenRandomRes struct {
Item *Item `json:"item"`
}
// OpenTrackDownloadReq 通知平台「这张图被下载了」。
// 目前只有 Unsplash 需要(其 API 许可的硬性要求),其它平台为空操作。
type OpenTrackDownloadReq struct {
g.Meta `path:"/wallpaper/download-track" method:"post" tags:"Open/Wallpaper" summary:"下载回调(平台统计)"`
Source string `json:"source"`
Id string `json:"id"`
}
type OpenTrackDownloadRes struct {
Message string `json:"message"`
}
// ---------------------------------------------------------------------------
// admin 组:自建图库
// ---------------------------------------------------------------------------
// AdminListReq 后台图库列表(含已停用项)。
type AdminListReq struct {
g.Meta `path:"/wallpaper/list" method:"post" tags:"Admin/Wallpaper" summary:"图库列表(含停用)"`
Query string `json:"query"`
Orientation int `json:"orientation"`
Enabled int `json:"enabled" d:"-1" dc:"-1全部 0停用 1启用"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"24"`
}
type AdminListRes struct {
List []*Item `json:"list"`
Total int `json:"total"`
}
// AdminUploadReq 上传壁纸(文件走 multipart其余字段为表单字段
type AdminUploadReq struct {
g.Meta `path:"/wallpaper/upload" method:"post" mime:"multipart/form-data" tags:"Admin/Wallpaper" summary:"上传壁纸(可多选)"`
Files []*ghttp.UploadFile `json:"files" type:"file" dc:"图片文件,可多选"`
Title string `json:"title" dc:"标题,留空则用文件名"`
Tags string `json:"tags"`
Category string `json:"category"`
Sort int `json:"sort"`
}
// UploadItem 单个文件的处理结果。
// 批量上传时允许部分失败,故逐条返回,而不是整体成功/失败。
type UploadItem struct {
FileName string `json:"fileName"`
Ok bool `json:"ok"`
Duplicated bool `json:"duplicated"` // 内容重复,命中了已有图片(秒传)
Message string `json:"message"`
Item *Item `json:"item"`
}
type AdminUploadRes struct {
List []*UploadItem `json:"list"`
OkCount int `json:"okCount"`
FailCount int `json:"failCount"`
}
// AdminSaveReq 编辑壁纸元数据(不涉及文件)。
type AdminSaveReq struct {
g.Meta `path:"/wallpaper/save" method:"post" tags:"Admin/Wallpaper" summary:"保存壁纸信息"`
Id uint64 `json:"id" v:"required"`
Title string `json:"title"`
Tags string `json:"tags"`
Category string `json:"category"`
Enabled int `json:"enabled"`
Sort int `json:"sort"`
Remark string `json:"remark"`
}
type AdminSaveRes struct {
Message string `json:"message"`
}
// AdminDeleteReq 删除壁纸(同时清理原图与派生图)。
type AdminDeleteReq struct {
g.Meta `path:"/wallpaper/delete" method:"post" tags:"Admin/Wallpaper" summary:"删除壁纸"`
Id uint64 `json:"id" v:"required"`
}
type AdminDeleteRes struct {
Message string `json:"message"`
}
// AdminStatsReq 图库概览。
type AdminStatsReq struct {
g.Meta `path:"/wallpaper/stats" method:"post" tags:"Admin/Wallpaper" summary:"图库统计"`
}
type AdminStatsRes struct {
Total int `json:"total"`
Enabled int `json:"enabled"`
Disabled int `json:"disabled"`
Portrait int `json:"portrait"`
Landscape int `json:"landscape"`
TotalBytes int64 `json:"totalBytes"`
}
// ---------------------------------------------------------------------------
// admin 组:开源平台配置
// ---------------------------------------------------------------------------
// AdminSourceListReq 平台配置列表。
type AdminSourceListReq struct {
g.Meta `path:"/wallpaper/source/list" method:"post" tags:"Admin/Wallpaper" summary:"平台配置列表"`
}
type AdminSourceListRes struct {
List []*SourceInfo `json:"list"`
}
// AdminSourceSaveReq 保存平台配置。
type AdminSourceSaveReq struct {
g.Meta `path:"/wallpaper/source/save" method:"post" tags:"Admin/Wallpaper" summary:"保存平台配置"`
Code string `json:"code" v:"required"`
Name string `json:"name"`
Enabled int `json:"enabled"`
Sort int `json:"sort"`
Remark string `json:"remark"`
// Config 是平台配置的 JSON 字符串。
// 其中 apiKey 留空表示「不修改已保存的 Key」—— 界面不回传密钥明文,
// 用户只改开关时不必重新粘贴。
Config string `json:"config"`
}
type AdminSourceSaveRes struct {
Message string `json:"message"`
}
// AdminSourceTestReq 测试平台连通性。
type AdminSourceTestReq struct {
g.Meta `path:"/wallpaper/source/test" method:"post" tags:"Admin/Wallpaper" summary:"测试平台连通性"`
Code string `json:"code" v:"required"`
}
type AdminSourceTestRes struct {
Ok bool `json:"ok"`
Message string `json:"message"`
}

1
go.mod
View File

@ -5,6 +5,7 @@ go 1.23.0
require (
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
golang.org/x/image v0.23.0
)
require github.com/go-sql-driver/mysql v1.7.1 // indirect

2
go.sum
View File

@ -72,6 +72,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -6,7 +6,10 @@ gfcli:
dao:
- link: "mysql:root:root123@tcp(127.0.0.1:3306)/service_xpcool_com"
descriptionTag: true
tables: "house_community,house_building,house_listing,house_price_snapshot,house_transaction,house_facility,house_community_facility,house_school_district,house_preference,house_presale"
# 注意tables 决定 `gf gen dao` 会生成/覆盖哪些表
# 只加表不改成生表等于给下一次 re-gen 埋雷漏列的表其 entity/do/dao
# 会被重新生成的版本覆盖成不完整状态故新增表必须同步补到这里
tables: "house_community,house_building,house_listing,house_price_snapshot,house_transaction,house_facility,house_community_facility,house_school_district,house_preference,house_presale,wallpaper,wallpaper_source"
docker:
build: "-a amd64 -s linux -p temp -ew"

View File

@ -19,6 +19,7 @@ import (
recruitmentctl "service.xpcool.com/internal/controller/recruitment"
serversecurityctl "service.xpcool.com/internal/controller/serversecurity"
userctl "service.xpcool.com/internal/controller/user"
wallpaperctl "service.xpcool.com/internal/controller/wallpaper"
"service.xpcool.com/internal/library/crypto"
"service.xpcool.com/internal/library/jwt"
"service.xpcool.com/internal/middleware"
@ -40,6 +41,7 @@ import (
recruitmentsvc "service.xpcool.com/internal/service/recruitment"
serversecuritysvc "service.xpcool.com/internal/service/serversecurity"
userauth "service.xpcool.com/internal/service/user/auth"
wallpapersvc "service.xpcool.com/internal/service/wallpaper"
)
// 开发环境默认值任何启动方式GoLand / 命令行 / go run漏注入环境变量时兜底
@ -84,6 +86,23 @@ func injectEnv(ctx context.Context) {
_ = adapter.Set("jwt.secret", devDefaultJWTSecret)
}
}
// 壁纸库路径同理:占位符没被注入时要回填,否则会真的创建一个名为
// "${WALLPAPER_ROOT}" 的目录,图片全落到那里且难以察觉。
// 生产兜底到 /data/www/wallpaper由 nginx 直出),开发兜底到仓库内 data/。
if v, _ := adapter.Get(ctx, "wallpaper.root"); v != nil && gstr.Contains(gconv.String(v), "${") {
if isProd {
_ = adapter.Set("wallpaper.root", "/data/www/wallpaper")
} else {
_ = adapter.Set("wallpaper.root", "./data/wallpaper")
}
}
if v, _ := adapter.Get(ctx, "wallpaper.baseUrl"); v != nil && gstr.Contains(gconv.String(v), "${") {
if isProd {
_ = adapter.Set("wallpaper.baseUrl", "https://xpcool.com/wallpaper")
} else {
_ = adapter.Set("wallpaper.baseUrl", "/wallpaper")
}
}
setIfEmpty(adapter, "database.default.link", "DB_DSN")
setIfEmpty(adapter, "jwt.secret", "JWT_SECRET")
// 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。
@ -97,6 +116,10 @@ func injectEnv(ctx context.Context) {
// ENCRYPT_FULL_BODY=true 启用全量请求/响应加密生产ENCRYPT_ALLOW_PLAIN=true 仅开发联调。
setIfEmpty(adapter, "encrypt.fullBody", "ENCRYPT_FULL_BODY")
setIfEmpty(adapter, "encrypt.allowPlain", "ENCRYPT_ALLOW_PLAIN")
// 壁纸图库:上传文件的落盘根目录与对外访问前缀。
// 生产为 /data/www/wallpaper + https://xpcool.com/wallpaper由 nginx 直出)。
setIfEmpty(adapter, "wallpaper.root", "WALLPAPER_ROOT")
setIfEmpty(adapter, "wallpaper.baseUrl", "WALLPAPER_BASE_URL")
}
var (
@ -131,10 +154,13 @@ var (
noticesvc.RegisterNotice(noticesvc.New())
jobsvc.RegisterJob(jobsvc.New())
serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New())
wallpapersvc.RegisterWallpaper(wallpapersvc.New(ctx))
s.Group("/api/service/open", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
group.Bind(openctl.New()) // 开放工具接口(前端调用,免鉴权)。
group.Bind(serversecurityctl.NewReport()) // 安全日志上报(宿主机脚本,内部令牌校验)。
// 壁纸前台读取xpcool.com 是纯静态站,浏览器直连本服务取数据,故必须免鉴权。
group.Bind(wallpaperctl.NewOpen())
})
s.Group("/api/service/user", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
@ -163,6 +189,7 @@ var (
protected.Bind(noticectl.New())
protected.Bind(jobctl.New())
protected.Bind(serversecurityctl.NewManage())
protected.Bind(wallpaperctl.New())
})
})
// 启动自动任务调度器:招聘模块先把任务注册进来,再由 job 模块按 DB 配置统一调度。

View File

@ -0,0 +1,201 @@
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
}

View File

@ -0,0 +1,66 @@
// Package wallpaper 壁纸模块控制器。
//
// 分两个控制器:
// - Controlleradmin后台图库管理与平台配置走 RBAC 权限;
// - OpenControlleropen前台读取免鉴权。
//
// 控制器只做「参数搬运 + 结构转换」,业务逻辑一律在 internal/service/wallpaper。
package wallpaper
import (
wallpaperv1 "service.xpcool.com/api/wallpaper"
"service.xpcool.com/internal/model/dto"
)
// toAPIItem 把领域 DTO 转成接口输出结构。
// 两者字段一一对应,单独转换是为了让 API 契约与内部结构可以各自演进。
func toAPIItem(v *dto.WallpaperItem) *wallpaperv1.Item {
if v == nil {
return nil
}
return &wallpaperv1.Item{
Id: v.Id,
Title: v.Title,
Width: v.Width,
Height: v.Height,
Orientation: v.Orientation,
ThumbUrl: v.ThumbUrl,
PreviewUrl: v.PreviewUrl,
FullUrl: v.FullUrl,
Source: v.Source,
SourceName: v.SourceName,
FromOpen: v.FromOpen,
Author: v.Author,
AuthorUrl: v.AuthorUrl,
PageUrl: v.PageUrl,
License: v.License,
Tags: v.Tags,
Category: v.Category,
Filesize: v.Filesize,
CreatedAt: v.CreatedAt,
}
}
// toAPIItems 批量转换。
func toAPIItems(list []dto.WallpaperItem) []*wallpaperv1.Item {
out := make([]*wallpaperv1.Item, 0, len(list))
for i := range list {
out = append(out, toAPIItem(&list[i]))
}
return out
}
// toAPISource 把平台信息转成接口输出结构。
func toAPISource(v *dto.WallpaperSourceInfo) *wallpaperv1.SourceInfo {
return &wallpaperv1.SourceInfo{
Code: v.Code,
Name: v.Name,
Enabled: v.Enabled,
Sort: v.Sort,
Configured: v.Configured,
Available: v.Available,
Hint: v.Hint,
Remark: v.Remark,
HasApiKey: v.HasApiKey,
}
}

View File

@ -0,0 +1,87 @@
package wallpaper
import (
"context"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
wallpaperv1 "service.xpcool.com/api/wallpaper"
"service.xpcool.com/internal/model/dto"
wallpapersvc "service.xpcool.com/internal/service/wallpaper"
)
// OpenController 前台读取接口(免鉴权,供 xpcool.com 调用)。
type OpenController struct{}
// NewOpen 构造前台控制器。
func NewOpen() *OpenController { return &OpenController{} }
// Sources 返回全部来源清单。
//
// 除开源平台外还带回自建图库的启用数量:前台据此决定要不要显示
// 「我的图库」这个入口 —— 一张图都没有时显示它只会让人点进去看空白。
func (c *OpenController) Sources(ctx context.Context, req *wallpaperv1.OpenSourceListReq) (res *wallpaperv1.OpenSourceListRes, err error) {
svc := wallpapersvc.Wallpaper()
list, err := svc.Sources(ctx)
if err != nil {
return nil, err
}
// 图库计数失败不影响平台列表:把 0 当成「没有自建图」,入口不显示即可,
// 没必要因为一个计数把整个来源接口拖垮。
mineCount, err := svc.MineCount(ctx)
if err != nil {
g.Log().Warningf(ctx, "统计自建图库数量失败: %v", err)
mineCount = 0
}
out := make([]*wallpaperv1.SourceInfo, 0, len(list))
for i := range list {
out = append(out, toAPISource(&list[i]))
}
return &wallpaperv1.OpenSourceListRes{List: out, MineCount: mineCount}, nil
}
// List 按来源分页取图。
func (c *OpenController) List(ctx context.Context, req *wallpaperv1.OpenListReq) (res *wallpaperv1.OpenListRes, err error) {
q := dto.WallpaperQuery{
Source: req.Source,
Query: req.Query,
Orientation: req.Orientation,
Tag: req.Tag,
Page: req.Page,
Size: req.Size,
}
items, total, err := wallpapersvc.Wallpaper().List(ctx, q)
if err != nil {
// 前台是公开接口,平台侧的原始错误(含 URL、Key 片段)不应直接抛给浏览器
g.Log().Warningf(ctx, "壁纸列表获取失败 source=%s: %v", req.Source, err)
return nil, gerror.New("该来源暂时不可用,请换一个来源试试")
}
return &wallpaperv1.OpenListRes{
List: toAPIItems(items),
Total: total,
Page: q.Page,
Size: q.Size,
}, nil
}
// Random 跨来源随机取一张。
func (c *OpenController) Random(ctx context.Context, req *wallpaperv1.OpenRandomReq) (res *wallpaperv1.OpenRandomRes, err error) {
item, err := wallpapersvc.Wallpaper().Random(ctx, req.Sources, req.Orientation)
if err != nil {
g.Log().Warningf(ctx, "随机取壁纸失败 sources=%v: %v", req.Sources, err)
return nil, gerror.New("暂时取不到壁纸,请稍后再试")
}
return &wallpaperv1.OpenRandomRes{Item: toAPIItem(item)}, nil
}
// TrackDownload 下载回调(目前仅 Unsplash 需要)。
// 刻意做成「永远返回成功」:这是统计性质的回调,不能因为它失败而挡住用户下载。
func (c *OpenController) TrackDownload(ctx context.Context, req *wallpaperv1.OpenTrackDownloadReq) (res *wallpaperv1.OpenTrackDownloadRes, err error) {
if err = wallpapersvc.Wallpaper().TrackDownload(ctx, req.Source, req.Id); err != nil {
g.Log().Warningf(ctx, "下载回调失败 source=%s id=%s: %v", req.Source, req.Id, err)
}
return &wallpaperv1.OpenTrackDownloadRes{Message: "ok"}, nil
}

View File

@ -0,0 +1,119 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// WallpaperDao is the data access object for the table wallpaper.
type WallpaperDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns WallpaperColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// WallpaperColumns defines and stores column names for the table wallpaper.
type WallpaperColumns struct {
Id string //
Title string // 标题
Tags string // 标签(英文逗号分隔)
Category string // 分类(自由文本)
Orientation string // 朝向 0未知 1横版 2竖版 3方形
Width string // 原图宽度(px)
Height string // 原图高度(px)
Filesize string // 原图字节数
Mime string // 原图MIME(image/jpeg等)
Path string // 原图相对路径(相对存储根)
ThumbPath string // 缩略图相对路径(480px)
PreviewPath string // 预览图相对路径(长边1920)
Hash string // 原图MD5(秒传去重)
Enabled string // 是否启用 0否 1是
Sort string // 排序,小的在前
ViewCount string // 浏览次数
DownloadCount string // 下载次数
Remark string // 备注
CreatedAt string //
UpdatedAt string //
DeletedAt string //
}
// wallpaperColumns holds the columns for the table wallpaper.
var wallpaperColumns = WallpaperColumns{
Id: "id",
Title: "title",
Tags: "tags",
Category: "category",
Orientation: "orientation",
Width: "width",
Height: "height",
Filesize: "filesize",
Mime: "mime",
Path: "path",
ThumbPath: "thumb_path",
PreviewPath: "preview_path",
Hash: "hash",
Enabled: "enabled",
Sort: "sort",
ViewCount: "view_count",
DownloadCount: "download_count",
Remark: "remark",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
DeletedAt: "deleted_at",
}
// NewWallpaperDao creates and returns a new DAO object for table data access.
func NewWallpaperDao(handlers ...gdb.ModelHandler) *WallpaperDao {
return &WallpaperDao{
group: "default",
table: "wallpaper",
columns: wallpaperColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *WallpaperDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *WallpaperDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *WallpaperDao) Columns() WallpaperColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *WallpaperDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *WallpaperDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *WallpaperDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}

View File

@ -0,0 +1,95 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// WallpaperSourceDao is the data access object for the table wallpaper_source.
type WallpaperSourceDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns WallpaperSourceColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// WallpaperSourceColumns defines and stores column names for the table wallpaper_source.
type WallpaperSourceColumns struct {
Id string //
Code string // 平台编码 bing/picsum/unsplash/pexels/wallhaven
Name string // 平台显示名
Enabled string // 是否启用 0否 1是
Sort string // 排序,小的在前
Config string // 平台配置JSON(apiKey/defaultQuery等)
Remark string // 备注
CreatedAt string //
UpdatedAt string //
}
// wallpaperSourceColumns holds the columns for the table wallpaper_source.
var wallpaperSourceColumns = WallpaperSourceColumns{
Id: "id",
Code: "code",
Name: "name",
Enabled: "enabled",
Sort: "sort",
Config: "config",
Remark: "remark",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewWallpaperSourceDao creates and returns a new DAO object for table data access.
func NewWallpaperSourceDao(handlers ...gdb.ModelHandler) *WallpaperSourceDao {
return &WallpaperSourceDao{
group: "default",
table: "wallpaper_source",
columns: wallpaperSourceColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *WallpaperSourceDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *WallpaperSourceDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *WallpaperSourceDao) Columns() WallpaperSourceColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *WallpaperSourceDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *WallpaperSourceDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *WallpaperSourceDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}

22
internal/dao/wallpaper.go Normal file
View File

@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"service.xpcool.com/internal/dao/internal"
)
// wallpaperDao is the data access object for the table wallpaper.
// You can define custom methods on it to extend its functionality as needed.
type wallpaperDao struct {
*internal.WallpaperDao
}
var (
// Wallpaper is a globally accessible object for table wallpaper operations.
Wallpaper = wallpaperDao{internal.NewWallpaperDao()}
)
// Add your custom methods and functionality below.

View File

@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"service.xpcool.com/internal/dao/internal"
)
// wallpaperSourceDao is the data access object for the table wallpaper_source.
// You can define custom methods on it to extend its functionality as needed.
type wallpaperSourceDao struct {
*internal.WallpaperSourceDao
}
var (
// WallpaperSource is a globally accessible object for table wallpaper_source operations.
WallpaperSource = wallpaperSourceDao{internal.NewWallpaperSourceDao()}
)
// Add your custom methods and functionality below.

View File

@ -0,0 +1,35 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// Wallpaper is the golang structure of table wallpaper for DAO operations like Where/Data.
type Wallpaper struct {
g.Meta `orm:"table:wallpaper, do:true"`
Id any //
Title any // 标题
Tags any // 标签(英文逗号分隔)
Category any // 分类(自由文本)
Orientation any // 朝向 0未知 1横版 2竖版 3方形
Width any // 原图宽度(px)
Height any // 原图高度(px)
Filesize any // 原图字节数
Mime any // 原图MIME(image/jpeg等)
Path any // 原图相对路径(相对存储根)
ThumbPath any // 缩略图相对路径(480px)
PreviewPath any // 预览图相对路径(长边1920)
Hash any // 原图MD5(秒传去重)
Enabled any // 是否启用 0否 1是
Sort any // 排序,小的在前
ViewCount any // 浏览次数
DownloadCount any // 下载次数
Remark any // 备注
CreatedAt any //
UpdatedAt any //
DeletedAt any //
}

View File

@ -0,0 +1,23 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// WallpaperSource is the golang structure of table wallpaper_source for DAO operations like Where/Data.
type WallpaperSource struct {
g.Meta `orm:"table:wallpaper_source, do:true"`
Id any //
Code any // 平台编码 bing/picsum/unsplash/pexels/wallhaven
Name any // 平台显示名
Enabled any // 是否启用 0否 1是
Sort any // 排序,小的在前
Config any // 平台配置JSON(apiKey/defaultQuery等)
Remark any // 备注
CreatedAt any //
UpdatedAt any //
}

View File

@ -0,0 +1,121 @@
// Package dto 壁纸模块的数据传输对象。
//
// 设计要点:**把「开源平台」和「自建图库」两种来源统一成同一个输出形状
// WallpaperItem**,前端拿到的东西长得一模一样 —— 分页、渲染、下载、
// 全屏都只需要一套代码。来源差异只在后端消化。
package dto
// WallpaperItem 是壁纸模块对外的统一输出条目。
//
// 无论图片来自开源平台还是自建图库,都填这个结构:
// - FromOpen=true 时 Id 为平台内 id用于下载上报Url 指向平台 CDN
// - FromOpen=false 时 Id 为库内主键Url 指向本站 nginx 直出的原图。
type WallpaperItem struct {
// ---- 通用 ----
Id string `json:"id"`
Title string `json:"title"`
Width int `json:"width"`
Height int `json:"height"`
Orientation int `json:"orientation"` // 0未知 1横版 2竖版 3方形
// ---- 三档图片地址 ----
// Thumb 用于列表网格、Preview 用于全屏展示、Full 用于下载。
// 自建图库这三者分别是 480px 缩略图 / 长边 1920 预览图 / 原图;
// 开源平台则取各平台 CDN 提供的对应尺寸,没有的就退回上一档。
ThumbUrl string `json:"thumbUrl"`
PreviewUrl string `json:"previewUrl"`
FullUrl string `json:"fullUrl"`
// ---- 归属 ----
Source string `json:"source"` // 平台编码,自建图库固定为 "mine"
SourceName string `json:"sourceName"` // 平台显示名,用于界面标注
FromOpen bool `json:"fromOpen"` // true=开源平台 false=自建图库
// ---- 版权信息(自建图库留空)----
Author string `json:"author"`
AuthorUrl string `json:"authorUrl"`
PageUrl string `json:"pageUrl"` // 平台详情页,用于「查看原页面」
License string `json:"license"` // 许可说明,如 "Unsplash License"
// ---- 自建图库专有 ----
Tags string `json:"tags"`
Category string `json:"category"`
Filesize int64 `json:"filesize"`
CreatedAt string `json:"createdAt"`
}
// WallpaperSourceInfo 是开源平台的可用性描述,供前端渲染平台切换器。
//
// Enabled 与 Configured 要分开看:前者是用户的开关,后者是凭据是否齐备。
// 两者都为真时 Available 才为真 —— 前端据此把平台置灰并给出 Hint
// 而不是点了没反应。
type WallpaperSourceInfo struct {
Code string `json:"code"`
Name string `json:"name"`
Enabled int `json:"enabled"`
Sort int `json:"sort"`
Configured bool `json:"configured"`
Available bool `json:"available"`
Hint string `json:"hint"` // 不可用原因,可用时为空
Remark string `json:"remark"`
// Config 是脱敏后的配置apiKey 只回传是否已设置,不回传明文)。
HasApiKey bool `json:"hasApiKey"`
}
// WallpaperSourceConfig 对应 wallpaper_source.config 字段的 JSON。
//
// 全部字段可选:缺失即用该平台的默认值。解析失败不报错、回退全默认,
// 避免一个平台的脏配置拖垮整个来源列表。
type WallpaperSourceConfig struct {
// ApiKey / ApiSecret 平台凭据。Bing 与 Picsum 不需要。
ApiKey string `json:"apiKey"`
ApiSecret string `json:"apiSecret"`
// DefaultQuery 默认搜索词,用户没传关键词时用。
DefaultQuery string `json:"defaultQuery"`
// Purity 分级过滤Wallhavensfw / sketchy / nsfw。默认只放 sfw。
Purity string `json:"purity"`
// Categories 分类Wallhavengeneral/anime/people 的组合,逗号分隔。
Categories string `json:"categories"`
// BaseUrl 可选覆盖平台接口地址(便于自建反代或走镜像)。
BaseUrl string `json:"baseUrl"`
}
// WallpaperSourceSaveInput 是后台保存开源平台配置的入参。
type WallpaperSourceSaveInput struct {
Code string `json:"code"`
Name string `json:"name"`
Enabled int `json:"enabled"`
Sort int `json:"sort"`
Remark string `json:"remark"`
// Config 是 dto.WallpaperSourceConfig 的 JSON 字符串。
// 其中 apiKey 允许留空 —— 留空表示「不修改已保存的 Key」
// 这样后台就不必把密钥明文回传到前端再原样传回来。
Config string `json:"config"`
}
// WallpaperQuery 是列表查询条件,开源平台与自建图库共用。
type WallpaperQuery struct {
Source string // 平台编码;"mine" 表示自建图库
Query string // 搜索词(自建图库匹配标题/标签/分类)
Orientation int // 0不限 1横版 2竖版 3方形
Tag string // 标签(仅自建图库)
Page int
Size int
}
// WallpaperSaveInput 是后台编辑壁纸元数据的入参(不含文件本身)。
type WallpaperSaveInput struct {
Id uint64 `json:"id"`
Title string `json:"title"`
Tags string `json:"tags"`
Category string `json:"category"`
Enabled int `json:"enabled"`
Sort int `json:"sort"`
Remark string `json:"remark"`
}
// WallpaperStat 是图库概览统计。
type WallpaperStat struct {
Total int `json:"total"` // 总数
Enabled int `json:"enabled"` // 启用数
Disabled int `json:"disabled"` // 停用数
Portrait int `json:"portrait"` // 竖版数
Landscape int `json:"landscape"` // 横版数
TotalBytes int64 `json:"totalBytes"` // 原图占用字节
}

View File

@ -0,0 +1,30 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
// Wallpaper is the golang structure for table wallpaper.
type Wallpaper struct {
Id uint64 `json:"id" orm:"id" description:""` //
Title string `json:"title" orm:"title" description:"标题"` // 标题
Tags string `json:"tags" orm:"tags" description:"标签(英文逗号分隔)"` // 标签(英文逗号分隔)
Category string `json:"category" orm:"category" description:"分类(自由文本)"` // 分类(自由文本)
Orientation int `json:"orientation" orm:"orientation" description:"朝向 0未知 1横版 2竖版 3方形"` // 朝向 0未知 1横版 2竖版 3方形
Width int `json:"width" orm:"width" description:"原图宽度(px)"` // 原图宽度(px)
Height int `json:"height" orm:"height" description:"原图高度(px)"` // 原图高度(px)
Filesize int64 `json:"filesize" orm:"filesize" description:"原图字节数"` // 原图字节数
Mime string `json:"mime" orm:"mime" description:"原图MIME(image/jpeg等)"` // 原图MIME(image/jpeg等)
Path string `json:"path" orm:"path" description:"原图相对路径(相对存储根)"` // 原图相对路径(相对存储根)
ThumbPath string `json:"thumbPath" orm:"thumb_path" description:"缩略图相对路径(480px)"` // 缩略图相对路径(480px)
PreviewPath string `json:"previewPath" orm:"preview_path" description:"预览图相对路径(长边1920)"` // 预览图相对路径(长边1920)
Hash string `json:"hash" orm:"hash" description:"原图MD5(秒传去重)"` // 原图MD5(秒传去重)
Enabled int `json:"enabled" orm:"enabled" description:"是否启用 0否 1是"` // 是否启用 0否 1是
Sort int `json:"sort" orm:"sort" description:"排序,小的在前"` // 排序,小的在前
ViewCount int `json:"viewCount" orm:"view_count" description:"浏览次数"` // 浏览次数
DownloadCount int `json:"downloadCount" orm:"download_count" description:"下载次数"` // 下载次数
Remark string `json:"remark" orm:"remark" description:"备注"` // 备注
CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:""` //
DeletedAt string `json:"deletedAt" orm:"deleted_at" description:""` //
}

View File

@ -0,0 +1,18 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
// WallpaperSource is the golang structure for table wallpaper_source.
type WallpaperSource struct {
Id uint64 `json:"id" orm:"id" description:""` //
Code string `json:"code" orm:"code" description:"平台编码 bing/picsum/unsplash/pexels/wallhaven"` // 平台编码 bing/picsum/unsplash/pexels/wallhaven
Name string `json:"name" orm:"name" description:"平台显示名"` // 平台显示名
Enabled int `json:"enabled" orm:"enabled" description:"是否启用 0否 1是"` // 是否启用 0否 1是
Sort int `json:"sort" orm:"sort" description:"排序,小的在前"` // 排序,小的在前
Config string `json:"config" orm:"config" description:"平台配置JSON(apiKey/defaultQuery等)"` // 平台配置JSON(apiKey/defaultQuery等)
Remark string `json:"remark" orm:"remark" description:"备注"` // 备注
CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:""` //
}

View File

@ -0,0 +1,368 @@
package wallpaper
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"image"
"math/rand"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// ---------------------------------------------------------------------------
// 查询
// ---------------------------------------------------------------------------
// LibraryList 分页查询自建图库。
func (s *service) LibraryList(ctx context.Context, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
page, size := normalizePage(q.Page, q.Size)
m := dao.Wallpaper.Ctx(ctx).Where(do.Wallpaper{Enabled: 1})
if q.Orientation != 0 {
m = m.Where(do.Wallpaper{Orientation: q.Orientation})
}
if q.Tag != "" {
m = m.WhereLike(dao.Wallpaper.Columns().Tags, "%"+q.Tag+"%")
}
if q.Query != "" {
kw := "%" + q.Query + "%"
m = m.Where("title LIKE ? OR tags LIKE ? OR category LIKE ?", kw, kw, kw)
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "统计图库总数失败")
}
rows, err := m.Clone().OrderAsc("sort").OrderDesc("id").Page(page, size).All()
if err != nil {
return nil, 0, gerror.Wrap(err, "查询图库列表失败")
}
out := make([]dto.WallpaperItem, 0, len(rows))
for _, r := range rows {
var e entity.Wallpaper
if err = r.Struct(&e); err != nil {
continue
}
out = append(out, s.toItem(&e))
}
return out, total, nil
}
// LibraryAdminList 后台列表:与前台的区别是**包含已停用的图**。
func (s *service) LibraryAdminList(ctx context.Context, q dto.WallpaperQuery, enabled int) ([]dto.WallpaperItem, int, error) {
page, size := normalizePage(q.Page, q.Size)
m := dao.Wallpaper.Ctx(ctx)
if enabled >= 0 {
m = m.Where(do.Wallpaper{Enabled: enabled})
}
if q.Orientation != 0 {
m = m.Where(do.Wallpaper{Orientation: q.Orientation})
}
if q.Query != "" {
kw := "%" + q.Query + "%"
m = m.Where("title LIKE ? OR tags LIKE ? OR category LIKE ?", kw, kw, kw)
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "统计图库总数失败")
}
rows, err := m.Clone().OrderAsc("sort").OrderDesc("id").Page(page, size).All()
if err != nil {
return nil, 0, gerror.Wrap(err, "查询图库列表失败")
}
out := make([]dto.WallpaperItem, 0, len(rows))
for _, r := range rows {
var e entity.Wallpaper
if err = r.Struct(&e); err != nil {
continue
}
item := s.toItem(&e)
item.Source = "admin" // 后台列表用不到来源字段,留个标识避免被当成前台数据
out = append(out, item)
}
return out, total, nil
}
// MineCount 对外暴露「已启用的自建图片数量」。
func (s *service) MineCount(ctx context.Context) (int, error) {
return s.libraryCount(ctx, 0)
}
// libraryCount 统计启用的图库数量orientation=0 表示不限)。
func (s *service) libraryCount(ctx context.Context, orientation int) (int, error) {
m := dao.Wallpaper.Ctx(ctx).Where(do.Wallpaper{Enabled: 1})
if orientation != 0 {
m = m.Where(do.Wallpaper{Orientation: orientation})
}
return m.Count()
}
// libraryRandom 在自建图库里随机取一张。
//
// 刻意不用 ORDER BY RAND():那会对全表排序,图库上万张以后明显变慢。
// 改为「先数总数 → 随机取偏移量」,无论多大都是常数级开销。
func (s *service) libraryRandom(ctx context.Context, orientation int) (*dto.WallpaperItem, error) {
total, err := s.libraryCount(ctx, orientation)
if err != nil {
return nil, gerror.Wrap(err, "统计图库总数失败")
}
if total == 0 {
return nil, gerror.New("图库里还没有图片")
}
m := dao.Wallpaper.Ctx(ctx).Where(do.Wallpaper{Enabled: 1})
if orientation != 0 {
m = m.Where(do.Wallpaper{Orientation: orientation})
}
row, err := m.OrderAsc("sort").OrderAsc("id").Limit(1).Offset(rand.Intn(total)).One()
if err != nil {
return nil, gerror.Wrap(err, "随机取图失败")
}
if row.IsEmpty() {
return nil, gerror.New("图库里还没有图片")
}
var e entity.Wallpaper
if err = row.Struct(&e); err != nil {
return nil, gerror.Wrap(err, "解析图库记录失败")
}
item := s.toItem(&e)
return &item, nil
}
// toItem 把库内记录转成统一输出形状,并补齐三档 URL。
//
// URL 拼接只在这里发生 —— 换 CDN、换域名、加签名都只改这一处。
func (s *service) toItem(e *entity.Wallpaper) dto.WallpaperItem {
return dto.WallpaperItem{
Id: g.NewVar(e.Id).String(),
Title: e.Title,
Width: e.Width,
Height: e.Height,
Orientation: e.Orientation,
ThumbUrl: s.store.URL(firstNonEmpty(e.ThumbPath, e.Path)),
PreviewUrl: s.store.URL(firstNonEmpty(e.PreviewPath, e.Path)),
FullUrl: s.store.URL(e.Path),
Source: MineSourceCode,
SourceName: "我的图库",
FromOpen: false,
Tags: e.Tags,
Category: e.Category,
Filesize: e.Filesize,
CreatedAt: e.CreatedAt,
}
}
// ---------------------------------------------------------------------------
// 上传
// ---------------------------------------------------------------------------
// Upload 处理一张上传的原图:校验 → 去重 → 落盘 → 生成派生图 → 入库。
//
// 去重按内容 MD5同一张图重复上传不会产生第二份文件直接返回已有记录
// (第二个返回值为 true。这对「批量传一个已经传过的文件夹」很实用。
func (s *service) Upload(ctx context.Context, filename string, data []byte, meta dto.WallpaperSaveInput) (*dto.WallpaperItem, bool, error) {
if len(data) == 0 {
return nil, false, gerror.New("上传内容为空")
}
if len(data) > MaxUploadBytes {
return nil, false, gerror.Newf("图片超过 %d MB 上限", MaxUploadBytes>>20)
}
sum := md5.Sum(data)
hash := hex.EncodeToString(sum[:])
// 已存在同内容的图:直接返回,不重复落盘
exist, err := dao.Wallpaper.Ctx(ctx).Where(do.Wallpaper{Hash: hash}).One()
if err != nil {
return nil, false, gerror.Wrap(err, "查询重复图片失败")
}
if !exist.IsEmpty() {
var e entity.Wallpaper
if err = exist.Struct(&e); err == nil {
item := s.toItem(&e)
return &item, true, nil
}
}
// 先只解元信息:能在完整解码前挡掉超大图与非法格式,避免把内存打爆
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false, gerror.New("无法识别的图片格式(仅支持 JPEG/PNG/GIF/WebP")
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, false, gerror.New("图片尺寸异常")
}
const maxEdgeLimit = 16384
if cfg.Width > maxEdgeLimit || cfg.Height > maxEdgeLimit {
return nil, false, gerror.Newf("图片尺寸过大(最大支持 %d 像素边长)", maxEdgeLimit)
}
// 完整解码一次,供生成缩略图与预览图
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, false, gerror.New("图片解码失败,文件可能已损坏")
}
origRel, err := s.store.SaveOriginal(hash, normalizeExt(format), data)
if err != nil {
return nil, false, err
}
thumbRel, err := s.store.SaveDerived(src, hash, "_t", thumbMaxEdge, thumbQuality)
if err != nil {
// 派生图失败就把已落盘的原图清掉,避免留下孤儿文件
_ = s.store.Remove(origRel)
return nil, false, err
}
previewRel, err := s.store.SaveDerived(src, hash, "_p", previewMaxEdge, previewQuality)
if err != nil {
_ = s.store.Remove(origRel, thumbRel)
return nil, false, err
}
if meta.Title == "" {
meta.Title = strings.TrimSuffix(filename, "."+strings.TrimPrefix(normalizeExt(format), "."))
}
id, err := dao.Wallpaper.Ctx(ctx).Data(do.Wallpaper{
Title: meta.Title,
Tags: meta.Tags,
Category: meta.Category,
Orientation: orientationOf(cfg.Width, cfg.Height),
Width: cfg.Width,
Height: cfg.Height,
Filesize: len(data),
Mime: formatToMime(format),
Path: origRel,
ThumbPath: thumbRel,
PreviewPath: previewRel,
Hash: hash,
Enabled: 1,
Sort: meta.Sort,
Remark: meta.Remark,
}).InsertAndGetId()
if err != nil {
_ = s.store.Remove(origRel, thumbRel, previewRel)
return nil, false, gerror.Wrap(err, "写入图库记录失败")
}
row, err := dao.Wallpaper.Ctx(ctx).WherePri(id).One()
if err != nil || row.IsEmpty() {
return nil, false, gerror.New("图片已入库,但读取记录失败")
}
var e entity.Wallpaper
if err = row.Struct(&e); err != nil {
return nil, false, gerror.Wrap(err, "解析新纪录失败")
}
item := s.toItem(&e)
return &item, false, nil
}
// SaveMeta 更新壁纸的可编辑元数据(不涉及文件本身)。
func (s *service) SaveMeta(ctx context.Context, in dto.WallpaperSaveInput) error {
if in.Id == 0 {
return gerror.New("缺少壁纸 ID")
}
_, err := dao.Wallpaper.Ctx(ctx).WherePri(in.Id).Data(do.Wallpaper{
Title: in.Title,
Tags: in.Tags,
Category: in.Category,
Enabled: in.Enabled,
Sort: in.Sort,
Remark: in.Remark,
}).Update()
if err != nil {
return gerror.Wrap(err, "保存壁纸信息失败")
}
return nil
}
// Delete 删除一条壁纸记录及其三份文件。
//
// 先删记录再删文件:即便文件删除失败(比如权限问题),也只是留下无用文件,
// 不会出现「记录还在但图已没了」的破图。
func (s *service) Delete(ctx context.Context, id uint64) error {
if id == 0 {
return gerror.New("缺少壁纸 ID")
}
row, err := dao.Wallpaper.Ctx(ctx).WherePri(id).One()
if err != nil {
return gerror.Wrap(err, "查询待删除壁纸失败")
}
if row.IsEmpty() {
return gerror.New("壁纸不存在或已被删除")
}
var e entity.Wallpaper
if err = row.Struct(&e); err != nil {
return gerror.Wrap(err, "解析待删除壁纸失败")
}
if _, err = dao.Wallpaper.Ctx(ctx).WherePri(id).Delete(); err != nil {
return gerror.Wrap(err, "删除壁纸记录失败")
}
if err = s.store.Remove(e.Path, e.ThumbPath, e.PreviewPath); err != nil {
g.Log().Warningf(ctx, "壁纸记录已删除,但文件清理失败(可手工清理): %v", err)
}
return nil
}
// Stat 图库概览统计。
func (s *service) Stat(ctx context.Context) (*dto.WallpaperStat, error) {
m := dao.Wallpaper.Ctx(ctx)
total, err := m.Clone().Count()
if err != nil {
return nil, gerror.Wrap(err, "统计总数失败")
}
enabled, err := m.Clone().Where(do.Wallpaper{Enabled: 1}).Count()
if err != nil {
return nil, gerror.Wrap(err, "统计启用数失败")
}
portrait, err := m.Clone().Where(do.Wallpaper{Enabled: 1, Orientation: 2}).Count()
if err != nil {
return nil, gerror.Wrap(err, "统计竖版数失败")
}
landscape, err := m.Clone().Where(do.Wallpaper{Enabled: 1, Orientation: 1}).Count()
if err != nil {
return nil, gerror.Wrap(err, "统计横版数失败")
}
// 原图占用空间SUM 返回的是单值,用 Value() 取(返回 *gvar.Var自动转 int64
var totalBytes int64
if v, verr := m.Clone().Fields("IFNULL(SUM(filesize),0)").Value(); verr == nil && v != nil {
totalBytes = v.Int64()
}
return &dto.WallpaperStat{
Total: total,
Enabled: enabled,
Disabled: total - enabled,
Portrait: portrait,
Landscape: landscape,
TotalBytes: totalBytes,
}, nil
}
// formatToMime 把 image.Decode 返回的格式名转成 MIME。
func formatToMime(format string) string {
switch strings.ToLower(format) {
case "jpeg", "jpg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "webp":
return "image/webp"
default:
return "application/octet-stream"
}
}

View File

@ -0,0 +1,218 @@
// Package wallpaper 提供壁纸模块的领域服务。
//
// 模块有两种来源,对外输出完全同构(见 dto.WallpaperItem
//
// ① 开源平台wallpaper_source 表配置 + provider_*.go 适配器)
// 后端只代理「元数据」,图片本体走各平台官方 CDN 直链 —— 零带宽消耗。
//
// ② 自建图库wallpaper 表 + 本机磁盘)
// 用户后台上传的真实图片,落盘后由 nginx 直出Go 服务不参与传图。
//
// 新增一个开源平台 = 写一个 provider_xxx.go + 在 allProviders() 里加一行,
// 表结构与前端都不用动。
package wallpaper
import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"service.xpcool.com/internal/model/dto"
)
// Provider 是单个开源壁纸平台的适配器。
//
// 实现约定:
// - 一律按 dto.WallpaperQuery 里的 Page/Size/Orientation 做归一化,
// 平台不支持的能力(比如 Bing 没有竖版、Picsum 不支持搜索)就地降级,
// 不要把平台差异抛给调用方;
// - 网络请求必须带超时(用 httpGetJSON 即可),单平台失败不能影响其它来源。
type Provider interface {
// Code 平台编码,与 wallpaper_source.code 一致。
Code() string
// Name 平台显示名(兜底用,正常取库里配置的名称)。
Name() string
// RequiresKey 是否必须配置 API Key 才能使用。
// 为 true 时config.apiKey 为空即视为「未配置」,前端置灰。
RequiresKey() bool
// List 拉取一批壁纸。
// total 为平台返回的总数,平台不给则返回 0表示「未知」而非「没有」
List(ctx context.Context, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) (items []dto.WallpaperItem, total int, err error)
}
// downloadTracker 是可选能力:某些平台(如 Unsplash要求下载前回调一次接口
// 既是许可要求也是给摄影师的统计。实现该接口的平台会在用户点下载时被调用。
type downloadTracker interface {
TrackDownload(ctx context.Context, cfg dto.WallpaperSourceConfig, id string) error
}
// allProviders 返回全部内置平台适配器。
//
// 刻意用显式列表而不是 init() 自注册:这样「到底支持哪些平台」一眼可见,
// 也不会出现 import 顺序导致的隐式副作用。
func allProviders() []Provider {
return []Provider{
&BingProvider{},
&PicsumProvider{},
&UnsplashProvider{},
&PexelsProvider{},
&WallhavenProvider{},
}
}
// providerMap 是 code → Provider 的索引,进程启动时由 buildProviderMap 构建。
var providerMap = buildProviderMap()
func buildProviderMap() map[string]Provider {
all := allProviders()
m := make(map[string]Provider, len(all))
for _, p := range all {
m[p.Code()] = p
}
return m
}
// GetProvider 按编码取平台适配器。
func GetProvider(code string) (Provider, bool) {
p, ok := providerMap[code]
return p, ok
}
// ListProviders 返回全部已注册的适配器(不区分是否启用)。
// 供后台「平台配置」页展示:库里没有记录的平台也要能列出来,否则无从开启。
func ListProviders() []Provider {
return allProviders()
}
// ---------------------------------------------------------------------------
// 缓存
// ---------------------------------------------------------------------------
// openCache 缓存开源平台的列表响应。
//
// 为什么要缓存:这些平台都有速率限制(免费额度通常每小时几十到几百次),
// 而壁纸站的访问是「多人反复刷新」的模式,不缓存会很快触发 429。
// 只缓存元数据(几 KB不缓存图片本体。
var openCache = gcache.New()
// Bing 是「每日一图」,本身一天只变一次,缓存久一点没问题;
// 其余平台用短缓存,兼顾新鲜度与配额。
const (
cacheTTLDaily = time.Hour
cacheTTLSearch = 10 * time.Minute
)
// cacheKey 组装缓存键(含来源、关键词、分页、朝向,任一不同即为不同结果)。
func cacheKey(code string, q dto.WallpaperQuery) string {
return fmt.Sprintf("wallpaper:%s:%s:%d:%d:%d", code, q.Query, q.Page, q.Size, q.Orientation)
}
// ---------------------------------------------------------------------------
// 统一的 JSON 拉取工具
// ---------------------------------------------------------------------------
// httpGetJSON 发起带超时的 GET 并把响应体解析为 gjson。
//
// headers 用于传各平台的鉴权头Unsplash 用 Authorization: Client-ID xxx
// Pexels 用 Authorization: xxx。任何非 2xx 都会转成带状态码的错误,
// 便于上层区分「没配额」和「平台挂了」。
func httpGetJSON(ctx context.Context, url string, headers map[string]string, timeout time.Duration) (*gjson.Json, error) {
c := g.Client().Timeout(timeout)
if len(headers) > 0 {
c = c.Header(headers)
}
resp, err := c.Get(ctx, url)
if err != nil {
return nil, err
}
defer func() { _ = resp.Close() }()
body := resp.ReadAllString()
if resp.StatusCode != 200 {
// 只截前 200 字符,避免把整页 HTML 错误页塞进日志
head := body
if len(head) > 200 {
head = head[:200]
}
return nil, fmt.Errorf("平台返回 HTTP %d: %s", resp.StatusCode, head)
}
j, err := gjson.DecodeToJson(body)
if err != nil {
return nil, fmt.Errorf("解析平台响应失败: %w", err)
}
return j, nil
}
// ---------------------------------------------------------------------------
// 小工具
// ---------------------------------------------------------------------------
// orientationOf 由宽高判断朝向,与库里的 orientation 字段语义一致。
func orientationOf(w, h int) int {
switch {
case w == 0 || h == 0:
return 0
case w > h:
return 1 // 横版
case w < h:
return 2 // 竖版
default:
return 3 // 方形
}
}
// matchOrientation 判断尺寸是否符合筛选条件。want=0 表示不限。
func matchOrientation(w, h, want int) bool {
if want == 0 {
return true
}
return orientationOf(w, h) == want
}
// normalizePage 兜底分页参数:页码从 1 起,每页 1~60 条。
// 上限 60 是权衡结果:太小翻页烦,太大容易撞平台配额且首屏变重。
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 {
size = 24
}
if size > 60 {
size = 60
}
return page, size
}
// baseOf 返回平台接口基址:配置里覆盖了就用配置的,否则用内置默认值。
//
// 为什么要留这个口子:部分平台在境内不可直连(实测 wallhaven.cc 遭 DNS 污染,
// 解析到境外无关 IP此时把 BaseUrl 指向自建反代即可继续使用,
// 不必改代码、也不必给整个服务挂全局代理。
func baseOf(cfgBase, fallback string) string {
if cfgBase != "" {
return strings.TrimRight(cfgBase, "/")
}
return fallback
}
// containsFold 不区分大小写的子串判断,用于本地关键词过滤。
// 关键词可能为空的情况由调用方先判断,这里不额外兜底。
func containsFold(s, sub string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
}
// clamp 把 v 夹在 [lo, hi] 区间内。
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}

View File

@ -0,0 +1,124 @@
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 ""
}

View File

@ -0,0 +1,125 @@
package wallpaper
import (
"context"
"os"
"testing"
"time"
"service.xpcool.com/internal/model/dto"
)
// 这一组测试会**真实访问各平台接口**,用于验证 JSON 路径解析与尺寸字段映射。
//
// 平台接口的字段名一旦变化,单元测试(打桩)是发现不了的,只有真连一次才知道。
// 因此保留它们,但默认跳过,只在需要时手动开启:
//
// WALLPAPER_LIVE=1 go test ./internal/service/wallpaper/ -run TestLive -v
//
// 需要 Key 的平台会自动跳过(除非另外提供了环境变量)。
func liveEnabled(t *testing.T) {
t.Helper()
if os.Getenv("WALLPAPER_LIVE") != "1" {
t.Skip("跳过联网测试:设置 WALLPAPER_LIVE=1 后运行")
}
}
// runLive 拉一页并检查基本约束。
func runLive(t *testing.T, p Provider, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) []dto.WallpaperItem {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
items, _, err := p.List(ctx, cfg, q)
if err != nil {
t.Fatalf("%s 拉取失败: %v", p.Code(), err)
}
if len(items) == 0 {
t.Fatalf("%s 未返回任何条目", p.Code())
}
for i, it := range items {
if it.ThumbUrl == "" || it.FullUrl == "" {
t.Errorf("%s 第 %d 条缺少图片地址: %+v", p.Code(), i, it)
}
if it.Width <= 0 || it.Height <= 0 {
t.Errorf("%s 第 %d 条尺寸异常: %dx%d", p.Code(), i, it.Width, it.Height)
}
if it.Orientation != orientationOf(it.Width, it.Height) {
t.Errorf("%s 第 %d 条朝向与尺寸不一致", p.Code(), i)
}
}
t.Logf("%s 取到 %d 条,示例:%s | 缩略 %s", p.Code(), len(items), items[0].Title, items[0].ThumbUrl)
return items
}
func TestLiveBing(t *testing.T) {
liveEnabled(t)
runLive(t, &BingProvider{}, dto.WallpaperSourceConfig{}, dto.WallpaperQuery{Page: 1, Size: 8})
}
func TestLivePicsum(t *testing.T) {
liveEnabled(t)
items := runLive(t, &PicsumProvider{}, dto.WallpaperSourceConfig{}, dto.WallpaperQuery{Page: 1, Size: 6})
// 顺带验证朝向过滤确实生效Picsum 没有服务端筛选,全靠本地过滤)
portrait := runLive(t, &PicsumProvider{}, dto.WallpaperSourceConfig{},
dto.WallpaperQuery{Page: 1, Size: 3, Orientation: 2})
for i, it := range portrait {
if it.Width >= it.Height {
t.Errorf("竖版筛选失效:第 %d 条是 %dx%d", i, it.Width, it.Height)
}
}
_ = items
}
func TestLiveUnsplash(t *testing.T) {
liveEnabled(t)
key := os.Getenv("UNSPLASH_ACCESS_KEY")
if key == "" {
t.Skip("未提供 UNSPLASH_ACCESS_KEY跳过")
}
runLive(t, &UnsplashProvider{}, dto.WallpaperSourceConfig{ApiKey: key},
dto.WallpaperQuery{Page: 1, Size: 5, Query: "mountain"})
}
func TestLivePexels(t *testing.T) {
liveEnabled(t)
key := os.Getenv("PEXELS_API_KEY")
if key == "" {
t.Skip("未提供 PEXELS_API_KEY跳过")
}
runLive(t, &PexelsProvider{}, dto.WallpaperSourceConfig{ApiKey: key},
dto.WallpaperQuery{Page: 1, Size: 5, Query: "mountain"})
}
func TestLiveWallhaven(t *testing.T) {
liveEnabled(t)
// Wallhaven 的 SFW 检索无需 Key故这里不带凭据也应能跑通
items := runLive(t, &WallhavenProvider{}, dto.WallpaperSourceConfig{},
dto.WallpaperQuery{Page: 1, Size: 5})
for i := range items {
if items[i].FullUrl == "" {
t.Errorf("第 %d 条缺少原图地址", i)
}
}
}
// TestLiveBingCopyrightSplit 校验版权串的拆分(纯函数,不联网也跑)。
func TestLiveBingCopyrightSplit(t *testing.T) {
cases := []struct {
in string
wantTitle string
wantAuthor string
}{
{"冰岛某瀑布 (© 张三/Getty Images)", "冰岛某瀑布", "张三/Getty Images"},
{"没有括号的版权串", "没有括号的版权串", ""},
{"", "", ""},
}
for _, c := range cases {
gotTitle, gotAuthor := splitBingCopyright(c.in)
if gotTitle != c.wantTitle || gotAuthor != c.wantAuthor {
t.Errorf("splitBingCopyright(%q) = (%q, %q),期望 (%q, %q)",
c.in, gotTitle, gotAuthor, c.wantTitle, c.wantAuthor)
}
}
}

View File

@ -0,0 +1,106 @@
package wallpaper
import (
"context"
"fmt"
"net/url"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"service.xpcool.com/internal/model/dto"
)
// PexelsProvider Pexels 官方 API。
//
// 特点:免费额度高、图片量大、接口直接给出多档尺寸且支持朝向筛选。
// 鉴权Authorization 头直接放 API Key注意与 Unsplash 不同,**没有** Client-ID 前缀)。
// 许可:可免费用于个人与商业用途,无需转存,直接用其 CDN 地址即可。
type PexelsProvider struct{}
const pexelsBase = "https://api.pexels.com/v1"
func (p *PexelsProvider) Code() string { return "pexels" }
func (p *PexelsProvider) Name() string { return "Pexels" }
func (p *PexelsProvider) RequiresKey() bool { return true }
func (p *PexelsProvider) List(ctx context.Context, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
if cfg.ApiKey == "" {
return nil, 0, fmt.Errorf("Pexels 未配置 API Key")
}
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
}
}
// 没有关键词时走「精选」端点,避免请求空 query 的搜索接口
base := baseOf(cfg.BaseUrl, pexelsBase)
var u string
if keyword := firstNonEmpty(q.Query, cfg.DefaultQuery); keyword != "" {
u = fmt.Sprintf("%s/search?query=%s&page=%d&per_page=%d", base, url.QueryEscape(keyword), page, size)
} else {
u = fmt.Sprintf("%s/curated?page=%d&per_page=%d", base, page, size)
}
if o := pexelsOrientation(q.Orientation); o != "" {
u += "&orientation=" + url.QueryEscape(o)
}
headers := map[string]string{"Authorization": cfg.ApiKey}
j, err := httpGetJSON(ctx, u, headers, 12*time.Second)
if err != nil {
return nil, 0, fmt.Errorf("获取 Pexels 列表失败: %w", err)
}
out := make([]dto.WallpaperItem, 0, size)
for _, raw := range j.Get("photos").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
}
out = append(out, dto.WallpaperItem{
Id: id,
Title: firstNonEmpty(item.Get("alt").String(), "Pexels "+id),
Width: w,
Height: h,
Orientation: orientationOf(w, h),
ThumbUrl: item.Get("src.medium").String(),
PreviewUrl: firstNonEmpty(item.Get("src.large2x").String(), item.Get("src.large").String()),
FullUrl: item.Get("src.original").String(),
Source: p.Code(),
SourceName: p.Name(),
FromOpen: true,
Author: item.Get("photographer").String(),
AuthorUrl: item.Get("photographer_url").String(),
PageUrl: item.Get("url").String(),
License: "Pexels License",
})
}
_ = openCache.Set(ctx, key, out, cacheTTLSearch)
return out, j.Get("total_results").Int(), nil
}
// pexelsOrientation 把内部朝向编码翻译成 Pexels 的取值。
// 注意 Pexels 用的是 square不是 Unsplash 的 squarish
func pexelsOrientation(o int) string {
switch o {
case 1:
return "landscape"
case 2:
return "portrait"
case 3:
return "square"
default:
return ""
}
}

View File

@ -0,0 +1,123 @@
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)
}

View File

@ -0,0 +1,144 @@
package wallpaper
import (
"context"
"fmt"
"net/url"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"service.xpcool.com/internal/model/dto"
)
// UnsplashProvider Unsplash 官方 API。
//
// 特点:画质与内容质量最好,支持关键词搜索与朝向筛选,接口给三档尺寸。
// 鉴权:需要在 unsplash.com/developers 申请 Access Key走 Client-ID 头。
//
// 许可要求(必须遵守,不是可选项):
// - 图片必须走 Unsplash 的 CDN 直链,**不得转存到自己服务器再分发**
// - 用户下载前要回调一次 /photos/{id}/download这是给摄影师的统计口径。
// 故本适配器实现了 downloadTracker。
type UnsplashProvider struct{}
const unsplashBase = "https://api.unsplash.com"
func (p *UnsplashProvider) Code() string { return "unsplash" }
func (p *UnsplashProvider) Name() string { return "Unsplash" }
func (p *UnsplashProvider) RequiresKey() bool { return true }
func (p *UnsplashProvider) List(ctx context.Context, cfg dto.WallpaperSourceConfig, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
if cfg.ApiKey == "" {
return nil, 0, fmt.Errorf("Unsplash 未配置 Access Key")
}
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
}
}
headers := map[string]string{"Authorization": "Client-ID " + cfg.ApiKey}
orient := unsplashOrientation(q.Orientation)
keyword := firstNonEmpty(q.Query, cfg.DefaultQuery)
base := baseOf(cfg.BaseUrl, unsplashBase)
var (
j *gjson.Json
total int
err error
items []interface{}
)
if keyword == "" {
// 没有关键词就走「最新照片」列表;该端点响应体是根数组。
u := fmt.Sprintf("%s/photos?page=%d&per_page=%d", base, page, size)
if orient != "" {
u += "&orientation=" + url.QueryEscape(orient)
}
if j, err = httpGetJSON(ctx, u, headers, 12*time.Second); err != nil {
return nil, 0, fmt.Errorf("获取 Unsplash 列表失败: %w", err)
}
// gjson.Json 没有 IsArray(),判数组要走 Var().IsSlice()
if !j.Var().IsSlice() {
return nil, 0, fmt.Errorf("Unsplash 响应格式异常:期望数组")
}
items = j.Array()
} else {
u := fmt.Sprintf("%s/search/photos?query=%s&page=%d&per_page=%d",
base, url.QueryEscape(keyword), page, size)
if orient != "" {
u += "&orientation=" + url.QueryEscape(orient)
}
if j, err = httpGetJSON(ctx, u, headers, 12*time.Second); err != nil {
return nil, 0, fmt.Errorf("搜索 Unsplash 失败: %w", err)
}
items = j.Get("results").Array()
total = j.Get("total").Int()
}
out := make([]dto.WallpaperItem, 0, len(items))
for _, raw := range items {
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
}
out = append(out, dto.WallpaperItem{
Id: id,
Title: firstNonEmpty(item.Get("alt_description").String(), item.Get("description").String(), "Unsplash "+id),
Width: w,
Height: h,
Orientation: orientationOf(w, h),
ThumbUrl: item.Get("urls.small").String(),
PreviewUrl: item.Get("urls.regular").String(),
FullUrl: firstNonEmpty(item.Get("urls.full").String(), item.Get("urls.regular").String()),
Source: p.Code(),
SourceName: p.Name(),
FromOpen: true,
Author: item.Get("user.name").String(),
AuthorUrl: item.Get("user.links.html").String(),
PageUrl: item.Get("links.html").String(),
License: "Unsplash License",
})
}
_ = openCache.Set(ctx, key, out, cacheTTLSearch)
return out, total, nil
}
// TrackDownload 回调 Unsplash 的下载端点。
// 这是其 API 指南的硬性要求;失败只记日志,不能阻断用户下载。
func (p *UnsplashProvider) TrackDownload(ctx context.Context, cfg dto.WallpaperSourceConfig, id string) error {
if cfg.ApiKey == "" || id == "" {
return nil
}
headers := map[string]string{"Authorization": "Client-ID " + cfg.ApiKey}
_, err := httpGetJSON(ctx, fmt.Sprintf("%s/photos/%s/download", baseOf(cfg.BaseUrl, unsplashBase), url.PathEscape(id)), headers, 8*time.Second)
if err != nil {
return fmt.Errorf("Unsplash 下载回调失败: %w", err)
}
return nil
}
// unsplashOrientation 把内部朝向编码翻译成 Unsplash 的取值。
// 无对应值(方形除外)时返回空串,表示不加该参数。
func unsplashOrientation(o int) string {
switch o {
case 1:
return "landscape"
case 2:
return "portrait"
case 3:
return "squarish"
default:
return ""
}
}

View File

@ -0,0 +1,134 @@
package wallpaper
import (
"context"
"fmt"
"net/url"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"service.xpcool.com/internal/model/dto"
)
// WallhavenProvider Wallhaven 壁纸站。
//
// 特点:专门做壁纸,分辨率与横竖版筛选最贴合本需求,缩略图与预览图质量都高。
//
// 鉴权:**纯 SFW 检索不需要 API Key**purity=100 匿名可用),故本适配器
// RequiresKey 为 false配置了 Key 之后才允许放开更高分级。
//
// 安全约束purity 默认锁死 SFW。只有「配置了 Key」且「后台显式改成
// sketchy/nsfw」两个条件同时满足才会放开过滤 —— 避免误把成人内容
// 推到公网首页。
type WallhavenProvider struct{}
const wallhavenBase = "https://wallhaven.cc/api/v1"
func (p *WallhavenProvider) Code() string { return "wallhaven" }
func (p *WallhavenProvider) Name() string { return "Wallhaven" }
func (p *WallhavenProvider) RequiresKey() bool { return false }
func (p *WallhavenProvider) 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
}
}
// 该站单页上限 24需要本地按朝向过滤时就直接取满免得过滤后不够一页。
perPage := size
if q.Orientation != 0 || perPage > 24 {
perPage = 24
}
params := url.Values{}
params.Set("page", fmt.Sprint(page))
params.Set("per_page", fmt.Sprint(perPage))
params.Set("purity", wallhavenPurity(cfg))
params.Set("categories", firstNonEmpty(cfg.Categories, "111"))
params.Set("atleast", "1920x1080")
// 有搜索词按相关度排,否则取月榜 —— 对壁纸站来说「热门」比「最新」更耐看
if keyword := firstNonEmpty(q.Query, cfg.DefaultQuery); keyword != "" {
params.Set("q", keyword)
params.Set("sorting", "relevance")
} else {
params.Set("sorting", "toplist")
params.Set("topRange", "1M")
}
if cfg.ApiKey != "" {
params.Set("apikey", cfg.ApiKey)
}
// 超时给得比其它平台短wallhaven.cc 在境内遭 DNS 污染(实测解析到境外无关 IP
// 直连必然超时。这里快速失败,避免一个不可达的平台拖慢整个来源列表的响应。
// 需要使用时把后台配置里的「接口地址」指向自建反代即可。
base := baseOf(cfg.BaseUrl, wallhavenBase)
j, err := httpGetJSON(ctx, base+"/search?"+params.Encode(), nil, 8*time.Second)
if err != nil {
return nil, 0, fmt.Errorf("获取 Wallhaven 列表失败(该站境内多不可直连,可在平台配置里填写反代地址): %w", err)
}
if errMsg := j.Get("error").String(); errMsg != "" {
return nil, 0, fmt.Errorf("Wallhaven 返回错误: %s", errMsg)
}
out := make([]dto.WallpaperItem, 0, size)
for _, raw := range j.Get("data").Array() {
item := gjson.New(raw)
id := item.Get("id").String()
if id == "" {
continue
}
w := item.Get("dimension_x").Int()
h := item.Get("dimension_y").Int()
if !matchOrientation(w, h, q.Orientation) {
continue
}
out = append(out, dto.WallpaperItem{
Id: id,
Title: firstNonEmpty(item.Get("source").String(), "Wallhaven "+id),
Width: w,
Height: h,
Orientation: orientationOf(w, h),
ThumbUrl: item.Get("thumbs.small").String(),
PreviewUrl: firstNonEmpty(item.Get("thumbs.original").String(), item.Get("thumbs.large").String()),
FullUrl: item.Get("path").String(),
Source: p.Code(),
SourceName: p.Name(),
FromOpen: true,
Author: "Wallhaven 用户投稿",
AuthorUrl: item.Get("url").String(),
PageUrl: item.Get("url").String(),
License: "Wallhaven版权归上传者请遵守站点条款",
})
if len(out) >= size {
break
}
}
_ = openCache.Set(ctx, key, out, cacheTTLSearch)
return out, j.Get("meta.total").Int(), nil
}
// wallhavenPurity 计算分级过滤参数。
//
// 编码规则:三位分别代表 SFW / Sketchy / NSFW1 为放开、0 为过滤。
// 默认 "100"(只要 SFW。未配置 Key 时无论后台怎么填都强制 "100"。
func wallhavenPurity(cfg dto.WallpaperSourceConfig) string {
if cfg.ApiKey == "" {
return "100"
}
switch cfg.Purity {
case "sketchy":
return "010"
case "nsfw":
return "001"
case "all":
return "111"
default:
return "100"
}
}

View File

@ -0,0 +1,218 @@
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
}

View File

@ -0,0 +1,195 @@
package wallpaper
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/jpeg"
_ "image/gif" // 注册 GIF 解码器
_ "image/png" // 注册 PNG 解码器
"os"
"path"
"path/filepath"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // 注册 WebP 解码器(只解不编,够用)
)
// 派生图的尺寸上限。
//
// 这两个数字直接决定服务器出口带宽的消耗方式,是本模块最重要的性能参数:
// - 缩略图 480px列表网格一屏可能要几十张必须小单张约 30~60KB
// - 预览图 1920px全屏展示用单张约 300~600KB一次只看一张可以接受
// - 原图:只在用户点「下载」时访问,不参与浏览。
//
// 5M 出口(约 625KB/s如果不分档、列表直接拉原图动辄 3~8MB
// 一屏就能把带宽占满十几秒。
const (
thumbMaxEdge = 480
previewMaxEdge = 1920
thumbQuality = 82
previewQuality = 88
// MaxUploadBytes 单张原图上限。4K 无损壁纸一般也不超过这个数。
MaxUploadBytes = 20 << 20 // 20MB
)
// Storage 负责自建图库的文件落盘与派生图生成。
type Storage struct {
root string // 存储根目录(绝对路径)
baseURL string // 对外访问前缀,如 https://xpcool.com/wallpaper
}
// NewStorage 从配置构造存储。
//
// 配置项(支持环境变量注入,见 cmd.injectEnv
//
// wallpaper.root 存储根目录,生产为 /data/www/wallpaper
// wallpaper.baseUrl 对外访问前缀,生产为 https://xpcool.com/wallpaper
func NewStorage(ctx context.Context) *Storage {
root := g.Cfg().MustGet(ctx, "wallpaper.root", "./data/wallpaper").String()
base := g.Cfg().MustGet(ctx, "wallpaper.baseUrl", "/wallpaper").String()
return &Storage{
root: root,
baseURL: strings.TrimRight(base, "/"),
}
}
// Root 返回存储根目录。
func (st *Storage) Root() string { return st.root }
// URL 把库里的相对路径拼成对外可访问的完整地址。
// 由 nginx 直出Go 服务不参与传图。
func (st *Storage) URL(rel string) string {
if rel == "" {
return ""
}
return st.baseURL + "/" + strings.TrimLeft(rel, "/")
}
// relPath 生成相对路径:按年月分目录,文件名用内容 MD5。
//
// 用 MD5 作文件名有两个好处:天然幂等(同一张图重复上传覆盖同一个文件,
// 不会堆垃圾URL 不可枚举。
func relPath(hash, ext string, t *gtime.Time) string {
return path.Join(t.Format("Y"), t.Format("m"), hash+ext)
}
// derivedRelPath 派生图的相对路径(在原名后加 _t / _p 后缀)。
func derivedRelPath(hash, suffix string, t *gtime.Time) string {
return path.Join(t.Format("Y"), t.Format("m"), hash+suffix+".jpg")
}
// SaveOriginal 把原图写到磁盘,返回相对路径。
func (st *Storage) SaveOriginal(hash, ext string, data []byte) (string, error) {
t := gtime.Now()
rel := relPath(hash, ext, t)
if err := st.writeFile(rel, data); err != nil {
return "", err
}
return rel, nil
}
// SaveDerived 生成并写入派生图(缩略图 / 预览图),返回相对路径。
//
// src 是已经解码好的原图。缩放走 CatmullRom质量与速度的平衡点
// 且**不放大**:原图本来就比上限小的时候直接沿用,避免糊图还白占空间。
func (st *Storage) SaveDerived(src image.Image, hash, suffix string, maxEdge, quality int) (string, error) {
resized := resizeTo(src, maxEdge)
t := gtime.Now()
rel := derivedRelPath(hash, suffix, t)
var b bytes.Buffer
if err := jpeg.Encode(&b, resized, &jpeg.Options{Quality: quality}); err != nil {
return "", fmt.Errorf("编码派生图失败: %w", err)
}
if err := st.writeFile(rel, b.Bytes()); err != nil {
return "", err
}
return rel, nil
}
// Remove 删除一组文件(原图 + 派生图)。文件不存在不算错误。
func (st *Storage) Remove(rels ...string) error {
for _, rel := range rels {
if rel == "" {
continue
}
full := filepath.Join(st.root, filepath.FromSlash(rel))
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除文件 %s 失败: %w", rel, err)
}
}
return nil
}
// writeFile 写文件,自动建目录。
func (st *Storage) writeFile(rel string, data []byte) error {
full := filepath.Join(st.root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
if err := os.WriteFile(full, data, 0o644); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// 图像处理
// ---------------------------------------------------------------------------
// resizeTo 等比缩放到「长边不超过 maxEdge」且不放大。
func resizeTo(src image.Image, maxEdge int) image.Image {
b := src.Bounds()
sw, sh := b.Dx(), b.Dy()
if sw <= 0 || sh <= 0 {
return src
}
longest := sw
if sh > longest {
longest = sh
}
if longest <= maxEdge {
return src
}
scale := float64(maxEdge) / float64(longest)
dw := int(float64(sw)*scale + 0.5)
dh := int(float64(sh)*scale + 0.5)
if dw < 1 {
dw = 1
}
if dh < 1 {
dh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
// 先铺白底再叠加原图若带透明通道PNG直接编成 JPEG 会变成黑底。
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst
}
// normalizeExt 把解码得到的格式名归一成文件扩展名。
// 未知格式一律按 .jpg 存(我们只接受能解码的格式,见 Upload 的校验)。
func normalizeExt(format string) string {
switch strings.ToLower(format) {
case "jpeg", "jpg":
return ".jpg"
case "png":
return ".png"
case "gif":
return ".gif"
case "webp":
return ".webp"
default:
return ".jpg"
}
}

View File

@ -0,0 +1,201 @@
package wallpaper
import (
"context"
"math/rand"
"github.com/gogf/gf/v2/errors/gerror"
"service.xpcool.com/internal/model/dto"
)
// MineSourceCode 自建图库在来源参数里的固定编码。
// 与平台编码区分开:它不是「某个开源平台」,而是用户自己上传的图。
const MineSourceCode = "mine"
// IWallpaper 壁纸领域服务接口。
type IWallpaper interface {
// ---- 前台open 组,免鉴权)----
Sources(ctx context.Context) ([]dto.WallpaperSourceInfo, error)
List(ctx context.Context, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error)
Random(ctx context.Context, sources []string, orientation int) (*dto.WallpaperItem, error)
TrackDownload(ctx context.Context, source, id string) error
// ---- 后台:自建图库 ----
// LibraryAdminList 与前台 LibraryList 的区别是「包含已停用的图」,
// 后台需要看得到停用项才能重新启用。
LibraryAdminList(ctx context.Context, q dto.WallpaperQuery, enabled int) ([]dto.WallpaperItem, int, error)
// Upload 的第二个返回值表示「内容重复,命中了已有图片(秒传)」,不是错误。
Upload(ctx context.Context, filename string, data []byte, meta dto.WallpaperSaveInput) (*dto.WallpaperItem, bool, error)
SaveMeta(ctx context.Context, in dto.WallpaperSaveInput) error
Delete(ctx context.Context, id uint64) error
Stat(ctx context.Context) (*dto.WallpaperStat, error)
// MineCount 返回自建图库中「已启用」的图片数量。
// 前台据此决定要不要显示「我的图库」入口。
MineCount(ctx context.Context) (int, error)
// ---- 后台:平台配置 ----
SaveSource(ctx context.Context, in dto.WallpaperSourceSaveInput) error
TestSource(ctx context.Context, code string) (string, error)
}
type service struct {
store *Storage
}
var localWallpaper IWallpaper
// New 构造壁纸服务。
func New(ctx context.Context) IWallpaper {
return &service{store: NewStorage(ctx)}
}
// Wallpaper 返回已注册的壁纸服务实现。
func Wallpaper() IWallpaper {
if localWallpaper == nil {
panic("Wallpaper 实现未注册")
}
return localWallpaper
}
// RegisterWallpaper 注册壁纸服务实现。
func RegisterWallpaper(i IWallpaper) { localWallpaper = i }
// List 按来源分派mine 走本地库,其余走对应的平台适配器。
//
// 对外的输出形状完全一致,前端不需要为不同来源写两套渲染逻辑。
func (s *service) List(ctx context.Context, q dto.WallpaperQuery) ([]dto.WallpaperItem, int, error) {
if q.Source == "" || q.Source == MineSourceCode {
return s.LibraryList(ctx, q)
}
p, ok := GetProvider(q.Source)
if !ok {
return nil, 0, gerror.Newf("未知的来源:%s", q.Source)
}
cfg, row, err := s.LoadSourceConfig(ctx, q.Source)
if err != nil {
return nil, 0, err
}
if row.Enabled != 1 {
return nil, 0, gerror.Newf("来源「%s」已停用", row.Name)
}
if p.RequiresKey() && cfg.ApiKey == "" {
return nil, 0, gerror.Newf("来源「%s」尚未配置 API Key", firstNonEmpty(row.Name, p.Name()))
}
return p.List(ctx, cfg, q)
}
// Random 跨来源随机取一张,供前台「换一张 / 每次进入都不同」使用。
//
// sources 为空表示「在所有可用来源里随机」;仅指定 mine 时只在自建图库里随机。
// 单个来源失败不视为整体失败 —— 换下一个可用来源重试,最多试 3 次。
// 壁纸站的核心体验就是「每次都能出图」,一个平台抽风不该让整页空白。
func (s *service) Random(ctx context.Context, sources []string, orientation int) (*dto.WallpaperItem, error) {
candidates := make([]string, 0, len(sources))
for _, code := range sources {
if code == "" {
continue
}
candidates = append(candidates, code)
}
if len(candidates) == 0 {
available, err := s.AvailableSourceCodes(ctx)
if err != nil {
return nil, err
}
candidates = append(candidates, available...)
// 自建图库有图的话也纳入候选(不占平台配额,优先给它一点权重)
if n, err := s.libraryCount(ctx, 0); err == nil && n > 0 {
candidates = append(candidates, MineSourceCode)
}
}
if len(candidates) == 0 {
return nil, gerror.New("当前没有任何可用的壁纸来源")
}
// 打乱候选顺序后依次尝试,避免总是卡在同一个坏来源上
rand.Shuffle(len(candidates), func(i, j int) {
candidates[i], candidates[j] = candidates[j], candidates[i]
})
tries := len(candidates)
if tries > 3 {
tries = 3
}
var lastErr error
for i := 0; i < tries; i++ {
item, err := s.randomOne(ctx, candidates[i], orientation)
if err == nil && item != nil {
return item, nil
}
if err != nil {
lastErr = err
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, gerror.New("没能取到任何壁纸,请稍后重试")
}
// randomOne 在单个来源里随机取一张。
func (s *service) randomOne(ctx context.Context, code string, orientation int) (*dto.WallpaperItem, error) {
if code == MineSourceCode {
return s.libraryRandom(ctx, orientation)
}
p, ok := GetProvider(code)
if !ok {
return nil, gerror.Newf("未知的来源:%s", code)
}
cfg, row, err := s.LoadSourceConfig(ctx, code)
if err != nil {
return nil, err
}
if row.Enabled != 1 || (p.RequiresKey() && cfg.ApiKey == "") {
return nil, gerror.Newf("来源「%s」不可用", firstNonEmpty(row.Name, p.Name()))
}
// 随机翻一页再随机取一张。平台的分页深度未知,故页数限制在前 3 页,
// 避免请求到空页Bing 那种只有两页的来源会直接被 normalizePage 兜住)。
q := dto.WallpaperQuery{
Page: 1 + rand.Intn(3),
Size: 24,
Orientation: orientation,
}
items, _, err := p.List(ctx, cfg, q)
if err != nil {
return nil, err
}
if len(items) == 0 && q.Page > 1 {
// 该页没有(多半是越界),退回第一页再试一次
items, _, err = p.List(ctx, cfg, dto.WallpaperQuery{Page: 1, Size: 24, Orientation: orientation})
if err != nil {
return nil, err
}
}
if len(items) == 0 {
return nil, gerror.Newf("来源「%s」没有符合条件的壁纸", firstNonEmpty(row.Name, p.Name()))
}
pick := items[rand.Intn(len(items))]
return &pick, nil
}
// TrackDownload 通知平台「这张图被下载了」。
// 只有实现了 downloadTracker 的平台需要(目前是 Unsplash属其 API 许可要求)。
// 失败只记日志、不阻断用户下载。
func (s *service) TrackDownload(ctx context.Context, source, id string) error {
p, ok := GetProvider(source)
if !ok {
return nil
}
tracker, ok := p.(downloadTracker)
if !ok {
return nil
}
cfg, _, err := s.LoadSourceConfig(ctx, source)
if err != nil {
return nil
}
return tracker.TrackDownload(ctx, cfg, id)
}

View File

@ -27,3 +27,8 @@ encrypt:
privateKey: "${ENCRYPT_PRIVATE_KEY}"
allowPlain: true
fullBody: false
# 壁纸图库本地默认落在仓库内的 data/wallpaperdata/ gitignore
# baseUrl 指向前端 dev server便于本地联调时 <img> 能直接取到图
wallpaper:
root: "${WALLPAPER_ROOT}"
baseUrl: "${WALLPAPER_BASE_URL}"

View File

@ -1,4 +1,6 @@
server: { address: ":10100", openapiPath: "/api.json", swaggerPath: "/swagger" }
# clientMaxBodySize 放宽到 64M壁纸原图上传单张可达 20MB且支持一次多选
# 沿用 gf 默认的 8M 会在网关层就被截断表现为"上传失败"但服务端看不到任何请求日志
server: { address: ":10100", openapiPath: "/api.json", swaggerPath: "/swagger", clientMaxBodySize: "64M" }
logger: { level: "warning", stdout: true }
database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_DB_DSN}" } }
jwt: { secret: "${JWT_SECRET}", accessExpire: "2h", refreshExpire: "720h" }
@ -8,3 +10,8 @@ bark: { baseUrl: "${BARK_BASE_URL}", deviceKey: "${BARK_DEVICE_KEY}", pushTime:
encrypt: { privateKey: "${ENCRYPT_PRIVATE_KEY}", allowPlain: false, fullBody: true }
# 安全日志上报令牌宿主机采集脚本携带环境变量 INTERNAL_TOKEN 注入
internalToken: "${INTERNAL_TOKEN}"
# 壁纸图库原图与派生图落盘到宿主机 /data/www/wallpaper
# nginx /wallpaper/ 直出Go 服务不参与传图
wallpaper:
root: "${WALLPAPER_ROOT}"
baseUrl: "${WALLPAPER_BASE_URL}"

View File

@ -0,0 +1,103 @@
-- 018_wallpaper.sql
-- 壁纸模块自建图库表 + 开源平台配置表 + 菜单/权限种子
--
-- 设计要点
-- 1) 自建图库wallpaper原图 + 缩略图 + 预览图三份路径
-- 原图只用于下载列表用缩略图480px全屏展示用预览图长边 1920
-- 这样 5M 出口带宽不会被大图打满三份文件都由 nginx 直出不过 Go 服务
-- 2) 开源平台wallpaper_source只存配置 API Key**不存图**
-- 图片本体走各平台官方 CDN 直链零带宽消耗
-- 加平台只需插一行数据 + 后端注册一个 Provider不改表结构
-- 3) 幂等CREATE TABLE IF NOT EXISTS菜单 INSERT ... ON DUPLICATE KEY UPDATE
-- 种子 INSERT ... ON DUPLICATE KEY UPDATE不覆盖用户已改的 enabled/config
--
-- 依赖service 主库admin_menu / admin_role_menu 已存在001_core.sql
-- ============ 1) 自建壁纸图库 ============
CREATE TABLE IF NOT EXISTS `wallpaper` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题',
`tags` VARCHAR(300) NOT NULL DEFAULT '' COMMENT '标签(英文逗号分隔)',
`category` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '分类(自由文本)',
`orientation` TINYINT NOT NULL DEFAULT 0 COMMENT '朝向 0未知 1横版 2竖版 3方形',
`width` INT NOT NULL DEFAULT 0 COMMENT '原图宽度(px)',
`height` INT NOT NULL DEFAULT 0 COMMENT '原图高度(px)',
`filesize` BIGINT NOT NULL DEFAULT 0 COMMENT '原图字节数',
`mime` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '原图MIME(image/jpeg等)',
`path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '原图相对路径(相对存储根)',
`thumb_path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '缩略图相对路径(480px)',
`preview_path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '预览图相对路径(长边1920)',
`hash` CHAR(32) NOT NULL DEFAULT '' COMMENT '原图MD5(秒传去重)',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用 0 1',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序小的在前',
`view_count` INT NOT NULL DEFAULT 0 COMMENT '浏览次数',
`download_count` INT NOT NULL DEFAULT 0 COMMENT '下载次数',
`remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_enabled_sort` (`enabled`, `sort`),
KEY `idx_orientation` (`orientation`),
KEY `idx_hash` (`hash`),
KEY `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自建壁纸图库';
-- ============ 2) 开源平台配置 ============
CREATE TABLE IF NOT EXISTS `wallpaper_source` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '平台编码 bing/picsum/unsplash/pexels/wallhaven',
`name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '平台显示名',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用 0 1',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序小的在前',
`config` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '平台配置JSON(apiKey/defaultQuery等)',
`remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='壁纸开源平台配置';
-- ============ 3) 平台种子 ============
-- bing / picsum 零配置即可用其余三个需要各自申请 API Key故默认停用
-- 后台填入 Key 并打开开关即可生效无需改代码无需重启
-- 注意ON DUPLICATE KEY UPDATE 只更新展示名与排序**不动 enabled config**
-- 避免重放本脚本时把用户已填的 Key 或已改的开关状态覆盖掉
INSERT INTO `wallpaper_source` (`code`, `name`, `enabled`, `sort`, `config`, `remark`) VALUES
('bing', 'Bing 每日一图', 1, 1, '', '微软必应每日壁纸无需 API Key每日更新一张'),
('picsum', 'Lorem Picsum', 1, 2, '', 'Lorem Picsum 随机摄影图无需 API Key'),
('unsplash', 'Unsplash', 0, 3, '', '需在 unsplash.com/developers 申请 Access Key'),
('pexels', 'Pexels', 0, 4, '', '需在 pexels.com/api 申请 API Key'),
('wallhaven', 'Wallhaven', 0, 5, '', '需在 wallhaven.cc 申请 API Key可按分辨率与横竖版筛选')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `sort` = VALUES(`sort`), `remark` = VALUES(`remark`);
-- ============ 4) 菜单壁纸中心 ============
-- id 段选择说明一级菜单已用 1/5/9/90/97/98/99故取 100
-- 其下子菜单取 1001/1002 API 权限 10011.. / 10021..与既有编号无冲突
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
(100, 0, '壁纸中心', 'mdi:image-multiple-outline', 1, '/wallpaper', '', 'wallpaper:center', 10, 1, 0, NOW(), NOW()),
(1001, 100, '图库管理', 'mdi:image-album', 1, 'library', 'wallpaper/library/index', 'wallpaper:library', 1, 1, 0, NOW(), NOW()),
(1002, 100, '平台配置', 'mdi:cloud-cog-outline', 1, 'source', 'wallpaper/source/index', 'wallpaper:source', 2, 1, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id), name=VALUES(name), icon=VALUES(icon), type=VALUES(type),
path=VALUES(path), component=VALUES(component), permission=VALUES(permission), sort=VALUES(sort),
status=VALUES(status), hidden=VALUES(hidden), deleted_at=NULL;
-- ============ 5) type=2 API 权限 ============
-- path 必须与实际路由完全一致中间件按方法+路径匹配 POST URL 参数
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
(10011, 1001, '查询', '', 2, 'POST /api/service/admin/wallpaper/list', '', 'wallpaper:library:list', 1, 1, 0, NOW(), NOW()),
(10012, 1001, '上传', '', 2, 'POST /api/service/admin/wallpaper/upload', '', 'wallpaper:library:upload', 2, 1, 0, NOW(), NOW()),
(10013, 1001, '编辑', '', 2, 'POST /api/service/admin/wallpaper/save', '', 'wallpaper:library:save', 3, 1, 0, NOW(), NOW()),
(10014, 1001, '删除', '', 2, 'POST /api/service/admin/wallpaper/delete', '', 'wallpaper:library:delete', 4, 1, 0, NOW(), NOW()),
(10015, 1001, '统计', '', 2, 'POST /api/service/admin/wallpaper/stats', '', 'wallpaper:library:stats', 5, 1, 1, NOW(), NOW()),
(10021, 1002, '查询', '', 2, 'POST /api/service/admin/wallpaper/source/list', '', 'wallpaper:source:list', 1, 1, 0, NOW(), NOW()),
(10022, 1002, '保存', '', 2, 'POST /api/service/admin/wallpaper/source/save', '', 'wallpaper:source:save', 2, 1, 0, NOW(), NOW()),
(10023, 1002, '测试', '', 2, 'POST /api/service/admin/wallpaper/source/test', '', 'wallpaper:source:test', 3, 1, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id), name=VALUES(name), type=VALUES(type),
path=VALUES(path), permission=VALUES(permission), sort=VALUES(sort), status=VALUES(status), hidden=VALUES(hidden), deleted_at=NULL;
-- ============ 6) 超管角色绑定 ============
-- role_id=1 为超级管理员 004_seed.sql / 009_admin_account_v2.sql
INSERT IGNORE INTO admin_role_menu (role_id, menu_id, created_at, updated_at)
SELECT 1, id, NOW(), NOW() FROM admin_menu
WHERE id IN (100, 1001, 1002, 10011, 10012, 10013, 10014, 10015, 10021, 10022, 10023);