✨ Quota limit
This commit is contained in:
@@ -16,6 +16,7 @@ namespace DysonNetwork.Develop.Identity;
|
||||
[Authorize]
|
||||
public class BotAccountController(
|
||||
BotAccountService botService,
|
||||
DeveloperQuotaService quotaService,
|
||||
DeveloperService ds,
|
||||
DevProjectService projectService,
|
||||
ILogger<BotAccountController> logger,
|
||||
@@ -149,6 +150,13 @@ public class BotAccountController(
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var hydratedAccount = SnAccount.FromProtoValue(
|
||||
await remoteAccounts.GetAccount(Guid.Parse(currentUser.Id))
|
||||
);
|
||||
var quota = await quotaService.GetQuotaAsync(hydratedAccount);
|
||||
if (quota.Used >= quota.Total)
|
||||
return StatusCode(403, $"Bot quota exceeded ({quota.Used}/{quota.Total}).");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var accountId = Guid.NewGuid();
|
||||
var account = new DyAccount
|
||||
@@ -462,4 +470,4 @@ public class BotAccountController(
|
||||
|
||||
return (developer, project, bot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
DysonNetwork.Develop/Identity/DevelopQuotaController.cs
Normal file
26
DysonNetwork.Develop/Identity/DevelopQuotaController.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/develop/quota")]
|
||||
[Authorize]
|
||||
public class DevelopQuotaController(
|
||||
DeveloperQuotaService quotaService,
|
||||
RemoteAccountService remoteAccounts
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ResourceQuotaResponse<DeveloperBotQuotaRecord>>> GetQuota()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not DyAccount currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var account = SnAccount.FromProtoValue(await remoteAccounts.GetAccount(Guid.Parse(currentUser.Id)));
|
||||
return Ok(await quotaService.GetQuotaAsync(account));
|
||||
}
|
||||
}
|
||||
91
DysonNetwork.Develop/Identity/DeveloperQuotaService.cs
Normal file
91
DysonNetwork.Develop/Identity/DeveloperQuotaService.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class DeveloperQuotaService(
|
||||
AppDatabase db,
|
||||
RemotePublisherService remotePublisherService,
|
||||
RemoteRealmService remoteRealmService
|
||||
)
|
||||
{
|
||||
public async Task<ResourceQuotaResponse<DeveloperBotQuotaRecord>> GetQuotaAsync(SnAccount account)
|
||||
{
|
||||
var records = await GetOwnedBotRecordsAsync(account.Id);
|
||||
var level = account.Profile?.Level ?? 0;
|
||||
var total = ResourceQuotaCalculator.GetTieredQuota(level, account.PerkLevel);
|
||||
|
||||
return new ResourceQuotaResponse<DeveloperBotQuotaRecord>
|
||||
{
|
||||
Total = total,
|
||||
Used = records.Count,
|
||||
Remaining = Math.Max(0, total - records.Count),
|
||||
Level = level,
|
||||
PerkLevel = account.PerkLevel,
|
||||
Records = records
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<DeveloperBotQuotaRecord>> GetOwnedBotRecordsAsync(Guid accountId)
|
||||
{
|
||||
var developers = await db.Developers.ToListAsync();
|
||||
if (developers.Count == 0)
|
||||
return [];
|
||||
|
||||
var publishers = await remotePublisherService.GetPublishersBatch(
|
||||
developers.Select(d => d.PublisherId.ToString()).Distinct().ToList()
|
||||
);
|
||||
var publisherMap = publishers.ToDictionary(p => p.Id);
|
||||
|
||||
var orgRealmIds = publishers
|
||||
.Where(p => p.AccountId == null && p.RealmId.HasValue)
|
||||
.Select(p => p.RealmId!.Value.ToString())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var ownedRealmIds = orgRealmIds.Count == 0
|
||||
? new HashSet<Guid>()
|
||||
: (await remoteRealmService.GetRealmBatch(orgRealmIds))
|
||||
.Where(r => r.AccountId == accountId)
|
||||
.Select(r => r.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var ownedDeveloperIds = developers
|
||||
.Where(d => publisherMap.TryGetValue(d.PublisherId, out var publisher)
|
||||
&& (
|
||||
publisher.AccountId == accountId
|
||||
|| (publisher.RealmId.HasValue && ownedRealmIds.Contains(publisher.RealmId.Value))
|
||||
)
|
||||
)
|
||||
.Select(d => d.Id)
|
||||
.ToHashSet();
|
||||
|
||||
if (ownedDeveloperIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var bots = await db.BotAccounts
|
||||
.Include(b => b.Project)
|
||||
.Where(b => ownedDeveloperIds.Contains(b.Project.DeveloperId))
|
||||
.OrderBy(b => b.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var developerNames = developers
|
||||
.Where(d => ownedDeveloperIds.Contains(d.Id))
|
||||
.ToDictionary(
|
||||
d => d.Id,
|
||||
d => publisherMap.TryGetValue(d.PublisherId, out var publisher) ? publisher.Name : string.Empty
|
||||
);
|
||||
|
||||
return bots
|
||||
.Select(b => new DeveloperBotQuotaRecord
|
||||
{
|
||||
BotId = b.Id,
|
||||
Slug = b.Slug,
|
||||
ProjectId = b.ProjectId,
|
||||
ProjectName = b.Project.Name,
|
||||
DeveloperId = b.Project.DeveloperId,
|
||||
DeveloperName = developerNames.GetValueOrDefault(b.Project.DeveloperId, string.Empty)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public static class ServiceCollectionExtensions
|
||||
});
|
||||
|
||||
services.AddScoped<DeveloperService>();
|
||||
services.AddScoped<DeveloperQuotaService>();
|
||||
services.AddScoped<CustomAppService>();
|
||||
services.AddScoped<DevProjectService>();
|
||||
services.AddScoped<BotAccountService>();
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace DysonNetwork.Passport.Realm;
|
||||
public class RealmController(
|
||||
AppDatabase db,
|
||||
RealmService rs,
|
||||
RealmQuotaService quotaService,
|
||||
AccountService accounts,
|
||||
DyFileService.DyFileServiceClient files,
|
||||
ActionLogService als,
|
||||
@@ -23,6 +24,19 @@ public class RealmController(
|
||||
AccountEventService accountEvents
|
||||
) : Controller
|
||||
{
|
||||
[HttpGet("quota")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<ResourceQuotaResponse<RealmQuotaRecord>>> GetQuota()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var account = await accounts.GetAccount(currentUser.Id);
|
||||
if (account is null) return Unauthorized();
|
||||
if (account.PerkLevel == 0 && currentUser.PerkLevel > 0) account.PerkLevel = currentUser.PerkLevel;
|
||||
|
||||
return Ok(await quotaService.GetQuotaAsync(account));
|
||||
}
|
||||
|
||||
[HttpGet("{slug}")]
|
||||
public async Task<ActionResult<SnRealm>> GetRealm(string slug)
|
||||
{
|
||||
@@ -360,6 +374,14 @@ public class RealmController(
|
||||
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name.");
|
||||
if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug.");
|
||||
|
||||
var account = await accounts.GetAccount(currentUser.Id);
|
||||
if (account is null) return Unauthorized();
|
||||
if (account.PerkLevel == 0 && currentUser.PerkLevel > 0) account.PerkLevel = currentUser.PerkLevel;
|
||||
|
||||
var quota = await quotaService.GetQuotaAsync(account);
|
||||
if (quota.Used >= quota.Total)
|
||||
return StatusCode(403, $"Realm quota exceeded ({quota.Used}/{quota.Total}).");
|
||||
|
||||
var slugExists = await db.Realms.AnyAsync(r => r.Slug == request.Slug);
|
||||
if (slugExists) return BadRequest("Realm with this slug already exists.");
|
||||
|
||||
|
||||
35
DysonNetwork.Passport/Realm/RealmQuotaService.cs
Normal file
35
DysonNetwork.Passport/Realm/RealmQuotaService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Passport.Realm;
|
||||
|
||||
public class RealmQuotaService(AppDatabase db)
|
||||
{
|
||||
public async Task<ResourceQuotaResponse<RealmQuotaRecord>> GetQuotaAsync(SnAccount account)
|
||||
{
|
||||
var ownedRealms = await db.Realms
|
||||
.Where(r => r.AccountId == account.Id)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var level = account.Profile?.Level ?? 0;
|
||||
var total = ResourceQuotaCalculator.GetTieredQuota(level, account.PerkLevel);
|
||||
|
||||
return new ResourceQuotaResponse<RealmQuotaRecord>
|
||||
{
|
||||
Total = total,
|
||||
Used = ownedRealms.Count,
|
||||
Remaining = Math.Max(0, total - ownedRealms.Count),
|
||||
Level = level,
|
||||
PerkLevel = account.PerkLevel,
|
||||
Records = ownedRealms
|
||||
.Select(r => new RealmQuotaRecord
|
||||
{
|
||||
Id = r.Id,
|
||||
Slug = r.Slug,
|
||||
Name = r.Name
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<SocialCreditService>();
|
||||
services.AddScoped<ExperienceService>();
|
||||
services.AddScoped<RealmService>();
|
||||
services.AddScoped<RealmQuotaService>();
|
||||
services.AddScoped<AffiliationSpellService>();
|
||||
|
||||
services.AddScoped<SpotifyPresenceService>();
|
||||
|
||||
59
DysonNetwork.Shared/Models/ResourceQuota.cs
Normal file
59
DysonNetwork.Shared/Models/ResourceQuota.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace DysonNetwork.Shared.Models;
|
||||
|
||||
public static class ResourceQuotaCalculator
|
||||
{
|
||||
public static int GetPublisherQuota(int level, int perkLevel)
|
||||
{
|
||||
var baseQuota = level >= 30 ? 3 : 2;
|
||||
return baseQuota + (2 * perkLevel);
|
||||
}
|
||||
|
||||
public static int GetTieredQuota(int level, int perkLevel)
|
||||
{
|
||||
var baseQuota = level switch
|
||||
{
|
||||
>= 90 => 3,
|
||||
>= 60 => 2,
|
||||
>= 30 => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
return baseQuota + perkLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public class ResourceQuotaResponse<TRecord>
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public int Used { get; set; }
|
||||
public int Remaining { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int PerkLevel { get; set; }
|
||||
public List<TRecord> Records { get; set; } = [];
|
||||
}
|
||||
|
||||
public class PublisherQuotaRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Nick { get; set; } = string.Empty;
|
||||
public PublisherType Type { get; set; }
|
||||
public Guid? RealmId { get; set; }
|
||||
}
|
||||
|
||||
public class RealmQuotaRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class DeveloperBotQuotaRecord
|
||||
{
|
||||
public Guid BotId { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public Guid ProjectId { get; set; }
|
||||
public string ProjectName { get; set; } = string.Empty;
|
||||
public Guid DeveloperId { get; set; }
|
||||
public string DeveloperName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -18,13 +18,26 @@ namespace DysonNetwork.Sphere.Publisher;
|
||||
public class PublisherController(
|
||||
AppDatabase db,
|
||||
PublisherService ps,
|
||||
PublisherQuotaService quotaService,
|
||||
DyAccountService.DyAccountServiceClient accounts,
|
||||
DyFileService.DyFileServiceClient files,
|
||||
RemoteActionLogService als,
|
||||
RemoteRealmService remoteRealmService,
|
||||
IServiceScopeFactory factory
|
||||
IServiceScopeFactory factory,
|
||||
RemoteAccountService remoteAccounts
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("quota")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<ResourceQuotaResponse<PublisherQuotaRecord>>> GetQuota()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not DyAccount currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var account = SnAccount.FromProtoValue(await remoteAccounts.GetAccount(Guid.Parse(currentUser.Id)));
|
||||
return Ok(await quotaService.GetQuotaAsync(account));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnPublisher>>> ListManagedPublishers()
|
||||
@@ -308,6 +321,13 @@ public class PublisherController(
|
||||
if (HttpContext.Items["CurrentUser"] is not DyAccount currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var hydratedAccount = SnAccount.FromProtoValue(
|
||||
await remoteAccounts.GetAccount(Guid.Parse(currentUser.Id))
|
||||
);
|
||||
var quota = await quotaService.GetQuotaAsync(hydratedAccount);
|
||||
if (quota.Used >= quota.Total)
|
||||
return StatusCode(403, $"Publisher quota exceeded ({quota.Used}/{quota.Total}).");
|
||||
|
||||
var takenName = request.Name ?? currentUser.Name;
|
||||
var duplicateNameCount = await db.Publishers.Where(p => p.Name == takenName).CountAsync();
|
||||
if (duplicateNameCount > 0)
|
||||
@@ -384,6 +404,13 @@ public class PublisherController(
|
||||
if (HttpContext.Items["CurrentUser"] is not DyAccount currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var hydratedAccount = SnAccount.FromProtoValue(
|
||||
await remoteAccounts.GetAccount(Guid.Parse(currentUser.Id))
|
||||
);
|
||||
var quota = await quotaService.GetQuotaAsync(hydratedAccount);
|
||||
if (quota.Used >= quota.Total)
|
||||
return StatusCode(403, $"Publisher quota exceeded ({quota.Used}/{quota.Total}).");
|
||||
|
||||
var realm = await remoteRealmService.GetRealmBySlug(realmSlug);
|
||||
if (realm == null)
|
||||
return NotFound("Realm not found");
|
||||
|
||||
73
DysonNetwork.Sphere/Publisher/PublisherQuotaService.cs
Normal file
73
DysonNetwork.Sphere/Publisher/PublisherQuotaService.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Sphere.Publisher;
|
||||
|
||||
public class PublisherQuotaService(AppDatabase db, RemoteRealmService remoteRealmService)
|
||||
{
|
||||
public async Task<ResourceQuotaResponse<PublisherQuotaRecord>> GetQuotaAsync(SnAccount account)
|
||||
{
|
||||
var ownedPublishers = await GetOwnedPublishersAsync(account.Id);
|
||||
var level = account.Profile?.Level ?? 0;
|
||||
var total = ResourceQuotaCalculator.GetPublisherQuota(level, account.PerkLevel);
|
||||
|
||||
return new ResourceQuotaResponse<PublisherQuotaRecord>
|
||||
{
|
||||
Total = total,
|
||||
Used = ownedPublishers.Count,
|
||||
Remaining = Math.Max(0, total - ownedPublishers.Count),
|
||||
Level = level,
|
||||
PerkLevel = account.PerkLevel,
|
||||
Records = ownedPublishers
|
||||
.Select(p => new PublisherQuotaRecord
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Nick = p.Nick,
|
||||
Type = p.Type,
|
||||
RealmId = p.RealmId
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> HasCapacityAsync(SnAccount account)
|
||||
{
|
||||
var quota = await GetQuotaAsync(account);
|
||||
return quota.Used < quota.Total;
|
||||
}
|
||||
|
||||
private async Task<List<SnPublisher>> GetOwnedPublishersAsync(Guid accountId)
|
||||
{
|
||||
var directPublishers = await db.Publishers
|
||||
.Where(p => p.AccountId == accountId)
|
||||
.ToListAsync();
|
||||
|
||||
var organizationalPublishers = await db.Publishers
|
||||
.Where(p => p.AccountId == null && p.RealmId != null)
|
||||
.ToListAsync();
|
||||
|
||||
if (organizationalPublishers.Count == 0)
|
||||
return directPublishers;
|
||||
|
||||
var realmIds = organizationalPublishers
|
||||
.Where(p => p.RealmId.HasValue)
|
||||
.Select(p => p.RealmId!.Value.ToString())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var ownedRealmIds = (await remoteRealmService.GetRealmBatch(realmIds))
|
||||
.Where(r => r.AccountId == accountId)
|
||||
.Select(r => r.Id)
|
||||
.ToHashSet();
|
||||
|
||||
directPublishers.AddRange(
|
||||
organizationalPublishers.Where(p => p.RealmId.HasValue && ownedRealmIds.Contains(p.RealmId.Value))
|
||||
);
|
||||
|
||||
return directPublishers
|
||||
.OrderBy(p => p.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,7 @@ public static class ServiceCollectionExtensions
|
||||
services.Configure<ActivityPubDeliveryOptions>(configuration.GetSection("ActivityPubDelivery"));
|
||||
services.AddScoped<GeoService>();
|
||||
services.AddScoped<PublisherService>();
|
||||
services.AddScoped<PublisherQuotaService>();
|
||||
services.AddScoped<PublisherSubscriptionService>();
|
||||
services.AddScoped<TimelineService>();
|
||||
services.AddScoped<PostService>();
|
||||
@@ -232,4 +233,4 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user