Quota and better drive dashboard

This commit is contained in:
2025-07-27 18:08:39 +08:00
parent 4a0117906a
commit c875c82bdc
16 changed files with 758 additions and 46 deletions

View File

@@ -0,0 +1,28 @@
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Drive.Billing;
/// <summary>
/// The quota record stands for the extra quota that a user has.
/// For normal users, the quota is 1GiB.
/// For stellar program t1 users, the quota is 5GiB
/// For stellar program t2 users, the quota is 10GiB
/// For stellar program t3 users, the quota is 15GiB
///
/// If users want to increase the quota, they need to pay for it.
/// Each 1NSD they paid for one GiB.
///
/// But the quota record unit is MiB, the minimal billable unit.
/// </summary>
public class QuotaRecord : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid AccountId { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public long Quota { get; set; }
public Instant? ExpiredAt { get; set; }
}

View File

@@ -0,0 +1,66 @@
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Drive.Billing;
[ApiController]
[Route("/api/billing/quota")]
public class QuotaController(AppDatabase db, QuotaService quota) : ControllerBase
{
public class QuotaDetails
{
public long BasedQuota { get; set; }
public long ExtraQuota { get; set; }
public long TotalQuota { get; set; }
}
[HttpGet]
[Authorize]
public async Task<ActionResult<QuotaDetails>> GetQuota()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var (based, extra) = await quota.GetQuotaVerbose(accountId);
return Ok(new QuotaDetails
{
BasedQuota = based,
ExtraQuota = extra,
TotalQuota = based + extra
});
}
[HttpGet("records")]
[Authorize]
public async Task<ActionResult<List<QuotaRecord>>> GetQuotaRecords(
[FromQuery] bool expired = false,
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var now = SystemClock.Instance.GetCurrentInstant();
var query = db.QuotaRecords
.Where(r => r.AccountId == accountId)
.AsQueryable();
if (!expired)
query = query
.Where(r => !r.ExpiredAt.HasValue || r.ExpiredAt > now);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var records = await query
.OrderByDescending(r => r.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(records);
}
}

View File

@@ -0,0 +1,69 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Drive.Billing;
public class QuotaService(
AppDatabase db,
UsageService usage,
AccountService.AccountServiceClient accounts,
ICacheService cache
)
{
public async Task<(bool ok, long billable, long quota)> IsFileAcceptable(Guid accountId, double costMultiplier, long newFileSize)
{
// The billable unit is MiB
var billableUnit = (long)Math.Ceiling(newFileSize / 1024.0 / 1024.0 * costMultiplier);
var totalBillableUsage = await usage.GetTotalBillableUsage();
var quota = await GetQuota(accountId);
return (totalBillableUsage + billableUnit <= quota, billableUnit, quota);
}
public async Task<long> GetQuota(Guid accountId)
{
var cacheKey = $"file:quota:{accountId}";
var cachedResult = await cache.GetAsync<long?>(cacheKey);
if (cachedResult.HasValue) return cachedResult.Value;
var (based, extra) = await GetQuotaVerbose(accountId);
var quota = based + extra;
await cache.SetAsync(cacheKey, quota);
return quota;
}
public async Task<(long based, long extra)> GetQuotaVerbose(Guid accountId)
{
var response = await accounts.GetAccountAsync(new GetAccountRequest { Id = accountId.ToString() });
var perkSubscription = response.PerkSubscription;
// The base quota is 1GiB, T1 is 5GiB, T2 is 10GiB, T3 is 15GiB
var basedQuota = 1L;
if (perkSubscription != null)
{
var privilege = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perkSubscription.Identifier);
basedQuota = privilege switch
{
1 => 5L,
2 => 10L,
3 => 15L,
_ => basedQuota
};
}
// The based quota is in GiB, we need to convert it to MiB
basedQuota *= 1024L;
var now = SystemClock.Instance.GetCurrentInstant();
var extraQuota = await db.QuotaRecords
.Where(e => e.AccountId == accountId)
.Where(e => !e.ExpiredAt.HasValue || e.ExpiredAt > now)
.SumAsync(e => e.Quota);
return (basedQuota, extraQuota);
}
}

View File

@@ -1,25 +1,49 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Drive.Billing;
[ApiController]
[Route("api/billing/usage")]
public class UsageController(UsageService usageService) : ControllerBase
public class UsageController(UsageService usage, QuotaService quota, ICacheService cache) : ControllerBase
{
[HttpGet]
[Authorize]
public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage()
{
return await usageService.GetTotalUsage();
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var cacheKey = $"file:usage:{accountId}";
// Try to get from cache first
var (found, cachedResult) = await cache.GetAsyncWithStatus<TotalUsageDetails>(cacheKey);
if (found && cachedResult != null)
return Ok(cachedResult);
// If not in cache, get from services
var result = await usage.GetTotalUsage(accountId);
var totalQuota = await quota.GetQuota(accountId);
result.TotalQuota = totalQuota;
// Cache the result for 5 minutes
await cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
return Ok(result);
}
[Authorize]
[HttpGet("{poolId:guid}")]
public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId)
{
var usageDetails = await usageService.GetPoolUsage(poolId);
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var usageDetails = await usage.GetPoolUsage(poolId, accountId);
if (usageDetails == null)
{
return NotFound();
}
return usageDetails;
}
}

View File

@@ -12,27 +12,27 @@ public class UsageDetails
public required long FileCount { get; set; }
}
public class UsageDetailsWithPercentage : UsageDetails
{
public required double Percentage { get; set; }
}
public class TotalUsageDetails
{
public required List<UsageDetailsWithPercentage> PoolUsages { get; set; }
public required List<UsageDetails> PoolUsages { get; set; }
public required long TotalUsageBytes { get; set; }
public required double TotalCost { get; set; }
public required long TotalFileCount { get; set; }
// Quota, cannot be loaded in the service, cause circular dependency
// Let the controller do the calculation
public long? TotalQuota { get; set; }
public long? UsedQuota { get; set; }
}
public class UsageService(AppDatabase db)
{
public async Task<TotalUsageDetails> GetTotalUsage()
public async Task<TotalUsageDetails> GetTotalUsage(Guid accountId)
{
var now = SystemClock.Instance.GetCurrentInstant();
var fileQuery = db.Files
.Where(f => !f.IsMarkedRecycle)
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
.Where(f => f.AccountId == accountId)
.AsQueryable();
var poolUsages = await db.Pools
@@ -56,26 +56,16 @@ public class UsageService(AppDatabase db)
var totalCost = poolUsages.Sum(p => p.Cost);
var totalFileCount = poolUsages.Sum(p => p.FileCount);
var poolUsagesWithPercentage = poolUsages.Select(p => new UsageDetailsWithPercentage
{
PoolId = p.PoolId,
PoolName = p.PoolName,
UsageBytes = p.UsageBytes,
Cost = p.Cost,
FileCount = p.FileCount,
Percentage = totalUsage > 0 ? (double)p.UsageBytes / totalUsage : 0
}).ToList();
return new TotalUsageDetails
{
PoolUsages = poolUsagesWithPercentage,
PoolUsages = poolUsages,
TotalUsageBytes = totalUsage,
TotalCost = totalCost,
TotalFileCount = totalFileCount
TotalFileCount = totalFileCount,
UsedQuota = await GetTotalBillableUsage()
};
}
public async Task<UsageDetails?> GetPoolUsage(Guid poolId)
public async Task<UsageDetails?> GetPoolUsage(Guid poolId, Guid accountId)
{
var pool = await db.Pools.FindAsync(poolId);
if (pool == null)
@@ -87,6 +77,7 @@ public class UsageService(AppDatabase db)
var fileQuery = db.Files
.Where(f => !f.IsMarkedRecycle)
.Where(f => f.ExpiredAt.HasValue && f.ExpiredAt > now)
.Where(f => f.AccountId == accountId)
.AsQueryable();
var usageBytes = await fileQuery
@@ -107,4 +98,24 @@ public class UsageService(AppDatabase db)
FileCount = fileCount
};
}
public async Task<long> GetTotalBillableUsage()
{
var now = SystemClock.Instance.GetCurrentInstant();
var files = await db.Files
.Where(f => f.PoolId.HasValue)
.Where(f => !f.IsMarkedRecycle)
.Include(f => f.Pool)
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
.Select(f => new
{
f.Size,
Multiplier = f.Pool!.BillingConfig.CostMultiplier ?? 1.0
})
.ToListAsync();
var totalCost = files.Sum(f => f.Size * f.Multiplier) / 1024.0 / 1024.0;
return (long)Math.Ceiling(totalCost);
}
}