feat: modularize external providers

This commit is contained in:
2026-07-27 17:47:21 +08:00
parent db4c7b4496
commit 70b99e6063
47 changed files with 1219 additions and 667 deletions

View File

@@ -1,6 +1,6 @@
# TIKU Backend # 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 和前端约定之间。新后端把这些东西收回应用层和数据库约束里,开发、排查、审计都会更直接。 当前判断很明确:现在团队已经有后端开发,继续把核心认证、权限、多租户隔离、业务一致性交给 BaaS 规则会让复杂度藏在平台、SQL policy 和前端约定之间。新后端把这些东西收回应用层和数据库约束里,开发、排查、审计都会更直接。
@@ -74,12 +74,13 @@ Tiku.IntegrationTests # API / EF 模型集成测试
### 5. 资源存储不绑 Supabase ### 5. 资源存储不绑 Supabase
旧版资源层实际使用 Node `ali-oss`。新后端按这个方向迁移到 `AlibabaCloud.OSS.V2` 旧版资源层实际使用 Node `ali-oss`。新后端已经按这个方向迁移到 `AlibabaCloud.OSS.V2`
- Application 只依赖 `IObjectStorageService` - Application 只依赖 `IObjectStorageService`
- Infrastructure 收敛阿里云 OSS SDK 细节。 - Infrastructure 收敛阿里云 OSS SDK 细节。
- 租户外部服务统一通过 `TenantExternalProvider` + `TenantSecret` 配置。
- 对象 key 默认要求租户前缀,避免资源混放。 - 对象 key 默认要求租户前缀,避免资源混放。
- 上传签名前校验 MIME、大小和 provider。 - 上传签名前校验 MIME、大小和租户对象存储 provider。
- 下载/预览必须先过业务授权,再签发临时 URL。 - 下载/预览必须先过业务授权,再签发临时 URL。
- OSS HEAD metadata 用于上传确认、大小/MIME/ETag/安全扫描校验。 - OSS HEAD metadata 用于上传确认、大小/MIME/ETag/安全扫描校验。
@@ -90,7 +91,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试
数据库模型迁移已经完成到 greenfield 初始 schema 数据库模型迁移已经完成到 greenfield 初始 schema
```text ```text
Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.cs Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
``` ```
当前 EF 模型覆盖: 当前 EF 模型覆盖:

View File

@@ -453,16 +453,18 @@ public sealed class CreateTenantDomainDto
} }
} }
public sealed class UpsertTenantAuthProviderDto public sealed class UpsertTenantIdentityProviderDto
{ {
public required string Provider { get; set; } public required string Provider { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public string? DisplayName { 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 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);
} }
} }

View File

@@ -29,18 +29,24 @@ public sealed class UpsertPaymentAccountDto
[StringLength(50)] [StringLength(50)]
public string Provider { get; set; } = string.Empty; public string Provider { get; set; } = string.Empty;
public TenantPaymentMode Mode { get; set; } = TenantPaymentMode.TenantCollect; [StringLength(50)]
public string? Mode { get; set; } = "TenantCollect";
[StringLength(200)] [StringLength(200)]
public string? DisplayName { get; set; } 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 JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
public UpsertPaymentAccountCommand ToCommand() public UpsertPaymentAccountCommand ToCommand()
{ {
return new UpsertPaymentAccountCommand(Provider, Mode, DisplayName, Status, ConfigPublic); return new UpsertPaymentAccountCommand(Provider, Mode, DisplayName, Status, SecretRef, Priority, ConfigPublic);
} }
} }

View File

@@ -302,17 +302,17 @@ public sealed class TenantAdminDirectController(
[HttpGet("auth-providers")] [HttpGet("auth-providers")]
[EndpointSummary("查询租户登录 Provider 公开配置")] [EndpointSummary("查询租户登录 Provider 公开配置")]
[ProducesResponseType<CatalogList<TenantAuthProviderItem>>(StatusCodes.Status200OK)] [ProducesResponseType<CatalogList<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAuthProviderItem>>> GetAuthProviders(CancellationToken cancellationToken) public async Task<ActionResult<CatalogList<TenantIdentityProviderItem>>> GetAuthProviders(CancellationToken cancellationToken)
{ {
return Ok(await tenantAdminService.GetAuthProvidersAsync(ResolveActor(), cancellationToken)); return Ok(await tenantAdminService.GetAuthProvidersAsync(ResolveActor(), cancellationToken));
} }
[HttpPut("auth-providers")] [HttpPut("auth-providers")]
[EndpointSummary("新增或更新租户登录 Provider")] [EndpointSummary("新增或更新租户登录 Provider")]
[ProducesResponseType<ContentManagementResult<TenantAuthProviderItem>>(StatusCodes.Status200OK)] [ProducesResponseType<ContentManagementResult<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAuthProviderItem>>> UpsertAuthProvider( public async Task<ActionResult<ContentManagementResult<TenantIdentityProviderItem>>> UpsertAuthProvider(
UpsertTenantAuthProviderDto request, UpsertTenantIdentityProviderDto request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
return Ok(await tenantAdminService.UpsertAuthProviderAsync(ResolveActor(), request.ToCommand(), cancellationToken)); return Ok(await tenantAdminService.UpsertAuthProviderAsync(ResolveActor(), request.ToCommand(), cancellationToken));

View File

@@ -17,8 +17,8 @@ public sealed class TenantCommerceController(
{ {
[HttpGet("payment-accounts")] [HttpGet("payment-accounts")]
[EndpointSummary("查询租户支付账号")] [EndpointSummary("查询租户支付账号")]
[ProducesResponseType<IReadOnlyCollection<TenantPaymentAccountItem>>(StatusCodes.Status200OK)] [ProducesResponseType<IReadOnlyCollection<TenantPaymentProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyCollection<TenantPaymentAccountItem>>> PaymentAccounts( public async Task<ActionResult<IReadOnlyCollection<TenantPaymentProviderItem>>> PaymentAccounts(
[FromQuery] TenantCommerceQueryDto query, [FromQuery] TenantCommerceQueryDto query,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -30,8 +30,8 @@ public sealed class TenantCommerceController(
[HttpPut("payment-accounts")] [HttpPut("payment-accounts")]
[EndpointSummary("新增或更新租户支付账号")] [EndpointSummary("新增或更新租户支付账号")]
[ProducesResponseType<TenantPaymentAccountItem>(StatusCodes.Status200OK)] [ProducesResponseType<TenantPaymentProviderItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPaymentAccountItem>> UpsertPaymentAccount( public async Task<ActionResult<TenantPaymentProviderItem>> UpsertPaymentAccount(
UpsertPaymentAccountDto request, UpsertPaymentAccountDto request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {

View File

@@ -284,6 +284,16 @@ public sealed class ExceptionHandlingMiddleware(
return; return;
} }
if (exception is TenantExternalProviderException externalProviderException)
{
await WriteProblemAsync(
context,
externalProviderException.Message,
StatusCodes.Status400BadRequest,
externalProviderException.Code);
return;
}
if (exception is ObjectStorageException storageException) if (exception is ObjectStorageException storageException)
{ {
await WriteProblemAsync( await WriteProblemAsync(

View File

@@ -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<IdentityProviderResult> AuthenticateAsync(
IdentityProviderRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -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<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -12,17 +12,21 @@ public sealed record TenantPointQuery(string? Status = null, int? Limit = null,
public sealed record UpsertPaymentAccountCommand( public sealed record UpsertPaymentAccountCommand(
string Provider, string Provider,
TenantPaymentMode Mode, string? Mode,
string? DisplayName, string? DisplayName,
TenantPaymentAccountStatus Status, TenantExternalProviderStatus Status,
string? SecretRef,
int? Priority,
JsonElement ConfigPublic); JsonElement ConfigPublic);
public sealed record TenantPaymentAccountItem( public sealed record TenantPaymentProviderItem(
Guid Id, Guid Id,
string Provider, string Provider,
string Mode, string Mode,
string? DisplayName, string? DisplayName,
string Status, TenantExternalProviderStatus Status,
string? SecretRef,
int Priority,
JsonElement ConfigPublic, JsonElement ConfigPublic,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
@@ -150,12 +154,12 @@ public sealed record TenantCouponReport(int CouponCount, int ClaimedCount, int U
public interface ICommerceAdminService public interface ICommerceAdminService
{ {
Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync( Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor, CommerceAdminActor actor,
CommerceAdminQuery query, CommerceAdminQuery query,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<TenantPaymentAccountItem> UpsertPaymentAccountAsync( Task<TenantPaymentProviderItem> UpsertPaymentAccountAsync(
CommerceAdminActor actor, CommerceAdminActor actor,
UpsertPaymentAccountCommand command, UpsertPaymentAccountCommand command,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);

View File

@@ -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<UserNotification> UpsertInAppAsync(
InAppNotificationRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -3,7 +3,6 @@ namespace Tiku.Application.Storage;
public static class ObjectStorageProviders public static class ObjectStorageProviders
{ {
public const string ExternalUrl = "external_url"; public const string ExternalUrl = "external_url";
public const string SupabaseStorage = "supabase_storage";
public const string AliyunOss = "aliyun_oss"; public const string AliyunOss = "aliyun_oss";
public const string TencentCos = "tencent_cos"; public const string TencentCos = "tencent_cos";
public const string QiniuKodo = "qiniu_kodo"; public const string QiniuKodo = "qiniu_kodo";

View File

@@ -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<TenantExternalProviderAccount> GetActiveProviderAsync(
Guid tenantId,
TenantExternalProviderCapability capability,
string? provider = null,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TenantExternalProviderItem>> GetProvidersAsync(
Guid tenantId,
TenantExternalProviderCapability? capability = null,
string? provider = null,
int? limit = null,
CancellationToken cancellationToken = default);
Task<TenantExternalProviderItem> UpsertProviderAsync(
Guid tenantId,
UpsertTenantExternalProviderCommand command,
CancellationToken cancellationToken = default);
}
public sealed class TenantExternalProviderException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -146,13 +146,13 @@ public interface ITenantAdminDirectService
CreateTenantDomainCommand command, CreateTenantDomainCommand command,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<CatalogList<TenantAuthProviderItem>> GetAuthProvidersAsync( Task<CatalogList<TenantIdentityProviderItem>> GetAuthProvidersAsync(
TenantAdminActor actor, TenantAdminActor actor,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAuthProviderItem>> UpsertAuthProviderAsync( Task<ContentManagementResult<TenantIdentityProviderItem>> UpsertAuthProviderAsync(
TenantAdminActor actor, TenantAdminActor actor,
UpsertTenantAuthProviderCommand command, UpsertTenantIdentityProviderCommand command,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync( Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync(

View File

@@ -189,10 +189,12 @@ public sealed record CreateTenantDomainCommand(
string? DomainType, string? DomainType,
bool IsPrimary); bool IsPrimary);
public sealed record UpsertTenantAuthProviderCommand( public sealed record UpsertTenantIdentityProviderCommand(
string Provider, string Provider,
string? Status, string? Status,
string? DisplayName, string? DisplayName,
string? SecretRef,
int? Priority,
JsonElement ConfigPublic); JsonElement ConfigPublic);
public sealed record UpsertTenantAdminBadgeCommand( public sealed record UpsertTenantAdminBadgeCommand(
@@ -467,11 +469,13 @@ public sealed record TenantDomainItem(
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public sealed record TenantAuthProviderItem( public sealed record TenantIdentityProviderItem(
Guid Id, Guid Id,
string Provider, string Provider,
TenantAuthProviderStatus Status, TenantExternalProviderStatus Status,
string? DisplayName, string? DisplayName,
string? SecretRef,
int Priority,
JsonElement ConfigPublic, JsonElement ConfigPublic,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);

View File

@@ -193,15 +193,6 @@ public sealed class CouponRedemption : AuditableTenantEntity
public DateTimeOffset? UsedAt { get; set; } 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 sealed class TenantSubscription : AuditableTenantEntity
{ {
public string PlanCode { get; set; } = string.Empty; 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 EntitlementStatus { Active, Revoked, Expired }
public enum DiscountType { Percent, Fixed } public enum DiscountType { Percent, Fixed }
public enum CouponRedemptionStatus { Claimed, Used, Expired, Cancelled } 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 TenantSubscriptionStatus { Trial, Active, PastDue, Cancelled }
public enum CommerceRefundStatus { Requested, Approved, Processing, Succeeded, Failed, Rejected, Cancelled } public enum CommerceRefundStatus { Requested, Approved, Processing, Succeeded, Failed, Rejected, Cancelled }
public enum RefundEntitlementAction { None, RevokeOnSuccess } public enum RefundEntitlementAction { None, RevokeOnSuccess }

View File

@@ -170,7 +170,6 @@ public enum ContentAssetType
public enum AssetStorageProvider public enum AssetStorageProvider
{ {
ExternalUrl, ExternalUrl,
SupabaseStorage,
AliyunOss, AliyunOss,
TencentCos, TencentCos,
QiniuKodo, QiniuKodo,

View File

@@ -3,12 +3,16 @@ using Tiku.Domain.Common;
namespace Tiku.Domain.Tenancy; 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 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? DisplayName { get; set; }
public string? SecretRef { get; set; }
public int Priority { get; set; } = 100;
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
} }
public sealed class TenantSecret : AuditableTenantEntity public sealed class TenantSecret : AuditableTenantEntity
@@ -154,7 +158,8 @@ public sealed class TenantStudentFollowup : AuditableTenantEntity
public Guid? UpdatedBy { get; set; } 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 TenantSecretStatus { Active, Disabled, Rotating }
public enum SmsPurpose { Login, BindPhone, ResetPassword } public enum SmsPurpose { Login, BindPhone, ResetPassword }
public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked } public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked }

View File

@@ -318,7 +318,6 @@ public sealed class AssetAccessService(
return provider switch return provider switch
{ {
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl, AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage,
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos, AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo, AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,

View File

@@ -4,15 +4,18 @@ using Tiku.Application.Assets;
using Tiku.Application.Catalog; using Tiku.Application.Catalog;
using Tiku.Application.Content; using Tiku.Application.Content;
using Tiku.Application.Storage; using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Domain.Common; using Tiku.Domain.Common;
using Tiku.Domain.Content; using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Assets; namespace Tiku.Infrastructure.Assets;
public sealed class AssetManagementService( public sealed class AssetManagementService(
TikuDbContext dbContext, TikuDbContext dbContext,
IObjectStorageService objectStorageService) : IAssetManagementService IObjectStorageService objectStorageService,
ITenantExternalProviderConfigService providerConfigService) : IAssetManagementService
{ {
private const int DefaultLimit = 100; private const int DefaultLimit = 100;
private const int MaxLimit = 500; private const int MaxLimit = 500;
@@ -140,10 +143,9 @@ public sealed class AssetManagementService(
ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName); ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName);
ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType); ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType);
var provider = objectStorageService.NormalizeProvider(command.Provider, objectStorageService.ConfiguredDefaultProvider()); var storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken);
var bucket = string.IsNullOrWhiteSpace(command.Bucket) var provider = storageConfig.Provider;
? objectStorageService.ConfiguredDefaultBucket() var bucket = storageConfig.Bucket;
: command.Bucket.Trim();
var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim()); var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim());
var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes); var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes);
var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken); var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken);
@@ -782,7 +784,6 @@ public sealed class AssetManagementService(
return provider switch return provider switch
{ {
ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl, ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl,
ObjectStorageProviders.SupabaseStorage => AssetStorageProvider.SupabaseStorage,
ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss, ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos, ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos,
ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo, ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo,
@@ -796,7 +797,6 @@ public sealed class AssetManagementService(
return provider switch return provider switch
{ {
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl, AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage,
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos, AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo, AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
@@ -804,4 +804,50 @@ public sealed class AssetManagementService(
_ => ObjectStorageProviders.ExternalUrl _ => 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;
}
} }

View File

@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Tiku.Application.Auth; using Tiku.Application.Auth;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity; using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy; using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Persistence;
@@ -12,12 +13,13 @@ public sealed class AuthService(
IPasswordHasher passwordHasher, IPasswordHasher passwordHasher,
ISmsVerificationService smsVerificationService, ISmsVerificationService smsVerificationService,
ISessionService sessionService, ISessionService sessionService,
IWechatOAuthClient wechatOAuthClient) : IAuthService IWechatOAuthClient wechatOAuthClient,
ITenantExternalProviderConfigService providerConfigService) : IAuthService
{ {
private const string PasswordProvider = "password"; private const string PasswordProvider = "password";
private const string SmsProvider = "sms"; private const string SmsProvider = "sms";
private const string WechatWebProvider = "wechat_web"; 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[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"];
private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; 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"]; 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<string> aliases, IReadOnlyList<string> aliases,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var rows = await dbContext.TenantAuthProviders TenantExternalProviderAccount? account = null;
.Where(entity => foreach (var alias in aliases)
entity.TenantId == tenantId && {
aliases.Contains(entity.Provider) && try
(entity.Status == TenantAuthProviderStatus.Active || {
entity.Status == TenantAuthProviderStatus.Testing)) account = await providerConfigService.GetActiveProviderAsync(
.ToListAsync(cancellationToken); tenantId,
var row = aliases TenantExternalProviderCapability.Identity,
.Select(alias => rows.FirstOrDefault(entity => entity.Provider == alias)) alias,
.FirstOrDefault(entity => entity is not null); cancellationToken);
break;
}
catch (TenantExternalProviderException)
{
}
}
if (row is null) if (account is null)
{ {
throw new AuthProviderNotConfiguredException(provider); throw new AuthProviderNotConfiguredException(provider);
} }
var appId = GetJsonString(row.ConfigPublic, "appId", "clientId"); var appId = GetJsonString(account.ConfigPublic, "appId", "clientId");
var appSecret = GetJsonString(row.ConfigPublic, "appSecret", "clientSecret", "secret"); var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret");
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
{ {
throw new AuthProviderNotConfiguredException(provider); throw new AuthProviderNotConfiguredException(provider);

View File

@@ -0,0 +1,14 @@
using Tiku.Application.Auth;
namespace Tiku.Infrastructure.Auth;
internal sealed class NoopSmsProvider : ISmsProvider
{
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new SmsProviderSendResult("noop", "accepted"));
}
}

View File

@@ -0,0 +1,54 @@
using Tiku.Application.Auth;
namespace Tiku.Infrastructure.Auth;
internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IIdentityProvider
{
public async Task<IdentityProviderResult> 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);
}
}

View File

@@ -5,7 +5,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth; 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 const int MaxPhoneRequestsPerHour = 5;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10); private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
@@ -42,13 +44,22 @@ public sealed class SmsVerificationService(TikuDbContext dbContext) : ISmsVerifi
rateLimit.UpdatedAt = DateTimeOffset.UtcNow; rateLimit.UpdatedAt = DateTimeOffset.UtcNow;
var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture); 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 var verification = new SmsVerificationCode
{ {
TenantId = request.TenantId, TenantId = request.TenantId,
Phone = phone, Phone = phone,
Purpose = request.Purpose, Purpose = request.Purpose,
CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code), CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code),
Provider = "mock", Provider = sendResult.Provider,
Status = SmsVerificationStatus.Sent, Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime), ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
IpAddress = request.IpAddress, IpAddress = request.IpAddress,

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce; using Tiku.Application.Commerce;
using Tiku.Application.Tenancy;
using Tiku.Domain.Catalog; using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce; using Tiku.Domain.Commerce;
using Tiku.Domain.Tenancy; using Tiku.Domain.Tenancy;
@@ -12,55 +13,44 @@ namespace Tiku.Infrastructure.Commerce;
internal sealed class CommerceAdminService( internal sealed class CommerceAdminService(
TikuDbContext dbContext, TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector) : ICommerceAdminService ITenantSecretProtector tenantSecretProtector,
ITenantExternalProviderConfigService providerConfigService) : ICommerceAdminService
{ {
public async Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync( public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor, CommerceAdminActor actor,
CommerceAdminQuery query, CommerceAdminQuery query,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
await AssertAdminAsync(actor, cancellationToken); await AssertAdminAsync(actor, cancellationToken);
var accounts = dbContext.TenantPaymentAccounts.AsNoTracking() var accounts = await providerConfigService.GetProvidersAsync(
.Where(item => item.TenantId == actor.TenantId); actor.TenantId,
if (!string.IsNullOrWhiteSpace(query.Provider)) TenantExternalProviderCapability.Payment,
{ string.IsNullOrWhiteSpace(query.Provider) ? null : NormalizeProvider(query.Provider),
var provider = NormalizeProvider(query.Provider); query.Limit,
accounts = accounts.Where(item => item.Provider == provider); cancellationToken);
}
return await accounts return accounts.Select(ToPaymentAccountItem).ToArray();
.OrderBy(item => item.Provider)
.Take(Math.Clamp(query.Limit ?? 50, 1, 100))
.Select(item => ToPaymentAccountItem(item))
.ToArrayAsync(cancellationToken);
} }
public async Task<TenantPaymentAccountItem> UpsertPaymentAccountAsync( public async Task<TenantPaymentProviderItem> UpsertPaymentAccountAsync(
CommerceAdminActor actor, CommerceAdminActor actor,
UpsertPaymentAccountCommand command, UpsertPaymentAccountCommand command,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
await AssertAdminAsync(actor, cancellationToken); await AssertAdminAsync(actor, cancellationToken);
AssertNoSecrets(command.ConfigPublic, "config_public"); var account = await providerConfigService.UpsertProviderAsync(
var provider = NormalizeProvider(command.Provider); actor.TenantId,
var account = await dbContext.TenantPaymentAccounts new UpsertTenantExternalProviderCommand(
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Provider == provider, cancellationToken); TenantExternalProviderCapability.Payment,
if (account is null) command.Provider,
{ command.Status,
account = new TenantPaymentAccount command.DisplayName,
{ command.SecretRef,
TenantId = actor.TenantId, command.Priority,
Provider = provider WithPaymentMode(command.ConfigPublic, command.Mode),
}; JsonObjectOrDefault(default)),
dbContext.TenantPaymentAccounts.Add(account); 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); return ToPaymentAccountItem(account);
} }
@@ -588,8 +578,18 @@ internal sealed class CommerceAdminService(
} }
} }
private static TenantPaymentAccountItem ToPaymentAccountItem(TenantPaymentAccount item) => private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
new(item.Id, item.Provider, item.Mode.ToString(), item.DisplayName, item.Status.ToString(), item.ConfigPublic, item.CreatedAt, item.UpdatedAt); 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) => 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); 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<string, JsonElement>(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) => private static JsonElement JsonObjectOrDefault(JsonElement element) =>
element.ValueKind == JsonValueKind.Object element.ValueKind == JsonValueKind.Object
? element.Clone() ? element.Clone()

View File

@@ -1,49 +1,41 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce; using Tiku.Application.Commerce;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using Tiku.Domain.Commerce; using Tiku.Domain.Commerce;
using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce; namespace Tiku.Infrastructure.Commerce;
internal sealed class PaymentProviderConfigService( internal sealed class PaymentProviderConfigService(
TikuDbContext dbContext, ITenantExternalProviderConfigService providerConfigService) : IPaymentProviderConfigService
ITenantSecretService tenantSecretService) : IPaymentProviderConfigService
{ {
public async Task<PaymentProviderAccount> GetActiveAccountAsync( public async Task<PaymentProviderAccount> GetActiveAccountAsync(
Guid tenantId, Guid tenantId,
string provider, string provider,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var normalizedProvider = NormalizeProvider(provider); TenantExternalProviderAccount account;
var account = await dbContext.TenantPaymentAccounts try
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.Provider == normalizedProvider &&
item.Status == TenantPaymentAccountStatus.Active)
.SingleOrDefaultAsync(cancellationToken);
if (account is null)
{ {
throw new PaymentProviderException( account = await providerConfigService.GetActiveProviderAsync(
"Payment provider account is not configured.",
"payment_provider_not_configured");
}
var secretPayload = normalizedProvider == PaymentProviders.Manual
? JsonDocument.Parse("{}").RootElement.Clone()
: await tenantSecretService.GetActiveSecretPayloadAsync(
tenantId, tenantId,
GetString(account.ConfigPublic, "secretRef", "secret_ref") ?? string.Empty, TenantExternalProviderCapability.Payment,
NormalizeProvider(provider),
cancellationToken); cancellationToken);
}
catch (TenantExternalProviderException exception)
{
throw new PaymentProviderException(exception.Message, "payment_provider_not_configured");
}
return new PaymentProviderAccount( return new PaymentProviderAccount(
account.TenantId, account.TenantId,
normalizedProvider, account.Provider,
account.Mode.ToString(), GetString(account.ConfigPublic, "mode") ?? "TenantCollect",
account.ConfigPublic, account.ConfigPublic,
secretPayload); account.SecretPayload);
} }
private static string NormalizeProvider(string provider) private static string NormalizeProvider(string provider)

View File

@@ -34,7 +34,7 @@ public sealed class TenantSecretEncryptionOptions
string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal); string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal);
} }
internal interface ITenantSecretProtector public interface ITenantSecretProtector
{ {
ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload); ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload);
@@ -47,13 +47,13 @@ internal interface ITenantSecretProtector
byte[] tag); byte[] tag);
} }
internal sealed record ProtectedTenantSecret( public sealed record ProtectedTenantSecret(
string KeyId, string KeyId,
byte[] Ciphertext, byte[] Ciphertext,
byte[] Nonce, byte[] Nonce,
byte[] Tag); byte[] Tag);
internal sealed class TenantSecretProtector( public sealed class TenantSecretProtector(
Microsoft.Extensions.Options.IOptions<TenantSecretEncryptionOptions> options) : ITenantSecretProtector Microsoft.Extensions.Options.IOptions<TenantSecretEncryptionOptions> options) : ITenantSecretProtector
{ {
private readonly TenantSecretEncryptionOptions options = options.Value; private readonly TenantSecretEncryptionOptions options = options.Value;

View File

@@ -8,6 +8,7 @@ using Tiku.Application.Commerce;
using Tiku.Application.Content; using Tiku.Application.Content;
using Tiku.Application.Growth; using Tiku.Application.Growth;
using Tiku.Application.Learning; using Tiku.Application.Learning;
using Tiku.Application.Notifications;
using Tiku.Application.QuestionBanks; using Tiku.Application.QuestionBanks;
using Tiku.Application.Profile; using Tiku.Application.Profile;
using Tiku.Application.Points; using Tiku.Application.Points;
@@ -24,6 +25,7 @@ using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Growth; using Tiku.Infrastructure.Growth;
using Tiku.Infrastructure.Learning; using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Notifications;
using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Profile; using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.Points; using Tiku.Infrastructure.Points;
@@ -56,6 +58,7 @@ public static class DependencyInjection
services.AddScoped<ITenantDirectory, TenantDirectory>(); services.AddScoped<ITenantDirectory, TenantDirectory>();
services.AddMemoryCache(); services.AddMemoryCache();
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>(); services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
services.AddSingleton<ITenantRuntimeCacheInvalidator, TenantRuntimeCacheInvalidator>(); services.AddSingleton<ITenantRuntimeCacheInvalidator, TenantRuntimeCacheInvalidator>();
services.AddHttpClient<IDomainOwnershipVerifier, DnsDomainOwnershipVerifier>(); services.AddHttpClient<IDomainOwnershipVerifier, DnsDomainOwnershipVerifier>();
services.AddHttpClient<IDomainGatewayProvisioner, HttpDomainGatewayProvisioner>(); services.AddHttpClient<IDomainGatewayProvisioner, HttpDomainGatewayProvisioner>();
@@ -65,9 +68,11 @@ public static class DependencyInjection
services.AddScoped<IPasswordHasher, PasswordHasher>(); services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddScoped<ITokenService, TokenService>(); services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ISessionService, SessionService>(); services.AddScoped<ISessionService, SessionService>();
services.AddScoped<ISmsProvider, NoopSmsProvider>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>(); services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>(); services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
services.AddScoped<IAuthService, AuthService>(); services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IIdentityProvider, SelfHostedIdentityProvider>();
services.AddScoped<ICatalogQueryService, CatalogQueryService>(); services.AddScoped<ICatalogQueryService, CatalogQueryService>();
services.AddScoped<ITaxonomyService, TaxonomyService>(); services.AddScoped<ITaxonomyService, TaxonomyService>();
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>(); services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
@@ -91,6 +96,7 @@ public static class DependencyInjection
services.AddScoped<IReferralService, ReferralService>(); services.AddScoped<IReferralService, ReferralService>();
services.AddScoped<ICrmService, CrmService>(); services.AddScoped<ICrmService, CrmService>();
services.AddScoped<ICommissionService, CommissionService>(); services.AddScoped<ICommissionService, CommissionService>();
services.AddScoped<INotificationProvider, InAppNotificationProvider>();
services.AddSingleton<ITenantSecretProtector, TenantSecretProtector>(); services.AddSingleton<ITenantSecretProtector, TenantSecretProtector>();
services.AddScoped<ITenantSecretService, TenantSecretService>(); services.AddScoped<ITenantSecretService, TenantSecretService>();
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>(); services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();

View File

@@ -1,18 +1,17 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Senparc.Weixin.WxOpen.AdvancedAPIs.WxApp; using Senparc.Weixin.WxOpen.AdvancedAPIs.WxApp;
using Senparc.Weixin.WxOpen.Containers; using Senparc.Weixin.WxOpen.Containers;
using Tiku.Application.Growth; using Tiku.Application.Growth;
using Tiku.Application.Storage; using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy; using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Growth; namespace Tiku.Infrastructure.Growth;
public sealed class ReferralQrcodeGenerator( public sealed class ReferralQrcodeGenerator(
TikuDbContext dbContext, IObjectStorageService objectStorageService,
IObjectStorageService objectStorageService) : IReferralQrcodeGenerator ITenantExternalProviderConfigService providerConfigService) : IReferralQrcodeGenerator
{ {
private static readonly string[] WechatMiniappProviderAliases = private static readonly string[] WechatMiniappProviderAliases =
[ [
@@ -84,17 +83,23 @@ public sealed class ReferralQrcodeGenerator(
Guid tenantId, Guid tenantId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var providers = await dbContext.TenantAuthProviders TenantExternalProviderAccount? provider = null;
.AsNoTracking() foreach (var alias in WechatMiniappProviderAliases)
.Where(entity => {
entity.TenantId == tenantId && try
WechatMiniappProviderAliases.Contains(entity.Provider) && {
(entity.Status == TenantAuthProviderStatus.Active || provider = await providerConfigService.GetActiveProviderAsync(
entity.Status == TenantAuthProviderStatus.Testing)) tenantId,
.ToListAsync(cancellationToken); TenantExternalProviderCapability.Identity,
var provider = WechatMiniappProviderAliases alias,
.Select(alias => providers.FirstOrDefault(entity => entity.Provider == alias)) cancellationToken);
.FirstOrDefault(entity => entity is not null); break;
}
catch (TenantExternalProviderException)
{
}
}
if (provider is null) if (provider is null)
{ {
throw new ReferralException( throw new ReferralException(
@@ -103,7 +108,7 @@ public sealed class ReferralQrcodeGenerator(
} }
var appId = GetJsonString(provider.ConfigPublic, "appId", "clientId"); 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)) if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
{ {
throw new ReferralException( throw new ReferralException(

View File

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

View File

@@ -299,21 +299,6 @@ internal sealed class CouponRedemptionConfiguration : IEntityTypeConfiguration<C
} }
} }
internal sealed class TenantPaymentAccountConfiguration : IEntityTypeConfiguration<TenantPaymentAccount>
{
public void Configure(EntityTypeBuilder<TenantPaymentAccount> 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<TenantSubscription> internal sealed class TenantSubscriptionConfiguration : IEntityTypeConfiguration<TenantSubscription>
{ {
public void Configure(EntityTypeBuilder<TenantSubscription> builder) public void Configure(EntityTypeBuilder<TenantSubscription> builder)

View File

@@ -6,18 +6,21 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations; namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class TenantAuthProviderConfiguration : IEntityTypeConfiguration<TenantAuthProvider> internal sealed class TenantExternalProviderConfiguration : IEntityTypeConfiguration<TenantExternalProvider>
{ {
public void Configure(EntityTypeBuilder<TenantAuthProvider> builder) public void Configure(EntityTypeBuilder<TenantExternalProvider> builder)
{ {
builder.ConfigureTenantEntity("tenant_auth_providers"); builder.ConfigureTenantEntity("tenant_external_providers");
builder.ConfigureTimestamps(); builder.ConfigureTimestamps();
builder.Property(entity => entity.Capability).HasSnakeCaseEnum();
builder.Property(entity => entity.Provider).HasMaxLength(50); builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.DisplayName).HasMaxLength(200); builder.Property(entity => entity.DisplayName).HasMaxLength(200);
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
builder.Property(entity => entity.ConfigPublic).IsJson("{}"); builder.Property(entity => entity.ConfigPublic).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Provider }).IsUnique(); builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.Status }); builder.HasIndex(entity => new { entity.TenantId, entity.Capability, entity.Provider }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Capability, entity.Status, entity.Priority });
} }
} }

View File

@@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Persistence.Migrations namespace Tiku.Infrastructure.Persistence.Migrations
{ {
[DbContext(typeof(TikuDbContext))] [DbContext(typeof(TikuDbContext))]
[Migration("20260727084726_InitialSchema")] [Migration("20260727093301_InitialSchema")]
partial class InitialSchema partial class InitialSchema
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -3400,72 +3400,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
}); });
}); });
modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("mode");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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 => modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -12476,69 +12410,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenants", (string)null); b.ToTable("tenants", (string)null);
}); });
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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 => modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
{ {
b.Property<Guid>("TenantId") b.Property<Guid>("TenantId")
@@ -12900,6 +12771,90 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_domains", (string)null); b.ToTable("tenant_domains", (string)null);
}); });
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Capability")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("capability");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("SecretRef")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("secret_ref");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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 => modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -14363,16 +14318,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_svip_plans_regions_tenant_id_region_id"); .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 => modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b =>
{ {
b.HasOne("Tiku.Domain.Tenancy.Tenant", null) b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16541,16 +16486,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenants_users_owner_user_id"); .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 => modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
{ {
b.HasOne("Tiku.Domain.Tenancy.Tenant", null) b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16625,6 +16560,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenant_domains_tenants_tenant_id"); .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 => modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b =>
{ {
b.HasOne("Tiku.Domain.Tenancy.Tenant", null) b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -1087,31 +1087,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateTable(
name: "tenant_auth_providers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(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( migrationBuilder.CreateTable(
name: "tenant_billing_profiles", name: "tenant_billing_profiles",
columns: table => new columns: table => new
@@ -1236,6 +1211,35 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateTable(
name: "tenant_external_providers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
capability = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
priority = table.Column<int>(type: "integer", nullable: false),
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(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( migrationBuilder.CreateTable(
name: "tenant_frontend_configs", name: "tenant_frontend_configs",
columns: table => new columns: table => new
@@ -1319,32 +1323,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull); onDelete: ReferentialAction.SetNull);
}); });
migrationBuilder.CreateTable(
name: "tenant_payment_accounts",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(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( migrationBuilder.CreateTable(
name: "tenant_role_templates", name: "tenant_role_templates",
columns: table => new columns: table => new
@@ -7995,17 +7973,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
columns: new[] { "tenant_id", "code" }, columns: new[] { "tenant_id", "code" },
unique: true); 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( migrationBuilder.CreateIndex(
name: "ix_tenant_class_members_tenant_id_class_id_status_member_type", name: "ix_tenant_class_members_tenant_id_class_id_status_member_type",
table: "tenant_class_members", table: "tenant_class_members",
@@ -8117,6 +8084,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations
unique: true, unique: true,
filter: "is_primary"); 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( migrationBuilder.CreateIndex(
name: "ix_tenant_frontend_configs_tenant_id", name: "ix_tenant_frontend_configs_tenant_id",
table: "tenant_frontend_configs", table: "tenant_frontend_configs",
@@ -8214,12 +8192,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "tenant_memberships", table: "tenant_memberships",
column: "user_id"); 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( migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_preferences_created_by", name: "ix_tenant_question_bank_preferences_created_by",
table: "tenant_question_bank_preferences", table: "tenant_question_bank_preferences",
@@ -9142,9 +9114,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "subject_shares"); name: "subject_shares");
migrationBuilder.DropTable(
name: "tenant_auth_providers");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "tenant_billing_profiles"); name: "tenant_billing_profiles");
@@ -9163,6 +9132,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "tenant_domains"); name: "tenant_domains");
migrationBuilder.DropTable(
name: "tenant_external_providers");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "tenant_frontend_configs"); name: "tenant_frontend_configs");
@@ -9175,9 +9147,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "tenant_memberships"); name: "tenant_memberships");
migrationBuilder.DropTable(
name: "tenant_payment_accounts");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "tenant_question_bank_preferences"); name: "tenant_question_bank_preferences");

View File

@@ -3397,72 +3397,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
}); });
}); });
modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("mode");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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 => modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -12473,69 +12407,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenants", (string)null); b.ToTable("tenants", (string)null);
}); });
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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 => modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
{ {
b.Property<Guid>("TenantId") b.Property<Guid>("TenantId")
@@ -12897,6 +12768,90 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_domains", (string)null); b.ToTable("tenant_domains", (string)null);
}); });
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Capability")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("capability");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("SecretRef")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("secret_ref");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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 => modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -14360,16 +14315,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_svip_plans_regions_tenant_id_region_id"); .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 => modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b =>
{ {
b.HasOne("Tiku.Domain.Tenancy.Tenant", null) b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16538,16 +16483,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenants_users_owner_user_id"); .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 => modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
{ {
b.HasOne("Tiku.Domain.Tenancy.Tenant", null) b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16622,6 +16557,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenant_domains_tenants_tenant_id"); .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 => modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b =>
{ {
b.HasOne("Tiku.Domain.Tenancy.Tenant", null) b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -44,7 +44,7 @@ public sealed class TikuDbContext(
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>(); public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>(); public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>(); public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
public DbSet<TenantAuthProvider> TenantAuthProviders => Set<TenantAuthProvider>(); public DbSet<TenantExternalProvider> TenantExternalProviders => Set<TenantExternalProvider>();
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>(); public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>(); public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>(); public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
@@ -124,7 +124,6 @@ public sealed class TikuDbContext(
public DbSet<ActivationCode> ActivationCodes => Set<ActivationCode>(); public DbSet<ActivationCode> ActivationCodes => Set<ActivationCode>();
public DbSet<Coupon> Coupons => Set<Coupon>(); public DbSet<Coupon> Coupons => Set<Coupon>();
public DbSet<CouponRedemption> CouponRedemptions => Set<CouponRedemption>(); public DbSet<CouponRedemption> CouponRedemptions => Set<CouponRedemption>();
public DbSet<TenantPaymentAccount> TenantPaymentAccounts => Set<TenantPaymentAccount>();
public DbSet<TenantSubscription> TenantSubscriptions => Set<TenantSubscription>(); public DbSet<TenantSubscription> TenantSubscriptions => Set<TenantSubscription>();
public DbSet<TenantUsageRecord> TenantUsageRecords => Set<TenantUsageRecord>(); public DbSet<TenantUsageRecord> TenantUsageRecords => Set<TenantUsageRecord>();
public DbSet<CommerceRefundRequest> CommerceRefundRequests => Set<CommerceRefundRequest>(); public DbSet<CommerceRefundRequest> CommerceRefundRequests => Set<CommerceRefundRequest>();

View File

@@ -17,7 +17,6 @@ public sealed partial class AliyunOssObjectStorageService(
private static readonly HashSet<string> SupportedProviders = private static readonly HashSet<string> SupportedProviders =
[ [
ObjectStorageProviders.ExternalUrl, ObjectStorageProviders.ExternalUrl,
ObjectStorageProviders.SupabaseStorage,
ObjectStorageProviders.AliyunOss, ObjectStorageProviders.AliyunOss,
ObjectStorageProviders.TencentCos, ObjectStorageProviders.TencentCos,
ObjectStorageProviders.QiniuKodo, ObjectStorageProviders.QiniuKodo,

View File

@@ -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<TenantExternalProviderAccount> 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<IReadOnlyCollection<TenantExternalProviderItem>> 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<TenantExternalProviderItem> 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}");
}
}
}
}

View File

@@ -2,6 +2,8 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog; using Tiku.Application.Catalog;
using Tiku.Application.Content; using Tiku.Application.Content;
using Tiku.Application.Notifications;
using Tiku.Application.Tenancy;
using Tiku.Application.TenantAdmin; using Tiku.Application.TenantAdmin;
using Tiku.Domain.Catalog; using Tiku.Domain.Catalog;
using Tiku.Domain.Common; using Tiku.Domain.Common;
@@ -13,7 +15,10 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.TenantAdmin; 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 = private static readonly TenantAdminPermissionCatalogItem[] PermissionCatalog =
[ [
@@ -1099,42 +1104,39 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
return new ContentManagementResult<TenantDomainItem>(ToDomainItem(item)); return new ContentManagementResult<TenantDomainItem>(ToDomainItem(item));
} }
public async Task<CatalogList<TenantAuthProviderItem>> GetAuthProvidersAsync( public async Task<CatalogList<TenantIdentityProviderItem>> GetAuthProvidersAsync(
TenantAdminActor actor, TenantAdminActor actor,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var items = await dbContext.TenantAuthProviders.AsNoTracking() var items = await providerConfigService.GetProvidersAsync(
.Where(item => item.TenantId == actor.TenantId) actor.TenantId,
.OrderBy(item => item.CreatedAt) TenantExternalProviderCapability.Identity,
.Select(item => ToAuthProviderItem(item)) cancellationToken: cancellationToken);
.ToArrayAsync(cancellationToken); return new CatalogList<TenantIdentityProviderItem>(items.Select(ToAuthProviderItem).ToArray());
return new CatalogList<TenantAuthProviderItem>(items);
} }
public async Task<ContentManagementResult<TenantAuthProviderItem>> UpsertAuthProviderAsync( public async Task<ContentManagementResult<TenantIdentityProviderItem>> UpsertAuthProviderAsync(
TenantAdminActor actor, TenantAdminActor actor,
UpsertTenantAuthProviderCommand command, UpsertTenantIdentityProviderCommand command,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider); ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider);
AssertNoSecrets(command.ConfigPublic, "config_public"); var item = await providerConfigService.UpsertProviderAsync(
var provider = command.Provider.Trim().ToLowerInvariant(); actor.TenantId,
var item = await dbContext.TenantAuthProviders.FirstOrDefaultAsync( new UpsertTenantExternalProviderCommand(
authProvider => authProvider.TenantId == actor.TenantId && authProvider.Provider == provider, TenantExternalProviderCapability.Identity,
command.Provider,
ParseEnum(command.Status, TenantExternalProviderStatus.Disabled, "invalid_auth_provider_status"),
command.DisplayName,
command.SecretRef,
command.Priority,
command.ConfigPublic,
JsonObjectOrDefault(default)),
cancellationToken); 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); await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<TenantAuthProviderItem>(ToAuthProviderItem(item)); return new ContentManagementResult<TenantIdentityProviderItem>(ToAuthProviderItem(item));
} }
public async Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync( public async Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync(
@@ -1273,33 +1275,22 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
} }
var dedupeKey = $"badge:{grant.Id:N}"; var dedupeKey = $"badge:{grant.Id:N}";
var notification = await dbContext.UserNotifications.FirstOrDefaultAsync( await notificationProvider.UpsertInAppAsync(
item => new InAppNotificationRequest(
item.TenantId == actor.TenantId && actor.TenantId,
item.UserId == command.UserId && command.UserId,
item.NotificationType == "badge_granted" && "badge_granted",
item.DedupeKey == dedupeKey, 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); 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 AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
@@ -1346,35 +1337,22 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
ArgumentException.ThrowIfNullOrWhiteSpace(command.Message); ArgumentException.ThrowIfNullOrWhiteSpace(command.Message);
await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken); await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken);
var item = !string.IsNullOrWhiteSpace(command.DedupeKey) var item = await notificationProvider.UpsertInAppAsync(
? await dbContext.UserNotifications.FirstOrDefaultAsync(notification => new InAppNotificationRequest(
notification.TenantId == actor.TenantId && actor.TenantId,
notification.UserId == command.UserId && command.UserId,
notification.NotificationType == command.NotificationType && command.NotificationType,
notification.DedupeKey == command.DedupeKey, ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"),
cancellationToken) command.Title,
: null; command.Message,
var isNew = item is null; actor.UserId,
item ??= new UserNotification command.ActionLabel,
{ command.ActionPath,
TenantId = actor.TenantId, command.SourceType,
UserId = command.UserId, command.SourceId,
CreatedBy = actor.UserId command.DedupeKey,
}; JsonObjectOrDefault(command.Metadata)),
item.NotificationType = command.NotificationType.Trim(); cancellationToken);
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);
}
await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken); await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
@@ -1941,13 +1919,15 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
item.UpdatedAt); item.UpdatedAt);
} }
private static TenantAuthProviderItem ToAuthProviderItem(TenantAuthProvider item) private static TenantIdentityProviderItem ToAuthProviderItem(TenantExternalProviderItem item)
{ {
return new TenantAuthProviderItem( return new TenantIdentityProviderItem(
item.Id, item.Id,
item.Provider, item.Provider,
item.Status, item.Status,
item.DisplayName, item.DisplayName,
item.SecretRef,
item.Priority,
item.ConfigPublic, item.ConfigPublic,
item.CreatedAt, item.CreatedAt,
item.UpdatedAt); item.UpdatedAt);

View File

@@ -2,11 +2,13 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth; using Tiku.Application.Auth;
using Tiku.Api.Contracts; using Tiku.Api.Contracts;
using Tiku.Domain.Identity; using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy; using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api; namespace Tiku.IntegrationTests.Api;
@@ -166,6 +168,11 @@ public sealed class AuthEndpointTests
{ {
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient()); await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
var tenantId = Guid.NewGuid(); 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( await factory.SeedAsync(
new Tenant new Tenant
{ {
@@ -173,16 +180,30 @@ public sealed class AuthEndpointTests
Slug = tenantId.ToString("N"), Slug = tenantId.ToString("N"),
Name = "Wechat Tenant" Name = "Wechat Tenant"
}, },
new TenantAuthProvider new TenantExternalProvider
{ {
TenantId = tenantId, TenantId = tenantId,
Provider = "wechat-miniapp", Provider = "wechat_miniapp",
Status = TenantAuthProviderStatus.Testing, Capability = TenantExternalProviderCapability.Identity,
Status = TenantExternalProviderStatus.Active,
SecretRef = secretRef,
ConfigPublic = JsonSerializer.SerializeToElement(new ConfigPublic = JsonSerializer.SerializeToElement(new
{ {
appId = "wx-app-id", appId = "wx-app-id"
appSecret = "wx-app-secret"
}) })
},
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(); using var client = factory.CreateClient();
@@ -207,7 +228,7 @@ public sealed class AuthEndpointTests
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
Assert.Contains(dbContext.UserIdentities, identity => Assert.Contains(dbContext.UserIdentities, identity =>
identity.Provider == "wechat-miniapp" && identity.Provider == "wechat_miniapp" &&
identity.OpenId == "mini-open-id" && identity.OpenId == "mini-open-id" &&
identity.UnionId == "union-id"); identity.UnionId == "union-id");
} }
@@ -285,6 +306,16 @@ public sealed class AuthEndpointTests
return document.RootElement.Clone(); 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 private sealed class FakeWechatOAuthClient : IWechatOAuthClient
{ {
public Task<WechatIdentity> ExchangeWebCodeAsync( public Task<WechatIdentity> ExchangeWebCodeAsync(

View File

@@ -375,11 +375,13 @@ public sealed class CommerceEndpointTests
Days = 39, Days = 39,
IsActive = true IsActive = true
}, },
new TenantPaymentAccount new TenantExternalProvider
{ {
TenantId = tenantId, TenantId = tenantId,
Capability = TenantExternalProviderCapability.Payment,
Provider = "manual", Provider = "manual",
Status = TenantPaymentAccountStatus.Active Status = TenantExternalProviderStatus.Active,
ConfigPublic = JsonSerializer.SerializeToElement(new { mode = "TenantCollect" })
} }
}; };
if (includeMembership) if (includeMembership)

View File

@@ -217,7 +217,7 @@ public sealed class TenantAdminDirectEndpointTests
}); });
var authProviderResponse = await client.PutAsJsonAsync( var authProviderResponse = await client.PutAsJsonAsync(
"/api/tenant-admin/auth-providers", "/api/tenant-admin/auth-providers",
new UpsertTenantAuthProviderDto new UpsertTenantIdentityProviderDto
{ {
Provider = "wechat_mp", Provider = "wechat_mp",
Status = "active", 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.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.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.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] [Fact]

View File

@@ -42,7 +42,7 @@ public sealed class TenantCommerceEndpointTests
new UpsertPaymentAccountDto new UpsertPaymentAccountDto
{ {
Provider = "wechat_pay", Provider = "wechat_pay",
Status = TenantPaymentAccountStatus.Active, Status = TenantExternalProviderStatus.Active,
ConfigPublic = JsonSerializer.SerializeToElement(new ConfigPublic = JsonSerializer.SerializeToElement(new
{ {
merchantId = "mch", merchantId = "mch",
@@ -80,11 +80,11 @@ public sealed class TenantCommerceEndpointTests
new UpsertPaymentAccountDto new UpsertPaymentAccountDto
{ {
Provider = "wechat_pay", Provider = "wechat_pay",
Status = TenantPaymentAccountStatus.Active, Status = TenantExternalProviderStatus.Active,
SecretRef = "tenant_secrets:payment:wechat_pay:default",
ConfigPublic = JsonSerializer.SerializeToElement(new ConfigPublic = JsonSerializer.SerializeToElement(new
{ {
merchantId = "mch", merchantId = "mch"
secretRef = "tenant_secrets:payment:wechat_pay:default"
}) })
}); });
var accountsResponse = await client.GetAsync("/api/tenant-commerce/payment-accounts?provider=wechat_pay"); var accountsResponse = await client.GetAsync("/api/tenant-commerce/payment-accounts?provider=wechat_pay");

View File

@@ -39,6 +39,46 @@ public sealed class ArchitectureBoundaryTests
$"Forbidden tenant-boundary bypasses were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); $"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() private static string FindRepositoryRoot()
{ {
var directory = new DirectoryInfo(AppContext.BaseDirectory); var directory = new DirectoryInfo(AppContext.BaseDirectory);

View File

@@ -68,7 +68,7 @@ public sealed class PersistenceModelTests
Assert.Contains("practice_session_report_sections", tableNames); Assert.Contains("practice_session_report_sections", tableNames);
Assert.Contains("dashboard_daily_stats", tableNames); Assert.Contains("dashboard_daily_stats", tableNames);
Assert.Contains("revenue_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("tenant_secrets", tableNames);
Assert.Contains("sms_verification_codes", tableNames); Assert.Contains("sms_verification_codes", tableNames);
Assert.Contains("auth_login_events", tableNames); Assert.Contains("auth_login_events", tableNames);
@@ -90,7 +90,6 @@ public sealed class PersistenceModelTests
Assert.Contains("activation_codes", tableNames); Assert.Contains("activation_codes", tableNames);
Assert.Contains("coupons", tableNames); Assert.Contains("coupons", tableNames);
Assert.Contains("coupon_redemptions", tableNames); Assert.Contains("coupon_redemptions", tableNames);
Assert.Contains("tenant_payment_accounts", tableNames);
Assert.Contains("tenant_subscriptions", tableNames); Assert.Contains("tenant_subscriptions", tableNames);
Assert.Contains("tenant_usage_records", tableNames); Assert.Contains("tenant_usage_records", tableNames);
Assert.Contains("commerce_refund_requests", tableNames); Assert.Contains("commerce_refund_requests", tableNames);
@@ -506,7 +505,8 @@ public sealed class PersistenceModelTests
} }
[Theory] [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(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")]
[InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")] [InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")]
[InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")] [InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")]
@@ -536,9 +536,10 @@ public sealed class PersistenceModelTests
{ {
using var context = new TikuDbContext(Options); using var context = new TikuDbContext(Options);
AssertHasUniqueIndex<TenantAuthProvider>( AssertHasUniqueIndex<TenantExternalProvider>(
nameof(TenantAuthProvider.TenantId), nameof(TenantExternalProvider.TenantId),
nameof(TenantAuthProvider.Provider)); nameof(TenantExternalProvider.Capability),
nameof(TenantExternalProvider.Provider));
AssertHasUniqueIndex<TenantSecret>( AssertHasUniqueIndex<TenantSecret>(
nameof(TenantSecret.TenantId), nameof(TenantSecret.TenantId),
nameof(TenantSecret.SecretRef)); nameof(TenantSecret.SecretRef));
@@ -574,7 +575,6 @@ public sealed class PersistenceModelTests
[InlineData(typeof(Payment), nameof(Payment.RawPayload), "'{}'::jsonb")] [InlineData(typeof(Payment), nameof(Payment.RawPayload), "'{}'::jsonb")]
[InlineData(typeof(PaymentEvent), nameof(PaymentEvent.Payload), "'{}'::jsonb")] [InlineData(typeof(PaymentEvent), nameof(PaymentEvent.Payload), "'{}'::jsonb")]
[InlineData(typeof(Entitlement), nameof(Entitlement.Metadata), "'{}'::jsonb")] [InlineData(typeof(Entitlement), nameof(Entitlement.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantPaymentAccount), nameof(TenantPaymentAccount.ConfigPublic), "'{}'::jsonb")]
[InlineData(typeof(TenantSubscription), nameof(TenantSubscription.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantSubscription), nameof(TenantSubscription.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantUsageRecord), nameof(TenantUsageRecord.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantUsageRecord), nameof(TenantUsageRecord.Metadata), "'{}'::jsonb")]
[InlineData(typeof(CommerceRefundRequest), nameof(CommerceRefundRequest.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommerceRefundRequest), nameof(CommerceRefundRequest.Metadata), "'{}'::jsonb")]
@@ -622,9 +622,6 @@ public sealed class PersistenceModelTests
AssertHasUniqueIndex<Order>(nameof(Order.TenantId), nameof(Order.OrderNo)); AssertHasUniqueIndex<Order>(nameof(Order.TenantId), nameof(Order.OrderNo));
AssertHasUniqueIndex<ActivationCode>(nameof(ActivationCode.TenantId), nameof(ActivationCode.Code)); AssertHasUniqueIndex<ActivationCode>(nameof(ActivationCode.TenantId), nameof(ActivationCode.Code));
AssertHasUniqueIndex<TenantPaymentAccount>(
nameof(TenantPaymentAccount.TenantId),
nameof(TenantPaymentAccount.Provider));
AssertHasUniqueIndex<CommerceRefundRequest>( AssertHasUniqueIndex<CommerceRefundRequest>(
nameof(CommerceRefundRequest.TenantId), nameof(CommerceRefundRequest.TenantId),
nameof(CommerceRefundRequest.RefundNo)); nameof(CommerceRefundRequest.RefundNo));

View File

@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Tiku.Application.Auth; using Tiku.Application.Auth;
using Tiku.Application.Security; using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity; using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy; using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Auth;
@@ -182,7 +183,7 @@ public sealed class AuthServiceTests
Assert.Equal(tenantId, result.Tenant.TenantId); Assert.Equal(tenantId, result.Tenant.TenantId);
Assert.Single(context.Users); Assert.Single(context.Users);
Assert.Contains(context.UserIdentities, identity => Assert.Contains(context.UserIdentities, identity =>
identity.Provider == "wechat-miniapp" && identity.Provider == "wechat_miniapp" &&
identity.ProviderSubject == "wx-app-id:mini-open-id" && identity.ProviderSubject == "wx-app-id:mini-open-id" &&
identity.OpenId == "mini-open-id" && identity.OpenId == "mini-open-id" &&
identity.UnionId == "union-id"); identity.UnionId == "union-id");
@@ -198,11 +199,13 @@ public sealed class AuthServiceTests
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp"); var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp");
context.TenantAuthProviders.Add(new TenantAuthProvider context.TenantExternalProviders.Add(new TenantExternalProvider
{ {
TenantId = tenantId, TenantId = tenantId,
Provider = "wechat_web", Provider = "wechat_web",
Status = TenantAuthProviderStatus.Testing, Capability = TenantExternalProviderCapability.Identity,
Status = TenantExternalProviderStatus.Active,
SecretRef = "tenant_secrets:identity:wechat_web:default",
ConfigPublic = WechatProviderConfig() ConfigPublic = WechatProviderConfig()
}); });
var user = new User var user = new User
@@ -257,14 +260,15 @@ public sealed class AuthServiceTests
{ {
var tokenService = new TokenService(Options.Create(JwtOptions)); var tokenService = new TokenService(Options.Create(JwtOptions));
var sessionService = new SessionService(context, 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( return new AuthService(
context, context,
new PasswordHasher(), new PasswordHasher(),
smsService, smsService,
sessionService, sessionService,
wechatOAuthClient ?? new FakeWechatOAuthClient()); wechatOAuthClient ?? new FakeWechatOAuthClient(),
new FakeProviderConfigService(context));
} }
private static async Task<Guid> SeedTenantWithWechatProviderAsync( private static async Task<Guid> SeedTenantWithWechatProviderAsync(
@@ -278,11 +282,13 @@ public sealed class AuthServiceTests
Name = "Wechat Tenant" Name = "Wechat Tenant"
}; };
context.Tenants.Add(tenant); context.Tenants.Add(tenant);
context.TenantAuthProviders.Add(new TenantAuthProvider context.TenantExternalProviders.Add(new TenantExternalProvider
{ {
TenantId = tenant.Id, TenantId = tenant.Id,
Provider = provider, Provider = provider.Replace("-", "_", StringComparison.Ordinal),
Status = TenantAuthProviderStatus.Testing, Capability = TenantExternalProviderCapability.Identity,
Status = TenantExternalProviderStatus.Active,
SecretRef = $"tenant_secrets:identity:{provider}:default",
ConfigPublic = WechatProviderConfig() ConfigPublic = WechatProviderConfig()
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
@@ -294,8 +300,7 @@ public sealed class AuthServiceTests
{ {
return JsonSerializer.SerializeToElement(new return JsonSerializer.SerializeToElement(new
{ {
appId = "wx-app-id", appId = "wx-app-id"
appSecret = "wx-app-secret"
}); });
} }
@@ -347,6 +352,64 @@ public sealed class AuthServiceTests
return document.RootElement.Clone(); return document.RootElement.Clone();
} }
private sealed class FakeProviderConfigService(TikuDbContext context) : ITenantExternalProviderConfigService
{
public Task<TenantExternalProviderAccount> 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<IReadOnlyCollection<TenantExternalProviderItem>> GetProvidersAsync(
Guid tenantId,
TenantExternalProviderCapability? capability = null,
string? provider = null,
int? limit = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<TenantExternalProviderItem> UpsertProviderAsync(
Guid tenantId,
UpsertTenantExternalProviderCommand command,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
private sealed class FakeSmsProvider : ISmsProvider
{
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default) =>
Task.FromResult(new SmsProviderSendResult("fake", "accepted"));
}
private sealed class FakeWechatOAuthClient( private sealed class FakeWechatOAuthClient(
WechatIdentity? WebIdentity = null, WechatIdentity? WebIdentity = null,
WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient

View File

@@ -12,8 +12,8 @@
1. 本仓库是题库 SaaS 唯一继续演进的后端。 1. 本仓库是题库 SaaS 唯一继续演进的后端。
2. 旧 NestJS 仓库冻结为业务行为、接口契约和数据迁移参考;除迁移阻断问题外,不再承接新功能。 2. 旧 NestJS 仓库冻结为业务行为、接口契约和数据迁移参考;除迁移阻断问题外,不再承接新功能。
3. EF Core Migration 是普通数据库结构变更的唯一权威历史。生产环境通过 `Tiku.DbMigrator` 显式执行经审查的迁移API 不在启动时自动同步结构。 3. EF Core Migration 是普通数据库结构变更的唯一权威历史。生产环境通过 `Tiku.DbMigrator` 显式执行经审查的迁移API 不在启动时自动同步结构。
4. 应用只依赖标准 PostgreSQL 能力和 NpgsqlSupabase 可以临时作为 PostgreSQL 托管商,但不是应用层依赖 4. 应用只依赖标准 PostgreSQL 能力和 Npgsql,不再以 Supabase 作为运行时依赖或托管目标
5. 身份、对象存储、短信、支付和通知通过 Application 层接口隔离供应商。前端不直连 Supabase 业务表 5. 身份、对象存储、短信、支付和通知通过 Application 层接口隔离供应商,并通过租户级 `TenantExternalProvider` + `TenantSecret` 配置
6. Yudao 不作为运行时依赖,只参考其 RBAC、租户套餐、审计和后台产品设计。 6. Yudao 不作为运行时依赖,只参考其 RBAC、租户套餐、审计和后台产品设计。
7. 新功能按完整领域闭环迁移和验收,不以 Controller 或 endpoint 数量作为完成标准。 7. 新功能按完整领域闭环迁移和验收,不以 Controller 或 endpoint 数量作为完成标准。
@@ -27,5 +27,5 @@
## 结果 ## 结果
- 不再建设长期双后端或双写链路。 - 不再建设长期双后端或双写链路。
- 更换 PostgreSQL 托管商不要求重写业务代码。 - 更换 PostgreSQL 托管商或外部服务供应商不要求重写业务代码。
- 后续开发先补真实 PostgreSQL 测试、强制租户边界、生产配置 fail-fast 和敏感配置加密,再扩展业务模块。 - 后续开发先补真实 PostgreSQL 测试、强制租户边界、生产配置 fail-fast 和敏感配置加密,再扩展业务模块。

View File

@@ -28,7 +28,8 @@
- Serilog 结构化日志。 - Serilog 结构化日志。
- CORS / RateLimiter / Options 校验。 - CORS / RateLimiter / Options 校验。
- Scalar / OpenAPI 基础入口。 - Scalar / OpenAPI 基础入口。
- 阿里云 OSS V2 资源存储抽象 - 统一租户外部服务配置:`TenantExternalProvider` + `TenantSecret`
- 阿里云 OSS V2 资源存储抽象,业务层不感知 Supabase Storage 或 bucket 细节。
- Senparc 微信登录与小程序码生成边界。 - Senparc 微信登录与小程序码生成边界。
### 数据库 ### 数据库