浏览代码

优化缓存中间件

懒得勤快 1 年之前
父节点
当前提交
f9a01a4b13

+ 5 - 5
src/Masuit.MyBlogs.Core/Common/Mails/IMailSender.cs

@@ -2,11 +2,11 @@
 
 public interface IMailSender
 {
-	Task Send(string title, string content, string tos, string clientip);
+    Task Send(string title, string content, string tos, string clientip);
 
-	List<string> GetBounces();
+    Task<List<string>> GetBounces();
 
-	Task<string> AddRecipient(string email);
+    Task<string> AddRecipient(string email);
 
-	public bool HasBounced(string address);
-}
+    public Task<bool> HasBounced(string address);
+}

+ 54 - 62
src/Masuit.MyBlogs.Core/Common/Mails/MailgunSender.cs

@@ -1,5 +1,4 @@
-using CacheManager.Core;
-using FreeRedis;
+using FreeRedis;
 using Hangfire;
 using Masuit.Tools.Models;
 using Newtonsoft.Json.Linq;
@@ -10,68 +9,61 @@ namespace Masuit.MyBlogs.Core.Common.Mails;
 
 public sealed class MailgunSender : IMailSender
 {
-	private readonly HttpClient _httpClient;
-	private readonly IConfiguration _configuration;
-	private readonly ICacheManager<List<string>> _cacheManager;
-	private readonly ICacheManager<bool> _bouncedCacheManager;
-	private readonly IRedisClient _redisClient;
+    private readonly HttpClient _httpClient;
+    private readonly IConfiguration _configuration;
+    private readonly IRedisClient _redisClient;
 
-	public MailgunSender(HttpClient httpClient, IConfiguration configuration, ICacheManager<List<string>> cacheManager, ICacheManager<bool> bouncedCacheManager, IRedisClient redisClient)
-	{
-		_configuration = configuration;
-		_cacheManager = cacheManager;
-		_bouncedCacheManager = bouncedCacheManager;
-		_redisClient = redisClient;
-		_httpClient = httpClient;
-		_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"api:{_configuration["MailgunConfig:apikey"]}")));
-	}
+    public MailgunSender(HttpClient httpClient, IConfiguration configuration, IRedisClient redisClient)
+    {
+        _configuration = configuration;
+        _redisClient = redisClient;
+        _httpClient = httpClient;
+        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"api:{_configuration["MailgunConfig:apikey"]}")));
+    }
 
-	[AutomaticRetry(Attempts = 1, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
-	public async Task Send(string title, string content, string tos, string clientip)
-	{
-		EmailAddress email = _configuration["MailgunConfig:from"];
-		using var form = new MultipartFormDataContent
-		{
-			{ new StringContent(email,Encoding.UTF8), "from" },
-			{ new StringContent(tos,Encoding.UTF8), "to" },
-			{ new StringContent(title,Encoding.UTF8), "subject" },
-			{ new StringContent(content,Encoding.UTF8), "html" }
-		};
-		await _httpClient.PostAsync($"https://api.mailgun.net/v3/{email.Domain}/messages", form);
-		_redisClient.SAdd($"Email:{DateTime.Now:yyyyMMdd}", new { title, content, tos, time = DateTime.Now, clientip });
-		_redisClient.Expire($"Email:{DateTime.Now:yyyyMMdd}", 86400);
-	}
+    [AutomaticRetry(Attempts = 1, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
+    public async Task Send(string title, string content, string tos, string clientip)
+    {
+        EmailAddress email = _configuration["MailgunConfig:from"];
+        using var form = new MultipartFormDataContent
+        {
+            { new StringContent(email,Encoding.UTF8), "from" },
+            { new StringContent(tos,Encoding.UTF8), "to" },
+            { new StringContent(title,Encoding.UTF8), "subject" },
+            { new StringContent(content,Encoding.UTF8), "html" }
+        };
+        await _httpClient.PostAsync($"https://api.mailgun.net/v3/{email.Domain}/messages", form);
+        await _redisClient.SAddAsync($"Email:{DateTime.Now:yyyyMMdd}", new { title, content, tos, time = DateTime.Now, clientip });
+        await _redisClient.ExpireAsync($"Email:{DateTime.Now:yyyyMMdd}", 86400);
+    }
 
-	public List<string> GetBounces()
-	{
-		EmailAddress email = _configuration["MailgunConfig:from"];
-		return _cacheManager.GetOrAdd("email-bounces", _ => _httpClient.GetStringAsync($"https://api.mailgun.net/v3/{email.Domain}/bounces").ContinueWith(t =>
-		{
-			return t.IsCompletedSuccessfully ? ((JArray)JObject.Parse(t.Result)["items"])?.Select(x => (string)x["address"]).ToList() : new List<string>();
-		}).Result);
-	}
+    public Task<List<string>> GetBounces()
+    {
+        EmailAddress email = _configuration["MailgunConfig:from"];
+        return _redisClient.GetOrAddAsync("email-bounces", () => _httpClient.GetStringAsync($"https://api.mailgun.net/v3/{email.Domain}/bounces").ContinueWith(t => t.IsCompletedSuccessfully ? ((JArray)JObject.Parse(t.Result)["items"])?.Select(x => (string)x["address"]).ToList() : new List<string>()), TimeSpan.FromHours(1));
+    }
 
-	public bool HasBounced(string address)
-	{
-		EmailAddress email = _configuration["MailgunConfig:from"];
-		return _bouncedCacheManager.GetOrAdd("email-bounced", _ => _httpClient.GetStringAsync($"https://api.mailgun.net/v3/{email.Domain}/bounces/{address}").ContinueWith(t => t.IsCompletedSuccessfully && JObject.Parse(t.Result).ContainsKey("error")).Result);
-	}
+    public Task<bool> HasBounced(string address)
+    {
+        EmailAddress email = _configuration["MailgunConfig:from"];
+        return _redisClient.GetOrAddAsync("email-bounced", () => _httpClient.GetStringAsync($"https://api.mailgun.net/v3/{email.Domain}/bounces/{address}").ContinueWith(t => t.IsCompletedSuccessfully && JObject.Parse(t.Result).ContainsKey("error")), TimeSpan.FromHours(1));
+    }
 
-	public Task<string> AddRecipient(string email)
-	{
-		EmailAddress mail = _configuration["MailgunConfig:from"];
-		return _httpClient.PostAsync($"https://api.mailgun.net/v3/{mail.Domain}/bounces", new MultipartFormDataContent
-		{
-			{ new StringContent(email,Encoding.UTF8), "address" },
-			{ new StringContent("黑名单邮箱",Encoding.UTF8), "error" }
-		}).ContinueWith(t =>
-		{
-			var resp = t.Result;
-			if (resp.IsSuccessStatusCode)
-			{
-				return (string)JObject.Parse(resp.Content.ReadAsStringAsync().Result)["message"];
-			}
-			return "添加失败";
-		});
-	}
-}
+    public Task<string> AddRecipient(string email)
+    {
+        EmailAddress mail = _configuration["MailgunConfig:from"];
+        return _httpClient.PostAsync($"https://api.mailgun.net/v3/{mail.Domain}/bounces", new MultipartFormDataContent
+        {
+            { new StringContent(email,Encoding.UTF8), "address" },
+            { new StringContent("黑名单邮箱",Encoding.UTF8), "error" }
+        }).ContinueWith(t =>
+        {
+            var resp = t.Result;
+            if (resp.IsSuccessStatusCode)
+            {
+                return (string)JObject.Parse(resp.Content.ReadAsStringAsync().Result)["message"];
+            }
+            return "添加失败";
+        });
+    }
+}

+ 39 - 39
src/Masuit.MyBlogs.Core/Common/Mails/SmtpSender.cs

@@ -7,47 +7,47 @@ namespace Masuit.MyBlogs.Core.Common.Mails;
 
 public sealed class SmtpSender : IMailSender
 {
-	private readonly IRedisClient _redisClient;
+    private readonly IRedisClient _redisClient;
 
-	public SmtpSender(IRedisClient redisClient)
-	{
-		_redisClient = redisClient;
-	}
+    public SmtpSender(IRedisClient redisClient)
+    {
+        _redisClient = redisClient;
+    }
 
-	[AutomaticRetry(Attempts = 1, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
-	public Task Send(string title, string content, string tos, string clientip)
-	{
-		new Email()
-		{
-			EnableSsl = bool.Parse(CommonHelper.SystemSettings.GetOrAdd("EnableSsl", "true")),
-			Body = content,
-			SmtpServer = CommonHelper.SystemSettings["SMTP"],
-			Username = CommonHelper.SystemSettings["EmailFrom"],
-			Password = CommonHelper.SystemSettings["EmailPwd"],
-			SmtpPort = CommonHelper.SystemSettings["SmtpPort"].ToInt32(),
-			Subject = title,
-			Tos = tos
-		}.Send();
-		_redisClient.SAdd($"Email:{DateTime.Now:yyyyMMdd}", new { title, content, tos, time = DateTime.Now, clientip });
-		_redisClient.Expire($"Email:{DateTime.Now:yyyyMMdd}", 86400);
-		return Task.CompletedTask;
-	}
+    [AutomaticRetry(Attempts = 1, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
+    public Task Send(string title, string content, string tos, string clientip)
+    {
+        new Email()
+        {
+            EnableSsl = bool.Parse(CommonHelper.SystemSettings.GetOrAdd("EnableSsl", "true")),
+            Body = content,
+            SmtpServer = CommonHelper.SystemSettings["SMTP"],
+            Username = CommonHelper.SystemSettings["EmailFrom"],
+            Password = CommonHelper.SystemSettings["EmailPwd"],
+            SmtpPort = CommonHelper.SystemSettings["SmtpPort"].ToInt32(),
+            Subject = title,
+            Tos = tos
+        }.Send();
+        _redisClient.SAdd($"Email:{DateTime.Now:yyyyMMdd}", new { title, content, tos, time = DateTime.Now, clientip });
+        _redisClient.Expire($"Email:{DateTime.Now:yyyyMMdd}", 86400);
+        return Task.CompletedTask;
+    }
 
-	public List<string> GetBounces()
-	{
-		return File.ReadAllText(Path.Combine(AppContext.BaseDirectory + "App_Data", "email-bounces.txt"), Encoding.UTF8).Split(',').ToList();
-	}
+    public Task<List<string>> GetBounces()
+    {
+        return Task.FromResult(File.ReadAllText(Path.Combine(AppContext.BaseDirectory + "App_Data", "email-bounces.txt"), Encoding.UTF8).Split(',').ToList());
+    }
 
-	public async Task<string> AddRecipient(string email)
-	{
-		var bounces = GetBounces();
-		bounces.Add(email);
-		await File.WriteAllTextAsync(Path.Combine(AppContext.BaseDirectory + "App_Data", "email-bounces.txt"), bounces.Join(","));
-		return "添加成功";
-	}
+    public async Task<string> AddRecipient(string email)
+    {
+        var bounces = await GetBounces();
+        bounces.Add(email);
+        await File.WriteAllTextAsync(Path.Combine(AppContext.BaseDirectory + "App_Data", "email-bounces.txt"), bounces.Join(","));
+        return "添加成功";
+    }
 
-	public bool HasBounced(string address)
-	{
-		return GetBounces().Contains(address);
-	}
-}
+    public Task<bool> HasBounced(string address)
+    {
+        return GetBounces().ContinueWith(list => list.Result.Contains(address));
+    }
+}

+ 367 - 0
src/Masuit.MyBlogs.Core/Common/RedisClientExtension.cs

@@ -0,0 +1,367 @@
+using System.Text.Json.Serialization;
+using FreeRedis;
+
+namespace Masuit.MyBlogs.Core.Common;
+
+public static class RedisClientExtension
+{
+    public static long IncrBy(this IRedisClient client, string key, int inc, TimeSpan? expire = null)
+    {
+        var incr = client.IncrBy(key, inc);
+        if (expire.HasValue)
+        {
+            client.Expire(key, expire.Value);
+        }
+
+        return incr;
+    }
+
+    public static long Incr(this IRedisClient client, string key, TimeSpan? expire = null)
+    {
+        var incr = client.Incr(key);
+        if (expire.HasValue)
+        {
+            client.Expire(key, expire.Value);
+        }
+
+        return incr;
+    }
+
+    public static async Task<long> IncrByAsync(this IRedisClient client, string key, int inc, TimeSpan? expire = null)
+    {
+        var incr = await client.IncrByAsync(key, inc);
+        if (expire.HasValue)
+        {
+            await client.ExpireAsync(key, expire.Value);
+        }
+
+        return incr;
+    }
+
+    public static async Task<long> IncrAsync(this IRedisClient client, string key, TimeSpan? expire = null)
+    {
+        var incr = await client.IncrAsync(key);
+        if (expire.HasValue)
+        {
+            await client.ExpireAsync(key, expire.Value);
+        }
+
+        return incr;
+    }
+
+    public static T GetOrAdd<T>(this IRedisClient client, string key, T addValue, TimeSpan? expire = null)
+    {
+        var value = client.Get<CacheEntry<T>>(key);
+        if (value is null)
+        {
+            client.Set(key, new CacheEntry<T>(addValue));
+            if (expire.HasValue)
+            {
+                client.Expire(key, expire.Value);
+            }
+            return addValue;
+        }
+        return value;
+    }
+
+    public static async Task<T> GetOrAddAsync<T>(this IRedisClient client, string key, T addValue, TimeSpan? expire = null)
+    {
+        var value = await client.GetAsync<CacheEntry<T>>(key);
+        if (value is null)
+        {
+            await client.SetAsync(key, new CacheEntry<T>(addValue));
+            if (expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+            return addValue;
+        }
+        return value;
+    }
+
+    public static T GetOrAdd<T>(this IRedisClient client, string key, Func<T> addValue, TimeSpan? expire = null)
+    {
+        var value = client.Get<CacheEntry<T>>(key);
+        if (value is null)
+        {
+            value = new CacheEntry<T>(addValue());
+            client.Set(key, value);
+            if (expire.HasValue)
+            {
+                client.Expire(key, expire.Value);
+            }
+        }
+        return value;
+    }
+
+    public static async Task<T> GetOrAddAsync<T>(this IRedisClient client, string key, Func<T> addValue, TimeSpan? expire = null)
+    {
+        var value = await client.GetAsync<CacheEntry<T>>(key);
+        if (value is null)
+        {
+            value = new CacheEntry<T>(addValue());
+            await client.SetAsync(key, value);
+            if (expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+        return value;
+    }
+
+    public static async Task<T> GetOrAddAsync<T>(this IRedisClient client, string key, Func<Task<T>> addValue, TimeSpan? expire = null)
+    {
+        var value = await client.GetAsync<CacheEntry<T>>(key);
+        if (value is null)
+        {
+            value = new CacheEntry<T>(await addValue());
+            await client.SetAsync(key, value);
+            if (expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+        return value;
+    }
+
+    public static T AddOrUpdate<T>(this IRedisClient client, string key, T addValue, T updateValue, TimeSpan? expire = null, bool isSliding = true)
+    {
+        T value;
+        if (client.Exists(key))
+        {
+            value = new CacheEntry<T>(updateValue);
+            client.Set(key, value);
+            if (isSliding && expire.HasValue)
+            {
+                client.Expire(key, expire.Value);
+            }
+        }
+        else
+        {
+            value = new CacheEntry<T>(addValue);
+            client.Set(key, value);
+            if (expire.HasValue)
+            {
+                client.Expire(key, expire.Value);
+            }
+        }
+
+        return value;
+    }
+
+    public static T AddOrUpdate<T>(this IRedisClient client, string key, T addValue, Func<T> updateValue, TimeSpan? expire = null, bool isSliding = true)
+    {
+        T value;
+        if (client.Exists(key))
+        {
+            var update = updateValue();
+            client.Set(key, new CacheEntry<T>(update));
+            value = update;
+            if (isSliding && expire.HasValue)
+            {
+                client.Expire(key, expire.Value);
+            }
+        }
+        else
+        {
+            client.Set(key, new CacheEntry<T>(addValue));
+            value = addValue;
+            if (expire.HasValue)
+            {
+                client.Expire(key, expire.Value);
+            }
+        }
+
+        return value;
+    }
+
+    public static T AddOrUpdate<T>(this IRedisClient client, string key, T addValue, Func<T, T> updateValue, TimeSpan? expire = null, bool isSliding = true)
+    {
+        var value = client.Get<CacheEntry<T>>(key);
+        if (value is null)
+        {
+            client.Set(key, new CacheEntry<T>(addValue));
+            value = addValue;
+            if (expire.HasValue)
+            {
+                client.Expire(key, expire.Value);
+            }
+        }
+        else
+        {
+            value = updateValue(value);
+            client.Set(key, new CacheEntry<T>(value));
+            if (isSliding && expire.HasValue)
+            {
+                client.Expire(key, expire.Value);
+            }
+        }
+
+        return value;
+    }
+
+    public static async Task<T> AddOrUpdateAsync<T>(this IRedisClient client, string key, T addValue, T updateValue, TimeSpan? expire = null, bool isSliding = true)
+    {
+        T value;
+        if (await client.ExistsAsync(key))
+        {
+            await client.SetAsync(key, new CacheEntry<T>(updateValue));
+            value = updateValue;
+            if (isSliding && expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+        else
+        {
+            await client.SetAsync(key, new CacheEntry<T>(addValue));
+            value = addValue;
+            if (expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+
+        return value;
+    }
+
+    public static async Task<T> AddOrUpdateAsync<T>(this IRedisClient client, string key, T addValue, Func<T> updateValue, TimeSpan? expire = null, bool isSliding = true)
+    {
+        T value;
+        if (await client.ExistsAsync(key))
+        {
+            var update = updateValue();
+            await client.SetAsync(key, new CacheEntry<T>(update));
+            value = update;
+            if (isSliding && expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+        else
+        {
+            await client.SetAsync(key, new CacheEntry<T>(addValue));
+            value = addValue;
+            if (expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+
+        return value;
+    }
+
+    public static async Task<T> AddOrUpdateAsync<T>(this IRedisClient client, string key, T addValue, Func<T, T> updateValue, TimeSpan? expire = null, bool isSliding = true)
+    {
+        var value = await client.GetAsync<CacheEntry<T>>(key);
+        if (value is null)
+        {
+            await client.SetAsync(key, new CacheEntry<T>(addValue));
+            value = addValue;
+            if (expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+        else
+        {
+            var update = updateValue(value);
+            await client.SetAsync(key, new CacheEntry<T>(update));
+            value = update;
+            if (isSliding && expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+
+        return value;
+    }
+
+    public static async Task<T> AddOrUpdateAsync<T>(this IRedisClient client, string key, T addValue, Func<Task<T>> updateValue, TimeSpan? expire = null, bool isSliding = true)
+    {
+        T value;
+        if (!await client.ExistsAsync(key))
+        {
+            await client.SetAsync(key, new CacheEntry<T>(addValue));
+            value = addValue;
+            if (expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+        else
+        {
+            var update = await updateValue();
+            await client.SetAsync(key, new CacheEntry<T>(update));
+            value = update;
+            if (isSliding && expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+
+        return value;
+    }
+
+    public static async Task<T> AddOrUpdateAsync<T>(this IRedisClient client, string key, T addValue, Func<T, Task<T>> updateValue, TimeSpan? expire = null, bool isSliding = true)
+    {
+        var value = await client.GetAsync<CacheEntry<T>>(key);
+        if (value is null)
+        {
+            await client.SetAsync(key, new CacheEntry<T>(addValue));
+            value = addValue;
+            if (expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+        else
+        {
+            var update = await updateValue(value);
+            await client.SetAsync(key, new CacheEntry<T>(update));
+            value = update;
+            if (isSliding && expire.HasValue)
+            {
+                await client.ExpireAsync(key, expire.Value);
+            }
+        }
+
+        return value;
+    }
+}
+
+/// <summary>
+/// 缓存实体
+/// </summary>
+/// <typeparam name="T"></typeparam>
+public record CacheEntry<T>
+{
+    public CacheEntry()
+    {
+    }
+
+    public CacheEntry(T value)
+    {
+        Value = value;
+    }
+
+    public T Value { get; set; }
+
+    /// <summary>
+    /// 隐式转换
+    /// </summary>
+    /// <param name="entry"></param>
+    public static implicit operator T(CacheEntry<T> entry)
+    {
+        return entry.Value;
+    }
+
+    /// <summary>
+    /// 隐式转换
+    /// </summary>
+    /// <param name="item"></param>
+    public static implicit operator CacheEntry<T>(T item)
+    {
+        return new CacheEntry<T>(item);
+    }
+}

+ 2 - 2
src/Masuit.MyBlogs.Core/Controllers/BaseController.cs

@@ -172,7 +172,7 @@ public class BaseController : Controller
     /// <param name="email">邮箱地址</param>
     /// <param name="code">验证码</param>
     /// <returns></returns>
-    internal string ValidateEmailCode(IMailSender mailSender, string email, string code)
+    internal async Task<string> ValidateEmailCode(IMailSender mailSender, string email, string code)
     {
         if (CurrentUser.IsAdmin)
         {
@@ -198,7 +198,7 @@ public class BaseController : Controller
             return "邮箱验证信息已失效,请刷新页面后重新评论!";
         }
 
-        if (mailSender.HasBounced(email))
+        if (await mailSender.HasBounced(email))
         {
             Response.Cookies.Delete("Email");
             Response.Cookies.Delete("NickName");

+ 6 - 9
src/Masuit.MyBlogs.Core/Controllers/CommentController.cs

@@ -1,5 +1,4 @@
-using CacheManager.Core;
-using Dispose.Scope;
+using Dispose.Scope;
 using Hangfire;
 using Masuit.MyBlogs.Core.Common;
 using Masuit.MyBlogs.Core.Common.Mails;
@@ -28,8 +27,6 @@ public sealed class CommentController : BaseController
 
     public IWebHostEnvironment HostEnvironment { get; set; }
 
-    public ICacheManager<int> CommentFeq { get; set; }
-
     /// <summary>
     /// 发表评论
     /// </summary>
@@ -46,7 +43,7 @@ public sealed class CommentController : BaseController
             LogManager.Info($"提交内容:{cmd.NickName}/{cmd.Content},敏感词:{match.Value}");
             return ResultData(null, false, "您提交的内容包含敏感词,被禁止发表,请检查您的内容后尝试重新提交!");
         }
-        var error = ValidateEmailCode(mailSender, cmd.Email, cmd.Code);
+        var error = await ValidateEmailCode(mailSender, cmd.Email, cmd.Code);
         if (!string.IsNullOrEmpty(error))
         {
             return ResultData(null, false, error);
@@ -66,9 +63,10 @@ public sealed class CommentController : BaseController
 
         cmd.Content = cmd.Content.Trim().Replace("<p><br></p>", string.Empty);
         var ip = ClientIP.ToString();
-        if (CommentFeq.GetOrAdd("Comments:" + ip, 1) > 2)
+
+        if (await RedisHelper.IncrAsync("Comments:" + ip) > 2)
         {
-            CommentFeq.Expire("Comments:" + ip, TimeSpan.FromMinutes(1));
+            await RedisHelper.ExpireAsync("Comments:" + ip, TimeSpan.FromMinutes(1));
             return ResultData(null, false, "您的发言频率过快,请稍后再发表吧!");
         }
 
@@ -117,8 +115,7 @@ public sealed class CommentController : BaseController
             SameSite = SameSiteMode.Lax
         });
         WriteEmailKeyCookie(cmd.Email);
-        CommentFeq.AddOrUpdate("Comments:" + comment.IP, 1, i => i + 1, 5);
-        CommentFeq.Expire("Comments:" + comment.IP, TimeSpan.FromMinutes(1));
+        await RedisHelper.ExpireAsync("Comments:" + comment.IP, TimeSpan.FromMinutes(1));
         var emails = new HashSet<string>();
         var email = CommonHelper.SystemSettings["ReceiveEmail"]; //站长邮箱
         emails.Add(email);

+ 4 - 5
src/Masuit.MyBlogs.Core/Controllers/FirewallController.cs

@@ -1,5 +1,4 @@
-using CacheManager.Core;
-using Dispose.Scope;
+using Dispose.Scope;
 using FreeRedis;
 using Masuit.MyBlogs.Core.Common;
 using Masuit.MyBlogs.Core.Configs;
@@ -133,7 +132,7 @@ public sealed class FirewallController : Controller
     /// <returns></returns>
     [HttpGet("/craw/{id}")]
     [ServiceFilter(typeof(FirewallAttribute))]
-    public IActionResult AntiCrawler(string id, [FromServices] ICacheManager<int> cacheManager, [FromServices] IWebHostEnvironment env)
+    public IActionResult AntiCrawler(string id, [FromServices] RedisClient cacheManager, [FromServices] IWebHostEnvironment env)
     {
         if (Request.IsRobot())
         {
@@ -157,8 +156,8 @@ public sealed class FirewallController : Controller
                 Request.Headers
             }.ToJsonString()
         });
-        cacheManager.AddOrUpdate("AntiCrawler:" + ip, 1, i => i + 1, 5);
-        cacheManager.Expire("AntiCrawler:" + ip, ExpirationMode.Sliding, TimeSpan.FromMinutes(10));
+        cacheManager.Incr("AntiCrawler:" + ip);
+        cacheManager.Expire("AntiCrawler:" + ip, TimeSpan.FromMinutes(10));
         if (cacheManager.Get<int>("AntiCrawler:" + ip) > 3)
         {
             Response.StatusCode = 429;

+ 5 - 9
src/Masuit.MyBlogs.Core/Controllers/MsgController.cs

@@ -1,5 +1,4 @@
-using CacheManager.Core;
-using Dispose.Scope;
+using Dispose.Scope;
 using Hangfire;
 using Masuit.MyBlogs.Core.Common;
 using Masuit.MyBlogs.Core.Common.Mails;
@@ -34,8 +33,6 @@ public sealed class MsgController : BaseController
 
     public IWebHostEnvironment HostEnvironment { get; set; }
 
-    public ICacheManager<int> MsgFeq { get; set; }
-
     /// <summary>
     /// 留言板
     /// </summary>
@@ -131,7 +128,7 @@ public sealed class MsgController : BaseController
             return ResultData(null, false, "您提交的内容包含敏感词,被禁止发表,请检查您的内容后尝试重新提交!");
         }
 
-        var error = ValidateEmailCode(mailSender, cmd.Email, cmd.Code);
+        var error = await ValidateEmailCode(mailSender, cmd.Email, cmd.Code);
         if (!string.IsNullOrEmpty(error))
         {
             return ResultData(null, false, error);
@@ -144,9 +141,9 @@ public sealed class MsgController : BaseController
 
         cmd.Content = cmd.Content.Trim().Replace("<p><br></p>", string.Empty);
         var ip = ClientIP.ToString();
-        if (MsgFeq.GetOrAdd("Comments:" + ip, 1) > 2)
+        if (await RedisHelper.IncrAsync("Comments:" + ip) > 2)
         {
-            MsgFeq.Expire("Comments:" + ip, TimeSpan.FromMinutes(1));
+            await RedisHelper.ExpireAsync("Comments:" + ip, TimeSpan.FromMinutes(1));
             return ResultData(null, false, "您的发言频率过快,请稍后再发表吧!");
         }
 
@@ -196,8 +193,7 @@ public sealed class MsgController : BaseController
             SameSite = SameSiteMode.Lax
         });
         WriteEmailKeyCookie(cmd.Email);
-        MsgFeq.AddOrUpdate("Comments:" + ip, 1, i => i + 1, 5);
-        MsgFeq.Expire("Comments:" + ip, TimeSpan.FromMinutes(1));
+        await RedisHelper.ExpireAsync("Comments:" + ip, TimeSpan.FromMinutes(1));
         var email = CommonHelper.SystemSettings["ReceiveEmail"];
         var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/notify.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8)).Set("title", "网站留言板").Set("time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")).Set("nickname", msg.NickName).Set("content", msg.Content);
         if (msg.Status == Status.Published)

+ 3 - 3
src/Masuit.MyBlogs.Core/Controllers/PassportController.cs

@@ -1,5 +1,4 @@
 using AutoMapper;
-using CacheManager.Core;
 using Hangfire;
 using Masuit.MyBlogs.Core.Configs;
 using Masuit.MyBlogs.Core.Extensions.Firewall;
@@ -11,6 +10,7 @@ using Microsoft.AspNetCore.Mvc;
 using System.Net;
 using System.Web;
 using Dispose.Scope;
+using FreeRedis;
 
 namespace Masuit.MyBlogs.Core.Controllers;
 
@@ -123,7 +123,7 @@ public sealed class PassportController : Controller
     /// <param name="remem"></param>
     /// <returns></returns>
     [HttpPost, ValidateAntiForgeryToken]
-    public ActionResult Login([FromServices] ICacheManager<int> cacheManager, string username, string password, string valid, string remem)
+    public ActionResult Login([FromServices] IRedisClient cacheManager, string username, string password, string valid, string remem)
     {
         string validSession = HttpContext.Session.Get<string>("valid") ?? string.Empty; //将验证码从Session中取出来,用于登录验证比较
         if (string.IsNullOrEmpty(validSession) || !valid.Trim().Equals(validSession, StringComparison.InvariantCultureIgnoreCase))
@@ -150,7 +150,7 @@ public sealed class PassportController : Controller
         var userInfo = UserInfoService.Login(username, password);
         if (userInfo == null)
         {
-            var times = cacheManager.AddOrUpdate("LoginError:" + ClientIP, 1, i => i + 1, 5);
+            var times = cacheManager.Incr("LoginError:" + ClientIP);
             if (times > 30)
             {
                 FirewallRepoter.ReportAsync(IPAddress.Parse(ClientIP)).ContinueWith(_ => LogManager.Info($"多次登录用户名或密码错误,疑似爆破行为,已上报IP{ClientIP}至:" + FirewallRepoter.ReporterName));

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

@@ -26,6 +26,7 @@ using System.Net;
 using System.Text;
 using System.Text.RegularExpressions;
 using EFCoreSecondLevelCacheInterceptor;
+using FreeRedis;
 using Masuit.Tools.Mime;
 using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode;
 
@@ -36,1299 +37,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).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 后端管理
+    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] IRedisClient 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 = (int)await cacheManager.SCardAsync(nameof(PostOnline) + ":" + item.Id);
+        }
+
+        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 = await RedisHelper.KeysAsync(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 后端管理
 }

+ 232 - 240
src/Masuit.MyBlogs.Core/Extensions/Firewall/FirewallAttribute.cs

@@ -1,5 +1,4 @@
-using CacheManager.Core;
-using FreeRedis;
+using FreeRedis;
 using Markdig;
 using Masuit.MyBlogs.Core.Common;
 using Masuit.MyBlogs.Core.Configs;
@@ -20,271 +19,264 @@ namespace Masuit.MyBlogs.Core.Extensions.Firewall;
 
 public sealed class FirewallAttribute : IAsyncActionFilter
 {
-	public ICacheManager<int> CacheManager { get; set; }
+    public IFirewallRepoter FirewallRepoter { get; set; }
 
-	public IFirewallRepoter FirewallRepoter { get; set; }
+    public IMemoryCache MemoryCache { get; set; }
 
-	public IMemoryCache MemoryCache { get; set; }
+    public IRedisClient RedisClient { get; set; }
 
-	public IRedisClient RedisClient { get; set; }
+    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+    {
+        var request = context.HttpContext.Request;
+        if (CommonHelper.SystemSettings.TryGetValue("BlockHeaderValues", out var v) && v.Length > 0)
+        {
+            var strs = v.Split("|", StringSplitOptions.RemoveEmptyEntries);
+            if (request.Headers.Values.Any(values => strs.Any(s => values.Contains(s))))
+            {
+                context.Result = new NotFoundResult();
+                return;
+            }
+        }
 
-	public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
-	{
-		var request = context.HttpContext.Request;
-		if (CommonHelper.SystemSettings.TryGetValue("BlockHeaderValues", out var v) && v.Length > 0)
-		{
-			var strs = v.Split("|", StringSplitOptions.RemoveEmptyEntries);
-			if (request.Headers.Values.Any(values => strs.Any(s => values.Contains(s))))
-			{
-				context.Result = new NotFoundResult();
-				return Task.CompletedTask;
-			}
-		}
+        request.Headers.Values.Contains("");
+        var ip = context.HttpContext.Connection.RemoteIpAddress.ToString();
+        var tokenValid = request.Cookies.ContainsKey("FullAccessToken") && request.Cookies["Email"].MDString(AppConfig.BaiduAK).Equals(request.Cookies["FullAccessToken"]);
 
-		request.Headers.Values.Contains("");
-		var ip = context.HttpContext.Connection.RemoteIpAddress.ToString();
-		var tokenValid = request.Cookies.ContainsKey("FullAccessToken") && request.Cookies["Email"].MDString(AppConfig.BaiduAK).Equals(request.Cookies["FullAccessToken"]);
+        //黑名单
+        if (ip.IsDenyIpAddress() && !tokenValid)
+        {
+            await AccessDeny(ip, request, "黑名单IP地址");
+            context.Result = new BadRequestObjectResult("您当前所在的网络环境不支持访问本站!");
+            return;
+        }
 
-		//黑名单
-		if (ip.IsDenyIpAddress() && !tokenValid)
-		{
-			AccessDeny(ip, request, "黑名单IP地址");
-			context.Result = new BadRequestObjectResult("您当前所在的网络环境不支持访问本站!");
-			return Task.CompletedTask;
-		}
+        //bypass
+        if (CommonHelper.SystemSettings.GetOrAdd("FirewallEnabled", "true") == "false" || context.ActionDescriptor.EndpointMetadata.Any(o => o is MyAuthorizeAttribute or AllowAccessFirewallAttribute) || tokenValid)
+        {
+            await next();
+            return;
+        }
 
-		//bypass
-		if (CommonHelper.SystemSettings.GetOrAdd("FirewallEnabled", "true") == "false" || context.ActionDescriptor.EndpointMetadata.Any(o => o is MyAuthorizeAttribute or AllowAccessFirewallAttribute) || tokenValid)
-		{
-			return next();
-		}
+        //UserAgent
+        var ua = request.Headers[HeaderNames.UserAgent] + "";
+        var blocked = CommonHelper.SystemSettings.GetOrAdd("UserAgentBlocked", "").Split(new[] { ',', '|' }, StringSplitOptions.RemoveEmptyEntries);
+        if (ua.Contains(blocked))
+        {
+            var agent = UserAgent.Parse(ua);
+            await AccessDeny(ip, request, $"UA黑名单({agent.Browser} {agent.BrowserVersion}/{agent.Platform})");
+            var msg = CommonHelper.SystemSettings.GetOrAdd("UserAgentBlockedMsg", "当前浏览器不支持访问本站");
+            context.Result = new ContentResult
+            {
+                Content = Markdown.ToHtml(Template.Create(msg).Set("browser", agent.Browser + " " + agent.BrowserVersion).Set("os", agent.Platform).Render()),
+                ContentType = ContentType.Html + "; charset=utf-8",
+                StatusCode = 403
+            };
+            return;
+        }
 
-		//UserAgent
-		var ua = request.Headers[HeaderNames.UserAgent] + "";
-		var blocked = CommonHelper.SystemSettings.GetOrAdd("UserAgentBlocked", "").Split(new[]
-		{
-			',',
-			'|'
-		}, StringSplitOptions.RemoveEmptyEntries);
-		if (ua.Contains(blocked))
-		{
-			var agent = UserAgent.Parse(ua);
-			AccessDeny(ip, request, $"UA黑名单({agent.Browser} {agent.BrowserVersion}/{agent.Platform})");
-			var msg = CommonHelper.SystemSettings.GetOrAdd("UserAgentBlockedMsg", "当前浏览器不支持访问本站");
-			context.Result = new ContentResult
-			{
-				Content = Markdown.ToHtml(Template.Create(msg).Set("browser", agent.Browser + " " + agent.BrowserVersion).Set("os", agent.Platform).Render()),
-				ContentType = ContentType.Html + "; charset=utf-8",
-				StatusCode = 403
-			};
-			return Task.CompletedTask;
-		}
+        //搜索引擎
+        if (Regex.IsMatch(request.Method, "OPTIONS|HEAD", RegexOptions.IgnoreCase) || request.IsRobot())
+        {
+            await next();
+            return;
+        }
 
-		//搜索引擎
-		if (Regex.IsMatch(request.Method, "OPTIONS|HEAD", RegexOptions.IgnoreCase) || request.IsRobot())
-		{
-			return next();
-		}
+        // 反爬虫
+        if (await RedisClient.GetAsync<int>(nameof(FirewallController.AntiCrawler) + ":" + ip) > 3)
+        {
+            context.Result = new ContentResult
+            {
+                ContentType = ContentType.Html + "; charset=utf-8",
+                StatusCode = 429,
+                Content = "检测到访问异常,请在10分钟后再试!"
+            };
+            return;
+        }
 
-		// 反爬虫
-		if (CacheManager.GetOrAdd(nameof(FirewallController.AntiCrawler) + ":" + ip, 0) > 3)
-		{
-			context.Result = new ContentResult
-			{
-				ContentType = ContentType.Html + "; charset=utf-8",
-				StatusCode = 429,
-				Content = "检测到访问异常,请在10分钟后再试!"
-			};
-			return Task.CompletedTask;
-		}
+        //安全模式
+        if (request.Query[SessionKey.SafeMode].Count > 0)
+        {
+            request.Cookies.TryGetValue(SessionKey.HideCategories, out var s);
+            context.HttpContext.Response.Cookies.Append(SessionKey.HideCategories, request.Query[SessionKey.SafeMode] + "," + s, new CookieOptions
+            {
+                Expires = DateTime.Now.AddYears(1),
+                SameSite = SameSiteMode.Lax
+            });
+        }
 
-		//安全模式
-		if (request.Query[SessionKey.SafeMode].Count > 0)
-		{
-			request.Cookies.TryGetValue(SessionKey.HideCategories, out var s);
-			context.HttpContext.Response.Cookies.Append(SessionKey.HideCategories, request.Query[SessionKey.SafeMode] + "," + s, new CookieOptions
-			{
-				Expires = DateTime.Now.AddYears(1),
-				SameSite = SameSiteMode.Lax
-			});
-		}
+        //白名单地区
+        var ipLocation = context.HttpContext.Connection.RemoteIpAddress.GetIPLocation();
+        var (location, network, pos) = ipLocation;
+        pos += ipLocation.Coodinate;
+        var allowedAreas = CommonHelper.SystemSettings.GetOrAdd("AllowedArea", "").Split(new[]
+        {
+            ',',
+            ','
+        }, StringSplitOptions.RemoveEmptyEntries);
+        if (allowedAreas.Any() && pos.Contains(allowedAreas))
+        {
+            await next();
+            return;
+        }
 
-		//白名单地区
-		var ipLocation = context.HttpContext.Connection.RemoteIpAddress.GetIPLocation();
-		var (location, network, pos) = ipLocation;
-		pos += ipLocation.Coodinate;
-		var allowedAreas = CommonHelper.SystemSettings.GetOrAdd("AllowedArea", "").Split(new[]
-		{
-			',',
-			','
-		}, StringSplitOptions.RemoveEmptyEntries);
-		if (allowedAreas.Any() && pos.Contains(allowedAreas))
-		{
-			return next();
-		}
+        //黑名单地区
+        var denyAreas = CommonHelper.SystemSettings.GetOrAdd("DenyArea", "").Split(new[] { ',', ',' }, StringSplitOptions.RemoveEmptyEntries);
+        if (denyAreas.Any())
+        {
+            if (string.IsNullOrWhiteSpace(location) || string.IsNullOrWhiteSpace(network) || pos.Contains(denyAreas) || denyAreas.Intersect(pos.Split("|")).Any()) // 未知地区的,未知网络的,禁区的
+            {
+                await AccessDeny(ip, request, "访问地区限制");
+                throw new AccessDenyException("访问地区限制");
+            }
+        }
 
-		//黑名单地区
-		var denyAreas = CommonHelper.SystemSettings.GetOrAdd("DenyArea", "").Split(new[] { ',', ',' }, StringSplitOptions.RemoveEmptyEntries);
-		if (denyAreas.Any())
-		{
-			if (string.IsNullOrWhiteSpace(location) || string.IsNullOrWhiteSpace(network) || pos.Contains(denyAreas) || denyAreas.Intersect(pos.Split("|")).Any()) // 未知地区的,未知网络的,禁区的
-			{
-				AccessDeny(ip, request, "访问地区限制");
-				throw new AccessDenyException("访问地区限制");
-			}
-		}
+        //挑战模式
+        if (context.HttpContext.Session.TryGetValue("js-challenge", out _) || request.Path.ToUriComponent().Contains("."))
+        {
+            await next();
+            return;
+        }
 
-		//挑战模式
-		if (context.HttpContext.Session.TryGetValue("js-challenge", out _) || request.Path.ToUriComponent().Contains("."))
-		{
-			return next();
-		}
+        try
+        {
+            if (request.Cookies.TryGetValue(SessionKey.ChallengeBypass, out var time) && time.AESDecrypt(AppConfig.BaiduAK).ToDateTime() > DateTime.Now)
+            {
+                context.HttpContext.Session.Set("js-challenge", 1);
+                await next();
+                return;
+            }
+        }
+        catch
+        {
+            context.HttpContext.Response.Cookies.Delete(SessionKey.ChallengeBypass);
+        }
 
-		try
-		{
-			if (request.Cookies.TryGetValue(SessionKey.ChallengeBypass, out var time) && time.AESDecrypt(AppConfig.BaiduAK).ToDateTime() > DateTime.Now)
-			{
-				context.HttpContext.Session.Set("js-challenge", 1);
-				return next();
-			}
-		}
-		catch
-		{
-			context.HttpContext.Response.Cookies.Delete(SessionKey.ChallengeBypass);
-		}
+        if (Challenge(context))
+        {
+            return;
+        }
 
-		if (Challenge(context, out var completedTask))
-		{
-			return completedTask;
-		}
+        //限流
+        await ThrottleLimit(ip, request, next);
+    }
 
-		//限流
-		return ThrottleLimit(ip, request, next);
-	}
-
-	private static bool Challenge(ActionExecutingContext context, out Task completedTask)
-	{
+    private static bool Challenge(ActionExecutingContext context)
+    {
 #if DEBUG
-		completedTask = Task.CompletedTask;
-		return false;
+        return false;
 #endif
-		var rule = CommonHelper.SystemSettings.GetOrAdd("ChallengeRule", "");
-		var regions = CommonHelper.SystemSettings.GetOrAdd("ChallengeRegions", "");
-		var limitMode = CommonHelper.SystemSettings.GetOrAdd("ChallengeRegionLimitMode", "");
-		if (rule == "Region")
-		{
-			var match = Regex.IsMatch(context.HttpContext.Request.Location(), regions, RegexOptions.IgnoreCase);
-			switch (limitMode)
-			{
-				case "1": // 以内
-					if (match)
-					{
-						return ChallengeHandle(context, out completedTask);
-					}
-					break;
+        var rule = CommonHelper.SystemSettings.GetOrAdd("ChallengeRule", "");
+        var regions = CommonHelper.SystemSettings.GetOrAdd("ChallengeRegions", "");
+        var limitMode = CommonHelper.SystemSettings.GetOrAdd("ChallengeRegionLimitMode", "");
+        if (rule == "Region")
+        {
+            var match = Regex.IsMatch(context.HttpContext.Request.Location(), regions, RegexOptions.IgnoreCase);
+            switch (limitMode)
+            {
+                case "1": // 以内
+                    if (match)
+                    {
+                        return ChallengeHandle(context);
+                    }
+                    break;
 
-				case "2": // 以外
-					if (!match)
-					{
-						return ChallengeHandle(context, out completedTask);
-					}
-					break;
-			}
-			completedTask = Task.CompletedTask;
-			return false;
-		}
+                case "2": // 以外
+                    if (!match)
+                    {
+                        return ChallengeHandle(context);
+                    }
+                    break;
+            }
+            return false;
+        }
 
-		return ChallengeHandle(context, out completedTask);
-	}
+        return ChallengeHandle(context);
+    }
 
-	private static bool ChallengeHandle(ActionExecutingContext context, out Task completedTask)
-	{
-		var mode = CommonHelper.SystemSettings.GetOrAdd(SessionKey.ChallengeMode, "");
-		if (mode == SessionKey.JSChallenge)
-		{
-			context.Result = new ViewResult
-			{
-				ViewName = "/Views/Shared/JSChallenge.cshtml"
-			};
-			completedTask = Task.CompletedTask;
-			return true;
-		}
+    private static bool ChallengeHandle(ActionExecutingContext context)
+    {
+        var mode = CommonHelper.SystemSettings.GetOrAdd(SessionKey.ChallengeMode, "");
+        if (mode == SessionKey.JSChallenge)
+        {
+            context.Result = new ViewResult
+            {
+                ViewName = "/Views/Shared/JSChallenge.cshtml"
+            };
+            return true;
+        }
 
-		if (mode == SessionKey.CaptchaChallenge)
-		{
-			context.Result = new ViewResult
-			{
-				ViewName = "/Views/Shared/CaptchaChallenge.cshtml"
-			};
-			completedTask = Task.CompletedTask;
-			return true;
-		}
+        if (mode == SessionKey.CaptchaChallenge)
+        {
+            context.Result = new ViewResult
+            {
+                ViewName = "/Views/Shared/CaptchaChallenge.cshtml"
+            };
+            return true;
+        }
 
-		if (mode == SessionKey.CloudflareTurnstileChallenge)
-		{
-			context.Result = new ViewResult
-			{
-				ViewName = "/Views/Shared/CloudflareTurnstileChallenge.cshtml"
-			};
-			completedTask = Task.CompletedTask;
-			return true;
-		}
+        if (mode == SessionKey.CloudflareTurnstileChallenge)
+        {
+            context.Result = new ViewResult
+            {
+                ViewName = "/Views/Shared/CloudflareTurnstileChallenge.cshtml"
+            };
+            return true;
+        }
 
-		completedTask = Task.CompletedTask;
-		return false;
-	}
+        return false;
+    }
 
-	private Task ThrottleLimit(string ip, HttpRequest request, ActionExecutionDelegate next)
-	{
-		var times = CacheManager.AddOrUpdate("Frequency:" + ip, 1, i => i + 1, 5);
-		CacheManager.Expire("Frequency:" + ip, ExpirationMode.Absolute, TimeSpan.FromSeconds(CommonHelper.SystemSettings.GetOrAdd("LimitIPFrequency", "60").ToInt32()));
-		var limit = CommonHelper.SystemSettings.GetOrAdd("LimitIPRequestTimes", "90").ToInt32();
-		if (times <= limit)
-		{
-			return next();
-		}
+    private async Task ThrottleLimit(string ip, HttpRequest request, ActionExecutionDelegate next)
+    {
+        var times = RedisClient.Incr("Frequency:" + ip);
+        await RedisClient.ExpireAsync("Frequency:" + ip, TimeSpan.FromSeconds(CommonHelper.SystemSettings.GetOrAdd("LimitIPFrequency", "60").ToInt32()));
+        var limit = CommonHelper.SystemSettings.GetOrAdd("LimitIPRequestTimes", "90").ToInt32();
+        if (times <= limit)
+        {
+            await next();
+        }
 
-		if (times > limit * 1.2)
-		{
-			CacheManager.Expire("Frequency:" + ip, TimeSpan.FromMinutes(CommonHelper.SystemSettings.GetOrAdd("BanIPTimespan", "10").ToInt32()));
-			AccessDeny(ip, request, "访问频次限制");
-			throw new TempDenyException("访问频次限制");
-		}
+        if (times > limit * 1.2)
+        {
+            await RedisClient.ExpireAsync("Frequency:" + ip, TimeSpan.FromMinutes(CommonHelper.SystemSettings.GetOrAdd("BanIPTimespan", "10").ToInt32()));
+            await AccessDeny(ip, request, "访问频次限制");
+            throw new TempDenyException("访问频次限制");
+        }
 
-		return next();
-	}
+        await next();
+    }
 
-	private async void AccessDeny(string ip, HttpRequest request, string remark)
-	{
-		var path = HttpUtility.UrlDecode(request.Path + request.QueryString, Encoding.UTF8);
-		RedisClient.IncrBy("interceptCount", 1);
-		RedisClient.LPush("intercept", new IpIntercepter
-		{
-			IP = ip,
-			RequestUrl = HttpUtility.UrlDecode(request.Scheme + "://" + request.Host + path),
-			Time = DateTime.Now,
-			Referer = request.Headers[HeaderNames.Referer],
-			UserAgent = request.Headers[HeaderNames.UserAgent],
-			Remark = remark,
-			Address = request.Location(),
-			HttpVersion = request.Protocol,
-			Headers = new
-			{
-				request.Protocol,
-				request.Headers
-			}.ToJsonString()
-		});
-		var limit = CommonHelper.SystemSettings.GetOrAdd("LimitIPInterceptTimes", "30").ToInt32();
-		var list = RedisClient.LRange<IpIntercepter>("intercept", 0, -1);
-		var key = "FirewallRepoter:" + FirewallRepoter.ReporterName + ":" + ip;
-		if (list.Count(x => x.IP == ip) >= limit && !MemoryCache.TryGetValue(key, out _))
-		{
-			LogManager.Info($"准备上报IP{ip}到{FirewallRepoter.ReporterName}");
-			await FirewallRepoter.ReportAsync(IPAddress.Parse(ip)).ContinueWith(_ =>
-			{
-				MemoryCache.Set(key, 1, TimeSpan.FromDays(1));
-				LogManager.Info($"访问频次限制,已上报IP{ip}至:" + FirewallRepoter.ReporterName);
-			});
-		}
-	}
+    private async Task AccessDeny(string ip, HttpRequest request, string remark)
+    {
+        var path = HttpUtility.UrlDecode(request.Path + request.QueryString, Encoding.UTF8);
+        await RedisClient.IncrByAsync("interceptCount", 1);
+        await RedisClient.LPushAsync("intercept", new IpIntercepter
+        {
+            IP = ip,
+            RequestUrl = HttpUtility.UrlDecode(request.Scheme + "://" + request.Host + path),
+            Time = DateTime.Now,
+            Referer = request.Headers[HeaderNames.Referer],
+            UserAgent = request.Headers[HeaderNames.UserAgent],
+            Remark = remark,
+            Address = request.Location(),
+            HttpVersion = request.Protocol,
+            Headers = new
+            {
+                request.Protocol,
+                request.Headers
+            }.ToJsonString()
+        });
+        var limit = CommonHelper.SystemSettings.GetOrAdd("LimitIPInterceptTimes", "30").ToInt32();
+        var list = RedisClient.LRange<IpIntercepter>("intercept", 0, -1);
+        var key = "FirewallRepoter:" + FirewallRepoter.ReporterName + ":" + ip;
+        if (list.Count(x => x.IP == ip) >= limit && !MemoryCache.TryGetValue(key, out _))
+        {
+            LogManager.Info($"准备上报IP{ip}到{FirewallRepoter.ReporterName}");
+            await FirewallRepoter.ReportAsync(IPAddress.Parse(ip)).ContinueWith(_ =>
+            {
+                MemoryCache.Set(key, 1, TimeSpan.FromDays(1));
+                LogManager.Info($"访问频次限制,已上报IP{ip}至:" + FirewallRepoter.ReporterName);
+            });
+        }
+    }
 }

+ 155 - 177
src/Masuit.MyBlogs.Core/Extensions/MiddlewareExtension.cs

@@ -1,9 +1,7 @@
 using AutoMapper;
 using AutoMapper.Extensions.ExpressionMapping;
-using CacheManager.Core;
 using Masuit.MyBlogs.Core.Configs;
 using Masuit.Tools.Mime;
-using Masuit.Tools.AspNetCore.ModelBinder;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.Filters;
 using Microsoft.AspNetCore.ResponseCompression;
@@ -15,192 +13,172 @@ using System.IO.Compression;
 using System.Reflection;
 using System.Text.Encodings.Web;
 using System.Text.Unicode;
-using ConfigurationBuilder = CacheManager.Core.ConfigurationBuilder;
 
 namespace Masuit.MyBlogs.Core.Extensions;
 
 public static class MiddlewareExtension
 {
-	/// <summary>
-	/// 缓存配置
-	/// </summary>
-	/// <param name="services"></param>
-	/// <returns></returns>
-	public static IServiceCollection AddCacheConfig(this IServiceCollection services)
-	{
-		var jss = new JsonSerializerSettings
-		{
-			NullValueHandling = NullValueHandling.Ignore,
-			ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
-			TypeNameHandling = TypeNameHandling.Auto
-		};
-		services.AddSingleton(typeof(ICacheManager<>), typeof(BaseCacheManager<>));
-		services.AddSingleton(new ConfigurationBuilder().WithRedisConfiguration("redis", AppConfig.Redis).WithJsonSerializer(jss, jss).WithMaxRetries(5).WithRetryTimeout(100).WithRedisCacheHandle("redis").WithExpiration(ExpirationMode.Absolute, TimeSpan.FromMinutes(5))
-			.Build());
-		return services;
-	}
+    /// <summary>
+    /// automapper
+    /// </summary>
+    /// <param name="services"></param>
+    /// <returns></returns>
+    public static IServiceCollection AddMapper(this IServiceCollection services)
+    {
+        var mc = new MapperConfiguration(cfg => cfg.AddExpressionMapping().AddProfile(new MappingProfile()));
+        services.AddAutoMapper(cfg => cfg.AddExpressionMapping().AddProfile(new MappingProfile()), Assembly.GetExecutingAssembly());
+        services.AddSingleton(mc);
+        return services;
+    }
 
-	/// <summary>
-	/// automapper
-	/// </summary>
-	/// <param name="services"></param>
-	/// <returns></returns>
-	public static IServiceCollection AddMapper(this IServiceCollection services)
-	{
-		var mc = new MapperConfiguration(cfg => cfg.AddExpressionMapping().AddProfile(new MappingProfile()));
-		services.AddAutoMapper(cfg => cfg.AddExpressionMapping().AddProfile(new MappingProfile()), Assembly.GetExecutingAssembly());
-		services.AddSingleton(mc);
-		return services;
-	}
+    /// <summary>
+    /// mvc
+    /// </summary>
+    /// <param name="services"></param>
+    /// <returns></returns>
+    public static IServiceCollection AddMyMvc(this IServiceCollection services)
+    {
+        services.AddMvc(options =>
+        {
+            options.ReturnHttpNotAcceptable = true;
+            options.Filters.Add<ExceptionFilter>();
+            options.Filters.Add<PerfCounterFilterAttribute>();
+        }).AddNewtonsoftJson(options =>
+        {
+            options.SerializerSettings.ContractResolver = new DefaultContractResolver();
+            options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Local;
+        }).AddControllersAsServices().AddViewComponentsAsServices().AddTagHelpersAsServices(); // MVC
+        services.Configure<WebEncoderOptions>(options =>
+        {
+            options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All);
+        }); //解决razor视图中中文被编码的问题
+        return services;
+    }
 
-	/// <summary>
-	/// mvc
-	/// </summary>
-	/// <param name="services"></param>
-	/// <returns></returns>
-	public static IServiceCollection AddMyMvc(this IServiceCollection services)
-	{
-		services.AddMvc(options =>
-		{
-			options.ReturnHttpNotAcceptable = true;
-			options.Filters.Add<ExceptionFilter>();
-			options.Filters.Add<PerfCounterFilterAttribute>();
-		}).AddNewtonsoftJson(options =>
-		{
-			options.SerializerSettings.ContractResolver = new DefaultContractResolver();
-			options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Local;
-		}).AddControllersAsServices().AddViewComponentsAsServices().AddTagHelpersAsServices(); // MVC
-		services.Configure<WebEncoderOptions>(options =>
-		{
-			options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All);
-		}); //解决razor视图中中文被编码的问题
-		return services;
-	}
+    /// <summary>
+    /// 输出缓存
+    /// </summary>
+    /// <param name="services"></param>
+    /// <returns></returns>
+    public static IServiceCollection AddResponseCache(this IServiceCollection services)
+    {
+        services.AddResponseCaching(); //注入响应缓存
+        services.Configure<BrotliCompressionProviderOptions>(options =>
+        {
+            options.Level = CompressionLevel.Fastest;
+        }).Configure<GzipCompressionProviderOptions>(options =>
+        {
+            options.Level = CompressionLevel.Optimal;
+        }).AddResponseCompression(options =>
+        {
+            options.EnableForHttps = true;
+            options.Providers.Add<BrotliCompressionProvider>();
+            options.Providers.Add<GzipCompressionProvider>();
+            options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
+            {
+                "text/html; charset=utf-8",
+                "application/xhtml+xml",
+                "application/atom+xml",
+                "image/svg+xml"
+            });
+        });
+        return services;
+    }
 
-	/// <summary>
-	/// 输出缓存
-	/// </summary>
-	/// <param name="services"></param>
-	/// <returns></returns>
-	public static IServiceCollection AddResponseCache(this IServiceCollection services)
-	{
-		services.AddResponseCaching(); //注入响应缓存
-		services.Configure<BrotliCompressionProviderOptions>(options =>
-		{
-			options.Level = CompressionLevel.Fastest;
-		}).Configure<GzipCompressionProviderOptions>(options =>
-		{
-			options.Level = CompressionLevel.Optimal;
-		}).AddResponseCompression(options =>
-		{
-			options.EnableForHttps = true;
-			options.Providers.Add<BrotliCompressionProvider>();
-			options.Providers.Add<GzipCompressionProvider>();
-			options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
-			{
-				"text/html; charset=utf-8",
-				"application/xhtml+xml",
-				"application/atom+xml",
-				"image/svg+xml"
-			});
-		});
-		return services;
-	}
+    /// <summary>
+    /// 添加静态资源打包
+    /// </summary>
+    /// <param name="app"></param>
+    /// <returns></returns>
+    public static IApplicationBuilder UseBundles(this IApplicationBuilder app)
+    {
+        app.UseBundling(bundles =>
+        {
+            bundles.AddCss("/main.css")
+                .Include("/fonts/icomoon.css")
+                .Include("/Content/jquery.paging.css")
+                .Include("/Content/common/reset.css")
+                .Include("/Content/common/loading.css")
+                .Include("/Content/common/style.css")
+                .Include("/Content/common/articlestyle.css")
+                .Include("/Content/common/leaderboard.css")
+                .Include("/Assets/breadcrumb/style.css")
+                .Include("/Assets/nav/css/style.css");
+            bundles.AddCss("/filemanager.css")
+                .Include("/Content/bootswatch.min.css")
+                .Include("/fonts/icomoon.css")
+                .Include("/ng-views/filemanager/css/animations.css")
+                .Include("/ng-views/filemanager/css/dialogs.css")
+                .Include("/ng-views/filemanager/css/main.css")
+                .Include("/Content/common/loading.min.css");
+            bundles.AddCss("/dashboard.css")
+                .Include("/fonts/icomoon.css")
+                .Include("/Assets/fileupload/filestyle.css")
+                .Include("/Content/common/loading.min.css")
+                .Include("/Content/checkbox.min.css")
+                .Include("/ng-views/css/app.css");
+            bundles.AddCss("/article.css")
+                .Include("/Assets/jquery.tocify/jquery.tocify.css")
+                .Include("/Assets/UEditor/third-party/SyntaxHighlighter/styles/shCore.css")
+                .Include("/Assets/highlight/css/highlight.css");
 
-	/// <summary>
-	/// 添加静态资源打包
-	/// </summary>
-	/// <param name="app"></param>
-	/// <returns></returns>
-	public static IApplicationBuilder UseBundles(this IApplicationBuilder app)
-	{
-		app.UseBundling(bundles =>
-		{
-			bundles.AddCss("/main.css")
-				.Include("/fonts/icomoon.css")
-				.Include("/Content/jquery.paging.css")
-				.Include("/Content/common/reset.css")
-				.Include("/Content/common/loading.css")
-				.Include("/Content/common/style.css")
-				.Include("/Content/common/articlestyle.css")
-				.Include("/Content/common/leaderboard.css")
-				.Include("/Assets/breadcrumb/style.css")
-				.Include("/Assets/nav/css/style.css");
-			bundles.AddCss("/filemanager.css")
-				.Include("/Content/bootswatch.min.css")
-				.Include("/fonts/icomoon.css")
-				.Include("/ng-views/filemanager/css/animations.css")
-				.Include("/ng-views/filemanager/css/dialogs.css")
-				.Include("/ng-views/filemanager/css/main.css")
-				.Include("/Content/common/loading.min.css");
-			bundles.AddCss("/dashboard.css")
-				.Include("/fonts/icomoon.css")
-				.Include("/Assets/fileupload/filestyle.css")
-				.Include("/Content/common/loading.min.css")
-				.Include("/Content/checkbox.min.css")
-				.Include("/ng-views/css/app.css");
-			bundles.AddCss("/article.css")
-				.Include("/Assets/jquery.tocify/jquery.tocify.css")
-				.Include("/Assets/UEditor/third-party/SyntaxHighlighter/styles/shCore.css")
-				.Include("/Assets/highlight/css/highlight.css");
-
-			bundles.AddJs("/main.js")
-				.Include("/Scripts/jquery.query.js")
-				.Include("/Scripts/jquery.paging.js")
-				.Include("/Scripts/ripplet.js")
-				.Include("/Scripts/global/functions.js")
-				.Include("/Scripts/global/scripts.js")
-				.Include("/Assets/newsbox/jquery.bootstrap.newsbox.js")
-				.Include("/Assets/tagcloud/js/tagcloud.js")
-				.Include("/Assets/scrolltop/js/scrolltop.js")
-				.Include("/Assets/nav/js/main.js");
-			bundles.AddJs("/filemanager.js")
-				.Include("/Scripts/ng-file-upload.min.js")
-				.Include("/ng-views/filemanager/js/app.js")
-				.Include("/ng-views/filemanager/js/directives/directives.js")
-				.Include("/ng-views/filemanager/js/filters/filters.js")
-				.Include("/ng-views/filemanager/js/providers/config.js")
-				.Include("/ng-views/filemanager/js/entities/chmod.js")
-				.Include("/ng-views/filemanager/js/entities/item.js")
-				.Include("/ng-views/filemanager/js/services/apihandler.js")
-				.Include("/ng-views/filemanager/js/services/apimiddleware.js")
-				.Include("/ng-views/filemanager/js/services/filenavigator.js")
-				.Include("/ng-views/filemanager/js/providers/translations.js")
-				.Include("/ng-views/filemanager/js/controllers/main.js")
-				.Include("/ng-views/filemanager/js/controllers/selector-controller.js");
-			bundles.AddJs("/article.js")
-				.Include("/Assets/UEditor/third-party/SyntaxHighlighter/scripts/shCore.js")
-				.Include("/Assets/UEditor/third-party/SyntaxHighlighter/scripts/bundle.min.js")
-				.Include("/Assets/jquery.tocify/jquery.tocify.js")
-				.Include("/Scripts/global/article.js");
-		});
-		return app;
-	}
+            bundles.AddJs("/main.js")
+                .Include("/Scripts/jquery.query.js")
+                .Include("/Scripts/jquery.paging.js")
+                .Include("/Scripts/ripplet.js")
+                .Include("/Scripts/global/functions.js")
+                .Include("/Scripts/global/scripts.js")
+                .Include("/Assets/newsbox/jquery.bootstrap.newsbox.js")
+                .Include("/Assets/tagcloud/js/tagcloud.js")
+                .Include("/Assets/scrolltop/js/scrolltop.js")
+                .Include("/Assets/nav/js/main.js");
+            bundles.AddJs("/filemanager.js")
+                .Include("/Scripts/ng-file-upload.min.js")
+                .Include("/ng-views/filemanager/js/app.js")
+                .Include("/ng-views/filemanager/js/directives/directives.js")
+                .Include("/ng-views/filemanager/js/filters/filters.js")
+                .Include("/ng-views/filemanager/js/providers/config.js")
+                .Include("/ng-views/filemanager/js/entities/chmod.js")
+                .Include("/ng-views/filemanager/js/entities/item.js")
+                .Include("/ng-views/filemanager/js/services/apihandler.js")
+                .Include("/ng-views/filemanager/js/services/apimiddleware.js")
+                .Include("/ng-views/filemanager/js/services/filenavigator.js")
+                .Include("/ng-views/filemanager/js/providers/translations.js")
+                .Include("/ng-views/filemanager/js/controllers/main.js")
+                .Include("/ng-views/filemanager/js/controllers/selector-controller.js");
+            bundles.AddJs("/article.js")
+                .Include("/Assets/UEditor/third-party/SyntaxHighlighter/scripts/shCore.js")
+                .Include("/Assets/UEditor/third-party/SyntaxHighlighter/scripts/bundle.min.js")
+                .Include("/Assets/jquery.tocify/jquery.tocify.js")
+                .Include("/Scripts/global/article.js");
+        });
+        return app;
+    }
 }
 
 public class ExceptionFilter : IExceptionFilter
 {
-	public void OnException(ExceptionContext context)
-	{
-		if (context.Exception is NotFoundException)
-		{
-			context.HttpContext.Response.StatusCode = 404;
-			string accept = context.HttpContext.Request.Headers[HeaderNames.Accept] + "";
-			context.Result = true switch
-			{
-				_ when accept.StartsWith("image") => new VirtualFileResult("/Assets/images/404/4044.jpg", ContentType.Jpeg),
-				_ when context.HttpContext.Request.HasJsonContentType() || context.HttpContext.Request.Method == HttpMethods.Post => new JsonResult(new
-				{
-					StatusCode = 404,
-					Success = false,
-					Message = "页面未找到!"
-				}),
-				_ => new ViewResult()
-				{
-					ViewName = "/Views/Error/Index.cshtml"
-				}
-			};
-			context.ExceptionHandled = true;
-		}
-	}
+    public void OnException(ExceptionContext context)
+    {
+        if (context.Exception is NotFoundException)
+        {
+            context.HttpContext.Response.StatusCode = 404;
+            string accept = context.HttpContext.Request.Headers[HeaderNames.Accept] + "";
+            context.Result = true switch
+            {
+                _ when accept.StartsWith("image") => new VirtualFileResult("/Assets/images/404/4044.jpg", ContentType.Jpeg),
+                _ when context.HttpContext.Request.HasJsonContentType() || context.HttpContext.Request.Method == HttpMethods.Post => new JsonResult(new
+                {
+                    StatusCode = 404,
+                    Success = false,
+                    Message = "页面未找到!"
+                }),
+                _ => new ViewResult()
+                {
+                    ViewName = "/Views/Error/Index.cshtml"
+                }
+            };
+            context.ExceptionHandled = true;
+        }
+    }
 }

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

@@ -1,17 +1,16 @@
-using CacheManager.Core;
-using Masuit.LuceneEFCore.SearchEngine.Interfaces;
+using Masuit.LuceneEFCore.SearchEngine.Interfaces;
 using Masuit.MyBlogs.Core.Common;
 using Masuit.MyBlogs.Core.Infrastructure.Repository.Interface;
 using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Memory;
 using System.Text.RegularExpressions;
 using EFCoreSecondLevelCacheInterceptor;
+using FreeRedis;
 
 namespace Masuit.MyBlogs.Core.Infrastructure.Services;
 
-public sealed partial class AdvertisementService : BaseService<Advertisement>, IAdvertisementService
+public sealed class AdvertisementService : BaseService<Advertisement>, IAdvertisementService
 {
-    public ICacheManager<List<AdvertisementDto>> CacheManager { get; set; }
+    public IRedisClient CacheManager { get; set; }
 
     public ICategoryRepository CategoryRepository { get; set; }
 
@@ -65,7 +64,7 @@ public sealed partial class AdvertisementService : BaseService<Advertisement>, I
     public List<AdvertisementDto> GetsByWeightedPriceExternal(int count, AdvertiseType type, IPLocation ipinfo, int? cid = null, string keywords = "")
     {
         var (location, _, _) = ipinfo;
-        return CacheManager.GetOrAdd($"Advertisement:{location.Crc32()}:{type}:{count}-{cid}-{keywords}", _ =>
+        return CacheManager.GetOrAdd($"Advertisement:{location.Crc32()}:{type}:{count}-{cid}-{keywords}", () =>
         {
             var atype = type.ToString("D");
             Expression<Func<Advertisement, bool>> where = a => a.Types.Contains(atype) && a.Status == Status.Available;
@@ -112,8 +111,8 @@ public sealed partial class AdvertisementService : BaseService<Advertisement>, I
     public List<AdvertisementDto> GetsByWeightedPriceMemory(int count, AdvertiseType type, IPLocation ipinfo, int? cid = null, string keywords = "")
     {
         var (location, _, _) = ipinfo;
-        var all = CacheManager.GetOrAdd("Advertisement:all", _ => GetQuery<AdvertisementDto>(a => a.Status == Status.Available).ToList());
-        return CacheManager.GetOrAdd($"Advertisement:{location.Crc32()}:{type}:{count}-{cid}-{keywords}", _ =>
+        var all = CacheManager.GetOrAdd("Advertisement:all", () => GetQuery<AdvertisementDto>(a => a.Status == Status.Available).ToList(), TimeSpan.FromHours(1));
+        return CacheManager.GetOrAdd($"Advertisement:{location.Crc32()}:{type}:{count}-{cid}-{keywords}", () =>
         {
             var atype = type.ToString("D");
             var catCount = CategoryRepository.Count(_ => true);
@@ -142,6 +141,6 @@ public sealed partial class AdvertisementService : BaseService<Advertisement>, I
             var ids = list.Select(a => a.Id).ToArray();
             GetQuery(a => ids.Contains(a.Id)).ExecuteUpdate(p => p.SetProperty(a => a.DisplayCount, a => a.DisplayCount + 1));
             return list;
-        });
+        }, TimeSpan.FromHours(1));
     }
 }

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

@@ -3,7 +3,6 @@ using AngleSharp.Dom;
 using AngleSharp.Html.Parser;
 using AutoMapper;
 using AutoMapper.QueryableExtensions;
-using CacheManager.Core;
 using Masuit.LuceneEFCore.SearchEngine;
 using Masuit.LuceneEFCore.SearchEngine.Interfaces;
 using Masuit.MyBlogs.Core.Infrastructure.Repository.Interface;
@@ -14,289 +13,290 @@ using PanGu.HighLight;
 using System.Reflection;
 using System.Text.RegularExpressions;
 using EFCoreSecondLevelCacheInterceptor;
+using FreeRedis;
+using Masuit.MyBlogs.Core.Common;
 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 IRedisClient _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, IRedisClient 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}";
+        return _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
+            };
+        });
+    }
 
-	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).Cacheable().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();
+    }
 }

+ 1 - 3
src/Masuit.MyBlogs.Core/Masuit.MyBlogs.Core.csproj

@@ -44,8 +44,6 @@
         <PackageReference Include="AutoMapper.Extensions.ExpressionMapping" Version="6.0.4" />
         <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
         <PackageReference Include="Ben.Demystifier" Version="0.4.1" />
-        <PackageReference Include="CacheManager.Serialization.Json" Version="1.2.0" />
-        <PackageReference Include="CacheManager.StackExchange.Redis" Version="1.2.0" />
         <PackageReference Include="CHTCHSConv" Version="1.0.0" />
         <PackageReference Include="CLRStats" Version="1.0.0" />
         <PackageReference Include="Dispose.Scope.AspNetCore" Version="0.0.3" />
@@ -71,7 +69,7 @@
         <PackageReference Include="MiniProfiler.EntityFrameworkCore" Version="4.3.8" />
         <PackageReference Include="PanGu.HighLight" Version="1.0.0" />
         <PackageReference Include="SixLabors.ImageSharp.Web" Version="3.0.1" />
-        <PackageReference Include="System.Linq.Dynamic.Core" Version="1.3.5" />
+        <PackageReference Include="System.Linq.Dynamic.Core" Version="1.3.6" />
         <PackageReference Include="TimeZoneConverter" Version="6.1.0" />
         <PackageReference Include="WilderMinds.RssSyndication" Version="1.7.0" />
     </ItemGroup>

+ 131 - 2
src/Masuit.MyBlogs.Core/Startup.cs

@@ -23,6 +23,7 @@ using SixLabors.ImageSharp.Web.DependencyInjection;
 using System.Text.RegularExpressions;
 using Masuit.Tools.AspNetCore.ModelBinder;
 using EFCoreSecondLevelCacheInterceptor;
+using CacheManager.Core;
 
 namespace Masuit.MyBlogs.Core;
 
@@ -70,7 +71,7 @@ public class Startup
     /// <returns></returns>
     public void ConfigureServices(IServiceCollection services)
     {
-        services.AddEFSecondLevelCache(options => options.UseCacheManagerCoreProvider(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(5)).DisableLogging(true).UseCacheKeyPrefix("EFCache:"));
+        services.AddEFSecondLevelCache(options => options.UseCustomCacheProvider<EFCoreCacheProvider>().DisableLogging(true).UseCacheKeyPrefix("EFCache:").UseDbCallsIfCachingProviderIsDown(TimeSpan.FromMinutes(1)));
         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();
@@ -79,7 +80,7 @@ public class Startup
             options.RedirectStatusCode = StatusCodes.Status301MovedPermanently;
         });
         services.AddSession().AddAntiforgery(); //注入Session
-        services.AddResponseCache().AddCacheConfig();
+        services.AddResponseCache();
         services.AddHangfireServer().AddHangfire((serviceProvider, configuration) =>
         {
             configuration.UseActivator(new HangfireActivator(serviceProvider));
@@ -172,3 +173,131 @@ public class Startup
         Console.WriteLine("网站启动完成");
     }
 }
+
+public class EFCoreCacheProvider : IEFCacheServiceProvider
+{
+    private readonly IRedisClient _redisClient;
+
+    private readonly ILogger<EFCacheManagerCoreProvider> _cacheManagerCoreProviderLogger;
+    private readonly IEFDebugLogger _logger;
+
+    public EFCoreCacheProvider(IRedisClient redisClient, ILogger<EFCacheManagerCoreProvider> cacheManagerCoreProviderLogger, IEFDebugLogger logger)
+    {
+        _redisClient = redisClient;
+        _cacheManagerCoreProviderLogger = cacheManagerCoreProviderLogger;
+        _logger = logger;
+    }
+
+    /// <summary>
+    ///     Adds a new item to the cache.
+    /// </summary>
+    /// <param name="cacheKey">key</param>
+    /// <param name="value">value</param>
+    /// <param name="cachePolicy">Defines the expiration mode of the cache item.</param>
+    public void InsertValue(EFCacheKey cacheKey, EFCachedData value, EFCachePolicy cachePolicy)
+    {
+        if (cacheKey is null)
+        {
+            throw new ArgumentNullException(nameof(cacheKey));
+        }
+
+        if (value == null)
+        {
+            value = new EFCachedData
+            {
+                IsNull = true
+            };
+        }
+
+        var keyHash = cacheKey.KeyHash;
+
+        foreach (var rootCacheKey in cacheKey.CacheDependencies)
+        {
+            _redisClient.SAdd(rootCacheKey, keyHash);
+        }
+
+        if (cachePolicy == null)
+        {
+            _redisClient.Set(keyHash, value);
+        }
+        else
+        {
+            _redisClient.AddOrUpdate(keyHash, value, value, cachePolicy.CacheTimeout, cachePolicy.CacheExpirationMode == CacheExpirationMode.Sliding);
+        }
+    }
+
+    /// <summary>
+    ///     Removes the cached entries added by this library.
+    /// </summary>
+    public void ClearAllCachedEntries()
+    {
+        _redisClient.Del("EFCore:*");
+    }
+
+    /// <summary>
+    ///     Gets a cached entry by key.
+    /// </summary>
+    /// <param name="cacheKey">key to find</param>
+    /// <returns>cached value</returns>
+    /// <param name="cachePolicy">Defines the expiration mode of the cache item.</param>
+    public EFCachedData? GetValue(EFCacheKey cacheKey, EFCachePolicy cachePolicy)
+    {
+        if (cacheKey is null)
+        {
+            throw new ArgumentNullException(nameof(cacheKey));
+        }
+
+        return _redisClient.Get<EFCachedData>(cacheKey.KeyHash);
+    }
+
+    /// <summary>
+    ///     Invalidates all of the cache entries which are dependent on any of the specified root keys.
+    /// </summary>
+    /// <param name="cacheKey">Stores information of the computed key of the input LINQ query.</param>
+    public void InvalidateCacheDependencies(EFCacheKey cacheKey)
+    {
+        if (cacheKey is null)
+        {
+            throw new ArgumentNullException(nameof(cacheKey));
+        }
+
+        foreach (var rootCacheKey in cacheKey.CacheDependencies)
+        {
+            if (string.IsNullOrWhiteSpace(rootCacheKey))
+            {
+                continue;
+            }
+
+            var cachedValue = _redisClient.Get<EFCachedData>(cacheKey.KeyHash);
+            var dependencyKeys = _redisClient.SMembers(rootCacheKey);
+            if (AreRootCacheKeysExpired(cachedValue, dependencyKeys))
+            {
+                if (_logger.IsLoggerEnabled)
+                {
+                    _cacheManagerCoreProviderLogger.LogDebug(CacheableEventId.QueryResultInvalidated, "Invalidated all of the cache entries due to early expiration of a root cache key[{RootCacheKey}].", rootCacheKey);
+                }
+
+                ClearAllCachedEntries();
+                return;
+            }
+
+            ClearDependencyValues(dependencyKeys);
+            _redisClient.Del(rootCacheKey);
+        }
+    }
+
+    private void ClearDependencyValues(string[] dependencyKeys)
+    {
+        if (dependencyKeys is null)
+        {
+            return;
+        }
+
+        foreach (var dependencyKey in dependencyKeys)
+        {
+            _redisClient.SRem(dependencyKey);
+        }
+    }
+
+    private static bool AreRootCacheKeysExpired(EFCachedData? cachedValue, string[] dependencyKeys) => cachedValue is not null && dependencyKeys is null;
+}

+ 10 - 17
src/Masuit.MyBlogs.Core/Views/Post/PostOnline.razor

@@ -1,8 +1,9 @@
-@using CacheManager.Core
-@using System.Threading
+@using System.Threading
+@using FreeRedis
 @using Masuit.MyBlogs.Core.Common
+
 @implements IAsyncDisposable
-@inject ICacheManager<HashSet<string>> CacheManager
+@inject IRedisClient CacheManager
 @inject IJSRuntime _js;
 
 <span class="text-red online" @onclick="ShowViewer">@online</span>人正在浏览本文
@@ -17,23 +18,19 @@
     [Parameter]
     public bool IsAdmin { get; set; }
 
-    int online;
+    long online;
     Timer _timer;
 
     protected override void OnInitialized()
     {
         try {
             var key = nameof(PostOnline) + ":" + Id;
-            online = CacheManager.AddOrUpdate(key, new HashSet<string>(), set =>
-            {
-                set.Add(IP);
-                return set;
-            }, 3).Count;
-            CacheManager.Expire(key, ExpirationMode.Sliding, TimeSpan.FromMinutes(5));
+            online = CacheManager.SAdd(key, IP);
+            CacheManager.Expire(key, TimeSpan.FromMinutes(5));
             _timer = new Timer(_ =>
             {
                 try {
-                    online = CacheManager.Get(key)?.Count ?? 0;
+                    online = CacheManager.SCard(key);
                     InvokeAsync(StateHasChanged);
                 }
                 catch{
@@ -51,7 +48,7 @@
         if (online > 0 && IsAdmin)
         {
             try {
-                _js.InvokeVoidAsync("showViewer", CacheManager.Get(nameof(PostOnline) + ":" + Id).Select(s => KeyValuePair.Create(s, s.GetIPLocation().ToString())));
+                _js.InvokeVoidAsync("showViewer", CacheManager.SMembers(nameof(PostOnline) + ":" + Id).Select(s => KeyValuePair.Create(s, s.GetIPLocation().ToString())));
             }
             catch {
                 // ignored
@@ -62,11 +59,7 @@
     public ValueTask DisposeAsync()
     {
         try {
-            online = CacheManager.AddOrUpdate(nameof(PostOnline) + ":" + Id, new HashSet<string>(), set =>
-            {
-                set.Remove(IP);
-                return set;
-            }).Count;
+            online = CacheManager.SRem(nameof(PostOnline) + ":" + Id, IP);
         }
         catch {
             // ignored

+ 3 - 3
src/Masuit.MyBlogs.Core/Views/Shared/_ArticleListItem.cshtml

@@ -1,12 +1,12 @@
 @using Masuit.MyBlogs.Core.Common
 @using Masuit.Tools.Html
-@using CacheManager.Core
+@using FreeRedis
 @using Masuit.MyBlogs.Core.Views.Post
 @model Masuit.MyBlogs.Core.Models.DTO.PostDto
-@inject ICacheManager<HashSet<string>> CacheManager
+@inject IRedisClient CacheManager
 @{
 	string[] colors = { "success", "info", "primary", "warning", "danger", "default", "primary" };
-	var online = CacheManager.Get(nameof(PostOnline) + ":" + Model.Id)?.Count ?? 0;
+	var online = await CacheManager.SCardAsync(nameof(PostOnline) + ":" + Model.Id);
 }
 
 <div class="ibox wow fadeIn">

+ 3 - 3
src/Masuit.MyBlogs.Core/Views/Shared/_ArticleListItem_Admin.cshtml

@@ -1,13 +1,13 @@
 @using Masuit.MyBlogs.Core.Common
 @using Masuit.Tools.Html
 @using Masuit.Tools.Systems
-@using CacheManager.Core
 @using Masuit.MyBlogs.Core.Views.Post
-@inject ICacheManager<HashSet<string>> CacheManager
+@using FreeRedis
+@inject IRedisClient CacheManager
 @model Masuit.MyBlogs.Core.Models.DTO.PostDto
 @{
 	string[] colors = { "success", "info", "primary", "warning", "danger", "default", "primary" };
-	var online = CacheManager.Get(nameof(PostOnline) + ":" + Model.Id)?.Count ?? 0;
+	var online = await CacheManager.SCardAsync(nameof(PostOnline) + ":" + Model.Id);
 }
 
 <div class="ibox wow fadeIn">

+ 8 - 15
src/Masuit.MyBlogs.Core/Views/Tools/Loan.razor

@@ -1,8 +1,9 @@
 @using Masuit.Tools.Models
 @using System.Globalization
-@using CacheManager.Core
+@using FreeRedis
+
 @implements IAsyncDisposable
-@inject ICacheManager<HashSet<string>> CacheManager
+@inject IRedisClient CacheManager
 <div class="container">
     <p>当前<span class="text-red online">@online</span>人正在使用本功能</p>
     <p class="text-red">温馨提示:支持多次提前还款和多次调整利率,同时支持提前还款时变更贷款方式和缩短年限,如有利率调整或提前还款计划,因银行计算受实时利率或提前还款违约金影响,本试算模型的计算结果和银行结果大约有1‰的误差,结果仅供参考,请以银行结果为准</p>
@@ -215,7 +216,7 @@
 
 @code {
 
-    int online;
+    long online;
     Timer _timer;
 
     [Parameter]
@@ -251,17 +252,13 @@
         try
         {
             var key = nameof(Loan);
-            online = CacheManager.AddOrUpdate(key, new HashSet<string>(), set =>
-            {
-                set.Add(IP);
-                return set;
-            }, 3).Count;
-            CacheManager.Expire(key, ExpirationMode.Sliding, TimeSpan.FromMinutes(5));
+            online = CacheManager.SAdd(key,IP);
+            CacheManager.Expire(key, TimeSpan.FromMinutes(5));
             _timer = new Timer(_ =>
             {
                 try
                 {
-                    online = CacheManager.Get(key)?.Count ?? 0;
+                    online = CacheManager.SCard(key);
                     InvokeAsync(StateHasChanged);
                 }
                 catch
@@ -280,11 +277,7 @@
     {
         try
         {
-            online = CacheManager.AddOrUpdate(nameof(Loan), new HashSet<string>(), set =>
-            {
-                set.Remove(IP);
-                return set;
-            }).Count;
+            online = CacheManager.SAdd(nameof(Loan), IP);
         }
         catch
         {