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 类是一个文章服务,负责处理与文章相关的业务逻辑,包括文章的创建、查询、更新、删除等操作。以下是其主要功能:

核心功能

  1. 文章查询功能

    • GetTopArticlesAsync: 获取指定天数内浏览量最高的文章
    • GetRandomArticlesAsync: 随机获取文章
    • GetPublishArticlesAsync: 获取发布的文章(排除特定来源如cnBeta、ItHome)
    • GetPagesAsync: 分页获取文章,支持按标题、作者、标签筛选
    • GetLatestAsync: 获取最新发布的文章
    • GetArticleAsync: 根据ID获取单篇文章详情
  2. 统计功能

    • GetTotalCountAsync: 获取文章总数
    • GetTodayCountAsync: 获取今天发布的文章数
    • GetAllTagsAsync: 获取所有标签列表
  3. 文章管理功能

    • CreateArticleAsync: 创建新文章
    • EditArticleAsync: 编辑文章
    • DeleteAsync: 删除文章
    • DeleteOldCnBetaAsync: 删除旧的cnBeta/ItHome来源文章
  4. 辅助功能

    • ViewAsync: 增加文章浏览量
    • IsExistsAsync: 检查文章是否存在
    • NoExistsByFromAsync: 检查哪些来源的文章不存在

技术特点

  1. 缓存机制

    • 使用FusionCache进行多级缓存
    • 缓存键有统一前缀和过期时间配置
    • 特别处理了浏览量和评论数的缓存
    • 提供ClearCacheAsync方法清除所有相关缓存
  2. 并发控制

    • 使用Medallion.Threading的分布式锁防止创建文章时的并发问题
  3. 数据一致性

    • 浏览量更新使用原子操作确保准确性
    • 缓存与数据库数据同步机制
  4. 其他技术

    • 使用AutoMapper进行对象映射
    • 使用MediatR进行事件处理
    • MongoDB作为数据存储

业务逻辑

  • 对cnBeta和ItHome来源的文章有特殊处理(如随机获取、定期清理)
  • 文章浏览量统计和缓存
  • 文章内容中的图片处理(提取、删除等)
  • 支持Markdown格式的内容

这个服务类设计良好,考虑了性能(缓存)、并发安全(锁)、数据一致性(原子操作)等方面,是一个典型的内容管理系统核心服务实现。

评论加载中...