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) }