✨ Quota and better drive dashboard
This commit is contained in:
28
DysonNetwork.Drive/Billing/Quota.cs
Normal file
28
DysonNetwork.Drive/Billing/Quota.cs
Normal 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; }
|
||||
}
|
66
DysonNetwork.Drive/Billing/QuotaController.cs
Normal file
66
DysonNetwork.Drive/Billing/QuotaController.cs
Normal 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);
|
||||
}
|
||||
}
|
69
DysonNetwork.Drive/Billing/QuotaService.cs
Normal file
69
DysonNetwork.Drive/Billing/QuotaService.cs
Normal 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);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user