diff --git a/Yes.Application/Blogs/GetRssQueryHandler.cs b/Yes.Application/Blogs/GetRssQueryHandler.cs new file mode 100644 index 0000000..b94668e --- /dev/null +++ b/Yes.Application/Blogs/GetRssQueryHandler.cs @@ -0,0 +1,89 @@ + +namespace Yes.Application.Blogs +{ + + public record GetRssQuery() : IRequest; + + public record GetRssQueryResponse(string Xml); + + public class GetRssQueryHandler( + IOptionsMonitor options, + BlogDbContext db, + IMapper mapper) : IRequestHandler + { + private readonly BlogSettings _settings = options.CurrentValue; + private readonly BlogDbContext _db = db; + public async Task Handle(GetRssQuery request, CancellationToken cancellationToken) + { + var queryArticle = _db.Articles.Where(x => x.Type == ArticleTypeEnum.Article && x.Status == ArticleStatusEnum.Normal).AsQueryable(); + var pubDate = queryArticle.Any() ? _db.Articles.Min(x => x.PublishDate) : DateTime.MinValue; + + var channel = new Channel + { + Title = _settings.Name, + Link = _settings.Url, + Description = _settings.Description, + Language = "zh-CN", + FeedUrl = $"{_settings.Url}/feed/", + PubDate = pubDate + }; + + // 添加扩展元素(示例) + channel.Extensions.Add(new XElement("generator", _settings.Name)); + channel.Extensions.Add(new XElement("docs", "http://www.rssboard.org/rss-specification")); + + var query = from article in queryArticle + join categoryTemp in _db.Categories.AsNoTracking() on article.CategoryId equals categoryTemp.Id into tempCategory + from category in tempCategory.DefaultIfEmpty() + join userTemp in _db.Users.AsNoTracking() on article.UserId equals userTemp.Id into tempUser + from user in tempUser.DefaultIfEmpty() + orderby article.Id descending + select new ArticleModel + { + Id = article.Id, + CanComment = article.CanComment, + CommentCount = article.CommentCount, + CategoryId = article.CategoryId, + ModifyDate = article.ModifyDate.ToSafeTime(), + PublishDate = article.PublishDate.ToSafeTime(), + CreateDate = article.CreateDate.ToSafeTime(), + Content = article.Content, + CoverUrl = article.CoverUrl, + Slug = article.Slug, + ReadCount = article.ReadCount, + Title = article.Title, + Summary = article.Summary, + UserId = article.UserId, + Category = new CategoryModel + { + CoverUrl = category.CoverUrl, + Description = category.Description, + Id = category.Id, + Name = category.Name, + Slug = category.Slug + } + }; + + var items = query.Select(x => new Item + { + Content = x.Content, + Title = x.Title, + Url = _settings.ArticleRoute.ToRoute(x.Category, x), + Description = x.Summary, + CommentsCount = x.CommentCount, + Creator = x.UserId.ToString(), + PublishDate = x.PublishDate, + CommentsUrl = $"{_settings.ArticleRoute.ToRoute(x.Category, x)}#comments", + CommentRssUrl = $"{_settings.Url}/feed/" + }); + + + var generator = new RssGenerator(); + string xml = generator.GenerateRss(channel, items); + + return new GetRssQueryResponse(xml); + } + + + } +} diff --git a/Yes.Application/GlobalUsings.cs b/Yes.Application/GlobalUsings.cs index 1fb6b53..9eadd23 100644 --- a/Yes.Application/GlobalUsings.cs +++ b/Yes.Application/GlobalUsings.cs @@ -37,4 +37,6 @@ global using System.Security.Claims; global using System.IdentityModel.Tokens.Jwt; global using Microsoft.IdentityModel.Tokens; global using System.Text; -global using Yes.Infrastructure.Authorizations.Identity.Authroizations; \ No newline at end of file +global using Yes.Infrastructure.Authorizations.Identity.Authroizations; +global using System.Xml.Linq; +global using Yes.Infrastructure.Rss; diff --git a/Yes.Blog/Endpoints/Blogs/RssEndpoint.cs b/Yes.Blog/Endpoints/Blogs/RssEndpoint.cs new file mode 100644 index 0000000..80e5044 --- /dev/null +++ b/Yes.Blog/Endpoints/Blogs/RssEndpoint.cs @@ -0,0 +1,19 @@ + +namespace Yes.Blog.Endpoints.Blogs +{ + public class RssEndpoint : BlogEndpointScheme, IEndpoint + { + public void Map(IEndpointRouteBuilder app) => app.MapGet("feed", Handle).AllowAnonymous(); + + private async Task Handle( + IMediator mediator, + IMapper mapper, + CancellationToken cancellationToken) + { + var response = await mediator.Send(new GetRssQuery(), cancellationToken); + + return Results.Text(response.Xml, "application/rss+xml; charset=utf-8"); + } + + } +} diff --git a/Yes.Infrastructure/GlobalUsings.cs b/Yes.Infrastructure/GlobalUsings.cs index 861d532..decfea7 100644 --- a/Yes.Infrastructure/GlobalUsings.cs +++ b/Yes.Infrastructure/GlobalUsings.cs @@ -51,4 +51,5 @@ global using Yes.Infrastructure.Migrator.Migrations; global using System.Security; global using System.Security.Cryptography; global using Yes.Infrastructure.Helpers; -global using Microsoft.AspNetCore.Authentication.JwtBearer; \ No newline at end of file +global using Microsoft.AspNetCore.Authentication.JwtBearer; +global using System.Xml.Linq; \ No newline at end of file diff --git a/Yes.Infrastructure/Rss/Channel.cs b/Yes.Infrastructure/Rss/Channel.cs new file mode 100644 index 0000000..5ff1c4e --- /dev/null +++ b/Yes.Infrastructure/Rss/Channel.cs @@ -0,0 +1,21 @@ +namespace Yes.Infrastructure.Rss +{ + public class Channel + { + public string Title { get; set; } + public string Link { get; set; } + public string Description { get; set; } + public string Language { get; set; } = "zh-CN"; + + // 时间信息 + public DateTime LastBuildDate { get; set; } = DateTime.Now; + public DateTime PubDate { get; set; } = DateTime.Now; + + // Atom 链接 + public string FeedUrl { get; set; } + + // 扩展能力 + public List Extensions { get; } = new List(); + + } +} diff --git a/Yes.Infrastructure/Rss/Item.cs b/Yes.Infrastructure/Rss/Item.cs new file mode 100644 index 0000000..8610963 --- /dev/null +++ b/Yes.Infrastructure/Rss/Item.cs @@ -0,0 +1,15 @@ +namespace Yes.Infrastructure.Rss +{ + public class Item + { + public string Title { get; set; } + public string Url { get; set; } + public DateTime PublishDate { get; set; } + public string Creator { get; set; } + public string Description { get; set; } // 简短描述 + public string Content { get; set; } // 完整HTML内容 + public int CommentsCount { get; set; } + public string CommentsUrl { get; set; } + public string CommentRssUrl { get; set; } + } +} diff --git a/Yes.Infrastructure/Rss/RssGenerator.cs b/Yes.Infrastructure/Rss/RssGenerator.cs new file mode 100644 index 0000000..08de57a --- /dev/null +++ b/Yes.Infrastructure/Rss/RssGenerator.cs @@ -0,0 +1,80 @@ + +namespace Yes.Infrastructure.Rss +{ + public class RssGenerator + { + + private readonly XNamespace _contentNs = XNamespace.Get("http://purl.org/rss/1.0/modules/content/"); + private readonly XNamespace _dcNs = XNamespace.Get("http://purl.org/dc/elements/1.1/"); + private readonly XNamespace _slashNs = XNamespace.Get("http://purl.org/rss/1.0/modules/slash/"); + private readonly XNamespace _wfwNs = XNamespace.Get("http://wellformedweb.org/CommentAPI/"); + + public string GenerateRss(Channel channel, IEnumerable items) + { + var doc = new XDocument( + new XDeclaration("1.0", "UTF-8", null), + new XElement("rss", + new XAttribute("version", "2.0"), + GetNamespaceAttributes(), + new XElement("channel", + RequiredChannelElements(channel), + GenerateItems(items) + ) + ) + ); + + return doc.ToStringWithDeclaration(); + } + + + private IEnumerable GetNamespaceAttributes() + { + yield return new XAttribute(XNamespace.Xmlns + "content", "http://purl.org/rss/1.0/modules/content/"); + yield return new XAttribute(XNamespace.Xmlns + "dc", "http://purl.org/dc/elements/1.1/"); + yield return new XAttribute(XNamespace.Xmlns + "atom", "http://www.w3.org/2005/Atom"); + yield return new XAttribute(XNamespace.Xmlns + "wfw", "http://wellformedweb.org/CommentAPI/"); + yield return new XAttribute(XNamespace.Xmlns + "slash", "http://purl.org/rss/1.0/modules/slash/"); + } + + private IEnumerable RequiredChannelElements(Channel channel) + { + yield return new XElement("title", channel.Title); + yield return new XElement("link", channel.Link); + yield return new XElement("description", channel.Description); + yield return new XElement("language", channel.Language); + yield return new XElement("lastBuildDate", FormatRfc1123(channel.LastBuildDate)); + yield return new XElement("pubDate", FormatRfc1123(channel.PubDate)); + yield return new XElement(XNamespace.Get("http://www.w3.org/2005/Atom") + "link", + new XAttribute("href", channel.FeedUrl), + new XAttribute("rel", "self"), + new XAttribute("type", "application/rss+xml")); + } + + private static string FormatRfc1123(DateTimeOffset date) + { + return date.ToString("r", System.Globalization.CultureInfo.InvariantCulture); + } + + private IEnumerable GenerateItems(IEnumerable items) + { + foreach (var item in items) + { + yield return new XElement("item", + new XElement("title", item.Title), + new XElement("link", item.Url), + new XElement("guid", new XAttribute("isPermaLink", "true"), item.Url), + new XElement("pubDate", item.PublishDate.ToString("ddd, dd MMM yyyy HH:mm:ss zzz")), + new XElement(_dcNs + "creator", item.Creator), + new XElement("description", + new XCData(item.Description)), + new XElement(_contentNs + "encoded", + new XAttribute(XNamespace.Xml + "lang", "zh-CN"), + new XCData(item.Content)), + new XElement(_slashNs + "comments", item.CommentsCount), + new XElement("comments", item.CommentsUrl), + new XElement(_wfwNs + "commentRss", item.CommentRssUrl) + ); + } + } + } +} diff --git a/Yes.Infrastructure/Rss/XDocumentExtensions.cs b/Yes.Infrastructure/Rss/XDocumentExtensions.cs new file mode 100644 index 0000000..13c109c --- /dev/null +++ b/Yes.Infrastructure/Rss/XDocumentExtensions.cs @@ -0,0 +1,15 @@ +namespace Yes.Infrastructure.Rss +{ + public static class XDocumentExtensions + { + public static string ToStringWithDeclaration(this XDocument doc) + { + if (doc.Declaration == null) + return doc.ToString(); + + using var writer = new StringWriter(); + doc.Save(writer); + return writer.ToString(); + } + } +}