diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor b/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor index 4664b6c..be1a664 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/CountingStations.razor @@ -10,18 +10,18 @@

人员与场所 / 计票

计票所管理

按四级行政区划筛选;工作人员必须另行分配到具体计票所。

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

新增计票所

} +@if (creating) {

新增计票所

所属地区(可停在任意已授权层级)
}
@if (!loaded) { } else if (stations.Count == 0) { } @foreach (var station in stations) { }
代码名称所属地区状态
正在加载…
暂无计票所。
@station.Code@station.Name@station.Region@(station.Active ? "启用" : "停用")
每页 @PageSize 条

分配计票所工作人员

-@code { private const int PageSize = 50; [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; private HashSet scope = []; private bool loaded, creating; private string? notice, currentCursor, nextCursor; private string keyword = "", stationKeyword = ""; private Guid? provinceId, cityId, districtId, townshipId; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List regions = [], provinces = [], cities = [], districts = [], townships = []; private readonly List stations = []; private readonly List stationChoices = []; private readonly List cursorHistory = []; - protected override async Task OnInitializedAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); await LoadInitialOptionsAsync(id); await SearchAsync(); } +@code { private const int PageSize = 50; [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; private HashSet scope = []; private bool systemAdmin, loaded, creating; private string? notice, currentCursor, nextCursor; private string keyword = "", stationKeyword = ""; private Guid? provinceId, cityId, districtId, townshipId; private StationDraft draft = new(); private StaffDraft staffDraft = new(); private readonly List regions = [], provinces = [], cities = [], districts = [], townships = []; private readonly List stations = []; private readonly List stationChoices = []; private readonly List cursorHistory = []; + protected override async Task OnInitializedAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Scope.IsSystemAdministratorAsync(id, CancellationToken.None); if (!systemAdmin) scope = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); await LoadInitialOptionsAsync(id); await SearchAsync(); } private async Task LoadInitialOptionsAsync(Guid userId) { var systemAdmin = await Db.UserRegionalRoleAssignments.AsNoTracking().AnyAsync(x => x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null); if (systemAdmin) { await LoadOptionsAsync(provinces, null, AdministrativeRegionLevel.Province); return; } var roots = await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && Db.UserRegionalRoleAssignments.Any(a => a.UserId == userId && a.RegionId == x.Id && (a.Role == RegionalRole.ProvinceAdministrator || a.Role == RegionalRole.CityAdministrator || a.Role == RegionalRole.DistrictAdministrator || a.Role == RegionalRole.TownshipAdministrator))).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync(); provinces.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.Province)); cities.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.City)); districts.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.District)); townships.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.Township)); } - private async Task LoadOptionsAsync(List target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId && scope.Contains(x.Id)).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); } - private async Task ToggleCreateAsync() { creating = !creating; if (creating && regions.Count == 0) regions.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && scope.Contains(x.Id)).OrderBy(x => x.Level).ThenBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); } - private async Task SearchStationChoicesAsync() { stationChoices.Clear(); var query = Db.CountingStations.AsNoTracking().Where(x => x.IsActive && scope.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(stationKeyword)) query = query.Where(x => x.Code.Contains(stationKeyword) || x.Name.Contains(stationKeyword)); var rows = await query.OrderBy(x => x.Code).Take(PageSize).Select(x => new { x.Id, x.Code, x.Name, x.RegionId }).ToListAsync(); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stationChoices.AddRange(rows.Select(x => new StationChoice(x.Id, $"{x.Code} · {x.Name}({names.GetValueOrDefault(x.RegionId, "—")})"))); } - private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = null; cities.Clear(); districts.Clear(); townships.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); await SearchAsync(); } private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = null; districts.Clear(); townships.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); await SearchAsync(); } private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = null; townships.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); await SearchAsync(); } private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); await SearchAsync(); } private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null; + private async Task LoadOptionsAsync(List target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId && (systemAdmin || scope.Contains(x.Id))).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); } + private Task ToggleCreateAsync() { creating = !creating; if (creating) draft.RegionId = townshipId ?? districtId ?? cityId ?? provinceId; return Task.CompletedTask; } + private async Task SearchStationChoicesAsync() { stationChoices.Clear(); IQueryable query = Db.CountingStations.AsNoTracking().Where(x => x.IsActive); if (!systemAdmin) query = query.Where(x => scope.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(stationKeyword)) query = query.Where(x => x.Code.Contains(stationKeyword) || x.Name.Contains(stationKeyword)); var rows = await query.OrderBy(x => x.Code).Take(PageSize).Select(x => new { x.Id, x.Code, x.Name, x.RegionId }).ToListAsync(); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stationChoices.AddRange(rows.Select(x => new StationChoice(x.Id, $"{x.Code} · {x.Name}({names.GetValueOrDefault(x.RegionId, "—")})"))); } + private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = null; cities.Clear(); districts.Clear(); townships.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); SyncDraftRegion(); await SearchAsync(); } private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = null; districts.Clear(); townships.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); SyncDraftRegion(); await SearchAsync(); } private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = null; townships.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); SyncDraftRegion(); await SearchAsync(); } private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); SyncDraftRegion(); await SearchAsync(); } private void SyncDraftRegion() { if (creating) draft.RegionId = townshipId ?? districtId ?? cityId ?? provinceId; } private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null; private async Task SearchAsync() { currentCursor = null; cursorHistory.Clear(); await LoadAsync(); } private async Task NextAsync() { if (nextCursor is null) return; cursorHistory.Add(currentCursor); currentCursor = nextCursor; await LoadAsync(); } private async Task PreviousAsync() { if (cursorHistory.Count == 0) return; currentCursor = cursorHistory[^1]; cursorHistory.RemoveAt(cursorHistory.Count - 1); await LoadAsync(); } - private async Task LoadAsync() { loaded = false; try { var selectedRegions = await Cascade.ResolveAsync(scope, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); var query = Db.CountingStations.Where(x => selectedRegions.Contains(x.RegionId)); if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword)); if (!CursorToken.TryDecode(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.Code, token.Code) > 0 || (x.Code == token.Code && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.Code).ThenBy(x => x.Id).Take(PageSize + 1).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var map = await Db.AdministrativeRegions.Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stations.Clear(); stations.AddRange(rows.Select(x => new StationRow(x.Id, x.Code, x.Name, map.GetValueOrDefault(x.RegionId, "—"), x.IsActive))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].Code, rows[^1].Id)) : null; } catch (ArgumentException e) { notice = e.Message; stations.Clear(); nextCursor = null; } finally { 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; draft = new(); stationChoices.Clear(); await SearchAsync(); } + private async Task LoadAsync() { loaded = false; try { IQueryable query = Db.CountingStations.AsNoTracking(); if (systemAdmin) { var selectedRegions = await Cascade.ResolveSystemAdministratorQueryAsync(new(provinceId, cityId, districtId, townshipId), CancellationToken.None); if (selectedRegions is not null) query = query.Where(x => selectedRegions.Contains(x.RegionId)); } else { var selectedRegions = await Cascade.ResolveAsync(scope, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); query = query.Where(x => selectedRegions.Contains(x.RegionId)); } if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword)); if (!CursorToken.TryDecode(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.Code, token.Code) > 0 || (x.Code == token.Code && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.Code).ThenBy(x => x.Id).Take(PageSize + 1).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var map = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); stations.Clear(); stations.AddRange(rows.Select(x => new StationRow(x.Id, x.Code, x.Name, map.GetValueOrDefault(x.RegionId, "—"), x.IsActive))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].Code, rows[^1].Id)) : null; } catch (ArgumentException e) { notice = e.Message; stations.Clear(); nextCursor = null; } finally { loaded = true; } } + private async Task CreateAsync() { if (!draft.RegionId.HasValue || (!systemAdmin && !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; draft = new(); stationChoices.Clear(); await SearchAsync(); } 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, Guid? ParentId, AdministrativeRegionLevel Level, string Name, string Path); private sealed record StationRow(Guid Id, string Code, string Name, string Region, bool Active); private sealed record StationChoice(Guid Id, string Name); private sealed record Cursor(string Code, Guid Id); } diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor index 5bd9b52..168bfa0 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor @@ -28,7 +28,7 @@ private int registeredVoterCount => activeVoterCount + aggregateVoterCount; 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); aggregateVoterCount = await Db.VoterRegistrationAggregates.Where(x => scope.Contains(x.RegionId)).SumAsync(x => (int?)x.RegisteredCount) ?? 0; candidateCount = await Db.Candidates.CountAsync(x => scope.Contains(x.RegionId)); stationCount = await Db.CountingStations.CountAsync(x => scope.Contains(x.RegionId) && x.IsActive); loaded = true; } + try { var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var systemAdmin = await Scope.IsSystemAdministratorAsync(userId, CancellationToken.None); if (systemAdmin) { regionCount = await Db.AdministrativeRegions.CountAsync(); activeVoterCount = await Db.Voters.CountAsync(x => x.Status == VoterStatus.Active); aggregateVoterCount = await Db.VoterRegistrationAggregates.SumAsync(x => (int?)x.RegisteredCount) ?? 0; candidateCount = await Db.Candidates.CountAsync(); stationCount = await Db.CountingStations.CountAsync(x => x.IsActive); } else { 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); aggregateVoterCount = await Db.VoterRegistrationAggregates.Where(x => scope.Contains(x.RegionId)).SumAsync(x => (int?)x.RegisteredCount) ?? 0; 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/Roles.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor index 89a9db9..b9426e3 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Roles.razor @@ -6,10 +6,29 @@ @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(); } +

人员与场所 / 授权

地域角色分配

角色记录按页加载;行政区划按省、市、区县、镇街、村社区逐级查询。

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

授予角色

+@if (draft.Role != RegionalRole.SystemAdministrator) {
行政区划
} +
+
@if (!loaded) { } else if (items.Count == 0) { } @foreach (var row in items) { }
用户角色行政区划授予时间
正在加载…
暂无角色分配。
@row.UserName @row.DisplayName@RoleText(row.Role)@row.Region@row.CreatedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")
每页 @PageSize 条
} +
+@code { + private const int PageSize = 50; [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; + private bool systemAdmin, loaded; private string? error, currentCursor, nextCursor; private RoleDraft draft = new(); private Guid? provinceId, cityId, districtId, townshipId, villageId; + private readonly List provinces = [], cities = [], districts = [], townships = [], villages = []; private readonly List items = []; private readonly List cursorHistory = []; + protected override async Task OnInitializedAsync() { var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Scope.IsSystemAdministratorAsync(userId, CancellationToken.None); if (!systemAdmin) return; await LoadOptionsAsync(provinces, null, AdministrativeRegionLevel.Province); await LoadAsync(); } + private async Task LoadOptionsAsync(List target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.ParentId == parentId && x.Level == level).OrderBy(x => x.Code).Select(x => new Region(x.Id, x.Name)).ToListAsync()); } + private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = villageId = null; cities.Clear(); districts.Clear(); townships.Clear(); villages.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); } + private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = villageId = null; districts.Clear(); townships.Clear(); villages.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); } + private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = villageId = null; townships.Clear(); villages.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); } + private async Task SelectTownshipAsync(ChangeEventArgs e) { townshipId = Parse(e); villageId = null; villages.Clear(); if (townshipId.HasValue) await LoadOptionsAsync(villages, townshipId, AdministrativeRegionLevel.Village); } + private void SelectVillage(ChangeEventArgs e) => villageId = Parse(e); private static Guid? Parse(ChangeEventArgs e) => Guid.TryParse(e.Value?.ToString(), out var id) ? id : null; + private async Task LoadAsync() { loaded = false; try { if (!CursorToken.TryDecode(currentCursor, out var cursor)) throw new InvalidOperationException("分页游标无效,请重新查询。"); IQueryable query = Db.UserRegionalRoleAssignments.AsNoTracking(); if (cursor is not null) query = query.Where(x => x.CreatedAtUtc < cursor.CreatedAtUtc || (x.CreatedAtUtc == cursor.CreatedAtUtc && x.Id.CompareTo(cursor.Id) < 0)); var rows = await query.OrderByDescending(x => x.CreatedAtUtc).ThenByDescending(x => x.Id).Take(PageSize + 1).Select(x => new AssignmentRow(x.Id, x.UserId, x.RegionId, x.Role, x.CreatedAtUtc)).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var regionIds = rows.Where(x => x.RegionId.HasValue).Select(x => x.RegionId!.Value).Distinct().ToArray(); var userIds = rows.Select(x => x.UserId).Distinct().ToArray(); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => regionIds.Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); var users = await Db.Users.AsNoTracking().Where(x => userIds.Contains(x.Id)).Select(x => new UserNameRow(x.Id, x.UserName!, x.DisplayName)).ToDictionaryAsync(x => x.Id); items.Clear(); items.AddRange(rows.Select(x => { var user = users.GetValueOrDefault(x.UserId); return new RoleRow(user?.UserName ?? "已删除用户", user?.DisplayName ?? "—", x.Role, x.RegionId.HasValue ? names.GetValueOrDefault(x.RegionId.Value, "—") : "全部地区", x.CreatedAtUtc); })); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].CreatedAtUtc, rows[^1].Id)) : null; } catch (Exception exception) { error = exception.Message; items.Clear(); nextCursor = null; } finally { loaded = true; } } + private async Task NextAsync() { if (nextCursor is null) return; cursorHistory.Add(currentCursor); currentCursor = nextCursor; await LoadAsync(); } private async Task PreviousAsync() { if (cursorHistory.Count == 0) return; currentCursor = cursorHistory[^1]; cursorHistory.RemoveAt(cursorHistory.Count - 1); await LoadAsync(); } + private async Task SaveAsync() { error = null; draft.RegionId = RegionForRole(draft.Role); var userName = draft.UserName.Trim(); if (string.IsNullOrWhiteSpace(userName)) { error = "请输入用户名。"; return; } var userId = await Db.Users.AsNoTracking().Where(x => x.UserName == userName && x.IsActive).Select(x => (Guid?)x.Id).SingleOrDefaultAsync(); if (!userId.HasValue) { error = "用户名不存在或已停用。"; return; } if (draft.Role != RegionalRole.SystemAdministrator && !draft.RegionId.HasValue) { error = "请按角色选择对应层级的行政区划。"; return; } if (draft.RegionId.HasValue) { var level = await Db.AdministrativeRegions.AsNoTracking().Where(x => x.Id == draft.RegionId.Value && x.IsActive).Select(x => (AdministrativeRegionLevel?)x.Level).SingleOrDefaultAsync(); if (!level.HasValue || !Matches(draft.Role, level.Value)) { error = "管理员角色必须与行政区划层级一致;计票员和监督员可绑定任意已选层级。"; return; } } if (await Db.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId.Value && x.RegionId == draft.RegionId && x.Role == draft.Role)) { error = "该角色已存在。"; return; } Db.UserRegionalRoleAssignments.Add(new UserRegionalRoleAssignment { UserId = userId.Value, RegionId = draft.RegionId, Role = draft.Role }); await Db.SaveChangesAsync(); error = "角色已授予。"; draft = new(); currentCursor = null; cursorHistory.Clear(); await LoadAsync(); } + private Guid? RegionForRole(RegionalRole role) => role switch { RegionalRole.SystemAdministrator => null, RegionalRole.ProvinceAdministrator => provinceId, RegionalRole.CityAdministrator => cityId, RegionalRole.DistrictAdministrator => districtId, RegionalRole.TownshipAdministrator => townshipId, RegionalRole.VillageAdministrator => villageId, _ => villageId ?? townshipId ?? districtId ?? cityId ?? provinceId }; 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); } + 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 UserName { 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 AssignmentRow(Guid Id, Guid UserId, Guid? RegionId, RegionalRole Role, DateTime CreatedAtUtc); private sealed record UserNameRow(Guid Id, string UserName, string DisplayName); private sealed record RoleRow(string UserName, string DisplayName, RegionalRole Role, string Region, DateTime CreatedAtUtc); private sealed record Cursor(DateTime CreatedAtUtc, Guid Id); +} diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor index 007f0e8..cf727de 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Voters.razor @@ -24,11 +24,11 @@ private const int PageSize = 50; [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; private readonly List voters = []; private readonly List regions = [], provinces = [], cities = [], districts = [], townships = []; private readonly List cursorHistory = []; - private VoterDraft draft = new(); private string keyword = "", status = ""; private bool loaded, creating; private string? message, currentCursor, nextCursor; private HashSet managed = []; private Guid? provinceId, cityId, districtId, townshipId; + private VoterDraft draft = new(); private string keyword = "", status = ""; private bool systemAdmin, loaded, creating; private string? message, currentCursor, nextCursor; private HashSet managed = []; private Guid? provinceId, cityId, districtId, townshipId; protected override async Task OnInitializedAsync() { await LoadRegionsAsync(); await SearchAsync(); } - private async Task LoadRegionsAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); managed = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); await LoadInitialOptionsAsync(id); } + private async Task LoadRegionsAsync() { var id = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); systemAdmin = await Scope.IsSystemAdministratorAsync(id, CancellationToken.None); if (!systemAdmin) managed = await Scope.GetManagedRegionIdsAsync(id, CancellationToken.None); await LoadInitialOptionsAsync(id); } private async Task LoadInitialOptionsAsync(Guid userId) { var systemAdmin = await Db.UserRegionalRoleAssignments.AsNoTracking().AnyAsync(x => x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null); if (systemAdmin) { await LoadOptionsAsync(provinces, null, AdministrativeRegionLevel.Province); return; } var roots = await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && Db.UserRegionalRoleAssignments.Any(a => a.UserId == userId && a.RegionId == x.Id && (a.Role == RegionalRole.ProvinceAdministrator || a.Role == RegionalRole.CityAdministrator || a.Role == RegionalRole.DistrictAdministrator || a.Role == RegionalRole.TownshipAdministrator))).OrderBy(x => x.Code).Select(x => new RegionOption(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync(); provinces.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.Province)); cities.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.City)); districts.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.District)); townships.AddRange(roots.Where(x => x.Level == AdministrativeRegionLevel.Township)); } - private async Task LoadOptionsAsync(List target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId && managed.Contains(x.Id)).OrderBy(x => x.Code).Select(x => new RegionOption(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); } + private async Task LoadOptionsAsync(List target, Guid? parentId, AdministrativeRegionLevel level) { target.Clear(); target.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && x.Level == level && x.ParentId == parentId && (systemAdmin || managed.Contains(x.Id))).OrderBy(x => x.Code).Select(x => new RegionOption(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); } private async Task SelectProvinceAsync(ChangeEventArgs e) { provinceId = Parse(e); cityId = districtId = townshipId = null; cities.Clear(); districts.Clear(); townships.Clear(); if (provinceId.HasValue) await LoadOptionsAsync(cities, provinceId, AdministrativeRegionLevel.City); await SearchAsync(); } private async Task SelectCityAsync(ChangeEventArgs e) { cityId = Parse(e); districtId = townshipId = null; districts.Clear(); townships.Clear(); if (cityId.HasValue) await LoadOptionsAsync(districts, cityId, AdministrativeRegionLevel.District); await SearchAsync(); } private async Task SelectDistrictAsync(ChangeEventArgs e) { districtId = Parse(e); townshipId = null; townships.Clear(); if (districtId.HasValue) await LoadOptionsAsync(townships, districtId, AdministrativeRegionLevel.Township); await SearchAsync(); } @@ -37,7 +37,7 @@ private async Task SearchAsync() { currentCursor = null; cursorHistory.Clear(); await LoadAsync(); } private async Task NextAsync() { if (nextCursor is null) return; cursorHistory.Add(currentCursor); currentCursor = nextCursor; await LoadAsync(); } private async Task PreviousAsync() { if (cursorHistory.Count == 0) return; currentCursor = cursorHistory[^1]; cursorHistory.RemoveAt(cursorHistory.Count - 1); await LoadAsync(); } - private async Task LoadAsync() { loaded = false; try { var selectedRegions = await Cascade.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); var query = Db.Voters.Where(x => selectedRegions.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); if (!CursorToken.TryDecode(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.DisplayName, token.DisplayName) > 0 || (x.DisplayName == token.DisplayName && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.DisplayName).ThenBy(x => x.Id).Take(PageSize + 1).Select(x => new { x.Id, x.VoterNumber, x.DisplayName, x.RegionId, x.Status }).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var names = await Db.AdministrativeRegions.Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); voters.Clear(); voters.AddRange(rows.Select(x => new VoterRow(x.Id, x.VoterNumber, x.DisplayName, names.GetValueOrDefault(x.RegionId, "—"), x.Status))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].DisplayName, rows[^1].Id)) : null; } catch (ArgumentException e) { message = e.Message; voters.Clear(); nextCursor = null; } finally { loaded = true; } } + private async Task LoadAsync() { loaded = false; try { IQueryable query = Db.Voters.AsNoTracking(); if (systemAdmin) { var selectedRegions = await Cascade.ResolveSystemAdministratorQueryAsync(new(provinceId, cityId, districtId, townshipId), CancellationToken.None); if (selectedRegions is not null) query = query.Where(x => selectedRegions.Contains(x.RegionId)); } else { var selectedRegions = await Cascade.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), CancellationToken.None); query = query.Where(x => selectedRegions.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); if (!CursorToken.TryDecode(currentCursor, out var token)) throw new InvalidOperationException("分页游标无效,请重新查询。"); if (token is not null) query = query.Where(x => string.Compare(x.DisplayName, token.DisplayName) > 0 || (x.DisplayName == token.DisplayName && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.DisplayName).ThenBy(x => x.Id).Take(PageSize + 1).Select(x => new { x.Id, x.VoterNumber, x.DisplayName, x.RegionId, x.Status }).ToListAsync(); var hasMore = rows.Count > PageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); var names = await Db.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name); voters.Clear(); voters.AddRange(rows.Select(x => new VoterRow(x.Id, x.VoterNumber, x.DisplayName, names.GetValueOrDefault(x.RegionId, "—"), x.Status))); nextCursor = hasMore && rows.Count > 0 ? CursorToken.Encode(new Cursor(rows[^1].DisplayName, rows[^1].Id)) : null; } catch (ArgumentException e) { message = e.Message; voters.Clear(); nextCursor = null; } finally { loaded = true; } } private async Task ToggleCreate() { creating = !creating; message = null; if (creating && regions.Count == 0) regions.AddRange(await Db.AdministrativeRegions.AsNoTracking().Where(x => x.IsActive && managed.Contains(x.Id)).OrderBy(x => x.Level).ThenBy(x => x.Code).Select(x => new RegionOption(x.Id, x.ParentId, x.Level, x.Name, $"{x.Level}: {x.Name}")).ToListAsync()); } 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 SearchAsync(); } 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(); } diff --git a/src/ElectionSystem.Api/Controllers/CandidatesController.cs b/src/ElectionSystem.Api/Controllers/CandidatesController.cs index ac34488..131d905 100644 --- a/src/ElectionSystem.Api/Controllers/CandidatesController.cs +++ b/src/ElectionSystem.Api/Controllers/CandidatesController.cs @@ -13,8 +13,13 @@ public sealed class CandidatesController(ElectionDbContext dbContext, Modules.Or 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)); + var systemAdmin = await ScopeService.IsSystemAdministratorAsync(ActorId, cancellationToken); + IQueryable query = dbContext.Candidates.AsNoTracking(); + if (!systemAdmin) + { + var allowed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + query = query.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); @@ -29,9 +34,19 @@ public sealed class CandidatesController(ElectionDbContext dbContext, Modules.Or if (!CursorToken.TryDecode(cursor, out var token)) return BadRequest(new { code = "invalid_cursor", message = "分页游标无效,请重新查询。" }); try { - var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); - var regions = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); - var query = dbContext.Candidates.Where(x => regions.Contains(x.RegionId)); + var systemAdmin = await ScopeService.IsSystemAdministratorAsync(ActorId, cancellationToken); + IQueryable query = dbContext.Candidates.AsNoTracking(); + if (systemAdmin) + { + var regions = await cascadeFilterService.ResolveSystemAdministratorQueryAsync(new(provinceId, cityId, districtId, townshipId), cancellationToken); + if (regions is not null) query = query.Where(x => regions.Contains(x.RegionId)); + } + else + { + var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var regions = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); + query = query.Where(x => regions.Contains(x.RegionId)); + } if (status.HasValue) query = query.Where(x => x.Status == status); if (token is not null) query = query.Where(x => string.Compare(x.DisplayName, token.DisplayName) > 0 || (x.DisplayName == token.DisplayName && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.DisplayName).ThenBy(x => x.Id).Take(pageSize + 1).ToListAsync(cancellationToken); diff --git a/src/ElectionSystem.Api/Controllers/CountingStationsController.cs b/src/ElectionSystem.Api/Controllers/CountingStationsController.cs index 8fb5ef8..4ff448c 100644 --- a/src/ElectionSystem.Api/Controllers/CountingStationsController.cs +++ b/src/ElectionSystem.Api/Controllers/CountingStationsController.cs @@ -12,8 +12,13 @@ public sealed class CountingStationsController(ElectionDbContext dbContext, Modu [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)); + var systemAdmin = await ScopeService.IsSystemAdministratorAsync(ActorId, cancellationToken); + IQueryable query = dbContext.CountingStations.AsNoTracking(); + if (!systemAdmin) + { + var allowed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + query = query.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)); } @@ -27,16 +32,26 @@ public sealed class CountingStationsController(ElectionDbContext dbContext, Modu if (!CursorToken.TryDecode(cursor, out var token)) return BadRequest(new { code = "invalid_cursor", message = "分页游标无效,请重新查询。" }); try { - var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); - var regions = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); - var query = dbContext.CountingStations.Where(x => regions.Contains(x.RegionId)); + var systemAdmin = await ScopeService.IsSystemAdministratorAsync(ActorId, cancellationToken); + IQueryable query = dbContext.CountingStations.AsNoTracking(); + if (systemAdmin) + { + var regions = await cascadeFilterService.ResolveSystemAdministratorQueryAsync(new(provinceId, cityId, districtId, townshipId), cancellationToken); + if (regions is not null) query = query.Where(x => regions.Contains(x.RegionId)); + } + else + { + var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var regions = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); + query = query.Where(x => regions.Contains(x.RegionId)); + } if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword)); if (token is not null) query = query.Where(x => string.Compare(x.Code, token.Code) > 0 || (x.Code == token.Code && x.Id.CompareTo(token.Id) > 0)); var rows = await query.OrderBy(x => x.Code).ThenBy(x => x.Id).Take(pageSize + 1) .Select(x => new CountingStationCursorRow(x.Id, x.Code, x.Name, x.RegionId, x.IsActive)).ToListAsync(cancellationToken); var hasMore = rows.Count > pageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); - var regionNames = await dbContext.AdministrativeRegions.Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken); + var regionNames = await dbContext.AdministrativeRegions.AsNoTracking().Where(x => rows.Select(y => y.RegionId).Contains(x.Id)).ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken); var items = rows.Select(x => (object)new { x.Id, x.Code, x.Name, x.RegionId, RegionName = regionNames.GetValueOrDefault(x.RegionId, "—"), x.IsActive }).ToList(); var next = hasMore && rows.Count > 0 ? CursorToken.Encode(new CountingStationCursor(rows[^1].Code, rows[^1].Id)) : null; return Ok(new CursorPagedResult(items, next, hasMore, pageSize)); diff --git a/src/ElectionSystem.Api/Controllers/RegionalRoleAssignmentsController.cs b/src/ElectionSystem.Api/Controllers/RegionalRoleAssignmentsController.cs index 847b576..14bf4a3 100644 --- a/src/ElectionSystem.Api/Controllers/RegionalRoleAssignmentsController.cs +++ b/src/ElectionSystem.Api/Controllers/RegionalRoleAssignmentsController.cs @@ -1,3 +1,4 @@ +using ElectionSystem.Api.Common.Pagination; using ElectionSystem.Api.Infrastructure.Persistence; using ElectionSystem.Api.Modules.Organization.Domain; using Microsoft.AspNetCore.Mvc; @@ -9,12 +10,20 @@ namespace ElectionSystem.Api.Controllers; public sealed class RegionalRoleAssignmentsController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService) : AdministrationControllerBase(scopeService) { [HttpGet] - public async Task List([FromQuery] Guid? userId, CancellationToken cancellationToken) + public async Task>> List([FromQuery] Guid? userId, [FromQuery] string? cursor, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) { await ScopeService.RequireSystemAdministratorAsync(ActorId, cancellationToken); - var query = dbContext.UserRegionalRoleAssignments.AsQueryable(); + pageSize = Math.Clamp(pageSize, 1, 100); + if (!CursorToken.TryDecode(cursor, out var token)) return BadRequest(new { code = "invalid_cursor", message = "分页游标无效,请重新查询。" }); + IQueryable query = dbContext.UserRegionalRoleAssignments.AsNoTracking(); 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)); + if (token is not null) query = query.Where(x => x.CreatedAtUtc < token.CreatedAtUtc || (x.CreatedAtUtc == token.CreatedAtUtc && x.Id.CompareTo(token.Id) < 0)); + var rows = await query.OrderByDescending(x => x.CreatedAtUtc).ThenByDescending(x => x.Id).Take(pageSize + 1) + .Select(x => new RegionalRoleAssignmentRow(x.Id, x.UserId, x.RegionId, x.Role, x.CreatedAtUtc)).ToListAsync(cancellationToken); + var hasMore = rows.Count > pageSize; + if (hasMore) rows.RemoveAt(rows.Count - 1); + var next = hasMore && rows.Count > 0 ? CursorToken.Encode(new RegionalRoleAssignmentCursor(rows[^1].CreatedAtUtc, rows[^1].Id)) : null; + return Ok(new CursorPagedResult(rows.Cast().ToList(), next, hasMore, pageSize)); } [HttpPost] @@ -58,3 +67,5 @@ public sealed class RegionalRoleAssignmentsController(ElectionDbContext dbContex } public sealed record CreateRegionalRoleAssignmentRequest(Guid UserId, Guid? RegionId, RegionalRole Role); +public sealed record RegionalRoleAssignmentCursor(DateTime CreatedAtUtc, Guid Id); +internal sealed record RegionalRoleAssignmentRow(Guid Id, Guid UserId, Guid? RegionId, RegionalRole Role, DateTime CreatedAtUtc); diff --git a/src/ElectionSystem.Api/Controllers/VotersController.cs b/src/ElectionSystem.Api/Controllers/VotersController.cs index e39b310..9517721 100644 --- a/src/ElectionSystem.Api/Controllers/VotersController.cs +++ b/src/ElectionSystem.Api/Controllers/VotersController.cs @@ -14,8 +14,13 @@ public sealed class VotersController(ElectionDbContext dbContext, Modules.Organi 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)); + var systemAdmin = await ScopeService.IsSystemAdministratorAsync(ActorId, cancellationToken); + IQueryable query = dbContext.Voters.AsNoTracking(); + if (!systemAdmin) + { + var allowed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + query = query.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)); @@ -34,9 +39,19 @@ public sealed class VotersController(ElectionDbContext dbContext, Modules.Organi if (!CursorToken.TryDecode(cursor, out var token)) return BadRequest(new { code = "invalid_cursor", message = "分页游标无效,请重新查询。" }); try { - var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); - var regions = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); - var query = dbContext.Voters.Where(x => regions.Contains(x.RegionId)); + var systemAdmin = await ScopeService.IsSystemAdministratorAsync(ActorId, cancellationToken); + IQueryable query = dbContext.Voters.AsNoTracking(); + if (systemAdmin) + { + var regions = await cascadeFilterService.ResolveSystemAdministratorQueryAsync(new(provinceId, cityId, districtId, townshipId), cancellationToken); + if (regions is not null) query = query.Where(x => regions.Contains(x.RegionId)); + } + else + { + var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var regions = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); + query = query.Where(x => regions.Contains(x.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)); if (token is not null) query = query.Where(x => string.Compare(x.DisplayName, token.DisplayName) > 0 || (x.DisplayName == token.DisplayName && x.Id.CompareTo(token.Id) > 0)); diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs index 2b3350f..99b5ac4 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs @@ -63,6 +63,7 @@ public sealed class ElectionDbContext(DbContextOptions option { entity.ToTable("identity_user_regional_roles"); entity.HasIndex(x => new { x.UserId, x.Role, x.RegionId }).IsUnique(); + entity.HasIndex(x => new { x.CreatedAtUtc, x.Id }); entity.HasOne().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); entity.HasOne().WithMany().HasForeignKey(x => x.RegionId).OnDelete(DeleteBehavior.Restrict); }); diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260911121915_OptimizeRegionalRoleAssignmentListing.Designer.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260911121915_OptimizeRegionalRoleAssignmentListing.Designer.cs new file mode 100644 index 0000000..495e833 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260911121915_OptimizeRegionalRoleAssignmentListing.Designer.cs @@ -0,0 +1,1091 @@ +// +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("20260911121915_OptimizeRegionalRoleAssignmentListing")] + partial class OptimizeRegionalRoleAssignmentListing + { + /// + 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.Election.Domain.ElectionActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CountingEndsAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PublishLiveCandidateTotals") + .HasColumnType("tinyint(1)"); + + b.Property("PublishLiveParticipation") + .HasColumnType("tinyint(1)"); + + b.Property("PublishedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("ResultsPublishedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("VotingEndsAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("VotingStartsAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId", "Status", "VotingEndsAtUtc"); + + b.ToTable("election_activities", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionContest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Determination") + .HasColumnType("int"); + + b.Property("ElectionActivityId") + .HasColumnType("char(36)"); + + b.Property("MaxSelections") + .HasColumnType("int"); + + b.Property("MinSelections") + .HasColumnType("int"); + + b.Property("RequiredSupportPercent") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("SupportDenominator") + .HasColumnType("int"); + + b.Property("TieResolution") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ElectionActivityId", "SortOrder") + .IsUnique(); + + b.ToTable("election_contests", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionCountingStation", b => + { + b.Property("ElectionActivityId") + .HasColumnType("char(36)"); + + b.Property("CountingStationId") + .HasColumnType("char(36)"); + + b.HasKey("ElectionActivityId", "CountingStationId"); + + b.HasIndex("CountingStationId"); + + b.ToTable("election_counting_stations", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CandidateId") + .HasColumnType("char(36)"); + + b.Property("ElectionContestId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("ElectionContestId", "CandidateId") + .IsUnique(); + + b.HasIndex("ElectionContestId", "SortOrder") + .IsUnique(); + + b.ToTable("election_options", (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.OfflineCounting.Domain.OfflineCountSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EnteredByUserId") + .HasColumnType("char(36)"); + + b.Property("OfflineCountingSessionId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OfflineCountingSessionId", "EnteredByUserId") + .IsUnique(); + + b.ToTable("offline_count_submissions", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountTally", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Abstentions") + .HasColumnType("int"); + + b.Property("CountedBallots") + .HasColumnType("int"); + + b.Property("ElectionContestId") + .HasColumnType("char(36)"); + + b.Property("ElectionOptionId") + .HasColumnType("char(36)"); + + b.Property("InvalidBallots") + .HasColumnType("int"); + + b.Property("MissingSelections") + .HasColumnType("int"); + + b.Property("OfflineCountSubmissionId") + .HasColumnType("char(36)"); + + b.Property("Votes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ElectionContestId"); + + b.HasIndex("ElectionOptionId"); + + b.HasIndex("OfflineCountSubmissionId", "ElectionContestId", "ElectionOptionId") + .IsUnique(); + + b.ToTable("offline_count_tallies", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("ConfirmedByUserId") + .HasColumnType("char(36)"); + + b.Property("CorrectionReason") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CountingStationId") + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("ElectionActivityId") + .HasColumnType("char(36)"); + + b.Property("OpenedByUserId") + .HasColumnType("char(36)"); + + b.Property("ReplacesSessionId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CountingStationId"); + + b.HasIndex("ReplacesSessionId"); + + b.HasIndex("ElectionActivityId", "CountingStationId", "Status"); + + b.HasIndex("ElectionActivityId", "CountingStationId", "Version") + .IsUnique() + .HasDatabaseName("IX_offline_counting_sessions_ElectionActivityId_CountingStatio~1"); + + b.ToTable("offline_counting_sessions", (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("CreatedAtUtc", "Id"); + + 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.Voters.Domain.VoterRegistrationAggregate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("RegisteredCount") + .HasColumnType("int"); + + b.Property("SetByUserId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId") + .IsUnique(); + + b.HasIndex("SetByUserId"); + + b.ToTable("voter_registration_aggregates", (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.Election.Domain.ElectionActivity", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionContest", b => + { + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionActivity", null) + .WithMany() + .HasForeignKey("ElectionActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionCountingStation", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", null) + .WithMany() + .HasForeignKey("CountingStationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionActivity", null) + .WithMany() + .HasForeignKey("ElectionActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionOption", b => + { + b.HasOne("ElectionSystem.Api.Modules.Candidates.Domain.CandidateProfile", null) + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionContest", null) + .WithMany() + .HasForeignKey("ElectionContestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountSubmission", b => + { + b.HasOne("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountingSession", null) + .WithMany() + .HasForeignKey("OfflineCountingSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountTally", b => + { + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionContest", null) + .WithMany() + .HasForeignKey("ElectionContestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionOption", null) + .WithMany() + .HasForeignKey("ElectionOptionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountSubmission", null) + .WithMany() + .HasForeignKey("OfflineCountSubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountingSession", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", null) + .WithMany() + .HasForeignKey("CountingStationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionActivity", null) + .WithMany() + .HasForeignKey("ElectionActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountingSession", null) + .WithMany() + .HasForeignKey("ReplacesSessionId") + .OnDelete(DeleteBehavior.Restrict); + }); + + 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("ElectionSystem.Api.Modules.Voters.Domain.VoterRegistrationAggregate", 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("SetByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + 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/20260911121915_OptimizeRegionalRoleAssignmentListing.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260911121915_OptimizeRegionalRoleAssignmentListing.cs new file mode 100644 index 0000000..52f6d3d --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260911121915_OptimizeRegionalRoleAssignmentListing.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class OptimizeRegionalRoleAssignmentListing : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_identity_user_regional_roles_CreatedAtUtc_Id", + table: "identity_user_regional_roles", + columns: new[] { "CreatedAtUtc", "Id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_identity_user_regional_roles_CreatedAtUtc_Id", + table: "identity_user_regional_roles"); + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs index daae6c9..ea63b1b 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs @@ -652,6 +652,8 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations b.HasIndex("RegionId"); + b.HasIndex("CreatedAtUtc", "Id"); + b.HasIndex("UserId", "Role", "RegionId") .IsUnique(); diff --git a/src/ElectionSystem.Api/Modules/Organization/RegionCascadeFilterService.cs b/src/ElectionSystem.Api/Modules/Organization/RegionCascadeFilterService.cs index 6ec4944..23610fa 100644 --- a/src/ElectionSystem.Api/Modules/Organization/RegionCascadeFilterService.cs +++ b/src/ElectionSystem.Api/Modules/Organization/RegionCascadeFilterService.cs @@ -9,6 +9,45 @@ public sealed record AdministrativeRegionFilter(Guid? ProvinceId, Guid? CityId, /// Validates province-to-township selections and returns the selected subtree intersected with the caller scope. public sealed class RegionCascadeFilterService(ElectionDbContext dbContext) { + /// + /// Builds a database-side subtree for a system administrator. This deliberately + /// returns a query rather than materializing every descendant as GUIDs, which + /// would otherwise turn a national list into a very large IN predicate. + /// + public async Task?> ResolveSystemAdministratorQueryAsync(AdministrativeRegionFilter filter, CancellationToken cancellationToken) + { + var ids = new[] { filter.ProvinceId, filter.CityId, filter.DistrictId, filter.TownshipId } + .Where(x => x.HasValue).Select(x => x!.Value).Distinct().ToArray(); + if (ids.Length == 0) return null; + + var regions = await dbContext.AdministrativeRegions.AsNoTracking() + .Where(x => x.IsActive && ids.Contains(x.Id)) + .Select(x => new RegionNode(x.Id, x.ParentId, x.Level)) + .ToListAsync(cancellationToken); + var byId = regions.ToDictionary(x => x.Id); + Validate(filter.ProvinceId, AdministrativeRegionLevel.Province, byId, null); + Validate(filter.CityId, AdministrativeRegionLevel.City, byId, null); + Validate(filter.DistrictId, AdministrativeRegionLevel.District, byId, null); + Validate(filter.TownshipId, AdministrativeRegionLevel.Township, byId, null); + ValidateParent(filter.ProvinceId, filter.CityId, byId); + ValidateParent(filter.CityId, filter.DistrictId, byId); + ValidateParent(filter.DistrictId, filter.TownshipId, byId); + + var selectedId = filter.TownshipId ?? filter.DistrictId ?? filter.CityId ?? filter.ProvinceId; + IQueryable descendants = dbContext.AdministrativeRegions.AsNoTracking() + .Where(x => x.IsActive && x.Id == selectedId!.Value).Select(x => x.Id); + var parents = descendants; + for (var level = 0; level < 4; level++) + { + var parentLevel = parents; + parents = dbContext.AdministrativeRegions.AsNoTracking() + .Where(x => x.IsActive && x.ParentId.HasValue && parentLevel.Contains(x.ParentId.Value)) + .Select(x => x.Id); + descendants = descendants.Concat(parents); + } + return descendants.Distinct(); + } + public async Task> ResolveAsync(HashSet managedRegionIds, AdministrativeRegionFilter filter, CancellationToken cancellationToken) { if (managedRegionIds.Count == 0) return []; @@ -54,10 +93,10 @@ public sealed class RegionCascadeFilterService(ElectionDbContext dbContext) return subtree; } - private static void Validate(Guid? id, AdministrativeRegionLevel level, IReadOnlyDictionary regions, IReadOnlySet managed) + private static void Validate(Guid? id, AdministrativeRegionLevel level, IReadOnlyDictionary regions, IReadOnlySet? managed) { if (!id.HasValue) return; - if (!managed.Contains(id.Value) || !regions.TryGetValue(id.Value, out var region) || region.Level != level) + if ((managed is not null && !managed.Contains(id.Value)) || !regions.TryGetValue(id.Value, out var region) || region.Level != level) throw new ArgumentException("行政区划筛选无效或超出当前管理范围。"); } diff --git a/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs b/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs index 6069e3b..08dc8f5 100644 --- a/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs +++ b/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs @@ -18,11 +18,19 @@ public sealed class RegionScopeService(ElectionDbContext dbContext) return Guid.TryParse(raw, out var userId) ? userId : throw new UnauthorizedAccessException("Authenticated user identifier is required."); } + public Task IsSystemAdministratorAsync(Guid userId, CancellationToken cancellationToken) => + dbContext.UserRegionalRoleAssignments.AsNoTracking().AnyAsync(x => + x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null, + cancellationToken); + public async Task> GetManagedRegionIdsAsync(Guid userId, CancellationToken cancellationToken) { var assignments = await dbContext.UserRegionalRoleAssignments.AsNoTracking() .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); + // Interactive list callers should use IsSystemAdministratorAsync and omit + // the regional predicate instead of using this compatibility result to + // build a national IN (...) list. if (assignments.Any(x => x.Role == RegionalRole.SystemAdministrator && x.RegionId == null)) return (await dbContext.AdministrativeRegions.AsNoTracking().Select(x => x.Id).ToListAsync(cancellationToken)).ToHashSet();