using Dpz.Core.Public.ViewModel.Request;
using Dpz.Core.Public.ViewModel.RequestEvent;
using Dpz.Core.Public.ViewModel.Response;
using Medallion.Threading;
namespace Dpz.Core.Service.RepositoryServiceImpl;
public class ArticleService(
IRepository<Article> repository,
IMediator mediator,
IMapper mapper,
ILogger<ArticleService> logger,
IDistributedLockProvider distributedLockProvider,
IFusionCache fusionCache,
IConfiguration configuration
) : AbstractCacheService(fusionCache), IArticleService
{
#pragma warning disable MALinq2001
protected override string CachePrefixKey => ServiceCacheKeyPrefixes.ArticleService;
protected override TimeSpan CacheDefaultExpiration => TimeSpan.FromHours(3);
private readonly IFusionCache _fusionCache = fusionCache;
/// <summary>
/// 生成文章浏览量缓存键,形如 {Prefix}:ViewCount:{Id}
/// </summary>
private string BuildViewCountCacheKey(string articleId) => $"{ViewCountTag}:{articleId}";
/// <summary>
/// 浏览量缓存统一标签,便于批量移除
/// </summary>
private string ViewCountTag => CachePrefixKey + ":ViewCount";
/// <summary>
/// 生成文章评论数缓存键,形如 {Prefix}:Comment:{Id}
/// </summary>
private string BuildCommentCountCacheKey(string articleId) => $"{CommentCountTag}:{articleId}";
/// <summary>
/// 评论数缓存统一标签
/// </summary>
private string CommentCountTag => CachePrefixKey + ":Comment";
public async Task<List<ArticleMiniResponse>> GetTopArticlesAsync(int days = -7, uint count = 8)
{
if (days > 0)
{
throw new ArgumentException("days > 0", nameof(days));
}
var cache = await GetOrSetCacheAsync<List<ArticleMiniResponse>>(
nameof(GetTopArticlesAsync),
async (_, cancellationToken) =>
{
if (
!string.Equals(
configuration["AgileConfig:env"],
"PROD",
StringComparison.OrdinalIgnoreCase
)
)
{
var noProdData = await repository
.MongodbQueryable.Sample(count)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(noProdData);
}
var date = DateTime.Now.AddDays(days);
var length = (int)count;
var source = await repository
.SearchFor(x => x.CreateTime > date)
.OrderByDescending(x => x.ViewCount)
.Take(length)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(source);
},
new { days, count }
);
await ApplyViewCountsAsync(cache);
return cache;
}
public async Task<List<ArticleMiniResponse>> GetRandomArticlesAsync(int sample = 8)
{
if (sample <= 0)
{
throw new ArgumentException("sample <= 0", nameof(sample));
}
if (sample > 30)
{
throw new ArgumentException("sample > 30", nameof(sample));
}
var cache = await GetOrSetCacheAsync<List<ArticleMiniResponse>>(
nameof(GetRandomArticlesAsync),
async (_, cancellationToken) =>
{
var source = await repository
.SearchFor(x => x.Tags.Contains("cnBeta") || x.Tags.Contains("ItHome"))
.Sample(sample)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(source);
},
new { sample }
);
await ApplyViewCountsAsync(cache);
return cache;
}
public async Task<List<ArticleMiniResponse>> GetPublishArticlesAsync()
{
var cache = await GetOrSetCacheAsync<List<ArticleMiniResponse>>(
nameof(GetPublishArticlesAsync),
async (_, cancellationToken) =>
{
var source = await repository
.SearchFor(x =>
(!x.Tags.Contains("cnBeta") && !x.Tags.Contains("ItHome"))
|| x.CommentCount > 0
)
.OrderByDescending(x => x.CreateTime)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(source);
}
);
await ApplyViewCountsAsync(cache);
return cache;
}
public async Task<IPagedList<ArticleMiniResponse>> GetPagesAsync(
int pageIndex = 1,
int pageSize = 20,
string? title = "",
string? account = "",
params string?[]? tags
)
{
var pagedList = await GetOrSetPagedListAsync<ArticleMiniResponse>(
nameof(GetPagesAsync),
async _ =>
{
var filterEmpty = Builders<Article>.Filter.Empty;
var filters = new List<FilterDefinition<Article>>();
if (!string.IsNullOrEmpty(account))
{
var filter = Builders<Article>.Filter.Eq(x => x.Author.Id, account);
filters.Add(filter);
}
var clearTags =
tags?.Where(x => !string.IsNullOrEmpty(x)).Select(x => x!).ToList() ?? [];
if (clearTags.Count > 0)
{
var filter = Builders<Article>.Filter.AnyIn(x => x.Tags, clearTags);
filters.Add(filter);
}
if (!string.IsNullOrEmpty(title))
{
var filter = Builders<Article>.Filter.Regex(
x => x.Title,
new BsonRegularExpression(title, "i")
);
filters.Add(filter);
}
var filterResult =
filters.Count > 0 ? Builders<Article>.Filter.And(filters) : filterEmpty;
var result = await repository
.SearchFor(filterResult)
.SortByDescending(x => x.CreateTime)
.ToPagedListAsync<Article, ArticleMiniResponse>(pageIndex, pageSize);
return result;
},
new
{
pageIndex,
pageSize,
title,
account,
tags,
}
);
await ApplyViewCountsAsync(pagedList);
return pagedList;
}
public async Task<List<string>> GetAllTagsAsync()
{
var cache = await GetOrSetCacheAsync<List<string>>(
nameof(GetAllTagsAsync),
async (_, cancellationToken) =>
{
return await repository
.MongodbQueryable.SelectMany(x => x.Tags)
.GroupBy(x => x)
.Select(x => x.Key)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
}
);
return cache;
}
public async Task<ArticleResponse?> GetArticleAsync(string? id)
{
var cache = await GetOrSetCacheAsync<ArticleResponse?>(
nameof(GetArticleAsync),
async (_, _) =>
{
var article = await repository.TryGetAsync(id);
return article == null ? null : mapper.Map<ArticleResponse>(article);
},
new { id }
);
await ApplyViewCountAsync(cache);
return cache;
}
public async Task ViewAsync(string id)
{
if (!ObjectId.TryParse(id, out var oid))
{
return;
}
// 使用 FindOneAndUpdate 原子更新并返回新值
var filter = Builders<Article>.Filter.Eq(x => x.Id, oid);
var update = Builders<Article>.Update.Inc(x => x.ViewCount, 1);
var options = new FindOneAndUpdateOptions<Article, Article>
{
ReturnDocument = ReturnDocument.After,
};
var updated = await repository.Collection.FindOneAndUpdateAsync(filter, update, options);
if (updated == null)
{
return;
}
var newCount = updated.ViewCount;
// 使用从数据库返回的值更新缓存,避免并发下的覆盖问题
await SetViewCountCacheAsync(id, newCount);
await UpdateCachedArticleViewCountAsync(id, newCount);
}
public async Task<List<ArticleMiniResponse>> GetLatestAsync(int range = 5)
{
if (range <= 0)
{
throw new ArgumentException("range <= 0", nameof(range));
}
if (range > 200)
{
throw new ArgumentException("range > 200", nameof(range));
}
var cache = await GetOrSetCacheAsync<List<ArticleMiniResponse>>(
nameof(GetLatestAsync),
async (_, cancellationToken) =>
{
var source = await repository
//.SearchFor(x => true)
.MongodbQueryable.OrderByDescending(x => x.CreateTime)
.Take(range)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(source);
},
new { range }
);
await ApplyViewCountsAsync(cache);
return cache;
}
public async Task<bool> IsExistsAsync(string title)
{
var blog = await repository.SearchFor(x => x.Title == title).FirstOrDefaultAsync();
return blog != null;
}
public async Task<IReadOnlyCollection<string>> NoExistsByFromAsync(
IReadOnlyCollection<string> feeds
)
{
var filter = Builders<Article>.Filter.In(x => x.From, feeds);
var exists = await repository.SearchFor(filter).Project(x => x.From).ToListAsync();
return feeds
.Except(exists.Select(x => x ?? "").Where(x => !string.IsNullOrWhiteSpace(x)))
.ToArray();
}
public async Task DeleteAsync(string id)
{
if (ObjectId.TryParse(id, out var oid))
{
var article = await repository.FindAsync(oid);
await mediator.Send(new RemoveImagesRequest { Images = article?.ImagesAddress ?? [] });
await repository.DeleteAsync(x => x.Id == oid);
await ClearCacheAsync();
}
}
public async Task DeleteOldCnBetaAsync(int month, int limit)
{
if (month <= 0)
{
throw new ArgumentException("month no can't <= 0", nameof(month));
}
var date = DateTime.Now.AddMonths(-month);
var list = await repository
.SearchFor(x =>
(x.Tags.Contains("cnBeta") || x.Tags.Contains("ItHome"))
&& x.CreateTime < date
&& x.CommentCount == 0
)
.OrderBy(x => x.CreateTime)
.Take(limit)
.ToListAsync();
if (!list.Any())
{
return;
}
await mediator.Send(
new RemoveImagesRequest { Images = list.SelectMany(x => x.ImagesAddress).ToList() }
);
var ids = list.Select(x => x.Id);
await repository.DeleteAsync(x => ids.Contains(x.Id));
logger.LogInformation("旧数据删除完毕,共删除{Count}篇文章", list.Count);
}
public async Task<ArticleResponse> CreateArticleAsync(CreateArticleRequest article, VmUserInfo creator)
{
var author = mapper.Map<UserInfo>(creator);
var htmlContent = article.Markdown.MarkdownToHtml(false);
var htmlParse = new HtmlParser();
var document = await htmlParse.ParseDocumentAsync(htmlContent);
var imageElements = document.GetElementsByTagName("img");
var entity = new Article
{
Author = author,
Title = article.Title,
Tags = article.Tags,
Markdown = article.Markdown,
CommentCount = 0,
CreateTime = article.PublishTime ?? DateTime.Now,
From = article.From,
ImagesAddress = imageElements.GetElementsImageUrls(),
Introduction = article.Introduction,
MainImage = imageElements.FirstOrDefault()?.GetAttribute("src"),
ViewCount = 0,
LastUpdateTime = DateTime.Now,
Categories = article.Categories,
AdWeight = article.AdWeight,
};
await using (
await distributedLockProvider.AcquireLockAsync($"Article.Create.Lock:{entity.Title}")
)
{
if (await IsExistsAsync(entity.Title))
{
throw new ExistsException($"《{entity.Title}》已存在");
}
await repository.InsertAsync(entity);
}
return mapper.Map<ArticleResponse>(entity);
}
public async Task EditArticleAsync(EditArticleRequest article, VmUserInfo editor)
{
if (!ObjectId.TryParse(article.Id, out var id))
{
return;
}
var entity = await repository.FindAsync(id);
if (entity == null)
{
return;
}
var author = mapper.Map<UserInfo>(editor);
var request = new EditMarkdownRequest
{
Markdown = article.Markdown,
OriginalMarkdown = entity.Markdown,
};
var images = await mediator.Send(request);
var update = Builders<Article>
.Update.Set(x => x.Title, article.Title)
.Set(x => x.Markdown, article.Markdown)
.Set(x => x.Tags, article.Tags)
.Set(x => x.Introduction, article.Introduction)
.Set(x => x.MainImage, images.FirstOrDefault())
.Set(x => x.ImagesAddress, images)
.Set(x => x.Author, author)
.Set(x => x.LastUpdateTime, DateTime.Now);
await repository.UpdateAsync(x => x.Id == id, update);
//await ClearCacheAsync();
var cacheKey = BuildCacheKey(nameof(GetArticleAsync), new { id = article.Id });
await _fusionCache.RemoveAsync(cacheKey);
}
public async Task<int> GetTotalCountAsync()
{
checked
{
return (int)
await repository.Collection.CountDocumentsAsync(FilterDefinition<Article>.Empty);
}
}
public async Task<int> GetTodayCountAsync()
{
checked
{
var filter = Builders<Article>.Filter.Gte(x => x.CreateTime, DateTime.Now.Date);
return (int)await repository.Collection.CountDocumentsAsync(filter);
}
}
public async ValueTask ClearCacheAsync()
{
var removeTags = new[]
{
CachePrefixKey + nameof(GetTopArticlesAsync),
CachePrefixKey + nameof(GetPagesAsync),
CachePrefixKey + nameof(GetAllTagsAsync),
CachePrefixKey + nameof(GetLatestAsync),
CachePrefixKey + nameof(GetRandomArticlesAsync),
CachePrefixKey + nameof(GetPublishArticlesAsync),
ViewCountTag,
CommentCountTag,
};
await _fusionCache.RemoveByTagAsync(removeTags);
}
/// <summary>
/// 将缓存中的最新浏览量、评论数回填到文章列表中
/// </summary>
private async Task ApplyViewCountsAsync(IEnumerable<ArticleMiniResponse>? articles)
{
if (articles == null)
{
return;
}
foreach (var article in articles)
{
if (string.IsNullOrWhiteSpace(article.Id))
{
continue;
}
article.ViewCount = await EnsureViewCountCacheAsync(article.Id, article.ViewCount);
article.CommentCount = await EnsureCommentCountCacheAsync(
article.Id,
article.CommentCount
);
}
}
/// <summary>
/// 将缓存中的最新浏览量、评论数回填到文章详情中
/// </summary>
private async Task ApplyViewCountAsync(ArticleResponse? article)
{
if (article == null || string.IsNullOrWhiteSpace(article.Id))
{
return;
}
article.ViewCount = await EnsureViewCountCacheAsync(article.Id, article.ViewCount);
article.CommentCount = await EnsureCommentCountCacheAsync(article.Id, article.CommentCount);
}
/// <summary>
/// 确保浏览量缓存存在,缺失时使用传入值初始化
/// </summary>
private async Task<int> EnsureViewCountCacheAsync(string articleId, int fallbackValue)
{
var cache = await _fusionCache.TryGetAsync<int>(BuildViewCountCacheKey(articleId));
if (cache.HasValue)
{
return cache.Value;
}
await SetViewCountCacheAsync(articleId, fallbackValue);
return fallbackValue;
}
/// <summary>
/// 写入文章当前浏览量,并带上浏览量相关的缓存标签
/// </summary>
private ValueTask SetViewCountCacheAsync(string articleId, int viewCount)
{
return _fusionCache.SetAsync(
BuildViewCountCacheKey(articleId),
viewCount,
options => options.SetDuration(CacheDefaultExpiration),
[CachePrefixKey, ViewCountTag]
);
}
/// <summary>
/// 确保评论数缓存存在,缺失时使用传入值初始化
/// </summary>
private async Task<int> EnsureCommentCountCacheAsync(string articleId, int fallbackValue)
{
var cache = await _fusionCache.TryGetAsync<int>(BuildCommentCountCacheKey(articleId));
if (cache.HasValue)
{
return cache.Value;
}
await SetCommentCountCacheAsync(articleId, fallbackValue);
return fallbackValue;
}
/// <summary>
/// 写入文章当前评论数,并带上评论数相关标签
/// </summary>
private ValueTask SetCommentCountCacheAsync(string articleId, int commentCount)
{
return _fusionCache.SetAsync(
BuildCommentCountCacheKey(articleId),
commentCount,
options => options.SetDuration(CacheDefaultExpiration),
[CachePrefixKey, CommentCountTag]
);
}
/// <summary>
/// 更新已缓存的文章详情对象里的浏览量,避免列表与详情不一致
/// </summary>
private async Task UpdateCachedArticleViewCountAsync(string articleId, int viewCount)
{
var cachedArticle = await TryGetFromCacheAsync<ArticleResponse?>(
nameof(GetArticleAsync),
new { id = articleId }
);
if (cachedArticle.HasValue && cachedArticle.Value != null)
{
cachedArticle.Value.ViewCount = viewCount;
await SetCacheAsync(
nameof(GetArticleAsync),
cachedArticle.Value,
new { id = articleId }
);
}
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
ArticleService 代码分析
这个 ArticleService 类是一个文章服务,负责处理与文章相关的业务逻辑,包括文章的创建、查询、更新、删除等操作。以下是其主要功能:
核心功能
文章查询功能:
GetTopArticlesAsync: 获取指定天数内浏览量最高的文章GetRandomArticlesAsync: 随机获取文章GetPublishArticlesAsync: 获取发布的文章(排除特定来源如cnBeta、ItHome)GetPagesAsync: 分页获取文章,支持按标题、作者、标签筛选GetLatestAsync: 获取最新发布的文章GetArticleAsync: 根据ID获取单篇文章详情
统计功能:
GetTotalCountAsync: 获取文章总数GetTodayCountAsync: 获取今天发布的文章数GetAllTagsAsync: 获取所有标签列表
文章管理功能:
CreateArticleAsync: 创建新文章EditArticleAsync: 编辑文章DeleteAsync: 删除文章DeleteOldCnBetaAsync: 删除旧的cnBeta/ItHome来源文章
辅助功能:
ViewAsync: 增加文章浏览量IsExistsAsync: 检查文章是否存在NoExistsByFromAsync: 检查哪些来源的文章不存在
技术特点
缓存机制:
- 使用FusionCache进行多级缓存
- 缓存键有统一前缀和过期时间配置
- 特别处理了浏览量和评论数的缓存
- 提供
ClearCacheAsync方法清除所有相关缓存
并发控制:
- 使用
Medallion.Threading的分布式锁防止创建文章时的并发问题
- 使用
数据一致性:
- 浏览量更新使用原子操作确保准确性
- 缓存与数据库数据同步机制
其他技术:
- 使用AutoMapper进行对象映射
- 使用MediatR进行事件处理
- MongoDB作为数据存储
业务逻辑
- 对cnBeta和ItHome来源的文章有特殊处理(如随机获取、定期清理)
- 文章浏览量统计和缓存
- 文章内容中的图片处理(提取、删除等)
- 支持Markdown格式的内容
这个服务类设计良好,考虑了性能(缓存)、并发安全(锁)、数据一致性(原子操作)等方面,是一个典型的内容管理系统核心服务实现。
评论加载中...