初始提交:浏览器首页 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 模式)
This commit is contained in:
2026-07-05 05:09:56 +08:00
commit 68be41e7a2
129 changed files with 15900 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class BookmarkService : IBookmarkService
{
/// <summary>判定「用户未指定图标」的默认值集合。匹配其中之一则视为未指定,触发 favicon 自动抓取。</summary>
private static readonly HashSet<string> DefaultIconNames = new(StringComparer.OrdinalIgnoreCase)
{
"link", "globe", "bookmark", "", null! // null/empty 也算未指定
};
private readonly ISqlSugarClient _db;
private readonly SyncLogHelper _sync;
private readonly FaviconService _favicon;
public BookmarkService(ISqlSugarClient db, SyncLogHelper sync, FaviconService favicon)
{
_db = db;
_sync = sync;
_favicon = favicon;
}
/// <inheritdoc />
public async Task<List<BookmarkDto>> ListAsync(int? categoryId = null)
{
var query = _db.Queryable<Bookmark>().Where(b => !b.IsDeleted);
if (categoryId.HasValue)
query = query.Where(b => b.CategoryId == categoryId.Value);
var list = await query
.OrderBy(b => b.Sort)
.OrderBy(b => b.Id)
.ToListAsync();
return list.Select(ToDto).ToList();
}
/// <inheritdoc />
public async Task<BookmarkDto?> GetByIdAsync(int id)
{
var b = await _db.Queryable<Bookmark>().InSingleAsync(id);
return b is null || b.IsDeleted ? null : ToDto(b);
}
/// <inheritdoc />
public async Task<BookmarkDto> CreateAsync(BookmarkUpsertRequest request)
{
Validate(request);
// 校验分类存在
var catExists = await _db.Queryable<Category>().AnyAsync(c => c.Id == request.CategoryId);
if (!catExists) throw new BusinessException("分类不存在", 400);
var now = DateTime.UtcNow;
var entity = new Bookmark
{
CategoryId = request.CategoryId,
Title = request.Title.Trim(),
Url = request.Url.Trim(),
Description = request.Description?.Trim(),
Icon = request.Icon,
IconType = request.IconType ?? "lucide",
IconUrl = request.IconUrl,
ColorBg = NormalizeColor(request.ColorBg),
Sort = request.Sort,
IsDeleted = false,
CreatedAt = now,
UpdatedAt = now
};
entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync();
// P31:未指定图标时自动抓取网站 favicon(失败静默用默认,不影响主记录创建)
await MaybeFetchFaviconAsync(entity);
await _sync.WriteAsync("bookmark", entity.Id, "create");
return ToDto(entity);
}
/// <inheritdoc />
public async Task<BookmarkDto> UpdateAsync(int id, BookmarkUpsertRequest request)
{
Validate(request);
var entity = await _db.Queryable<Bookmark>().InSingleAsync(id)
?? throw new BusinessException("链接不存在", 404);
entity.CategoryId = request.CategoryId;
entity.Title = request.Title.Trim();
entity.Url = request.Url.Trim();
entity.Description = request.Description?.Trim();
entity.Icon = request.Icon;
entity.IconType = request.IconType ?? "lucide";
entity.IconUrl = request.IconUrl;
entity.ColorBg = NormalizeColor(request.ColorBg); // P28 修复:原代码漏了 ColorBg,导致 PUT 后仍是旧值
entity.Sort = request.Sort;
entity.UpdatedAt = DateTime.UtcNow;
await _db.Updateable(entity).ExecuteCommandAsync();
// P31:URL 变了 或 图标从「自定义」回到「未指定」时,重新触发 favicon 抓取
if (IsIconUnspecified(entity))
{
await MaybeFetchFaviconAsync(entity);
}
await _sync.WriteAsync("bookmark", id, "update");
return ToDto(entity);
}
/// <summary>
/// P31:判定链接是否「未指定图标」(即需要自动抓 favicon 的状态):
/// - iconUrl 为空(用户没上传图片)
/// - iconType 为 lucide 或 null(即非 image / 非 emoji
/// - icon 字段是默认值("link" / "globe" / "bookmark" / 空)
/// </summary>
private static bool IsIconUnspecified(Bookmark b)
{
if (!string.IsNullOrEmpty(b.IconUrl)) return false;
if (b.IconType == "image" || b.IconType == "emoji") return false;
var name = (b.Icon ?? "").Trim();
return DefaultIconNames.Contains(name);
}
/// <summary>
/// P31:抓取并写入 favicon。失败静默(不影响主流程)。
/// 成功后:entity.IconType = "favicon"entity.IconUrl = /uploads/yyyy/MM/dd/favicons/xxx.ext
/// </summary>
private async Task MaybeFetchFaviconAsync(Bookmark entity)
{
if (!IsIconUnspecified(entity)) return;
try
{
var iconUrl = await _favicon.FetchAndSaveAsync(entity.Url);
if (!string.IsNullOrEmpty(iconUrl))
{
entity.IconType = "favicon";
entity.IconUrl = iconUrl;
entity.Icon = null;
entity.UpdatedAt = DateTime.UtcNow;
await _db.Updateable(entity)
.UpdateColumns(it => new { it.IconType, it.IconUrl, it.Icon, it.UpdatedAt })
.ExecuteCommandAsync();
}
}
catch
{
// 静默吞掉异常(favicon 抓取失败不影响链接创建/更新)
}
}
/// <inheritdoc />
public async Task DeleteAsync(int id)
{
var entity = await _db.Queryable<Bookmark>().InSingleAsync(id)
?? throw new BusinessException("链接不存在", 404);
entity.IsDeleted = true;
entity.UpdatedAt = DateTime.UtcNow;
await _db.Updateable(entity).ExecuteCommandAsync();
await _sync.WriteAsync("bookmark", id, "delete");
}
private static void Validate(BookmarkUpsertRequest req)
{
if (string.IsNullOrWhiteSpace(req.Title)) throw new BusinessException("标题不能为空", 400);
if (string.IsNullOrWhiteSpace(req.Url)) throw new BusinessException("URL 不能为空", 400);
if (req.Title.Length > 128) throw new BusinessException("标题不能超过 128 字符", 400);
if (req.Url.Length > 512) throw new BusinessException("URL 过长", 400);
if (!Uri.TryCreate(req.Url, UriKind.Absolute, out _)) throw new BusinessException("URL 格式不正确", 400);
}
private static BookmarkDto ToDto(Bookmark b) => BookmarkDto.FromEntity(b);
/// <summary>
/// 规范化颜色:空串视为 null;仅保留 #hex / rgb(...) / hsl(...) 格式。无效则置 null。
/// 长度上限 32(够 rgb / hsl / 短 hex / 长 hex)。
/// </summary>
private static string? NormalizeColor(string? color)
{
if (string.IsNullOrWhiteSpace(color)) return null;
var c = color.Trim();
if (c.Length > 32) return null;
if (c.StartsWith('#') && (c.Length == 4 || c.Length == 7 || c.Length == 9)) return c;
if (c.StartsWith("rgb", StringComparison.OrdinalIgnoreCase) && c.EndsWith(')')) return c;
if (c.StartsWith("hsl", StringComparison.OrdinalIgnoreCase) && c.EndsWith(')')) return c;
return null;
}
}
+111
View File
@@ -0,0 +1,111 @@
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class CategoryService : ICategoryService
{
private readonly ISqlSugarClient _db;
private readonly SyncLogHelper _sync;
public CategoryService(ISqlSugarClient db, SyncLogHelper sync)
{
_db = db;
_sync = sync;
}
/// <inheritdoc />
public async Task<List<CategoryDto>> GetTreeAsync()
{
var all = await _db.Queryable<Category>()
.OrderBy(c => c.Sort)
.OrderBy(c => c.Id)
.ToListAsync();
return CategoryDto.BuildTree(all);
}
/// <inheritdoc />
public async Task<CategoryDto?> GetByIdAsync(int id)
{
var entity = await _db.Queryable<Category>().InSingleAsync(id);
return entity is null ? null : ToDto(entity);
}
/// <inheritdoc />
public async Task<CategoryDto> CreateAsync(CategoryUpsertRequest request)
{
Validate(request);
var now = DateTime.UtcNow;
var entity = new Category
{
ParentId = request.ParentId,
Name = request.Name.Trim(),
Icon = request.Icon,
Sort = request.Sort,
CreatedAt = now,
UpdatedAt = now
};
entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync();
await _sync.WriteAsync("category", entity.Id, "create");
return ToDto(entity);
}
/// <inheritdoc />
public async Task<CategoryDto> UpdateAsync(int id, CategoryUpsertRequest request)
{
Validate(request);
var entity = await _db.Queryable<Category>().InSingleAsync(id)
?? throw new BusinessException("分类不存在", 404);
entity.ParentId = request.ParentId;
entity.Name = request.Name.Trim();
entity.Icon = request.Icon;
entity.Sort = request.Sort;
entity.UpdatedAt = DateTime.UtcNow;
await _db.Updateable(entity).ExecuteCommandAsync();
await _sync.WriteAsync("category", id, "update");
return ToDto(entity);
}
/// <inheritdoc />
public async Task DeleteAsync(int id)
{
var entity = await _db.Queryable<Category>().InSingleAsync(id)
?? throw new BusinessException("分类不存在", 404);
// 如果是父分类,先检查是否有子分类 / 链接
if (entity.ParentId == 0)
{
var hasChildren = await _db.Queryable<Category>().AnyAsync(c => c.ParentId == id);
if (hasChildren) throw new BusinessException("请先删除子分类", 400);
}
var hasBookmarks = await _db.Queryable<Bookmark>().AnyAsync(b => b.CategoryId == id && !b.IsDeleted);
if (hasBookmarks) throw new BusinessException("该分类下仍有链接,请先删除链接", 400);
await _db.Deleteable<Category>(id).ExecuteCommandAsync();
await _sync.WriteAsync("category", id, "delete");
}
/// <summary>校验入参</summary>
private static void Validate(CategoryUpsertRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name)) throw new BusinessException("分类名称不能为空", 400);
if (req.Name.Length > 64) throw new BusinessException("分类名称不能超过 64 字符", 400);
}
private static CategoryDto ToDto(Category c) => new()
{
Id = c.Id,
ParentId = c.ParentId,
Name = c.Name,
Icon = c.Icon,
Sort = c.Sort,
CreatedAt = c.CreatedAt,
UpdatedAt = c.UpdatedAt
};
}
+454
View File
@@ -0,0 +1,454 @@
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using System.Web;
using Microsoft.Extensions.Caching.Memory;
using MyHomePage.Api.Common;
using MyHomePage.Api.Infrastructure.Configuration;
using Microsoft.Extensions.Options;
namespace MyHomePage.Api.Services;
/// <summary>
/// 自动抓取网站 favicon。
/// P31 主链路:BookmarkService.Create/Update 检测「未指定图标」时调用本服务:
/// 1. HTTP GET 目标页面(限制 5s / 1MBUser-Agent 模拟浏览器)
/// 2. 解析 HTML &lt;link rel="icon"&gt; / apple-touch-icon / shortcut icon
/// 3. 按优先级选最佳 iconapple-touch > sizes 最大 > /favicon.ico 兜底)
/// 4. 下载 icon 图片到 Upload/favicons/ 目录
/// 5. 返回前端可访问的 URL(保存到 bookmark.IconUrl + iconType='favicon'
/// SSRF 防护:拒绝内网 / 本地 / 链路本地地址。
/// 失败时返回 null(不抛异常),由调用方走默认图标。
/// </summary>
public class FaviconService
{
private readonly IUploadService _upload;
private readonly IMemoryCache _cache;
private readonly UploadOptions _uploadOptions;
private readonly ILogger<FaviconService> _logger;
/// <summary>缓存键前缀 + 缓存时长(同一 URL 24h 内不再重抓)</summary>
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24);
private const string CacheKeyPrefix = "favicon:";
/// <summary>UA 字符串:模拟常见浏览器,避免被部分站点拒绝</summary>
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>下载的 icon 大小上限(5MB</summary>
private const long MaxIconBytes = 5L * 1024 * 1024;
/// <summary>HttpClient 名字(与 Program.cs AddHttpClient(name) 对应)</summary>
private const string HttpClientName = nameof(FaviconService);
private readonly IHttpClientFactory _httpFactory;
public FaviconService(
IHttpClientFactory httpFactory,
IUploadService upload,
IMemoryCache cache,
IOptions<UploadOptions> uploadOptions,
ILogger<FaviconService> logger)
{
_httpFactory = httpFactory;
_upload = upload;
_cache = cache;
_uploadOptions = uploadOptions.Value;
_logger = logger;
}
/// <summary>每次调用前从 factory 取一个新 HttpClient(短生命周期,由 factory 池化)</summary>
private HttpClient NewClient() => _httpFactory.CreateClient(HttpClientName);
/// <summary>
/// 抓取 pageUrl 的 favicon 并保存到 upload 目录,返回前端可访问的 URL。
/// 任何环节失败均返回 null(不抛异常,由调用方静默用默认图标)。
/// </summary>
public async Task<string?> FetchAndSaveAsync(string pageUrl, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(pageUrl)) return null;
if (!Uri.TryCreate(pageUrl, UriKind.Absolute, out var pageUri)) return null;
if (pageUri.Scheme != Uri.UriSchemeHttp && pageUri.Scheme != Uri.UriSchemeHttps) return null;
var cacheKey = CacheKeyPrefix + pageUri.Host + pageUri.AbsolutePath;
if (_cache.TryGetValue<string?>(cacheKey, out var cached))
{
_logger.LogDebug("Favicon cache hit: {Url} → {Icon}", pageUrl, cached ?? "(null)");
return cached;
}
try
{
var iconUrl = await FetchIconUrlAsync(pageUri, ct);
if (string.IsNullOrEmpty(iconUrl)) { /* P51 临时:禁用负缓存以便重复请求能拿到新结果 CacheNull(cacheKey); */ return null; }
var saved = await DownloadAndSaveAsync(iconUrl, pageUri, ct);
if (saved == null) { /* P51 临时:禁用负缓存以便重复请求能拿到新结果 CacheNull(cacheKey); */ return null; }
_cache.Set(cacheKey, saved, CacheTtl);
_logger.LogInformation("Favicon fetched: {Page} → {Icon}", pageUrl, saved);
return saved;
}
catch (Exception ex)
{
// P51 修复:LogWarning → LogErrordocker logs 默认级别是 Information 看不到 warning 堆栈),
// 并附上 UploadOptions.Path 实际值,方便排查容器内 /uploads 权限 / 路径覆盖问题
_logger.LogError(ex,
"Favicon fetch failed: {Url} | UploadOptions.Path='{OptPath}' (env={Env})",
pageUrl, _uploadOptions.Path, Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "(default)");
return null;
}
}
private void CacheNull(string key) => _cache.Set(key, (string?)null, TimeSpan.FromMinutes(10));
/// <summary>
/// 主流程:抓 HTML → 解析 link → 选最佳 icon URL。
/// </summary>
private async Task<string?> FetchIconUrlAsync(Uri pageUri, CancellationToken ct)
{
// 1. GET 页面(限 1MB
var html = await FetchHtmlAsync(pageUri, ct);
if (string.IsNullOrEmpty(html)) return null;
// 2. 解析 link tags
var links = ParseIconLinks(html, pageUri);
// 3. 按优先级选最佳
if (links.Count == 0)
{
// 兜底:直接尝试 /favicon.ico
return new Uri(pageUri, "/favicon.ico").ToString();
}
// 优先级:apple-touch-icon > icon(type=image/* sizes 最大) > shortcut icon > 其他
var best = links
.OrderByDescending(l => l.Priority)
.ThenByDescending(l => l.Score)
.FirstOrDefault();
return best?.Url;
}
/// <summary>抓取页面 HTML(限 1MB5s 超时)</summary>
private async Task<string?> FetchHtmlAsync(Uri pageUri, CancellationToken ct)
{
if (await IsPrivateOrLocalhostAsync(pageUri, ct)) return null;
using var _http = NewClient();
using var req = new HttpRequestMessage(HttpMethod.Get, pageUri);
req.Headers.Add("User-Agent", UserAgent);
req.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
req.Headers.Add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
// P33:详细日志 — 让主人能看清楚拿到的 HTML 是什么(含 location 跳转到哪)
_logger.LogInformation("Favicon fetch HTML: {Url} → {Status} {ContentType} ({Len} bytes)",
pageUri, (int)resp.StatusCode, resp.Content.Headers.ContentType?.MediaType ?? "?",
resp.Content.Headers.ContentLength ?? -1);
if (!resp.IsSuccessStatusCode)
{
_logger.LogDebug("Favicon fetch: {Url} returned {Status}, skip", pageUri, resp.StatusCode);
return null;
}
// 限制 content-length
var contentLength = resp.Content.Headers.ContentLength;
if (contentLength.HasValue && contentLength.Value > 1024 * 1024) return null;
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
var buffer = new byte[1024 * 1024];
var total = 0;
int read;
while (total < buffer.Length && (read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), ct)) > 0)
{
total += read;
}
// 尝试解析为 HTML(先看 charset
var charset = resp.Content.Headers.ContentType?.CharSet ?? "utf-8";
string html;
try
{
html = System.Text.Encoding.GetEncoding(charset).GetString(buffer, 0, total);
}
catch
{
html = System.Text.Encoding.UTF8.GetString(buffer, 0, total);
}
// P33HTML 长度 + 是否含 favicon 关键字(方便定位"是否真的没找到")
var hasIconTag = html.Contains("rel=\"icon\"", StringComparison.OrdinalIgnoreCase)
|| html.Contains("rel='icon'", StringComparison.OrdinalIgnoreCase)
|| html.Contains("rel=\"alternate icon\"", StringComparison.OrdinalIgnoreCase);
_logger.LogDebug("Favicon HTML scan: {Url} len={Len} contains-icon-link={Has}",
pageUri, total, hasIconTag);
if (!hasIconTag)
{
// 截取 HTML 前 200 字符方便主人看是被什么页面拦了(如 FN Connect 反向代理页)
_logger.LogWarning("Favicon HTML has no <link rel=icon>: {Url} → first 200 chars: {Snippet}",
pageUri, html.Length > 0 ? html.Substring(0, Math.Min(200, html.Length)) : "(empty)");
}
return html;
}
/// <summary>
/// 解析 HTML 中的 favicon 链接。
/// P33 改进:
/// - 正则支持 rel / href 任意顺序(之前要求 rel 在前,对 href 在前的写法失败)
/// - priority 映射支持 `alternate icon` / `fluid-icon` 等包含 icon 关键字的 rel
/// - 同时解析 &lt;meta property="og:image"&gt; 作为兜底
/// - 加详细日志,方便定位"为什么没抓到"
/// </summary>
private List<IconLink> ParseIconLinks(string html, Uri baseUri)
{
var results = new List<IconLink>();
// ===== 第一步:解析 <link rel="..." href="..." [sizes] [type]> =====
// 用 .*? 懒匹配 rel/href 任意顺序;属性值允许 "..."/'...' 两种引号
var linkPattern = new Regex(
@"<link\b([^>]*?)/?>", // 整个 <link ... > 块(包括自闭合 />
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// P33 关键修复:属性名匹配前用 (?<![-\w]) 负向后行断言,
// 避免 `data-base-href` / `data-href` 等自定义 data-* 属性被误识别为 `href`。
// (之前 GitHub 真实 link 有 data-base-href,截断到下一引号,导致 favicon.svg 变成 favicon 然后 404
var attrPattern = new Regex(
@"(?<![-\w])(rel|href|size|sizes|type|as)\s*=\s*[""']([^""']*)[""']",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
foreach (Match linkMatch in linkPattern.Matches(html))
{
var block = linkMatch.Groups[1].Value;
string? rel = null, href = null, sizes = null, type = null;
foreach (Match a in attrPattern.Matches(block))
{
var name = a.Groups[1].Value.ToLowerInvariant();
var val = a.Groups[2].Value.Trim();
switch (name)
{
case "rel": rel = val; break;
case "href": href = val; break;
case "sizes": sizes = val; break;
case "type": type = val; break;
}
}
if (string.IsNullOrEmpty(rel) || string.IsNullOrEmpty(href)) continue;
if (href.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) continue;
var relLower = rel.ToLowerInvariant();
if (!relLower.Contains("icon")) continue;
if (relLower == "mask-icon") continue; // safari pinned tab mask, 不是图片
// mask-icon 之外只要含 icon 都算(含 "apple-touch-icon" / "shortcut icon" / "alternate icon" / "fluid-icon"
// 过滤掉非图片类型(极少出现但保险)
if (!string.IsNullOrEmpty(type) && !type.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && !type.Contains("icon"))
continue;
// 解析 sizes
int maxSize = 0;
if (!string.IsNullOrEmpty(sizes))
{
if (sizes.Trim().Equals("any", StringComparison.OrdinalIgnoreCase))
{
maxSize = 512; // any 通常是 svg/高分辨率
}
else
{
foreach (var s in sizes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
var parts = s.Split('x', 2);
if (parts.Length == 2 && int.TryParse(parts[0], out var w) && int.TryParse(parts[1], out var h))
{
var sz = Math.Max(w, h);
if (sz > maxSize) maxSize = sz;
}
}
}
}
// 解析绝对 URL
if (!Uri.TryCreate(baseUri, href, out var absoluteUri)) continue;
if (absoluteUri.Scheme != Uri.UriSchemeHttp && absoluteUri.Scheme != Uri.UriSchemeHttps) continue;
// P33 改进:根据 rel 包含的关键字判定 priority
int priority;
int score;
if (relLower.Contains("apple-touch"))
{
priority = 300;
score = maxSize > 0 ? maxSize : 180;
}
else if (relLower == "shortcut icon")
{
priority = 100;
score = maxSize;
}
else if (relLower == "icon")
{
priority = 200;
score = maxSize;
}
else if (relLower.Contains("icon"))
{
// 兜底:alternate icon / fluid-icon / icon-zzz 等
priority = 150;
score = maxSize;
}
else
{
priority = 50;
score = maxSize;
}
_logger.LogDebug("Favicon link candidate: rel={Rel} href={Href} sizes={Sizes} → priority={P} score={S}",
relLower, absoluteUri, sizes ?? "-", priority, score);
results.Add(new IconLink
{
Url = absoluteUri.ToString(),
Priority = priority,
Score = score
});
}
// ===== 第二步:兜底 <meta property="og:image" content="..."> =====
// 很多现代站点(特别是博客/文档站)有 og:image,作为 icon 兜底
var ogPattern = new Regex(
@"<meta\b[^>]*?\bproperty\s*=\s*[""']og:image[""'][^>]*?\bcontent\s*=\s*[""']([^""']+)[""']",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// 也匹配 content 在前的写法
var ogPatternAlt = new Regex(
@"<meta\b[^>]*?\bcontent\s*=\s*[""']([^""']+)[""'][^>]*?\bproperty\s*=\s*[""']og:image[""']",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
string? ogImage = null;
var ogMatch = ogPattern.Match(html);
if (ogMatch.Success) ogImage = ogMatch.Groups[1].Value;
else
{
var ogMatchAlt = ogPatternAlt.Match(html);
if (ogMatchAlt.Success) ogImage = ogMatchAlt.Groups[1].Value;
}
if (!string.IsNullOrEmpty(ogImage) && Uri.TryCreate(baseUri, ogImage, out var ogUri)
&& (ogUri.Scheme == Uri.UriSchemeHttp || ogUri.Scheme == Uri.UriSchemeHttps))
{
_logger.LogDebug("Favicon og:image fallback: {Url}", ogUri);
results.Add(new IconLink
{
Url = ogUri.ToString(),
Priority = 30, // 比 link 兜底还低,避免抢了真正的 favicon
Score = 0
});
}
return results;
}
/// <summary>下载 icon 图片并保存到 upload 目录</summary>
private async Task<string?> DownloadAndSaveAsync(string iconUrl, Uri pageUri, CancellationToken ct)
{
if (!Uri.TryCreate(iconUrl, UriKind.Absolute, out var iconUri)) return null;
if (iconUri.Scheme != Uri.UriSchemeHttp && iconUri.Scheme != Uri.UriSchemeHttps) return null;
if (await IsPrivateOrLocalhostAsync(iconUri, ct)) return null;
using var _http = NewClient();
using var req = new HttpRequestMessage(HttpMethod.Get, iconUri);
req.Headers.Add("User-Agent", UserAgent);
req.Headers.Add("Referer", pageUri.Scheme + "://" + pageUri.Host);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
if (!resp.IsSuccessStatusCode) return null;
// content-type 校验
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) &&
!contentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase))
{
return null;
}
// 限制 content-length
var contentLength = resp.Content.Headers.ContentLength;
if (contentLength.HasValue && contentLength.Value > MaxIconBytes) return null;
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
// 用 MemoryStream 缓冲以同时拿到 content-type
using var ms = new MemoryStream();
var buffer = new byte[81920];
long total = 0;
int read;
while (total < MaxIconBytes && (read = await stream.ReadAsync(buffer, 0, (int)Math.Min(buffer.Length, MaxIconBytes - total))) > 0)
{
ms.Write(buffer, 0, read);
total += read;
}
if (total == 0 || total >= MaxIconBytes) return null;
ms.Position = 0;
// 文件名:从 iconUrl 推断,最后一段
var fileName = Path.GetFileName(iconUri.AbsolutePath);
if (string.IsNullOrEmpty(fileName) || fileName == "/") fileName = "favicon";
var result = await _upload.SaveStreamAsync(ms, fileName, contentType, subDir: "favicons");
return result.Url;
}
/// <summary>SSRF 防护:解析域名 IP,拒绝内网/本地/链路本地</summary>
private async Task<bool> IsPrivateOrLocalhostAsync(Uri uri, CancellationToken ct)
{
try
{
// localhost 字面
if (uri.HostNameType == UriHostNameType.Basic)
{
if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true;
}
// 解析为 IP
IPAddress[] addresses;
try
{
addresses = await Dns.GetHostAddressesAsync(uri.Host, ct);
}
catch
{
return true; // 解析失败视为不安全
}
foreach (var ip in addresses)
{
if (IsPrivateOrLocalIp(ip)) return true;
}
return false;
}
catch
{
return true;
}
}
private static bool IsPrivateOrLocalIp(IPAddress ip)
{
if (IPAddress.IsLoopback(ip)) return true;
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = ip.GetAddressBytes();
// 10.0.0.0/8
if (bytes[0] == 10) return true;
// 172.16.0.0/12
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
// 192.168.0.0/16
if (bytes[0] == 192 && bytes[1] == 168) return true;
// 169.254.0.0/16 (link-local)
if (bytes[0] == 169 && bytes[1] == 254) return true;
// 0.0.0.0
if (bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0) return true;
}
return false;
}
private class IconLink
{
public string Url { get; set; } = string.Empty;
public int Priority { get; set; }
public int Score { get; set; }
}
}
+13
View File
@@ -0,0 +1,13 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>链接服务:按分类查询 + 软删。</summary>
public interface IBookmarkService
{
Task<List<BookmarkDto>> ListAsync(int? categoryId = null);
Task<BookmarkDto?> GetByIdAsync(int id);
Task<BookmarkDto> CreateAsync(BookmarkUpsertRequest request);
Task<BookmarkDto> UpdateAsync(int id, BookmarkUpsertRequest request);
Task DeleteAsync(int id);
}
+13
View File
@@ -0,0 +1,13 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>分类服务:支持二级树形结构。</summary>
public interface ICategoryService
{
Task<List<CategoryDto>> GetTreeAsync();
Task<CategoryDto?> GetByIdAsync(int id);
Task<CategoryDto> CreateAsync(CategoryUpsertRequest request);
Task<CategoryDto> UpdateAsync(int id, CategoryUpsertRequest request);
Task DeleteAsync(int id);
}
+14
View File
@@ -0,0 +1,14 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>搜索引擎服务:增删改 + 默认引擎切换(保证唯一)。</summary>
public interface ISearchEngineService
{
Task<List<SearchEngineDto>> ListAsync();
Task<SearchEngineDto?> GetByIdAsync(int id);
Task<SearchEngineDto> CreateAsync(SearchEngineUpsertRequest request);
Task<SearchEngineDto> UpdateAsync(int id, SearchEngineUpsertRequest request);
Task DeleteAsync(int id);
Task<SearchEngineDto> SetDefaultAsync(int id);
}
+10
View File
@@ -0,0 +1,10 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>设置服务:单行配置(Id=1),不存在则创建。</summary>
public interface ISettingService
{
Task<SettingDto> GetAsync();
Task<SettingDto> UpdateAsync(SettingUpdateRequest request);
}
+9
View File
@@ -0,0 +1,9 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>多端同步服务:基于 SyncLog 的增量同步 + 全量快照。</summary>
public interface ISyncService
{
Task<SyncChangesResponse> GetChangesAsync(DateTime? since);
}
+20
View File
@@ -0,0 +1,20 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>文件上传服务。</summary>
public interface IUploadService
{
/// <summary>保存浏览器上传的文件(IFormFile)。</summary>
Task<UploadResultDto> SaveAsync(IFormFile file);
/// <summary>保存任意来源的字节流(如抓取的 favicon)。</summary>
/// <param name="stream">数据流(由调用方负责释放)</param>
/// <param name="fileName">用于推断扩展名的原始文件名</param>
/// <param name="contentType">HTTP Content-Type(如 image/png</param>
/// <param name="subDir">可选子目录(如 "favicons"),用于逻辑分组</param>
Task<UploadResultDto> SaveStreamAsync(Stream stream, string fileName, string contentType, string? subDir = null);
/// <summary>确保上传根目录存在,返回根目录绝对路径。</summary>
string EnsureRoot();
}
+121
View File
@@ -0,0 +1,121 @@
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class SearchEngineService : ISearchEngineService
{
private readonly ISqlSugarClient _db;
private readonly SyncLogHelper _sync;
public SearchEngineService(ISqlSugarClient db, SyncLogHelper sync)
{
_db = db;
_sync = sync;
}
/// <inheritdoc />
public async Task<List<SearchEngineDto>> ListAsync()
{
var list = await _db.Queryable<SearchEngine>()
.OrderBy(e => e.Sort)
.OrderBy(e => e.Id)
.ToListAsync();
return list.Select(SearchEngineDto.FromEntity).ToList();
}
/// <inheritdoc />
public async Task<SearchEngineDto?> GetByIdAsync(int id)
{
var e = await _db.Queryable<SearchEngine>().InSingleAsync(id);
return e is null ? null : SearchEngineDto.FromEntity(e);
}
/// <inheritdoc />
public async Task<SearchEngineDto> CreateAsync(SearchEngineUpsertRequest request)
{
Validate(request);
var now = DateTime.UtcNow;
var entity = new SearchEngine
{
Name = request.Name.Trim(),
UrlTemplate = request.UrlTemplate.Trim(),
IconType = request.IconType,
Icon = request.Icon,
IconUrl = request.IconUrl,
ColorBg = request.ColorBg,
Sort = request.Sort,
IsDefault = request.IsDefault,
CreatedAt = now,
UpdatedAt = now
};
entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync();
if (entity.IsDefault) await ResetDefaultAsync(entity.Id);
await _sync.WriteAsync("search_engine", entity.Id, "create");
return SearchEngineDto.FromEntity(entity);
}
/// <inheritdoc />
public async Task<SearchEngineDto> UpdateAsync(int id, SearchEngineUpsertRequest request)
{
Validate(request);
var entity = await _db.Queryable<SearchEngine>().InSingleAsync(id)
?? throw new BusinessException("搜索引擎不存在", 404);
entity.Name = request.Name.Trim();
entity.UrlTemplate = request.UrlTemplate.Trim();
entity.IconType = request.IconType;
entity.Icon = request.Icon;
entity.IconUrl = request.IconUrl;
entity.ColorBg = request.ColorBg;
entity.Sort = request.Sort;
entity.IsDefault = request.IsDefault;
entity.UpdatedAt = DateTime.UtcNow;
await _db.Updateable(entity).ExecuteCommandAsync();
if (entity.IsDefault) await ResetDefaultAsync(entity.Id);
await _sync.WriteAsync("search_engine", id, "update");
return SearchEngineDto.FromEntity(entity);
}
/// <inheritdoc />
public async Task DeleteAsync(int id)
{
var entity = await _db.Queryable<SearchEngine>().InSingleAsync(id)
?? throw new BusinessException("搜索引擎不存在", 404);
await _db.Deleteable<SearchEngine>(id).ExecuteCommandAsync();
await _sync.WriteAsync("search_engine", id, "delete");
}
/// <inheritdoc />
public async Task<SearchEngineDto> SetDefaultAsync(int id)
{
var entity = await _db.Queryable<SearchEngine>().InSingleAsync(id)
?? throw new BusinessException("搜索引擎不存在", 404);
await ResetDefaultAsync(id);
entity.IsDefault = true;
entity.UpdatedAt = DateTime.UtcNow;
await _db.Updateable(entity).ExecuteCommandAsync();
await _sync.WriteAsync("search_engine", id, "update");
return SearchEngineDto.FromEntity(entity);
}
/// <summary>把其他引擎的 IsDefault 全部置为 false</summary>
private async Task ResetDefaultAsync(int keepId)
{
await _db.Updateable<SearchEngine>()
.SetColumns(e => e.IsDefault == false)
.Where(e => e.Id != keepId && e.IsDefault)
.ExecuteCommandAsync();
}
private static void Validate(SearchEngineUpsertRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name)) throw new BusinessException("名称不能为空", 400);
if (string.IsNullOrWhiteSpace(req.UrlTemplate)) throw new BusinessException("URL 模板不能为空", 400);
if (!req.UrlTemplate.Contains("{q}")) throw new BusinessException("URL 模板必须包含 {q} 占位符", 400);
}
}
+85
View File
@@ -0,0 +1,85 @@
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class SettingService : ISettingService
{
private const int DefaultId = 1;
private static readonly HashSet<string> AllowedThemeModes = new(StringComparer.OrdinalIgnoreCase) { "dark", "light", "auto" };
private static readonly HashSet<string> AllowedBackgroundTypes = new(StringComparer.OrdinalIgnoreCase) { "preset", "custom", "solid" };
// ===== P34 360 壁纸切换间隔合法值(分钟)=====
private static readonly HashSet<int> AllowedWallpaperIntervals = new() { 0, 1, 5, 15, 30, 60 };
private readonly ISqlSugarClient _db;
private readonly SyncLogHelper _sync;
public SettingService(ISqlSugarClient db, SyncLogHelper sync)
{
_db = db;
_sync = sync;
}
/// <inheritdoc />
public async Task<SettingDto> GetAsync()
{
var entity = await _db.Queryable<Setting>().InSingleAsync(DefaultId);
if (entity is null)
{
// 兜底:写入默认值
entity = new Setting { Id = DefaultId };
await _db.Insertable(entity).ExecuteCommandAsync();
}
return ToDto(entity);
}
/// <inheritdoc />
public async Task<SettingDto> UpdateAsync(SettingUpdateRequest request)
{
var entity = await _db.Queryable<Setting>().InSingleAsync(DefaultId);
if (entity is null)
{
entity = new Setting { Id = DefaultId };
await _db.Insertable(entity).ExecuteCommandAsync();
}
if (!string.IsNullOrEmpty(request.ThemeMode))
{
if (!AllowedThemeModes.Contains(request.ThemeMode)) throw new BusinessException("不支持的主题模式", 400);
entity.ThemeMode = request.ThemeMode;
}
if (!string.IsNullOrEmpty(request.AccentColor))
{
if (request.AccentColor.Length > 16) throw new BusinessException("主色调格式错误", 400);
entity.AccentColor = request.AccentColor;
}
if (request.BackgroundImage is not null) entity.BackgroundImage = request.BackgroundImage;
if (!string.IsNullOrEmpty(request.BackgroundType))
{
if (!AllowedBackgroundTypes.Contains(request.BackgroundType)) throw new BusinessException("不支持的背景类型", 400);
entity.BackgroundType = request.BackgroundType;
}
if (request.OpenLinksInNewTab.HasValue) entity.OpenLinksInNewTab = request.OpenLinksInNewTab.Value ? 1 : 0;
if (request.OpenSearchInNewTab.HasValue) entity.OpenSearchInNewTab = request.OpenSearchInNewTab.Value ? 1 : 0;
// ===== P34 360 壁纸模式 =====
if (request.WallpaperEnabled.HasValue) entity.WallpaperEnabled = request.WallpaperEnabled.Value ? 1 : 0;
if (request.WallpaperCategoryId is not null) entity.WallpaperCategoryId = request.WallpaperCategoryId;
if (request.WallpaperInterval.HasValue)
{
if (!AllowedWallpaperIntervals.Contains(request.WallpaperInterval.Value))
throw new BusinessException("不支持的壁纸切换间隔(允许:0/1/5/15/30/60 分钟)", 400);
entity.WallpaperInterval = request.WallpaperInterval.Value;
}
entity.UpdatedAt = DateTime.UtcNow;
await _db.Updateable(entity).ExecuteCommandAsync();
await _sync.WriteAsync("setting", entity.Id, "update");
return ToDto(entity);
}
private static SettingDto ToDto(Setting s) => SettingDto.FromEntity(s);}
+25
View File
@@ -0,0 +1,25 @@
using MyHomePage.Api.Models.Entities;
using MyHomePage.Api.Infrastructure.Database;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <summary>同步日志写入助手:增删改实体时统一调用。</summary>
public class SyncLogHelper
{
private readonly ISqlSugarClient _db;
public SyncLogHelper(ISqlSugarClient db) => _db = db;
/// <summary>写入一条同步日志</summary>
public Task WriteAsync(string entityType, int entityId, string operation)
{
return _db.Insertable(new SyncLog
{
EntityType = entityType,
EntityId = entityId,
Operation = operation,
Timestamp = DateTime.UtcNow
}).ExecuteCommandAsync();
}
}
+60
View File
@@ -0,0 +1,60 @@
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class SyncService : ISyncService
{
private readonly ISqlSugarClient _db;
public SyncService(ISqlSugarClient db) => _db = db;
/// <inheritdoc />
public async Task<SyncChangesResponse> GetChangesAsync(DateTime? since)
{
// 1. 增量变更记录
var changesQuery = _db.Queryable<SyncLog>().OrderBy(s => s.Id);
if (since.HasValue) changesQuery = changesQuery.Where(s => s.Timestamp > since.Value);
var logs = await changesQuery.ToListAsync();
var changes = logs.Select(l => new SyncChangeDto
{
EntityType = l.EntityType,
EntityId = l.EntityId,
Operation = l.Operation,
Timestamp = l.Timestamp
}).ToList();
// 2. 全量快照(无论 since 是否为空都返回,前端可以本地落库)
var snapshot = new SyncSnapshot();
var categories = await _db.Queryable<Category>().OrderBy(c => c.Sort).OrderBy(c => c.Id).ToListAsync();
snapshot.Categories = CategoryDto.BuildTree(categories);
var bookmarks = await _db.Queryable<Bookmark>().Where(b => !b.IsDeleted)
.OrderBy(b => b.Sort)
.OrderBy(b => b.Id)
.ToListAsync();
// P28 修复:用 BookmarkDto.FromEntity 共享映射,避免漏字段
snapshot.Bookmarks = bookmarks.Select(BookmarkDto.FromEntity).ToList();
var engines = await _db.Queryable<SearchEngine>().OrderBy(e => e.Sort).ToListAsync();
// P42 修复:用 SearchEngineDto.FromEntity 共享映射(之前手动 new 漏了 IconType/IconUrl/ColorBg
// 同步后前端 store 拿到老 DTOengineLogoStyle 命中兜底色块 → 引擎 logo "消失"
snapshot.SearchEngines = engines.Select(SearchEngineDto.FromEntity).ToList();
var setting = await _db.Queryable<Setting>().InSingleAsync(1);
if (setting is not null)
{
snapshot.Settings = SettingDto.FromEntity(setting);
}
return new SyncChangesResponse
{
Changes = changes,
Snapshot = snapshot,
ServerTime = DateTime.UtcNow
};
}
}
+144
View File
@@ -0,0 +1,144 @@
using Microsoft.Extensions.Options;
using MyHomePage.Api.Common;
using MyHomePage.Api.Infrastructure.Configuration;
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class UploadService : IUploadService
{
/// <summary>允许的图片扩展名白名单(与 SaveAsync 共用)</summary>
private static readonly string[] AllowedExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico" };
/// <summary>Content-Type 到扩展名的兜底映射</summary>
private static readonly Dictionary<string, string> ContentTypeToExt = new(StringComparer.OrdinalIgnoreCase)
{
["image/png"] = ".png",
["image/jpeg"] = ".jpg",
["image/jpg"] = ".jpg",
["image/gif"] = ".gif",
["image/webp"] = ".webp",
["image/svg+xml"] = ".svg",
["image/x-icon"] = ".ico",
["image/vnd.microsoft.icon"] = ".ico",
["image/ico"] = ".ico",
};
private readonly UploadOptions _options;
private readonly IWebHostEnvironment _env;
private readonly ILogger<UploadService> _logger;
/// <summary>P51 诊断:是否已记录过 upload root(避免每次保存重复 log</summary>
private bool _rootLogged;
public UploadService(
IOptions<UploadOptions> options,
IWebHostEnvironment env,
ILogger<UploadService> logger)
{
_options = options.Value;
_env = env;
_logger = logger;
}
/// <inheritdoc />
public string EnsureRoot()
{
var root = Path.IsPathRooted(_options.Path)
? _options.Path
: Path.Combine(_env.ContentRootPath, _options.Path);
Directory.CreateDirectory(root);
// P51 诊断:第一次调用时 log 一次实际 root(暴露容器内 /uploads 路径覆盖问题)
if (!_rootLogged)
{
_rootLogged = true;
_logger.LogInformation(
"Upload root resolved: Path='{Path}' (IsRooted={IsRooted}) → Actual='{Actual}' | env={Env}",
_options.Path, Path.IsPathRooted(_options.Path), root, _env.EnvironmentName);
}
return root;
}
/// <summary>P51 诊断兼容:保留旧方法名(Program.cs 启动期若想强制 log 可调)</summary>
public string EnsureRootWithLog() => EnsureRoot();
/// <inheritdoc />
public async Task<UploadResultDto> SaveAsync(IFormFile file)
{
if (file is null || file.Length == 0) throw new BusinessException("文件为空", 400);
if (file.Length > _options.MaxSizeBytes) throw new BusinessException($"文件大小超过限制({_options.MaxSizeBytes / 1024 / 1024}MB", 400);
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) throw new BusinessException("文件必须包含扩展名", 400);
if (Array.IndexOf(AllowedExtensions, ext) < 0) throw new BusinessException("仅支持图片格式", 400);
await using var stream = file.OpenReadStream();
return await SaveStreamInternalAsync(stream, file.FileName, file.ContentType, ext, subDir: null);
}
/// <inheritdoc />
public async Task<UploadResultDto> SaveStreamAsync(Stream stream, string fileName, string contentType, string? subDir = null)
{
if (stream is null) throw new BusinessException("数据流为空", 400);
// 推断扩展名:优先文件名 → 兜底 content-type
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext))
{
if (string.IsNullOrEmpty(contentType)) throw new BusinessException("无法推断文件扩展名", 400);
if (!ContentTypeToExt.TryGetValue(contentType, out ext))
throw new BusinessException($"不支持的内容类型:{contentType}", 400);
}
if (Array.IndexOf(AllowedExtensions, ext) < 0) throw new BusinessException("仅支持图片格式", 400);
return await SaveStreamInternalAsync(stream, fileName, contentType, ext, subDir);
}
/// <summary>实际写文件的内部流程(SaveAsync / SaveStreamAsync 共用)</summary>
private async Task<UploadResultDto> SaveStreamInternalAsync(Stream stream, string fileName, string contentType, string ext, string? subDir)
{
var root = EnsureRoot();
// 按日期分目录:2026/07/04[/favicons]
var datePath = DateTime.UtcNow.ToString("yyyy/MM/dd");
var relativeDir = string.IsNullOrEmpty(subDir) ? datePath : Path.Combine(datePath, subDir);
var dir = Path.Combine(root, relativeDir);
var name = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{Guid.NewGuid():N}{ext}";
var fullPath = Path.Combine(dir, name);
// P51 诊断:把 IO 异常(容器权限/路径/磁盘满)原样抛出 + 完整上下文日志
try
{
Directory.CreateDirectory(dir);
await using (var fs = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
await stream.CopyToAsync(fs);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Upload save failed: file={File}, contentType={ContentType}, ext={Ext}, subDir={SubDir}, " +
"UploadOptions.Path={OptPath} (IsRooted={IsRooted}), ContentRoot={ContentRoot}, env={Env}, " +
"computed root={Root}, dir={Dir}, fullPath={FullPath}",
fileName, contentType, ext, subDir,
_options.Path, Path.IsPathRooted(_options.Path), _env.ContentRootPath, _env.EnvironmentName,
root, dir, fullPath);
throw new BusinessException($"文件保存失败: {ex.GetType().Name}: {ex.Message}", 500);
}
var relative = Path.Combine(relativeDir, name).Replace('\\', '/');
var url = (_options.BaseUrl ?? "/uploads").TrimEnd('/') + "/" + relative;
_logger.LogInformation("Upload saved: {Path} ({ContentType})", fullPath, contentType);
return new UploadResultDto
{
Path = relative,
Url = url,
FileName = fileName,
Size = new FileInfo(fullPath).Length
};
}
}
+383
View File
@@ -0,0 +1,383 @@
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();
}
}