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