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:
parent
ad4567fc01
commit
582485ba2b
236
api/wallpaper/wallpaper.go
Normal file
236
api/wallpaper/wallpaper.go
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
// Package wallpaper_v1 壁纸模块接口契约。
|
||||||
|
//
|
||||||
|
// 分成两组:
|
||||||
|
// - open 组(/api/service/open/wallpaper/*):给 xpcool.com 前台调用,免鉴权;
|
||||||
|
// - admin 组(/api/service/admin/wallpaper/*):后台管理,走 RBAC 权限。
|
||||||
|
//
|
||||||
|
// 规范(2026-08-27 起):全部 POST;URL 不含参数;入参一律 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
1
go.mod
@ -5,6 +5,7 @@ go 1.23.0
|
|||||||
require (
|
require (
|
||||||
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2
|
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2
|
||||||
github.com/gogf/gf/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
|
require github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -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=
|
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 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
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 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
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=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@ -6,7 +6,10 @@ gfcli:
|
|||||||
dao:
|
dao:
|
||||||
- link: "mysql:root:root123@tcp(127.0.0.1:3306)/service_xpcool_com"
|
- link: "mysql:root:root123@tcp(127.0.0.1:3306)/service_xpcool_com"
|
||||||
descriptionTag: true
|
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:
|
docker:
|
||||||
build: "-a amd64 -s linux -p temp -ew"
|
build: "-a amd64 -s linux -p temp -ew"
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import (
|
|||||||
recruitmentctl "service.xpcool.com/internal/controller/recruitment"
|
recruitmentctl "service.xpcool.com/internal/controller/recruitment"
|
||||||
serversecurityctl "service.xpcool.com/internal/controller/serversecurity"
|
serversecurityctl "service.xpcool.com/internal/controller/serversecurity"
|
||||||
userctl "service.xpcool.com/internal/controller/user"
|
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/crypto"
|
||||||
"service.xpcool.com/internal/library/jwt"
|
"service.xpcool.com/internal/library/jwt"
|
||||||
"service.xpcool.com/internal/middleware"
|
"service.xpcool.com/internal/middleware"
|
||||||
@ -40,6 +41,7 @@ import (
|
|||||||
recruitmentsvc "service.xpcool.com/internal/service/recruitment"
|
recruitmentsvc "service.xpcool.com/internal/service/recruitment"
|
||||||
serversecuritysvc "service.xpcool.com/internal/service/serversecurity"
|
serversecuritysvc "service.xpcool.com/internal/service/serversecurity"
|
||||||
userauth "service.xpcool.com/internal/service/user/auth"
|
userauth "service.xpcool.com/internal/service/user/auth"
|
||||||
|
wallpapersvc "service.xpcool.com/internal/service/wallpaper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 开发环境默认值:任何启动方式(GoLand / 命令行 / go run)漏注入环境变量时兜底,
|
// 开发环境默认值:任何启动方式(GoLand / 命令行 / go run)漏注入环境变量时兜底,
|
||||||
@ -84,6 +86,23 @@ func injectEnv(ctx context.Context) {
|
|||||||
_ = adapter.Set("jwt.secret", devDefaultJWTSecret)
|
_ = 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, "database.default.link", "DB_DSN")
|
||||||
setIfEmpty(adapter, "jwt.secret", "JWT_SECRET")
|
setIfEmpty(adapter, "jwt.secret", "JWT_SECRET")
|
||||||
// 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。
|
// 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。
|
||||||
@ -97,6 +116,10 @@ func injectEnv(ctx context.Context) {
|
|||||||
// ENCRYPT_FULL_BODY=true 启用全量请求/响应加密(生产);ENCRYPT_ALLOW_PLAIN=true 仅开发联调。
|
// ENCRYPT_FULL_BODY=true 启用全量请求/响应加密(生产);ENCRYPT_ALLOW_PLAIN=true 仅开发联调。
|
||||||
setIfEmpty(adapter, "encrypt.fullBody", "ENCRYPT_FULL_BODY")
|
setIfEmpty(adapter, "encrypt.fullBody", "ENCRYPT_FULL_BODY")
|
||||||
setIfEmpty(adapter, "encrypt.allowPlain", "ENCRYPT_ALLOW_PLAIN")
|
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 (
|
var (
|
||||||
@ -131,10 +154,13 @@ var (
|
|||||||
noticesvc.RegisterNotice(noticesvc.New())
|
noticesvc.RegisterNotice(noticesvc.New())
|
||||||
jobsvc.RegisterJob(jobsvc.New())
|
jobsvc.RegisterJob(jobsvc.New())
|
||||||
serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New())
|
serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New())
|
||||||
|
wallpapersvc.RegisterWallpaper(wallpapersvc.New(ctx))
|
||||||
s.Group("/api/service/open", func(group *ghttp.RouterGroup) {
|
s.Group("/api/service/open", func(group *ghttp.RouterGroup) {
|
||||||
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||||
group.Bind(openctl.New()) // 开放工具接口(前端调用,免鉴权)。
|
group.Bind(openctl.New()) // 开放工具接口(前端调用,免鉴权)。
|
||||||
group.Bind(serversecurityctl.NewReport()) // 安全日志上报(宿主机脚本,内部令牌校验)。
|
group.Bind(serversecurityctl.NewReport()) // 安全日志上报(宿主机脚本,内部令牌校验)。
|
||||||
|
// 壁纸前台读取:xpcool.com 是纯静态站,浏览器直连本服务取数据,故必须免鉴权。
|
||||||
|
group.Bind(wallpaperctl.NewOpen())
|
||||||
})
|
})
|
||||||
s.Group("/api/service/user", func(group *ghttp.RouterGroup) {
|
s.Group("/api/service/user", func(group *ghttp.RouterGroup) {
|
||||||
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||||
@ -163,6 +189,7 @@ var (
|
|||||||
protected.Bind(noticectl.New())
|
protected.Bind(noticectl.New())
|
||||||
protected.Bind(jobctl.New())
|
protected.Bind(jobctl.New())
|
||||||
protected.Bind(serversecurityctl.NewManage())
|
protected.Bind(serversecurityctl.NewManage())
|
||||||
|
protected.Bind(wallpaperctl.New())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// 启动自动任务调度器:招聘模块先把任务注册进来,再由 job 模块按 DB 配置统一调度。
|
// 启动自动任务调度器:招聘模块先把任务注册进来,再由 job 模块按 DB 配置统一调度。
|
||||||
|
|||||||
201
internal/controller/wallpaper/admin.go
Normal file
201
internal/controller/wallpaper/admin.go
Normal 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
|
||||||
|
}
|
||||||
66
internal/controller/wallpaper/controller.go
Normal file
66
internal/controller/wallpaper/controller.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// Package wallpaper 壁纸模块控制器。
|
||||||
|
//
|
||||||
|
// 分两个控制器:
|
||||||
|
// - Controller(admin):后台图库管理与平台配置,走 RBAC 权限;
|
||||||
|
// - OpenController(open):前台读取,免鉴权。
|
||||||
|
//
|
||||||
|
// 控制器只做「参数搬运 + 结构转换」,业务逻辑一律在 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/controller/wallpaper/open.go
Normal file
87
internal/controller/wallpaper/open.go
Normal 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
|
||||||
|
}
|
||||||
119
internal/dao/internal/wallpaper.go
Normal file
119
internal/dao/internal/wallpaper.go
Normal 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)
|
||||||
|
}
|
||||||
95
internal/dao/internal/wallpaper_source.go
Normal file
95
internal/dao/internal/wallpaper_source.go
Normal 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
22
internal/dao/wallpaper.go
Normal 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.
|
||||||
22
internal/dao/wallpaper_source.go
Normal file
22
internal/dao/wallpaper_source.go
Normal 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.
|
||||||
35
internal/model/do/wallpaper.go
Normal file
35
internal/model/do/wallpaper.go
Normal 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 //
|
||||||
|
}
|
||||||
23
internal/model/do/wallpaper_source.go
Normal file
23
internal/model/do/wallpaper_source.go
Normal 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 //
|
||||||
|
}
|
||||||
121
internal/model/dto/wallpaper.go
Normal file
121
internal/model/dto/wallpaper.go
Normal 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 分级过滤(Wallhaven):sfw / sketchy / nsfw。默认只放 sfw。
|
||||||
|
Purity string `json:"purity"`
|
||||||
|
// Categories 分类(Wallhaven):general/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"` // 原图占用字节
|
||||||
|
}
|
||||||
30
internal/model/entity/wallpaper.go
Normal file
30
internal/model/entity/wallpaper.go
Normal 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:""` //
|
||||||
|
}
|
||||||
18
internal/model/entity/wallpaper_source.go
Normal file
18
internal/model/entity/wallpaper_source.go
Normal 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:""` //
|
||||||
|
}
|
||||||
368
internal/service/wallpaper/library.go
Normal file
368
internal/service/wallpaper/library.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
218
internal/service/wallpaper/provider.go
Normal file
218
internal/service/wallpaper/provider.go
Normal 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
|
||||||
|
}
|
||||||
124
internal/service/wallpaper/provider_bing.go
Normal file
124
internal/service/wallpaper/provider_bing.go
Normal 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 ""
|
||||||
|
}
|
||||||
125
internal/service/wallpaper/provider_live_test.go
Normal file
125
internal/service/wallpaper/provider_live_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/service/wallpaper/provider_pexels.go
Normal file
106
internal/service/wallpaper/provider_pexels.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
123
internal/service/wallpaper/provider_picsum.go
Normal file
123
internal/service/wallpaper/provider_picsum.go
Normal 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)
|
||||||
|
}
|
||||||
144
internal/service/wallpaper/provider_unsplash.go
Normal file
144
internal/service/wallpaper/provider_unsplash.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/service/wallpaper/provider_wallhaven.go
Normal file
134
internal/service/wallpaper/provider_wallhaven.go
Normal 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 / NSFW,1 为放开、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"
|
||||||
|
}
|
||||||
|
}
|
||||||
218
internal/service/wallpaper/source.go
Normal file
218
internal/service/wallpaper/source.go
Normal 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
|
||||||
|
}
|
||||||
195
internal/service/wallpaper/storage.go
Normal file
195
internal/service/wallpaper/storage.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
201
internal/service/wallpaper/wallpaper.go
Normal file
201
internal/service/wallpaper/wallpaper.go
Normal 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)
|
||||||
|
}
|
||||||
@ -27,3 +27,8 @@ encrypt:
|
|||||||
privateKey: "${ENCRYPT_PRIVATE_KEY}"
|
privateKey: "${ENCRYPT_PRIVATE_KEY}"
|
||||||
allowPlain: true
|
allowPlain: true
|
||||||
fullBody: false
|
fullBody: false
|
||||||
|
# 壁纸图库。本地默认落在仓库内的 data/wallpaper(data/ 已 gitignore)。
|
||||||
|
# baseUrl 指向前端 dev server,便于本地联调时 <img> 能直接取到图。
|
||||||
|
wallpaper:
|
||||||
|
root: "${WALLPAPER_ROOT}"
|
||||||
|
baseUrl: "${WALLPAPER_BASE_URL}"
|
||||||
|
|||||||
@ -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 }
|
logger: { level: "warning", stdout: true }
|
||||||
database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_DB_DSN}" } }
|
database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_DB_DSN}" } }
|
||||||
jwt: { secret: "${JWT_SECRET}", accessExpire: "2h", refreshExpire: "720h" }
|
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 }
|
encrypt: { privateKey: "${ENCRYPT_PRIVATE_KEY}", allowPlain: false, fullBody: true }
|
||||||
# 安全日志上报令牌(宿主机采集脚本携带,环境变量 INTERNAL_TOKEN 注入)。
|
# 安全日志上报令牌(宿主机采集脚本携带,环境变量 INTERNAL_TOKEN 注入)。
|
||||||
internalToken: "${INTERNAL_TOKEN}"
|
internalToken: "${INTERNAL_TOKEN}"
|
||||||
|
# 壁纸图库:原图与派生图落盘到宿主机 /data/www/wallpaper,
|
||||||
|
# 由 nginx 的 /wallpaper/ 直出(Go 服务不参与传图)。
|
||||||
|
wallpaper:
|
||||||
|
root: "${WALLPAPER_ROOT}"
|
||||||
|
baseUrl: "${WALLPAPER_BASE_URL}"
|
||||||
|
|||||||
103
manifest/sql/018_wallpaper.sql
Normal file
103
manifest/sql/018_wallpaper.sql
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user