初始提交:浏览器首页 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
+18
View File
@@ -0,0 +1,18 @@
## 忽略构建产物
bin/
obj/
## 用户专属
*.user
*.suo
.vs/
.idea/
## 上传目录的运行时文件(保留 .gitkeep)
Uploads/*
!Uploads/.gitkeep
## 本地数据库
*.db
*.db-shm
*.db-wal
+54
View File
@@ -0,0 +1,54 @@
namespace MyHomePage.Api.Common;
/// <summary>
/// 统一 API 响应包装。
/// 全部接口返回该类型,前端可依据 <see cref="Code"/> 判定业务结果。
/// </summary>
/// <typeparam name="T">业务数据类型</typeparam>
public class ApiResponse<T>
{
/// <summary>业务状态码:0 表示成功,非 0 表示错误</summary>
public int Code { get; set; }
/// <summary>提示信息</summary>
public string Message { get; set; } = string.Empty;
/// <summary>业务数据</summary>
public T? Data { get; set; }
/// <summary>服务器时间戳(毫秒)</summary>
public long Timestamp { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
/// <summary>构造成功响应</summary>
public static ApiResponse<T> Ok(T data, string message = "ok") =>
new() { Code = 0, Message = message, Data = data };
/// <summary>构造成功响应(无数据)</summary>
public static ApiResponse<T> Ok(string message = "ok") =>
new() { Code = 0, Message = message };
/// <summary>异步等待数据后构造成功响应(用于服务层返回 Task&lt;T&gt;</summary>
public static async Task<ApiResponse<T>> OkAsync(Task<T> dataTask, string message = "ok")
{
var data = await dataTask;
return Ok(data, message);
}
/// <summary>异步等待数据后构造成功响应(列表场景,重命名避免类型参数遮蔽)</summary>
public static async Task<ApiResponse<List<TItem>>> OkListAsync<TItem>(Task<List<TItem>> dataTask, string message = "ok")
{
var data = await dataTask;
return new ApiResponse<List<TItem>> { Code = 0, Message = message, Data = data };
}
/// <summary>构造失败响应</summary>
public static ApiResponse<T> Fail(int code, string message) =>
new() { Code = code, Message = message };
}
/// <summary>无泛型版本的快捷响应(用于无数据的接口)</summary>
public class ApiResponse : ApiResponse<object>
{
/// <summary>构造成功响应(无数据)</summary>
public static ApiResponse Ok() => new() { Code = 0, Message = "ok" };
}
+16
View File
@@ -0,0 +1,16 @@
namespace MyHomePage.Api.Common;
/// <summary>
/// 业务异常:被中间件捕获后转为 <c>ApiResponse.Fail</c>
/// 不会进入 ASP.NET Core 默认 500 处理流程。
/// </summary>
public class BusinessException : Exception
{
/// <summary>业务错误码(默认 400</summary>
public int Code { get; }
public BusinessException(string message, int code = 400) : base(message)
{
Code = code;
}
}
@@ -0,0 +1,56 @@
using System.Text.Json;
namespace MyHomePage.Api.Common;
/// <summary>
/// 全局异常处理中间件。
/// 捕获下游抛出的 <see cref="BusinessException"/> 与未处理异常,统一包装为 <see cref="ApiResponse{T}"/>。
/// </summary>
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger,
IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (BusinessException ex)
{
_logger.LogWarning("Business exception ({Code}): {Message}", ex.Code, ex.Message);
// 使用 ex.Code 透传业务状态码(默认 400/404/500),不再硬编码 200
var status = ex.Code is >= 400 and < 600 ? ex.Code : StatusCodes.Status400BadRequest;
await WriteAsync(context, ApiResponse<object>.Fail(ex.Code, ex.Message), status);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
var message = _env.IsDevelopment() ? ex.Message : "服务器内部错误";
await WriteAsync(context, ApiResponse<object>.Fail(500, message), StatusCodes.Status500InternalServerError);
}
}
private static async Task WriteAsync(HttpContext ctx, object payload, int statusCode)
{
ctx.Response.StatusCode = statusCode;
ctx.Response.ContentType = "application/json; charset=utf-8";
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await ctx.Response.WriteAsync(json);
}
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>链接收藏。</summary>
[ApiController]
[Route("api/bookmarks")]
public class BookmarksController : ControllerBase
{
private readonly IBookmarkService _service;
public BookmarksController(IBookmarkService service) => _service = service;
/// <summary>获取链接列表,可按分类过滤</summary>
[HttpGet]
public async Task<ApiResponse<List<BookmarkDto>>> List([FromQuery] int? categoryId = null) =>
await ApiResponse<List<BookmarkDto>>.OkListAsync(_service.ListAsync(categoryId));
/// <summary>根据 ID 获取链接</summary>
[HttpGet("{id:int}")]
public async Task<ApiResponse<BookmarkDto>> GetById(int id)
{
var dto = await _service.GetByIdAsync(id)
?? throw new BusinessException("链接不存在", 404);
return ApiResponse<BookmarkDto>.Ok(dto);
}
/// <summary>创建链接</summary>
[HttpPost]
public async Task<ApiResponse<BookmarkDto>> Create([FromBody] BookmarkUpsertRequest request) =>
ApiResponse<BookmarkDto>.Ok(await _service.CreateAsync(request));
/// <summary>更新链接</summary>
[HttpPut("{id:int}")]
public async Task<ApiResponse<BookmarkDto>> Update(int id, [FromBody] BookmarkUpsertRequest request) =>
ApiResponse<BookmarkDto>.Ok(await _service.UpdateAsync(id, request));
/// <summary>删除链接(软删)</summary>
[HttpDelete("{id:int}")]
public async Task<ApiResponse> Delete(int id)
{
await _service.DeleteAsync(id);
return ApiResponse.Ok();
}
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>分类管理:支持二级树形结构。</summary>
[ApiController]
[Route("api/categories")]
public class CategoriesController : ControllerBase
{
private readonly ICategoryService _service;
public CategoriesController(ICategoryService service) => _service = service;
/// <summary>获取全量分类(树形)</summary>
[HttpGet]
public async Task<ApiResponse<List<CategoryDto>>> GetTree() =>
await ApiResponse<List<CategoryDto>>.OkListAsync(_service.GetTreeAsync());
/// <summary>根据 ID 获取分类</summary>
[HttpGet("{id:int}")]
public async Task<ApiResponse<CategoryDto>> GetById(int id)
{
var dto = await _service.GetByIdAsync(id)
?? throw new BusinessException("分类不存在", 404);
return ApiResponse<CategoryDto>.Ok(dto);
}
/// <summary>创建分类</summary>
[HttpPost]
public async Task<ApiResponse<CategoryDto>> Create([FromBody] CategoryUpsertRequest request) =>
ApiResponse<CategoryDto>.Ok(await _service.CreateAsync(request));
/// <summary>更新分类</summary>
[HttpPut("{id:int}")]
public async Task<ApiResponse<CategoryDto>> Update(int id, [FromBody] CategoryUpsertRequest request) =>
ApiResponse<CategoryDto>.Ok(await _service.UpdateAsync(id, request));
/// <summary>删除分类</summary>
[HttpDelete("{id:int}")]
public async Task<ApiResponse> Delete(int id)
{
await _service.DeleteAsync(id);
return ApiResponse.Ok();
}
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>搜索引擎管理。</summary>
[ApiController]
[Route("api/search-engines")]
public class SearchEnginesController : ControllerBase
{
private readonly ISearchEngineService _service;
public SearchEnginesController(ISearchEngineService service) => _service = service;
[HttpGet]
public async Task<ApiResponse<List<SearchEngineDto>>> List() =>
await ApiResponse<List<SearchEngineDto>>.OkListAsync(_service.ListAsync());
[HttpGet("{id:int}")]
public async Task<ApiResponse<SearchEngineDto>> GetById(int id)
{
var dto = await _service.GetByIdAsync(id)
?? throw new BusinessException("搜索引擎不存在", 404);
return ApiResponse<SearchEngineDto>.Ok(dto);
}
[HttpPost]
public async Task<ApiResponse<SearchEngineDto>> Create([FromBody] SearchEngineUpsertRequest request) =>
ApiResponse<SearchEngineDto>.Ok(await _service.CreateAsync(request));
[HttpPut("{id:int}")]
public async Task<ApiResponse<SearchEngineDto>> Update(int id, [FromBody] SearchEngineUpsertRequest request) =>
ApiResponse<SearchEngineDto>.Ok(await _service.UpdateAsync(id, request));
[HttpDelete("{id:int}")]
public async Task<ApiResponse> Delete(int id)
{
await _service.DeleteAsync(id);
return ApiResponse.Ok();
}
/// <summary>将指定 ID 的引擎设为默认(唯一)</summary>
[HttpPut("{id:int}/default")]
public async Task<ApiResponse<SearchEngineDto>> SetDefault(int id) =>
ApiResponse<SearchEngineDto>.Ok(await _service.SetDefaultAsync(id));
}
+24
View File
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>用户设置:单行配置。</summary>
[ApiController]
[Route("api/settings")]
public class SettingsController : ControllerBase
{
private readonly ISettingService _service;
public SettingsController(ISettingService service) => _service = service;
[HttpGet]
public async Task<ApiResponse<SettingDto>> Get() =>
ApiResponse<SettingDto>.Ok(await _service.GetAsync());
[HttpPut]
public async Task<ApiResponse<SettingDto>> Update([FromBody] SettingUpdateRequest request) =>
ApiResponse<SettingDto>.Ok(await _service.UpdateAsync(request));
}
+45
View File
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>多端同步:拉取增量变更 + 全量快照。</summary>
[ApiController]
[Route("api/sync")]
public class SyncController : ControllerBase
{
private readonly ISyncService _service;
private readonly ILogger<SyncController> _logger;
public SyncController(ISyncService service, ILogger<SyncController> logger)
{
_service = service;
_logger = logger;
}
/// <summary>
/// 拉取自 <paramref name="since"/> 之后的变更。
/// <paramref name="since"/> 为空或解析失败时返回全量(P34.2 防御:避免前端传 ?since=undefined 触发 400)。
/// </summary>
[HttpGet("changes")]
public async Task<ApiResponse<SyncChangesResponse>> Changes([FromQuery] string? since)
{
DateTime? sinceDt = null;
if (!string.IsNullOrWhiteSpace(since))
{
// 严格解析(RoundtripKind 接受 ISO8601 字符串);失败则降级为全量
if (DateTime.TryParse(since, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
{
sinceDt = parsed;
}
else
{
_logger.LogWarning("Sync changes received unparseable since={Since}, fallback to full snapshot", since);
}
}
return ApiResponse<SyncChangesResponse>.Ok(await _service.GetChangesAsync(sinceDt));
}
}
+30
View File
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>文件上传(图片为主)。</summary>
[ApiController]
[Route("api/upload")]
public class UploadController : ControllerBase
{
private readonly IUploadService _service;
public UploadController(IUploadService service) => _service = service;
/// <summary>单文件上传</summary>
/// <remarks>
/// Swashbuckle 6.x 不支持 [FromForm] IFormFile 自动生成 schema(会抛 SwaggerGeneratorException),
/// 这里用 [ApiExplorerSettings(IgnoreApi = true)] 让 swagger UI 跳过此端点的文档生成,
/// 实际 API 功能完全不受影响(前端 BookmarkForm 仍可正常调用)。
/// </remarks>
[HttpPost]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<ApiResponse<UploadResultDto>> Upload([FromForm] IFormFile file)
{
var result = await _service.SaveAsync(file);
return ApiResponse<UploadResultDto>.Ok(result);
}
}
+43
View File
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>工具类 APIfavicon 抓取等小工具(手动测试用 / 调试入口)。</summary>
[ApiController]
[Route("api/utility")]
public class UtilityController : ControllerBase
{
private readonly FaviconService _favicon;
public UtilityController(FaviconService favicon) => _favicon = favicon;
/// <summary>
/// P31:手动触发 favicon 抓取(不影响正常创建流程)。
/// 任何失败(网络/404/SSRF)均返回 iconUrl=null,由调用方静默用默认。
/// </summary>
[HttpPost("favicon")]
public async Task<ApiResponse<FaviconResultDto>> FetchFavicon([FromBody] FaviconRequest request)
{
if (string.IsNullOrWhiteSpace(request.Url))
throw new BusinessException("URL 不能为空", 400);
var iconUrl = await _favicon.FetchAndSaveAsync(request.Url, HttpContext.RequestAborted);
return ApiResponse<FaviconResultDto>.Ok(new FaviconResultDto { Url = request.Url, IconUrl = iconUrl });
}
}
/// <summary>favicon 抓取请求 DTO</summary>
public class FaviconRequest
{
[Required] public string Url { get; set; } = string.Empty;
}
/// <summary>favicon 抓取结果 DTO</summary>
public class FaviconResultDto
{
public string Url { get; set; } = string.Empty;
public string? IconUrl { get; set; }
}
@@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>
/// 360 在线壁纸代理(P34):
/// - GET /api/wallpaper/categories 拉全部分类(24h 缓存)
/// - GET /api/wallpaper/random 按视口分辨率返回 1 张随机图
/// - POST /api/wallpaper/refresh 立即刷新分类池子并返回 1 张新随机图
/// </summary>
[ApiController]
[Route("api/wallpaper")]
public class WallpaperController : ControllerBase
{
private readonly WallpaperService _wallpaper;
private readonly ILogger<WallpaperController> _logger;
public WallpaperController(WallpaperService wallpaper, ILogger<WallpaperController> logger)
{
_wallpaper = wallpaper;
_logger = logger;
}
/// <summary>全部分类列表(24h 缓存)。失败返回空集合(前端展示「暂无可用分类」)。</summary>
[HttpGet("categories")]
public async Task<ApiResponse<List<WallpaperCategoryDto>>> GetCategories(CancellationToken ct)
{
var cats = await _wallpaper.GetCategoriesAsync(ct);
return ApiResponse<List<WallpaperCategoryDto>>.Ok(cats);
}
/// <summary>
/// 按分类 + 视口分辨率返回 1 张随机壁纸 URL。
/// 查询参数:cid(可空,例 "36")、w(视口宽 px,默认 1920)、h(视口高 px,默认 1080)。
/// </summary>
[HttpGet("random")]
public async Task<ApiResponse<WallpaperRandomDto>> GetRandom(
[FromQuery] string? cid,
[FromQuery] int? w,
[FromQuery] int? h,
CancellationToken ct)
{
var (width, height) = SanitizeViewport(w, h);
var result = await _wallpaper.GetRandomAsync(cid ?? "", width, height, ct);
if (result is null)
throw new BusinessException("暂无可用壁纸(分类可能无效或 360 接口暂不可达)", 404);
return ApiResponse<WallpaperRandomDto>.Ok(result);
}
/// <summary>
/// 立即刷新指定分类的池子(清缓存重新拉 200 张),并立即返回 1 张新随机图。
/// 即主人要求的「立即切换」按钮后端入口。
/// </summary>
[HttpPost("refresh")]
public async Task<ApiResponse<WallpaperRandomDto>> Refresh(
[FromQuery] string? cid,
[FromQuery] int? w,
[FromQuery] int? h,
CancellationToken ct)
{
var (width, height) = SanitizeViewport(w, h);
var result = await _wallpaper.RefreshAsync(cid ?? "", width, height, ct);
if (result is null)
throw new BusinessException("暂无可用壁纸(分类可能无效或 360 接口暂不可达)", 404);
_logger.LogInformation("Wallpaper manual refresh: cid={Cid} {W}x{H}", cid ?? "", width, height);
return ApiResponse<WallpaperRandomDto>.Ok(result);
}
/// <summary>把客户端上报的 w/h 限制在合理范围(避免异常大数把 360 路径撑爆)</summary>
private static (int w, int h) SanitizeViewport(int? w, int? h)
{
// 默认 1920x1080 = PC 桌面
var width = w is > 0 and < 8000 ? w.Value : 1920;
var height = h is > 0 and < 8000 ? h.Value : 1080;
return (width, height);
}
}
@@ -0,0 +1,10 @@
namespace MyHomePage.Api.Infrastructure.Configuration;
/// <summary>跨域配置节点(对应 appsettings.json 中的 Cors</summary>
public class CorsOptions
{
public const string SectionName = "Cors";
/// <summary>允许的来源列表</summary>
public string[] Origins { get; set; } = Array.Empty<string>();
}
@@ -0,0 +1,13 @@
namespace MyHomePage.Api.Infrastructure.Configuration;
/// <summary>数据库配置节点(对应 appsettings.json 中的 Database</summary>
public class DatabaseOptions
{
public const string SectionName = "Database";
/// <summary>数据库提供者:MySql | Sqlite</summary>
public string Provider { get; set; } = "Sqlite";
/// <summary>连接字符串</summary>
public string ConnectionString { get; set; } = "Data Source=myhomepage.db";
}
@@ -0,0 +1,16 @@
namespace MyHomePage.Api.Infrastructure.Configuration;
/// <summary>文件上传配置节点(对应 appsettings.json 中的 Upload</summary>
public class UploadOptions
{
public const string SectionName = "Upload";
/// <summary>上传文件保存目录(相对 ContentRoot 解析)</summary>
public string Path { get; set; } = "Uploads";
/// <summary>前端访问上传文件时使用的基础 URL 前缀</summary>
public string BaseUrl { get; set; } = "/uploads";
/// <summary>单文件最大字节数(默认 10MB</summary>
public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024;
}
@@ -0,0 +1,245 @@
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Infrastructure.Database;
/// <summary>
/// 数据库初始化器:CodeFirst 建表 + 种子数据。
/// 应用启动时调用一次。
/// </summary>
public class DatabaseInitializer
{
private readonly SqlSugarContext _ctx;
private readonly ILogger<DatabaseInitializer> _logger;
public DatabaseInitializer(SqlSugarContext ctx, ILogger<DatabaseInitializer> logger)
{
_ctx = ctx;
_logger = logger;
}
/// <summary>建表 + 种子数据</summary>
public async Task InitializeAsync()
{
try
{
_logger.LogInformation("开始 CodeFirst 建表...");
// ===== 兼容老库:表已存在则跳过 CodeFirst InitTablesSqlite 不支持 alter column primary key,触发表结构不一致会抛)=====
// 后续靠 MigrateSettingColumns / MigrateBookmarkColumns 给老库补列。
const string settingsTable = "settings";
if (_ctx.Db.DbMaintenance.IsAnyTable(settingsTable))
{
_logger.LogInformation("检测到 settings 表已存在,跳过 CodeFirst(已通过轻量迁移补齐列)");
}
else
{
_ctx.Db.CodeFirst.InitTables(
typeof(Category),
typeof(Bookmark),
typeof(SearchEngine),
typeof(Setting),
typeof(SyncLog)
);
}
// 对已存在数据库做轻量迁移:给 settings 表补上新增列(CodeFirst InitTables 不会自动 ALTER 老库)
MigrateSettingColumns();
// 给 bookmarks 表补充 ColorBg 列(P28 链接 logo 背景色)
MigrateBookmarkColumns();
// 给 search_engines 表补充 IconType / IconUrl / ColorBg 列(P37 引擎图标逻辑对齐链接)
MigrateSearchEngineColumns();
// 给 settings 表补充 OpenSearchInNewTab 列(P46 搜索框行为开关)
MigrateSettingColumnsV2();
await SeedAsync();
_logger.LogInformation("数据库初始化完成");
}
catch (Exception ex)
{
_logger.LogError(ex, "数据库初始化失败");
throw;
}
}
/// <summary>为 settings 表补充新列(已存在则跳过)。</summary>
private void MigrateSettingColumns()
{
const string tableName = "settings";
// P26.2
if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "OpenLinksInNewTab"))
{
_ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo
{
DbColumnName = "OpenLinksInNewTab",
DataType = "INTEGER",
IsNullable = false,
DefaultValue = "1"
});
_logger.LogInformation("已为 settings 表补充列 {Column}", "OpenLinksInNewTab");
}
// P34 360 壁纸模式
if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "WallpaperEnabled"))
{
_ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo
{
DbColumnName = "WallpaperEnabled",
DataType = "INTEGER",
IsNullable = false,
DefaultValue = "0"
});
_logger.LogInformation("已为 settings 表补充列 {Column}", "WallpaperEnabled");
}
if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "WallpaperCategoryId"))
{
_ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo
{
DbColumnName = "WallpaperCategoryId",
DataType = "varchar(32)",
IsNullable = true,
DefaultValue = ""
});
_logger.LogInformation("已为 settings 表补充列 {Column}", "WallpaperCategoryId");
}
if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "WallpaperInterval"))
{
_ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo
{
DbColumnName = "WallpaperInterval",
DataType = "INTEGER",
IsNullable = false,
DefaultValue = "30"
});
_logger.LogInformation("已为 settings 表补充列 {Column}", "WallpaperInterval");
}
}
/// <summary>为 bookmarks 表补充 ColorBg 列(已存在则跳过)。</summary>
private void MigrateBookmarkColumns()
{
const string tableName = "bookmarks";
const string newColumn = "ColorBg";
if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, newColumn))
{
_ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo
{
DbColumnName = newColumn,
DataType = "varchar(32)",
IsNullable = true
});
_logger.LogInformation("已为 bookmarks 表补充列 {Column}", newColumn);
}
}
/// <summary>为 search_engines 表补充 IconType / IconUrl / ColorBg 列(已存在则跳过,P37 引擎图标逻辑对齐链接)。</summary>
private void MigrateSearchEngineColumns()
{
const string tableName = "search_engines";
AddColumnIfMissing(tableName, "IconType", "varchar(16)", isNullable: false, defaultValue: "lucide");
AddColumnIfMissing(tableName, "IconUrl", "varchar(512)", isNullable: true);
AddColumnIfMissing(tableName, "ColorBg", "varchar(32)", isNullable: true);
}
/// <summary>P46:给 settings 表补 OpenSearchInNewTab 列(int default 1)—— 复刻 P37/P42 的「轻量迁移」模式</summary>
private void MigrateSettingColumnsV2()
{
const string tableName = "settings";
// 注意:int 类型的 column 在 SqlSugar + SQLite 下要显式声明 DataType
if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "OpenSearchInNewTab"))
{
_ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo
{
DbColumnName = "OpenSearchInNewTab",
DataType = "int",
IsNullable = false,
DefaultValue = "1"
});
}
}
private void AddColumnIfMissing(string tableName, string columnName, string dataType, bool isNullable, string? defaultValue = null)
{
if (_ctx.Db.DbMaintenance.IsAnyColumn(tableName, columnName)) return;
_ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo
{
DbColumnName = columnName,
DataType = dataType,
IsNullable = isNullable,
DefaultValue = defaultValue
});
_logger.LogInformation("已为 {Table} 表补充列 {Column}", tableName, columnName);
}
/// <summary>写入种子数据(仅当表为空时执行)</summary>
private async Task SeedAsync()
{
var db = _ctx.Db;
// 搜索引擎种子
if (!db.Queryable<SearchEngine>().Any())
{
var engines = new List<SearchEngine>
{
new() { Name = "百度", UrlTemplate = "https://www.baidu.com/s?wd={q}", Icon = "search", Sort = 0, IsDefault = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow },
new() { Name = "Google", UrlTemplate = "https://www.google.com/search?q={q}", Icon = "search", Sort = 1, IsDefault = false, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow },
new() { Name = "Bing", UrlTemplate = "https://www.bing.com/search?q={q}", Icon = "search", Sort = 2, IsDefault = false, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }
};
await db.Insertable(engines).ExecuteCommandAsync();
_logger.LogInformation("已写入搜索引擎种子数据 ({Count} 条)", engines.Count);
}
// 设置种子(单行)
if (!db.Queryable<Setting>().Any())
{
var setting = new Setting
{
Id = 1,
ThemeMode = "dark",
AccentColor = "#6c5ce7",
BackgroundImage = "wp1",
BackgroundType = "preset",
OpenLinksInNewTab = 1,
UpdatedAt = DateTime.UtcNow
};
await db.Insertable(setting).ExecuteCommandAsync();
_logger.LogInformation("已写入默认设置");
}
// 分类 + 链接种子
if (!db.Queryable<Category>().Any())
{
var now = DateTime.UtcNow;
// 一级:常用工具
var catTools = new Category
{
ParentId = 0,
Name = "常用工具",
Icon = "wrench",
Sort = 0,
CreatedAt = now,
UpdatedAt = now
};
catTools.Id = await db.Insertable(catTools).ExecuteReturnIdentityAsync();
var catToolsId = catTools.Id;
// 二级分类(单独插入回填 Id,方便后续链接绑定)
var subAi = new Category { ParentId = catToolsId, Name = "AI 工具", Icon = "bot", Sort = 1, CreatedAt = now, UpdatedAt = now };
var subDev = new Category { ParentId = catToolsId, Name = "开发工具", Icon = "code-2", Sort = 2, CreatedAt = now, UpdatedAt = now };
subAi.Id = await db.Insertable(subAi).ExecuteReturnIdentityAsync();
subDev.Id = await db.Insertable(subDev).ExecuteReturnIdentityAsync();
// 链接示例
var bookmarks = new List<Bookmark>
{
new() { CategoryId = subAi.Id, Title = "ChatGPT", Url = "https://chat.openai.com", Description = "AI 对话助手,智能问答", Icon = "bot", IconType = "lucide", Sort = 0, CreatedAt = now, UpdatedAt = now },
new() { CategoryId = subAi.Id, Title = "Claude", Url = "https://claude.ai", Description = "Anthropic 推出的 AI 助手", Icon = "bot", IconType = "lucide", Sort = 1, CreatedAt = now, UpdatedAt = now },
new() { CategoryId = subDev.Id, Title = "GitHub", Url = "https://github.com", Description = "代码托管与协作平台", Icon = "github", IconType = "lucide", Sort = 0, CreatedAt = now, UpdatedAt = now },
new() { CategoryId = subDev.Id, Title = "MDN", Url = "https://developer.mozilla.org", Description = "Web 技术文档参考", Icon = "book", IconType = "lucide", Sort = 1, CreatedAt = now, UpdatedAt = now },
new() { CategoryId = subDev.Id, Title = "Stack Overflow", Url = "https://stackoverflow.com", Description = "开发者问答社区", Icon = "message-circle", IconType = "lucide", Sort = 2, CreatedAt = now, UpdatedAt = now },
new() { CategoryId = subDev.Id, Title = "VS Code", Url = "https://code.visualstudio.com", Description = "轻量级代码编辑器", Icon = "code-2", IconType = "lucide", Sort = 3, CreatedAt = now, UpdatedAt = now }
};
await db.Insertable(bookmarks).ExecuteCommandAsync();
_logger.LogInformation("已写入分类 / 链接种子数据");
}
}
}
@@ -0,0 +1,59 @@
using Microsoft.Extensions.Options;
using MyHomePage.Api.Infrastructure.Configuration;
using SqlSugar;
namespace MyHomePage.Api.Infrastructure.Database;
/// <summary>
/// SqlSugar 上下文(单例生命周期)。
/// 根据 <see cref="DatabaseOptions.Provider"/> 自动切换 MySQL / SQLite。
/// </summary>
public class SqlSugarContext : IDisposable
{
private readonly DatabaseOptions _options;
public ISqlSugarClient Db { get; }
public SqlSugarContext(IOptions<DatabaseOptions> options)
{
_options = options.Value;
Db = new SqlSugarScope(BuildConnectionConfig(_options), BuildAopConfig());
}
/// <summary>根据配置构建 SqlSugar 连接配置</summary>
private static ConnectionConfig BuildConnectionConfig(DatabaseOptions options)
{
var dbType = options.Provider.Equals("MySql", StringComparison.OrdinalIgnoreCase)
? DbType.MySql
: DbType.Sqlite;
return new ConnectionConfig
{
ConfigId = "default",
ConnectionString = options.ConnectionString,
DbType = dbType,
IsAutoCloseConnection = true,
InitKeyType = InitKeyType.Attribute,
// SqlSugar AOP 启用默认值
MoreSettings = new ConnMoreSettings
{
IsAutoRemoveDataCache = true
}
};
}
/// <summary>配置 AOP:日志 + 性能监控</summary>
private static Action<SqlSugarClient> BuildAopConfig() => db =>
{
db.Aop.OnLogExecuting = (sql, parameters) =>
{
// 由 Serilog / 默认 logger 接管,避免在控制台双打
// 这里只做轻量占位,实际日志由 SqlSugarScopeClientConfiguration 注入的 logger 输出
};
};
public void Dispose()
{
Db?.Dispose();
GC.SuppressFinalize(this);
}
}
+55
View File
@@ -0,0 +1,55 @@
using MyHomePage.Api.Models.Entities;
namespace MyHomePage.Api.Models.Dtos;
/// <summary>链接输出 DTO</summary>
public class BookmarkDto
{
public int Id { get; set; }
public int CategoryId { get; set; }
public string Title { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Icon { get; set; }
public string IconType { get; set; } = "lucide";
public string? IconUrl { get; set; }
public string? ColorBg { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>
/// 集中映射 Bookmark → BookmarkDto。BookmarkService / SyncService 共用,
/// 避免手写 new BookmarkDto { ... } 漏字段(P28 Bug 教训)。
/// </summary>
public static BookmarkDto FromEntity(Bookmark b) => new()
{
Id = b.Id,
CategoryId = b.CategoryId,
Title = b.Title,
Url = b.Url,
Description = b.Description,
Icon = b.Icon,
IconType = b.IconType,
IconUrl = b.IconUrl,
ColorBg = b.ColorBg,
Sort = b.Sort,
CreatedAt = b.CreatedAt,
UpdatedAt = b.UpdatedAt
};
}
/// <summary>链接创建/更新入参</summary>
public class BookmarkUpsertRequest
{
public int? Id { get; set; }
public int CategoryId { get; set; }
public string Title { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Icon { get; set; }
public string? IconType { get; set; }
public string? IconUrl { get; set; }
public string? ColorBg { get; set; }
public int Sort { get; set; }
}
+82
View File
@@ -0,0 +1,82 @@
using MyHomePage.Api.Models.Entities;
namespace MyHomePage.Api.Models.Dtos;
/// <summary>分类输出 DTO(包含二级子项)</summary>
public class CategoryDto
{
public int Id { get; set; }
public int ParentId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Icon { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public List<CategoryDto> Children { get; set; } = new();
/// <summary>
/// 把扁平的 Category 实体集合构建为树形 DTO 列表。
/// 规则:parentId == 0 → 顶级;其余 → 挂到对应父分类的 Children 下。
/// 若父分类在当前集合中不存在(孤儿),降级为顶级。
/// 子项按 Sort, Id 升序排列。
/// </summary>
public static List<CategoryDto> BuildTree(IEnumerable<Category> entities)
{
var list = entities
.Select(c => new CategoryDto
{
Id = c.Id,
ParentId = c.ParentId,
Name = c.Name,
Icon = c.Icon,
Sort = c.Sort,
CreatedAt = c.CreatedAt,
UpdatedAt = c.UpdatedAt
})
.ToList();
return BuildTreeFromFlat(list);
}
/// <summary>
/// 把扁平的 DTO 集合构建为树形 DTO 列表(按 Id 重新组织父子关系)。
/// </summary>
public static List<CategoryDto> BuildTreeFromFlat(IEnumerable<CategoryDto> flat)
{
var all = flat.ToList();
var byId = all.ToDictionary(d => d.Id);
var roots = new List<CategoryDto>();
// 重置所有 Children(防止调用方传入了预填的 Children 造成重复)
foreach (var d in all) d.Children = new List<CategoryDto>();
foreach (var d in all.OrderBy(x => x.Sort).ThenBy(x => x.Id))
{
if (d.ParentId == 0 || !byId.TryGetValue(d.ParentId, out var parent))
{
// 顶级 OR 孤儿(父分类不存在)→ 降级为顶级
roots.Add(d);
}
else
{
parent.Children.Add(d);
}
}
// 给每个父分类的 children 排序
foreach (var r in roots)
{
r.Children = r.Children.OrderBy(x => x.Sort).ThenBy(x => x.Id).ToList();
}
return roots;
}
}
/// <summary>分类创建/更新入参</summary>
public class CategoryUpsertRequest
{
public int? Id { get; set; }
public int ParentId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Icon { get; set; }
public int Sort { get; set; }
}
+49
View File
@@ -0,0 +1,49 @@
using MyHomePage.Api.Models.Entities;
namespace MyHomePage.Api.Models.Dtos;
/// <summary>搜索引擎输出 DTO</summary>
public class SearchEngineDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string UrlTemplate { get; set; } = string.Empty;
public string IconType { get; set; } = "lucide";
public string? Icon { get; set; }
public string? IconUrl { get; set; }
public string? ColorBg { get; set; }
public int Sort { get; set; }
public bool IsDefault { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>从实体映射(中心化转换,防止漏字段,与 BookmarkDto.FromEntity 对齐)</summary>
public static SearchEngineDto FromEntity(SearchEngine e) => new()
{
Id = e.Id,
Name = e.Name,
UrlTemplate = e.UrlTemplate,
IconType = e.IconType,
Icon = e.Icon,
IconUrl = e.IconUrl,
ColorBg = e.ColorBg,
Sort = e.Sort,
IsDefault = e.IsDefault,
CreatedAt = e.CreatedAt,
UpdatedAt = e.UpdatedAt
};
}
/// <summary>搜索引擎创建/更新入参</summary>
public class SearchEngineUpsertRequest
{
public int? Id { get; set; }
public string Name { get; set; } = string.Empty;
public string UrlTemplate { get; set; } = string.Empty;
public string IconType { get; set; } = "lucide";
public string? Icon { get; set; }
public string? IconUrl { get; set; }
public string? ColorBg { get; set; }
public int Sort { get; set; }
public bool IsDefault { get; set; }
}
+59
View File
@@ -0,0 +1,59 @@
using MyHomePage.Api.Models.Entities;
namespace MyHomePage.Api.Models.Dtos;
/// <summary>设置输出 DTO</summary>
public class SettingDto
{
public string ThemeMode { get; set; } = "dark";
public string AccentColor { get; set; } = "#6c5ce7";
public string? BackgroundImage { get; set; }
public string BackgroundType { get; set; } = "preset";
public bool OpenLinksInNewTab { get; set; } = true;
/// <summary>搜索框行为(P46):true = 搜索结果在新选项卡打开(默认);false = 当前选项卡打开</summary>
public bool OpenSearchInNewTab { get; set; } = true;
// ===== P34 360 在线壁纸模式 =====
/// <summary>是否启用 360 在线壁纸(按分类随机 + 定时切换)</summary>
public bool WallpaperEnabled { get; set; } = false;
/// <summary>360 壁纸分类 ID,空字符串 = 全部/推荐</summary>
public string WallpaperCategoryId { get; set; } = "";
/// <summary>自动切换间隔(分钟),0 = 不自动切换</summary>
public int WallpaperInterval { get; set; } = 30;
public DateTime UpdatedAt { get; set; }
/// <summary>从实体构造 DTO,统一所有 Controller / Service 的转换逻辑,避免漏字段。</summary>
public static SettingDto FromEntity(Setting s) => new()
{
ThemeMode = s.ThemeMode,
AccentColor = s.AccentColor,
BackgroundImage = s.BackgroundImage,
BackgroundType = s.BackgroundType,
OpenLinksInNewTab = s.OpenLinksInNewTab != 0,
OpenSearchInNewTab = s.OpenSearchInNewTab != 0,
WallpaperEnabled = s.WallpaperEnabled != 0,
WallpaperCategoryId = s.WallpaperCategoryId ?? "",
WallpaperInterval = s.WallpaperInterval,
UpdatedAt = s.UpdatedAt
};
}
/// <summary>设置更新入参(全部可选)</summary>
public class SettingUpdateRequest
{
public string? ThemeMode { get; set; }
public string? AccentColor { get; set; }
public string? BackgroundImage { get; set; }
public string? BackgroundType { get; set; }
public bool? OpenLinksInNewTab { get; set; }
/// <summary>搜索框行为(P46):true = 搜索结果在新选项卡打开(默认);false = 当前选项卡打开</summary>
public bool? OpenSearchInNewTab { get; set; }
// ===== P34 360 在线壁纸 =====
public bool? WallpaperEnabled { get; set; }
public string? WallpaperCategoryId { get; set; }
public int? WallpaperInterval { get; set; }
}
+32
View File
@@ -0,0 +1,32 @@
namespace MyHomePage.Api.Models.Dtos;
/// <summary>同步单条记录</summary>
public class SyncChangeDto
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public string Operation { get; set; } = "update";
public DateTime Timestamp { get; set; }
}
/// <summary>同步响应</summary>
public class SyncChangesResponse
{
/// <summary>本次拉取的变更记录</summary>
public List<SyncChangeDto> Changes { get; set; } = new();
/// <summary>全量最新数据快照(按实体类型分组)</summary>
public SyncSnapshot Snapshot { get; set; } = new();
/// <summary>服务器当前时间(用作下次 since</summary>
public DateTime ServerTime { get; set; } = DateTime.UtcNow;
}
/// <summary>全量快照</summary>
public class SyncSnapshot
{
public List<CategoryDto> Categories { get; set; } = new();
public List<BookmarkDto> Bookmarks { get; set; } = new();
public List<SearchEngineDto> SearchEngines { get; set; } = new();
public SettingDto? Settings { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
namespace MyHomePage.Api.Models.Dtos;
/// <summary>文件上传结果</summary>
public class UploadResultDto
{
/// <summary>相对 BaseUrl 的子路径(如 2026/07/04/abc.png</summary>
public string Path { get; set; } = string.Empty;
/// <summary>前端可直接访问的完整 URL</summary>
public string Url { get; set; } = string.Empty;
/// <summary>原始文件名</summary>
public string FileName { get; set; } = string.Empty;
/// <summary>文件大小(字节)</summary>
public long Size { get; set; }
}
+29
View File
@@ -0,0 +1,29 @@
namespace MyHomePage.Api.Models.Dtos;
/// <summary>360 壁纸分类(P34</summary>
public class WallpaperCategoryDto
{
/// <summary>分类 ID(字符串,例:"36"</summary>
public string Id { get; set; } = string.Empty;
/// <summary>分类名(中文,例:"4K专区"</summary>
public string Name { get; set; } = string.Empty;
/// <summary>排序权重(P34.1 主人反馈:360 接口真实数据有此字段,降序展示)</summary>
public int OrderNum { get; set; }
}
/// <summary>随机壁纸返回结果(P34</summary>
public class WallpaperRandomDto
{
/// <summary>最终 URL(命中 360 预设分辨率的 img_* 字段,或兜底 RewriteUrl 自构)</summary>
public string Url { get; set; } = string.Empty;
/// <summary>360 接口原始 urlbdr/__85 画质低的版本,调试用)</summary>
public string OriginalUrl { get; set; } = string.Empty;
/// <summary>请求的视口宽度(px</summary>
public int Width { get; set; }
/// <summary>请求的视口高度(px</summary>
public int Height { get; set; }
/// <summary>命中的 360 预设分辨率(形如 "1600x900");未命中为 null(走 RewriteUrl 兜底)</summary>
public string? Preset { get; set; }
/// <summary>是否走 RewriteUrl 兜底(true = preset 没命中)</summary>
public bool UsedFallback { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>实体基类:所有业务表都包含主键 + 时间戳</summary>
public abstract class BaseEntity
{
/// <summary>主键(自增,使用 int 以兼容 SQLite + MySQL</summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>创建时间(UTC</summary>
[SugarColumn(IsNullable = false)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>更新时间(UTC</summary>
[SugarColumn(IsNullable = false)]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+48
View File
@@ -0,0 +1,48 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>链接收藏</summary>
[SugarTable("bookmarks")]
public class Bookmark : BaseEntity
{
/// <summary>所属分类 ID</summary>
[SugarColumn(IsNullable = false, IndexGroupNameList = new[] { "idx_category" })]
public int CategoryId { get; set; }
/// <summary>链接标题</summary>
[SugarColumn(Length = 128, IsNullable = false)]
public string Title { get; set; } = string.Empty;
/// <summary>链接 URL</summary>
[SugarColumn(Length = 512, IsNullable = false)]
public string Url { get; set; } = string.Empty;
/// <summary>简介</summary>
[SugarColumn(Length = 512, IsNullable = true)]
public string? Description { get; set; }
/// <summary>图标标识:lucide 名 / emoji / 自定义 key</summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? Icon { get; set; }
/// <summary>图标类型:lucide | emoji | image</summary>
[SugarColumn(Length = 16, IsNullable = true, DefaultValue = "lucide")]
public string IconType { get; set; } = "lucide";
/// <summary>图标 URLIconType = image 时使用)</summary>
[SugarColumn(Length = 512, IsNullable = true)]
public string? IconUrl { get; set; }
/// <summary>logo 背景色(#hex / rgb / hsl);null = 自适应(由前端从 url / iconUrl 推断)</summary>
[SugarColumn(Length = 32, IsNullable = true)]
public string? ColorBg { get; set; }
/// <summary>排序值</summary>
[SugarColumn(DefaultValue = "0")]
public int Sort { get; set; }
/// <summary>软删标记</summary>
[SugarColumn(DefaultValue = "0")]
public bool IsDeleted { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>分类(二级树形,ParentId 为 0 表示一级)</summary>
[SugarTable("categories")]
public class Category : BaseEntity
{
/// <summary>父分类 ID;一级分类为 0</summary>
[SugarColumn(DefaultValue = "0")]
public int ParentId { get; set; }
/// <summary>分类名称</summary>
[SugarColumn(Length = 64, IsNullable = false)]
public string Name { get; set; } = string.Empty;
/// <summary>lucide 图标名</summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? Icon { get; set; }
/// <summary>排序值,越小越靠前</summary>
[SugarColumn(DefaultValue = "0")]
public int Sort { get; set; }
}
+40
View File
@@ -0,0 +1,40 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>搜索引擎</summary>
[SugarTable("search_engines")]
public class SearchEngine : BaseEntity
{
/// <summary>展示名</summary>
[SugarColumn(Length = 64, IsNullable = false)]
public string Name { get; set; } = string.Empty;
/// <summary>URL 模板,必须包含 {q} 占位符</summary>
[SugarColumn(Length = 512, IsNullable = false)]
public string UrlTemplate { get; set; } = string.Empty;
/// <summary>图标类型:lucide / image / emoji(与 Bookmark.IconType 对齐)</summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "lucide")]
public string IconType { get; set; } = "lucide";
/// <summary>图标内容:lucide 名 / emoji 字符(IconType=lucide/emoji 时使用)</summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? Icon { get; set; }
/// <summary>图标图片 URLIconType=image 时使用)</summary>
[SugarColumn(Length = 512, IsNullable = true)]
public string? IconUrl { get; set; }
/// <summary>logo 背景色(#hex / rgb / hsl);null = 自适应(与 Bookmark.ColorBg 对齐)</summary>
[SugarColumn(Length = 32, IsNullable = true)]
public string? ColorBg { get; set; }
/// <summary>排序值</summary>
[SugarColumn(DefaultValue = "0")]
public int Sort { get; set; }
/// <summary>是否默认引擎(应用层保证唯一)</summary>
[SugarColumn(DefaultValue = "0")]
public bool IsDefault { get; set; }
}
+44
View File
@@ -0,0 +1,44 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>用户设置(单行记录,Id 固定为 1</summary>
[SugarTable("settings")]
public class Setting : BaseEntity
{
/// <summary>主题模式:dark | light | auto</summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "dark")]
public string ThemeMode { get; set; } = "dark";
/// <summary>主色调(HEX 字符串)</summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "#6c5ce7")]
public string AccentColor { get; set; } = "#6c5ce7";
/// <summary>背景图:预设 keywp1..wp6)或自定义 URL</summary>
[SugarColumn(Length = 512, IsNullable = true, DefaultValue = "wp1")]
public string? BackgroundImage { get; set; }
/// <summary>背景类型:preset | custom | solid</summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "preset")]
public string BackgroundType { get; set; } = "preset";
/// <summary>链接打开方式:1 = 新选项卡(默认);0 = 当前选项卡。底层用 int 存储以兼容 SqlSugar + SQLite。</summary>
[SugarColumn(IsNullable = false, DefaultValue = "1")]
public int OpenLinksInNewTab { get; set; } = 1;
/// <summary>搜索框行为:1 = 搜索结果在新选项卡打开(默认);0 = 当前选项卡打开(P46)。</summary>
[SugarColumn(IsNullable = false, DefaultValue = "1")]
public int OpenSearchInNewTab { get; set; } = 1;
/// <summary>是否启用 360 在线壁纸模式(P34):0 = 关闭(默认,使用预设/自定义背景),1 = 开启(按分类随机 + 定时切换)</summary>
[SugarColumn(IsNullable = false, DefaultValue = "0")]
public int WallpaperEnabled { get; set; } = 0;
/// <summary>360 壁纸分类 IDP34),例如 "36"。空字符串表示「全部/推荐」。</summary>
[SugarColumn(Length = 32, IsNullable = true, DefaultValue = "")]
public string? WallpaperCategoryId { get; set; } = "";
/// <summary>壁纸自动切换间隔(分钟,P34)。默认 30。0 表示不自动切换,仅手动触发立即切换按钮。</summary>
[SugarColumn(IsNullable = false, DefaultValue = "30")]
public int WallpaperInterval { get; set; } = 30;
}
+27
View File
@@ -0,0 +1,27 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>同步日志:每次增删改都写一条,前端通过 since=timestamp 拉取增量</summary>
[SugarTable("sync_log")]
public class SyncLog
{
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>实体类型:category | bookmark | search_engine | setting</summary>
[SugarColumn(Length = 32, IsNullable = false, IndexGroupNameList = new[] { "idx_synclog_type" })]
public string EntityType { get; set; } = string.Empty;
/// <summary>实体 ID</summary>
[SugarColumn(IsNullable = false, IndexGroupNameList = new[] { "idx_synclog_type" })]
public int EntityId { get; set; }
/// <summary>操作:create | update | delete</summary>
[SugarColumn(Length = 16, IsNullable = false)]
public string Operation { get; set; } = "update";
/// <summary>变更时间(UTC</summary>
[SugarColumn(IsNullable = false)]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
+32
View File
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MyHomePage.Api</RootNamespace>
<AssemblyName>MyHomePage.Api</AssemblyName>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- SqlSugar 多数据库 ORM -->
<PackageReference Include="SqlSugarCore" Version="5.1.4.171" />
<!-- MySQL 驱动 -->
<PackageReference Include="MySqlConnector" Version="2.3.7" />
<!-- SQLite 驱动 -->
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
<!-- Swagger / OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<!-- 启动时排除 Uploads 目录中的所有文件 -->
<None Update="Uploads\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+140
View File
@@ -0,0 +1,140 @@
using Microsoft.Extensions.Options;
using MyHomePage.Api.Common;
using MyHomePage.Api.Infrastructure.Configuration;
using MyHomePage.Api.Infrastructure.Database;
using MyHomePage.Api.Services;
using SqlSugar;
var builder = WebApplication.CreateBuilder(args);
// ===== 配置节点绑定 =====
builder.Services.Configure<DatabaseOptions>(builder.Configuration.GetSection(DatabaseOptions.SectionName));
builder.Services.Configure<UploadOptions>(builder.Configuration.GetSection(UploadOptions.SectionName));
builder.Services.Configure<CorsOptions>(builder.Configuration.GetSection(CorsOptions.SectionName));
// ===== 控制器 =====
builder.Services.AddControllers()
.AddJsonOptions(o =>
{
// 前端约定使用 camelCase
o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
o.JsonSerializerOptions.DictionaryKeyPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
});
// ===== Swagger / OpenAPI =====
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "MyHomePage API", Version = "v1" });
// 注意:API 全部返回 ApiResponse<T>,类型签名会进 swagger
c.CustomOperationIds(apiDesc =>
{
// Controller Action 有 action 键,minimal APIMapGet / MapPost 等)没有
// 用 TryGetValue 防御,避免 KeyNotFoundException 启动崩
var routeValues = apiDesc.ActionDescriptor.RouteValues;
if (routeValues.TryGetValue("action", out var action)
&& !string.IsNullOrEmpty(action)
&& routeValues.TryGetValue("controller", out var controller)
&& !string.IsNullOrEmpty(controller))
{
return $"{controller}_{action}";
}
// minimal API 兜底:用路由模板做 operationId(去掉 / 和特殊字符)
// 例:/health → health/api/wallpaper/random → api_wallpaper_random
return apiDesc.RelativePath?
.Replace("/", "_", StringComparison.Ordinal)
.Trim('_')
?? "UnknownEndpoint";
});
});
// ===== CORS =====
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
{
if (corsOrigins.Length > 0)
p.WithOrigins(corsOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
else
p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
}));
// ===== SqlSugar 单例 =====
builder.Services.AddSingleton<SqlSugarContext>();
// ===== 服务依赖注入 =====
builder.Services.AddScoped<SyncLogHelper>();
builder.Services.AddScoped<ISqlSugarClient>(sp => sp.GetRequiredService<SqlSugarContext>().Db);
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBookmarkService, BookmarkService>();
builder.Services.AddScoped<ISearchEngineService, SearchEngineService>();
builder.Services.AddScoped<ISettingService, SettingService>();
builder.Services.AddScoped<IUploadService, UploadService>();
builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddScoped<DatabaseInitializer>();
// ===== P31favicon 自动抓取 =====
builder.Services.AddMemoryCache(); // IMemoryCache24h 缓存已抓 faviconSingleton
builder.Services.AddHttpClient(nameof(FaviconService), c => // 命名 HttpClientIHttpClientFactory 管理生命周期)
{
c.Timeout = TimeSpan.FromSeconds(5); // 全局 5s 超时(个别 GET 还会二次限制)
c.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36");
});
builder.Services.AddScoped<FaviconService>(); // 注入 IHttpClientFactory + IMemoryCache
// ===== P34360 在线壁纸代理 =====
builder.Services.AddHttpClient(nameof(WallpaperService), c =>
{
c.Timeout = TimeSpan.FromSeconds(10); // 拉分类 / 200 张池子给 10s 充裕
c.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36");
});
builder.Services.AddScoped<WallpaperService>();
// ===== 上传文件大小限制 =====
var maxUpload = builder.Configuration.GetValue<long?>("Upload:MaxSizeBytes") ?? 10L * 1024 * 1024;
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
{
o.MultipartBodyLengthLimit = maxUpload;
});
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = maxUpload);
var app = builder.Build();
// ===== 启动时初始化数据库(CodeFirst + 种子) =====
using (var scope = app.Services.CreateScope())
{
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await initializer.InitializeAsync();
}
// ===== HTTP Pipeline =====
app.UseMiddleware<ExceptionHandlingMiddleware>();
// 静态文件:暴露 Upload 目录
var uploadPath = Path.IsPathRooted(app.Configuration["Upload:Path"] ?? "Uploads")
? app.Configuration["Upload:Path"]!
: Path.Combine(app.Environment.ContentRootPath, app.Configuration["Upload:Path"] ?? "Uploads");
Directory.CreateDirectory(uploadPath);
app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadPath),
RequestPath = app.Configuration["Upload:BaseUrl"] ?? "/uploads",
ServeUnknownFileTypes = true
});
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
app.UseAuthorization();
app.MapControllers();
// 根路径健康检查
app.MapGet("/", () => Results.Ok(new { name = "MyHomePage API", version = "1.0.0", status = "ok" }));
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTimeOffset.UtcNow }));
app.Run();
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<DeleteExistingFiles>false</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>bin\Release\net8.0\publish\</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<ProjectGuid>55a8f953-c4cd-4c72-3c4b-a1fc5ba1847b</ProjectGuid>
<SelfContained>true</SelfContained>
</PropertyGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Linq.Expressions;
using MyHomePage.Api.Infrastructure.Database;
using SqlSugar;
namespace MyHomePage.Api.Repositories;
/// <summary>通用仓储接口:覆盖最常用的 CRUD 能力。</summary>
/// <typeparam name="T">实体类型</typeparam>
public interface IBaseRepository<T> where T : class, new()
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> ListAsync(Expression<Func<T, bool>>? where = null);
Task<int> InsertAsync(T entity);
Task<int> UpdateAsync(T entity);
Task<int> DeleteAsync(int id);
}
/// <summary>通用仓储实现:直接包装 SqlSugar 客户端。</summary>
public class BaseRepository<T> : IBaseRepository<T> where T : class, new()
{
protected readonly ISqlSugarClient Db;
public BaseRepository(SqlSugarContext ctx)
{
Db = ctx.Db;
}
public Task<T?> GetByIdAsync(int id) =>
Db.Queryable<T>().InSingleAsync(id);
public Task<List<T>> ListAsync(Expression<Func<T, bool>>? where = null) =>
where is null
? Db.Queryable<T>().ToListAsync()
: Db.Queryable<T>().Where(where).ToListAsync();
public Task<int> InsertAsync(T entity) =>
Db.Insertable(entity).ExecuteReturnIdentityAsync();
public Task<int> UpdateAsync(T entity) =>
Db.Updateable(entity).ExecuteCommandAsync();
public Task<int> DeleteAsync(int id) =>
Db.Deleteable<T>().In(id).ExecuteCommandAsync();
}
+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();
}
}
+1
View File
@@ -0,0 +1 @@
+13
View File
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"SqlSugar": "Debug"
}
},
"Database": {
"Provider": "Sqlite",
"ConnectionString": "Data Source=myhomepage.dev.db"
}
}
+67
View File
@@ -0,0 +1,67 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"SqlSugar": "Information"
}
},
"AllowedHosts": "*",
"Urls": "http://0.0.0.0:5080",
"Database": {
// ===== 当前默认:SQLite(开发 / 单机部署) =====
"Provider": "Sqlite",
"ConnectionString": "Data Source=myhomepage.db",
// ======================================================================
// 部署到 MySQL 时切换方式(任选其一,1Panel 推荐方式 C)
// ======================================================================
//
// 方式 A:直接修改本文件(不推荐,会被提交到 git,不便多环境管理)
// "Provider": "MySql",
// "ConnectionString": "server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=YOUR_PASSWORD;charset=utf8mb4;",
//
// 方式 B:新建 backend/appsettings.Production.json 覆盖 Database 节
// 内容:
// {
// "Database": {
// "Provider": "MySql",
// "ConnectionString": "server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=YOUR_PASSWORD;charset=utf8mb4;"
// }
// }
// 启动时设环境变量 ASPNETCORE_ENVIRONMENT=Production 即生效
//
// 方式 C(1Panel 推荐,零文件改动):
// 在 1Panel 网站详情页 → 「环境变量」里设:
// Database__Provider=MySql
// Database__ConnectionString=server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=xxx;charset=utf8mb4;
// ASP.NET Core 配置系统会自动用环境变量覆盖 appsettings.json 里的 Database 节
//
// 连接串参数说明:
// server MySQL 主机(1Panel 部署本机用 127.0.0.1;远程用实际 IP
// port 端口(默认 3306
// database 数据库名(需要先在 1Panel 数据库面板建好,utf8mb4 字符集)
// user 用户名(建议建专用用户,权限限定在 myhomepage 库,主机锁 localhost
// password 用户密码(如果含特殊字符需 URL encode# → %23@ → %40; → %3B 等)
// charset 字符集(务必 utf8mb4,否则 emoji 存不进)
// 其他可选 SslMode=None/RequiredPooling=trueTreatTinyAsBoolean=true 等
// ======================================================================
},
"Upload": {
// P52 修复:1Panel Docker 部署时改成 "/uploads"(绝对路径)让 volume 挂载生效
// - 容器内路径 = 宿主机 /data/myhomepage/upload
// - 容器销毁后 favicon 仍然保留
// - 不污染 /app 代码目录
// 本地 dev / 单机 SQLite 时用 "Uploads"(相对路径 = /app/Uploads)即可
"Path": "/uploads",
"BaseUrl": "/uploads",
"MaxSizeBytes": 10485760
},
"Cors": {
"Origins": [
"http://localhost:5173",
"http://localhost:4173",
"http://localhost:3000"
]
}
}
+1
View File
@@ -0,0 +1 @@
{"code":0,"message":"ok","data":[{"id":1,"parentId":0,"name":"常用工具","icon":"wrench","sort":0,"createdAt":"2026-07-04T09:58:39.5249273","updatedAt":"2026-07-04T09:58:39.5249273","children":[{"id":21,"parentId":1,"name":"测试","icon":"alarm-clock","sort":0,"createdAt":"2026-07-04T11:49:12.616178","updatedAt":"2026-07-04T11:49:12.616178","children":[]},{"id":22,"parentId":1,"name":"测试1","icon":"alert-circle","sort":0,"createdAt":"2026-07-04T11:50:36.1058327","updatedAt":"2026-07-04T11:50:36.1058327","children":[]},{"id":23,"parentId":1,"name":"测试2","icon":"alert-triangle","sort":0,"createdAt":"2026-07-04T11:51:47.9096876","updatedAt":"2026-07-04T11:51:47.9096876","children":[]},{"id":24,"parentId":1,"name":"测试3","icon":"aperture","sort":0,"createdAt":"2026-07-04T11:53:28.595894","updatedAt":"2026-07-04T11:53:28.595894","children":[]},{"id":25,"parentId":1,"name":"test_debug","icon":"layers","sort":0,"createdAt":"2026-07-04T11:57:49.3220922","updatedAt":"2026-07-04T11:57:49.3220922","children":[]},{"id":2,"parentId":1,"name":"AI 工具","icon":"bot","sort":1,"createdAt":"2026-07-04T09:58:39.5249273","updatedAt":"2026-07-04T09:58:39.5249273","children":[]},{"id":3,"parentId":1,"name":"开发工具","icon":"code-2","sort":2,"createdAt":"2026-07-04T09:58:39.5249273","updatedAt":"2026-07-04T09:58:39.5249273","children":[]}]}],"timestamp":1783166718609}
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}