V13-FE-011: finalize search list layout slice
This commit is contained in:
+65
@@ -0,0 +1,65 @@
|
||||
// <auto-generated />
|
||||
namespace KBX.Shared.Authorization.Generated;
|
||||
|
||||
public static class KbxPermissions
|
||||
{
|
||||
public const string CommonAiUse = "common.ai.use";
|
||||
public const string CommonAiExecute = "common.ai.execute";
|
||||
public const string CommonOperationsClaim = "common.operations.claim";
|
||||
public const string CommonOperationsCreate = "common.operations.create";
|
||||
public const string CommonOperationsRead = "common.operations.read";
|
||||
public const string CommonOperationsResolve = "common.operations.resolve";
|
||||
public const string CommonOperationsRetry = "common.operations.retry";
|
||||
public const string CommonReconcileRead = "common.reconcile.read";
|
||||
public const string CommonRuntimeRead = "common.runtime.read";
|
||||
public const string CommonSuggestionCreate = "common.suggestion.create";
|
||||
public const string ErpInventoryMoveConfirm = "erp.inventory.move.confirm";
|
||||
public const string ErpInventoryMoveRead = "erp.inventory.move.read";
|
||||
public const string ErpInventoryMoveReceive = "erp.inventory.move.receive";
|
||||
public const string ErpInventoryMoveShip = "erp.inventory.move.ship";
|
||||
public const string ErpInventoryMoveWrite = "erp.inventory.move.write";
|
||||
public const string ErpInventoryRead = "erp.inventory.read";
|
||||
public const string ErpItemPriceRead = "erp.item.price.read";
|
||||
public const string ErpItemPriceWrite = "erp.item.price.write";
|
||||
public const string ErpItemCreate = "erp.item.create";
|
||||
public const string ErpItemRead = "erp.item.read";
|
||||
public const string ErpItemWrite = "erp.item.write";
|
||||
public const string ErpPurchaseCancel = "erp.purchase.cancel";
|
||||
public const string ErpPurchaseConfirm = "erp.purchase.confirm";
|
||||
public const string ErpPurchaseRead = "erp.purchase.read";
|
||||
public const string ErpPurchaseWrite = "erp.purchase.write";
|
||||
public const string ImportsExecute = "imports.execute";
|
||||
public const string KbxDesignRead = "kbx.design.read";
|
||||
public const string OmsClaimApprove = "oms.claim.approve";
|
||||
public const string OmsClaimHold = "oms.claim.hold";
|
||||
public const string OmsClaimProcess = "oms.claim.process";
|
||||
public const string OmsClaimRead = "oms.claim.read";
|
||||
public const string OmsOrderCreate = "oms.order.create";
|
||||
public const string OmsOrderImport = "oms.order.import";
|
||||
public const string OmsOrderRead = "oms.order.read";
|
||||
public const string OmsOrderShip = "oms.order.ship";
|
||||
public const string OmsOrderRecipientUnmask = "oms.order.recipient.unmask";
|
||||
public const string OmsOrderRecipientExport = "oms.order.recipient.export";
|
||||
public const string WmsInventoryCount = "wms.inventory.count";
|
||||
public const string WmsPickingExecute = "wms.picking.execute";
|
||||
public const string WmsPutawayExecute = "wms.putaway.execute";
|
||||
public const string WmsReceivingExecute = "wms.receiving.execute";
|
||||
public const string WmsWorkExecute = "wms.work.execute";
|
||||
public const string WmsWorkRead = "wms.work.read";
|
||||
public const string CommonTelemetryWrite = "common.telemetry.write";
|
||||
public const string CommonUxRead = "common.ux.read";
|
||||
public const string CommonExperimentEvaluate = "common.experiment.evaluate";
|
||||
public const string CommonExperimentRead = "common.experiment.read";
|
||||
public const string CommonExperimentManage = "common.experiment.manage";
|
||||
public const string CommonIntegrationRead = "common.integration.read";
|
||||
public const string CommonIntegrationRetry = "common.integration.retry";
|
||||
public const string CommonExternalDataRead = "common.external-data.read";
|
||||
public const string CommonExternalDataRefresh = "common.external-data.refresh";
|
||||
public const string OmsOrderConfirm = "oms.order.confirm";
|
||||
public const string OmsOrderWrite = "oms.order.write";
|
||||
}
|
||||
|
||||
public static class KbxSensitivePolicies
|
||||
{
|
||||
public const string OmsOrderRecipient = "oms.order.recipient";
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
namespace KBX.Shared.Authorization;
|
||||
|
||||
public interface IKbxPermissionEvaluator
|
||||
{
|
||||
bool Has(string permission);
|
||||
bool HasAll(params string[] permissions);
|
||||
}
|
||||
|
||||
public sealed record KbxSensitiveDisclosure(
|
||||
Guid TenantId,
|
||||
Guid UserId,
|
||||
string PolicyId,
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
IReadOnlyList<string> Fields,
|
||||
string? Reason,
|
||||
string CorrelationId);
|
||||
@@ -0,0 +1,14 @@
|
||||
using KBX.Shared.Authorization.Generated;
|
||||
namespace KBX.Shared.Authorization;
|
||||
|
||||
public static class KbxSensitiveDataPolicy
|
||||
{
|
||||
public static bool CanRevealOrderRecipient(IKbxPermissionEvaluator permissions)
|
||||
=> permissions.Has(KbxPermissions.OmsOrderRecipientUnmask);
|
||||
|
||||
public static bool CanExportOrderRecipientUnmasked(IKbxPermissionEvaluator permissions)
|
||||
=> permissions.Has(KbxPermissions.OmsOrderRecipientExport);
|
||||
|
||||
public static IReadOnlyList<string> OrderRecipientFields { get; } =
|
||||
["receiverName", "phone", "postalCode", "address1", "address2"];
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
namespace KBX.Shared.Authorization;
|
||||
|
||||
public sealed class SensitiveDataDisclosureAuditWriter(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task WriteAsync(KbxSensitiveDisclosure disclosure, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
insert into kbx.sensitive_data_disclosures
|
||||
(tenant_id,user_id,policy_id,entity_type,entity_id,fields,reason,correlation_id,occurred_at)
|
||||
values (@TenantId,@UserId,@PolicyId,@EntityType,@EntityId,@Fields::jsonb,@Reason,@CorrelationId,now())
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new {
|
||||
disclosure.TenantId, disclosure.UserId, disclosure.PolicyId, disclosure.EntityType,
|
||||
disclosure.EntityId, Fields = System.Text.Json.JsonSerializer.Serialize(disclosure.Fields),
|
||||
disclosure.Reason, disclosure.CorrelationId }, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// generated from contracts/configuration/kbx.configuration.json; do not edit.
|
||||
namespace Kbx.Shared.Configuration.Generated;
|
||||
public sealed record KbxConfigurationSettingDefinition(string Key,string Env,string Category,string Type,bool Secret,bool RestartRequired,string? DefaultValue,string[] AllowedValues,string[] RequiredIn,string? RequiredWhenKey,string? RequiredWhenEquals,int? Minimum,int? Maximum,string[] AllowedSources);
|
||||
public sealed record KbxEnvironmentProfileDefinition(string Id,bool ArtifactPromotion,string MigrationStrategy,string ProviderNetwork,bool RequireHttps,bool ProviderEndpointOverrideAllowed,string[] RequiredGates);
|
||||
public static class KbxConfigurationCatalog {
|
||||
public const string SourceSha256="e09186d626b034a524b7cdf78f1e4bb6adbc112975b3baf4fd6194b1c1abd771";
|
||||
public static readonly IReadOnlyDictionary<string,KbxConfigurationSettingDefinition> Settings=new Dictionary<string,KbxConfigurationSettingDefinition>(StringComparer.Ordinal) {
|
||||
["Kbx:Runtime:Environment"] = new("Kbx:Runtime:Environment","KBX__Runtime__Environment","runtime","enum",false,true,"Development",new string[]{"Development","Test","Staging","Production"},new string[]{"Development","Test","Staging","Production"},null,null,null,null,new string[]{}),
|
||||
["Kbx:Runtime:ReadOnly"] = new("Kbx:Runtime:ReadOnly","KBX__Runtime__ReadOnly","runtime","boolean",false,false,"false",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["ConnectionStrings:Main"] = new("ConnectionStrings:Main","ConnectionStrings__Main","database","connection-string",true,true,null,new string[]{},new string[]{"Development","Test","Staging","Production"},null,null,null,null,new string[]{"environment","secret-store","user-secrets"}),
|
||||
["Kbx:Database:MigrationsMode"] = new("Kbx:Database:MigrationsMode","KBX__Database__MigrationsMode","database","enum",false,true,"validate",new string[]{"validate","startup-apply","predeploy"},new string[]{"Development","Test","Staging","Production"},null,null,null,null,new string[]{}),
|
||||
["Kbx:Database:CommandTimeoutSeconds"] = new("Kbx:Database:CommandTimeoutSeconds","KBX__Database__CommandTimeoutSeconds","database","integer",false,true,"30",new string[]{},new string[]{},null,null,1,300,new string[]{}),
|
||||
["Kbx:Hangfire:Enabled"] = new("Kbx:Hangfire:Enabled","KBX__Hangfire__Enabled","background-jobs","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Hangfire:WorkerCount"] = new("Kbx:Hangfire:WorkerCount","KBX__Hangfire__WorkerCount","background-jobs","integer",false,true,"4",new string[]{},new string[]{},null,null,1,64,new string[]{}),
|
||||
["Kbx:SignalR:Enabled"] = new("Kbx:SignalR:Enabled","KBX__SignalR__Enabled","realtime","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Outbox:DispatcherEnabled"] = new("Kbx:Outbox:DispatcherEnabled","KBX__Outbox__DispatcherEnabled","messaging","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Inbox:CleanupEnabled"] = new("Kbx:Inbox:CleanupEnabled","KBX__Inbox__CleanupEnabled","messaging","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Integration:DispatcherEnabled"] = new("Kbx:Integration:DispatcherEnabled","KBX__Integration__DispatcherEnabled","integration","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:ExternalData:RefreshEnabled"] = new("Kbx:ExternalData:RefreshEnabled","KBX__ExternalData__RefreshEnabled","external-data","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:ExternalData:ObservationRetentionEnabled"] = new("Kbx:ExternalData:ObservationRetentionEnabled","KBX__ExternalData__ObservationRetentionEnabled","external-data","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Experiments:Enabled"] = new("Kbx:Experiments:Enabled","KBX__Experiments__Enabled","experiments","boolean",false,false,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Logging:MinimumLevel"] = new("Kbx:Logging:MinimumLevel","KBX__Logging__MinimumLevel","logging","enum",false,true,"Information",new string[]{"Debug","Information","Warning","Error"},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Logging:JsonConsoleEnabled"] = new("Kbx:Logging:JsonConsoleEnabled","KBX__Logging__JsonConsoleEnabled","logging","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Telemetry:Enabled"] = new("Kbx:Telemetry:Enabled","KBX__Telemetry__Enabled","observability","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Telemetry:OtlpEndpoint"] = new("Kbx:Telemetry:OtlpEndpoint","KBX__Telemetry__OtlpEndpoint","observability","uri",false,true,null,new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Telegram:Enabled"] = new("Kbx:Telegram:Enabled","KBX__Telegram__Enabled","alerting","boolean",false,true,"false",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Telegram:BotToken"] = new("Kbx:Telegram:BotToken","KBX__Telegram__BotToken","alerting","string",true,true,null,new string[]{},new string[]{},"Kbx:Telegram:Enabled","true",null,null,new string[]{"environment","secret-store","user-secrets"}),
|
||||
["Kbx:Telegram:ChatId"] = new("Kbx:Telegram:ChatId","KBX__Telegram__ChatId","alerting","string",true,true,null,new string[]{},new string[]{},"Kbx:Telegram:Enabled","true",null,null,new string[]{"environment","secret-store","user-secrets"}),
|
||||
["ExternalProviders:Krx:Enabled"] = new("ExternalProviders:Krx:Enabled","ExternalProviders__Krx__Enabled","provider","boolean",false,true,"false",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["ExternalProviders:Krx:AuthKey"] = new("ExternalProviders:Krx:AuthKey","ExternalProviders__Krx__AuthKey","provider","string",true,true,null,new string[]{},new string[]{},"ExternalProviders:Krx:Enabled","true",null,null,new string[]{"environment","secret-store","user-secrets"}),
|
||||
["ExternalProviders:OpenDart:Enabled"] = new("ExternalProviders:OpenDart:Enabled","ExternalProviders__OpenDart__Enabled","provider","boolean",false,true,"false",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["ExternalProviders:OpenDart:ApiKey"] = new("ExternalProviders:OpenDart:ApiKey","ExternalProviders__OpenDart__ApiKey","provider","string",true,true,null,new string[]{},new string[]{},"ExternalProviders:OpenDart:Enabled","true",null,null,new string[]{"environment","secret-store","user-secrets"}),
|
||||
["ExternalProviders:Kis:Enabled"] = new("ExternalProviders:Kis:Enabled","ExternalProviders__Kis__Enabled","provider","boolean",false,true,"false",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["ExternalProviders:Kis:Environment"] = new("ExternalProviders:Kis:Environment","ExternalProviders__Kis__Environment","provider","enum",false,true,"sandbox",new string[]{"sandbox","production"},new string[]{},"ExternalProviders:Kis:Enabled","true",null,null,new string[]{}),
|
||||
["ExternalProviders:Kis:AppKey"] = new("ExternalProviders:Kis:AppKey","ExternalProviders__Kis__AppKey","provider","string",true,true,null,new string[]{},new string[]{},"ExternalProviders:Kis:Enabled","true",null,null,new string[]{"environment","secret-store","user-secrets"}),
|
||||
["ExternalProviders:Kis:AppSecret"] = new("ExternalProviders:Kis:AppSecret","ExternalProviders__Kis__AppSecret","provider","string",true,true,null,new string[]{},new string[]{},"ExternalProviders:Kis:Enabled","true",null,null,new string[]{"environment","secret-store","user-secrets"}),
|
||||
["Kbx:Security:RequireHttps"] = new("Kbx:Security:RequireHttps","KBX__Security__RequireHttps","security","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Health:ReadinessEnabled"] = new("Kbx:Health:ReadinessEnabled","KBX__Health__ReadinessEnabled","runtime","boolean",false,true,"true",new string[]{},new string[]{},null,null,null,null,new string[]{}),
|
||||
["Kbx:Import:MaxUploadBytes"] = new("Kbx:Import:MaxUploadBytes","KBX__Import__MaxUploadBytes","import","integer",false,true,"52428800",new string[]{},new string[]{},null,null,1048576,209715200,new string[]{}),
|
||||
["Kbx:Import:MaxRows"] = new("Kbx:Import:MaxRows","KBX__Import__MaxRows","import","integer",false,true,"100000",new string[]{},new string[]{},null,null,1,1000000,new string[]{})
|
||||
};
|
||||
public static readonly IReadOnlyDictionary<string,KbxEnvironmentProfileDefinition> Environments=new Dictionary<string,KbxEnvironmentProfileDefinition>(StringComparer.Ordinal) {
|
||||
["Development"] = new("Development",false,"startup-apply-allowed","opt-in",false,true,new string[]{"static-governance"}),
|
||||
["Test"] = new("Test",false,"ephemeral-apply","forbidden",false,true,new string[]{"static-governance","build","unit","integration","scenario"}),
|
||||
["Staging"] = new("Staging",true,"predeploy","opt-in",true,false,new string[]{"static-governance","build","unit","integration","scenario","migration-dry-run"}),
|
||||
["Production"] = new("Production",true,"predeploy","opt-in",true,false,new string[]{"static-governance","build","unit","integration","scenario","migration-dry-run","configuration-validation","release-governance"})
|
||||
};
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
namespace Kbx.Shared.Configuration;
|
||||
|
||||
public sealed record KbxConfigurationIssue(
|
||||
string Code,
|
||||
string Key,
|
||||
string Message,
|
||||
bool Fatal);
|
||||
|
||||
public sealed record KbxConfigurationValidationResult(
|
||||
string Environment,
|
||||
string NonSecretFingerprint,
|
||||
IReadOnlyList<KbxConfigurationIssue> Issues)
|
||||
{
|
||||
public bool IsValid => Issues.All(x => !x.Fatal);
|
||||
}
|
||||
|
||||
public sealed record KbxDeploymentDescriptor(
|
||||
string Environment,
|
||||
string ApplicationVersion,
|
||||
string KbxContractVersion,
|
||||
string NonSecretConfigurationFingerprint,
|
||||
DateTimeOffset StartedAt);
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Kbx.Shared.Configuration.Generated;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Kbx.Shared.Configuration;
|
||||
|
||||
public static class KbxConfigurationFingerprint
|
||||
{
|
||||
public static string Compute(IConfiguration configuration)
|
||||
{
|
||||
var lines = KbxConfigurationCatalog.Settings.Values
|
||||
.Where(x => !x.Secret)
|
||||
.OrderBy(x => x.Key, StringComparer.Ordinal)
|
||||
.Select(x => $"{x.Key}={configuration[x.Key] ?? x.DefaultValue ?? string.Empty}");
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n", lines)));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Kbx.Shared.Configuration;
|
||||
|
||||
public static class KbxConfigurationRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxConfigurationGovernance(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddSingleton(new KbxConfigurationStartupValidator(configuration));
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
using Kbx.Shared.Configuration.Generated;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Kbx.Shared.Configuration;
|
||||
|
||||
public sealed class KbxConfigurationStartupValidator(IConfiguration configuration)
|
||||
{
|
||||
private static readonly string[] PlaceholderSecrets = ["changeme", "change-me", "example", "secret", "password", "todo"];
|
||||
|
||||
public KbxConfigurationValidationResult Validate()
|
||||
{
|
||||
var environment = configuration["Kbx:Runtime:Environment"] ?? "Development";
|
||||
var issues = new List<KbxConfigurationIssue>();
|
||||
|
||||
if (!KbxConfigurationCatalog.Environments.TryGetValue(environment, out _))
|
||||
issues.Add(new("CONFIG_ENVIRONMENT_UNKNOWN", "Kbx:Runtime:Environment", $"Unknown KBX environment '{environment}'.", true));
|
||||
|
||||
foreach (var setting in KbxConfigurationCatalog.Settings.Values)
|
||||
{
|
||||
var value = configuration[setting.Key] ?? setting.DefaultValue;
|
||||
if (setting.RequiredIn.Contains(environment, StringComparer.Ordinal) && string.IsNullOrWhiteSpace(value))
|
||||
issues.Add(new("CONFIG_REQUIRED", setting.Key, "Required configuration is missing.", true));
|
||||
|
||||
if (setting.RequiredWhenKey is not null &&
|
||||
string.Equals(configuration[setting.RequiredWhenKey], setting.RequiredWhenEquals, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
issues.Add(new("CONFIG_DEPENDENCY_REQUIRED", setting.Key, $"{setting.Key} is required when {setting.RequiredWhenKey}={setting.RequiredWhenEquals}.", true));
|
||||
|
||||
if (setting.Secret && !string.IsNullOrWhiteSpace(value) && PlaceholderSecrets.Any(x => string.Equals(x, value, StringComparison.OrdinalIgnoreCase)))
|
||||
issues.Add(new("CONFIG_SECRET_PLACEHOLDER", setting.Key, "Secret configuration still contains a placeholder value.", true));
|
||||
|
||||
if (setting.AllowedValues.Length > 0 && !string.IsNullOrWhiteSpace(value) && !setting.AllowedValues.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
issues.Add(new("CONFIG_ENUM", setting.Key, $"Unsupported configured value for {setting.Key}.", true));
|
||||
|
||||
if (setting.Type == "integer" && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
if (!int.TryParse(value, out var number)) issues.Add(new("CONFIG_INTEGER", setting.Key, "Configuration must be an integer.", true));
|
||||
else
|
||||
{
|
||||
if (setting.Minimum is not null && number < setting.Minimum) issues.Add(new("CONFIG_MINIMUM", setting.Key, "Configuration is below the allowed minimum.", true));
|
||||
if (setting.Maximum is not null && number > setting.Maximum) issues.Add(new("CONFIG_MAXIMUM", setting.Key, "Configuration exceeds the allowed maximum.", true));
|
||||
}
|
||||
}
|
||||
if (setting.Type == "uri" && !string.IsNullOrWhiteSpace(value) && !Uri.TryCreate(value, UriKind.Absolute, out _))
|
||||
issues.Add(new("CONFIG_URI", setting.Key, "Configuration must be an absolute URI.", true));
|
||||
}
|
||||
|
||||
if (string.Equals(environment, "Production", StringComparison.Ordinal))
|
||||
{
|
||||
if (!string.Equals(configuration["Kbx:Database:MigrationsMode"] ?? "validate", "predeploy", StringComparison.OrdinalIgnoreCase))
|
||||
issues.Add(new("CONFIG_PRODUCTION_MIGRATION_MODE", "Kbx:Database:MigrationsMode", "Production requires predeploy migrations; startup schema mutation is forbidden.", true));
|
||||
|
||||
if (!bool.TryParse(configuration["Kbx:Security:RequireHttps"], out var https) || !https)
|
||||
issues.Add(new("CONFIG_PRODUCTION_HTTPS", "Kbx:Security:RequireHttps", "Production requires HTTPS.", true));
|
||||
|
||||
if (string.Equals(configuration["Kbx:Logging:MinimumLevel"], "Debug", StringComparison.OrdinalIgnoreCase))
|
||||
issues.Add(new("CONFIG_PRODUCTION_DEBUG_LOGGING", "Kbx:Logging:MinimumLevel", "Production Debug logging is forbidden by the KBX reference policy.", true));
|
||||
}
|
||||
|
||||
return new(environment, KbxConfigurationFingerprint.Compute(configuration), issues);
|
||||
}
|
||||
|
||||
public KbxConfigurationValidationResult ValidateOrThrow()
|
||||
{
|
||||
var result = Validate();
|
||||
if (!result.IsValid)
|
||||
throw new InvalidOperationException("KBX configuration validation failed: " + string.Join(" | ", result.Issues.Where(x => x.Fatal).Select(x => $"{x.Code}:{x.Key}")));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace Kbx.Shared.Configuration.Tests;
|
||||
|
||||
public sealed class KbxConfigurationContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void Production_rejects_startup_apply()
|
||||
{
|
||||
var values = new Dictionary<string,string?> {
|
||||
["Kbx:Runtime:Environment"]="Production",
|
||||
["Kbx:Database:MigrationsMode"]="startup-apply",
|
||||
["Kbx:Security:RequireHttps"]="true",
|
||||
["ConnectionStrings:Main"]="Host=test;Database=test"
|
||||
};
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
var result = new KbxConfigurationStartupValidator(config).Validate();
|
||||
Assert.Contains(result.Issues, x => x.Code == "CONFIG_PRODUCTION_MIGRATION_MODE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_secret_is_required_only_when_enabled()
|
||||
{
|
||||
var values = new Dictionary<string,string?> {
|
||||
["Kbx:Runtime:Environment"]="Development",
|
||||
["Kbx:Database:MigrationsMode"]="validate",
|
||||
["ConnectionStrings:Main"]="Host=test;Database=test",
|
||||
["ExternalProviders:Krx:Enabled"]="true"
|
||||
};
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
var result = new KbxConfigurationStartupValidator(config).Validate();
|
||||
Assert.Contains(result.Issues, x => x.Key == "ExternalProviders:Krx:AuthKey");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Generated from contracts/api/kbx.api.json. Do not edit.
|
||||
namespace Shared.Contracts.Generated;
|
||||
|
||||
public sealed record KbxApiOperationContract(
|
||||
string Id, string Method, string Path, string? Permission, string Kind, string Idempotency, IReadOnlyList<int> SuccessStatuses);
|
||||
|
||||
public static class KbxApiCatalog
|
||||
{
|
||||
public const string SourceSha256 = "8a8008b0eca039ef22b703b142dd7fa2efeaf5b14784ba9f3a954f656a1a0c8f";
|
||||
public static readonly IReadOnlyList<KbxApiOperationContract> All = new KbxApiOperationContract[]
|
||||
{
|
||||
new("common.ai.ask", "POST", "/api/common/ai/ask", "common.ai.use", "command", "supported", new[] { 200 }),
|
||||
new("common.experiments.assignments", "GET", "/api/kbx/experiments/assignments", "common.experiment.evaluate", "query", "none", new[] { 200 }),
|
||||
new("common.experiments.overview", "GET", "/api/kbx/experiments", "common.experiment.read", "query", "none", new[] { 200 }),
|
||||
new("common.experiments.rollback", "POST", "/api/kbx/experiments/{experimentId}/rollback", "common.experiment.manage", "command", "supported", new[] { 200 }),
|
||||
new("common.experiments.rollout", "POST", "/api/kbx/experiments/{experimentId}/rollout", "common.experiment.manage", "command", "supported", new[] { 200 }),
|
||||
new("common.externalData.refresh", "POST", "/api/kbx/external-data/{datasetId}/refresh", "common.external-data.refresh", "command", "supported", new[] { 202 }),
|
||||
new("common.externalData.status", "GET", "/api/kbx/external-data/status", "common.external-data.read", "query", "none", new[] { 200 }),
|
||||
new("common.imports.commit", "POST", "/api/imports/sessions/{sessionId:guid}/commit", "imports.execute", "command", "supported", new[] { 200 }),
|
||||
new("common.imports.createSession", "POST", "/api/imports/sessions", "imports.execute", "upload", "supported", new[] { 200 }),
|
||||
new("common.imports.errorWorkbook", "GET", "/api/imports/sessions/{sessionId:guid}/errors.xlsx", "imports.execute", "query", "none", new[] { 200 }),
|
||||
new("common.imports.getSession", "GET", "/api/imports/sessions/{sessionId:guid}", "imports.execute", "query", "none", new[] { 200 }),
|
||||
new("common.imports.saveMapping", "PUT", "/api/imports/sessions/{sessionId:guid}/mapping", "imports.execute", "command", "supported", new[] { 200 }),
|
||||
new("common.imports.saveNamedMapping", "POST", "/api/imports/sessions/{sessionId:guid}/saved-mappings", "imports.execute", "command", "supported", new[] { 204 }),
|
||||
new("common.imports.template", "GET", "/api/imports/{importType}/template", "imports.execute", "query", "none", new[] { 200 }),
|
||||
new("common.imports.validate", "POST", "/api/imports/sessions/{sessionId:guid}/validate", "imports.execute", "command", "supported", new[] { 200 }),
|
||||
new("common.integrations.getAttempt", "GET", "/api/integrations/attempts/{attemptId:guid}", "common.integration.read", "query", "none", new[] { 200 }),
|
||||
new("common.integrations.retryAttempt", "POST", "/api/integrations/attempts/{attemptId:guid}/retry", "common.integration.retry", "command", "required", new[] { 200 }),
|
||||
new("common.operations.claim", "POST", "/api/operations/work-items/claim", "common.operations.claim", "command", "supported", new[] { 200 }),
|
||||
new("common.operations.resolve", "POST", "/api/operations/work-items/resolve", "common.operations.resolve", "command", "supported", new[] { 200 }),
|
||||
new("common.operations.retry", "POST", "/api/operations/work-items/{id:guid}/retry", "common.operations.retry", "command", "supported", new[] { 200 }),
|
||||
new("common.operations.search", "GET", "/api/operations/work-items", "common.operations.read", "query", "none", new[] { 200 }),
|
||||
new("common.reconcile.createExceptions", "POST", "/api/reconcile/items/create-exceptions", "common.operations.create", "command", "supported", new[] { 200 }),
|
||||
new("common.reconcile.search", "GET", "/api/reconcile/items", "common.reconcile.read", "query", "none", new[] { 200 }),
|
||||
new("common.runtime.notice", "GET", "/api/kbx/runtime/notice", "common.runtime.read", "query", "none", new[] { 200 }),
|
||||
new("common.runtime.notificationRead", "POST", "/api/kbx/runtime/notifications/{Id}/read", "common.runtime.read", "command", "supported", new[] { 204 }),
|
||||
new("common.runtime.notifications", "GET", "/api/kbx/runtime/notifications", "common.runtime.read", "query", "none", new[] { 200 }),
|
||||
new("common.runtime.operations", "GET", "/api/kbx/runtime/operations", "common.runtime.read", "query", "none", new[] { 200 }),
|
||||
new("common.suggestions.submit", "POST", "/api/common/suggestions", "common.suggestion.create", "command", "supported", new[] { 200 }),
|
||||
new("common.uxTelemetry.ingest", "POST", "/api/kbx/ux/events", "common.telemetry.write", "command", "none", new[] { 204 }),
|
||||
new("common.uxTelemetry.metrics", "GET", "/api/kbx/ux/metrics", "common.ux.read", "query", "none", new[] { 200 }),
|
||||
new("erp.inventory.history", "GET", "/api/erp/inventory/{itemId}/history", "erp.inventory.read", "query", "none", new[] { 200 }),
|
||||
new("erp.inventory.locations", "GET", "/api/erp/inventory/{itemId}/locations", "erp.inventory.read", "query", "none", new[] { 200 }),
|
||||
new("erp.inventory.search", "GET", "/api/erp/inventory", "erp.inventory.read", "query", "none", new[] { 200 }),
|
||||
new("erp.itemPrices.bulkSave", "POST", "/api/erp/item-prices/bulk", "erp.item.price.write", "command", "required", new[] { 200 }),
|
||||
new("erp.items.get", "GET", "/api/erp/items/{id}", "erp.item.read", "query", "none", new[] { 200 }),
|
||||
new("erp.items.search", "GET", "/api/erp/items", "erp.item.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.customers.resolveByCode", "GET", "/api/lookups/customers/by-code/{code}", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.customers.resolveById", "GET", "/api/lookups/customers/{id}", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.customers.search", "GET", "/api/lookups/customers", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.items.resolveByCode", "GET", "/api/lookups/items/by-code/{code}", "erp.item.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.items.resolveById", "GET", "/api/lookups/items/{id}", "erp.item.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.items.search", "GET", "/api/lookups/items", "erp.item.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.warehouses.resolveByCode", "GET", "/api/lookups/warehouses/by-code/{code}", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.warehouses.resolveById", "GET", "/api/lookups/warehouses/{id}", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("lookup.warehouses.search", "GET", "/api/lookups/warehouses", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("oms.claims.approve", "POST", "/api/oms/claims/{id:guid}/approve", "oms.claim.approve", "command", "supported", new[] { 200 }),
|
||||
new("oms.claims.complete", "POST", "/api/oms/claims/{id:guid}/complete", "oms.claim.process", "command", "supported", new[] { 200 }),
|
||||
new("oms.claims.hold", "POST", "/api/oms/claims/{id:guid}/hold", "oms.claim.hold", "command", "supported", new[] { 200 }),
|
||||
new("oms.claims.search", "GET", "/api/oms/claims", "oms.claim.read", "query", "none", new[] { 200 }),
|
||||
new("oms.claims.start", "POST", "/api/oms/claims/{id:guid}/start", "oms.claim.process", "command", "supported", new[] { 200 }),
|
||||
new("oms.orders.register", "POST", "/api/oms/orders/register", "oms.order.create", "command", "supported", new[] { 200 }),
|
||||
new("oms.orders.search", "GET", "/api/oms/orders", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("oms.orders.ship", "POST", "/api/oms/orders/ship", "oms.order.ship", "command", "required", new[] { 200 }),
|
||||
new("wms.picking.getTask", "GET", "/api/wms/picking/tasks/{taskId:guid}", "wms.picking.execute", "query", "none", new[] { 200 }),
|
||||
new("wms.picking.reportException", "POST", "/api/wms/picking/tasks/{taskId:guid}/exceptions", "wms.picking.execute", "command", "required", new[] { 200 }),
|
||||
new("wms.picking.scan", "POST", "/api/wms/picking/tasks/{taskId:guid}/scan", "wms.picking.execute", "command", "required", new[] { 200, 422 }),
|
||||
new("wms.picking.setQuantity", "POST", "/api/wms/picking/tasks/{taskId:guid}/quantity", "wms.picking.execute", "command", "required", new[] { 200 }),
|
||||
new("wms.picking.start", "POST", "/api/wms/picking/tasks/{taskId:guid}/start", "wms.picking.execute", "command", "supported", new[] { 200 }),
|
||||
new("erp.items.create", "POST", "/api/erp/items", "erp.item.create", "command", "none", new[] { 200 }),
|
||||
new("erp.items.update", "PUT", "/api/erp/items/{id}", "erp.item.write", "command", "none", new[] { 200 }),
|
||||
new("erp.items.deactivate", "POST", "/api/erp/items/{id}/deactivate", "erp.item.write", "command", "none", new[] { 200 }),
|
||||
new("erp.items.audit", "GET", "/api/erp/items/{id}/audit", "erp.item.read", "query", "none", new[] { 200 }),
|
||||
new("oms.orders.get", "GET", "/api/oms/orders/{id}", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("oms.orders.confirm", "POST", "/api/oms/orders/{id}/confirm", "oms.order.confirm", "command", "required", new[] { 200 }),
|
||||
new("oms.orders.audit", "GET", "/api/oms/orders/{id}/audit", "oms.order.read", "query", "none", new[] { 200 }),
|
||||
new("oms.orders.update", "PUT", "/api/oms/orders/{id}", "oms.order.write", "command", "supported", new[] { 200 })
|
||||
};
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// <auto-generated />
|
||||
// Source: contracts/fields/kbx.fields.json
|
||||
// Source SHA256: f76e073cd8052e9fddb25cc73322686c889822a22fab712ff1a25bef29b32e3c
|
||||
namespace Kbx.Contracts.Generated;
|
||||
|
||||
public sealed record KbxFieldMetadata(
|
||||
string Key, string Label, string DataType, IReadOnlyList<string> Aliases,
|
||||
bool Required, int? MaxLength, int? Precision, int? Scale, string? LookupEntity,
|
||||
bool Readonly, bool Importable, bool Exportable, bool Sensitive, string? Masking);
|
||||
|
||||
public static class KbxFieldKeys
|
||||
{
|
||||
public const string Address1 = "address1";
|
||||
public const string Address2 = "address2";
|
||||
public const string AllocatedQty = "allocatedQty";
|
||||
public const string Amount = "amount";
|
||||
public const string AvailableQty = "availableQty";
|
||||
public const string Barcode = "barcode";
|
||||
public const string ChannelId = "channelId";
|
||||
public const string ChannelName = "channelName";
|
||||
public const string CreatedAt = "createdAt";
|
||||
public const string CreatedBy = "createdBy";
|
||||
public const string CustomerCode = "customerCode";
|
||||
public const string CustomerId = "customerId";
|
||||
public const string CustomerName = "customerName";
|
||||
public const string DamagedQty = "damagedQty";
|
||||
public const string EffectiveDate = "effectiveDate";
|
||||
public const string ExceptionCount = "exceptionCount";
|
||||
public const string ExpiryDate = "expiryDate";
|
||||
public const string HoldQty = "holdQty";
|
||||
public const string ItemCode = "itemCode";
|
||||
public const string ItemId = "itemId";
|
||||
public const string ItemName = "itemName";
|
||||
public const string ItemSummary = "itemSummary";
|
||||
public const string LocationCode = "locationCode";
|
||||
public const string LocationId = "locationId";
|
||||
public const string LotNo = "lotNo";
|
||||
public const string OccurredAt = "occurredAt";
|
||||
public const string OnHandQty = "onHandQty";
|
||||
public const string OrderDate = "orderDate";
|
||||
public const string OrderedAt = "orderedAt";
|
||||
public const string OrderId = "orderId";
|
||||
public const string OrderNo = "orderNo";
|
||||
public const string OrderQty = "orderQty";
|
||||
public const string OwnerName = "ownerName";
|
||||
public const string Phone = "phone";
|
||||
public const string PickedQty = "pickedQty";
|
||||
public const string PostalCode = "postalCode";
|
||||
public const string Quantity = "quantity";
|
||||
public const string ReceiverName = "receiverName";
|
||||
public const string ReferenceNo = "referenceNo";
|
||||
public const string Remark = "remark";
|
||||
public const string ShippedQty = "shippedQty";
|
||||
public const string Specification = "specification";
|
||||
public const string Status = "status";
|
||||
public const string TotalQty = "totalQty";
|
||||
public const string UnitPrice = "unitPrice";
|
||||
public const string UpdatedAt = "updatedAt";
|
||||
public const string UpdatedBy = "updatedBy";
|
||||
public const string Version = "version";
|
||||
public const string WarehouseCode = "warehouseCode";
|
||||
public const string WarehouseId = "warehouseId";
|
||||
public const string WarehouseName = "warehouseName";
|
||||
}
|
||||
|
||||
public static class KbxFieldCatalog
|
||||
{
|
||||
public const string Version = "1.1.0";
|
||||
private static readonly IReadOnlyDictionary<string, KbxFieldMetadata> Items =
|
||||
new Dictionary<string, KbxFieldMetadata>(StringComparer.Ordinal)
|
||||
{
|
||||
["address1"] = new("address1", "주소", "text", new[] { "기본주소", "배송주소", "Address1" }, true, 500, null, null, null, false, true, true, true, "address"),
|
||||
["address2"] = new("address2", "상세주소", "text", new[] { "주소2", "Address2" }, false, 500, null, null, null, false, true, true, true, "address"),
|
||||
["allocatedQty"] = new("allocatedQty", "할당수량", "quantity", new[] { "AllocatedQty" }, false, null, 18, 4, null, true, false, true, false, null),
|
||||
["amount"] = new("amount", "금액", "money", new[] { "Amount", "주문금액" }, false, null, 18, 2, null, true, false, true, false, null),
|
||||
["availableQty"] = new("availableQty", "가용재고", "quantity", new[] { "AvailableQty", "출고가능수량" }, false, null, 18, 4, null, true, false, true, false, null),
|
||||
["barcode"] = new("barcode", "바코드", "code", new[] { "Barcode", "EAN", "JAN" }, false, 100, null, null, "item", false, true, true, false, null),
|
||||
["channelId"] = new("channelId", "판매채널ID", "lookup", new[] { "ChannelId" }, false, null, null, null, "salesChannel", true, false, false, false, null),
|
||||
["channelName"] = new("channelName", "판매채널", "text", new[] { "ChannelName", "채널" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["createdAt"] = new("createdAt", "등록일시", "datetime", new[] { "CreatedAt" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["createdBy"] = new("createdBy", "등록자", "text", new[] { "CreatedBy" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["customerCode"] = new("customerCode", "거래처코드", "code", new[] { "거래처", "업체코드", "CustomerCode" }, true, 50, null, null, "customer", false, true, true, false, null),
|
||||
["customerId"] = new("customerId", "거래처ID", "lookup", new[] { "CustomerId" }, false, null, null, null, "customer", true, false, false, false, null),
|
||||
["customerName"] = new("customerName", "거래처명", "text", new[] { "CustomerName", "업체명" }, false, 200, null, null, null, true, false, true, false, null),
|
||||
["damagedQty"] = new("damagedQty", "불량수량", "quantity", new[] { "DamagedQty" }, false, null, 18, 4, null, true, false, true, false, null),
|
||||
["effectiveDate"] = new("effectiveDate", "적용일", "date", new[] { "EffectiveDate", "시작일", "적용시작일" }, true, null, null, null, null, false, true, true, false, null),
|
||||
["exceptionCount"] = new("exceptionCount", "예외건수", "integer", new[] { "ExceptionCount", "오류건수" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["expiryDate"] = new("expiryDate", "유통기한", "date", new[] { "ExpiryDate", "ExpirationDate" }, false, null, null, null, null, false, true, true, false, null),
|
||||
["holdQty"] = new("holdQty", "보류수량", "quantity", new[] { "HoldQty" }, false, null, 18, 4, null, true, false, true, false, null),
|
||||
["itemCode"] = new("itemCode", "품목코드", "code", new[] { "상품코드", "SKU", "ItemCode" }, true, 80, null, null, "item", false, true, true, false, null),
|
||||
["itemId"] = new("itemId", "품목ID", "lookup", new[] { "ItemId" }, false, null, null, null, "item", true, false, false, false, null),
|
||||
["itemName"] = new("itemName", "품목명", "text", new[] { "상품명", "ItemName" }, false, 300, null, null, null, true, false, true, false, null),
|
||||
["itemSummary"] = new("itemSummary", "대표상품", "text", new[] { "ItemSummary" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["locationCode"] = new("locationCode", "로케이션", "code", new[] { "Location", "LocationCode", "위치" }, false, 80, null, null, "location", false, true, true, false, null),
|
||||
["locationId"] = new("locationId", "로케이션ID", "lookup", new[] { "LocationId" }, false, null, null, null, "location", true, false, false, false, null),
|
||||
["lotNo"] = new("lotNo", "LOT", "code", new[] { "LotNo", "LOT번호" }, false, 100, null, null, null, false, true, true, false, null),
|
||||
["occurredAt"] = new("occurredAt", "발생시각", "datetime", new[] { "OccurredAt" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["onHandQty"] = new("onHandQty", "현재고", "quantity", new[] { "OnHandQty", "장부재고" }, false, null, 18, 4, null, true, false, true, false, null),
|
||||
["orderDate"] = new("orderDate", "주문일", "date", new[] { "주문일자", "OrderDate" }, true, null, null, null, null, false, true, true, false, null),
|
||||
["orderedAt"] = new("orderedAt", "주문일시", "datetime", new[] { "OrderedAt", "주문시간" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["orderId"] = new("orderId", "주문ID", "code", new[] { "OrderId" }, false, null, null, null, null, true, false, false, false, null),
|
||||
["orderNo"] = new("orderNo", "주문번호", "code", new[] { "OrderNo", "주문ID" }, true, 50, null, null, null, false, true, true, false, null),
|
||||
["orderQty"] = new("orderQty", "주문수량", "quantity", new[] { "주문수량", "수량", "Qty", "OrderQty" }, true, null, 18, 4, null, false, true, true, false, null),
|
||||
["ownerName"] = new("ownerName", "담당자", "text", new[] { "OwnerName", "담당" }, false, 100, null, null, null, true, false, true, false, null),
|
||||
["phone"] = new("phone", "연락처", "text", new[] { "휴대폰", "전화번호", "Phone" }, true, 50, null, null, null, false, true, true, true, "phone"),
|
||||
["pickedQty"] = new("pickedQty", "피킹수량", "quantity", new[] { "PickedQty" }, false, null, 18, 4, null, true, false, true, false, null),
|
||||
["postalCode"] = new("postalCode", "우편번호", "text", new[] { "ZipCode", "PostalCode" }, false, 20, null, null, null, false, true, true, true, "address"),
|
||||
["quantity"] = new("quantity", "수량", "quantity", new[] { "Qty" }, false, null, 18, 4, null, false, false, false, false, null),
|
||||
["receiverName"] = new("receiverName", "수취인", "text", new[] { "받는분", "수령인", "ReceiverName" }, true, 100, null, null, null, false, true, true, true, "name"),
|
||||
["referenceNo"] = new("referenceNo", "참조번호", "code", new[] { "ReferenceNo" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["remark"] = new("remark", "비고", "text", new[] { "메모", "배송메모", "Remark" }, false, 500, null, null, null, false, true, true, false, null),
|
||||
["shippedQty"] = new("shippedQty", "출고수량", "quantity", new[] { "ShippedQty" }, false, null, 18, 4, null, true, false, true, false, null),
|
||||
["specification"] = new("specification", "규격", "text", new[] { "Specification", "Spec" }, false, 200, null, null, null, false, true, true, false, null),
|
||||
["status"] = new("status", "상태", "status", new[] { "Status" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["totalQty"] = new("totalQty", "총수량", "quantity", new[] { "TotalQty" }, false, null, 18, 4, null, true, false, true, false, null),
|
||||
["unitPrice"] = new("unitPrice", "단가", "money", new[] { "판매단가", "UnitPrice" }, false, null, 18, 4, null, false, true, true, false, null),
|
||||
["updatedAt"] = new("updatedAt", "수정일시", "datetime", new[] { "UpdatedAt" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["updatedBy"] = new("updatedBy", "수정자", "text", new[] { "UpdatedBy" }, false, null, null, null, null, true, false, true, false, null),
|
||||
["version"] = new("version", "버전", "integer", new[] { "Version", "RowVersion" }, false, null, null, null, null, true, false, false, false, null),
|
||||
["warehouseCode"] = new("warehouseCode", "창고코드", "code", new[] { "창고", "창고코드", "출고창고", "WarehouseCode" }, true, 50, null, null, "warehouse", false, true, true, false, null),
|
||||
["warehouseId"] = new("warehouseId", "창고ID", "lookup", new[] { "WarehouseId" }, false, null, null, null, "warehouse", true, false, false, false, null),
|
||||
["warehouseName"] = new("warehouseName", "창고명", "text", new[] { "WarehouseName" }, false, 200, null, null, null, true, false, true, false, null),
|
||||
};
|
||||
|
||||
public static KbxFieldMetadata Get(string key) => Items.TryGetValue(key, out var value)
|
||||
? value
|
||||
: throw new KeyNotFoundException($"Unknown KBX field key: {key}");
|
||||
|
||||
public static bool TryGet(string key, out KbxFieldMetadata? value) => Items.TryGetValue(key, out value);
|
||||
public static IReadOnlyCollection<KbxFieldMetadata> All => Items.Values;
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Shared.Contracts.Generated;
|
||||
using Shared.Problems;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Shared.Contracts;
|
||||
|
||||
public static class KbxApiContractSwaggerExtensions
|
||||
{
|
||||
public static void AddKbxApiContracts(this SwaggerGenOptions options)
|
||||
{
|
||||
options.OperationFilter<KbxApiContractOperationFilter>();
|
||||
options.OperationFilter<KbxProblemOpenApiOperationFilter>();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class KbxApiContractOperationFilter : IOperationFilter
|
||||
{
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
var method = context.ApiDescription.HttpMethod?.ToUpperInvariant();
|
||||
var relative = "/" + (context.ApiDescription.RelativePath ?? string.Empty).TrimStart('/');
|
||||
var normalized = Normalize(relative);
|
||||
var contract = KbxApiCatalog.All.FirstOrDefault(x =>
|
||||
x.Method == method && Normalize(x.Path) == normalized);
|
||||
if (contract is null) return;
|
||||
|
||||
operation.OperationId = contract.Id;
|
||||
operation.Extensions["x-kbx-permission"] = new OpenApiString(contract.Permission ?? string.Empty);
|
||||
operation.Extensions["x-kbx-idempotency"] = new OpenApiString(contract.Idempotency);
|
||||
operation.Extensions["x-kbx-kind"] = new OpenApiString(contract.Kind);
|
||||
}
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
Regex.Replace(value, @"\{([^}:]+):[^}]+\}", "{$1}").ToLowerInvariant();
|
||||
}
|
||||
|
||||
public sealed class KbxProblemOpenApiOperationFilter : IOperationFilter
|
||||
{
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
var problem = new OpenApiSchema
|
||||
{
|
||||
OneOf = new List<OpenApiSchema>
|
||||
{
|
||||
context.SchemaGenerator.GenerateSchema(typeof(KbxValidationProblem), context.SchemaRepository),
|
||||
context.SchemaGenerator.GenerateSchema(typeof(KbxBusinessProblem), context.SchemaRepository),
|
||||
context.SchemaGenerator.GenerateSchema(typeof(KbxConflictProblem), context.SchemaRepository),
|
||||
context.SchemaGenerator.GenerateSchema(typeof(KbxPermissionProblem), context.SchemaRepository),
|
||||
context.SchemaGenerator.GenerateSchema(typeof(KbxNotFoundProblem), context.SchemaRepository),
|
||||
context.SchemaGenerator.GenerateSchema(typeof(KbxIntegrationProblem), context.SchemaRepository),
|
||||
context.SchemaGenerator.GenerateSchema(typeof(KbxSystemProblem), context.SchemaRepository),
|
||||
}
|
||||
};
|
||||
|
||||
Add(operation, "400", "Validation problem", problem);
|
||||
Add(operation, "403", "Permission problem", problem);
|
||||
Add(operation, "404", "Not found problem", problem);
|
||||
Add(operation, "409", "Conflict or business rule problem", problem);
|
||||
Add(operation, "422", "Business rule problem", problem);
|
||||
Add(operation, "500", "System problem", problem);
|
||||
}
|
||||
|
||||
private static void Add(OpenApiOperation operation, string status, string description, OpenApiSchema schema)
|
||||
{
|
||||
if (operation.Responses.ContainsKey(status)) return;
|
||||
operation.Responses[status] = new OpenApiResponse
|
||||
{
|
||||
Description = description,
|
||||
Content = new Dictionary<string, OpenApiMediaType>
|
||||
{
|
||||
["application/json"] = new() { Schema = schema }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Kbx.Contracts;
|
||||
|
||||
public static class KbxFieldContractSwaggerExtensions
|
||||
{
|
||||
public static void AddKbxFieldContract(this SwaggerGenOptions options)
|
||||
=> options.SchemaFilter<KbxFieldOpenApiSchemaFilter>();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Kbx.Contracts;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false)]
|
||||
public sealed class KbxFieldKeyAttribute(string fieldKey) : Attribute
|
||||
{
|
||||
public string FieldKey { get; } = fieldKey;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Kbx.Contracts.Generated;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Kbx.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Adds non-business KBX field metadata to OpenAPI properties.
|
||||
/// Domain validation rules are intentionally not emitted from the field dictionary.
|
||||
/// </summary>
|
||||
public sealed class KbxFieldOpenApiSchemaFilter : ISchemaFilter
|
||||
{
|
||||
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
||||
{
|
||||
foreach (var property in context.Type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
var attribute = property.GetCustomAttribute<KbxFieldKeyAttribute>();
|
||||
if (attribute is null || !KbxFieldCatalog.TryGet(attribute.FieldKey, out var field) || field is null)
|
||||
continue;
|
||||
|
||||
var jsonName = JsonNamingPolicy.CamelCase.ConvertName(property.Name);
|
||||
if (!schema.Properties.TryGetValue(jsonName, out var propertySchema))
|
||||
{
|
||||
var match = schema.Properties.FirstOrDefault(x => string.Equals(x.Key, property.Name, StringComparison.OrdinalIgnoreCase));
|
||||
propertySchema = match.Value;
|
||||
}
|
||||
if (propertySchema is null) continue;
|
||||
|
||||
propertySchema.Extensions["x-kbx-field-key"] = new OpenApiString(field.Key);
|
||||
propertySchema.Extensions["x-kbx-field-label"] = new OpenApiString(field.Label);
|
||||
propertySchema.Extensions["x-kbx-sensitive"] = new OpenApiBoolean(field.Sensitive);
|
||||
if (!string.IsNullOrWhiteSpace(field.Masking))
|
||||
propertySchema.Extensions["x-kbx-masking"] = new OpenApiString(field.Masking);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Shared.Contracts.Generated;
|
||||
using Xunit;
|
||||
|
||||
namespace Shared.Contracts.Tests;
|
||||
|
||||
public sealed class KbxApiCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Operation_ids_and_routes_are_unique()
|
||||
{
|
||||
Assert.NotEmpty(KbxApiCatalog.SourceSha256);
|
||||
Assert.Equal(KbxApiCatalog.All.Count, KbxApiCatalog.All.Select(x => x.Id).Distinct().Count());
|
||||
Assert.Equal(KbxApiCatalog.All.Count, KbxApiCatalog.All.Select(x => $"{x.Method} {x.Path}").Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Required_idempotency_is_not_used_by_get_queries()
|
||||
{
|
||||
Assert.DoesNotContain(KbxApiCatalog.All, x => x.Method == "GET" && x.Idempotency == "required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Kbx.Contracts.Generated;
|
||||
using Xunit;
|
||||
|
||||
namespace Kbx.Contracts.Tests;
|
||||
|
||||
public sealed class KbxFieldCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sensitive_fields_have_masking_metadata()
|
||||
{
|
||||
var invalid = KbxFieldCatalog.All.Where(x => x.Sensitive && string.IsNullOrWhiteSpace(x.Masking)).ToArray();
|
||||
Assert.Empty(invalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Order_quantity_is_canonical()
|
||||
{
|
||||
var field = KbxFieldCatalog.Get(KbxFieldKeys.OrderQty);
|
||||
Assert.Equal("quantity", field.DataType);
|
||||
Assert.True(field.Importable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public sealed class ClosedXmlWorkbookService
|
||||
{
|
||||
public IReadOnlyList<string> ReadHeaders(Stream stream)
|
||||
{
|
||||
using var workbook = new XLWorkbook(stream);
|
||||
var sheet = workbook.Worksheets.First();
|
||||
var last = sheet.Row(1).LastCellUsed()?.Address.ColumnNumber ?? 0;
|
||||
if (last == 0) return [];
|
||||
|
||||
return Enumerable.Range(1, last)
|
||||
.Select(i => sheet.Cell(1, i).GetFormattedString().Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IEnumerable<ImportRawRow> ReadRows(Stream stream, int maxRows)
|
||||
{
|
||||
using var workbook = new XLWorkbook(stream);
|
||||
var sheet = workbook.Worksheets.First();
|
||||
var lastColumn = sheet.Row(1).LastCellUsed()?.Address.ColumnNumber ?? 0;
|
||||
var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
|
||||
var headers = Enumerable.Range(1, lastColumn)
|
||||
.Select(i => sheet.Cell(1, i).GetFormattedString().Trim())
|
||||
.ToArray();
|
||||
|
||||
if (lastRow - 1 > maxRows)
|
||||
throw new InvalidOperationException($"최대 {maxRows:N0}행까지 업로드할 수 있습니다.");
|
||||
|
||||
for (var rowNo = 2; rowNo <= lastRow; rowNo++)
|
||||
{
|
||||
var values = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
var hasValue = false;
|
||||
for (var column = 1; column <= lastColumn; column++)
|
||||
{
|
||||
var header = headers[column - 1];
|
||||
if (string.IsNullOrWhiteSpace(header)) continue;
|
||||
var value = sheet.Cell(rowNo, column).GetFormattedString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value)) hasValue = true;
|
||||
values[header] = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
if (hasValue) yield return new ImportRawRow(rowNo, values);
|
||||
}
|
||||
}
|
||||
|
||||
public MemoryStream CreateTemplate(ImportDefinition definition)
|
||||
{
|
||||
var workbook = new XLWorkbook();
|
||||
var sheet = workbook.AddWorksheet("업로드");
|
||||
var fields = definition.Fields.Where(x => x.Importable).ToArray();
|
||||
|
||||
for (var i = 0; i < fields.Length; i++)
|
||||
{
|
||||
var cell = sheet.Cell(1, i + 1);
|
||||
cell.Value = fields[i].Label;
|
||||
cell.Style.Font.Bold = true;
|
||||
cell.Style.Fill.BackgroundColor = XLColor.LightGray;
|
||||
cell.Comment.AddText(fields[i].Required ? "필수 입력" : "선택 입력");
|
||||
}
|
||||
|
||||
sheet.SheetView.FreezeRows(1);
|
||||
sheet.Columns(1, fields.Length).Width = 18;
|
||||
sheet.Row(1).Height = 22;
|
||||
sheet.Range(1, 1, 1, fields.Length).SetAutoFilter();
|
||||
|
||||
var help = workbook.AddWorksheet("필드설명");
|
||||
help.Cell("A1").Value = "필드";
|
||||
help.Cell("B1").Value = "필수";
|
||||
help.Cell("C1").Value = "형식";
|
||||
help.Cell("D1").Value = "허용 별칭";
|
||||
help.Range("A1:D1").Style.Font.Bold = true;
|
||||
for (var i = 0; i < fields.Length; i++)
|
||||
{
|
||||
var row = i + 2;
|
||||
help.Cell(row, 1).Value = fields[i].Label;
|
||||
help.Cell(row, 2).Value = fields[i].Required ? "Y" : "N";
|
||||
help.Cell(row, 3).Value = fields[i].DataType;
|
||||
help.Cell(row, 4).Value = string.Join(", ", fields[i].Aliases);
|
||||
}
|
||||
help.Columns().AdjustToContents(8, 35);
|
||||
|
||||
var output = new MemoryStream();
|
||||
workbook.SaveAs(output);
|
||||
output.Position = 0;
|
||||
workbook.Dispose();
|
||||
return output;
|
||||
}
|
||||
|
||||
public MemoryStream CreateErrorWorkbook(
|
||||
Stream source,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<ImportIssue>> issues)
|
||||
{
|
||||
using var workbook = new XLWorkbook(source);
|
||||
var sheet = workbook.Worksheets.First();
|
||||
var errorColumn = (sheet.Row(1).LastCellUsed()?.Address.ColumnNumber ?? 0) + 1;
|
||||
sheet.Cell(1, errorColumn).Value = "오류사유";
|
||||
sheet.Cell(1, errorColumn).Style.Font.Bold = true;
|
||||
|
||||
foreach (var (rowNo, rowIssues) in issues)
|
||||
{
|
||||
sheet.Cell(rowNo, errorColumn).Value = string.Join(" | ", rowIssues.Select(x => x.Message));
|
||||
}
|
||||
sheet.Column(errorColumn).Width = 45;
|
||||
|
||||
var output = new MemoryStream();
|
||||
workbook.SaveAs(output);
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public interface IImportDefinition
|
||||
{
|
||||
ImportDefinition Definition { get; }
|
||||
|
||||
Task<IReadOnlyList<ImportRowValidation>> ValidateAsync(
|
||||
IReadOnlyList<ImportRawRow> rows,
|
||||
IReadOnlyDictionary<string, string> targetToSource,
|
||||
NpgsqlConnection connection,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Commit valid staged rows. Implementations own domain grouping and transaction boundaries.
|
||||
/// They must be idempotent for a session retry.
|
||||
/// </summary>
|
||||
Task<ImportCommitResult> CommitAsync(Guid sessionId, string actor, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ImportDefinitionRegistry(IEnumerable<IImportDefinition> definitions)
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IImportDefinition> _definitions =
|
||||
definitions.ToDictionary(x => x.Definition.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IImportDefinition Get(string importType) =>
|
||||
_definitions.TryGetValue(importType, out var value)
|
||||
? value
|
||||
: throw new KeyNotFoundException($"Unknown import type: {importType}");
|
||||
|
||||
public bool TryGet(string importType, out IImportDefinition? definition) =>
|
||||
_definitions.TryGetValue(importType, out definition);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public static class ImportStatuses
|
||||
{
|
||||
public const string Created = "created";
|
||||
public const string Uploaded = "uploaded";
|
||||
public const string MappingRequired = "mapping-required";
|
||||
public const string Validating = "validating";
|
||||
public const string Validated = "validated";
|
||||
public const string Committing = "committing";
|
||||
public const string Completed = "completed";
|
||||
public const string PartiallyCompleted = "partially-completed";
|
||||
public const string Failed = "failed";
|
||||
public const string Cancelled = "cancelled";
|
||||
}
|
||||
|
||||
public sealed record ImportFieldDefinition(
|
||||
string Key,
|
||||
string Label,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string DataType,
|
||||
bool Required = false,
|
||||
int? MaxLength = null,
|
||||
int? Precision = null,
|
||||
int? Scale = null,
|
||||
string? LookupEntity = null,
|
||||
bool Importable = true,
|
||||
bool Exportable = true);
|
||||
|
||||
public sealed record ImportDefinition(
|
||||
string Id,
|
||||
string ScreenId,
|
||||
string Entity,
|
||||
string Title,
|
||||
IReadOnlyList<ImportFieldDefinition> Fields,
|
||||
bool AllowCreate,
|
||||
bool AllowUpdate,
|
||||
long MaxFileSizeBytes = 20 * 1024 * 1024,
|
||||
int MaxRows = 100_000);
|
||||
|
||||
public sealed record ImportMapping(
|
||||
string SourceColumn,
|
||||
string? TargetField,
|
||||
string Source,
|
||||
decimal? Confidence = null,
|
||||
string? Reason = null);
|
||||
|
||||
public sealed record ImportIssue(
|
||||
int RowNumber,
|
||||
string? Field,
|
||||
string? SourceColumn,
|
||||
string Code,
|
||||
string Message,
|
||||
string Severity);
|
||||
|
||||
public sealed record ImportProgressEvent(
|
||||
Guid SessionId,
|
||||
string Status,
|
||||
int ProgressPercent,
|
||||
int TotalRows,
|
||||
int ProcessedRows,
|
||||
int ValidRows,
|
||||
int InvalidRows,
|
||||
int WarningRows,
|
||||
string? Message = null);
|
||||
|
||||
public sealed record ImportSessionDto(
|
||||
Guid Id,
|
||||
string ImportType,
|
||||
string ScreenId,
|
||||
string FileName,
|
||||
string Status,
|
||||
int TotalRows,
|
||||
int ValidRows,
|
||||
int InvalidRows,
|
||||
int WarningRows,
|
||||
int CreatedRows,
|
||||
int UpdatedRows,
|
||||
int ProgressPercent,
|
||||
IReadOnlyList<string> SourceColumns,
|
||||
IReadOnlyList<ImportMapping> Mapping,
|
||||
IReadOnlyList<ImportIssue>? Errors,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? CompletedAt);
|
||||
|
||||
public sealed record ImportRawRow(int RowNumber, IReadOnlyDictionary<string, string?> Values);
|
||||
public sealed record ImportNormalizedRow(int RowNumber, JsonDocument Data, string? DomainKey);
|
||||
|
||||
public sealed record ImportRowValidation(
|
||||
int RowNumber,
|
||||
IReadOnlyDictionary<string, string?> RawData,
|
||||
string? DomainKey,
|
||||
JsonDocument? NormalizedData,
|
||||
IReadOnlyList<ImportIssue> Errors,
|
||||
IReadOnlyList<ImportIssue> Warnings)
|
||||
{
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
}
|
||||
|
||||
public sealed record ImportCommitResult(int Created, int Updated, int Failed);
|
||||
@@ -0,0 +1,26 @@
|
||||
using Kbx.Contracts.Generated;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public static class ImportFieldDefinitionFactory
|
||||
{
|
||||
public static ImportFieldDefinition FromKbxField(string key, bool? required = null, string? label = null)
|
||||
{
|
||||
var field = KbxFieldCatalog.Get(key);
|
||||
if (!field.Importable)
|
||||
throw new InvalidOperationException($"KBX field '{key}' is not importable.");
|
||||
|
||||
return new ImportFieldDefinition(
|
||||
Key: field.Key,
|
||||
Label: label ?? field.Label,
|
||||
Aliases: field.Aliases,
|
||||
DataType: field.DataType,
|
||||
Required: required ?? field.Required,
|
||||
MaxLength: field.MaxLength,
|
||||
Precision: field.Precision,
|
||||
Scale: field.Scale,
|
||||
LookupEntity: field.LookupEntity,
|
||||
Importable: field.Importable,
|
||||
Exportable: field.Exportable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public static class ImportIdentity
|
||||
{
|
||||
public static string TenantId(ClaimsPrincipal user) =>
|
||||
user.FindFirst("tenant_id")?.Value
|
||||
?? user.FindFirst("tenant")?.Value
|
||||
?? "default";
|
||||
|
||||
public static string UserId(ClaimsPrincipal user) =>
|
||||
user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.Identity?.Name
|
||||
?? "unknown";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Shared.Excel;
|
||||
|
||||
public sealed class ImportMappingEngine
|
||||
{
|
||||
public IReadOnlyList<ImportMapping> Map(
|
||||
IReadOnlyList<string> sourceColumns,
|
||||
ImportDefinition definition,
|
||||
IReadOnlyList<ImportMapping>? saved = null)
|
||||
{
|
||||
var savedBySource = (saved ?? [])
|
||||
.Where(x => x.TargetField is not null)
|
||||
.ToDictionary(x => Normalize(x.SourceColumn), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var result = new List<ImportMapping>(sourceColumns.Count);
|
||||
foreach (var source in sourceColumns)
|
||||
{
|
||||
var normalized = Normalize(source);
|
||||
if (savedBySource.TryGetValue(normalized, out var savedMapping) &&
|
||||
definition.Fields.Any(x => x.Key.Equals(savedMapping.TargetField, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
result.Add(savedMapping with { SourceColumn = source, Source = "saved", Confidence = 1m, Reason = "저장된 매핑" });
|
||||
continue;
|
||||
}
|
||||
|
||||
var exact = definition.Fields.FirstOrDefault(x => Normalize(x.Label) == normalized || Normalize(x.Key) == normalized);
|
||||
if (exact is not null)
|
||||
{
|
||||
result.Add(new(source, exact.Key, "exact", 1m, "필드명 일치"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var alias = definition.Fields.FirstOrDefault(x => x.Aliases.Any(a => Normalize(a) == normalized));
|
||||
if (alias is not null)
|
||||
{
|
||||
result.Add(new(source, alias.Key, "alias", 1m, $"별칭 '{source}' 일치"));
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new(source, null, "manual", null, null));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public static class ImportSourceSignature
|
||||
{
|
||||
public static string Create(IEnumerable<string> columns)
|
||||
{
|
||||
var canonical = string.Join("|", columns.Select(Normalize).Order(StringComparer.Ordinal));
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
|
||||
}
|
||||
|
||||
public interface IImportMappingSuggester
|
||||
{
|
||||
Task<IReadOnlyList<ImportMapping>> SuggestAsync(
|
||||
IReadOnlyList<string> unmappedColumns,
|
||||
ImportDefinition definition,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safe default. A production AI adapter can replace this registration. The adapter must return
|
||||
/// only target fields present in ImportDefinition and never mutate data or persist a mapping by itself.
|
||||
/// </summary>
|
||||
public sealed class NoopImportMappingSuggester : IImportMappingSuggester
|
||||
{
|
||||
public Task<IReadOnlyList<ImportMapping>> SuggestAsync(
|
||||
IReadOnlyList<string> unmappedColumns,
|
||||
ImportDefinition definition,
|
||||
CancellationToken ct) => Task.FromResult<IReadOnlyList<ImportMapping>>([]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
[Authorize]
|
||||
public sealed class ImportProgressHub : Hub
|
||||
{
|
||||
public Task Subscribe(string sessionId) =>
|
||||
Groups.AddToGroupAsync(Context.ConnectionId, $"import:{sessionId}");
|
||||
|
||||
public Task Unsubscribe(string sessionId) =>
|
||||
Groups.RemoveFromGroupAsync(Context.ConnectionId, $"import:{sessionId}");
|
||||
}
|
||||
|
||||
public sealed class ImportProgressPublisher(IHubContext<ImportProgressHub> hub)
|
||||
{
|
||||
public Task PublishAsync(ImportProgressEvent value, CancellationToken ct) =>
|
||||
hub.Clients.Group($"import:{value.SessionId}")
|
||||
.SendAsync("ImportProgress", value, ct);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public sealed class ImportRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<Guid> CreateSessionAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
ImportDefinition definition,
|
||||
string fileName,
|
||||
string contentType,
|
||||
byte[] data,
|
||||
IReadOnlyList<string> sourceColumns,
|
||||
IReadOnlyList<ImportMapping> mapping,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.import_sessions(
|
||||
id, tenant_id, user_id, screen_id, import_type, original_file_name, status,
|
||||
source_columns, mapping, created_at, updated_at)
|
||||
values(
|
||||
@Id, @TenantId, @UserId, @ScreenId, @ImportType, @FileName, @Status,
|
||||
cast(@SourceColumns as jsonb), cast(@Mapping as jsonb), now(), now());
|
||||
""", new {
|
||||
Id = id, TenantId = tenantId, UserId = userId,
|
||||
definition.ScreenId, ImportType = definition.Id, FileName = fileName,
|
||||
Status = ImportStatuses.MappingRequired,
|
||||
SourceColumns = JsonSerializer.Serialize(sourceColumns, Json),
|
||||
Mapping = JsonSerializer.Serialize(mapping, Json),
|
||||
}, tx, cancellationToken: ct));
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.import_files(session_id, content_type, file_size, sha256, data)
|
||||
values(@SessionId, @ContentType, @FileSize, @Sha256, @Data);
|
||||
""", new { SessionId = id, ContentType = contentType, FileSize = data.LongLength, Sha256 = hash, Data = data }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task<(string ContentType, byte[] Data)> GetFileAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.QuerySingleAsync<(string, byte[])>(new CommandDefinition(
|
||||
"select content_type, data from kbx.import_files where session_id=@SessionId",
|
||||
new { SessionId = sessionId }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ImportMapping>?> GetSavedMappingAsync(
|
||||
string tenantId, string userId, string importType, IReadOnlyList<string> sourceColumns, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var signature = ImportSourceSignature.Create(sourceColumns);
|
||||
var json = await connection.QueryFirstOrDefaultAsync<string>(new CommandDefinition("""
|
||||
select mapping::text from kbx.saved_import_mappings
|
||||
where tenant_id=@TenantId and user_id=@UserId and import_type=@ImportType and source_signature=@Signature
|
||||
order by updated_at desc limit 1;
|
||||
""", new { TenantId = tenantId, UserId = userId, ImportType = importType, Signature = signature }, cancellationToken: ct));
|
||||
return json is null ? null : JsonSerializer.Deserialize<ImportMapping[]>(json, Json);
|
||||
}
|
||||
|
||||
public async Task SaveNamedMappingAsync(
|
||||
string tenantId, string userId, string importType, string name, IReadOnlyList<string> sourceColumns, IReadOnlyList<ImportMapping> mapping, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.saved_import_mappings(id,tenant_id,user_id,import_type,name,source_signature,mapping,created_at,updated_at)
|
||||
values(@Id,@TenantId,@UserId,@ImportType,@Name,@Signature,cast(@Mapping as jsonb),now(),now())
|
||||
on conflict(tenant_id,user_id,import_type,name) do update
|
||||
set source_signature=excluded.source_signature,mapping=excluded.mapping,updated_at=now();
|
||||
""", new {
|
||||
Id = Guid.NewGuid(), TenantId=tenantId, UserId=userId, ImportType=importType, Name=name,
|
||||
Signature=ImportSourceSignature.Create(sourceColumns), Mapping=JsonSerializer.Serialize(mapping, Json)
|
||||
}, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task SaveMappingAsync(Guid sessionId, IReadOnlyList<ImportMapping> mapping, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.import_sessions set mapping=cast(@Mapping as jsonb), status=@Status, updated_at=now()
|
||||
where id=@SessionId;
|
||||
""", new { SessionId = sessionId, Mapping = JsonSerializer.Serialize(mapping, Json), Status = ImportStatuses.MappingRequired }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task<ImportSessionDto?> GetAsync(Guid sessionId, string tenantId, string userId, bool includeErrors, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<SessionRow>(new CommandDefinition("""
|
||||
select id, import_type as "ImportType", screen_id as "ScreenId", original_file_name as "FileName", status,
|
||||
total_rows as "TotalRows", valid_rows as "ValidRows", invalid_rows as "InvalidRows", warning_rows as "WarningRows", created_rows as "CreatedRows", updated_rows as "UpdatedRows",
|
||||
progress_percent as "ProgressPercent", source_columns::text as "SourceColumnsJson",
|
||||
mapping::text as "MappingJson", created_at as "CreatedAt", completed_at as "CompletedAt"
|
||||
from kbx.import_sessions
|
||||
where id=@SessionId and tenant_id=@TenantId and user_id=@UserId;
|
||||
""", new { SessionId = sessionId, TenantId = tenantId, UserId = userId }, cancellationToken: ct));
|
||||
if (row is null) return null;
|
||||
|
||||
IReadOnlyList<ImportIssue>? errors = null;
|
||||
if (includeErrors)
|
||||
{
|
||||
var issueJson = await connection.QueryAsync<string>(new CommandDefinition("""
|
||||
select errors::text from kbx.import_rows
|
||||
where session_id=@SessionId and jsonb_array_length(errors) > 0
|
||||
order by row_number limit 100;
|
||||
""", new { SessionId = sessionId }, cancellationToken: ct));
|
||||
errors = issueJson.SelectMany(x => JsonSerializer.Deserialize<ImportIssue[]>(x, Json) ?? []).ToArray();
|
||||
}
|
||||
|
||||
return new ImportSessionDto(
|
||||
row.Id, row.ImportType, row.ScreenId, row.FileName, row.Status,
|
||||
row.TotalRows, row.ValidRows, row.InvalidRows, row.WarningRows,
|
||||
row.CreatedRows, row.UpdatedRows, row.ProgressPercent,
|
||||
JsonSerializer.Deserialize<string[]>(row.SourceColumnsJson, Json) ?? [],
|
||||
JsonSerializer.Deserialize<ImportMapping[]>(row.MappingJson, Json) ?? [],
|
||||
errors, row.CreatedAt, row.CompletedAt);
|
||||
}
|
||||
|
||||
public async Task<(string ImportType, IReadOnlyList<ImportMapping> Mapping)> GetWorkDefinitionAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var row = await connection.QuerySingleAsync<(string ImportType, string MappingJson)>(new CommandDefinition(
|
||||
"select import_type as "ImportType", mapping::text as "MappingJson" from kbx.import_sessions where id=@SessionId",
|
||||
new { SessionId = sessionId }, cancellationToken: ct));
|
||||
return (row.ImportType, JsonSerializer.Deserialize<ImportMapping[]>(row.MappingJson, Json) ?? []);
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> TryTransitionAsync(Guid sessionId, IReadOnlyCollection<string> from, string to, int progress, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var affected = await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.import_sessions
|
||||
set status=@To, progress_percent=@Progress, updated_at=now()
|
||||
where id=@SessionId and status = any(@From);
|
||||
""", new { SessionId = sessionId, From = from.ToArray(), To = to, Progress = progress }, cancellationToken: ct));
|
||||
return affected == 1;
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(Guid sessionId, string status, int progress, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
"update kbx.import_sessions set status=@Status, progress_percent=@Progress, updated_at=now() where id=@SessionId",
|
||||
new { SessionId = sessionId, Status = status, Progress = progress }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task ReplaceRowsAsync(Guid sessionId, IReadOnlyList<ImportRowValidation> rows, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("delete from kbx.import_rows where session_id=@SessionId", new { SessionId = sessionId }, tx, cancellationToken: ct));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.import_rows(session_id,row_number,raw_data,normalized_data,status,errors,warnings,domain_key)
|
||||
values(@SessionId,@RowNumber,cast(@RawData as jsonb),cast(@NormalizedData as jsonb),@Status,cast(@Errors as jsonb),cast(@Warnings as jsonb),@DomainKey);
|
||||
""", new {
|
||||
SessionId = sessionId, row.RowNumber,
|
||||
RawData = JsonSerializer.Serialize(row.RawData, Json),
|
||||
NormalizedData = row.NormalizedData?.RootElement.GetRawText(),
|
||||
Status = row.IsValid ? "VALID" : "INVALID",
|
||||
Errors = JsonSerializer.Serialize(row.Errors, Json),
|
||||
Warnings = JsonSerializer.Serialize(row.Warnings, Json),
|
||||
row.DomainKey,
|
||||
}, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
var total = rows.Count;
|
||||
var valid = rows.Count(x => x.IsValid);
|
||||
var invalid = total - valid;
|
||||
var warningRows = rows.Count(x => x.Warnings.Count > 0);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.import_sessions
|
||||
set status=@Status,total_rows=@Total,valid_rows=@Valid,invalid_rows=@Invalid,
|
||||
warning_rows=@Warnings,progress_percent=100,updated_at=now()
|
||||
where id=@SessionId;
|
||||
""", new { SessionId = sessionId, Status = ImportStatuses.Validated, Total = total, Valid = valid, Invalid = invalid, Warnings = warningRows }, tx, cancellationToken: ct));
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(Guid sessionId, ImportCommitResult result, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var status = result.Failed > 0 ? ImportStatuses.PartiallyCompleted : ImportStatuses.Completed;
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.import_sessions
|
||||
set status=@Status, created_rows=@Created, updated_rows=@Updated,
|
||||
invalid_rows=invalid_rows + @Failed, progress_percent=100,
|
||||
completed_at=now(), updated_at=now()
|
||||
where id=@SessionId;
|
||||
""", new { SessionId = sessionId, Status = status, result.Created, result.Updated, result.Failed }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, IReadOnlyList<ImportIssue>>> GetErrorsAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<(int RowNumber, string ErrorsJson)>(new CommandDefinition("""
|
||||
select row_number as "RowNumber", errors::text as "ErrorsJson" from kbx.import_rows
|
||||
where session_id=@SessionId and jsonb_array_length(errors) > 0 order by row_number;
|
||||
""", new { SessionId = sessionId }, cancellationToken: ct));
|
||||
return rows.ToDictionary(x => x.RowNumber, x => (IReadOnlyList<ImportIssue>)(JsonSerializer.Deserialize<ImportIssue[]>(x.ErrorsJson, Json) ?? []));
|
||||
}
|
||||
|
||||
private sealed record SessionRow(
|
||||
Guid Id, string ImportType, string ScreenId, string FileName, string Status,
|
||||
int TotalRows, int ValidRows, int InvalidRows, int WarningRows,
|
||||
int CreatedRows, int UpdatedRows, int ProgressPercent,
|
||||
string SourceColumnsJson, string MappingJson,
|
||||
DateTimeOffset CreatedAt, DateTimeOffset? CompletedAt);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Modules.Common.Imports.Jobs;
|
||||
using Modules.OMS.Orders.Import;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public static class KbxExcelImportRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxExcelImport(this IServiceCollection services)
|
||||
{
|
||||
services.AddSignalR();
|
||||
services.AddSingleton<XlsxSafetyInspector>();
|
||||
services.AddSingleton<ClosedXmlWorkbookService>();
|
||||
services.AddSingleton<ImportMappingEngine>();
|
||||
services.AddSingleton<ImportRepository>();
|
||||
services.AddSingleton<IImportMappingSuggester, NoopImportMappingSuggester>();
|
||||
services.AddSingleton<IImportDefinition, OrderImportDefinition>();
|
||||
services.AddSingleton<ImportDefinitionRegistry>();
|
||||
services.AddSingleton<ImportProgressPublisher>();
|
||||
services.AddTransient<ValidateImportJob>();
|
||||
services.AddTransient<CommitImportJob>();
|
||||
services.AddTransient<PurgeExpiredImportsJob>();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapKbxExcelImport(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapHub<ImportProgressHub>("/hubs/import-progress");
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
/// <summary>
|
||||
/// Run daily from Hangfire. The default reference retention is 14 days.
|
||||
/// Audit/domain records survive; raw uploaded files and staging rows do not.
|
||||
/// </summary>
|
||||
public sealed class PurgeExpiredImportsJob(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<int> RunAsync(CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.ExecuteAsync(new CommandDefinition("""
|
||||
delete from kbx.import_sessions
|
||||
where expires_at < now()
|
||||
and status in ('completed','partially-completed','failed','cancelled');
|
||||
""", cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Shared.Excel.Tests;
|
||||
|
||||
public sealed class ImportMappingEngineTests
|
||||
{
|
||||
private static readonly ImportDefinition Definition = new(
|
||||
"test.orders", "TEST-ORD-001", "order", "주문 Import",
|
||||
[
|
||||
new("itemCode", "품목코드", ["상품코드", "SKU"], "lookup", true),
|
||||
new("quantity", "수량", ["주문수량", "Qty"], "quantity", true),
|
||||
], true, false);
|
||||
|
||||
[Fact]
|
||||
public void Exact_label_has_priority()
|
||||
{
|
||||
var mapping = new ImportMappingEngine().Map(["품목코드", "수량"], Definition);
|
||||
Assert.Equal("itemCode", mapping[0].TargetField);
|
||||
Assert.Equal("exact", mapping[0].Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alias_is_resolved_without_ai()
|
||||
{
|
||||
var mapping = new ImportMappingEngine().Map(["SKU", "주문수량"], Definition);
|
||||
Assert.Equal("itemCode", mapping[0].TargetField);
|
||||
Assert.Equal("alias", mapping[0].Source);
|
||||
Assert.Equal("quantity", mapping[1].TargetField);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saved_mapping_wins_before_new_inference()
|
||||
{
|
||||
var saved = new[] { new ImportMapping("MySku", "itemCode", "manual") };
|
||||
var mapping = new ImportMappingEngine().Map(["MySku"], Definition, saved);
|
||||
Assert.Equal("itemCode", mapping[0].TargetField);
|
||||
Assert.Equal("saved", mapping[0].Source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.IO.Compression;
|
||||
using Xunit;
|
||||
|
||||
namespace Shared.Excel.Tests;
|
||||
|
||||
public sealed class XlsxSafetyInspectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rejects_zip_without_xlsx_structure()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true))
|
||||
{
|
||||
var entry = zip.CreateEntry("hello.txt");
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write("not an xlsx");
|
||||
}
|
||||
Assert.Throws<InvalidDataException>(() => new XlsxSafetyInspector().EnsureSafe(stream.ToArray()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace Shared.Excel;
|
||||
|
||||
public sealed class XlsxSafetyInspector
|
||||
{
|
||||
public const long DefaultMaxUncompressedBytes = 250L * 1024 * 1024;
|
||||
public const int DefaultMaxEntries = 5_000;
|
||||
|
||||
public void EnsureSafe(byte[] bytes, long maxUncompressedBytes = DefaultMaxUncompressedBytes, int maxEntries = DefaultMaxEntries)
|
||||
{
|
||||
using var stream = new MemoryStream(bytes, writable: false);
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
|
||||
if (archive.Entries.Count == 0 || archive.Entries.Count > maxEntries)
|
||||
throw new InvalidDataException("Excel 파일 내부 구조가 비정상적입니다.");
|
||||
|
||||
if (!archive.Entries.Any(x => x.FullName.Equals("[Content_Types].xml", StringComparison.OrdinalIgnoreCase)) ||
|
||||
!archive.Entries.Any(x => x.FullName.Equals("xl/workbook.xml", StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidDataException("유효한 .xlsx 통합문서가 아닙니다.");
|
||||
|
||||
long total = 0;
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
total = checked(total + entry.Length);
|
||||
if (total > maxUncompressedBytes)
|
||||
throw new InvalidDataException("압축 해제된 Excel 데이터가 허용 크기를 초과합니다.");
|
||||
|
||||
// A very small compressed entry expanding to an extreme size is suspicious.
|
||||
if (entry.CompressedLength > 0 && entry.Length > 10L * 1024 * 1024 && entry.Length / entry.CompressedLength > 200)
|
||||
throw new InvalidDataException("비정상적인 압축률의 Excel 항목이 감지되었습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace KBX.Shared.Experience;
|
||||
|
||||
public interface IKbxAiAssistantProvider
|
||||
{
|
||||
Task<AiAnswer> AskAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
AiAskCommand command,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class KbxNoopAiAssistantProvider : IKbxAiAssistantProvider
|
||||
{
|
||||
public Task<AiAnswer> AskAsync(Guid tenantId, Guid userId, AiAskCommand command, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new AiAnswer(
|
||||
"AI 공급자가 아직 구성되지 않았습니다. 현재 업무는 기존 조회·도움말·표준 기능으로 계속 처리할 수 있습니다."));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace KBX.Shared.Experience;
|
||||
|
||||
public sealed record SuggestionContext(
|
||||
string ScreenId,
|
||||
string ScreenVersion,
|
||||
string Route,
|
||||
string AppVersion,
|
||||
string UserRole,
|
||||
IReadOnlyList<string>? ActiveFilters,
|
||||
string? GridLayoutVersion);
|
||||
|
||||
public sealed record SubmitSuggestionCommand(
|
||||
string Category,
|
||||
string Message,
|
||||
bool IncludeScreenContext,
|
||||
SuggestionContext Context);
|
||||
|
||||
public sealed record AiScreenContext(
|
||||
string ScreenId,
|
||||
string ScreenVersion,
|
||||
Guid? EntityId,
|
||||
IReadOnlyList<Guid>? SelectedIds,
|
||||
IReadOnlyDictionary<string, object?>? Filters,
|
||||
IReadOnlyList<string> AllowedCapabilities);
|
||||
|
||||
public sealed record AiAskCommand(string Question, AiScreenContext Context);
|
||||
public sealed record AiEvidence(string Label, string SourceType, string? Reference);
|
||||
public sealed record AiAction(string Id, string Label, string Kind);
|
||||
public sealed record AiAnswer(
|
||||
string Answer,
|
||||
IReadOnlyList<AiAction>? Actions = null,
|
||||
IReadOnlyList<AiEvidence>? Evidence = null,
|
||||
object? Proposal = null);
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace KBX.Shared.Experience;
|
||||
|
||||
public static class KbxExperienceRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxExperience(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IKbxAiAssistantProvider, KbxNoopAiAssistantProvider>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// generated from contracts/experiments/kbx.experiments.json; do not edit.
|
||||
namespace Kbx.Shared.Experiments.Generated;
|
||||
|
||||
public sealed record KbxFeatureFlagDefinition(string Id,string ScreenId,string Surface,string DefaultVariant,bool KillSwitch);
|
||||
public sealed record KbxVariantWeight(string Key,int Weight);
|
||||
public sealed record KbxExperimentDefinition(string Id,string FlagId,string ScreenId,string State,int RolloutPercent,int MinExposurePerVariant,IReadOnlyList<KbxVariantWeight> Variants,string PrimaryMetric,string PrimaryDirection,double MinimumImprovementPercent);
|
||||
public static class KbxExperimentCatalog
|
||||
{
|
||||
public const string SourceSha256 = "2510a2927bab297858e09e75c8af9bb766d91b92cc0daf99433f6377d399fbf9";
|
||||
public static readonly IReadOnlyDictionary<string,KbxFeatureFlagDefinition> FeatureFlags = new Dictionary<string,KbxFeatureFlagDefinition>(StringComparer.Ordinal)
|
||||
{
|
||||
["oms.order-list.exception-summary-v2"] = new("oms.order-list.exception-summary-v2", "OMS-ORD-001", "information-emphasis", "control", true)
|
||||
};
|
||||
public static readonly IReadOnlyDictionary<string,KbxExperimentDefinition> Experiments = new Dictionary<string,KbxExperimentDefinition>(StringComparer.Ordinal)
|
||||
{
|
||||
["exp.oms.order-list.exception-summary-v2"] = new("exp.oms.order-list.exception-summary-v2", "oms.order-list.exception-summary-v2", "OMS-ORD-001", "draft", 0, 200, new[] { new KbxVariantWeight("control", 50), new KbxVariantWeight("exception-summary", 50) }, "semantic_interactions_per_task", "lower", 10)
|
||||
};
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Kbx.Shared.Experiments.Generated;
|
||||
namespace Kbx.Shared.Experiments;
|
||||
public static class KbxExperimentAssignmentPolicy
|
||||
{
|
||||
public static bool IsEnrolled(Guid tenantId,Guid userId,string experimentId,int rolloutPercent) =>
|
||||
rolloutPercent>0 && Bucket(tenantId,userId,experimentId,"enroll") < rolloutPercent*100;
|
||||
public static string ChooseVariant(Guid tenantId,Guid userId,KbxExperimentDefinition definition)
|
||||
{
|
||||
var point=Bucket(tenantId,userId,definition.Id,"variant")%100,cumulative=0;
|
||||
foreach(var item in definition.Variants){cumulative+=item.Weight;if(point<cumulative)return item.Key;}
|
||||
return "control";
|
||||
}
|
||||
private static int Bucket(Guid tenantId,Guid userId,string experimentId,string purpose)
|
||||
{
|
||||
var bytes=SHA256.HashData(Encoding.UTF8.GetBytes($"{tenantId:N}|{userId:N}|{experimentId}|{purpose}|kbx-v1"));
|
||||
return (int)(BitConverter.ToUInt32(bytes,0)%10000);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using Dapper;
|
||||
using Kbx.Shared.Experiments.Generated;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Experiments;
|
||||
|
||||
public sealed class KbxExperimentAssignmentService(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<KbxExperimentAssignmentsResponse> EvaluateAsync(Guid tenantId,Guid userId,string screenId,CancellationToken ct)
|
||||
{
|
||||
var definitions=KbxExperimentCatalog.Experiments.Values.Where(x=>x.ScreenId==screenId).ToArray();
|
||||
var items=new List<KbxExperimentAssignmentDto>();
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
foreach(var definition in definitions)
|
||||
{
|
||||
var runtime=await connection.QuerySingleOrDefaultAsync<RuntimeRow>(new CommandDefinition("""
|
||||
select state State,rollout_percent RolloutPercent,kill_switch KillSwitch
|
||||
from kbx.experiment_runtime where tenant_id=@TenantId and experiment_id=@ExperimentId;
|
||||
""",new{TenantId=tenantId,ExperimentId=definition.Id},cancellationToken:ct));
|
||||
var state=runtime?.State ?? definition.State;
|
||||
var rollout=runtime?.RolloutPercent ?? definition.RolloutPercent;
|
||||
var killed=runtime?.KillSwitch ?? false;
|
||||
// Regular users only receive actively enrolled assignments. Draft/paused/rolled-back definitions stay internal.
|
||||
if(killed||state!="running"||rollout<=0)continue;
|
||||
// Rollout percentage remains authoritative even for previously assigned users.
|
||||
// Assignment is preserved for reproducibility and becomes active again if rollout is raised later.
|
||||
if(!KbxExperimentAssignmentPolicy.IsEnrolled(tenantId,userId,definition.Id,rollout))continue;
|
||||
var existing=await connection.QuerySingleOrDefaultAsync<string>(new CommandDefinition("""
|
||||
select variant from kbx.experiment_assignments where tenant_id=@TenantId and experiment_id=@ExperimentId and user_id=@UserId;
|
||||
""",new{TenantId=tenantId,ExperimentId=definition.Id,UserId=userId},cancellationToken:ct));
|
||||
if(existing is not null){items.Add(new(definition.Id,definition.FlagId,screenId,true,existing,state));continue;}
|
||||
var variant=KbxExperimentAssignmentPolicy.ChooseVariant(tenantId,userId,definition);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.experiment_assignments(tenant_id,experiment_id,user_id,variant)
|
||||
values(@TenantId,@ExperimentId,@UserId,@Variant) on conflict do nothing;
|
||||
""",new{TenantId=tenantId,ExperimentId=definition.Id,UserId=userId,Variant=variant},cancellationToken:ct));
|
||||
existing=await connection.QuerySingleAsync<string>(new CommandDefinition("""
|
||||
select variant from kbx.experiment_assignments where tenant_id=@TenantId and experiment_id=@ExperimentId and user_id=@UserId;
|
||||
""",new{TenantId=tenantId,ExperimentId=definition.Id,UserId=userId},cancellationToken:ct));
|
||||
items.Add(new(definition.Id,definition.FlagId,screenId,true,existing,state));
|
||||
}
|
||||
return new(items);
|
||||
}
|
||||
private sealed record RuntimeRow(string State,int RolloutPercent,bool KillSwitch);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Kbx.Shared.Experiments;
|
||||
public sealed record KbxExperimentAssignmentDto(string ExperimentId,string FlagId,string ScreenId,bool Enrolled,string Variant,string State);
|
||||
public sealed record KbxExperimentAssignmentsResponse(IReadOnlyList<KbxExperimentAssignmentDto> Assignments);
|
||||
public sealed record KbxExperimentVariantMetric(string Variant,long Exposures,long TaskCompletions,double? InteractionsPerTask,double? TaskCompletionP95Ms,double? ValidationFailureRate);
|
||||
public sealed record KbxExperimentOverviewRow(string ExperimentId,string ScreenId,string State,int RolloutPercent,bool KillSwitch,string Decision,IReadOnlyList<KbxExperimentVariantMetric> Variants,DateTimeOffset? UpdatedAt);
|
||||
public sealed record KbxExperimentOverviewResponse(IReadOnlyList<KbxExperimentOverviewRow> Items);
|
||||
public sealed record KbxExperimentRolloutRequest(int RolloutPercent,string State,string Reason);
|
||||
public sealed record KbxExperimentRollbackRequest(string Reason);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
namespace Kbx.Shared.Experiments;
|
||||
// Schedule hourly at most. This is an operational safety net, not a statistical significance engine.
|
||||
public sealed class KbxExperimentGuardrailJob(KbxExperimentOverviewQuery query,KbxExperimentRuntimeStore store)
|
||||
{
|
||||
public async Task ExecuteAsync(Guid tenantId,CancellationToken ct)
|
||||
{
|
||||
var overview=await query.ExecuteAsync(tenantId,ct);
|
||||
foreach(var item in overview.Items.Where(x=>x.State=="running"&&x.Decision=="guardrail-breach"))
|
||||
await store.RollbackAsync(tenantId,Guid.Empty,item.ExperimentId,"automatic guardrail rollback",null,ct);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using Dapper;
|
||||
using Kbx.Shared.Experiments.Generated;
|
||||
using Npgsql;
|
||||
namespace Kbx.Shared.Experiments;
|
||||
public sealed class KbxExperimentOverviewQuery(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<KbxExperimentOverviewResponse> ExecuteAsync(Guid tenantId,CancellationToken ct)
|
||||
{
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);var items=new List<KbxExperimentOverviewRow>();
|
||||
foreach(var def in KbxExperimentCatalog.Experiments.Values)
|
||||
{
|
||||
var runtime=await connection.QuerySingleOrDefaultAsync<RuntimeRow>(new CommandDefinition("select state State,rollout_percent RolloutPercent,kill_switch KillSwitch,updated_at UpdatedAt from kbx.experiment_runtime where tenant_id=@TenantId and experiment_id=@Id",new{TenantId=tenantId,Id=def.Id},cancellationToken:ct));
|
||||
var since=runtime?.UpdatedAt ?? DateTimeOffset.UtcNow.AddDays(-30);
|
||||
var metrics=(await connection.QueryAsync<MetricRow>(new CommandDefinition("""
|
||||
select experiment_variant Variant,
|
||||
count(*) filter(where event_name='experiment.exposed') Exposures,
|
||||
count(*) filter(where event_name='task.complete') TaskCompletions,
|
||||
count(*) filter(where event_name='interaction.execute') Interactions,
|
||||
count(*) filter(where event_name='command.execute') Commands,
|
||||
count(*) filter(where event_name='validation.failed') ValidationFailures,
|
||||
percentile_cont(0.95) within group(order by duration_ms) filter(where event_name='task.complete' and duration_ms is not null) TaskP95
|
||||
from kbx.ux_events where tenant_id=@TenantId and experiment_id=@Id and occurred_at>=@Since
|
||||
group by experiment_variant;
|
||||
""",new{TenantId=tenantId,Id=def.Id,Since=since},cancellationToken:ct))).Select(x=>new KbxExperimentVariantMetric(x.Variant,x.Exposures,x.TaskCompletions,x.TaskCompletions==0?null:Math.Round((double)x.Interactions/x.TaskCompletions,2),x.TaskP95,x.Commands==0?null:Math.Round((double)x.ValidationFailures*100d/x.Commands,2))).ToArray();
|
||||
var state=runtime?.State??def.State;var decision=Decide(def,state,runtime?.KillSwitch??false,metrics);
|
||||
items.Add(new(def.Id,def.ScreenId,state,runtime?.RolloutPercent??def.RolloutPercent,runtime?.KillSwitch??false,decision,metrics,runtime?.UpdatedAt));
|
||||
}
|
||||
return new(items);
|
||||
}
|
||||
private static string Decide(KbxExperimentDefinition def,string state,bool killed,IReadOnlyList<KbxExperimentVariantMetric> rows)
|
||||
{
|
||||
if(killed||state=="rolled-back")return "rolled-back";var control=rows.FirstOrDefault(x=>x.Variant=="control"),treatment=rows.FirstOrDefault(x=>x.Variant!="control");
|
||||
if(control is null||treatment is null||control.Exposures<def.MinExposurePerVariant||treatment.Exposures<def.MinExposurePerVariant)return "inconclusive";
|
||||
if(control.TaskCompletionP95Ms is >0 && treatment.TaskCompletionP95Ms>control.TaskCompletionP95Ms*1.20)return "guardrail-breach";
|
||||
if(control.ValidationFailureRate is not null&&treatment.ValidationFailureRate>control.ValidationFailureRate+2)return "guardrail-breach";
|
||||
if(control.InteractionsPerTask is >0&&treatment.InteractionsPerTask<=control.InteractionsPerTask*(1-def.MinimumImprovementPercent/100d))return "candidate";
|
||||
return "neutral";
|
||||
}
|
||||
private sealed record RuntimeRow(string State,int RolloutPercent,bool KillSwitch,DateTimeOffset UpdatedAt);
|
||||
private sealed record MetricRow(string Variant,long Exposures,long TaskCompletions,long Interactions,long Commands,long ValidationFailures,double? TaskP95);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Kbx.Shared.Experiments.Generated;
|
||||
using Kbx.Shared.Runtime;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
namespace Kbx.Shared.Experiments;
|
||||
public sealed record KbxExperimentChangedEvent(string ExperimentId,string ScreenId,DateTimeOffset ChangedAt);
|
||||
[Authorize]
|
||||
public sealed class KbxExperimentHub : Hub
|
||||
{
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var tenantId=RuntimeIdentity.TenantId(Context.User!);
|
||||
if(tenantId!=Guid.Empty)await Groups.AddToGroupAsync(Context.ConnectionId,$"experiment-tenant:{tenantId:N}");
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
}
|
||||
public sealed class KbxExperimentRolloutPublisher(IHubContext<KbxExperimentHub> hub)
|
||||
{
|
||||
public Task PublishAsync(Guid tenantId,string experimentId,CancellationToken ct)
|
||||
{
|
||||
var screenId=KbxExperimentCatalog.Experiments.TryGetValue(experimentId,out var definition)?definition.ScreenId:string.Empty;
|
||||
return hub.Clients.Group($"experiment-tenant:{tenantId:N}").SendAsync("ExperimentChanged",new KbxExperimentChangedEvent(experimentId,screenId,DateTimeOffset.UtcNow),ct);
|
||||
}
|
||||
}
|
||||
public static class KbxExperimentRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxExperiments(this IServiceCollection services)
|
||||
{
|
||||
services.AddSignalR();services.AddSingleton<KbxExperimentAssignmentService>();services.AddSingleton<KbxExperimentOverviewQuery>();services.AddSingleton<KbxExperimentRuntimeStore>();services.AddSingleton<KbxExperimentRolloutPublisher>();services.AddTransient<KbxExperimentGuardrailJob>();return services;
|
||||
}
|
||||
public static IEndpointRouteBuilder MapKbxExperiments(this IEndpointRouteBuilder endpoints){endpoints.MapHub<KbxExperimentHub>("/hubs/kbx-experiments");return endpoints;}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Kbx.Shared.Experiments.Generated;
|
||||
using Npgsql;
|
||||
namespace Kbx.Shared.Experiments;
|
||||
public sealed class KbxExperimentRuntimeStore(NpgsqlDataSource dataSource,KbxExperimentRolloutPublisher publisher)
|
||||
{
|
||||
public async Task UpdateRolloutAsync(Guid tenantId,Guid actorId,string experimentId,int percent,string state,string reason,string? correlationId,CancellationToken ct)
|
||||
{
|
||||
RequireKnownExperiment(experimentId);if(percent is <0 or >100)throw new ArgumentOutOfRangeException(nameof(percent));if(state is not ("running" or "paused"))throw new ArgumentException("state must be running or paused");if(string.IsNullOrWhiteSpace(reason))throw new ArgumentException("reason is required");
|
||||
await ChangeAsync(tenantId,actorId,experimentId,"rollout",reason,correlationId,ct,async(connection,tx,before)=>{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.experiment_runtime(tenant_id,experiment_id,state,rollout_percent,kill_switch,updated_by)
|
||||
values(@TenantId,@ExperimentId,@State,@Percent,false,@Actor)
|
||||
on conflict(tenant_id,experiment_id) do update set state=excluded.state,rollout_percent=excluded.rollout_percent,kill_switch=false,version=kbx.experiment_runtime.version+1,updated_by=excluded.updated_by,updated_at=now();
|
||||
""",new{TenantId=tenantId,ExperimentId=experimentId,State=state,Percent=percent,Actor=actorId},tx,cancellationToken:ct));
|
||||
});
|
||||
await publisher.PublishAsync(tenantId,experimentId,ct);
|
||||
}
|
||||
public async Task RollbackAsync(Guid tenantId,Guid actorId,string experimentId,string reason,string? correlationId,CancellationToken ct)
|
||||
{
|
||||
RequireKnownExperiment(experimentId);if(string.IsNullOrWhiteSpace(reason))throw new ArgumentException("reason is required");
|
||||
await ChangeAsync(tenantId,actorId,experimentId,"rollback",reason,correlationId,ct,async(connection,tx,before)=>{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.experiment_runtime(tenant_id,experiment_id,state,rollout_percent,kill_switch,updated_by)
|
||||
values(@TenantId,@ExperimentId,'rolled-back',0,true,@Actor)
|
||||
on conflict(tenant_id,experiment_id) do update set state='rolled-back',rollout_percent=0,kill_switch=true,version=kbx.experiment_runtime.version+1,updated_by=excluded.updated_by,updated_at=now();
|
||||
""",new{TenantId=tenantId,ExperimentId=experimentId,Actor=actorId},tx,cancellationToken:ct));
|
||||
});
|
||||
await publisher.PublishAsync(tenantId,experimentId,ct);
|
||||
}
|
||||
private static void RequireKnownExperiment(string experimentId)
|
||||
{
|
||||
if(!KbxExperimentCatalog.Experiments.ContainsKey(experimentId))throw new ArgumentException($"Unknown experiment: {experimentId}");
|
||||
}
|
||||
private async Task ChangeAsync(Guid tenantId,Guid actorId,string experimentId,string action,string reason,string? correlationId,CancellationToken ct,Func<NpgsqlConnection,NpgsqlTransaction,string?,Task> mutate)
|
||||
{
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);await using var tx=await connection.BeginTransactionAsync(ct);
|
||||
var before=await connection.QuerySingleOrDefaultAsync<string>(new CommandDefinition("select row_to_json(x)::text from (select state,rollout_percent,kill_switch,version from kbx.experiment_runtime where tenant_id=@TenantId and experiment_id=@ExperimentId for update) x",new{TenantId=tenantId,ExperimentId=experimentId},tx,cancellationToken:ct));
|
||||
await mutate(connection,tx,before);
|
||||
var after=await connection.QuerySingleOrDefaultAsync<string>(new CommandDefinition("select row_to_json(x)::text from (select state,rollout_percent,kill_switch,version from kbx.experiment_runtime where tenant_id=@TenantId and experiment_id=@ExperimentId) x",new{TenantId=tenantId,ExperimentId=experimentId},tx,cancellationToken:ct));
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.experiment_audit(id,tenant_id,experiment_id,action,before_state,after_state,actor_id,reason,correlation_id)
|
||||
values(@Id,@TenantId,@ExperimentId,@Action,cast(@Before as jsonb),cast(@After as jsonb),@Actor,@Reason,@CorrelationId);
|
||||
""",new{Id=Guid.NewGuid(),TenantId=tenantId,ExperimentId=experimentId,Action=action,Before=before,After=after,Actor=actorId,Reason=reason,CorrelationId=correlationId},tx,cancellationToken:ct));
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
using Kbx.Shared.Experiments.Generated;using Xunit;
|
||||
namespace Kbx.Shared.Experiments.Tests;
|
||||
public sealed class KbxExperimentContractTests{[Fact]public void ReferenceExperiment_IsDisabledByDefault(){var x=KbxExperimentCatalog.Experiments["exp.oms.order-list.exception-summary-v2"];Assert.Equal("draft",x.State);Assert.Equal(0,x.RolloutPercent);Assert.Equal(100,x.Variants.Sum(v=>v.Weight));Assert.Contains(x.Variants,v=>v.Key=="control");}}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// generated from contracts/external-data/kbx.external-data.json; do not edit.
|
||||
namespace Kbx.Shared.ExternalData.Generated;
|
||||
public sealed record KbxExternalDataDatasetDefinition(string Id,string ProviderId,string ProviderOperationId,string CanonicalType,string Normalizer,string NormalizerVersion,string FreshnessMode,int? FreshForSeconds,int? MaxStaleSeconds,bool BackgroundRefresh,string RawRetention,int NormalizedRetentionDays,string SourceLabel);
|
||||
public static class KbxExternalDataCatalog { public const string SourceSha256="da54ff739c6dc5ed6f9390d1fc8a8e138060d7cf19531a80cc91e421d62954a4"; public static readonly IReadOnlyDictionary<string,KbxExternalDataDatasetDefinition> All=new Dictionary<string,KbxExternalDataDatasetDefinition>(StringComparer.Ordinal) {
|
||||
["dataset.opendart.company-profile"] = new("dataset.opendart.company-profile", "provider.opendart", "opendart.company", "company-profile", "OpenDartCompanyProfileNormalizer", "1.0.0", "stale-while-revalidate", 86400, 604800, true, "hash-only", 365, "OPENDART"),
|
||||
["dataset.opendart.disclosures"] = new("dataset.opendart.disclosures", "provider.opendart", "opendart.disclosures", "disclosure-list", "OpenDartDisclosureListNormalizer", "1.0.0", "stale-while-revalidate", 300, 3600, true, "hash-only", 90, "OPENDART"),
|
||||
["dataset.kis.domestic-stock.current-price"] = new("dataset.kis.domestic-stock.current-price", "provider.kis.market-data", "kis.domestic-stock.current-price", "market-price", "KisCurrentPriceNormalizer", "1.0.0", "strict", 3, 0, false, "hash-only", 7, "KIS"),
|
||||
["dataset.krx.approved-service"] = new("dataset.krx.approved-service", "provider.krx.openapi", "krx.approved-service.invoke", "provider-defined", "registered-per-approved-service", "provider-defined", "provider-defined", null, null, false, "hash-only", 30, "KRX")
|
||||
}; }
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using System.Text.Json;
|
||||
using Kbx.Shared.ExternalData.Generated;
|
||||
using Kbx.Shared.Providers;
|
||||
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
|
||||
public sealed record KbxNormalizedExternalData<T>(T? Data, DateTimeOffset? ProviderObservedAt = null);
|
||||
|
||||
public interface IKbxExternalDataRefreshScheduler
|
||||
{
|
||||
Task ScheduleEntryAsync(Guid tenantId, string datasetId, string cacheKey, CancellationToken cancellationToken);
|
||||
Task ScheduleDatasetAsync(Guid tenantId, string datasetId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class KbxExternalDataCacheCoordinator(KbxExternalDataRepository repository, IKbxExternalDataRefreshScheduler refreshScheduler)
|
||||
{
|
||||
public async Task<KbxExternalDataEnvelope<T>> GetOrFetchAsync<T>(
|
||||
Guid tenantId,
|
||||
string datasetId,
|
||||
IReadOnlyDictionary<string,string?> cacheKeyValues,
|
||||
Func<CancellationToken,Task<KbxProviderResult<byte[]>>> fetch,
|
||||
Func<ReadOnlyMemory<byte>,KbxNormalizedExternalData<T>> normalize,
|
||||
string correlationId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!KbxExternalDataCatalog.All.TryGetValue(datasetId, out var dataset)) throw new ArgumentOutOfRangeException(nameof(datasetId));
|
||||
if (dataset.FreshnessMode == "provider-defined" && dataset.FreshForSeconds is null)
|
||||
throw new InvalidOperationException($"Dataset '{datasetId}' requires an approved service-specific freshness policy before runtime use.");
|
||||
|
||||
var now=DateTimeOffset.UtcNow;
|
||||
var cacheKey=KbxExternalDataProvenanceFactory.CacheKey(cacheKeyValues);
|
||||
var requestDescriptor=JsonSerializer.SerializeToDocument(cacheKeyValues.OrderBy(x=>x.Key,StringComparer.Ordinal).ToDictionary(x=>x.Key,x=>x.Value));
|
||||
var cached=await repository.GetAsync(tenantId,datasetId,cacheKey,ct);
|
||||
if(cached is not null)
|
||||
{
|
||||
var state=KbxExternalDataFreshnessPolicy.Evaluate(dataset,now,cached.FreshUntil,cached.UsableUntil);
|
||||
if(state==KbxExternalDataState.Fresh)
|
||||
return FromCache<T>(dataset,cached,state,false);
|
||||
if(state==KbxExternalDataState.Stale && dataset.FreshnessMode=="stale-while-revalidate")
|
||||
{
|
||||
await refreshScheduler.ScheduleEntryAsync(tenantId,datasetId,cacheKey,ct);
|
||||
return FromCache<T>(dataset,cached,state,true,"공급자 데이터를 다시 확인하는 동안 마지막 정상값을 표시합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
var requestedAt=DateTimeOffset.UtcNow;
|
||||
var result=await fetch(ct);
|
||||
var receivedAt=DateTimeOffset.UtcNow;
|
||||
if(result.Kind==KbxProviderResultKind.NoData)
|
||||
{
|
||||
var empty=JsonDocument.Parse("null");
|
||||
var (freshUntil,usableUntil)=KbxExternalDataFreshnessPolicy.Window(dataset,receivedAt);
|
||||
var entry=new KbxExternalDataCacheEntry(tenantId,dataset.Id,dataset.ProviderId,cacheKey,requestDescriptor,empty,null,requestedAt,receivedAt,DateTimeOffset.UtcNow,freshUntil,usableUntil,KbxExternalDataProvenanceFactory.PayloadSha256(Array.Empty<byte>()),dataset.NormalizerVersion,"fresh",correlationId);
|
||||
await repository.UpsertAsync(entry,ct);
|
||||
return new(default,KbxExternalDataProvenanceFactory.Create(dataset,KbxExternalDataState.Fresh,requestedAt,receivedAt,entry.IngestedAt,null,freshUntil,usableUntil,entry.PayloadSha256,false));
|
||||
}
|
||||
if(result.Kind!=KbxProviderResultKind.Success || result.Value is null)
|
||||
throw new KbxExternalDataUnavailableException(dataset.Id,result.Code,result.Message);
|
||||
|
||||
var raw=result.Value;
|
||||
var normalized=normalize(raw);
|
||||
var ingestedAt=DateTimeOffset.UtcNow;
|
||||
var window=KbxExternalDataFreshnessPolicy.Window(dataset,receivedAt);
|
||||
var document=JsonSerializer.SerializeToDocument(normalized.Data);
|
||||
var payloadSha=KbxExternalDataProvenanceFactory.PayloadSha256(raw);
|
||||
var stored=new KbxExternalDataCacheEntry(tenantId,dataset.Id,dataset.ProviderId,cacheKey,requestDescriptor,document,normalized.ProviderObservedAt,requestedAt,receivedAt,ingestedAt,window.FreshUntil,window.UsableUntil,payloadSha,dataset.NormalizerVersion,"fresh",correlationId);
|
||||
await repository.UpsertAsync(stored,ct);
|
||||
return new(normalized.Data,KbxExternalDataProvenanceFactory.Create(dataset,KbxExternalDataState.Fresh,requestedAt,receivedAt,ingestedAt,normalized.ProviderObservedAt,window.FreshUntil,window.UsableUntil,payloadSha,false));
|
||||
}
|
||||
|
||||
private static KbxExternalDataEnvelope<T> FromCache<T>(KbxExternalDataDatasetDefinition dataset,KbxExternalDataCacheEntry entry,KbxExternalDataState state,bool refreshing,string? warning=null)
|
||||
{
|
||||
var data=entry.NormalizedData.RootElement.ValueKind==JsonValueKind.Null?default:entry.NormalizedData.RootElement.Deserialize<T>();
|
||||
return new(data,KbxExternalDataProvenanceFactory.Create(dataset,state,entry.RequestedAt,entry.ReceivedAt,entry.IngestedAt,entry.ProviderObservedAt,entry.FreshUntil,entry.UsableUntil,entry.PayloadSha256,true,refreshing,warning));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class KbxExternalDataUnavailableException(string datasetId,string? code,string? detail):Exception($"External dataset '{datasetId}' is unavailable. {code}: {detail}")
|
||||
{
|
||||
public string DatasetId { get; }=datasetId;
|
||||
public string? ProviderCode { get; }=code;
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
|
||||
public enum KbxExternalDataState { Fresh, Stale, Expired, Unavailable }
|
||||
public enum KbxExternalDataFreshnessMode { Strict, StaleWhileRevalidate, ProviderDefined }
|
||||
|
||||
public sealed record KbxExternalDataProvenance(
|
||||
string DatasetId,
|
||||
string ProviderId,
|
||||
string ProviderOperationId,
|
||||
string SourceLabel,
|
||||
KbxExternalDataState State,
|
||||
DateTimeOffset? ProviderObservedAt,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset ReceivedAt,
|
||||
DateTimeOffset IngestedAt,
|
||||
DateTimeOffset? FreshUntil,
|
||||
DateTimeOffset? UsableUntil,
|
||||
string PayloadSha256,
|
||||
string NormalizerVersion,
|
||||
bool CacheHit,
|
||||
bool RefreshInProgress = false,
|
||||
string? Warning = null);
|
||||
|
||||
public sealed record KbxExternalDataEnvelope<T>(T? Data, KbxExternalDataProvenance Provenance);
|
||||
|
||||
public sealed record KbxExternalDataCacheEntry(
|
||||
Guid TenantId,
|
||||
string DatasetId,
|
||||
string ProviderId,
|
||||
string CacheKey,
|
||||
JsonDocument RequestDescriptor,
|
||||
JsonDocument NormalizedData,
|
||||
DateTimeOffset? ProviderObservedAt,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset ReceivedAt,
|
||||
DateTimeOffset IngestedAt,
|
||||
DateTimeOffset? FreshUntil,
|
||||
DateTimeOffset? UsableUntil,
|
||||
string PayloadSha256,
|
||||
string NormalizerVersion,
|
||||
string State,
|
||||
string CorrelationId);
|
||||
|
||||
public sealed record KbxCompanyProfileSnapshot(string CorpCode, string CorpName, string? StockCode, string? BusinessNumber);
|
||||
public sealed record KbxDisclosureSummarySnapshot(string ReceiptNo, string CorpCode, string CorpName, string ReportName, string ReceiptDate);
|
||||
public sealed record KbxMarketPriceSnapshot(string MarketDivisionCode, string StockCode, decimal CurrentPrice);
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Kbx.Shared.ExternalData.Generated;
|
||||
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
|
||||
public static class KbxExternalDataFreshnessPolicy
|
||||
{
|
||||
public static KbxExternalDataState Evaluate(KbxExternalDataDatasetDefinition dataset, DateTimeOffset now, DateTimeOffset? freshUntil, DateTimeOffset? usableUntil)
|
||||
{
|
||||
if (freshUntil is null && dataset.FreshnessMode == "provider-defined") return KbxExternalDataState.Unavailable;
|
||||
if (freshUntil is not null && now <= freshUntil.Value) return KbxExternalDataState.Fresh;
|
||||
if (dataset.FreshnessMode == "strict") return KbxExternalDataState.Expired;
|
||||
if (usableUntil is not null && now <= usableUntil.Value) return KbxExternalDataState.Stale;
|
||||
return KbxExternalDataState.Expired;
|
||||
}
|
||||
|
||||
public static (DateTimeOffset? FreshUntil, DateTimeOffset? UsableUntil) Window(KbxExternalDataDatasetDefinition dataset, DateTimeOffset receivedAt)
|
||||
{
|
||||
if (dataset.FreshForSeconds is null) return (null, null);
|
||||
var fresh = receivedAt.AddSeconds(dataset.FreshForSeconds.Value);
|
||||
var usable = dataset.FreshnessMode == "stale-while-revalidate" && dataset.MaxStaleSeconds is > 0
|
||||
? fresh.AddSeconds(dataset.MaxStaleSeconds.Value)
|
||||
: fresh;
|
||||
return (fresh, usable);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Kbx.Shared.ExternalData.Generated;
|
||||
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
|
||||
public static class KbxExternalDataProvenanceFactory
|
||||
{
|
||||
public static string PayloadSha256(ReadOnlySpan<byte> payload) => Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
|
||||
public static string CacheKey(IEnumerable<KeyValuePair<string,string?>> values)
|
||||
{
|
||||
var canonical=string.Join("&",values.OrderBy(x=>x.Key,StringComparer.Ordinal).Select(x=>$"{x.Key}={x.Value?.Trim() ?? string.Empty}"));
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
|
||||
}
|
||||
public static KbxExternalDataProvenance Create(KbxExternalDataDatasetDefinition d,KbxExternalDataState state,DateTimeOffset requestedAt,DateTimeOffset receivedAt,DateTimeOffset ingestedAt,DateTimeOffset? observedAt,DateTimeOffset? freshUntil,DateTimeOffset? usableUntil,string payloadSha,bool cacheHit,bool refreshInProgress=false,string? warning=null)
|
||||
=> new(d.Id,d.ProviderId,d.ProviderOperationId,d.SourceLabel,state,observedAt,requestedAt,receivedAt,ingestedAt,freshUntil,usableUntil,payloadSha,d.NormalizerVersion,cacheHit,refreshInProgress,warning);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using Hangfire;
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
public sealed class KbxExternalDataRefreshScheduler(IBackgroundJobClient jobs):IKbxExternalDataRefreshScheduler
|
||||
{
|
||||
public Task ScheduleEntryAsync(Guid tenantId,string datasetId,string cacheKey,CancellationToken cancellationToken)
|
||||
{
|
||||
jobs.Enqueue<KbxExternalDataRefreshJob>(x=>x.RunEntryAsync(tenantId,datasetId,cacheKey,CancellationToken.None));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task ScheduleDatasetAsync(Guid tenantId,string datasetId,CancellationToken cancellationToken)
|
||||
{
|
||||
jobs.Enqueue<KbxExternalDataRefreshJob>(x=>x.RunDatasetAsync(tenantId,datasetId,CancellationToken.None));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
public sealed class KbxExternalDataRefreshJob(KbxExternalDataRefresherRegistry registry)
|
||||
{
|
||||
public Task RunEntryAsync(Guid tenantId,string datasetId,string cacheKey,CancellationToken ct)=>registry.GetRequired(datasetId).RefreshEntryAsync(tenantId,cacheKey,ct);
|
||||
public Task RunDatasetAsync(Guid tenantId,string datasetId,CancellationToken ct)=>registry.GetRequired(datasetId).RefreshKnownEntriesAsync(tenantId,ct);
|
||||
}
|
||||
public interface IKbxExternalDataDatasetRefresher
|
||||
{
|
||||
string DatasetId { get; }
|
||||
Task RefreshEntryAsync(Guid tenantId,string cacheKey,CancellationToken ct);
|
||||
Task RefreshKnownEntriesAsync(Guid tenantId,CancellationToken ct);
|
||||
}
|
||||
public sealed class KbxExternalDataRefresherRegistry(IEnumerable<IKbxExternalDataDatasetRefresher> refreshers)
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string,IKbxExternalDataDatasetRefresher> map=refreshers.ToDictionary(x=>x.DatasetId,StringComparer.Ordinal);
|
||||
public IKbxExternalDataDatasetRefresher GetRequired(string datasetId)=>map.TryGetValue(datasetId,out var r)?r:throw new InvalidOperationException($"No external-data refresher registered for {datasetId}.");
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
public static class KbxExternalDataRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxExternalData(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<KbxExternalDataRepository>();
|
||||
services.AddSingleton<IKbxExternalDataRefreshScheduler,KbxExternalDataRefreshScheduler>();
|
||||
services.AddSingleton<KbxExternalDataRefresherRegistry>();
|
||||
services.AddSingleton<KbxExternalDataCacheCoordinator>();
|
||||
services.AddSingleton<KrxApprovedServiceNormalizerRegistry>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
|
||||
public sealed class KbxExternalDataRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<KbxExternalDataCacheEntry?> GetAsync(Guid tenantId, string datasetId, string cacheKey, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select tenant_id as TenantId, dataset_id as DatasetId, provider_id as ProviderId, cache_key as CacheKey, request_descriptor::text as RequestDescriptorJson,
|
||||
normalized_data::text as NormalizedJson, provider_observed_at as ProviderObservedAt,
|
||||
requested_at as RequestedAt, received_at as ReceivedAt, ingested_at as IngestedAt,
|
||||
fresh_until as FreshUntil, usable_until as UsableUntil, payload_sha256 as PayloadSha256,
|
||||
normalizer_version as NormalizerVersion, state, correlation_id as CorrelationId
|
||||
from kbx.external_data_cache
|
||||
where tenant_id=@TenantId and dataset_id=@DatasetId and cache_key=@CacheKey
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<Row>(new CommandDefinition(sql, new { TenantId=tenantId, DatasetId=datasetId, CacheKey=cacheKey }, cancellationToken:ct));
|
||||
return row is null ? null : new KbxExternalDataCacheEntry(row.TenantId,row.DatasetId,row.ProviderId,row.CacheKey,JsonDocument.Parse(row.RequestDescriptorJson),JsonDocument.Parse(row.NormalizedJson),row.ProviderObservedAt,row.RequestedAt,row.ReceivedAt,row.IngestedAt,row.FreshUntil,row.UsableUntil,row.PayloadSha256,row.NormalizerVersion,row.State,row.CorrelationId);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(KbxExternalDataCacheEntry entry, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
insert into kbx.external_data_cache(tenant_id,dataset_id,provider_id,cache_key,request_descriptor,normalized_data,provider_observed_at,requested_at,received_at,ingested_at,fresh_until,usable_until,payload_sha256,normalizer_version,state,correlation_id)
|
||||
values(@TenantId,@DatasetId,@ProviderId,@CacheKey,cast(@RequestDescriptorJson as jsonb),cast(@NormalizedJson as jsonb),@ProviderObservedAt,@RequestedAt,@ReceivedAt,@IngestedAt,@FreshUntil,@UsableUntil,@PayloadSha256,@NormalizerVersion,@State,@CorrelationId)
|
||||
on conflict(tenant_id,dataset_id,cache_key) do update set
|
||||
provider_id=excluded.provider_id, request_descriptor=excluded.request_descriptor, normalized_data=excluded.normalized_data, provider_observed_at=excluded.provider_observed_at,
|
||||
requested_at=excluded.requested_at, received_at=excluded.received_at, ingested_at=excluded.ingested_at,
|
||||
fresh_until=excluded.fresh_until, usable_until=excluded.usable_until, payload_sha256=excluded.payload_sha256,
|
||||
normalizer_version=excluded.normalizer_version, state=excluded.state, correlation_id=excluded.correlation_id;
|
||||
insert into kbx.external_data_observations(tenant_id,dataset_id,provider_id,cache_key,payload_sha256,normalizer_version,provider_observed_at,received_at,ingested_at,state,correlation_id)
|
||||
values(@TenantId,@DatasetId,@ProviderId,@CacheKey,@PayloadSha256,@NormalizerVersion,@ProviderObservedAt,@ReceivedAt,@IngestedAt,@State,@CorrelationId);
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql,new { entry.TenantId,entry.DatasetId,entry.ProviderId,entry.CacheKey,RequestDescriptorJson=entry.RequestDescriptor.RootElement.GetRawText(),NormalizedJson=entry.NormalizedData.RootElement.GetRawText(),entry.ProviderObservedAt,entry.RequestedAt,entry.ReceivedAt,entry.IngestedAt,entry.FreshUntil,entry.UsableUntil,entry.PayloadSha256,entry.NormalizerVersion,entry.State,entry.CorrelationId },transaction,cancellationToken:ct));
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
private sealed record Row(Guid TenantId,string DatasetId,string ProviderId,string CacheKey,string RequestDescriptorJson,string NormalizedJson,DateTimeOffset? ProviderObservedAt,DateTimeOffset RequestedAt,DateTimeOffset ReceivedAt,DateTimeOffset IngestedAt,DateTimeOffset? FreshUntil,DateTimeOffset? UsableUntil,string PayloadSha256,string NormalizerVersion,string State,string CorrelationId);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Kbx.Shared.ExternalData.Generated;
|
||||
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
|
||||
public sealed class KbxExternalDataRetentionJob(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
foreach(var dataset in KbxExternalDataCatalog.All.Values)
|
||||
{
|
||||
var before=DateTimeOffset.UtcNow.AddDays(-dataset.NormalizedRetentionDays);
|
||||
const string sql="delete from kbx.external_data_observations where dataset_id=@DatasetId and ingested_at < @Before";
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql,new{DatasetId=dataset.Id,Before=before},cancellationToken:ct));
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
public static class KisCurrentPriceNormalizer
|
||||
{
|
||||
public static KbxMarketPriceSnapshot Normalize(ReadOnlySpan<byte> payload,string marketDivisionCode,string stockCode)
|
||||
{
|
||||
using var doc=JsonDocument.Parse(payload);
|
||||
if(!doc.RootElement.TryGetProperty("output",out var output) || !output.TryGetProperty("stck_prpr",out var priceValue) || !decimal.TryParse(priceValue.GetString(),NumberStyles.Number,CultureInfo.InvariantCulture,out var price))
|
||||
throw new InvalidDataException("KIS current price response does not contain a valid stck_prpr.");
|
||||
return new(marketDivisionCode,stockCode,price);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
public interface IKrxApprovedServiceNormalizer { string ServiceId { get; } string NormalizerVersion { get; } object Normalize(ReadOnlyMemory<byte> payload); }
|
||||
public sealed class KrxApprovedServiceNormalizerRegistry(IEnumerable<IKrxApprovedServiceNormalizer> normalizers)
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string,IKrxApprovedServiceNormalizer> map=normalizers.ToDictionary(x=>x.ServiceId,StringComparer.Ordinal);
|
||||
public IKrxApprovedServiceNormalizer GetRequired(string serviceId)=>map.TryGetValue(serviceId,out var n)?n:throw new InvalidOperationException($"No approved KRX normalizer is registered for service '{serviceId}'. Universal KRX field mapping is intentionally not guessed.");
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json;
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
public static class OpenDartCompanyProfileNormalizer
|
||||
{
|
||||
public static KbxCompanyProfileSnapshot Normalize(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
using var doc=JsonDocument.Parse(payload);
|
||||
var r=doc.RootElement;
|
||||
return new(
|
||||
Required(r,"corp_code"),
|
||||
Required(r,"corp_name"),
|
||||
Optional(r,"stock_code"),
|
||||
Optional(r,"bizr_no"));
|
||||
}
|
||||
private static string Required(JsonElement e,string name)=>e.TryGetProperty(name,out var p)&&!string.IsNullOrWhiteSpace(p.GetString())?p.GetString()!:throw new InvalidDataException($"OPENDART field missing: {name}");
|
||||
private static string? Optional(JsonElement e,string name)=>e.TryGetProperty(name,out var p)?p.GetString():null;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json;
|
||||
namespace Kbx.Shared.ExternalData;
|
||||
public static class OpenDartDisclosureListNormalizer
|
||||
{
|
||||
public static IReadOnlyList<KbxDisclosureSummarySnapshot> Normalize(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
using var doc=JsonDocument.Parse(payload);
|
||||
if(!doc.RootElement.TryGetProperty("list",out var list)||list.ValueKind!=JsonValueKind.Array)return Array.Empty<KbxDisclosureSummarySnapshot>();
|
||||
return list.EnumerateArray().Select(x=>new KbxDisclosureSummarySnapshot(Get(x,"rcept_no"),Get(x,"corp_code"),Get(x,"corp_name"),Get(x,"report_nm"),Get(x,"rcept_dt"))).ToArray();
|
||||
}
|
||||
private static string Get(JsonElement e,string n)=>e.TryGetProperty(n,out var p)?p.GetString()??string.Empty:string.Empty;
|
||||
}
|
||||
+13
@@ -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)
|
||||
};
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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}");
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
+18
@@ -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,
|
||||
};
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
+20
@@ -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: "업무 데이터는 유지됩니다. 확인 필요 항목에서 원인과 재처리 가능 여부를 확인하세요."),
|
||||
};
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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();
|
||||
}
|
||||
}
|
||||
+14
@@ -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));
|
||||
}
|
||||
}
|
||||
+12
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Shared.Operations;
|
||||
|
||||
public static class KbxOperationsRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxOperations(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<OperationsProjectionWriter>();
|
||||
services.AddSingleton<ReconcileProjectionWriter>();
|
||||
services.AddSingleton<WorkItemActionRegistry>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Shared.Operations;
|
||||
|
||||
public sealed record WorkItemProjection(
|
||||
Guid Id,
|
||||
string SourceModule,
|
||||
string SourceType,
|
||||
string SourceId,
|
||||
string ReferenceNo,
|
||||
string? SourceScreenId,
|
||||
string Code,
|
||||
string Title,
|
||||
string? Detail,
|
||||
string Severity,
|
||||
string Status,
|
||||
string? OwnerId,
|
||||
string? OwnerName,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset? DueAt,
|
||||
int AgeMinutes,
|
||||
long Version,
|
||||
string ContextJson,
|
||||
string? RetryActionKey,
|
||||
bool AllowManualResolution);
|
||||
|
||||
public sealed record WorkQueueCounter(string Key, string Label, int Count, string Severity);
|
||||
public sealed record WorkItemActionDto(string Id, string Label, string Kind, string? Permission = null, bool Danger = false);
|
||||
public sealed record WorkItemDto(
|
||||
Guid Id, string SourceModule, string SourceType, string SourceId, string ReferenceNo, string? SourceScreenId, string Code, string Title, string? Detail,
|
||||
string Severity, string Status, string? OwnerId, string? OwnerName, DateTimeOffset OccurredAt,
|
||||
DateTimeOffset? DueAt, int AgeMinutes, long Version, object Context, IReadOnlyList<WorkItemActionDto> Actions);
|
||||
public sealed record WorkQueueResponse(IReadOnlyList<WorkItemDto> Items, int TotalCount, IReadOnlyList<WorkQueueCounter> Counters);
|
||||
|
||||
public sealed record ReconcileItemDto(
|
||||
Guid Id, string ReconcileType, string ReferenceNo, string SourceLabel, string TargetLabel,
|
||||
string ExpectedValue, string ActualValue, string? DifferenceValue, string? ReasonCode, string? ReasonText,
|
||||
string Status, DateTimeOffset OccurredAt, string? SourceId, string? TargetId, long Version);
|
||||
public sealed record ReconcileSummary(int TotalCount, int MatchedCount, int MismatchCount, int PendingCount, int ResolvedCount);
|
||||
public sealed record ReconcileResponse(IReadOnlyList<ReconcileItemDto> Items, ReconcileSummary Summary);
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Shared.Operations;
|
||||
|
||||
public static class OperationsIdentity
|
||||
{
|
||||
public static string TenantId(ClaimsPrincipal user) =>
|
||||
user.FindFirst("tenant_id")?.Value ?? user.FindFirst("tenant")?.Value ?? "default";
|
||||
|
||||
public static string UserId(ClaimsPrincipal user) =>
|
||||
user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.Identity?.Name ?? "unknown";
|
||||
|
||||
public static string UserName(ClaimsPrincipal user) => user.Identity?.Name ?? UserId(user);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Shared.Operations;
|
||||
|
||||
public sealed record UpsertWorkItem(
|
||||
string TenantId,
|
||||
string SourceModule,
|
||||
string SourceType,
|
||||
string SourceId,
|
||||
string ReferenceNo,
|
||||
string? SourceScreenId,
|
||||
long? SourceVersion,
|
||||
string Code,
|
||||
string Title,
|
||||
string? Detail,
|
||||
string Severity,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset? DueAt = null,
|
||||
string? RetryActionKey = null,
|
||||
bool AllowManualResolution = false,
|
||||
object? Context = null);
|
||||
|
||||
public sealed class OperationsProjectionWriter(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<Guid> UpsertAsync(UpsertWorkItem item, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var id = Guid.NewGuid();
|
||||
const string sql = """
|
||||
insert into kbx.work_items(
|
||||
id, tenant_id, source_module, source_type, source_id, reference_no, source_screen_id, source_version,
|
||||
code, title, detail, severity, status, retry_action_key, allow_manual_resolution,
|
||||
context, occurred_at, due_at)
|
||||
values(
|
||||
@Id, @TenantId, @SourceModule, @SourceType, @SourceId, @ReferenceNo, @SourceScreenId, @SourceVersion,
|
||||
@Code, @Title, @Detail, @Severity, 'open', @RetryActionKey, @AllowManualResolution,
|
||||
cast(@Context as jsonb), @OccurredAt, @DueAt)
|
||||
on conflict(tenant_id, source_module, source_type, source_id, code)
|
||||
do update set
|
||||
source_version = excluded.source_version,
|
||||
reference_no = excluded.reference_no,
|
||||
source_screen_id = excluded.source_screen_id,
|
||||
title = excluded.title,
|
||||
detail = excluded.detail,
|
||||
severity = excluded.severity,
|
||||
status = case when kbx.work_items.status = 'resolved' then 'open' else kbx.work_items.status end,
|
||||
retry_action_key = excluded.retry_action_key,
|
||||
allow_manual_resolution = excluded.allow_manual_resolution,
|
||||
context = excluded.context,
|
||||
occurred_at = excluded.occurred_at,
|
||||
due_at = excluded.due_at,
|
||||
resolved_at = null,
|
||||
resolution_reason = null,
|
||||
version = kbx.work_items.version + 1,
|
||||
updated_at = now()
|
||||
where excluded.source_version is null
|
||||
or kbx.work_items.source_version is null
|
||||
or excluded.source_version > kbx.work_items.source_version
|
||||
returning id;
|
||||
""";
|
||||
var args = new {
|
||||
Id = id,
|
||||
item.TenantId, item.SourceModule, item.SourceType, item.SourceId, item.ReferenceNo, item.SourceScreenId, item.SourceVersion,
|
||||
item.Code, item.Title, item.Detail, item.Severity, item.RetryActionKey, item.AllowManualResolution,
|
||||
Context = JsonSerializer.Serialize(item.Context ?? new { }), item.OccurredAt, item.DueAt
|
||||
};
|
||||
var changedId = await connection.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql, args, cancellationToken: ct));
|
||||
if (changedId is not null) return changedId.Value;
|
||||
return await connection.ExecuteScalarAsync<Guid>(new CommandDefinition("""
|
||||
select id from kbx.work_items
|
||||
where tenant_id=@TenantId and source_module=@SourceModule and source_type=@SourceType
|
||||
and source_id=@SourceId and code=@Code;
|
||||
""", args, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task ResolveBySourceAsync(string tenantId, string sourceModule, string sourceType, string sourceId, string code, string reason, long? sourceVersion, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.work_items
|
||||
set status = 'resolved', resolved_at = now(), resolution_reason = @Reason,
|
||||
source_version = coalesce(@SourceVersion, source_version),
|
||||
version = version + 1, updated_at = now()
|
||||
where tenant_id = @TenantId and source_module = @SourceModule and source_type = @SourceType
|
||||
and source_id = @SourceId and code = @Code and status <> 'resolved'
|
||||
and (@SourceVersion is null or source_version is null or @SourceVersion >= source_version);
|
||||
""", new { TenantId = tenantId, SourceModule = sourceModule, SourceType = sourceType, SourceId = sourceId, Code = code, Reason = reason, SourceVersion = sourceVersion }, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Shared.Operations;
|
||||
|
||||
public static class ReconcilePolicy
|
||||
{
|
||||
public static string DecideStatus(
|
||||
bool valuesMatch,
|
||||
DateTimeOffset sourceChangedAt,
|
||||
DateTimeOffset observedAt,
|
||||
TimeSpan gracePeriod)
|
||||
{
|
||||
if (valuesMatch) return "matched";
|
||||
if (observedAt < sourceChangedAt) throw new ArgumentOutOfRangeException(nameof(observedAt));
|
||||
return observedAt - sourceChangedAt < gracePeriod ? "pending" : "mismatch";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Shared.Operations;
|
||||
|
||||
public sealed record UpsertReconcileItem(
|
||||
string TenantId,
|
||||
string ReconcileType,
|
||||
string ReferenceNo,
|
||||
string SourceLabel,
|
||||
string TargetLabel,
|
||||
string ExpectedValue,
|
||||
string ActualValue,
|
||||
string? DifferenceValue,
|
||||
string? ReasonCode,
|
||||
string? ReasonText,
|
||||
string Status,
|
||||
string? SourceId,
|
||||
string? TargetId,
|
||||
DateTimeOffset OccurredAt);
|
||||
|
||||
public sealed class ReconcileProjectionWriter(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<Guid> UpsertAsync(UpsertReconcileItem item, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var id = Guid.NewGuid();
|
||||
const string sql = """
|
||||
insert into kbx.reconcile_items(
|
||||
id, tenant_id, reconcile_type, reference_no, source_label, target_label,
|
||||
expected_value, actual_value, difference_value, reason_code, reason_text, status,
|
||||
source_id, target_id, identity_key, occurred_at)
|
||||
values(
|
||||
@Id, @TenantId, @ReconcileType, @ReferenceNo, @SourceLabel, @TargetLabel,
|
||||
@ExpectedValue, @ActualValue, @DifferenceValue, @ReasonCode, @ReasonText, @Status,
|
||||
@SourceId, @TargetId, @IdentityKey, @OccurredAt)
|
||||
on conflict(tenant_id, reconcile_type, reference_no, identity_key)
|
||||
do update set
|
||||
source_label=excluded.source_label, target_label=excluded.target_label,
|
||||
expected_value=excluded.expected_value, actual_value=excluded.actual_value,
|
||||
difference_value=excluded.difference_value, reason_code=excluded.reason_code,
|
||||
reason_text=excluded.reason_text, status=excluded.status,
|
||||
occurred_at=excluded.occurred_at, version=kbx.reconcile_items.version+1, updated_at=now()
|
||||
where excluded.occurred_at >= kbx.reconcile_items.occurred_at
|
||||
returning id;
|
||||
""";
|
||||
var args = new {
|
||||
Id = id,
|
||||
item.TenantId, item.ReconcileType, item.ReferenceNo, item.SourceLabel, item.TargetLabel,
|
||||
item.ExpectedValue, item.ActualValue, item.DifferenceValue, item.ReasonCode, item.ReasonText,
|
||||
item.Status, item.SourceId, item.TargetId, IdentityKey = $"{item.SourceId ?? "-"}|{item.TargetId ?? "-"}", item.OccurredAt,
|
||||
};
|
||||
var changedId = await connection.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql, args, cancellationToken: ct));
|
||||
if (changedId is not null) return changedId.Value;
|
||||
return await connection.ExecuteScalarAsync<Guid>(new CommandDefinition("""
|
||||
select id from kbx.reconcile_items
|
||||
where tenant_id=@TenantId and reconcile_type=@ReconcileType and reference_no=@ReferenceNo and identity_key=@IdentityKey;
|
||||
""", args, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Shared.Operations;
|
||||
using Xunit;
|
||||
|
||||
namespace Shared.Operations.Tests;
|
||||
|
||||
public sealed class ReconcilePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Equal_values_are_matched_immediately()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
Assert.Equal("matched", ReconcilePolicy.DecideStatus(true, now, now, TimeSpan.FromSeconds(30)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mismatch_inside_eventual_consistency_window_is_pending()
|
||||
{
|
||||
var changed = DateTimeOffset.UtcNow;
|
||||
Assert.Equal("pending", ReconcilePolicy.DecideStatus(false, changed, changed.AddSeconds(10), TimeSpan.FromSeconds(30)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mismatch_after_grace_period_is_actionable()
|
||||
{
|
||||
var changed = DateTimeOffset.UtcNow;
|
||||
Assert.Equal("mismatch", ReconcilePolicy.DecideStatus(false, changed, changed.AddMinutes(2), TimeSpan.FromSeconds(30)));
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Shared.Operations;
|
||||
using Xunit;
|
||||
|
||||
namespace Shared.Operations.Tests;
|
||||
|
||||
public sealed class WorkItemActionPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Domain_exception_is_not_manually_resolvable_by_default() =>
|
||||
Assert.False(WorkItemActionPolicy.CanManualResolve("open", false));
|
||||
|
||||
[Fact]
|
||||
public void Explicit_operational_item_can_be_manually_resolved() =>
|
||||
Assert.True(WorkItemActionPolicy.CanManualResolve("claimed", true));
|
||||
|
||||
[Fact]
|
||||
public void Retry_requires_registered_action_key() =>
|
||||
Assert.False(WorkItemActionPolicy.CanRetry("open", null));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Shared.Operations;
|
||||
|
||||
public static class WorkItemActionPolicy
|
||||
{
|
||||
public static bool CanClaim(string status) => status == "open";
|
||||
public static bool CanManualResolve(string status, bool allowManualResolution) =>
|
||||
allowManualResolution && status is "open" or "claimed";
|
||||
public static bool CanRetry(string status, string? retryActionKey) =>
|
||||
!string.IsNullOrWhiteSpace(retryActionKey) && status is "open" or "claimed";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Shared.Operations;
|
||||
|
||||
public interface IWorkItemActionHandler
|
||||
{
|
||||
string Key { get; }
|
||||
Task ExecuteAsync(string tenantId, string actorId, Guid workItemId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class WorkItemActionRegistry(IEnumerable<IWorkItemActionHandler> handlers)
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IWorkItemActionHandler> _handlers =
|
||||
handlers.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool TryGet(string key, out IWorkItemActionHandler? handler) => _handlers.TryGetValue(key, out handler);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace Shared.Problems;
|
||||
|
||||
public static class KbxFastEndpoints
|
||||
{
|
||||
public static void ConfigureErrors(Config config)
|
||||
{
|
||||
config.Errors.ProducesMetadataType = typeof(KbxValidationProblem);
|
||||
config.Errors.ResponseBuilder = (failures, _, _) =>
|
||||
KbxValidationProblem.Create(failures.Select(f => new KbxValidationError(
|
||||
Field: ToCamelCase(f.PropertyName),
|
||||
RowKey: null,
|
||||
Code: string.IsNullOrWhiteSpace(f.ErrorCode) ? "VALIDATION_ERROR" : f.ErrorCode,
|
||||
Message: f.ErrorMessage)).ToArray());
|
||||
}
|
||||
|
||||
private static string? ToCamelCase(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return value;
|
||||
return char.ToLowerInvariant(value[0]) + value[1..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace Shared.Problems;
|
||||
|
||||
public sealed record KbxProblemAction(string Id, string Label);
|
||||
|
||||
public sealed record KbxValidationError(
|
||||
string? Field,
|
||||
string? RowKey,
|
||||
string Code,
|
||||
string Message);
|
||||
|
||||
public sealed record KbxValidationProblem(
|
||||
string Type,
|
||||
string Title,
|
||||
IReadOnlyList<KbxValidationError> Errors,
|
||||
string? Detail = null,
|
||||
string? CorrelationId = null)
|
||||
{
|
||||
public static KbxValidationProblem Create(params KbxValidationError[] errors) =>
|
||||
new("validation", "입력값을 확인하세요.", errors);
|
||||
}
|
||||
|
||||
public sealed record KbxBusinessProblem(
|
||||
string Type,
|
||||
string Code,
|
||||
string Title,
|
||||
string? Detail = null,
|
||||
IReadOnlyList<KbxProblemAction>? Actions = null,
|
||||
string? CorrelationId = null)
|
||||
{
|
||||
public static KbxBusinessProblem Create(
|
||||
string code,
|
||||
string title,
|
||||
string? detail = null,
|
||||
IReadOnlyList<KbxProblemAction>? actions = null) =>
|
||||
new("business-rule", code, title, detail, actions);
|
||||
}
|
||||
|
||||
public sealed record KbxConflictProblem(
|
||||
string Type,
|
||||
string Code,
|
||||
string Title,
|
||||
long? CurrentVersion = null,
|
||||
string? Detail = null,
|
||||
string? CorrelationId = null)
|
||||
{
|
||||
public static KbxConflictProblem Version(long? currentVersion = null) =>
|
||||
new("conflict", "VERSION_CONFLICT", "다른 사용자가 데이터를 변경했습니다.", currentVersion);
|
||||
}
|
||||
|
||||
public sealed record KbxPermissionProblem(
|
||||
string Type,
|
||||
string Code,
|
||||
string Title,
|
||||
string? Detail = null,
|
||||
string? CorrelationId = null)
|
||||
{
|
||||
public static KbxPermissionProblem Denied(string? detail = null) =>
|
||||
new("permission", "PERMISSION_DENIED", "이 작업을 수행할 권한이 없습니다.", detail);
|
||||
}
|
||||
|
||||
public sealed record KbxNotFoundProblem(
|
||||
string Type,
|
||||
string Code,
|
||||
string Title,
|
||||
string? Detail = null,
|
||||
string? CorrelationId = null)
|
||||
{
|
||||
public static KbxNotFoundProblem Create(string code, string title, string? detail = null) =>
|
||||
new("not-found", code, title, detail);
|
||||
}
|
||||
|
||||
public sealed record KbxIntegrationProblem(
|
||||
string Type,
|
||||
string Code,
|
||||
string Title,
|
||||
bool Retryable,
|
||||
string? Detail = null,
|
||||
string? CorrelationId = null)
|
||||
{
|
||||
public static KbxIntegrationProblem Create(string code, string title, bool retryable, string? detail = null) =>
|
||||
new("integration", code, title, retryable, detail);
|
||||
}
|
||||
|
||||
public sealed record KbxSystemProblem(
|
||||
string Type,
|
||||
string Code,
|
||||
string Title,
|
||||
string CorrelationId,
|
||||
string? Detail = null,
|
||||
bool Retryable = false)
|
||||
{
|
||||
public static KbxSystemProblem Unexpected(string correlationId, string? detail = null) =>
|
||||
new("system", "UNEXPECTED_ERROR", "요청을 처리하지 못했습니다.", correlationId, detail);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
// generated from contracts/providers/kbx.providers.json; do not edit.
|
||||
namespace Kbx.Shared.Providers.Generated;
|
||||
public sealed record KbxExternalProviderDefinition(string Id,string Title,string OwnerModule,string Purpose,bool MutationAllowed,IReadOnlyList<string> OfficialSources);
|
||||
public static class KbxExternalProviderCatalog { public const string SourceSha256="5515e5e002767ed895ea0442003829b0bad8fa9c7a0693ce6cdcbd65969b830f"; public static readonly IReadOnlyDictionary<string,KbxExternalProviderDefinition> All=new Dictionary<string,KbxExternalProviderDefinition>(StringComparer.Ordinal) {
|
||||
["provider.krx.openapi"] = new("provider.krx.openapi", "KRX Data Marketplace OPEN API", "COMMON", "market-data-readonly", false, new[] { "https://openapi.krx.co.kr/contents/OPP/INFO/OPPINFO003.jsp", "https://openapi.krx.co.kr/contents/OPP/INFO/service/OPPINFO004.cmd" }),
|
||||
["provider.opendart"] = new("provider.opendart", "금융감독원 OPENDART OpenAPI", "COMMON", "disclosure-data-readonly", false, new[] { "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019001", "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019002", "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019018" }),
|
||||
["provider.kis.market-data"] = new("provider.kis.market-data", "한국투자증권 KIS Open API — 국내주식 시세", "COMMON", "market-data-readonly", false, new[] { "https://apiportal.koreainvestment.com/apiservice-apiservice", "https://apiportal.koreainvestment.com/community/10000000-0000-0011-0000-000000000001/post/d0d1a83f-6f8d-4437-9700-6d26702fd989", "https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/domestic_stock/inquire_price/inquire_price.py", "https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/kis_auth.py" })
|
||||
}; }
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public enum KbxProviderResultKind { Success, NoData, TransientFailure, PermanentFailure }
|
||||
|
||||
public sealed record KbxProviderResult<T>(KbxProviderResultKind Kind,T? Value=null,string? Code=null,string? Message=null,int? HttpStatus=null)
|
||||
{
|
||||
public bool IsSuccess => Kind is KbxProviderResultKind.Success or KbxProviderResultKind.NoData;
|
||||
}
|
||||
|
||||
public interface IKbxProviderSecretStore
|
||||
{
|
||||
ValueTask<string> GetRequiredAsync(string configurationKey, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IKbxExternalProviderAdapter
|
||||
{
|
||||
string ProviderId { get; }
|
||||
}
|
||||
|
||||
public sealed record KrxApprovedServiceRequest(HttpMethod Method, Uri ServiceUri, IReadOnlyDictionary<string,string?> Query, string? Body = null);
|
||||
public sealed record KisCurrentPriceRequest(string MarketDivisionCode,string StockCode,bool Sandbox=false);
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public static class KbxProviderRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxExternalProviders(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<KisRequestPacer>();
|
||||
services.AddSingleton<IKisAccessTokenProvider, KisAccessTokenProvider>();
|
||||
services.AddHttpClient<KrxOpenApiAdapter>();
|
||||
services.AddHttpClient<OpenDartAdapter>();
|
||||
services.AddHttpClient<KisAccessTokenProvider>();
|
||||
services.AddHttpClient<KisMarketDataAdapter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public interface IKisAccessTokenProvider { ValueTask<string> GetAsync(bool sandbox, CancellationToken cancellationToken); }
|
||||
|
||||
public sealed class KisAccessTokenProvider(HttpClient http, IKbxProviderSecretStore secrets) : IKisAccessTokenProvider
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1,1);
|
||||
private string? _token;
|
||||
private DateTimeOffset _expiresAt;
|
||||
private bool _sandbox;
|
||||
|
||||
public async ValueTask<string> GetAsync(bool sandbox, CancellationToken ct)
|
||||
{
|
||||
if (_token is not null && _sandbox == sandbox && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-5)) return _token;
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_token is not null && _sandbox == sandbox && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-5)) return _token;
|
||||
var appKey = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppKey", ct);
|
||||
var appSecret = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppSecret", ct);
|
||||
var baseUri = sandbox ? "https://openapivts.koreainvestment.com:29443" : "https://openapi.koreainvestment.com:9443";
|
||||
var payload = JsonSerializer.Serialize(new { grant_type="client_credentials", appkey=appKey, appsecret=appSecret });
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, baseUri + "/oauth2/tokenP") { Content = new StringContent(payload, Encoding.UTF8, "application/json") };
|
||||
using var res = await http.SendAsync(req, ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
_token = doc.RootElement.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("KIS access_token missing");
|
||||
_sandbox = sandbox;
|
||||
if (doc.RootElement.TryGetProperty("access_token_token_expired", out var exp) && DateTimeOffset.TryParse(exp.GetString(), out var parsed)) _expiresAt = parsed;
|
||||
else if (doc.RootElement.TryGetProperty("expires_in", out var sec) && sec.TryGetInt32(out var seconds)) _expiresAt = DateTimeOffset.UtcNow.AddSeconds(seconds);
|
||||
else _expiresAt = DateTimeOffset.UtcNow.AddHours(24);
|
||||
return _token;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public sealed class KisMarketDataAdapter(HttpClient http, IKbxProviderSecretStore secrets, IKisAccessTokenProvider tokens, KisRequestPacer pacer) : IKbxExternalProviderAdapter
|
||||
{
|
||||
public string ProviderId => "provider.kis.market-data";
|
||||
|
||||
public async Task<KbxProviderResult<JsonDocument>> GetDomesticStockCurrentPriceAsync(KisCurrentPriceRequest input, CancellationToken ct)
|
||||
{
|
||||
await pacer.WaitAsync(input.Sandbox, ct);
|
||||
var appKey = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppKey", ct);
|
||||
var appSecret = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppSecret", ct);
|
||||
var token = await tokens.GetAsync(input.Sandbox, ct);
|
||||
var baseUri = input.Sandbox ? "https://openapivts.koreainvestment.com:29443" : "https://openapi.koreainvestment.com:9443";
|
||||
var uri = baseUri + "/uapi/domestic-stock/v1/quotations/inquire-price" +
|
||||
$"?FID_COND_MRKT_DIV_CODE={Uri.EscapeDataString(input.MarketDivisionCode)}&FID_INPUT_ISCD={Uri.EscapeDataString(input.StockCode)}";
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
req.Headers.TryAddWithoutValidation("authorization", "Bearer " + token);
|
||||
req.Headers.TryAddWithoutValidation("appkey", appKey);
|
||||
req.Headers.TryAddWithoutValidation("appsecret", appSecret);
|
||||
req.Headers.TryAddWithoutValidation("tr_id", "FHKST01010100");
|
||||
req.Headers.TryAddWithoutValidation("custtype", "P");
|
||||
req.Headers.TryAddWithoutValidation("tr_cont", "");
|
||||
using var res = await http.SendAsync(req, ct);
|
||||
var text = await res.Content.ReadAsStringAsync(ct);
|
||||
if (!res.IsSuccessStatusCode)
|
||||
return new((int)res.StatusCode >= 500 || (int)res.StatusCode is 408 or 429 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)res.StatusCode}", Message:"KIS 시세 HTTP 호출 실패", HttpStatus:(int)res.StatusCode);
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(text); } catch { return new(KbxProviderResultKind.PermanentFailure, Code:"KIS_INVALID_JSON", Message:"KIS 응답 JSON을 해석할 수 없습니다."); }
|
||||
var root=doc.RootElement;
|
||||
var rt=root.TryGetProperty("rt_cd",out var r)?r.GetString():null;
|
||||
if(rt=="0") return new(KbxProviderResultKind.Success,doc);
|
||||
var code=root.TryGetProperty("msg_cd",out var c)?c.GetString():"KIS_PROVIDER_ERROR";
|
||||
var msg=root.TryGetProperty("msg1",out var m)?m.GetString():"KIS 시세 조회 실패";
|
||||
return new(KbxProviderResultKind.PermanentFailure,doc,code,msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public sealed class KisRequestPacer
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1,1);
|
||||
private DateTimeOffset _last = DateTimeOffset.MinValue;
|
||||
public async ValueTask WaitAsync(bool sandbox, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var spacing = TimeSpan.FromMilliseconds(sandbox ? 1000 : 100); // KBX conservative default; official production max is higher.
|
||||
var remaining = spacing - (DateTimeOffset.UtcNow - _last);
|
||||
if (remaining > TimeSpan.Zero) await Task.Delay(remaining, ct);
|
||||
_last = DateTimeOffset.UtcNow;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public sealed class KrxOpenApiAdapter(HttpClient http, IKbxProviderSecretStore secrets) : IKbxExternalProviderAdapter
|
||||
{
|
||||
public string ProviderId => "provider.krx.openapi";
|
||||
|
||||
public async Task<KbxProviderResult<string>> InvokeApprovedServiceAsync(KrxApprovedServiceRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!request.ServiceUri.IsAbsoluteUri || request.ServiceUri.Scheme != Uri.UriSchemeHttps || !request.ServiceUri.Host.EndsWith(".krx.co.kr", StringComparison.OrdinalIgnoreCase))
|
||||
return new(KbxProviderResultKind.PermanentFailure, Code:"KRX_SERVICE_URL_NOT_APPROVED", Message:"KRX 승인 서비스의 HTTPS URL만 사용할 수 있습니다.");
|
||||
if (request.Method != HttpMethod.Get && request.Method != HttpMethod.Post)
|
||||
return new(KbxProviderResultKind.PermanentFailure, Code:"KRX_METHOD_NOT_ALLOWED", Message:"조회성 GET/POST만 허용됩니다.");
|
||||
|
||||
var key = await secrets.GetRequiredAsync("ExternalProviders:Krx:AuthKey", ct);
|
||||
var uri = AppendQuery(request.ServiceUri, request.Query);
|
||||
using var message = new HttpRequestMessage(request.Method, uri);
|
||||
message.Headers.TryAddWithoutValidation("AUTH_KEY", key);
|
||||
if (request.Method == HttpMethod.Post && request.Body is not null)
|
||||
message.Content = new StringContent(request.Body, System.Text.Encoding.UTF8, "application/json");
|
||||
using var response = await http.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (response.IsSuccessStatusCode) return new(KbxProviderResultKind.Success, body, HttpStatus:(int)response.StatusCode);
|
||||
var transient = response.StatusCode is HttpStatusCode.RequestTimeout or (HttpStatusCode)429 || (int)response.StatusCode >= 500;
|
||||
return new(transient ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"KRX OPEN API 호출이 실패했습니다.", HttpStatus:(int)response.StatusCode);
|
||||
}
|
||||
|
||||
private static Uri AppendQuery(Uri uri, IReadOnlyDictionary<string,string?> query)
|
||||
{
|
||||
if (query.Count == 0) return uri;
|
||||
var parts = query.Where(x => x.Value is not null).Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}");
|
||||
var builder = new UriBuilder(uri) { Query = string.Join("&", parts) };
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public sealed class OpenDartAdapter(HttpClient http, IKbxProviderSecretStore secrets) : IKbxExternalProviderAdapter
|
||||
{
|
||||
private static readonly Uri BaseUri = new("https://opendart.fss.or.kr/api/");
|
||||
public string ProviderId => "provider.opendart";
|
||||
|
||||
public Task<KbxProviderResult<JsonDocument>> GetDisclosuresAsync(IReadOnlyDictionary<string,string?> query, CancellationToken ct)
|
||||
=> GetJsonAsync("list.json", query, ct);
|
||||
public Task<KbxProviderResult<JsonDocument>> GetCompanyAsync(string corpCode, CancellationToken ct)
|
||||
=> GetJsonAsync("company.json", new Dictionary<string,string?> { ["corp_code"] = corpCode }, ct);
|
||||
|
||||
public async Task<KbxProviderResult<byte[]>> DownloadCorpCodeAsync(CancellationToken ct)
|
||||
{
|
||||
var key = await secrets.GetRequiredAsync("ExternalProviders:OpenDart:ApiKey", ct);
|
||||
var uri = new Uri(BaseUri, $"corpCode.xml?crtfc_key={Uri.EscapeDataString(key)}");
|
||||
using var response = await http.GetAsync(uri, ct);
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(ct);
|
||||
if (response.IsSuccessStatusCode) return new(KbxProviderResultKind.Success, bytes, HttpStatus:(int)response.StatusCode);
|
||||
return new((int)response.StatusCode >= 500 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"OPENDART 고유번호 파일 호출 실패", HttpStatus:(int)response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<KbxProviderResult<JsonDocument>> GetJsonAsync(string path, IReadOnlyDictionary<string,string?> query, CancellationToken ct)
|
||||
{
|
||||
var key = await secrets.GetRequiredAsync("ExternalProviders:OpenDart:ApiKey", ct);
|
||||
var pairs = new List<string> { $"crtfc_key={Uri.EscapeDataString(key)}" };
|
||||
pairs.AddRange(query.Where(x=>x.Value is not null).Select(x=>$"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}"));
|
||||
var uri = new Uri(BaseUri, path + "?" + string.Join("&", pairs));
|
||||
using var response = await http.GetAsync(uri, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new((int)response.StatusCode >= 500 || (int)response.StatusCode == 429 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"OPENDART HTTP 호출 실패", HttpStatus:(int)response.StatusCode);
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(text); } catch { return new(KbxProviderResultKind.PermanentFailure, Code:"DART_INVALID_JSON", Message:"OPENDART 응답 JSON을 해석할 수 없습니다."); }
|
||||
var root = doc.RootElement;
|
||||
var status = root.TryGetProperty("status", out var s) ? s.GetString() : null;
|
||||
var message = root.TryGetProperty("message", out var m) ? m.GetString() : null;
|
||||
return status switch
|
||||
{
|
||||
"000" or null => new(KbxProviderResultKind.Success, doc),
|
||||
"013" => new(KbxProviderResultKind.NoData, doc, status, message),
|
||||
"020" or "800" or "900" => new(KbxProviderResultKind.TransientFailure, doc, status, message),
|
||||
_ => new(KbxProviderResultKind.PermanentFailure, doc, status, message)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public sealed class KbxCorrelationMiddleware(RequestDelegate next, ILogger<KbxCorrelationMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(correlationId)) correlationId = Guid.NewGuid().ToString("N");
|
||||
|
||||
context.TraceIdentifier = correlationId;
|
||||
context.Response.Headers["X-Correlation-Id"] = correlationId;
|
||||
Activity.Current?.SetTag("kbx.correlation.id", correlationId);
|
||||
|
||||
using (logger.BeginScope(new Dictionary<string, object?> { ["CorrelationId"] = correlationId }))
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public sealed class KbxNotificationRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<IReadOnlyList<KbxUserNotificationDto>> GetRecentAsync(Guid tenantId, Guid userId, int limit, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select id, severity, title, message, created_at as CreatedAt, read_at as ReadAt,
|
||||
screen_id as ScreenId, entity_type as EntityType, entity_id as EntityId,
|
||||
action::text as ActionJson
|
||||
from kbx.user_notifications
|
||||
where tenant_id=@tenantId and user_id=@userId
|
||||
and (expires_at is null or expires_at > now())
|
||||
order by created_at desc
|
||||
limit @limit;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<KbxUserNotificationDto>(new CommandDefinition(sql, new { tenantId, userId, limit = Math.Clamp(limit, 1, 100) }, cancellationToken: ct));
|
||||
return rows.AsList();
|
||||
}
|
||||
|
||||
public async Task MarkReadAsync(Guid tenantId, Guid userId, Guid id, CancellationToken ct)
|
||||
{
|
||||
const string sql = "update kbx.user_notifications set read_at=coalesce(read_at, now()) where tenant_id=@tenantId and user_id=@userId and id=@id";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new { tenantId, userId, id }, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public static class KbxOperationPolicy
|
||||
{
|
||||
public static bool IsTerminal(string status) => status is "completed" or "partially-completed" or "failed" or "cancelled";
|
||||
|
||||
public static bool CanRetryTransport(string httpMethod, bool hasIdempotencyKey)
|
||||
{
|
||||
if (httpMethod is "GET" or "HEAD") return true;
|
||||
return hasIdempotencyKey;
|
||||
}
|
||||
|
||||
public static bool CanStartCommit(string currentStatus) => currentStatus is "validated" or "retryable";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public sealed class KbxOperationRunRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<IReadOnlyList<KbxOperationRunDto>> GetRecentAsync(Guid tenantId, Guid userId, int limit, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select id, operation_type as Type, title, source_screen_id as SourceScreenId,
|
||||
status, requested_at as RequestedAt, started_at as StartedAt,
|
||||
completed_at as CompletedAt, processed, total, succeeded, failed,
|
||||
correlation_id as CorrelationId, result_message as ResultMessage
|
||||
from kbx.operation_runs
|
||||
where tenant_id=@tenantId and user_id=@userId
|
||||
order by requested_at desc
|
||||
limit @limit;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<KbxOperationRunDto>(new CommandDefinition(sql, new { tenantId, userId, limit = Math.Clamp(limit, 1, 50) }, cancellationToken: ct));
|
||||
return rows.AsList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public enum KbxRuntimeMode { Normal, Degraded, ReadOnly, Offline }
|
||||
public enum KbxOperationStatus { Queued, Running, Completed, PartiallyCompleted, Failed, Cancelled }
|
||||
|
||||
public sealed record KbxRequestContext(
|
||||
Guid? TenantId,
|
||||
Guid? UserId,
|
||||
string? ScreenId,
|
||||
string CorrelationId,
|
||||
string? RequestId);
|
||||
|
||||
public sealed record KbxRuntimeNotice(
|
||||
KbxRuntimeMode Mode,
|
||||
string Title,
|
||||
string? Message,
|
||||
DateTimeOffset? Since,
|
||||
string? CorrelationId,
|
||||
bool RetryAllowed);
|
||||
|
||||
public sealed record KbxOperationRunDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string? SourceScreenId,
|
||||
string Status,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
long Processed,
|
||||
long? Total,
|
||||
long Succeeded,
|
||||
long Failed,
|
||||
string? CorrelationId,
|
||||
string? ResultMessage);
|
||||
|
||||
public sealed record KbxUserNotificationDto(
|
||||
Guid Id,
|
||||
string Severity,
|
||||
string Title,
|
||||
string? Message,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ReadAt,
|
||||
string? ScreenId,
|
||||
string? EntityType,
|
||||
string? EntityId,
|
||||
string? ActionJson);
|
||||
@@ -0,0 +1,32 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public sealed class KbxRuntimeNoticeRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<KbxRuntimeNotice?> GetActiveAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
select mode as Mode, title, message, started_at as Since,
|
||||
correlation_id as CorrelationId
|
||||
from kbx.runtime_incidents
|
||||
where ended_at is null
|
||||
and (tenant_id is null or tenant_id=@tenantId)
|
||||
order by case mode when 'read-only' then 0 when 'offline' then 1 else 2 end,
|
||||
started_at desc
|
||||
limit 1;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<RuntimeNoticeRow>(new CommandDefinition(sql, new { tenantId }, cancellationToken: ct));
|
||||
if (row is null) return null;
|
||||
var mode = row.Mode switch {
|
||||
"read-only" => KbxRuntimeMode.ReadOnly,
|
||||
"offline" => KbxRuntimeMode.Offline,
|
||||
_ => KbxRuntimeMode.Degraded,
|
||||
};
|
||||
return new KbxRuntimeNotice(mode, row.Title, row.Message, row.Since, row.CorrelationId, mode is KbxRuntimeMode.Degraded or KbxRuntimeMode.Offline);
|
||||
}
|
||||
|
||||
private sealed record RuntimeNoticeRow(string Mode, string Title, string? Message, DateTimeOffset Since, string? CorrelationId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public static class KbxRuntimeRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxRuntime(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<KbxOperationRunRepository>();
|
||||
services.AddScoped<KbxNotificationRepository>();
|
||||
services.AddScoped<KbxRuntimeNoticeRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Kbx.Shared.Runtime;
|
||||
|
||||
public static class KbxTelemetry
|
||||
{
|
||||
public static readonly ActivitySource ActivitySource = new("KBX.BusinessRuntime");
|
||||
|
||||
public static Activity? StartOperation(
|
||||
string operationName,
|
||||
KbxRequestContext context,
|
||||
string? entityType = null,
|
||||
string? entityId = null)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity(operationName, ActivityKind.Internal);
|
||||
if (activity is null) return null;
|
||||
|
||||
activity.SetTag("kbx.screen.id", context.ScreenId);
|
||||
activity.SetTag("kbx.correlation.id", context.CorrelationId);
|
||||
activity.SetTag("kbx.tenant.id", context.TenantId?.ToString());
|
||||
activity.SetTag("kbx.entity.type", entityType);
|
||||
activity.SetTag("kbx.entity.id", entityId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static void LogFailure(
|
||||
ILogger logger,
|
||||
Exception exception,
|
||||
KbxRequestContext context,
|
||||
string operation)
|
||||
{
|
||||
logger.LogError(exception,
|
||||
"KBX operation failed. Operation={Operation} ScreenId={ScreenId} CorrelationId={CorrelationId}",
|
||||
operation, context.ScreenId, context.CorrelationId);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user