using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.Caching.Memory; using MyHomePage.Api.Common; using MyHomePage.Api.Models.Dtos; namespace MyHomePage.Api.Services; /// /// 360 在线壁纸代理服务(P34 + P34.1 修正)。 /// /// 数据来源(参考 360 chrome 壁纸公开接口): /// 1. 全部分类列表(含 order_num 排序字段) /// http://cdn.apc.360.cn/index.php?c=WallPaper&a=getAllCategoriesV2&from=360chrome /// 2. 按分类 ID 获取图片列表(每条 data 含 url + 6 个预设分辨率 + 原始分辨率) /// http://wallpaper.apc.360.cn/index.php?c=WallPaper&a=getAppsByCategory /// &cid={cid}&start=0&count=200&from=360chrome /// /// P34.1 修正(主人反馈 360 接口实际返回内容): /// - 分类数据有 order_num 字段(110/100/99/.../9),应按 order_num 降序展示(不再是字母排序) /// - 列表里 img_1600_900 / img_1440_900 / img_1366_768 / img_1280_800 / img_1280_1024 / img_1024_768 /// 是 360 官方为每张图准备的预设分辨率 URL,**优先用这些**(CDN 必定存在,画质 85) /// - 视口分辨率若不匹配任何 preset,兜底走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85 /// - 原图 url 字段用 bdr/__85/...,**不直接使用**(那是 bdr 压缩档,画质低) /// /// 后端缓存策略(避免每次前端访问都打 360 接口): /// - 分类列表:启动拉一次,缓存 24h /// - 图片池(每个 cid):缓存 200 张,TTL 12h /// - 立即刷新:手动调 RefreshAsync 清缓存重新拉,并立即返回一张随机图 /// public class WallpaperService { private readonly IHttpClientFactory _httpFactory; private readonly IMemoryCache _cache; private readonly ILogger _logger; private const string HttpClientName = nameof(WallpaperService); private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; /// 分类列表缓存 TTL(24h,启动再热一次) private static readonly TimeSpan CategoryTtl = TimeSpan.FromHours(24); /// 图片池缓存 TTL(12h) private static readonly TimeSpan PoolTtl = TimeSpan.FromHours(12); /// 每次拉取池子的最大张数(主人决策:200 张池子) private const int PoolCount = 200; /// 分类列表 URL private const string CategoryUrl = "http://cdn.apc.360.cn/index.php?c=WallPaper&a=getAllCategoriesV2&from=360chrome"; /// 分类图片 URL 模板({0}=cid, {1}=start, {2}=count) private const string AppsByCategoryUrlTemplate = "http://wallpaper.apc.360.cn/index.php?c=WallPaper&a=getAppsByCategory&cid={0}&start={1}&count={2}&from=360chrome"; /// 画质固定 85(与 360 官方预设 img_*_85 一致,主人截图原始数据证实) private const int DefaultQuality = 85; /// /// 360 官方为每张壁纸预设的固定分辨率 + 画质 85(P34.1 主人反馈真实接口结构)。 /// 视口尺寸进来后 → 在这 6 个 preset 里挑"宽高比例最接近且单边≥视口"的命中即用。 /// private static readonly (int W, int H)[] PresetResolutions = { (1600, 900), (1440, 900), (1366, 768), (1280, 800), (1280, 1024), (1024, 768) }; public WallpaperService( IHttpClientFactory httpFactory, IMemoryCache cache, ILogger logger) { _httpFactory = httpFactory; _cache = cache; _logger = logger; } /// 每次调用前从 factory 取一个新 HttpClient(短生命周期,由 factory 池化) private HttpClient NewClient() => _httpFactory.CreateClient(HttpClientName); // ===================================================================== // 公开 API(Controller 调用) // ===================================================================== /// 获取全部分类列表(24h 缓存)。失败时返回空集合(不抛)。 public async Task> GetCategoriesAsync(CancellationToken ct = default) { const string cacheKey = "wallpaper:categories"; if (_cache.TryGetValue>(cacheKey, out var cached) && cached is not null) { _logger.LogDebug("Wallpaper categories cache hit: {Count}", cached.Count); return cached; } try { using var client = NewClient(); using var req = new HttpRequestMessage(HttpMethod.Get, CategoryUrl); req.Headers.Add("User-Agent", UserAgent); req.Headers.Add("Referer", "http://chrome.360.cn/"); using var resp = await client.SendAsync(req, ct); if (!resp.IsSuccessStatusCode) { _logger.LogWarning("Wallpaper categories fetch failed: HTTP {Status}", (int)resp.StatusCode); return new List(); } var json = await resp.Content.ReadAsStringAsync(ct); // P34.1:按 360 官方 order_num 字段降序(之前是字母排序,错的) var list = ParseCategoryJson(json); _cache.Set(cacheKey, list, CategoryTtl); _logger.LogInformation("Wallpaper categories fetched: {Count}", list.Count); return list; } catch (Exception ex) { _logger.LogWarning(ex, "Wallpaper categories fetch error"); return new List(); } } /// /// 取一张随机壁纸 URL(按视口分辨率选最接近的 preset,无命中走 RewriteUrl 兜底)。 /// /// 分类 ID(空字符串 = 全部/推荐) /// 视口宽度(px) /// 视口高度(px) /// 取消令牌 public async Task GetRandomAsync(string cid, int w, int h, CancellationToken ct = default) { var pool = await GetOrFetchPoolAsync(cid ?? "", ct); if (pool.Count == 0) { _logger.LogWarning("Wallpaper pool empty for cid={Cid}", cid); return null; } return BuildRandom(pool, w, h); } /// 强制刷新图片池(立即切换按钮使用),并立即返回一张随机图。 public async Task RefreshAsync(string cid, int w, int h, CancellationToken ct = default) { cid ??= ""; _cache.Remove(PoolKey(cid)); _logger.LogInformation("Wallpaper pool refreshed: cid={Cid}", string.IsNullOrEmpty(cid) ? "(all)" : cid); var pool = await GetOrFetchPoolAsync(cid, ct); if (pool.Count == 0) return null; return BuildRandom(pool, w, h); } // ===================================================================== // 池子管理 // ===================================================================== private string PoolKey(string cid) => $"wallpaper:pool:{cid}"; /// 获取分类的图片池(缓存 12h)。无池时主动拉。 private async Task> GetOrFetchPoolAsync(string cid, CancellationToken ct) { var key = PoolKey(cid); if (_cache.TryGetValue>(key, out var cached) && cached is not null && cached.Count > 0) return cached; var fetched = await FetchPoolFrom360Async(cid, ct); if (fetched.Count > 0) _cache.Set(key, fetched, PoolTtl); return fetched; } /// 实际请求 360 接口拿 200 张 PoolItem 列表(含 6 个预设分辨率 URL) private async Task> FetchPoolFrom360Async(string cid, CancellationToken ct) { try { using var client = NewClient(); // 360 接口 cid 空字符串会返回空,这里用一个常见的「推荐」分类(36 = 4K专区)兜底 var effectiveCid = string.IsNullOrEmpty(cid) ? "36" : cid; var url = string.Format(AppsByCategoryUrlTemplate, effectiveCid, 0, PoolCount); using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Add("User-Agent", UserAgent); req.Headers.Add("Referer", "http://chrome.360.cn/"); using var resp = await client.SendAsync(req, ct); if (!resp.IsSuccessStatusCode) { _logger.LogWarning("Wallpaper pool fetch failed: cid={Cid} HTTP {Status}", effectiveCid, (int)resp.StatusCode); return new List(); } var json = await resp.Content.ReadAsStringAsync(ct); var items = ParseAppsJson(json); _logger.LogInformation("Wallpaper pool fetched: cid={Cid} count={Count}", effectiveCid, items.Count); return items; } catch (Exception ex) { _logger.LogWarning(ex, "Wallpaper pool fetch error: cid={Cid}", cid); return new List(); } } /// 从池中随机选 1 张 → 用 PickBestUrl 选最佳 URL → 构造 DTO private static WallpaperRandomDto BuildRandom(List pool, int w, int h) { if (pool.Count == 0) return null!; var pick = pool[Random.Shared.Next(pool.Count)]; var (finalUrl, usedPreset, fallback) = PickBestUrl(pick, w, h); return new WallpaperRandomDto { Url = finalUrl, OriginalUrl = pick.Url, Width = w, Height = h, // 扩展信息:P34.1 调试用,前端可忽略 Preset = usedPreset is null ? null : $"{usedPreset.Value.W}x{usedPreset.Value.H}", UsedFallback = fallback }; } /// /// 为给定 PoolItem 选最合适的 URL。 /// 选法:先按"宽高比 (aspect) 差最小"在 6 个 preset 里挑(要求 aspect 差 < 0.15), /// 比例匹配的候选里再按"单边最接近视口"选最佳。 /// 没有任何 preset 比例匹配 → 走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85 兜底(避免 preset 5:4 拉伸到 9:16 视口的变形)。 /// private static (string url, (int W, int H)? preset, bool fallback) PickBestUrl(PoolItem item, int w, int h) { if (item.Presets.Count > 0) { double targetAspect = (double)w / Math.Max(1, h); const double AspectTolerance = 0.15; // 比例差阈值(绝对值):超过即视为比例不匹配,走兜底 (int W, int H)? best = null; double bestAspectDelta = double.MaxValue; foreach (var preset in PresetResolutions) { if (!item.Presets.ContainsKey(preset)) continue; double presetAspect = (double)preset.W / Math.Max(1, preset.H); double aspectDelta = Math.Abs(presetAspect - targetAspect); if (aspectDelta >= AspectTolerance) continue; // 比例不匹配,跳过 // 在比例匹配的 preset 里选 aspect 最接近的 if (aspectDelta < bestAspectDelta) { bestAspectDelta = aspectDelta; best = preset; } } if (best is not null && item.Presets.TryGetValue(best.Value, out var presetUrl)) { return (presetUrl, best, false); } } // 兜底:RewriteUrl(quality=85) 自构 var fallbackUrl = RewriteUrl(item.Url, w, h, DefaultQuality); return (fallbackUrl, null, true); } /// /// 把 360 原始 URL 改造成指定分辨率/画质。 /// 例如:http://p8.qhimg.com/bdr/__85/t01e5f605262fb61fb4.jpg /// → http://p8.qhimg.com/bdm/1920_1080_85/t01e5f605262fb61fb4.jpg /// 只改路径段 bdr/__85 → bdm/{W}_{H}_{Q},主机保持原样(360 CDN p3-p19 等节点都支持 bdm 路径)。 /// private static string RewriteUrl(string original, int w, int h, int quality) { if (string.IsNullOrEmpty(original)) return original; return Regex.Replace(original, @"/bdr/__85/", $"/bdm/{w}_{h}_{quality}/"); } // ===================================================================== // JSON 解析 // ===================================================================== /// /// 解析分类 JSON:{ errno, errmsg, total, data: [{ id, name, order_num, tag, create_time }] }。 /// P34.1 修正:直接按 order_num int 降序排(不再字母排序),保留 18 个原始顺序。 /// private static List ParseCategoryJson(string json) { var result = new List(); if (string.IsNullOrEmpty(json)) return result; try { using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("data", out var dataEl) || dataEl.ValueKind != JsonValueKind.Array) return result; foreach (var item in dataEl.EnumerateArray()) { string id = item.TryGetProperty("id", out var idEl) ? idEl.ToString() : ""; string name = item.TryGetProperty("name", out var nameEl) ? nameEl.ToString() : ""; // order_num 是字符串(接口实际为 "110"),用 TryGetInt32 + TryGetString 兼容 int orderNum = 0; if (item.TryGetProperty("order_num", out var orderEl)) { if (orderEl.ValueKind == JsonValueKind.Number && orderEl.TryGetInt32(out var n)) orderNum = n; else if (int.TryParse(orderEl.ToString(), out var s)) orderNum = s; } if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name)) { result.Add(new WallpaperCategoryDto { Id = id, Name = name, OrderNum = orderNum }); } } // P34.1:按 360 官方 order_num 降序(4K 专区 110 → 文字控 9) result = result.OrderByDescending(c => c.OrderNum).ToList(); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Wallpaper] category parse error: {ex.Message}"); } return result; } /// /// 解析分类图片 JSON:每条 data 含 url + 6 个 img_xxx_xxx 预设分辨率。 /// P34.1 修正:不再只读 url,而是同时读 img_1600_900 / img_1440_900 / img_1366_768 / img_1280_800 / img_1280_1024 / img_1024_768。 /// private static List ParseAppsJson(string json) { var result = new List(); if (string.IsNullOrEmpty(json)) return result; try { using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("data", out var dataEl) || dataEl.ValueKind != JsonValueKind.Array) return result; // 预设字段名列表 string[] presetKeys = { "img_1600_900", "img_1440_900", "img_1366_768", "img_1280_800", "img_1280_1024", "img_1024_768" }; foreach (var item in dataEl.EnumerateArray()) { // 原始 url(兜底用) string url = item.TryGetProperty("url", out var urlEl) ? urlEl.ToString() : ""; if (string.IsNullOrEmpty(url)) continue; var presets = new Dictionary<(int, int), string>(); foreach (var key in presetKeys) { if (!item.TryGetProperty(key, out var valEl)) continue; var val = valEl.ToString(); if (string.IsNullOrEmpty(val)) continue; // key 形如 "img_1600_900" var parts = key.Substring(4).Split('_'); // ["1600","900"] if (parts.Length != 2) continue; if (!int.TryParse(parts[0], out var w)) continue; if (!int.TryParse(parts[1], out var h)) continue; presets[(w, h)] = val; } if (presets.Count == 0) continue; // 没有任何 preset → 跳过(不要单 url 兜底条目,避免池子里混入"无 preset"项) result.Add(new PoolItem { Url = url, Presets = presets }); } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Wallpaper] apps parse error: {ex.Message}"); } return result; } // ===================================================================== // 内部类型 // ===================================================================== /// 池子里的单条记录:原始 url + 6 个预设分辨率 URL 字典 private sealed class PoolItem { /// 原始 bdr/__85 url(兜底用) public string Url { get; set; } = string.Empty; /// 预设分辨率 → URL(例:{(1600,900): "http://.../bdm/1600_900_85/..."}) public Dictionary<(int W, int H), string> Presets { get; set; } = new(); } }