feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -25,6 +25,11 @@ public interface IAssetManagementService
AssetUploadConfirmCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<ContentAssetManagementItem>> ArchiveAssetAsync(
AssetManagementActor actor,
Guid assetId,
CancellationToken cancellationToken = default);
Task<AssetManagementSignedAccessResult> SignDownloadAsync(
AssetManagementActor actor,
AssetAccessSignCommand command,

View File

@@ -36,14 +36,16 @@ public sealed record BackofficeBootstrap(
public sealed record BackofficeUiBootstrap(
IReadOnlyCollection<string> PermissionCodes,
IReadOnlyCollection<BackofficeMenuItem> Menus);
IReadOnlyCollection<BackofficeMenuItem> Menus,
IReadOnlyCollection<string> EnabledFeatures,
IReadOnlyCollection<FeatureQuotaSnapshot> Quotas);
public sealed record BackofficePermissionItem(
Guid Id,
string Code,
string Name,
BackendPermissionArea Area,
string Module,
string PermissionModuleCode,
string? Description,
int SortOrder);

View File

@@ -58,12 +58,12 @@ public sealed record PlatformTenantDomainItem(
public sealed record PlatformTenantSubscriptionItem(
Guid Id,
Guid TenantId,
string PlanCode,
TenantSubscriptionStatus Status,
DateTimeOffset? StartsAt,
DateTimeOffset? ExpiresAt,
string? BillingCycle,
int AmountCents,
Guid BaseOfferingVersionId,
TenantSaasSubscriptionStatus Status,
DateTimeOffset StartsAt,
DateTimeOffset CurrentPeriodStart,
DateTimeOffset CurrentPeriodEnd,
bool CancelAtPeriodEnd,
JsonElement Metadata);
public sealed record TenantBillingProfileItem(
@@ -75,7 +75,7 @@ public sealed record TenantBillingProfileItem(
string? ContactEmail,
string? BillingAddress,
string? InvoiceTitle,
TenantInvoiceTitleType? InvoiceType,
TenantBillingInvoiceTitleType? InvoiceType,
string? BankName,
string? BankAccountMasked,
JsonElement Metadata);
@@ -86,7 +86,17 @@ public sealed record CreatePlatformTenantCommand(
string? LegalName,
TenantStatus Status,
BillingStatus BillingStatus,
JsonElement Metadata);
JsonElement Metadata,
string? OwnerEmail,
string? OwnerPhone,
string OwnerName,
string TemporaryPassword);
public sealed record PlatformTenantProvisioningResult(
PlatformTenantItem Tenant,
Guid OwnerUserId,
string OwnerIdentifier,
bool MustChangePassword);
public sealed record UpdatePlatformTenantStatusCommand(
Guid TenantId,
@@ -103,45 +113,11 @@ public sealed record UpsertPlatformTenantBillingProfileCommand(
string? ContactEmail,
string? BillingAddress,
string? InvoiceTitle,
TenantInvoiceTitleType? InvoiceType,
TenantBillingInvoiceTitleType? InvoiceType,
string? BankName,
string? BankAccountMasked,
JsonElement Metadata);
public sealed record PlatformPlanList(IReadOnlyCollection<PlatformSaasPlan> Items);
public sealed record PlatformPlanModuleEntitlements(
string PlanCode,
IReadOnlyCollection<string> ModuleCodes);
public sealed record ReplacePlatformPlanModulesCommand(
string PlanCode,
IReadOnlyCollection<string> ModuleCodes);
public sealed record PlatformTenantModuleOverrideItem(
Guid TenantId,
string ModuleCode,
TenantModuleOverrideMode Mode,
DateTimeOffset? ExpiresAt,
string? Reason);
public sealed record UpsertPlatformTenantModuleOverrideCommand(
Guid TenantId,
string ModuleCode,
TenantModuleOverrideMode Mode,
DateTimeOffset? ExpiresAt,
string Reason);
public sealed record UpsertPlatformSubscriptionCommand(
Guid TenantId,
string PlanCode,
TenantSubscriptionStatus Status,
DateTimeOffset? StartsAt,
DateTimeOffset? ExpiresAt,
string? BillingCycle,
int AmountCents,
JsonElement Metadata);
public sealed record PlatformDomainList(IReadOnlyCollection<PlatformTenantDomainItem> Items);
public sealed record PlatformDomainRecheckResult(Guid DomainId, TenantDomainStatus Status, DateTimeOffset LastCheckedAt);
@@ -189,15 +165,15 @@ public sealed record UpdatePlatformAuditAlertStatusCommand(
PlatformAuditAlertStatus Status,
string? ResolutionNote);
public sealed record PlatformDunningChannelList(IReadOnlyCollection<PlatformDunningChannelItem> Items);
public sealed record PlatformBillingDunningChannelList(IReadOnlyCollection<PlatformBillingDunningChannelItem> Items);
public sealed record PlatformDunningChannelItem(
public sealed record PlatformBillingDunningChannelItem(
Guid Id,
string ChannelCode,
string Name,
string? Description,
bool Enabled,
PlatformDunningProvider Provider,
PlatformBillingDunningProvider Provider,
string WebhookUrlMasked,
string? SecretRef,
IReadOnlyCollection<string> ReminderTypes,
@@ -209,13 +185,13 @@ public sealed record PlatformDunningChannelItem(
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record UpsertPlatformDunningChannelCommand(
public sealed record UpsertPlatformBillingDunningChannelCommand(
Guid? ChannelId,
string ChannelCode,
string Name,
string? Description,
bool Enabled,
PlatformDunningProvider Provider,
PlatformBillingDunningProvider Provider,
string WebhookUrl,
string? SecretRef,
IReadOnlyCollection<string> ReminderTypes,
@@ -225,18 +201,18 @@ public sealed record UpsertPlatformDunningChannelCommand(
int TimeoutSeconds,
JsonElement Metadata);
public sealed record DisablePlatformDunningChannelCommand(Guid ChannelId, string? Reason);
public sealed record DisablePlatformBillingDunningChannelCommand(Guid ChannelId, string? Reason);
public sealed record PlatformDunningEventList(IReadOnlyCollection<PlatformDunningEventItem> Items);
public sealed record PlatformBillingDunningEventList(IReadOnlyCollection<PlatformBillingDunningEventItem> Items);
public sealed record PlatformDunningEventItem(
public sealed record PlatformBillingDunningEventItem(
Guid Id,
Guid TenantId,
Guid ChannelId,
Guid ReminderId,
Guid InvoiceId,
PlatformDunningProvider Provider,
PlatformDunningNotificationStatus Status,
PlatformBillingDunningProvider Provider,
PlatformBillingDunningNotificationStatus Status,
int Attempts,
DateTimeOffset ScheduledAt,
DateTimeOffset? NextAttemptAt,
@@ -250,20 +226,16 @@ public sealed record PlatformDunningEventItem(
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record RetryPlatformDunningEventCommand(Guid EventId, string? Reason);
public sealed record RetryPlatformBillingDunningEventCommand(Guid EventId, string? Reason);
public interface IPlatformAdminService
{
Task<PlatformOverview> GetOverviewAsync(PlatformAdminActor actor, CancellationToken cancellationToken = default);
Task<PlatformTenantList> GetTenantsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformTenantDetail> GetTenantDetailAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default);
Task<PlatformTenantItem> CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
Task<PlatformTenantProvisioningResult> CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
Task<PlatformPlanList> GetPlansAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(PlatformAdminActor actor, ReplacePlatformPlanModulesCommand command, CancellationToken cancellationToken = default);
Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
Task<PlatformTenantModuleOverrideItem> UpsertTenantModuleOverrideAsync(PlatformAdminActor actor, UpsertPlatformTenantModuleOverrideCommand command, CancellationToken cancellationToken = default);
Task<PlatformDomainList> GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformDomainRecheckResult> RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
Task<PlatformStaffList> GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
@@ -272,12 +244,12 @@ public interface IPlatformAdminService
Task<PlatformAuditLogList> GetAuditLogsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformAuditAlertList> GetAuditAlertsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformAuditAlert> UpdateAuditAlertStatusAsync(PlatformAdminActor actor, UpdatePlatformAuditAlertStatusCommand command, CancellationToken cancellationToken = default);
Task<PlatformDunningChannelList> GetDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformDunningChannelItem> UpsertDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformDunningChannelCommand command, CancellationToken cancellationToken = default);
Task<PlatformDunningChannelItem> DisableDunningChannelAsync(PlatformAdminActor actor, DisablePlatformDunningChannelCommand command, CancellationToken cancellationToken = default);
Task<PlatformDunningEventList> GetDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformDunningEventItem> GetDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default);
Task<PlatformDunningEventItem> RetryDunningEventAsync(PlatformAdminActor actor, RetryPlatformDunningEventCommand command, CancellationToken cancellationToken = default);
Task<PlatformBillingDunningChannelList> GetBillingDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformBillingDunningChannelItem> UpsertBillingDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default);
Task<PlatformBillingDunningChannelItem> DisableBillingDunningChannelAsync(PlatformAdminActor actor, DisablePlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default);
Task<PlatformBillingDunningEventList> GetBillingDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformBillingDunningEventItem> GetBillingDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default);
Task<PlatformBillingDunningEventItem> RetryBillingDunningEventAsync(PlatformAdminActor actor, RetryPlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default);
}
public sealed class PlatformAdminException(string message, string code) : Exception(message)

View File

@@ -0,0 +1,242 @@
using System.Text.Json;
using Tiku.Application.Commerce;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
namespace Tiku.Application.PlatformBilling;
public sealed record SaasCatalogActor(Guid UserId);
public sealed record TenantBillingActor(Guid UserId, Guid TenantId);
public sealed record UpsertSaasFeatureCommand(
Guid? Id,
string Code,
string Name,
string Category,
string? Description,
int ReferencePriceCents,
string Currency,
SaasFeatureStatus Status,
int SortOrder);
public sealed record UpsertSaasOfferingCommand(
Guid? Id,
string Code,
string Name,
SaasOfferingType Type,
SaasOfferingStatus Status,
string? Description,
int SortOrder);
public sealed record UpsertSaasFeatureLimitCommand(
Guid? Id,
string MetricCode,
string FeatureCode,
string Name,
string Unit,
SaasFeatureLimitKind Kind,
int WarningPercent,
bool IsHardLimit);
public sealed record UpsertSaasOfferingVersionCommand(
Guid? Id,
Guid OfferingId,
PlatformBillingCycle BillingCycle,
int OriginalAmountCents,
int AmountCents,
string Currency,
DateTimeOffset? EffectiveAt,
IReadOnlyCollection<string> FeatureCodes,
IReadOnlyDictionary<string, long> Limits,
JsonElement Metadata);
public sealed record SaasOfferingVersionItem(
Guid Id,
Guid OfferingId,
string OfferingCode,
string OfferingName,
SaasOfferingType OfferingType,
int Version,
SaasOfferingVersionStatus Status,
PlatformBillingCycle BillingCycle,
int OriginalAmountCents,
int AmountCents,
string Currency,
DateTimeOffset? EffectiveAt,
DateTimeOffset? PublishedAt,
IReadOnlyCollection<string> FeatureCodes,
IReadOnlyDictionary<string, long> Limits);
public sealed record SaasCatalogSnapshot(
IReadOnlyCollection<SaasFeature> Features,
IReadOnlyCollection<SaasFeatureLimitDefinition> LimitDefinitions,
IReadOnlyCollection<SaasOffering> Offerings,
IReadOnlyCollection<SaasOfferingVersionItem> Versions);
public interface ISaasCatalogAdminService
{
Task<SaasCatalogSnapshot> GetCatalogAsync(SaasCatalogActor actor, CancellationToken cancellationToken = default);
Task<SaasFeature> UpsertFeatureAsync(SaasCatalogActor actor, UpsertSaasFeatureCommand command, CancellationToken cancellationToken = default);
Task<SaasFeatureLimitDefinition> UpsertLimitDefinitionAsync(SaasCatalogActor actor, UpsertSaasFeatureLimitCommand command, CancellationToken cancellationToken = default);
Task<SaasOffering> UpsertOfferingAsync(SaasCatalogActor actor, UpsertSaasOfferingCommand command, CancellationToken cancellationToken = default);
Task<SaasOfferingVersionItem> UpsertDraftVersionAsync(SaasCatalogActor actor, UpsertSaasOfferingVersionCommand command, CancellationToken cancellationToken = default);
Task<SaasOfferingVersionItem> PublishVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
Task<SaasOfferingVersionItem> CloneVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
Task<SaasOfferingVersionItem> RetireVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
}
public sealed record TenantBillingCatalog(
IReadOnlyCollection<SaasFeature> Features,
IReadOnlyCollection<SaasOfferingVersionItem> BasePlans,
IReadOnlyCollection<SaasOfferingVersionItem> AddOns);
public sealed record CreatePlatformBillingQuoteCommand(
Guid BaseOfferingVersionId,
IReadOnlyCollection<Guid> AddOnOfferingVersionIds,
PlatformBillingOrderPurpose Purpose,
string IdempotencyKey);
public sealed record PlatformBillingQuoteItemView(
Guid OfferingVersionId,
string OfferingCode,
string OfferingName,
PlatformBillingItemType ItemType,
int UnitAmountCents,
int AmountCents);
public sealed record PlatformBillingQuoteView(
Guid Id,
string QuoteNo,
PlatformBillingQuoteStatus Status,
PlatformBillingOrderPurpose Purpose,
int OriginalAmountCents,
int DiscountAmountCents,
int TotalAmountCents,
string Currency,
DateTimeOffset ExpiresAt,
IReadOnlyCollection<PlatformBillingQuoteItemView> Items,
IReadOnlyCollection<string> FeatureCodes,
IReadOnlyDictionary<string, long> Limits);
public sealed record CreatePlatformBillingOrderCommand(Guid QuoteId, string IdempotencyKey);
public sealed record CreatePlatformBillingPaymentCommand(
string OrderNo,
string Provider,
string Method,
string IdempotencyKey,
string? OpenId,
string? ReturnUrl,
string? QuitUrl);
public sealed record PlatformBillingOrderView(
Guid Id,
string OrderNo,
PlatformBillingOrderPurpose Purpose,
PlatformBillingOrderStatus Status,
int TotalAmountCents,
string Currency,
DateTimeOffset ExpiresAt,
DateTimeOffset? PaidAt,
IReadOnlyCollection<PlatformBillingQuoteItemView> Items);
public sealed record PlatformBillingPaymentView(
Guid Id,
string PaymentNo,
string Provider,
string Method,
PlatformBillingPaymentStatus Status,
int AmountCents,
JsonElement ClientPayload);
public sealed record TenantSubscriptionView(
Guid Id,
TenantSaasSubscriptionStatus Status,
DateTimeOffset StartsAt,
DateTimeOffset CurrentPeriodStart,
DateTimeOffset CurrentPeriodEnd,
bool CancelAtPeriodEnd,
Guid BaseOfferingVersionId,
Guid? ScheduledBaseOfferingVersionId,
IReadOnlyCollection<string> FeatureCodes);
public sealed record ChangeTenantSubscriptionCommand(Guid BaseOfferingVersionId, IReadOnlyCollection<Guid> AddOnOfferingVersionIds);
public interface ITenantBillingService
{
Task<TenantBillingCatalog> GetCatalogAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
Task<PlatformBillingQuoteView> CreateQuoteAsync(TenantBillingActor actor, CreatePlatformBillingQuoteCommand command, CancellationToken cancellationToken = default);
Task<PlatformBillingOrderView> CreateOrderAsync(TenantBillingActor actor, CreatePlatformBillingOrderCommand command, CancellationToken cancellationToken = default);
Task<PlatformBillingPaymentView> CreatePaymentAsync(TenantBillingActor actor, CreatePlatformBillingPaymentCommand command, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformBillingOrderView>> GetOrdersAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default);
Task<PlatformBillingOrderView> GetOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default);
Task<TenantSubscriptionView?> GetSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
Task<PlatformBillingOrderView> ChangeSubscriptionAsync(TenantBillingActor actor, ChangeTenantSubscriptionCommand command, string idempotencyKey, CancellationToken cancellationToken = default);
Task<PlatformBillingOrderView> RenewSubscriptionAsync(TenantBillingActor actor, string idempotencyKey, CancellationToken cancellationToken = default);
Task<TenantSubscriptionView> CancelSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetUsageAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default);
}
public sealed record PlatformBillingAdminQuery(Guid? TenantId, string? Status, int Limit = 100);
public sealed record ConfirmManualPaymentCommand(Guid PaymentId, string? ProviderTradeNo, DateTimeOffset? PaidAt, string Reason);
public sealed record UpsertTenantFeatureOverrideCommand(Guid TenantId, string FeatureCode, TenantFeatureOverrideMode Mode, DateTimeOffset? ExpiresAt, string Reason);
public interface IPlatformBillingAdminService
{
Task<IReadOnlyCollection<PlatformBillingOrder>> GetOrdersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformBillingPayment>> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
Task<PlatformBillingPayment> ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default);
Task<TenantFeatureOverride> UpsertFeatureOverrideAsync(SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default);
}
public interface IPlatformBillingPaymentGateway
{
Task<CreatePaymentProviderResult> CreatePaymentAsync(string provider, CreatePaymentProviderRequest request, CancellationToken cancellationToken = default);
Task<PaymentNotificationResult> ParseNotificationAsync(string provider, PaymentNotificationRequest request, CancellationToken cancellationToken = default);
}
public sealed record PlatformBillingNotification(
string Provider,
IReadOnlyDictionary<string, string> Headers,
string RawBody,
JsonElement Body);
public interface IPlatformBillingNotificationService
{
Task ProcessAsync(PlatformBillingNotification notification, CancellationToken cancellationToken = default);
}
public interface IPlatformBillingSettlementService
{
Task<PlatformBillingPayment> MarkPaidAsync(
Guid paymentId,
string providerEventId,
string eventType,
string? providerTradeNo,
DateTimeOffset paidAt,
JsonElement payload,
Guid? actorUserId,
CancellationToken cancellationToken = default);
}
public sealed class SaasSubscriptionLifecycleOptions
{
public bool Enabled { get; set; } = true;
public int BatchSize { get; set; } = 100;
public int PastDueGraceDays { get; set; } = 7;
}
public interface ISaasSubscriptionLifecycleService
{
Task<int> ProcessDueAsync(
DateTimeOffset? asOf = null,
CancellationToken cancellationToken = default);
}
public sealed class PlatformBillingException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -6,13 +6,19 @@ public static class BackendPermissions
public const string TenantStaffManage = "tenant:staff:manage";
public const string TenantRoleManage = "tenant:role:manage";
public const string TenantStudentManage = "tenant:student:manage";
public const string TenantContentManage = "tenant:content:manage";
public const string TenantContentManage = "tenant:question-bank:manage";
public const string TenantVocabularyManage = "tenant:vocabulary:manage";
public const string TenantHandbookManage = "tenant:handbook:manage";
public const string TenantVideoManage = "tenant:video:manage";
public const string TenantScorelineManage = "tenant:scoreline:manage";
public const string TenantSiteContentManage = "tenant:site-content:manage";
public const string TenantSettingsManage = "tenant:settings:manage";
public const string TenantProviderManage = "tenant:provider:manage";
public const string TenantCommerceOperate = "tenant:commerce:operate";
public const string TenantCrmManage = "tenant:crm:manage";
public const string TenantCommissionManage = "tenant:commission:manage";
public const string TenantJobManage = "tenant:job:manage";
public const string TenantBillingManage = "tenant:billing:manage";
public const string PlatformDashboardView = "platform:dashboard:view";
public const string PlatformTenantManage = "platform:tenant:manage";
@@ -21,6 +27,8 @@ public static class BackendPermissions
public const string PlatformQuestionBankManage = "platform:question-bank:manage";
public const string PlatformAuditView = "platform:audit:view";
public const string PlatformBillingNotification = "platform:billing:notification";
public const string PlatformSaasCatalogManage = "platform:saas-catalog:manage";
public const string PlatformSaasBillingManage = "platform:saas-billing:manage";
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
{
@@ -29,12 +37,18 @@ public static class BackendPermissions
TenantRoleManage,
TenantStudentManage,
TenantContentManage,
TenantVocabularyManage,
TenantHandbookManage,
TenantVideoManage,
TenantScorelineManage,
TenantSiteContentManage,
TenantSettingsManage,
TenantProviderManage,
TenantCommerceOperate,
TenantCrmManage,
TenantCommissionManage,
TenantJobManage
TenantJobManage,
TenantBillingManage
};
public static readonly IReadOnlySet<string> Platform = new HashSet<string>(StringComparer.Ordinal)
@@ -45,7 +59,9 @@ public static class BackendPermissions
PlatformRoleManage,
PlatformQuestionBankManage,
PlatformAuditView,
PlatformBillingNotification
PlatformBillingNotification,
PlatformSaasCatalogManage,
PlatformSaasBillingManage
};
public static void EnsureTenant(string permissionCode)

View File

@@ -0,0 +1,7 @@
namespace Tiku.Application.Security;
public enum CapabilityOperation
{
Read,
Write
}

View File

@@ -0,0 +1,26 @@
namespace Tiku.Application.Security;
public static class FeatureQuotaConsumptionExtensions
{
public static async Task<bool> ConsumeQuotaIfConfiguredAsync(
this IFeatureAccessService featureAccessService,
Guid tenantId,
string metricCode,
long amount = 1,
CancellationToken cancellationToken = default)
{
var configured = (await featureAccessService.GetQuotaSummaryAsync(tenantId, cancellationToken))
.Any(item => string.Equals(item.MetricCode, metricCode, StringComparison.Ordinal));
if (!configured)
{
return false;
}
if (!await featureAccessService.TryConsumeQuotaAsync(tenantId, metricCode, amount, cancellationToken))
{
throw new FeatureAccessException("The tenant feature quota has been exhausted.", "feature_quota_exhausted");
}
return true;
}
}

View File

@@ -1,21 +0,0 @@
namespace Tiku.Application.Security;
public enum CapabilityOperation
{
Read,
Write
}
public interface ICapabilityAccessEvaluator
{
Task<bool> IsAllowedAsync(
Guid tenantId,
string moduleCode,
CapabilityOperation operation,
CancellationToken cancellationToken = default);
Task<IReadOnlySet<string>> GetEnabledModulesAsync(
Guid tenantId,
CapabilityOperation operation = CapabilityOperation.Read,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,64 @@
namespace Tiku.Application.Security;
public enum FeatureAccessOperation
{
Read,
Write
}
public sealed record FeatureAccessDecision(
bool Allowed,
string? DenialCode,
string FeatureCode,
FeatureAccessOperation Operation);
public sealed record FeatureQuotaSnapshot(
string MetricCode,
long UsedValue,
long LimitValue,
int UsedPercent,
bool Warning,
bool Exceeded,
DateTimeOffset PeriodStart,
DateTimeOffset PeriodEnd);
public interface IFeatureAccessService
{
Task<FeatureAccessDecision> EvaluateAsync(
Guid tenantId,
string featureCode,
FeatureAccessOperation operation,
CancellationToken cancellationToken = default);
Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
Guid tenantId,
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default);
Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
Guid tenantId,
IEnumerable<string> permissionCodes,
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
Task<bool> TryConsumeQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default);
Task ReleaseQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default);
}
public sealed class FeatureAccessException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -0,0 +1,31 @@
namespace Tiku.Application.Security;
public sealed record ReconcileFeatureUsageRequest(
Guid TenantId,
SystemScopeCallerType CallerType,
string Caller,
string Reason,
string CorrelationId);
public sealed record ReconciledFeatureUsage(
string MetricCode,
long ActualValue,
long LimitValue,
bool Warning,
bool Exceeded);
public interface IFeatureUsageReconciliationService
{
Task<IReadOnlyCollection<ReconciledFeatureUsage>> ReconcileTenantAsync(
ReconcileFeatureUsageRequest request,
CancellationToken cancellationToken = default);
Task<int> ProcessDueAsync(CancellationToken cancellationToken = default);
}
public sealed class FeatureUsageReconciliationOptions
{
public bool Enabled { get; set; } = true;
public int BatchSize { get; set; } = 100;
public int IntervalMinutes { get; set; } = 60;
}

View File

@@ -1,23 +0,0 @@
namespace Tiku.Application.Security;
public static class ProductModuleCatalog
{
public static readonly IReadOnlyDictionary<string, string> All =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["dashboard"] = "Dashboard",
["staff"] = "Staff",
["role"] = "Roles",
["student"] = "Students",
["content"] = "Content",
["settings"] = "Settings",
["provider"] = "Providers",
["commerce"] = "Commerce",
["crm"] = "CRM",
["commission"] = "Commission",
["job"] = "Background Jobs"
};
public static bool Contains(string moduleCode) =>
All.ContainsKey(moduleCode.Trim().ToLowerInvariant());
}

View File

@@ -0,0 +1,138 @@
namespace Tiku.Application.Security;
public static class SaasFeatureCatalog
{
public const string CoreBackoffice = "core.backoffice";
public const string PrivateQuestionBank = "question_bank.private";
public const string Practice = "learning.practice";
public const string Assignment = "learning.assignment";
public const string Exam = "learning.exam";
public const string Vocabulary = "content.vocabulary";
public const string Handbook = "content.handbook";
public const string Video = "content.video";
public const string Scoreline = "content.scoreline";
public const string SiteContent = "marketing.site_content";
public const string StudentManagement = "student.management";
public const string StudentStore = "commerce.student_store";
public const string Crm = "crm.followup";
public const string ReferralCommission = "growth.referral_commission";
public const string TeacherAi = "ai.teacher_assistant";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.Ordinal)
{
CoreBackoffice,
PrivateQuestionBank,
Practice,
Assignment,
Exam,
Vocabulary,
Handbook,
Video,
Scoreline,
SiteContent,
StudentManagement,
StudentStore,
Crm,
ReferralCommission,
TeacherAi
};
public static string? ResolveContentImportFeature(string? importType)
{
var normalized = importType?.Trim().ToLowerInvariant().Replace('-', '_');
return normalized switch
{
"question" or "questions" or "question_bank" or "question_banks" => PrivateQuestionBank,
"vocabulary" or "vocabulary_unit" or "vocabulary_units" or "vocabulary_word" or "vocabulary_words" => Vocabulary,
"handbook" or "handbook_subject" or "handbook_subjects" or "handbook_chapter" or "handbook_chapters" or
"handbook_entry" or "handbook_entries" => Handbook,
"scoreline" or "scorelines" => Scoreline,
"video" or "videos" => Video,
_ => null
};
}
}
public static class PermissionModuleCatalog
{
public static readonly IReadOnlyDictionary<string, string?> RequiredFeatures =
new Dictionary<string, string?>(StringComparer.Ordinal)
{
["tenant_dashboard"] = null,
["tenant_staff"] = null,
["tenant_settings"] = null,
["tenant_provider"] = null,
["tenant_job"] = null,
["tenant_billing"] = null,
["tenant_student"] = SaasFeatureCatalog.StudentManagement,
["tenant_question_bank"] = SaasFeatureCatalog.PrivateQuestionBank,
["tenant_vocabulary"] = SaasFeatureCatalog.Vocabulary,
["tenant_handbook"] = SaasFeatureCatalog.Handbook,
["tenant_video"] = SaasFeatureCatalog.Video,
["tenant_scoreline"] = SaasFeatureCatalog.Scoreline,
["tenant_site_content"] = SaasFeatureCatalog.SiteContent,
["tenant_commerce"] = SaasFeatureCatalog.StudentStore,
["tenant_crm"] = SaasFeatureCatalog.Crm,
["tenant_commission"] = SaasFeatureCatalog.ReferralCommission,
["platform_dashboard"] = null,
["platform_tenant"] = null,
["platform_staff"] = null,
["platform_content"] = null,
["platform_audit"] = null,
["platform_billing"] = null,
["commerce"] = SaasFeatureCatalog.StudentStore
};
public static string ResolvePermissionModuleCode(string permissionCode) => permissionCode switch
{
BackendPermissions.TenantDashboardView => "tenant_dashboard",
BackendPermissions.TenantStaffManage or BackendPermissions.TenantRoleManage => "tenant_staff",
BackendPermissions.TenantStudentManage => "tenant_student",
BackendPermissions.TenantContentManage => "tenant_question_bank",
BackendPermissions.TenantVocabularyManage => "tenant_vocabulary",
BackendPermissions.TenantHandbookManage => "tenant_handbook",
BackendPermissions.TenantVideoManage => "tenant_video",
BackendPermissions.TenantScorelineManage => "tenant_scoreline",
BackendPermissions.TenantSiteContentManage => "tenant_site_content",
BackendPermissions.TenantSettingsManage => "tenant_settings",
BackendPermissions.TenantProviderManage => "tenant_provider",
BackendPermissions.TenantCommerceOperate => "tenant_commerce",
BackendPermissions.TenantCrmManage => "tenant_crm",
BackendPermissions.TenantCommissionManage => "tenant_commission",
BackendPermissions.TenantJobManage => "tenant_job",
BackendPermissions.TenantBillingManage => "tenant_billing",
BackendPermissions.PlatformDashboardView => "platform_dashboard",
BackendPermissions.PlatformTenantManage => "platform_tenant",
BackendPermissions.PlatformStaffManage or BackendPermissions.PlatformRoleManage => "platform_staff",
BackendPermissions.PlatformQuestionBankManage => "platform_content",
BackendPermissions.PlatformAuditView => "platform_audit",
BackendPermissions.PlatformBillingNotification => "platform_billing",
BackendPermissions.PlatformSaasCatalogManage or BackendPermissions.PlatformSaasBillingManage => "platform_billing",
_ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce",
_ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Permission module mapping is missing.")
};
}
public static class SaasQuotaMetricCatalog
{
public const string StaffCount = "staff.count";
public const string StudentCount = "student.count";
public const string PrivateQuestionCount = "private_question.count";
public const string StorageBytes = "storage.bytes";
public const string ImportCount = "import.count";
public const string ExportCount = "export.count";
public const string SmsCount = "sms.count";
public const string AiCallCount = "ai.call.count";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.Ordinal)
{
StaffCount,
StudentCount,
PrivateQuestionCount,
StorageBytes,
ImportCount,
ExportCount,
SmsCount,
AiCallCount
};
}

View File

@@ -7,6 +7,7 @@ public static class TikuPolicies
public const string TenantBackofficeBootstrap = "tenant_backoffice_bootstrap";
public const string PlatformBackofficeBootstrap = "platform_backoffice_bootstrap";
public const string TenantContentManageAllScope = "tenant:content:manage:all_scope";
public const string TenantAllDataScope = "tenant:data-scope:all";
public const string TenantCommerceOperateAllScope = "tenant:commerce:operate:all_scope";
public const string TenantAdmin = "tenant_admin";

View File

@@ -25,7 +25,9 @@ public sealed record TenantRuntimeBootstrap(
JsonElement Theme,
JsonElement Features,
JsonElement Navigation,
JsonElement HomeModules);
JsonElement HomeModules,
IReadOnlyCollection<string> EnabledFeatures,
IReadOnlyCollection<string> LoginMethods);
public interface ITenantFrontendConfigService
{

View File

@@ -0,0 +1,19 @@
namespace Tiku.Application.Tenancy;
public sealed record TenantOnboardingStep(
string Code,
bool Required,
bool Completed,
string? Detail);
public sealed record TenantOnboardingStatus(
Guid TenantId,
bool ReadyForStudentTraffic,
int CompletedRequiredSteps,
int RequiredSteps,
IReadOnlyCollection<TenantOnboardingStep> Steps);
public interface ITenantOnboardingService
{
Task<TenantOnboardingStatus> GetStatusAsync(Guid tenantId, CancellationToken cancellationToken = default);
}