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,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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}