PostService.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. using System.Collections.Frozen;
  2. using AngleSharp;
  3. using AngleSharp.Dom;
  4. using AngleSharp.Html.Parser;
  5. using AutoMapper;
  6. using AutoMapper.QueryableExtensions;
  7. using Masuit.LuceneEFCore.SearchEngine;
  8. using Masuit.LuceneEFCore.SearchEngine.Interfaces;
  9. using Masuit.MyBlogs.Core.Infrastructure.Repository.Interface;
  10. using Masuit.Tools.Html;
  11. using PanGu;
  12. using PanGu.HighLight;
  13. using System.Reflection;
  14. using System.Text.RegularExpressions;
  15. using EFCoreSecondLevelCacheInterceptor;
  16. using FreeRedis;
  17. using Configuration = AngleSharp.Configuration;
  18. namespace Masuit.MyBlogs.Core.Infrastructure.Services;
  19. public sealed class PostService(IPostRepository repository, ISearchEngine<DataContext> searchEngine, ILuceneIndexSearcher searcher, IRedisClient cacheManager, ICategoryRepository categoryRepository, IMapper mapper, IPostTagsRepository postTagsRepository) : BaseService<Post>(repository, searchEngine, searcher), IPostService
  20. {
  21. /// <summary>
  22. /// 文章高亮关键词处理
  23. /// </summary>
  24. /// <param name="p"></param>
  25. /// <param name="keyword"></param>
  26. public async Task Highlight(Post p, string keyword)
  27. {
  28. try
  29. {
  30. var simpleHtmlFormatter = new SimpleHTMLFormatter("<span style='color:red;background-color:yellow;font-size: 1.1em;font-weight:700;'>", "</span>");
  31. var highlighter = new Highlighter(simpleHtmlFormatter, new Segment()) { FragmentSize = int.MaxValue };
  32. keyword = Regex.Replace(keyword, @"<|>|\(|\)|\{|\}|\[|\]", " ");
  33. var keywords = Searcher.CutKeywords(keyword);
  34. var context = BrowsingContext.New(Configuration.Default);
  35. var document = await context.OpenAsync(req => req.Content(p.Content));
  36. var elements = document.DocumentElement.GetElementsByTagName("p");
  37. foreach (var e in elements)
  38. {
  39. for (var index = 0; index < e.ChildNodes.Length; index++)
  40. {
  41. var node = e.ChildNodes[index];
  42. bool handled = false;
  43. foreach (var s in keywords)
  44. {
  45. string frag;
  46. if (!handled && node.TextContent.Contains(s, StringComparison.CurrentCultureIgnoreCase) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, node.TextContent)))
  47. {
  48. switch (node)
  49. {
  50. case IElement el:
  51. el.InnerHtml = frag;
  52. handled = true;
  53. break;
  54. case IText t:
  55. var parser = new HtmlParser();
  56. var parseDoc = parser.ParseDocument(frag).Body;
  57. e.ReplaceChild(parseDoc, t);
  58. handled = true;
  59. break;
  60. }
  61. }
  62. }
  63. }
  64. }
  65. p.Content = document.Body.InnerHtml;
  66. }
  67. catch
  68. {
  69. // ignored
  70. }
  71. }
  72. public SearchResult<PostDto> SearchPage(Expression<Func<Post, bool>> whereBase, int page, int size, string keyword)
  73. {
  74. var cacheKey = $"search:{keyword}:{page}:{size}";
  75. return cacheManager.GetOrAdd(cacheKey, () =>
  76. {
  77. var searchResult = SearchEngine.ScoredSearch<Post>(BuildSearchOptions(page, size, keyword));
  78. var entities = searchResult.Results.Where(s => s.Entity.Status == Status.Published).DistinctBy(s => s.Entity.Id).ToList();
  79. var ids = entities.Select(s => s.Entity.Id).ToArray();
  80. var dic = GetQuery(whereBase.And(p => ids.Contains(p.Id) && p.LimitMode != RegionLimitMode.OnlyForSearchEngine)).ProjectTo<PostDto>(mapper.ConfigurationProvider).ToFrozenDictionary(p => p.Id);
  81. var posts = entities.Where(s => dic.ContainsKey(s.Entity.Id)).Select(s => dic[s.Entity.Id]).ToList();
  82. var simpleHtmlFormatter = new SimpleHTMLFormatter("<span style='color:red;background-color:yellow;font-size: 1.1em;font-weight:700;'>", "</span>");
  83. var highlighter = new Highlighter(simpleHtmlFormatter, new Segment()) { FragmentSize = 200 };
  84. var keywords = Searcher.CutKeywords(keyword);
  85. HighlightSegment(posts, keywords, highlighter);
  86. SolvePostsCategory(posts);
  87. return new SearchResult<PostDto>()
  88. {
  89. Results = posts,
  90. Elapsed = searchResult.Elapsed,
  91. Total = searchResult.TotalHits
  92. };
  93. }, TimeSpan.FromMinutes(10));
  94. }
  95. public void SolvePostsCategory(IList<PostDto> posts)
  96. {
  97. var cids = posts.Select(p => p.CategoryId).Distinct().ToArray();
  98. var categories = categoryRepository.GetQuery(c => cids.Contains(c.Id)).Include(c => c.Parent).ToFrozenDictionary(c => c.Id);
  99. posts.ForEach(p => p.Category = mapper.Map<CategoryDto_P>(categories[p.CategoryId]));
  100. }
  101. /// <summary>
  102. /// 高亮截取处理
  103. /// </summary>
  104. /// <param name="posts"></param>
  105. /// <param name="keywords"></param>
  106. /// <param name="highlighter"></param>
  107. private static void HighlightSegment(IList<PostDto> posts, List<string> keywords, Highlighter highlighter)
  108. {
  109. foreach (var p in posts)
  110. {
  111. p.Content = p.Content.RemoveHtmlTag();
  112. foreach (var s in keywords)
  113. {
  114. string frag;
  115. if (p.Title.Contains(s) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, p.Title)))
  116. {
  117. p.Title = frag;
  118. break;
  119. }
  120. }
  121. bool handled = false;
  122. foreach (var s in keywords)
  123. {
  124. string frag;
  125. if (p.Content.Contains(s) && !string.IsNullOrEmpty(frag = highlighter.GetBestFragment(s, p.Content)))
  126. {
  127. p.Content = frag;
  128. handled = true;
  129. break;
  130. }
  131. }
  132. if (p.Content.Length > 200 && !handled)
  133. {
  134. p.Content = p.Content[..200];
  135. }
  136. }
  137. }
  138. private static SearchOptions BuildSearchOptions(int page, int size, string keyword)
  139. {
  140. keyword = Regex.Replace(keyword, @":\s+", ":");
  141. var fields = new List<string>();
  142. var newkeywords = new List<string>();
  143. foreach (var item in keyword.Split(' ', ' ').Where(s => s.Contains(new[] { ":", ":" })))
  144. {
  145. var part = item.Split(':', ':');
  146. var field = typeof(Post).GetProperty(part[0], BindingFlags.IgnoreCase)?.Name;
  147. if (!string.IsNullOrEmpty(field))
  148. {
  149. fields.Add(field);
  150. }
  151. newkeywords.Add(part[1]);
  152. }
  153. var searchOptions = fields.Any() ? new SearchOptions(newkeywords.Join(" "), page, size, fields.Join(",")) : new SearchOptions(keyword, page, size, typeof(Post));
  154. if (keyword.Contains(new[] { " ", ",", ";" }))
  155. {
  156. searchOptions.Score = 0.3f;
  157. }
  158. return searchOptions;
  159. }
  160. /// <summary>
  161. /// 文章所有tag
  162. /// </summary>
  163. /// <returns></returns>
  164. public FrozenDictionary<string, int> GetTags()
  165. {
  166. return postTagsRepository.GetAll(t => t.Count, false).Cacheable().ToFrozenDictionary(g => g.Name, g => g.Count);
  167. }
  168. /// <summary>
  169. /// 添加实体并保存
  170. /// </summary>
  171. /// <param name="t">需要添加的实体</param>
  172. /// <returns>添加成功</returns>
  173. public override Post AddEntitySaved(Post t)
  174. {
  175. t = base.AddEntity(t);
  176. SearchEngine.SaveChanges(t.Status == Status.Published);
  177. return t;
  178. }
  179. /// <summary>
  180. /// 添加实体并保存(异步)
  181. /// </summary>
  182. /// <param name="t">需要添加的实体</param>
  183. /// <returns>添加成功</returns>
  184. public override Task<int> AddEntitySavedAsync(Post t)
  185. {
  186. base.AddEntity(t);
  187. return SearchEngine.SaveChangesAsync(t.Status == Status.Published);
  188. }
  189. /// <summary>
  190. /// 根据ID删除实体并保存
  191. /// </summary>
  192. /// <param name="id">实体id</param>
  193. /// <returns>删除成功</returns>
  194. public override bool DeleteById(int id)
  195. {
  196. DeleteEntity(GetById(id));
  197. return SearchEngine.SaveChanges() > 0;
  198. }
  199. /// <summary>
  200. /// 根据ID删除实体并保存(异步)
  201. /// </summary>
  202. /// <param name="id">实体id</param>
  203. /// <returns>删除成功</returns>
  204. public override Task<int> DeleteByIdAsync(int id)
  205. {
  206. base.DeleteById(id);
  207. return SearchEngine.SaveChangesAsync();
  208. }
  209. /// <summary>
  210. /// 删除多个实体并保存(异步)
  211. /// </summary>
  212. /// <param name="list">实体集合</param>
  213. /// <returns>删除成功</returns>
  214. public override Task<int> DeleteEntitiesSavedAsync(IEnumerable<Post> list)
  215. {
  216. base.DeleteEntities(list);
  217. return SearchEngine.SaveChangesAsync();
  218. }
  219. /// <summary>
  220. /// 根据条件删除实体
  221. /// </summary>
  222. /// <param name="where">查询条件</param>
  223. /// <returns>删除成功</returns>
  224. public override int DeleteEntitySaved(Expression<Func<Post, bool>> where)
  225. {
  226. base.DeleteEntity(where);
  227. return SearchEngine.SaveChanges();
  228. }
  229. /// <summary>
  230. /// 删除实体并保存
  231. /// </summary>
  232. /// <param name="t">需要删除的实体</param>
  233. /// <returns>删除成功</returns>
  234. public override bool DeleteEntitySaved(Post t)
  235. {
  236. base.DeleteEntity(t);
  237. return SearchEngine.SaveChanges() > 0;
  238. }
  239. /// <summary>
  240. /// 根据条件删除实体
  241. /// </summary>
  242. /// <param name="where">查询条件</param>
  243. /// <returns>删除成功</returns>
  244. public override Task<int> DeleteEntitySavedAsync(Expression<Func<Post, bool>> where)
  245. {
  246. base.DeleteEntity(where);
  247. return SearchEngine.SaveChangesAsync();
  248. }
  249. /// <summary>
  250. /// 统一保存的方法
  251. /// </summary>
  252. /// <returns>受影响的行数</returns>
  253. public int SaveChanges(bool flushIndex)
  254. {
  255. return flushIndex ? SearchEngine.SaveChanges() : base.SaveChanges();
  256. }
  257. /// <summary>
  258. /// 统一保存数据
  259. /// </summary>
  260. /// <returns>受影响的行数</returns>
  261. public async Task<int> SaveChangesAsync(bool flushIndex)
  262. {
  263. return flushIndex ? await SearchEngine.SaveChangesAsync() : await base.SaveChangesAsync();
  264. }
  265. }