GET /api/public/home
GET /api/public/notices/{id}
SQLite / MySQL 双数据库只读连接
.env 与现有数据库配置兼容
公告 HTML 安全清洗
学校、班级、考试、科目、报名及系统通知聚合
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Eis.Application.Public;
|
||||
|
||||
public interface IPublicQueryService
|
||||
{
|
||||
Task<JsonObject> GetHomeAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<JsonObject?> GetNoticeAsync(string id, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Eis.Application.Public;
|
||||
|
||||
public interface IPublicSiteConfiguration
|
||||
{
|
||||
PublicSiteBranding Branding { get; }
|
||||
}
|
||||
|
||||
public sealed record PublicSiteBranding(
|
||||
PublicOrganization Organization,
|
||||
PublicSiteCopy SiteCopy);
|
||||
|
||||
public sealed record PublicOrganization(
|
||||
string Name,
|
||||
string Code,
|
||||
string Phone,
|
||||
string Address,
|
||||
string Email);
|
||||
|
||||
public sealed record PublicSiteCopy(
|
||||
string HeroEyebrow,
|
||||
string HeroTitle,
|
||||
string HeroHighlight,
|
||||
string HeroDescription,
|
||||
string FooterNotice);
|
||||
@@ -0,0 +1,92 @@
|
||||
using MySqlConnector;
|
||||
|
||||
namespace Eis.Infrastructure.Data;
|
||||
|
||||
public sealed class DatabaseOptions
|
||||
{
|
||||
private DatabaseOptions(string client, string? sqlitePath, string? mysqlConnectionString)
|
||||
{
|
||||
Client = client;
|
||||
SqlitePath = sqlitePath;
|
||||
MySqlConnectionString = mysqlConnectionString;
|
||||
}
|
||||
|
||||
public string Client { get; }
|
||||
|
||||
public string? SqlitePath { get; }
|
||||
|
||||
public string? MySqlConnectionString { get; }
|
||||
|
||||
public static DatabaseOptions FromEnvironment(string applicationRoot, bool production)
|
||||
{
|
||||
var client = (Environment.GetEnvironmentVariable("DATABASE_CLIENT")
|
||||
?? (production ? "mysql" : "sqlite")).Trim().ToLowerInvariant();
|
||||
|
||||
return client switch
|
||||
{
|
||||
"sqlite" => new DatabaseOptions(client, ResolveSqlitePath(applicationRoot), null),
|
||||
"mysql" => new DatabaseOptions(client, null, BuildMySqlConnectionString()),
|
||||
_ => throw new InvalidOperationException($"不支持的数据库类型:{client}")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveSqlitePath(string applicationRoot)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SQLITE_PATH");
|
||||
var path = string.IsNullOrWhiteSpace(configured) ? Path.Combine("data", "exam.sqlite") : configured;
|
||||
return Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(applicationRoot, path));
|
||||
}
|
||||
|
||||
private static string BuildMySqlConnectionString()
|
||||
{
|
||||
var connectionUrl = Environment.GetEnvironmentVariable("DATABASE_URL");
|
||||
if (!string.IsNullOrWhiteSpace(connectionUrl))
|
||||
{
|
||||
return BuildFromUrl(connectionUrl);
|
||||
}
|
||||
|
||||
var host = Environment.GetEnvironmentVariable("MYSQL_HOST");
|
||||
var user = Environment.GetEnvironmentVariable("MYSQL_USER");
|
||||
var database = Environment.GetEnvironmentVariable("MYSQL_DATABASE");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(database))
|
||||
{
|
||||
throw new InvalidOperationException("MySQL 配置不完整:请设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE");
|
||||
}
|
||||
|
||||
var builder = CreateMySqlBuilder();
|
||||
builder.Server = host;
|
||||
builder.Port = uint.TryParse(Environment.GetEnvironmentVariable("MYSQL_PORT"), out var port) ? port : 3306;
|
||||
builder.UserID = user;
|
||||
builder.Password = Environment.GetEnvironmentVariable("MYSQL_PASSWORD") ?? string.Empty;
|
||||
builder.Database = database;
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private static string BuildFromUrl(string connectionUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(connectionUrl, UriKind.Absolute, out var uri) ||
|
||||
!uri.Scheme.Equals("mysql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("DATABASE_URL 必须是有效的 mysql:// 地址");
|
||||
}
|
||||
|
||||
var credentials = uri.UserInfo.Split(':', 2);
|
||||
var builder = CreateMySqlBuilder();
|
||||
builder.Server = uri.Host;
|
||||
builder.Port = uri.IsDefaultPort ? 3306u : checked((uint)uri.Port);
|
||||
builder.UserID = Uri.UnescapeDataString(credentials[0]);
|
||||
builder.Password = credentials.Length > 1 ? Uri.UnescapeDataString(credentials[1]) : string.Empty;
|
||||
builder.Database = Uri.UnescapeDataString(uri.AbsolutePath.Trim('/'));
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private static MySqlConnectionStringBuilder CreateMySqlBuilder() => new()
|
||||
{
|
||||
CharacterSet = "utf8mb4",
|
||||
ConnectionTimeout = 10,
|
||||
DefaultCommandTimeout = 30,
|
||||
Pooling = true,
|
||||
MaximumPoolSize = uint.TryParse(Environment.GetEnvironmentVariable("MYSQL_CONNECTION_LIMIT"), out var limit) ? limit : 10,
|
||||
ConnectionReset = true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Data.Common;
|
||||
|
||||
namespace Eis.Infrastructure.Data;
|
||||
|
||||
public interface IRelationalConnectionFactory
|
||||
{
|
||||
ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using MySqlConnector;
|
||||
|
||||
namespace Eis.Infrastructure.Data;
|
||||
|
||||
public sealed class RelationalConnectionFactory(DatabaseOptions options) : IRelationalConnectionFactory
|
||||
{
|
||||
public async ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
DbConnection connection = options.Client switch
|
||||
{
|
||||
"sqlite" => new SqliteConnection(new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = options.SqlitePath,
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
ForeignKeys = true,
|
||||
DefaultTimeout = 5
|
||||
}.ConnectionString),
|
||||
"mysql" => new MySqlConnection(options.MySqlConnectionString),
|
||||
_ => throw new InvalidOperationException($"不支持的数据库类型:{options.Client}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Eis.Application.Public;
|
||||
using Eis.Infrastructure.Data;
|
||||
using Eis.Infrastructure.Public;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Eis.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddEisInfrastructure(
|
||||
this IServiceCollection services,
|
||||
DatabaseOptions databaseOptions)
|
||||
{
|
||||
services.AddSingleton(databaseOptions);
|
||||
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
|
||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,10 @@
|
||||
<ProjectReference Include="..\Eis.Application\Eis.Application.csproj" />
|
||||
<ProjectReference Include="..\Eis.Domain\Eis.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="MySqlConnector" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,7 +7,7 @@ public static class MigrationFeatureCatalog
|
||||
{
|
||||
public static IReadOnlyList<MigrationFeature> Current { get; } =
|
||||
[
|
||||
new(FeatureArea.Public, false, "/api/public"),
|
||||
new(FeatureArea.Public, false, "/api/public/home, /api/public/notices/{id}(部分原生)"),
|
||||
new(FeatureArea.Authentication, false, "/api/auth"),
|
||||
new(FeatureArea.Candidate, false, "/api/candidate"),
|
||||
new(FeatureArea.Administration, false, "/api/admin"),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Eis.Infrastructure.Tests")]
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace Eis.Infrastructure.Public;
|
||||
|
||||
internal sealed partial class NoticeContentFormatter
|
||||
{
|
||||
private static readonly HashSet<string> AllowedTags = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"p", "br", "h2", "h3", "h4", "strong", "em", "u", "s", "ul", "ol", "li", "blockquote", "a",
|
||||
"figure", "figcaption", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> DangerousTags = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"script", "style", "iframe", "object", "embed", "template"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AllowedFigureClasses = new(StringComparer.Ordinal)
|
||||
{
|
||||
"image", "table", "image-style-inline", "image-style-block", "image-style-side",
|
||||
"image-style-align-left", "image-style-align-right", "image-style-block-align-left", "image-style-block-align-right"
|
||||
};
|
||||
|
||||
public string Sanitize(string? value)
|
||||
{
|
||||
var source = (value ?? string.Empty).Trim();
|
||||
if (source.Length > 20_000)
|
||||
{
|
||||
source = source[..20_000];
|
||||
}
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var document = parser.ParseDocument($"<!doctype html><html><body>{source}</body></html>");
|
||||
if (document.Body is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
SanitizeChildren(document.Body);
|
||||
return document.Body.InnerHtml;
|
||||
}
|
||||
|
||||
public string ContentHtml(string? value)
|
||||
{
|
||||
var source = (value ?? string.Empty).Trim();
|
||||
if (source.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (AllowedTagRegex().IsMatch(source))
|
||||
{
|
||||
return Sanitize(source);
|
||||
}
|
||||
|
||||
return string.Join(string.Empty, ParagraphRegex().Split(source)
|
||||
.Select(paragraph => $"<p>{WebUtility.HtmlEncode(paragraph).Replace("\r\n", "<br>", StringComparison.Ordinal).Replace("\n", "<br>", StringComparison.Ordinal)}</p>"));
|
||||
}
|
||||
|
||||
private static void SanitizeChildren(INode parent)
|
||||
{
|
||||
foreach (var child in parent.ChildNodes.ToArray())
|
||||
{
|
||||
if (child.NodeType == NodeType.Comment)
|
||||
{
|
||||
child.Parent?.RemoveChild(child);
|
||||
continue;
|
||||
}
|
||||
if (child is not IElement element)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!AllowedTags.Contains(element.LocalName))
|
||||
{
|
||||
if (DangerousTags.Contains(element.LocalName))
|
||||
{
|
||||
element.Parent?.RemoveChild(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
SanitizeChildren(element);
|
||||
var container = element.Parent;
|
||||
if (container is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (var grandchild in element.ChildNodes.ToArray())
|
||||
{
|
||||
container.InsertBefore(grandchild, element);
|
||||
}
|
||||
container.RemoveChild(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
SanitizeAttributes(element);
|
||||
SanitizeChildren(element);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SanitizeAttributes(IElement element)
|
||||
{
|
||||
var allowed = element.LocalName switch
|
||||
{
|
||||
"a" => new HashSet<string>(["href", "target", "rel"], StringComparer.OrdinalIgnoreCase),
|
||||
"figure" => new HashSet<string>(["class"], StringComparer.OrdinalIgnoreCase),
|
||||
"img" => new HashSet<string>(["src", "alt"], StringComparer.OrdinalIgnoreCase),
|
||||
"th" or "td" => new HashSet<string>(["colspan", "rowspan"], StringComparer.OrdinalIgnoreCase),
|
||||
_ => []
|
||||
};
|
||||
foreach (var attribute in element.Attributes.ToArray())
|
||||
{
|
||||
if (!allowed.Contains(attribute.Name))
|
||||
{
|
||||
element.RemoveAttribute(attribute.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.LocalName == "a")
|
||||
{
|
||||
var href = element.GetAttribute("href");
|
||||
if (!IsSafeLink(href))
|
||||
{
|
||||
element.RemoveAttribute("href");
|
||||
}
|
||||
if (!string.Equals(element.GetAttribute("target"), "_blank", StringComparison.Ordinal))
|
||||
{
|
||||
element.RemoveAttribute("target");
|
||||
}
|
||||
element.SetAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
else if (element.LocalName == "img")
|
||||
{
|
||||
var source = element.GetAttribute("src");
|
||||
if (!Uri.TryCreate(source, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
element.RemoveAttribute("src");
|
||||
}
|
||||
}
|
||||
else if (element.LocalName == "figure")
|
||||
{
|
||||
var classes = (element.GetAttribute("class") ?? string.Empty)
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(AllowedFigureClasses.Contains)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (classes.Length == 0)
|
||||
{
|
||||
element.RemoveAttribute("class");
|
||||
}
|
||||
else
|
||||
{
|
||||
element.SetAttribute("class", string.Join(' ', classes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSafeLink(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (value.StartsWith('/') || value.StartsWith('#') || value.StartsWith('?'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme is "http" or "https" or "mailto" or "tel";
|
||||
}
|
||||
|
||||
[GeneratedRegex("<\\/?(?:p|h[2-4]|strong|em|u|s|ul|ol|li|blockquote|a|br|figure|figcaption|img|table|thead|tbody|tfoot|tr|th|td)\\b", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex AllowedTagRegex();
|
||||
|
||||
[GeneratedRegex("\\r?\\n{2,}")]
|
||||
private static partial Regex ParagraphRegex();
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Application.Public;
|
||||
using Eis.Infrastructure.Data;
|
||||
|
||||
namespace Eis.Infrastructure.Public;
|
||||
|
||||
public sealed class PublicQueryService(
|
||||
IRelationalConnectionFactory connectionFactory,
|
||||
IPublicSiteConfiguration siteConfiguration) : IPublicQueryService
|
||||
{
|
||||
private readonly NoticeContentFormatter _noticeFormatter = new();
|
||||
|
||||
public async Task<JsonObject> GetHomeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
var metadata = await QuerySingleAsync(connection,
|
||||
"SELECT self_registration_enabled FROM schema_metadata WHERE id = 1",
|
||||
reader => ReadBoolean(reader, "self_registration_enabled"), cancellationToken);
|
||||
var schools = await QueryAsync(connection,
|
||||
"SELECT id, name, code, address, is_source_school, is_admission_school, active FROM schools ORDER BY name, id",
|
||||
ReadSchool, cancellationToken);
|
||||
var classes = await QueryAsync(connection,
|
||||
"SELECT id, school_id, name, grade, active FROM school_classes ORDER BY school_id, name, id",
|
||||
ReadClass, cancellationToken);
|
||||
var notices = await QueryAsync(connection,
|
||||
"SELECT id, title, summary, content, category, pinned, status, publish_at, created_at, author FROM notices WHERE status = 'published' ORDER BY publish_at, created_at, id",
|
||||
ReadNotice, cancellationToken);
|
||||
var exams = await QueryAsync(connection,
|
||||
"SELECT id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at FROM exams WHERE status = 'published' AND archived_at IS NULL ORDER BY created_at, id",
|
||||
ReadExam, cancellationToken);
|
||||
var subjects = await QueryAsync(connection,
|
||||
"SELECT id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position FROM exam_subjects ORDER BY exam_id, position, id",
|
||||
ReadSubject, cancellationToken);
|
||||
var registrationCounts = await QueryAsync(connection,
|
||||
"SELECT exam_id, COUNT(*) AS item_count FROM registrations GROUP BY exam_id",
|
||||
reader => new CountByKey(ReadString(reader, "exam_id"), ReadInt64(reader, "item_count")), cancellationToken);
|
||||
var candidateCount = await QuerySingleAsync(connection,
|
||||
"SELECT COUNT(*) AS item_count FROM candidate_profiles",
|
||||
reader => ReadInt64(reader, "item_count"), cancellationToken);
|
||||
var registrationCount = await QuerySingleAsync(connection,
|
||||
"SELECT COUNT(*) AS item_count FROM registrations",
|
||||
reader => ReadInt64(reader, "item_count"), cancellationToken);
|
||||
var admissionRecords = await QueryAsync(connection,
|
||||
"SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records ORDER BY created_at, id",
|
||||
ReadAdmissionRecord, cancellationToken);
|
||||
|
||||
var schoolNames = schools.ToDictionary(item => item.Id, item => item.Name, StringComparer.Ordinal);
|
||||
var examNames = exams.ToDictionary(item => item.Id, item => item.Name, StringComparer.Ordinal);
|
||||
var subjectGroups = subjects.GroupBy(item => item.ExamId).ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
|
||||
var countsByExam = registrationCounts.ToDictionary(item => item.Key, item => item.Count, StringComparer.Ordinal);
|
||||
|
||||
var noticeItems = new List<NoticeItem>();
|
||||
noticeItems.AddRange(notices.Select(ToManualNotice));
|
||||
noticeItems.AddRange(BuildSystemNotices(admissionRecords, examNames, schoolNames));
|
||||
noticeItems.Sort(CompareNotices);
|
||||
|
||||
var branding = siteConfiguration.Branding;
|
||||
return new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["organization"] = new JsonObject
|
||||
{
|
||||
["name"] = branding.Organization.Name,
|
||||
["code"] = branding.Organization.Code,
|
||||
["phone"] = branding.Organization.Phone,
|
||||
["address"] = branding.Organization.Address,
|
||||
["email"] = branding.Organization.Email
|
||||
},
|
||||
["siteCopy"] = new JsonObject
|
||||
{
|
||||
["heroEyebrow"] = branding.SiteCopy.HeroEyebrow,
|
||||
["heroTitle"] = branding.SiteCopy.HeroTitle,
|
||||
["heroHighlight"] = branding.SiteCopy.HeroHighlight,
|
||||
["heroDescription"] = branding.SiteCopy.HeroDescription,
|
||||
["footerNotice"] = branding.SiteCopy.FooterNotice
|
||||
},
|
||||
["schools"] = new JsonArray(schools.Where(item => item.Active && item.IsSourceSchool).Select(ToSchoolJson).ToArray()),
|
||||
["classes"] = new JsonArray(classes.Where(item => item.Active).Select(ToClassJson).ToArray()),
|
||||
["selfRegistrationEnabled"] = metadata,
|
||||
["notices"] = new JsonArray(noticeItems.Select(item => item.Json).ToArray()),
|
||||
["exams"] = new JsonArray(exams.Select(exam => ToExamJson(
|
||||
exam,
|
||||
subjectGroups.GetValueOrDefault(exam.Id) ?? [],
|
||||
countsByExam.GetValueOrDefault(exam.Id))).ToArray()),
|
||||
["stats"] = new JsonObject
|
||||
{
|
||||
["candidates"] = candidateCount,
|
||||
["exams"] = exams.Count,
|
||||
["registrations"] = registrationCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<JsonObject?> GetNoticeAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var home = await GetHomeAsync(cancellationToken);
|
||||
var notices = home["notices"]?.AsArray();
|
||||
return notices?
|
||||
.OfType<JsonObject>()
|
||||
.FirstOrDefault(item => string.Equals(item["id"]?.GetValue<string>(), id, StringComparison.Ordinal))?
|
||||
.DeepClone()
|
||||
.AsObject();
|
||||
}
|
||||
|
||||
private NoticeItem ToManualNotice(NoticeRow row)
|
||||
{
|
||||
var sanitized = _noticeFormatter.Sanitize(row.Content);
|
||||
var json = new JsonObject
|
||||
{
|
||||
["id"] = row.Id,
|
||||
["title"] = row.Title,
|
||||
["summary"] = row.Summary,
|
||||
["content"] = sanitized,
|
||||
["category"] = row.Category,
|
||||
["pinned"] = row.Pinned,
|
||||
["status"] = row.Status,
|
||||
["publishAt"] = JsonValue.Create(row.PublishAt),
|
||||
["createdAt"] = JsonValue.Create(row.CreatedAt),
|
||||
["author"] = row.Author,
|
||||
["contentHtml"] = _noticeFormatter.ContentHtml(row.Content)
|
||||
};
|
||||
return new NoticeItem(json, row.Pinned, ParseDate(row.PublishAt));
|
||||
}
|
||||
|
||||
private static IEnumerable<NoticeItem> BuildSystemNotices(
|
||||
IReadOnlyCollection<AdmissionRecordRow> records,
|
||||
IReadOnlyDictionary<string, string> examNames,
|
||||
IReadOnlyDictionary<string, string> schoolNames)
|
||||
{
|
||||
string ExamName(string id) => examNames.GetValueOrDefault(id) ?? "未知考试";
|
||||
string SchoolName(string? id) => id is not null ? schoolNames.GetValueOrDefault(id) ?? "未知学校" : "未知学校";
|
||||
var settings = records.Where(item => item.Kind == "setting")
|
||||
.GroupBy(item => item.ExamId)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal);
|
||||
var output = new List<NoticeItem>();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
var visible = GetBoolean(record.Payload, "publicVisible", true);
|
||||
if (!visible)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? sourceType = null;
|
||||
string? category = null;
|
||||
string? title = null;
|
||||
string? summary = null;
|
||||
string? content = null;
|
||||
string? publishAt = null;
|
||||
|
||||
if (record.Kind == "plan" && record.Status == "approved")
|
||||
{
|
||||
sourceType = "plan";
|
||||
category = "招生计划";
|
||||
title = $"{ExamName(record.ExamId)} · {SchoolName(record.SchoolId)}招生计划公示";
|
||||
summary = "招生计划审核通过,类别人数与指标分配已经公开。";
|
||||
publishAt = GetString(record.Payload, "reviewedAt") ?? record.UpdatedAt;
|
||||
var listItems = GetArray(record.Payload, "categories").OfType<JsonObject>()
|
||||
.Select(item => $"<li>{Html(GetString(item, "name"))}:{GetNumber(item, "quota"):0.##} 人</li>");
|
||||
content = $"<p>{Html(SchoolName(record.SchoolId))}招生计划已经审核通过。</p><ul>{string.Concat(listItems)}</ul>";
|
||||
}
|
||||
else if (record.Kind == "qualification_publication" && record.Status == "published")
|
||||
{
|
||||
sourceType = "qualification";
|
||||
category = "指标资格";
|
||||
title = $"{ExamName(record.ExamId)} · {SchoolName(record.SchoolId)}指标分配资格公示";
|
||||
summary = "生源学校资格确认完成,系统已生成指标分配资格公示。";
|
||||
publishAt = GetString(record.Payload, "publishedAt") ?? record.UpdatedAt;
|
||||
content = $"<p>{Html(SchoolName(record.SchoolId))}指标分配资格确认已经完成,共 {GetArray(record.Payload, "rows").Count} 条记录。</p>";
|
||||
}
|
||||
else if (record.Kind == "notification" && record.Status == "published" &&
|
||||
GetString(record.Payload, "type") == "admission_round_publication")
|
||||
{
|
||||
var round = Math.Max(1, (int)GetNumber(record.Payload, "round", 1));
|
||||
var rowCount = GetArray(record.Payload, "rows").Count;
|
||||
sourceType = "admission";
|
||||
category = "录取名单";
|
||||
title = $"{ExamName(record.ExamId)}第 {round} 轮录取名单公示";
|
||||
summary = $"第 {round} 轮录取通知书已签发,共 {rowCount} 名考生进入本轮录取公示。";
|
||||
publishAt = GetString(record.Payload, "publishedAt") ?? record.UpdatedAt;
|
||||
content = $"<p>{Html(ExamName(record.ExamId))}第 {round} 轮录取工作已经完成,共 {rowCount} 名考生正式录取。</p>";
|
||||
}
|
||||
else if (record.Kind == "setting" && record.Status == "completed" && GetBoolean(record.Payload, "autoPublish", true))
|
||||
{
|
||||
sourceType = "admission";
|
||||
category = "录取名单";
|
||||
title = $"{ExamName(record.ExamId)}最终录取名单";
|
||||
summary = "录取与报到决策已经办结,最终录取结果已自动公开。";
|
||||
publishAt = GetString(record.Payload, "completedAt") ?? record.UpdatedAt;
|
||||
content = $"<p>{Html(ExamName(record.ExamId))}录取工作已经完成,请在招生录取公示中查询脱敏结果。</p>";
|
||||
}
|
||||
else if (record.Kind == "cutoff_publication" && record.Status == "published" &&
|
||||
(!settings.TryGetValue(record.ExamId, out var setting) || GetBoolean(setting.Payload, "autoPublish", true)))
|
||||
{
|
||||
sourceType = "cutoff";
|
||||
category = "录取分数线";
|
||||
title = $"{ExamName(record.ExamId)}录取分数线";
|
||||
summary = "各招生学校和类别录取分数线已经由系统汇总发布。";
|
||||
publishAt = GetString(record.Payload, "publishedAt") ?? record.UpdatedAt;
|
||||
content = $"<p>系统已汇总 {GetArray(record.Payload, "rows").Count} 个学校招生类别的录取分数线。</p>";
|
||||
}
|
||||
else if (record.Kind == "notification" && record.UserId is null && record.Status == "approved" &&
|
||||
GetString(record.Payload, "type") == "admission_reporting")
|
||||
{
|
||||
var statistics = GetObject(record.Payload, "statistics");
|
||||
var supplement = GetString(record.Payload, "supplementDecision") == "supplement";
|
||||
var totalQuota = GetNumber(statistics, "totalQuota");
|
||||
var reported = GetNumber(statistics, "reportedCount");
|
||||
var reportingRate = GetNumber(statistics, "reportingRate");
|
||||
sourceType = "reporting";
|
||||
category = "考生报到";
|
||||
title = supplement
|
||||
? $"{ExamName(record.ExamId)} · {SchoolName(record.SchoolId)}考生报到情况及补录说明"
|
||||
: $"{ExamName(record.ExamId)} · {SchoolName(record.SchoolId)}考生报到情况公示";
|
||||
summary = $"计划 {totalQuota:0.##} 人,已报到 {reported:0.##} 人,完成率 {reportingRate:0.##}%。";
|
||||
publishAt = GetString(record.Payload, "approvedAt") ?? record.UpdatedAt;
|
||||
var decision = supplement ? "学校申请补录并已获批准。" : GetString(record.Payload, "decisionNote") ?? "本轮不进行补录。";
|
||||
content = $"<p>{Html(summary)}</p><p>{Html(decision)}</p><ul><li>正式录取:{GetNumber(statistics, "finalCount"):0.##} 人</li><li>已报到:{reported:0.##} 人</li><li>未报到:{GetNumber(statistics, "notReportedCount"):0.##} 人</li><li>计划缺额:{GetNumber(statistics, "reportingGap"):0.##} 人</li></ul><p>{Html(GetString(record.Payload, "approvalNote"))}</p>";
|
||||
}
|
||||
|
||||
if (sourceType is null || publishAt is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = new JsonObject
|
||||
{
|
||||
["id"] = $"system-{sourceType}-{record.Id}",
|
||||
["noticeId"] = $"system-{sourceType}-{record.Id}",
|
||||
["sourceType"] = sourceType,
|
||||
["schoolId"] = JsonValue.Create(record.SchoolId),
|
||||
["examId"] = record.ExamId,
|
||||
["category"] = category,
|
||||
["title"] = title,
|
||||
["summary"] = summary,
|
||||
["content"] = content,
|
||||
["author"] = "系统自动发布",
|
||||
["publishAt"] = publishAt,
|
||||
["publishedAt"] = publishAt,
|
||||
["pinned"] = false,
|
||||
["visible"] = true,
|
||||
["status"] = "visible"
|
||||
};
|
||||
output.Add(new NoticeItem(json, false, ParseDate(publishAt)));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static JsonObject ToExamJson(ExamRow exam, IReadOnlyCollection<SubjectRow> subjects, long registrationCount)
|
||||
{
|
||||
var subjectJson = subjects.Select(subject =>
|
||||
{
|
||||
var passRule = subject.PassRule == "score_ratio" ? "rank_percent" : subject.PassRule;
|
||||
var passValue = subject.PassValue;
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = subject.Id,
|
||||
["name"] = subject.Name,
|
||||
["date"] = subject.Date,
|
||||
["start"] = subject.Start,
|
||||
["end"] = subject.End,
|
||||
["fee"] = subject.Fee,
|
||||
["fullScore"] = subject.FullScore,
|
||||
["passRule"] = passRule,
|
||||
["passValue"] = passValue,
|
||||
["passScore"] = passRule == "fixed_score" ? JsonValue.Create(Math.Round(passValue, 2)) : null,
|
||||
["order"] = subject.Position
|
||||
};
|
||||
}).ToArray();
|
||||
var totalScore = subjects.Sum(subject => subject.FullScore);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var registrationState = exam.ArchivedAt is not null
|
||||
? "archived"
|
||||
: now < ParseDate(exam.RegistrationStart)
|
||||
? "upcoming"
|
||||
: now > ParseDate(exam.RegistrationEnd) ? "closed" : "open";
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = exam.Id,
|
||||
["code"] = exam.Code,
|
||||
["name"] = exam.Name,
|
||||
["description"] = exam.Description,
|
||||
["registrationStart"] = exam.RegistrationStart,
|
||||
["registrationEnd"] = exam.RegistrationEnd,
|
||||
["examStart"] = exam.ExamStart,
|
||||
["examEnd"] = exam.ExamEnd,
|
||||
["admitDownloadStart"] = exam.AdmitDownloadStart,
|
||||
["admitDownloadEnd"] = exam.AdmitDownloadEnd,
|
||||
["location"] = exam.Location,
|
||||
["passPolicy"] = exam.PassPolicy == "score_ratio" ? "rank_percent" : exam.PassPolicy,
|
||||
["passValue"] = exam.PassValue,
|
||||
["status"] = exam.Status,
|
||||
["archivedAt"] = JsonValue.Create(exam.ArchivedAt),
|
||||
["archivedBy"] = JsonValue.Create(exam.ArchivedBy),
|
||||
["createdAt"] = exam.CreatedAt,
|
||||
["subjects"] = new JsonArray(subjectJson),
|
||||
["totalScore"] = totalScore,
|
||||
["registrationState"] = registrationState,
|
||||
["registrationCount"] = registrationCount
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject ToSchoolJson(SchoolRow row) => new()
|
||||
{
|
||||
["id"] = row.Id,
|
||||
["name"] = row.Name,
|
||||
["code"] = row.Code,
|
||||
["address"] = row.Address,
|
||||
["isSourceSchool"] = row.IsSourceSchool,
|
||||
["isAdmissionSchool"] = row.IsAdmissionSchool,
|
||||
["active"] = row.Active
|
||||
};
|
||||
|
||||
private static JsonObject ToClassJson(ClassRow row) => new()
|
||||
{
|
||||
["id"] = row.Id,
|
||||
["schoolId"] = row.SchoolId,
|
||||
["name"] = row.Name,
|
||||
["grade"] = row.Grade,
|
||||
["active"] = row.Active
|
||||
};
|
||||
|
||||
private static int CompareNotices(NoticeItem left, NoticeItem right)
|
||||
{
|
||||
var pinned = right.Pinned.CompareTo(left.Pinned);
|
||||
return pinned != 0 ? pinned : right.PublishAt.CompareTo(left.PublishAt);
|
||||
}
|
||||
|
||||
private static SchoolRow ReadSchool(DbDataReader reader) => new(
|
||||
ReadString(reader, "id"), ReadString(reader, "name"), ReadString(reader, "code"),
|
||||
ReadNullableString(reader, "address") ?? string.Empty, ReadBoolean(reader, "is_source_school", true),
|
||||
ReadBoolean(reader, "is_admission_school", true), ReadBoolean(reader, "active"));
|
||||
|
||||
private static ClassRow ReadClass(DbDataReader reader) => new(
|
||||
ReadString(reader, "id"), ReadString(reader, "school_id"), ReadString(reader, "name"),
|
||||
ReadString(reader, "grade"), ReadBoolean(reader, "active"));
|
||||
|
||||
private static NoticeRow ReadNotice(DbDataReader reader) => new(
|
||||
ReadString(reader, "id"), ReadString(reader, "title"), ReadString(reader, "summary"),
|
||||
ReadString(reader, "content"), ReadString(reader, "category"), ReadBoolean(reader, "pinned"),
|
||||
ReadString(reader, "status"), ReadNullableString(reader, "publish_at"), ReadNullableString(reader, "created_at"),
|
||||
ReadString(reader, "author"));
|
||||
|
||||
private static ExamRow ReadExam(DbDataReader reader) => new(
|
||||
ReadString(reader, "id"), ReadString(reader, "code"), ReadString(reader, "name"), ReadString(reader, "description"),
|
||||
ReadString(reader, "registration_start"), ReadString(reader, "registration_end"), ReadString(reader, "exam_start"),
|
||||
ReadString(reader, "exam_end"), ReadString(reader, "admit_download_start"), ReadString(reader, "admit_download_end"),
|
||||
ReadString(reader, "location"), ReadString(reader, "pass_policy"), ReadDouble(reader, "pass_value"),
|
||||
ReadString(reader, "status"), ReadNullableString(reader, "archived_at"), ReadNullableString(reader, "archived_by"),
|
||||
ReadString(reader, "created_at"));
|
||||
|
||||
private static SubjectRow ReadSubject(DbDataReader reader) => new(
|
||||
ReadString(reader, "id"), ReadString(reader, "exam_id"), ReadString(reader, "name"), ReadString(reader, "subject_date"),
|
||||
ReadString(reader, "start_time"), ReadString(reader, "end_time"), ReadDouble(reader, "fee"),
|
||||
ReadDouble(reader, "full_score"), ReadString(reader, "pass_rule"), ReadDouble(reader, "pass_value"),
|
||||
checked((int)ReadInt64(reader, "position")));
|
||||
|
||||
private static AdmissionRecordRow ReadAdmissionRecord(DbDataReader reader)
|
||||
{
|
||||
var payloadText = ReadString(reader, "payload_json");
|
||||
JsonObject payload;
|
||||
try
|
||||
{
|
||||
payload = JsonNode.Parse(payloadText)?.AsObject() ?? new JsonObject();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
payload = new JsonObject();
|
||||
}
|
||||
|
||||
return new AdmissionRecordRow(
|
||||
ReadString(reader, "id"), ReadString(reader, "kind"), ReadString(reader, "exam_id"),
|
||||
ReadNullableString(reader, "user_id"), ReadNullableString(reader, "school_id"), ReadString(reader, "status"),
|
||||
payload, ReadString(reader, "created_at"), ReadString(reader, "updated_at"));
|
||||
}
|
||||
|
||||
private static async Task<List<T>> QueryAsync<T>(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
Func<DbDataReader, T> map,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
var output = new List<T>();
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
output.Add(map(reader));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private static async Task<T> QuerySingleAsync<T>(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
Func<DbDataReader, T> map,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await QueryAsync(connection, sql, map, cancellationToken);
|
||||
return rows.Count > 0 ? rows[0] : throw new InvalidOperationException("数据库尚未完成初始化");
|
||||
}
|
||||
|
||||
private static string ReadString(DbDataReader reader, string name) =>
|
||||
Convert.ToString(reader[name], CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
private static string? ReadNullableString(DbDataReader reader, string name) =>
|
||||
reader[name] is DBNull ? null : Convert.ToString(reader[name], CultureInfo.InvariantCulture);
|
||||
|
||||
private static bool ReadBoolean(DbDataReader reader, string name, bool fallback = false) =>
|
||||
reader[name] is DBNull ? fallback : Convert.ToBoolean(reader[name], CultureInfo.InvariantCulture);
|
||||
|
||||
private static long ReadInt64(DbDataReader reader, string name) =>
|
||||
Convert.ToInt64(reader[name], CultureInfo.InvariantCulture);
|
||||
|
||||
private static double ReadDouble(DbDataReader reader, string name) =>
|
||||
Convert.ToDouble(reader[name], CultureInfo.InvariantCulture);
|
||||
|
||||
private static DateTimeOffset ParseDate(string? value) =>
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed
|
||||
: DateTimeOffset.MinValue;
|
||||
|
||||
private static string? GetString(JsonObject? value, string name) =>
|
||||
value?[name] is JsonValue json && json.TryGetValue<string>(out var text) ? text : null;
|
||||
|
||||
private static bool GetBoolean(JsonObject? value, string name, bool fallback) =>
|
||||
value?[name] is JsonValue json && json.TryGetValue<bool>(out var result) ? result : fallback;
|
||||
|
||||
private static double GetNumber(JsonObject? value, string name, double fallback = 0)
|
||||
{
|
||||
if (value?[name] is not JsonValue json)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
if (json.TryGetValue<double>(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
return json.TryGetValue<string>(out var text) && double.TryParse(text, CultureInfo.InvariantCulture, out number)
|
||||
? number
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static JsonArray GetArray(JsonObject? value, string name) => value?[name] as JsonArray ?? [];
|
||||
|
||||
private static JsonObject GetObject(JsonObject? value, string name) => value?[name] as JsonObject ?? [];
|
||||
|
||||
private static string Html(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
|
||||
private sealed record SchoolRow(string Id, string Name, string Code, string Address, bool IsSourceSchool, bool IsAdmissionSchool, bool Active);
|
||||
private sealed record ClassRow(string Id, string SchoolId, string Name, string Grade, bool Active);
|
||||
private sealed record NoticeRow(string Id, string Title, string Summary, string Content, string Category, bool Pinned, string Status, string? PublishAt, string? CreatedAt, string Author);
|
||||
private sealed record ExamRow(string Id, string Code, string Name, string Description, string RegistrationStart, string RegistrationEnd, string ExamStart, string ExamEnd, string AdmitDownloadStart, string AdmitDownloadEnd, string Location, string PassPolicy, double PassValue, string Status, string? ArchivedAt, string? ArchivedBy, string CreatedAt);
|
||||
private sealed record SubjectRow(string Id, string ExamId, string Name, string Date, string Start, string End, double Fee, double FullScore, string PassRule, double PassValue, int Position);
|
||||
private sealed record CountByKey(string Key, long Count);
|
||||
private sealed record AdmissionRecordRow(string Id, string Kind, string ExamId, string? UserId, string? SchoolId, string Status, JsonObject Payload, string CreatedAt, string UpdatedAt);
|
||||
private sealed record NoticeItem(JsonObject Json, bool Pinned, DateTimeOffset PublishAt);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Eis.Web.Configuration;
|
||||
|
||||
public static class ApplicationPaths
|
||||
{
|
||||
public static string FindApplicationRoot()
|
||||
{
|
||||
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.GetFullPath(start));
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "Eis.slnx")) ||
|
||||
File.Exists(Path.Combine(directory.FullName, "package.json")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Directory.GetCurrentDirectory());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace Eis.Web.Configuration;
|
||||
|
||||
public static class EnvironmentFile
|
||||
{
|
||||
public static void Load(string applicationRoot)
|
||||
{
|
||||
var path = Path.Combine(applicationRoot, ".env");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var rawLine in File.ReadLines(path))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("export ", StringComparison.Ordinal))
|
||||
{
|
||||
line = line[7..].TrimStart();
|
||||
}
|
||||
|
||||
var separator = line.IndexOf('=');
|
||||
if (separator <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line[..separator].Trim();
|
||||
if (Environment.GetEnvironmentVariable(key) is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = line[(separator + 1)..].Trim();
|
||||
if (value.Length >= 2 && ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\'')))
|
||||
{
|
||||
value = value[1..^1];
|
||||
}
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Eis.Application.Public;
|
||||
|
||||
namespace Eis.Web.Configuration;
|
||||
|
||||
public sealed class PublicSiteConfiguration : IPublicSiteConfiguration
|
||||
{
|
||||
public PublicSiteConfiguration()
|
||||
{
|
||||
Branding = new PublicSiteBranding(
|
||||
new PublicOrganization(
|
||||
Read("PUBLIC_SITE_NAME", "考试服务平台"),
|
||||
Read("PUBLIC_SITE_CODE", "EXAM-SERVICE"),
|
||||
Read("PUBLIC_SITE_PHONE", string.Empty),
|
||||
Read("PUBLIC_SITE_ADDRESS", string.Empty),
|
||||
Read("PUBLIC_SITE_EMAIL", string.Empty)),
|
||||
new PublicSiteCopy(
|
||||
Read("PUBLIC_SITE_HERO_EYEBROW", "EXAMINATION SERVICE"),
|
||||
Read("PUBLIC_SITE_HERO_TITLE", "一个报名号,"),
|
||||
Read("PUBLIC_SITE_HERO_HIGHLIGHT", "贯穿每一次考试。"),
|
||||
Read("PUBLIC_SITE_HERO_DESCRIPTION", "使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。"),
|
||||
Read("PUBLIC_SITE_FOOTER_NOTICE", string.Empty)));
|
||||
}
|
||||
|
||||
public PublicSiteBranding Branding { get; }
|
||||
|
||||
private static string Read(string name, string fallback) =>
|
||||
Environment.GetEnvironmentVariable(name) ?? fallback;
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
using System.Net;
|
||||
using Eis.Application.Public;
|
||||
using Eis.Infrastructure;
|
||||
using Eis.Infrastructure.Data;
|
||||
using Eis.Infrastructure.Migration;
|
||||
using Eis.Web.Configuration;
|
||||
using Eis.Web.Frontend;
|
||||
using Eis.Web.Legacy;
|
||||
using Eis.Web.Public;
|
||||
|
||||
var applicationRoot = ApplicationPaths.FindApplicationRoot();
|
||||
EnvironmentFile.Load(applicationRoot);
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
|
||||
@@ -20,6 +27,8 @@ builder.Services.AddHttpClient<LegacyApiProxy>((services, client) =>
|
||||
UseCookies = false
|
||||
});
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>();
|
||||
builder.Services.AddEisInfrastructure(DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -51,6 +60,8 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
||||
}, statusCode: statusCode);
|
||||
});
|
||||
|
||||
app.MapNativePublicEndpoints();
|
||||
|
||||
string[] methods =
|
||||
[
|
||||
HttpMethods.Get,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Eis.Application.Public;
|
||||
|
||||
namespace Eis.Web.Public;
|
||||
|
||||
public static class PublicEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNativePublicEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/api/public/home", async (
|
||||
HttpContext context,
|
||||
IPublicQueryService queries,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
|
||||
return Results.Json(await queries.GetHomeAsync(cancellationToken));
|
||||
});
|
||||
|
||||
endpoints.MapGet("/api/public/notices/{id}", async (
|
||||
string id,
|
||||
HttpContext context,
|
||||
IPublicQueryService queries,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
|
||||
var notice = await queries.GetNoticeAsync(id, cancellationToken);
|
||||
return notice is null
|
||||
? Results.Json(new { ok = false, message = "通知不存在或尚未发布" }, statusCode: StatusCodes.Status404NotFound)
|
||||
: Results.Json(new { ok = true, notice });
|
||||
});
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user