forked from gongxuegit/tiku-backend.net
feat: modularize external providers
This commit is contained in:
@@ -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 模型覆盖:
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
23
Tiku.Application/Auth/IIdentityProvider.cs
Normal file
23
Tiku.Application/Auth/IIdentityProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
23
Tiku.Application/Auth/ISmsProvider.cs
Normal file
23
Tiku.Application/Auth/ISmsProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
26
Tiku.Application/Notifications/INotificationProvider.cs
Normal file
26
Tiku.Application/Notifications/INotificationProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
65
Tiku.Application/Tenancy/TenantExternalProviderModels.cs
Normal file
65
Tiku.Application/Tenancy/TenantExternalProviderModels.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -170,7 +170,6 @@ public enum ContentAssetType
|
|||||||
public enum AssetStorageProvider
|
public enum AssetStorageProvider
|
||||||
{
|
{
|
||||||
ExternalUrl,
|
ExternalUrl,
|
||||||
SupabaseStorage,
|
|
||||||
AliyunOss,
|
AliyunOss,
|
||||||
TencentCos,
|
TencentCos,
|
||||||
QiniuKodo,
|
QiniuKodo,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
14
Tiku.Infrastructure/Auth/NoopSmsProvider.cs
Normal file
14
Tiku.Infrastructure/Auth/NoopSmsProvider.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs
Normal file
54
Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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");
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 能力和 Npgsql。Supabase 可以临时作为 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 和敏感配置加密,再扩展业务模块。
|
||||||
|
|||||||
@@ -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 微信登录与小程序码生成边界。
|
||||||
|
|
||||||
### 数据库
|
### 数据库
|
||||||
|
|||||||
Reference in New Issue
Block a user