From 2fbc8c92854ee8798577faa83e8ba37485e455b3 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 29 Aug 2026 19:07:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 3 +- docs/admin-management.md | 34 + docs/architecture.md | 6 +- src/ElectionSystem.Api/Components/App.razor | 4 +- .../Components/Layout/AdminLayout.razor | 22 + .../Components/Layout/MainLayout.razor | 2 + .../Components/Pages/Admin/Candidates.razor | 19 + .../Pages/Admin/CountingStations.razor | 16 + .../Components/Pages/Admin/Dashboard.razor | 33 + .../Components/Pages/Admin/Regions.razor | 12 + .../Components/Pages/Admin/Roles.razor | 14 + .../Components/Pages/Admin/Voters.razor | 27 + .../Components/Routes.razor | 6 +- .../Components/_Imports.razor | 10 + .../ElectionSystem.Api.csproj | 1 + .../Persistence/ElectionDbContext.cs | 12 +- .../Persistence/ElectionDbContextFactory.cs | 22 +- ...29_LocalIdentityAuthentication.Designer.cs | 669 ++++++++++++++++++ ...60829094229_LocalIdentityAuthentication.cs | 367 ++++++++++ .../ElectionDbContextModelSnapshot.cs | 231 ++++++ .../Identity/Domain/ApplicationUser.cs | 12 +- .../Identity/IdentityBootstrapService.cs | 44 ++ .../Pages/Account/Login.cshtml | 4 + .../Pages/Account/Login.cshtml.cs | 30 + .../Pages/Account/Logout.cshtml | 3 + .../Pages/Account/Logout.cshtml.cs | 13 + .../Pages/Admin/Index.cshtml | 3 + .../Pages/Admin/Index.cshtml.cs | 7 + .../Pages/Admin/RegionImport.cshtml | 5 + .../Pages/Admin/RegionImport.cshtml.cs | 26 + .../Pages/Admin/Users.cshtml | 7 + .../Pages/Admin/Users.cshtml.cs | 51 ++ .../Pages/Admin/VoterImport.cshtml | 9 + .../Pages/Admin/VoterImport.cshtml.cs | 25 + src/ElectionSystem.Api/Program.cs | 33 +- src/ElectionSystem.Api/wwwroot/css/site.css | 11 + .../PersistenceModelTests.cs | 13 + 37 files changed, 1793 insertions(+), 13 deletions(-) create mode 100644 docs/admin-management.md create mode 100644 src/ElectionSystem.Api/Components/Layout/AdminLayout.razor create mode 100644 src/ElectionSystem.Api/Components/Pages/Admin/Candidates.razor create mode 100644 src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor create mode 100644 src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor create mode 100644 src/ElectionSystem.Api/Components/Pages/Admin/Regions.razor create mode 100644 src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor create mode 100644 src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor create mode 100644 src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829094229_LocalIdentityAuthentication.Designer.cs create mode 100644 src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829094229_LocalIdentityAuthentication.cs create mode 100644 src/ElectionSystem.Api/Modules/Identity/IdentityBootstrapService.cs create mode 100644 src/ElectionSystem.Api/Pages/Account/Login.cshtml create mode 100644 src/ElectionSystem.Api/Pages/Account/Login.cshtml.cs create mode 100644 src/ElectionSystem.Api/Pages/Account/Logout.cshtml create mode 100644 src/ElectionSystem.Api/Pages/Account/Logout.cshtml.cs create mode 100644 src/ElectionSystem.Api/Pages/Admin/Index.cshtml create mode 100644 src/ElectionSystem.Api/Pages/Admin/Index.cshtml.cs create mode 100644 src/ElectionSystem.Api/Pages/Admin/RegionImport.cshtml create mode 100644 src/ElectionSystem.Api/Pages/Admin/RegionImport.cshtml.cs create mode 100644 src/ElectionSystem.Api/Pages/Admin/Users.cshtml create mode 100644 src/ElectionSystem.Api/Pages/Admin/Users.cshtml.cs create mode 100644 src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml create mode 100644 src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs diff --git a/TODO.md b/TODO.md index 45acd56..a81a617 100644 --- a/TODO.md +++ b/TODO.md @@ -30,7 +30,7 @@ - [x] 创建解决方案与模块边界:Identity、Organization、Election、Voting、OfflineCounting、Cms、PublicBoard、Audit。 - [x] 配置 MySQL、EF Core 初始迁移、开发环境配置与健康检查。 -- [ ] 接入身份认证,建立用户、角色、权限和组织范围授权。 +- [x] 内置身份认证:ASP.NET Core Identity 本地用户、密码策略、锁定、安全 Cookie、首个系统管理员初始化,以及地域角色范围授权。 - [x] 建立统一 API 错误格式和分页响应模型;参数校验、排序和筛选随首个业务列表接口落地。 - [x] 建立基础审计拦截器;Serilog 与敏感字段脱敏随身份及业务字段接入时配置。 - [ ] 配置 Redis、Hangfire、对象存储和本地开发替代实现。 @@ -44,6 +44,7 @@ - [ ] 选民资格规则:按组织、名单、身份属性或人工审核确定。 - [x] 候选人管理:提名、自荐、服务端资格状态转换、撤回、取消资格和公示;资格材料文件待对象存储阶段接入。 - [x] 提供 Excel 批量导入,导入预校验并返回逐行错误;导出将在管理端与授权下载审计接入时实施。 +- [x] 管理端基础资料页面:使用 Razor Pages + Blazor Interactive Server 提供地域、选民、候选人、角色、计票所与导入操作,不引入独立 SPA。 ## 第三阶段:选举活动与在线投票 diff --git a/docs/admin-management.md b/docs/admin-management.md new file mode 100644 index 0000000..96a5ff4 --- /dev/null +++ b/docs/admin-management.md @@ -0,0 +1,34 @@ +# 管理端使用说明 + +管理端入口为 `/admin`。未认证请求会返回 `401`,不会展示任何选民、候选人、计票所或区域数据。 + +## 身份配置 + +主站使用 ASP.NET Core Identity 保存本地用户名、密码哈希、失败次数、锁定时间与安全戳;浏览器管理端使用 `__Host-election-auth` 安全 Cookie。登录入口是 `/account/login`,退出使用 `POST /account/logout`。 + +首次部署时,先执行数据库迁移,再通过用户机密或环境变量提供一次性的首个系统管理员信息: + +```powershell +dotnet user-secrets set "Bootstrap:SystemAdministrator:UserName" "admin" --project src/ElectionSystem.Api +dotnet user-secrets set "Bootstrap:SystemAdministrator:DisplayName" "系统管理员" --project src/ElectionSystem.Api +dotnet user-secrets set "Bootstrap:SystemAdministrator:Password" "替换为至少12位的高强度密码" --project src/ElectionSystem.Api +``` + +应用启动后会创建该账号并授予无地域绑定的 `SystemAdministrator`。确认首次登录成功后,立即删除密码机密: + +```powershell +dotnet user-secrets remove "Bootstrap:SystemAdministrator:Password" --project src/ElectionSystem.Api +``` + +此后在“本地用户”页面创建、停用、恢复及重置其他账号密码,再在“地域角色”页面授权业务范围。停用或重置密码会更新安全戳,使既有 Cookie 会话失效。 + +## 页面职责 + +- `/admin/dashboard`:仅显示当前授权范围内的汇总。 +- `/admin/regions` 与 `/admin/region-import`:系统管理员导入 AreaCity 四级目录;村/社区使用本地确认目录。 +- `/admin/voters` 与 `/admin/voter-import`:按地域维护选民、预校验并确认 Excel 导入。 +- `/admin/candidates`:维护候选人资料和服务端允许的状态转换。 +- `/admin/roles`:系统管理员授予地域管理员、计票员或监督员角色。 +- `/admin/counting-stations`:建立辖区计票所,并向已获地域角色的人员分配具体计票所。 + +管理端用于第二阶段基础资料与授权操作。选举活动、匿名投票、线下双录计票和公开结果将在后续阶段接入相应菜单,且不会通过本阶段页面绕过其规则。 diff --git a/docs/architecture.md b/docs/architecture.md index 17816fe..454eb29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,8 +30,12 @@ CMS 使用独立的 `ElectionSystem.Cms` Orchard Core 主机及数据库;具 `identity_user_regional_roles` 将用户角色与地域范围分开保存。省、市、区、镇、村管理员的范围自动覆盖全部下级地域;系统管理员没有地域限制。计票员和监督员还必须经 `identity_user_counting_stations` 分配到具体计票所,未来线下计票接口必须同时检查地域角色和计票所分配。 -所有 `/api/admin/*` 路由要求 JWT Bearer 认证,并要求身份提供的本地用户 GUID(`nameidentifier` 或 `sub` claim)。部署时配置 `Authentication:Authority`、`Authentication:Audience`,并在 OpenIddict/Keycloak 的声明映射中提供该 GUID。未认证请求返回 401,不能触发地域范围查询或写入。 +主站使用 ASP.NET Core Identity 的本地账号与安全 Cookie 认证;`ApplicationUser.Id` 是地域角色和选民账号关联的唯一主体。密码哈希、锁定、登录安全戳由成熟的 Identity 组件处理,业务表不保存明文密码。保留 JWT Bearer 验证注册,后续接 OpenIddict 或 Keycloak 时可为 API 客户端启用令牌流程;未认证请求不能触发地域范围查询或写入。 `voter_profiles` 是选民主数据,按地域、状态查询和写入均须先应用管理员范围。选民 Excel 采用“上传预校验—逐行错误—同一操作者确认”模式,预览在 30 分钟后失效,确认前重新检查范围和重复选民编号。它不保存投票内容,后续 `Voting` 模块只可从选民 ID 创建资格/已投状态。 `candidate_profiles` 维护候选人地域归属和提名、审核、撤回、取消资格、公示状态;状态转换必须在服务端验证,撤回和取消资格必须说明原因。候选人材料文件将在对象存储接入后作为独立、授权的附件实体实现。 + +## 管理端 + +主站使用混合 Razor Pages 与 Blazor Interactive Server,不引入单页应用构建链。`/admin` 是需要认证的 Razor Pages 入口;`/admin/dashboard`、`/admin/regions`、`/admin/voters`、`/admin/candidates`、`/admin/roles`、`/admin/counting-stations` 使用 Blazor Server 完成交互;`/admin/voter-import` 和 `/admin/region-import` 使用 Razor Pages 的受控文件上传表单。两类页面均复用同一用户声明和 `RegionScopeService`,不能通过 UI 参数越出地域范围。 diff --git a/src/ElectionSystem.Api/Components/App.razor b/src/ElectionSystem.Api/Components/App.razor index 0f0d994..5c28c22 100644 --- a/src/ElectionSystem.Api/Components/App.razor +++ b/src/ElectionSystem.Api/Components/App.razor @@ -9,7 +9,9 @@ - + + + diff --git a/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor b/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor new file mode 100644 index 0000000..96a2f63 --- /dev/null +++ b/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor @@ -0,0 +1,22 @@ +@inherits LayoutComponentBase + +
+
+ 选务管理工作台 + 返回公开门户 ↗ +
+ +
@Body
+
diff --git a/src/ElectionSystem.Api/Components/Layout/MainLayout.razor b/src/ElectionSystem.Api/Components/Layout/MainLayout.razor index 1644068..31e57f8 100644 --- a/src/ElectionSystem.Api/Components/Layout/MainLayout.razor +++ b/src/ElectionSystem.Api/Components/Layout/MainLayout.razor @@ -5,6 +5,8 @@ diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Candidates.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Candidates.razor new file mode 100644 index 0000000..53a8f00 --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Candidates.razor @@ -0,0 +1,19 @@ +@page "/admin/candidates" +@attribute [Authorize] +@layout AdminLayout +@inject ElectionDbContext Db +@inject RegionScopeService Scope + +候选人管理 +

基础资料 / 候选人

候选人资格管理

候选人撤回或取消资格必须在服务端记录原因。

+@if (notice is not null) {
@notice
} +@if (creating) {

新增候选人

} +
@if (!loaded) { } else if (items.Count == 0) { } @foreach (var item in items) { }
姓名地区状态处理
正在加载…
暂无候选人资料。
@item.Name@item.Region@Text(item.Status)@if (item.Status == CandidateStatus.Draft) { } else if (item.Status == CandidateStatus.Submitted) { } else if (item.Status == CandidateStatus.UnderReview) { } else if (item.Status == CandidateStatus.Approved) { } @if (item.Status is CandidateStatus.UnderReview or CandidateStatus.Approved or CandidateStatus.Publicized) { } @if (item.Status is CandidateStatus.Approved or CandidateStatus.Publicized) { }
+@if (actionId.HasValue) {

@Text(actionTarget)

}
+@code { [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; private HashSet scope = []; private bool loaded, creating; private string? notice; private CandidateDraft draft = new(); private Guid? actionId; private CandidateStatus actionTarget; private string actionReason = ""; private readonly List regions = []; private readonly List items = []; + protected override async Task OnInitializedAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); regions.AddRange(await Db.AdministrativeRegions.Where(x => scope.Contains(x.Id) && x.IsActive).OrderBy(x => x.Level).ThenBy(x => x.Name).Select(x => new RegionOption(x.Id, x.Name)).ToListAsync()); await LoadAsync(); } + private async Task LoadAsync() { var map = await Db.AdministrativeRegions.ToDictionaryAsync(x => x.Id, x => x.Name); items.Clear(); items.AddRange((await Db.Candidates.Where(x => scope.Contains(x.RegionId)).OrderBy(x => x.DisplayName).ToListAsync()).Select(x => new CandidateRow(x.Id, x.DisplayName, map.GetValueOrDefault(x.RegionId, "—"), x.Status))); loaded = true; } + private async Task CreateAsync() { if (!draft.RegionId.HasValue || !scope.Contains(draft.RegionId.Value)) { notice = "请选择可管理地区。"; return; } Db.Candidates.Add(new CandidateProfile { DisplayName = draft.Name.Trim(), RegionId = draft.RegionId.Value, NominationSummary = draft.Summary.Trim() }); await Db.SaveChangesAsync(); draft = new(); creating = false; notice = "候选人草稿已创建。"; await LoadAsync(); } + private async Task MoveAsync(Guid id, CandidateStatus target, string? reason = null) { var candidate = await Db.Candidates.FindAsync(id); if (candidate is null || !scope.Contains(candidate.RegionId)) return; if (!Allows(candidate.Status, target)) { notice = "候选人状态转换不合法。"; return; } if (target is CandidateStatus.Withdrawn or CandidateStatus.Disqualified && string.IsNullOrWhiteSpace(reason)) { notice = "撤回或取消资格必须填写原因。"; return; } candidate.Status = target; candidate.StatusReason = reason; candidate.UpdatedAtUtc = DateTime.UtcNow; await Db.SaveChangesAsync(); notice = $"已更新为{Text(target)}。"; await LoadAsync(); } + private void OpenDisposition(Guid id, CandidateStatus target) { actionId = id; actionTarget = target; actionReason = ""; } private async Task ConfirmDispositionAsync() { if (actionId.HasValue) await MoveAsync(actionId.Value, actionTarget, actionReason.Trim()); CloseDisposition(); } private void CloseDisposition() { actionId = null; actionReason = ""; } + private static bool Allows(CandidateStatus current, CandidateStatus target) => current == target || (current, target) switch { (CandidateStatus.Draft, CandidateStatus.Submitted) => true, (CandidateStatus.Submitted, CandidateStatus.UnderReview) => true, (CandidateStatus.UnderReview, CandidateStatus.Approved or CandidateStatus.Disqualified) => true, (CandidateStatus.Approved, CandidateStatus.Publicized or CandidateStatus.Withdrawn or CandidateStatus.Disqualified) => true, (CandidateStatus.Publicized, CandidateStatus.Withdrawn or CandidateStatus.Disqualified) => true, _ => false }; private static string Text(CandidateStatus s) => s switch { CandidateStatus.Draft => "草稿", CandidateStatus.Submitted => "已提交", CandidateStatus.UnderReview => "审核中", CandidateStatus.Approved => "已通过", CandidateStatus.Publicized => "已公示", CandidateStatus.Withdrawn => "已撤回", _ => "已取消资格" }; private sealed class CandidateDraft { public string Name { get; set; } = ""; public Guid? RegionId { get; set; } public string Summary { get; set; } = ""; } private sealed record RegionOption(Guid Id, string Name); private sealed record CandidateRow(Guid Id, string Name, string Region, CandidateStatus Status); } diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor b/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor new file mode 100644 index 0000000..bdf62f6 --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor @@ -0,0 +1,16 @@ +@page "/admin/counting-stations" +@attribute [Authorize] +@layout AdminLayout +@inject ElectionDbContext Db +@inject RegionScopeService Scope + +计票所管理 +

人员与场所 / 计票

计票所管理

计票员与监督员后续必须再分配到具体计票所,才能参与线下计票。

+@if (notice is not null) {
@notice
} @if (creating) {

新增计票所

} +
@if (!loaded) { } else if (stations.Count == 0) { } @foreach (var station in stations) { }
代码名称所属地区状态
正在加载…
暂无计票所。
@station.Code@station.Name@station.Region@(station.Active ? "启用" : "停用")
+

分配计票所工作人员

+@code { [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; private HashSet scope = []; private bool loaded, creating; private string? notice; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List regions = []; private readonly List stations = []; private readonly List stationChoices = []; + protected override async Task OnInitializedAsync() { regions.Clear(); stations.Clear(); stationChoices.Clear(); var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); var map = await Db.AdministrativeRegions.Where(x => scope.Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); regions.AddRange(map.Select(x => new Region(x.Key, x.Value))); var entities = await Db.CountingStations.Where(x => scope.Contains(x.RegionId)).OrderBy(x => x.Code).ToListAsync(); stations.AddRange(entities.Select(x => new StationRow(x.Code, x.Name, map.GetValueOrDefault(x.RegionId, "—"), x.IsActive))); stationChoices.AddRange(entities.Where(x => x.IsActive).Select(x => new StationChoice(x.Id, x.Name))); loaded = true; } + private async Task CreateAsync() { if (!draft.RegionId.HasValue || !scope.Contains(draft.RegionId.Value)) { notice = "请选择您有权管理的地区。"; return; } if (await Db.CountingStations.AnyAsync(x => x.Code == draft.Code)) { notice = "计票所代码已存在。"; return; } Db.CountingStations.Add(new CountingStation { Code = draft.Code.Trim(), Name = draft.Name.Trim(), RegionId = draft.RegionId.Value }); await Db.SaveChangesAsync(); notice = "计票所已创建。"; creating = false; stations.Clear(); regions.Clear(); await OnInitializedAsync(); } + private async Task AssignStaffAsync() { if (!staffDraft.StationId.HasValue || !Guid.TryParse(staffDraft.UserId, out var userId)) { notice = "请选择计票所并输入有效用户 ID。"; return; } var station = await Db.CountingStations.FindAsync(staffDraft.StationId); if (station is null || !scope.Contains(station.RegionId)) { notice = "不能管理此计票所。"; return; } if (!await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.RegionId == station.RegionId && (x.Role == RegionalRole.CountingStationClerk || x.Role == RegionalRole.Supervisor))) { notice = "用户必须先取得本辖区的计票员或监督员角色。"; return; } if (await Db.UserCountingStationAssignments.AnyAsync(x => x.UserId == userId && x.CountingStationId == station.Id)) { notice = "该用户已分配到此计票所。"; return; } Db.UserCountingStationAssignments.Add(new UserCountingStationAssignment { UserId = userId, CountingStationId = station.Id }); await Db.SaveChangesAsync(); notice = "工作人员已分配。"; staffDraft = new(); } + private sealed class StationDraft { public string Code { get; set; } = ""; public string Name { get; set; } = ""; public Guid? RegionId { get; set; } } private sealed class StaffDraft { public Guid? StationId { get; set; } public string UserId { get; set; } = ""; } private sealed record Region(Guid Id, string Name); private sealed record StationRow(string Code, string Name, string Region, bool Active); private sealed record StationChoice(Guid Id, string Name); } diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor new file mode 100644 index 0000000..880936d --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor @@ -0,0 +1,33 @@ +@page "/admin/dashboard" +@attribute [Authorize] +@layout AdminLayout +@inject ElectionDbContext Db +@inject RegionScopeService Scope + +管理工作台 + +
+

管理工作台

当前辖区概览

所有统计仅覆盖您已获授权的行政区划及其下级。

+ @if (error is not null) { } + else if (!loaded) {

正在核对权限范围…

} + else + { +
+
可管理地区@regionCount含下级辖区
+
有效选民@activeVoterCount可参加后续资格核验
+
候选人资料@candidateCount含待审核与已公示
+
启用计票所@stationCount须单独分配工作人员
+
+

建议操作顺序

  1. 导入并核验省、市、区、镇目录
  2. 预校验选民名单,再确认导入
  3. 分配地域管理员、计票员和监督员
+ } +
+ +@code { + private bool loaded; private string? error; private int regionCount, activeVoterCount, candidateCount, stationCount; + protected override async Task OnInitializedAsync() + { + try { var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var scope = await Scope.GetManagedRegionIdsAsync(userId, CancellationToken.None); regionCount = scope.Count; activeVoterCount = await Db.Voters.CountAsync(x => scope.Contains(x.RegionId) && x.Status == VoterStatus.Active); candidateCount = await Db.Candidates.CountAsync(x => scope.Contains(x.RegionId)); stationCount = await Db.CountingStations.CountAsync(x => scope.Contains(x.RegionId) && x.IsActive); loaded = true; } + catch (Exception) { error = "无法读取管理范围。请确认此账号已完成地域角色分配。"; } + } + [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; +} diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Regions.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Regions.razor new file mode 100644 index 0000000..4374417 --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Regions.razor @@ -0,0 +1,12 @@ +@page "/admin/regions" +@attribute [Authorize] +@layout AdminLayout +@inject ElectionDbContext Db +@inject RegionScopeService Scope + +行政区划 +

基础资料 / 地域

行政区划目录

省、市、区县、镇街来自受控 AreaCity 数据版本;村/社区只可使用本地确认目录。

导入四级目录
+@if (!loaded) {

正在加载目录…

} else {
@if (rows.Count == 0) { } @foreach (var row in rows) { }
层级名称代码数据来源版本状态
尚未导入行政区划目录。请先导入 AreaCity 四级 CSV。
@Level(row.Level)@row.Name@row.Code@row.Source@row.Version@(row.Active ? "启用" : "停用")
}
+@code { [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; private bool loaded; private readonly List rows = []; + protected override async Task OnInitializedAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); rows.AddRange(await Db.AdministrativeRegions.Where(x => scope.Contains(x.Id)).OrderBy(x => x.Level).ThenBy(x => x.Code).Take(500).Select(x => new Row(x.Level, x.Name, x.Code, x.Source, x.SourceVersion, x.IsActive)).ToListAsync()); loaded = true; } + private static string Level(AdministrativeRegionLevel value) => value switch { AdministrativeRegionLevel.Province => "省", AdministrativeRegionLevel.City => "市", AdministrativeRegionLevel.District => "区/县", AdministrativeRegionLevel.Township => "镇/街道", _ => "村/社区" }; private sealed record Row(AdministrativeRegionLevel Level, string Name, string Code, string Source, string Version, bool Active); } diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor new file mode 100644 index 0000000..2ebe6bd --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor @@ -0,0 +1,14 @@ +@page "/admin/roles" +@attribute [Authorize] +@layout AdminLayout +@inject ElectionDbContext Db +@inject RegionScopeService Scope + +地域角色 +

人员与场所 / 授权

地域角色分配

系统管理员维护授权;地域管理员只能管理自身辖区及全部下级辖区。

+@if (error is not null) {
@error
} else if (!systemAdmin) {
此页面仅向系统管理员开放。
} else {

授予角色

@foreach (var row in items) { }
用户角色行政区划
@row.UserId@RoleText(row.Role)@row.Region
}
+@code { [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; private bool systemAdmin; private string? error; private RoleDraft draft = new(); private readonly List regions = []; private readonly List items = []; + protected override async Task OnInitializedAsync() { regions.Clear(); items.Clear(); var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null); if (!systemAdmin) return; var map = await Db.AdministrativeRegions.ToDictionaryAsync(x => x.Id, x => x.Name); regions.AddRange(map.Select(x => new Region(x.Key, x.Value))); items.AddRange((await Db.UserRegionalRoleAssignments.OrderBy(x => x.UserId).ToListAsync()).Select(x => new RoleRow(x.UserId, x.Role, x.RegionId.HasValue ? map.GetValueOrDefault(x.RegionId.Value, "—") : "全部地区"))); } + private async Task SaveAsync() { if (!Guid.TryParse(draft.UserId, out var userId)) { error = "用户 ID 必须为 GUID。"; return; } if (draft.Role == RegionalRole.SystemAdministrator) draft.RegionId = null; else if (!draft.RegionId.HasValue) { error = "非系统管理员必须选择行政区划。"; return; } if (!await Db.Users.AnyAsync(x => x.Id == userId && x.IsActive)) { error = "用户不存在或已停用。"; return; } if (draft.RegionId.HasValue) { var level = await Db.AdministrativeRegions.Where(x => x.Id == draft.RegionId).Select(x => x.Level).SingleAsync(); if (!Matches(draft.Role, level)) { error = "管理员角色必须与行政区划层级一致。"; return; } } if (await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.RegionId == draft.RegionId && x.Role == draft.Role)) { error = "该角色已存在。"; return; } Db.UserRegionalRoleAssignments.Add(new UserRegionalRoleAssignment { UserId = userId, RegionId = draft.RegionId, Role = draft.Role }); await Db.SaveChangesAsync(); error = "角色已授予。"; await OnInitializedAsync(); } + private static bool Matches(RegionalRole role, AdministrativeRegionLevel level) => role switch { RegionalRole.ProvinceAdministrator => level == AdministrativeRegionLevel.Province, RegionalRole.CityAdministrator => level == AdministrativeRegionLevel.City, RegionalRole.DistrictAdministrator => level == AdministrativeRegionLevel.District, RegionalRole.TownshipAdministrator => level == AdministrativeRegionLevel.Township, RegionalRole.VillageAdministrator => level == AdministrativeRegionLevel.Village, _ => true }; + private static string RoleText(RegionalRole role) => role switch { RegionalRole.SystemAdministrator => "系统管理员", RegionalRole.ProvinceAdministrator => "省管理员", RegionalRole.CityAdministrator => "市管理员", RegionalRole.DistrictAdministrator => "区县管理员", RegionalRole.TownshipAdministrator => "镇街管理员", RegionalRole.VillageAdministrator => "村社区管理员", RegionalRole.CountingStationClerk => "计票员", _ => "监督员" }; private sealed class RoleDraft { public string UserId { get; set; } = ""; public Guid? RegionId { get; set; } public RegionalRole Role { get; set; } = RegionalRole.CityAdministrator; } private sealed record Region(Guid Id, string Name); private sealed record RoleRow(Guid UserId, RegionalRole Role, string Region); } diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor new file mode 100644 index 0000000..88ec35a --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor @@ -0,0 +1,27 @@ +@page "/admin/voters" +@attribute [Authorize] +@layout AdminLayout +@inject ElectionDbContext Db +@inject RegionScopeService Scope + +选民管理 +
+

基础资料 / 选民

选民管理

只能查询和维护当前辖区及下级地区的选民。

+ @if (message is not null) {
@message
} +
+ @if (creating) {

新增选民

} + @if (!loaded) {

正在加载选民…

} else {
@if (voters.Count == 0) { } @foreach (var voter in voters) { }
选民编号姓名所属地区状态操作
当前条件下没有选民。可先从“选民导入”上传名单。
@voter.VoterNumber@voter.DisplayName@voter.RegionName@StatusText(voter.Status)@if (voter.Status == VoterStatus.Disabled) { } else { }
} +
+@code { + [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; + private readonly List voters = []; private readonly List regions = []; private VoterDraft draft = new(); private string keyword = "", status = ""; private bool loaded, creating; private string? message; private HashSet managed = []; + protected override async Task OnInitializedAsync() { await LoadRegionsAsync(); await LoadAsync(); } + private async Task LoadRegionsAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); managed = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); var all = await Db.AdministrativeRegions.Where(x => managed.Contains(x.Id) && x.IsActive).OrderBy(x => x.Level).ThenBy(x => x.Code).ToListAsync(); foreach (var x in all) regions.Add(new(x.Id, $"{x.Level}: {x.Name}")); } + private async Task LoadAsync() { var query = Db.Voters.Where(x => managed.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.VoterNumber.Contains(keyword) || x.DisplayName.Contains(keyword)); if (Enum.TryParse(status, out var state)) query = query.Where(x => x.Status == state); var names = await Db.AdministrativeRegions.ToDictionaryAsync(x => x.Id, x => x.Name); var rows = await query.OrderBy(x => x.DisplayName).Take(100).Select(x => new { x.Id, x.VoterNumber, x.DisplayName, x.RegionId, x.Status }).ToListAsync(); voters.Clear(); voters.AddRange(rows.Select(x => new VoterRow(x.Id, x.VoterNumber, x.DisplayName, names.GetValueOrDefault(x.RegionId, "—"), x.Status))); loaded = true; } + private void ToggleCreate() { creating = !creating; message = null; } + private async Task CreateAsync() { if (!draft.RegionId.HasValue || !managed.Contains(draft.RegionId.Value)) { message = "请选择您有权管理的行政区划。"; return; } if (await Db.Voters.AnyAsync(x => x.VoterNumber == draft.VoterNumber)) { message = "选民编号已存在。"; return; } Db.Voters.Add(new VoterProfile { VoterNumber = draft.VoterNumber.Trim(), DisplayName = draft.DisplayName.Trim(), RegionId = draft.RegionId.Value }); await Db.SaveChangesAsync(); draft = new(); creating = false; message = "已新增选民。"; await LoadAsync(); } + private async Task SetStatusAsync(VoterRow voter, VoterStatus target) { var entity = await Db.Voters.FindAsync(voter.Id); if (entity is null || !managed.Contains(entity.RegionId)) { message = "未找到可管理的选民。"; return; } entity.Status = target; entity.DisabledReason = target == VoterStatus.Disabled ? "管理员在管理端停用" : null; entity.UpdatedAtUtc = DateTime.UtcNow; await Db.SaveChangesAsync(); message = target == VoterStatus.Disabled ? "已停用选民。" : "已恢复选民。"; await LoadAsync(); } + private static string StatusText(VoterStatus value) => value switch { VoterStatus.Active => "有效", VoterStatus.Disabled => "已停用", _ => "待审核" }; + private sealed class VoterDraft { public string VoterNumber { get; set; } = ""; public string DisplayName { get; set; } = ""; public Guid? RegionId { get; set; } } + private sealed record VoterRow(Guid Id, string VoterNumber, string DisplayName, string RegionName, VoterStatus Status); private sealed record RegionOption(Guid Id, string Path); +} diff --git a/src/ElectionSystem.Api/Components/Routes.razor b/src/ElectionSystem.Api/Components/Routes.razor index e09470a..b7b0f93 100644 --- a/src/ElectionSystem.Api/Components/Routes.razor +++ b/src/ElectionSystem.Api/Components/Routes.razor @@ -1,6 +1,10 @@ - + + +

需要登录

请使用已配置的组织身份登录后访问管理功能。

+
+
diff --git a/src/ElectionSystem.Api/Components/_Imports.razor b/src/ElectionSystem.Api/Components/_Imports.razor index a2cc7c8..2959a86 100644 --- a/src/ElectionSystem.Api/Components/_Imports.razor +++ b/src/ElectionSystem.Api/Components/_Imports.razor @@ -1,4 +1,14 @@ @using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.EntityFrameworkCore +@using ElectionSystem.Api.Infrastructure.Persistence +@using ElectionSystem.Api.Modules.Organization +@using ElectionSystem.Api.Modules.Organization.Domain +@using ElectionSystem.Api.Modules.Voters.Domain +@using ElectionSystem.Api.Modules.Candidates.Domain +@using ElectionSystem.Api.Components.Layout @using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/src/ElectionSystem.Api/ElectionSystem.Api.csproj b/src/ElectionSystem.Api/ElectionSystem.Api.csproj index 35cd3ba..0c21f9b 100644 --- a/src/ElectionSystem.Api/ElectionSystem.Api.csproj +++ b/src/ElectionSystem.Api/ElectionSystem.Api.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs index fbaf53b..aee95d0 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs @@ -4,12 +4,13 @@ using ElectionSystem.Api.Modules.Organization.Domain; using ElectionSystem.Api.Modules.Voters.Domain; using ElectionSystem.Api.Modules.Candidates.Domain; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; namespace ElectionSystem.Api.Infrastructure.Persistence; -public sealed class ElectionDbContext(DbContextOptions options) : DbContext(options) +public sealed class ElectionDbContext(DbContextOptions options) : IdentityDbContext(options) { - public DbSet Users => Set(); public DbSet OrganizationUnits => Set(); public DbSet AdministrativeRegions => Set(); public DbSet UserRegionalRoleAssignments => Set(); @@ -21,6 +22,7 @@ public sealed class ElectionDbContext(DbContextOptions option protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); modelBuilder.Entity(entity => { entity.ToTable("identity_users"); @@ -30,6 +32,12 @@ public sealed class ElectionDbContext(DbContextOptions option entity.HasIndex(x => x.UserName).IsUnique(); entity.HasIndex(x => x.ExternalSubject).IsUnique(); }); + modelBuilder.Entity().ToTable("identity_roles"); + modelBuilder.Entity>().ToTable("identity_user_roles"); + modelBuilder.Entity>().ToTable("identity_user_claims"); + modelBuilder.Entity>().ToTable("identity_user_logins"); + modelBuilder.Entity>().ToTable("identity_user_tokens"); + modelBuilder.Entity>().ToTable("identity_role_claims"); modelBuilder.Entity(entity => { entity.ToTable("administrative_regions"); diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs index 78e596b..5564f02 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; namespace ElectionSystem.Api.Infrastructure.Persistence; @@ -7,8 +8,25 @@ public sealed class ElectionDbContextFactory : IDesignTimeDbContextFactory(optional: true); + configurationBuilder.AddEnvironmentVariables(); + + var connectionString = configurationBuilder.Build().GetConnectionString("ElectionDatabase"); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new InvalidOperationException("ConnectionStrings:ElectionDatabase is not configured. Set it with dotnet user-secrets or ConnectionStrings__ElectionDatabase."); // Migrations must be generated without requiring a reachable local MySQL instance. var options = new DbContextOptionsBuilder().UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 36))).Options; return new ElectionDbContext(options); diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829094229_LocalIdentityAuthentication.Designer.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829094229_LocalIdentityAuthentication.Designer.cs new file mode 100644 index 0000000..f03d633 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829094229_LocalIdentityAuthentication.Designer.cs @@ -0,0 +1,669 @@ +// +using System; +using ElectionSystem.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ElectionDbContext))] + [Migration("20260829094229_LocalIdentityAuthentication")] + partial class LocalIdentityAuthentication + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ElectionSystem.Api.Infrastructure.Auditing.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("ActorId") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("MetadataJson") + .HasColumnType("json"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("ResourceId") + .HasColumnType("longtext"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("TraceId") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAtUtc", "ResourceType"); + + b.ToTable("audit_entries", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Candidates.Domain.CandidateProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("NominationSummary") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StatusReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId", "Status", "DisplayName"); + + b.ToTable("candidate_profiles", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("identity_roles", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("ExternalSubject") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSubject") + .IsUnique(); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("identity_users", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsSynthetic") + .HasColumnType("tinyint(1)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ParentId") + .HasColumnType("char(36)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SourceVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Code", "SourceVersion") + .IsUnique(); + + b.HasIndex("ParentId", "Level", "IsActive"); + + b.ToTable("administrative_regions", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("RegionId"); + + b.ToTable("counting_stations", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ParentId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("organization_units", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.UserCountingStationAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CountingStationId") + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("CountingStationId"); + + b.HasIndex("UserId", "CountingStationId") + .IsUnique(); + + b.ToTable("identity_user_counting_stations", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.UserRegionalRoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.HasIndex("UserId", "Role", "RegionId") + .IsUnique(); + + b.ToTable("identity_user_regional_roles", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Voters.Domain.VoterProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("DisabledReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VoterNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("VoterNumber") + .IsUnique(); + + b.HasIndex("RegionId", "Status", "DisplayName"); + + b.ToTable("voter_profiles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("identity_role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("identity_user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("identity_user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("identity_user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("identity_user_tokens", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Candidates.Domain.CandidateProfile", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.UserCountingStationAssignment", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", null) + .WithMany() + .HasForeignKey("CountingStationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.UserRegionalRoleAssignment", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Voters.Domain.VoterProfile", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829094229_LocalIdentityAuthentication.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829094229_LocalIdentityAuthentication.cs new file mode 100644 index 0000000..6c0bbea --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829094229_LocalIdentityAuthentication.cs @@ -0,0 +1,367 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class LocalIdentityAuthentication : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AccessFailedCount", + table: "identity_users", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ConcurrencyStamp", + table: "identity_users", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "Email", + table: "identity_users", + type: "varchar(256)", + maxLength: 256, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "EmailConfirmed", + table: "identity_users", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LockoutEnabled", + table: "identity_users", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LockoutEnd", + table: "identity_users", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "NormalizedEmail", + table: "identity_users", + type: "varchar(256)", + maxLength: 256, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "NormalizedUserName", + table: "identity_users", + type: "varchar(256)", + maxLength: 256, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "identity_users", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "PhoneNumber", + table: "identity_users", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "PhoneNumberConfirmed", + table: "identity_users", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SecurityStamp", + table: "identity_users", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "TwoFactorEnabled", + table: "identity_users", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "identity_roles", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Name = table.Column(type: "varchar(256)", maxLength: 256, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + NormalizedName = table.Column(type: "varchar(256)", maxLength: 256, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ConcurrencyStamp = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_identity_roles", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "identity_user_claims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ClaimType = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ClaimValue = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_identity_user_claims", x => x.Id); + table.ForeignKey( + name: "FK_identity_user_claims_identity_users_UserId", + column: x => x.UserId, + principalTable: "identity_users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "identity_user_logins", + columns: table => new + { + LoginProvider = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ProviderKey = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ProviderDisplayName = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_identity_user_logins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_identity_user_logins_identity_users_UserId", + column: x => x.UserId, + principalTable: "identity_users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "identity_user_tokens", + columns: table => new + { + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + LoginProvider = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Name = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Value = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_identity_user_tokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_identity_user_tokens_identity_users_UserId", + column: x => x.UserId, + principalTable: "identity_users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "identity_role_claims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + RoleId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ClaimType = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ClaimValue = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_identity_role_claims", x => x.Id); + table.ForeignKey( + name: "FK_identity_role_claims_identity_roles_RoleId", + column: x => x.RoleId, + principalTable: "identity_roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "identity_user_roles", + columns: table => new + { + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RoleId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_identity_user_roles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_identity_user_roles_identity_roles_RoleId", + column: x => x.RoleId, + principalTable: "identity_roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_identity_user_roles_identity_users_UserId", + column: x => x.UserId, + principalTable: "identity_users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "identity_users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "identity_users", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_identity_role_claims_RoleId", + table: "identity_role_claims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "identity_roles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_identity_user_claims_UserId", + table: "identity_user_claims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_identity_user_logins_UserId", + table: "identity_user_logins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_identity_user_roles_RoleId", + table: "identity_user_roles", + column: "RoleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "identity_role_claims"); + + migrationBuilder.DropTable( + name: "identity_user_claims"); + + migrationBuilder.DropTable( + name: "identity_user_logins"); + + migrationBuilder.DropTable( + name: "identity_user_roles"); + + migrationBuilder.DropTable( + name: "identity_user_tokens"); + + migrationBuilder.DropTable( + name: "identity_roles"); + + migrationBuilder.DropIndex( + name: "EmailIndex", + table: "identity_users"); + + migrationBuilder.DropIndex( + name: "UserNameIndex", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "AccessFailedCount", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "ConcurrencyStamp", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "Email", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "EmailConfirmed", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "LockoutEnabled", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "LockoutEnd", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "NormalizedEmail", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "NormalizedUserName", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "PhoneNumber", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "PhoneNumberConfirmed", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "SecurityStamp", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "TwoFactorEnabled", + table: "identity_users"); + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs index 6f3e109..93efc50 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs @@ -100,12 +100,46 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations b.ToTable("candidate_profiles", (string)null); }); + modelBuilder.Entity("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("identity_roles", (string)null); + }); + modelBuilder.Entity("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("char(36)"); + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + b.Property("CreatedAtUtc") .HasColumnType("datetime(6)"); @@ -114,6 +148,13 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations .HasMaxLength(128) .HasColumnType("varchar(128)"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + b.Property("ExternalSubject") .HasMaxLength(256) .HasColumnType("varchar(256)"); @@ -121,6 +162,35 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations b.Property("IsActive") .HasColumnType("tinyint(1)"); + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + b.Property("UserName") .IsRequired() .HasMaxLength(128) @@ -131,6 +201,13 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations b.HasIndex("ExternalSubject") .IsUnique(); + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + b.HasIndex("UserName") .IsUnique(); @@ -353,6 +430,109 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations b.ToTable("voter_profiles", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("identity_role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("identity_user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("identity_user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("identity_user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("identity_user_tokens", (string)null); + }); + modelBuilder.Entity("ElectionSystem.Api.Modules.Candidates.Domain.CandidateProfile", b => { b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) @@ -429,6 +609,57 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Restrict); }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs b/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs index 3c5f11e..9e0e47b 100644 --- a/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs +++ b/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs @@ -1,11 +1,15 @@ +using Microsoft.AspNetCore.Identity; + namespace ElectionSystem.Api.Modules.Identity.Domain; -public sealed class ApplicationUser +public sealed class ApplicationUser : IdentityUser { - public Guid Id { get; set; } = Guid.NewGuid(); - public required string UserName { get; set; } - public required string DisplayName { get; set; } + public string DisplayName { get; set; } = string.Empty; public bool IsActive { get; set; } = true; public string? ExternalSubject { get; set; } public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; } + +public sealed class ApplicationRole : IdentityRole +{ +} diff --git a/src/ElectionSystem.Api/Modules/Identity/IdentityBootstrapService.cs b/src/ElectionSystem.Api/Modules/Identity/IdentityBootstrapService.cs new file mode 100644 index 0000000..d4e46a6 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Identity/IdentityBootstrapService.cs @@ -0,0 +1,44 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Identity.Domain; +using ElectionSystem.Api.Modules.Organization.Domain; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Modules.Identity; + +/// Creates exactly one local system administrator when explicitly configured through user secrets or environment variables. +public sealed class IdentityBootstrapService(IServiceScopeFactory scopeFactory, IConfiguration configuration, ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var userName = configuration["Bootstrap:SystemAdministrator:UserName"]; + var password = configuration["Bootstrap:SystemAdministrator:Password"]; + if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(password)) + { + logger.LogInformation("No local system administrator bootstrap configuration was supplied."); + return; + } + + using var scope = scopeFactory.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var user = await users.FindByNameAsync(userName); + if (user is null) + { + user = new ApplicationUser { UserName = userName.Trim(), DisplayName = configuration["Bootstrap:SystemAdministrator:DisplayName"]?.Trim() ?? "系统管理员", IsActive = true }; + var result = await users.CreateAsync(user, password); + if (!result.Succeeded) + throw new InvalidOperationException($"Failed to bootstrap system administrator: {string.Join("; ", result.Errors.Select(x => x.Description))}"); + logger.LogInformation("Created configured local system administrator {UserName}.", userName); + } + + if (!await dbContext.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == user.Id && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null, cancellationToken)) + { + dbContext.UserRegionalRoleAssignments.Add(new UserRegionalRoleAssignment { UserId = user.Id, Role = RegionalRole.SystemAdministrator }); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Granted system administrator scope to configured local account {UserName}.", userName); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ElectionSystem.Api/Pages/Account/Login.cshtml b/src/ElectionSystem.Api/Pages/Account/Login.cshtml new file mode 100644 index 0000000..0b26cf1 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Account/Login.cshtml @@ -0,0 +1,4 @@ +@page "/account/login" +@model ElectionSystem.Api.Pages.Account.LoginModel +@{ ViewData["Title"] = "管理端登录"; } +
diff --git a/src/ElectionSystem.Api/Pages/Account/Login.cshtml.cs b/src/ElectionSystem.Api/Pages/Account/Login.cshtml.cs new file mode 100644 index 0000000..76159c6 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Account/Login.cshtml.cs @@ -0,0 +1,30 @@ +using ElectionSystem.Api.Modules.Identity.Domain; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages.Account; + +[AllowAnonymous] +public sealed class LoginModel(SignInManager signInManager, UserManager userManager) : PageModel +{ + [BindProperty] public LoginInput Input { get; set; } = new(); + [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; } + public void OnGet() { } + public async Task OnPostAsync() + { + if (!ModelState.IsValid) return Page(); + var user = await userManager.FindByNameAsync(Input.UserName); + if (user is null || !user.IsActive) + { + ModelState.AddModelError(string.Empty, "用户名或密码不正确,或账号已停用。"); + return Page(); + } + var result = await signInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: true); + if (result.Succeeded) return LocalRedirect(Url.IsLocalUrl(ReturnUrl) ? ReturnUrl! : "/admin"); + ModelState.AddModelError(string.Empty, result.IsLockedOut ? "账号已暂时锁定,请稍后重试。" : "用户名或密码不正确,或账号已停用。"); + return Page(); + } + public sealed class LoginInput { public string UserName { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public bool RememberMe { get; set; } } +} diff --git a/src/ElectionSystem.Api/Pages/Account/Logout.cshtml b/src/ElectionSystem.Api/Pages/Account/Logout.cshtml new file mode 100644 index 0000000..0ffd600 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Account/Logout.cshtml @@ -0,0 +1,3 @@ +@page "/account/logout" +@model ElectionSystem.Api.Pages.Account.LogoutModel +
diff --git a/src/ElectionSystem.Api/Pages/Account/Logout.cshtml.cs b/src/ElectionSystem.Api/Pages/Account/Logout.cshtml.cs new file mode 100644 index 0000000..2d9ba1f --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Account/Logout.cshtml.cs @@ -0,0 +1,13 @@ +using ElectionSystem.Api.Modules.Identity.Domain; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages.Account; + +[Authorize] +public sealed class LogoutModel(SignInManager signInManager) : PageModel +{ + public async Task OnPostAsync() { await signInManager.SignOutAsync(); return LocalRedirect("/"); } +} diff --git a/src/ElectionSystem.Api/Pages/Admin/Index.cshtml b/src/ElectionSystem.Api/Pages/Admin/Index.cshtml new file mode 100644 index 0000000..567f49e --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Admin/Index.cshtml @@ -0,0 +1,3 @@ +@page "/admin" +@model ElectionSystem.Api.Pages.Admin.IndexModel +@{ Response.Redirect("/admin/dashboard"); } diff --git a/src/ElectionSystem.Api/Pages/Admin/Index.cshtml.cs b/src/ElectionSystem.Api/Pages/Admin/Index.cshtml.cs new file mode 100644 index 0000000..65099cb --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Admin/Index.cshtml.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages.Admin; + +[Authorize] +public sealed class IndexModel : PageModel { } diff --git a/src/ElectionSystem.Api/Pages/Admin/RegionImport.cshtml b/src/ElectionSystem.Api/Pages/Admin/RegionImport.cshtml new file mode 100644 index 0000000..f772f19 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Admin/RegionImport.cshtml @@ -0,0 +1,5 @@ +@page "/admin/region-import" +@model ElectionSystem.Api.Pages.Admin.RegionImportModel +

基础资料 / 数据目录

AreaCity 四级目录导入

上传 ok_data_level4.csv,并填写可追溯的数据版本。此操作仅系统管理员可执行,不会删除已有历史版本。

+@if (Model.Message is not null) {
@Model.Message
} +
diff --git a/src/ElectionSystem.Api/Pages/Admin/RegionImport.cshtml.cs b/src/ElectionSystem.Api/Pages/Admin/RegionImport.cshtml.cs new file mode 100644 index 0000000..a2176b7 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Admin/RegionImport.cshtml.cs @@ -0,0 +1,26 @@ +using ElectionSystem.Api.Modules.Organization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages.Admin; + +[Authorize] +public sealed class RegionImportModel(AreaCityCatalogImportService importService, RegionScopeService scopeService) : PageModel +{ + public string? Message { get; private set; } + public async Task OnPostAsync(IFormFile? file, string? sourceVersion, CancellationToken cancellationToken) + { + try + { + var userId = RegionScopeService.GetRequiredUserId(User); + await scopeService.RequireSystemAdministratorAsync(userId, cancellationToken); + if (file is null || file.Length == 0 || !Path.GetExtension(file.FileName).Equals(".csv", StringComparison.OrdinalIgnoreCase)) { Message = "请选择 AreaCity 的 .csv 文件。"; return Page(); } + await using var stream = file.OpenReadStream(); + var result = await importService.ImportAsync(stream, sourceVersion ?? "", cancellationToken); + Message = $"目录导入完成:新增 {result.CreatedCount} 条,更新 {result.UpdatedCount} 条。"; + } + catch (Exception exception) when (exception is InvalidOperationException or UnauthorizedAccessException) { Message = exception.Message; } + return Page(); + } +} diff --git a/src/ElectionSystem.Api/Pages/Admin/Users.cshtml b/src/ElectionSystem.Api/Pages/Admin/Users.cshtml new file mode 100644 index 0000000..fa01353 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Admin/Users.cshtml @@ -0,0 +1,7 @@ +@page "/admin/users" +@model ElectionSystem.Api.Pages.Admin.UsersModel +

人员与场所 / 本地身份

本地用户

仅系统管理员可创建、停用或恢复本地账号。密码只在创建或重置时提交,不会展示或记录在审计元数据中。

+@if (Model.Message is not null) {
@Model.Message
} +

创建用户

+

重置密码

+
@foreach (var user in Model.Users) { }
用户名显示名称状态创建时间操作
@user.UserName@user.DisplayName@(user.IsActive ? "启用" : "停用")@user.CreatedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")
diff --git a/src/ElectionSystem.Api/Pages/Admin/Users.cshtml.cs b/src/ElectionSystem.Api/Pages/Admin/Users.cshtml.cs new file mode 100644 index 0000000..f2fd0e3 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Admin/Users.cshtml.cs @@ -0,0 +1,51 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Identity.Domain; +using ElectionSystem.Api.Modules.Organization; +using ElectionSystem.Api.Modules.Organization.Domain; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Pages.Admin; + +[Authorize] +public sealed class UsersModel(ElectionDbContext dbContext, UserManager userManager, RegionScopeService scopeService) : PageModel +{ + public IReadOnlyList Users { get; private set; } = []; + public string? Message { get; private set; } + public async Task OnGetAsync(CancellationToken cancellationToken) { await RequireSystemAdministratorAsync(cancellationToken); await LoadAsync(cancellationToken); return Page(); } + public async Task OnPostCreateAsync(string userName, string displayName, string password, CancellationToken cancellationToken) + { + await RequireSystemAdministratorAsync(cancellationToken); + var result = await userManager.CreateAsync(new ApplicationUser { UserName = userName.Trim(), DisplayName = displayName.Trim(), IsActive = true }, password); + Message = result.Succeeded ? "本地用户已创建。请在“地域角色”中授予业务权限。" : string.Join(";", result.Errors.Select(x => x.Description)); + await LoadAsync(cancellationToken); return Page(); + } + public async Task OnPostToggleAsync(Guid id, bool active, CancellationToken cancellationToken) + { + await RequireSystemAdministratorAsync(cancellationToken); + var user = await userManager.FindByIdAsync(id.ToString()); + if (user is null) Message = "未找到用户。"; + else if (user.Id == RegionScopeService.GetRequiredUserId(User)) Message = "不能停用当前登录账号。"; + else { user.IsActive = active; var result = await userManager.UpdateAsync(user); if (result.Succeeded) await userManager.UpdateSecurityStampAsync(user); Message = result.Succeeded ? (active ? "用户已恢复。" : "用户已停用。") : string.Join(";", result.Errors.Select(x => x.Description)); } + await LoadAsync(cancellationToken); return Page(); + } + public async Task OnPostResetPasswordAsync(Guid id, string newPassword, CancellationToken cancellationToken) + { + await RequireSystemAdministratorAsync(cancellationToken); + var user = await userManager.FindByIdAsync(id.ToString()); + if (user is null) Message = "未找到用户。"; + else + { + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, token, newPassword); + Message = result.Succeeded ? "密码已重置,原有登录会话已失效。" : string.Join(";", result.Errors.Select(x => x.Description)); + } + await LoadAsync(cancellationToken); return Page(); + } + private async Task RequireSystemAdministratorAsync(CancellationToken cancellationToken) => await scopeService.RequireSystemAdministratorAsync(RegionScopeService.GetRequiredUserId(User), cancellationToken); + private async Task LoadAsync(CancellationToken cancellationToken) => Users = await dbContext.Users.OrderBy(x => x.UserName).Select(x => new UserRow(x.Id, x.UserName ?? "", x.DisplayName, x.IsActive, x.CreatedAtUtc)).ToListAsync(cancellationToken); + public sealed record UserRow(Guid Id, string UserName, string DisplayName, bool IsActive, DateTime CreatedAtUtc); +} diff --git a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml new file mode 100644 index 0000000..764b844 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml @@ -0,0 +1,9 @@ +@page "/admin/voter-import" +@model ElectionSystem.Api.Pages.Admin.VoterImportModel +@{ ViewData["Title"] = "选民导入"; } +
+

基础资料 / 批量导入

选民名单导入

Excel 首行必须依次为:选民编号、姓名、行政区划代码。系统先预校验,逐行错误清零后才可确认写入。

+ @if (Model.Message is not null) {
@Model.Message
} +
+ @if (Model.Preview is not null) {

预校验结果

可导入 @Model.Preview.ValidCount 行;错误 @Model.Preview.ErrorCount 行。

@foreach (var row in Model.Preview.Rows) { }
行号选民编号姓名行政区划结果
@row.RowNumber@row.VoterNumber@row.DisplayName@row.RegionCode@(row.Error ?? "通过")
} +
diff --git a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs new file mode 100644 index 0000000..a22cfe7 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs @@ -0,0 +1,25 @@ +using ElectionSystem.Api.Modules.Organization; +using ElectionSystem.Api.Modules.Voters; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages.Admin; + +[Authorize] +public sealed class VoterImportModel(VoterImportService importService) : PageModel +{ + public VoterImportPreview? Preview { get; private set; } + public string? Message { get; private set; } + public async Task OnPostPreviewAsync(IFormFile? file, CancellationToken cancellationToken) + { + if (file is null || file.Length == 0 || !Path.GetExtension(file.FileName).Equals(".xlsx", StringComparison.OrdinalIgnoreCase)) { Message = "请选择 .xlsx 文件。"; return Page(); } + await using var stream = file.OpenReadStream(); Preview = await importService.PreviewAsync(RegionScopeService.GetRequiredUserId(User), stream, cancellationToken); return Page(); + } + public async Task OnPostConfirmAsync(Guid token, CancellationToken cancellationToken) + { + try { var count = await importService.ConfirmAsync(RegionScopeService.GetRequiredUserId(User), token, cancellationToken); Message = $"已导入 {count} 名选民。"; } + catch (Exception exception) when (exception is InvalidOperationException or UnauthorizedAccessException) { Message = exception.Message; } + return Page(); + } +} diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs index 0b82e4c..b28da62 100644 --- a/src/ElectionSystem.Api/Program.cs +++ b/src/ElectionSystem.Api/Program.cs @@ -5,8 +5,11 @@ using ElectionSystem.Api.Infrastructure.Persistence; using ElectionSystem.Api.Modules.Cms; using ElectionSystem.Api.Modules.Organization; using ElectionSystem.Api.Modules.Voters; +using ElectionSystem.Api.Modules.Identity; +using ElectionSystem.Api.Modules.Identity.Domain; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -20,6 +23,7 @@ if (string.IsNullOrWhiteSpace(connectionString)) builder.Services.AddControllers(); builder.Services.AddRazorPages(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddCascadingAuthenticationState(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddProblemDetails(); @@ -37,7 +41,33 @@ builder.Services.AddDbContext((serviceProvider, options) => options.AddInterceptors(auditInterceptor); }); builder.Services.AddHealthChecks().AddDbContextCheck("mysql"); -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => +builder.Services.AddIdentity(options => +{ + options.User.RequireUniqueEmail = false; + options.Password.RequiredLength = 12; + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Lockout.AllowedForNewUsers = true; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); +}) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); +builder.Services.ConfigureApplicationCookie(options => +{ + options.LoginPath = "/account/login"; + options.AccessDeniedPath = "/account/access-denied"; + options.Cookie.Name = "__Host-election-auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromHours(8); +}); +builder.Services.Configure(options => options.ValidationInterval = TimeSpan.Zero); +builder.Services.AddAuthentication().AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { var authority = builder.Configuration["Authentication:Authority"]; if (!string.IsNullOrWhiteSpace(authority)) options.Authority = authority; @@ -45,6 +75,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJw if (!string.IsNullOrWhiteSpace(audience)) options.Audience = audience; }); builder.Services.AddAuthorization(); +builder.Services.AddHostedService(); builder.Services.AddHttpClient(client => { var cmsBaseUrl = builder.Configuration["Cms:PublicContentBaseUrl"] ?? "http://localhost:5065"; diff --git a/src/ElectionSystem.Api/wwwroot/css/site.css b/src/ElectionSystem.Api/wwwroot/css/site.css index a535cd2..17c3f64 100644 --- a/src/ElectionSystem.Api/wwwroot/css/site.css +++ b/src/ElectionSystem.Api/wwwroot/css/site.css @@ -61,3 +61,14 @@ a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid @media (max-width: 52rem) { .site-header__inner { align-items: flex-start; flex-direction: column; justify-content: center; padding: .9rem 0; }.site-nav { justify-content: flex-start; }.hero { min-height: 0; padding: 4.2rem 0 4rem; }.hero::after { right: -16rem; }.process-band ol, .disclosure-grid { grid-template-columns: 1fr; }.process-band li, .process-band li:first-child { padding: .85rem 0; border-top: 1px solid var(--rule); border-left: 0; }.process-band li:first-child { border-top: 0; }.area-intro { grid-template-columns: 1fr; gap: .75rem; }.disclosure-section { padding: 1.3rem; } } @media (max-width: 40rem) { .site-header__inner, .site-footer__inner, .container { width: min(100% - 2rem, 76rem); }.site-nav { gap: .15rem .8rem; }.site-nav a { font-size: .8rem; }.hero { padding: 3.5rem 0; }.hero h1 { font-size: clamp(2.05rem, 11vw, 3rem); }.article { padding: 3rem 0 4rem; }.content-list li { grid-template-columns: 1fr; gap: .35rem; }.content-list time { order: -1; }.site-footer__inner { align-items: flex-start; flex-direction: column; justify-content: center; gap: .35rem; padding: 1rem 0; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; } } + +/* 管理端:以卷宗分页和辖区边界为结构,而非复用公开门户的展示卡片。 */ +.admin-shell { display: grid; grid-template: auto 1fr / 15.5rem minmax(0, 1fr); min-height: 100vh; background: #eef2f5; } +.admin-topbar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; min-height: 4.25rem; padding: 0 1.5rem; color: #fff; background: #163a59; }.admin-brand { display: flex; align-items: baseline; gap: .55rem; color: #fff; text-decoration: none; }.admin-brand span { padding: .16rem .38rem; color: #163a59; background: #d4e7f5; font-weight: 850; letter-spacing: .08em; }.admin-brand strong { letter-spacing: .08em; }.admin-public-link { color: #d4e7f5; font-size: .85rem; } +.admin-sidebar { padding: 1.25rem .8rem; background: #fff; border-right: 1px solid #d6dfe7; }.admin-sidebar a { display: block; margin: .15rem 0; padding: .66rem .8rem; color: #3c5264; font-size: .92rem; text-decoration: none; border-left: .18rem solid transparent; }.admin-sidebar a.active, .admin-sidebar a:hover { color: #123c60; background: #eef6fb; border-left-color: #1c73ad; font-weight: 750; }.admin-sidebar__label { margin: 1.2rem .8rem .4rem; color: #758899; font-size: .7rem; font-weight: 800; letter-spacing: .12em; }.admin-sidebar__label:first-child { margin-top: .3rem; } +.admin-content { min-width: 0; padding: clamp(1.25rem, 3vw, 2.5rem); }.admin-page, .admin-razor-page { width: min(100%, 82rem); }.admin-page__heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; }.admin-page h1, .admin-razor-page h1 { margin: 0; font-family: "Noto Serif SC", SimSun, serif; font-size: clamp(1.8rem, 3.4vw, 2.6rem); font-weight: 600; }.admin-page__heading p:not(.eyebrow), .admin-razor-page > p:not(.eyebrow) { max-width: 48rem; margin: .55rem 0 0; color: #617487; line-height: 1.7; } +.admin-stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; border: 1px solid #d5e0e8; background: #d5e0e8; }.admin-stat-grid article { padding: 1.25rem; background: #fff; }.admin-stat-grid span, .admin-stat-grid small { display: block; color: #64798b; font-size: .8rem; }.admin-stat-grid strong { display: block; margin: .35rem 0 .25rem; color: #163a59; font-family: ui-monospace, Consolas, monospace; font-size: 2.15rem; }.admin-next { margin-top: 1.5rem; padding: 1.35rem; background: #fff; border-top: .25rem solid #1c73ad; }.admin-next h2, .admin-form h2, .admin-preview h2 { margin: 0 0 .8rem; font-family: "Noto Serif SC", SimSun, serif; font-size: 1.25rem; }.admin-next ol { margin: 0; padding-left: 1.25rem; }.admin-next li { padding: .35rem 0; }.admin-next a { color: #1769aa; font-weight: 700; } +.admin-filter, .admin-form { display: flex; flex-wrap: wrap; align-items: end; gap: .9rem; margin: 1rem 0 1.35rem; padding: 1.1rem; background: #fff; border: 1px solid #d7e0e8; }.admin-filter label, .admin-form label { display: grid; flex: 1 1 12rem; gap: .38rem; color: #405769; font-size: .83rem; font-weight: 750; }.admin-filter input, .admin-filter select, .admin-form input, .admin-form select, .admin-form textarea { width: 100%; padding: .62rem .68rem; color: #182b3d; background: #fff; border: 1px solid #b9c8d5; border-radius: 0; font: inherit; }.admin-form textarea { min-height: 5.5rem; resize: vertical; }.admin-button { display: inline-flex; align-items: center; justify-content: center; min-height: 2.5rem; padding: .56rem .9rem; color: #fff; background: #1769aa; border: 1px solid #1769aa; border-radius: 0; font: inherit; font-weight: 750; text-decoration: none; cursor: pointer; }.admin-button:hover { background: #0d568e; }.admin-button:disabled { opacity: .5; cursor: not-allowed; }.admin-button--quiet { color: #1769aa; background: #fff; }.admin-link-button { padding: 0; color: #1769aa; background: transparent; border: 0; font: inherit; font-size: .86rem; font-weight: 750; cursor: pointer; }.admin-link-button--danger { color: #a22f2f; }.admin-alert { margin: 1rem 0; padding: .85rem 1rem; color: #204d6e; background: #e7f2fa; border-left: .25rem solid #1769aa; }.admin-loading, .admin-empty { padding: 1.2rem; color: #64798b; background: #fff; }.admin-table-wrap { overflow-x: auto; background: #fff; border: 1px solid #d7e0e8; }.admin-table { width: 100%; border-collapse: collapse; font-size: .9rem; }.admin-table th, .admin-table td { padding: .8rem .9rem; text-align: left; border-bottom: 1px solid #e0e8ee; vertical-align: middle; }.admin-table th { color: #526a7c; background: #f5f8fa; font-size: .76rem; letter-spacing: .06em; }.admin-table tbody tr:hover { background: #f7fbfd; }.admin-table .admin-empty { text-align: center; }.admin-status { display: inline-block; padding: .18rem .48rem; color: #35556e; background: #e8f1f6; font-size: .78rem; font-weight: 750; }.admin-status.Disabled { color: #8c3939; background: #f9e8e8; }.admin-mono { font-family: ui-monospace, Consolas, monospace; font-size: .8rem; }.admin-preview { margin-top: 1rem; }.admin-razor-page { padding: 2.5rem; } +@media (max-width: 62rem) { .admin-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.admin-shell { grid-template-columns: 12.5rem minmax(0, 1fr); } } +@media (max-width: 46rem) { .admin-shell { display: block; }.admin-sidebar { display: flex; overflow-x: auto; padding: .5rem .75rem; border-right: 0; border-bottom: 1px solid #d6dfe7; }.admin-sidebar__label { display: none; }.admin-sidebar a { flex: 0 0 auto; border-left: 0; border-bottom: .18rem solid transparent; }.admin-sidebar a.active { border-bottom-color: #1c73ad; }.admin-content { padding: 1.2rem 1rem; }.admin-page__heading { align-items: start; flex-direction: column; }.admin-stat-grid { grid-template-columns: 1fr 1fr; }.admin-razor-page { padding: 1.2rem 1rem; }.admin-topbar { padding: 0 1rem; } } +.login-page { display: grid; min-height: 100vh; place-items: center; padding: 1.5rem; background: linear-gradient(135deg, #e5eff6 0%, #f4f7fa 55%, #dce9f2 100%); }.login-card { width: min(100%, 27rem); padding: 2.25rem; background: #fff; border-top: .3rem solid #1769aa; box-shadow: 0 1.4rem 3rem rgba(22, 58, 89, .14); }.login-card h1 { margin: 0; font-family: "Noto Serif SC", SimSun, serif; font-size: 2rem; }.login-card > p:not(.eyebrow) { color: #617487; line-height: 1.7; }.login-form { display: grid; gap: 1rem; margin-top: 1.5rem; }.login-form label { display: grid; gap: .4rem; color: #405769; font-size: .86rem; font-weight: 750; }.login-form input:not([type="checkbox"]) { padding: .68rem; border: 1px solid #b9c8d5; font: inherit; }.login-check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: .5rem !important; font-weight: 500 !important; }.login-error { color: #992f2f; font-size: .88rem; } diff --git a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs index bbe351a..4242879 100644 --- a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs +++ b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs @@ -5,6 +5,7 @@ using ElectionSystem.Api.Modules.Organization; using ElectionSystem.Api.Modules.Organization.Domain; using ElectionSystem.Api.Modules.Voters.Domain; using System.Text; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Tests; @@ -73,4 +74,16 @@ public sealed class PersistenceModelTests Assert.Equal(district.Id, township.ParentId); Assert.Equal(AdministrativeRegionLevel.Township, township.Level); } + + [Fact] + public void Local_identity_password_hash_cannot_be_reused_as_plaintext() + { + var user = new ApplicationUser { UserName = "local-admin", DisplayName = "Local administrator" }; + var hasher = new PasswordHasher(); + var hash = hasher.HashPassword(user, "Correct-Horse-7!Battery"); + + Assert.NotEqual("Correct-Horse-7!Battery", hash); + Assert.NotEqual(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "Correct-Horse-7!Battery")); + Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong-password")); + } }