feat(ai): centralize durable usage
CI and Deploy / test (pull_request) Successful in 5m19s
CI and Deploy / deploy (pull_request) Has been skipped

Add a content-free usage ledger with legacy backfill. Reserve Workspace and durable Strategy/CV work before execution so deleted history or duplicate admission cannot reset limits.
This commit is contained in:
cesnimda
2026-08-15 20:03:06 +02:00
parent dbff0f8d49
commit 134aac7bcf
28 changed files with 3539 additions and 83 deletions
+11 -27
View File
@@ -1,5 +1,6 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -14,11 +15,13 @@ public sealed class AiUsageController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly JobTrackerContext _db;
private readonly AiUsageMeter? _usage;
public AiUsageController(UserManager<ApplicationUser> users, JobTrackerContext db)
public AiUsageController(UserManager<ApplicationUser> users, JobTrackerContext db, AiUsageMeter? usage = null)
{
_users = users;
_db = db;
_usage = usage;
}
public sealed record UsagePeriodDto(int Calls, long InputCharacters, long OutputCharacters, long EstimatedTokens);
@@ -32,38 +35,19 @@ public sealed class AiUsageController : ControllerBase
var roles = await _users.GetRolesAsync(user);
var entitlements = AccountPlans.ForRoles(roles);
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
var interactions = _db.AiInteractions.Where(x => x.OwnerUserId == user.Id);
var currentMonth = _db.Database.IsSqlite()
? Sum((await interactions.ToListAsync(cancellationToken)).Where(x => x.CreatedAtUtc >= monthStart))
: await SumAsync(interactions.Where(x => x.CreatedAtUtc >= monthStart), cancellationToken);
var meter = _usage ?? new AiUsageMeter(_db, TimeProvider.System);
var currentMonth = ToDto(await meter.CurrentMonthAsync(user.Id, cancellationToken));
return Ok(new UsageDto(
currentMonth,
await SumAsync(interactions, cancellationToken),
ToDto(await meter.AllTimeAsync(user.Id, cancellationToken)),
AccountPlans.Name(entitlements),
entitlements.MonthlyAiCalls,
entitlements.MonthlyAiTokens,
await _db.Attachments.Where(x => x.JobApplication.OwnerUserId == user.Id).SumAsync(x => (long?)x.FileSize, cancellationToken) ?? 0,
await _db.Attachments.Where(x => x.JobApplication.OwnerUserId == user.Id)
.SumAsync(x => (long?)x.FileSize, cancellationToken) ?? 0,
entitlements.StorageBytes));
}
private static async Task<UsagePeriodDto> SumAsync(IQueryable<AiInteraction> query, CancellationToken cancellationToken)
{
var totals = await query.GroupBy(_ => 1).Select(group => new UsagePeriodDto(
group.Count(),
group.Sum(x => (long)x.InputCharacterCount),
group.Sum(x => (long)x.OutputCharacterCount),
group.Sum(x => (long)x.EstimatedTokenCount))).FirstOrDefaultAsync(cancellationToken);
return totals ?? new UsagePeriodDto(0, 0, 0, 0);
}
private static UsagePeriodDto Sum(IEnumerable<AiInteraction> interactions)
{
var rows = interactions.ToList();
return new UsagePeriodDto(
rows.Count,
rows.Sum(x => (long)x.InputCharacterCount),
rows.Sum(x => (long)x.OutputCharacterCount),
rows.Sum(x => (long)x.EstimatedTokenCount));
}
private static UsagePeriodDto ToDto(AiUsageTotals totals)
=> new(totals.Calls, totals.InputCharacters, totals.OutputCharacters, totals.EstimatedTokens);
}