V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using KBX.Shared.Experience;
|
||||
using Npgsql;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace KBX.Modules.Common.Ai.Ask;
|
||||
|
||||
public sealed record Request(string Question, AiScreenContext Context);
|
||||
|
||||
public sealed class Endpoint(IKbxAiAssistantProvider assistant, NpgsqlDataSource dataSource) : Endpoint<Request, AiAnswer>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/common/ai/ask");
|
||||
Permissions("common.ai.use");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Question) || req.Question.Length > 2000)
|
||||
{
|
||||
AddError(r => r.Question, "질문을 1~2000자로 입력하세요.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Host application replaces this with authenticated claims resolution.
|
||||
var tenantId = Guid.Empty;
|
||||
var userId = Guid.Empty;
|
||||
var sw = Stopwatch.StartNew();
|
||||
var answer = await assistant.AskAsync(tenantId, userId, new AiAskCommand(req.Question.Trim(), req.Context), ct);
|
||||
sw.Stop();
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.ai_interactions
|
||||
(id, tenant_id, user_id, screen_id, screen_version, capability, result_kind, provider, duration_ms)
|
||||
values
|
||||
(@Id, @TenantId, @UserId, @ScreenId, @ScreenVersion, 'explain', @ResultKind, @Provider, @DurationMs)
|
||||
""", new
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
req.Context.ScreenId,
|
||||
req.Context.ScreenVersion,
|
||||
ResultKind = answer.Proposal is null ? "answer" : "proposal",
|
||||
Provider = assistant.GetType().Name,
|
||||
DurationMs = (int)sw.ElapsedMilliseconds,
|
||||
}, cancellationToken: ct));
|
||||
|
||||
await Send.OkAsync(answer, ct);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
|
||||
namespace Kbx.Modules.Common.Experiments.Assignments;
|
||||
public sealed record Request(string ScreenId);
|
||||
public sealed class Endpoint(KbxExperimentAssignmentService service):Endpoint<Request,KbxExperimentAssignmentsResponse>{public override void Configure(){Get("/api/kbx/experiments/assignments");Permissions("common.experiment.evaluate");}public override async Task HandleAsync(Request req,CancellationToken ct)=>await Send.OkAsync(await service.EvaluateAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ScreenId,ct),ct);}
|
||||
@@ -0,0 +1,3 @@
|
||||
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
|
||||
namespace Kbx.Modules.Common.Experiments.Overview;
|
||||
public sealed class Endpoint(KbxExperimentOverviewQuery query):EndpointWithoutRequest<KbxExperimentOverviewResponse>{public override void Configure(){Get("/api/kbx/experiments");Permissions("common.experiment.read");}public override async Task HandleAsync(CancellationToken ct)=>await Send.OkAsync(await query.ExecuteAsync(RuntimeIdentity.TenantId(User),ct),ct);}
|
||||
@@ -0,0 +1,4 @@
|
||||
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
|
||||
namespace Kbx.Modules.Common.Experiments.Rollback;
|
||||
public sealed record Request(string ExperimentId,string Reason);
|
||||
public sealed class Endpoint(KbxExperimentRuntimeStore store):Endpoint<Request>{public override void Configure(){Post("/api/kbx/experiments/{experimentId}/rollback");Permissions("common.experiment.manage");}public override async Task HandleAsync(Request req,CancellationToken ct){await store.RollbackAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ExperimentId,req.Reason,HttpContext.TraceIdentifier,ct);await Send.OkAsync(ct);}}
|
||||
@@ -0,0 +1,4 @@
|
||||
using FastEndpoints;using Kbx.Shared.Experiments;using Kbx.Shared.Runtime;
|
||||
namespace Kbx.Modules.Common.Experiments.Rollout;
|
||||
public sealed record Request(string ExperimentId,int RolloutPercent,string State,string Reason);
|
||||
public sealed class Endpoint(KbxExperimentRuntimeStore store):Endpoint<Request>{public override void Configure(){Post("/api/kbx/experiments/{experimentId}/rollout");Permissions("common.experiment.manage");}public override async Task HandleAsync(Request req,CancellationToken ct){await store.UpdateRolloutAsync(RuntimeIdentity.TenantId(User),RuntimeIdentity.UserId(User),req.ExperimentId,req.RolloutPercent,req.State,req.Reason,HttpContext.TraceIdentifier,ct);await Send.OkAsync(ct);}}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.ExternalData;
|
||||
using Kbx.Shared.ExternalData.Generated;
|
||||
using Kbx.Shared.Runtime;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Kbx.Modules.Common.ExternalData.Refresh;
|
||||
|
||||
public sealed record Response(string DatasetId,string Status);
|
||||
public sealed class Endpoint(IKbxExternalDataRefreshScheduler scheduler):EndpointWithoutRequest<Response>
|
||||
{
|
||||
public override void Configure(){Post("/api/kbx/external-data/{datasetId}/refresh");Permissions("common.external-data.refresh");}
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var datasetId=Route<string>("datasetId")!;
|
||||
if(!KbxExternalDataCatalog.All.TryGetValue(datasetId,out var dataset))
|
||||
{ await Send.ResponseAsync(KbxNotFoundProblem.Create("EXTERNAL_DATASET_NOT_FOUND","외부 데이터셋을 찾을 수 없습니다."),404,cancellation:ct); return; }
|
||||
if(dataset.FreshnessMode=="provider-defined" && dataset.FreshForSeconds is null)
|
||||
{ await Send.ResponseAsync(KbxBusinessProblem.Create("EXTERNAL_DATA_POLICY_REQUIRED","승인된 서비스별 신선도 정책이 먼저 필요합니다.","KRX 범용 데이터셋에는 임의 TTL을 적용하지 않습니다."),422,cancellation:ct); return; }
|
||||
await scheduler.ScheduleDatasetAsync(RuntimeIdentity.TenantId(User),datasetId,ct);
|
||||
await Send.ResponseAsync(new Response(datasetId,"queued"),202,cancellation:ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Kbx.Shared.ExternalData.Generated;
|
||||
using Kbx.Shared.Runtime;
|
||||
|
||||
namespace Kbx.Modules.Common.ExternalData.Status;
|
||||
|
||||
public sealed record Row(string DatasetId,string ProviderId,string SourceLabel,string State,long CacheEntries,DateTimeOffset? LastReceivedAt,DateTimeOffset? OldestFreshUntil,long StaleEntries,long UnavailableEntries);
|
||||
public sealed record Response(IReadOnlyList<Row> Items);
|
||||
file sealed record DbRow(string DatasetId,string ProviderId,string State,long CacheEntries,DateTimeOffset? LastReceivedAt,DateTimeOffset? OldestFreshUntil,long StaleEntries,long UnavailableEntries);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<Response>
|
||||
{
|
||||
public override void Configure(){Get("/api/kbx/external-data/status");Permissions("common.external-data.read");}
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
const string sql="""
|
||||
select dataset_id as DatasetId, provider_id as ProviderId,
|
||||
case
|
||||
when bool_or(fresh_until is not null and now() <= fresh_until) then 'fresh'
|
||||
when bool_or(usable_until is not null and now() <= usable_until) then 'stale'
|
||||
when bool_or(state='unavailable') then 'unavailable'
|
||||
else 'expired'
|
||||
end as State,
|
||||
count(*) as CacheEntries,
|
||||
max(received_at) as LastReceivedAt,
|
||||
min(fresh_until) as OldestFreshUntil,
|
||||
count(*) filter(where fresh_until is not null and now()>fresh_until and usable_until is not null and now()<=usable_until) as StaleEntries,
|
||||
count(*) filter(where state='unavailable') as UnavailableEntries
|
||||
from kbx.external_data_cache
|
||||
where tenant_id=@TenantId
|
||||
group by dataset_id,provider_id
|
||||
""";
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
var db=(await connection.QueryAsync<DbRow>(new CommandDefinition(sql,new{TenantId=RuntimeIdentity.TenantId(User)},cancellationToken:ct))).ToDictionary(x=>x.DatasetId,StringComparer.Ordinal);
|
||||
var rows=KbxExternalDataCatalog.All.Values.OrderBy(x=>x.Id,StringComparer.Ordinal).Select(dataset=>{
|
||||
if(db.TryGetValue(dataset.Id,out var current))return new Row(dataset.Id,dataset.ProviderId,dataset.SourceLabel,current.State,current.CacheEntries,current.LastReceivedAt,current.OldestFreshUntil,current.StaleEntries,current.UnavailableEntries);
|
||||
var state=dataset.FreshnessMode=="provider-defined" && dataset.FreshForSeconds is null ? "unavailable" : "expired";
|
||||
return new Row(dataset.Id,dataset.ProviderId,dataset.SourceLabel,state,0,null,null,0,state=="unavailable"?1:0);
|
||||
}).ToArray();
|
||||
await Send.OkAsync(new Response(rows),ct);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using FastEndpoints;
|
||||
using Hangfire;
|
||||
using Modules.Common.Imports.Jobs;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class CommitEndpoint(ImportRepository repository, IBackgroundJobClient jobs)
|
||||
: EndpointWithoutRequest<ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/imports/sessions/{sessionId:guid}/commit");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var tenant = ImportIdentity.TenantId(User);
|
||||
var actor = ImportIdentity.UserId(User);
|
||||
var session = await repository.GetAsync(id, tenant, actor, false, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
if (session.Status != ImportStatuses.Validated)
|
||||
{
|
||||
AddError("검증 완료된 Import만 반영할 수 있습니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
var acquired = await repository.TryTransitionAsync(id, [ImportStatuses.Validated], ImportStatuses.Committing, 1, ct);
|
||||
if (acquired)
|
||||
{
|
||||
try { jobs.Enqueue<CommitImportJob>(job => job.RunAsync(id, actor, CancellationToken.None)); }
|
||||
catch { await repository.SetStatusAsync(id, ImportStatuses.Validated, 100, ct); throw; }
|
||||
}
|
||||
var current = await repository.GetAsync(id, tenant, actor, false, ct);
|
||||
await Send.OkAsync(current!, ct);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class CreateSessionRequest
|
||||
{
|
||||
public string ImportType { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CreateSessionEndpoint(
|
||||
ImportDefinitionRegistry registry,
|
||||
ImportRepository repository,
|
||||
ClosedXmlWorkbookService workbook,
|
||||
ImportMappingEngine mappingEngine,
|
||||
IImportMappingSuggester mappingSuggester,
|
||||
XlsxSafetyInspector safety)
|
||||
: Endpoint<CreateSessionRequest, ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/imports/sessions");
|
||||
AllowFileUploads();
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!registry.TryGet(req.ImportType, out var importDefinition) || importDefinition is null)
|
||||
{
|
||||
await Send.NotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var file = Files.FirstOrDefault();
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
AddError("Excel 파일을 선택하세요.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var definition = importDefinition.Definition;
|
||||
if (file.Length > definition.MaxFileSizeBytes)
|
||||
{
|
||||
AddError($"파일 크기는 최대 {definition.MaxFileSizeBytes / 1024 / 1024}MB입니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
if (!Path.GetExtension(file.FileName).Equals(".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddError(".xlsx 파일만 업로드할 수 있습니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var source = file.OpenReadStream();
|
||||
using var buffer = new MemoryStream();
|
||||
await source.CopyToAsync(buffer, ct);
|
||||
var bytes = buffer.ToArray();
|
||||
try { safety.EnsureSafe(bytes); }
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
await using var read = new MemoryStream(bytes, writable: false);
|
||||
var columns = workbook.ReadHeaders(read);
|
||||
var tenantId = ImportIdentity.TenantId(User);
|
||||
var userId = ImportIdentity.UserId(User);
|
||||
var saved = await repository.GetSavedMappingAsync(tenantId, userId, definition.Id, columns, ct);
|
||||
var mapping = mappingEngine.Map(columns, definition, saved).ToList();
|
||||
var unresolved = mapping.Where(x => x.TargetField is null).Select(x => x.SourceColumn).ToArray();
|
||||
if (unresolved.Length > 0)
|
||||
{
|
||||
var suggestions = await mappingSuggester.SuggestAsync(unresolved, definition, ct);
|
||||
var allowed = definition.Fields.Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var suggestion in suggestions.Where(x => x.TargetField is not null && allowed.Contains(x.TargetField)))
|
||||
{
|
||||
var index = mapping.FindIndex(x => x.SourceColumn.Equals(suggestion.SourceColumn, StringComparison.OrdinalIgnoreCase) && x.TargetField is null);
|
||||
if (index >= 0) mapping[index] = suggestion with { Source = "ai" };
|
||||
}
|
||||
}
|
||||
var sessionId = await repository.CreateSessionAsync(
|
||||
tenantId, userId, definition, file.FileName,
|
||||
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
bytes, columns, mapping, ct);
|
||||
|
||||
var session = await repository.GetAsync(sessionId, tenantId, userId, includeErrors: false, ct);
|
||||
await Send.OkAsync(session!, ct);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class ErrorWorkbookEndpoint(ImportRepository repository, ClosedXmlWorkbookService workbook)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/imports/sessions/{sessionId:guid}/errors.xlsx");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var session = await repository.GetAsync(id, ImportIdentity.TenantId(User), ImportIdentity.UserId(User), false, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
var (_, bytes) = await repository.GetFileAsync(id, ct);
|
||||
await using var source = new MemoryStream(bytes, writable: false);
|
||||
var errors = await repository.GetErrorsAsync(id, ct);
|
||||
var output = workbook.CreateErrorWorkbook(source, errors);
|
||||
await Send.StreamAsync(output,
|
||||
fileName: $"{Path.GetFileNameWithoutExtension(session.FileName)}_errors.xlsx",
|
||||
fileLengthBytes: output.Length,
|
||||
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class GetSessionEndpoint(ImportRepository repository)
|
||||
: EndpointWithoutRequest<ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/imports/sessions/{sessionId:guid}");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var session = await repository.GetAsync(id, ImportIdentity.TenantId(User), ImportIdentity.UserId(User), includeErrors: true, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await Send.OkAsync(session, ct);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed record SaveMappingRequest(IReadOnlyList<ImportMapping> Mappings);
|
||||
|
||||
public sealed class SaveMappingEndpoint(ImportRepository repository)
|
||||
: Endpoint<SaveMappingRequest, ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/imports/sessions/{sessionId:guid}/mapping");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SaveMappingRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var tenant = ImportIdentity.TenantId(User);
|
||||
var user = ImportIdentity.UserId(User);
|
||||
if (await repository.GetAsync(id, tenant, user, false, ct) is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await repository.SaveMappingAsync(id, req.Mappings, ct);
|
||||
var session = await repository.GetAsync(id, tenant, user, false, ct);
|
||||
await Send.OkAsync(session!, ct);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed record SaveNamedMappingRequest(string Name, IReadOnlyList<ImportMapping> Mappings);
|
||||
|
||||
public sealed class SaveNamedMappingEndpoint(ImportRepository repository)
|
||||
: Endpoint<SaveNamedMappingRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/imports/sessions/{sessionId:guid}/saved-mappings");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SaveNamedMappingRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var tenant = ImportIdentity.TenantId(User);
|
||||
var user = ImportIdentity.UserId(User);
|
||||
var session = await repository.GetAsync(id, tenant, user, false, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
await repository.SaveNamedMappingAsync(tenant, user, session.ImportType, req.Name, session.SourceColumns, req.Mappings, ct);
|
||||
await Send.NoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class TemplateEndpoint(ImportDefinitionRegistry registry, ClosedXmlWorkbookService workbook)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/imports/{importType}/template");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var importType = Route<string>("importType")!;
|
||||
if (!registry.TryGet(importType, out var definition) || definition is null) { await Send.NotFoundAsync(ct); return; }
|
||||
var stream = workbook.CreateTemplate(definition.Definition);
|
||||
await Send.StreamAsync(stream,
|
||||
fileName: $"{definition.Definition.Entity}_import_template.xlsx",
|
||||
fileLengthBytes: stream.Length,
|
||||
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using FastEndpoints;
|
||||
using Hangfire;
|
||||
using Modules.Common.Imports.Jobs;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Endpoints;
|
||||
|
||||
public sealed class ValidateEndpoint(ImportRepository repository, IBackgroundJobClient jobs)
|
||||
: EndpointWithoutRequest<ImportSessionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/imports/sessions/{sessionId:guid}/validate");
|
||||
Permissions("imports.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("sessionId");
|
||||
var tenant = ImportIdentity.TenantId(User);
|
||||
var user = ImportIdentity.UserId(User);
|
||||
var session = await repository.GetAsync(id, tenant, user, false, ct);
|
||||
if (session is null) { await Send.NotFoundAsync(ct); return; }
|
||||
|
||||
var acquired = await repository.TryTransitionAsync(id,
|
||||
[ImportStatuses.MappingRequired, ImportStatuses.Uploaded, ImportStatuses.Failed],
|
||||
ImportStatuses.Validating, 1, ct);
|
||||
if (acquired)
|
||||
{
|
||||
try { jobs.Enqueue<ValidateImportJob>(job => job.RunAsync(id, CancellationToken.None)); }
|
||||
catch { await repository.SetStatusAsync(id, ImportStatuses.MappingRequired, 0, ct); throw; }
|
||||
}
|
||||
var current = await repository.GetAsync(id, tenant, user, false, ct);
|
||||
await Send.OkAsync(current!, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Jobs;
|
||||
|
||||
public sealed class CommitImportJob(
|
||||
ImportRepository repository,
|
||||
ImportDefinitionRegistry registry,
|
||||
ImportProgressPublisher progress)
|
||||
{
|
||||
public async Task RunAsync(Guid sessionId, string actor, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (importType, _) = await repository.GetWorkDefinitionAsync(sessionId, ct);
|
||||
var definition = registry.Get(importType);
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId, ImportStatuses.Committing, 1, 0, 0, 0, 0, 0,
|
||||
"검증된 데이터를 반영하고 있습니다."), ct);
|
||||
|
||||
var result = await definition.CommitAsync(sessionId, actor, ct);
|
||||
await repository.CompleteAsync(sessionId, result, ct);
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId,
|
||||
result.Failed > 0 ? ImportStatuses.PartiallyCompleted : ImportStatuses.Completed,
|
||||
100,
|
||||
result.Created + result.Updated + result.Failed,
|
||||
result.Created + result.Updated + result.Failed,
|
||||
result.Created + result.Updated,
|
||||
result.Failed,
|
||||
0,
|
||||
result.Failed > 0 ? "일부 데이터가 오류로 제외되었습니다." : "반영이 완료되었습니다."), ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await repository.SetStatusAsync(sessionId, ImportStatuses.Failed, 100, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using Npgsql;
|
||||
using Shared.Excel;
|
||||
|
||||
namespace Modules.Common.Imports.Jobs;
|
||||
|
||||
public sealed class ValidateImportJob(
|
||||
ImportRepository repository,
|
||||
ImportDefinitionRegistry registry,
|
||||
ClosedXmlWorkbookService workbook,
|
||||
ImportProgressPublisher progress,
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task RunAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (importType, mapping) = await repository.GetWorkDefinitionAsync(sessionId, ct);
|
||||
var definition = registry.Get(importType);
|
||||
|
||||
var targetToSource = mapping
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.TargetField))
|
||||
.ToDictionary(x => x.TargetField!, x => x.SourceColumn, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var (contentType, bytes) = await repository.GetFileAsync(sessionId, ct);
|
||||
await using var stream = new MemoryStream(bytes, writable: false);
|
||||
var rows = workbook.ReadRows(stream, definition.Definition.MaxRows).ToArray();
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId, ImportStatuses.Validating, 15, rows.Length, 0, 0, 0, 0,
|
||||
"참조 데이터를 확인하고 있습니다."), ct);
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var results = await definition.ValidateAsync(rows, targetToSource, connection, ct);
|
||||
var valid = results.Count(x => x.IsValid);
|
||||
var invalid = results.Count - valid;
|
||||
var warnings = results.Count(x => x.Warnings.Count > 0);
|
||||
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId, ImportStatuses.Validating, 80, rows.Length, rows.Length,
|
||||
valid, invalid, warnings, "검증 결과를 저장하고 있습니다."), ct);
|
||||
|
||||
await repository.ReplaceRowsAsync(sessionId, results, ct);
|
||||
await progress.PublishAsync(new ImportProgressEvent(
|
||||
sessionId, ImportStatuses.Validated, 100, rows.Length, rows.Length,
|
||||
valid, invalid, warnings, "검증이 완료되었습니다."), ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await repository.SetStatusAsync(sessionId, ImportStatuses.Failed, 100, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Dapper;using FastEndpoints;using Npgsql;using Kbx.Shared.Runtime;using Shared.Problems;
|
||||
namespace Kbx.Modules.Common.Integrations.Attempts.Get;
|
||||
public sealed record Response(Guid Id,string IntegrationId,string MessageId,string AggregateType,string AggregateId,string State,int AttemptNo,string? FailureCode,string? Detail,DateTimeOffset StartedAt,DateTimeOffset? CompletedAt,DateTimeOffset? NextRetryAt,string CorrelationId);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<object>
|
||||
{
|
||||
public override void Configure(){Get("/api/integrations/attempts/{attemptId:guid}");Permissions("common.integration.read");}
|
||||
public override async Task HandleAsync(CancellationToken ct){var attemptId=Route<Guid>("attemptId");await using var c=await dataSource.OpenConnectionAsync(ct);const string sql="""select id, integration_id as IntegrationId, message_id::text as MessageId, aggregate_type as AggregateType, aggregate_id as AggregateId, state, attempt_no as AttemptNo, failure_code as FailureCode, detail, started_at as StartedAt, completed_at as CompletedAt, next_retry_at as NextRetryAt, correlation_id as CorrelationId from kbx.integration_attempts where id=@Id and tenant_id=@TenantId""";var row=await c.QuerySingleOrDefaultAsync<Response>(new CommandDefinition(sql,new{Id=attemptId,TenantId=RuntimeIdentity.TenantId(User)},cancellationToken:ct));if(row is null){await Send.ResponseAsync(KbxNotFoundProblem.Create("INTEGRATION_ATTEMPT_NOT_FOUND","연계 이력을 찾을 수 없습니다."),404,cancellation:ct);return;}await Send.OkAsync(row,ct);}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Kbx.Shared.Runtime;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Kbx.Modules.Common.Integrations.Attempts.Retry;
|
||||
|
||||
public sealed record Request(string? IdempotencyKey = null);
|
||||
public sealed record Response(Guid AttemptId,string State,string Message);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):Endpoint<Request,object>
|
||||
{
|
||||
private const string OperationId = "common.integrations.retryAttempt";
|
||||
public override void Configure(){Post("/api/integrations/attempts/{attemptId:guid}/retry");Permissions("common.integration.retry");}
|
||||
|
||||
public override async Task HandleAsync(Request req,CancellationToken ct)
|
||||
{
|
||||
var attemptId=Route<Guid>("attemptId");
|
||||
var key=HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault()??req.IdempotencyKey;
|
||||
if(string.IsNullOrWhiteSpace(key)){
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"IDEMPOTENCY_KEY_REQUIRED","안전한 재처리를 위해 Idempotency-Key가 필요합니다.")),400,cancellation:ct);return;
|
||||
}
|
||||
await using var c=await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx=await c.BeginTransactionAsync(ct);
|
||||
var replay=await c.QuerySingleOrDefaultAsync<string?>(new CommandDefinition(
|
||||
"select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",
|
||||
new { OperationId, Key=key },tx,cancellationToken:ct));
|
||||
if(replay is not null){await tx.RollbackAsync(ct);await Send.OkAsync(JsonSerializer.Deserialize<Response>(replay)!,ct);return;}
|
||||
|
||||
const string sql="""
|
||||
update kbx.integration_attempts
|
||||
set state='retrying', next_retry_at=now(), manual_retry_key=@Key
|
||||
where id=@Id and tenant_id=@TenantId and state='failed'
|
||||
returning id;
|
||||
""";
|
||||
var id=await c.ExecuteScalarAsync<Guid?>(new CommandDefinition(sql,new{Id=attemptId,TenantId=RuntimeIdentity.TenantId(User),Key=key},tx,cancellationToken:ct));
|
||||
if(id is null){await tx.RollbackAsync(ct);await Send.ResponseAsync(KbxNotFoundProblem.Create("INTEGRATION_ATTEMPT_NOT_RETRYABLE","재처리할 수 있는 연계 실패를 찾지 못했습니다."),404,cancellation:ct);return;}
|
||||
|
||||
var response=new Response(id.Value,"retrying","재처리를 예약했습니다.");
|
||||
await c.ExecuteAsync(new CommandDefinition("insert into kbx.command_receipts(operation_id,idempotency_key,response_json) values(@OperationId,@Key,cast(@Json as jsonb))",
|
||||
new { OperationId, Key=key, Json=JsonSerializer.Serialize(response) },tx,cancellationToken:ct));
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(response,ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Modules.Common.Operations.Claim;
|
||||
|
||||
public sealed record ClaimWorkItemsRequest(IReadOnlyList<Guid> Ids);
|
||||
public sealed record ClaimWorkItemsResponse(int ClaimedCount, int SkippedCount);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<ClaimWorkItemsRequest, ClaimWorkItemsResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/operations/work-items/claim"); Permissions("common.operations.claim"); }
|
||||
|
||||
public override async Task HandleAsync(ClaimWorkItemsRequest req, CancellationToken ct)
|
||||
{
|
||||
var ids = req.Ids.Distinct().Take(500).ToArray();
|
||||
if (ids.Length == 0) { await Send.OkAsync(new(0, 0), ct); return; }
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var actorId = OperationsIdentity.UserId(User);
|
||||
var actorName = OperationsIdentity.UserName(User);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var changed = (await connection.QueryAsync<(Guid Id, string Status)>(new CommandDefinition("""
|
||||
update kbx.work_items
|
||||
set status='claimed', owner_id=@ActorId, owner_name=@ActorName, version=version+1, updated_at=now()
|
||||
where tenant_id=@Tenant and id=any(@Ids) and status='open'
|
||||
returning id, 'open'::text as Status;
|
||||
""", new { Tenant = tenant, Ids = ids, ActorId = actorId, ActorName = actorName }, tx, cancellationToken: ct))).AsList();
|
||||
|
||||
foreach (var row in changed)
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.work_item_audit(id, work_item_id, tenant_id, action, actor_id, actor_name, before_status, after_status)
|
||||
values(@Id, @WorkItemId, @Tenant, 'claim', @ActorId, @ActorName, 'open', 'claimed');
|
||||
""", new { Id = Guid.NewGuid(), WorkItemId = row.Id, Tenant = tenant, ActorId = actorId, ActorName = actorName }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(new(changed.Count, ids.Length - changed.Count), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Modules.Common.Operations.Resolve;
|
||||
|
||||
public sealed record ResolveWorkItemsRequest(IReadOnlyList<Guid> Ids, string Reason);
|
||||
public sealed record ResolveWorkItemsResponse(int ResolvedCount, int RejectedCount);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<ResolveWorkItemsRequest, ResolveWorkItemsResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/operations/work-items/resolve"); Permissions("common.operations.resolve"); }
|
||||
|
||||
public override async Task HandleAsync(ResolveWorkItemsRequest req, CancellationToken ct)
|
||||
{
|
||||
var ids = req.Ids.Distinct().Take(500).ToArray();
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var actorId = OperationsIdentity.UserId(User);
|
||||
var actorName = OperationsIdentity.UserName(User);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// Manual resolution is opt-in. Domain-originated exceptions should normally be resolved
|
||||
// by source-module events through OperationsProjectionWriter.ResolveBySourceAsync().
|
||||
var changed = (await connection.QueryAsync<Guid>(new CommandDefinition("""
|
||||
update kbx.work_items
|
||||
set status='resolved', resolution_reason=@Reason, resolved_at=now(), version=version+1, updated_at=now()
|
||||
where tenant_id=@Tenant and id=any(@Ids) and allow_manual_resolution=true and status in ('open','claimed')
|
||||
returning id;
|
||||
""", new { Tenant = tenant, Ids = ids, Reason = req.Reason }, tx, cancellationToken: ct))).AsList();
|
||||
|
||||
foreach (var id in changed)
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.work_item_audit(id, work_item_id, tenant_id, action, actor_id, actor_name, reason, after_status)
|
||||
values(@Id, @WorkItemId, @Tenant, 'manual-resolve', @ActorId, @ActorName, @Reason, 'resolved');
|
||||
""", new { Id = Guid.NewGuid(), WorkItemId = id, Tenant = tenant, ActorId = actorId, ActorName = actorName, Reason = req.Reason }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(new(changed.Count, ids.Length - changed.Count), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Modules.Common.Operations.Retry;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource, WorkItemActionRegistry registry) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure() { Post("/api/operations/work-items/{id:guid}/retry"); Permissions("common.operations.retry"); }
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("id");
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var actor = OperationsIdentity.UserId(User);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var key = await connection.QuerySingleOrDefaultAsync<string?>(new CommandDefinition("""
|
||||
select retry_action_key from kbx.work_items
|
||||
where tenant_id=@Tenant and id=@Id and status in ('open','claimed');
|
||||
""", new { Tenant = tenant, Id = id }, cancellationToken: ct));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key) || !registry.TryGet(key, out var handler) || handler is null)
|
||||
{
|
||||
AddError("이 예외에는 안전한 재처리 동작이 등록되어 있지 않습니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await handler.ExecuteAsync(tenant, actor, id, ct);
|
||||
await Send.OkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Modules.Common.Operations.Search;
|
||||
|
||||
public sealed class SearchWorkItemsRequest
|
||||
{
|
||||
public string? Module { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Owner { get; init; }
|
||||
public string? Code { get; init; }
|
||||
public string? Keyword { get; init; }
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 200;
|
||||
}
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SearchWorkItemsRequest, WorkQueueResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/operations/work-items");
|
||||
Permissions("common.operations.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SearchWorkItemsRequest req, CancellationToken ct)
|
||||
{
|
||||
var tenantId = OperationsIdentity.TenantId(User);
|
||||
var userId = OperationsIdentity.UserId(User);
|
||||
var page = Math.Max(1, req.Page);
|
||||
var pageSize = Math.Clamp(req.PageSize, 1, 500);
|
||||
var offset = (page - 1) * pageSize;
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
const string rowsSql = """
|
||||
select id, source_module as SourceModule, source_type as SourceType, source_id as SourceId,
|
||||
reference_no as ReferenceNo, source_screen_id as SourceScreenId, code, title, detail, severity, status, owner_id as OwnerId, owner_name as OwnerName,
|
||||
occurred_at as OccurredAt, due_at as DueAt,
|
||||
greatest(0, floor(extract(epoch from (now() - occurred_at)) / 60))::int as AgeMinutes,
|
||||
version, context::text as ContextJson, retry_action_key as RetryActionKey,
|
||||
allow_manual_resolution as AllowManualResolution
|
||||
from kbx.work_items
|
||||
where tenant_id = @TenantId
|
||||
and (@Module is null or source_module = @Module)
|
||||
and (@Severity is null or severity = @Severity)
|
||||
and (@Status is null or status = @Status)
|
||||
and (@Code is null or code = @Code)
|
||||
and (@Keyword is null or title ilike '%' || @Keyword || '%' or detail ilike '%' || @Keyword || '%' or reference_no ilike '%' || @Keyword || '%' or source_id ilike '%' || @Keyword || '%')
|
||||
and (@Owner is null
|
||||
or (@Owner = 'mine' and owner_id = @UserId)
|
||||
or (@Owner = 'unassigned' and owner_id is null))
|
||||
order by
|
||||
case severity when 'critical' then 0 when 'warning' then 1 else 2 end,
|
||||
coalesce(due_at, occurred_at), occurred_at
|
||||
limit @PageSize offset @Offset;
|
||||
""";
|
||||
|
||||
const string countSql = """
|
||||
select count(*)::int
|
||||
from kbx.work_items
|
||||
where tenant_id = @TenantId
|
||||
and (@Module is null or source_module = @Module)
|
||||
and (@Severity is null or severity = @Severity)
|
||||
and (@Status is null or status = @Status)
|
||||
and (@Code is null or code = @Code)
|
||||
and (@Keyword is null or title ilike '%' || @Keyword || '%' or detail ilike '%' || @Keyword || '%' or reference_no ilike '%' || @Keyword || '%' or source_id ilike '%' || @Keyword || '%')
|
||||
and (@Owner is null
|
||||
or (@Owner = 'mine' and owner_id = @UserId)
|
||||
or (@Owner = 'unassigned' and owner_id is null));
|
||||
""";
|
||||
|
||||
const string countersSql = """
|
||||
select code as Key, min(title) as Label, count(*)::int as Count,
|
||||
case max(case severity when 'critical' then 3 when 'warning' then 2 else 1 end)
|
||||
when 3 then 'critical' when 2 then 'warning' else 'info' end as Severity
|
||||
from kbx.work_items
|
||||
where tenant_id = @TenantId and status in ('open','claimed')
|
||||
group by code
|
||||
order by max(case severity when 'critical' then 3 when 'warning' then 2 else 1 end) desc, count(*) desc
|
||||
limit 12;
|
||||
""";
|
||||
|
||||
var args = new {
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Module = EmptyToNull(req.Module),
|
||||
Severity = EmptyToNull(req.Severity),
|
||||
Status = EmptyToNull(req.Status),
|
||||
Owner = EmptyToNull(req.Owner),
|
||||
Code = EmptyToNull(req.Code),
|
||||
Keyword = EmptyToNull(req.Keyword),
|
||||
PageSize = pageSize,
|
||||
Offset = offset,
|
||||
};
|
||||
|
||||
var rows = (await connection.QueryAsync<WorkItemProjection>(new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList();
|
||||
var total = await connection.ExecuteScalarAsync<int>(new CommandDefinition(countSql, args, cancellationToken: ct));
|
||||
var counters = (await connection.QueryAsync<WorkQueueCounter>(new CommandDefinition(countersSql, new { TenantId = tenantId }, cancellationToken: ct))).AsList();
|
||||
var items = rows.Select(ToDto).ToList();
|
||||
await Send.OkAsync(new WorkQueueResponse(items, total, counters), ct);
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static WorkItemDto ToDto(WorkItemProjection row)
|
||||
{
|
||||
var actions = new List<WorkItemActionDto> {
|
||||
new("navigate", "원 업무 보기", "navigate"),
|
||||
};
|
||||
if (WorkItemActionPolicy.CanClaim(row.Status)) actions.Add(new("claim", "내가 처리", "claim", "common.operations.claim"));
|
||||
if (WorkItemActionPolicy.CanRetry(row.Status, row.RetryActionKey)) actions.Add(new("retry", "재처리", "retry", "common.operations.retry"));
|
||||
if (WorkItemActionPolicy.CanManualResolve(row.Status, row.AllowManualResolution)) actions.Add(new("resolve", "해결 처리", "resolve", "common.operations.resolve"));
|
||||
var context = JsonSerializer.Deserialize<JsonElement>(row.ContextJson);
|
||||
return new(row.Id, row.SourceModule, row.SourceType, row.SourceId, row.ReferenceNo, row.SourceScreenId, row.Code, row.Title, row.Detail,
|
||||
row.Severity, row.Status, row.OwnerId, row.OwnerName, row.OccurredAt, row.DueAt, row.AgeMinutes,
|
||||
row.Version, context, actions);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Modules.Common.Reconcile.CreateExceptions;
|
||||
|
||||
public sealed record CreateReconcileExceptionsRequest(IReadOnlyList<Guid> Ids);
|
||||
public sealed record CreateReconcileExceptionsResponse(int CreatedOrUpdatedCount, int SkippedCount);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource, OperationsProjectionWriter projection)
|
||||
: Endpoint<CreateReconcileExceptionsRequest, CreateReconcileExceptionsResponse>
|
||||
{
|
||||
public override void Configure() { Post("/api/reconcile/items/create-exceptions"); Permissions("common.operations.create"); }
|
||||
|
||||
public override async Task HandleAsync(CreateReconcileExceptionsRequest req, CancellationToken ct)
|
||||
{
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var ids = req.Ids.Distinct().Take(500).ToArray();
|
||||
if (ids.Length == 0) { await Send.OkAsync(new(0, 0), ct); return; }
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = (await connection.QueryAsync<Row>(new CommandDefinition("""
|
||||
select id, reconcile_type as ReconcileType, reference_no as ReferenceNo,
|
||||
expected_value as ExpectedValue, actual_value as ActualValue,
|
||||
difference_value as DifferenceValue, reason_code as ReasonCode, reason_text as ReasonText,
|
||||
occurred_at as OccurredAt, version
|
||||
from kbx.reconcile_items
|
||||
where tenant_id=@Tenant and id=any(@Ids) and status in ('mismatch','pending');
|
||||
""", new { Tenant = tenant, Ids = ids }, cancellationToken: ct))).AsList();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
await projection.UpsertAsync(new UpsertWorkItem(
|
||||
tenant, "COMMON", "reconcile", row.Id.ToString(), row.ReferenceNo, "COMMON-REC-001", row.Version,
|
||||
"RECONCILE_MISMATCH", $"대사 불일치 · {row.ReferenceNo}", row.ReasonText,
|
||||
"warning", row.OccurredAt, Context: new {
|
||||
row.ReconcileType, row.ReferenceNo, row.ExpectedValue, row.ActualValue,
|
||||
row.DifferenceValue, row.ReasonCode
|
||||
}), ct);
|
||||
}
|
||||
|
||||
await Send.OkAsync(new(rows.Count, ids.Length - rows.Count), ct);
|
||||
}
|
||||
|
||||
private sealed record Row(Guid Id, string ReconcileType, string ReferenceNo, string ExpectedValue,
|
||||
string ActualValue, string? DifferenceValue, string? ReasonCode, string? ReasonText,
|
||||
DateTimeOffset OccurredAt, long Version);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Modules.Common.Reconcile.Search;
|
||||
|
||||
public sealed class SearchReconcileRequest
|
||||
{
|
||||
public string? ReconcileType { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Keyword { get; init; }
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 200;
|
||||
}
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SearchReconcileRequest, ReconcileResponse>
|
||||
{
|
||||
public override void Configure() { Get("/api/reconcile/items"); Permissions("common.reconcile.read"); }
|
||||
|
||||
public override async Task HandleAsync(SearchReconcileRequest req, CancellationToken ct)
|
||||
{
|
||||
var tenant = OperationsIdentity.TenantId(User);
|
||||
var page = Math.Max(1, req.Page);
|
||||
var pageSize = Math.Clamp(req.PageSize, 1, 500);
|
||||
var offset = (page - 1) * pageSize;
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
const string rowsSql = """
|
||||
select id, reconcile_type as ReconcileType, reference_no as ReferenceNo,
|
||||
source_label as SourceLabel, target_label as TargetLabel,
|
||||
expected_value as ExpectedValue, actual_value as ActualValue,
|
||||
difference_value as DifferenceValue, reason_code as ReasonCode, reason_text as ReasonText,
|
||||
status, occurred_at as OccurredAt, source_id as SourceId, target_id as TargetId, version
|
||||
from kbx.reconcile_items
|
||||
where tenant_id=@Tenant
|
||||
and (@ReconcileType is null or reconcile_type=@ReconcileType)
|
||||
and (@Status is null or status=@Status)
|
||||
and (@Keyword is null or reference_no ilike '%' || @Keyword || '%' or reason_text ilike '%' || @Keyword || '%')
|
||||
order by case status when 'mismatch' then 0 when 'pending' then 1 when 'resolved' then 2 else 3 end,
|
||||
occurred_at desc
|
||||
limit @PageSize offset @Offset;
|
||||
""";
|
||||
const string summarySql = """
|
||||
select count(*)::int as TotalCount,
|
||||
count(*) filter(where status='matched')::int as MatchedCount,
|
||||
count(*) filter(where status='mismatch')::int as MismatchCount,
|
||||
count(*) filter(where status='pending')::int as PendingCount,
|
||||
count(*) filter(where status='resolved')::int as ResolvedCount
|
||||
from kbx.reconcile_items
|
||||
where tenant_id=@Tenant
|
||||
and (@ReconcileType is null or reconcile_type=@ReconcileType)
|
||||
and (@Keyword is null or reference_no ilike '%' || @Keyword || '%' or reason_text ilike '%' || @Keyword || '%');
|
||||
""";
|
||||
var args = new {
|
||||
Tenant = tenant,
|
||||
ReconcileType = Empty(req.ReconcileType),
|
||||
Status = Empty(req.Status),
|
||||
Keyword = Empty(req.Keyword),
|
||||
PageSize = pageSize,
|
||||
Offset = offset,
|
||||
};
|
||||
var rows = (await connection.QueryAsync<ReconcileItemDto>(new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList();
|
||||
var summary = await connection.QuerySingleAsync<ReconcileSummary>(new CommandDefinition(summarySql, args, cancellationToken: ct));
|
||||
await Send.OkAsync(new ReconcileResponse(rows, summary), ct);
|
||||
}
|
||||
|
||||
private static string? Empty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.Runtime;
|
||||
|
||||
namespace Kbx.Modules.Common.Runtime.Health;
|
||||
|
||||
public sealed class Endpoint(KbxRuntimeNoticeRepository repository) : EndpointWithoutRequest<KbxRuntimeNotice?>
|
||||
{
|
||||
public override void Configure() { Get("/api/kbx/runtime/notice"); Permissions("common.runtime.read"); }
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
=> await Send.OkAsync(await repository.GetActiveAsync(RuntimeIdentity.TenantId(User), ct), ct);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.Runtime;
|
||||
|
||||
namespace Kbx.Modules.Common.Runtime.Notifications;
|
||||
|
||||
public sealed class Request { public int Limit { get; init; } = 30; }
|
||||
public sealed class Endpoint(KbxNotificationRepository repository) : Endpoint<Request, IReadOnlyList<KbxUserNotificationDto>>
|
||||
{
|
||||
public override void Configure() { Get("/api/kbx/runtime/notifications"); Permissions("common.runtime.read"); }
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
=> await Send.OkAsync(await repository.GetRecentAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Limit, ct), ct);
|
||||
}
|
||||
|
||||
public sealed class MarkReadRequest { public Guid Id { get; init; } }
|
||||
public sealed class MarkReadEndpoint(KbxNotificationRepository repository) : Endpoint<MarkReadRequest>
|
||||
{
|
||||
public override void Configure() { Post("/api/kbx/runtime/notifications/{Id}/read"); Permissions("common.runtime.read"); }
|
||||
public override async Task HandleAsync(MarkReadRequest req, CancellationToken ct)
|
||||
{
|
||||
await repository.MarkReadAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Id, ct);
|
||||
await Send.NoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.Runtime;
|
||||
|
||||
namespace Kbx.Modules.Common.Runtime.Operations;
|
||||
|
||||
public sealed class Request { public int Limit { get; init; } = 20; }
|
||||
public sealed class Endpoint(KbxOperationRunRepository repository) : Endpoint<Request, IReadOnlyList<KbxOperationRunDto>>
|
||||
{
|
||||
public override void Configure() { Get("/api/kbx/runtime/operations"); Permissions("common.runtime.read"); }
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
=> await Send.OkAsync(await repository.GetRecentAsync(RuntimeIdentity.TenantId(User), RuntimeIdentity.UserId(User), req.Limit, ct), ct);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using KBX.Shared.Experience;
|
||||
using Npgsql;
|
||||
|
||||
namespace KBX.Modules.Common.Suggestions.Submit;
|
||||
|
||||
public sealed record Request(
|
||||
string Category,
|
||||
string Message,
|
||||
bool IncludeScreenContext,
|
||||
SuggestionContext Context);
|
||||
|
||||
public sealed record Response(Guid SuggestionId, DateTimeOffset ReceivedAt);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<Request, Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/common/suggestions");
|
||||
Permissions("common.suggestion.create");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
if (req.Message.Trim().Length is < 3 or > 2000)
|
||||
{
|
||||
AddError(r => r.Message, "의견 내용은 3~2000자로 입력하세요.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.Category is not ("inconvenience" or "bug" or "improvement"))
|
||||
{
|
||||
AddError(r => r.Category, "지원하지 않는 의견 유형입니다.");
|
||||
await Send.ErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Host application must replace these claims adapters with its authenticated tenant/user resolver.
|
||||
var tenantId = Guid.Empty;
|
||||
var userId = Guid.Empty;
|
||||
var id = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
object? context = null;
|
||||
if (req.IncludeScreenContext)
|
||||
{
|
||||
// Deliberately persist diagnostic keys only, not arbitrary screen/business values.
|
||||
context = new
|
||||
{
|
||||
req.Context.ActiveFilters,
|
||||
req.Context.GridLayoutVersion,
|
||||
};
|
||||
}
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.user_suggestions
|
||||
(id, tenant_id, user_id, category, message, screen_id, screen_version,
|
||||
route, app_version, user_role, context, status, created_at)
|
||||
values
|
||||
(@Id, @TenantId, @UserId, @Category, @Message, @ScreenId, @ScreenVersion,
|
||||
@Route, @AppVersion, @UserRole, cast(@Context as jsonb), 'NEW', @CreatedAt)
|
||||
""", new
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
req.Category,
|
||||
Message = req.Message.Trim(),
|
||||
req.Context.ScreenId,
|
||||
req.Context.ScreenVersion,
|
||||
req.Context.Route,
|
||||
req.Context.AppVersion,
|
||||
req.Context.UserRole,
|
||||
Context = context is null ? null : System.Text.Json.JsonSerializer.Serialize(context),
|
||||
CreatedAt = now,
|
||||
}, cancellationToken: ct));
|
||||
|
||||
await Send.OkAsync(new Response(id, now), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FastEndpoints;
|
||||
using Kbx.Shared.Telemetry;
|
||||
namespace Kbx.Modules.Common.UxTelemetry.Ingest;
|
||||
public sealed class Endpoint(KbxUxTelemetryRepository repository) : Endpoint<KbxUxEventBatchRequest>
|
||||
{
|
||||
public override void Configure(){Post("/api/kbx/ux/events");Permissions("common.telemetry.write");}
|
||||
public override async Task HandleAsync(KbxUxEventBatchRequest req,CancellationToken ct)
|
||||
{
|
||||
// Replace with the deployment's authenticated tenant accessor.
|
||||
var tenantId=Kbx.Shared.Runtime.RuntimeIdentity.TenantId(User);
|
||||
await repository.AppendAsync(tenantId,Kbx.Shared.Runtime.RuntimeIdentity.UserId(User),req.Events,ct);await Send.NoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FastEndpoints;using Kbx.Shared.Telemetry;
|
||||
namespace Kbx.Modules.Common.UxTelemetry.Metrics;
|
||||
public sealed record Request(DateOnly From,DateOnly To,string? ScreenId);
|
||||
public sealed class Endpoint(KbxUxMetricsQuery query):Endpoint<Request,KbxUxMetricsResponse>
|
||||
{
|
||||
public override void Configure(){Get("/api/kbx/ux/metrics");Permissions("common.ux.read");}
|
||||
public override async Task HandleAsync(Request req,CancellationToken ct)
|
||||
{
|
||||
var tenantId=Kbx.Shared.Runtime.RuntimeIdentity.TenantId(User);
|
||||
await Send.OkAsync(await query.ExecuteAsync(tenantId,req.From,req.To,req.ScreenId,ct),ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user