using Dpz.Core.MessageQueue.Abstractions;
using Dpz.Core.Public.ViewModel.Messages;
using Dpz.Core.Public.ViewModel.Request;
using Dpz.Core.Public.ViewModel.Response;
using Dpz.Core.Service.Mediator.Features.Markdown.Commands;
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,
    IMessagePublisher<RemoveImagesMessage> removeImagesPublisher
) : IArticleService
{
    private static readonly TimeSpan CacheDefaultExpiration = TimeSpan.FromHours(3);

    /// <summary>
    /// 生成文章浏览量缓存键, {Prefix}:ViewCount:{Id}
    /// </summary>
    private string BuildViewCountCacheKey(string articleId) => $"{ViewCountTag}:{articleId}";

    /// <summary>
    /// 浏览量缓存统一标签
    /// </summary>
    private string ViewCountTag =>
        GeneratedArticleServiceCacheMetadata.DefaultPrefix + ":ViewCount";

    /// <summary>
    /// 生成文章评论数缓存键, {Prefix}:Comment:{Id}
    /// </summary>
    private string BuildCommentCountCacheKey(string articleId) => $"{CommentCountTag}:{articleId}";

    /// <summary>
    /// 评论数缓存统一标签
    /// </summary>
    private string CommentCountTag =>
        GeneratedArticleServiceCacheMetadata.DefaultPrefix + ":Comment";

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
    public async Task<List<ArticleMiniResponse>> GetTopArticlesAsync(
        int days = -7,
        uint count = 8,
        CancellationToken cancellationToken = default
    )
    {
        if (days > 0)
        {
            throw new ArgumentException("days > 0", nameof(days));
        }
        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);
    }

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
    public async Task<List<ArticleMiniResponse>> GetRandomArticlesAsync(
        int sample = 8,
        CancellationToken cancellationToken = default
    )
    {
        if (sample <= 0)
        {
            throw new ArgumentException("sample <= 0", nameof(sample));
        }
        if (sample > 30)
        {
            throw new ArgumentException("sample > 30", nameof(sample));
        }
        var source = await repository
            .SearchFor(x => x.Tags.Contains("cnBeta") || x.Tags.Contains("ItHome"))
            .Sample(sample)
            .ToListAsync(cancellationToken);
        return mapper.Map<List<ArticleMiniResponse>>(source);
    }

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
    public async Task<List<ArticleMiniResponse>> GetPublishArticlesAsync(
        CancellationToken cancellationToken = default
    )
    {
        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);
    }

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
    public async Task<IPagedList<ArticleMiniResponse>> GetPagesAsync(
        int pageIndex = 1,
        int pageSize = 20,
        string? title = "",
        string? account = "",
        CancellationToken cancellationToken = default,
        params string?[]? tags
    )
    {
        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, cancellationToken);

        return result;
    }

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour)]
    public async Task<List<string>> GetAllTagsAsync(CancellationToken cancellationToken = default)
    {
        return await repository
            .MongodbQueryable.SelectMany(x => x.Tags)
            .GroupBy(x => x)
            .Select(x => x.Key)
            .OrderBy(x => x)
            .ToListAsync(cancellationToken);
    }

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountAsync))]
    public async Task<ArticleResponse?> GetArticleAsync(
        string? id,
        CancellationToken cancellationToken = default
    )
    {
        var article = await repository.TryGetAsync(id, cancellationToken: cancellationToken);
        return article == null ? null : mapper.Map<ArticleResponse>(article);
    }

    public async Task ViewAsync(string id, CancellationToken cancellationToken = default)
    {
        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,
            cancellationToken
        );
        if (updated == null)
        {
            return;
        }

        var newCount = updated.ViewCount;

        // 使用从数据库返回的值更新缓存,避免并发下的覆盖问题
        await SetViewCountCacheAsync(id, newCount);
        await SyncArticleViewCountAsync(id, newCount);
    }

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
    public async Task<List<ArticleMiniResponse>> GetLatestAsync(
        int range = 5,
        CancellationToken cancellationToken = default
    )
    {
        if (range <= 0)
        {
            throw new ArgumentException("range <= 0", nameof(range));
        }

        if (range > 200)
        {
            throw new ArgumentException("range > 200", nameof(range));
        }

        var source = await repository
            .MongodbQueryable.OrderByDescending(x => x.CreateTime)
            .Take(range)
            .ToListAsync(cancellationToken);
        return mapper.Map<List<ArticleMiniResponse>>(source);
    }

    public async Task<bool> IsExistsAsync(
        string title,
        CancellationToken cancellationToken = default
    )
    {
        var blog = await repository
            .SearchFor(x => x.Title == title)
            .FirstOrDefaultAsync(cancellationToken);
        return blog != null;
    }

    public async Task<IReadOnlyCollection<string>> NoExistsByFromAsync(
        IReadOnlyCollection<string> feeds,
        CancellationToken cancellationToken = default
    )
    {
        var filter = Builders<Article>.Filter.In(x => x.From, feeds);
        var exists = await repository
            .SearchFor(filter)
            .Project(x => x.From)
            .ToListAsync(cancellationToken);
        return feeds
            .Except(exists.Select(x => x ?? "").Where(x => !string.IsNullOrWhiteSpace(x)))
            .ToArray();
    }

    public async Task DeleteAsync(string id, CancellationToken cancellationToken = default)
    {
        if (ObjectId.TryParse(id, out var oid))
        {
            var article = await repository.FindAsync(oid, cancellationToken);
            await repository.DeleteAsync(x => x.Id == oid, cancellationToken);
            await ClearCacheAsync(cancellationToken);
            // 发布消息到队列,异步删除图片
            if (article?.ImagesAddress is { Count: > 0 })
            {
                await PublishDeleteImagesMessageAsync(article.ImagesAddress, cancellationToken);
            }
        }
    }

    public async Task DeleteOldCnBetaAsync(
        int month,
        int limit,
        CancellationToken cancellationToken = default
    )
    {
        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(cancellationToken);
        if (list.Count == 0)
        {
            return;
        }

        // 发布消息到队列,异步删除图片
        var allImages = list.SelectMany(x => x.ImagesAddress).ToList();

        var ids = list.Select(x => x.Id);
        await repository.DeleteAsync(x => ids.Contains(x.Id), cancellationToken);
        logger.LogInformation("旧数据删除完毕,共删除{Count}篇文章", list.Count);
        if (allImages.Count > 0)
        {
            await PublishDeleteImagesMessageAsync(allImages, cancellationToken);
        }
    }

    private async Task PublishDeleteImagesMessageAsync(
        IEnumerable<string> images,
        CancellationToken cancellationToken = default
    )
    {
        var messages = images.Select(x => new RemoveImagesMessage { ImageUrl = x });
        // 批量发布删除图片队列消息
        await removeImagesPublisher.PublishBatchAsync(
            messages,
            cancellationToken: cancellationToken
        );
    }

    public async Task<ArticleResponse> CreateArticleAsync(
        CreateArticleRequest article,
        VmUserInfo creator,
        CancellationToken cancellationToken = default
    )
    {
        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,
            MainImageMetadata = article.MainImageMetadata,
            Images = article.Images,
        };

        await using (
            await distributedLockProvider.AcquireLockAsync(
                $"Article.Create.Lock:{entity.Title}",
                cancellationToken: cancellationToken
            )
        )
        {
            if (await IsExistsAsync(entity.Title, cancellationToken))
            {
                throw new ExistsException($"《{entity.Title}》已存在");
            }
            await repository.InsertAsync(entity, cancellationToken);
        }
        return mapper.Map<ArticleResponse>(entity);
    }

    public async Task EditArticleAsync(
        EditArticleRequest article,
        CancellationToken cancellationToken = default
    )
    {
        if (!ObjectId.TryParse(article.Id, out var id))
        {
            return;
        }
        var entity = await repository.FindAsync(id, cancellationToken);
        if (entity == null)
        {
            return;
        }
        var images = await mediator.Send(
            new EditMarkdownRequest
            {
                Markdown = article.Markdown,
                OriginalMarkdown = entity.Markdown,
            },
            cancellationToken
        );

        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.MainImageMetadata,
                article.MainImageMetadata ?? article.Images.FirstOrDefault()
            )
            .Set(x => x.Images, article.Images)
            .Set(x => x.LastUpdateTime, DateTime.Now);
        await repository.UpdateAsync(x => x.Id == id, update, cancellationToken);
        //await ClearCacheAsync();
        var cacheKey = GeneratedArticleServiceCacheMetadata.BuildGetArticleAsyncCacheKey(
            article.Id
        );
        await fusionCache.RemoveAsync(cacheKey, token: cancellationToken);
        await fusionCache.RemoveAsync(
            GeneratedArticleServiceCacheMetadata.BuildGetAllTagsAsyncCacheKey(),
            token: cancellationToken
        );
    }

    public async Task<int> GetTotalCountAsync(CancellationToken cancellationToken = default)
    {
        checked
        {
            return (int)
                await repository.Collection.CountDocumentsAsync(
                    FilterDefinition<Article>.Empty,
                    cancellationToken: cancellationToken
                );
        }
    }

    public async Task<int> GetTodayCountAsync(CancellationToken cancellationToken = default)
    {
        checked
        {
            var filter = Builders<Article>.Filter.Gte(x => x.CreateTime, DateTime.Now.Date);
            return (int)
                await repository.Collection.CountDocumentsAsync(
                    filter,
                    cancellationToken: cancellationToken
                );
        }
    }

    public async ValueTask ClearCacheAsync(CancellationToken cancellationToken = default)
    {
        var removeTags = new[]
        {
            GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetTopArticlesAsync)),
            GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetPagesAsync)),
            GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetAllTagsAsync)),
            GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetLatestAsync)),
            GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetRandomArticlesAsync)),
            GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetPublishArticlesAsync)),
            GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetAllAuthorsAsync)),
            GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetArticleAsync)),
            ViewCountTag,
            CommentCountTag,
        };

        await fusionCache.RemoveByTagAsync(removeTags, token: cancellationToken);
    }

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour)]
    public Task<List<string>> GetAllAuthorsAsync(CancellationToken cancellationToken)
    {
        return GetAllAuthorsCoreAsync(cancellationToken);
    }

    private async Task<List<string>> GetAllAuthorsCoreAsync(CancellationToken cancellationToken)
    {
        var authors = await repository
            .MongodbQueryable.Select(x => x.Author.Id)
            .Distinct()
            .ToListAsync(cancellationToken: cancellationToken);
        return authors.OrderBy(x => x).ToList();
    }

    /// <summary>
    /// 回填列表缓存的浏览量和评论数
    /// </summary>
    internal async Task ApplyViewCountsAsync(IEnumerable<ArticleMiniResponse>? articles)
    {
        if (articles == null)
        {
            return;
        }

        foreach (var article in articles)
        {
            if (string.IsNullOrWhiteSpace(article.Id))
            {
                continue;
            }

            article.ViewCount = await GetCachedViewCountAsync(article.Id, article.ViewCount);
            article.CommentCount = await GetCachedCommentCountAsync(
                article.Id,
                article.CommentCount
            );
        }
    }

    /// <summary>
    /// 回填文章详情缓存的浏览量和评论数
    /// </summary>
    internal async Task ApplyViewCountAsync(ArticleResponse? article)
    {
        if (article == null || string.IsNullOrWhiteSpace(article.Id))
        {
            return;
        }

        article.ViewCount = await GetCachedViewCountAsync(article.Id, article.ViewCount);
        article.CommentCount = await GetCachedCommentCountAsync(article.Id, article.CommentCount);
    }

    /// <summary>
    /// 读取浏览量缓存,缺失则回填
    /// </summary>
    private async Task<int> GetCachedViewCountAsync(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),
            [GeneratedArticleServiceCacheMetadata.DefaultPrefix, ViewCountTag]
        );
    }

    /// <summary>
    /// 读取评论数缓存,缺失则回填
    /// </summary>
    private async Task<int> GetCachedCommentCountAsync(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),
            [GeneratedArticleServiceCacheMetadata.DefaultPrefix, CommentCountTag]
        );
    }

    /// <summary>
    /// 同步文章详情缓存中的浏览量
    /// </summary>
    private async Task SyncArticleViewCountAsync(string articleId, int viewCount)
    {
        var cacheKey = GeneratedArticleServiceCacheMetadata.BuildGetArticleAsyncCacheKey(articleId);
        var cachedArticle = await fusionCache.TryGetAsync<ArticleResponse?>(
            cacheKey,
            token: CancellationToken.None
        );
        if (cachedArticle is { HasValue: true, Value: not null })
        {
            cachedArticle.Value.ViewCount = viewCount;
            await SetArticleCacheAsync(cacheKey, cachedArticle.Value);
        }
    }

    [Cache(ExpirationSeconds = 3 * ExpirationTime.Hour)]
    public async Task<ArticleArchiveResponse> GetArchivePagedAsync(
        int pageIndex = 1,
        int pageSize = 50,
        CancellationToken cancellationToken = default
    )
    {
        var pagedList = await repository
            .SearchFor(x => !x.Tags.Contains("cnBeta") && !x.Tags.Contains("ItHome"))
            .OrderByDescending(x => x.CreateTime)
            .ToPagedListAsync<Article>(pageIndex, pageSize, cancellationToken);

        var years = pagedList
            .GroupBy(x => x.CreateTime.Year)
            .Select(yearGroup => new ArchiveYear
            {
                Year = yearGroup.Key,
                Count = yearGroup.Count(),
                Months = yearGroup
                    .GroupBy(x => x.CreateTime.Month)
                    .Select(monthGroup => new ArchiveMonth
                    {
                        Month = monthGroup.Key,
                        Count = monthGroup.Count(),
                        Articles = monthGroup
                            .Select(x => new ArchiveItem
                            {
                                Id = x.Id.ToString(),
                                Title = x.Title,
                                CreateTime = x.CreateTime,
                                ViewCount = x.ViewCount,
                                CommentCount = x.CommentCount,
                                Tags = x.Tags,
                            })
                            .ToList(),
                    })
                    .OrderByDescending(m => m.Month)
                    .ToList(),
            })
            .OrderByDescending(y => y.Year)
            .ToList();

        return new ArticleArchiveResponse { TotalCount = pagedList.TotalItemCount, Years = years };
    }

    public async Task UpdateCommentCountAsync(
        string articleId,
        int count,
        CancellationToken cancellationToken = default
    )
    {
        var article = await repository.TryGetAsync(articleId, cancellationToken);
        if (article == null)
        {
            return;
        }

        // 更新数据库
        var update = Builders<Article>.Update.Set(x => x.CommentCount, count);
        await repository.UpdateAsync(x => x.Id == article.Id, update, cancellationToken);

        // 更新缓存
        await SetCommentCountCacheAsync(articleId, count);
        await SyncArticleCommentCountAsync(articleId, count);
    }

    /// <summary>
    /// 同步文章详情缓存中的评论数
    /// </summary>
    private async Task SyncArticleCommentCountAsync(string articleId, int commentCount)
    {
        var cacheKey = GeneratedArticleServiceCacheMetadata.BuildGetArticleAsyncCacheKey(articleId);
        var cachedArticle = await fusionCache.TryGetAsync<ArticleResponse?>(
            cacheKey,
            token: CancellationToken.None
        );
        if (cachedArticle is { HasValue: true, Value: not null })
        {
            cachedArticle.Value.CommentCount = commentCount;
            await SetArticleCacheAsync(cacheKey, cachedArticle.Value);
        }
    }

    /// <summary>
    /// 写入文章详情缓存
    /// </summary>
    private ValueTask SetArticleCacheAsync(string cacheKey, ArticleResponse article)
    {
        return fusionCache.SetAsync(
            cacheKey,
            article,
            options => options.SetDuration(CacheDefaultExpiration),
            [
                GeneratedArticleServiceCacheMetadata.DefaultPrefix,
                GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetArticleAsync)),
                GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetArchivePagedAsync)),
            ]
        );
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

代码分析:ArticleService 文章服务实现

概述

这是一个功能完善的文章服务实现类,负责处理文章的增删改查、浏览统计、缓存管理等核心业务逻辑。采用了仓储模式缓存策略分布式锁消息队列等现代架构模式。


核心功能模块

1. 依赖注入组件

public class ArticleService(
    IRepository<Article> repository,           // MongoDB仓储
    IMediator mediator,                        // MediatR中介者
    IMapper mapper,                            // AutoMapper映射
    ILogger<ArticleService> logger,            // 日志记录
    IDistributedLockProvider distributedLockProvider, // 分布式锁
    IFusionCache fusionCache,                  // 多级缓存
    IConfiguration configuration,               // 配置服务
    IMessagePublisher<RemoveImagesMessage> removeImagesPublisher // 消息发布者
)

2. 缓存管理策略

缓存键生成

// 浏览量缓存键:{Prefix}:ViewCount:{Id}
private string BuildViewCountCacheKey(string articleId) 
    => $"{ViewCountTag}:{articleId}";

// 评论数缓存键:{Prefix}:Comment:{Id}
private string BuildCommentCountCacheKey(string articleId) 
    => $"{CommentCountTag}:{articleId}";

缓存失效时间

  • 默认过期时间:3小时
  • 使用 [Cache] 特性标记方法自动缓存
  • 支持 PostProcess 后处理回填数据

主要功能实现

3. 文章查询接口

热门文章查询

[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
public async Task<List<ArticleMiniResponse>> GetTopArticlesAsync(
    int days = -7,      // 最近7天
    uint count = 8,     // 取8篇
    CancellationToken cancellationToken = default
)

特性:

  • 非生产环境随机采样
  • 生产环境按浏览量排序
  • 自动回填缓存浏览量

随机文章查询

public async Task<List<ArticleMiniResponse>> GetRandomArticlesAsync(
    int sample = 8, 
    CancellationToken cancellationToken = default
)
  • 仅查询 cnBetaItHome 标签的文章
  • 限制采样数 1-30 篇

分页查询

public async Task<IPagedList<ArticleMiniResponse>> GetPagesAsync(
    int pageIndex = 1,
    int pageSize = 20,
    string? title = "",
    string? account = "",
    CancellationToken cancellationToken = default,
    params string?[]? tags
)

支持多条件过滤:

  • 标题模糊搜索(正则表达式)
  • 作者ID精确匹配
  • 多标签筛选
  • 按创建时间倒序排序

4. 浏览量管理

原子性增加浏览量

public async Task ViewAsync(string id, CancellationToken cancellationToken = default)
{
    // 1. 使用 FindOneAndUpdate 原子更新数据库
    var update = Builders<Article>.Update.Inc(x => x.ViewCount, 1);
    var options = new FindOneAndUpdateOptions<Article, Article>
    {
        ReturnDocument = ReturnDocument.After  // 返回更新后的文档
    };
    
    // 2. 更新缓存
    await SetViewCountCacheAsync(id, newCount);
    
    // 3. 同步详情缓存
    await SyncArticleViewCountAsync(id, newCount);
}

关键点:

  • 使用 MongoDB 的原子操作避免并发问题
  • 三级同步:数据库 → 浏览量缓存 → 详情缓存

缓存回填机制

// 批量回填列表数据
internal async Task ApplyViewCountsAsync(IEnumerable<ArticleMiniResponse>? articles)
{
    foreach (var article in articles)
    {
        article.ViewCount = await GetCachedViewCountAsync(article.Id, article.ViewCount);
        article.CommentCount = await GetCachedCommentCountAsync(article.Id, article.CommentCount);
    }
}

5. 文章创建流程

public async Task<ArticleResponse> CreateArticleAsync(
    CreateArticleRequest article,
    VmUserInfo creator,
    CancellationToken cancellationToken = default
)
{
    // 1. 解析Markdown提取图片
    var htmlContent = article.Markdown.MarkdownToHtml(false);
    var document = await htmlParse.ParseDocumentAsync(htmlContent);
    var imageElements = document.GetElementsByTagName("img");
    
    // 2. 构建实体
    var entity = new Article
    {
        Author = mapper.Map<UserInfo>(creator),
        ImagesAddress = imageElements.GetElementsImageUrls(),
        MainImage = imageElements.FirstOrDefault()?.GetAttribute("src"),
        // ...
    };
    
    // 3. 分布式锁防止重复创建
    await using (await distributedLockProvider.AcquireLockAsync(
        $"Article.Create.Lock:{entity.Title}",
        cancellationToken: cancellationToken
    ))
    {
        if (await IsExistsAsync(entity.Title, cancellationToken))
        {
            throw new ExistsException($"《{entity.Title}》已存在");
        }
        await repository.InsertAsync(entity, cancellationToken);
    }
}

安全措施:

  • 分布式锁确保并发安全
  • 重复标题校验
  • HTML解析提取图片资源

6. 文章编辑流程

public async Task EditArticleAsync(EditArticleRequest article, ...)
{
    // 1. 通过MediatR处理图片变更
    var images = await mediator.Send(
        new EditMarkdownRequest
        {
            Markdown = article.Markdown,
            OriginalMarkdown = entity.Markdown
        },
        cancellationToken
    );
    
    // 2. 更新文档
    var update = Builders<Article>
        .Update.Set(x => x.Title, article.Title)
        .Set(x => x.Markdown, article.Markdown)
        .Set(x => x.ImagesAddress, images)
        .Set(x => x.LastUpdateTime, DateTime.Now);
    
    // 3. 精确清除相关缓存
    await fusionCache.RemoveAsync(cacheKey, token: cancellationToken);
}

7. 文章删除与清理

单篇删除

public async Task DeleteAsync(string id, CancellationToken cancellationToken = default)
{
    var article = await repository.FindAsync(oid, cancellationToken);
    await repository.DeleteAsync(x => x.Id == oid, cancellationToken);
    
    // 异步删除关联图片
    if (article?.ImagesAddress is { Count: > 0 })
    {
        await PublishDeleteImagesMessageAsync(article.ImagesAddress, cancellationToken);
    }
}

批量清理旧数据

public async Task DeleteOldCnBetaAsync(int month, int limit, ...)
{
    // 查询旧数据:特定标签 && 无评论 && 超过指定月数
    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(cancellationToken);
    
    // 批量删除图片
    await PublishDeleteImagesMessageAsync(allImages, cancellationToken);
}

消息队列异步删除

private async Task PublishDeleteImagesMessageAsync(...)
{
    var messages = images.Select(x => new RemoveImagesMessage { ImageUrl = x });
    await removeImagesPublisher.PublishBatchAsync(messages, ...);
}

8. 归档查询

[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour)]
public async Task<ArticleArchiveResponse> GetArchivePagedAsync(...)
{
    // 按年份 -> 月份 -> 文章三级分组
    var years = pagedList
        .GroupBy(x => x.CreateTime.Year)
        .Select(yearGroup => new ArchiveYear
        {
            Year = yearGroup.Key,
            Months = yearGroup
                .GroupBy(x => x.CreateTime.Month)
                .Select(monthGroup => new ArchiveMonth
                {
                    Articles = monthGroup.Select(...).ToList()
                })
                .ToList()
        })
        .ToList();
}

技术亮点

9. 缓存策略设计

缓存类型键格式用途过期时间
浏览量{Prefix}:ViewCount:{Id}高频读写分离3小时
评论数{Prefix}:Comment:{Id}减少数据库压力3小时
文章详情方法特征生成热点数据缓存3小时

缓存一致性保障:

// 更新评论数时同步三处
public async Task UpdateCommentCountAsync(...)
{
    // 1. 更新数据库
    await repository.UpdateAsync(...);
    
    // 2. 更新评论数缓存
    await SetCommentCountCacheAsync(articleId, count);
    
    // 3. 同步详情缓存
    await SyncArticleCommentCountAsync(articleId, count);
}

10. 性能优化

  1. 分布式锁应用

    await using (await distributedLockProvider.AcquireLockAsync(...))
    {
        // 临界区代码
    }
    
  2. MongoDB 聚合管道

    // 随机采样
    .Sample(count)
    
    // 数组展开去重
    .SelectMany(x => x.Tags).GroupBy(x => x).Select(x => x.Key)
    
  3. 批量操作

    // 批量发布消息
    await removeImagesPublisher.PublishBatchAsync(messages, ...);
    

总结

架构特点

  • CQRS模式:读写分离(查询缓存/写直达数据库)
  • DDD领域模型:清晰的业务边界
  • 异步消息:图片删除解耦
  • 多级缓存:内存+分布式缓存
  • 并发控制:分布式锁+原子操作

潜在优化点

  1. 浏览量可考虑使用 Redis 管道批量更新
  2. 缓存穿透可增加布隆过滤器
  3. 热点文章可配置更长缓存时间
  4. 图片删除失败需要补偿机制
评论加载中...