V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,13 @@
// generated from contracts/integrations/kbx.integrations.json; do not edit.
namespace Kbx.Shared.Integrations.Generated;
public sealed record KbxIntegrationDefinition(string Id,string Title,string OwnerModule,string Direction,string Transport,string Criticality,string SourceEvent,string Target,string Delivery,string Ordering,string Idempotency,int TimeoutMs,int ShortRetryAttempts,int ShortRetryBaseDelayMs,string ShortRetryBackoff,int LongRetryAttempts,IReadOnlyList<int> LongRetryScheduleSeconds,double CircuitFailureRatio,int CircuitSamplingSeconds,int CircuitMinimumThroughput,int CircuitBreakSeconds,string TerminalAction,bool UserVisible);
public static class KbxIntegrationCatalog
{
public const string SourceSha256 = "607853e43ab35fdacc1adea7d5b470dfad791f6718afba215b5600c974b0642e";
public static readonly IReadOnlyDictionary<string,KbxIntegrationDefinition> All = new Dictionary<string,KbxIntegrationDefinition>(StringComparer.Ordinal)
{
["integration.oms.wms.dispatch"] = new("integration.oms.wms.dispatch", "OMS → WMS 출고지시 전달", "OMS", "outbound", "outbox-http", "high", "OrderShipmentRequested", "WMS", "at-least-once", "per-aggregate", "event-id", 3000, 2, 250, "exponential", 8, new[] { 10, 30, 120, 300, 900, 1800, 3600, 7200 }, 0.5, 30, 10, 30, "operations-exception", true),
["integration.wms.oms.picking-result"] = new("integration.wms.oms.picking-result", "WMS → OMS 피킹결과 전달", "WMS", "outbound", "outbox-http", "high", "PickingCompleted", "OMS", "at-least-once", "per-aggregate", "event-id", 3000, 2, 250, "exponential", 8, new[] { 10, 30, 120, 300, 900, 1800, 3600, 7200 }, 0.5, 30, 10, 30, "operations-exception", true),
["integration.carrier.tracking"] = new("integration.carrier.tracking", "배송사 송장/배송상태 연계", "OMS", "outbound", "outbox-http", "medium", "TrackingSubmissionRequested", "CarrierGateway", "at-least-once", "none", "business-key", 5000, 2, 500, "exponential", 6, new[] { 30, 120, 600, 1800, 3600, 7200 }, 0.5, 60, 10, 60, "operations-exception", true)
};
}
@@ -0,0 +1,18 @@
using Kbx.Shared.Integrations.Generated;
namespace Kbx.Shared.Integrations;
public static class KbxDurableIntegrationRetryPlanner
{
public static DateTimeOffset? NextAttemptAt(
KbxIntegrationDefinition definition,
int durableAttempt,
DateTimeOffset now)
{
if (durableAttempt < 1) throw new ArgumentOutOfRangeException(nameof(durableAttempt));
if (durableAttempt > definition.LongRetryAttempts) return null;
var index = durableAttempt - 1;
if (index >= definition.LongRetryScheduleSeconds.Count) return null;
return now.AddSeconds(definition.LongRetryScheduleSeconds[index]);
}
}
@@ -0,0 +1,18 @@
using Dapper;
using Npgsql;
namespace Kbx.Shared.Integrations;
public sealed class KbxInboxReceiptStore(NpgsqlDataSource dataSource)
{
public async Task<bool> TryBeginAsync(Guid tenantId, string integrationId, string messageKey, CancellationToken ct)
{
const string sql="""
insert into kbx.integration_receipts(tenant_id,integration_id,message_key,received_at)
values (@TenantId,@IntegrationId,@MessageKey,now())
on conflict do nothing;
""";
await using var connection=await dataSource.OpenConnectionAsync(ct);
return await connection.ExecuteAsync(new CommandDefinition(sql,new { TenantId=tenantId, IntegrationId=integrationId, MessageKey=messageKey },cancellationToken:ct))==1;
}
}
@@ -0,0 +1,13 @@
namespace Kbx.Shared.Integrations;
public sealed class KbxIntegrationAdapterRegistry(IEnumerable<IKbxIntegrationAdapter> adapters)
: IKbxIntegrationAdapterRegistry
{
private readonly IReadOnlyDictionary<string, IKbxIntegrationAdapter> _adapters =
adapters.ToDictionary(x => x.IntegrationId, StringComparer.Ordinal);
public IKbxIntegrationAdapter GetRequired(string integrationId)
=> _adapters.TryGetValue(integrationId, out var adapter)
? adapter
: throw new InvalidOperationException($"Integration adapter is not registered: {integrationId}");
}
@@ -0,0 +1,35 @@
using Dapper;
using Npgsql;
namespace Kbx.Shared.Integrations;
public sealed class KbxIntegrationAttemptRepository(NpgsqlDataSource dataSource)
{
public async Task<Guid> StartAsync(KbxIntegrationMessage message, int attemptNo, CancellationToken ct)
{
const string sql = """
insert into kbx.integration_attempts(id, tenant_id, integration_id, message_id, aggregate_type, aggregate_id,
aggregate_version, attempt_no, state, correlation_id, started_at)
values (@Id,@TenantId,@IntegrationId,@MessageId,@AggregateType,@AggregateId,@AggregateVersion,@AttemptNo,'delivering',@CorrelationId,now())
on conflict (tenant_id,integration_id,message_id,attempt_no) do update set correlation_id=excluded.correlation_id
returning id;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.ExecuteScalarAsync<Guid>(new CommandDefinition(sql, new {
Id=Guid.NewGuid(), message.TenantId, message.IntegrationId, message.MessageId, message.AggregateType,
message.AggregateId, message.AggregateVersion, AttemptNo=attemptNo, message.CorrelationId
}, cancellationToken:ct));
}
public async Task CompleteAsync(Guid id, KbxIntegrationAttemptResult result, string state, DateTimeOffset? nextRetryAt, CancellationToken ct)
{
const string sql = """
update kbx.integration_attempts
set state=@State, completed_at=now(), failure_kind=@FailureKind, failure_code=@Code, detail=@Detail,
http_status=@HttpStatus, external_reference=@ExternalReference, next_retry_at=@NextRetryAt
where id=@Id;
""";
await using var connection=await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql,new { Id=id, State=state, FailureKind=result.FailureKind?.ToString().ToLowerInvariant(), result.Code, result.Detail, result.HttpStatus, result.ExternalReference, NextRetryAt=nextRetryAt },cancellationToken:ct));
}
}
@@ -0,0 +1,34 @@
namespace Kbx.Shared.Integrations;
public enum KbxIntegrationDeliveryState { Queued, Delivering, Retrying, Delivered, Failed, Suspended }
public enum KbxIntegrationFailureKind { Transient, Permanent }
public sealed record KbxIntegrationMessage(
Guid MessageId,
Guid TenantId,
string IntegrationId,
string AggregateType,
string AggregateId,
long? AggregateVersion,
string Payload,
string CorrelationId,
DateTimeOffset CreatedAt);
public sealed record KbxIntegrationAttemptResult(
bool Succeeded,
KbxIntegrationFailureKind? FailureKind = null,
string? Code = null,
string? Detail = null,
int? HttpStatus = null,
string? ExternalReference = null);
public interface IKbxIntegrationAdapter
{
string IntegrationId { get; }
ValueTask<KbxIntegrationAttemptResult> SendAsync(KbxIntegrationMessage message, CancellationToken cancellationToken);
}
public interface IKbxIntegrationAdapterRegistry
{
IKbxIntegrationAdapter GetRequired(string integrationId);
}
@@ -0,0 +1,18 @@
namespace Kbx.Shared.Integrations;
public static class KbxIntegrationFailureClassifier
{
public static KbxIntegrationFailureKind Classify(Exception error) => error switch
{
OperationCanceledException => KbxIntegrationFailureKind.Transient,
HttpRequestException => KbxIntegrationFailureKind.Transient,
_ => KbxIntegrationFailureKind.Permanent,
};
public static KbxIntegrationFailureKind ClassifyHttp(int statusCode) => statusCode switch
{
408 or 429 => KbxIntegrationFailureKind.Transient,
>= 500 and <= 599 => KbxIntegrationFailureKind.Transient,
_ => KbxIntegrationFailureKind.Permanent,
};
}
@@ -0,0 +1,42 @@
using Kbx.Shared.Integrations.Generated;
using Shared.Operations;
namespace Kbx.Shared.Integrations;
public sealed class KbxIntegrationOperationsProjector(OperationsProjectionWriter operations)
{
private const string Code = "INTEGRATION_FAILED";
public Task OnPermanentFailureAsync(
KbxIntegrationMessage message,
KbxIntegrationDefinition definition,
KbxIntegrationAttemptResult result,
CancellationToken ct)
=> operations.UpsertAsync(new UpsertWorkItem(
TenantId: message.TenantId.ToString(),
SourceModule: definition.OwnerModule,
SourceType: "Integration",
SourceId: message.MessageId.ToString(),
ReferenceNo: message.AggregateId,
SourceScreenId: null,
SourceVersion: message.AggregateVersion,
Code: Code,
Title: $"{definition.Title} 실패",
Detail: result.Detail ?? result.Code,
Severity: definition.Criticality == "high" ? "critical" : "warning",
OccurredAt: DateTimeOffset.UtcNow,
RetryActionKey: definition.Idempotency == "none" ? null : "integration.retry",
AllowManualResolution: false,
Context: new { message.IntegrationId, message.CorrelationId, result.Code, result.HttpStatus }), ct);
public Task OnDeliveredAsync(KbxIntegrationMessage message, KbxIntegrationDefinition definition, CancellationToken ct)
=> operations.ResolveBySourceAsync(
message.TenantId.ToString(),
definition.OwnerModule,
"Integration",
message.MessageId.ToString(),
Code,
"외부 연계 전달 완료",
message.AggregateVersion,
ct);
}
@@ -0,0 +1,20 @@
using Shared.Problems;
namespace Kbx.Shared.Integrations;
public static class KbxIntegrationProblemFactory
{
public static KbxIntegrationProblem ToProblem(KbxIntegrationAttemptResult result) => result.FailureKind switch
{
KbxIntegrationFailureKind.Transient => KbxIntegrationProblem.Create(
result.Code ?? "INTEGRATION_DELAYED",
"외부 연계가 지연되고 있습니다.",
retryable: true,
detail: "시스템이 자동 재처리합니다. 동일 업무를 다시 실행하지 마세요."),
_ => KbxIntegrationProblem.Create(
result.Code ?? "INTEGRATION_FAILED",
"외부 연계를 완료하지 못했습니다.",
retryable: false,
detail: "업무 데이터는 유지됩니다. 확인 필요 항목에서 원인과 재처리 가능 여부를 확인하세요."),
};
}
@@ -0,0 +1,18 @@
using Microsoft.Extensions.DependencyInjection;
namespace Kbx.Shared.Integrations;
public static class KbxIntegrationRegistration
{
public static IServiceCollection AddKbxIntegrations(this IServiceCollection services)
{
services.AddScoped<IKbxIntegrationAdapterRegistry, KbxIntegrationAdapterRegistry>();
services.AddScoped<KbxIntegrationAttemptRepository>();
services.AddScoped<KbxInboxReceiptStore>();
services.AddSingleton<KbxPollyIntegrationPipeline>();
services.AddScoped<KbxIntegrationOperationsProjector>();
services.AddScoped<Shared.Operations.IWorkItemActionHandler, KbxIntegrationRetryActionHandler>();
services.AddScoped<KbxOutboxIntegrationDispatcher>();
return services;
}
}
@@ -0,0 +1,42 @@
using Dapper;
using Npgsql;
using Shared.Operations;
namespace Kbx.Shared.Integrations;
public sealed class KbxIntegrationRetryActionHandler(NpgsqlDataSource dataSource) : IWorkItemActionHandler
{
public string Key => "integration.retry";
public async Task ExecuteAsync(string tenantId, string actorId, Guid workItemId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var tx = await connection.BeginTransactionAsync(ct);
var messageId = await connection.QuerySingleOrDefaultAsync<Guid?>(new CommandDefinition("""
select source_id::uuid
from kbx.work_items
where id=@WorkItemId and tenant_id=@TenantId and retry_action_key='integration.retry'
for update;
""", new { WorkItemId = workItemId, TenantId = tenantId }, tx, cancellationToken: ct));
if (messageId is null) { await tx.RollbackAsync(ct); return; }
var changed = await connection.ExecuteAsync(new CommandDefinition("""
update kbx.integration_attempts
set state='retrying', next_retry_at=now()
where id = (
select id from kbx.integration_attempts
where message_id=@MessageId and state='failed'
order by attempt_no desc limit 1
);
""", new { MessageId = messageId.Value }, tx, cancellationToken: ct));
if (changed == 1)
{
await connection.ExecuteAsync(new CommandDefinition("""
insert into kbx.work_item_audit(id,work_item_id,tenant_id,action,actor_id,actor_name,reason,before_status,after_status)
values(gen_random_uuid(),@WorkItemId,@TenantId,'integration-retry',@ActorId,@ActorId,' ','open','open');
""", new { WorkItemId = workItemId, TenantId = tenantId, ActorId = actorId }, tx, cancellationToken: ct));
}
await tx.CommitAsync(ct);
}
}
@@ -0,0 +1,46 @@
using Kbx.Shared.Integrations.Generated;
namespace Kbx.Shared.Integrations;
public sealed class KbxOutboxIntegrationDispatcher(
IKbxIntegrationAdapterRegistry adapters,
KbxIntegrationAttemptRepository attempts,
KbxPollyIntegrationPipeline pipelineFactory,
KbxIntegrationOperationsProjector operations)
{
public async Task<KbxIntegrationAttemptResult> DispatchAsync(KbxIntegrationMessage message, int durableAttempt, CancellationToken ct)
{
if (!KbxIntegrationCatalog.All.TryGetValue(message.IntegrationId, out var definition))
return new(false, KbxIntegrationFailureKind.Permanent, "INTEGRATION_NOT_REGISTERED", message.IntegrationId);
var attemptId = await attempts.StartAsync(message, durableAttempt, ct);
var pipeline = pipelineFactory.Build(
definition.ShortRetryAttempts,
TimeSpan.FromMilliseconds(definition.ShortRetryBaseDelayMs),
TimeSpan.FromMilliseconds(definition.TimeoutMs),
definition.CircuitFailureRatio,
TimeSpan.FromSeconds(definition.CircuitSamplingSeconds),
definition.CircuitMinimumThroughput,
TimeSpan.FromSeconds(definition.CircuitBreakSeconds));
KbxIntegrationAttemptResult result;
try
{
var adapter = adapters.GetRequired(message.IntegrationId);
result = await pipeline.ExecuteAsync(async token => await adapter.SendAsync(message, token), ct);
}
catch (Exception error)
{
result = new(false, KbxIntegrationFailureClassifier.Classify(error), error.GetType().Name, "외부 연계 호출이 완료되지 않았습니다.");
}
// A transient result is persisted and delegated to Hangfire for durable retry.
var state = result.Succeeded ? "delivered" : result.FailureKind == KbxIntegrationFailureKind.Transient ? "retrying" : "failed";
var next = state == "retrying" ? KbxDurableIntegrationRetryPlanner.NextAttemptAt(definition, durableAttempt, DateTimeOffset.UtcNow) : null;
await attempts.CompleteAsync(attemptId, result, state, next, ct);
if (result.Succeeded) await operations.OnDeliveredAsync(message, definition, ct);
else if (result.FailureKind == KbxIntegrationFailureKind.Permanent && definition.TerminalAction == "operations-exception")
await operations.OnPermanentFailureAsync(message, definition, result, ct);
return result;
}
}
@@ -0,0 +1,50 @@
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
namespace Kbx.Shared.Integrations;
// Polly is intentionally limited to short-lived transient resilience.
// Persistent retries are scheduled by Hangfire from durable integration state.
public sealed class KbxPollyIntegrationPipeline
{
public ResiliencePipeline<KbxIntegrationAttemptResult> Build(
int maxRetryAttempts,
TimeSpan baseDelay,
TimeSpan timeout,
double failureRatio,
TimeSpan samplingDuration,
int minimumThroughput,
TimeSpan breakDuration)
{
return new ResiliencePipelineBuilder<KbxIntegrationAttemptResult>()
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<KbxIntegrationAttemptResult>
{
ShouldHandle = static args => args.Outcome switch
{
{ Exception: HttpRequestException } => PredicateResult.True(),
{ Result.FailureKind: KbxIntegrationFailureKind.Transient } => PredicateResult.True(),
_ => PredicateResult.False(),
},
FailureRatio = failureRatio,
SamplingDuration = samplingDuration,
MinimumThroughput = minimumThroughput,
BreakDuration = breakDuration,
})
.AddRetry(new RetryStrategyOptions<KbxIntegrationAttemptResult>
{
ShouldHandle = static args => args.Outcome switch
{
{ Exception: HttpRequestException } => PredicateResult.True(),
{ Result.FailureKind: KbxIntegrationFailureKind.Transient } => PredicateResult.True(),
_ => PredicateResult.False(),
},
MaxRetryAttempts = maxRetryAttempts,
Delay = baseDelay,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
})
.AddTimeout(timeout)
.Build();
}
}
@@ -0,0 +1,14 @@
using Kbx.Shared.Integrations.Generated;
using Xunit;
namespace Kbx.Shared.Integrations.Tests;
public sealed class KbxDurableIntegrationRetryPlannerTests
{
[Fact]
public void StopsAfterConfiguredDurableAttempts()
{
var d = KbxIntegrationCatalog.All["integration.oms.wms.dispatch"];
var now = new DateTimeOffset(2026,8,8,9,0,0,TimeSpan.Zero);
Assert.NotNull(KbxDurableIntegrationRetryPlanner.NextAttemptAt(d, 1, now));
Assert.Null(KbxDurableIntegrationRetryPlanner.NextAttemptAt(d, d.LongRetryAttempts + 1, now));
}
}
@@ -0,0 +1,12 @@
using Kbx.Shared.Integrations;
using Xunit;
namespace Kbx.Shared.Integrations.Tests;
public sealed class KbxIntegrationFailureClassifierTests
{
[Theory]
[InlineData(408)] [InlineData(429)] [InlineData(500)] [InlineData(503)]
public void TransientHttpStatusesAreRetryable(int status) => Assert.Equal(KbxIntegrationFailureKind.Transient,KbxIntegrationFailureClassifier.ClassifyHttp(status));
[Theory]
[InlineData(400)] [InlineData(401)] [InlineData(403)] [InlineData(404)] [InlineData(422)]
public void ClientOrDomainRejectionsArePermanent(int status) => Assert.Equal(KbxIntegrationFailureKind.Permanent,KbxIntegrationFailureClassifier.ClassifyHttp(status));
}