From 70b99e6063e489127514711c85dd077847a989dd Mon Sep 17 00:00:00 2001 From: xiong Date: Mon, 27 Jul 2026 17:47:21 +0800 Subject: [PATCH] feat: modularize external providers --- README.md | 9 +- Tiku.Api/Contracts/TenantAdminDirectDtos.cs | 8 +- Tiku.Api/Contracts/TenantCommerceDtos.cs | 12 +- .../TenantAdminDirectController.cs | 10 +- .../Controllers/TenantCommerceController.cs | 8 +- .../Middleware/ExceptionHandlingMiddleware.cs | 10 + Tiku.Application/Auth/IIdentityProvider.cs | 23 ++ Tiku.Application/Auth/ISmsProvider.cs | 23 ++ .../Commerce/CommerceAdminModels.cs | 16 +- .../Notifications/INotificationProvider.cs | 26 ++ .../Storage/ObjectStorageContracts.cs | 1 - .../Tenancy/TenantExternalProviderModels.cs | 65 +++++ .../TenantAdmin/ITenantAdminDirectService.cs | 6 +- .../TenantAdmin/TenantAdminDirectModels.cs | 10 +- Tiku.Domain/Commerce/CommerceEntities.cs | 11 - Tiku.Domain/Content/AssetEntities.cs | 1 - .../Tenancy/TenantOperationsEntities.cs | 11 +- .../Assets/AssetAccessService.cs | 1 - .../Assets/AssetManagementService.cs | 60 ++++- Tiku.Infrastructure/Auth/AuthService.cs | 38 +-- Tiku.Infrastructure/Auth/NoopSmsProvider.cs | 14 + .../Auth/SelfHostedIdentityProvider.cs | 54 ++++ .../Auth/SmsVerificationService.cs | 15 +- .../Commerce/CommerceAdminService.cs | 106 +++++--- .../Commerce/PaymentProviderConfigService.cs | 40 ++- .../Commerce/TenantSecretEncryption.cs | 6 +- Tiku.Infrastructure/DependencyInjection.cs | 6 + .../Growth/ReferralQrcodeGenerator.cs | 37 +-- .../InAppNotificationProvider.cs | 55 ++++ .../Configurations/CommerceConfigurations.cs | 15 -- .../TenantOperationsConfigurations.cs | 13 +- ... 20260727093301_InitialSchema.Designer.cs} | 245 +++++++----------- ...ema.cs => 20260727093301_InitialSchema.cs} | 117 +++------ .../Migrations/TikuDbContextModelSnapshot.cs | 243 +++++++---------- .../Persistence/TikuDbContext.cs | 3 +- .../Storage/AliyunOssObjectStorageService.cs | 1 - .../TenantExternalProviderConfigService.cs | 210 +++++++++++++++ .../TenantAdmin/TenantAdminDirectService.cs | 144 +++++----- .../Api/AuthEndpointTests.cs | 43 ++- .../Api/CommerceEndpointTests.cs | 6 +- .../Api/TenantAdminDirectEndpointTests.cs | 7 +- .../Api/TenantCommerceEndpointTests.cs | 8 +- .../ArchitectureBoundaryTests.cs | 40 +++ .../PersistenceModelTests.cs | 17 +- Tiku.UnitTests/Auth/AuthServiceTests.cs | 83 +++++- docs/adr/0001-authoritative-dotnet-backend.md | 6 +- docs/migration-roadmap.md | 3 +- 47 files changed, 1219 insertions(+), 667 deletions(-) create mode 100644 Tiku.Application/Auth/IIdentityProvider.cs create mode 100644 Tiku.Application/Auth/ISmsProvider.cs create mode 100644 Tiku.Application/Notifications/INotificationProvider.cs create mode 100644 Tiku.Application/Tenancy/TenantExternalProviderModels.cs create mode 100644 Tiku.Infrastructure/Auth/NoopSmsProvider.cs create mode 100644 Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs create mode 100644 Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs rename Tiku.Infrastructure/Persistence/Migrations/{20260727084726_InitialSchema.Designer.cs => 20260727093301_InitialSchema.Designer.cs} (99%) rename Tiku.Infrastructure/Persistence/Migrations/{20260727084726_InitialSchema.cs => 20260727093301_InitialSchema.cs} (99%) create mode 100644 Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs diff --git a/README.md b/README.md index b37629c..5d13bf9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TIKU Backend -题库 SaaS 正式后端。项目正在从旧 PocketBase / Supabase / Nest 方案迁到更可控的 ASP.NET Core + PostgreSQL 架构。本仓库是后续开发的唯一目标后端,架构决策见 [`docs/adr/0001-authoritative-dotnet-backend.md`](docs/adr/0001-authoritative-dotnet-backend.md)。 +题库 SaaS 正式后端。项目已从旧 PocketBase / Supabase / Nest 方案收敛到 ASP.NET Core + PostgreSQL 架构。本仓库是后续开发的唯一目标后端,架构决策见 [`docs/adr/0001-authoritative-dotnet-backend.md`](docs/adr/0001-authoritative-dotnet-backend.md)。 当前判断很明确:现在团队已经有后端开发,继续把核心认证、权限、多租户隔离、业务一致性交给 BaaS 规则,会让复杂度藏在平台、SQL policy 和前端约定之间。新后端把这些东西收回应用层和数据库约束里,开发、排查、审计都会更直接。 @@ -74,12 +74,13 @@ Tiku.IntegrationTests # API / EF 模型集成测试 ### 5. 资源存储不绑 Supabase -旧版资源层实际使用 Node `ali-oss`。新后端按这个方向迁移到 `AlibabaCloud.OSS.V2`: +旧版资源层实际使用 Node `ali-oss`。新后端已经按这个方向迁移到 `AlibabaCloud.OSS.V2`: - Application 只依赖 `IObjectStorageService`。 - Infrastructure 收敛阿里云 OSS SDK 细节。 +- 租户外部服务统一通过 `TenantExternalProvider` + `TenantSecret` 配置。 - 对象 key 默认要求租户前缀,避免资源混放。 -- 上传签名前校验 MIME、大小和 provider。 +- 上传签名前校验 MIME、大小和租户对象存储 provider。 - 下载/预览必须先过业务授权,再签发临时 URL。 - OSS HEAD metadata 用于上传确认、大小/MIME/ETag/安全扫描校验。 @@ -90,7 +91,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试 数据库模型迁移已经完成到 greenfield 初始 schema: ```text -Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.cs +Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs ``` 当前 EF 模型覆盖: diff --git a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs index 7cb4e6c..e7370c2 100644 --- a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs +++ b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs @@ -453,16 +453,18 @@ public sealed class CreateTenantDomainDto } } -public sealed class UpsertTenantAuthProviderDto +public sealed class UpsertTenantIdentityProviderDto { public required string Provider { get; set; } public string? Status { get; set; } public string? DisplayName { get; set; } + public string? SecretRef { get; set; } + public int? Priority { get; set; } public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); - public UpsertTenantAuthProviderCommand ToCommand() + public UpsertTenantIdentityProviderCommand ToCommand() { - return new UpsertTenantAuthProviderCommand(Provider, Status, DisplayName, ConfigPublic); + return new UpsertTenantIdentityProviderCommand(Provider, Status, DisplayName, SecretRef, Priority, ConfigPublic); } } diff --git a/Tiku.Api/Contracts/TenantCommerceDtos.cs b/Tiku.Api/Contracts/TenantCommerceDtos.cs index 2f87b46..9e1aebd 100644 --- a/Tiku.Api/Contracts/TenantCommerceDtos.cs +++ b/Tiku.Api/Contracts/TenantCommerceDtos.cs @@ -29,18 +29,24 @@ public sealed class UpsertPaymentAccountDto [StringLength(50)] public string Provider { get; set; } = string.Empty; - public TenantPaymentMode Mode { get; set; } = TenantPaymentMode.TenantCollect; + [StringLength(50)] + public string? Mode { get; set; } = "TenantCollect"; [StringLength(200)] public string? DisplayName { get; set; } - public TenantPaymentAccountStatus Status { get; set; } = TenantPaymentAccountStatus.Disabled; + public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled; + + [StringLength(300)] + public string? SecretRef { get; set; } + + public int? Priority { get; set; } public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); public UpsertPaymentAccountCommand ToCommand() { - return new UpsertPaymentAccountCommand(Provider, Mode, DisplayName, Status, ConfigPublic); + return new UpsertPaymentAccountCommand(Provider, Mode, DisplayName, Status, SecretRef, Priority, ConfigPublic); } } diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs index f702732..34ee26e 100644 --- a/Tiku.Api/Controllers/TenantAdminDirectController.cs +++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs @@ -302,17 +302,17 @@ public sealed class TenantAdminDirectController( [HttpGet("auth-providers")] [EndpointSummary("查询租户登录 Provider 公开配置")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetAuthProviders(CancellationToken cancellationToken) + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetAuthProviders(CancellationToken cancellationToken) { return Ok(await tenantAdminService.GetAuthProvidersAsync(ResolveActor(), cancellationToken)); } [HttpPut("auth-providers")] [EndpointSummary("新增或更新租户登录 Provider")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertAuthProvider( - UpsertTenantAuthProviderDto request, + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertAuthProvider( + UpsertTenantIdentityProviderDto request, CancellationToken cancellationToken) { return Ok(await tenantAdminService.UpsertAuthProviderAsync(ResolveActor(), request.ToCommand(), cancellationToken)); diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs index 8a2053f..fa30b5f 100644 --- a/Tiku.Api/Controllers/TenantCommerceController.cs +++ b/Tiku.Api/Controllers/TenantCommerceController.cs @@ -17,8 +17,8 @@ public sealed class TenantCommerceController( { [HttpGet("payment-accounts")] [EndpointSummary("查询租户支付账号")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> PaymentAccounts( + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> PaymentAccounts( [FromQuery] TenantCommerceQueryDto query, CancellationToken cancellationToken) { @@ -30,8 +30,8 @@ public sealed class TenantCommerceController( [HttpPut("payment-accounts")] [EndpointSummary("新增或更新租户支付账号")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> UpsertPaymentAccount( + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertPaymentAccount( UpsertPaymentAccountDto request, CancellationToken cancellationToken) { diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 7c4cfe4..180404e 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -284,6 +284,16 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is TenantExternalProviderException externalProviderException) + { + await WriteProblemAsync( + context, + externalProviderException.Message, + StatusCodes.Status400BadRequest, + externalProviderException.Code); + return; + } + if (exception is ObjectStorageException storageException) { await WriteProblemAsync( diff --git a/Tiku.Application/Auth/IIdentityProvider.cs b/Tiku.Application/Auth/IIdentityProvider.cs new file mode 100644 index 0000000..41c4eba --- /dev/null +++ b/Tiku.Application/Auth/IIdentityProvider.cs @@ -0,0 +1,23 @@ +namespace Tiku.Application.Auth; + +public sealed record IdentityProviderRequest( + Guid TenantId, + string Provider, + string Identifier, + string Secret, + string? IpAddress, + string? UserAgent); + +public sealed record IdentityProviderResult( + string Provider, + string Subject, + string? Phone, + string? Email, + string? DisplayName); + +public interface IIdentityProvider +{ + Task AuthenticateAsync( + IdentityProviderRequest request, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Auth/ISmsProvider.cs b/Tiku.Application/Auth/ISmsProvider.cs new file mode 100644 index 0000000..18bcf23 --- /dev/null +++ b/Tiku.Application/Auth/ISmsProvider.cs @@ -0,0 +1,23 @@ +using Tiku.Domain.Tenancy; + +namespace Tiku.Application.Auth; + +public sealed record SmsProviderSendRequest( + Guid TenantId, + string Phone, + SmsPurpose Purpose, + string Code, + string? IpAddress, + string? UserAgent); + +public sealed record SmsProviderSendResult( + string Provider, + string Status, + string? MessageId = null); + +public interface ISmsProvider +{ + Task SendAsync( + SmsProviderSendRequest request, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Commerce/CommerceAdminModels.cs b/Tiku.Application/Commerce/CommerceAdminModels.cs index 2e6e30c..3ee086f 100644 --- a/Tiku.Application/Commerce/CommerceAdminModels.cs +++ b/Tiku.Application/Commerce/CommerceAdminModels.cs @@ -12,17 +12,21 @@ public sealed record TenantPointQuery(string? Status = null, int? Limit = null, public sealed record UpsertPaymentAccountCommand( string Provider, - TenantPaymentMode Mode, + string? Mode, string? DisplayName, - TenantPaymentAccountStatus Status, + TenantExternalProviderStatus Status, + string? SecretRef, + int? Priority, JsonElement ConfigPublic); -public sealed record TenantPaymentAccountItem( +public sealed record TenantPaymentProviderItem( Guid Id, string Provider, string Mode, string? DisplayName, - string Status, + TenantExternalProviderStatus Status, + string? SecretRef, + int Priority, JsonElement ConfigPublic, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); @@ -150,12 +154,12 @@ public sealed record TenantCouponReport(int CouponCount, int ClaimedCount, int U public interface ICommerceAdminService { - Task> GetPaymentAccountsAsync( + Task> GetPaymentAccountsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default); - Task UpsertPaymentAccountAsync( + Task UpsertPaymentAccountAsync( CommerceAdminActor actor, UpsertPaymentAccountCommand command, CancellationToken cancellationToken = default); diff --git a/Tiku.Application/Notifications/INotificationProvider.cs b/Tiku.Application/Notifications/INotificationProvider.cs new file mode 100644 index 0000000..f88a1e4 --- /dev/null +++ b/Tiku.Application/Notifications/INotificationProvider.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using Tiku.Domain.Operations; + +namespace Tiku.Application.Notifications; + +public sealed record InAppNotificationRequest( + Guid TenantId, + Guid UserId, + string NotificationType, + NotificationSeverity Severity, + string Title, + string Message, + Guid? CreatedBy = null, + string? ActionLabel = null, + string? ActionPath = null, + string? SourceType = null, + Guid? SourceId = null, + string? DedupeKey = null, + JsonElement Metadata = default); + +public interface INotificationProvider +{ + Task UpsertInAppAsync( + InAppNotificationRequest request, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Storage/ObjectStorageContracts.cs b/Tiku.Application/Storage/ObjectStorageContracts.cs index 8eed747..a27744f 100644 --- a/Tiku.Application/Storage/ObjectStorageContracts.cs +++ b/Tiku.Application/Storage/ObjectStorageContracts.cs @@ -3,7 +3,6 @@ namespace Tiku.Application.Storage; public static class ObjectStorageProviders { public const string ExternalUrl = "external_url"; - public const string SupabaseStorage = "supabase_storage"; public const string AliyunOss = "aliyun_oss"; public const string TencentCos = "tencent_cos"; public const string QiniuKodo = "qiniu_kodo"; diff --git a/Tiku.Application/Tenancy/TenantExternalProviderModels.cs b/Tiku.Application/Tenancy/TenantExternalProviderModels.cs new file mode 100644 index 0000000..076d396 --- /dev/null +++ b/Tiku.Application/Tenancy/TenantExternalProviderModels.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Tiku.Domain.Tenancy; + +namespace Tiku.Application.Tenancy; + +public sealed record TenantExternalProviderAccount( + Guid TenantId, + TenantExternalProviderCapability Capability, + string Provider, + TenantExternalProviderStatus Status, + string? DisplayName, + JsonElement ConfigPublic, + JsonElement SecretPayload, + string? SecretRef, + int Priority, + JsonElement Metadata); + +public sealed record UpsertTenantExternalProviderCommand( + TenantExternalProviderCapability Capability, + string Provider, + TenantExternalProviderStatus Status, + string? DisplayName, + string? SecretRef, + int? Priority, + JsonElement ConfigPublic, + JsonElement Metadata); + +public sealed record TenantExternalProviderItem( + Guid Id, + TenantExternalProviderCapability Capability, + string Provider, + TenantExternalProviderStatus Status, + string? DisplayName, + string? SecretRef, + int Priority, + JsonElement ConfigPublic, + JsonElement Metadata, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public interface ITenantExternalProviderConfigService +{ + Task GetActiveProviderAsync( + Guid tenantId, + TenantExternalProviderCapability capability, + string? provider = null, + CancellationToken cancellationToken = default); + + Task> GetProvidersAsync( + Guid tenantId, + TenantExternalProviderCapability? capability = null, + string? provider = null, + int? limit = null, + CancellationToken cancellationToken = default); + + Task UpsertProviderAsync( + Guid tenantId, + UpsertTenantExternalProviderCommand command, + CancellationToken cancellationToken = default); +} + +public sealed class TenantExternalProviderException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs index 9779204..dae6b19 100644 --- a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs +++ b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs @@ -146,13 +146,13 @@ public interface ITenantAdminDirectService CreateTenantDomainCommand command, CancellationToken cancellationToken = default); - Task> GetAuthProvidersAsync( + Task> GetAuthProvidersAsync( TenantAdminActor actor, CancellationToken cancellationToken = default); - Task> UpsertAuthProviderAsync( + Task> UpsertAuthProviderAsync( TenantAdminActor actor, - UpsertTenantAuthProviderCommand command, + UpsertTenantIdentityProviderCommand command, CancellationToken cancellationToken = default); Task> GetBadgesAsync( diff --git a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs index e06e508..033d88e 100644 --- a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs +++ b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs @@ -189,10 +189,12 @@ public sealed record CreateTenantDomainCommand( string? DomainType, bool IsPrimary); -public sealed record UpsertTenantAuthProviderCommand( +public sealed record UpsertTenantIdentityProviderCommand( string Provider, string? Status, string? DisplayName, + string? SecretRef, + int? Priority, JsonElement ConfigPublic); public sealed record UpsertTenantAdminBadgeCommand( @@ -467,11 +469,13 @@ public sealed record TenantDomainItem( DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); -public sealed record TenantAuthProviderItem( +public sealed record TenantIdentityProviderItem( Guid Id, string Provider, - TenantAuthProviderStatus Status, + TenantExternalProviderStatus Status, string? DisplayName, + string? SecretRef, + int Priority, JsonElement ConfigPublic, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); diff --git a/Tiku.Domain/Commerce/CommerceEntities.cs b/Tiku.Domain/Commerce/CommerceEntities.cs index 987c2a6..d21ad1d 100644 --- a/Tiku.Domain/Commerce/CommerceEntities.cs +++ b/Tiku.Domain/Commerce/CommerceEntities.cs @@ -193,15 +193,6 @@ public sealed class CouponRedemption : AuditableTenantEntity public DateTimeOffset? UsedAt { get; set; } } -public sealed class TenantPaymentAccount : AuditableTenantEntity -{ - public string Provider { get; set; } = string.Empty; - public TenantPaymentMode Mode { get; set; } = TenantPaymentMode.PlatformCollect; - public string? DisplayName { get; set; } - public TenantPaymentAccountStatus Status { get; set; } = TenantPaymentAccountStatus.Disabled; - public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); -} - public sealed class TenantSubscription : AuditableTenantEntity { public string PlanCode { get; set; } = string.Empty; @@ -364,8 +355,6 @@ public enum EntitlementScopeType { Tenant, Region, Module, Subject, QuestionBank public enum EntitlementStatus { Active, Revoked, Expired } public enum DiscountType { Percent, Fixed } public enum CouponRedemptionStatus { Claimed, Used, Expired, Cancelled } -public enum TenantPaymentMode { PlatformCollect, TenantCollect, ServiceProvider } -public enum TenantPaymentAccountStatus { Active, Disabled, Pending } public enum TenantSubscriptionStatus { Trial, Active, PastDue, Cancelled } public enum CommerceRefundStatus { Requested, Approved, Processing, Succeeded, Failed, Rejected, Cancelled } public enum RefundEntitlementAction { None, RevokeOnSuccess } diff --git a/Tiku.Domain/Content/AssetEntities.cs b/Tiku.Domain/Content/AssetEntities.cs index c578c69..6813d48 100644 --- a/Tiku.Domain/Content/AssetEntities.cs +++ b/Tiku.Domain/Content/AssetEntities.cs @@ -170,7 +170,6 @@ public enum ContentAssetType public enum AssetStorageProvider { ExternalUrl, - SupabaseStorage, AliyunOss, TencentCos, QiniuKodo, diff --git a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs index 9d88d4e..b075b08 100644 --- a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs +++ b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs @@ -3,12 +3,16 @@ using Tiku.Domain.Common; namespace Tiku.Domain.Tenancy; -public sealed class TenantAuthProvider : AuditableTenantEntity +public sealed class TenantExternalProvider : AuditableTenantEntity { + public TenantExternalProviderCapability Capability { get; set; } = TenantExternalProviderCapability.Identity; public string Provider { get; set; } = string.Empty; - public TenantAuthProviderStatus Status { get; set; } = TenantAuthProviderStatus.Disabled; + public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled; public string? DisplayName { get; set; } + public string? SecretRef { get; set; } + public int Priority { get; set; } = 100; public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); } public sealed class TenantSecret : AuditableTenantEntity @@ -154,7 +158,8 @@ public sealed class TenantStudentFollowup : AuditableTenantEntity public Guid? UpdatedBy { get; set; } } -public enum TenantAuthProviderStatus { Active, Disabled, Testing } +public enum TenantExternalProviderCapability { Identity, ObjectStorage, Sms, Payment, Notification } +public enum TenantExternalProviderStatus { Active, Disabled, Testing } public enum TenantSecretStatus { Active, Disabled, Rotating } public enum SmsPurpose { Login, BindPhone, ResetPassword } public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked } diff --git a/Tiku.Infrastructure/Assets/AssetAccessService.cs b/Tiku.Infrastructure/Assets/AssetAccessService.cs index 8452e5e..99395f2 100644 --- a/Tiku.Infrastructure/Assets/AssetAccessService.cs +++ b/Tiku.Infrastructure/Assets/AssetAccessService.cs @@ -318,7 +318,6 @@ public sealed class AssetAccessService( return provider switch { AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl, - AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage, AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos, AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo, diff --git a/Tiku.Infrastructure/Assets/AssetManagementService.cs b/Tiku.Infrastructure/Assets/AssetManagementService.cs index 95ca50b..7fc18f3 100644 --- a/Tiku.Infrastructure/Assets/AssetManagementService.cs +++ b/Tiku.Infrastructure/Assets/AssetManagementService.cs @@ -4,15 +4,18 @@ using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.Storage; +using Tiku.Application.Tenancy; using Tiku.Domain.Common; using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; public sealed class AssetManagementService( TikuDbContext dbContext, - IObjectStorageService objectStorageService) : IAssetManagementService + IObjectStorageService objectStorageService, + ITenantExternalProviderConfigService providerConfigService) : IAssetManagementService { private const int DefaultLimit = 100; private const int MaxLimit = 500; @@ -140,10 +143,9 @@ public sealed class AssetManagementService( ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName); ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType); - var provider = objectStorageService.NormalizeProvider(command.Provider, objectStorageService.ConfiguredDefaultProvider()); - var bucket = string.IsNullOrWhiteSpace(command.Bucket) - ? objectStorageService.ConfiguredDefaultBucket() - : command.Bucket.Trim(); + var storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken); + var provider = storageConfig.Provider; + var bucket = storageConfig.Bucket; var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim()); var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes); var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken); @@ -782,7 +784,6 @@ public sealed class AssetManagementService( return provider switch { ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl, - ObjectStorageProviders.SupabaseStorage => AssetStorageProvider.SupabaseStorage, ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss, ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos, ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo, @@ -796,7 +797,6 @@ public sealed class AssetManagementService( return provider switch { AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl, - AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage, AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos, AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo, @@ -804,4 +804,50 @@ public sealed class AssetManagementService( _ => ObjectStorageProviders.ExternalUrl }; } + + private async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync( + Guid tenantId, + CancellationToken cancellationToken) + { + try + { + var account = await providerConfigService.GetActiveProviderAsync( + tenantId, + TenantExternalProviderCapability.ObjectStorage, + cancellationToken: cancellationToken); + var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket"); + if (string.IsNullOrWhiteSpace(bucket)) + { + throw new ObjectStorageException( + "Object storage provider bucket is not configured.", + "STORAGE_BUCKET_NOT_CONFIGURED"); + } + + return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim()); + } + catch (TenantExternalProviderException) + { + return ( + objectStorageService.ConfiguredDefaultProvider(), + objectStorageService.ConfiguredDefaultBucket()); + } + } + + private static string? GetJsonString(JsonElement element, params string[] keys) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var key in keys) + { + if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + } + + return null; + } } diff --git a/Tiku.Infrastructure/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs index 5b78107..214678f 100644 --- a/Tiku.Infrastructure/Auth/AuthService.cs +++ b/Tiku.Infrastructure/Auth/AuthService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Auth; +using Tiku.Application.Tenancy; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -12,12 +13,13 @@ public sealed class AuthService( IPasswordHasher passwordHasher, ISmsVerificationService smsVerificationService, ISessionService sessionService, - IWechatOAuthClient wechatOAuthClient) : IAuthService + IWechatOAuthClient wechatOAuthClient, + ITenantExternalProviderConfigService providerConfigService) : IAuthService { private const string PasswordProvider = "password"; private const string SmsProvider = "sms"; private const string WechatWebProvider = "wechat_web"; - private const string WechatMiniAppProvider = "wechat-miniapp"; + private const string WechatMiniAppProvider = "wechat_miniapp"; private static readonly string[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"]; private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; @@ -356,24 +358,30 @@ public sealed class AuthService( IReadOnlyList aliases, CancellationToken cancellationToken) { - var rows = await dbContext.TenantAuthProviders - .Where(entity => - entity.TenantId == tenantId && - aliases.Contains(entity.Provider) && - (entity.Status == TenantAuthProviderStatus.Active || - entity.Status == TenantAuthProviderStatus.Testing)) - .ToListAsync(cancellationToken); - var row = aliases - .Select(alias => rows.FirstOrDefault(entity => entity.Provider == alias)) - .FirstOrDefault(entity => entity is not null); + TenantExternalProviderAccount? account = null; + foreach (var alias in aliases) + { + try + { + account = await providerConfigService.GetActiveProviderAsync( + tenantId, + TenantExternalProviderCapability.Identity, + alias, + cancellationToken); + break; + } + catch (TenantExternalProviderException) + { + } + } - if (row is null) + if (account is null) { throw new AuthProviderNotConfiguredException(provider); } - var appId = GetJsonString(row.ConfigPublic, "appId", "clientId"); - var appSecret = GetJsonString(row.ConfigPublic, "appSecret", "clientSecret", "secret"); + var appId = GetJsonString(account.ConfigPublic, "appId", "clientId"); + var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret"); if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) { throw new AuthProviderNotConfiguredException(provider); diff --git a/Tiku.Infrastructure/Auth/NoopSmsProvider.cs b/Tiku.Infrastructure/Auth/NoopSmsProvider.cs new file mode 100644 index 0000000..f56cf88 --- /dev/null +++ b/Tiku.Infrastructure/Auth/NoopSmsProvider.cs @@ -0,0 +1,14 @@ +using Tiku.Application.Auth; + +namespace Tiku.Infrastructure.Auth; + +internal sealed class NoopSmsProvider : ISmsProvider +{ + public Task SendAsync( + SmsProviderSendRequest request, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new SmsProviderSendResult("noop", "accepted")); + } +} diff --git a/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs b/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs new file mode 100644 index 0000000..df0fe56 --- /dev/null +++ b/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs @@ -0,0 +1,54 @@ +using Tiku.Application.Auth; + +namespace Tiku.Infrastructure.Auth; + +internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IIdentityProvider +{ + public async Task AuthenticateAsync( + IdentityProviderRequest request, + CancellationToken cancellationToken = default) + { + var provider = request.Provider.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + var authenticated = provider switch + { + "password" => await authService.LoginWithPasswordAsync( + new PasswordLoginRequest( + request.TenantId, + request.Identifier, + request.Secret, + request.IpAddress, + request.UserAgent), + cancellationToken), + "sms" => await authService.LoginWithSmsAsync( + new SmsLoginRequest( + request.TenantId, + request.Identifier, + request.Secret, + request.IpAddress, + request.UserAgent), + cancellationToken), + "wechat_web" => await authService.LoginWithWechatWebAsync( + new WechatLoginRequest( + request.TenantId, + request.Secret, + request.IpAddress, + request.UserAgent), + cancellationToken), + "wechat_miniapp" => await authService.LoginWithWechatMiniAppAsync( + new WechatLoginRequest( + request.TenantId, + request.Secret, + request.IpAddress, + request.UserAgent), + cancellationToken), + _ => throw new AuthProviderNotConfiguredException(provider) + }; + + return new IdentityProviderResult( + provider, + authenticated.UserId.ToString("N"), + authenticated.Phone, + authenticated.Email, + authenticated.Name); + } +} diff --git a/Tiku.Infrastructure/Auth/SmsVerificationService.cs b/Tiku.Infrastructure/Auth/SmsVerificationService.cs index 86e51e9..54df003 100644 --- a/Tiku.Infrastructure/Auth/SmsVerificationService.cs +++ b/Tiku.Infrastructure/Auth/SmsVerificationService.cs @@ -5,7 +5,9 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; -public sealed class SmsVerificationService(TikuDbContext dbContext) : ISmsVerificationService +public sealed class SmsVerificationService( + TikuDbContext dbContext, + ISmsProvider smsProvider) : ISmsVerificationService { private const int MaxPhoneRequestsPerHour = 5; private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10); @@ -42,13 +44,22 @@ public sealed class SmsVerificationService(TikuDbContext dbContext) : ISmsVerifi rateLimit.UpdatedAt = DateTimeOffset.UtcNow; var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture); + var sendResult = await smsProvider.SendAsync( + new SmsProviderSendRequest( + request.TenantId, + phone, + request.Purpose, + code, + request.IpAddress, + request.UserAgent), + cancellationToken); var verification = new SmsVerificationCode { TenantId = request.TenantId, Phone = phone, Purpose = request.Purpose, CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code), - Provider = "mock", + Provider = sendResult.Provider, Status = SmsVerificationStatus.Sent, ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime), IpAddress = request.IpAddress, diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs index 83ad9c8..587058c 100644 --- a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; +using Tiku.Application.Tenancy; using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; using Tiku.Domain.Tenancy; @@ -12,55 +13,44 @@ namespace Tiku.Infrastructure.Commerce; internal sealed class CommerceAdminService( TikuDbContext dbContext, - ITenantSecretProtector tenantSecretProtector) : ICommerceAdminService + ITenantSecretProtector tenantSecretProtector, + ITenantExternalProviderConfigService providerConfigService) : ICommerceAdminService { - public async Task> GetPaymentAccountsAsync( + public async Task> GetPaymentAccountsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var accounts = dbContext.TenantPaymentAccounts.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(query.Provider)) - { - var provider = NormalizeProvider(query.Provider); - accounts = accounts.Where(item => item.Provider == provider); - } + var accounts = await providerConfigService.GetProvidersAsync( + actor.TenantId, + TenantExternalProviderCapability.Payment, + string.IsNullOrWhiteSpace(query.Provider) ? null : NormalizeProvider(query.Provider), + query.Limit, + cancellationToken); - return await accounts - .OrderBy(item => item.Provider) - .Take(Math.Clamp(query.Limit ?? 50, 1, 100)) - .Select(item => ToPaymentAccountItem(item)) - .ToArrayAsync(cancellationToken); + return accounts.Select(ToPaymentAccountItem).ToArray(); } - public async Task UpsertPaymentAccountAsync( + public async Task UpsertPaymentAccountAsync( CommerceAdminActor actor, UpsertPaymentAccountCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - AssertNoSecrets(command.ConfigPublic, "config_public"); - var provider = NormalizeProvider(command.Provider); - var account = await dbContext.TenantPaymentAccounts - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Provider == provider, cancellationToken); - if (account is null) - { - account = new TenantPaymentAccount - { - TenantId = actor.TenantId, - Provider = provider - }; - dbContext.TenantPaymentAccounts.Add(account); - } + var account = await providerConfigService.UpsertProviderAsync( + actor.TenantId, + new UpsertTenantExternalProviderCommand( + TenantExternalProviderCapability.Payment, + command.Provider, + command.Status, + command.DisplayName, + command.SecretRef, + command.Priority, + WithPaymentMode(command.ConfigPublic, command.Mode), + JsonObjectOrDefault(default)), + cancellationToken); - account.Mode = command.Mode; - account.DisplayName = command.DisplayName?.Trim(); - account.Status = command.Status; - account.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic); - - await dbContext.SaveChangesAsync(cancellationToken); return ToPaymentAccountItem(account); } @@ -588,8 +578,18 @@ internal sealed class CommerceAdminService( } } - private static TenantPaymentAccountItem ToPaymentAccountItem(TenantPaymentAccount item) => - new(item.Id, item.Provider, item.Mode.ToString(), item.DisplayName, item.Status.ToString(), item.ConfigPublic, item.CreatedAt, item.UpdatedAt); + private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) => + new( + item.Id, + item.Provider, + GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect", + item.DisplayName, + item.Status, + item.SecretRef, + item.Priority, + item.ConfigPublic, + item.CreatedAt, + item.UpdatedAt); private static TenantSecretItem ToSecretItem(TenantSecret item) => new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt); @@ -651,6 +651,40 @@ internal sealed class CommerceAdminService( }; } + private static JsonElement WithPaymentMode(JsonElement element, string? mode) + { + var values = new Dictionary(StringComparer.Ordinal); + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + values[property.Name] = property.Value.Clone(); + } + } + + values["mode"] = JsonSerializer.SerializeToElement( + string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim()); + return JsonSerializer.SerializeToElement(values); + } + + private static string? GetJsonString(JsonElement element, params string[] keys) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var key in keys) + { + if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + } + + return null; + } + private static JsonElement JsonObjectOrDefault(JsonElement element) => element.ValueKind == JsonValueKind.Object ? element.Clone() diff --git a/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs b/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs index 637e03d..db40b4d 100644 --- a/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs +++ b/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs @@ -1,49 +1,41 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; +using Tiku.Application.Tenancy; +using Tiku.Domain.Tenancy; using Tiku.Domain.Commerce; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; internal sealed class PaymentProviderConfigService( - TikuDbContext dbContext, - ITenantSecretService tenantSecretService) : IPaymentProviderConfigService + ITenantExternalProviderConfigService providerConfigService) : IPaymentProviderConfigService { public async Task GetActiveAccountAsync( Guid tenantId, string provider, CancellationToken cancellationToken = default) { - var normalizedProvider = NormalizeProvider(provider); - var account = await dbContext.TenantPaymentAccounts - .AsNoTracking() - .Where(item => - item.TenantId == tenantId && - item.Provider == normalizedProvider && - item.Status == TenantPaymentAccountStatus.Active) - .SingleOrDefaultAsync(cancellationToken); - - if (account is null) + TenantExternalProviderAccount account; + try { - throw new PaymentProviderException( - "Payment provider account is not configured.", - "payment_provider_not_configured"); - } - - var secretPayload = normalizedProvider == PaymentProviders.Manual - ? JsonDocument.Parse("{}").RootElement.Clone() - : await tenantSecretService.GetActiveSecretPayloadAsync( + account = await providerConfigService.GetActiveProviderAsync( tenantId, - GetString(account.ConfigPublic, "secretRef", "secret_ref") ?? string.Empty, + TenantExternalProviderCapability.Payment, + NormalizeProvider(provider), cancellationToken); + } + catch (TenantExternalProviderException exception) + { + throw new PaymentProviderException(exception.Message, "payment_provider_not_configured"); + } return new PaymentProviderAccount( account.TenantId, - normalizedProvider, - account.Mode.ToString(), + account.Provider, + GetString(account.ConfigPublic, "mode") ?? "TenantCollect", account.ConfigPublic, - secretPayload); + account.SecretPayload); } private static string NormalizeProvider(string provider) diff --git a/Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs b/Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs index 332784f..82b626e 100644 --- a/Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs +++ b/Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs @@ -34,7 +34,7 @@ public sealed class TenantSecretEncryptionOptions string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal); } -internal interface ITenantSecretProtector +public interface ITenantSecretProtector { ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload); @@ -47,13 +47,13 @@ internal interface ITenantSecretProtector byte[] tag); } -internal sealed record ProtectedTenantSecret( +public sealed record ProtectedTenantSecret( string KeyId, byte[] Ciphertext, byte[] Nonce, byte[] Tag); -internal sealed class TenantSecretProtector( +public sealed class TenantSecretProtector( Microsoft.Extensions.Options.IOptions options) : ITenantSecretProtector { private readonly TenantSecretEncryptionOptions options = options.Value; diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 00ef287..b943e1b 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -8,6 +8,7 @@ using Tiku.Application.Commerce; using Tiku.Application.Content; using Tiku.Application.Growth; using Tiku.Application.Learning; +using Tiku.Application.Notifications; using Tiku.Application.QuestionBanks; using Tiku.Application.Profile; using Tiku.Application.Points; @@ -24,6 +25,7 @@ using Tiku.Infrastructure.Commerce; using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Growth; using Tiku.Infrastructure.Learning; +using Tiku.Infrastructure.Notifications; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Profile; using Tiku.Infrastructure.Points; @@ -56,6 +58,7 @@ public static class DependencyInjection services.AddScoped(); services.AddMemoryCache(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddHttpClient(); services.AddHttpClient(); @@ -65,9 +68,11 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -91,6 +96,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs b/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs index 0334392..65b2f20 100644 --- a/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs +++ b/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs @@ -1,18 +1,17 @@ using System.Security.Cryptography; using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Senparc.Weixin.WxOpen.AdvancedAPIs.WxApp; using Senparc.Weixin.WxOpen.Containers; using Tiku.Application.Growth; using Tiku.Application.Storage; +using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; public sealed class ReferralQrcodeGenerator( - TikuDbContext dbContext, - IObjectStorageService objectStorageService) : IReferralQrcodeGenerator + IObjectStorageService objectStorageService, + ITenantExternalProviderConfigService providerConfigService) : IReferralQrcodeGenerator { private static readonly string[] WechatMiniappProviderAliases = [ @@ -84,17 +83,23 @@ public sealed class ReferralQrcodeGenerator( Guid tenantId, CancellationToken cancellationToken) { - var providers = await dbContext.TenantAuthProviders - .AsNoTracking() - .Where(entity => - entity.TenantId == tenantId && - WechatMiniappProviderAliases.Contains(entity.Provider) && - (entity.Status == TenantAuthProviderStatus.Active || - entity.Status == TenantAuthProviderStatus.Testing)) - .ToListAsync(cancellationToken); - var provider = WechatMiniappProviderAliases - .Select(alias => providers.FirstOrDefault(entity => entity.Provider == alias)) - .FirstOrDefault(entity => entity is not null); + TenantExternalProviderAccount? provider = null; + foreach (var alias in WechatMiniappProviderAliases) + { + try + { + provider = await providerConfigService.GetActiveProviderAsync( + tenantId, + TenantExternalProviderCapability.Identity, + alias, + cancellationToken); + break; + } + catch (TenantExternalProviderException) + { + } + } + if (provider is null) { throw new ReferralException( @@ -103,7 +108,7 @@ public sealed class ReferralQrcodeGenerator( } var appId = GetJsonString(provider.ConfigPublic, "appId", "clientId"); - var appSecret = GetJsonString(provider.ConfigPublic, "appSecret", "clientSecret", "secret"); + var appSecret = GetJsonString(provider.SecretPayload, "appSecret", "clientSecret", "secret"); if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) { throw new ReferralException( diff --git a/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs b/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs new file mode 100644 index 0000000..c04af4d --- /dev/null +++ b/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Notifications; +using Tiku.Domain.Common; +using Tiku.Domain.Operations; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Notifications; + +internal sealed class InAppNotificationProvider(TikuDbContext dbContext) : INotificationProvider +{ + public async Task UpsertInAppAsync( + InAppNotificationRequest request, + CancellationToken cancellationToken = default) + { + var item = !string.IsNullOrWhiteSpace(request.DedupeKey) + ? await dbContext.UserNotifications.FirstOrDefaultAsync(notification => + notification.TenantId == request.TenantId && + notification.UserId == request.UserId && + notification.NotificationType == request.NotificationType && + notification.DedupeKey == request.DedupeKey, + cancellationToken) + : null; + + var isNew = item is null; + item ??= new UserNotification + { + TenantId = request.TenantId, + UserId = request.UserId, + CreatedBy = request.CreatedBy + }; + + item.NotificationType = request.NotificationType.Trim(); + item.Severity = request.Severity; + item.Title = request.Title.Trim(); + item.Message = request.Message.Trim(); + item.ActionLabel = Normalize(request.ActionLabel); + item.ActionPath = Normalize(request.ActionPath); + item.SourceType = Normalize(request.SourceType); + item.SourceId = request.SourceId; + item.DedupeKey = Normalize(request.DedupeKey); + item.Metadata = request.Metadata.ValueKind == System.Text.Json.JsonValueKind.Object + ? request.Metadata.Clone() + : JsonDefaults.Object(); + + if (isNew) + { + dbContext.UserNotifications.Add(item); + } + + return item; + } + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs index 299b1b0..1dc46ac 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs @@ -299,21 +299,6 @@ internal sealed class CouponRedemptionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ConfigureTenantEntity("tenant_payment_accounts"); - builder.ConfigureTimestamps(); - builder.Property(entity => entity.Provider).HasMaxLength(50); - builder.Property(entity => entity.Mode).HasSnakeCaseEnum(); - builder.Property(entity => entity.DisplayName).HasMaxLength(200); - builder.Property(entity => entity.Status).HasSnakeCaseEnum(); - builder.Property(entity => entity.ConfigPublic).IsJson("{}"); - builder.HasIndex(entity => new { entity.TenantId, entity.Provider }).IsUnique(); - } -} - internal sealed class TenantSubscriptionConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs index 6f7e409..e5f0cd6 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs @@ -6,18 +6,21 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence.Configurations; -internal sealed class TenantAuthProviderConfiguration : IEntityTypeConfiguration +internal sealed class TenantExternalProviderConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder builder) + public void Configure(EntityTypeBuilder builder) { - builder.ConfigureTenantEntity("tenant_auth_providers"); + builder.ConfigureTenantEntity("tenant_external_providers"); builder.ConfigureTimestamps(); + builder.Property(entity => entity.Capability).HasSnakeCaseEnum(); builder.Property(entity => entity.Provider).HasMaxLength(50); builder.Property(entity => entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.DisplayName).HasMaxLength(200); + builder.Property(entity => entity.SecretRef).HasMaxLength(300); builder.Property(entity => entity.ConfigPublic).IsJson("{}"); - builder.HasIndex(entity => new { entity.TenantId, entity.Provider }).IsUnique(); - builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.Status }); + builder.Property(entity => entity.Metadata).IsJson("{}"); + builder.HasIndex(entity => new { entity.TenantId, entity.Capability, entity.Provider }).IsUnique(); + builder.HasIndex(entity => new { entity.TenantId, entity.Capability, entity.Status, entity.Priority }); } } diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs similarity index 99% rename from Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.Designer.cs rename to Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs index 09865d1..7b08705 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.Designer.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs @@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Persistence.Migrations { [DbContext(typeof(TikuDbContext))] - [Migration("20260727084726_InitialSchema")] + [Migration("20260727093301_InitialSchema")] partial class InitialSchema { /// @@ -3400,72 +3400,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations }); }); - modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id") - .HasDefaultValueSql("gen_random_uuid()"); - - b.Property("ConfigPublic") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("config_public") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("now()"); - - b.Property("DisplayName") - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("display_name"); - - b.Property("Mode") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("mode"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") - .HasColumnName("provider"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("status"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at") - .HasDefaultValueSql("now()"); - - b.HasKey("Id") - .HasName("pk_tenant_payment_accounts"); - - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_tenant_payment_accounts_tenant_id_id"); - - b.HasIndex("TenantId", "Provider") - .IsUnique() - .HasDatabaseName("ix_tenant_payment_accounts_tenant_id_provider"); - - b.ToTable("tenant_payment_accounts", (string)null); - }); - modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b => { b.Property("Id") @@ -12476,69 +12410,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenants", (string)null); }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id") - .HasDefaultValueSql("gen_random_uuid()"); - - b.Property("ConfigPublic") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("config_public") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("now()"); - - b.Property("DisplayName") - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("display_name"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") - .HasColumnName("provider"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("status"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at") - .HasDefaultValueSql("now()"); - - b.HasKey("Id") - .HasName("pk_tenant_auth_providers"); - - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_tenant_auth_providers_tenant_id_id"); - - b.HasIndex("TenantId", "Provider") - .IsUnique() - .HasDatabaseName("ix_tenant_auth_providers_tenant_id_provider"); - - b.HasIndex("TenantId", "Provider", "Status") - .HasDatabaseName("ix_tenant_auth_providers_tenant_id_provider_status"); - - b.ToTable("tenant_auth_providers", (string)null); - }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => { b.Property("TenantId") @@ -12900,6 +12771,90 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenant_domains", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("capability"); + + b.Property("ConfigPublic") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config_public") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_external_providers"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_external_providers_tenant_id_id"); + + b.HasIndex("TenantId", "Capability", "Provider") + .IsUnique() + .HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_provider"); + + b.HasIndex("TenantId", "Capability", "Status", "Priority") + .HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_status_prior~"); + + b.ToTable("tenant_external_providers", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b => { b.Property("Id") @@ -14363,16 +14318,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_svip_plans_regions_tenant_id_region_id"); }); - modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b => - { - b.HasOne("Tiku.Domain.Tenancy.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_tenant_payment_accounts_tenants_tenant_id"); - }); - modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -16541,16 +16486,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_tenants_users_owner_user_id"); }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b => - { - b.HasOne("Tiku.Domain.Tenancy.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_tenant_auth_providers_tenants_tenant_id"); - }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -16625,6 +16560,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_tenant_domains_tenants_tenant_id"); }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_external_providers_tenants_tenant_id"); + }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.cs b/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs similarity index 99% rename from Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.cs rename to Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs index 5464345..0b48a98 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs @@ -1087,31 +1087,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.Cascade); }); - migrationBuilder.CreateTable( - name: "tenant_auth_providers", - columns: table => new - { - id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), - provider = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), - config_public = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - tenant_id = table.Column(type: "uuid", nullable: false), - created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), - updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") - }, - constraints: table => - { - table.PrimaryKey("pk_tenant_auth_providers", x => x.id); - table.UniqueConstraint("ak_tenant_auth_providers_tenant_id_id", x => new { x.tenant_id, x.id }); - table.ForeignKey( - name: "fk_tenant_auth_providers_tenants_tenant_id", - column: x => x.tenant_id, - principalTable: "tenants", - principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); - migrationBuilder.CreateTable( name: "tenant_billing_profiles", columns: table => new @@ -1236,6 +1211,35 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "tenant_external_providers", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + capability = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + provider = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + secret_ref = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + priority = table.Column(type: "integer", nullable: false), + config_public = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + metadata = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + tenant_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_external_providers", x => x.id); + table.UniqueConstraint("ak_tenant_external_providers_tenant_id_id", x => new { x.tenant_id, x.id }); + table.ForeignKey( + name: "fk_tenant_external_providers_tenants_tenant_id", + column: x => x.tenant_id, + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "tenant_frontend_configs", columns: table => new @@ -1319,32 +1323,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.SetNull); }); - migrationBuilder.CreateTable( - name: "tenant_payment_accounts", - columns: table => new - { - id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), - provider = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - mode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), - status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - config_public = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - tenant_id = table.Column(type: "uuid", nullable: false), - created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), - updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") - }, - constraints: table => - { - table.PrimaryKey("pk_tenant_payment_accounts", x => x.id); - table.UniqueConstraint("ak_tenant_payment_accounts_tenant_id_id", x => new { x.tenant_id, x.id }); - table.ForeignKey( - name: "fk_tenant_payment_accounts_tenants_tenant_id", - column: x => x.tenant_id, - principalTable: "tenants", - principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); - migrationBuilder.CreateTable( name: "tenant_role_templates", columns: table => new @@ -7995,17 +7973,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations columns: new[] { "tenant_id", "code" }, unique: true); - migrationBuilder.CreateIndex( - name: "ix_tenant_auth_providers_tenant_id_provider", - table: "tenant_auth_providers", - columns: new[] { "tenant_id", "provider" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "ix_tenant_auth_providers_tenant_id_provider_status", - table: "tenant_auth_providers", - columns: new[] { "tenant_id", "provider", "status" }); - migrationBuilder.CreateIndex( name: "ix_tenant_class_members_tenant_id_class_id_status_member_type", table: "tenant_class_members", @@ -8117,6 +8084,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations unique: true, filter: "is_primary"); + migrationBuilder.CreateIndex( + name: "ix_tenant_external_providers_tenant_id_capability_provider", + table: "tenant_external_providers", + columns: new[] { "tenant_id", "capability", "provider" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_tenant_external_providers_tenant_id_capability_status_prior~", + table: "tenant_external_providers", + columns: new[] { "tenant_id", "capability", "status", "priority" }); + migrationBuilder.CreateIndex( name: "ix_tenant_frontend_configs_tenant_id", table: "tenant_frontend_configs", @@ -8214,12 +8192,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations table: "tenant_memberships", column: "user_id"); - migrationBuilder.CreateIndex( - name: "ix_tenant_payment_accounts_tenant_id_provider", - table: "tenant_payment_accounts", - columns: new[] { "tenant_id", "provider" }, - unique: true); - migrationBuilder.CreateIndex( name: "ix_tenant_question_bank_preferences_created_by", table: "tenant_question_bank_preferences", @@ -9142,9 +9114,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "subject_shares"); - migrationBuilder.DropTable( - name: "tenant_auth_providers"); - migrationBuilder.DropTable( name: "tenant_billing_profiles"); @@ -9163,6 +9132,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "tenant_domains"); + migrationBuilder.DropTable( + name: "tenant_external_providers"); + migrationBuilder.DropTable( name: "tenant_frontend_configs"); @@ -9175,9 +9147,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "tenant_memberships"); - migrationBuilder.DropTable( - name: "tenant_payment_accounts"); - migrationBuilder.DropTable( name: "tenant_question_bank_preferences"); diff --git a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs index 63bfc12..5fd04a3 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs @@ -3397,72 +3397,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations }); }); - modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id") - .HasDefaultValueSql("gen_random_uuid()"); - - b.Property("ConfigPublic") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("config_public") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("now()"); - - b.Property("DisplayName") - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("display_name"); - - b.Property("Mode") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("mode"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") - .HasColumnName("provider"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("status"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at") - .HasDefaultValueSql("now()"); - - b.HasKey("Id") - .HasName("pk_tenant_payment_accounts"); - - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_tenant_payment_accounts_tenant_id_id"); - - b.HasIndex("TenantId", "Provider") - .IsUnique() - .HasDatabaseName("ix_tenant_payment_accounts_tenant_id_provider"); - - b.ToTable("tenant_payment_accounts", (string)null); - }); - modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b => { b.Property("Id") @@ -12473,69 +12407,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenants", (string)null); }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id") - .HasDefaultValueSql("gen_random_uuid()"); - - b.Property("ConfigPublic") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("config_public") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("now()"); - - b.Property("DisplayName") - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("display_name"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") - .HasColumnName("provider"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("status"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at") - .HasDefaultValueSql("now()"); - - b.HasKey("Id") - .HasName("pk_tenant_auth_providers"); - - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_tenant_auth_providers_tenant_id_id"); - - b.HasIndex("TenantId", "Provider") - .IsUnique() - .HasDatabaseName("ix_tenant_auth_providers_tenant_id_provider"); - - b.HasIndex("TenantId", "Provider", "Status") - .HasDatabaseName("ix_tenant_auth_providers_tenant_id_provider_status"); - - b.ToTable("tenant_auth_providers", (string)null); - }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => { b.Property("TenantId") @@ -12897,6 +12768,90 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenant_domains", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("capability"); + + b.Property("ConfigPublic") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config_public") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_external_providers"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_external_providers_tenant_id_id"); + + b.HasIndex("TenantId", "Capability", "Provider") + .IsUnique() + .HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_provider"); + + b.HasIndex("TenantId", "Capability", "Status", "Priority") + .HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_status_prior~"); + + b.ToTable("tenant_external_providers", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b => { b.Property("Id") @@ -14360,16 +14315,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_svip_plans_regions_tenant_id_region_id"); }); - modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b => - { - b.HasOne("Tiku.Domain.Tenancy.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_tenant_payment_accounts_tenants_tenant_id"); - }); - modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -16538,16 +16483,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_tenants_users_owner_user_id"); }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b => - { - b.HasOne("Tiku.Domain.Tenancy.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_tenant_auth_providers_tenants_tenant_id"); - }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -16622,6 +16557,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_tenant_domains_tenants_tenant_id"); }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_external_providers_tenants_tenant_id"); + }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) diff --git a/Tiku.Infrastructure/Persistence/TikuDbContext.cs b/Tiku.Infrastructure/Persistence/TikuDbContext.cs index c71114d..e806db0 100644 --- a/Tiku.Infrastructure/Persistence/TikuDbContext.cs +++ b/Tiku.Infrastructure/Persistence/TikuDbContext.cs @@ -44,7 +44,7 @@ public sealed class TikuDbContext( public DbSet TenantBrandings => Set(); public DbSet TenantSettings => Set(); public DbSet TenantFrontendConfigs => Set(); - public DbSet TenantAuthProviders => Set(); + public DbSet TenantExternalProviders => Set(); public DbSet TenantSecrets => Set(); public DbSet SmsVerificationCodes => Set(); public DbSet AuthLoginEvents => Set(); @@ -124,7 +124,6 @@ public sealed class TikuDbContext( public DbSet ActivationCodes => Set(); public DbSet Coupons => Set(); public DbSet CouponRedemptions => Set(); - public DbSet TenantPaymentAccounts => Set(); public DbSet TenantSubscriptions => Set(); public DbSet TenantUsageRecords => Set(); public DbSet CommerceRefundRequests => Set(); diff --git a/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs index 6e86b38..1a98f13 100644 --- a/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs +++ b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs @@ -17,7 +17,6 @@ public sealed partial class AliyunOssObjectStorageService( private static readonly HashSet SupportedProviders = [ ObjectStorageProviders.ExternalUrl, - ObjectStorageProviders.SupabaseStorage, ObjectStorageProviders.AliyunOss, ObjectStorageProviders.TencentCos, ObjectStorageProviders.QiniuKodo, diff --git a/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs b/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs new file mode 100644 index 0000000..4ec6de1 --- /dev/null +++ b/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Tenancy; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Tenancy; + +internal sealed class TenantExternalProviderConfigService( + TikuDbContext dbContext, + ITenantSecretService tenantSecretService) : ITenantExternalProviderConfigService +{ + public async Task GetActiveProviderAsync( + Guid tenantId, + TenantExternalProviderCapability capability, + string? provider = null, + CancellationToken cancellationToken = default) + { + var normalizedProvider = NormalizeProvider(provider); + var query = dbContext.TenantExternalProviders + .AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.Capability == capability && + item.Status == TenantExternalProviderStatus.Active); + + if (!string.IsNullOrWhiteSpace(normalizedProvider)) + { + query = query.Where(item => item.Provider == normalizedProvider); + } + + var item = await query + .OrderBy(item => item.Priority) + .ThenBy(item => item.Provider) + .FirstOrDefaultAsync(cancellationToken); + + if (item is null) + { + throw new TenantExternalProviderException( + "Tenant external provider is not configured.", + "tenant_external_provider_not_configured"); + } + + var secretPayload = string.IsNullOrWhiteSpace(item.SecretRef) + ? JsonDocument.Parse("{}").RootElement.Clone() + : await tenantSecretService.GetActiveSecretPayloadAsync( + tenantId, + item.SecretRef, + cancellationToken); + + return new TenantExternalProviderAccount( + item.TenantId, + item.Capability, + item.Provider, + item.Status, + item.DisplayName, + item.ConfigPublic, + secretPayload, + item.SecretRef, + item.Priority, + item.Metadata); + } + + public async Task> GetProvidersAsync( + Guid tenantId, + TenantExternalProviderCapability? capability = null, + string? provider = null, + int? limit = null, + CancellationToken cancellationToken = default) + { + var normalizedProvider = NormalizeProvider(provider); + var query = dbContext.TenantExternalProviders + .AsNoTracking() + .Where(item => item.TenantId == tenantId); + + if (capability.HasValue) + { + query = query.Where(item => item.Capability == capability.Value); + } + + if (!string.IsNullOrWhiteSpace(normalizedProvider)) + { + query = query.Where(item => item.Provider == normalizedProvider); + } + + return await query + .OrderBy(item => item.Capability) + .ThenBy(item => item.Priority) + .ThenBy(item => item.Provider) + .Take(Math.Clamp(limit ?? 100, 1, 200)) + .Select(item => ToItem(item)) + .ToArrayAsync(cancellationToken); + } + + public async Task UpsertProviderAsync( + Guid tenantId, + UpsertTenantExternalProviderCommand command, + CancellationToken cancellationToken = default) + { + var provider = NormalizeRequiredProvider(command.Provider); + AssertPublicConfig(command.ConfigPublic, "config_public"); + AssertPublicConfig(command.Metadata, "metadata"); + + var item = await dbContext.TenantExternalProviders + .SingleOrDefaultAsync( + providerConfig => + providerConfig.TenantId == tenantId && + providerConfig.Capability == command.Capability && + providerConfig.Provider == provider, + cancellationToken); + + if (item is null) + { + item = new TenantExternalProvider + { + TenantId = tenantId, + Capability = command.Capability, + Provider = provider + }; + dbContext.TenantExternalProviders.Add(item); + } + + item.Status = command.Status; + item.DisplayName = command.DisplayName?.Trim(); + item.SecretRef = string.IsNullOrWhiteSpace(command.SecretRef) ? null : command.SecretRef.Trim(); + item.Priority = Math.Clamp(command.Priority ?? item.Priority, 0, 10_000); + item.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic); + item.Metadata = JsonObjectOrDefault(command.Metadata); + + await dbContext.SaveChangesAsync(cancellationToken); + return ToItem(item); + } + + public static string NormalizeRequiredProvider(string provider) + { + var normalized = NormalizeProvider(provider); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new TenantExternalProviderException("Provider is required.", "provider_required"); + } + + return normalized; + } + + public static string? NormalizeProvider(string? provider) + { + var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + return normalized switch + { + "wechat" or "wechatpay" or "wxpay" or "wx_pay" => "wechat_pay", + "ali_pay" => "alipay", + "" => null, + _ => normalized + }; + } + + private static TenantExternalProviderItem ToItem(TenantExternalProvider item) => + new( + item.Id, + item.Capability, + item.Provider, + item.Status, + item.DisplayName, + item.SecretRef, + item.Priority, + item.ConfigPublic, + item.Metadata, + item.CreatedAt, + item.UpdatedAt); + + private static JsonElement JsonObjectOrDefault(JsonElement element) => + element.ValueKind == JsonValueKind.Object + ? element.Clone() + : JsonSerializer.SerializeToElement(new { }); + + private static void AssertPublicConfig(JsonElement element, string path) + { + if (element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return; + } + + if (element.ValueKind != JsonValueKind.Object) + { + throw new TenantExternalProviderException( + $"{path} must be a JSON object.", + "provider_config_must_be_object"); + } + + foreach (var property in element.EnumerateObject()) + { + var key = property.Name.Replace("_", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); + if (key.Contains("secret", StringComparison.Ordinal) || + key.Contains("token", StringComparison.Ordinal) || + key.Contains("privatekey", StringComparison.Ordinal) || + key is "key" or "apikey" or "accesskey" or "accesskeyid" or "accesskeysecret" or "appsecret" or "apiv3key") + { + throw new TenantExternalProviderException( + $"{path} cannot contain secrets.", + "public_config_contains_secret"); + } + + if (property.Value.ValueKind == JsonValueKind.Object) + { + AssertPublicConfig(property.Value, $"{path}.{property.Name}"); + } + } + } +} diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs index e629801..027b31e 100644 --- a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -2,6 +2,8 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; using Tiku.Domain.Catalog; using Tiku.Domain.Common; @@ -13,7 +15,10 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.TenantAdmin; -public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantAdminDirectService +public sealed class TenantAdminDirectService( + TikuDbContext dbContext, + ITenantExternalProviderConfigService providerConfigService, + INotificationProvider notificationProvider) : ITenantAdminDirectService { private static readonly TenantAdminPermissionCatalogItem[] PermissionCatalog = [ @@ -1099,42 +1104,39 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA return new ContentManagementResult(ToDomainItem(item)); } - public async Task> GetAuthProvidersAsync( + public async Task> GetAuthProvidersAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { - var items = await dbContext.TenantAuthProviders.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .OrderBy(item => item.CreatedAt) - .Select(item => ToAuthProviderItem(item)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); + var items = await providerConfigService.GetProvidersAsync( + actor.TenantId, + TenantExternalProviderCapability.Identity, + cancellationToken: cancellationToken); + return new CatalogList(items.Select(ToAuthProviderItem).ToArray()); } - public async Task> UpsertAuthProviderAsync( + public async Task> UpsertAuthProviderAsync( TenantAdminActor actor, - UpsertTenantAuthProviderCommand command, + UpsertTenantIdentityProviderCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider); - AssertNoSecrets(command.ConfigPublic, "config_public"); - var provider = command.Provider.Trim().ToLowerInvariant(); - var item = await dbContext.TenantAuthProviders.FirstOrDefaultAsync( - authProvider => authProvider.TenantId == actor.TenantId && authProvider.Provider == provider, + var item = await providerConfigService.UpsertProviderAsync( + actor.TenantId, + new UpsertTenantExternalProviderCommand( + TenantExternalProviderCapability.Identity, + command.Provider, + ParseEnum(command.Status, TenantExternalProviderStatus.Disabled, "invalid_auth_provider_status"), + command.DisplayName, + command.SecretRef, + command.Priority, + command.ConfigPublic, + JsonObjectOrDefault(default)), cancellationToken); - var isNew = item is null; - item ??= new TenantAuthProvider { TenantId = actor.TenantId, Provider = provider }; - item.Status = ParseEnum(command.Status, TenantAuthProviderStatus.Disabled, "invalid_auth_provider_status"); - item.DisplayName = Normalize(command.DisplayName); - item.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic); - if (isNew) - { - dbContext.TenantAuthProviders.Add(item); - } - await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_auth_providers", item.Id, cancellationToken); + await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToAuthProviderItem(item)); + return new ContentManagementResult(ToAuthProviderItem(item)); } public async Task> GetBadgesAsync( @@ -1273,33 +1275,22 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA } var dedupeKey = $"badge:{grant.Id:N}"; - var notification = await dbContext.UserNotifications.FirstOrDefaultAsync( - item => - item.TenantId == actor.TenantId && - item.UserId == command.UserId && - item.NotificationType == "badge_granted" && - item.DedupeKey == dedupeKey, + await notificationProvider.UpsertInAppAsync( + new InAppNotificationRequest( + actor.TenantId, + command.UserId, + "badge_granted", + NotificationSeverity.Success, + $"获得勋章:{badge.Name}", + Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。", + actor.UserId, + "查看勋章", + "/profile?tab=badges", + "user_badges", + grant.Id, + dedupeKey, + JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name })), cancellationToken); - notification ??= new UserNotification - { - TenantId = actor.TenantId, - UserId = command.UserId, - NotificationType = "badge_granted", - DedupeKey = dedupeKey, - CreatedBy = actor.UserId - }; - notification.Severity = NotificationSeverity.Success; - notification.Title = $"获得勋章:{badge.Name}"; - notification.Message = Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。"; - notification.ActionLabel = "查看勋章"; - notification.ActionPath = "/profile?tab=badges"; - notification.SourceType = "user_badges"; - notification.SourceId = grant.Id; - notification.Metadata = JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name }); - if (dbContext.Entry(notification).State == EntityState.Detached) - { - dbContext.UserNotifications.Add(notification); - } await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); @@ -1346,35 +1337,22 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA ArgumentException.ThrowIfNullOrWhiteSpace(command.Message); await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken); - var item = !string.IsNullOrWhiteSpace(command.DedupeKey) - ? await dbContext.UserNotifications.FirstOrDefaultAsync(notification => - notification.TenantId == actor.TenantId && - notification.UserId == command.UserId && - notification.NotificationType == command.NotificationType && - notification.DedupeKey == command.DedupeKey, - cancellationToken) - : null; - var isNew = item is null; - item ??= new UserNotification - { - TenantId = actor.TenantId, - UserId = command.UserId, - CreatedBy = actor.UserId - }; - item.NotificationType = command.NotificationType.Trim(); - item.Severity = ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"); - item.Title = command.Title.Trim(); - item.Message = command.Message.Trim(); - item.ActionLabel = Normalize(command.ActionLabel); - item.ActionPath = Normalize(command.ActionPath); - item.SourceType = Normalize(command.SourceType); - item.SourceId = command.SourceId; - item.DedupeKey = Normalize(command.DedupeKey); - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.UserNotifications.Add(item); - } + var item = await notificationProvider.UpsertInAppAsync( + new InAppNotificationRequest( + actor.TenantId, + command.UserId, + command.NotificationType, + ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"), + command.Title, + command.Message, + actor.UserId, + command.ActionLabel, + command.ActionPath, + command.SourceType, + command.SourceId, + command.DedupeKey, + JsonObjectOrDefault(command.Metadata)), + cancellationToken); await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); @@ -1941,13 +1919,15 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA item.UpdatedAt); } - private static TenantAuthProviderItem ToAuthProviderItem(TenantAuthProvider item) + private static TenantIdentityProviderItem ToAuthProviderItem(TenantExternalProviderItem item) { - return new TenantAuthProviderItem( + return new TenantIdentityProviderItem( item.Id, item.Provider, item.Status, item.DisplayName, + item.SecretRef, + item.Priority, item.ConfigPublic, item.CreatedAt, item.UpdatedAt); diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs index d10cd5e..b5af5dc 100644 --- a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -2,11 +2,13 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Tiku.Application.Auth; using Tiku.Api.Contracts; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Commerce; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -166,6 +168,11 @@ public sealed class AuthEndpointTests { await using var factory = new ApiTestFactory(new FakeWechatOAuthClient()); var tenantId = Guid.NewGuid(); + var secretRef = "tenant_secrets:identity:wechat_miniapp:default"; + var protectedSecret = ProtectTenantSecret( + tenantId, + secretRef, + JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" })); await factory.SeedAsync( new Tenant { @@ -173,16 +180,30 @@ public sealed class AuthEndpointTests Slug = tenantId.ToString("N"), Name = "Wechat Tenant" }, - new TenantAuthProvider + new TenantExternalProvider { TenantId = tenantId, - Provider = "wechat-miniapp", - Status = TenantAuthProviderStatus.Testing, + Provider = "wechat_miniapp", + Capability = TenantExternalProviderCapability.Identity, + Status = TenantExternalProviderStatus.Active, + SecretRef = secretRef, ConfigPublic = JsonSerializer.SerializeToElement(new { - appId = "wx-app-id", - appSecret = "wx-app-secret" + appId = "wx-app-id" }) + }, + new TenantSecret + { + TenantId = tenantId, + Purpose = "identity", + Provider = "wechat_miniapp", + SecretKey = "default", + SecretRef = secretRef, + Status = TenantSecretStatus.Active, + EncryptionKeyId = protectedSecret.KeyId, + EncryptedPayload = protectedSecret.Ciphertext, + EncryptionNonce = protectedSecret.Nonce, + EncryptionTag = protectedSecret.Tag }); using var client = factory.CreateClient(); @@ -207,7 +228,7 @@ public sealed class AuthEndpointTests Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Contains(dbContext.UserIdentities, identity => - identity.Provider == "wechat-miniapp" && + identity.Provider == "wechat_miniapp" && identity.OpenId == "mini-open-id" && identity.UnionId == "union-id"); } @@ -285,6 +306,16 @@ public sealed class AuthEndpointTests return document.RootElement.Clone(); } + private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload) + { + var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions + { + KeyId = "development-v1", + MasterKey = TenantSecretEncryptionOptions.DevelopmentMasterKey + })); + return protector.Protect(tenantId, secretRef, payload); + } + private sealed class FakeWechatOAuthClient : IWechatOAuthClient { public Task ExchangeWebCodeAsync( diff --git a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs index 4623c10..7537267 100644 --- a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs @@ -375,11 +375,13 @@ public sealed class CommerceEndpointTests Days = 39, IsActive = true }, - new TenantPaymentAccount + new TenantExternalProvider { TenantId = tenantId, + Capability = TenantExternalProviderCapability.Payment, Provider = "manual", - Status = TenantPaymentAccountStatus.Active + Status = TenantExternalProviderStatus.Active, + ConfigPublic = JsonSerializer.SerializeToElement(new { mode = "TenantCollect" }) } }; if (includeMembership) diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs index 64a808c..1503841 100644 --- a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -217,7 +217,7 @@ public sealed class TenantAdminDirectEndpointTests }); var authProviderResponse = await client.PutAsJsonAsync( "/api/tenant-admin/auth-providers", - new UpsertTenantAuthProviderDto + new UpsertTenantIdentityProviderDto { Provider = "wechat_mp", Status = "active", @@ -244,7 +244,10 @@ public sealed class TenantAdminDirectEndpointTests Assert.True(await dbContext.TenantBrandings.AnyAsync(item => item.TenantId == seed.TenantId && item.BrandName == "机构题库")); Assert.True(await dbContext.TenantThemeConfigs.AnyAsync(item => item.TenantId == seed.TenantId && item.ActiveTemplateCode == "classic")); Assert.True(await dbContext.TenantDomains.AnyAsync(item => item.TenantId == seed.TenantId && item.Host == "learn.example.com" && item.IsPrimary)); - Assert.True(await dbContext.TenantAuthProviders.AnyAsync(item => item.TenantId == seed.TenantId && item.Provider == "wechat_mp")); + Assert.True(await dbContext.TenantExternalProviders.AnyAsync(item => + item.TenantId == seed.TenantId && + item.Capability == TenantExternalProviderCapability.Identity && + item.Provider == "wechat_mp")); } [Fact] diff --git a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs index 1afc4e6..8da8d1f 100644 --- a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs @@ -42,7 +42,7 @@ public sealed class TenantCommerceEndpointTests new UpsertPaymentAccountDto { Provider = "wechat_pay", - Status = TenantPaymentAccountStatus.Active, + Status = TenantExternalProviderStatus.Active, ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch", @@ -80,11 +80,11 @@ public sealed class TenantCommerceEndpointTests new UpsertPaymentAccountDto { Provider = "wechat_pay", - Status = TenantPaymentAccountStatus.Active, + Status = TenantExternalProviderStatus.Active, + SecretRef = "tenant_secrets:payment:wechat_pay:default", ConfigPublic = JsonSerializer.SerializeToElement(new { - merchantId = "mch", - secretRef = "tenant_secrets:payment:wechat_pay:default" + merchantId = "mch" }) }); var accountsResponse = await client.GetAsync("/api/tenant-commerce/payment-accounts?provider=wechat_pay"); diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index b1a2347..a792003 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -39,6 +39,46 @@ public sealed class ArchitectureBoundaryTests $"Forbidden tenant-boundary bypasses were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); } + [Fact] + public void Business_code_does_not_depend_on_supabase_or_removed_provider_models() + { + var root = FindRepositoryRoot(); + var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain", "Tiku.Infrastructure" }; + var forbidden = new[] + { + "Supabase", + "supabase_storage", + "SUPABASE_", + "TenantAuthProvider", + "TenantPaymentAccount", + "TenantPaymentMode", + "TenantPaymentAccountStatus", + "TenantAuthProviderStatus" + }; + + var allowedPathFragments = new[] + { + $"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}" + }; + + var violations = sourceRoots + .SelectMany(directory => Directory.EnumerateFiles( + Path.Combine(root, directory), + "*.cs", + SearchOption.AllDirectories)) + .Where(path => !allowedPathFragments.Any(fragment => path.Contains(fragment, StringComparison.Ordinal))) + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { path, line, lineNumber = index + 1 })) + .Where(candidate => forbidden.Any(symbol => + candidate.line.Contains(symbol, StringComparison.Ordinal))) + .Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}") + .ToArray(); + + Assert.True( + violations.Length == 0, + $"Forbidden external-provider coupling was found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + private static string FindRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/Tiku.IntegrationTests/PersistenceModelTests.cs b/Tiku.IntegrationTests/PersistenceModelTests.cs index 864490b..3cd60c0 100644 --- a/Tiku.IntegrationTests/PersistenceModelTests.cs +++ b/Tiku.IntegrationTests/PersistenceModelTests.cs @@ -68,7 +68,7 @@ public sealed class PersistenceModelTests Assert.Contains("practice_session_report_sections", tableNames); Assert.Contains("dashboard_daily_stats", tableNames); Assert.Contains("revenue_daily_stats", tableNames); - Assert.Contains("tenant_auth_providers", tableNames); + Assert.Contains("tenant_external_providers", tableNames); Assert.Contains("tenant_secrets", tableNames); Assert.Contains("sms_verification_codes", tableNames); Assert.Contains("auth_login_events", tableNames); @@ -90,7 +90,6 @@ public sealed class PersistenceModelTests Assert.Contains("activation_codes", tableNames); Assert.Contains("coupons", tableNames); Assert.Contains("coupon_redemptions", tableNames); - Assert.Contains("tenant_payment_accounts", tableNames); Assert.Contains("tenant_subscriptions", tableNames); Assert.Contains("tenant_usage_records", tableNames); Assert.Contains("commerce_refund_requests", tableNames); @@ -506,7 +505,8 @@ public sealed class PersistenceModelTests } [Theory] - [InlineData(typeof(TenantAuthProvider), nameof(TenantAuthProvider.ConfigPublic), "'{}'::jsonb")] + [InlineData(typeof(TenantExternalProvider), nameof(TenantExternalProvider.ConfigPublic), "'{}'::jsonb")] + [InlineData(typeof(TenantExternalProvider), nameof(TenantExternalProvider.Metadata), "'{}'::jsonb")] [InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")] [InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")] [InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")] @@ -536,9 +536,10 @@ public sealed class PersistenceModelTests { using var context = new TikuDbContext(Options); - AssertHasUniqueIndex( - nameof(TenantAuthProvider.TenantId), - nameof(TenantAuthProvider.Provider)); + AssertHasUniqueIndex( + nameof(TenantExternalProvider.TenantId), + nameof(TenantExternalProvider.Capability), + nameof(TenantExternalProvider.Provider)); AssertHasUniqueIndex( nameof(TenantSecret.TenantId), nameof(TenantSecret.SecretRef)); @@ -574,7 +575,6 @@ public sealed class PersistenceModelTests [InlineData(typeof(Payment), nameof(Payment.RawPayload), "'{}'::jsonb")] [InlineData(typeof(PaymentEvent), nameof(PaymentEvent.Payload), "'{}'::jsonb")] [InlineData(typeof(Entitlement), nameof(Entitlement.Metadata), "'{}'::jsonb")] - [InlineData(typeof(TenantPaymentAccount), nameof(TenantPaymentAccount.ConfigPublic), "'{}'::jsonb")] [InlineData(typeof(TenantSubscription), nameof(TenantSubscription.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantUsageRecord), nameof(TenantUsageRecord.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommerceRefundRequest), nameof(CommerceRefundRequest.Metadata), "'{}'::jsonb")] @@ -622,9 +622,6 @@ public sealed class PersistenceModelTests AssertHasUniqueIndex(nameof(Order.TenantId), nameof(Order.OrderNo)); AssertHasUniqueIndex(nameof(ActivationCode.TenantId), nameof(ActivationCode.Code)); - AssertHasUniqueIndex( - nameof(TenantPaymentAccount.TenantId), - nameof(TenantPaymentAccount.Provider)); AssertHasUniqueIndex( nameof(CommerceRefundRequest.TenantId), nameof(CommerceRefundRequest.RefundNo)); diff --git a/Tiku.UnitTests/Auth/AuthServiceTests.cs b/Tiku.UnitTests/Auth/AuthServiceTests.cs index db006b6..37e721c 100644 --- a/Tiku.UnitTests/Auth/AuthServiceTests.cs +++ b/Tiku.UnitTests/Auth/AuthServiceTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Tiku.Application.Auth; using Tiku.Application.Security; +using Tiku.Application.Tenancy; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; @@ -182,7 +183,7 @@ public sealed class AuthServiceTests Assert.Equal(tenantId, result.Tenant.TenantId); Assert.Single(context.Users); Assert.Contains(context.UserIdentities, identity => - identity.Provider == "wechat-miniapp" && + identity.Provider == "wechat_miniapp" && identity.ProviderSubject == "wx-app-id:mini-open-id" && identity.OpenId == "mini-open-id" && identity.UnionId == "union-id"); @@ -198,11 +199,13 @@ public sealed class AuthServiceTests { await using var context = CreateContext(); var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp"); - context.TenantAuthProviders.Add(new TenantAuthProvider + context.TenantExternalProviders.Add(new TenantExternalProvider { TenantId = tenantId, Provider = "wechat_web", - Status = TenantAuthProviderStatus.Testing, + Capability = TenantExternalProviderCapability.Identity, + Status = TenantExternalProviderStatus.Active, + SecretRef = "tenant_secrets:identity:wechat_web:default", ConfigPublic = WechatProviderConfig() }); var user = new User @@ -257,14 +260,15 @@ public sealed class AuthServiceTests { var tokenService = new TokenService(Options.Create(JwtOptions)); var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions)); - var smsService = new SmsVerificationService(context); + var smsService = new SmsVerificationService(context, new FakeSmsProvider()); return new AuthService( context, new PasswordHasher(), smsService, sessionService, - wechatOAuthClient ?? new FakeWechatOAuthClient()); + wechatOAuthClient ?? new FakeWechatOAuthClient(), + new FakeProviderConfigService(context)); } private static async Task SeedTenantWithWechatProviderAsync( @@ -278,11 +282,13 @@ public sealed class AuthServiceTests Name = "Wechat Tenant" }; context.Tenants.Add(tenant); - context.TenantAuthProviders.Add(new TenantAuthProvider + context.TenantExternalProviders.Add(new TenantExternalProvider { TenantId = tenant.Id, - Provider = provider, - Status = TenantAuthProviderStatus.Testing, + Provider = provider.Replace("-", "_", StringComparison.Ordinal), + Capability = TenantExternalProviderCapability.Identity, + Status = TenantExternalProviderStatus.Active, + SecretRef = $"tenant_secrets:identity:{provider}:default", ConfigPublic = WechatProviderConfig() }); await context.SaveChangesAsync(); @@ -294,8 +300,7 @@ public sealed class AuthServiceTests { return JsonSerializer.SerializeToElement(new { - appId = "wx-app-id", - appSecret = "wx-app-secret" + appId = "wx-app-id" }); } @@ -347,6 +352,64 @@ public sealed class AuthServiceTests return document.RootElement.Clone(); } + private sealed class FakeProviderConfigService(TikuDbContext context) : ITenantExternalProviderConfigService + { + public Task GetActiveProviderAsync( + Guid tenantId, + TenantExternalProviderCapability capability, + string? provider = null, + CancellationToken cancellationToken = default) + { + var normalizedProvider = provider?.Replace("-", "_", StringComparison.Ordinal); + var item = context.TenantExternalProviders + .AsEnumerable() + .Where(entity => + entity.TenantId == tenantId && + entity.Capability == capability && + entity.Status == TenantExternalProviderStatus.Active) + .Where(entity => string.IsNullOrWhiteSpace(normalizedProvider) || entity.Provider == normalizedProvider) + .OrderBy(entity => entity.Priority) + .FirstOrDefault() + ?? throw new TenantExternalProviderException( + "Tenant external provider is not configured.", + "tenant_external_provider_not_configured"); + + return Task.FromResult(new TenantExternalProviderAccount( + item.TenantId, + item.Capability, + item.Provider, + item.Status, + item.DisplayName, + item.ConfigPublic, + JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }), + item.SecretRef, + item.Priority, + item.Metadata)); + } + + public Task> GetProvidersAsync( + Guid tenantId, + TenantExternalProviderCapability? capability = null, + string? provider = null, + int? limit = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task UpsertProviderAsync( + Guid tenantId, + UpsertTenantExternalProviderCommand command, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + private sealed class FakeSmsProvider : ISmsProvider + { + public Task SendAsync( + SmsProviderSendRequest request, + CancellationToken cancellationToken = default) => + Task.FromResult(new SmsProviderSendResult("fake", "accepted")); + } + private sealed class FakeWechatOAuthClient( WechatIdentity? WebIdentity = null, WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient diff --git a/docs/adr/0001-authoritative-dotnet-backend.md b/docs/adr/0001-authoritative-dotnet-backend.md index 2c5dac6..5d5fb6f 100644 --- a/docs/adr/0001-authoritative-dotnet-backend.md +++ b/docs/adr/0001-authoritative-dotnet-backend.md @@ -12,8 +12,8 @@ 1. 本仓库是题库 SaaS 唯一继续演进的后端。 2. 旧 NestJS 仓库冻结为业务行为、接口契约和数据迁移参考;除迁移阻断问题外,不再承接新功能。 3. EF Core Migration 是普通数据库结构变更的唯一权威历史。生产环境通过 `Tiku.DbMigrator` 显式执行经审查的迁移,API 不在启动时自动同步结构。 -4. 应用只依赖标准 PostgreSQL 能力和 Npgsql。Supabase 可以临时作为 PostgreSQL 托管商,但不是应用层依赖。 -5. 身份、对象存储、短信、支付和通知通过 Application 层接口隔离供应商。前端不直连 Supabase 业务表。 +4. 应用只依赖标准 PostgreSQL 能力和 Npgsql,不再以 Supabase 作为运行时依赖或托管目标。 +5. 身份、对象存储、短信、支付和通知通过 Application 层接口隔离供应商,并通过租户级 `TenantExternalProvider` + `TenantSecret` 配置。 6. Yudao 不作为运行时依赖,只参考其 RBAC、租户套餐、审计和后台产品设计。 7. 新功能按完整领域闭环迁移和验收,不以 Controller 或 endpoint 数量作为完成标准。 @@ -27,5 +27,5 @@ ## 结果 - 不再建设长期双后端或双写链路。 -- 更换 PostgreSQL 托管商不要求重写业务代码。 +- 更换 PostgreSQL 托管商或外部服务供应商不要求重写业务代码。 - 后续开发先补真实 PostgreSQL 测试、强制租户边界、生产配置 fail-fast 和敏感配置加密,再扩展业务模块。 diff --git a/docs/migration-roadmap.md b/docs/migration-roadmap.md index cfef723..e59422d 100644 --- a/docs/migration-roadmap.md +++ b/docs/migration-roadmap.md @@ -28,7 +28,8 @@ - Serilog 结构化日志。 - CORS / RateLimiter / Options 校验。 - Scalar / OpenAPI 基础入口。 -- 阿里云 OSS V2 资源存储抽象。 +- 统一租户外部服务配置:`TenantExternalProvider` + `TenantSecret`。 +- 阿里云 OSS V2 资源存储抽象,业务层不感知 Supabase Storage 或 bucket 细节。 - Senparc 微信登录与小程序码生成边界。 ### 数据库