From 4ef6f6c90de0fe1a209169de796a8d73c61582da Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 29 Aug 2026 17:26:59 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 9 +- docs/architecture.md | 12 + .../Common/Errors/GlobalExceptionHandler.cs | 14 +- .../AdministrationControllerBase.cs | 13 + .../AdministrativeRegionsController.cs | 45 ++ .../Controllers/CandidatesController.cs | 61 +++ .../Controllers/CountingStationsController.cs | 43 ++ .../RegionalRoleAssignmentsController.cs | 60 +++ .../Controllers/VotersController.cs | 63 +++ .../ElectionSystem.Api.csproj | 2 + .../Persistence/ElectionDbContext.cs | 65 +++ .../Persistence/ElectionDbContextFactory.cs | 3 +- ...4_OrganizationVotersCandidates.Designer.cs | 438 ++++++++++++++++++ ...0829092014_OrganizationVotersCandidates.cs | 298 ++++++++++++ .../ElectionDbContextModelSnapshot.cs | 300 +++++++++++- .../Candidates/Domain/CandidateProfile.cs | 15 + .../Identity/Domain/ApplicationUser.cs | 1 + .../AreaCityCatalogImportService.cs | 68 +++ .../Domain/AdministrativeRegion.cs | 63 +++ .../Organization/RegionScopeService.cs | 44 ++ .../Modules/Voters/Domain/VoterProfile.cs | 18 + .../Modules/Voters/VoterImportService.cs | 56 +++ src/ElectionSystem.Api/Program.cs | 15 + .../PersistenceModelTests.cs | 49 ++ 24 files changed, 1745 insertions(+), 10 deletions(-) create mode 100644 src/ElectionSystem.Api/Controllers/AdministrationControllerBase.cs create mode 100644 src/ElectionSystem.Api/Controllers/AdministrativeRegionsController.cs create mode 100644 src/ElectionSystem.Api/Controllers/CandidatesController.cs create mode 100644 src/ElectionSystem.Api/Controllers/CountingStationsController.cs create mode 100644 src/ElectionSystem.Api/Controllers/RegionalRoleAssignmentsController.cs create mode 100644 src/ElectionSystem.Api/Controllers/VotersController.cs create mode 100644 src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829092014_OrganizationVotersCandidates.Designer.cs create mode 100644 src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829092014_OrganizationVotersCandidates.cs create mode 100644 src/ElectionSystem.Api/Modules/Candidates/Domain/CandidateProfile.cs create mode 100644 src/ElectionSystem.Api/Modules/Organization/AreaCityCatalogImportService.cs create mode 100644 src/ElectionSystem.Api/Modules/Organization/Domain/AdministrativeRegion.cs create mode 100644 src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs create mode 100644 src/ElectionSystem.Api/Modules/Voters/Domain/VoterProfile.cs create mode 100644 src/ElectionSystem.Api/Modules/Voters/VoterImportService.cs diff --git a/TODO.md b/TODO.md index 515f7bc..45acd56 100644 --- a/TODO.md +++ b/TODO.md @@ -38,11 +38,12 @@ ## 第二阶段:组织、选民与候选人 -- [ ] 组织架构:组织、部门、班级等层级与成员归属。 -- [ ] 用户和选民资料:导入、停用、有效状态、组织范围查询。 +- [x] 行政区划目录:省、市、区县、镇街、村社区五级;省市区镇采用受控 AreaCity 数据版本,村社区采用本地确认目录。 +- [x] 地域角色与范围:省、市、区、镇、村管理员的下级覆盖;计票员、监督员与具体计票所分配。 +- [x] 用户和选民资料:导入预校验/确认、停用、有效状态、地域范围查询。 - [ ] 选民资格规则:按组织、名单、身份属性或人工审核确定。 -- [ ] 候选人管理:提名、自荐、资格材料、审核、撤回、取消资格和公示。 -- [ ] 提供 Excel 批量导入/导出,导入须预校验并返回逐行错误。 +- [x] 候选人管理:提名、自荐、服务端资格状态转换、撤回、取消资格和公示;资格材料文件待对象存储阶段接入。 +- [x] 提供 Excel 批量导入,导入预校验并返回逐行错误;导出将在管理端与授权下载审计接入时实施。 ## 第三阶段:选举活动与在线投票 diff --git a/docs/architecture.md b/docs/architecture.md index 920615d..17816fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,3 +23,15 @@ CMS 使用独立的 `ElectionSystem.Cms` Orchard Core 主机及数据库;具 ## 当前数据库对象 初始迁移仅创建 `identity_users`、`organization_units` 和 `audit_entries`。审计元数据只记录受影响的字段名,不能用于保存线上匿名选票内容或关联信息。 + +## 第二阶段:地域、人员与候选人 + +`administrative_regions` 是行政区划目录,不承载单位、支部或选举组织。其层级固定为省、市、区县、镇街、村社区五级;省市区镇基础数据应由 AreaCity-JsSpider-StatsGov 的受控版本导入,村社区只能由本地确认目录预校验后导入,并保留 `Source` 与 `SourceVersion`。补齐节点保留 `IsSynthetic`,不可擅自扁平化。 + +`identity_user_regional_roles` 将用户角色与地域范围分开保存。省、市、区、镇、村管理员的范围自动覆盖全部下级地域;系统管理员没有地域限制。计票员和监督员还必须经 `identity_user_counting_stations` 分配到具体计票所,未来线下计票接口必须同时检查地域角色和计票所分配。 + +所有 `/api/admin/*` 路由要求 JWT Bearer 认证,并要求身份提供的本地用户 GUID(`nameidentifier` 或 `sub` claim)。部署时配置 `Authentication:Authority`、`Authentication:Audience`,并在 OpenIddict/Keycloak 的声明映射中提供该 GUID。未认证请求返回 401,不能触发地域范围查询或写入。 + +`voter_profiles` 是选民主数据,按地域、状态查询和写入均须先应用管理员范围。选民 Excel 采用“上传预校验—逐行错误—同一操作者确认”模式,预览在 30 分钟后失效,确认前重新检查范围和重复选民编号。它不保存投票内容,后续 `Voting` 模块只可从选民 ID 创建资格/已投状态。 + +`candidate_profiles` 维护候选人地域归属和提名、审核、撤回、取消资格、公示状态;状态转换必须在服务端验证,撤回和取消资格必须说明原因。候选人材料文件将在对象存储接入后作为独立、授权的附件实体实现。 diff --git a/src/ElectionSystem.Api/Common/Errors/GlobalExceptionHandler.cs b/src/ElectionSystem.Api/Common/Errors/GlobalExceptionHandler.cs index 1143ed6..0b7da38 100644 --- a/src/ElectionSystem.Api/Common/Errors/GlobalExceptionHandler.cs +++ b/src/ElectionSystem.Api/Common/Errors/GlobalExceptionHandler.cs @@ -8,12 +8,18 @@ public sealed class GlobalExceptionHandler(ILogger logge public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { var traceId = httpContext.TraceIdentifier; - logger.LogError(exception, "Unhandled request failure. TraceId: {TraceId}", traceId); + var status = exception switch + { + UnauthorizedAccessException => StatusCodes.Status403Forbidden, + InvalidOperationException => StatusCodes.Status409Conflict, + _ => StatusCodes.Status500InternalServerError + }; + if (status >= 500) logger.LogError(exception, "Unhandled request failure. TraceId: {TraceId}", traceId); var problem = new ProblemDetails { - Status = StatusCodes.Status500InternalServerError, - Title = "An unexpected error occurred.", - Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", + Status = status, + Title = status == StatusCodes.Status403Forbidden ? "The action is outside your authorized scope." : status == StatusCodes.Status409Conflict ? "The request conflicts with current data or workflow state." : "An unexpected error occurred.", + Type = status == StatusCodes.Status403Forbidden ? "https://tools.ietf.org/html/rfc9110#section-15.5.4" : "https://tools.ietf.org/html/rfc9110#section-15.6.1", Instance = httpContext.Request.Path }; problem.Extensions["traceId"] = traceId; diff --git a/src/ElectionSystem.Api/Controllers/AdministrationControllerBase.cs b/src/ElectionSystem.Api/Controllers/AdministrationControllerBase.cs new file mode 100644 index 0000000..04fade1 --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/AdministrationControllerBase.cs @@ -0,0 +1,13 @@ +using ElectionSystem.Api.Modules.Organization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ElectionSystem.Api.Controllers; + +[ApiController] +[Authorize] +public abstract class AdministrationControllerBase(RegionScopeService scopeService) : ControllerBase +{ + protected Guid ActorId => RegionScopeService.GetRequiredUserId(User); + protected RegionScopeService ScopeService { get; } = scopeService; +} diff --git a/src/ElectionSystem.Api/Controllers/AdministrativeRegionsController.cs b/src/ElectionSystem.Api/Controllers/AdministrativeRegionsController.cs new file mode 100644 index 0000000..7809768 --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/AdministrativeRegionsController.cs @@ -0,0 +1,45 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Organization.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Controllers; + +[Route("api/admin/administrative-regions")] +public sealed class AdministrativeRegionsController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, Modules.Organization.AreaCityCatalogImportService catalogImportService) : AdministrationControllerBase(scopeService) +{ + [HttpGet] + public async Task List([FromQuery] Guid? parentId, CancellationToken cancellationToken) + { + var allowed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var items = await dbContext.AdministrativeRegions.Where(x => x.ParentId == parentId && allowed.Contains(x.Id)).OrderBy(x => x.Code) + .Select(x => new { x.Id, x.ParentId, x.Code, x.Name, x.Level, x.Source, x.SourceVersion, x.IsSynthetic, x.IsActive }).ToListAsync(cancellationToken); + return Ok(items); + } + + [HttpPost("area-city-import")] + [RequestSizeLimit(15_000_000)] + public async Task ImportAreaCity(IFormFile file, [FromForm] string sourceVersion, CancellationToken cancellationToken) + { + await ScopeService.RequireSystemAdministratorAsync(ActorId, cancellationToken); + if (file.Length == 0 || !Path.GetExtension(file.FileName).Equals(".csv", StringComparison.OrdinalIgnoreCase)) return ValidationProblem("请上传 AreaCity 的 ok_data_level4.csv 文件。"); + await using var stream = file.OpenReadStream(); + return Ok(await catalogImportService.ImportAsync(stream, sourceVersion, cancellationToken)); + } + + [HttpPost] + public async Task Create(CreateAdministrativeRegionRequest request, CancellationToken cancellationToken) + { + await ScopeService.RequireSystemAdministratorAsync(ActorId, cancellationToken); + if (request.Level != AdministrativeRegionLevel.Village) return ValidationProblem("基础省市区镇目录必须由受控目录导入,管理端仅可新增经确认的村/社区。"); + var parent = await dbContext.AdministrativeRegions.SingleOrDefaultAsync(x => x.Id == request.ParentId && x.IsActive, cancellationToken); + if (parent is null || parent.Level != AdministrativeRegionLevel.Township) return ValidationProblem("村/社区必须属于有效的镇/街道。"); + if (await dbContext.AdministrativeRegions.AnyAsync(x => x.Code == request.Code && x.SourceVersion == request.SourceVersion, cancellationToken)) return Conflict(new { code = "region_code_exists" }); + var region = new AdministrativeRegion { ParentId = parent.Id, Code = request.Code.Trim(), Name = request.Name.Trim(), Level = request.Level, Source = "LocalApprovedVillageImport", SourceVersion = request.SourceVersion.Trim() }; + dbContext.AdministrativeRegions.Add(region); + await dbContext.SaveChangesAsync(cancellationToken); + return CreatedAtAction(nameof(List), new { parentId = parent.Id }, new { region.Id }); + } +} + +public sealed record CreateAdministrativeRegionRequest(Guid ParentId, string Code, string Name, AdministrativeRegionLevel Level, string SourceVersion); diff --git a/src/ElectionSystem.Api/Controllers/CandidatesController.cs b/src/ElectionSystem.Api/Controllers/CandidatesController.cs new file mode 100644 index 0000000..3d74108 --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/CandidatesController.cs @@ -0,0 +1,61 @@ +using ElectionSystem.Api.Common.Pagination; +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Candidates.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Controllers; + +[Route("api/admin/candidates")] +public sealed class CandidatesController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService) : AdministrationControllerBase(scopeService) +{ + [HttpGet] + public async Task List([FromQuery] Guid? regionId, [FromQuery] CandidateStatus? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) + { + page = Math.Max(page, 1); pageSize = Math.Clamp(pageSize, 1, 100); + var allowed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var query = dbContext.Candidates.Where(x => allowed.Contains(x.RegionId)); + if (regionId.HasValue) query = query.Where(x => x.RegionId == regionId); + if (status.HasValue) query = query.Where(x => x.Status == status); + var total = await query.LongCountAsync(cancellationToken); + var items = await query.OrderBy(x => x.DisplayName).ThenBy(x => x.Id).Skip((page - 1) * pageSize).Take(pageSize).Select(x => new { x.Id, x.DisplayName, x.RegionId, x.Status, x.NominationSummary, x.StatusReason, x.UpdatedAtUtc }).ToListAsync(cancellationToken); + return Ok(new PagedResult(items.Cast().ToList(), page, pageSize, total)); + } + + [HttpPost] + public async Task Create(CreateCandidateRequest request, CancellationToken cancellationToken) + { + await ScopeService.RequireManagedRegionAsync(ActorId, request.RegionId, cancellationToken); + var candidate = new CandidateProfile { DisplayName = request.DisplayName.Trim(), RegionId = request.RegionId, NominationSummary = request.NominationSummary?.Trim() }; + dbContext.Candidates.Add(candidate); await dbContext.SaveChangesAsync(cancellationToken); + return Ok(new { candidate.Id }); + } + + [HttpPut("{id:guid}/status")] + public async Task SetStatus(Guid id, SetCandidateStatusRequest request, CancellationToken cancellationToken) + { + var candidate = await dbContext.Candidates.SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + if (candidate is null) return NotFound(); + await ScopeService.RequireManagedRegionAsync(ActorId, candidate.RegionId, cancellationToken); + if (!CandidateTransitions.Allows(candidate.Status, request.Status)) return ValidationProblem("候选人状态转换不合法。"); + if ((request.Status is CandidateStatus.Withdrawn or CandidateStatus.Disqualified) && string.IsNullOrWhiteSpace(request.Reason)) return ValidationProblem("撤回或取消资格必须填写原因。"); + candidate.Status = request.Status; candidate.StatusReason = request.Reason?.Trim(); candidate.UpdatedAtUtc = DateTime.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); return NoContent(); + } +} + +public sealed record CreateCandidateRequest(string DisplayName, Guid RegionId, string? NominationSummary); +public sealed record SetCandidateStatusRequest(CandidateStatus Status, string? Reason); + +internal static class CandidateTransitions +{ + public static bool Allows(CandidateStatus current, CandidateStatus next) => current == next || (current, next) 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 + }; +} diff --git a/src/ElectionSystem.Api/Controllers/CountingStationsController.cs b/src/ElectionSystem.Api/Controllers/CountingStationsController.cs new file mode 100644 index 0000000..e1ab44a --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/CountingStationsController.cs @@ -0,0 +1,43 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Organization.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Controllers; + +[Route("api/admin/counting-stations")] +public sealed class CountingStationsController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService) : AdministrationControllerBase(scopeService) +{ + [HttpGet] + public async Task List([FromQuery] Guid? regionId, CancellationToken cancellationToken) + { + var allowed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var query = dbContext.CountingStations.Where(x => allowed.Contains(x.RegionId)); + if (regionId.HasValue) query = query.Where(x => x.RegionId == regionId); + return Ok(await query.OrderBy(x => x.Code).Select(x => new { x.Id, x.Code, x.Name, x.RegionId, x.IsActive }).ToListAsync(cancellationToken)); + } + + [HttpPost] + public async Task Create(CreateCountingStationRequest request, CancellationToken cancellationToken) + { + await ScopeService.RequireManagedRegionAsync(ActorId, request.RegionId, cancellationToken); + if (await dbContext.CountingStations.AnyAsync(x => x.Code == request.Code, cancellationToken)) return Conflict(new { code = "counting_station_code_exists" }); + var station = new CountingStation { Code = request.Code.Trim(), Name = request.Name.Trim(), RegionId = request.RegionId }; + dbContext.CountingStations.Add(station); await dbContext.SaveChangesAsync(cancellationToken); return Ok(new { station.Id }); + } + + [HttpPost("{stationId:guid}/staff")] + public async Task AssignStaff(Guid stationId, AssignCountingStationStaffRequest request, CancellationToken cancellationToken) + { + var station = await dbContext.CountingStations.SingleOrDefaultAsync(x => x.Id == stationId && x.IsActive, cancellationToken); + if (station is null) return NotFound(); + await ScopeService.RequireManagedRegionAsync(ActorId, station.RegionId, cancellationToken); + if (!await dbContext.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == request.UserId && x.RegionId == station.RegionId && (x.Role == RegionalRole.CountingStationClerk || x.Role == RegionalRole.Supervisor), cancellationToken)) return ValidationProblem("工作人员必须先在计票所所属辖区获得计票员或监督员角色。"); + if (await dbContext.UserCountingStationAssignments.AnyAsync(x => x.UserId == request.UserId && x.CountingStationId == stationId, cancellationToken)) return Conflict(new { code = "counting_station_staff_exists" }); + dbContext.UserCountingStationAssignments.Add(new UserCountingStationAssignment { UserId = request.UserId, CountingStationId = stationId }); + await dbContext.SaveChangesAsync(cancellationToken); return NoContent(); + } +} + +public sealed record CreateCountingStationRequest(string Code, string Name, Guid RegionId); +public sealed record AssignCountingStationStaffRequest(Guid UserId); diff --git a/src/ElectionSystem.Api/Controllers/RegionalRoleAssignmentsController.cs b/src/ElectionSystem.Api/Controllers/RegionalRoleAssignmentsController.cs new file mode 100644 index 0000000..847b576 --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/RegionalRoleAssignmentsController.cs @@ -0,0 +1,60 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Organization.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Controllers; + +[Route("api/admin/regional-role-assignments")] +public sealed class RegionalRoleAssignmentsController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService) : AdministrationControllerBase(scopeService) +{ + [HttpGet] + public async Task List([FromQuery] Guid? userId, CancellationToken cancellationToken) + { + await ScopeService.RequireSystemAdministratorAsync(ActorId, cancellationToken); + var query = dbContext.UserRegionalRoleAssignments.AsQueryable(); + if (userId.HasValue) query = query.Where(x => x.UserId == userId); + return Ok(await query.OrderBy(x => x.UserId).ThenBy(x => x.Role).Select(x => new { x.Id, x.UserId, x.RegionId, x.Role, x.CreatedAtUtc }).ToListAsync(cancellationToken)); + } + + [HttpPost] + public async Task Create(CreateRegionalRoleAssignmentRequest request, CancellationToken cancellationToken) + { + await ScopeService.RequireSystemAdministratorAsync(ActorId, cancellationToken); + if (!await dbContext.Users.AnyAsync(x => x.Id == request.UserId && x.IsActive, cancellationToken)) return ValidationProblem("用户不存在或已停用。"); + if (request.Role == RegionalRole.SystemAdministrator) + { + if (request.RegionId is not null) return ValidationProblem("系统管理员不绑定行政区划。"); + } + else + { + if (request.RegionId is null || !await dbContext.AdministrativeRegions.AnyAsync(x => x.Id == request.RegionId && x.IsActive, cancellationToken)) return ValidationProblem("非系统角色必须绑定有效行政区划。"); + if (!RoleMatchesRegionLevel(request.Role, await dbContext.AdministrativeRegions.Where(x => x.Id == request.RegionId).Select(x => x.Level).SingleAsync(cancellationToken))) return ValidationProblem("管理员角色必须与对应行政区划层级一致;计票和监督角色可绑定任何层级。"); + } + if (await dbContext.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == request.UserId && x.Role == request.Role && x.RegionId == request.RegionId, cancellationToken)) return Conflict(new { code = "role_assignment_exists" }); + var assignment = new UserRegionalRoleAssignment { UserId = request.UserId, RegionId = request.RegionId, Role = request.Role }; + dbContext.UserRegionalRoleAssignments.Add(assignment); await dbContext.SaveChangesAsync(cancellationToken); + return Ok(new { assignment.Id }); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + await ScopeService.RequireSystemAdministratorAsync(ActorId, cancellationToken); + var assignment = await dbContext.UserRegionalRoleAssignments.SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + if (assignment is null) return NotFound(); + dbContext.UserRegionalRoleAssignments.Remove(assignment); await dbContext.SaveChangesAsync(cancellationToken); return NoContent(); + } + + private static bool RoleMatchesRegionLevel(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 + }; +} + +public sealed record CreateRegionalRoleAssignmentRequest(Guid UserId, Guid? RegionId, RegionalRole Role); diff --git a/src/ElectionSystem.Api/Controllers/VotersController.cs b/src/ElectionSystem.Api/Controllers/VotersController.cs new file mode 100644 index 0000000..1e20d8d --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/VotersController.cs @@ -0,0 +1,63 @@ +using ElectionSystem.Api.Common.Pagination; +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Voters; +using ElectionSystem.Api.Modules.Voters.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Controllers; + +[Route("api/admin/voters")] +public sealed class VotersController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, VoterImportService importService) : AdministrationControllerBase(scopeService) +{ + [HttpGet] + public async Task>> List([FromQuery] Guid? regionId, [FromQuery] VoterStatus? status, [FromQuery] string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) + { + page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); + var allowed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var query = dbContext.Voters.Where(x => allowed.Contains(x.RegionId)); + if (regionId.HasValue) query = query.Where(x => x.RegionId == regionId); + if (status.HasValue) query = query.Where(x => x.Status == status); + if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.VoterNumber.Contains(keyword) || x.DisplayName.Contains(keyword)); + var total = await query.LongCountAsync(cancellationToken); + var items = await query.OrderBy(x => x.DisplayName).ThenBy(x => x.Id).Skip((page - 1) * pageSize).Take(pageSize).Select(x => (object)new { x.Id, x.VoterNumber, x.DisplayName, x.RegionId, x.Status, x.DisabledReason, x.UpdatedAtUtc }).ToListAsync(cancellationToken); + return Ok(new PagedResult(items, page, pageSize, total)); + } + + [HttpPost] + public async Task Create(CreateVoterRequest request, CancellationToken cancellationToken) + { + await ScopeService.RequireManagedRegionAsync(ActorId, request.RegionId, cancellationToken); + if (await dbContext.Voters.AnyAsync(x => x.VoterNumber == request.VoterNumber, cancellationToken)) return Conflict(new { code = "voter_number_exists" }); + dbContext.Voters.Add(new VoterProfile { VoterNumber = request.VoterNumber.Trim(), DisplayName = request.DisplayName.Trim(), RegionId = request.RegionId }); + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPut("{id:guid}/status")] + public async Task SetStatus(Guid id, SetVoterStatusRequest request, CancellationToken cancellationToken) + { + var voter = await dbContext.Voters.SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + if (voter is null) return NotFound(); + await ScopeService.RequireManagedRegionAsync(ActorId, voter.RegionId, cancellationToken); + if (request.Status == VoterStatus.Disabled && string.IsNullOrWhiteSpace(request.Reason)) return ValidationProblem("停用选民必须说明原因。"); + voter.Status = request.Status; voter.DisabledReason = request.Status == VoterStatus.Disabled ? request.Reason?.Trim() : null; voter.UpdatedAtUtc = DateTime.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPost("import/preview")] + [RequestSizeLimit(10_000_000)] + public async Task PreviewImport(IFormFile file, CancellationToken cancellationToken) + { + if (file.Length == 0 || !Path.GetExtension(file.FileName).Equals(".xlsx", StringComparison.OrdinalIgnoreCase)) return ValidationProblem("请上传 .xlsx 文件,列顺序为:选民编号、姓名、行政区划代码。"); + await using var stream = file.OpenReadStream(); + return Ok(await importService.PreviewAsync(ActorId, stream, cancellationToken)); + } + + [HttpPost("import/{token:guid}/confirm")] + public async Task ConfirmImport(Guid token, CancellationToken cancellationToken) => Ok(new { importedCount = await importService.ConfirmAsync(ActorId, token, cancellationToken) }); +} + +public sealed record CreateVoterRequest(string VoterNumber, string DisplayName, Guid RegionId); +public sealed record SetVoterStatusRequest(VoterStatus Status, string? Reason); diff --git a/src/ElectionSystem.Api/ElectionSystem.Api.csproj b/src/ElectionSystem.Api/ElectionSystem.Api.csproj index 1523fc2..35cd3ba 100644 --- a/src/ElectionSystem.Api/ElectionSystem.Api.csproj +++ b/src/ElectionSystem.Api/ElectionSystem.Api.csproj @@ -8,6 +8,8 @@ + + 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 b7b9801..fbaf53b 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs @@ -1,6 +1,8 @@ using ElectionSystem.Api.Infrastructure.Auditing; using ElectionSystem.Api.Modules.Identity.Domain; using ElectionSystem.Api.Modules.Organization.Domain; +using ElectionSystem.Api.Modules.Voters.Domain; +using ElectionSystem.Api.Modules.Candidates.Domain; using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Infrastructure.Persistence; @@ -9,6 +11,12 @@ public sealed class ElectionDbContext(DbContextOptions option { public DbSet Users => Set(); public DbSet OrganizationUnits => Set(); + public DbSet AdministrativeRegions => Set(); + public DbSet UserRegionalRoleAssignments => Set(); + public DbSet CountingStations => Set(); + public DbSet UserCountingStationAssignments => Set(); + public DbSet Voters => Set(); + public DbSet Candidates => Set(); public DbSet AuditEntries => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -18,7 +26,64 @@ public sealed class ElectionDbContext(DbContextOptions option entity.ToTable("identity_users"); entity.Property(x => x.UserName).HasMaxLength(128).IsRequired(); entity.Property(x => x.DisplayName).HasMaxLength(128).IsRequired(); + entity.Property(x => x.ExternalSubject).HasMaxLength(256); entity.HasIndex(x => x.UserName).IsUnique(); + entity.HasIndex(x => x.ExternalSubject).IsUnique(); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("administrative_regions"); + entity.Property(x => x.Code).HasMaxLength(32).IsRequired(); + entity.Property(x => x.Name).HasMaxLength(200).IsRequired(); + entity.Property(x => x.Source).HasMaxLength(128).IsRequired(); + entity.Property(x => x.SourceVersion).HasMaxLength(64).IsRequired(); + entity.HasIndex(x => new { x.Code, x.SourceVersion }).IsUnique(); + entity.HasIndex(x => new { x.ParentId, x.Level, x.IsActive }); + entity.HasOne().WithMany().HasForeignKey(x => x.ParentId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("identity_user_regional_roles"); + entity.HasIndex(x => new { x.UserId, x.Role, x.RegionId }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne().WithMany().HasForeignKey(x => x.RegionId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("counting_stations"); + entity.Property(x => x.Name).HasMaxLength(200).IsRequired(); + entity.Property(x => x.Code).HasMaxLength(64).IsRequired(); + entity.HasIndex(x => x.Code).IsUnique(); + entity.HasIndex(x => x.RegionId); + entity.HasOne().WithMany().HasForeignKey(x => x.RegionId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("identity_user_counting_stations"); + entity.HasIndex(x => new { x.UserId, x.CountingStationId }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne().WithMany().HasForeignKey(x => x.CountingStationId).OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("voter_profiles"); + entity.Property(x => x.VoterNumber).HasMaxLength(64).IsRequired(); + entity.Property(x => x.DisplayName).HasMaxLength(128).IsRequired(); + entity.Property(x => x.DisabledReason).HasMaxLength(500); + entity.HasIndex(x => x.VoterNumber).IsUnique(); + entity.HasIndex(x => new { x.RegionId, x.Status, x.DisplayName }); + entity.HasIndex(x => x.UserId).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.RegionId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("candidate_profiles"); + entity.Property(x => x.DisplayName).HasMaxLength(128).IsRequired(); + entity.Property(x => x.NominationSummary).HasMaxLength(4000); + entity.Property(x => x.StatusReason).HasMaxLength(500); + entity.HasIndex(x => new { x.RegionId, x.Status, x.DisplayName }); + entity.HasOne().WithMany().HasForeignKey(x => x.RegionId).OnDelete(DeleteBehavior.Restrict); }); modelBuilder.Entity(entity => { diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs index ac4e8db..78e596b 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs @@ -9,7 +9,8 @@ public sealed class ElectionDbContextFactory : IDesignTimeDbContextFactory().UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)).Options; + // 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/20260829092014_OrganizationVotersCandidates.Designer.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829092014_OrganizationVotersCandidates.Designer.cs new file mode 100644 index 0000000..f5637ec --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829092014_OrganizationVotersCandidates.Designer.cs @@ -0,0 +1,438 @@ +// +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("20260829092014_OrganizationVotersCandidates")] + partial class OrganizationVotersCandidates + { + /// + 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.ApplicationUser", 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("ExternalSubject") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSubject") + .IsUnique(); + + 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("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); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829092014_OrganizationVotersCandidates.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829092014_OrganizationVotersCandidates.cs new file mode 100644 index 0000000..926a71d --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829092014_OrganizationVotersCandidates.cs @@ -0,0 +1,298 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class OrganizationVotersCandidates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExternalSubject", + table: "identity_users", + type: "varchar(256)", + maxLength: 256, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "administrative_regions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ParentId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + Code = table.Column(type: "varchar(32)", maxLength: 32, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Name = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Level = table.Column(type: "int", nullable: false), + Source = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SourceVersion = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IsSynthetic = table.Column(type: "tinyint(1)", nullable: false), + IsActive = table.Column(type: "tinyint(1)", nullable: false), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_administrative_regions", x => x.Id); + table.ForeignKey( + name: "FK_administrative_regions_administrative_regions_ParentId", + column: x => x.ParentId, + principalTable: "administrative_regions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "candidate_profiles", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + DisplayName = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Status = table.Column(type: "int", nullable: false), + NominationSummary = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + StatusReason = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_candidate_profiles", x => x.Id); + table.ForeignKey( + name: "FK_candidate_profiles_administrative_regions_RegionId", + column: x => x.RegionId, + principalTable: "administrative_regions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "counting_stations", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Name = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Code = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + IsActive = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_counting_stations", x => x.Id); + table.ForeignKey( + name: "FK_counting_stations_administrative_regions_RegionId", + column: x => x.RegionId, + principalTable: "administrative_regions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "identity_user_regional_roles", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RegionId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + Role = table.Column(type: "int", nullable: false), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_identity_user_regional_roles", x => x.Id); + table.ForeignKey( + name: "FK_identity_user_regional_roles_administrative_regions_RegionId", + column: x => x.RegionId, + principalTable: "administrative_regions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_identity_user_regional_roles_identity_users_UserId", + column: x => x.UserId, + principalTable: "identity_users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "voter_profiles", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + VoterNumber = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DisplayName = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Status = table.Column(type: "int", nullable: false), + DisabledReason = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_voter_profiles", x => x.Id); + table.ForeignKey( + name: "FK_voter_profiles_administrative_regions_RegionId", + column: x => x.RegionId, + principalTable: "administrative_regions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_voter_profiles_identity_users_UserId", + column: x => x.UserId, + principalTable: "identity_users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "identity_user_counting_stations", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CountingStationId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_identity_user_counting_stations", x => x.Id); + table.ForeignKey( + name: "FK_identity_user_counting_stations_counting_stations_CountingSt~", + column: x => x.CountingStationId, + principalTable: "counting_stations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_identity_user_counting_stations_identity_users_UserId", + column: x => x.UserId, + principalTable: "identity_users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_identity_users_ExternalSubject", + table: "identity_users", + column: "ExternalSubject", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_administrative_regions_Code_SourceVersion", + table: "administrative_regions", + columns: new[] { "Code", "SourceVersion" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_administrative_regions_ParentId_Level_IsActive", + table: "administrative_regions", + columns: new[] { "ParentId", "Level", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_candidate_profiles_RegionId_Status_DisplayName", + table: "candidate_profiles", + columns: new[] { "RegionId", "Status", "DisplayName" }); + + migrationBuilder.CreateIndex( + name: "IX_counting_stations_Code", + table: "counting_stations", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_counting_stations_RegionId", + table: "counting_stations", + column: "RegionId"); + + migrationBuilder.CreateIndex( + name: "IX_identity_user_counting_stations_CountingStationId", + table: "identity_user_counting_stations", + column: "CountingStationId"); + + migrationBuilder.CreateIndex( + name: "IX_identity_user_counting_stations_UserId_CountingStationId", + table: "identity_user_counting_stations", + columns: new[] { "UserId", "CountingStationId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_identity_user_regional_roles_RegionId", + table: "identity_user_regional_roles", + column: "RegionId"); + + migrationBuilder.CreateIndex( + name: "IX_identity_user_regional_roles_UserId_Role_RegionId", + table: "identity_user_regional_roles", + columns: new[] { "UserId", "Role", "RegionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_voter_profiles_RegionId_Status_DisplayName", + table: "voter_profiles", + columns: new[] { "RegionId", "Status", "DisplayName" }); + + migrationBuilder.CreateIndex( + name: "IX_voter_profiles_UserId", + table: "voter_profiles", + column: "UserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_voter_profiles_VoterNumber", + table: "voter_profiles", + column: "VoterNumber", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "candidate_profiles"); + + migrationBuilder.DropTable( + name: "identity_user_counting_stations"); + + migrationBuilder.DropTable( + name: "identity_user_regional_roles"); + + migrationBuilder.DropTable( + name: "voter_profiles"); + + migrationBuilder.DropTable( + name: "counting_stations"); + + migrationBuilder.DropTable( + name: "administrative_regions"); + + migrationBuilder.DropIndex( + name: "IX_identity_users_ExternalSubject", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "ExternalSubject", + table: "identity_users"); + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs index 738f904..6f3e109 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.17") + .HasAnnotation("ProductVersion", "9.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -62,6 +62,44 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations 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.ApplicationUser", b => { b.Property("Id") @@ -76,6 +114,10 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations .HasMaxLength(128) .HasColumnType("varchar(128)"); + b.Property("ExternalSubject") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + b.Property("IsActive") .HasColumnType("tinyint(1)"); @@ -86,12 +128,98 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations b.HasKey("Id"); + b.HasIndex("ExternalSubject") + .IsUnique(); + 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") @@ -124,6 +252,133 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations 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("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) @@ -131,6 +386,49 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations .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); + }); #pragma warning restore 612, 618 } } diff --git a/src/ElectionSystem.Api/Modules/Candidates/Domain/CandidateProfile.cs b/src/ElectionSystem.Api/Modules/Candidates/Domain/CandidateProfile.cs new file mode 100644 index 0000000..977cb39 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Candidates/Domain/CandidateProfile.cs @@ -0,0 +1,15 @@ +namespace ElectionSystem.Api.Modules.Candidates.Domain; + +public enum CandidateStatus { Draft, Submitted, UnderReview, Approved, Withdrawn, Disqualified, Publicized } + +public sealed class CandidateProfile +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public required string DisplayName { get; set; } + public Guid RegionId { get; set; } + public CandidateStatus Status { get; set; } = CandidateStatus.Draft; + public string? NominationSummary { get; set; } + public string? StatusReason { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs b/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs index c9bf335..3c5f11e 100644 --- a/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs +++ b/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs @@ -6,5 +6,6 @@ public sealed class ApplicationUser public required string UserName { get; set; } public required string DisplayName { get; set; } public bool IsActive { get; set; } = true; + public string? ExternalSubject { get; set; } public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; } diff --git a/src/ElectionSystem.Api/Modules/Organization/AreaCityCatalogImportService.cs b/src/ElectionSystem.Api/Modules/Organization/AreaCityCatalogImportService.cs new file mode 100644 index 0000000..b12ce62 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Organization/AreaCityCatalogImportService.cs @@ -0,0 +1,68 @@ +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualBasic.FileIO; +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Organization.Domain; + +namespace ElectionSystem.Api.Modules.Organization; + +/// Imports AreaCity ok_data_level4.csv. It deliberately accepts only the four public levels; villages use the separately approved local directory. +public sealed class AreaCityCatalogImportService(ElectionDbContext dbContext) +{ + public async Task ImportAsync(Stream csv, string sourceVersion, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(sourceVersion) || sourceVersion.Length > 64) throw new InvalidOperationException("必须提供不超过64字符的数据版本。"); + var records = ReadRecords(csv); + if (records.Count == 0) throw new InvalidOperationException("CSV 中没有行政区划记录。"); + var duplicate = records.GroupBy(x => x.Code).FirstOrDefault(x => x.Count() > 1); + if (duplicate is not null) throw new InvalidOperationException($"CSV 中行政区划代码重复:{duplicate.Key}。"); + var levels = records.Select(x => x.Level).Distinct().OrderBy(x => x).ToArray(); + if (levels.Any(x => x is < AdministrativeRegionLevel.Province or > AdministrativeRegionLevel.Township)) throw new InvalidOperationException("AreaCity 导入只接受省、市、区县、镇街四级数据。"); + + var codes = records.Select(x => x.Code).ToArray(); + var existing = await dbContext.AdministrativeRegions.Where(x => x.Source == "AreaCity-JsSpider-StatsGov" && x.SourceVersion == sourceVersion && codes.Contains(x.Code)).ToDictionaryAsync(x => x.Code, cancellationToken); + var all = new Dictionary(existing, StringComparer.Ordinal); + foreach (var record in records) + if (!all.ContainsKey(record.Code)) + { + var region = new AdministrativeRegion { Code = record.Code, Name = record.Name, Level = record.Level, Source = "AreaCity-JsSpider-StatsGov", SourceVersion = sourceVersion, IsSynthetic = record.IsSynthetic }; + all.Add(record.Code, region); dbContext.AdministrativeRegions.Add(region); + } + + foreach (var record in records) + { + var region = all[record.Code]; + region.Name = record.Name; region.Level = record.Level; region.IsSynthetic = record.IsSynthetic; region.IsActive = true; + region.ParentId = record.ParentCode is null ? null : all.TryGetValue(record.ParentCode, out var parent) ? parent.Id : throw new InvalidOperationException($"行政区划 {record.Code} 缺少上级 {record.ParentCode}。"); + } + await dbContext.SaveChangesAsync(cancellationToken); + return new AreaCityCatalogImportResult(records.Count, records.Count(x => existing.ContainsKey(x.Code)), records.Count(x => !existing.ContainsKey(x.Code))); + } + + private static List ReadRecords(Stream csv) + { + using var parser = new TextFieldParser(csv, Encoding.UTF8, detectEncoding: true) { TextFieldType = FieldType.Delimited, HasFieldsEnclosedInQuotes = true, TrimWhiteSpace = false }; + parser.SetDelimiters(","); + var header = parser.ReadFields() ?? throw new InvalidOperationException("CSV 缺少表头。"); + var index = header.Select((value, i) => new { value = value.TrimStart('\ufeff'), i }).ToDictionary(x => x.value, x => x.i, StringComparer.OrdinalIgnoreCase); + foreach (var required in new[] { "id", "pid", "deep", "name" }) if (!index.ContainsKey(required)) throw new InvalidOperationException($"CSV 缺少 {required} 列。"); + var result = new List(); + while (!parser.EndOfData) + { + var fields = parser.ReadFields(); + if (fields is null || fields.All(string.IsNullOrWhiteSpace)) continue; + string Value(string name) => index[name] < fields.Length ? fields[index[name]].Trim() : ""; + if (!int.TryParse(Value("deep"), out var depth) || depth is < 0 or > 3) throw new InvalidOperationException("deep 必须是 0 至 3 的整数。"); + var code = Value("id"); var parentCode = Value("pid"); var name = Value("name"); + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) throw new InvalidOperationException("id 和 name 不能为空。"); + // AreaCity documents synthetic placeholders as the parent ID followed by zero padding. + var synthetic = parentCode.Length > 0 && code.StartsWith(parentCode, StringComparison.Ordinal) && code.Length > parentCode.Length && code[parentCode.Length..].All(character => character == '0'); + result.Add(new AreaCityRecord(code, string.IsNullOrWhiteSpace(parentCode) || parentCode == "0" ? null : parentCode, name, (AdministrativeRegionLevel)(depth + 1), synthetic)); + } + return result; + } + + private sealed record AreaCityRecord(string Code, string? ParentCode, string Name, AdministrativeRegionLevel Level, bool IsSynthetic); +} + +public sealed record AreaCityCatalogImportResult(int ProcessedCount, int UpdatedCount, int CreatedCount); diff --git a/src/ElectionSystem.Api/Modules/Organization/Domain/AdministrativeRegion.cs b/src/ElectionSystem.Api/Modules/Organization/Domain/AdministrativeRegion.cs new file mode 100644 index 0000000..1250bf6 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Organization/Domain/AdministrativeRegion.cs @@ -0,0 +1,63 @@ +namespace ElectionSystem.Api.Modules.Organization.Domain; + +public enum AdministrativeRegionLevel +{ + Province = 1, + City = 2, + District = 3, + Township = 4, + Village = 5 +} + +/// Versioned authoritative administrative catalog. Village records are locally approved imports. +public sealed class AdministrativeRegion +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? ParentId { get; set; } + public required string Code { get; set; } + public required string Name { get; set; } + public required AdministrativeRegionLevel Level { get; set; } + public required string Source { get; set; } + public required string SourceVersion { get; set; } + public bool IsSynthetic { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} + +public enum RegionalRole +{ + SystemAdministrator, + ProvinceAdministrator, + CityAdministrator, + DistrictAdministrator, + TownshipAdministrator, + VillageAdministrator, + CountingStationClerk, + Supervisor +} + +public sealed class UserRegionalRoleAssignment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public Guid? RegionId { get; set; } + public required RegionalRole Role { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class CountingStation +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public required string Name { get; set; } + public required string Code { get; set; } + public Guid RegionId { get; set; } + public bool IsActive { get; set; } = true; +} + +public sealed class UserCountingStationAssignment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public Guid CountingStationId { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs b/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs new file mode 100644 index 0000000..a4734cc --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Organization.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Modules.Organization; + +public sealed class RegionScopeService(ElectionDbContext dbContext) +{ + public static Guid GetRequiredUserId(ClaimsPrincipal principal) + { + var raw = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? principal.FindFirstValue("sub"); + return Guid.TryParse(raw, out var userId) ? userId : throw new UnauthorizedAccessException("Authenticated user identifier is required."); + } + + public async Task> GetManagedRegionIdsAsync(Guid userId, CancellationToken cancellationToken) + { + var assignments = await dbContext.UserRegionalRoleAssignments + .Where(x => x.UserId == userId && (x.Role == RegionalRole.SystemAdministrator || x.Role == RegionalRole.ProvinceAdministrator || x.Role == RegionalRole.CityAdministrator || x.Role == RegionalRole.DistrictAdministrator || x.Role == RegionalRole.TownshipAdministrator || x.Role == RegionalRole.VillageAdministrator)) + .ToListAsync(cancellationToken); + if (assignments.Any(x => x.Role == RegionalRole.SystemAdministrator && x.RegionId == null)) + return (await dbContext.AdministrativeRegions.Select(x => x.Id).ToListAsync(cancellationToken)).ToHashSet(); + + var children = await dbContext.AdministrativeRegions.Select(x => new { x.Id, x.ParentId }).ToListAsync(cancellationToken); + var allowed = assignments.Where(x => x.RegionId.HasValue).Select(x => x.RegionId!.Value).ToHashSet(); + var queue = new Queue(allowed); + while (queue.TryDequeue(out var parentId)) + foreach (var childId in children.Where(x => x.ParentId == parentId).Select(x => x.Id)) + if (allowed.Add(childId)) queue.Enqueue(childId); + return allowed; + } + + public async Task RequireManagedRegionAsync(Guid userId, Guid regionId, CancellationToken cancellationToken) + { + if (!(await GetManagedRegionIdsAsync(userId, cancellationToken)).Contains(regionId)) + throw new UnauthorizedAccessException("This action is outside the administrator's regional scope."); + } + + public async Task RequireSystemAdministratorAsync(Guid userId, CancellationToken cancellationToken) + { + if (!await dbContext.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null, cancellationToken)) + throw new UnauthorizedAccessException("System administrator role is required."); + } +} diff --git a/src/ElectionSystem.Api/Modules/Voters/Domain/VoterProfile.cs b/src/ElectionSystem.Api/Modules/Voters/Domain/VoterProfile.cs new file mode 100644 index 0000000..fa0b6aa --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Voters/Domain/VoterProfile.cs @@ -0,0 +1,18 @@ +using ElectionSystem.Api.Modules.Organization.Domain; + +namespace ElectionSystem.Api.Modules.Voters.Domain; + +public enum VoterStatus { Active, Disabled, PendingReview } + +public sealed class VoterProfile +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? UserId { get; set; } + public required string VoterNumber { get; set; } + public required string DisplayName { get; set; } + public Guid RegionId { get; set; } + public VoterStatus Status { get; set; } = VoterStatus.Active; + public string? DisabledReason { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/ElectionSystem.Api/Modules/Voters/VoterImportService.cs b/src/ElectionSystem.Api/Modules/Voters/VoterImportService.cs new file mode 100644 index 0000000..e2a7135 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Voters/VoterImportService.cs @@ -0,0 +1,56 @@ +using ClosedXML.Excel; +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Organization; +using ElectionSystem.Api.Modules.Voters.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace ElectionSystem.Api.Modules.Voters; + +public sealed record VoterImportRow(int RowNumber, string VoterNumber, string DisplayName, string RegionCode, string? Error); +public sealed record VoterImportPreview(Guid Token, IReadOnlyList Rows, int ValidCount, int ErrorCount); + +public sealed class VoterImportService(ElectionDbContext dbContext, IMemoryCache cache, RegionScopeService scopeService) +{ + public async Task PreviewAsync(Guid actorId, Stream workbook, CancellationToken cancellationToken) + { + using var document = new XLWorkbook(workbook); + var sheet = document.Worksheets.FirstOrDefault() ?? throw new InvalidOperationException("Workbook must contain a worksheet."); + var rows = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var row in sheet.RowsUsed().Skip(1)) + { + var voterNumber = row.Cell(1).GetString().Trim(); + var displayName = row.Cell(2).GetString().Trim(); + var regionCode = row.Cell(3).GetString().Trim(); + string? error = voterNumber.Length is < 1 or > 64 ? "选民编号不能为空且最多64字符" : displayName.Length is < 1 or > 128 ? "姓名不能为空且最多128字符" : regionCode.Length is < 1 or > 32 ? "行政区划代码不能为空且最多32字符" : !seen.Add(voterNumber) ? "文件内选民编号重复" : null; + rows.Add(new VoterImportRow(row.RowNumber(), voterNumber, displayName, regionCode, error)); + } + var regionCodes = rows.Where(x => x.Error is null).Select(x => x.RegionCode).Distinct().ToArray(); + var regions = await dbContext.AdministrativeRegions.Where(x => regionCodes.Contains(x.Code) && x.IsActive).ToDictionaryAsync(x => x.Code, cancellationToken); + var allowed = await scopeService.GetManagedRegionIdsAsync(actorId, cancellationToken); + var existing = await dbContext.Voters.Where(x => rows.Select(r => r.VoterNumber).Contains(x.VoterNumber)).Select(x => x.VoterNumber).ToListAsync(cancellationToken); + var existingSet = existing.ToHashSet(StringComparer.OrdinalIgnoreCase); + rows = rows.Select(x => x.Error is not null ? x : !regions.TryGetValue(x.RegionCode, out var region) ? x with { Error = "行政区划不存在或已停用" } : !allowed.Contains(region.Id) ? x with { Error = "无权管理该行政区划" } : existingSet.Contains(x.VoterNumber) ? x with { Error = "选民编号已存在" } : x).ToList(); + var preview = new VoterImportPreview(Guid.NewGuid(), rows, rows.Count(x => x.Error is null), rows.Count(x => x.Error is not null)); + cache.Set(GetKey(actorId, preview.Token), preview, TimeSpan.FromMinutes(30)); + return preview; + } + + public async Task ConfirmAsync(Guid actorId, Guid token, CancellationToken cancellationToken) + { + if (!cache.TryGetValue(GetKey(actorId, token), out var preview) || preview is null) throw new InvalidOperationException("导入预校验已过期,请重新上传文件。"); + if (preview.ErrorCount != 0) throw new InvalidOperationException("存在逐行错误,不能确认导入。"); + var codes = preview.Rows.Select(x => x.RegionCode).Distinct().ToArray(); + var regions = await dbContext.AdministrativeRegions.Where(x => codes.Contains(x.Code) && x.IsActive).ToDictionaryAsync(x => x.Code, cancellationToken); + var allowed = await scopeService.GetManagedRegionIdsAsync(actorId, cancellationToken); + if (regions.Count != codes.Length || regions.Values.Any(x => !allowed.Contains(x.Id))) throw new UnauthorizedAccessException("行政区划范围在确认前已发生变化。"); + if (await dbContext.Voters.AnyAsync(x => preview.Rows.Select(r => r.VoterNumber).Contains(x.VoterNumber), cancellationToken)) throw new InvalidOperationException("存在已被其他操作导入的选民编号,请重新预校验。"); + dbContext.Voters.AddRange(preview.Rows.Select(x => new VoterProfile { VoterNumber = x.VoterNumber, DisplayName = x.DisplayName, RegionId = regions[x.RegionCode].Id })); + await dbContext.SaveChangesAsync(cancellationToken); + cache.Remove(GetKey(actorId, token)); + return preview.ValidCount; + } + + private static string GetKey(Guid actorId, Guid token) => $"voter-import:{actorId}:{token}"; +} diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs index 0b0c05e..0b82e4c 100644 --- a/src/ElectionSystem.Api/Program.cs +++ b/src/ElectionSystem.Api/Program.cs @@ -3,7 +3,10 @@ using ElectionSystem.Api.Components; using ElectionSystem.Api.Infrastructure.Auditing; using ElectionSystem.Api.Infrastructure.Persistence; using ElectionSystem.Api.Modules.Cms; +using ElectionSystem.Api.Modules.Organization; +using ElectionSystem.Api.Modules.Voters; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -23,6 +26,10 @@ builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddMemoryCache(); builder.Services.AddDbContext((serviceProvider, options) => { var auditInterceptor = serviceProvider.GetRequiredService(); @@ -30,6 +37,13 @@ builder.Services.AddDbContext((serviceProvider, options) => options.AddInterceptors(auditInterceptor); }); builder.Services.AddHealthChecks().AddDbContextCheck("mysql"); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => +{ + var authority = builder.Configuration["Authentication:Authority"]; + if (!string.IsNullOrWhiteSpace(authority)) options.Authority = authority; + var audience = builder.Configuration["Authentication:Audience"]; + if (!string.IsNullOrWhiteSpace(audience)) options.Audience = audience; +}); builder.Services.AddAuthorization(); builder.Services.AddHttpClient(client => { @@ -48,6 +62,7 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } +app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); app.MapStaticAssets(); diff --git a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs index 60e62c0..bbe351a 100644 --- a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs +++ b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs @@ -1,6 +1,10 @@ using ElectionSystem.Api.Infrastructure.Auditing; using ElectionSystem.Api.Infrastructure.Persistence; using ElectionSystem.Api.Modules.Identity.Domain; +using ElectionSystem.Api.Modules.Organization; +using ElectionSystem.Api.Modules.Organization.Domain; +using ElectionSystem.Api.Modules.Voters.Domain; +using System.Text; using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Tests; @@ -24,4 +28,49 @@ public sealed class PersistenceModelTests var properties = typeof(AuditEntry).GetProperties().Select(property => property.Name); Assert.DoesNotContain(properties, property => property.Contains("Vote", StringComparison.OrdinalIgnoreCase)); } + + [Fact] + public async Task Provincial_administrator_scope_includes_all_descendant_regions() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + await using var context = new ElectionDbContext(options); + var province = new AdministrativeRegion { Code = "11", Name = "省", Level = AdministrativeRegionLevel.Province, Source = "test", SourceVersion = "test" }; + var city = new AdministrativeRegion { Code = "1101", Name = "市", Level = AdministrativeRegionLevel.City, ParentId = province.Id, Source = "test", SourceVersion = "test" }; + var village = new AdministrativeRegion { Code = "110101001001", Name = "村", Level = AdministrativeRegionLevel.Village, ParentId = city.Id, Source = "test", SourceVersion = "test" }; + var admin = new ApplicationUser { UserName = "province-admin", DisplayName = "Province administrator" }; + context.AddRange(province, city, village, admin); + context.UserRegionalRoleAssignments.Add(new UserRegionalRoleAssignment { UserId = admin.Id, RegionId = province.Id, Role = RegionalRole.ProvinceAdministrator }); + await context.SaveChangesAsync(); + + var scope = await new RegionScopeService(context).GetManagedRegionIdsAsync(admin.Id, CancellationToken.None); + Assert.Equal(3, scope.Count); + Assert.Contains(village.Id, scope); + } + + [Fact] + public void Voter_number_and_region_status_indexes_are_defined() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + using var context = new ElectionDbContext(options); + var entity = context.Model.FindEntityType(typeof(VoterProfile))!; + Assert.Contains(entity.GetIndexes(), x => x.IsUnique && x.Properties.Single().Name == nameof(VoterProfile.VoterNumber)); + Assert.Contains(entity.GetIndexes(), x => x.Properties.Select(p => p.Name).SequenceEqual(new[] { nameof(VoterProfile.RegionId), nameof(VoterProfile.Status), nameof(VoterProfile.DisplayName) })); + } + + [Fact] + public async Task AreaCity_import_preserves_parent_chain_and_four_levels() + { + const string csv = "id,pid,deep,name\n11,0,0,北京市\n1101,11,1,市辖区\n110101,1101,2,东城区\n110101001,110101,3,东华门街道\n"; + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + await using var context = new ElectionDbContext(options); + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv)); + + var result = await new AreaCityCatalogImportService(context).ImportAsync(stream, "test-2026", CancellationToken.None); + + Assert.Equal(4, result.CreatedCount); + var township = await context.AdministrativeRegions.SingleAsync(x => x.Code == "110101001"); + var district = await context.AdministrativeRegions.SingleAsync(x => x.Code == "110101"); + Assert.Equal(district.Id, township.ParentId); + Assert.Equal(AdministrativeRegionLevel.Township, township.Level); + } }