PostController.cs 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330
  1. using CacheManager.Core;
  2. using Hangfire;
  3. using Masuit.LuceneEFCore.SearchEngine;
  4. using Masuit.LuceneEFCore.SearchEngine.Interfaces;
  5. using Masuit.MyBlogs.Core.Common;
  6. using Masuit.MyBlogs.Core.Configs;
  7. using Masuit.MyBlogs.Core.Extensions;
  8. using Masuit.MyBlogs.Core.Extensions.Firewall;
  9. using Masuit.MyBlogs.Core.Extensions.Hangfire;
  10. using Masuit.Tools.AspNetCore.Mime;
  11. using Masuit.Tools.AspNetCore.ModelBinder;
  12. using Masuit.Tools.AspNetCore.ResumeFileResults.Extensions;
  13. using Masuit.Tools.Core.Validator;
  14. using Masuit.Tools.Excel;
  15. using Masuit.Tools.Html;
  16. using Masuit.Tools.Logging;
  17. using Masuit.Tools.Models;
  18. using Microsoft.AspNetCore.Http.Extensions;
  19. using Microsoft.AspNetCore.Mvc;
  20. using Microsoft.EntityFrameworkCore;
  21. using Microsoft.Net.Http.Headers;
  22. using System.ComponentModel.DataAnnotations;
  23. using System.Linq.Dynamic.Core;
  24. using System.Net;
  25. using System.Text;
  26. using System.Text.RegularExpressions;
  27. using Z.EntityFramework.Plus;
  28. using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode;
  29. namespace Masuit.MyBlogs.Core.Controllers;
  30. /// <summary>
  31. /// 文章管理
  32. /// </summary>
  33. public sealed class PostController : BaseController
  34. {
  35. public IPostService PostService { get; set; }
  36. public ICategoryService CategoryService { get; set; }
  37. public ISeminarService SeminarService { get; set; }
  38. public IPostHistoryVersionService PostHistoryVersionService { get; set; }
  39. public IWebHostEnvironment HostEnvironment { get; set; }
  40. public ISearchEngine<DataContext> SearchEngine { get; set; }
  41. public ImagebedClient ImagebedClient { get; set; }
  42. public IPostVisitRecordService PostVisitRecordService { get; set; }
  43. public ICommentService CommentService { get; set; }
  44. public IPostTagService PostTagService { get; set; }
  45. /// <summary>
  46. /// 文章详情页
  47. /// </summary>
  48. /// <returns></returns>
  49. [Route("{id:int}"), Route("{id:int}/comments/{cid:int}"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
  50. public async Task<ActionResult> Details(int id, string kw, int cid, string t)
  51. {
  52. var notRobot = !Request.IsRobot();
  53. if (string.IsNullOrEmpty(t) && notRobot)
  54. {
  55. return RedirectToAction("Details", cid > 0 ? new { id, kw, cid, t = SnowFlake.NewId } : new { id, kw, t = SnowFlake.NewId });
  56. }
  57. var post = await PostService.GetQuery(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)).Include(p => p.Seminar).AsNoTracking().FirstOrDefaultAsync() ?? throw new NotFoundException("文章未找到");
  58. CheckPermission(post);
  59. if (!string.IsNullOrEmpty(post.Redirect))
  60. {
  61. if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
  62. {
  63. BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
  64. HttpContext.Session.Set("post" + id, id.ToString());
  65. }
  66. return Redirect(post.Redirect);
  67. }
  68. post.Category = CategoryService[post.CategoryId];
  69. ViewBag.CommentsCount = CommentService.Count(c => c.PostId == id && c.ParentId == null && c.Status == Status.Published);
  70. ViewBag.HistoryCount = PostHistoryVersionService.Count(c => c.PostId == id);
  71. ViewBag.Keyword = post.Keyword + "," + post.Label;
  72. if (Request.Query.ContainsKey("share"))
  73. {
  74. ViewBag.Desc = await post.Content.GetSummary(200);
  75. }
  76. else
  77. {
  78. ViewBag.Desc = "若页面无法访问,可通过搜索引擎网页快照进行浏览。" + await post.Content.GetSummary(200);
  79. }
  80. var modifyDate = post.ModifyDate;
  81. 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);
  82. 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);
  83. ViewData[nameof(post.Author)] = post.Author;
  84. ViewData[nameof(post.PostDate)] = post.PostDate;
  85. ViewData[nameof(post.ModifyDate)] = post.ModifyDate;
  86. ViewData["cover"] = post.Content.MatchFirstImgSrc();
  87. if (!string.IsNullOrEmpty(kw))
  88. {
  89. await PostService.Highlight(post, kw);
  90. }
  91. var regex = SearchEngine.LuceneIndexSearcher.CutKeywords(string.IsNullOrWhiteSpace(post.Keyword + post.Label) ? post.Title : post.Keyword + post.Label).Select(Regex.Escape).Join("|");
  92. ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, regex);
  93. var related = PostService.GetQuery(PostBaseWhere().And(p => p.Id != id && Regex.IsMatch(p.Title + (p.Keyword ?? "") + (p.Label ?? ""), regex, RegexOptions.IgnoreCase)), p => p.AverageViewCount, false).Take(10).Select(p => new { p.Id, p.Title }).FromCache().ToDictionary(p => p.Id, p => p.Title);
  94. ViewBag.Related = related;
  95. post.ModifyDate = post.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  96. post.PostDate = post.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  97. post.Content = await ReplaceVariables(post.Content).Next(s => notRobot && post.DisableCopy ? s.InjectFingerprint() : Task.FromResult(s));
  98. post.ProtectContent = await ReplaceVariables(post.ProtectContent).Next(s => notRobot && post.DisableCopy ? s.InjectFingerprint() : Task.FromResult(s));
  99. if (CurrentUser.IsAdmin)
  100. {
  101. return View("Details_Admin", post);
  102. }
  103. if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
  104. {
  105. BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
  106. HttpContext.Session.Set("post" + id, id.ToString());
  107. }
  108. if (post.LimitMode == RegionLimitMode.OnlyForSearchEngine)
  109. {
  110. BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
  111. }
  112. return View(post);
  113. }
  114. /// <summary>
  115. /// 文章历史版本
  116. /// </summary>
  117. /// <param name="id"></param>
  118. /// <param name="page"></param>
  119. /// <param name="size"></param>
  120. /// <returns></returns>
  121. [Route("{id:int}/history"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "page", "size" }, VaryByHeader = "Cookie")]
  122. 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)
  123. {
  124. var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
  125. CheckPermission(post);
  126. ViewBag.Primary = post;
  127. var list = await PostHistoryVersionService.GetPagesAsync(page, size, v => v.PostId == id, v => v.ModifyDate, false);
  128. foreach (var item in list.Data)
  129. {
  130. item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  131. }
  132. ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, post.Keyword + "," + post.Label);
  133. return View(list);
  134. }
  135. /// <summary>
  136. /// 文章历史版本
  137. /// </summary>
  138. /// <param name="id"></param>
  139. /// <param name="hid"></param>
  140. /// <returns></returns>
  141. [Route("{id:int}/history/{hid:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "hid" }, VaryByHeader = "Cookie")]
  142. public async Task<ActionResult> HistoryVersion(int id, int hid)
  143. {
  144. var history = await PostHistoryVersionService.GetAsync(v => v.Id == hid && (v.Post.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
  145. CheckPermission(history.Post);
  146. history.Content = await ReplaceVariables(history.Content).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
  147. history.ProtectContent = await ReplaceVariables(history.ProtectContent).Next(s => CurrentUser.IsAdmin || Request.IsRobot() ? Task.FromResult(s) : s.InjectFingerprint());
  148. history.ModifyDate = history.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  149. var next = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate > history.ModifyDate, p => p.ModifyDate);
  150. var prev = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate < history.ModifyDate, p => p.ModifyDate, false);
  151. ViewBag.Next = next;
  152. ViewBag.Prev = prev;
  153. ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), history.CategoryId, history.Label);
  154. ViewData[nameof(history.Post.Author)] = history.Post.Author;
  155. ViewData[nameof(history.Post.PostDate)] = history.Post.PostDate;
  156. ViewData[nameof(history.ModifyDate)] = history.ModifyDate;
  157. ViewData["cover"] = history.Content.MatchFirstImgSrc();
  158. return CurrentUser.IsAdmin ? View("HistoryVersion_Admin", history) : View(history);
  159. }
  160. /// <summary>
  161. /// 版本对比
  162. /// </summary>
  163. /// <param name="id"></param>
  164. /// <param name="v1"></param>
  165. /// <param name="v2"></param>
  166. /// <returns></returns>
  167. [Route("{id:int}/history/{v1:int}-{v2:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "v1", "v2" }, VaryByHeader = "Cookie")]
  168. public async Task<ActionResult> CompareVersion(int id, int v1, int v2)
  169. {
  170. var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin));
  171. var main = post.Mapper<PostHistoryVersion>() ?? throw new NotFoundException("文章未找到");
  172. CheckPermission(post);
  173. var left = v1 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v1) ?? throw new NotFoundException("文章未找到");
  174. var right = v2 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v2) ?? throw new NotFoundException("文章未找到");
  175. main.Id = id;
  176. var diff = new HtmlDiff.HtmlDiff(right.Content, left.Content);
  177. var diffOutput = diff.Build();
  178. right.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());
  179. right.ModifyDate = right.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  180. left.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());
  181. left.ModifyDate = left.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  182. ViewBag.Ads = AdsService.GetsByWeightedPrice(2, AdvertiseType.InPage, Request.Location(), main.CategoryId, main.Label);
  183. ViewBag.DisableCopy = post.DisableCopy;
  184. return View(new[] { main, left, right });
  185. }
  186. /// <summary>
  187. /// 反对
  188. /// </summary>
  189. /// <param name="id"></param>
  190. /// <returns></returns>
  191. public async Task<ActionResult> VoteDown(int id)
  192. {
  193. if (HttpContext.Session.Get("post-vote" + id) != null)
  194. {
  195. return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
  196. }
  197. var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.VoteDownCount, m => m.VoteDownCount + 1)) > 0;
  198. if (b)
  199. {
  200. HttpContext.Session.Set("post-vote" + id, id.GetBytes());
  201. }
  202. return ResultData(null, b, b ? "投票成功!" : "投票失败!");
  203. }
  204. /// <summary>
  205. /// 支持
  206. /// </summary>
  207. /// <param name="id"></param>
  208. /// <returns></returns>
  209. public async Task<ActionResult> VoteUp(int id)
  210. {
  211. if (HttpContext.Session.Get("post-vote" + id) != null)
  212. {
  213. return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
  214. }
  215. var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.VoteUpCount, m => m.VoteUpCount + 1)) > 0;
  216. if (b)
  217. {
  218. HttpContext.Session.Set("post-vote" + id, id.GetBytes());
  219. }
  220. return ResultData(null, b, b ? "投票成功!" : "投票失败!");
  221. }
  222. /// <summary>
  223. /// 投稿页
  224. /// </summary>
  225. /// <returns></returns>
  226. public ActionResult Publish()
  227. {
  228. return View();
  229. }
  230. /// <summary>
  231. /// 发布投稿
  232. /// </summary>
  233. /// <param name="post"></param>
  234. /// <param name="code"></param>
  235. /// <param name="cancellationToken"></param>
  236. /// <returns></returns>
  237. [HttpPost, ValidateAntiForgeryToken]
  238. public async Task<ActionResult> Publish(PostCommand post, [Required(ErrorMessage = "验证码不能为空")] string code, CancellationToken cancellationToken)
  239. {
  240. if (RedisHelper.Get("code:" + post.Email) != code)
  241. {
  242. return ResultData(null, false, "验证码错误!");
  243. }
  244. if (PostService.Any(p => p.Status == Status.Forbidden && p.Email == post.Email))
  245. {
  246. return ResultData(null, false, "由于您曾经恶意投稿,该邮箱已经被标记为黑名单,无法进行投稿,如有疑问,请联系网站管理员进行处理。");
  247. }
  248. var match = Regex.Match(post.Title + post.Author + post.Content, CommonHelper.BanRegex);
  249. if (match.Success)
  250. {
  251. LogManager.Info($"提交内容:{post.Title}/{post.Author}/{post.Content},敏感词:{match.Value}");
  252. return ResultData(null, false, "您提交的内容包含敏感词,被禁止发表,请检查您的内容后尝试重新提交!");
  253. }
  254. if (!CategoryService.Any(c => c.Id == post.CategoryId))
  255. {
  256. return ResultData(null, message: "请选择一个分类");
  257. }
  258. post.Label = string.IsNullOrEmpty(post.Label?.Trim()) ? null : post.Label.Replace(",", ",");
  259. post.Status = Status.Pending;
  260. post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.HtmlSantinizerStandard().ClearImgAttributes(), cancellationToken);
  261. Post p = post.Mapper<Post>();
  262. p.IP = ClientIP;
  263. p.Modifier = p.Author;
  264. p.ModifierEmail = p.Email;
  265. p.DisableCopy = true;
  266. p.Rss = true;
  267. PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
  268. {
  269. Name = s,
  270. Count = PostService.Count(t => t.Label.Contains(s))
  271. }));
  272. p = PostService.AddEntitySaved(p);
  273. if (p == null)
  274. {
  275. return ResultData(null, false, "文章发表失败!");
  276. }
  277. RedisHelper.Expire("code:" + p.Email, 1);
  278. var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/publish.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
  279. .Set("link", Url.Action("Details", "Post", new { id = p.Id }, Request.Scheme))
  280. .Set("time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))
  281. .Set("title", p.Title).Render();
  282. BackgroundJob.Enqueue(() => CommonHelper.SendMail(CommonHelper.SystemSettings["Title"] + "有访客投稿:", content, CommonHelper.SystemSettings["ReceiveEmail"], ClientIP));
  283. return ResultData(p.Mapper<PostDto>(), message: "文章发表成功,待站长审核通过以后将显示到列表中!");
  284. }
  285. /// <summary>
  286. /// 获取标签
  287. /// </summary>
  288. /// <returns></returns>
  289. [ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
  290. public ActionResult GetTag()
  291. {
  292. return ResultData(PostService.GetTags().Select(x => x.Key).OrderBy(s => s));
  293. }
  294. /// <summary>
  295. /// 标签云
  296. /// </summary>
  297. /// <returns></returns>
  298. [Route("all"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
  299. public async Task<ActionResult> All()
  300. {
  301. ViewBag.tags = new Dictionary<string, int>(PostService.GetTags().Where(x => x.Value > 1).OrderBy(x => x.Key));
  302. 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
  303. ViewBag.seminars = await SeminarService.GetAll(c => c.Post.Count, false).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Title); //seminars
  304. return View();
  305. }
  306. /// <summary>
  307. /// 检查访问密码
  308. /// </summary>
  309. /// <param name="email"></param>
  310. /// <param name="token"></param>
  311. /// <returns></returns>
  312. [HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
  313. public ActionResult CheckViewToken(string email, string token)
  314. {
  315. if (string.IsNullOrEmpty(token))
  316. {
  317. return ResultData(null, false, "请输入访问密码!");
  318. }
  319. var s = RedisHelper.Get("token:" + email);
  320. if (token.Equals(s))
  321. {
  322. HttpContext.Session.Set("AccessViewToken", token);
  323. Response.Cookies.Append("Email", email, new CookieOptions
  324. {
  325. Expires = DateTime.Now.AddYears(1),
  326. SameSite = SameSiteMode.Lax
  327. });
  328. Response.Cookies.Append("PostAccessToken", email.MDString3(AppConfig.BaiduAK), new CookieOptions
  329. {
  330. Expires = DateTime.Now.AddYears(1),
  331. SameSite = SameSiteMode.Lax
  332. });
  333. return ResultData(null);
  334. }
  335. return ResultData(null, false, "访问密码不正确!");
  336. }
  337. /// <summary>
  338. /// 检查授权邮箱
  339. /// </summary>
  340. /// <param name="email"></param>
  341. /// <returns></returns>
  342. [HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
  343. public ActionResult GetViewToken(string email)
  344. {
  345. var validator = new IsEmailAttribute();
  346. if (!validator.IsValid(email))
  347. {
  348. return ResultData(null, false, validator.ErrorMessage);
  349. }
  350. if (RedisHelper.Exists("get:" + email))
  351. {
  352. RedisHelper.Expire("get:" + email, 120);
  353. return ResultData(null, false, "发送频率限制,请在2分钟后重新尝试发送邮件!请检查你的邮件,若未收到,请检查你的邮箱地址或邮件垃圾箱!");
  354. }
  355. if (!UserInfoService.Any(b => b.Email.Equals(email)))
  356. {
  357. return ResultData(null, false, "您目前没有权限访问这个链接,请联系站长开通访问权限!");
  358. }
  359. var token = SnowFlake.GetInstance().GetUniqueShortId(6);
  360. RedisHelper.Set("token:" + email, token, 86400);
  361. BackgroundJob.Enqueue(() => CommonHelper.SendMail(Request.Host + "博客访问验证码", $"{Request.Host}本次验证码是:<span style='color:red'>{token}</span>,有效期为24h,请按时使用!", email, ClientIP));
  362. RedisHelper.Set("get:" + email, token, 120);
  363. return ResultData(null);
  364. }
  365. /// <summary>
  366. /// 文章合并
  367. /// </summary>
  368. /// <param name="id"></param>
  369. /// <returns></returns>
  370. [HttpGet("{id}/merge")]
  371. public async Task<ActionResult> PushMerge(int id)
  372. {
  373. var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
  374. CheckPermission(post);
  375. return View(post);
  376. }
  377. /// <summary>
  378. /// 文章合并
  379. /// </summary>
  380. /// <param name="id"></param>
  381. /// <param name="mid"></param>
  382. /// <returns></returns>
  383. [HttpGet("{id}/merge/{mid}")]
  384. public async Task<ActionResult> RepushMerge(int id, int mid)
  385. {
  386. var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
  387. CheckPermission(post);
  388. var merge = post.PostMergeRequests.FirstOrDefault(p => p.Id == mid && p.MergeState != MergeStatus.Merged) ?? throw new NotFoundException("待合并文章未找到");
  389. return View(merge);
  390. }
  391. /// <summary>
  392. /// 文章合并
  393. /// </summary>
  394. /// <param name="messageService"></param>
  395. /// <param name="postMergeRequestService"></param>
  396. /// <param name="dto"></param>
  397. /// <returns></returns>
  398. [HttpPost("{id}/pushmerge")]
  399. public async Task<ActionResult> PushMerge([FromServices] IInternalMessageService messageService, [FromServices] IPostMergeRequestService postMergeRequestService, PostMergeRequestCommand dto)
  400. {
  401. if (RedisHelper.Get("code:" + dto.ModifierEmail) != dto.Code)
  402. {
  403. return ResultData(null, false, "验证码错误!");
  404. }
  405. var post = await PostService.GetAsync(p => p.Id == dto.PostId && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
  406. if (post.Title.Equals(dto.Title) && post.Content.HammingDistance(dto.Content) <= 1)
  407. {
  408. return ResultData(null, false, "内容未被修改或修改的内容过少(无意义修改)!");
  409. }
  410. #region 合并验证
  411. if (postMergeRequestService.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Block))
  412. {
  413. return ResultData(null, false, "由于您曾经多次恶意修改文章,已经被标记为黑名单,无法修改任何文章,如有疑问,请联系网站管理员进行处理。");
  414. }
  415. if (post.PostMergeRequests.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Pending))
  416. {
  417. return ResultData(null, false, "您已经提交过一次修改请求正在待处理,暂不能继续提交修改请求!");
  418. }
  419. #endregion 合并验证
  420. #region 直接合并
  421. if (post.Email.Equals(dto.ModifierEmail))
  422. {
  423. var history = post.Mapper<PostHistoryVersion>();
  424. Mapper.Map(dto, post);
  425. post.PostHistoryVersion.Add(history);
  426. post.ModifyDate = DateTime.Now;
  427. return await PostService.SaveChangesAsync() > 0 ? ResultData(null, true, "你是文章原作者,无需审核,文章已自动更新并在首页展示!") : ResultData(null, false, "操作失败!");
  428. }
  429. #endregion 直接合并
  430. var merge = post.PostMergeRequests.FirstOrDefault(r => r.Id == dto.Id && r.MergeState != MergeStatus.Merged);
  431. if (merge != null)
  432. {
  433. Mapper.Map(dto, merge);
  434. merge.SubmitTime = DateTime.Now;
  435. merge.MergeState = MergeStatus.Pending;
  436. }
  437. else
  438. {
  439. merge = Mapper.Map<PostMergeRequest>(dto);
  440. merge.SubmitTime = DateTime.Now;
  441. post.PostMergeRequests.Add(merge);
  442. }
  443. merge.IP = ClientIP;
  444. var b = await PostService.SaveChangesAsync() > 0;
  445. if (!b)
  446. {
  447. return ResultData(null, false, "操作失败!");
  448. }
  449. RedisHelper.Expire("code:" + dto.ModifierEmail, 1);
  450. await messageService.AddEntitySavedAsync(new InternalMessage()
  451. {
  452. Title = $"来自【{dto.Modifier}】对文章《{post.Title}》的修改请求",
  453. Content = dto.Title,
  454. Link = "#/merge/compare?id=" + merge.Id
  455. });
  456. var htmlDiff = new HtmlDiff.HtmlDiff(post.Content.RemoveHtmlTag(), dto.Content.RemoveHtmlTag());
  457. var diff = htmlDiff.Build();
  458. var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/merge-request.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
  459. .Set("title", post.Title)
  460. .Set("link", Url.Action("Index", "Dashboard", new { }, Request.Scheme) + "#/merge/compare?id=" + merge.Id)
  461. .Set("diff", diff)
  462. .Set("host", "//" + Request.Host)
  463. .Set("id", merge.Id.ToString())
  464. .Render();
  465. BackgroundJob.Enqueue(() => CommonHelper.SendMail("博客文章修改请求:", content, CommonHelper.SystemSettings["ReceiveEmail"], ClientIP));
  466. return ResultData(null, true, "您的修改请求已提交,已进入审核状态,感谢您的参与!");
  467. }
  468. #region 后端管理
  469. /// <summary>
  470. /// 固顶
  471. /// </summary>
  472. /// <param name="id"></param>
  473. /// <returns></returns>
  474. [MyAuthorize]
  475. public async Task<ActionResult> Fixtop(int id)
  476. {
  477. Post post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  478. post.IsFixedTop = !post.IsFixedTop;
  479. bool b = await PostService.SaveChangesAsync() > 0;
  480. return b ? ResultData(null, true, post.IsFixedTop ? "置顶成功!" : "取消置顶成功!") : ResultData(null, false, "操作失败!");
  481. }
  482. /// <summary>
  483. /// 审核
  484. /// </summary>
  485. /// <param name="id"></param>
  486. /// <returns></returns>
  487. [MyAuthorize]
  488. public async Task<ActionResult> Pass(int id)
  489. {
  490. var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  491. post.Status = Status.Published;
  492. post.ModifyDate = DateTime.Now;
  493. post.PostDate = DateTime.Now;
  494. var b = await PostService.SaveChangesAsync() > 0;
  495. if (!b)
  496. {
  497. return ResultData(null, false, "审核失败!");
  498. }
  499. (post.Keyword + "," + post.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
  500. SearchEngine.LuceneIndexer.Add(post);
  501. return ResultData(null, true, "审核通过!");
  502. }
  503. /// <summary>
  504. /// 下架文章
  505. /// </summary>
  506. /// <param name="id"></param>
  507. /// <returns></returns>
  508. [MyAuthorize]
  509. public async Task<ActionResult> Takedown(int id)
  510. {
  511. var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  512. post.Status = Status.Takedown;
  513. bool b = await PostService.SaveChangesAsync(true) > 0;
  514. SearchEngine.LuceneIndexer.Delete(post);
  515. return ResultData(null, b, b ? $"文章《{post.Title}》已下架!" : "下架失败!");
  516. }
  517. /// <summary>
  518. /// 还原版本
  519. /// </summary>
  520. /// <param name="id"></param>
  521. /// <returns></returns>
  522. [MyAuthorize]
  523. public async Task<ActionResult> Takeup(int id)
  524. {
  525. var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  526. post.Status = Status.Published;
  527. bool b = await PostService.SaveChangesAsync() > 0;
  528. SearchEngine.LuceneIndexer.Add(post);
  529. return ResultData(null, b, b ? "上架成功!" : "上架失败!");
  530. }
  531. /// <summary>
  532. /// 彻底删除文章
  533. /// </summary>
  534. /// <param name="id"></param>
  535. /// <returns></returns>
  536. [MyAuthorize]
  537. public ActionResult Truncate(int id)
  538. {
  539. bool b = PostService - id;
  540. return ResultData(null, b, b ? "删除成功!" : "删除失败!");
  541. }
  542. /// <summary>
  543. /// 获取文章
  544. /// </summary>
  545. /// <param name="id"></param>
  546. /// <returns></returns>
  547. [MyAuthorize]
  548. public ActionResult Get(int id)
  549. {
  550. var post = PostService.GetQuery(e => e.Id == id).Include(e => e.Seminar).FirstOrDefault() ?? throw new NotFoundException("文章未找到");
  551. var model = post.Mapper<PostDto>();
  552. model.Seminars = post.Seminar.Select(s => s.Id).Join(",");
  553. return ResultData(model);
  554. }
  555. /// <summary>
  556. /// 获取文章分页
  557. /// </summary>
  558. /// <returns></returns>
  559. [MyAuthorize]
  560. 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)
  561. {
  562. Expression<Func<Post, bool>> where = p => true;
  563. if (cid.HasValue)
  564. {
  565. where = where.And(p => p.CategoryId == cid.Value || p.Category.ParentId == cid.Value || p.Category.Parent.ParentId == cid.Value);
  566. }
  567. if (!string.IsNullOrEmpty(kw))
  568. {
  569. kw = Regex.Escape(kw);
  570. where = where.And(p => Regex.IsMatch(p.Title + p.Author + p.Email + p.Content, kw, RegexOptions.IgnoreCase));
  571. }
  572. var list = orderby switch
  573. {
  574. 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),
  575. _ => await PostService.GetQuery(where).OrderBy($"{nameof(Post.Status)} desc,{nameof(Post.IsFixedTop)} desc,{orderby.GetDisplay()} desc").ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig)
  576. };
  577. foreach (var item in list.Data)
  578. {
  579. item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  580. item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  581. item.Online = cacheManager.Get(nameof(PostOnline) + ":" + item.Id)?.Count ?? 0;
  582. }
  583. return Ok(list);
  584. }
  585. /// <summary>
  586. /// 获取未审核文章
  587. /// </summary>
  588. /// <param name="page"></param>
  589. /// <param name="size"></param>
  590. /// <param name="search"></param>
  591. /// <returns></returns>
  592. [MyAuthorize]
  593. 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 = "")
  594. {
  595. Expression<Func<Post, bool>> where = p => p.Status == Status.Pending;
  596. if (!string.IsNullOrEmpty(search))
  597. {
  598. where = where.And(p => p.Title.Contains(search) || p.Author.Contains(search) || p.Email.Contains(search) || p.Label.Contains(search));
  599. }
  600. var pages = await PostService.GetQuery(where).OrderByDescending(p => p.IsFixedTop).ThenByDescending(p => p.ModifyDate).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig);
  601. foreach (var item in pages.Data)
  602. {
  603. item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  604. item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
  605. }
  606. return Ok(pages);
  607. }
  608. /// <summary>
  609. /// 编辑
  610. /// </summary>
  611. /// <param name="post"></param>
  612. /// <param name="cancellationToken"></param>
  613. /// <returns></returns>
  614. [HttpPost, MyAuthorize]
  615. public async Task<ActionResult> Edit([FromBodyOrDefault] PostCommand post, CancellationToken cancellationToken = default)
  616. {
  617. post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
  618. if (!ValidatePost(post, out var resultData))
  619. {
  620. return resultData;
  621. }
  622. Post p = await PostService.GetByIdAsync(post.Id);
  623. if (post.Reserve && p.Status == Status.Published)
  624. {
  625. if (p.Content.HammingDistance(post.Content) > 0)
  626. {
  627. var history = p.Mapper<PostHistoryVersion>();
  628. history.PostId = p.Id;
  629. PostHistoryVersionService.AddEntity(history);
  630. }
  631. if (p.Title.HammingDistance(post.Title) > 10 && CommentService.Any(c => c.PostId == p.Id && c.ParentId == null))
  632. {
  633. CommentService.AddEntity(new Comment
  634. {
  635. Status = Status.Published,
  636. NickName = "系统自动评论",
  637. Email = p.Email,
  638. Content = $"<p style=\"color:red\">温馨提示:由于文章发生了重大更新,本条评论之前的所有评论仅作为原文《{p.Title}》的历史评论保留,不作为本文的最新评论参考,请知悉!了解更多信息,请查阅本文的历史修改记录。</p>",
  639. PostId = p.Id,
  640. CommentDate = DateTime.Now,
  641. IsMaster = true,
  642. IsAuthor = true,
  643. IP = "127.0.0.1",
  644. Location = "内网",
  645. GroupTag = SnowFlake.NewId,
  646. Path = SnowFlake.NewId,
  647. });
  648. }
  649. p.ModifyDate = DateTime.Now;
  650. var user = HttpContext.Session.Get<UserInfoDto>(SessionKey.UserInfo);
  651. post.Modifier = string.IsNullOrEmpty(post.Modifier) ? user.NickName : post.Modifier;
  652. post.ModifierEmail = string.IsNullOrEmpty(post.ModifierEmail) ? user.Email : post.ModifierEmail;
  653. }
  654. Mapper.Map(post, p);
  655. p.IP = ClientIP;
  656. p.Seminar.Clear();
  657. if (!string.IsNullOrEmpty(post.Seminars))
  658. {
  659. var tmp = post.Seminars.Split(',', StringSplitOptions.RemoveEmptyEntries).Distinct().Select(int.Parse).ToArray();
  660. var seminars = SeminarService.GetQuery(s => tmp.Contains(s.Id)).ToList();
  661. p.Seminar.AddRange(seminars);
  662. }
  663. (p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
  664. PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
  665. {
  666. Name = s,
  667. Count = PostService.Count(t => t.Label.Contains(s))
  668. }));
  669. bool b = await SearchEngine.SaveChangesAsync() > 0;
  670. if (!b)
  671. {
  672. return ResultData(null, false, "文章修改失败!");
  673. }
  674. if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
  675. {
  676. SearchEngine.LuceneIndexer.Delete(p);
  677. }
  678. return ResultData(p.Mapper<PostDto>(), message: "文章修改成功!");
  679. }
  680. /// <summary>
  681. /// 发布
  682. /// </summary>
  683. /// <param name="post"></param>
  684. /// <param name="timespan"></param>
  685. /// <param name="schedule"></param>
  686. /// <param name="cancellationToken"></param>
  687. /// <returns></returns>
  688. [MyAuthorize, HttpPost]
  689. public async Task<ActionResult> Write([FromBodyOrDefault] PostCommand post, [FromBodyOrDefault] DateTime? timespan, [FromBodyOrDefault] bool schedule = false, CancellationToken cancellationToken = default)
  690. {
  691. post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
  692. if (!ValidatePost(post, out var resultData))
  693. {
  694. return resultData;
  695. }
  696. post.Status = Status.Published;
  697. Post p = post.Mapper<Post>();
  698. p.Modifier = p.Author;
  699. p.ModifierEmail = p.Email;
  700. p.IP = ClientIP;
  701. p.Rss = p.LimitMode is null or RegionLimitMode.All;
  702. if (!string.IsNullOrEmpty(post.Seminars))
  703. {
  704. var tmp = post.Seminars.Split(',').Distinct().Select(int.Parse).ToArray();
  705. p.Seminar.AddRange(SeminarService[s => tmp.Contains(s.Id)]);
  706. }
  707. if (schedule)
  708. {
  709. if (!timespan.HasValue || timespan.Value <= DateTime.Now)
  710. {
  711. return ResultData(null, false, "如果要定时发布,请选择正确的一个将来时间点!");
  712. }
  713. p.Status = Status.Schedule;
  714. p.PostDate = timespan.Value.ToUniversalTime();
  715. p.ModifyDate = timespan.Value.ToUniversalTime();
  716. BackgroundJob.Enqueue<IHangfireBackJob>(job => job.PublishPost(p));
  717. return ResultData(p.Mapper<PostDto>(), message: $"文章于{timespan.Value:yyyy-MM-dd HH:mm:ss}将会自动发表!");
  718. }
  719. PostService.AddEntity(p);
  720. (p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
  721. PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
  722. {
  723. Name = s,
  724. Count = PostService.Count(t => t.Label.Contains(s))
  725. }));
  726. bool b = await SearchEngine.SaveChangesAsync() > 0;
  727. if (!b)
  728. {
  729. return ResultData(null, false, "文章发表失败!");
  730. }
  731. if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
  732. {
  733. SearchEngine.LuceneIndexer.Delete(p);
  734. }
  735. return ResultData(null, true, "文章发表成功!");
  736. }
  737. private bool ValidatePost(PostCommand post, out ActionResult resultData)
  738. {
  739. if (!CategoryService.Any(c => c.Id == post.CategoryId && c.Status == Status.Available))
  740. {
  741. resultData = ResultData(null, false, "请选择一个分类");
  742. return false;
  743. }
  744. switch (post.LimitMode)
  745. {
  746. case RegionLimitMode.AllowRegion:
  747. case RegionLimitMode.ForbidRegion:
  748. if (string.IsNullOrEmpty(post.Regions))
  749. {
  750. resultData = ResultData(null, false, "请输入限制的地区");
  751. return false;
  752. }
  753. post.Regions = post.Regions.Replace(",", "|").Replace(",", "|");
  754. break;
  755. case RegionLimitMode.AllowRegionExceptForbidRegion:
  756. case RegionLimitMode.ForbidRegionExceptAllowRegion:
  757. if (string.IsNullOrEmpty(post.ExceptRegions))
  758. {
  759. resultData = ResultData(null, false, "请输入排除的地区");
  760. return false;
  761. }
  762. post.ExceptRegions = post.ExceptRegions.Replace(",", "|").Replace(",", "|");
  763. goto case RegionLimitMode.AllowRegion;
  764. }
  765. if (string.IsNullOrEmpty(post.Label?.Trim()) || post.Label.Equals("null"))
  766. {
  767. post.Label = null;
  768. }
  769. else if (post.Label.Trim().Length > 50)
  770. {
  771. post.Label = post.Label.Replace(",", ",");
  772. post.Label = post.Label.Trim().Substring(0, 50);
  773. }
  774. else
  775. {
  776. post.Label = post.Label.Replace(",", ",");
  777. }
  778. if (string.IsNullOrEmpty(post.ProtectContent?.RemoveHtmlTag()) || post.ProtectContent.Equals("null"))
  779. {
  780. post.ProtectContent = null;
  781. }
  782. resultData = null;
  783. return true;
  784. }
  785. /// <summary>
  786. /// 添加专题
  787. /// </summary>
  788. /// <param name="id"></param>
  789. /// <param name="sid"></param>
  790. /// <returns></returns>
  791. [MyAuthorize]
  792. public async Task<ActionResult> AddSeminar(int id, int sid)
  793. {
  794. var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  795. Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
  796. post.Seminar.Add(seminar);
  797. bool b = await PostService.SaveChangesAsync() > 0;
  798. return ResultData(null, b, b ? $"已将文章【{post.Title}】添加到专题【{seminar.Title}】" : "添加失败");
  799. }
  800. /// <summary>
  801. /// 移除专题
  802. /// </summary>
  803. /// <param name="id"></param>
  804. /// <param name="sid"></param>
  805. /// <returns></returns>
  806. [MyAuthorize]
  807. public async Task<ActionResult> RemoveSeminar(int id, int sid)
  808. {
  809. var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  810. Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
  811. post.Seminar.Remove(seminar);
  812. bool b = await PostService.SaveChangesAsync() > 0;
  813. return ResultData(null, b, b ? $"已将文章【{post.Title}】从【{seminar.Title}】专题移除" : "添加失败");
  814. }
  815. /// <summary>
  816. /// 删除历史版本
  817. /// </summary>
  818. /// <param name="id"></param>
  819. /// <returns></returns>
  820. [MyAuthorize]
  821. public async Task<ActionResult> DeleteHistory(int id)
  822. {
  823. bool b = await PostHistoryVersionService.DeleteByIdAsync(id) > 0;
  824. return ResultData(null, b, b ? "历史版本文章删除成功!" : "历史版本文章删除失败!");
  825. }
  826. /// <summary>
  827. /// 还原版本
  828. /// </summary>
  829. /// <param name="id"></param>
  830. /// <returns></returns>
  831. [MyAuthorize]
  832. public async Task<ActionResult> Revert(int id)
  833. {
  834. var history = await PostHistoryVersionService.GetByIdAsync(id) ?? throw new NotFoundException("版本不存在");
  835. history.Post.Category = history.Category;
  836. history.Post.CategoryId = history.CategoryId;
  837. history.Post.Content = history.Content;
  838. history.Post.Title = history.Title;
  839. history.Post.Label = history.Label;
  840. history.Post.ModifyDate = history.ModifyDate;
  841. history.Post.Seminar.Clear();
  842. foreach (var s in history.Seminar)
  843. {
  844. history.Post.Seminar.Add(s);
  845. }
  846. bool b = await SearchEngine.SaveChangesAsync() > 0;
  847. await PostHistoryVersionService.DeleteByIdAsync(id);
  848. return ResultData(null, b, b ? "回滚成功" : "回滚失败");
  849. }
  850. /// <summary>
  851. /// 禁用或开启文章评论
  852. /// </summary>
  853. /// <param name="id">文章id</param>
  854. /// <returns></returns>
  855. [MyAuthorize]
  856. [HttpPost("post/{id}/DisableComment")]
  857. public async Task<ActionResult> DisableComment(int id)
  858. {
  859. var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  860. post.DisableComment = !post.DisableComment;
  861. return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableComment ? $"已禁用【{post.Title}】这篇文章的评论功能!" : $"已启用【{post.Title}】这篇文章的评论功能!");
  862. }
  863. /// <summary>
  864. /// 禁用或开启文章评论
  865. /// </summary>
  866. /// <param name="id">文章id</param>
  867. /// <returns></returns>
  868. [MyAuthorize]
  869. [HttpPost("post/{id}/DisableCopy")]
  870. public async Task<ActionResult> DisableCopy(int id)
  871. {
  872. var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  873. post.DisableCopy = !post.DisableCopy;
  874. return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableCopy ? $"已开启【{post.Title}】这篇文章的防复制功能!" : $"已关闭【{post.Title}】这篇文章的防复制功能!");
  875. }
  876. /// <summary>
  877. /// 禁用或开启NSFW
  878. /// </summary>
  879. /// <param name="id">文章id</param>
  880. /// <returns></returns>
  881. [MyAuthorize]
  882. [HttpPost("post/{id}/nsfw")]
  883. public async Task<ActionResult> Nsfw(int id)
  884. {
  885. var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
  886. post.IsNsfw = !post.IsNsfw;
  887. return ResultData(null, await PostService.SaveChangesAsync() > 0, post.IsNsfw ? $"已将文章【{post.Title}】标记为不安全内容!" : $"已将文章【{post.Title}】取消标记为不安全内容!");
  888. }
  889. /// <summary>
  890. /// 修改分类
  891. /// </summary>
  892. /// <param name="id"></param>
  893. /// <param name="cid"></param>
  894. /// <returns></returns>
  895. [HttpPost("post/{id}/ChangeCategory/{cid}")]
  896. public async Task<ActionResult> ChangeCategory(int id, int cid)
  897. {
  898. await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(p => p.CategoryId, cid));
  899. return Ok();
  900. }
  901. /// <summary>
  902. /// 修改专题
  903. /// </summary>
  904. /// <param name="id"></param>
  905. /// <param name="sids"></param>
  906. /// <returns></returns>
  907. [HttpPost("post/{id}/ChangeSeminar")]
  908. public async Task<ActionResult> ChangeSeminar(int id, string sids)
  909. {
  910. var post = PostService.GetQuery(e => e.Id == id).Include(e => e.Seminar).FirstOrDefault() ?? throw new NotFoundException("文章不存在");
  911. post.Seminar.Clear();
  912. if (!string.IsNullOrEmpty(sids))
  913. {
  914. var ids = sids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray();
  915. post.Seminar.AddRange(SeminarService[s => ids.Contains(s.Id)]);
  916. }
  917. await PostService.SaveChangesAsync();
  918. return Ok();
  919. }
  920. /// <summary>
  921. /// 刷新文章
  922. /// </summary>
  923. /// <param name="id">文章id</param>
  924. /// <param name="cancellationToken"></param>
  925. /// <returns></returns>
  926. [MyAuthorize]
  927. public async Task<ActionResult> Refresh(int id, CancellationToken cancellationToken = default)
  928. {
  929. await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.ModifyDate, DateTime.Now), cancellationToken: cancellationToken);
  930. return RedirectToAction("Details", new { id });
  931. }
  932. /// <summary>
  933. /// 标记为恶意修改
  934. /// </summary>
  935. /// <param name="id"></param>
  936. /// <param name="cancellationToken"></param>
  937. /// <returns></returns>
  938. [MyAuthorize]
  939. [HttpPost("post/block/{id}")]
  940. public async Task<ActionResult> Block(int id, CancellationToken cancellationToken = default)
  941. {
  942. var b = await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Status, Status.Forbidden), cancellationToken: cancellationToken) > 0;
  943. return b ? ResultData(null, true, "操作成功!") : ResultData(null, false, "操作失败!");
  944. }
  945. /// <summary>
  946. /// 切换允许rss订阅
  947. /// </summary>
  948. /// <param name="id"></param>
  949. /// <param name="cancellationToken"></param>
  950. /// <returns></returns>
  951. [MyAuthorize]
  952. [HttpPost("post/{id}/rss-switch")]
  953. public async Task<ActionResult> RssSwitch(int id, CancellationToken cancellationToken = default)
  954. {
  955. await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Rss, p => !p.Rss), cancellationToken: cancellationToken);
  956. return ResultData(null, message: "操作成功");
  957. }
  958. /// <summary>
  959. /// 切换锁定编辑
  960. /// </summary>
  961. /// <param name="id"></param>
  962. /// <param name="cancellationToken"></param>
  963. /// <returns></returns>
  964. [MyAuthorize]
  965. [HttpPost("post/{id}/locked-switch")]
  966. public async Task<ActionResult> LockedSwitch(int id, CancellationToken cancellationToken = default)
  967. {
  968. await PostService.GetQuery(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(m => m.Locked, p => !p.Locked), cancellationToken: cancellationToken);
  969. return ResultData(null, message: "操作成功");
  970. }
  971. /// <summary>
  972. /// 文章统计
  973. /// </summary>
  974. /// <returns></returns>
  975. [MyAuthorize]
  976. public async Task<IActionResult> Statistic(CancellationToken cancellationToken = default)
  977. {
  978. var keys = RedisHelper.Keys(nameof(PostOnline) + ":*");
  979. var sets = keys.Select(s => (Id: s.Split(':')[1].ToInt32(), Clients: RedisHelper.HGet<HashSet<string>>(s, "value")));
  980. var ids = sets.Where(t => t.Clients?.Count > 0).OrderByDescending(t => t.Clients.Count).Take(10).Select(t => t.Id).ToArray();
  981. var mostHots = await PostService.GetQuery<PostModelBase>(p => ids.Contains(p.Id)).ToListAsync().ContinueWith(t =>
  982. {
  983. foreach (var item in t.Result)
  984. {
  985. item.ViewCount = sets.FirstOrDefault(x => x.Id == item.Id).Clients.Count;
  986. }
  987. return t.Result.OrderByDescending(p => p.ViewCount);
  988. });
  989. var postsQuery = PostService.GetQuery(p => p.Status == Status.Published);
  990. var mostView = await postsQuery.OrderByDescending(p => p.TotalViewCount).Take(10).Select(p => new PostModelBase()
  991. {
  992. Id = p.Id,
  993. Title = p.Title,
  994. ViewCount = p.TotalViewCount
  995. }).ToListAsync(cancellationToken);
  996. var mostAverage = await postsQuery.OrderByDescending(p => p.AverageViewCount).Take(10).Select(p => new PostModelBase()
  997. {
  998. Id = p.Id,
  999. Title = p.Title,
  1000. ViewCount = (int)p.AverageViewCount
  1001. }).ToListAsync(cancellationToken);
  1002. var yesterday = DateTime.Now.AddDays(-1);
  1003. var trending = await postsQuery.Select(p => new PostModelBase()
  1004. {
  1005. Id = p.Id,
  1006. Title = p.Title,
  1007. ViewCount = p.PostVisitRecords.Count(t => t.Time >= yesterday)
  1008. }).OrderByDescending(p => p.ViewCount).Take(10).ToListAsync(cancellationToken);
  1009. var readCount = PostVisitRecordService.Count(e => e.Time >= yesterday);
  1010. return ResultData(new
  1011. {
  1012. mostHots,
  1013. mostView,
  1014. mostAverage,
  1015. trending,
  1016. readCount
  1017. });
  1018. }
  1019. /// <summary>
  1020. /// 文章访问记录
  1021. /// </summary>
  1022. /// <param name="id"></param>
  1023. /// <param name="page"></param>
  1024. /// <param name="size"></param>
  1025. /// <returns></returns>
  1026. [HttpGet("/{id}/records"), MyAuthorize]
  1027. [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
  1028. public async Task<IActionResult> PostVisitRecords(int id, int page = 1, int size = 15, string kw = "")
  1029. {
  1030. Expression<Func<PostVisitRecord, bool>> where = e => e.PostId == id;
  1031. if (!string.IsNullOrEmpty(kw))
  1032. {
  1033. kw = Regex.Escape(kw);
  1034. where = where.And(e => Regex.IsMatch(e.IP + e.Location + e.Referer + e.RequestUrl, kw, RegexOptions.IgnoreCase));
  1035. }
  1036. var pages = await PostVisitRecordService.GetPagesAsync<DateTime, PostVisitRecordViewModel>(page, size, where, e => e.Time, false);
  1037. return Ok(pages);
  1038. }
  1039. /// <summary>
  1040. /// 导出文章访问记录
  1041. /// </summary>
  1042. /// <param name="id"></param>
  1043. /// <returns></returns>
  1044. [HttpGet("/{id}/records-export"), MyAuthorize]
  1045. [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
  1046. public IActionResult ExportPostVisitRecords(int id)
  1047. {
  1048. var list = PostVisitRecordService.GetQuery<DateTime, PostVisitRecordViewModel>(e => e.PostId == id, e => e.Time, false).ToList();
  1049. using var ms = list.ToExcel();
  1050. var post = PostService[id];
  1051. return this.ResumeFile(ms.ToArray(), ContentType.Xlsx, post.Title + "访问记录.xlsx");
  1052. }
  1053. /// <summary>
  1054. /// 文章访问记录图表
  1055. /// </summary>
  1056. /// <returns></returns>
  1057. [HttpGet("/{id}/records-chart"), MyAuthorize]
  1058. [ProducesResponseType((int)HttpStatusCode.OK)]
  1059. public async Task<IActionResult> PostVisitRecordChart([FromServices] IPostVisitRecordStatsService statsService, int id, bool compare, uint period, CancellationToken cancellationToken)
  1060. {
  1061. if (compare)
  1062. {
  1063. var start1 = DateTime.Today.AddDays(-period);
  1064. var list1 = await statsService.GetQuery(e => e.PostId == id && e.Date >= start1).GroupBy(t => t.Date).Select(g => new
  1065. {
  1066. Date = g.Key,
  1067. Count = g.Sum(t => t.Count),
  1068. UV = g.Sum(t => t.UV)
  1069. }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
  1070. if (list1.Count == 0)
  1071. {
  1072. return Ok(Array.Empty<int>());
  1073. }
  1074. var start2 = start1.AddDays(-period - 1);
  1075. var list2 = await statsService.GetQuery(e => e.PostId == id && e.Date >= start2 && e.Date < start1).GroupBy(t => t.Date).Select(g => new
  1076. {
  1077. Date = g.Key,
  1078. Count = g.Sum(t => t.Count),
  1079. UV = g.Sum(t => t.UV)
  1080. }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
  1081. // 将数据填充成连续的数据
  1082. for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
  1083. {
  1084. if (list1.All(a => a.Date != i))
  1085. {
  1086. list1.Add(new { Date = i, Count = 0, UV = 0 });
  1087. }
  1088. }
  1089. for (var i = start2; i < start1; i = i.AddDays(1))
  1090. {
  1091. if (list2.All(a => a.Date != i))
  1092. {
  1093. list2.Add(new { Date = i, Count = 0, UV = 0 });
  1094. }
  1095. }
  1096. return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
  1097. }
  1098. var list = await statsService.GetQuery(e => e.PostId == id).GroupBy(t => t.Date).Select(g => new
  1099. {
  1100. Date = g.Key,
  1101. Count = g.Sum(t => t.Count),
  1102. UV = g.Sum(t => t.UV)
  1103. }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
  1104. var min = list.Min(a => a.Date);
  1105. var max = list.Max(a => a.Date);
  1106. for (var i = min; i < max; i = i.AddDays(1))
  1107. {
  1108. if (list.All(a => a.Date != i))
  1109. {
  1110. list.Add(new { Date = i, Count = 0, UV = 0 });
  1111. }
  1112. }
  1113. return Ok(new[] { list.OrderBy(a => a.Date) });
  1114. }
  1115. /// <summary>
  1116. /// 文章访问记录图表
  1117. /// </summary>
  1118. /// <returns></returns>
  1119. [HttpGet("/post/records-chart"), MyAuthorize]
  1120. [ProducesResponseType((int)HttpStatusCode.OK)]
  1121. public async Task<IActionResult> PostVisitRecordChart(bool compare, uint period, CancellationToken cancellationToken)
  1122. {
  1123. if (compare)
  1124. {
  1125. var start1 = DateTime.Today.AddDays(-period);
  1126. var list1 = await PostVisitRecordService.GetQuery(e => e.Time >= start1).Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
  1127. {
  1128. Date = g.Key,
  1129. Count = g.Count(),
  1130. UV = g.Select(e => e.IP).Distinct().Count()
  1131. }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
  1132. if (list1.Count == 0)
  1133. {
  1134. return Ok(Array.Empty<int>());
  1135. }
  1136. var start2 = start1.AddDays(-period - 1);
  1137. 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
  1138. {
  1139. Date = g.Key,
  1140. Count = g.Count(),
  1141. UV = g.Select(e => e.IP).Distinct().Count()
  1142. }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
  1143. // 将数据填充成连续的数据
  1144. for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
  1145. {
  1146. if (list1.All(a => a.Date != i))
  1147. {
  1148. list1.Add(new { Date = i, Count = 0, UV = 0 });
  1149. }
  1150. }
  1151. for (var i = start2; i < start1; i = i.AddDays(1))
  1152. {
  1153. if (list2.All(a => a.Date != i))
  1154. {
  1155. list2.Add(new { Date = i, Count = 0, UV = 0 });
  1156. }
  1157. }
  1158. return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
  1159. }
  1160. var list = await PostVisitRecordService.GetAll().Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
  1161. {
  1162. Date = g.Key,
  1163. Count = g.Count(),
  1164. UV = g.Select(e => e.IP).Distinct().Count()
  1165. }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
  1166. var min = list.Min(a => a.Date);
  1167. var max = list.Max(a => a.Date);
  1168. for (var i = min; i < max; i = i.AddDays(1))
  1169. {
  1170. if (list.All(a => a.Date != i))
  1171. {
  1172. list.Add(new { Date = i, Count = 0, UV = 0 });
  1173. }
  1174. }
  1175. return Ok(new[] { list.OrderBy(a => a.Date) });
  1176. }
  1177. /// <summary>
  1178. /// 文章访问记录分析
  1179. /// </summary>
  1180. /// <param name="id"></param>
  1181. /// <returns></returns>
  1182. [HttpGet("/{id}/insight"), MyAuthorize]
  1183. [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
  1184. public IActionResult PostVisitRecordInsight(int id)
  1185. {
  1186. return View(PostService[id]);
  1187. }
  1188. /// <summary>
  1189. /// 获取地区集
  1190. /// </summary>
  1191. /// <param name="name"></param>
  1192. /// <returns></returns>
  1193. [MyAuthorize]
  1194. [ProducesResponseType(typeof(List<string>), (int)HttpStatusCode.OK)]
  1195. public async Task<IActionResult> GetRegions(string name)
  1196. {
  1197. return ResultData(await PostService.GetAll().Select(p => EF.Property<string>(p, name)).Distinct().ToListAsync());
  1198. }
  1199. #endregion 后端管理
  1200. }