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

@@ -4,6 +4,7 @@ using Tiku.Application.Backoffice;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -12,27 +13,82 @@ namespace Tiku.Infrastructure.Backoffice;
internal sealed class BackofficeService(
TikuDbContext dbContext,
IOperationAuditService auditService,
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackofficeService
IFeatureAccessService featureAccessService) : IBackofficeService
{
private static readonly BuiltinFeature[] BuiltinFeatures =
[
new(SaasFeatureCatalog.CoreBackoffice, "后台基础能力", "core", true, 0),
new(SaasFeatureCatalog.PrivateQuestionBank, "私有题库", "question_bank", false, 10),
new(SaasFeatureCatalog.Practice, "题库练习", "learning", false, 20),
new(SaasFeatureCatalog.Assignment, "作业", "learning", false, 30),
new(SaasFeatureCatalog.Exam, "考试", "learning", false, 40),
new(SaasFeatureCatalog.Vocabulary, "词汇", "content", false, 50),
new(SaasFeatureCatalog.Handbook, "知识手册", "content", false, 60),
new(SaasFeatureCatalog.Video, "视频", "content", false, 70),
new(SaasFeatureCatalog.Scoreline, "分数线", "content", false, 80),
new(SaasFeatureCatalog.SiteContent, "站点运营内容", "marketing", false, 90),
new(SaasFeatureCatalog.StudentManagement, "学生管理", "student", false, 100),
new(SaasFeatureCatalog.StudentStore, "学生商城", "commerce", false, 110),
new(SaasFeatureCatalog.Crm, "学生跟进", "crm", false, 120),
new(SaasFeatureCatalog.ReferralCommission, "推广与分佣", "growth", false, 130),
new(SaasFeatureCatalog.TeacherAi, "教师 AI 助手", "ai", false, 140)
];
private static readonly BuiltinPermissionModule[] BuiltinPermissionModules =
[
new("tenant_dashboard", "租户总览", BackendPermissionArea.Tenant, null, 10),
new("tenant_staff", "员工与角色", BackendPermissionArea.Tenant, null, 20),
new("tenant_settings", "租户设置", BackendPermissionArea.Tenant, null, 30),
new("tenant_provider", "外部服务", BackendPermissionArea.Tenant, null, 40),
new("tenant_job", "后台任务", BackendPermissionArea.Tenant, null, 50),
new("tenant_billing", "SaaS 账务", BackendPermissionArea.Tenant, null, 60),
new("tenant_student", "学生管理", BackendPermissionArea.Tenant, SaasFeatureCatalog.StudentManagement, 100),
new("tenant_question_bank", "私有题库", BackendPermissionArea.Tenant, SaasFeatureCatalog.PrivateQuestionBank, 110),
new("tenant_vocabulary", "词汇", BackendPermissionArea.Tenant, SaasFeatureCatalog.Vocabulary, 120),
new("tenant_handbook", "知识手册", BackendPermissionArea.Tenant, SaasFeatureCatalog.Handbook, 130),
new("tenant_video", "视频", BackendPermissionArea.Tenant, SaasFeatureCatalog.Video, 140),
new("tenant_scoreline", "分数线", BackendPermissionArea.Tenant, SaasFeatureCatalog.Scoreline, 150),
new("tenant_site_content", "站点运营内容", BackendPermissionArea.Tenant, SaasFeatureCatalog.SiteContent, 160),
new("tenant_commerce", "学生商城", BackendPermissionArea.Tenant, SaasFeatureCatalog.StudentStore, 170),
new("tenant_crm", "学生跟进", BackendPermissionArea.Tenant, SaasFeatureCatalog.Crm, 180),
new("tenant_commission", "推广分佣", BackendPermissionArea.Tenant, SaasFeatureCatalog.ReferralCommission, 190),
new("platform_dashboard", "平台总览", BackendPermissionArea.Platform, null, 200),
new("platform_tenant", "平台租户", BackendPermissionArea.Platform, null, 210),
new("platform_staff", "平台员工", BackendPermissionArea.Platform, null, 220),
new("platform_content", "公共题库", BackendPermissionArea.Platform, null, 230),
new("platform_audit", "平台审计", BackendPermissionArea.Platform, null, 240),
new("platform_billing", "平台 SaaS 商城", BackendPermissionArea.Platform, null, 250),
new("commerce", "交易运营", BackendPermissionArea.Both, SaasFeatureCatalog.StudentStore, 300)
];
private static readonly BuiltinPermission[] BuiltinPermissions =
[
new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
new(BackendPermissions.TenantContentManage, "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
new(BackendPermissions.TenantContentManage, "租户题库管理", BackendPermissionArea.Tenant, "tenant_question_bank"),
new(BackendPermissions.TenantVocabularyManage, "租户词汇管理", BackendPermissionArea.Tenant, "tenant_vocabulary"),
new(BackendPermissions.TenantHandbookManage, "租户手册管理", BackendPermissionArea.Tenant, "tenant_handbook"),
new(BackendPermissions.TenantVideoManage, "租户视频管理", BackendPermissionArea.Tenant, "tenant_video"),
new(BackendPermissions.TenantScorelineManage, "租户分数线管理", BackendPermissionArea.Tenant, "tenant_scoreline"),
new(BackendPermissions.TenantSiteContentManage, "租户运营内容管理", BackendPermissionArea.Tenant, "tenant_site_content"),
new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"),
new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"),
new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"),
new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"),
new(BackendPermissions.TenantBillingManage, "租户 SaaS 账务", BackendPermissionArea.Tenant, "tenant_billing"),
new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, "platform_billing"),
new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, "platform_billing"),
new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, "platform_billing"),
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
@@ -43,14 +99,21 @@ internal sealed class BackofficeService(
new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10),
new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20),
new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30),
new("tenant.content", null, "内容管理", BackendPermissionArea.Tenant, "/tenant/content", "tenant:content:manage", 40),
new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", "tenant:provider:manage", 50),
new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", "tenant:commerce:operate", 60),
new("tenant.question-bank", null, "私有题库", BackendPermissionArea.Tenant, "/tenant/question-bank", BackendPermissions.TenantContentManage, 40),
new("tenant.vocabulary", null, "词汇", BackendPermissionArea.Tenant, "/tenant/vocabulary", BackendPermissions.TenantVocabularyManage, 50),
new("tenant.handbook", null, "知识手册", BackendPermissionArea.Tenant, "/tenant/handbook", BackendPermissions.TenantHandbookManage, 60),
new("tenant.video", null, "视频", BackendPermissionArea.Tenant, "/tenant/video", BackendPermissions.TenantVideoManage, 70),
new("tenant.scoreline", null, "分数线", BackendPermissionArea.Tenant, "/tenant/scoreline", BackendPermissions.TenantScorelineManage, 80),
new("tenant.site-content", null, "运营内容", BackendPermissionArea.Tenant, "/tenant/site-content", BackendPermissions.TenantSiteContentManage, 90),
new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", BackendPermissions.TenantProviderManage, 100),
new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", BackendPermissions.TenantCommerceOperate, 110),
new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing", BackendPermissions.TenantBillingManage, 120),
new("platform.dashboard", null, "平台总览", BackendPermissionArea.Platform, "/platform/dashboard", "platform:dashboard:view", 10),
new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/platform/tenants", "platform:tenant:manage", 20),
new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30),
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40),
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50)
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50),
new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60)
];
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
@@ -73,7 +136,10 @@ internal sealed class BackofficeService(
BackendPermissionArea.Tenant,
permissionCodes,
cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus);
var features = await featureAccessService.GetEnabledFeaturesAsync(
access.TenantId.Value, FeatureAccessOperation.Read, cancellationToken);
var quotas = await featureAccessService.GetQuotaSummaryAsync(access.TenantId.Value, cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus, features.Order(StringComparer.Ordinal).ToArray(), quotas);
}
public async Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
@@ -91,7 +157,7 @@ internal sealed class BackofficeService(
BackendPermissionArea.Platform,
permissionCodes,
cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus);
return new BackofficeUiBootstrap(permissionCodes, menus, [], []);
}
public async Task<BackofficeBootstrap> GetTenantBootstrapAsync(
@@ -103,7 +169,7 @@ internal sealed class BackofficeService(
var roles = await LoadTenantRolesAsync(tenantId, cancellationToken);
var permissions = await dbContext.BackendPermissions.AsNoTracking()
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
.OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var enabledPermissionCodes = await FilterTenantPermissionCodesAsync(
tenantId,
@@ -132,7 +198,7 @@ internal sealed class BackofficeService(
var roles = await LoadPlatformRolesAsync(cancellationToken);
var permissions = await dbContext.BackendPermissions.AsNoTracking()
.Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both)
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
.OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var menus = await dbContext.BackendMenus.AsNoTracking()
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Platform)
@@ -313,6 +379,39 @@ internal sealed class BackofficeService(
private async Task EnsureCatalogAsync(CancellationToken cancellationToken)
{
foreach (var feature in BuiltinFeatures)
{
if (!await dbContext.SaasFeatures.AnyAsync(item => item.Code == feature.Code, cancellationToken))
{
dbContext.SaasFeatures.Add(new SaasFeature
{
Code = feature.Code,
Name = feature.Name,
Category = feature.Category,
IsCore = feature.IsCore,
Status = SaasFeatureStatus.Active,
SortOrder = feature.SortOrder
});
}
}
foreach (var module in BuiltinPermissionModules)
{
if (!await dbContext.PermissionModules.AnyAsync(item => item.Code == module.Code, cancellationToken))
{
dbContext.PermissionModules.Add(new PermissionModule
{
Code = module.Code,
Name = module.Name,
Area = module.Area,
RequiredFeatureCode = module.RequiredFeatureCode,
SortOrder = module.SortOrder
});
}
}
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var permission in BuiltinPermissions)
{
if (await dbContext.BackendPermissions.AnyAsync(item => item.Code == permission.Code, cancellationToken))
@@ -325,7 +424,7 @@ internal sealed class BackofficeService(
Code = permission.Code,
Name = permission.Name,
Area = permission.Area,
Module = permission.Module,
PermissionModuleCode = permission.PermissionModuleCode,
IsSystem = true
});
}
@@ -363,18 +462,13 @@ internal sealed class BackofficeService(
var normalizedPermissions = NormalizeCodes(permissionCodes);
var normalizedMenus = NormalizeCodes(menuCodes);
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
foreach (var permissionCode in normalizedPermissions)
var allowedPermissions = await featureAccessService.FilterPermissionCodesAsync(
tenantId, normalizedPermissions, FeatureAccessOperation.Write, cancellationToken);
if (allowedPermissions.Count != normalizedPermissions.Length)
{
if (!await capabilityAccessEvaluator.IsAllowedAsync(
tenantId,
ResolveModuleCode(permissionCode),
CapabilityOperation.Write,
cancellationToken))
{
throw new BackofficeException(
"One or more permissions belong to a module unavailable to this tenant.",
"capability_not_available");
}
throw new BackofficeException(
"One or more permissions belong to a feature unavailable to this tenant.",
"feature_not_available");
}
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
@@ -433,25 +527,12 @@ internal sealed class BackofficeService(
CapabilityOperation operation,
CancellationToken cancellationToken)
{
var enabled = new List<string>();
foreach (var permissionCode in permissionCodes.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal))
{
if (await capabilityAccessEvaluator.IsAllowedAsync(
tenantId,
ResolveModuleCode(permissionCode),
operation,
cancellationToken))
{
enabled.Add(permissionCode);
}
}
return enabled.ToArray();
}
private static string ResolveModuleCode(string permissionCode)
{
var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant();
var featureOperation = operation == CapabilityOperation.Read
? FeatureAccessOperation.Read
: FeatureAccessOperation.Write;
var enabled = await featureAccessService.FilterPermissionCodesAsync(
tenantId, permissionCodes, featureOperation, cancellationToken);
return enabled.Order(StringComparer.Ordinal).ToArray();
}
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
@@ -544,7 +625,7 @@ internal sealed class BackofficeService(
private static BackofficePermissionItem ToPermissionItem(BackendPermission item)
{
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.Module, item.Description, item.SortOrder);
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.PermissionModuleCode, item.Description, item.SortOrder);
}
private static BackofficeMenuItem ToMenuItem(BackendMenu item)
@@ -579,7 +660,9 @@ internal sealed class BackofficeService(
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray());
}
private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string Module);
private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string PermissionModuleCode);
private sealed record BuiltinFeature(string Code, string Name, string Category, bool IsCore, int SortOrder);
private sealed record BuiltinPermissionModule(string Code, string Name, BackendPermissionArea Area, string? RequiredFeatureCode, int SortOrder);
private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder);
}