From 0385a1accdd16b795847e4d50ec00b95d38b4f9a Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 20:26:08 +0800 Subject: [PATCH] feat: add referral crm queue endpoints --- Tiku.Api/Contracts/CrmDtos.cs | 104 +++++ Tiku.Api/Controllers/CrmController.cs | 85 ++++ .../Middleware/ExceptionHandlingMiddleware.cs | 21 + Tiku.Application/Growth/CrmModels.cs | 104 +++++ Tiku.Infrastructure/DependencyInjection.cs | 1 + Tiku.Infrastructure/Growth/CrmService.cs | 420 ++++++++++++++++++ Tiku.IntegrationTests/Api/CrmEndpointTests.cs | 170 +++++++ 7 files changed, 905 insertions(+) create mode 100644 Tiku.Api/Contracts/CrmDtos.cs create mode 100644 Tiku.Api/Controllers/CrmController.cs create mode 100644 Tiku.Application/Growth/CrmModels.cs create mode 100644 Tiku.Infrastructure/Growth/CrmService.cs create mode 100644 Tiku.IntegrationTests/Api/CrmEndpointTests.cs diff --git a/Tiku.Api/Contracts/CrmDtos.cs b/Tiku.Api/Contracts/CrmDtos.cs new file mode 100644 index 0000000..cb33f74 --- /dev/null +++ b/Tiku.Api/Contracts/CrmDtos.cs @@ -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); + } +} diff --git a/Tiku.Api/Controllers/CrmController.cs b/Tiku.Api/Controllers/CrmController.cs new file mode 100644 index 0000000..179822e --- /dev/null +++ b/Tiku.Api/Controllers/CrmController.cs @@ -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(StatusCodes.Status200OK)] + public async Task> Config(CancellationToken cancellationToken) + { + return Ok(await crmService.GetConfigAsync(ResolveActor(), cancellationToken)); + } + + [HttpPut("config")] + [EndpointSummary("保存 CRM 推送配置")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertConfig( + UpsertCrmConfigDto request, + CancellationToken cancellationToken) + { + return Ok(await crmService.UpsertConfigAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("queue")] + [EndpointSummary("查询 CRM webhook 队列")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> Queue( + [FromQuery] CrmQueueQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await crmService.GetQueueAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpGet("dead-letters")] + [EndpointSummary("查询 CRM 死信任务与汇总")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeadLetters( + [FromQuery] CrmQueueQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await crmService.GetDeadLettersAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpGet("queue/logs")] + [EndpointSummary("查询 CRM 队列执行日志")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> Logs( + [FromQuery] CrmQueueLogQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await crmService.GetLogsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpPost("queue/action")] + [EndpointSummary("重试或忽略 CRM 死信任务")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> 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); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index f7197e4..0833161 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -205,6 +205,16 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is CrmException crmException) + { + await WriteProblemAsync( + context, + crmException.Message, + CrmStatusCode(crmException.Code), + crmException.Code); + return; + } + if (exception is PaymentProviderException paymentProviderException) { await WriteProblemAsync( @@ -389,4 +399,15 @@ public sealed class ExceptionHandlingMiddleware( _ => 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 + }; + } } diff --git a/Tiku.Application/Growth/CrmModels.cs b/Tiku.Application/Growth/CrmModels.cs new file mode 100644 index 0000000..7430539 --- /dev/null +++ b/Tiku.Application/Growth/CrmModels.cs @@ -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 Items); + +public sealed record CrmList(IReadOnlyCollection Items); + +public interface ICrmService +{ + Task GetConfigAsync(CrmAdminActor actor, CancellationToken cancellationToken = default); + + Task UpsertConfigAsync( + CrmAdminActor actor, + UpsertCrmConfigCommand command, + CancellationToken cancellationToken = default); + + Task> GetQueueAsync( + CrmAdminActor actor, + CrmQueueQuery query, + CancellationToken cancellationToken = default); + + Task GetDeadLettersAsync( + CrmAdminActor actor, + CrmQueueQuery query, + CancellationToken cancellationToken = default); + + Task> GetLogsAsync( + CrmAdminActor actor, + CrmQueueLogQuery query, + CancellationToken cancellationToken = default); + + Task ApplyQueueActionAsync( + CrmAdminActor actor, + CrmQueueActionCommand command, + CancellationToken cancellationToken = default); +} + +public sealed class CrmException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 4331681..9b93770 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -71,6 +71,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Growth/CrmService.cs b/Tiku.Infrastructure/Growth/CrmService.cs new file mode 100644 index 0000000..171eee9 --- /dev/null +++ b/Tiku.Infrastructure/Growth/CrmService.cs @@ -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 SensitiveKeys = new(StringComparer.OrdinalIgnoreCase) + { + "secret", + "password", + "token", + "accessToken", + "refreshToken", + "signature", + "privateKey", + "apiKey" + }; + + public async Task 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 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> 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(result.Select(ToQueueItem).ToArray()); + } + + public async Task 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> 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(result.Select(ToLogItem).ToArray()); + } + + public async Task 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 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(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()) + { + 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(); + } +} diff --git a/Tiku.IntegrationTests/Api/CrmEndpointTests.cs b/Tiku.IntegrationTests/Api/CrmEndpointTests.cs new file mode 100644 index 0000000..a2821b0 --- /dev/null +++ b/Tiku.IntegrationTests/Api/CrmEndpointTests.cs @@ -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(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(); + 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(); + + 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 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); +}