diff --git a/docs/admin-management.md b/docs/admin-management.md index 4ffe870..a3113cb 100644 --- a/docs/admin-management.md +++ b/docs/admin-management.md @@ -31,6 +31,6 @@ dotnet user-secrets remove "Bootstrap:SystemAdministrator:Password" --project sr - `/admin/roles`:系统管理员授予地域管理员、计票员或监督员角色。 - `/admin/counting-stations`:建立辖区计票所,并向已获地域角色的人员分配具体计票所;列表同样使用游标分页及四级级联筛选。 -对应管理 API 提供 `GET /api/admin/voters/cursor` 与 `GET /api/admin/counting-stations/cursor`。`cursor` 只能用于同一组筛选条件;筛选范围、下级地域展开和管理员地域授权均在服务端先行执行。非法游标或跨层级、越权的行政区划参数会返回 `400`,不会放宽数据范围。 +对应管理 API 提供 `GET /api/admin/voters/cursor`、`GET /api/admin/counting-stations/cursor`、`GET /api/admin/candidates/cursor` 与 `GET /api/admin/administrative-regions/cursor`。`cursor` 只能用于同一组筛选条件;筛选范围、下级地域展开和管理员地域授权均在服务端先行执行。非法游标或跨层级、越权的行政区划参数会返回 `400`,不会放宽数据范围。 管理端用于第二阶段基础资料与授权操作。选举活动、匿名投票、线下双录计票和公开结果将在后续阶段接入相应菜单,且不会通过本阶段页面绕过其规则。 diff --git a/src/ElectionSystem.Api/Controllers/AdministrativeRegionsController.cs b/src/ElectionSystem.Api/Controllers/AdministrativeRegionsController.cs index 7809768..14c8f36 100644 --- a/src/ElectionSystem.Api/Controllers/AdministrativeRegionsController.cs +++ b/src/ElectionSystem.Api/Controllers/AdministrativeRegionsController.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; @@ -6,7 +7,7 @@ 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) +public sealed class AdministrativeRegionsController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, Modules.Organization.RegionCascadeFilterService cascadeFilterService, Modules.Organization.AreaCityCatalogImportService catalogImportService) : AdministrationControllerBase(scopeService) { [HttpGet] public async Task List([FromQuery] Guid? parentId, CancellationToken cancellationToken) @@ -17,6 +18,25 @@ public sealed class AdministrativeRegionsController(ElectionDbContext dbContext, return Ok(items); } + [HttpGet("cursor")] + public async Task>> ListCursor([FromQuery] Guid? provinceId, [FromQuery] Guid? cityId, [FromQuery] Guid? districtId, [FromQuery] Guid? townshipId, [FromQuery] string? cursor, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) + { + pageSize = Math.Clamp(pageSize, 1, 100); + if (!CursorToken.TryDecode(cursor, out var token)) return BadRequest(new { code = "invalid_cursor", message = "分页游标无效,请重新查询。" }); + try + { + var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var filtered = await cascadeFilterService.ResolveAsync(managed, new(provinceId, cityId, districtId, townshipId), cancellationToken); + var query = dbContext.AdministrativeRegions.Where(x => filtered.Contains(x.Id)); + 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(cancellationToken); + var hasMore = rows.Count > pageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); + var items = rows.Select(x => (object)new { x.Id, x.ParentId, x.Code, x.Name, x.Level, x.Source, x.SourceVersion, x.IsSynthetic, x.IsActive }).ToList(); + return Ok(new CursorPagedResult(items, hasMore && rows.Count > 0 ? CursorToken.Encode(new RegionCursor(rows[^1].Code, rows[^1].Id)) : null, hasMore, pageSize)); + } + catch (ArgumentException exception) { return BadRequest(new { code = "invalid_region_filter", message = exception.Message }); } + } + [HttpPost("area-city-import")] [RequestSizeLimit(15_000_000)] public async Task ImportAreaCity(IFormFile file, [FromForm] string sourceVersion, CancellationToken cancellationToken) @@ -43,3 +63,4 @@ public sealed class AdministrativeRegionsController(ElectionDbContext dbContext, } public sealed record CreateAdministrativeRegionRequest(Guid ParentId, string Code, string Name, AdministrativeRegionLevel Level, string SourceVersion); +public sealed record RegionCursor(string Code, Guid Id); diff --git a/src/ElectionSystem.Api/Controllers/CandidatesController.cs b/src/ElectionSystem.Api/Controllers/CandidatesController.cs index 3d74108..ac34488 100644 --- a/src/ElectionSystem.Api/Controllers/CandidatesController.cs +++ b/src/ElectionSystem.Api/Controllers/CandidatesController.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Controllers; [Route("api/admin/candidates")] -public sealed class CandidatesController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService) : AdministrationControllerBase(scopeService) +public sealed class CandidatesController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, Modules.Organization.RegionCascadeFilterService cascadeFilterService) : AdministrationControllerBase(scopeService) { [HttpGet] public async Task List([FromQuery] Guid? regionId, [FromQuery] CandidateStatus? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) @@ -22,6 +22,27 @@ public sealed class CandidatesController(ElectionDbContext dbContext, Modules.Or return Ok(new PagedResult(items.Cast().ToList(), page, pageSize, total)); } + [HttpGet("cursor")] + public async Task>> ListCursor([FromQuery] Guid? provinceId, [FromQuery] Guid? cityId, [FromQuery] Guid? districtId, [FromQuery] Guid? townshipId, [FromQuery] CandidateStatus? status, [FromQuery] string? cursor, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) + { + pageSize = Math.Clamp(pageSize, 1, 100); + 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)); + 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); + var hasMore = rows.Count > pageSize; if (hasMore) rows.RemoveAt(rows.Count - 1); + var names = await dbContext.AdministrativeRegions.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.DisplayName, x.RegionId, RegionName = names.GetValueOrDefault(x.RegionId, "—"), x.Status, x.NominationSummary, x.StatusReason, x.UpdatedAtUtc }).ToList(); + return Ok(new CursorPagedResult(items, hasMore && rows.Count > 0 ? CursorToken.Encode(new CandidateCursor(rows[^1].DisplayName, rows[^1].Id)) : null, hasMore, pageSize)); + } + catch (ArgumentException exception) { return BadRequest(new { code = "invalid_region_filter", message = exception.Message }); } + } + [HttpPost] public async Task Create(CreateCandidateRequest request, CancellationToken cancellationToken) { @@ -46,6 +67,7 @@ public sealed class CandidatesController(ElectionDbContext dbContext, Modules.Or public sealed record CreateCandidateRequest(string DisplayName, Guid RegionId, string? NominationSummary); public sealed record SetCandidateStatusRequest(CandidateStatus Status, string? Reason); +public sealed record CandidateCursor(string DisplayName, Guid Id); internal static class CandidateTransitions { diff --git a/src/ElectionSystem.Api/Modules/Organization/CountingStationImportService.cs b/src/ElectionSystem.Api/Modules/Organization/CountingStationImportService.cs new file mode 100644 index 0000000..d359cbf --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Organization/CountingStationImportService.cs @@ -0,0 +1,25 @@ +using ClosedXML.Excel; +using ElectionSystem.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace ElectionSystem.Api.Modules.Organization; + +public sealed record CountingStationImportRow(int RowNumber, string Code, string Name, string RegionCode, string? Error); +public sealed record CountingStationImportPreview(Guid Token, IReadOnlyList Rows, int ValidCount, int ErrorCount); + +public sealed class CountingStationImportService(ElectionDbContext db, IMemoryCache cache, RegionScopeService scope) +{ + public async Task PreviewAsync(Guid actorId, Stream stream, CancellationToken ct) + { + using var book = new XLWorkbook(stream); var sheet = book.Worksheets.FirstOrDefault() ?? throw new InvalidOperationException("Excel 文件没有工作表。"); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var rows = sheet.RowsUsed().Skip(1).Select(r => { var code=r.Cell(1).GetString().Trim(); var name=r.Cell(2).GetString().Trim(); var region=r.Cell(3).GetString().Trim(); string? e=code.Length is <1 or >64 ? "计票所代码不能为空且最多64字符" : name.Length is <1 or >128 ? "名称不能为空且最多128字符" : region.Length is <1 or >32 ? "行政区划代码不能为空且最多32字符" : !seen.Add(code) ? "文件内代码重复" : null; return new CountingStationImportRow(r.RowNumber(),code,name,region,e); }).ToList(); + var codes=rows.Where(x=>x.Error is null).Select(x=>x.RegionCode).Distinct().ToArray(); var regions=await db.AdministrativeRegions.Where(x=>codes.Contains(x.Code)&&x.IsActive).ToDictionaryAsync(x=>x.Code,ct); var allowed=await scope.GetManagedRegionIdsAsync(actorId,ct); var exists=(await db.CountingStations.Where(x=>rows.Select(r=>r.Code).Contains(x.Code)).Select(x=>x.Code).ToListAsync(ct)).ToHashSet(StringComparer.OrdinalIgnoreCase); + rows=rows.Select(x=>x.Error is not null?x:!regions.TryGetValue(x.RegionCode,out var r)?x with{Error="行政区划不存在或已停用"}:!allowed.Contains(r.Id)?x with{Error="无权管理该行政区划"}:exists.Contains(x.Code)?x with{Error="计票所代码已存在"}:x).ToList(); var p=new CountingStationImportPreview(Guid.NewGuid(),rows,rows.Count(x=>x.Error is null),rows.Count(x=>x.Error is not null)); cache.Set(Key(actorId,p.Token),p,TimeSpan.FromMinutes(30)); return p; + } + public async Task ConfirmAsync(Guid actorId, Guid token, CancellationToken ct) + { + if(!cache.TryGetValue(Key(actorId,token),out var p)||p is null) throw new InvalidOperationException("预校验已过期,请重新上传。"); if(p.ErrorCount!=0) throw new InvalidOperationException("存在逐行错误,不能确认导入。"); var codes=p.Rows.Select(x=>x.RegionCode).Distinct().ToArray(); var regions=await db.AdministrativeRegions.Where(x=>codes.Contains(x.Code)&&x.IsActive).ToDictionaryAsync(x=>x.Code,ct); var allowed=await scope.GetManagedRegionIdsAsync(actorId,ct); if(regions.Count!=codes.Length||regions.Values.Any(x=>!allowed.Contains(x.Id))||await db.CountingStations.AnyAsync(x=>p.Rows.Select(r=>r.Code).Contains(x.Code),ct)) throw new InvalidOperationException("数据或权限已变化,请重新预校验。"); db.CountingStations.AddRange(p.Rows.Select(x=>new Domain.CountingStation{Code=x.Code,Name=x.Name,RegionId=regions[x.RegionCode].Id})); await db.SaveChangesAsync(ct); cache.Remove(Key(actorId,token)); return p.ValidCount; + } + private static string Key(Guid actor,Guid token)=>$"station-import:{actor}:{token}"; +} diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs index e637b0a..67cf9fa 100644 --- a/src/ElectionSystem.Api/Program.cs +++ b/src/ElectionSystem.Api/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddMemoryCache(); builder.Services.AddDbContext((serviceProvider, options) => {