Add cursor pagination for candidates and regions

This commit is contained in:
biss committed 2026-08-29 20:04:42 +08:00
1 parent 08fe656480
commit e39c265f66
5 files changed
+72 -3

No files matched your search

@@ -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<ActionResult> List([FromQuery] Guid? parentId, CancellationToken cancellationToken)
@@ -17,6 +18,25 @@ public sealed class AdministrativeRegionsController(ElectionDbContext dbContext,
return Ok(items);
}
[HttpGet("cursor")]
public async Task<ActionResult<CursorPagedResult<object>>> 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<RegionCursor>(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<object>(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<ActionResult> 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);
@@ -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<ActionResult> 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<object>(items.Cast<object>().ToList(), page, pageSize, total));
}
[HttpGet("cursor")]
public async Task<ActionResult<CursorPagedResult<object>>> 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<CandidateCursor>(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<object>(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<ActionResult> 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
{
@@ -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<CountingStationImportRow> Rows, int ValidCount, int ErrorCount);
public sealed class CountingStationImportService(ElectionDbContext db, IMemoryCache cache, RegionScopeService scope)
{
public async Task<CountingStationImportPreview> 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<string>(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<int> ConfirmAsync(Guid actorId, Guid token, CancellationToken ct)
{
if(!cache.TryGetValue<CountingStationImportPreview>(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}";
}
Loaded 3 of 5 files, more files were not shown because too many files have changed in this diff. Show more