9 Commits
46 changed files with 11618 additions and 242 deletions

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
using System.Text;
using System.Text.Json;
namespace Jiaowu.Api.Contracts;
/// <summary>Encodes endpoint-specific keyset values without exposing a query offset.</summary>
public static class CursorToken
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
public static string Encode<T>(T value) =>
Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, Options)))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static bool TryDecode<T>(string? token, out T? value)
{
value = default;
if (string.IsNullOrWhiteSpace(token)) return true;
try
{
var normalized = token.Replace('-', '+').Replace('_', '/');
normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '=');
value = JsonSerializer.Deserialize<T>(Encoding.UTF8.GetString(Convert.FromBase64String(normalized)), Options);
return value is not null;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
}
}
+15
View File
@@ -1,5 +1,20 @@
namespace Jiaowu.Api.Contracts; namespace Jiaowu.Api.Contracts;
/// <summary>
/// A forward-only keyset page. <see cref="NextCursor"/> is opaque to clients and
/// must be sent back unchanged as the <c>cursor</c> query parameter.
/// </summary>
public sealed record CursorPagedResult<T>(
IReadOnlyCollection<T> Items,
string? NextCursor,
bool HasMore,
int PageSize,
int? Total = null);
/// <summary>
/// Legacy offset-page response. New list endpoints must use
/// <see cref="CursorPagedResult{T}"/> instead.
/// </summary>
public sealed record PagedResult<T>( public sealed record PagedResult<T>(
IReadOnlyCollection<T> Items, IReadOnlyCollection<T> Items,
int Total, int Total,
@@ -113,15 +113,17 @@ public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBa
[Authorize(Roles = SystemRoles.SuperAdmin)] [Authorize(Roles = SystemRoles.SuperAdmin)]
[HttpGet("releases")] [HttpGet("releases")]
public async Task<ActionResult<PagedResult<AppUpdateReleaseItem>>> GetReleases( public async Task<ActionResult<CursorPagedResult<AppUpdateReleaseItem>>> GetReleases(
[FromQuery] int page = 1, [FromQuery] string? cursor = null,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
[FromQuery] string? platform = null, [FromQuery] string? platform = null,
[FromQuery] string? channel = null, [FromQuery] string? channel = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (page < 1 || pageSize is < 1 or > 100) if (pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于零,每页数量必须在 1 到 100 之间。"); return ValidationProblem("每批数量必须在 1 到 100 之间。");
if (!CursorToken.TryDecode<ReleaseCursor>(cursor, out var after))
return BadRequest("更新包游标无效。");
var query = db.AppUpdateReleases.AsNoTracking().AsQueryable(); var query = db.AppUpdateReleases.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(platform)) if (!string.IsNullOrWhiteSpace(platform))
@@ -137,11 +139,21 @@ public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBa
query = query.Where(x => x.Channel == parsedChannel); query = query.Where(x => x.Channel == parsedChannel);
} }
var total = await query.CountAsync(cancellationToken); if (after is not null)
var rows = await query {
query = query.Where(x => (x.PublishedAt ?? x.CreatedAt) < after.SortAt ||
(x.PublishedAt ?? x.CreatedAt) == after.SortAt && x.Id.CompareTo(after.Id) < 0);
}
var keys = await query
.OrderByDescending(x => x.PublishedAt ?? x.CreatedAt) .OrderByDescending(x => x.PublishedAt ?? x.CreatedAt)
.Skip((page - 1) * pageSize) .ThenByDescending(x => x.Id)
.Take(pageSize) .Take(pageSize + 1)
.Select(x => new { x.Id, SortAt = x.PublishedAt ?? x.CreatedAt })
.ToArrayAsync(cancellationToken);
var hasMore = keys.Length > pageSize;
var pageIds = keys.Take(pageSize).Select(x => x.Id).ToArray();
var rows = await query.WhereIn(pageIds, x => x.Id)
.OrderByDescending(x => x.PublishedAt ?? x.CreatedAt).ThenByDescending(x => x.Id)
.Select(x => new AppUpdateReleaseItem( .Select(x => new AppUpdateReleaseItem(
x.Id, x.Id,
x.Platform, x.Platform,
@@ -158,13 +170,16 @@ public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBa
x.PublishedByUserName, x.PublishedByUserName,
x.PublishedAt)) x.PublishedAt))
.ToArrayAsync(cancellationToken); .ToArrayAsync(cancellationToken);
return Ok(new PagedResult<AppUpdateReleaseItem>( var last = keys.Take(pageSize).LastOrDefault();
return Ok(new CursorPagedResult<AppUpdateReleaseItem>(
rows, rows,
total, hasMore && last is not null ? CursorToken.Encode(new ReleaseCursor(last.SortAt, last.Id)) : null,
page, hasMore,
pageSize)); pageSize));
} }
private sealed record ReleaseCursor(DateTime SortAt, Guid Id);
[Authorize(Roles = SystemRoles.SuperAdmin)] [Authorize(Roles = SystemRoles.SuperAdmin)]
[Consumes("multipart/form-data")] [Consumes("multipart/form-data")]
[RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)] [RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)]
Loaded 3 of 46 files, more files were not shown because too many files have changed in this diff. Show more