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

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