using System.Xml.Serialization;
using Dpz.Core.Service.ObjectStorage.Services;
using Dpz.Core.Web.Library.Hub;
using Hangfire;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.SignalR;

namespace Dpz.Core.Web.Library.Activator;

/// <summary>
/// 站点地图生成任务
/// </summary>
/// <remarks>
/// </remarks>
[UsedImplicitly]
public class SitemapTaskActivator(
    LinkGenerator linkGenerator,
    IHubContext<Notification> hubContext,
    ILogger<SitemapTaskActivator> logger,
    IArticleService articleService,
    IMumbleService mumbleService,
    IObjectStorageOperation objectStorageService,
    IConfiguration configuration,
    IWebHostEnvironment webHostEnvironment
) : JobActivator
{
    private const string HubMethod = "siteMapPushMessage";

    private static readonly SemaphoreSlim SemaphoreSlim = new(1, 1);

    private readonly bool _isProd = string.Equals(configuration["AgileConfig:env"], "PROD");

    private readonly SitemapConfiguration _config = new()
    {
        BaseUrl = configuration["SitemapBaseUrl"] ?? "https://core.dpangzi.com",
        BatchSize = configuration.GetValue("Sitemap:BatchSize", 500),
        MaxUrlsPerSitemap = configuration.GetValue("Sitemap:MaxUrlsPerSitemap", 10000),
    };

    [ProlongExpirationTime]
    public async Task StartAsync()
    {
        if (!_isProd)
        {
            logger.LogInformation("非生产环境,跳过站点地图生成!");
            return;
        }

        await SemaphoreSlim.WaitAsync();
        try
        {
            await PushMessageAsync(SitemapProgressMessage.Info("🚀 站点地图生成任务启动"));

            var allUrls = new List<SitemapUrl>();

            // 生成静态页面链接
            var fixedPages = GenerateFixedPages();
            allUrls.AddRange(fixedPages);
            await PushMessageAsync(SitemapProgressMessage.Info($"📄 固定链接页面:{fixedPages.Count} 个"));

            // 生成文章相关链接
            await PushMessageAsync(SitemapProgressMessage.Info("📝 正在查询文章数据..."));
            var articleUrls = await GenerateArticleUrlsAsync();
            allUrls.AddRange(articleUrls);

            // 生成碎碎念链接
            await PushMessageAsync(SitemapProgressMessage.Info("💬 正在查询碎碎念数据..."));
            var mumbleUrls = await GenerateMumbleUrlsAsync();
            allUrls.AddRange(mumbleUrls);

            // 添加基础 URL
            allUrls.ForEach(x => x.Location = _config.BaseUrl + x.Location);

            // 根据 URL 数量决定生成单文件还是索引
            await PushMessageAsync(SitemapProgressMessage.Info("📦 正在生成 XML 文件..."));
            if (allUrls.Count > _config.MaxUrlsPerSitemap)
            {
                await GenerateSitemapIndexAsync(allUrls);
            }
            else
            {
                await GenerateSingleSitemapAsync(allUrls);
            }

            logger.LogInformation("站点地图生成成功,共 {Count} 个 URL", allUrls.Count);
            await PushMessageAsync(
                SitemapProgressMessage.Info($"✅ 完成!共收录 {allUrls.Count} 个链接")
            );
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "站点地图生成失败");
            await PushMessageAsync(SitemapProgressMessage.Error("❌ 站点地图生成失败"));
            throw;
        }
        finally
        {
            SemaphoreSlim.Release();
        }
    }

    /// <summary>
    /// 生成固定页面链接
    /// </summary>
    private List<SitemapUrl> GenerateFixedPages()
    {
        var today = DateTime.Now.ToString("yyyy-MM-dd");
        var fixedPages = new[]
        {
            // 首页
            ("Index", "Home", (object?)null, 1.0f),
            // Steam
            ("Index", "Steam", null, 0.8f),
            // 时间轴
            ("Index", "Timeline", null, 0.8f),
            // 源码
            ("Index", "Code", new { path = "" }, 0.8f),
            // 友链
            ("Friends", "Home", null, 0.8f),
            // 书签
            ("Index", "Bookmark", null, 0.8f),
            // 相册
            ("GetAlbums", "Home", null, 0.8f),
        };

        return fixedPages
            .Select(page => new SitemapUrl
            {
                Location = linkGenerator.GetPathByAction(page.Item1, page.Item2, page.Item3),
                ChangeFrequency = "weekly",
                Priority = page.Item4,
                LastModified = today,
            })
            .ToList();
    }

    /// <summary>
    /// 生成文章相关链接
    /// </summary>
    private async Task<List<SitemapUrl>> GenerateArticleUrlsAsync()
    {
        var urls = new List<SitemapUrl>();
        var today = DateTime.Now.ToString("yyyy-MM-dd");

        // 文章分页链接(根据实际总数计算)
        var page = await articleService.GetPagesAsync(1, _config.BatchSize);

        var articleListPages = Enumerable
            .Range(1, 200)
            .Select(x => new SitemapUrl
            {
                Location = linkGenerator.GetPathByAction("Index", "Article", new { pageIndex = x }),
                ChangeFrequency = "daily",
                Priority = 0.5f,
                LastModified = today,
            });
        urls.AddRange(articleListPages);

        // 标签页面
        var tags = await articleService.GetAllTagsAsync();
        var tagUrls = tags.Select(tag => new SitemapUrl
        {
            Location = linkGenerator.GetPathByAction("Index", "Article", new { tag }),
            ChangeFrequency = "weekly",
            Priority = 0.5f,
            LastModified = today,
        });
        urls.AddRange(tagUrls);

        // 第一页文章详情(最新内容,搜索引擎优先抓取)
        var firstPageIds = page.Select(article => article.Id).ToHashSet();
        foreach (var article in page)
        {
            var articleUrl = new SitemapUrl
            {
                Location = linkGenerator.GetPathByAction("Read", "Article", new { id = article.Id }),
                ChangeFrequency = "weekly",
                Priority = 0.8f,
                LastModified = article.CreateTime.ToString("yyyy-MM-dd"),
            };
            urls.Add(articleUrl);
        }

        // 用户发布的文章(排除第一页已包含的)
        var publishArticles = await articleService.GetPublishArticlesAsync();
        var userArticleUrls = publishArticles
            .Where(x => !firstPageIds.Contains(x.Id))
            .Select(article => new SitemapUrl
            {
                Location = linkGenerator.GetPathByAction("Read", "Article", new { id = article.Id }),
                ChangeFrequency = "monthly",
                Priority = 0.8f,
                LastModified = article.CreateTime.ToString("yyyy-MM-dd"),
            })
            .ToList();
        urls.AddRange(userArticleUrls);

        var detailCount = page.Count() + userArticleUrls.Count;
        await PushMessageAsync(
            SitemapProgressMessage.Info(
                $"📝 文章:{detailCount} 篇详情 / {tags.Count} 个标签 / 200 页列表"
            )
        );

        return urls;
    }

    /// <summary>
    /// 生成碎碎念相关链接
    /// </summary>
    private async Task<List<SitemapUrl>> GenerateMumbleUrlsAsync()
    {
        var today = DateTime.Now.ToString("yyyy-MM-dd");
        var page = await mumbleService.GetPagesAsync(1, _config.BatchSize);
        var totalPage = (int)Math.Ceiling(page.TotalItemCount / 10d);
        List<SitemapUrl> mumbleLinks = [];

        do
        {
            foreach (var item in page)
            {
                var mumbleLink = new SitemapUrl
                {
                    Location = linkGenerator.GetPathByAction(
                        "Comment",
                        "Mumble",
                        new { id = item.Id }
                    ),
                    ChangeFrequency = "daily",
                    Priority = 0.6f,
                    LastModified = item.CreateTime.ToString("yyyy-MM-dd"),
                };
                mumbleLinks.Add(mumbleLink);
            }

            // 还有更多页时继续查询
            if (page.CurrentPageIndex < page.TotalPageCount)
            {
                page = await mumbleService.GetPagesAsync(
                    page.CurrentPageIndex + 1,
                    _config.BatchSize
                );
                // 避免DB压力过大
                await Task.Delay(500);
            }
        } while (page.CurrentPageIndex < page.TotalPageCount);

        // 碎碎念列表分页
        var mumblePages = Enumerable
            .Range(1, totalPage)
            .Select(x => new SitemapUrl
            {
                Location = linkGenerator.GetPathByAction("Index", "Mumble", new { pageIndex = x }),
                ChangeFrequency = "daily",
                Priority = 0.8f,
                LastModified = today,
            })
            .ToList();

        await PushMessageAsync(
            SitemapProgressMessage.Info(
                $"💬 碎碎念:{mumbleLinks.Count} 条详情 / {mumblePages.Count} 页列表"
            )
        );

        mumbleLinks.AddRange(mumblePages);
        return mumbleLinks;
    }

    /// <summary>
    /// 生成单个 sitemap 文件
    /// </summary>
    private async Task GenerateSingleSitemapAsync(List<SitemapUrl> urls)
    {
        var sitemap = new SitemapUrlSet { Urls = urls };
        var bytes = SerializeToXml(sitemap);
        await UploadSitemapAsync(bytes, "sitemap.xml");
    }

    /// <summary>
    /// 生成 Sitemap Index(多个 sitemap 文件)
    /// </summary>
    private async Task GenerateSitemapIndexAsync(List<SitemapUrl> allUrls)
    {
        var today = DateTime.Now.ToString("yyyy-MM-dd");
        var chunks = allUrls
            .Select((url, index) => new { url, index })
            .GroupBy(x => x.index / _config.MaxUrlsPerSitemap)
            .Select(g => g.Select(x => x.url).ToList())
            .ToList();

        var sitemapEntries = new List<SitemapEntry>();

        for (var i = 0; i < chunks.Count; i++)
        {
            var fileName = $"sitemap_{i + 1}.xml";
            var sitemap = new SitemapUrlSet { Urls = chunks[i] };
            var bytes = SerializeToXml(sitemap);
            await UploadSitemapAsync(bytes, fileName);

            sitemapEntries.Add(
                new SitemapEntry
                {
                    Location = $"{_config.BaseUrl}/sitemap/{fileName}",
                    LastModified = today,
                }
            );

            await PushMessageAsync(
                SitemapProgressMessage.Info($"已上传 {fileName},包含 {chunks[i].Count} 个链接")
            );
        }

        // 生成并上传索引文件
        var index = new SitemapIndex { Sitemaps = sitemapEntries };
        var indexBytes = SerializeToXml(index);
        await UploadSitemapAsync(indexBytes, "sitemap.xml");

        await PushMessageAsync(
            SitemapProgressMessage.Info($"Sitemap Index 生成完成,共 {chunks.Count} 个子文件")
        );
    }

    /// <summary>
    /// 序列化为 XML
    /// </summary>
    private static byte[] SerializeToXml<T>(T obj)
    {
        var serializer = new XmlSerializer(typeof(T));
        using var stream = new MemoryStream();
        serializer.Serialize(stream, obj);
        return stream.ToArray();
    }

    /// <summary>
    /// 上传站点地图文件
    /// </summary>
    private async Task UploadSitemapAsync(byte[] content, string fileName)
    {
        if (!_isProd)
        {
            var sitemapPath = Path.Combine(webHostEnvironment.WebRootPath, fileName);
            await File.WriteAllBytesAsync(sitemapPath, content);
            return;
        }
        await objectStorageService.UploadAsync(content, ["sitemap"], fileName);
    }

    /// <summary>
    /// 推送进度消息
    /// </summary>
    private async Task PushMessageAsync(SitemapProgressMessage message)
    {
        await hubContext.Clients.All.SendAsync(HubMethod, message);
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

总体功能

  • 这是一个用于生成网站 sitemap 的后台任务激活器(继承自 Hangfire 的 JobActivator),定期或按需运行,收集站点上所有需要被搜索引擎抓取的 URL,生成 XML 格式的 sitemap(单文件或索引 + 多个子文件),并将结果上传到对象存储(生产环境)或写到本地 wwwroot(非生产环境)。任务在执行过程中通过 SignalR 向前端推送进度消息,并记录日志。

主要依赖(构造函数注入)

  • LinkGenerator:根据控制器/动作和路由数据生成站点内相对路径。
  • IHubContext:用于通过 SignalR 推送进度消息给所有连接的客户端。
  • ILogger:日志记录。
  • IArticleService、IMumbleService:分别用于读取文章和“碎碎念”数据(分页、标签、已发布文章等)。
  • IObjectStorageOperation:用于把生成的 sitemap 上传到对象存储(生产环境)。
  • IConfiguration:读取配置(BaseUrl、BatchSize、MaxUrlsPerSitemap、env 等)。
  • IWebHostEnvironment:用于非生产环境把 sitemap 写入 wwwroot。

配置与并发控制

  • _isProd:通过配置判断是否为生产环境(只有生产环境才上传到对象存储)。
  • _config:包含 BaseUrl、BatchSize、MaxUrlsPerSitemap 等。
  • SemaphoreSlim:防止同一时间并发运行多个 sitemap 生成任务(只允许一个执行)。

核心流程(StartAsync)

  1. 检查环境:非生产环境会记录日志并跳过生成(直接返回)。
  2. 获取 SemaphoreSlim 锁,防止并发。
  3. 推送开始消息到前端。
  4. 收集所有 URL:
    • 固定页面(GenerateFixedPages)
    • 文章相关链接(GenerateArticleUrlsAsync)
    • 碎碎念/短帖相关链接(GenerateMumbleUrlsAsync)
  5. 为每个 URL 前缀上 BaseUrl(把相对路径变为完整 URL)。
  6. 根据总 URL 数量决定:
    • 如果 URL 数 <= MaxUrlsPerSitemap:生成单个 sitemap 文件(GenerateSingleSitemapAsync)。
    • 否则:分片生成多个 sitemap 子文件并生成 sitemap 索引(GenerateSitemapIndexAsync)。
  7. 上传文件并在过程中不断推送进度消息。
  8. 捕获并记录异常,同时推送失败消息;最后释放锁。

各方法说明

  • GenerateFixedPages:

    • 生成一组固定的静态页面的链接(首页、时间轴、书签、友链、相册等)。
    • 生成的每条 SitemapUrl 设置 location(使用 LinkGenerator.GetPathByAction)、changefreq、priority、lastmod。
  • GenerateArticleUrlsAsync:

    • 查询文章第一页(用于取最新若干篇作为优先收录),并生成一定数量的文章列表分页链接(示例中固定生成 200 页的列表 URL)。
    • 获取所有标签并为每个标签生成标签页 URL。
    • 把第一页文章详情加入(优先抓取),并把其它已发布文章(排除第一页包含的)也加入详情 URL。
    • 推送包含文章总数、标签数、列表页数的进度消息。
  • GenerateMumbleUrlsAsync:

    • 分页读取“碎碎念”数据,逐页把每条详情 URL 加入列表(并在循环内对下一页查询有短暂延迟以减轻 DB 压力)。
    • 计算列表页总数并生成对应的分页 URL。
    • 推送包含碎碎念详情数和页数的进度消息。
  • GenerateSingleSitemapAsync / GenerateSitemapIndexAsync:

    • 使用 XmlSerializer 把 Sitemap 对象序列化为 XML bytes。
    • 单文件:直接上传为 sitemap.xml。
    • 多文件:把所有 URL 分成多个 chunk(每个文件最多 _config.MaxUrlsPerSitemap),为每个 chunk 生成子文件(sitemap_1.xml、sitemap_2.xml 等)并上传,然后生成 sitemap.xml 作为索引文件,里面列出所有子文件的位置和最后修改时间。
  • SerializeToXml:使用 System.Xml.Serialization.XmlSerializer 将对象序列化成 XML 字节数组。

  • UploadSitemapAsync:

    • 非生产环境:将文件写入 webRoot(本地文件系统)。
    • 生产环境:调用对象存储服务上传(上传时会使用类似 bucket/path 等参数)。
  • PushMessageAsync:通过 SignalR hubContext 向所有客户端广播进度消息(方法名 siteMapPushMessage)。

日志与实时反馈

  • 在关键步骤(开始、各阶段完成、上传每个子文件、完成或失败)记录日志并通过 SignalR 推送消息到前端,便于实时看到任务进度。

注意与潜在问题(实现细节层面)

  • 代码示例中使用了一些自定义类型(SitemapUrl、SitemapUrlSet、SitemapIndex、SitemapEntry、SitemapConfiguration、SitemapProgressMessage 等),这些类在示例中未展示,但从语义可以推断其字段(Location、ChangeFrequency、Priority、LastModified 等)。
  • 部分实现细节需注意:
    • 确保分页逻辑中使用的 page size 与 totalPage 计算一致(例子中 totalPage 使用 page.TotalItemCount/10,实际应和每页大小一致)。
    • 上传到对象存储时的参数格式需要与 IObjectStorageOperation.UploadAsync 的签名匹配(示例中写法像数组字面量,需要使用正确的集合类型)。
    • XmlSerializer 序列化时,Sitemap 类型需要包含相应的 [XmlRoot]、[XmlElement] 等特性以生成符合搜索引擎要求的 sitemap XML(例如 urlset、url 元素,或 sitemapindex、sitemap 元素)。
    • 并发控制使用 SemaphoreSlim 是合理的,但若任务异常抛出需确保 finally 中释放(示例中已有释放)。
    • 非生产环境直接跳过上传但仍返回早期信息,代码实际逻辑是非生产环境会在 StartAsync 的最开始就直接 return(示例中是跳过生成并记录信息),注意是否希望在本地也能生成文件用于调试(示例后面 UploadSitemapAsync 还是会把文件写入 webroot,如果前面直接 return 就不会到那一步)。

简短总结

  • 该类是一个完整的 sitemap 生成器任务:收集固定页面、文章、碎碎念等 URL,按数量决定单文件或索引文件形式生成 XML,序列化并上传到存储(或写本地),并通过 SignalR 推送进度消息,同时用日志记录过程,且通过信号量避免并发执行。
评论加载中...