1
0

PostController.cs 56 KB

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