diff --git a/api/wallpaper/wallpaper.go b/api/wallpaper/wallpaper.go new file mode 100644 index 0000000..604cadb --- /dev/null +++ b/api/wallpaper/wallpaper.go @@ -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"` +} diff --git a/go.mod b/go.mod index 6bbc3a2..8733fd7 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 require ( github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2 github.com/gogf/gf/v2 v2.10.2 + golang.org/x/image v0.23.0 ) require github.com/go-sql-driver/mysql v1.7.1 // indirect diff --git a/go.sum b/go.sum index dba8ac4..dacd28f 100644 --- a/go.sum +++ b/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= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/hack/config.yaml b/hack/config.yaml index 093485a..a390eec 100644 --- a/hack/config.yaml +++ b/hack/config.yaml @@ -6,7 +6,10 @@ gfcli: dao: - link: "mysql:root:root123@tcp(127.0.0.1:3306)/service_xpcool_com" descriptionTag: true - tables: "house_community,house_building,house_listing,house_price_snapshot,house_transaction,house_facility,house_community_facility,house_school_district,house_preference,house_presale" + # 注意:tables 决定 `gf gen dao` 会生成/覆盖哪些表。 + # 「只加表不改成生表」等于给下一次 re-gen 埋雷——漏列的表其 entity/do/dao + # 会被重新生成的版本覆盖成不完整状态,故新增表必须同步补到这里。 + tables: "house_community,house_building,house_listing,house_price_snapshot,house_transaction,house_facility,house_community_facility,house_school_district,house_preference,house_presale,wallpaper,wallpaper_source" docker: build: "-a amd64 -s linux -p temp -ew" diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 8f28bdb..952664e 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -19,6 +19,7 @@ import ( recruitmentctl "service.xpcool.com/internal/controller/recruitment" serversecurityctl "service.xpcool.com/internal/controller/serversecurity" userctl "service.xpcool.com/internal/controller/user" + wallpaperctl "service.xpcool.com/internal/controller/wallpaper" "service.xpcool.com/internal/library/crypto" "service.xpcool.com/internal/library/jwt" "service.xpcool.com/internal/middleware" @@ -40,6 +41,7 @@ import ( recruitmentsvc "service.xpcool.com/internal/service/recruitment" serversecuritysvc "service.xpcool.com/internal/service/serversecurity" userauth "service.xpcool.com/internal/service/user/auth" + wallpapersvc "service.xpcool.com/internal/service/wallpaper" ) // 开发环境默认值:任何启动方式(GoLand / 命令行 / go run)漏注入环境变量时兜底, @@ -84,6 +86,23 @@ func injectEnv(ctx context.Context) { _ = adapter.Set("jwt.secret", devDefaultJWTSecret) } } + // 壁纸库路径同理:占位符没被注入时要回填,否则会真的创建一个名为 + // "${WALLPAPER_ROOT}" 的目录,图片全落到那里且难以察觉。 + // 生产兜底到 /data/www/wallpaper(由 nginx 直出),开发兜底到仓库内 data/。 + if v, _ := adapter.Get(ctx, "wallpaper.root"); v != nil && gstr.Contains(gconv.String(v), "${") { + if isProd { + _ = adapter.Set("wallpaper.root", "/data/www/wallpaper") + } else { + _ = adapter.Set("wallpaper.root", "./data/wallpaper") + } + } + if v, _ := adapter.Get(ctx, "wallpaper.baseUrl"); v != nil && gstr.Contains(gconv.String(v), "${") { + if isProd { + _ = adapter.Set("wallpaper.baseUrl", "https://xpcool.com/wallpaper") + } else { + _ = adapter.Set("wallpaper.baseUrl", "/wallpaper") + } + } setIfEmpty(adapter, "database.default.link", "DB_DSN") setIfEmpty(adapter, "jwt.secret", "JWT_SECRET") // 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。 @@ -97,6 +116,10 @@ func injectEnv(ctx context.Context) { // ENCRYPT_FULL_BODY=true 启用全量请求/响应加密(生产);ENCRYPT_ALLOW_PLAIN=true 仅开发联调。 setIfEmpty(adapter, "encrypt.fullBody", "ENCRYPT_FULL_BODY") setIfEmpty(adapter, "encrypt.allowPlain", "ENCRYPT_ALLOW_PLAIN") + // 壁纸图库:上传文件的落盘根目录与对外访问前缀。 + // 生产为 /data/www/wallpaper + https://xpcool.com/wallpaper(由 nginx 直出)。 + setIfEmpty(adapter, "wallpaper.root", "WALLPAPER_ROOT") + setIfEmpty(adapter, "wallpaper.baseUrl", "WALLPAPER_BASE_URL") } var ( @@ -131,10 +154,13 @@ var ( noticesvc.RegisterNotice(noticesvc.New()) jobsvc.RegisterJob(jobsvc.New()) serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New()) + wallpapersvc.RegisterWallpaper(wallpapersvc.New(ctx)) s.Group("/api/service/open", func(group *ghttp.RouterGroup) { group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse) group.Bind(openctl.New()) // 开放工具接口(前端调用,免鉴权)。 group.Bind(serversecurityctl.NewReport()) // 安全日志上报(宿主机脚本,内部令牌校验)。 + // 壁纸前台读取:xpcool.com 是纯静态站,浏览器直连本服务取数据,故必须免鉴权。 + group.Bind(wallpaperctl.NewOpen()) }) s.Group("/api/service/user", func(group *ghttp.RouterGroup) { group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse) @@ -163,6 +189,7 @@ var ( protected.Bind(noticectl.New()) protected.Bind(jobctl.New()) protected.Bind(serversecurityctl.NewManage()) + protected.Bind(wallpaperctl.New()) }) }) // 启动自动任务调度器:招聘模块先把任务注册进来,再由 job 模块按 DB 配置统一调度。 diff --git a/internal/controller/wallpaper/admin.go b/internal/controller/wallpaper/admin.go new file mode 100644 index 0000000..658b17d --- /dev/null +++ b/internal/controller/wallpaper/admin.go @@ -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 +} diff --git a/internal/controller/wallpaper/controller.go b/internal/controller/wallpaper/controller.go new file mode 100644 index 0000000..050b2d7 --- /dev/null +++ b/internal/controller/wallpaper/controller.go @@ -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, + } +} diff --git a/internal/controller/wallpaper/open.go b/internal/controller/wallpaper/open.go new file mode 100644 index 0000000..688e44c --- /dev/null +++ b/internal/controller/wallpaper/open.go @@ -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 +} diff --git a/internal/dao/internal/wallpaper.go b/internal/dao/internal/wallpaper.go new file mode 100644 index 0000000..e085a31 --- /dev/null +++ b/internal/dao/internal/wallpaper.go @@ -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) +} diff --git a/internal/dao/internal/wallpaper_source.go b/internal/dao/internal/wallpaper_source.go new file mode 100644 index 0000000..c6ecf6b --- /dev/null +++ b/internal/dao/internal/wallpaper_source.go @@ -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) +} diff --git a/internal/dao/wallpaper.go b/internal/dao/wallpaper.go new file mode 100644 index 0000000..3157aa2 --- /dev/null +++ b/internal/dao/wallpaper.go @@ -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. diff --git a/internal/dao/wallpaper_source.go b/internal/dao/wallpaper_source.go new file mode 100644 index 0000000..b563c78 --- /dev/null +++ b/internal/dao/wallpaper_source.go @@ -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. diff --git a/internal/model/do/wallpaper.go b/internal/model/do/wallpaper.go new file mode 100644 index 0000000..db5d5ac --- /dev/null +++ b/internal/model/do/wallpaper.go @@ -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 // +} diff --git a/internal/model/do/wallpaper_source.go b/internal/model/do/wallpaper_source.go new file mode 100644 index 0000000..d85e65f --- /dev/null +++ b/internal/model/do/wallpaper_source.go @@ -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 // +} diff --git a/internal/model/dto/wallpaper.go b/internal/model/dto/wallpaper.go new file mode 100644 index 0000000..89cd7e1 --- /dev/null +++ b/internal/model/dto/wallpaper.go @@ -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"` // 原图占用字节 +} diff --git a/internal/model/entity/wallpaper.go b/internal/model/entity/wallpaper.go new file mode 100644 index 0000000..fa8eb93 --- /dev/null +++ b/internal/model/entity/wallpaper.go @@ -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:""` // +} diff --git a/internal/model/entity/wallpaper_source.go b/internal/model/entity/wallpaper_source.go new file mode 100644 index 0000000..e47fe72 --- /dev/null +++ b/internal/model/entity/wallpaper_source.go @@ -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:""` // +} diff --git a/internal/service/wallpaper/library.go b/internal/service/wallpaper/library.go new file mode 100644 index 0000000..b203db3 --- /dev/null +++ b/internal/service/wallpaper/library.go @@ -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" + } +} diff --git a/internal/service/wallpaper/provider.go b/internal/service/wallpaper/provider.go new file mode 100644 index 0000000..ed746f5 --- /dev/null +++ b/internal/service/wallpaper/provider.go @@ -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 +} diff --git a/internal/service/wallpaper/provider_bing.go b/internal/service/wallpaper/provider_bing.go new file mode 100644 index 0000000..6d42405 --- /dev/null +++ b/internal/service/wallpaper/provider_bing.go @@ -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 "" +} diff --git a/internal/service/wallpaper/provider_live_test.go b/internal/service/wallpaper/provider_live_test.go new file mode 100644 index 0000000..8f87f53 --- /dev/null +++ b/internal/service/wallpaper/provider_live_test.go @@ -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) + } + } +} diff --git a/internal/service/wallpaper/provider_pexels.go b/internal/service/wallpaper/provider_pexels.go new file mode 100644 index 0000000..64b5a56 --- /dev/null +++ b/internal/service/wallpaper/provider_pexels.go @@ -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 "" + } +} diff --git a/internal/service/wallpaper/provider_picsum.go b/internal/service/wallpaper/provider_picsum.go new file mode 100644 index 0000000..4182b66 --- /dev/null +++ b/internal/service/wallpaper/provider_picsum.go @@ -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) +} diff --git a/internal/service/wallpaper/provider_unsplash.go b/internal/service/wallpaper/provider_unsplash.go new file mode 100644 index 0000000..18c6a93 --- /dev/null +++ b/internal/service/wallpaper/provider_unsplash.go @@ -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 "" + } +} diff --git a/internal/service/wallpaper/provider_wallhaven.go b/internal/service/wallpaper/provider_wallhaven.go new file mode 100644 index 0000000..02c775d --- /dev/null +++ b/internal/service/wallpaper/provider_wallhaven.go @@ -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" + } +} diff --git a/internal/service/wallpaper/source.go b/internal/service/wallpaper/source.go new file mode 100644 index 0000000..2992c2a --- /dev/null +++ b/internal/service/wallpaper/source.go @@ -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 +} diff --git a/internal/service/wallpaper/storage.go b/internal/service/wallpaper/storage.go new file mode 100644 index 0000000..f56598c --- /dev/null +++ b/internal/service/wallpaper/storage.go @@ -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" + } +} diff --git a/internal/service/wallpaper/wallpaper.go b/internal/service/wallpaper/wallpaper.go new file mode 100644 index 0000000..158ed02 --- /dev/null +++ b/internal/service/wallpaper/wallpaper.go @@ -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) +} diff --git a/manifest/config/config.dev.yaml b/manifest/config/config.dev.yaml index 04606c2..4d47ee4 100644 --- a/manifest/config/config.dev.yaml +++ b/manifest/config/config.dev.yaml @@ -27,3 +27,8 @@ encrypt: privateKey: "${ENCRYPT_PRIVATE_KEY}" allowPlain: true fullBody: false +# 壁纸图库。本地默认落在仓库内的 data/wallpaper(data/ 已 gitignore)。 +# baseUrl 指向前端 dev server,便于本地联调时 能直接取到图。 +wallpaper: + root: "${WALLPAPER_ROOT}" + baseUrl: "${WALLPAPER_BASE_URL}" diff --git a/manifest/config/config.prod.yaml b/manifest/config/config.prod.yaml index bef0346..f1ea36a 100644 --- a/manifest/config/config.prod.yaml +++ b/manifest/config/config.prod.yaml @@ -1,4 +1,6 @@ -server: { address: ":10100", openapiPath: "/api.json", swaggerPath: "/swagger" } +# clientMaxBodySize 放宽到 64M:壁纸原图上传单张可达 20MB,且支持一次多选, +# 沿用 gf 默认的 8M 会在网关层就被截断(表现为"上传失败"但服务端看不到任何请求日志)。 +server: { address: ":10100", openapiPath: "/api.json", swaggerPath: "/swagger", clientMaxBodySize: "64M" } logger: { level: "warning", stdout: true } database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_DB_DSN}" } } jwt: { secret: "${JWT_SECRET}", accessExpire: "2h", refreshExpire: "720h" } @@ -8,3 +10,8 @@ bark: { baseUrl: "${BARK_BASE_URL}", deviceKey: "${BARK_DEVICE_KEY}", pushTime: encrypt: { privateKey: "${ENCRYPT_PRIVATE_KEY}", allowPlain: false, fullBody: true } # 安全日志上报令牌(宿主机采集脚本携带,环境变量 INTERNAL_TOKEN 注入)。 internalToken: "${INTERNAL_TOKEN}" +# 壁纸图库:原图与派生图落盘到宿主机 /data/www/wallpaper, +# 由 nginx 的 /wallpaper/ 直出(Go 服务不参与传图)。 +wallpaper: + root: "${WALLPAPER_ROOT}" + baseUrl: "${WALLPAPER_BASE_URL}" diff --git a/manifest/sql/018_wallpaper.sql b/manifest/sql/018_wallpaper.sql new file mode 100644 index 0000000..53c20a9 --- /dev/null +++ b/manifest/sql/018_wallpaper.sql @@ -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);