Browse Source

更换efcore缓存中间件

懒得勤快 2 years ago
parent
commit
dc1a436533

+ 1 - 4
src/Masuit.MyBlogs.Core/Controllers/CategoryController.cs

@@ -2,7 +2,6 @@
 using Masuit.Tools.AspNetCore.ModelBinder;
 using Masuit.Tools.Models;
 using Microsoft.AspNetCore.Mvc;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -60,7 +59,6 @@ public sealed class CategoryController : BaseController
 		cat.ParentId = cmd.ParentId;
 		cat.Path = cmd.ParentId > 0 ? (CategoryService[cmd.ParentId.Value].Path + "," + cmd.ParentId).Trim(',') : SnowFlake.NewId;
 		bool b = await CategoryService.SaveChangesAsync() > 0;
-		QueryCacheManager.ExpireType<Category>();
 		return ResultData(null, b, b ? "分类修改成功!" : "分类修改失败!");
 	}
 
@@ -74,7 +72,6 @@ public sealed class CategoryController : BaseController
 	public async Task<ActionResult> Delete(int id, int cid = 1)
 	{
 		bool b = await CategoryService.Delete(id, cid);
-		QueryCacheManager.ExpireType<Category>();
 		return ResultData(null, b, b ? "分类删除成功" : "分类删除失败");
 	}
-}
+}

+ 289 - 289
src/Masuit.MyBlogs.Core/Controllers/HomeController.cs

@@ -14,7 +14,7 @@ using System.Linq.Dynamic.Core;
 using System.Runtime.InteropServices;
 using System.Text;
 using System.Text.RegularExpressions;
-using Z.EntityFramework.Plus;
+using EFCoreSecondLevelCacheInterceptor;
 using Configuration = AngleSharp.Configuration;
 
 namespace Masuit.MyBlogs.Core.Controllers;
@@ -24,315 +24,315 @@ namespace Masuit.MyBlogs.Core.Controllers;
 /// </summary>
 public sealed class HomeController : BaseController
 {
-    /// <summary>
-    /// 文章
-    /// </summary>
-    public IPostService PostService { get; set; }
+	/// <summary>
+	/// 文章
+	/// </summary>
+	public IPostService PostService { get; set; }
 
-    /// <summary>
-    /// 分类
-    /// </summary>
-    public ICategoryService CategoryService { get; set; }
+	/// <summary>
+	/// 分类
+	/// </summary>
+	public ICategoryService CategoryService { get; set; }
 
-    /// <summary>
-    /// 网站公告
-    /// </summary>
-    public INoticeService NoticeService { get; set; }
+	/// <summary>
+	/// 网站公告
+	/// </summary>
+	public INoticeService NoticeService { get; set; }
 
-    /// <summary>
-    /// 首页
-    /// </summary>
-    /// <returns></returns>
-    [ResponseCache(Duration = 600, VaryByHeader = nameof(HeaderNames.Cookie))]
-    public async Task<ActionResult> Index([FromServices] IFastShareService fastShareService)
-    {
-        var banners = AdsService.GetsByWeightedPrice(8, AdvertiseType.Banner, Request.Location()).OrderByRandom().ToPooledListScope();
-        var fastShares = fastShareService.GetAllFromCache(s => s.Sort);
-        var postsQuery = PostService.GetQuery(PostBaseWhere()); //准备文章的查询
-        var posts = await postsQuery.Where(p => !p.IsFixedTop).OrderBy(OrderBy.ModifyDate.GetDisplay() + " desc").ToPagedListAsync<Post, PostDto>(1, 15, MapperConfig);
-        posts.Data.InsertRange(0, postsQuery.Where(p => p.IsFixedTop).OrderByDescending(p => p.ModifyDate).ProjectTo<PostDto>(MapperConfig).FromCache().ToPooledListScope());
-        var viewModel = GetIndexPageViewModel();
-        viewModel.Banner = banners;
-        viewModel.Posts = posts;
-        ViewBag.FastShare = fastShares;
-        viewModel.PageParams = new Pagination(1, 15, posts.TotalCount, OrderBy.ModifyDate);
-        viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location());
-        viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location());
-        PostService.SolvePostsCategory(posts.Data);
-        foreach (var item in posts.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        }
+	/// <summary>
+	/// 首页
+	/// </summary>
+	/// <returns></returns>
+	[ResponseCache(Duration = 600, VaryByHeader = nameof(HeaderNames.Cookie))]
+	public async Task<ActionResult> Index([FromServices] IFastShareService fastShareService)
+	{
+		var banners = AdsService.GetsByWeightedPrice(8, AdvertiseType.Banner, Request.Location()).OrderByRandom().ToPooledListScope();
+		var fastShares = fastShareService.GetAllFromCache(s => s.Sort);
+		var postsQuery = PostService.GetQuery(PostBaseWhere()); //准备文章的查询
+		var posts = await postsQuery.Where(p => !p.IsFixedTop).OrderBy(OrderBy.ModifyDate.GetDisplay() + " desc").ToPagedListAsync<Post, PostDto>(1, 15, MapperConfig);
+		posts.Data.InsertRange(0, postsQuery.Where(p => p.IsFixedTop).OrderByDescending(p => p.ModifyDate).ProjectTo<PostDto>(MapperConfig).Cacheable().ToPooledListScope());
+		var viewModel = GetIndexPageViewModel();
+		viewModel.Banner = banners;
+		viewModel.Posts = posts;
+		ViewBag.FastShare = fastShares;
+		viewModel.PageParams = new Pagination(1, 15, posts.TotalCount, OrderBy.ModifyDate);
+		viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location());
+		viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location());
+		PostService.SolvePostsCategory(posts.Data);
+		foreach (var item in posts.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		}
 
-        return View(viewModel);
-    }
+		return View(viewModel);
+	}
 
-    /// <summary>
-    /// 文章列表页
-    /// </summary>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <param name="orderBy"></param>
-    /// <returns></returns>
-    [Route("posts"), Route("p", Order = 1), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
-    public async Task<ActionResult> Post([Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
-    {
-        page = Math.Max(1, page);
-        var viewModel = GetIndexPageViewModel();
-        var postsQuery = PostService.GetQuery(PostBaseWhere()); //准备文章的查询
-        var h24 = DateTime.Today.AddDays(-1);
-        var posts = orderBy switch
-        {
-            OrderBy.Trending => await postsQuery.Where(p => !p.IsFixedTop).OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(t => t.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
-            _ => await postsQuery.Where(p => !p.IsFixedTop).OrderBy((orderBy ?? OrderBy.ModifyDate).GetDisplay() + " desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
-        };
-        if (page == 1)
-        {
-            posts.Data.InsertRange(0, postsQuery.Where(p => p.IsFixedTop).OrderByDescending(p => p.ModifyDate).ProjectTo<PostDto>(MapperConfig));
-        }
+	/// <summary>
+	/// 文章列表页
+	/// </summary>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <param name="orderBy"></param>
+	/// <returns></returns>
+	[Route("posts"), Route("p", Order = 1), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
+	public async Task<ActionResult> Post([Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
+	{
+		page = Math.Max(1, page);
+		var viewModel = GetIndexPageViewModel();
+		var postsQuery = PostService.GetQuery(PostBaseWhere()); //准备文章的查询
+		var h24 = DateTime.Today.AddDays(-1);
+		var posts = orderBy switch
+		{
+			OrderBy.Trending => await postsQuery.Where(p => !p.IsFixedTop).OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(t => t.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
+			_ => await postsQuery.Where(p => !p.IsFixedTop).OrderBy((orderBy ?? OrderBy.ModifyDate).GetDisplay() + " desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
+		};
+		if (page == 1)
+		{
+			posts.Data.InsertRange(0, postsQuery.Where(p => p.IsFixedTop).OrderByDescending(p => p.ModifyDate).ProjectTo<PostDto>(MapperConfig));
+		}
 
-        viewModel.Posts = posts;
-        viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
-        viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location());
-        viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location());
-        PostService.SolvePostsCategory(posts.Data);
-        foreach (var item in posts.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        }
+		viewModel.Posts = posts;
+		viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
+		viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location());
+		viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location());
+		PostService.SolvePostsCategory(posts.Data);
+		foreach (var item in posts.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		}
 
-        return View(viewModel);
-    }
+		return View(viewModel);
+	}
 
-    /// <summary>
-    /// 标签文章页
-    /// </summary>
-    /// <param name="tag"></param>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <param name="orderBy"></param>
-    /// <returns></returns>
-    [Route("tag/{tag}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
-    public async Task<ActionResult> Tag(string tag, [Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
-    {
-        page = Math.Max(1, page);
-        if (string.IsNullOrWhiteSpace(tag))
-        {
-            throw new NotFoundException("");
-        }
+	/// <summary>
+	/// 标签文章页
+	/// </summary>
+	/// <param name="tag"></param>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <param name="orderBy"></param>
+	/// <returns></returns>
+	[Route("tag/{tag}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
+	public async Task<ActionResult> Tag(string tag, [Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
+	{
+		page = Math.Max(1, page);
+		if (string.IsNullOrWhiteSpace(tag))
+		{
+			throw new NotFoundException("");
+		}
 
-        var where = PostBaseWhere();
-        var queryable = PostService.GetQuery(tag.Split(",", StringSplitOptions.RemoveEmptyEntries).Select(s => Regex.Escape(s.Trim())).Aggregate(where, (current, s) => current.And(p => Regex.IsMatch(p.Label, s, RegexOptions.IgnoreCase))));
-        var h24 = DateTime.Today.AddDays(-1);
-        var posts = orderBy switch
-        {
-            OrderBy.Trending => await queryable.OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(e => e.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
-            _ => await queryable.OrderBy($"{nameof(PostDto.IsFixedTop)} desc,{(orderBy ?? OrderBy.ModifyDate).GetDisplay()} desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
-        };
-        var viewModel = GetIndexPageViewModel();
-        ViewBag.Tag = tag;
-        viewModel.Posts = posts;
-        viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
-        viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location(), keywords: tag);
-        viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location(), keywords: tag);
-        PostService.SolvePostsCategory(posts.Data);
-        foreach (var item in posts.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        }
+		var where = PostBaseWhere();
+		var queryable = PostService.GetQuery(tag.Split(",", StringSplitOptions.RemoveEmptyEntries).Select(s => Regex.Escape(s.Trim())).Aggregate(where, (current, s) => current.And(p => Regex.IsMatch(p.Label, s, RegexOptions.IgnoreCase))));
+		var h24 = DateTime.Today.AddDays(-1);
+		var posts = orderBy switch
+		{
+			OrderBy.Trending => await queryable.OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(e => e.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
+			_ => await queryable.OrderBy($"{nameof(PostDto.IsFixedTop)} desc,{(orderBy ?? OrderBy.ModifyDate).GetDisplay()} desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
+		};
+		var viewModel = GetIndexPageViewModel();
+		ViewBag.Tag = tag;
+		viewModel.Posts = posts;
+		viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
+		viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location(), keywords: tag);
+		viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location(), keywords: tag);
+		PostService.SolvePostsCategory(posts.Data);
+		foreach (var item in posts.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		}
 
-        return View(viewModel);
-    }
+		return View(viewModel);
+	}
 
-    /// <summary>
-    /// 存档文章页
-    /// </summary>
-    /// <param name="yyyy"></param>
-    /// <param name="mm"></param>
-    /// <param name="dd"></param>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <param name="orderBy"></param>
-    /// <returns></returns>
-    [Route("{yyyy:int}/{mm:int}/{dd:int}/{mode}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
-    public async Task<ActionResult> Archieve([Range(2010, 2099)] int yyyy, [Range(1, 12)] int mm, [Range(1, 31)] int dd, [Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15, string mode = nameof(Models.Entity.Post.ModifyDate))
-    {
-        page = Math.Max(1, page);
-        if (!DateTime.TryParse(yyyy + "-" + mm + "-" + dd, out var date))
-        {
-            date = DateTime.Today;
-        }
+	/// <summary>
+	/// 存档文章页
+	/// </summary>
+	/// <param name="yyyy"></param>
+	/// <param name="mm"></param>
+	/// <param name="dd"></param>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <param name="orderBy"></param>
+	/// <returns></returns>
+	[Route("{yyyy:int}/{mm:int}/{dd:int}/{mode}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
+	public async Task<ActionResult> Archieve([Range(2010, 2099)] int yyyy, [Range(1, 12)] int mm, [Range(1, 31)] int dd, [Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15, string mode = nameof(Models.Entity.Post.ModifyDate))
+	{
+		page = Math.Max(1, page);
+		if (!DateTime.TryParse(yyyy + "-" + mm + "-" + dd, out var date))
+		{
+			date = DateTime.Today;
+		}
 
-        var where = mode switch
-        {
-            nameof(Models.Entity.Post.PostDate) => PostBaseWhere().And(p => p.PostDate.Date == date),
-            _ => PostBaseWhere().And(p => p.ModifyDate.Date == date),
-        };
-        var queryable = PostService.GetQuery(where);
-        var h24 = DateTime.Today.AddDays(-1);
-        var posts = orderBy switch
-        {
-            OrderBy.Trending => await queryable.OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(e => e.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
-            _ => await queryable.OrderBy($"{nameof(PostDto.IsFixedTop)} desc,{(orderBy ?? OrderBy.ModifyDate).GetDisplay()} desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
-        };
-        var viewModel = GetIndexPageViewModel();
-        viewModel.Posts = posts;
-        viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
-        viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location());
-        viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location());
-        PostService.SolvePostsCategory(posts.Data);
-        foreach (var item in posts.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        }
+		var where = mode switch
+		{
+			nameof(Models.Entity.Post.PostDate) => PostBaseWhere().And(p => p.PostDate.Date == date),
+			_ => PostBaseWhere().And(p => p.ModifyDate.Date == date),
+		};
+		var queryable = PostService.GetQuery(where);
+		var h24 = DateTime.Today.AddDays(-1);
+		var posts = orderBy switch
+		{
+			OrderBy.Trending => await queryable.OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(e => e.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
+			_ => await queryable.OrderBy($"{nameof(PostDto.IsFixedTop)} desc,{(orderBy ?? OrderBy.ModifyDate).GetDisplay()} desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
+		};
+		var viewModel = GetIndexPageViewModel();
+		viewModel.Posts = posts;
+		viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
+		viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location());
+		viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location());
+		PostService.SolvePostsCategory(posts.Data);
+		foreach (var item in posts.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		}
 
-        return View(viewModel);
-    }
+		return View(viewModel);
+	}
 
-    /// <summary>
-    /// 作者文章页
-    /// </summary>
-    /// <param name="author"></param>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <param name="orderBy"></param>
-    /// <returns></returns>
-    [Route("author/{author}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
-    public async Task<ActionResult> Author(string author, [Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
-    {
-        page = Math.Max(1, page);
-        Expression<Func<Post, bool>> where = PostBaseWhere().And(p => p.Author.Equals(author) || p.Modifier.Equals(author) || p.Email.Equals(author) || p.PostHistoryVersion.Any(v => v.Modifier.Equals(author) || v.ModifierEmail.Equals(author)));
-        var h24 = DateTime.Today.AddDays(-1);
-        var posts = orderBy switch
-        {
-            OrderBy.Trending => await PostService.GetQuery(where).OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(e => e.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
-            _ => await PostService.GetQuery(where).OrderBy($"{nameof(PostDto.IsFixedTop)} desc,{(orderBy ?? OrderBy.ModifyDate).GetDisplay()} desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
-        };
-        var viewModel = GetIndexPageViewModel();
-        ViewBag.Author = author;
-        viewModel.Posts = posts;
-        viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
-        viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location());
-        viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location());
-        PostService.SolvePostsCategory(posts.Data);
-        foreach (var item in posts.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        }
+	/// <summary>
+	/// 作者文章页
+	/// </summary>
+	/// <param name="author"></param>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <param name="orderBy"></param>
+	/// <returns></returns>
+	[Route("author/{author}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
+	public async Task<ActionResult> Author(string author, [Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
+	{
+		page = Math.Max(1, page);
+		Expression<Func<Post, bool>> where = PostBaseWhere().And(p => p.Author.Equals(author) || p.Modifier.Equals(author) || p.Email.Equals(author) || p.PostHistoryVersion.Any(v => v.Modifier.Equals(author) || v.ModifierEmail.Equals(author)));
+		var h24 = DateTime.Today.AddDays(-1);
+		var posts = orderBy switch
+		{
+			OrderBy.Trending => await PostService.GetQuery(where).OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(e => e.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
+			_ => await PostService.GetQuery(where).OrderBy($"{nameof(PostDto.IsFixedTop)} desc,{(orderBy ?? OrderBy.ModifyDate).GetDisplay()} desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
+		};
+		var viewModel = GetIndexPageViewModel();
+		ViewBag.Author = author;
+		viewModel.Posts = posts;
+		viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
+		viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location());
+		viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location());
+		PostService.SolvePostsCategory(posts.Data);
+		foreach (var item in posts.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		}
 
-        return View(viewModel);
-    }
+		return View(viewModel);
+	}
 
-    /// <summary>
-    /// 分类文章页
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <param name="orderBy"></param>
-    /// <returns></returns>
-    [Route("cat/{id:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
-    public async Task<ActionResult> Category(int id, [Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
-    {
-        page = Math.Max(1, page);
-        var cat = await CategoryService.GetByIdAsync(id) ?? throw new NotFoundException("文章分类未找到");
-        var cids = cat.Flatten().Select(c => c.Id).ToArray();
-        var h24 = DateTime.Today.AddDays(-1);
-        var posts = orderBy switch
-        {
-            OrderBy.Trending => await PostService.GetQuery(PostBaseWhere().And(p => cids.Contains(p.CategoryId))).OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(e => e.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
-            _ => await PostService.GetQuery(PostBaseWhere().And(p => cids.Contains(p.CategoryId))).OrderBy($"{nameof(PostDto.IsFixedTop)} desc,{(orderBy ?? OrderBy.ModifyDate).GetDisplay()} desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
-        };
-        var viewModel = GetIndexPageViewModel();
-        viewModel.Posts = posts;
-        ViewBag.Category = cat;
-        viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
-        viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location(), id);
-        viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location(), id);
-        PostService.SolvePostsCategory(posts.Data);
-        foreach (var item in posts.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        }
+	/// <summary>
+	/// 分类文章页
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <param name="orderBy"></param>
+	/// <returns></returns>
+	[Route("cat/{id:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "orderBy" }, VaryByHeader = nameof(HeaderNames.Cookie))]
+	public async Task<ActionResult> Category(int id, [Optional] OrderBy? orderBy, int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
+	{
+		page = Math.Max(1, page);
+		var cat = await CategoryService.GetByIdAsync(id) ?? throw new NotFoundException("文章分类未找到");
+		var cids = cat.Flatten().Select(c => c.Id).ToArray();
+		var h24 = DateTime.Today.AddDays(-1);
+		var posts = orderBy switch
+		{
+			OrderBy.Trending => await PostService.GetQuery(PostBaseWhere().And(p => cids.Contains(p.CategoryId))).OrderByDescending(p => p.PostVisitRecordStats.Where(e => e.Date >= h24).Sum(e => e.Count)).ToPagedListAsync<Post, PostDto>(page, size, MapperConfig),
+			_ => await PostService.GetQuery(PostBaseWhere().And(p => cids.Contains(p.CategoryId))).OrderBy($"{nameof(PostDto.IsFixedTop)} desc,{(orderBy ?? OrderBy.ModifyDate).GetDisplay()} desc").ToPagedListAsync<Post, PostDto>(page, size, MapperConfig)
+		};
+		var viewModel = GetIndexPageViewModel();
+		viewModel.Posts = posts;
+		ViewBag.Category = cat;
+		viewModel.PageParams = new Pagination(page, size, posts.TotalCount, orderBy);
+		viewModel.SidebarAds = AdsService.GetsByWeightedPrice(2, AdvertiseType.SideBar, Request.Location(), id);
+		viewModel.ListAdvertisement = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location(), id);
+		PostService.SolvePostsCategory(posts.Data);
+		foreach (var item in posts.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		}
 
-        return View(viewModel);
-    }
+		return View(viewModel);
+	}
 
-    /// <summary>
-    /// 切换语言
-    /// </summary>
-    /// <param name="lang"></param>
-    /// <returns></returns>
-    [Route("lang/{lang}")]
-    public ActionResult SetLang(string lang)
-    {
-        Response.Cookies.Append("lang", lang, new CookieOptions()
-        {
-            Expires = DateTime.Now.AddYears(1),
-        });
-        var referer = Request.Headers[HeaderNames.Referer].ToString();
-        return Redirect(string.IsNullOrEmpty(referer) ? "/" : referer);
-    }
+	/// <summary>
+	/// 切换语言
+	/// </summary>
+	/// <param name="lang"></param>
+	/// <returns></returns>
+	[Route("lang/{lang}")]
+	public ActionResult SetLang(string lang)
+	{
+		Response.Cookies.Append("lang", lang, new CookieOptions()
+		{
+			Expires = DateTime.Now.AddYears(1),
+		});
+		var referer = Request.Headers[HeaderNames.Referer].ToString();
+		return Redirect(string.IsNullOrEmpty(referer) ? "/" : referer);
+	}
 
-    /// <summary>
-    /// 站点地图
-    /// </summary>
-    /// <param name="env"></param>
-    /// <param name="ext"></param>
-    /// <returns></returns>
-    [HttpGet("/sitemaps.{ext}")]
-    public async Task<ActionResult> Sitemap([FromServices] IWebHostEnvironment env, string ext)
-    {
-        var sitemap = Path.Combine(env.WebRootPath, "sitemap." + ext);
-        if (System.IO.File.Exists(sitemap))
-        {
-            var fs = new FileInfo(sitemap).ShareReadWrite();
-            switch (ext)
-            {
-                case "txt":
-                    return Content((await fs.ReadAllLinesAsync(Encoding.UTF8)).Select(s => Request.Scheme + "://" + Request.Host.Host + new Uri(s).GetComponents(UriComponents.PathAndQuery, UriFormat.UriEscaped)).Join("\r\n"), ContentType.Txt);
+	/// <summary>
+	/// 站点地图
+	/// </summary>
+	/// <param name="env"></param>
+	/// <param name="ext"></param>
+	/// <returns></returns>
+	[HttpGet("/sitemaps.{ext}")]
+	public async Task<ActionResult> Sitemap([FromServices] IWebHostEnvironment env, string ext)
+	{
+		var sitemap = Path.Combine(env.WebRootPath, "sitemap." + ext);
+		if (System.IO.File.Exists(sitemap))
+		{
+			var fs = new FileInfo(sitemap).ShareReadWrite();
+			switch (ext)
+			{
+				case "txt":
+					return Content((await fs.ReadAllLinesAsync(Encoding.UTF8)).Select(s => Request.Scheme + "://" + Request.Host.Host + new Uri(s).GetComponents(UriComponents.PathAndQuery, UriFormat.UriEscaped)).Join("\r\n"), ContentType.Txt);
 
-                case "html":
-                    var context = BrowsingContext.New(Configuration.Default);
-                    var doc = await context.OpenAsync(req => req.Content(fs.ReadAllText(Encoding.UTF8)));
-                    foreach (var e in doc.Body.QuerySelectorAll("li a"))
-                    {
-                        e.SetAttribute("href", Request.Scheme + "://" + Request.Host.Host + new Uri(e.GetAttribute("href")).GetComponents(UriComponents.PathAndQuery, UriFormat.UriEscaped));
-                    }
+				case "html":
+					var context = BrowsingContext.New(Configuration.Default);
+					var doc = await context.OpenAsync(req => req.Content(fs.ReadAllText(Encoding.UTF8)));
+					foreach (var e in doc.Body.QuerySelectorAll("li a"))
+					{
+						e.SetAttribute("href", Request.Scheme + "://" + Request.Host.Host + new Uri(e.GetAttribute("href")).GetComponents(UriComponents.PathAndQuery, UriFormat.UriEscaped));
+					}
 
-                    return Content(doc.DocumentElement.OuterHtml, ContentType.Html);
-            }
-            return File("/sitemap." + ext, new MimeMapper().GetMimeFromExtension("." + ext));
-        }
+					return Content(doc.DocumentElement.OuterHtml, ContentType.Html);
+			}
+			return File("/sitemap." + ext, new MimeMapper().GetMimeFromExtension("." + ext));
+		}
 
-        return NotFound();
-    }
+		return NotFound();
+	}
 
-    /// <summary>
-    /// 获取页面视图模型
-    /// </summary>
-    /// <returns></returns>
-    private HomePageViewModel GetIndexPageViewModel()
-    {
-        var postsQuery = PostService.GetQuery<PostDto>(PostBaseWhere()); //准备文章的查询
-        var notices = NoticeService.GetPagesFromCache<DateTime, NoticeDto>(1, 5, n => n.NoticeStatus == NoticeStatus.Normal, n => n.ModifyDate, false); //加载前5条公告
-        var cats = CategoryService.GetQuery(c => c.Status == Status.Available && c.Post.Count > 0).Include(c => c.Parent).OrderBy(c => c.Name).ThenBy(c => c.Path).AsNoTracking().FromCache().ToPooledListScope(); //加载分类目录
-        var hotSearches = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Week").AsNotNull().Take(10).ToPooledListScope(); //热词统计
-        var hot5Post = postsQuery.OrderBy((new Random().Next() % 3) switch
-        {
-            1 => nameof(OrderBy.VoteUpCount),
-            2 => nameof(OrderBy.AverageViewCount),
-            _ => nameof(OrderBy.TotalViewCount)
-        } + " desc").Skip(0).Take(5).FromCache().ToPooledListScope(); //热门文章
-        var tagdic = PostService.GetTags().OrderByRandom().Take(20).ToDictionary(x => x.Key, x => Math.Min(x.Value + 12, 32)); //统计标签
-        return new HomePageViewModel
-        {
-            Categories = Mapper.Map<List<CategoryDto_P>>(cats.ToTree(c => c.Id, c => c.ParentId).Flatten()),
-            HotSearch = hotSearches,
-            Notices = notices.Data,
-            Tags = tagdic,
-            Top5Post = hot5Post,
-            PostsQueryable = postsQuery
-        };
-    }
+	/// <summary>
+	/// 获取页面视图模型
+	/// </summary>
+	/// <returns></returns>
+	private HomePageViewModel GetIndexPageViewModel()
+	{
+		var postsQuery = PostService.GetQuery<PostDto>(PostBaseWhere()); //准备文章的查询
+		var notices = NoticeService.GetPagesFromCache<DateTime, NoticeDto>(1, 5, n => n.NoticeStatus == NoticeStatus.Normal, n => n.ModifyDate, false); //加载前5条公告
+		var cats = CategoryService.GetQuery(c => c.Status == Status.Available && c.Post.Count > 0).Include(c => c.Parent).OrderBy(c => c.Name).ThenBy(c => c.Path).AsNoTracking().Cacheable().ToPooledListScope(); //加载分类目录
+		var hotSearches = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Week").AsNotNull().Take(10).ToPooledListScope(); //热词统计
+		var hot5Post = postsQuery.OrderBy((new Random().Next() % 3) switch
+		{
+			1 => nameof(OrderBy.VoteUpCount),
+			2 => nameof(OrderBy.AverageViewCount),
+			_ => nameof(OrderBy.TotalViewCount)
+		} + " desc").Skip(0).Take(5).Cacheable().ToPooledListScope(); //热门文章
+		var tagdic = PostService.GetTags().OrderByRandom().Take(20).ToDictionary(x => x.Key, x => Math.Min(x.Value + 12, 32)); //统计标签
+		return new HomePageViewModel
+		{
+			Categories = Mapper.Map<List<CategoryDto_P>>(cats.ToTree(c => c.Id, c => c.ParentId).Flatten()),
+			HotSearch = hotSearches,
+			Notices = notices.Data,
+			Tags = tagdic,
+			Top5Post = hot5Post,
+			PostsQueryable = postsQuery
+		};
+	}
 }

+ 3 - 6
src/Masuit.MyBlogs.Core/Controllers/LinksController.cs

@@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc;
 using Microsoft.EntityFrameworkCore;
 using System.Text;
 using Dispose.Scope;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -16,7 +15,9 @@ namespace Masuit.MyBlogs.Core.Controllers;
 public sealed class LinksController : BaseController
 {
 	public IHttpClientFactory HttpClientFactory { get; set; }
+
 	public IConfiguration Configuration { get; set; }
+
 	private HttpClient HttpClient => HttpClientFactory.CreateClient();
 
 	/// <summary>
@@ -89,7 +90,6 @@ public sealed class LinksController : BaseController
 			}
 
 			var b = LinksService.AddEntitySaved(link) != null;
-			QueryCacheManager.ExpireType<Links>();
 			return ResultData(null, b, b ? "添加成功!这可能有一定的延迟,如果没有看到您的链接,请稍等几分钟后刷新页面即可,如有疑问,请联系站长。" : "添加失败!这可能是由于网站服务器内部发生了错误,如有疑问,请联系站长。");
 		});
 	}
@@ -103,7 +103,6 @@ public sealed class LinksController : BaseController
 	public async Task<ActionResult> Save([FromBodyOrDefault] Links links)
 	{
 		bool b = await LinksService.AddOrUpdateSavedAsync(l => l.Id, links) > 0;
-		QueryCacheManager.ExpireType<Links>();
 		return b ? ResultData(null, message: "添加成功!") : ResultData(null, false, "添加失败!");
 	}
 
@@ -149,7 +148,6 @@ public sealed class LinksController : BaseController
 	public async Task<ActionResult> Delete(int id)
 	{
 		bool b = await LinksService.DeleteByIdAsync(id) > 0;
-		QueryCacheManager.ExpireType<Links>();
 		return ResultData(null, b, b ? "删除成功!" : "删除失败!");
 	}
 
@@ -200,7 +198,6 @@ public sealed class LinksController : BaseController
 	public async Task<ActionResult> Toggle(int id)
 	{
 		var b = await LinksService.GetQuery(m => m.Id == id).ExecuteUpdateAsync(s => s.SetProperty(e => e.Status, m => m.Status == Status.Unavailable ? Status.Available : Status.Unavailable)) > 0;
-		QueryCacheManager.ExpireType<Links>();
 		return ResultData(null, b, b ? "切换成功!" : "切换失败!");
 	}
-}
+}

+ 1 - 3
src/Masuit.MyBlogs.Core/Controllers/MenuController.cs

@@ -1,7 +1,6 @@
 using Masuit.Tools.AspNetCore.ModelBinder;
 using Masuit.Tools.Models;
 using Microsoft.AspNetCore.Mvc;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -80,7 +79,6 @@ public sealed class MenuController : AdminController
 		Mapper.Map(model, m);
 		m.Path = model.ParentId > 0 ? (MenuService[model.ParentId.Value].Path + "," + model.ParentId).Trim(',') : SnowFlake.NewId;
 		bool b = await MenuService.SaveChangesAsync() > 0;
-		QueryCacheManager.ExpireType<Menu>();
 		return ResultData(null, b, b ? "修改成功" : "修改失败");
 	}
-}
+}

+ 1 - 6
src/Masuit.MyBlogs.Core/Controllers/NoticeController.cs

@@ -5,7 +5,6 @@ using Masuit.MyBlogs.Core.Models;
 using Masuit.Tools.AspNetCore.ModelBinder;
 using Microsoft.AspNetCore.Mvc;
 using System.ComponentModel.DataAnnotations;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -87,7 +86,6 @@ public sealed class NoticeController : BaseController
 		}
 
 		var e = NoticeService.AddEntitySaved(notice);
-		QueryCacheManager.ExpireType<Notice>();
 		return e != null ? ResultData(null, message: "发布成功") : ResultData(null, false, "发布失败");
 	}
 
@@ -100,7 +98,6 @@ public sealed class NoticeController : BaseController
 	public async Task<ActionResult> Delete(int id)
 	{
 		bool b = await NoticeService.DeleteByIdAsync(id) > 0;
-		QueryCacheManager.ExpireType<Notice>();
 		return ResultData(null, b, b ? "删除成功" : "删除失败");
 	}
 
@@ -115,7 +112,6 @@ public sealed class NoticeController : BaseController
 		var notice = await NoticeService.GetByIdAsync(id) ?? throw new NotFoundException("公告未找到");
 		notice.NoticeStatus = notice.NoticeStatus == NoticeStatus.Normal ? NoticeStatus.Expired : NoticeStatus.Normal;
 		var b = await NoticeService.SaveChangesAsync() > 0;
-		QueryCacheManager.ExpireType<Notice>();
 		return ResultData(null, b, notice.NoticeStatus == NoticeStatus.Normal ? $"【{notice.Title}】已上架!" : $"【{notice.Title}】已下架!");
 	}
 
@@ -145,7 +141,6 @@ public sealed class NoticeController : BaseController
 		entity.StrongAlert = notice.StrongAlert;
 		entity.Content = await ImagebedClient.ReplaceImgSrc(await notice.Content.ClearImgAttributes(), cancellationToken);
 		bool b = await NoticeService.SaveChangesAsync() > 0;
-		QueryCacheManager.ExpireType<Notice>();
 		return ResultData(null, b, b ? "修改成功" : "修改失败");
 	}
 
@@ -214,4 +209,4 @@ public sealed class NoticeController : BaseController
 		});
 		return ResultData(dto);
 	}
-}
+}

+ 1296 - 1296
src/Masuit.MyBlogs.Core/Controllers/PostController.cs

@@ -25,8 +25,8 @@ using System.Linq.Dynamic.Core;
 using System.Net;
 using System.Text;
 using System.Text.RegularExpressions;
+using EFCoreSecondLevelCacheInterceptor;
 using Masuit.Tools.Mime;
-using Z.EntityFramework.Plus;
 using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode;
 
 namespace Masuit.MyBlogs.Core.Controllers;
@@ -36,1299 +36,1299 @@ namespace Masuit.MyBlogs.Core.Controllers;
 /// </summary>
 public sealed class PostController : BaseController
 {
-    public IPostService PostService { get; set; }
-
-    public ICategoryService CategoryService { get; set; }
-
-    public ISeminarService SeminarService { get; set; }
-
-    public IPostHistoryVersionService PostHistoryVersionService { get; set; }
-
-    public IWebHostEnvironment HostEnvironment { get; set; }
-
-    public ISearchEngine<DataContext> SearchEngine { get; set; }
-
-    public ImagebedClient ImagebedClient { get; set; }
-
-    public IPostVisitRecordService PostVisitRecordService { get; set; }
-
-    public ICommentService CommentService { get; set; }
-
-    public IPostTagService PostTagService { get; set; }
-
-    /// <summary>
-    /// 文章详情页
-    /// </summary>
-    /// <returns></returns>
-    [Route("{id:int}"), Route("{id:int}/comments/{cid:int}"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
-    public async Task<ActionResult> Details([FromServices] ISearchDetailsService searchService, int id, string kw, int cid, string t)
-    {
-        var notRobot = !Request.IsRobot();
-        if (string.IsNullOrEmpty(t) && notRobot)
-        {
-            return RedirectToAction("Details", cid > 0 ? new { id, kw, cid, t = HttpContext.Connection.Id } : new { id, kw, t = HttpContext.Connection.Id });
-        }
-
-        var post = await PostService.GetQuery(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)).Include(p => p.Seminar).AsNoTracking().FirstOrDefaultAsync() ?? throw new NotFoundException("文章未找到");
-        CheckPermission(post);
-        var ip = ClientIP.ToString();
-        if (!string.IsNullOrEmpty(post.Redirect))
-        {
-            if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
-            {
-                BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ip, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
-                HttpContext.Session.Set("post" + id, id.ToString());
-            }
-
-            return Redirect(post.Redirect);
-        }
-
-        post.Category = CategoryService[post.CategoryId];
-        ViewBag.CommentsCount = CommentService.Count(c => c.PostId == id && c.ParentId == null && c.Status == Status.Published);
-        ViewBag.HistoryCount = PostHistoryVersionService.Count(c => c.PostId == id);
-        ViewBag.Keyword = post.Keyword + "," + post.Label;
-        if (Request.Query.ContainsKey("share"))
-        {
-            ViewBag.Desc = await post.Content.GetSummary(200);
-        }
-        else
-        {
-            ViewBag.Desc = "若页面无法访问,可通过搜索引擎网页快照进行浏览。" + await post.Content.GetSummary(200);
-        }
-
-        var modifyDate = post.ModifyDate;
-        ViewBag.Next = await PostService.GetFromCacheAsync<DateTime, PostModelBase>(p => p.ModifyDate > modifyDate && (p.LimitMode ?? 0) == RegionLimitMode.All && (p.Status == Status.Published || CurrentUser.IsAdmin), p => p.ModifyDate);
-        ViewBag.Prev = await PostService.GetFromCacheAsync<DateTime, PostModelBase>(p => p.ModifyDate < modifyDate && (p.LimitMode ?? 0) == RegionLimitMode.All && (p.Status == Status.Published || CurrentUser.IsAdmin), p => p.ModifyDate, false);
-        ViewData[nameof(post.Author)] = post.Author;
-        ViewData[nameof(post.PostDate)] = post.PostDate;
-        ViewData[nameof(post.ModifyDate)] = post.ModifyDate;
-        ViewData["cover"] = post.Content.MatchFirstImgSrc();
-        if (!string.IsNullOrEmpty(kw))
-        {
-            await PostService.Highlight(post, kw);
-        }
-
-        var keys = searchService.GetQuery(e => e.IP == ip).OrderByDescending(e => e.SearchTime).Select(e => e.Keywords).Distinct().Take(5).FromCache();
-        var regex = SearchEngine.LuceneIndexSearcher.CutKeywords(string.IsNullOrWhiteSpace(post.Keyword + post.Label) ? post.Title : post.Keyword + post.Label).Union(keys).Select(Regex.Escape).Join("|");
-        ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, regex);
-        ViewBag.Related = PostService.GetQuery(PostBaseWhere().And(p => p.Id != post.Id && Regex.IsMatch(p.Title + (p.Keyword ?? "") + (p.Label ?? ""), regex, RegexOptions.IgnoreCase)), p => p.AverageViewCount, false).Take(10).Select(p => new { p.Id, p.Title }).FromCache().ToDictionary(p => p.Id, p => p.Title);
-
-        post.ModifyDate = post.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        post.PostDate = post.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        post.Content = await ReplaceVariables(post.Content).Next(s => notRobot && post.DisableCopy ? s.InjectFingerprint() : Task.FromResult(s));
-        post.ProtectContent = await ReplaceVariables(post.ProtectContent).Next(s => notRobot && post.DisableCopy ? s.InjectFingerprint() : Task.FromResult(s));
-
-        if (CurrentUser.IsAdmin)
-        {
-            return View("Details_Admin", post);
-        }
-
-        if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
-        {
-            BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ip, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
-            HttpContext.Session.Set("post" + id, id.ToString());
-        }
-
-        if (post.LimitMode == RegionLimitMode.OnlyForSearchEngine)
-        {
-            BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ip, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
-        }
-
-        return View(post);
-    }
-
-    /// <summary>
-    /// 文章历史版本
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <returns></returns>
-    [Route("{id:int}/history"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "page", "size" }, VaryByHeader = "Cookie")]
-    public async Task<ActionResult> History(int id, [Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 20)
-    {
-        var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
-        CheckPermission(post);
-        ViewBag.Primary = post;
-        var list = await PostHistoryVersionService.GetPagesAsync(page, size, v => v.PostId == id, v => v.ModifyDate, false);
-        foreach (var item in list.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        }
-
-        ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, post.Keyword + "," + post.Label);
-        return View(list);
-    }
-
-    /// <summary>
-    /// 文章历史版本
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="hid"></param>
-    /// <returns></returns>
-    [Route("{id:int}/history/{hid:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "hid" }, VaryByHeader = "Cookie")]
-    public async Task<ActionResult> HistoryVersion(int id, int hid)
-    {
-        var history = await PostHistoryVersionService.GetAsync(v => v.Id == hid && (v.Post.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
-        CheckPermission(history.Post);
-        history.Content = await ReplaceVariables(history.Content).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
-        history.ProtectContent = await ReplaceVariables(history.ProtectContent).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
-        history.ModifyDate = history.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        var next = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate > history.ModifyDate, p => p.ModifyDate);
-        var prev = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate < history.ModifyDate, p => p.ModifyDate, false);
-        ViewBag.Next = next;
-        ViewBag.Prev = prev;
-        ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), history.CategoryId, history.Label);
-        ViewData[nameof(history.Post.Author)] = history.Post.Author;
-        ViewData[nameof(history.Post.PostDate)] = history.Post.PostDate;
-        ViewData[nameof(history.ModifyDate)] = history.ModifyDate;
-        ViewData["cover"] = history.Content.MatchFirstImgSrc();
-        return CurrentUser.IsAdmin ? View("HistoryVersion_Admin", history) : View(history);
-    }
-
-    /// <summary>
-    /// 版本对比
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="v1"></param>
-    /// <param name="v2"></param>
-    /// <returns></returns>
-    [Route("{id:int}/history/{v1:int}-{v2:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "v1", "v2" }, VaryByHeader = "Cookie")]
-    public async Task<ActionResult> CompareVersion(int id, int v1, int v2)
-    {
-        var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin));
-        var main = Mapper.Map<PostHistoryVersion>(post) ?? throw new NotFoundException("文章未找到");
-        CheckPermission(post);
-        var right = v1 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v1) ?? throw new NotFoundException("文章未找到");
-        var left = v2 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v2) ?? throw new NotFoundException("文章未找到");
-        main.Id = id;
-        var diff = new HtmlDiff.HtmlDiff(left.Content, right.Content);
-        var diffOutput = diff.Build();
-        left.Content = await ReplaceVariables(Regex.Replace(Regex.Replace(diffOutput, "<ins.+?</ins>", string.Empty), @"<\w+></\w+>", string.Empty)).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
-        left.ModifyDate = left.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        right.Content = await ReplaceVariables(Regex.Replace(Regex.Replace(diffOutput, "<del.+?</del>", string.Empty), @"<\w+></\w+>", string.Empty)).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
-        right.ModifyDate = right.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        ViewBag.Ads = AdsService.GetsByWeightedPrice(2, AdvertiseType.InPage, Request.Location(), main.CategoryId, main.Label);
-        ViewBag.DisableCopy = post.DisableCopy;
-        return View(new[] { main, left, right });
-    }
-
-    /// <summary>
-    /// 反对
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    public async Task<ActionResult> VoteDown(int id)
-    {
-        if (HttpContext.Session.Get("post-vote" + id) != null)
-        {
-            return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
-        }
-
-        var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.VoteDownCount, m => m.VoteDownCount + 1)) > 0;
-        if (b)
-        {
-            HttpContext.Session.Set("post-vote" + id, id.GetBytes());
-        }
-
-        return ResultData(null, b, b ? "投票成功!" : "投票失败!");
-    }
-
-    /// <summary>
-    /// 支持
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    public async Task<ActionResult> VoteUp(int id)
-    {
-        if (HttpContext.Session.Get("post-vote" + id) != null)
-        {
-            return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
-        }
-
-        var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.VoteUpCount, m => m.VoteUpCount + 1)) > 0;
-        if (b)
-        {
-            HttpContext.Session.Set("post-vote" + id, id.GetBytes());
-        }
-
-        return ResultData(null, b, b ? "投票成功!" : "投票失败!");
-    }
-
-    /// <summary>
-    /// 投稿页
-    /// </summary>
-    /// <returns></returns>
-    public ActionResult Publish()
-    {
-        return View();
-    }
-
-    /// <summary>
-    /// 发布投稿
-    /// </summary>
-    /// <param name="post"></param>
-    /// <param name="code"></param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    [HttpPost, ValidateAntiForgeryToken]
-    public async Task<ActionResult> Publish(PostCommand post, [Required(ErrorMessage = "验证码不能为空")] string code, CancellationToken cancellationToken)
-    {
-        if (RedisHelper.Get("code:" + post.Email) != code)
-        {
-            return ResultData(null, false, "验证码错误!");
-        }
-
-        if (PostService.Any(p => p.Status == Status.Forbidden && p.Email == post.Email))
-        {
-            return ResultData(null, false, "由于您曾经恶意投稿,该邮箱已经被标记为黑名单,无法进行投稿,如有疑问,请联系网站管理员进行处理。");
-        }
-
-        var match = Regex.Match(post.Title + post.Author + post.Content, CommonHelper.BanRegex);
-        if (match.Success)
-        {
-            LogManager.Info($"提交内容:{post.Title}/{post.Author}/{post.Content},敏感词:{match.Value}");
-            return ResultData(null, false, "您提交的内容包含敏感词,被禁止发表,请检查您的内容后尝试重新提交!");
-        }
-
-        if (!CategoryService.Any(c => c.Id == post.CategoryId))
-        {
-            return ResultData(null, message: "请选择一个分类");
-        }
-
-        post.Label = string.IsNullOrEmpty(post.Label?.Trim()) ? null : post.Label.Replace(",", ",");
-        post.Status = Status.Pending;
-        post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.HtmlSanitizerStandard().ClearImgAttributes(), cancellationToken);
-        Post p = Mapper.Map<Post>(post);
-        p.IP = ClientIP.ToString();
-        p.Modifier = p.Author;
-        p.ModifierEmail = p.Email;
-        p.DisableCopy = true;
-        p.Rss = true;
-        PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
-        {
-            Name = s,
-            Count = PostService.Count(t => t.Label.Contains(s))
-        }));
-        p = PostService.AddEntitySaved(p);
-        if (p == null)
-        {
-            return ResultData(null, false, "文章发表失败!");
-        }
-
-        RedisHelper.Expire("code:" + p.Email, 1);
-        var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/publish.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
-            .Set("link", Url.Action("Details", "Post", new { id = p.Id }, Request.Scheme))
-            .Set("time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))
-            .Set("title", p.Title).Render();
-        BackgroundJob.Enqueue<IMailSender>(sender => sender.Send(CommonHelper.SystemSettings["Title"] + "有访客投稿:", content, CommonHelper.SystemSettings["ReceiveEmail"], p.IP));
-        return ResultData(Mapper.Map<PostDto>(p), message: "文章发表成功,待站长审核通过以后将显示到列表中!");
-    }
-
-    /// <summary>
-    /// 获取标签
-    /// </summary>
-    /// <returns></returns>
-    [ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
-    public ActionResult GetTag()
-    {
-        return ResultData(PostService.GetTags().Select(x => x.Key).OrderBy(s => s));
-    }
-
-    /// <summary>
-    /// 标签云
-    /// </summary>
-    /// <returns></returns>
-    [Route("all"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
-    public async Task<ActionResult> All()
-    {
-        ViewBag.tags = new Dictionary<string, int>(PostService.GetTags().Where(x => x.Value > 1).OrderBy(x => x.Key));
-        ViewBag.cats = await CategoryService.GetQuery(c => c.Post.Count > 0, c => c.Post.Count, false).Include(c => c.Parent).ThenInclude(c => c.Parent).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Path()); //category
-        ViewBag.seminars = await SeminarService.GetAll(c => c.Post.Count, false).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Title); //seminars
-        return View();
-    }
-
-    /// <summary>
-    /// 检查访问密码
-    /// </summary>
-    /// <param name="email"></param>
-    /// <param name="token"></param>
-    /// <returns></returns>
-    [HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
-    public ActionResult CheckViewToken(string email, string token)
-    {
-        if (string.IsNullOrEmpty(token))
-        {
-            return ResultData(null, false, "请输入访问密码!");
-        }
-
-        var s = RedisHelper.Get("token:" + email);
-        if (token.Equals(s))
-        {
-            HttpContext.Session.Set("AccessViewToken", token);
-            Response.Cookies.Append("Email", email, new CookieOptions
-            {
-                Expires = DateTime.Now.AddYears(1),
-                SameSite = SameSiteMode.Lax
-            });
-            Response.Cookies.Append("PostAccessToken", email.MDString3(AppConfig.BaiduAK), new CookieOptions
-            {
-                Expires = DateTime.Now.AddYears(1),
-                SameSite = SameSiteMode.Lax
-            });
-            return ResultData(null);
-        }
-
-        return ResultData(null, false, "访问密码不正确!");
-    }
-
-    /// <summary>
-    /// 检查授权邮箱
-    /// </summary>
-    /// <param name="email"></param>
-    /// <returns></returns>
-    [HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
-    public ActionResult GetViewToken(string email)
-    {
-        var validator = new IsEmailAttribute();
-        if (!validator.IsValid(email))
-        {
-            return ResultData(null, false, validator.ErrorMessage);
-        }
-
-        if (RedisHelper.Exists("get:" + email))
-        {
-            RedisHelper.Expire("get:" + email, 120);
-            return ResultData(null, false, "发送频率限制,请在2分钟后重新尝试发送邮件!请检查你的邮件,若未收到,请检查你的邮箱地址或邮件垃圾箱!");
-        }
-
-        if (!UserInfoService.Any(b => b.Email.Equals(email)))
-        {
-            return ResultData(null, false, "您目前没有权限访问这个链接,请联系站长开通访问权限!");
-        }
-
-        var token = SnowFlake.GetInstance().GetUniqueShortId(6);
-        RedisHelper.Set("token:" + email, token, 86400);
-        BackgroundJob.Enqueue<IMailSender>(sender => sender.Send(Request.Host + "博客访问验证码", $"{Request.Host}本次验证码是:<span style='color:red'>{token}</span>,有效期为24h,请按时使用!", email, ClientIP.ToString()));
-        RedisHelper.Set("get:" + email, token, 120);
-        return ResultData(null);
-    }
-
-    /// <summary>
-    /// 文章合并
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [HttpGet("{id}/merge")]
-    public async Task<ActionResult> PushMerge(int id)
-    {
-        var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
-        CheckPermission(post);
-        return View(post);
-    }
-
-    /// <summary>
-    /// 文章合并
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="mid"></param>
-    /// <returns></returns>
-    [HttpGet("{id}/merge/{mid}")]
-    public async Task<ActionResult> RepushMerge(int id, int mid)
-    {
-        var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
-        CheckPermission(post);
-        var merge = post.PostMergeRequests.FirstOrDefault(p => p.Id == mid && p.MergeState != MergeStatus.Merged) ?? throw new NotFoundException("待合并文章未找到");
-        return View(merge);
-    }
-
-    /// <summary>
-    /// 文章合并
-    /// </summary>
-    /// <param name="messageService"></param>
-    /// <param name="postMergeRequestService"></param>
-    /// <param name="dto"></param>
-    /// <returns></returns>
-    [HttpPost("{id}/pushmerge")]
-    public async Task<ActionResult> PushMerge([FromServices] IInternalMessageService messageService, [FromServices] IPostMergeRequestService postMergeRequestService, PostMergeRequestCommand dto)
-    {
-        if (RedisHelper.Get("code:" + dto.ModifierEmail) != dto.Code)
-        {
-            return ResultData(null, false, "验证码错误!");
-        }
-
-        var post = await PostService.GetAsync(p => p.Id == dto.PostId && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
-        if (post.Title.Equals(dto.Title) && post.Content.HammingDistance(dto.Content) <= 1)
-        {
-            return ResultData(null, false, "内容未被修改或修改的内容过少(无意义修改)!");
-        }
-
-        #region 合并验证
-
-        if (postMergeRequestService.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Block))
-        {
-            return ResultData(null, false, "由于您曾经多次恶意修改文章,已经被标记为黑名单,无法修改任何文章,如有疑问,请联系网站管理员进行处理。");
-        }
-
-        if (post.PostMergeRequests.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Pending))
-        {
-            return ResultData(null, false, "您已经提交过一次修改请求正在待处理,暂不能继续提交修改请求!");
-        }
-
-        #endregion 合并验证
-
-        #region 直接合并
-
-        if (post.Email.Equals(dto.ModifierEmail))
-        {
-            var history = Mapper.Map<PostHistoryVersion>(post);
-            Mapper.Map(dto, post);
-            post.PostHistoryVersion.Add(history);
-            post.ModifyDate = DateTime.Now;
-            return await PostService.SaveChangesAsync() > 0 ? ResultData(null, true, "你是文章原作者,无需审核,文章已自动更新并在首页展示!") : ResultData(null, false, "操作失败!");
-        }
-
-        #endregion 直接合并
-
-        var merge = post.PostMergeRequests.FirstOrDefault(r => r.Id == dto.Id && r.MergeState != MergeStatus.Merged);
-        if (merge != null)
-        {
-            Mapper.Map(dto, merge);
-            merge.SubmitTime = DateTime.Now;
-            merge.MergeState = MergeStatus.Pending;
-        }
-        else
-        {
-            merge = Mapper.Map<PostMergeRequest>(dto);
-            merge.SubmitTime = DateTime.Now;
-            post.PostMergeRequests.Add(merge);
-        }
-        merge.IP = ClientIP.ToString();
-        var b = await PostService.SaveChangesAsync() > 0;
-        if (!b)
-        {
-            return ResultData(null, false, "操作失败!");
-        }
-
-        RedisHelper.Expire("code:" + dto.ModifierEmail, 1);
-        await messageService.AddEntitySavedAsync(new InternalMessage()
-        {
-            Title = $"来自【{dto.Modifier}】对文章《{post.Title}》的修改请求",
-            Content = dto.Title,
-            Link = "#/merge/compare?id=" + merge.Id
-        });
-
-        var htmlDiff = new HtmlDiff.HtmlDiff(post.Content.RemoveHtmlTag(), dto.Content.RemoveHtmlTag());
-        var diff = htmlDiff.Build();
-        var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/merge-request.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
-            .Set("title", post.Title)
-            .Set("link", Url.Action("Index", "Dashboard", new { }, Request.Scheme) + "#/merge/compare?id=" + merge.Id)
-            .Set("diff", diff)
-            .Set("host", "//" + Request.Host)
-            .Set("id", merge.Id.ToString())
-            .Render();
-        BackgroundJob.Enqueue<IMailSender>(sender => sender.Send("博客文章修改请求:", content, CommonHelper.SystemSettings["ReceiveEmail"], merge.IP));
-        return ResultData(null, true, "您的修改请求已提交,已进入审核状态,感谢您的参与!");
-    }
-
-    #region 后端管理
-
-    /// <summary>
-    /// 固顶
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> Fixtop(int id)
-    {
-        Post post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        post.IsFixedTop = !post.IsFixedTop;
-        bool b = await PostService.SaveChangesAsync() > 0;
-        return b ? ResultData(null, true, post.IsFixedTop ? "置顶成功!" : "取消置顶成功!") : ResultData(null, false, "操作失败!");
-    }
-
-    /// <summary>
-    /// 审核
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> Pass(int id)
-    {
-        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        post.Status = Status.Published;
-        post.ModifyDate = DateTime.Now;
-        post.PostDate = DateTime.Now;
-        var b = await PostService.SaveChangesAsync() > 0;
-        if (!b)
-        {
-            return ResultData(null, false, "审核失败!");
-        }
-
-        (post.Keyword + "," + post.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
-        SearchEngine.LuceneIndexer.Add(post);
-        return ResultData(null, true, "审核通过!");
-    }
-
-    /// <summary>
-    /// 下架文章
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> Takedown(int id)
-    {
-        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        post.Status = Status.Takedown;
-        bool b = await PostService.SaveChangesAsync(true) > 0;
-        SearchEngine.LuceneIndexer.Delete(post);
-        return ResultData(null, b, b ? $"文章《{post.Title}》已下架!" : "下架失败!");
-    }
-
-    /// <summary>
-    /// 还原版本
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> Takeup(int id)
-    {
-        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        post.Status = Status.Published;
-        bool b = await PostService.SaveChangesAsync() > 0;
-        SearchEngine.LuceneIndexer.Add(post);
-        return ResultData(null, b, b ? "上架成功!" : "上架失败!");
-    }
-
-    /// <summary>
-    /// 彻底删除文章
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public ActionResult Truncate(int id)
-    {
-        bool b = PostService - id;
-        return ResultData(null, b, b ? "删除成功!" : "删除失败!");
-    }
-
-    /// <summary>
-    /// 获取文章
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public ActionResult Get(int id)
-    {
-        var post = PostService.GetQuery(e => e.Id == id).Include(e => e.Seminar).FirstOrDefault() ?? throw new NotFoundException("文章未找到");
-        var model = Mapper.Map<PostDto>(post);
-        model.Seminars = post.Seminar.Select(s => s.Id).Join(",");
-        return ResultData(model);
-    }
-
-    /// <summary>
-    /// 获取文章分页
-    /// </summary>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> GetPageData([FromServices] ICacheManager<HashSet<string>> cacheManager, int page = 1, [Range(1, 200, ErrorMessage = "页大小必须介于{1}-{2}")] int size = 10, OrderBy orderby = OrderBy.ModifyDate, string kw = "", int? cid = null)
-    {
-        Expression<Func<Post, bool>> where = p => true;
-        if (cid.HasValue)
-        {
-            where = where.And(p => p.CategoryId == cid.Value || p.Category.ParentId == cid.Value || p.Category.Parent.ParentId == cid.Value);
-        }
-
-        if (!string.IsNullOrEmpty(kw))
-        {
-            kw = Regex.Escape(kw);
-            where = where.And(p => Regex.IsMatch(p.Title + p.Author + p.Email + p.Content, kw, RegexOptions.IgnoreCase));
-        }
-
-        var list = orderby switch
-        {
-            OrderBy.Trending => await PostService.GetQuery(where).OrderByDescending(p => p.Status).ThenByDescending(p => p.IsFixedTop).ThenByDescending(p => p.PostVisitRecordStats.Average(t => t.Count)).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig),
-            _ => await PostService.GetQuery(where).OrderBy($"{nameof(Post.Status)} desc,{nameof(Post.IsFixedTop)} desc,{orderby.GetDisplay()} desc").ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig)
-        };
-        foreach (var item in list.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            item.Online = cacheManager.Get(nameof(PostOnline) + ":" + item.Id)?.Count ?? 0;
-        }
-
-        return Ok(list);
-    }
-
-    /// <summary>
-    /// 获取未审核文章
-    /// </summary>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <param name="search"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> GetPending([Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15, string search = "")
-    {
-        Expression<Func<Post, bool>> where = p => p.Status == Status.Pending;
-        if (!string.IsNullOrEmpty(search))
-        {
-            where = where.And(p => p.Title.Contains(search) || p.Author.Contains(search) || p.Email.Contains(search) || p.Label.Contains(search));
-        }
-
-        var pages = await PostService.GetQuery(where).OrderByDescending(p => p.IsFixedTop).ThenByDescending(p => p.ModifyDate).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig);
-        foreach (var item in pages.Data)
-        {
-            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-        }
-
-        return Ok(pages);
-    }
-
-    /// <summary>
-    /// 编辑
-    /// </summary>
-    /// <param name="post"></param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    [HttpPost, MyAuthorize]
-    public async Task<ActionResult> Edit([FromBodyOrDefault] PostCommand post, CancellationToken cancellationToken = default)
-    {
-        post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
-        if (!ValidatePost(post, out var resultData))
-        {
-            return resultData;
-        }
-
-        Post p = await PostService.GetByIdAsync(post.Id);
-        if (post.Reserve && p.Status == Status.Published)
-        {
-            if (p.Content.HammingDistance(post.Content) > 0)
-            {
-                var history = Mapper.Map<PostHistoryVersion>(p);
-                history.PostId = p.Id;
-                PostHistoryVersionService.AddEntity(history);
-            }
-
-            if (p.Title.HammingDistance(post.Title) > 10 && CommentService.Any(c => c.PostId == p.Id && c.ParentId == null))
-            {
-                CommentService.AddEntity(new Comment
-                {
-                    Status = Status.Published,
-                    NickName = "系统自动评论",
-                    Email = p.Email,
-                    Content = $"<p style=\"color:red\">温馨提示:由于文章发生了重大更新,本条评论之前的所有评论仅作为原文《{p.Title}》的历史评论保留,不作为本文的最新评论参考,请知悉!了解更多信息,请查阅本文的历史修改记录。</p>",
-                    PostId = p.Id,
-                    CommentDate = DateTime.Now,
-                    IsMaster = true,
-                    IsAuthor = true,
-                    IP = "127.0.0.1",
-                    Location = "内网",
-                    GroupTag = SnowFlake.NewId,
-                    Path = SnowFlake.NewId,
-                });
-            }
-
-            p.ModifyDate = DateTime.Now;
-            var user = HttpContext.Session.Get<UserInfoDto>(SessionKey.UserInfo);
-            post.Modifier = string.IsNullOrEmpty(post.Modifier) ? user.NickName : post.Modifier;
-            post.ModifierEmail = string.IsNullOrEmpty(post.ModifierEmail) ? user.Email : post.ModifierEmail;
-        }
-
-        Mapper.Map(post, p);
-        p.IP = ClientIP.ToString();
-        p.Seminar.Clear();
-        if (!string.IsNullOrEmpty(post.Seminars))
-        {
-            var tmp = post.Seminars.Split(',', StringSplitOptions.RemoveEmptyEntries).Distinct().Select(int.Parse).ToArray();
-            var seminars = SeminarService.GetQuery(s => tmp.Contains(s.Id)).ToPooledListScope();
-            p.Seminar.AddRange(seminars);
-        }
-
-        (p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
-        PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
-        {
-            Name = s,
-            Count = PostService.Count(t => t.Label.Contains(s))
-        }));
-        bool b = await SearchEngine.SaveChangesAsync() > 0;
-        if (!b)
-        {
-            return ResultData(null, false, "文章修改失败!");
-        }
-
-        if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
-        {
-            SearchEngine.LuceneIndexer.Delete(p);
-        }
-        return ResultData(Mapper.Map<PostDto>(p), message: "文章修改成功!");
-    }
-
-    /// <summary>
-    /// 发布
-    /// </summary>
-    /// <param name="post"></param>
-    /// <param name="timespan"></param>
-    /// <param name="schedule"></param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    [MyAuthorize, HttpPost]
-    public async Task<ActionResult> Write([FromBodyOrDefault] PostCommand post, [FromBodyOrDefault] DateTime? timespan, [FromBodyOrDefault] bool schedule = false, CancellationToken cancellationToken = default)
-    {
-        post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
-        if (!ValidatePost(post, out var resultData))
-        {
-            return resultData;
-        }
-
-        post.Status = Status.Published;
-        Post p = Mapper.Map<Post>(post);
-        p.Modifier = p.Author;
-        p.ModifierEmail = p.Email;
-        p.IP = ClientIP.ToString();
-        p.Rss = p.LimitMode is null or RegionLimitMode.All;
-        if (!string.IsNullOrEmpty(post.Seminars))
-        {
-            var tmp = post.Seminars.Split(',').Distinct().Select(int.Parse).ToArray();
-            p.Seminar.AddRange(SeminarService[s => tmp.Contains(s.Id)]);
-        }
-
-        if (schedule)
-        {
-            if (!timespan.HasValue || timespan.Value <= DateTime.Now)
-            {
-                return ResultData(null, false, "如果要定时发布,请选择正确的一个将来时间点!");
-            }
-
-            p.Status = Status.Schedule;
-            p.PostDate = timespan.Value.ToUniversalTime();
-            p.ModifyDate = timespan.Value.ToUniversalTime();
-            BackgroundJob.Enqueue<IHangfireBackJob>(job => job.PublishPost(p));
-            return ResultData(Mapper.Map<PostDto>(p), message: $"文章于{timespan.Value:yyyy-MM-dd HH:mm:ss}将会自动发表!");
-        }
-
-        PostService.AddEntity(p);
-        (p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
-        PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
-        {
-            Name = s,
-            Count = PostService.Count(t => t.Label.Contains(s))
-        }));
-        bool b = await SearchEngine.SaveChangesAsync() > 0;
-        if (!b)
-        {
-            return ResultData(null, false, "文章发表失败!");
-        }
-
-        if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
-        {
-            SearchEngine.LuceneIndexer.Delete(p);
-        }
-
-        return ResultData(null, true, "文章发表成功!");
-    }
-
-    private bool ValidatePost(PostCommand post, out ActionResult resultData)
-    {
-        if (!CategoryService.Any(c => c.Id == post.CategoryId && c.Status == Status.Available))
-        {
-            resultData = ResultData(null, false, "请选择一个分类");
-            return false;
-        }
-
-        switch (post.LimitMode)
-        {
-            case RegionLimitMode.AllowRegion:
-            case RegionLimitMode.ForbidRegion:
-                if (string.IsNullOrEmpty(post.Regions))
-                {
-                    resultData = ResultData(null, false, "请输入限制的地区");
-                    return false;
-                }
-
-                post.Regions = post.Regions.Replace(",", "|").Replace(",", "|");
-                break;
-
-            case RegionLimitMode.AllowRegionExceptForbidRegion:
-            case RegionLimitMode.ForbidRegionExceptAllowRegion:
-                if (string.IsNullOrEmpty(post.ExceptRegions))
-                {
-                    resultData = ResultData(null, false, "请输入排除的地区");
-                    return false;
-                }
-
-                post.ExceptRegions = post.ExceptRegions.Replace(",", "|").Replace(",", "|");
-                goto case RegionLimitMode.AllowRegion;
-        }
-
-        if (string.IsNullOrEmpty(post.Label?.Trim()) || post.Label.Equals("null"))
-        {
-            post.Label = null;
-        }
-        else if (post.Label.Trim().Length > 50)
-        {
-            post.Label = post.Label.Replace(",", ",");
-            post.Label = post.Label.Trim().Substring(0, 50);
-        }
-        else
-        {
-            post.Label = post.Label.Replace(",", ",");
-        }
-
-        if (string.IsNullOrEmpty(post.ProtectContent?.RemoveHtmlTag()) || post.ProtectContent.Equals("null"))
-        {
-            post.ProtectContent = null;
-        }
-
-        resultData = null;
-        return true;
-    }
-
-    /// <summary>
-    /// 添加专题
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="sid"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> AddSeminar(int id, int sid)
-    {
-        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
-        post.Seminar.Add(seminar);
-        bool b = await PostService.SaveChangesAsync() > 0;
-        return ResultData(null, b, b ? $"已将文章【{post.Title}】添加到专题【{seminar.Title}】" : "添加失败");
-    }
-
-    /// <summary>
-    /// 移除专题
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="sid"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> RemoveSeminar(int id, int sid)
-    {
-        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
-        post.Seminar.Remove(seminar);
-        bool b = await PostService.SaveChangesAsync() > 0;
-        return ResultData(null, b, b ? $"已将文章【{post.Title}】从【{seminar.Title}】专题移除" : "添加失败");
-    }
-
-    /// <summary>
-    /// 删除历史版本
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> DeleteHistory(int id)
-    {
-        bool b = await PostHistoryVersionService.DeleteByIdAsync(id) > 0;
-        return ResultData(null, b, b ? "历史版本文章删除成功!" : "历史版本文章删除失败!");
-    }
-
-    /// <summary>
-    /// 还原版本
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> Revert(int id)
-    {
-        var history = await PostHistoryVersionService.GetByIdAsync(id) ?? throw new NotFoundException("版本不存在");
-        history.Post.Category = history.Category;
-        history.Post.CategoryId = history.CategoryId;
-        history.Post.Content = history.Content;
-        history.Post.Title = history.Title;
-        history.Post.Label = history.Label;
-        history.Post.ModifyDate = history.ModifyDate;
-        history.Post.Seminar.Clear();
-        foreach (var s in history.Seminar)
-        {
-            history.Post.Seminar.Add(s);
-        }
-        bool b = await SearchEngine.SaveChangesAsync() > 0;
-        await PostHistoryVersionService.DeleteByIdAsync(id);
-        return ResultData(null, b, b ? "回滚成功" : "回滚失败");
-    }
-
-    /// <summary>
-    /// 禁用或开启文章评论
-    /// </summary>
-    /// <param name="id">文章id</param>
-    /// <returns></returns>
-    [MyAuthorize]
-    [HttpPost("post/{id}/DisableComment")]
-    public async Task<ActionResult> DisableComment(int id)
-    {
-        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        post.DisableComment = !post.DisableComment;
-        return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableComment ? $"已禁用【{post.Title}】这篇文章的评论功能!" : $"已启用【{post.Title}】这篇文章的评论功能!");
-    }
-
-    /// <summary>
-    /// 禁用或开启文章评论
-    /// </summary>
-    /// <param name="id">文章id</param>
-    /// <returns></returns>
-    [MyAuthorize]
-    [HttpPost("post/{id}/DisableCopy")]
-    public async Task<ActionResult> DisableCopy(int id)
-    {
-        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        post.DisableCopy = !post.DisableCopy;
-        return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableCopy ? $"已开启【{post.Title}】这篇文章的防复制功能!" : $"已关闭【{post.Title}】这篇文章的防复制功能!");
-    }
-
-    /// <summary>
-    /// 禁用或开启NSFW
-    /// </summary>
-    /// <param name="id">文章id</param>
-    /// <returns></returns>
-    [MyAuthorize]
-    [HttpPost("post/{id}/nsfw")]
-    public async Task<ActionResult> Nsfw(int id)
-    {
-        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-        post.IsNsfw = !post.IsNsfw;
-        return ResultData(null, await PostService.SaveChangesAsync() > 0, post.IsNsfw ? $"已将文章【{post.Title}】标记为不安全内容!" : $"已将文章【{post.Title}】取消标记为不安全内容!");
-    }
-
-    /// <summary>
-    /// 修改分类
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="cid"></param>
-    /// <returns></returns>
-    [HttpPost("post/{id}/ChangeCategory/{cid}")]
-    public async Task<ActionResult> ChangeCategory(int id, int cid)
-    {
-        await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(p => p.CategoryId, cid));
-        return Ok();
-    }
-
-    /// <summary>
-    /// 修改专题
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="sids"></param>
-    /// <returns></returns>
-    [HttpPost("post/{id}/ChangeSeminar")]
-    public async Task<ActionResult> ChangeSeminar(int id, string sids)
-    {
-        var post = PostService.GetQuery(e => e.Id == id).Include(e => e.Seminar).FirstOrDefault() ?? throw new NotFoundException("文章不存在");
-        post.Seminar.Clear();
-        if (!string.IsNullOrEmpty(sids))
-        {
-            var ids = sids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray();
-            post.Seminar.AddRange(SeminarService[s => ids.Contains(s.Id)]);
-        }
-
-        await PostService.SaveChangesAsync();
-        return Ok();
-    }
-
-    /// <summary>
-    /// 刷新文章
-    /// </summary>
-    /// <param name="id">文章id</param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<ActionResult> Refresh(int id, CancellationToken cancellationToken = default)
-    {
-        await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.ModifyDate, DateTime.Now), cancellationToken: cancellationToken);
-        return RedirectToAction("Details", new { id });
-    }
-
-    /// <summary>
-    /// 标记为恶意修改
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    [HttpPost("post/block/{id}")]
-    public async Task<ActionResult> Block(int id, CancellationToken cancellationToken = default)
-    {
-        var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Status, Status.Forbidden), cancellationToken: cancellationToken) > 0;
-        return b ? ResultData(null, true, "操作成功!") : ResultData(null, false, "操作失败!");
-    }
-
-    /// <summary>
-    /// 切换允许rss订阅
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    [HttpPost("post/{id}/rss-switch")]
-    public async Task<ActionResult> RssSwitch(int id, CancellationToken cancellationToken = default)
-    {
-        await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Rss, p => !p.Rss), cancellationToken: cancellationToken);
-        return ResultData(null, message: "操作成功");
-    }
-
-    /// <summary>
-    /// 切换锁定编辑
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    [HttpPost("post/{id}/locked-switch")]
-    public async Task<ActionResult> LockedSwitch(int id, CancellationToken cancellationToken = default)
-    {
-        await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Locked, p => !p.Locked), cancellationToken: cancellationToken);
-        return ResultData(null, message: "操作成功");
-    }
-
-    /// <summary>
-    /// 文章统计
-    /// </summary>
-    /// <returns></returns>
-    [MyAuthorize]
-    public async Task<IActionResult> Statistic(CancellationToken cancellationToken = default)
-    {
-        var keys = RedisHelper.Keys(nameof(PostOnline) + ":*");
-        var sets = keys.Select(s => (Id: s.Split(':')[1].ToInt32(), Clients: RedisHelper.HGet<HashSet<string>>(s, "value")));
-        var ids = sets.Where(t => t.Clients?.Count > 0).OrderByDescending(t => t.Clients.Count).Take(10).Select(t => t.Id).ToArray();
-        var mostHots = await PostService.GetQuery<PostModelBase>(p => ids.Contains(p.Id)).ToListAsync(cancellationToken).ContinueWith(t =>
-        {
-            foreach (var item in t.Result)
-            {
-                item.ViewCount = sets.FirstOrDefault(x => x.Id == item.Id).Clients.Count;
-            }
-
-            return t.Result.OrderByDescending(p => p.ViewCount);
-        });
-        var postsQuery = PostService.GetQuery(p => p.Status == Status.Published);
-        var mostView = await postsQuery.OrderByDescending(p => p.TotalViewCount).Take(10).Select(p => new PostModelBase()
-        {
-            Id = p.Id,
-            Title = p.Title,
-            ViewCount = p.TotalViewCount
-        }).ToListAsync(cancellationToken);
-        var mostAverage = await postsQuery.OrderByDescending(p => p.AverageViewCount).Take(10).Select(p => new PostModelBase()
-        {
-            Id = p.Id,
-            Title = p.Title,
-            ViewCount = (int)p.AverageViewCount
-        }).ToListAsync(cancellationToken);
-        var yesterday = DateTime.Now.AddDays(-1);
-        var trending = await postsQuery.Select(p => new PostModelBase()
-        {
-            Id = p.Id,
-            Title = p.Title,
-            ViewCount = p.PostVisitRecords.Count(t => t.Time >= yesterday)
-        }).OrderByDescending(p => p.ViewCount).Take(10).ToListAsync(cancellationToken);
-        var readCount = PostVisitRecordService.Count(e => e.Time >= yesterday);
-        return ResultData(new
-        {
-            mostHots,
-            mostView,
-            mostAverage,
-            trending,
-            readCount
-        });
-    }
-
-    /// <summary>
-    /// 文章访问记录
-    /// </summary>
-    /// <param name="id"></param>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <returns></returns>
-    [HttpGet("/{id}/records"), MyAuthorize]
-    [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
-    public async Task<IActionResult> PostVisitRecords(int id, int page = 1, int size = 15, string kw = "")
-    {
-        Expression<Func<PostVisitRecord, bool>> where = e => e.PostId == id;
-        if (!string.IsNullOrEmpty(kw))
-        {
-            kw = Regex.Escape(kw);
-            where = where.And(e => Regex.IsMatch(e.IP + e.Location + e.Referer + e.RequestUrl, kw, RegexOptions.IgnoreCase));
-        }
-
-        var pages = await PostVisitRecordService.GetPagesAsync<DateTime, PostVisitRecordViewModel>(page, size, where, e => e.Time, false);
-        return Ok(pages);
-    }
-
-    /// <summary>
-    /// 导出文章访问记录
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [HttpGet("/{id}/records-export"), MyAuthorize]
-    [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
-    public IActionResult ExportPostVisitRecords(int id)
-    {
-        var list = PostVisitRecordService.GetQuery<DateTime, PostVisitRecordViewModel>(e => e.PostId == id, e => e.Time, false).ToPooledListScope();
-        using var ms = list.ToExcel();
-        var post = PostService[id];
-        return this.ResumeFile(ms.ToArray(), ContentType.Xlsx, post.Title + "访问记录.xlsx");
-    }
-
-    /// <summary>
-    /// 文章访问记录图表
-    /// </summary>
-    /// <returns></returns>
-    [HttpGet("/{id}/records-chart"), MyAuthorize]
-    [ProducesResponseType((int)HttpStatusCode.OK)]
-    public async Task<IActionResult> PostVisitRecordChart([FromServices] IPostVisitRecordStatsService statsService, int id, bool compare, uint period, CancellationToken cancellationToken)
-    {
-        if (compare)
-        {
-            var start1 = DateTime.Today.AddDays(-period);
-            var list1 = await statsService.GetQuery(e => e.PostId == id && e.Date >= start1).GroupBy(t => t.Date).Select(g => new
-            {
-                Date = g.Key,
-                Count = g.Sum(t => t.Count),
-                UV = g.Sum(t => t.UV)
-            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-            if (list1.Count == 0)
-            {
-                return Ok(Array.Empty<int>());
-            }
-
-            var start2 = start1.AddDays(-period - 1);
-            var list2 = await statsService.GetQuery(e => e.PostId == id && e.Date >= start2 && e.Date < start1).GroupBy(t => t.Date).Select(g => new
-            {
-                Date = g.Key,
-                Count = g.Sum(t => t.Count),
-                UV = g.Sum(t => t.UV)
-            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-
-            // 将数据填充成连续的数据
-            for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
-            {
-                if (list1.All(a => a.Date != i))
-                {
-                    list1.Add(new { Date = i, Count = 0, UV = 0 });
-                }
-            }
-            for (var i = start2; i < start1; i = i.AddDays(1))
-            {
-                if (list2.All(a => a.Date != i))
-                {
-                    list2.Add(new { Date = i, Count = 0, UV = 0 });
-                }
-            }
-            return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
-        }
-
-        var list = await statsService.GetQuery(e => e.PostId == id).GroupBy(t => t.Date).Select(g => new
-        {
-            Date = g.Key,
-            Count = g.Sum(t => t.Count),
-            UV = g.Sum(t => t.UV)
-        }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-        var min = list.Min(a => a.Date);
-        var max = list.Max(a => a.Date);
-        for (var i = min; i < max; i = i.AddDays(1))
-        {
-            if (list.All(a => a.Date != i))
-            {
-                list.Add(new { Date = i, Count = 0, UV = 0 });
-            }
-        }
-
-        return Ok(new[] { list.OrderBy(a => a.Date) });
-    }
-
-    /// <summary>
-    /// 文章访问记录图表
-    /// </summary>
-    /// <returns></returns>
-    [HttpGet("/post/records-chart"), MyAuthorize]
-    [ProducesResponseType((int)HttpStatusCode.OK)]
-    public async Task<IActionResult> PostVisitRecordChart(bool compare, uint period, CancellationToken cancellationToken)
-    {
-        if (compare)
-        {
-            var start1 = DateTime.Today.AddDays(-period);
-            var list1 = await PostVisitRecordService.GetQuery(e => e.Time >= start1).Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
-            {
-                Date = g.Key,
-                Count = g.Count(),
-                UV = g.Select(e => e.IP).Distinct().Count()
-            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-            if (list1.Count == 0)
-            {
-                return Ok(Array.Empty<int>());
-            }
-
-            var start2 = start1.AddDays(-period - 1);
-            var list2 = await PostVisitRecordService.GetQuery(e => e.Time >= start2 && e.Time < start1).Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
-            {
-                Date = g.Key,
-                Count = g.Count(),
-                UV = g.Select(e => e.IP).Distinct().Count()
-            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-
-            // 将数据填充成连续的数据
-            for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
-            {
-                if (list1.All(a => a.Date != i))
-                {
-                    list1.Add(new { Date = i, Count = 0, UV = 0 });
-                }
-            }
-            for (var i = start2; i < start1; i = i.AddDays(1))
-            {
-                if (list2.All(a => a.Date != i))
-                {
-                    list2.Add(new { Date = i, Count = 0, UV = 0 });
-                }
-            }
-            return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
-        }
-
-        var list = await PostVisitRecordService.GetAll().Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
-        {
-            Date = g.Key,
-            Count = g.Count(),
-            UV = g.Select(e => e.IP).Distinct().Count()
-        }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-        var min = list.Min(a => a.Date);
-        var max = list.Max(a => a.Date);
-        for (var i = min; i < max; i = i.AddDays(1))
-        {
-            if (list.All(a => a.Date != i))
-            {
-                list.Add(new { Date = i, Count = 0, UV = 0 });
-            }
-        }
-
-        return Ok(new[] { list.OrderBy(a => a.Date) });
-    }
-
-    /// <summary>
-    /// 文章访问记录分析
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [HttpGet("/{id}/insight"), MyAuthorize]
-    [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
-    public IActionResult PostVisitRecordInsight(int id)
-    {
-        return View(PostService[id]);
-    }
-
-    /// <summary>
-    /// 获取地区集
-    /// </summary>
-    /// <param name="name"></param>
-    /// <returns></returns>
-    [MyAuthorize]
-    [ProducesResponseType(typeof(List<string>), (int)HttpStatusCode.OK)]
-    public async Task<IActionResult> GetRegions(string name)
-    {
-        return ResultData(await PostService.GetAll().Select(p => EF.Property<string>(p, name)).Distinct().ToListAsync());
-    }
-
-    #endregion 后端管理
+	public IPostService PostService { get; set; }
+
+	public ICategoryService CategoryService { get; set; }
+
+	public ISeminarService SeminarService { get; set; }
+
+	public IPostHistoryVersionService PostHistoryVersionService { get; set; }
+
+	public IWebHostEnvironment HostEnvironment { get; set; }
+
+	public ISearchEngine<DataContext> SearchEngine { get; set; }
+
+	public ImagebedClient ImagebedClient { get; set; }
+
+	public IPostVisitRecordService PostVisitRecordService { get; set; }
+
+	public ICommentService CommentService { get; set; }
+
+	public IPostTagService PostTagService { get; set; }
+
+	/// <summary>
+	/// 文章详情页
+	/// </summary>
+	/// <returns></returns>
+	[Route("{id:int}"), Route("{id:int}/comments/{cid:int}"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
+	public async Task<ActionResult> Details([FromServices] ISearchDetailsService searchService, int id, string kw, int cid, string t)
+	{
+		var notRobot = !Request.IsRobot();
+		if (string.IsNullOrEmpty(t) && notRobot)
+		{
+			return RedirectToAction("Details", cid > 0 ? new { id, kw, cid, t = HttpContext.Connection.Id } : new { id, kw, t = HttpContext.Connection.Id });
+		}
+
+		var post = await PostService.GetQuery(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)).Include(p => p.Seminar).AsNoTracking().FirstOrDefaultAsync() ?? throw new NotFoundException("文章未找到");
+		CheckPermission(post);
+		var ip = ClientIP.ToString();
+		if (!string.IsNullOrEmpty(post.Redirect))
+		{
+			if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
+			{
+				BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ip, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
+				HttpContext.Session.Set("post" + id, id.ToString());
+			}
+
+			return Redirect(post.Redirect);
+		}
+
+		post.Category = CategoryService[post.CategoryId];
+		ViewBag.CommentsCount = CommentService.Count(c => c.PostId == id && c.ParentId == null && c.Status == Status.Published);
+		ViewBag.HistoryCount = PostHistoryVersionService.Count(c => c.PostId == id);
+		ViewBag.Keyword = post.Keyword + "," + post.Label;
+		if (Request.Query.ContainsKey("share"))
+		{
+			ViewBag.Desc = await post.Content.GetSummary(200);
+		}
+		else
+		{
+			ViewBag.Desc = "若页面无法访问,可通过搜索引擎网页快照进行浏览。" + await post.Content.GetSummary(200);
+		}
+
+		var modifyDate = post.ModifyDate;
+		ViewBag.Next = await PostService.GetFromCacheAsync<DateTime, PostModelBase>(p => p.ModifyDate > modifyDate && (p.LimitMode ?? 0) == RegionLimitMode.All && (p.Status == Status.Published || CurrentUser.IsAdmin), p => p.ModifyDate);
+		ViewBag.Prev = await PostService.GetFromCacheAsync<DateTime, PostModelBase>(p => p.ModifyDate < modifyDate && (p.LimitMode ?? 0) == RegionLimitMode.All && (p.Status == Status.Published || CurrentUser.IsAdmin), p => p.ModifyDate, false);
+		ViewData[nameof(post.Author)] = post.Author;
+		ViewData[nameof(post.PostDate)] = post.PostDate;
+		ViewData[nameof(post.ModifyDate)] = post.ModifyDate;
+		ViewData["cover"] = post.Content.MatchFirstImgSrc();
+		if (!string.IsNullOrEmpty(kw))
+		{
+			await PostService.Highlight(post, kw);
+		}
+
+		var keys = searchService.GetQuery(e => e.IP == ip).OrderByDescending(e => e.SearchTime).Select(e => e.Keywords).Distinct().Take(5).Cacheable().ToPooledListScope();
+		var regex = SearchEngine.LuceneIndexSearcher.CutKeywords(string.IsNullOrWhiteSpace(post.Keyword + post.Label) ? post.Title : post.Keyword + post.Label).Union(keys).Select(Regex.Escape).Join("|");
+		ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, regex);
+		ViewBag.Related = PostService.GetQuery(PostBaseWhere().And(p => p.Id != post.Id && Regex.IsMatch(p.Title + (p.Keyword ?? "") + (p.Label ?? ""), regex, RegexOptions.IgnoreCase)), p => p.AverageViewCount, false).Take(10).Select(p => new { p.Id, p.Title }).Cacheable().ToDictionary(p => p.Id, p => p.Title);
+
+		post.ModifyDate = post.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		post.PostDate = post.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		post.Content = await ReplaceVariables(post.Content).Next(s => notRobot && post.DisableCopy ? s.InjectFingerprint() : Task.FromResult(s));
+		post.ProtectContent = await ReplaceVariables(post.ProtectContent).Next(s => notRobot && post.DisableCopy ? s.InjectFingerprint() : Task.FromResult(s));
+
+		if (CurrentUser.IsAdmin)
+		{
+			return View("Details_Admin", post);
+		}
+
+		if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
+		{
+			BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ip, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
+			HttpContext.Session.Set("post" + id, id.ToString());
+		}
+
+		if (post.LimitMode == RegionLimitMode.OnlyForSearchEngine)
+		{
+			BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ip, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
+		}
+
+		return View(post);
+	}
+
+	/// <summary>
+	/// 文章历史版本
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <returns></returns>
+	[Route("{id:int}/history"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "page", "size" }, VaryByHeader = "Cookie")]
+	public async Task<ActionResult> History(int id, [Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 20)
+	{
+		var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
+		CheckPermission(post);
+		ViewBag.Primary = post;
+		var list = await PostHistoryVersionService.GetPagesAsync(page, size, v => v.PostId == id, v => v.ModifyDate, false);
+		foreach (var item in list.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		}
+
+		ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, post.Keyword + "," + post.Label);
+		return View(list);
+	}
+
+	/// <summary>
+	/// 文章历史版本
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="hid"></param>
+	/// <returns></returns>
+	[Route("{id:int}/history/{hid:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "hid" }, VaryByHeader = "Cookie")]
+	public async Task<ActionResult> HistoryVersion(int id, int hid)
+	{
+		var history = await PostHistoryVersionService.GetAsync(v => v.Id == hid && (v.Post.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
+		CheckPermission(history.Post);
+		history.Content = await ReplaceVariables(history.Content).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
+		history.ProtectContent = await ReplaceVariables(history.ProtectContent).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
+		history.ModifyDate = history.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		var next = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate > history.ModifyDate, p => p.ModifyDate);
+		var prev = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate < history.ModifyDate, p => p.ModifyDate, false);
+		ViewBag.Next = next;
+		ViewBag.Prev = prev;
+		ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), history.CategoryId, history.Label);
+		ViewData[nameof(history.Post.Author)] = history.Post.Author;
+		ViewData[nameof(history.Post.PostDate)] = history.Post.PostDate;
+		ViewData[nameof(history.ModifyDate)] = history.ModifyDate;
+		ViewData["cover"] = history.Content.MatchFirstImgSrc();
+		return CurrentUser.IsAdmin ? View("HistoryVersion_Admin", history) : View(history);
+	}
+
+	/// <summary>
+	/// 版本对比
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="v1"></param>
+	/// <param name="v2"></param>
+	/// <returns></returns>
+	[Route("{id:int}/history/{v1:int}-{v2:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "v1", "v2" }, VaryByHeader = "Cookie")]
+	public async Task<ActionResult> CompareVersion(int id, int v1, int v2)
+	{
+		var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin));
+		var main = Mapper.Map<PostHistoryVersion>(post) ?? throw new NotFoundException("文章未找到");
+		CheckPermission(post);
+		var right = v1 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v1) ?? throw new NotFoundException("文章未找到");
+		var left = v2 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v2) ?? throw new NotFoundException("文章未找到");
+		main.Id = id;
+		var diff = new HtmlDiff.HtmlDiff(left.Content, right.Content);
+		var diffOutput = diff.Build();
+		left.Content = await ReplaceVariables(Regex.Replace(Regex.Replace(diffOutput, "<ins.+?</ins>", string.Empty), @"<\w+></\w+>", string.Empty)).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
+		left.ModifyDate = left.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		right.Content = await ReplaceVariables(Regex.Replace(Regex.Replace(diffOutput, "<del.+?</del>", string.Empty), @"<\w+></\w+>", string.Empty)).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
+		right.ModifyDate = right.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		ViewBag.Ads = AdsService.GetsByWeightedPrice(2, AdvertiseType.InPage, Request.Location(), main.CategoryId, main.Label);
+		ViewBag.DisableCopy = post.DisableCopy;
+		return View(new[] { main, left, right });
+	}
+
+	/// <summary>
+	/// 反对
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	public async Task<ActionResult> VoteDown(int id)
+	{
+		if (HttpContext.Session.Get("post-vote" + id) != null)
+		{
+			return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
+		}
+
+		var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.VoteDownCount, m => m.VoteDownCount + 1)) > 0;
+		if (b)
+		{
+			HttpContext.Session.Set("post-vote" + id, id.GetBytes());
+		}
+
+		return ResultData(null, b, b ? "投票成功!" : "投票失败!");
+	}
+
+	/// <summary>
+	/// 支持
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	public async Task<ActionResult> VoteUp(int id)
+	{
+		if (HttpContext.Session.Get("post-vote" + id) != null)
+		{
+			return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
+		}
+
+		var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.VoteUpCount, m => m.VoteUpCount + 1)) > 0;
+		if (b)
+		{
+			HttpContext.Session.Set("post-vote" + id, id.GetBytes());
+		}
+
+		return ResultData(null, b, b ? "投票成功!" : "投票失败!");
+	}
+
+	/// <summary>
+	/// 投稿页
+	/// </summary>
+	/// <returns></returns>
+	public ActionResult Publish()
+	{
+		return View();
+	}
+
+	/// <summary>
+	/// 发布投稿
+	/// </summary>
+	/// <param name="post"></param>
+	/// <param name="code"></param>
+	/// <param name="cancellationToken"></param>
+	/// <returns></returns>
+	[HttpPost, ValidateAntiForgeryToken]
+	public async Task<ActionResult> Publish(PostCommand post, [Required(ErrorMessage = "验证码不能为空")] string code, CancellationToken cancellationToken)
+	{
+		if (RedisHelper.Get("code:" + post.Email) != code)
+		{
+			return ResultData(null, false, "验证码错误!");
+		}
+
+		if (PostService.Any(p => p.Status == Status.Forbidden && p.Email == post.Email))
+		{
+			return ResultData(null, false, "由于您曾经恶意投稿,该邮箱已经被标记为黑名单,无法进行投稿,如有疑问,请联系网站管理员进行处理。");
+		}
+
+		var match = Regex.Match(post.Title + post.Author + post.Content, CommonHelper.BanRegex);
+		if (match.Success)
+		{
+			LogManager.Info($"提交内容:{post.Title}/{post.Author}/{post.Content},敏感词:{match.Value}");
+			return ResultData(null, false, "您提交的内容包含敏感词,被禁止发表,请检查您的内容后尝试重新提交!");
+		}
+
+		if (!CategoryService.Any(c => c.Id == post.CategoryId))
+		{
+			return ResultData(null, message: "请选择一个分类");
+		}
+
+		post.Label = string.IsNullOrEmpty(post.Label?.Trim()) ? null : post.Label.Replace(",", ",");
+		post.Status = Status.Pending;
+		post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.HtmlSanitizerStandard().ClearImgAttributes(), cancellationToken);
+		Post p = Mapper.Map<Post>(post);
+		p.IP = ClientIP.ToString();
+		p.Modifier = p.Author;
+		p.ModifierEmail = p.Email;
+		p.DisableCopy = true;
+		p.Rss = true;
+		PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
+		{
+			Name = s,
+			Count = PostService.Count(t => t.Label.Contains(s))
+		}));
+		p = PostService.AddEntitySaved(p);
+		if (p == null)
+		{
+			return ResultData(null, false, "文章发表失败!");
+		}
+
+		RedisHelper.Expire("code:" + p.Email, 1);
+		var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/publish.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
+			.Set("link", Url.Action("Details", "Post", new { id = p.Id }, Request.Scheme))
+			.Set("time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))
+			.Set("title", p.Title).Render();
+		BackgroundJob.Enqueue<IMailSender>(sender => sender.Send(CommonHelper.SystemSettings["Title"] + "有访客投稿:", content, CommonHelper.SystemSettings["ReceiveEmail"], p.IP));
+		return ResultData(Mapper.Map<PostDto>(p), message: "文章发表成功,待站长审核通过以后将显示到列表中!");
+	}
+
+	/// <summary>
+	/// 获取标签
+	/// </summary>
+	/// <returns></returns>
+	[ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
+	public ActionResult GetTag()
+	{
+		return ResultData(PostService.GetTags().Select(x => x.Key).OrderBy(s => s));
+	}
+
+	/// <summary>
+	/// 标签云
+	/// </summary>
+	/// <returns></returns>
+	[Route("all"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
+	public async Task<ActionResult> All()
+	{
+		ViewBag.tags = new Dictionary<string, int>(PostService.GetTags().Where(x => x.Value > 1).OrderBy(x => x.Key));
+		ViewBag.cats = await CategoryService.GetQuery(c => c.Post.Count > 0, c => c.Post.Count, false).Include(c => c.Parent).ThenInclude(c => c.Parent).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Path()); //category
+		ViewBag.seminars = await SeminarService.GetAll(c => c.Post.Count, false).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Title); //seminars
+		return View();
+	}
+
+	/// <summary>
+	/// 检查访问密码
+	/// </summary>
+	/// <param name="email"></param>
+	/// <param name="token"></param>
+	/// <returns></returns>
+	[HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
+	public ActionResult CheckViewToken(string email, string token)
+	{
+		if (string.IsNullOrEmpty(token))
+		{
+			return ResultData(null, false, "请输入访问密码!");
+		}
+
+		var s = RedisHelper.Get("token:" + email);
+		if (token.Equals(s))
+		{
+			HttpContext.Session.Set("AccessViewToken", token);
+			Response.Cookies.Append("Email", email, new CookieOptions
+			{
+				Expires = DateTime.Now.AddYears(1),
+				SameSite = SameSiteMode.Lax
+			});
+			Response.Cookies.Append("PostAccessToken", email.MDString3(AppConfig.BaiduAK), new CookieOptions
+			{
+				Expires = DateTime.Now.AddYears(1),
+				SameSite = SameSiteMode.Lax
+			});
+			return ResultData(null);
+		}
+
+		return ResultData(null, false, "访问密码不正确!");
+	}
+
+	/// <summary>
+	/// 检查授权邮箱
+	/// </summary>
+	/// <param name="email"></param>
+	/// <returns></returns>
+	[HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
+	public ActionResult GetViewToken(string email)
+	{
+		var validator = new IsEmailAttribute();
+		if (!validator.IsValid(email))
+		{
+			return ResultData(null, false, validator.ErrorMessage);
+		}
+
+		if (RedisHelper.Exists("get:" + email))
+		{
+			RedisHelper.Expire("get:" + email, 120);
+			return ResultData(null, false, "发送频率限制,请在2分钟后重新尝试发送邮件!请检查你的邮件,若未收到,请检查你的邮箱地址或邮件垃圾箱!");
+		}
+
+		if (!UserInfoService.Any(b => b.Email.Equals(email)))
+		{
+			return ResultData(null, false, "您目前没有权限访问这个链接,请联系站长开通访问权限!");
+		}
+
+		var token = SnowFlake.GetInstance().GetUniqueShortId(6);
+		RedisHelper.Set("token:" + email, token, 86400);
+		BackgroundJob.Enqueue<IMailSender>(sender => sender.Send(Request.Host + "博客访问验证码", $"{Request.Host}本次验证码是:<span style='color:red'>{token}</span>,有效期为24h,请按时使用!", email, ClientIP.ToString()));
+		RedisHelper.Set("get:" + email, token, 120);
+		return ResultData(null);
+	}
+
+	/// <summary>
+	/// 文章合并
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[HttpGet("{id}/merge")]
+	public async Task<ActionResult> PushMerge(int id)
+	{
+		var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
+		CheckPermission(post);
+		return View(post);
+	}
+
+	/// <summary>
+	/// 文章合并
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="mid"></param>
+	/// <returns></returns>
+	[HttpGet("{id}/merge/{mid}")]
+	public async Task<ActionResult> RepushMerge(int id, int mid)
+	{
+		var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
+		CheckPermission(post);
+		var merge = post.PostMergeRequests.FirstOrDefault(p => p.Id == mid && p.MergeState != MergeStatus.Merged) ?? throw new NotFoundException("待合并文章未找到");
+		return View(merge);
+	}
+
+	/// <summary>
+	/// 文章合并
+	/// </summary>
+	/// <param name="messageService"></param>
+	/// <param name="postMergeRequestService"></param>
+	/// <param name="dto"></param>
+	/// <returns></returns>
+	[HttpPost("{id}/pushmerge")]
+	public async Task<ActionResult> PushMerge([FromServices] IInternalMessageService messageService, [FromServices] IPostMergeRequestService postMergeRequestService, PostMergeRequestCommand dto)
+	{
+		if (RedisHelper.Get("code:" + dto.ModifierEmail) != dto.Code)
+		{
+			return ResultData(null, false, "验证码错误!");
+		}
+
+		var post = await PostService.GetAsync(p => p.Id == dto.PostId && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
+		if (post.Title.Equals(dto.Title) && post.Content.HammingDistance(dto.Content) <= 1)
+		{
+			return ResultData(null, false, "内容未被修改或修改的内容过少(无意义修改)!");
+		}
+
+		#region 合并验证
+
+		if (postMergeRequestService.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Block))
+		{
+			return ResultData(null, false, "由于您曾经多次恶意修改文章,已经被标记为黑名单,无法修改任何文章,如有疑问,请联系网站管理员进行处理。");
+		}
+
+		if (post.PostMergeRequests.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Pending))
+		{
+			return ResultData(null, false, "您已经提交过一次修改请求正在待处理,暂不能继续提交修改请求!");
+		}
+
+		#endregion 合并验证
+
+		#region 直接合并
+
+		if (post.Email.Equals(dto.ModifierEmail))
+		{
+			var history = Mapper.Map<PostHistoryVersion>(post);
+			Mapper.Map(dto, post);
+			post.PostHistoryVersion.Add(history);
+			post.ModifyDate = DateTime.Now;
+			return await PostService.SaveChangesAsync() > 0 ? ResultData(null, true, "你是文章原作者,无需审核,文章已自动更新并在首页展示!") : ResultData(null, false, "操作失败!");
+		}
+
+		#endregion 直接合并
+
+		var merge = post.PostMergeRequests.FirstOrDefault(r => r.Id == dto.Id && r.MergeState != MergeStatus.Merged);
+		if (merge != null)
+		{
+			Mapper.Map(dto, merge);
+			merge.SubmitTime = DateTime.Now;
+			merge.MergeState = MergeStatus.Pending;
+		}
+		else
+		{
+			merge = Mapper.Map<PostMergeRequest>(dto);
+			merge.SubmitTime = DateTime.Now;
+			post.PostMergeRequests.Add(merge);
+		}
+		merge.IP = ClientIP.ToString();
+		var b = await PostService.SaveChangesAsync() > 0;
+		if (!b)
+		{
+			return ResultData(null, false, "操作失败!");
+		}
+
+		RedisHelper.Expire("code:" + dto.ModifierEmail, 1);
+		await messageService.AddEntitySavedAsync(new InternalMessage()
+		{
+			Title = $"来自【{dto.Modifier}】对文章《{post.Title}》的修改请求",
+			Content = dto.Title,
+			Link = "#/merge/compare?id=" + merge.Id
+		});
+
+		var htmlDiff = new HtmlDiff.HtmlDiff(post.Content.RemoveHtmlTag(), dto.Content.RemoveHtmlTag());
+		var diff = htmlDiff.Build();
+		var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/merge-request.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
+			.Set("title", post.Title)
+			.Set("link", Url.Action("Index", "Dashboard", new { }, Request.Scheme) + "#/merge/compare?id=" + merge.Id)
+			.Set("diff", diff)
+			.Set("host", "//" + Request.Host)
+			.Set("id", merge.Id.ToString())
+			.Render();
+		BackgroundJob.Enqueue<IMailSender>(sender => sender.Send("博客文章修改请求:", content, CommonHelper.SystemSettings["ReceiveEmail"], merge.IP));
+		return ResultData(null, true, "您的修改请求已提交,已进入审核状态,感谢您的参与!");
+	}
+
+	#region 后端管理
+
+	/// <summary>
+	/// 固顶
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> Fixtop(int id)
+	{
+		Post post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		post.IsFixedTop = !post.IsFixedTop;
+		bool b = await PostService.SaveChangesAsync() > 0;
+		return b ? ResultData(null, true, post.IsFixedTop ? "置顶成功!" : "取消置顶成功!") : ResultData(null, false, "操作失败!");
+	}
+
+	/// <summary>
+	/// 审核
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> Pass(int id)
+	{
+		var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		post.Status = Status.Published;
+		post.ModifyDate = DateTime.Now;
+		post.PostDate = DateTime.Now;
+		var b = await PostService.SaveChangesAsync() > 0;
+		if (!b)
+		{
+			return ResultData(null, false, "审核失败!");
+		}
+
+		(post.Keyword + "," + post.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
+		SearchEngine.LuceneIndexer.Add(post);
+		return ResultData(null, true, "审核通过!");
+	}
+
+	/// <summary>
+	/// 下架文章
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> Takedown(int id)
+	{
+		var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		post.Status = Status.Takedown;
+		bool b = await PostService.SaveChangesAsync(true) > 0;
+		SearchEngine.LuceneIndexer.Delete(post);
+		return ResultData(null, b, b ? $"文章《{post.Title}》已下架!" : "下架失败!");
+	}
+
+	/// <summary>
+	/// 还原版本
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> Takeup(int id)
+	{
+		var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		post.Status = Status.Published;
+		bool b = await PostService.SaveChangesAsync() > 0;
+		SearchEngine.LuceneIndexer.Add(post);
+		return ResultData(null, b, b ? "上架成功!" : "上架失败!");
+	}
+
+	/// <summary>
+	/// 彻底删除文章
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public ActionResult Truncate(int id)
+	{
+		bool b = PostService - id;
+		return ResultData(null, b, b ? "删除成功!" : "删除失败!");
+	}
+
+	/// <summary>
+	/// 获取文章
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public ActionResult Get(int id)
+	{
+		var post = PostService.GetQuery(e => e.Id == id).Include(e => e.Seminar).FirstOrDefault() ?? throw new NotFoundException("文章未找到");
+		var model = Mapper.Map<PostDto>(post);
+		model.Seminars = post.Seminar.Select(s => s.Id).Join(",");
+		return ResultData(model);
+	}
+
+	/// <summary>
+	/// 获取文章分页
+	/// </summary>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> GetPageData([FromServices] ICacheManager<HashSet<string>> cacheManager, int page = 1, [Range(1, 200, ErrorMessage = "页大小必须介于{1}-{2}")] int size = 10, OrderBy orderby = OrderBy.ModifyDate, string kw = "", int? cid = null)
+	{
+		Expression<Func<Post, bool>> where = p => true;
+		if (cid.HasValue)
+		{
+			where = where.And(p => p.CategoryId == cid.Value || p.Category.ParentId == cid.Value || p.Category.Parent.ParentId == cid.Value);
+		}
+
+		if (!string.IsNullOrEmpty(kw))
+		{
+			kw = Regex.Escape(kw);
+			where = where.And(p => Regex.IsMatch(p.Title + p.Author + p.Email + p.Content, kw, RegexOptions.IgnoreCase));
+		}
+
+		var list = orderby switch
+		{
+			OrderBy.Trending => await PostService.GetQuery(where).OrderByDescending(p => p.Status).ThenByDescending(p => p.IsFixedTop).ThenByDescending(p => p.PostVisitRecordStats.Average(t => t.Count)).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig),
+			_ => await PostService.GetQuery(where).OrderBy($"{nameof(Post.Status)} desc,{nameof(Post.IsFixedTop)} desc,{orderby.GetDisplay()} desc").ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig)
+		};
+		foreach (var item in list.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+			item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+			item.Online = cacheManager.Get(nameof(PostOnline) + ":" + item.Id)?.Count ?? 0;
+		}
+
+		return Ok(list);
+	}
+
+	/// <summary>
+	/// 获取未审核文章
+	/// </summary>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <param name="search"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> GetPending([Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15, string search = "")
+	{
+		Expression<Func<Post, bool>> where = p => p.Status == Status.Pending;
+		if (!string.IsNullOrEmpty(search))
+		{
+			where = where.And(p => p.Title.Contains(search) || p.Author.Contains(search) || p.Email.Contains(search) || p.Label.Contains(search));
+		}
+
+		var pages = await PostService.GetQuery(where).OrderByDescending(p => p.IsFixedTop).ThenByDescending(p => p.ModifyDate).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig);
+		foreach (var item in pages.Data)
+		{
+			item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+			item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+		}
+
+		return Ok(pages);
+	}
+
+	/// <summary>
+	/// 编辑
+	/// </summary>
+	/// <param name="post"></param>
+	/// <param name="cancellationToken"></param>
+	/// <returns></returns>
+	[HttpPost, MyAuthorize]
+	public async Task<ActionResult> Edit([FromBodyOrDefault] PostCommand post, CancellationToken cancellationToken = default)
+	{
+		post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
+		if (!ValidatePost(post, out var resultData))
+		{
+			return resultData;
+		}
+
+		Post p = await PostService.GetByIdAsync(post.Id);
+		if (post.Reserve && p.Status == Status.Published)
+		{
+			if (p.Content.HammingDistance(post.Content) > 0)
+			{
+				var history = Mapper.Map<PostHistoryVersion>(p);
+				history.PostId = p.Id;
+				PostHistoryVersionService.AddEntity(history);
+			}
+
+			if (p.Title.HammingDistance(post.Title) > 10 && CommentService.Any(c => c.PostId == p.Id && c.ParentId == null))
+			{
+				CommentService.AddEntity(new Comment
+				{
+					Status = Status.Published,
+					NickName = "系统自动评论",
+					Email = p.Email,
+					Content = $"<p style=\"color:red\">温馨提示:由于文章发生了重大更新,本条评论之前的所有评论仅作为原文《{p.Title}》的历史评论保留,不作为本文的最新评论参考,请知悉!了解更多信息,请查阅本文的历史修改记录。</p>",
+					PostId = p.Id,
+					CommentDate = DateTime.Now,
+					IsMaster = true,
+					IsAuthor = true,
+					IP = "127.0.0.1",
+					Location = "内网",
+					GroupTag = SnowFlake.NewId,
+					Path = SnowFlake.NewId,
+				});
+			}
+
+			p.ModifyDate = DateTime.Now;
+			var user = HttpContext.Session.Get<UserInfoDto>(SessionKey.UserInfo);
+			post.Modifier = string.IsNullOrEmpty(post.Modifier) ? user.NickName : post.Modifier;
+			post.ModifierEmail = string.IsNullOrEmpty(post.ModifierEmail) ? user.Email : post.ModifierEmail;
+		}
+
+		Mapper.Map(post, p);
+		p.IP = ClientIP.ToString();
+		p.Seminar.Clear();
+		if (!string.IsNullOrEmpty(post.Seminars))
+		{
+			var tmp = post.Seminars.Split(',', StringSplitOptions.RemoveEmptyEntries).Distinct().Select(int.Parse).ToArray();
+			var seminars = SeminarService.GetQuery(s => tmp.Contains(s.Id)).ToPooledListScope();
+			p.Seminar.AddRange(seminars);
+		}
+
+		(p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
+		PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
+		{
+			Name = s,
+			Count = PostService.Count(t => t.Label.Contains(s))
+		}));
+		bool b = await SearchEngine.SaveChangesAsync() > 0;
+		if (!b)
+		{
+			return ResultData(null, false, "文章修改失败!");
+		}
+
+		if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
+		{
+			SearchEngine.LuceneIndexer.Delete(p);
+		}
+		return ResultData(Mapper.Map<PostDto>(p), message: "文章修改成功!");
+	}
+
+	/// <summary>
+	/// 发布
+	/// </summary>
+	/// <param name="post"></param>
+	/// <param name="timespan"></param>
+	/// <param name="schedule"></param>
+	/// <param name="cancellationToken"></param>
+	/// <returns></returns>
+	[MyAuthorize, HttpPost]
+	public async Task<ActionResult> Write([FromBodyOrDefault] PostCommand post, [FromBodyOrDefault] DateTime? timespan, [FromBodyOrDefault] bool schedule = false, CancellationToken cancellationToken = default)
+	{
+		post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
+		if (!ValidatePost(post, out var resultData))
+		{
+			return resultData;
+		}
+
+		post.Status = Status.Published;
+		Post p = Mapper.Map<Post>(post);
+		p.Modifier = p.Author;
+		p.ModifierEmail = p.Email;
+		p.IP = ClientIP.ToString();
+		p.Rss = p.LimitMode is null or RegionLimitMode.All;
+		if (!string.IsNullOrEmpty(post.Seminars))
+		{
+			var tmp = post.Seminars.Split(',').Distinct().Select(int.Parse).ToArray();
+			p.Seminar.AddRange(SeminarService[s => tmp.Contains(s.Id)]);
+		}
+
+		if (schedule)
+		{
+			if (!timespan.HasValue || timespan.Value <= DateTime.Now)
+			{
+				return ResultData(null, false, "如果要定时发布,请选择正确的一个将来时间点!");
+			}
+
+			p.Status = Status.Schedule;
+			p.PostDate = timespan.Value.ToUniversalTime();
+			p.ModifyDate = timespan.Value.ToUniversalTime();
+			BackgroundJob.Enqueue<IHangfireBackJob>(job => job.PublishPost(p));
+			return ResultData(Mapper.Map<PostDto>(p), message: $"文章于{timespan.Value:yyyy-MM-dd HH:mm:ss}将会自动发表!");
+		}
+
+		PostService.AddEntity(p);
+		(p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
+		PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
+		{
+			Name = s,
+			Count = PostService.Count(t => t.Label.Contains(s))
+		}));
+		bool b = await SearchEngine.SaveChangesAsync() > 0;
+		if (!b)
+		{
+			return ResultData(null, false, "文章发表失败!");
+		}
+
+		if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
+		{
+			SearchEngine.LuceneIndexer.Delete(p);
+		}
+
+		return ResultData(null, true, "文章发表成功!");
+	}
+
+	private bool ValidatePost(PostCommand post, out ActionResult resultData)
+	{
+		if (!CategoryService.Any(c => c.Id == post.CategoryId && c.Status == Status.Available))
+		{
+			resultData = ResultData(null, false, "请选择一个分类");
+			return false;
+		}
+
+		switch (post.LimitMode)
+		{
+			case RegionLimitMode.AllowRegion:
+			case RegionLimitMode.ForbidRegion:
+				if (string.IsNullOrEmpty(post.Regions))
+				{
+					resultData = ResultData(null, false, "请输入限制的地区");
+					return false;
+				}
+
+				post.Regions = post.Regions.Replace(",", "|").Replace(",", "|");
+				break;
+
+			case RegionLimitMode.AllowRegionExceptForbidRegion:
+			case RegionLimitMode.ForbidRegionExceptAllowRegion:
+				if (string.IsNullOrEmpty(post.ExceptRegions))
+				{
+					resultData = ResultData(null, false, "请输入排除的地区");
+					return false;
+				}
+
+				post.ExceptRegions = post.ExceptRegions.Replace(",", "|").Replace(",", "|");
+				goto case RegionLimitMode.AllowRegion;
+		}
+
+		if (string.IsNullOrEmpty(post.Label?.Trim()) || post.Label.Equals("null"))
+		{
+			post.Label = null;
+		}
+		else if (post.Label.Trim().Length > 50)
+		{
+			post.Label = post.Label.Replace(",", ",");
+			post.Label = post.Label.Trim().Substring(0, 50);
+		}
+		else
+		{
+			post.Label = post.Label.Replace(",", ",");
+		}
+
+		if (string.IsNullOrEmpty(post.ProtectContent?.RemoveHtmlTag()) || post.ProtectContent.Equals("null"))
+		{
+			post.ProtectContent = null;
+		}
+
+		resultData = null;
+		return true;
+	}
+
+	/// <summary>
+	/// 添加专题
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="sid"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> AddSeminar(int id, int sid)
+	{
+		var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
+		post.Seminar.Add(seminar);
+		bool b = await PostService.SaveChangesAsync() > 0;
+		return ResultData(null, b, b ? $"已将文章【{post.Title}】添加到专题【{seminar.Title}】" : "添加失败");
+	}
+
+	/// <summary>
+	/// 移除专题
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="sid"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> RemoveSeminar(int id, int sid)
+	{
+		var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
+		post.Seminar.Remove(seminar);
+		bool b = await PostService.SaveChangesAsync() > 0;
+		return ResultData(null, b, b ? $"已将文章【{post.Title}】从【{seminar.Title}】专题移除" : "添加失败");
+	}
+
+	/// <summary>
+	/// 删除历史版本
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> DeleteHistory(int id)
+	{
+		bool b = await PostHistoryVersionService.DeleteByIdAsync(id) > 0;
+		return ResultData(null, b, b ? "历史版本文章删除成功!" : "历史版本文章删除失败!");
+	}
+
+	/// <summary>
+	/// 还原版本
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> Revert(int id)
+	{
+		var history = await PostHistoryVersionService.GetByIdAsync(id) ?? throw new NotFoundException("版本不存在");
+		history.Post.Category = history.Category;
+		history.Post.CategoryId = history.CategoryId;
+		history.Post.Content = history.Content;
+		history.Post.Title = history.Title;
+		history.Post.Label = history.Label;
+		history.Post.ModifyDate = history.ModifyDate;
+		history.Post.Seminar.Clear();
+		foreach (var s in history.Seminar)
+		{
+			history.Post.Seminar.Add(s);
+		}
+		bool b = await SearchEngine.SaveChangesAsync() > 0;
+		await PostHistoryVersionService.DeleteByIdAsync(id);
+		return ResultData(null, b, b ? "回滚成功" : "回滚失败");
+	}
+
+	/// <summary>
+	/// 禁用或开启文章评论
+	/// </summary>
+	/// <param name="id">文章id</param>
+	/// <returns></returns>
+	[MyAuthorize]
+	[HttpPost("post/{id}/DisableComment")]
+	public async Task<ActionResult> DisableComment(int id)
+	{
+		var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		post.DisableComment = !post.DisableComment;
+		return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableComment ? $"已禁用【{post.Title}】这篇文章的评论功能!" : $"已启用【{post.Title}】这篇文章的评论功能!");
+	}
+
+	/// <summary>
+	/// 禁用或开启文章评论
+	/// </summary>
+	/// <param name="id">文章id</param>
+	/// <returns></returns>
+	[MyAuthorize]
+	[HttpPost("post/{id}/DisableCopy")]
+	public async Task<ActionResult> DisableCopy(int id)
+	{
+		var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		post.DisableCopy = !post.DisableCopy;
+		return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableCopy ? $"已开启【{post.Title}】这篇文章的防复制功能!" : $"已关闭【{post.Title}】这篇文章的防复制功能!");
+	}
+
+	/// <summary>
+	/// 禁用或开启NSFW
+	/// </summary>
+	/// <param name="id">文章id</param>
+	/// <returns></returns>
+	[MyAuthorize]
+	[HttpPost("post/{id}/nsfw")]
+	public async Task<ActionResult> Nsfw(int id)
+	{
+		var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+		post.IsNsfw = !post.IsNsfw;
+		return ResultData(null, await PostService.SaveChangesAsync() > 0, post.IsNsfw ? $"已将文章【{post.Title}】标记为不安全内容!" : $"已将文章【{post.Title}】取消标记为不安全内容!");
+	}
+
+	/// <summary>
+	/// 修改分类
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="cid"></param>
+	/// <returns></returns>
+	[HttpPost("post/{id}/ChangeCategory/{cid}")]
+	public async Task<ActionResult> ChangeCategory(int id, int cid)
+	{
+		await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(p => p.CategoryId, cid));
+		return Ok();
+	}
+
+	/// <summary>
+	/// 修改专题
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="sids"></param>
+	/// <returns></returns>
+	[HttpPost("post/{id}/ChangeSeminar")]
+	public async Task<ActionResult> ChangeSeminar(int id, string sids)
+	{
+		var post = PostService.GetQuery(e => e.Id == id).Include(e => e.Seminar).FirstOrDefault() ?? throw new NotFoundException("文章不存在");
+		post.Seminar.Clear();
+		if (!string.IsNullOrEmpty(sids))
+		{
+			var ids = sids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray();
+			post.Seminar.AddRange(SeminarService[s => ids.Contains(s.Id)]);
+		}
+
+		await PostService.SaveChangesAsync();
+		return Ok();
+	}
+
+	/// <summary>
+	/// 刷新文章
+	/// </summary>
+	/// <param name="id">文章id</param>
+	/// <param name="cancellationToken"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<ActionResult> Refresh(int id, CancellationToken cancellationToken = default)
+	{
+		await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.ModifyDate, DateTime.Now), cancellationToken: cancellationToken);
+		return RedirectToAction("Details", new { id });
+	}
+
+	/// <summary>
+	/// 标记为恶意修改
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="cancellationToken"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	[HttpPost("post/block/{id}")]
+	public async Task<ActionResult> Block(int id, CancellationToken cancellationToken = default)
+	{
+		var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Status, Status.Forbidden), cancellationToken: cancellationToken) > 0;
+		return b ? ResultData(null, true, "操作成功!") : ResultData(null, false, "操作失败!");
+	}
+
+	/// <summary>
+	/// 切换允许rss订阅
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="cancellationToken"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	[HttpPost("post/{id}/rss-switch")]
+	public async Task<ActionResult> RssSwitch(int id, CancellationToken cancellationToken = default)
+	{
+		await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Rss, p => !p.Rss), cancellationToken: cancellationToken);
+		return ResultData(null, message: "操作成功");
+	}
+
+	/// <summary>
+	/// 切换锁定编辑
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="cancellationToken"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	[HttpPost("post/{id}/locked-switch")]
+	public async Task<ActionResult> LockedSwitch(int id, CancellationToken cancellationToken = default)
+	{
+		await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Locked, p => !p.Locked), cancellationToken: cancellationToken);
+		return ResultData(null, message: "操作成功");
+	}
+
+	/// <summary>
+	/// 文章统计
+	/// </summary>
+	/// <returns></returns>
+	[MyAuthorize]
+	public async Task<IActionResult> Statistic(CancellationToken cancellationToken = default)
+	{
+		var keys = RedisHelper.Keys(nameof(PostOnline) + ":*");
+		var sets = keys.Select(s => (Id: s.Split(':')[1].ToInt32(), Clients: RedisHelper.HGet<HashSet<string>>(s, "value")));
+		var ids = sets.Where(t => t.Clients?.Count > 0).OrderByDescending(t => t.Clients.Count).Take(10).Select(t => t.Id).ToArray();
+		var mostHots = await PostService.GetQuery<PostModelBase>(p => ids.Contains(p.Id)).ToListAsync(cancellationToken).ContinueWith(t =>
+		{
+			foreach (var item in t.Result)
+			{
+				item.ViewCount = sets.FirstOrDefault(x => x.Id == item.Id).Clients.Count;
+			}
+
+			return t.Result.OrderByDescending(p => p.ViewCount);
+		});
+		var postsQuery = PostService.GetQuery(p => p.Status == Status.Published);
+		var mostView = await postsQuery.OrderByDescending(p => p.TotalViewCount).Take(10).Select(p => new PostModelBase()
+		{
+			Id = p.Id,
+			Title = p.Title,
+			ViewCount = p.TotalViewCount
+		}).ToListAsync(cancellationToken);
+		var mostAverage = await postsQuery.OrderByDescending(p => p.AverageViewCount).Take(10).Select(p => new PostModelBase()
+		{
+			Id = p.Id,
+			Title = p.Title,
+			ViewCount = (int)p.AverageViewCount
+		}).ToListAsync(cancellationToken);
+		var yesterday = DateTime.Now.AddDays(-1);
+		var trending = await postsQuery.Select(p => new PostModelBase()
+		{
+			Id = p.Id,
+			Title = p.Title,
+			ViewCount = p.PostVisitRecords.Count(t => t.Time >= yesterday)
+		}).OrderByDescending(p => p.ViewCount).Take(10).ToListAsync(cancellationToken);
+		var readCount = PostVisitRecordService.Count(e => e.Time >= yesterday);
+		return ResultData(new
+		{
+			mostHots,
+			mostView,
+			mostAverage,
+			trending,
+			readCount
+		});
+	}
+
+	/// <summary>
+	/// 文章访问记录
+	/// </summary>
+	/// <param name="id"></param>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <returns></returns>
+	[HttpGet("/{id}/records"), MyAuthorize]
+	[ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
+	public async Task<IActionResult> PostVisitRecords(int id, int page = 1, int size = 15, string kw = "")
+	{
+		Expression<Func<PostVisitRecord, bool>> where = e => e.PostId == id;
+		if (!string.IsNullOrEmpty(kw))
+		{
+			kw = Regex.Escape(kw);
+			where = where.And(e => Regex.IsMatch(e.IP + e.Location + e.Referer + e.RequestUrl, kw, RegexOptions.IgnoreCase));
+		}
+
+		var pages = await PostVisitRecordService.GetPagesAsync<DateTime, PostVisitRecordViewModel>(page, size, where, e => e.Time, false);
+		return Ok(pages);
+	}
+
+	/// <summary>
+	/// 导出文章访问记录
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[HttpGet("/{id}/records-export"), MyAuthorize]
+	[ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
+	public IActionResult ExportPostVisitRecords(int id)
+	{
+		var list = PostVisitRecordService.GetQuery<DateTime, PostVisitRecordViewModel>(e => e.PostId == id, e => e.Time, false).ToPooledListScope();
+		using var ms = list.ToExcel();
+		var post = PostService[id];
+		return this.ResumeFile(ms.ToArray(), ContentType.Xlsx, post.Title + "访问记录.xlsx");
+	}
+
+	/// <summary>
+	/// 文章访问记录图表
+	/// </summary>
+	/// <returns></returns>
+	[HttpGet("/{id}/records-chart"), MyAuthorize]
+	[ProducesResponseType((int)HttpStatusCode.OK)]
+	public async Task<IActionResult> PostVisitRecordChart([FromServices] IPostVisitRecordStatsService statsService, int id, bool compare, uint period, CancellationToken cancellationToken)
+	{
+		if (compare)
+		{
+			var start1 = DateTime.Today.AddDays(-period);
+			var list1 = await statsService.GetQuery(e => e.PostId == id && e.Date >= start1).GroupBy(t => t.Date).Select(g => new
+			{
+				Date = g.Key,
+				Count = g.Sum(t => t.Count),
+				UV = g.Sum(t => t.UV)
+			}).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+			if (list1.Count == 0)
+			{
+				return Ok(Array.Empty<int>());
+			}
+
+			var start2 = start1.AddDays(-period - 1);
+			var list2 = await statsService.GetQuery(e => e.PostId == id && e.Date >= start2 && e.Date < start1).GroupBy(t => t.Date).Select(g => new
+			{
+				Date = g.Key,
+				Count = g.Sum(t => t.Count),
+				UV = g.Sum(t => t.UV)
+			}).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+
+			// 将数据填充成连续的数据
+			for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
+			{
+				if (list1.All(a => a.Date != i))
+				{
+					list1.Add(new { Date = i, Count = 0, UV = 0 });
+				}
+			}
+			for (var i = start2; i < start1; i = i.AddDays(1))
+			{
+				if (list2.All(a => a.Date != i))
+				{
+					list2.Add(new { Date = i, Count = 0, UV = 0 });
+				}
+			}
+			return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
+		}
+
+		var list = await statsService.GetQuery(e => e.PostId == id).GroupBy(t => t.Date).Select(g => new
+		{
+			Date = g.Key,
+			Count = g.Sum(t => t.Count),
+			UV = g.Sum(t => t.UV)
+		}).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+		var min = list.Min(a => a.Date);
+		var max = list.Max(a => a.Date);
+		for (var i = min; i < max; i = i.AddDays(1))
+		{
+			if (list.All(a => a.Date != i))
+			{
+				list.Add(new { Date = i, Count = 0, UV = 0 });
+			}
+		}
+
+		return Ok(new[] { list.OrderBy(a => a.Date) });
+	}
+
+	/// <summary>
+	/// 文章访问记录图表
+	/// </summary>
+	/// <returns></returns>
+	[HttpGet("/post/records-chart"), MyAuthorize]
+	[ProducesResponseType((int)HttpStatusCode.OK)]
+	public async Task<IActionResult> PostVisitRecordChart(bool compare, uint period, CancellationToken cancellationToken)
+	{
+		if (compare)
+		{
+			var start1 = DateTime.Today.AddDays(-period);
+			var list1 = await PostVisitRecordService.GetQuery(e => e.Time >= start1).Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
+			{
+				Date = g.Key,
+				Count = g.Count(),
+				UV = g.Select(e => e.IP).Distinct().Count()
+			}).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+			if (list1.Count == 0)
+			{
+				return Ok(Array.Empty<int>());
+			}
+
+			var start2 = start1.AddDays(-period - 1);
+			var list2 = await PostVisitRecordService.GetQuery(e => e.Time >= start2 && e.Time < start1).Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
+			{
+				Date = g.Key,
+				Count = g.Count(),
+				UV = g.Select(e => e.IP).Distinct().Count()
+			}).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+
+			// 将数据填充成连续的数据
+			for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
+			{
+				if (list1.All(a => a.Date != i))
+				{
+					list1.Add(new { Date = i, Count = 0, UV = 0 });
+				}
+			}
+			for (var i = start2; i < start1; i = i.AddDays(1))
+			{
+				if (list2.All(a => a.Date != i))
+				{
+					list2.Add(new { Date = i, Count = 0, UV = 0 });
+				}
+			}
+			return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
+		}
+
+		var list = await PostVisitRecordService.GetAll().Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
+		{
+			Date = g.Key,
+			Count = g.Count(),
+			UV = g.Select(e => e.IP).Distinct().Count()
+		}).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+		var min = list.Min(a => a.Date);
+		var max = list.Max(a => a.Date);
+		for (var i = min; i < max; i = i.AddDays(1))
+		{
+			if (list.All(a => a.Date != i))
+			{
+				list.Add(new { Date = i, Count = 0, UV = 0 });
+			}
+		}
+
+		return Ok(new[] { list.OrderBy(a => a.Date) });
+	}
+
+	/// <summary>
+	/// 文章访问记录分析
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[HttpGet("/{id}/insight"), MyAuthorize]
+	[ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
+	public IActionResult PostVisitRecordInsight(int id)
+	{
+		return View(PostService[id]);
+	}
+
+	/// <summary>
+	/// 获取地区集
+	/// </summary>
+	/// <param name="name"></param>
+	/// <returns></returns>
+	[MyAuthorize]
+	[ProducesResponseType(typeof(List<string>), (int)HttpStatusCode.OK)]
+	public async Task<IActionResult> GetRegions(string name)
+	{
+		return ResultData(await PostService.GetAll().Select(p => EF.Property<string>(p, name)).Distinct().ToListAsync());
+	}
+
+	#endregion 后端管理
 }

+ 98 - 101
src/Masuit.MyBlogs.Core/Controllers/SearchController.cs

@@ -1,11 +1,11 @@
-using Dispose.Scope;
-using Masuit.MyBlogs.Core.Common;
+using Masuit.MyBlogs.Core.Common;
 using Masuit.MyBlogs.Core.Extensions;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Caching.Memory;
 using Microsoft.International.Converters.TraditionalChineseToSimplifiedConverter;
 using System.ComponentModel.DataAnnotations;
-using Z.EntityFramework.Plus;
+using Dispose.Scope;
+using EFCoreSecondLevelCacheInterceptor;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -14,112 +14,109 @@ namespace Masuit.MyBlogs.Core.Controllers;
 /// </summary>
 public sealed class SearchController : BaseController
 {
-    public ISearchDetailsService SearchDetailsService { get; set; }
+	public ISearchDetailsService SearchDetailsService { get; set; }
 
-    /// <summary>
-    /// 搜索页
-    /// </summary>
-    /// <param name="postService"></param>
-    /// <param name="wd"></param>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <returns></returns>
-    [HttpGet("search/{**wd:maxlength(64)}"), HttpGet("search", Order = 2), HttpGet("s/{**wd:maxlength(64)}", Order = 3), HttpGet("s", Order = 4)]
-    public async Task<ActionResult> Search([FromServices] IPostService postService, string wd = "", [Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
-    {
-        wd = ChineseConverter.Convert(wd?.Trim() ?? "", ChineseConversionDirection.TraditionalToSimplified);
-        if (wd.Length > 128)
-        {
-            wd = wd[..128];
-        }
+	/// <summary>
+	/// 搜索页
+	/// </summary>
+	/// <param name="postService"></param>
+	/// <param name="wd"></param>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <returns></returns>
+	[HttpGet("search/{**wd:maxlength(64)}"), HttpGet("search", Order = 2), HttpGet("s/{**wd:maxlength(64)}", Order = 3), HttpGet("s", Order = 4)]
+	public async Task<ActionResult> Search([FromServices] IPostService postService, string wd = "", [Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15)
+	{
+		wd = ChineseConverter.Convert(wd?.Trim() ?? "", ChineseConversionDirection.TraditionalToSimplified);
+		if (wd.Length > 128)
+		{
+			wd = wd[..128];
+		}
 
-        ViewBag.PageSize = size;
-        ViewBag.Keyword = wd;
-        if (!wd.IsNullOrEmpty())
-        {
-            var posts = postService.SearchPage(PostBaseWhere(), page, size, wd);
-            if (posts.Results.Count > 1)
-            {
-                ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location(), keywords: wd);
-            }
+		ViewBag.PageSize = size;
+		ViewBag.Keyword = wd;
+		if (!wd.IsNullOrEmpty())
+		{
+			var posts = postService.SearchPage(PostBaseWhere(), page, size, wd);
+			if (posts.Results.Count > 1)
+			{
+				ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.ListItem, Request.Location(), keywords: wd);
+			}
 
-            ViewBag.hotSearches = new List<KeywordsRank>();
-            if (posts.Total > size)
-            {
-                ViewBag.RelateKeywords = SearchDetailsService.GetQuery(s => s.Keywords.Contains(wd) && s.Keywords != wd).Select(s => s.Keywords).GroupBy(s => s).OrderByDescending(g => g.Count()).Select(g => g.Key).Take(10).FromCache(new MemoryCacheEntryOptions()
-                {
-                    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
-                }).ToPooledListScope();
-            }
+			ViewBag.hotSearches = new List<KeywordsRank>();
+			if (posts.Total > size)
+			{
+				ViewBag.RelateKeywords = SearchDetailsService.GetQuery(s => s.Keywords.Contains(wd) && s.Keywords != wd).Select(s => s.Keywords).GroupBy(s => s).OrderByDescending(g => g.Count()).Select(g => g.Key).Take(10).Cacheable(CacheExpirationMode.Absolute, TimeSpan.FromHours(1)).ToPooledListScope();
+			}
 
-            if (!HttpContext.Session.TryGetValue("search:" + wd, out _) && !Request.IsRobot())
-            {
-                SearchDetailsService.AddEntity(new SearchDetails
-                {
-                    Keywords = wd,
-                    SearchTime = DateTime.Now,
-                    IP = ClientIP.ToString(),
-                    Elapsed = posts.Elapsed,
-                    ResultCount = posts.Total
-                });
-                await SearchDetailsService.SaveChangesAsync();
-                HttpContext.Session.Set("search:" + wd, wd);
-            }
+			if (!HttpContext.Session.TryGetValue("search:" + wd, out _) && !Request.IsRobot())
+			{
+				SearchDetailsService.AddEntity(new SearchDetails
+				{
+					Keywords = wd,
+					SearchTime = DateTime.Now,
+					IP = ClientIP.ToString(),
+					Elapsed = posts.Elapsed,
+					ResultCount = posts.Total
+				});
+				await SearchDetailsService.SaveChangesAsync();
+				HttpContext.Session.Set("search:" + wd, wd);
+			}
 
-            if (page == 1 && posts.Results.Count == 1 && (posts.Results[0].Title.Contains(wd) || posts.Results[0].Content.Contains(wd)))
-            {
-                return RedirectToAction("Details", "Post", new { id = posts.Results[0].Id, kw = wd });
-            }
+			if (page == 1 && posts.Results.Count == 1 && (posts.Results[0].Title.Contains(wd) || posts.Results[0].Content.Contains(wd)))
+			{
+				return RedirectToAction("Details", "Post", new { id = posts.Results[0].Id, kw = wd });
+			}
 
-            return View(posts);
-        }
+			return View(posts);
+		}
 
-        ViewBag.hotSearches = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Week").Take(10).ToList();
-        return View(new SearchResult<PostDto>());
-    }
+		ViewBag.hotSearches = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Week").Take(10).ToList();
+		return View(new SearchResult<PostDto>());
+	}
 
-    /// <summary>
-    /// 关键词搜索记录
-    /// </summary>
-    /// <param name="page"></param>
-    /// <param name="size"></param>
-    /// <param name="search"></param>
-    /// <returns></returns>
-    [MyAuthorize, HttpPost("search/SearchList"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "search" })]
-    public ActionResult SearchList([Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 200, ErrorMessage = "页大小必须在0到50之间")] int size = 15, string search = "")
-    {
-        var where = string.IsNullOrEmpty(search) ? (Expression<Func<SearchDetails, bool>>)(s => true) : s => s.Keywords.Contains(search);
-        var pages = SearchDetailsService.GetPages<DateTime, SearchDetailsDto>(page, size, where, s => s.SearchTime, false);
-        return Ok(pages);
-    }
+	/// <summary>
+	/// 关键词搜索记录
+	/// </summary>
+	/// <param name="page"></param>
+	/// <param name="size"></param>
+	/// <param name="search"></param>
+	/// <returns></returns>
+	[MyAuthorize, HttpPost("search/SearchList"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "page", "size", "search" })]
+	public ActionResult SearchList([Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 200, ErrorMessage = "页大小必须在0到50之间")] int size = 15, string search = "")
+	{
+		var where = string.IsNullOrEmpty(search) ? (Expression<Func<SearchDetails, bool>>)(s => true) : s => s.Keywords.Contains(search);
+		var pages = SearchDetailsService.GetPages<DateTime, SearchDetailsDto>(page, size, where, s => s.SearchTime, false);
+		return Ok(pages);
+	}
 
-    /// <summary>
-    /// 热词
-    /// </summary>
-    /// <returns></returns>
-    [MyAuthorize, Route("search/HotKey"), ResponseCache(Duration = 600)]
-    public ActionResult HotKey()
-    {
-        return ResultData(new
-        {
-            month = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Month"),
-            week = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Week"),
-            today = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Today"),
-            wish_month = RedisHelper.Get<List<KeywordsRank>>("SearchWishRank:Month"),
-            wish_week = RedisHelper.Get<List<KeywordsRank>>("SearchWishRank:Week"),
-            wish_today = RedisHelper.Get<List<KeywordsRank>>("SearchWishRank:Today")
-        });
-    }
+	/// <summary>
+	/// 热词
+	/// </summary>
+	/// <returns></returns>
+	[MyAuthorize, Route("search/HotKey"), ResponseCache(Duration = 600)]
+	public ActionResult HotKey()
+	{
+		return ResultData(new
+		{
+			month = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Month"),
+			week = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Week"),
+			today = RedisHelper.Get<List<KeywordsRank>>("SearchRank:Today"),
+			wish_month = RedisHelper.Get<List<KeywordsRank>>("SearchWishRank:Month"),
+			wish_week = RedisHelper.Get<List<KeywordsRank>>("SearchWishRank:Week"),
+			wish_today = RedisHelper.Get<List<KeywordsRank>>("SearchWishRank:Today")
+		});
+	}
 
-    /// <summary>
-    /// 删除搜索记录
-    /// </summary>
-    /// <param name="id"></param>
-    /// <returns></returns>
-    [HttpPost, MyAuthorize]
-    public async Task<ActionResult> Delete(int id)
-    {
-        bool b = await SearchDetailsService.DeleteByIdAsync(id) > 0;
-        return ResultData(null, b, b ? "删除成功!" : "删除失败!");
-    }
+	/// <summary>
+	/// 删除搜索记录
+	/// </summary>
+	/// <param name="id"></param>
+	/// <returns></returns>
+	[HttpPost, MyAuthorize]
+	public async Task<ActionResult> Delete(int id)
+	{
+		bool b = await SearchDetailsService.DeleteByIdAsync(id) > 0;
+		return ResultData(null, b, b ? "删除成功!" : "删除失败!");
+	}
 }

+ 1 - 5
src/Masuit.MyBlogs.Core/Controllers/ShareController.cs

@@ -2,7 +2,6 @@
 using Masuit.Tools.AspNetCore.ModelBinder;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.EntityFrameworkCore;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -35,7 +34,6 @@ public sealed class ShareController : AdminController
 	public ActionResult Add([FromBodyOrDefault] FastShare share)
 	{
 		bool b = FastShareService.AddEntitySaved(share) != null;
-		QueryCacheManager.ExpireType<FastShare>();
 		return ResultData(null, b, b ? "添加成功" : "添加失败");
 	}
 
@@ -48,7 +46,6 @@ public sealed class ShareController : AdminController
 	public async Task<ActionResult> Remove(int id)
 	{
 		bool b = await FastShareService.DeleteByIdAsync(id) > 0;
-		QueryCacheManager.ExpireType<FastShare>();
 		return ResultData(null, b, b ? "删除成功" : "删除失败");
 	}
 
@@ -61,7 +58,6 @@ public sealed class ShareController : AdminController
 	public async Task<ActionResult> Update([FromBodyOrDefault] FastShare model)
 	{
 		var b = await FastShareService.GetQuery(s => s.Id == model.Id).ExecuteUpdateAsync(s => s.SetProperty(e => e.Title, model.Title).SetProperty(e => e.Link, model.Link).SetProperty(e => e.Sort, model.Sort)) > 0;
-		QueryCacheManager.ExpireType<FastShare>();
 		return ResultData(null, b, b ? "更新成功" : "更新失败");
 	}
-}
+}

+ 309 - 318
src/Masuit.MyBlogs.Core/Controllers/SubscribeController.cs

@@ -11,8 +11,8 @@ using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Net.Http.Headers;
 using System.Text;
 using System.Text.RegularExpressions;
+using EFCoreSecondLevelCacheInterceptor;
 using WilderMinds.RssSyndication;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -21,342 +21,333 @@ namespace Masuit.MyBlogs.Core.Controllers;
 /// </summary>
 public sealed class SubscribeController : Controller
 {
-    public IPostService PostService { get; set; }
+	public IPostService PostService { get; set; }
 
-    public IAdvertisementService AdvertisementService { get; set; }
+	public IAdvertisementService AdvertisementService { get; set; }
 
-    public IRedisClient RedisClient { get; set; }
+	public IRedisClient RedisClient { get; set; }
 
-    /// <summary>
-    /// RSS订阅
-    /// </summary>
-    /// <returns></returns>
-    [Route("/rss"), ResponseCache(Duration = 3600)]
-    public async Task<IActionResult> Rss()
-    {
-        if (CommonHelper.SystemSettings.GetOrAdd("EnableRss", "true") != "true")
-        {
-            throw new NotFoundException("不允许订阅");
-        }
+	/// <summary>
+	/// RSS订阅
+	/// </summary>
+	/// <returns></returns>
+	[Route("/rss"), ResponseCache(Duration = 3600)]
+	public async Task<IActionResult> Rss()
+	{
+		if (CommonHelper.SystemSettings.GetOrAdd("EnableRss", "true") != "true")
+		{
+			throw new NotFoundException("不允许订阅");
+		}
 
-        var time = DateTime.Today.AddDays(-1);
-        string scheme = Request.Scheme;
-        var host = Request.Host;
-        var raw = PostService.GetQuery(PostBaseWhere().And(p => p.Rss && p.ModifyDate >= time), p => p.ModifyDate, false).Include(p => p.Category).AsNoTracking().FromCache(new MemoryCacheEntryOptions()
-        {
-            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2)
-        });
-        var data = await raw.SelectAsync(async p =>
-        {
-            var summary = await p.Content.GetSummary(300, 50);
-            return new Item()
-            {
-                Author = new Author
-                {
-                    Name = p.Modifier
-                },
-                Body = summary,
-                Categories = new List<string>
-                {
-                    p.Category.Name
-                },
-                Link = new Uri(scheme + "://" + host + "/" + p.Id),
-                PublishDate = p.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone)),
-                Title = p.Title,
-                Permalink = scheme + "://" + host + "/" + p.Id,
-                Guid = p.Id.ToString(),
-                FullHtmlContent = summary
-            };
-        });
-        var posts = data.ToPooledListScope();
-        InsertAdvertisement(posts);
-        var feed = new Feed()
-        {
-            Title = CommonHelper.SystemSettings["Title"],
-            Description = CommonHelper.SystemSettings["Description"],
-            Link = new Uri(scheme + "://" + host + "/rss"),
-            Copyright = CommonHelper.SystemSettings["Title"],
-            Language = "zh-cn",
-            Items = posts
-        };
-        var rss = feed.Serialize(new SerializeOption()
-        {
-            Encoding = Encoding.UTF8
-        });
-        return Content(rss, ContentType.Xml);
-    }
+		var time = DateTime.Today.AddDays(-1);
+		string scheme = Request.Scheme;
+		var host = Request.Host;
+		var raw = PostService.GetQuery(PostBaseWhere().And(p => p.Rss && p.ModifyDate >= time), p => p.ModifyDate, false).Include(p => p.Category).AsNoTracking().Cacheable(CacheExpirationMode.Absolute, TimeSpan.FromHours(2)).ToPooledListScope();
+		var data = await raw.SelectAsync(async p =>
+		{
+			var summary = await p.Content.GetSummary(300, 50);
+			return new Item()
+			{
+				Author = new Author
+				{
+					Name = p.Modifier
+				},
+				Body = summary,
+				Categories = new List<string>
+				{
+					p.Category.Name
+				},
+				Link = new Uri(scheme + "://" + host + "/" + p.Id),
+				PublishDate = p.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone)),
+				Title = p.Title,
+				Permalink = scheme + "://" + host + "/" + p.Id,
+				Guid = p.Id.ToString(),
+				FullHtmlContent = summary
+			};
+		});
+		var posts = data.ToPooledListScope();
+		InsertAdvertisement(posts);
+		var feed = new Feed()
+		{
+			Title = CommonHelper.SystemSettings["Title"],
+			Description = CommonHelper.SystemSettings["Description"],
+			Link = new Uri(scheme + "://" + host + "/rss"),
+			Copyright = CommonHelper.SystemSettings["Title"],
+			Language = "zh-cn",
+			Items = posts
+		};
+		var rss = feed.Serialize(new SerializeOption()
+		{
+			Encoding = Encoding.UTF8
+		});
+		return Content(rss, ContentType.Xml);
+	}
 
-    private void InsertAdvertisement(IList<Item> posts, int? cid = null, string keywords = "")
-    {
-        if (posts.Count > 2)
-        {
-            var ad = AdvertisementService.GetByWeightedPrice((AdvertiseType)(DateTime.Now.Second % 4 + 1), Request.Location(), cid, keywords);
-            if (ad is not null)
-            {
-                posts.Insert(new Random().Next(1, posts.Count), new Item()
-                {
-                    Author = new Author()
-                    {
-                        Name = ad.Title
-                    },
-                    Body = ad.Description,
-                    Title = ad.Title,
-                    FullHtmlContent = ad.Description,
-                    Guid = SnowFlake.NewId,
-                    PublishDate = DateTime.UtcNow,
-                    Link = new Uri(Url.ActionLink("Redirect", "Advertisement", new { id = ad.Id })),
-                    Permalink = Url.ActionLink("Redirect", "Advertisement", new { id = ad.Id })
-                });
-            }
-        }
-    }
+	private void InsertAdvertisement(IList<Item> posts, int? cid = null, string keywords = "")
+	{
+		if (posts.Count > 2)
+		{
+			var ad = AdvertisementService.GetByWeightedPrice((AdvertiseType)(DateTime.Now.Second % 4 + 1), Request.Location(), cid, keywords);
+			if (ad is not null)
+			{
+				posts.Insert(new Random().Next(1, posts.Count), new Item()
+				{
+					Author = new Author()
+					{
+						Name = ad.Title
+					},
+					Body = ad.Description,
+					Title = ad.Title,
+					FullHtmlContent = ad.Description,
+					Guid = SnowFlake.NewId,
+					PublishDate = DateTime.UtcNow,
+					Link = new Uri(Url.ActionLink("Redirect", "Advertisement", new { id = ad.Id })),
+					Permalink = Url.ActionLink("Redirect", "Advertisement", new { id = ad.Id })
+				});
+			}
+		}
+	}
 
-    /// <summary>
-    /// RSS分类订阅
-    /// </summary>
-    /// <returns></returns>
-    [Route("/cat/{id}/rss"), ResponseCache(Duration = 3600)]
-    public async Task<IActionResult> CategoryRss([FromServices] ICategoryService categoryService, int id)
-    {
-        if (CommonHelper.SystemSettings.GetOrAdd("EnableRss", "true") != "true")
-        {
-            throw new NotFoundException("不允许订阅");
-        }
+	/// <summary>
+	/// RSS分类订阅
+	/// </summary>
+	/// <returns></returns>
+	[Route("/cat/{id}/rss"), ResponseCache(Duration = 3600)]
+	public async Task<IActionResult> CategoryRss([FromServices] ICategoryService categoryService, int id)
+	{
+		if (CommonHelper.SystemSettings.GetOrAdd("EnableRss", "true") != "true")
+		{
+			throw new NotFoundException("不允许订阅");
+		}
 
-        var time = DateTime.Today.AddDays(-1);
-        string scheme = Request.Scheme;
-        var host = Request.Host;
-        var category = await categoryService.GetByIdAsync(id) ?? throw new NotFoundException("分类未找到");
-        var cids = category.Flatten().Select(c => c.Id).ToArray();
-        var raw = PostService.GetQuery(PostBaseWhere().And(p => p.Rss && cids.Contains(p.CategoryId) && p.ModifyDate >= time), p => p.ModifyDate, false).Include(p => p.Category).AsNoTracking().FromCache(new MemoryCacheEntryOptions()
-        {
-            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2)
-        });
-        var data = await raw.SelectAsync(async p =>
-        {
-            var summary = await p.Content.GetSummary(300, 50);
-            return new Item()
-            {
-                Author = new Author
-                {
-                    Name = p.Modifier
-                },
-                Body = summary,
-                Categories = new List<string>
-                {
-                    p.Category.Name
-                },
-                Link = new Uri(scheme + "://" + host + "/" + p.Id),
-                PublishDate = p.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone)),
-                Title = p.Title,
-                Permalink = scheme + "://" + host + "/" + p.Id,
-                Guid = p.Id.ToString(),
-                FullHtmlContent = summary
-            };
-        });
-        var posts = data.ToPooledListScope();
-        InsertAdvertisement(posts, id, category.Name);
-        var feed = new Feed()
-        {
-            Title = Request.Host + $":分类{category.Name}文章订阅",
-            Description = category.Description,
-            Link = new Uri(scheme + "://" + host + "/rss"),
-            Copyright = CommonHelper.SystemSettings["Title"],
-            Language = "zh-cn",
-            Items = posts
-        };
-        var rss = feed.Serialize(new SerializeOption()
-        {
-            Encoding = Encoding.UTF8
-        });
-        return Content(rss, ContentType.Xml);
-    }
+		var time = DateTime.Today.AddDays(-1);
+		string scheme = Request.Scheme;
+		var host = Request.Host;
+		var category = await categoryService.GetByIdAsync(id) ?? throw new NotFoundException("分类未找到");
+		var cids = category.Flatten().Select(c => c.Id).ToArray();
+		var raw = PostService.GetQuery(PostBaseWhere().And(p => p.Rss && cids.Contains(p.CategoryId) && p.ModifyDate >= time), p => p.ModifyDate, false).Include(p => p.Category).AsNoTracking().Cacheable(CacheExpirationMode.Absolute, TimeSpan.FromHours(2)).ToPooledListScope();
+		var data = await raw.SelectAsync(async p =>
+		{
+			var summary = await p.Content.GetSummary(300, 50);
+			return new Item()
+			{
+				Author = new Author
+				{
+					Name = p.Modifier
+				},
+				Body = summary,
+				Categories = new List<string>
+				{
+					p.Category.Name
+				},
+				Link = new Uri(scheme + "://" + host + "/" + p.Id),
+				PublishDate = p.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone)),
+				Title = p.Title,
+				Permalink = scheme + "://" + host + "/" + p.Id,
+				Guid = p.Id.ToString(),
+				FullHtmlContent = summary
+			};
+		});
+		var posts = data.ToPooledListScope();
+		InsertAdvertisement(posts, id, category.Name);
+		var feed = new Feed()
+		{
+			Title = Request.Host + $":分类{category.Name}文章订阅",
+			Description = category.Description,
+			Link = new Uri(scheme + "://" + host + "/rss"),
+			Copyright = CommonHelper.SystemSettings["Title"],
+			Language = "zh-cn",
+			Items = posts
+		};
+		var rss = feed.Serialize(new SerializeOption()
+		{
+			Encoding = Encoding.UTF8
+		});
+		return Content(rss, ContentType.Xml);
+	}
 
-    /// <summary>
-    /// RSS专题订阅
-    /// </summary>
-    /// <returns></returns>
-    [Route("/special/{id}/rss"), ResponseCache(Duration = 3600)]
-    public async Task<IActionResult> SeminarRss([FromServices] ISeminarService seminarService, int id)
-    {
-        if (CommonHelper.SystemSettings.GetOrAdd("EnableRss", "true") != "true")
-        {
-            throw new NotFoundException("不允许订阅");
-        }
+	/// <summary>
+	/// RSS专题订阅
+	/// </summary>
+	/// <returns></returns>
+	[Route("/special/{id}/rss"), ResponseCache(Duration = 3600)]
+	public async Task<IActionResult> SeminarRss([FromServices] ISeminarService seminarService, int id)
+	{
+		if (CommonHelper.SystemSettings.GetOrAdd("EnableRss", "true") != "true")
+		{
+			throw new NotFoundException("不允许订阅");
+		}
 
-        var time = DateTime.Today.AddDays(-1);
-        string scheme = Request.Scheme;
-        var host = Request.Host;
-        var seminar = await seminarService.GetByIdAsync(id) ?? throw new NotFoundException("专题未找到");
-        var raw = PostService.GetQuery(PostBaseWhere().And(p => p.Rss && p.Seminar.Any(s => s.Id == id) && p.ModifyDate >= time), p => p.ModifyDate, false).Include(p => p.Category).AsNoTracking().FromCache(new MemoryCacheEntryOptions()
-        {
-            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2)
-        });
-        var data = await raw.SelectAsync(async p =>
-        {
-            var summary = await p.Content.GetSummary(300, 50);
-            return new Item()
-            {
-                Author = new Author
-                {
-                    Name = p.Modifier
-                },
-                Body = summary,
-                Categories = new List<string>
-                {
-                    p.Category.Name
-                },
-                Link = new Uri(scheme + "://" + host + "/" + p.Id),
-                PublishDate = p.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone)),
-                Title = p.Title,
-                Permalink = scheme + "://" + host + "/" + p.Id,
-                Guid = p.Id.ToString(),
-                FullHtmlContent = summary
-            };
-        });
-        var posts = data.ToPooledListScope();
-        InsertAdvertisement(posts, id, seminar.Title);
-        var feed = new Feed()
-        {
-            Title = Request.Host + $":专题{seminar.Title}文章订阅",
-            Description = seminar.Description,
-            Link = new Uri(scheme + "://" + host + "/rss"),
-            Copyright = CommonHelper.SystemSettings["Title"],
-            Language = "zh-cn",
-            Items = posts
-        };
-        var rss = feed.Serialize(new SerializeOption()
-        {
-            Encoding = Encoding.UTF8
-        });
-        return Content(rss, ContentType.Xml);
-    }
+		var time = DateTime.Today.AddDays(-1);
+		string scheme = Request.Scheme;
+		var host = Request.Host;
+		var seminar = await seminarService.GetByIdAsync(id) ?? throw new NotFoundException("专题未找到");
+		var raw = PostService.GetQuery(PostBaseWhere().And(p => p.Rss && p.Seminar.Any(s => s.Id == id) && p.ModifyDate >= time), p => p.ModifyDate, false).Include(p => p.Category).AsNoTracking().Cacheable(CacheExpirationMode.Absolute, TimeSpan.FromHours(2)).ToPooledListScope();
+		var data = await raw.SelectAsync(async p =>
+		{
+			var summary = await p.Content.GetSummary(300, 50);
+			return new Item()
+			{
+				Author = new Author
+				{
+					Name = p.Modifier
+				},
+				Body = summary,
+				Categories = new List<string>
+				{
+					p.Category.Name
+				},
+				Link = new Uri(scheme + "://" + host + "/" + p.Id),
+				PublishDate = p.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone)),
+				Title = p.Title,
+				Permalink = scheme + "://" + host + "/" + p.Id,
+				Guid = p.Id.ToString(),
+				FullHtmlContent = summary
+			};
+		});
+		var posts = data.ToPooledListScope();
+		InsertAdvertisement(posts, id, seminar.Title);
+		var feed = new Feed()
+		{
+			Title = Request.Host + $":专题{seminar.Title}文章订阅",
+			Description = seminar.Description,
+			Link = new Uri(scheme + "://" + host + "/rss"),
+			Copyright = CommonHelper.SystemSettings["Title"],
+			Language = "zh-cn",
+			Items = posts
+		};
+		var rss = feed.Serialize(new SerializeOption()
+		{
+			Encoding = Encoding.UTF8
+		});
+		return Content(rss, ContentType.Xml);
+	}
 
-    /// <summary>
-    /// RSS文章订阅
-    /// </summary>
-    /// <returns></returns>
-    [Route("/{id}/rss"), ResponseCache(Duration = 3600)]
-    public async Task<IActionResult> PostRss(int id)
-    {
-        if (CommonHelper.SystemSettings.GetOrAdd("EnableRss", "true") != "true")
-        {
-            throw new NotFoundException("不允许订阅");
-        }
+	/// <summary>
+	/// RSS文章订阅
+	/// </summary>
+	/// <returns></returns>
+	[Route("/{id}/rss"), ResponseCache(Duration = 3600)]
+	public async Task<IActionResult> PostRss(int id)
+	{
+		if (CommonHelper.SystemSettings.GetOrAdd("EnableRss", "true") != "true")
+		{
+			throw new NotFoundException("不允许订阅");
+		}
 
-        string scheme = Request.Scheme;
-        var host = Request.Host;
-        var post = await PostService.GetAsync(p => p.Rss && p.Status == Status.Published && p.Id == id) ?? throw new NotFoundException("文章未找到");
-        CheckPermission(post);
-        var summary = await post.Content.GetSummary(300, 50);
-        var item = new Item()
-        {
-            Author = new Author
-            {
-                Name = post.Modifier
-            },
-            Body = summary,
-            Categories = new List<string>
-            {
-                post.Category.Path()
-            },
-            Link = new Uri(scheme + "://" + host + "/" + post.Id),
-            PublishDate = post.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone)),
-            Title = post.Title,
-            Permalink = scheme + "://" + host + "/" + post.Id,
-            Guid = post.Id.ToString(),
-            FullHtmlContent = summary
-        };
-        var feed = new Feed()
-        {
-            Title = Request.Host + $":文章【{post.Title}】更新订阅",
-            Description = summary,
-            Link = new Uri(scheme + "://" + host + "/rss/" + id),
-            Copyright = CommonHelper.SystemSettings["Title"],
-            Language = "zh-cn",
-            Items = new List<Item>() { item }
-        };
-        var rss = feed.Serialize(new SerializeOption()
-        {
-            Encoding = Encoding.UTF8
-        });
-        return Content(rss, ContentType.Xml);
-    }
+		string scheme = Request.Scheme;
+		var host = Request.Host;
+		var post = await PostService.GetAsync(p => p.Rss && p.Status == Status.Published && p.Id == id) ?? throw new NotFoundException("文章未找到");
+		CheckPermission(post);
+		var summary = await post.Content.GetSummary(300, 50);
+		var item = new Item()
+		{
+			Author = new Author
+			{
+				Name = post.Modifier
+			},
+			Body = summary,
+			Categories = new List<string>
+			{
+				post.Category.Path()
+			},
+			Link = new Uri(scheme + "://" + host + "/" + post.Id),
+			PublishDate = post.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone)),
+			Title = post.Title,
+			Permalink = scheme + "://" + host + "/" + post.Id,
+			Guid = post.Id.ToString(),
+			FullHtmlContent = summary
+		};
+		var feed = new Feed()
+		{
+			Title = Request.Host + $":文章【{post.Title}】更新订阅",
+			Description = summary,
+			Link = new Uri(scheme + "://" + host + "/rss/" + id),
+			Copyright = CommonHelper.SystemSettings["Title"],
+			Language = "zh-cn",
+			Items = new List<Item>() { item }
+		};
+		var rss = feed.Serialize(new SerializeOption()
+		{
+			Encoding = Encoding.UTF8
+		});
+		return Content(rss, ContentType.Xml);
+	}
 
-    private Expression<Func<Post, bool>> PostBaseWhere()
-    {
-        var ipLocation = Request.Location();
-        var location = ipLocation + ipLocation.Coodinate + "|" + Request.Headers[HeaderNames.Referer] + "|" + Request.Headers[HeaderNames.UserAgent] + "|" + Request.Headers.Where(x => x.Key.StartsWith("cf-")).Select(x => x.Value).Join("|"); ;
-        return p => p.Status == Status.Published && p.LimitMode != RegionLimitMode.OnlyForSearchEngine
-            && (p.LimitMode == null || p.LimitMode == RegionLimitMode.All ? true :
-                p.LimitMode == RegionLimitMode.AllowRegion ? Regex.IsMatch(location, p.Regions, RegexOptions.IgnoreCase) :
-                p.LimitMode == RegionLimitMode.ForbidRegion ? !Regex.IsMatch(location, p.Regions, RegexOptions.IgnoreCase) :
-                p.LimitMode == RegionLimitMode.AllowRegionExceptForbidRegion ? Regex.IsMatch(location, p.Regions, RegexOptions.IgnoreCase) && !Regex.IsMatch(location, p.ExceptRegions, RegexOptions.IgnoreCase) :
-                !Regex.IsMatch(location, p.Regions, RegexOptions.IgnoreCase) || Regex.IsMatch(location, p.ExceptRegions, RegexOptions.IgnoreCase));
-    }
+	private Expression<Func<Post, bool>> PostBaseWhere()
+	{
+		var ipLocation = Request.Location();
+		var location = ipLocation + ipLocation.Coodinate + "|" + Request.Headers[HeaderNames.Referer] + "|" + Request.Headers[HeaderNames.UserAgent] + "|" + Request.Headers.Where(x => x.Key.StartsWith("cf-")).Select(x => x.Value).Join("|"); ;
+		return p => p.Status == Status.Published && p.LimitMode != RegionLimitMode.OnlyForSearchEngine
+			&& (p.LimitMode == null || p.LimitMode == RegionLimitMode.All ? true :
+				p.LimitMode == RegionLimitMode.AllowRegion ? Regex.IsMatch(location, p.Regions, RegexOptions.IgnoreCase) :
+				p.LimitMode == RegionLimitMode.ForbidRegion ? !Regex.IsMatch(location, p.Regions, RegexOptions.IgnoreCase) :
+				p.LimitMode == RegionLimitMode.AllowRegionExceptForbidRegion ? Regex.IsMatch(location, p.Regions, RegexOptions.IgnoreCase) && !Regex.IsMatch(location, p.ExceptRegions, RegexOptions.IgnoreCase) :
+				!Regex.IsMatch(location, p.Regions, RegexOptions.IgnoreCase) || Regex.IsMatch(location, p.ExceptRegions, RegexOptions.IgnoreCase));
+	}
 
-    private void CheckPermission(Post post)
-    {
-        var ipLocation = Request.Location();
-        var location = ipLocation + ipLocation.Coodinate + "|" + Request.Headers[HeaderNames.Referer] + "|" + Request.Headers[HeaderNames.UserAgent] + "|" + Request.Headers.Where(x => x.Key.StartsWith("cf-")).Select(x => x.Value).Join("|"); ;
-        switch (post.LimitMode)
-        {
-            case RegionLimitMode.OnlyForSearchEngine:
-                Disallow(post);
-                break;
+	private void CheckPermission(Post post)
+	{
+		var ipLocation = Request.Location();
+		var location = ipLocation + ipLocation.Coodinate + "|" + Request.Headers[HeaderNames.Referer] + "|" + Request.Headers[HeaderNames.UserAgent] + "|" + Request.Headers.Where(x => x.Key.StartsWith("cf-")).Select(x => x.Value).Join("|"); ;
+		switch (post.LimitMode)
+		{
+			case RegionLimitMode.OnlyForSearchEngine:
+				Disallow(post);
+				break;
 
-            case RegionLimitMode.AllowRegion:
-                if (!Regex.IsMatch(location, post.Regions, RegexOptions.IgnoreCase) && !Request.IsRobot())
-                {
-                    Disallow(post);
-                }
+			case RegionLimitMode.AllowRegion:
+				if (!Regex.IsMatch(location, post.Regions, RegexOptions.IgnoreCase) && !Request.IsRobot())
+				{
+					Disallow(post);
+				}
 
-                break;
+				break;
 
-            case RegionLimitMode.ForbidRegion:
-                if (Regex.IsMatch(location, post.Regions, RegexOptions.IgnoreCase) && !Request.IsRobot())
-                {
-                    Disallow(post);
-                }
+			case RegionLimitMode.ForbidRegion:
+				if (Regex.IsMatch(location, post.Regions, RegexOptions.IgnoreCase) && !Request.IsRobot())
+				{
+					Disallow(post);
+				}
 
-                break;
+				break;
 
-            case RegionLimitMode.AllowRegionExceptForbidRegion:
-                if (Regex.IsMatch(location, post.ExceptRegions, RegexOptions.IgnoreCase))
-                {
-                    Disallow(post);
-                }
+			case RegionLimitMode.AllowRegionExceptForbidRegion:
+				if (Regex.IsMatch(location, post.ExceptRegions, RegexOptions.IgnoreCase))
+				{
+					Disallow(post);
+				}
 
-                goto case RegionLimitMode.AllowRegion;
-            case RegionLimitMode.ForbidRegionExceptAllowRegion:
-                if (Regex.IsMatch(location, post.ExceptRegions, RegexOptions.IgnoreCase))
-                {
-                    break;
-                }
+				goto case RegionLimitMode.AllowRegion;
+			case RegionLimitMode.ForbidRegionExceptAllowRegion:
+				if (Regex.IsMatch(location, post.ExceptRegions, RegexOptions.IgnoreCase))
+				{
+					break;
+				}
 
-                goto case RegionLimitMode.ForbidRegion;
-        }
-    }
+				goto case RegionLimitMode.ForbidRegion;
+		}
+	}
 
-    private void Disallow(Post post)
-    {
-        RedisClient.IncrBy("interceptCount", 1);
-        RedisClient.LPush("intercept", new IpIntercepter()
-        {
-            IP = HttpContext.Connection.RemoteIpAddress.ToString(),
-            RequestUrl = $"//{Request.Host}/{post.Id}",
-            Referer = Request.Headers[HeaderNames.Referer],
-            Time = DateTime.Now,
-            UserAgent = Request.Headers[HeaderNames.UserAgent],
-            Remark = "无权限查看该文章",
-            Address = Request.Location(),
-            HttpVersion = Request.Protocol,
-            Headers = new
-            {
-                Request.Protocol,
-                Request.Headers
-            }.ToJsonString()
-        });
-        throw new NotFoundException("文章未找到");
-    }
+	private void Disallow(Post post)
+	{
+		RedisClient.IncrBy("interceptCount", 1);
+		RedisClient.LPush("intercept", new IpIntercepter()
+		{
+			IP = HttpContext.Connection.RemoteIpAddress.ToString(),
+			RequestUrl = $"//{Request.Host}/{post.Id}",
+			Referer = Request.Headers[HeaderNames.Referer],
+			Time = DateTime.Now,
+			UserAgent = Request.Headers[HeaderNames.UserAgent],
+			Remark = "无权限查看该文章",
+			Address = Request.Location(),
+			HttpVersion = Request.Protocol,
+			Headers = new
+			{
+				Request.Protocol,
+				Request.Headers
+			}.ToJsonString()
+		});
+		throw new NotFoundException("文章未找到");
+	}
 }

+ 1 - 4
src/Masuit.MyBlogs.Core/Controllers/ValuesController.cs

@@ -1,7 +1,6 @@
 using Masuit.Tools.AspNetCore.ModelBinder;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.EntityFrameworkCore;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -20,7 +19,6 @@ public sealed class ValuesController : AdminController
 	public async Task<ActionResult> Save([FromBodyOrDefault] Variables model)
 	{
 		var b = await VariablesService.AddOrUpdateSavedAsync(v => v.Key, model) > 0;
-		QueryCacheManager.ExpireType<Variables>();
 		return ResultData(null, b, b ? "保存成功" : "保存失败");
 	}
 
@@ -28,7 +26,6 @@ public sealed class ValuesController : AdminController
 	public ActionResult Delete(int id)
 	{
 		var b = VariablesService - id;
-		QueryCacheManager.ExpireType<Variables>();
 		return ResultData(null, b, b ? "删除成功" : "保存失败");
 	}
-}
+}

+ 9 - 9
src/Masuit.MyBlogs.Core/Infrastructure/Repository/BaseRepository.cs

@@ -2,12 +2,12 @@
 using AutoMapper.QueryableExtensions;
 using Collections.Pooled;
 using Dispose.Scope;
+using EFCoreSecondLevelCacheInterceptor;
 using Masuit.LuceneEFCore.SearchEngine;
 using Masuit.MyBlogs.Core.Infrastructure.Repository.Interface;
 using Masuit.Tools.Core.AspNetCore;
 using Masuit.Tools.Models;
 using Microsoft.EntityFrameworkCore;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Infrastructure.Repository;
 
@@ -82,7 +82,7 @@ public abstract class BaseRepository<T> : Disposable, IBaseRepository<T> where T
 	/// <returns></returns>
 	public virtual PooledList<T> GetAllFromCache<TS>(Expression<Func<T, TS>> orderby, bool isAsc = true)
 	{
-		return GetAllNoTracking(orderby, isAsc).FromCache().ToPooledListScope();
+		return GetAllNoTracking(orderby, isAsc).Cacheable().ToPooledListScope();
 	}
 
 	/// <summary>
@@ -128,7 +128,7 @@ public abstract class BaseRepository<T> : Disposable, IBaseRepository<T> where T
 	/// <returns></returns>
 	public virtual PooledList<T> GetQueryFromCache(Expression<Func<T, bool>> where)
 	{
-		return DataContext.Set<T>().Where(where).AsNoTracking().FromCache().ToPooledListScope();
+		return DataContext.Set<T>().Where(where).AsNoTracking().Cacheable().ToPooledListScope();
 	}
 
 	/// <summary>
@@ -141,12 +141,12 @@ public abstract class BaseRepository<T> : Disposable, IBaseRepository<T> where T
 	/// <returns></returns>
 	public virtual PooledList<T> GetQueryFromCache<TS>(Expression<Func<T, bool>> where, Expression<Func<T, TS>> orderby, bool isAsc = true)
 	{
-		return GetQueryNoTracking(where, orderby, isAsc).FromCache().ToPooledListScope();
+		return GetQueryNoTracking(where, orderby, isAsc).Cacheable().ToPooledListScope();
 	}
 
 	public PooledList<TDto> GetQueryFromCache<TDto>(Expression<Func<T, bool>> where) where TDto : class
 	{
-		return GetQuery<TDto>(where).FromCache().ToPooledListScope();
+		return GetQuery<TDto>(where).Cacheable().ToPooledListScope();
 	}
 
 	/// <summary>
@@ -207,7 +207,7 @@ public abstract class BaseRepository<T> : Disposable, IBaseRepository<T> where T
 	/// <returns></returns>
 	public virtual PooledList<TDto> GetQueryFromCache<TS, TDto>(Expression<Func<T, bool>> where, Expression<Func<T, TS>> orderby, bool isAsc = true) where TDto : class
 	{
-		return GetQuery(where, orderby, isAsc).ProjectTo<TDto>(MapperConfig).FromCache().ToPooledListScope();
+		return GetQuery(where, orderby, isAsc).ProjectTo<TDto>(MapperConfig).Cacheable().ToPooledListScope();
 	}
 
 	/// <summary>
@@ -227,7 +227,7 @@ public abstract class BaseRepository<T> : Disposable, IBaseRepository<T> where T
 	/// <returns>实体</returns>
 	public Task<T> GetFromCacheAsync(Expression<Func<T, bool>> @where)
 	{
-		return DataContext.Set<T>().Where(where).AsNoTracking().DeferredFirstOrDefault().ExecuteAsync();
+		return DataContext.Set<T>().Where(where).AsNoTracking().Cacheable().FirstOrDefaultAsync();
 	}
 
 	/// <summary>
@@ -268,7 +268,7 @@ public abstract class BaseRepository<T> : Disposable, IBaseRepository<T> where T
 	/// <returns>映射实体</returns>
 	public Task<TDto> GetFromCacheAsync<TS, TDto>(Expression<Func<T, bool>> @where, Expression<Func<T, TS>> @orderby, bool isAsc = true) where TDto : class
 	{
-		return isAsc ? DataContext.Set<T>().Where(where).OrderBy(orderby).ProjectTo<TDto>(MapperConfig).DeferredFirstOrDefault().ExecuteAsync() : DataContext.Set<T>().Where(where).OrderByDescending(orderby).ProjectTo<TDto>(MapperConfig).DeferredFirstOrDefault().ExecuteAsync();
+		return isAsc ? DataContext.Set<T>().Where(where).OrderBy(orderby).ProjectTo<TDto>(MapperConfig).Cacheable().FirstOrDefaultAsync() : DataContext.Set<T>().Where(where).OrderByDescending(orderby).ProjectTo<TDto>(MapperConfig).Cacheable().FirstOrDefaultAsync();
 	}
 
 	/// <summary>
@@ -573,4 +573,4 @@ public abstract class BaseRepository<T> : Disposable, IBaseRepository<T> where T
 	}
 
 	public T this[int id] => GetById(id);
-}
+}

+ 3 - 3
src/Masuit.MyBlogs.Core/Infrastructure/Repository/PostRepository.cs

@@ -1,8 +1,8 @@
 using Collections.Pooled;
 using Dispose.Scope;
+using EFCoreSecondLevelCacheInterceptor;
 using Masuit.MyBlogs.Core.Infrastructure.Repository.Interface;
 using Microsoft.EntityFrameworkCore;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Infrastructure.Repository;
 
@@ -25,7 +25,7 @@ public sealed partial class PostRepository : BaseRepository<Post>, IPostReposito
 	/// <returns>还未执行的SQL语句</returns>
 	public override PooledList<Post> GetQueryFromCache(Expression<Func<Post, bool>> where)
 	{
-		return DataContext.Post.Include(p => p.Category).Where(where).FromCache().ToPooledListScope();
+		return DataContext.Post.Include(p => p.Category).Where(where).Cacheable().ToPooledListScope();
 	}
 
 	/// <summary>
@@ -40,4 +40,4 @@ public sealed partial class PostRepository : BaseRepository<Post>, IPostReposito
 	{
 		return isAsc ? DataContext.Post.Include(p => p.Category).Where(where).OrderBy(orderby) : DataContext.Post.Include(p => p.Category).Where(where).OrderByDescending(orderby);
 	}
-}
+}

+ 100 - 100
src/Masuit.MyBlogs.Core/Infrastructure/Repository/QueryableExt.cs

@@ -1,121 +1,121 @@
 using AutoMapper;
 using AutoMapper.QueryableExtensions;
+using EFCoreSecondLevelCacheInterceptor;
 using Masuit.LuceneEFCore.SearchEngine;
 using Masuit.Tools.Models;
 using Microsoft.EntityFrameworkCore;
-using Z.EntityFramework.Plus;
 
 namespace Masuit.MyBlogs.Core.Infrastructure.Repository;
 
 public static class QueryableExt
 {
-    /// <summary>
-    /// 从二级缓存生成分页集合
-    /// </summary>
-    /// <typeparam name="T"></typeparam>
-    /// <param name="query"></param>
-    /// <param name="page">当前页</param>
-    /// <param name="size">页大小</param>
-    /// <returns></returns>
-    public static PagedList<T> ToCachedPagedList<T>(this IOrderedQueryable<T> query, int page, int size) where T : LuceneIndexableBaseEntity
-    {
-        page = Math.Max(1, page);
-        var totalCount = query.Count();
-        if (1L * page * size > totalCount)
-        {
-            page = (int)Math.Ceiling(totalCount / (size * 1.0));
-        }
+	/// <summary>
+	/// 从二级缓存生成分页集合
+	/// </summary>
+	/// <typeparam name="T"></typeparam>
+	/// <param name="query"></param>
+	/// <param name="page">当前页</param>
+	/// <param name="size">页大小</param>
+	/// <returns></returns>
+	public static PagedList<T> ToCachedPagedList<T>(this IOrderedQueryable<T> query, int page, int size) where T : LuceneIndexableBaseEntity
+	{
+		page = Math.Max(1, page);
+		var totalCount = query.Count();
+		if (1L * page * size > totalCount)
+		{
+			page = (int)Math.Ceiling(totalCount / (size * 1.0));
+		}
 
-        if (page <= 0)
-        {
-            page = 1;
-        }
+		if (page <= 0)
+		{
+			page = 1;
+		}
 
-        var list = query.Skip(size * (page - 1)).Take(size).FromCache().ToList();
-        return new PagedList<T>(list, page, size, totalCount);
-    }
+		var list = query.Skip(size * (page - 1)).Take(size).Cacheable().ToList();
+		return new PagedList<T>(list, page, size, totalCount);
+	}
 
-    /// <summary>
-    /// 生成分页集合
-    /// </summary>
-    /// <typeparam name="T"></typeparam>
-    /// <typeparam name="TDto"></typeparam>
-    /// <param name="query"></param>
-    /// <param name="page">当前页</param>
-    /// <param name="size">页大小</param>
-    /// <param name="mapper"></param>
-    /// <returns></returns>
-    public static PagedList<TDto> ToPagedList<T, TDto>(this IOrderedQueryable<T> query, int page, int size, MapperConfiguration mapper)
-    {
-        page = Math.Max(1, page);
-        var totalCount = query.Count();
-        if (1L * page * size > totalCount)
-        {
-            page = (int)Math.Ceiling(totalCount / (size * 1.0));
-        }
+	/// <summary>
+	/// 生成分页集合
+	/// </summary>
+	/// <typeparam name="T"></typeparam>
+	/// <typeparam name="TDto"></typeparam>
+	/// <param name="query"></param>
+	/// <param name="page">当前页</param>
+	/// <param name="size">页大小</param>
+	/// <param name="mapper"></param>
+	/// <returns></returns>
+	public static PagedList<TDto> ToPagedList<T, TDto>(this IOrderedQueryable<T> query, int page, int size, MapperConfiguration mapper)
+	{
+		page = Math.Max(1, page);
+		var totalCount = query.Count();
+		if (1L * page * size > totalCount)
+		{
+			page = (int)Math.Ceiling(totalCount / (size * 1.0));
+		}
 
-        if (page <= 0)
-        {
-            page = 1;
-        }
+		if (page <= 0)
+		{
+			page = 1;
+		}
 
-        var list = query.Skip(size * (page - 1)).Take(size).ProjectTo<TDto>(mapper).ToList();
-        return new PagedList<TDto>(list, page, size, totalCount);
-    }
+		var list = query.Skip(size * (page - 1)).Take(size).ProjectTo<TDto>(mapper).ToList();
+		return new PagedList<TDto>(list, page, size, totalCount);
+	}
 
-    /// <summary>
-    /// 生成分页集合
-    /// </summary>
-    /// <typeparam name="T"></typeparam>
-    /// <typeparam name="TDto"></typeparam>
-    /// <param name="query"></param>
-    /// <param name="page">当前页</param>
-    /// <param name="size">页大小</param>
-    /// <param name="mapper"></param>
-    /// <returns></returns>
-    public static async Task<PagedList<TDto>> ToPagedListAsync<T, TDto>(this IOrderedQueryable<T> query, int page, int size, MapperConfiguration mapper)
-    {
-        page = Math.Max(1, page);
-        var totalCount = await query.CountAsync();
-        if (1L * page * size > totalCount)
-        {
-            page = (int)Math.Ceiling(totalCount / (size * 1.0));
-        }
+	/// <summary>
+	/// 生成分页集合
+	/// </summary>
+	/// <typeparam name="T"></typeparam>
+	/// <typeparam name="TDto"></typeparam>
+	/// <param name="query"></param>
+	/// <param name="page">当前页</param>
+	/// <param name="size">页大小</param>
+	/// <param name="mapper"></param>
+	/// <returns></returns>
+	public static async Task<PagedList<TDto>> ToPagedListAsync<T, TDto>(this IOrderedQueryable<T> query, int page, int size, MapperConfiguration mapper)
+	{
+		page = Math.Max(1, page);
+		var totalCount = await query.CountAsync();
+		if (1L * page * size > totalCount)
+		{
+			page = (int)Math.Ceiling(totalCount / (size * 1.0));
+		}
 
-        if (page <= 0)
-        {
-            page = 1;
-        }
+		if (page <= 0)
+		{
+			page = 1;
+		}
 
-        var list = await query.Skip(size * (page - 1)).Take(size).ProjectTo<TDto>(mapper).ToListAsync();
-        return new PagedList<TDto>(list, page, size, totalCount);
-    }
+		var list = await query.Skip(size * (page - 1)).Take(size).ProjectTo<TDto>(mapper).ToListAsync();
+		return new PagedList<TDto>(list, page, size, totalCount);
+	}
 
-    /// <summary>
-    /// 从二级缓存生成分页集合
-    /// </summary>
-    /// <typeparam name="T"></typeparam>
-    /// <typeparam name="TDto"></typeparam>
-    /// <param name="query"></param>
-    /// <param name="page">当前页</param>
-    /// <param name="size">页大小</param>
-    /// <param name="mapper"></param>
-    /// <returns></returns>
-    public static PagedList<TDto> ToCachedPagedList<T, TDto>(this IOrderedQueryable<T> query, int page, int size, MapperConfiguration mapper) where TDto : class where T : LuceneIndexableBaseEntity
-    {
-        page = Math.Max(1, page);
-        var totalCount = query.Count();
-        if (1L * page * size > totalCount)
-        {
-            page = (int)Math.Ceiling(totalCount / (size * 1.0));
-        }
+	/// <summary>
+	/// 从二级缓存生成分页集合
+	/// </summary>
+	/// <typeparam name="T"></typeparam>
+	/// <typeparam name="TDto"></typeparam>
+	/// <param name="query"></param>
+	/// <param name="page">当前页</param>
+	/// <param name="size">页大小</param>
+	/// <param name="mapper"></param>
+	/// <returns></returns>
+	public static PagedList<TDto> ToCachedPagedList<T, TDto>(this IOrderedQueryable<T> query, int page, int size, MapperConfiguration mapper) where TDto : class where T : LuceneIndexableBaseEntity
+	{
+		page = Math.Max(1, page);
+		var totalCount = query.Count();
+		if (1L * page * size > totalCount)
+		{
+			page = (int)Math.Ceiling(totalCount / (size * 1.0));
+		}
 
-        if (page <= 0)
-        {
-            page = 1;
-        }
+		if (page <= 0)
+		{
+			page = 1;
+		}
 
-        var list = query.Skip(size * (page - 1)).Take(size).ProjectTo<TDto>(mapper).FromCache().ToList();
-        return new PagedList<TDto>(list, page, size, totalCount);
-    }
-}
+		var list = query.Skip(size * (page - 1)).Take(size).ProjectTo<TDto>(mapper).Cacheable().ToList();
+		return new PagedList<TDto>(list, page, size, totalCount);
+	}
+}

+ 3 - 9
src/Masuit.MyBlogs.Core/Infrastructure/Services/AdvertisementService.cs

@@ -5,7 +5,7 @@ using Masuit.MyBlogs.Core.Infrastructure.Repository.Interface;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Caching.Memory;
 using System.Text.RegularExpressions;
-using Z.EntityFramework.Plus;
+using EFCoreSecondLevelCacheInterceptor;
 
 namespace Masuit.MyBlogs.Core.Infrastructure.Services;
 
@@ -73,10 +73,7 @@ public sealed partial class AdvertisementService : BaseService<Advertisement>, I
 			where = where.And(a => a.RegionMode == RegionLimitMode.All || (a.RegionMode == RegionLimitMode.AllowRegion ? Regex.IsMatch(location, a.Regions, RegexOptions.IgnoreCase) : !Regex.IsMatch(location, a.Regions, RegexOptions.IgnoreCase)));
 			if (cid.HasValue)
 			{
-				var pids = CategoryRepository.GetQuery(c => c.Id == cid).Select(c => string.Concat(c.ParentId, "|", c.Parent.ParentId).Trim('|')).Distinct().FromCache(new MemoryCacheEntryOptions()
-				{
-					AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(5)
-				});
+				var pids = CategoryRepository.GetQuery(c => c.Id == cid).Select(c => string.Concat(c.ParentId, "|", c.Parent.ParentId).Trim('|')).Distinct().Cacheable(CacheExpirationMode.Absolute, TimeSpan.FromHours(5)).ToList();
 				var scid = pids.Append(cid + "").Join("|");
 				if (Any(a => Regex.IsMatch(a.CategoryIds, scid)))
 				{
@@ -123,10 +120,7 @@ public sealed partial class AdvertisementService : BaseService<Advertisement>, I
 			string scid = "";
 			if (cid.HasValue)
 			{
-				scid = CategoryRepository.GetQuery(c => c.Id == cid).Select(c => string.Concat(c.ParentId, "|", c.Parent.ParentId).Trim('|')).Distinct().FromCache(new MemoryCacheEntryOptions()
-				{
-					AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(5)
-				}).Append(cid + "").Join("|");
+				scid = CategoryRepository.GetQuery(c => c.Id == cid).Select(c => string.Concat(c.ParentId, "|", c.Parent.ParentId).Trim('|')).Distinct().Cacheable(CacheExpirationMode.Absolute, TimeSpan.FromHours(5)).AsEnumerable().Append(cid + "").Join("|");
 			}
 
 			var array = all.GroupBy(a => a.Merchant).Select(g => g.OrderByRandom().FirstOrDefault().Id).Take(50).ToArray();

+ 257 - 257
src/Masuit.MyBlogs.Core/Infrastructure/Services/PostService.cs

@@ -13,290 +13,290 @@ using PanGu;
 using PanGu.HighLight;
 using System.Reflection;
 using System.Text.RegularExpressions;
-using Z.EntityFramework.Plus;
+using EFCoreSecondLevelCacheInterceptor;
 using Configuration = AngleSharp.Configuration;
 
 namespace Masuit.MyBlogs.Core.Infrastructure.Services;
 
 public sealed class PostService : BaseService<Post>, IPostService
 {
-    private readonly ICacheManager<SearchResult<PostDto>> _cacheManager;
-    private readonly ICategoryRepository _categoryRepository;
-    private readonly IMapper _mapper;
-    private readonly IPostTagsRepository _postTagsRepository;
+	private readonly ICacheManager<SearchResult<PostDto>> _cacheManager;
+	private readonly ICategoryRepository _categoryRepository;
+	private readonly IMapper _mapper;
+	private readonly IPostTagsRepository _postTagsRepository;
 
-    public PostService(IPostRepository repository, ISearchEngine<DataContext> searchEngine, ILuceneIndexSearcher searcher, ICacheManager<SearchResult<PostDto>> cacheManager, ICategoryRepository categoryRepository, IMapper mapper, IPostTagsRepository postTagsRepository) : base(repository, searchEngine, searcher)
-    {
-        _cacheManager = cacheManager;
-        _categoryRepository = categoryRepository;
-        _mapper = mapper;
-        _postTagsRepository = postTagsRepository;
-    }
+	public PostService(IPostRepository repository, ISearchEngine<DataContext> searchEngine, ILuceneIndexSearcher searcher, ICacheManager<SearchResult<PostDto>> cacheManager, ICategoryRepository categoryRepository, IMapper mapper, IPostTagsRepository postTagsRepository) : base(repository, searchEngine, searcher)
+	{
+		_cacheManager = cacheManager;
+		_categoryRepository = categoryRepository;
+		_mapper = mapper;
+		_postTagsRepository = postTagsRepository;
+	}
 
-    /// <summary>
-    /// 文章高亮关键词处理
-    /// </summary>
-    /// <param name="p"></param>
-    /// <param name="keyword"></param>
-    public async Task Highlight(Post p, string keyword)
-    {
-        try
-        {
-            var simpleHtmlFormatter = new SimpleHTMLFormatter("<span style='color:red;background-color:yellow;font-size: 1.1em;font-weight:700;'>", "</span>");
-            var highlighter = new Highlighter(simpleHtmlFormatter, new Segment()) { FragmentSize = int.MaxValue };
-            keyword = Regex.Replace(keyword, @"<|>|\(|\)|\{|\}|\[|\]", " ");
-            var keywords = Searcher.CutKeywords(keyword);
-            var context = BrowsingContext.New(Configuration.Default);
-            var document = await context.OpenAsync(req => req.Content(p.Content));
-            var elements = document.DocumentElement.GetElementsByTagName("p");
-            foreach (var e in elements)
-            {
-                for (var index = 0; index < e.ChildNodes.Length; index++)
-                {
-                    var node = e.ChildNodes[index];
-                    bool handled = false;
-                    foreach (var s in keywords)
-                    {
-                        string frag;
-                        if (handled == false && node.TextContent.Contains(s, StringComparison.CurrentCultureIgnoreCase) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, node.TextContent)))
-                        {
-                            switch (node)
-                            {
-                                case IElement el:
-                                    el.InnerHtml = frag;
-                                    handled = true;
-                                    break;
+	/// <summary>
+	/// 文章高亮关键词处理
+	/// </summary>
+	/// <param name="p"></param>
+	/// <param name="keyword"></param>
+	public async Task Highlight(Post p, string keyword)
+	{
+		try
+		{
+			var simpleHtmlFormatter = new SimpleHTMLFormatter("<span style='color:red;background-color:yellow;font-size: 1.1em;font-weight:700;'>", "</span>");
+			var highlighter = new Highlighter(simpleHtmlFormatter, new Segment()) { FragmentSize = int.MaxValue };
+			keyword = Regex.Replace(keyword, @"<|>|\(|\)|\{|\}|\[|\]", " ");
+			var keywords = Searcher.CutKeywords(keyword);
+			var context = BrowsingContext.New(Configuration.Default);
+			var document = await context.OpenAsync(req => req.Content(p.Content));
+			var elements = document.DocumentElement.GetElementsByTagName("p");
+			foreach (var e in elements)
+			{
+				for (var index = 0; index < e.ChildNodes.Length; index++)
+				{
+					var node = e.ChildNodes[index];
+					bool handled = false;
+					foreach (var s in keywords)
+					{
+						string frag;
+						if (handled == false && node.TextContent.Contains(s, StringComparison.CurrentCultureIgnoreCase) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, node.TextContent)))
+						{
+							switch (node)
+							{
+								case IElement el:
+									el.InnerHtml = frag;
+									handled = true;
+									break;
 
-                                case IText t:
-                                    var parser = new HtmlParser();
-                                    var parseDoc = parser.ParseDocument(frag).Body;
-                                    e.ReplaceChild(parseDoc, t);
-                                    handled = true;
-                                    break;
-                            }
-                        }
-                    }
-                }
-            }
-            p.Content = document.Body.InnerHtml;
-        }
-        catch
-        {
-            // ignored
-        }
-    }
+								case IText t:
+									var parser = new HtmlParser();
+									var parseDoc = parser.ParseDocument(frag).Body;
+									e.ReplaceChild(parseDoc, t);
+									handled = true;
+									break;
+							}
+						}
+					}
+				}
+			}
+			p.Content = document.Body.InnerHtml;
+		}
+		catch
+		{
+			// ignored
+		}
+	}
 
-    public SearchResult<PostDto> SearchPage(Expression<Func<Post, bool>> whereBase, int page, int size, string keyword)
-    {
-        var cacheKey = $"search:{keyword}:{page}:{size}";
-        var result = _cacheManager.GetOrAdd(cacheKey, _ =>
-        {
-            var searchResult = SearchEngine.ScoredSearch<Post>(BuildSearchOptions(page, size, keyword));
-            var entities = searchResult.Results.Where(s => s.Entity.Status == Status.Published).DistinctBy(s => s.Entity.Id).ToList();
-            var ids = entities.Select(s => s.Entity.Id).ToArray();
-            var dic = GetQuery(whereBase.And(p => ids.Contains(p.Id) && p.LimitMode != RegionLimitMode.OnlyForSearchEngine)).ProjectTo<PostDto>(_mapper.ConfigurationProvider).ToDictionary(p => p.Id);
-            var posts = entities.Where(s => dic.ContainsKey(s.Entity.Id)).Select(s => dic[s.Entity.Id]).ToList();
-            var simpleHtmlFormatter = new SimpleHTMLFormatter("<span style='color:red;background-color:yellow;font-size: 1.1em;font-weight:700;'>", "</span>");
-            var highlighter = new Highlighter(simpleHtmlFormatter, new Segment()) { FragmentSize = 200 };
-            var keywords = Searcher.CutKeywords(keyword);
-            HighlightSegment(posts, keywords, highlighter);
-            SolvePostsCategory(posts);
-            return new SearchResult<PostDto>()
-            {
-                Results = posts,
-                Elapsed = searchResult.Elapsed,
-                Total = searchResult.TotalHits
-            };
-        });
-        return result;
-    }
+	public SearchResult<PostDto> SearchPage(Expression<Func<Post, bool>> whereBase, int page, int size, string keyword)
+	{
+		var cacheKey = $"search:{keyword}:{page}:{size}";
+		var result = _cacheManager.GetOrAdd(cacheKey, _ =>
+		{
+			var searchResult = SearchEngine.ScoredSearch<Post>(BuildSearchOptions(page, size, keyword));
+			var entities = searchResult.Results.Where(s => s.Entity.Status == Status.Published).DistinctBy(s => s.Entity.Id).ToList();
+			var ids = entities.Select(s => s.Entity.Id).ToArray();
+			var dic = GetQuery(whereBase.And(p => ids.Contains(p.Id) && p.LimitMode != RegionLimitMode.OnlyForSearchEngine)).ProjectTo<PostDto>(_mapper.ConfigurationProvider).ToDictionary(p => p.Id);
+			var posts = entities.Where(s => dic.ContainsKey(s.Entity.Id)).Select(s => dic[s.Entity.Id]).ToList();
+			var simpleHtmlFormatter = new SimpleHTMLFormatter("<span style='color:red;background-color:yellow;font-size: 1.1em;font-weight:700;'>", "</span>");
+			var highlighter = new Highlighter(simpleHtmlFormatter, new Segment()) { FragmentSize = 200 };
+			var keywords = Searcher.CutKeywords(keyword);
+			HighlightSegment(posts, keywords, highlighter);
+			SolvePostsCategory(posts);
+			return new SearchResult<PostDto>()
+			{
+				Results = posts,
+				Elapsed = searchResult.Elapsed,
+				Total = searchResult.TotalHits
+			};
+		});
+		return result;
+	}
 
-    public void SolvePostsCategory(IList<PostDto> posts)
-    {
-        var cids = posts.Select(p => p.CategoryId).Distinct().ToArray();
-        var categories = _categoryRepository.GetQuery(c => cids.Contains(c.Id)).Include(c => c.Parent).ToDictionary(c => c.Id);
-        posts.ForEach(p => p.Category = _mapper.Map<CategoryDto_P>(categories[p.CategoryId]));
-    }
+	public void SolvePostsCategory(IList<PostDto> posts)
+	{
+		var cids = posts.Select(p => p.CategoryId).Distinct().ToArray();
+		var categories = _categoryRepository.GetQuery(c => cids.Contains(c.Id)).Include(c => c.Parent).ToDictionary(c => c.Id);
+		posts.ForEach(p => p.Category = _mapper.Map<CategoryDto_P>(categories[p.CategoryId]));
+	}
 
-    /// <summary>
-    /// 高亮截取处理
-    /// </summary>
-    /// <param name="posts"></param>
-    /// <param name="keywords"></param>
-    /// <param name="highlighter"></param>
-    private static void HighlightSegment(IList<PostDto> posts, List<string> keywords, Highlighter highlighter)
-    {
-        foreach (var p in posts)
-        {
-            p.Content = p.Content.RemoveHtmlTag();
-            foreach (var s in keywords)
-            {
-                string frag;
-                if (p.Title.Contains(s) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, p.Title)))
-                {
-                    p.Title = frag;
-                    break;
-                }
-            }
+	/// <summary>
+	/// 高亮截取处理
+	/// </summary>
+	/// <param name="posts"></param>
+	/// <param name="keywords"></param>
+	/// <param name="highlighter"></param>
+	private static void HighlightSegment(IList<PostDto> posts, List<string> keywords, Highlighter highlighter)
+	{
+		foreach (var p in posts)
+		{
+			p.Content = p.Content.RemoveHtmlTag();
+			foreach (var s in keywords)
+			{
+				string frag;
+				if (p.Title.Contains(s) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, p.Title)))
+				{
+					p.Title = frag;
+					break;
+				}
+			}
 
-            bool handled = false;
-            foreach (var s in keywords)
-            {
-                string frag;
-                if (p.Content.Contains(s) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, p.Content)))
-                {
-                    p.Content = frag;
-                    handled = true;
-                    break;
-                }
-            }
+			bool handled = false;
+			foreach (var s in keywords)
+			{
+				string frag;
+				if (p.Content.Contains(s) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, p.Content)))
+				{
+					p.Content = frag;
+					handled = true;
+					break;
+				}
+			}
 
-            if (p.Content.Length > 200 && !handled)
-            {
-                p.Content = p.Content[..200];
-            }
-        }
-    }
+			if (p.Content.Length > 200 && !handled)
+			{
+				p.Content = p.Content[..200];
+			}
+		}
+	}
 
-    private static SearchOptions BuildSearchOptions(int page, int size, string keyword)
-    {
-        keyword = Regex.Replace(keyword, @":\s+", ":");
-        var fields = new List<string>();
-        var newkeywords = new List<string>();
-        foreach (var item in keyword.Split(' ', ' ').Where(s => s.Contains(new[] { ":", ":" })))
-        {
-            var part = item.Split(':', ':');
-            var field = typeof(Post).GetProperty(part[0], BindingFlags.IgnoreCase)?.Name;
-            if (!string.IsNullOrEmpty(field))
-            {
-                fields.Add(field);
-            }
+	private static SearchOptions BuildSearchOptions(int page, int size, string keyword)
+	{
+		keyword = Regex.Replace(keyword, @":\s+", ":");
+		var fields = new List<string>();
+		var newkeywords = new List<string>();
+		foreach (var item in keyword.Split(' ', ' ').Where(s => s.Contains(new[] { ":", ":" })))
+		{
+			var part = item.Split(':', ':');
+			var field = typeof(Post).GetProperty(part[0], BindingFlags.IgnoreCase)?.Name;
+			if (!string.IsNullOrEmpty(field))
+			{
+				fields.Add(field);
+			}
 
-            newkeywords.Add(part[1]);
-        }
+			newkeywords.Add(part[1]);
+		}
 
-        var searchOptions = fields.Any() ? new SearchOptions(newkeywords.Join(" "), page, size, fields.Join(",")) : new SearchOptions(keyword, page, size, typeof(Post));
-        if (keyword.Contains(new[] { " ", ",", ";" }))
-        {
-            searchOptions.Score = 0.3f;
-        }
+		var searchOptions = fields.Any() ? new SearchOptions(newkeywords.Join(" "), page, size, fields.Join(",")) : new SearchOptions(keyword, page, size, typeof(Post));
+		if (keyword.Contains(new[] { " ", ",", ";" }))
+		{
+			searchOptions.Score = 0.3f;
+		}
 
-        return searchOptions;
-    }
+		return searchOptions;
+	}
 
-    /// <summary>
-    /// 文章所有tag
-    /// </summary>
-    /// <returns></returns>
-    public Dictionary<string, int> GetTags()
-    {
-        return _postTagsRepository.GetAll(t => t.Count, false).FromCache().ToDictionary(g => g.Name, g => g.Count);
-    }
+	/// <summary>
+	/// 文章所有tag
+	/// </summary>
+	/// <returns></returns>
+	public Dictionary<string, int> GetTags()
+	{
+		return _postTagsRepository.GetAll(t => t.Count, false).Cacheable().ToDictionary(g => g.Name, g => g.Count);
+	}
 
-    /// <summary>
-    /// 添加实体并保存
-    /// </summary>
-    /// <param name="t">需要添加的实体</param>
-    /// <returns>添加成功</returns>
-    public override Post AddEntitySaved(Post t)
-    {
-        t = base.AddEntity(t);
-        SearchEngine.SaveChanges(t.Status == Status.Published);
-        return t;
-    }
+	/// <summary>
+	/// 添加实体并保存
+	/// </summary>
+	/// <param name="t">需要添加的实体</param>
+	/// <returns>添加成功</returns>
+	public override Post AddEntitySaved(Post t)
+	{
+		t = base.AddEntity(t);
+		SearchEngine.SaveChanges(t.Status == Status.Published);
+		return t;
+	}
 
-    /// <summary>
-    /// 添加实体并保存(异步)
-    /// </summary>
-    /// <param name="t">需要添加的实体</param>
-    /// <returns>添加成功</returns>
-    public override Task<int> AddEntitySavedAsync(Post t)
-    {
-        base.AddEntity(t);
-        return SearchEngine.SaveChangesAsync(t.Status == Status.Published);
-    }
+	/// <summary>
+	/// 添加实体并保存(异步)
+	/// </summary>
+	/// <param name="t">需要添加的实体</param>
+	/// <returns>添加成功</returns>
+	public override Task<int> AddEntitySavedAsync(Post t)
+	{
+		base.AddEntity(t);
+		return SearchEngine.SaveChangesAsync(t.Status == Status.Published);
+	}
 
-    /// <summary>
-    /// 根据ID删除实体并保存
-    /// </summary>
-    /// <param name="id">实体id</param>
-    /// <returns>删除成功</returns>
-    public override bool DeleteById(int id)
-    {
-        DeleteEntity(GetById(id));
-        return SearchEngine.SaveChanges() > 0;
-    }
+	/// <summary>
+	/// 根据ID删除实体并保存
+	/// </summary>
+	/// <param name="id">实体id</param>
+	/// <returns>删除成功</returns>
+	public override bool DeleteById(int id)
+	{
+		DeleteEntity(GetById(id));
+		return SearchEngine.SaveChanges() > 0;
+	}
 
-    /// <summary>
-    /// 根据ID删除实体并保存(异步)
-    /// </summary>
-    /// <param name="id">实体id</param>
-    /// <returns>删除成功</returns>
-    public override Task<int> DeleteByIdAsync(int id)
-    {
-        base.DeleteById(id);
-        return SearchEngine.SaveChangesAsync();
-    }
+	/// <summary>
+	/// 根据ID删除实体并保存(异步)
+	/// </summary>
+	/// <param name="id">实体id</param>
+	/// <returns>删除成功</returns>
+	public override Task<int> DeleteByIdAsync(int id)
+	{
+		base.DeleteById(id);
+		return SearchEngine.SaveChangesAsync();
+	}
 
-    /// <summary>
-    /// 删除多个实体并保存(异步)
-    /// </summary>
-    /// <param name="list">实体集合</param>
-    /// <returns>删除成功</returns>
-    public override Task<int> DeleteEntitiesSavedAsync(IEnumerable<Post> list)
-    {
-        base.DeleteEntities(list);
-        return SearchEngine.SaveChangesAsync();
-    }
+	/// <summary>
+	/// 删除多个实体并保存(异步)
+	/// </summary>
+	/// <param name="list">实体集合</param>
+	/// <returns>删除成功</returns>
+	public override Task<int> DeleteEntitiesSavedAsync(IEnumerable<Post> list)
+	{
+		base.DeleteEntities(list);
+		return SearchEngine.SaveChangesAsync();
+	}
 
-    /// <summary>
-    /// 根据条件删除实体
-    /// </summary>
-    /// <param name="where">查询条件</param>
-    /// <returns>删除成功</returns>
-    public override int DeleteEntitySaved(Expression<Func<Post, bool>> where)
-    {
-        base.DeleteEntity(where);
-        return SearchEngine.SaveChanges();
-    }
+	/// <summary>
+	/// 根据条件删除实体
+	/// </summary>
+	/// <param name="where">查询条件</param>
+	/// <returns>删除成功</returns>
+	public override int DeleteEntitySaved(Expression<Func<Post, bool>> where)
+	{
+		base.DeleteEntity(where);
+		return SearchEngine.SaveChanges();
+	}
 
-    /// <summary>
-    /// 删除实体并保存
-    /// </summary>
-    /// <param name="t">需要删除的实体</param>
-    /// <returns>删除成功</returns>
-    public override bool DeleteEntitySaved(Post t)
-    {
-        base.DeleteEntity(t);
-        return SearchEngine.SaveChanges() > 0;
-    }
+	/// <summary>
+	/// 删除实体并保存
+	/// </summary>
+	/// <param name="t">需要删除的实体</param>
+	/// <returns>删除成功</returns>
+	public override bool DeleteEntitySaved(Post t)
+	{
+		base.DeleteEntity(t);
+		return SearchEngine.SaveChanges() > 0;
+	}
 
-    /// <summary>
-    /// 根据条件删除实体
-    /// </summary>
-    /// <param name="where">查询条件</param>
-    /// <returns>删除成功</returns>
-    public override Task<int> DeleteEntitySavedAsync(Expression<Func<Post, bool>> where)
-    {
-        base.DeleteEntity(where);
-        return SearchEngine.SaveChangesAsync();
-    }
+	/// <summary>
+	/// 根据条件删除实体
+	/// </summary>
+	/// <param name="where">查询条件</param>
+	/// <returns>删除成功</returns>
+	public override Task<int> DeleteEntitySavedAsync(Expression<Func<Post, bool>> where)
+	{
+		base.DeleteEntity(where);
+		return SearchEngine.SaveChangesAsync();
+	}
 
-    /// <summary>
-    /// 统一保存的方法
-    /// </summary>
-    /// <returns>受影响的行数</returns>
-    public int SaveChanges(bool flushIndex)
-    {
-        return flushIndex ? SearchEngine.SaveChanges() : base.SaveChanges();
-    }
+	/// <summary>
+	/// 统一保存的方法
+	/// </summary>
+	/// <returns>受影响的行数</returns>
+	public int SaveChanges(bool flushIndex)
+	{
+		return flushIndex ? SearchEngine.SaveChanges() : base.SaveChanges();
+	}
 
-    /// <summary>
-    /// 统一保存数据
-    /// </summary>
-    /// <returns>受影响的行数</returns>
-    public async Task<int> SaveChangesAsync(bool flushIndex)
-    {
-        return flushIndex ? await SearchEngine.SaveChangesAsync() : await base.SaveChangesAsync();
-    }
+	/// <summary>
+	/// 统一保存数据
+	/// </summary>
+	/// <returns>受影响的行数</returns>
+	public async Task<int> SaveChangesAsync(bool flushIndex)
+	{
+		return flushIndex ? await SearchEngine.SaveChangesAsync() : await base.SaveChangesAsync();
+	}
 }

+ 5 - 5
src/Masuit.MyBlogs.Core/Masuit.MyBlogs.Core.csproj

@@ -49,6 +49,7 @@
         <PackageReference Include="CHTCHSConv" Version="1.0.0" />
         <PackageReference Include="CLRStats" Version="1.0.0" />
         <PackageReference Include="Dispose.Scope.AspNetCore" Version="0.0.3" />
+        <PackageReference Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.2" />
         <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="6.0.3" />
         <PackageReference Include="FreeRedis" Version="1.1.10" />
         <PackageReference Include="Hangfire" Version="1.8.5" />
@@ -57,10 +58,10 @@
         <PackageReference Include="Karambolo.AspNetCore.Bundling.NUglify" Version="3.7.0" />
         <PackageReference Include="Markdig" Version="0.33.0" />
         <PackageReference Include="MaxMind.GeoIP2" Version="5.1.0" />
-        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.11" />
-        <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.11" />
-        <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.11" />
-        <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.11" />
+        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.12" />
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.12" />
+        <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.12" />
+        <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.12" />
         <PackageReference Include="Microsoft.Graph" Version="4.54.0" />
         <PackageReference Include="Microsoft.Graph.Auth" Version="1.0.0-preview.7" />
         <PackageReference Include="Microsoft.NETCore.Platforms" Version="7.0.4" />
@@ -73,7 +74,6 @@
         <PackageReference Include="System.Linq.Dynamic.Core" Version="1.3.5" />
         <PackageReference Include="TimeZoneConverter" Version="6.1.0" />
         <PackageReference Include="WilderMinds.RssSyndication" Version="1.7.0" />
-        <PackageReference Include="Z.EntityFramework.Plus.EFCore" Version="7.100.0.3" />
     </ItemGroup>
     <ItemGroup>
         <Content Update="appsettings.json">

+ 0 - 6
src/Masuit.MyBlogs.Core/Program.cs

@@ -4,14 +4,8 @@ using Masuit.MyBlogs.Core.Common;
 using Masuit.MyBlogs.Core.Extensions.DriveHelpers;
 using Masuit.MyBlogs.Core.Infrastructure.Drive;
 using Microsoft.AspNetCore.Server.Kestrel.Core;
-using Microsoft.Extensions.Caching.Memory;
 using System.Diagnostics;
-using Z.EntityFramework.Plus;
 
-QueryCacheManager.DefaultMemoryCacheEntryOptions = new MemoryCacheEntryOptions()
-{
-	AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
-};
 AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
 
 if (Environment.OSVersion.Platform is not (PlatformID.MacOSX or PlatformID.Unix))

+ 4 - 1
src/Masuit.MyBlogs.Core/Startup.cs

@@ -22,6 +22,8 @@ using Newtonsoft.Json;
 using SixLabors.ImageSharp.Web.DependencyInjection;
 using System.Text.RegularExpressions;
 using Masuit.Tools.AspNetCore.ModelBinder;
+using EFCoreSecondLevelCacheInterceptor;
+using Microsoft.Extensions.DependencyInjection;
 
 namespace Masuit.MyBlogs.Core;
 
@@ -68,7 +70,8 @@ public class Startup
 	/// <returns></returns>
 	public void ConfigureServices(IServiceCollection services)
 	{
-		services.AddDbContext<DataContext>(opt => opt.UseNpgsql(AppConfig.ConnString, builder => builder.EnableRetryOnFailure(10)).EnableSensitiveDataLogging()); //配置数据库
+		services.AddEFSecondLevelCache(options => options.UseCacheManagerCoreProvider(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(5)).DisableLogging(true).UseCacheKeyPrefix("EFCache:"));
+		services.AddDbContext<DataContext>((serviceProvider, opt) => opt.UseNpgsql(AppConfig.ConnString, builder => builder.EnableRetryOnFailure(10)).EnableSensitiveDataLogging().AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>())); //配置数据库
 		services.AddDbContext<LoggerDbContext>(opt => opt.UseNpgsql(AppConfig.ConnString)); //配置数据库
 		services.ConfigureOptions();
 		services.AddHttpsRedirection(options =>

+ 5 - 5
src/Masuit.MyBlogs.Core/Views/Home/Category.cshtml

@@ -6,8 +6,8 @@
 @using Masuit.MyBlogs.Core.Infrastructure.Services.Interface
 @using Masuit.MyBlogs.Core.Models.Enum
 @using Masuit.Tools.Models
-@using Z.EntityFramework.Plus
 @using Dispose.Scope
+@using EFCoreSecondLevelCacheInterceptor
 @model Masuit.MyBlogs.Core.Models.ViewModel.HomePageViewModel
 @inject ICategoryService CategoryService
 @{
@@ -23,16 +23,16 @@
             children2.AddRange(cat.Children.Where(c => c.Status == Status.Available).OrderBy(c => c.Id).ToPooledListScope());
             break;
         case 2:
-            children2.AddRange(CategoryService.GetQuery(c => c.Status == Status.Available && c.ParentId == parentId, c => c.Name).FromCache().ToPooledListScope());
+            children2.AddRange(CategoryService.GetQuery(c => c.Status == Status.Available && c.ParentId == parentId, c => c.Name).Cacheable().ToPooledListScope());
             children3.AddRange(cat.Children.Where(c => c.Status == Status.Available).OrderBy(c => c.Id).ToPooledListScope());
             break;
         case 3:
             var topid = cat.Parent.ParentId;
-            children2.AddRange(CategoryService.GetQuery(c => c.Status == Status.Available && c.ParentId == topid, c => c.Name).FromCache().ToPooledListScope());
-            children3.AddRange(CategoryService.GetQuery(c => c.Status == Status.Available && c.ParentId == parentId, c => c.Name).FromCache().ToPooledListScope());
+            children2.AddRange(CategoryService.GetQuery(c => c.Status == Status.Available && c.ParentId == topid, c => c.Name).Cacheable().ToPooledListScope());
+            children3.AddRange(CategoryService.GetQuery(c => c.Status == Status.Available && c.ParentId == parentId, c => c.Name).Cacheable().ToPooledListScope());
             break;
     }
-    var alllist = CategoryService.GetQuery(c => c.Status == Status.Available && c.ParentId == null, c => c.Name).Select(c => new{c.Id,c.Name}).FromCache().ToPooledListScope();
+    var alllist = CategoryService.GetQuery(c => c.Status == Status.Available && c.ParentId == null, c => c.Name).Select(c => new{c.Id,c.Name}).Cacheable().ToPooledListScope();
 }
 <style>
     .bg-title {

+ 4 - 6
src/Masuit.MyBlogs.Core/Views/Shared/_Layout.cshtml

@@ -9,19 +9,17 @@
 @using Masuit.MyBlogs.Core.Views.Shared
 @using Masuit.Tools
 @using Masuit.Tools.Core.Net
-@using Masuit.Tools.Models
 @using Microsoft.AspNetCore.Http.Extensions
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@using StackExchange.Profiling
 @using Masuit.Tools.AspNetCore.Extensions
-@using Z.EntityFramework.Plus
+@using EFCoreSecondLevelCacheInterceptor
+@using Masuit.Tools.Models
 @using Dispose.Scope
 
 @{
     string[] colors = { "success", "info", "warning", "danger", "default" };
-    var menus = MenuService.GetQueryNoTracking(m => m.Status == Status.Available,m => m.Sort).FromCache().ToTree(m => m.Id,m => m.ParentId);
+    var menus = MenuService.GetQueryNoTracking(m => m.Status == Status.Available,m => m.Sort).Cacheable().ToTree(m => m.Id,m => m.ParentId);
     var user = Context.Session.Get<UserInfoDto>(SessionKey.UserInfo) ?? new UserInfoDto();
-    var links = LinksService.GetQuery(l => l.Status == Status.Available).OrderByDescending(l => l.Recommend).ThenByDescending(l => l.Loopbacks.GroupBy(x => x.IP).Count()).Take(30).Select(e => new{e.Url,e.Name}).FromCache().ToPooledListScope();
+    var links = LinksService.GetQuery(l => l.Status == Status.Available).OrderByDescending(l => l.Recommend).ThenByDescending(l => l.Loopbacks.GroupBy(x => x.IP).Count()).Take(30).Select(e => new{e.Url,e.Name}).Cacheable().ToPooledListScope();
 }
 
 <!DOCTYPE html>