V13-FE-011: finalize search list layout slice

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