feat: modularize external providers

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

View File

@@ -2,11 +2,13 @@ using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Api.Contracts;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
@@ -166,6 +168,11 @@ public sealed class AuthEndpointTests
{
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
var tenantId = Guid.NewGuid();
var secretRef = "tenant_secrets:identity:wechat_miniapp:default";
var protectedSecret = ProtectTenantSecret(
tenantId,
secretRef,
JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }));
await factory.SeedAsync(
new Tenant
{
@@ -173,16 +180,30 @@ public sealed class AuthEndpointTests
Slug = tenantId.ToString("N"),
Name = "Wechat Tenant"
},
new TenantAuthProvider
new TenantExternalProvider
{
TenantId = tenantId,
Provider = "wechat-miniapp",
Status = TenantAuthProviderStatus.Testing,
Provider = "wechat_miniapp",
Capability = TenantExternalProviderCapability.Identity,
Status = TenantExternalProviderStatus.Active,
SecretRef = secretRef,
ConfigPublic = JsonSerializer.SerializeToElement(new
{
appId = "wx-app-id",
appSecret = "wx-app-secret"
appId = "wx-app-id"
})
},
new TenantSecret
{
TenantId = tenantId,
Purpose = "identity",
Provider = "wechat_miniapp",
SecretKey = "default",
SecretRef = secretRef,
Status = TenantSecretStatus.Active,
EncryptionKeyId = protectedSecret.KeyId,
EncryptedPayload = protectedSecret.Ciphertext,
EncryptionNonce = protectedSecret.Nonce,
EncryptionTag = protectedSecret.Tag
});
using var client = factory.CreateClient();
@@ -207,7 +228,7 @@ public sealed class AuthEndpointTests
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
Assert.Contains(dbContext.UserIdentities, identity =>
identity.Provider == "wechat-miniapp" &&
identity.Provider == "wechat_miniapp" &&
identity.OpenId == "mini-open-id" &&
identity.UnionId == "union-id");
}
@@ -285,6 +306,16 @@ public sealed class AuthEndpointTests
return document.RootElement.Clone();
}
private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload)
{
var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions
{
KeyId = "development-v1",
MasterKey = TenantSecretEncryptionOptions.DevelopmentMasterKey
}));
return protector.Protect(tenantId, secretRef, payload);
}
private sealed class FakeWechatOAuthClient : IWechatOAuthClient
{
public Task<WechatIdentity> ExchangeWebCodeAsync(

View File

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

View File

@@ -217,7 +217,7 @@ public sealed class TenantAdminDirectEndpointTests
});
var authProviderResponse = await client.PutAsJsonAsync(
"/api/tenant-admin/auth-providers",
new UpsertTenantAuthProviderDto
new UpsertTenantIdentityProviderDto
{
Provider = "wechat_mp",
Status = "active",
@@ -244,7 +244,10 @@ public sealed class TenantAdminDirectEndpointTests
Assert.True(await dbContext.TenantBrandings.AnyAsync(item => item.TenantId == seed.TenantId && item.BrandName == "机构题库"));
Assert.True(await dbContext.TenantThemeConfigs.AnyAsync(item => item.TenantId == seed.TenantId && item.ActiveTemplateCode == "classic"));
Assert.True(await dbContext.TenantDomains.AnyAsync(item => item.TenantId == seed.TenantId && item.Host == "learn.example.com" && item.IsPrimary));
Assert.True(await dbContext.TenantAuthProviders.AnyAsync(item => item.TenantId == seed.TenantId && item.Provider == "wechat_mp"));
Assert.True(await dbContext.TenantExternalProviders.AnyAsync(item =>
item.TenantId == seed.TenantId &&
item.Capability == TenantExternalProviderCapability.Identity &&
item.Provider == "wechat_mp"));
}
[Fact]

View File

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

View File

@@ -39,6 +39,46 @@ public sealed class ArchitectureBoundaryTests
$"Forbidden tenant-boundary bypasses were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
[Fact]
public void Business_code_does_not_depend_on_supabase_or_removed_provider_models()
{
var root = FindRepositoryRoot();
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain", "Tiku.Infrastructure" };
var forbidden = new[]
{
"Supabase",
"supabase_storage",
"SUPABASE_",
"TenantAuthProvider",
"TenantPaymentAccount",
"TenantPaymentMode",
"TenantPaymentAccountStatus",
"TenantAuthProviderStatus"
};
var allowedPathFragments = new[]
{
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}"
};
var violations = sourceRoots
.SelectMany(directory => Directory.EnumerateFiles(
Path.Combine(root, directory),
"*.cs",
SearchOption.AllDirectories))
.Where(path => !allowedPathFragments.Any(fragment => path.Contains(fragment, StringComparison.Ordinal)))
.SelectMany(path => File.ReadLines(path)
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
.Where(candidate => forbidden.Any(symbol =>
candidate.line.Contains(symbol, StringComparison.Ordinal)))
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
.ToArray();
Assert.True(
violations.Length == 0,
$"Forbidden external-provider coupling was found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);

View File

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