PostController.cs 54 KB

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