Quota limit

This commit is contained in:
2026-03-11 00:19:12 +08:00
parent 4437068ac0
commit 432788b245
11 changed files with 347 additions and 3 deletions

View File

@@ -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);
}
}
}

View 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));
}
}

View 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();
}
}

View File

@@ -45,6 +45,7 @@ public static class ServiceCollectionExtensions
});
services.AddScoped<DeveloperService>();
services.AddScoped<DeveloperQuotaService>();
services.AddScoped<CustomAppService>();
services.AddScoped<DevProjectService>();
services.AddScoped<BotAccountService>();

View File

@@ -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.");

View 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()
};
}
}

View File

@@ -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>();

View 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;
}

View File

@@ -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");

View 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();
}
}

View File

@@ -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;
}
}
}
}