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 -1
View File
@@ -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`,不会放宽数据范围。
管理端用于第二阶段基础资料与授权操作。选举活动、匿名投票、线下双录计票和公开结果将在后续阶段接入相应菜单,且不会通过本阶段页面绕过其规则。
@@ -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
{
Loaded 3 of 5 files, more files were not shown because too many files have changed in this diff. Show more