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)
- 检查环境:非生产环境会记录日志并跳过生成(直接返回)。
- 获取 SemaphoreSlim 锁,防止并发。
- 推送开始消息到前端。
- 收集所有 URL:
- 固定页面(GenerateFixedPages)
- 文章相关链接(GenerateArticleUrlsAsync)
- 碎碎念/短帖相关链接(GenerateMumbleUrlsAsync)
- 为每个 URL 前缀上 BaseUrl(把相对路径变为完整 URL)。
- 根据总 URL 数量决定:
- 如果 URL 数 <= MaxUrlsPerSitemap:生成单个 sitemap 文件(GenerateSingleSitemapAsync)。
- 否则:分片生成多个 sitemap 子文件并生成 sitemap 索引(GenerateSitemapIndexAsync)。
- 上传文件并在过程中不断推送进度消息。
- 捕获并记录异常,同时推送失败消息;最后释放锁。
各方法说明
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 推送进度消息,同时用日志记录过程,且通过信号量避免并发执行。
评论加载中...