68be41e7a2
# 项目概述 个人浏览器首页导航应用,支持书签分类管理、搜索引擎快捷搜索、 必应每日壁纸轮播、前后端分离部署,适配 1Panel 服务器(Docker 模式)。 # 技术栈 - 前端:Vue 3 + TypeScript + Vite + Pinia + Capacitor(Android 打包) - 后端:.NET 8 + SqlSugar(多数据库) + SQLite/MySQL + Swashbuckle - 部署:1Panel 应用商店自定义应用(Docker Compose 模式) # 项目结构 - backend/ .NET 8 API 后端(8 个 Controller + 15 个 Service) - frontend/ Vue 3 前端(19 个组件 + 9 个 API 模块 + 5 个 Store) - docker/ Docker 部署文件(后端镜像 + Nginx 反代) - docs/ 部署手册(1Panel 实战版) - scripts/ E2E 测试脚本 # 已实现功能 - 书签管理:增删改查 + 树形分类 + 拖拽排序 + 主色自适应 - 搜索引擎:8 个内置引擎 + 自定义引擎 + favicon 自动抓取 - 必应壁纸:每日轮播 + 多分辨率自动选择 + 1.6MP 质量优先 - 全局设置:主题/行为/数据/工具 4 分类 + 跨设备同步 - 文件上传:图标/书签/通用(容器持久化 + 跨域 URL 拼接) - 同步:基于变更日志的设备间数据同步 - 跨域部署:前后端分离 + runtime config.json 无需重新编译 # 进度记录 - 已完成 P0~P52 共 53 个开发节点(详细见 说明文档.md) - 当前版本:v1.0 部署就绪 # 部署文档 - README.md:项目说明 + 快速开始 - 说明文档.md:完整开发进度(中文) - docs/DEPLOY.md:1Panel 部署手册(Docker 模式)
384 lines
17 KiB
C#
384 lines
17 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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 清缓存重新拉,并立即返回一张随机图
|
||
/// </summary>
|
||
public class WallpaperService
|
||
{
|
||
private readonly IHttpClientFactory _httpFactory;
|
||
private readonly IMemoryCache _cache;
|
||
private readonly ILogger<WallpaperService> _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";
|
||
|
||
/// <summary>分类列表缓存 TTL(24h,启动再热一次)</summary>
|
||
private static readonly TimeSpan CategoryTtl = TimeSpan.FromHours(24);
|
||
/// <summary>图片池缓存 TTL(12h)</summary>
|
||
private static readonly TimeSpan PoolTtl = TimeSpan.FromHours(12);
|
||
|
||
/// <summary>每次拉取池子的最大张数(主人决策:200 张池子)</summary>
|
||
private const int PoolCount = 200;
|
||
|
||
/// <summary>分类列表 URL</summary>
|
||
private const string CategoryUrl =
|
||
"http://cdn.apc.360.cn/index.php?c=WallPaper&a=getAllCategoriesV2&from=360chrome";
|
||
/// <summary>分类图片 URL 模板({0}=cid, {1}=start, {2}=count)</summary>
|
||
private const string AppsByCategoryUrlTemplate =
|
||
"http://wallpaper.apc.360.cn/index.php?c=WallPaper&a=getAppsByCategory&cid={0}&start={1}&count={2}&from=360chrome";
|
||
|
||
/// <summary>画质固定 85(与 360 官方预设 img_*_85 一致,主人截图原始数据证实)</summary>
|
||
private const int DefaultQuality = 85;
|
||
|
||
/// <summary>
|
||
/// 360 官方为每张壁纸预设的固定分辨率 + 画质 85(P34.1 主人反馈真实接口结构)。
|
||
/// 视口尺寸进来后 → 在这 6 个 preset 里挑"宽高比例最接近且单边≥视口"的命中即用。
|
||
/// </summary>
|
||
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<WallpaperService> logger)
|
||
{
|
||
_httpFactory = httpFactory;
|
||
_cache = cache;
|
||
_logger = logger;
|
||
}
|
||
|
||
/// <summary>每次调用前从 factory 取一个新 HttpClient(短生命周期,由 factory 池化)</summary>
|
||
private HttpClient NewClient() => _httpFactory.CreateClient(HttpClientName);
|
||
|
||
// =====================================================================
|
||
// 公开 API(Controller 调用)
|
||
// =====================================================================
|
||
|
||
/// <summary>获取全部分类列表(24h 缓存)。失败时返回空集合(不抛)。</summary>
|
||
public async Task<List<WallpaperCategoryDto>> GetCategoriesAsync(CancellationToken ct = default)
|
||
{
|
||
const string cacheKey = "wallpaper:categories";
|
||
if (_cache.TryGetValue<List<WallpaperCategoryDto>>(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<WallpaperCategoryDto>();
|
||
}
|
||
|
||
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<WallpaperCategoryDto>();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 取一张随机壁纸 URL(按视口分辨率选最接近的 preset,无命中走 RewriteUrl 兜底)。
|
||
/// </summary>
|
||
/// <param name="cid">分类 ID(空字符串 = 全部/推荐)</param>
|
||
/// <param name="w">视口宽度(px)</param>
|
||
/// <param name="h">视口高度(px)</param>
|
||
/// <param name="ct">取消令牌</param>
|
||
public async Task<WallpaperRandomDto?> 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);
|
||
}
|
||
|
||
/// <summary>强制刷新图片池(立即切换按钮使用),并立即返回一张随机图。</summary>
|
||
public async Task<WallpaperRandomDto?> 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}";
|
||
|
||
/// <summary>获取分类的图片池(缓存 12h)。无池时主动拉。</summary>
|
||
private async Task<List<PoolItem>> GetOrFetchPoolAsync(string cid, CancellationToken ct)
|
||
{
|
||
var key = PoolKey(cid);
|
||
if (_cache.TryGetValue<List<PoolItem>>(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;
|
||
}
|
||
|
||
/// <summary>实际请求 360 接口拿 200 张 PoolItem 列表(含 6 个预设分辨率 URL)</summary>
|
||
private async Task<List<PoolItem>> 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<PoolItem>();
|
||
}
|
||
|
||
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<PoolItem>();
|
||
}
|
||
}
|
||
|
||
/// <summary>从池中随机选 1 张 → 用 PickBestUrl 选最佳 URL → 构造 DTO</summary>
|
||
private static WallpaperRandomDto BuildRandom(List<PoolItem> 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
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 为给定 PoolItem 选最合适的 URL。
|
||
/// 选法:先按"宽高比 (aspect) 差最小"在 6 个 preset 里挑(要求 aspect 差 < 0.15),
|
||
/// 比例匹配的候选里再按"单边最接近视口"选最佳。
|
||
/// 没有任何 preset 比例匹配 → 走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85 兜底(避免 preset 5:4 拉伸到 9:16 视口的变形)。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把 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 路径)。
|
||
/// </summary>
|
||
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 解析
|
||
// =====================================================================
|
||
|
||
/// <summary>
|
||
/// 解析分类 JSON:{ errno, errmsg, total, data: [{ id, name, order_num, tag, create_time }] }。
|
||
/// P34.1 修正:直接按 order_num int 降序排(不再字母排序),保留 18 个原始顺序。
|
||
/// </summary>
|
||
private static List<WallpaperCategoryDto> ParseCategoryJson(string json)
|
||
{
|
||
var result = new List<WallpaperCategoryDto>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析分类图片 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。
|
||
/// </summary>
|
||
private static List<PoolItem> ParseAppsJson(string json)
|
||
{
|
||
var result = new List<PoolItem>();
|
||
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;
|
||
}
|
||
|
||
// =====================================================================
|
||
// 内部类型
|
||
// =====================================================================
|
||
|
||
/// <summary>池子里的单条记录:原始 url + 6 个预设分辨率 URL 字典</summary>
|
||
private sealed class PoolItem
|
||
{
|
||
/// <summary>原始 bdr/__85 url(兜底用)</summary>
|
||
public string Url { get; set; } = string.Empty;
|
||
/// <summary>预设分辨率 → URL(例:{(1600,900): "http://.../bdm/1600_900_85/..."})</summary>
|
||
public Dictionary<(int W, int H), string> Presets { get; set; } = new();
|
||
}
|
||
}
|