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