diff --git a/docs/cms-setup.md b/docs/cms-setup.md index c0f607d..e8cfa81 100644 --- a/docs/cms-setup.md +++ b/docs/cms-setup.md @@ -10,6 +10,14 @@ 初始化完成后,在 **Configuration → Features** 启用 `ElectionSystem.Cms.PublicContent`。该模块的数据迁移会自动创建下列固定公开栏目和公共元数据字段;停用模块不会删除已有公开内容。 +## 公开前台接入 + +选举主程序的 Razor Pages 通过 CMS 主机的只读 `GET /api/public-content` 接口读取内容,绝不直连 CMS 数据库。该接口只允许固定公开内容类型,并且只返回已发布版本的标题、正文、发布时间和公开元数据;草稿、历史版本、选民资料、投票状态、线下计票和内部审计记录均不会返回。 + +开发环境中主程序默认读取 `http://localhost:5065`。如 CMS 使用其他地址,配置主程序的 `Cms:PublicContentBaseUrl`(部署时使用环境变量 `Cms__PublicContentBaseUrl`)。CMS 未初始化、模块未启用或短暂不可用时,公开前台显示可恢复的“内容服务暂不可用”提示,选举业务不受影响。 + +内容编辑完成后必须先发布,才会出现在公开首页和 `/content` 各固定栏目中;前台内容详情固定为 `/content/item/{内容 ID}`,不接受 CMS 编辑器自定义路由来覆盖选举系统路由。 + ## 首批内容类型 在 CMS 的 Content Definition 中创建以下类型,并统一启用 Title、HTML/Markdown 正文、SEO、Taxonomy、Autoroute、Publish Later、Archive Later 和版本历史: diff --git a/src/ElectionSystem.Api/Modules/Cms/PublicContentClient.cs b/src/ElectionSystem.Api/Modules/Cms/PublicContentClient.cs new file mode 100644 index 0000000..391955c --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Cms/PublicContentClient.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.Http.Json; + +namespace ElectionSystem.Api.Modules.Cms; + +/// +/// Uses the CMS's deliberately small, read-only public contract. It never connects to the CMS database. +/// +public sealed class PublicContentClient(HttpClient httpClient, ILogger logger) +{ + public async Task>> ListAsync( + string? contentType, + int take, + CancellationToken cancellationToken) + { + var requestUri = contentType is null + ? $"api/public-content?take={take}" + : $"api/public-content?contentType={Uri.EscapeDataString(contentType)}&take={take}"; + + try + { + using var response = await httpClient.GetAsync(requestUri, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return PublicContentFetch>.Unavailable; + } + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadFromJsonAsync>(cancellationToken); + return new PublicContentFetch>(content ?? [], true); + } + catch (HttpRequestException exception) + { + logger.LogWarning(exception, "The CMS public-content endpoint is unavailable."); + return PublicContentFetch>.Unavailable; + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning("The CMS public-content endpoint timed out."); + return PublicContentFetch>.Unavailable; + } + } + + public async Task> GetAsync(string contentItemId, CancellationToken cancellationToken) + { + try + { + using var response = await httpClient.GetAsync($"api/public-content/{Uri.EscapeDataString(contentItemId)}", cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return new PublicContentFetch(null, true); + } + + response.EnsureSuccessStatusCode(); + return new PublicContentFetch( + await response.Content.ReadFromJsonAsync(cancellationToken), + true); + } + catch (HttpRequestException exception) + { + logger.LogWarning(exception, "The CMS public-content endpoint is unavailable."); + return PublicContentFetch.Unavailable; + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning("The CMS public-content endpoint timed out."); + return PublicContentFetch.Unavailable; + } + } +} + +public sealed record PublicContentFetch(T? Value, bool IsAvailable) +{ + public static PublicContentFetch Unavailable { get; } = new(default, false); +} + +public sealed record PublicContentSummary( + string ContentItemId, + string ContentType, + string Title, + string? RoutePath, + DateTime? PublishedUtc); + +public sealed record PublicContentDetail( + string ContentItemId, + string ContentType, + string Title, + string? RoutePath, + string Html, + DateTime? PublishedUtc, + string? ElectionReference, + string? EffectiveFromUtc, + string? EffectiveToUtc); diff --git a/src/ElectionSystem.Api/Pages/Content/Detail.cshtml b/src/ElectionSystem.Api/Pages/Content/Detail.cshtml new file mode 100644 index 0000000..fb42bf9 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Content/Detail.cshtml @@ -0,0 +1,34 @@ +@page "/content/item/{contentItemId}" +@model ElectionSystem.Api.Pages.Content.DetailModel +@{ + ViewData["Title"] = Model.Item?.Title ?? "公开内容"; +} + + + + + + @ViewData["Title"] - 选举与投票信息系统 + + + + +
+ @if (!Model.CmsAvailable) + { +

信息公开服务暂不可用,请稍后刷新页面。

+ } + else if (Model.Item is { } item) + { +

@item.ContentType

+

@item.Title

+ @if (item.PublishedUtc is { } publishedUtc) + { +

发布时间:

+ } +
@Html.Raw(item.Html)
+ } +
+
所有时间和结果以活动正式公告为准。
+ + diff --git a/src/ElectionSystem.Api/Pages/Content/Detail.cshtml.cs b/src/ElectionSystem.Api/Pages/Content/Detail.cshtml.cs new file mode 100644 index 0000000..6df3316 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Content/Detail.cshtml.cs @@ -0,0 +1,19 @@ +using ElectionSystem.Api.Modules.Cms; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages.Content; + +public sealed class DetailModel(PublicContentClient publicContentClient) : PageModel +{ + public PublicContentDetail? Item { get; private set; } + public bool CmsAvailable { get; private set; } + + public async Task OnGetAsync(string contentItemId, CancellationToken cancellationToken) + { + var result = await publicContentClient.GetAsync(contentItemId, cancellationToken); + CmsAvailable = result.IsAvailable; + Item = result.Value; + return !CmsAvailable ? Page() : Item is null ? NotFound() : Page(); + } +} diff --git a/src/ElectionSystem.Api/Pages/Content/Index.cshtml b/src/ElectionSystem.Api/Pages/Content/Index.cshtml new file mode 100644 index 0000000..7921751 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Content/Index.cshtml @@ -0,0 +1,30 @@ +@page "/content/{contentType?}" +@model ElectionSystem.Api.Pages.Content.IndexModel +@{ + ViewData["Title"] = Model.Heading; +} + + + + + + @Model.Heading - 选举与投票信息系统 + + + + +
+

PUBLIC INFORMATION

+

@Model.Heading

+ @if (!Model.CmsAvailable) + { +

信息公开服务暂不可用,请稍后刷新页面。

+ } + else + { + + } +
+
所有时间和结果以活动正式公告为准。
+ + diff --git a/src/ElectionSystem.Api/Pages/Content/Index.cshtml.cs b/src/ElectionSystem.Api/Pages/Content/Index.cshtml.cs new file mode 100644 index 0000000..36cf380 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Content/Index.cshtml.cs @@ -0,0 +1,39 @@ +using ElectionSystem.Api.Modules.Cms; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages.Content; + +public sealed class IndexModel(PublicContentClient publicContentClient) : PageModel +{ + private static readonly IReadOnlyDictionary Sections = new Dictionary(StringComparer.Ordinal) + { + ["ElectionNotice"] = "选举公告", + ["PolicyDocument"] = "规则与政策", + ["CandidateDisclosure"] = "候选人公示", + ["VoterGuide"] = "选民指南", + ["ResultAnnouncement"] = "结果公告", + ["PublicFaq"] = "常见问题", + ["DownloadAsset"] = "资料下载", + ["ArchiveRecord"] = "历史归档" + }; + + public IReadOnlyList Items { get; private set; } = []; + public bool CmsAvailable { get; private set; } + public string Heading { get; private set; } = "信息公开"; + + public async Task OnGetAsync(string? contentType, CancellationToken cancellationToken) + { + string? sectionName = null; + if (contentType is not null && !Sections.TryGetValue(contentType, out sectionName)) + { + return NotFound(); + } + + Heading = sectionName ?? Heading; + var result = await publicContentClient.ListAsync(contentType, 20, cancellationToken); + CmsAvailable = result.IsAvailable; + Items = result.Value ?? []; + return Page(); + } +} diff --git a/src/ElectionSystem.Api/Pages/Content/Notice.cshtml b/src/ElectionSystem.Api/Pages/Content/Notice.cshtml index 6069785..f1f6489 100644 --- a/src/ElectionSystem.Api/Pages/Content/Notice.cshtml +++ b/src/ElectionSystem.Api/Pages/Content/Notice.cshtml @@ -15,9 +15,15 @@

NOTICE

-

公开公告模板

-

这是一个静态 SSR 页面示例。CMS 接入后,已审核发布的公告将从服务端读取,并可配置缓存和 SEO 元数据。

-

未发布内容、未确认线下计票和选民个人数据不会经由公开路由输出。

+

公开公告

+ @if (!Model.CmsAvailable) + { +

公告服务暂不可用,请稍后刷新页面。

+ } + else + { + + }
所有时间和结果以活动正式公告为准。
diff --git a/src/ElectionSystem.Api/Pages/Content/Notice.cshtml.cs b/src/ElectionSystem.Api/Pages/Content/Notice.cshtml.cs index 3153ade..bfd8f1b 100644 --- a/src/ElectionSystem.Api/Pages/Content/Notice.cshtml.cs +++ b/src/ElectionSystem.Api/Pages/Content/Notice.cshtml.cs @@ -1,8 +1,17 @@ +using ElectionSystem.Api.Modules.Cms; using Microsoft.AspNetCore.Mvc.RazorPages; namespace ElectionSystem.Api.Pages.Content; -public sealed class NoticeModel : PageModel +public sealed class NoticeModel(PublicContentClient publicContentClient) : PageModel { - public void OnGet() { } + public IReadOnlyList Notices { get; private set; } = []; + public bool CmsAvailable { get; private set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + var result = await publicContentClient.ListAsync("ElectionNotice", 20, cancellationToken); + CmsAvailable = result.IsAvailable; + Notices = result.Value ?? []; + } } diff --git a/src/ElectionSystem.Api/Pages/Content/_PublicContentList.cshtml b/src/ElectionSystem.Api/Pages/Content/_PublicContentList.cshtml new file mode 100644 index 0000000..1d766bd --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Content/_PublicContentList.cshtml @@ -0,0 +1,22 @@ +@using ElectionSystem.Api.Modules.Cms +@model IReadOnlyList + +@if (Model.Count == 0) +{ +

暂无已发布内容。

+} +else +{ +
    + @foreach (var item in Model) + { +
  1. + @item.Title + @if (item.PublishedUtc is { } publishedUtc) + { + + } +
  2. + } +
+} diff --git a/src/ElectionSystem.Api/Pages/Index.cshtml b/src/ElectionSystem.Api/Pages/Index.cshtml index ba9a985..3fdc36a 100644 --- a/src/ElectionSystem.Api/Pages/Index.cshtml +++ b/src/ElectionSystem.Api/Pages/Index.cshtml @@ -14,14 +14,35 @@

PUBLIC INFORMATION

公开内容以服务端渲染快速交付

-

公告、候选人公示和正式结果将由 Razor Pages 呈现;具体投票和管理交互将按需嵌入 Blazor,并始终由服务端校验资格与规则。

- 查看公告模板 +

公告、候选人公示和正式结果由 CMS 审核发布后,通过 Razor Pages 快速呈现;具体投票和管理交互将按需嵌入 Blazor,并始终由服务端校验资格与规则。

+ 查看信息公开
+ @if (!Model.CmsAvailable) + { + + } + else + { +
+
+

NOTICE

最新公告

全部公告
+ +
+
+

DISCLOSURE

候选人公示

全部公示
+ +
+
+

RESULT

结果公告

全部结果
+ +
+
+ }
所有时间和结果以活动正式公告为准。
diff --git a/src/ElectionSystem.Api/Pages/Index.cshtml.cs b/src/ElectionSystem.Api/Pages/Index.cshtml.cs index 7b41d9c..2c0e21c 100644 --- a/src/ElectionSystem.Api/Pages/Index.cshtml.cs +++ b/src/ElectionSystem.Api/Pages/Index.cshtml.cs @@ -1,8 +1,25 @@ +using ElectionSystem.Api.Modules.Cms; using Microsoft.AspNetCore.Mvc.RazorPages; namespace ElectionSystem.Api.Pages; -public sealed class IndexModel : PageModel +public sealed class IndexModel(PublicContentClient publicContentClient) : PageModel { - public void OnGet() { } + public IReadOnlyList Notices { get; private set; } = []; + public IReadOnlyList CandidateDisclosures { get; private set; } = []; + public IReadOnlyList ResultAnnouncements { get; private set; } = []; + public bool CmsAvailable { get; private set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + var results = await Task.WhenAll( + publicContentClient.ListAsync("ElectionNotice", 3, cancellationToken), + publicContentClient.ListAsync("CandidateDisclosure", 3, cancellationToken), + publicContentClient.ListAsync("ResultAnnouncement", 3, cancellationToken)); + + CmsAvailable = results.All(result => result.IsAvailable); + Notices = results[0].Value ?? []; + CandidateDisclosures = results[1].Value ?? []; + ResultAnnouncements = results[2].Value ?? []; + } } diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs index 37e9100..0b0c05e 100644 --- a/src/ElectionSystem.Api/Program.cs +++ b/src/ElectionSystem.Api/Program.cs @@ -2,6 +2,7 @@ using ElectionSystem.Api.Common.Errors; using ElectionSystem.Api.Components; using ElectionSystem.Api.Infrastructure.Auditing; using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Cms; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.EntityFrameworkCore; @@ -30,6 +31,12 @@ builder.Services.AddDbContext((serviceProvider, options) => }); builder.Services.AddHealthChecks().AddDbContextCheck("mysql"); builder.Services.AddAuthorization(); +builder.Services.AddHttpClient(client => +{ + var cmsBaseUrl = builder.Configuration["Cms:PublicContentBaseUrl"] ?? "http://localhost:5065"; + client.BaseAddress = new Uri(cmsBaseUrl, UriKind.Absolute); + client.Timeout = TimeSpan.FromSeconds(5); +}); var app = builder.Build(); diff --git a/src/ElectionSystem.Api/appsettings.Development.json b/src/ElectionSystem.Api/appsettings.Development.json index 6ae6dc6..fdde774 100644 --- a/src/ElectionSystem.Api/appsettings.Development.json +++ b/src/ElectionSystem.Api/appsettings.Development.json @@ -2,6 +2,9 @@ "ConnectionStrings": { "ElectionDatabase": "Server=localhost;Port=3306;Database=election;User ID=election;" }, + "Cms": { + "PublicContentBaseUrl": "http://localhost:5065" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/ElectionSystem.Api/wwwroot/css/site.css b/src/ElectionSystem.Api/wwwroot/css/site.css index 9cca308..48d1cb4 100644 --- a/src/ElectionSystem.Api/wwwroot/css/site.css +++ b/src/ElectionSystem.Api/wwwroot/css/site.css @@ -14,5 +14,22 @@ body { margin: 0; min-height: 100vh; } .option { display: block; padding: .65rem 0; border-bottom: 1px solid #e8eeeb; } .error { color: #a02d2d; } .article { max-width: 52rem; font-size: 1.08rem; line-height: 1.7; } +.disclosure-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; padding-top: 0; } +.disclosure-section { padding: 1.35rem; background: #fff; border-top: .28rem solid #16717e; box-shadow: 0 1px 0 #d6dfdc; } +.section-heading { display: grid; grid-template-columns: 1fr auto; column-gap: 1rem; align-items: baseline; } +.section-heading .eyebrow { grid-column: 1 / -1; margin: 0 0 .35rem; } +.section-heading h2 { margin: 0; font-size: 1.3rem; } +.section-heading a { color: #12606b; font-size: .88rem; } +.content-list { margin: 1rem 0 0; padding: 0; list-style: none; } +.content-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1rem; padding: .85rem 0; border-bottom: 1px solid #e8eeeb; } +.content-list a { color: #173d47; font-weight: 650; text-decoration: none; } +.content-list a:hover { color: #16717e; text-decoration: underline; } +.content-list time, .published-at { color: #52616b; font-size: .88rem; white-space: nowrap; } +.status-note, .empty-state { margin: 0; padding: 1rem 1.15rem; color: #35545d; background: #eaf3f2; border-left: .25rem solid #16717e; } +.cms-body { margin-top: 2rem; overflow-wrap: anywhere; } +.cms-body img, .cms-body video { max-width: 100%; height: auto; } +.cms-body table { max-width: 100%; border-collapse: collapse; } +.cms-body th, .cms-body td { padding: .5rem; border: 1px solid #d6dfdc; } .site-footer { padding: 2rem max(1.5rem, calc((100vw - 72rem) / 2)); color: #52616b; font-size: .9rem; } +@media (max-width: 52rem) { .disclosure-grid { grid-template-columns: 1fr; } } @media (max-width: 40rem) { .site-header { align-items: flex-start; flex-direction: column; } .container { padding: 2.5rem 0; } } diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Controllers/PublicContentController.cs b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Controllers/PublicContentController.cs new file mode 100644 index 0000000..e43cab1 --- /dev/null +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Controllers/PublicContentController.cs @@ -0,0 +1,103 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Records; +using YesSql; + +namespace ElectionSystem.Cms.PublicContent.Controllers; + +/// +/// Read-only boundary for the public site. Drafts, historical versions and non-public fields never cross this boundary. +/// +[ApiController] +[Route("api/public-content")] +public sealed class PublicContentController(ISession session) : ControllerBase +{ + private static readonly HashSet PublicContentTypes = new(StringComparer.Ordinal) + { + "ElectionNotice", + "PolicyDocument", + "CandidateDisclosure", + "VoterGuide", + "ResultAnnouncement", + "PublicFaq", + "DownloadAsset", + "ArchiveRecord" + }; + + [HttpGet] + [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)] + public async Task>> ListAsync( + [FromQuery] string? contentType, + [FromQuery] int take = 6) + { + if (contentType is not null && !PublicContentTypes.Contains(contentType)) + { + return BadRequest("Unsupported public content type."); + } + + var safeTake = Math.Clamp(take, 1, 20); + var query = session.Query(index => index.Published); + if (contentType is not null) + { + query = query.Where(index => index.ContentType == contentType); + } + + var items = await query + .OrderByDescending(index => index.PublishedUtc) + .Take(safeTake) + .ListAsync(); + + return Ok(items.Select(ToSummary).ToArray()); + } + + [HttpGet("{contentItemId}")] + [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)] + public async Task> GetAsync(string contentItemId) + { + var item = await session.Query(index => + index.ContentItemId == contentItemId && index.Published) + .FirstOrDefaultAsync(); + + return item is null || !PublicContentTypes.Contains(item.ContentType) ? NotFound() : Ok(ToDetail(item)); + } + + private static PublicContentSummary ToSummary(ContentItem item) => new( + item.ContentItemId, + item.ContentType, + ReadString(item.Content, "TitlePart", "Title") ?? "未命名公开内容", + ReadString(item.Content, "AutoroutePart", "Path"), + item.PublishedUtc); + + private static PublicContentDetail ToDetail(ContentItem item) => new( + item.ContentItemId, + item.ContentType, + ReadString(item.Content, "TitlePart", "Title") ?? "未命名公开内容", + ReadString(item.Content, "AutoroutePart", "Path"), + ReadString(item.Content, "HtmlBodyPart", "Html") ?? string.Empty, + item.PublishedUtc, + ReadString(item.Content, "ElectionPublicMetadataPart", "ElectionReference"), + ReadString(item.Content, "ElectionPublicMetadataPart", "EffectiveFromUtc"), + ReadString(item.Content, "ElectionPublicMetadataPart", "EffectiveToUtc")); + + private static string? ReadString(JsonObject content, string partName, string fieldName) => + content[partName]?[fieldName]?.GetValue(); +} + +public sealed record PublicContentSummary( + string ContentItemId, + string ContentType, + string Title, + string? RoutePath, + DateTime? PublishedUtc); + +public sealed record PublicContentDetail( + string ContentItemId, + string ContentType, + string Title, + string? RoutePath, + string Html, + DateTime? PublishedUtc, + string? ElectionReference, + string? EffectiveFromUtc, + string? EffectiveToUtc); diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/ElectionSystem.Cms.PublicContent.csproj b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/ElectionSystem.Cms.PublicContent.csproj index 05b1a34..5b00c2e 100644 --- a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/ElectionSystem.Cms.PublicContent.csproj +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/ElectionSystem.Cms.PublicContent.csproj @@ -14,6 +14,7 @@ +