feat(platform): add CRM SMS and payment settings

This commit is contained in:
2026-07-29 15:41:27 +08:00
parent 6db200a2fc
commit ead7bdc2e2
25 changed files with 23250 additions and 140 deletions

View File

@@ -7,6 +7,7 @@ using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
@@ -182,6 +183,134 @@ public sealed class PlatformAdminEndpointTests
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Platform_admin_can_manage_crm_sms_and_payment_settings()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
var platform = await SeedPlatformAdminAsync(factory);
var tenantA = Guid.NewGuid();
var tenantB = Guid.NewGuid();
var failedQueueId = Guid.NewGuid();
var otherQueueId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant { Id = tenantA, Slug = "capability-a", Name = "Capability A", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active },
new Tenant { Id = tenantB, Slug = "capability-b", Name = "Capability B", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active },
new CrmWebhookQueueItem { Id = failedQueueId, TenantId = tenantA, RecordId = "lead-a", Source = "tenant.student.crm_push", Status = CrmWebhookQueueStatus.Failed, Attempts = 2, IdempotencyKey = "platform-capability-lead-a", LastError = "timeout" },
new CrmWebhookQueueItem { Id = otherQueueId, TenantId = tenantB, RecordId = "lead-b", Source = "tenant.student.crm_push", Status = CrmWebhookQueueStatus.Failed, Attempts = 1, IdempotencyKey = "platform-capability-lead-b", LastError = "still failed" });
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
var crm = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/crm/configs", new UpsertPlatformCrmConfigDto
{
TenantId = tenantA,
Enabled = true,
Url = "https://crm.example.test/webhook",
SecretRef = "tenant_secret:crm:redacted",
AssignmentMode = "round_robin",
AssignmentPool = JsonSerializer.SerializeToElement(new[] { "sales-a" }),
AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3 })
});
var retry = await client.PostAsJsonAsync("/api/platform-admin/tenant-capabilities/crm/leads/retry", new RetryPlatformCrmLeadDto { QueueId = failedQueueId, Note = "retry" });
var smsChannel = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto
{
TenantId = tenantA,
Provider = "aliyun",
Name = "阿里云短信",
Signature = "题库测试",
Scene = "login",
Status = TenantExternalProviderStatus.Active,
SecretRef = "tenant_secret:sms:aliyun:redacted",
MonthlyQuota = 1000
});
var smsJson = JsonDocument.Parse(await smsChannel.Content.ReadAsStringAsync());
var channelId = smsJson.RootElement.GetProperty("id").GetGuid();
var template = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/templates", new UpsertPlatformSmsTemplateDto
{
TenantId = tenantA,
ChannelId = channelId,
Code = "login_code",
Name = "登录验证码",
Type = SmsTemplateType.VerificationCode,
AuditStatus = SmsTemplateAuditStatus.Draft,
Status = SmsTemplateStatus.Active,
Content = "验证码 ${code}"
});
var paymentApp = await client.PutAsJsonAsync("/api/platform-admin/payment-settings/apps", new UpsertPlatformPaymentAppDto
{
AppCode = "platform_collect_test",
AppName = "平台收款测试",
Status = PlatformPaymentAppStatus.Active,
SettlementMode = "PlatformCollect"
});
var tenantPayment = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/payments/apps", new UpsertPlatformTenantPaymentAppDto
{
TenantId = tenantA,
Provider = "manual",
Status = TenantExternalProviderStatus.Active,
DisplayName = "线下收款",
SecretRef = "tenant_payment:manual:redacted"
});
var reads = await Task.WhenAll(
client.GetAsync("/api/platform-admin/tenant-capabilities/crm/configs"),
client.GetAsync("/api/platform-admin/tenant-capabilities/crm/leads"),
client.GetAsync("/api/platform-admin/tenant-capabilities/sms/channels"),
client.GetAsync("/api/platform-admin/tenant-capabilities/sms/templates"),
client.GetAsync("/api/platform-admin/payment-settings/apps"),
client.GetAsync("/api/platform-admin/tenant-capabilities/payments/apps"));
Assert.Equal(HttpStatusCode.OK, crm.StatusCode);
Assert.Equal(HttpStatusCode.OK, retry.StatusCode);
Assert.Equal(HttpStatusCode.OK, smsChannel.StatusCode);
Assert.Equal(HttpStatusCode.OK, template.StatusCode);
Assert.Equal(HttpStatusCode.OK, paymentApp.StatusCode);
Assert.Equal(HttpStatusCode.OK, tenantPayment.StatusCode);
Assert.All(reads, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode));
using var scope = factory.CreateSystemScope("Verify platform capability endpoints");
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(CrmWebhookQueueStatus.Retrying, await db.CrmWebhookQueue.Where(item => item.Id == failedQueueId).Select(item => item.Status).SingleAsync());
Assert.Equal(CrmWebhookQueueStatus.Failed, await db.CrmWebhookQueue.Where(item => item.Id == otherQueueId).Select(item => item.Status).SingleAsync());
Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.crm.lead.retry"));
Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.sms.channel.upserted"));
Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.payment.app.upserted"));
}
[Fact]
public async Task Platform_capability_read_permissions_cannot_write()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
var platform = await SeedPlatformUserAsync(factory,
[
BackendPermissions.PlatformCrmRead,
BackendPermissions.PlatformSmsRead,
BackendPermissions.PlatformPaymentRead
]);
var tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant { Id = tenantId, Slug = "readonly-capability", Name = "Readonly Capability" });
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
var read = await client.GetAsync("/api/platform-admin/tenant-capabilities/sms/channels");
var write = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto
{
TenantId = tenantId,
Provider = "aliyun",
Name = "只读不应写入",
Signature = "题库",
Scene = "login",
Status = TenantExternalProviderStatus.Active
});
Assert.Equal(HttpStatusCode.OK, read.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, write.StatusCode);
}
[Fact]
public async Task Platform_admin_can_manage_dunning_channels_and_retry_events()
{
@@ -301,11 +430,19 @@ public sealed class PlatformAdminEndpointTests
}
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
{
return await SeedPlatformUserAsync(factory, BackendPermissions.Platform);
}
private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(
ApiTestFactory factory,
IEnumerable<string> platformPermissions)
{
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var email = $"platform-{Guid.NewGuid():N}@example.test";
var modules = BackendPermissions.Platform
var permissionCodes = platformPermissions.Distinct(StringComparer.Ordinal).ToArray();
var modules = permissionCodes
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
.Distinct(StringComparer.Ordinal)
.Select(code => new PermissionModule
@@ -317,7 +454,7 @@ public sealed class PlatformAdminEndpointTests
})
.Cast<object>()
.ToList();
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
var permissions = permissionCodes.Select(code => new BackendPermission
{
Code = code,
Name = code,
@@ -348,7 +485,7 @@ public sealed class PlatformAdminEndpointTests
Status = BackendRoleStatus.Active,
IsSystem = true
},
..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission
..permissionCodes.Select(code => new PlatformBackendRolePermission
{
RoleId = roleId,
PermissionCode = code