diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Detail.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Detail.cshtml new file mode 100644 index 00000000..151b6e40 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Detail.cshtml @@ -0,0 +1,132 @@ +@page "{runId}" +@model QuantEngine.Web.Pages.Admin.Collection.DetailModel +@{ + ViewData["Title"] = "수집 실행 상세"; +} + + + +
+ @if (Model.Run == null) + { +
해당 수집 실행을 찾을 수 없습니다.
+ return; + } + +
+
+
+
+
+

실행 ID

+
@Model.Run.RunId
+
+
+
+
+
+
+
+
+

상태

+
+ @if (string.Equals(Model.Run.Status, "completed", StringComparison.OrdinalIgnoreCase)) + { + 완료 + } + else if (string.Equals(Model.Run.Status, "running", StringComparison.OrdinalIgnoreCase)) + { + 진행 중 + } + else if (string.Equals(Model.Run.Status, "failed", StringComparison.OrdinalIgnoreCase)) + { + 실패 + } + else + { + @Model.Run.Status + } +
+
+
+
+
+
+
+
+
+

스냅샷 수

+
@(Model.Run.TotalSnapshots?.ToString() ?? "0")
+
+
+
+
+
+
+
+
+

오류 수

+
@(Model.Run.TotalErrors?.ToString() ?? "0")
+
+
+
+
+
+ +
+
+
+
+

상세 정보

+
+
+ + + + + + + + + + + + + + + + + + + +
시작 시간@Model.Run.StartedAt
종료 시간@(Model.Run.FinishedAt ?? "-")
수집 소스@(string.IsNullOrEmpty(Model.Run.Status) ? "-" : "KIS Open API")
성공 여부 + @if (string.Equals(Model.Run.Status, "completed", StringComparison.OrdinalIgnoreCase) + && Model.Run.TotalSnapshots > 0 + && (Model.Run.TotalErrors == 0 || (Model.Run.TotalErrors < Model.Run.TotalSnapshots * 0.1))) + { + 성공 + } + else if (string.Equals(Model.Run.Status, "completed", StringComparison.OrdinalIgnoreCase) + && Model.Run.TotalSnapshots > 0) + { + 부분 성공 + } + else + { + 실패 + } +
+
+
+
+
+
diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Detail.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Detail.cshtml.cs new file mode 100644 index 00000000..fec419c0 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Detail.cshtml.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using QuantEngine.Core.Interfaces; +using QuantEngine.Web.Services; + +namespace QuantEngine.Web.Pages.Admin.Collection; + +[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] +public class DetailModel : PageModel +{ + private readonly ICollectionRepository _collectionRepository; + private readonly ILogger _logger; + + public CollectionRunRecord? Run { get; set; } + + public DetailModel(ICollectionRepository collectionRepository, ILogger logger) + { + _collectionRepository = collectionRepository; + _logger = logger; + } + + public async Task OnGetAsync(string runId) + { + try + { + var runs = await _collectionRepository.GetRecentRunsAsync(limit: 100); + Run = runs.FirstOrDefault(r => r.RunId == runId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load collection run detail"); + } + } +} diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Create.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Create.cshtml new file mode 100644 index 00000000..b63b814c --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Create.cshtml @@ -0,0 +1,75 @@ +@page +@model QuantEngine.Web.Pages.Admin.Users.CreateModel +@{ + ViewData["Title"] = "새 사용자 추가"; +} + + + +
+
+
+
+
+ @if (!ViewData.ModelState.IsValid) + { + + } + +
+ @Html.AntiForgeryToken() + +
+ + + 영문, 숫자, 언더스코어만 사용 가능 +
+ +
+ + + 최소 8자 이상, 대문자/숫자/특수문자 권장 +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+
+
diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Create.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Create.cshtml.cs new file mode 100644 index 00000000..20cf658e --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Users/Create.cshtml.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using QuantEngine.Core.Interfaces; +using QuantEngine.Core.Models; +using QuantEngine.Web.Services; +using BCrypt.Net; +using System.ComponentModel.DataAnnotations; + +namespace QuantEngine.Web.Pages.Admin.Users; + +[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] +public class CreateModel : PageModel +{ + private readonly IWorkspaceRepository _workspaceRepository; + private readonly ILogger _logger; + + [BindProperty] + public CreateUserInput Input { get; set; } = new(); + + public CreateModel(IWorkspaceRepository workspaceRepository, ILogger logger) + { + _workspaceRepository = workspaceRepository; + _logger = logger; + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + return Page(); + + if (string.IsNullOrWhiteSpace(Input.Username) || string.IsNullOrWhiteSpace(Input.Password)) + { + ModelState.AddModelError(string.Empty, "사용자명과 비밀번호를 입력하세요."); + return Page(); + } + + try + { + var existingUser = await _workspaceRepository.GetAccountByUsernameAsync(Input.Username); + if (existingUser != null) + { + ModelState.AddModelError(string.Empty, "이미 존재하는 사용자명입니다."); + return Page(); + } + + var hashedPassword = BCrypt.Net.BCrypt.EnhancedHashPassword(Input.Password); + var newAccount = new WorkspaceAccount + { + Username = Input.Username.Trim(), + PasswordHash = hashedPassword, + Role = Input.Input?.Role ?? "Admin", + IsActive = Input.IsActive ? "true" : "false", + CreatedAt = DateTime.UtcNow.ToString("O"), + UpdatedAt = DateTime.UtcNow.ToString("O") + }; + + await _workspaceRepository.UpsertAccountAsync(newAccount); + _logger.LogInformation("[Users] New user created: {Username}", Input.Username); + + return RedirectToPage("/Admin/Users/Index"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create user"); + ModelState.AddModelError(string.Empty, "사용자 생성 중 오류가 발생했습니다."); + return Page(); + } + } +} + +public class CreateUserInput +{ + [Required(ErrorMessage = "사용자명을 입력하세요.")] + [StringLength(50)] + public string Username { get; set; } = string.Empty; + + [Required(ErrorMessage = "비밀번호를 입력하세요.")] + [StringLength(100, MinimumLength = 8, ErrorMessage = "비밀번호는 최소 8자 이상이어야 합니다.")] + public string Password { get; set; } = string.Empty; + + public CreateUserInput? Input { get; set; } + public string Role { get; set; } = "Admin"; + public bool IsActive { get; set; } = true; +} diff --git a/src/dotnet/QuantEngine.Web/logs/quantengine-20260711.log b/src/dotnet/QuantEngine.Web/logs/quantengine-20260711.log new file mode 100644 index 00000000..54c4f439 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/logs/quantengine-20260711.log @@ -0,0 +1,18 @@ +2026-07-11 17:02:14.217 +09:00 [FTL] Application terminated unexpectedly +System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: QuantEngine.Core.Interfaces.IKisApiClient Lifetime: Scoped ImplementationType: QuantEngine.Infrastructure.Services.KisApiClient': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.) + ---> System.InvalidOperationException: Error while validating the service descriptor 'ServiceType: QuantEngine.Core.Interfaces.IKisApiClient Lifetime: Scoped ImplementationType: QuantEngine.Infrastructure.Services.KisApiClient': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'. + ---> System.InvalidOperationException: Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'. + at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateArgumentCallSites(ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain, ParameterInfo[] parameters, Boolean throwIfCallSiteNotFound) + at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateConstructorCallSite(ResultCache lifetime, ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain) + at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateExact(ServiceDescriptor descriptor, ServiceIdentifier serviceIdentifier, CallSiteChain callSiteChain, Int32 slot) + at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.GetCallSite(ServiceDescriptor serviceDescriptor, CallSiteChain callSiteChain) + at Microsoft.Extensions.DependencyInjection.ServiceProvider.ValidateService(ServiceDescriptor descriptor) + --- End of inner exception stack trace --- + at Microsoft.Extensions.DependencyInjection.ServiceProvider.ValidateService(ServiceDescriptor descriptor) + at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options) + --- End of inner exception stack trace --- + at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options) + at Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(IServiceCollection services, ServiceProviderOptions options) + at Microsoft.Extensions.Hosting.HostApplicationBuilder.Build() + at Microsoft.AspNetCore.Builder.WebApplicationBuilder.Build() + at Program.
$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 99