forked from xiongyuxing/tiku-backend.net
feat: add referral crm queue endpoints
This commit is contained in:
104
Tiku.Api/Contracts/CrmDtos.cs
Normal file
104
Tiku.Api/Contracts/CrmDtos.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Application.Growth;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Contracts;
|
||||||
|
|
||||||
|
public sealed class UpsertCrmConfigDto
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
[StringLength(2048)]
|
||||||
|
public string? Url { get; set; }
|
||||||
|
|
||||||
|
[StringLength(300)]
|
||||||
|
public string? SecretRef { get; set; }
|
||||||
|
|
||||||
|
public string? Secret { get; set; }
|
||||||
|
|
||||||
|
[StringLength(200)]
|
||||||
|
public string? FormName { get; set; }
|
||||||
|
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? ExamType { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 120)]
|
||||||
|
public int? TimeoutSeconds { get; set; }
|
||||||
|
|
||||||
|
[Range(0, 86400)]
|
||||||
|
public int? DelaySeconds { get; set; }
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? AssignmentMode { get; set; }
|
||||||
|
|
||||||
|
public JsonElement? AssignmentPool { get; set; }
|
||||||
|
|
||||||
|
public JsonElement? AssignmentConfig { get; set; }
|
||||||
|
|
||||||
|
public UpsertCrmConfigCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new UpsertCrmConfigCommand(
|
||||||
|
Enabled,
|
||||||
|
Url,
|
||||||
|
SecretRef,
|
||||||
|
Secret,
|
||||||
|
FormName,
|
||||||
|
ExamType,
|
||||||
|
TimeoutSeconds,
|
||||||
|
DelaySeconds,
|
||||||
|
AssignmentMode,
|
||||||
|
AssignmentPool,
|
||||||
|
AssignmentConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CrmQueueQueryDto
|
||||||
|
{
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
public Guid? QueueId { get; set; }
|
||||||
|
|
||||||
|
[StringLength(200)]
|
||||||
|
public string? Source { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 500)]
|
||||||
|
public int? Limit { get; set; }
|
||||||
|
|
||||||
|
public CrmQueueQuery ToQuery()
|
||||||
|
{
|
||||||
|
return new CrmQueueQuery(Status, QueueId, Source, Limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CrmQueueLogQueryDto
|
||||||
|
{
|
||||||
|
public Guid? QueueId { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 200)]
|
||||||
|
public int? Limit { get; set; }
|
||||||
|
|
||||||
|
public CrmQueueLogQuery ToQuery()
|
||||||
|
{
|
||||||
|
return new CrmQueueLogQuery(QueueId, Limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CrmQueueActionDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid QueueId { get; set; }
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? Action { get; set; }
|
||||||
|
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? Note { get; set; }
|
||||||
|
|
||||||
|
public JsonElement? Metadata { get; set; }
|
||||||
|
|
||||||
|
public CrmQueueActionCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new CrmQueueActionCommand(QueueId, Action, Note, Metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Tiku.Api/Controllers/CrmController.cs
Normal file
85
Tiku.Api/Controllers/CrmController.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Application.Growth;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route("api/crm")]
|
||||||
|
public sealed class CrmController(
|
||||||
|
ICrmService crmService,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
ICurrentTenant currentTenant) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("config")]
|
||||||
|
[EndpointSummary("查询 CRM 推送配置")]
|
||||||
|
[ProducesResponseType<CrmConfigItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CrmConfigItem>> Config(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await crmService.GetConfigAsync(ResolveActor(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("config")]
|
||||||
|
[EndpointSummary("保存 CRM 推送配置")]
|
||||||
|
[ProducesResponseType<CrmConfigItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CrmConfigItem>> UpsertConfig(
|
||||||
|
UpsertCrmConfigDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await crmService.UpsertConfigAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("queue")]
|
||||||
|
[EndpointSummary("查询 CRM webhook 队列")]
|
||||||
|
[ProducesResponseType<CrmList<CrmQueueItem>>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CrmList<CrmQueueItem>>> Queue(
|
||||||
|
[FromQuery] CrmQueueQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await crmService.GetQueueAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("dead-letters")]
|
||||||
|
[EndpointSummary("查询 CRM 死信任务与汇总")]
|
||||||
|
[ProducesResponseType<CrmDeadLetterResult>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CrmDeadLetterResult>> DeadLetters(
|
||||||
|
[FromQuery] CrmQueueQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await crmService.GetDeadLettersAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("queue/logs")]
|
||||||
|
[EndpointSummary("查询 CRM 队列执行日志")]
|
||||||
|
[ProducesResponseType<CrmList<CrmQueueLogItem>>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CrmList<CrmQueueLogItem>>> Logs(
|
||||||
|
[FromQuery] CrmQueueLogQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await crmService.GetLogsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("queue/action")]
|
||||||
|
[EndpointSummary("重试或忽略 CRM 死信任务")]
|
||||||
|
[ProducesResponseType<CrmQueueItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CrmQueueItem>> Action(
|
||||||
|
CrmQueueActionDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await crmService.ApplyQueueActionAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CrmAdminActor ResolveActor()
|
||||||
|
{
|
||||||
|
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||||
|
{
|
||||||
|
throw new CrmException("CRM admin actor was not resolved.", "crm_access_denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CrmAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,6 +205,16 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception is CrmException crmException)
|
||||||
|
{
|
||||||
|
await WriteProblemAsync(
|
||||||
|
context,
|
||||||
|
crmException.Message,
|
||||||
|
CrmStatusCode(crmException.Code),
|
||||||
|
crmException.Code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (exception is PaymentProviderException paymentProviderException)
|
if (exception is PaymentProviderException paymentProviderException)
|
||||||
{
|
{
|
||||||
await WriteProblemAsync(
|
await WriteProblemAsync(
|
||||||
@@ -389,4 +399,15 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
_ => StatusCodes.Status400BadRequest
|
_ => StatusCodes.Status400BadRequest
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int CrmStatusCode(string code)
|
||||||
|
{
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
"crm_access_denied" => StatusCodes.Status403Forbidden,
|
||||||
|
"crm_queue_not_found" => StatusCodes.Status404NotFound,
|
||||||
|
"crm_queue_status_invalid" => StatusCodes.Status409Conflict,
|
||||||
|
_ => StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
Tiku.Application/Growth/CrmModels.cs
Normal file
104
Tiku.Application/Growth/CrmModels.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tiku.Application.Growth;
|
||||||
|
|
||||||
|
public sealed record CrmAdminActor(Guid TenantId, Guid UserId);
|
||||||
|
|
||||||
|
public sealed record UpsertCrmConfigCommand(
|
||||||
|
bool Enabled,
|
||||||
|
string? Url,
|
||||||
|
string? SecretRef,
|
||||||
|
string? Secret,
|
||||||
|
string? FormName,
|
||||||
|
string? ExamType,
|
||||||
|
int? TimeoutSeconds,
|
||||||
|
int? DelaySeconds,
|
||||||
|
string? AssignmentMode,
|
||||||
|
JsonElement? AssignmentPool,
|
||||||
|
JsonElement? AssignmentConfig);
|
||||||
|
|
||||||
|
public sealed record CrmQueueQuery(string? Status = null, Guid? QueueId = null, string? Source = null, int? Limit = null);
|
||||||
|
|
||||||
|
public sealed record CrmQueueLogQuery(Guid? QueueId = null, int? Limit = null);
|
||||||
|
|
||||||
|
public sealed record CrmQueueActionCommand(Guid QueueId, string? Action, string? Note, JsonElement? Metadata);
|
||||||
|
|
||||||
|
public sealed record CrmConfigItem(
|
||||||
|
Guid Id,
|
||||||
|
bool Enabled,
|
||||||
|
string? Url,
|
||||||
|
string? SecretRef,
|
||||||
|
string AssignmentMode,
|
||||||
|
JsonElement AssignmentPool,
|
||||||
|
string? FormName,
|
||||||
|
string? ExamType,
|
||||||
|
int? TimeoutSeconds,
|
||||||
|
int? DelaySeconds,
|
||||||
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
public sealed record CrmQueueItem(
|
||||||
|
Guid Id,
|
||||||
|
string Status,
|
||||||
|
int Attempts,
|
||||||
|
string? LeadId,
|
||||||
|
string? RecordId,
|
||||||
|
string? Source,
|
||||||
|
string? Provider,
|
||||||
|
string? LastError,
|
||||||
|
DateTimeOffset? ScheduledAt,
|
||||||
|
DateTimeOffset? NextAttemptAt,
|
||||||
|
JsonElement Payload);
|
||||||
|
|
||||||
|
public sealed record CrmQueueLogItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid? QueueId,
|
||||||
|
string? RecordId,
|
||||||
|
string? LeadId,
|
||||||
|
int? HttpCode,
|
||||||
|
string Outcome,
|
||||||
|
string? ErrorMessage,
|
||||||
|
string? Operation,
|
||||||
|
JsonElement RequestPayload,
|
||||||
|
string? ResponseSummary,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
public sealed record CrmDeadLetterSummary(int Failed, int Discarded, int Total, DateTimeOffset? OldestOpenAt);
|
||||||
|
|
||||||
|
public sealed record CrmDeadLetterResult(CrmDeadLetterSummary Summary, IReadOnlyCollection<CrmQueueItem> Items);
|
||||||
|
|
||||||
|
public sealed record CrmList<T>(IReadOnlyCollection<T> Items);
|
||||||
|
|
||||||
|
public interface ICrmService
|
||||||
|
{
|
||||||
|
Task<CrmConfigItem> GetConfigAsync(CrmAdminActor actor, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CrmConfigItem> UpsertConfigAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
UpsertCrmConfigCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CrmList<CrmQueueItem>> GetQueueAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CrmQueueQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CrmDeadLetterResult> GetDeadLettersAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CrmQueueQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CrmList<CrmQueueLogItem>> GetLogsAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CrmQueueLogQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CrmQueueItem> ApplyQueueActionAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CrmQueueActionCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CrmException(string message, string code) : Exception(message)
|
||||||
|
{
|
||||||
|
public string Code { get; } = code;
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||||
services.AddScoped<IPointService, PointService>();
|
services.AddScoped<IPointService, PointService>();
|
||||||
services.AddScoped<IReferralService, ReferralService>();
|
services.AddScoped<IReferralService, ReferralService>();
|
||||||
|
services.AddScoped<ICrmService, CrmService>();
|
||||||
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
||||||
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
||||||
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
||||||
|
|||||||
420
Tiku.Infrastructure/Growth/CrmService.cs
Normal file
420
Tiku.Infrastructure/Growth/CrmService.cs
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tiku.Application.Growth;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Growth;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Growth;
|
||||||
|
|
||||||
|
public sealed class CrmService(TikuDbContext dbContext) : ICrmService
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
"accessToken",
|
||||||
|
"refreshToken",
|
||||||
|
"signature",
|
||||||
|
"privateKey",
|
||||||
|
"apiKey"
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<CrmConfigItem> GetConfigAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var config = await dbContext.CrmConfigs
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId, cancellationToken);
|
||||||
|
return ToConfigItem(config ?? new CrmConfig { TenantId = actor.TenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CrmConfigItem> UpsertConfigAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
UpsertCrmConfigCommand command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var secretRef = NormalizeOptional(command.SecretRef);
|
||||||
|
if (!string.IsNullOrWhiteSpace(command.Secret))
|
||||||
|
{
|
||||||
|
secretRef ??= "tenant_secrets:crm:webhook:default";
|
||||||
|
await UpsertSecretAsync(actor.TenantId, secretRef, command.Secret, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = await dbContext.CrmConfigs
|
||||||
|
.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId, cancellationToken);
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
config = new CrmConfig { TenantId = actor.TenantId };
|
||||||
|
dbContext.CrmConfigs.Add(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Enabled = command.Enabled;
|
||||||
|
config.Url = NormalizeOptional(command.Url);
|
||||||
|
config.SecretRef = secretRef;
|
||||||
|
config.FormName = NormalizeOptional(command.FormName);
|
||||||
|
config.ExamType = NormalizeOptional(command.ExamType);
|
||||||
|
config.TimeoutSeconds = command.TimeoutSeconds;
|
||||||
|
config.DelaySeconds = command.DelaySeconds;
|
||||||
|
config.AssignmentMode = ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None, "invalid_assignment_mode");
|
||||||
|
config.AssignmentPool = EnsureArray(command.AssignmentPool);
|
||||||
|
config.AssignmentConfig = EnsureObject(command.AssignmentConfig);
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return ToConfigItem(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CrmList<CrmQueueItem>> GetQueueAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CrmQueueQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var items = ApplyQueueQuery(actor.TenantId, query);
|
||||||
|
var result = await items
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 100, 1, 500))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return new CrmList<CrmQueueItem>(result.Select(ToQueueItem).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CrmDeadLetterResult> GetDeadLettersAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CrmQueueQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var deadLetters = dbContext.CrmWebhookQueue
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.TenantId == actor.TenantId &&
|
||||||
|
(item.Status == CrmWebhookQueueStatus.Failed ||
|
||||||
|
item.Status == CrmWebhookQueueStatus.Discarded));
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Source))
|
||||||
|
{
|
||||||
|
var source = query.Source.Trim();
|
||||||
|
deadLetters = deadLetters.Where(item => item.Source == source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
|
{
|
||||||
|
deadLetters = deadLetters.Where(item => item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Failed, "invalid_crm_queue_status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await deadLetters
|
||||||
|
.OrderBy(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 100, 1, 500))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var failed = await dbContext.CrmWebhookQueue.CountAsync(
|
||||||
|
item => item.TenantId == actor.TenantId && item.Status == CrmWebhookQueueStatus.Failed,
|
||||||
|
cancellationToken);
|
||||||
|
var discarded = await dbContext.CrmWebhookQueue.CountAsync(
|
||||||
|
item => item.TenantId == actor.TenantId && item.Status == CrmWebhookQueueStatus.Discarded,
|
||||||
|
cancellationToken);
|
||||||
|
var oldest = await dbContext.CrmWebhookQueue
|
||||||
|
.Where(item => item.TenantId == actor.TenantId && item.Status == CrmWebhookQueueStatus.Failed)
|
||||||
|
.OrderBy(item => item.CreatedAt)
|
||||||
|
.Select(item => (DateTimeOffset?)item.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CrmDeadLetterResult(
|
||||||
|
new CrmDeadLetterSummary(failed, discarded, failed + discarded, oldest),
|
||||||
|
items.Select(ToQueueItem).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CrmList<CrmQueueLogItem>> GetLogsAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CrmQueueLogQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var logs = dbContext.CrmWebhookLogs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId);
|
||||||
|
if (query.QueueId.HasValue)
|
||||||
|
{
|
||||||
|
var queue = await dbContext.CrmWebhookQueue
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId && item.Id == query.QueueId.Value)
|
||||||
|
.Select(item => new { item.Id, item.RecordId })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found");
|
||||||
|
logs = logs.Where(item => item.RecordId == queue.RecordId || item.RecordId == queue.Id.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await logs
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return new CrmList<CrmQueueLogItem>(result.Select(ToLogItem).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CrmQueueItem> ApplyQueueActionAsync(
|
||||||
|
CrmAdminActor actor,
|
||||||
|
CrmQueueActionCommand command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var item = await dbContext.CrmWebhookQueue
|
||||||
|
.FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Id == command.QueueId, cancellationToken)
|
||||||
|
?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found");
|
||||||
|
var action = NormalizeOptional(command.Action) ?? "retry";
|
||||||
|
if (action.Equals("retry", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded or CrmWebhookQueueStatus.Retrying))
|
||||||
|
{
|
||||||
|
throw new CrmException("CRM queue item cannot be retried.", "crm_queue_status_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Status = CrmWebhookQueueStatus.Pending;
|
||||||
|
item.NextAttemptAt = DateTimeOffset.UtcNow;
|
||||||
|
item.LastError = null;
|
||||||
|
}
|
||||||
|
else if (action.Equals("ignore", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (item.Status is CrmWebhookQueueStatus.Sent)
|
||||||
|
{
|
||||||
|
throw new CrmException("CRM queue item cannot be ignored.", "crm_queue_status_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Status = CrmWebhookQueueStatus.Discarded;
|
||||||
|
item.LastError = NormalizeOptional(command.Note) ?? item.LastError;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new CrmException("CRM queue action was invalid.", "invalid_crm_queue_action");
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.CrmWebhookLogs.Add(new CrmWebhookLog
|
||||||
|
{
|
||||||
|
TenantId = actor.TenantId,
|
||||||
|
RecordId = item.RecordId ?? item.Id.ToString(),
|
||||||
|
LeadId = item.LeadId,
|
||||||
|
Outcome = "operator_action",
|
||||||
|
ErrorMessage = item.LastError,
|
||||||
|
RequestPayload = Redact(command.Metadata ?? JsonSerializer.SerializeToElement(new { note = command.Note })),
|
||||||
|
ResponseSummary = action,
|
||||||
|
Attempt = item.Attempts
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return ToQueueItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<CrmWebhookQueueItem> ApplyQueueQuery(Guid tenantId, CrmQueueQuery query)
|
||||||
|
{
|
||||||
|
var items = dbContext.CrmWebhookQueue
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId);
|
||||||
|
if (query.QueueId.HasValue)
|
||||||
|
{
|
||||||
|
items = items.Where(item => item.Id == query.QueueId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
|
{
|
||||||
|
items = items.Where(item => item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending, "invalid_crm_queue_status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Source))
|
||||||
|
{
|
||||||
|
var source = query.Source.Trim();
|
||||||
|
items = items.Where(item => item.Source == source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertSecretAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string secretRef,
|
||||||
|
string secret,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var item = await dbContext.TenantSecrets
|
||||||
|
.FirstOrDefaultAsync(secretItem => secretItem.TenantId == tenantId && secretItem.SecretRef == secretRef, cancellationToken);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
item = new TenantSecret
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Purpose = "crm",
|
||||||
|
Provider = "webhook",
|
||||||
|
SecretKey = "default",
|
||||||
|
SecretRef = secretRef
|
||||||
|
};
|
||||||
|
dbContext.TenantSecrets.Add(item);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item.RotatedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Status = TenantSecretStatus.Active;
|
||||||
|
item.SecretPayload = JsonSerializer.SerializeToElement(new { webhookSecret = secret });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AssertAdminAsync(CrmAdminActor actor, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var isAdmin = await dbContext.TenantMemberships.AnyAsync(
|
||||||
|
item =>
|
||||||
|
item.TenantId == actor.TenantId &&
|
||||||
|
item.UserId == actor.UserId &&
|
||||||
|
item.Status == MembershipStatus.Active &&
|
||||||
|
(item.Role == TenantRole.PlatformAdmin ||
|
||||||
|
item.Role == TenantRole.TenantOwner ||
|
||||||
|
item.Role == TenantRole.TenantAdmin),
|
||||||
|
cancellationToken);
|
||||||
|
if (!isAdmin)
|
||||||
|
{
|
||||||
|
throw new CrmException("CRM admin access was denied.", "crm_access_denied");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CrmConfigItem ToConfigItem(CrmConfig item)
|
||||||
|
{
|
||||||
|
return new CrmConfigItem(
|
||||||
|
item.Id,
|
||||||
|
item.Enabled,
|
||||||
|
item.Url,
|
||||||
|
item.SecretRef,
|
||||||
|
item.AssignmentMode.ToString(),
|
||||||
|
item.AssignmentPool,
|
||||||
|
item.FormName,
|
||||||
|
item.ExamType,
|
||||||
|
item.TimeoutSeconds,
|
||||||
|
item.DelaySeconds,
|
||||||
|
item.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CrmQueueItem ToQueueItem(CrmWebhookQueueItem item)
|
||||||
|
{
|
||||||
|
return new CrmQueueItem(
|
||||||
|
item.Id,
|
||||||
|
item.Status.ToString(),
|
||||||
|
item.Attempts,
|
||||||
|
item.LeadId,
|
||||||
|
item.RecordId,
|
||||||
|
item.Source,
|
||||||
|
item.Provider,
|
||||||
|
RedactText(item.LastError),
|
||||||
|
item.ScheduledAt,
|
||||||
|
item.NextAttemptAt,
|
||||||
|
Redact(item.Payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CrmQueueLogItem ToLogItem(CrmWebhookLog item)
|
||||||
|
{
|
||||||
|
return new CrmQueueLogItem(
|
||||||
|
item.Id,
|
||||||
|
null,
|
||||||
|
item.RecordId,
|
||||||
|
item.LeadId,
|
||||||
|
item.HttpCode,
|
||||||
|
item.Outcome ?? "unknown",
|
||||||
|
RedactText(item.ErrorMessage),
|
||||||
|
item.ResponseSummary,
|
||||||
|
Redact(item.RequestPayload),
|
||||||
|
RedactText(item.ResponseSummary),
|
||||||
|
item.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement EnsureObject(JsonElement? value)
|
||||||
|
{
|
||||||
|
if (value is null || value.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
return JsonDefaults.Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Value.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
throw new CrmException("CRM JSON value must be an object.", "invalid_json_payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Value.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement EnsureArray(JsonElement? value)
|
||||||
|
{
|
||||||
|
if (value is null || value.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
return JsonDefaults.Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Value.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
throw new CrmException("CRM JSON value must be an array.", "invalid_json_payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Value.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement Redact(JsonElement value)
|
||||||
|
{
|
||||||
|
object? converted = RedactValue(value);
|
||||||
|
return JsonSerializer.SerializeToElement(converted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? RedactValue(JsonElement value)
|
||||||
|
{
|
||||||
|
return value.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Object => value.EnumerateObject()
|
||||||
|
.ToDictionary(
|
||||||
|
property => property.Name,
|
||||||
|
property => SensitiveKeys.Contains(property.Name) ? "***" : RedactValue(property.Value)),
|
||||||
|
JsonValueKind.Array => value.EnumerateArray().Select(RedactValue).ToArray(),
|
||||||
|
JsonValueKind.String => value.GetString(),
|
||||||
|
JsonValueKind.Number when value.TryGetInt64(out var longValue) => longValue,
|
||||||
|
JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue,
|
||||||
|
JsonValueKind.True => true,
|
||||||
|
JsonValueKind.False => false,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? RedactText(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = value;
|
||||||
|
foreach (var key in SensitiveKeys)
|
||||||
|
{
|
||||||
|
result = result.Replace(key, "***", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TEnum ParseEnum<TEnum>(string? value, TEnum defaultValue, string errorCode)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal);
|
||||||
|
foreach (var enumValue in Enum.GetValues<TEnum>())
|
||||||
|
{
|
||||||
|
if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return enumValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CrmException("CRM enum value was invalid.", errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeOptional(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
170
Tiku.IntegrationTests/Api/CrmEndpointTests.cs
Normal file
170
Tiku.IntegrationTests/Api/CrmEndpointTests.cs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Application.Auth;
|
||||||
|
using Tiku.Application.Growth;
|
||||||
|
using Tiku.Domain.Growth;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Auth;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
|
public sealed class CrmEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Anonymous_crm_request_returns_401()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/crm/config");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_can_upsert_config_without_secret_leak()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var seed = await SeedAdminAsync(factory);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
|
||||||
|
var response = await client.PutAsJsonAsync(
|
||||||
|
"/api/crm/config",
|
||||||
|
new UpsertCrmConfigDto
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Url = "https://crm.example.test/webhook",
|
||||||
|
Secret = "super-secret",
|
||||||
|
AssignmentMode = "round_robin",
|
||||||
|
AssignmentPool = JsonSerializer.SerializeToElement(new[] { seed.UserId }),
|
||||||
|
FormName = "题库线索",
|
||||||
|
TimeoutSeconds = 10,
|
||||||
|
DelaySeconds = 3
|
||||||
|
});
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
var config = JsonSerializer.Deserialize<CrmConfigItem>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.DoesNotContain("super-secret", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal("tenant_secrets:crm:webhook:default", config!.SecretRef);
|
||||||
|
Assert.Equal("RoundRobin", config.AssignmentMode);
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.Contains(dbContext.TenantSecrets, item =>
|
||||||
|
item.TenantId == seed.TenantId &&
|
||||||
|
item.SecretRef == "tenant_secrets:crm:webhook:default");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_can_query_and_retry_dead_letter_with_redacted_payload()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var seed = await SeedAdminAsync(factory);
|
||||||
|
var queueId = Guid.NewGuid();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new CrmWebhookQueueItem
|
||||||
|
{
|
||||||
|
Id = queueId,
|
||||||
|
TenantId = seed.TenantId,
|
||||||
|
RecordId = "lead-1",
|
||||||
|
LeadId = "lead-1",
|
||||||
|
Source = "referral.bind",
|
||||||
|
Provider = "webhook",
|
||||||
|
Status = CrmWebhookQueueStatus.Failed,
|
||||||
|
Attempts = 3,
|
||||||
|
LastError = "token expired",
|
||||||
|
Payload = JsonSerializer.SerializeToElement(new { name = "student", secret = "hidden-value" })
|
||||||
|
},
|
||||||
|
new CrmWebhookLog
|
||||||
|
{
|
||||||
|
TenantId = seed.TenantId,
|
||||||
|
RecordId = "lead-1",
|
||||||
|
LeadId = "lead-1",
|
||||||
|
Outcome = "failed",
|
||||||
|
ErrorMessage = "password leaked",
|
||||||
|
RequestPayload = JsonSerializer.SerializeToElement(new { token = "abc" }),
|
||||||
|
ResponseSummary = "failed"
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
|
||||||
|
var queue = await client.GetAsync("/api/crm/queue?status=failed");
|
||||||
|
var deadLetters = await client.GetAsync("/api/crm/dead-letters");
|
||||||
|
var logs = await client.GetAsync($"/api/crm/queue/logs?queueId={queueId}");
|
||||||
|
var retry = await client.PostAsJsonAsync(
|
||||||
|
"/api/crm/queue/action",
|
||||||
|
new CrmQueueActionDto { QueueId = queueId, Action = "retry", Note = "again" });
|
||||||
|
var queueBody = await queue.Content.ReadAsStringAsync();
|
||||||
|
var logsBody = await logs.Content.ReadAsStringAsync();
|
||||||
|
var retried = await retry.Content.ReadFromJsonAsync<CrmQueueItem>();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, queue.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, deadLetters.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, logs.StatusCode);
|
||||||
|
Assert.DoesNotContain("hidden-value", queueBody, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("abc", logsBody, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, retry.StatusCode);
|
||||||
|
Assert.Equal("Pending", retried!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<LoginSeed> SeedAdminAsync(ApiTestFactory factory)
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var phone = "13800002001";
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CRM Tenant" },
|
||||||
|
new User { Id = userId, Phone = phone, Name = "CRM Admin" },
|
||||||
|
new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = userId,
|
||||||
|
Role = TenantRole.TenantAdmin,
|
||||||
|
Status = MembershipStatus.Active
|
||||||
|
},
|
||||||
|
new UserIdentity
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Provider = "password",
|
||||||
|
ProviderSubject = phone,
|
||||||
|
Phone = phone,
|
||||||
|
SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!"))
|
||||||
|
});
|
||||||
|
return new LoginSeed(tenantId, userId, phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||||
|
{
|
||||||
|
var loginResponse = await client.PostAsJsonAsync(
|
||||||
|
"/api/auth/login/password",
|
||||||
|
new PasswordLoginDto
|
||||||
|
{
|
||||||
|
TenantId = seed.TenantId,
|
||||||
|
Phone = seed.Phone,
|
||||||
|
Password = "passw0rd!"
|
||||||
|
});
|
||||||
|
loginResponse.EnsureSuccessStatusCode();
|
||||||
|
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||||
|
var accessToken = loginJson.RootElement
|
||||||
|
.GetProperty("tokens")
|
||||||
|
.GetProperty("accessToken")
|
||||||
|
.GetString();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(
|
||||||
|
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user