Files
g82tt 68be41e7a2 初始提交:浏览器首页 MyHomePage 全栈项目
# 项目概述
个人浏览器首页导航应用,支持书签分类管理、搜索引擎快捷搜索、
必应每日壁纸轮播、前后端分离部署,适配 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 模式)
2026-07-05 05:09:56 +08:00

384 lines
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&amp;a=getAllCategoriesV2&amp;from=360chrome
/// 2. 按分类 ID 获取图片列表(每条 data 含 url + 6 个预设分辨率 + 原始分辨率)
/// http://wallpaper.apc.360.cn/index.php?c=WallPaper&amp;a=getAppsByCategory
/// &amp;cid={cid}&amp;start=0&amp;count=200&amp;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>分类列表缓存 TTL24h,启动再热一次)</summary>
private static readonly TimeSpan CategoryTtl = TimeSpan.FromHours(24);
/// <summary>图片池缓存 TTL12h</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);
// =====================================================================
// 公开 APIController 调用)
// =====================================================================
/// <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 差 &lt; 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();
}
}