diff --git a/AGENTS.md b/AGENTS.md index 06b7ef8..0034255 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,14 +137,25 @@ - **임시 파일 관리**: 개발/디버깅 목적의 모든 휘발성 임시 파일 및 로그는 반드시 `Temp/` 디렉토리 하위에서만 생성해야 하며, 루트나 다른 패키지 경로에 임시 파일을 만드는 것은 금지한다. 불가피하게 생성할 경우 반드시 접두사/접미사 규칙(`debug_*`, `tmp_*`, `mock_*`, `*_temp.*`)을 준수하여 `.gitignore`에 필터링되도록 한다. ## 5b. Blazor & API-First 개발 규칙 (TaxBaik 참조 모델 적용) +- **핵심 아키텍처 원칙**: Blazor WASM 개발은 **패턴화(Pattern), 템플릿화(Template), 컴포넌트화(Component), MVVM 패턴, API-First 아키텍처**를 최우선 가치로 준수한다. - **렌더 모드 표준**: Blazor **Interactive WebAssembly** 를 기본 렌더 모드로 한다. InteractiveServer 는 사용하지 않으며, UI 컴포넌트는 **MudBlazor** 로 통일한다 (Fluent UI 는 폐기). -- **API-First 아키텍처**: Blazor Interactive WebAssembly UI 계층은 비즈니스 로직이나 DB에 직접 결합되지 않고, `IXxxBrowserClient` 등의 추상화된 API 클라이언트(HTTP/RESTful)를 통해서만 백엔드 API와 통신한다. +- **API-First 아키텍처 (MVVM + FastEndpoints)**: + - **백엔드(Server)**: 기존 컨트롤러 구조를 전면 배제하고, REPR(Request-Endpoint-Response) 패턴을 보장하는 **FastEndpoints** 프레임워크를 기반으로 백엔드 API 엔드포인트를 구현하여 단일 책임 원칙(SRP)을 준수한다. + - **프론트엔드(Client)**: Blazor WASM 클라이언트는 Razor 컴포넌트(View)와 상태/검증/로직을 갖춘 DTO 및 StateService(ViewModel) 구조의 **MVVM 패턴**을 지향하여 화면 바인딩 정합성을 극대화한다. UI 계층은 비즈니스 로직이나 DB에 직접 결합되지 않고, `IXxxBrowserClient` 또는 추상화된 HttpClient API 클라이언트를 통해서만 백엔드 API와 통신한다. - **이중 토큰 인증 패턴**: Access Token(15분) 및 Refresh Token(7일) 이중 토큰 패턴을 적용하며, HttpClient 요청 시 401 Unauthorized를 가로채어 자동으로 localStorage의 Refresh Token으로 토큰을 자동 갱신 및 재시도하는 `TokenRefreshHandler` (DelegatingHandler) 구조를 준수한다. - **실시간 알림 (SignalR)**: 실시간 알림 기능은 상태를 직접 동기화하는 용도가 아닌 단순 Event-driven 브로드캐스트 알림으로 설계하며, 클라이언트는 알림 수신 후 API 호출을 통해 최종 데이터를 검증 및 동기화한다. - **UI/UX 구현**: - MudBlazor 컴포넌트(MudDataGrid Dense + Virtualize)를 사용하여 고밀도(행높이 32px 수준) 및 대량 데이터 성능을 보장한다. - CRUD 생성 및 수정 작업 시 화면 플래시를 제거하기 위해 MudDialog 모달 대화상자 패턴을 사용하며, 삭제 작업에는 `ConfirmDialog` 등을 이용해 명시적 사용자 확인을 거친다. - 상태 및 등급 구분에는 시각적 가시성을 위한 Status Color Chips(Success, Warning, Error)를 적용한다. +- **DTO 및 유효성 검증 규칙**: API 입력 모델 및 데이터 전송 객체(DTO) 유효성 검증 시 데이터 어노테이션(DTO Annotation) 방식을 기본적으로 사용하되, 복잡한 비즈니스 조건부 유효성 검증이나 데이터베이스 연동 유효성 검사 등 어노테이션만으로 부족한 영역은 **FluentValidation**을 상호 보완적으로 적용하여 유효성 규칙을 중앙 집중식으로 엄격히 관리한다. +- **엔지니어링 표준화 지침**: + - **표준화 & 컴포넌트화**: UI 요소와 재사용 가능한 비즈니스 코어는 컴포넌트 단위로 구조화하며, 파편화된 개별 커스텀 스타일이나 인라인 데이터 변환을 배제하고 MudBlazor 및 표준 헬퍼 클래스를 공통 활용한다. + - **정규화 & 비정규화**: DB 스키마 설계 시에는 정규화 모델을 준수하여 중복과 파편화를 방지하고, 화면 조회 성능이나 BFF 통합 렌더링을 위한 데이터 구조화 단계에서만 안전하게 비정규화된 DTO/뷰 모델을 빌드하여 전송한다. + - **데이터 정합성 & 리팩토링**: 모든 비즈니스 도메인의 상태 전이는 ACID 트랜잭션 단위 및 인프라 레이어의 일관성 제어 규칙을 보장하며, 복잡도가 과한 하드코딩 영역은 SRP(단일 책임 원칙) 및 인터페이스 기반 구조로 점진적 리팩토링한다. + - **파편화 & 바이브 코드 방지**: provenance(근거) 없는 암묵적 룰이나 감에 의존한 구조(Vibe Code)의 무분별한 탑재를 금지하고, 모든 상태 및 에러 코드는 코드북에 엄격히 등록된 정방형 정규 값만 할당한다. + - **하네스 & 테스트 안정성**: 모든 패치는 `Temp/` 및 하네스 테스트 스위트의 빌드 및 통과 로그를 통해 데이터로 증빙한다. 하네스 실패 시 빌드 승격을 전면 차단한다. + - **비즈니스 로직 단순화**: 다차원 중첩 조건이나 연쇄 트리거를 제거하고 선형 구조(Waterfall, Sequence)의 단순 프로세스 플로우로 구현하여 추적 가능성을 극대화한다. - **코드 및 다국어 규칙**: 모든 관리자 UI 레이블, 폼, 오류 메시지는 한국어로 작성하며, 소스 코드 주석 및 내부 예외 메시지는 영어 작성을 허용한다. 클래스, 메서드, 프로퍼티는 `PascalCase`를 사용하고 비동기 메서드에는 `Async` 접미사를 지정한다. ## 6. 검증 규칙 diff --git a/src/dotnet/QuantEngine.Web/Client/Pages/Users.razor b/src/dotnet/QuantEngine.Web/Client/Pages/Users.razor index 0f27d5e..f91740c 100644 --- a/src/dotnet/QuantEngine.Web/Client/Pages/Users.razor +++ b/src/dotnet/QuantEngine.Web/Client/Pages/Users.razor @@ -1,6 +1,9 @@ @page "/users" @attribute [Authorize] +@using MudBlazor @inject HttpClient Http +@inject ISnackbar Snackbar +@inject IDialogService DialogService QuantEngine - 사용자 관리 @@ -32,25 +35,22 @@ 이름 - 이메일 역할 상태 - 가입일 + 생성일 + 수정일 작업
- @context.Name[0] - @context.Name + @context.Username[0].ToString().ToUpper() + @context.Username
- - @context.Email - @context.Role @@ -63,7 +63,10 @@ - @context.CreatedDate.ToString("yyyy-MM-dd") + @FormatDate(context.CreatedAt) + + + @FormatDate(context.UpdatedAt) 편집 @@ -74,16 +77,54 @@ } -@code { - private List _users = new(); - private string SearchQuery = ""; + + + + + + @(_isEditMode ? "사용자 편집" : "새 사용자 추가") + + + + + + + + + + Admin (관리자) + Operator (운영자) + Viewer (조회자) + - private IEnumerable FilteredUsers + @if (_isEditMode) + { + + } + + + + 취소 + 저장 + + + +@code { + private List _users = new(); + private string SearchQuery = ""; + private bool _dialogVisible; + private bool _isEditMode; + private MudForm _form = new(); + private UserFormModel _formModel = new(); + private DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + + private IEnumerable FilteredUsers { get => string.IsNullOrEmpty(SearchQuery) ? _users - : _users.Where(u => u.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) || - u.Email.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)); + : _users.Where(u => u.Username.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)); } protected override async Task OnInitializedAsync() @@ -95,68 +136,131 @@ { try { - _users = new List + var res = await Http.GetFromJsonAsync>("api/users"); + if (res != null) { - new UserModel - { - Id = "1", - Name = "admin", - Email = "admin@quantengine.local", - Role = "Admin", - IsActive = true, - CreatedDate = DateTime.Now.AddMonths(-6) - }, - new UserModel - { - Id = "2", - Name = "user1", - Email = "user1@example.com", - Role = "Viewer", - IsActive = true, - CreatedDate = DateTime.Now.AddMonths(-3) - }, - new UserModel - { - Id = "3", - Name = "user2", - Email = "user2@example.com", - Role = "Operator", - IsActive = true, - CreatedDate = DateTime.Now.AddMonths(-1) - } - }; + _users = res; + } } - catch + catch (Exception ex) { - // Handle error + Snackbar.Add($"사용자 목록 로드 실패: {ex.Message}", Severity.Error); } } - private async Task OpenAddUserDialog() + private void OpenAddUserDialog() { - // Dialog implementation would go here - await Task.CompletedTask; + _isEditMode = false; + _formModel = new UserFormModel { Role = "Viewer", IsActive = true }; + _dialogVisible = true; } - private async Task EditUser(UserModel user) + private void EditUser(UserDto user) { - // Edit dialog implementation - await Task.CompletedTask; + _isEditMode = true; + _formModel = new UserFormModel + { + Username = user.Username, + Role = user.Role, + IsActive = user.IsActive, + Password = "" // Clear password field for security + }; + _dialogVisible = true; } - private async Task DeleteUser(UserModel user) + private async Task DeleteUser(UserDto user) { - // Delete confirmation and implementation - await Task.CompletedTask; + bool? result = await DialogService.ShowMessageBox( + "사용자 삭제", + $"정말로 사용자 '{user.Username}' 계정을 비활성화하시겠습니까?", + yesText: "비활성화", cancelText: "취소"); + + if (result == true) + { + try + { + var response = await Http.DeleteAsync($"api/users?username={user.Username}"); + if (response.IsSuccessStatusCode) + { + Snackbar.Add("사용자 계정이 비활성화되었습니다.", Severity.Success); + await LoadUsers(); + } + else + { + Snackbar.Add("계정 비활성화 작업에 실패했습니다.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"API 에러: {ex.Message}", Severity.Error); + } + } } - private class UserModel + private void CloseDialog() { - public string Id { get; set; } - public string Name { get; set; } - public string Email { get; set; } - public string Role { get; set; } + _dialogVisible = false; + } + + private async Task SaveUser() + { + await _form.Validate(); + if (!_form.IsValid) return; + + try + { + HttpResponseMessage response; + if (_isEditMode) + { + response = await Http.PutAsJsonAsync("api/users", _formModel); + } + else + { + response = await Http.PostAsJsonAsync("api/users", _formModel); + } + + if (response.IsSuccessStatusCode) + { + Snackbar.Add("사용자 정보가 성공적으로 저장되었습니다.", Severity.Success); + _dialogVisible = false; + await LoadUsers(); + } + else + { + var error = await response.Content.ReadAsStringAsync(); + Snackbar.Add($"저장 실패: {error}", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"API 오류 발생: {ex.Message}", Severity.Error); + } + } + + private string FormatDate(string isoString) + { + if (string.IsNullOrWhiteSpace(isoString)) return "-"; + if (DateTime.TryParse(isoString, out var dt)) + { + return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); + } + return isoString; + } + + public class UserDto + { + public string Username { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; public bool IsActive { get; set; } - public DateTime CreatedDate { get; set; } + public string CreatedAt { get; set; } = string.Empty; + public string UpdatedAt { get; set; } = string.Empty; + } + + public class UserFormModel + { + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Role { get; set; } = "Viewer"; + public bool IsActive { get; set; } = true; } } diff --git a/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs b/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs index 29d86ad..8d2b71e 100644 --- a/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs +++ b/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs @@ -1,158 +1,233 @@ +using FastEndpoints; using QuantEngine.Core.Interfaces; -using QuantEngine.Application.Services; namespace QuantEngine.Web.Endpoints; -public static class CollectionEndpoints +public class GetCollectionStateEndpoint : EndpointWithoutRequest { - public static void MapCollectionEndpoints(this WebApplication app) + private readonly ICollectionRepository _repo; + + public GetCollectionStateEndpoint(ICollectionRepository repo) { - var group = app.MapGroup("/api/collection") - .WithName("Collection"); - - group.MapGet("/state", GetCollectionState) - .WithName("GetCollectionState") - .Produces(200) - .Produces(500); - - group.MapGet("/runs", GetRecentRuns) - .WithName("GetRecentRuns") - .Produces(200) - .Produces(500); - - group.MapGet("/runs/{runId}/snapshots", GetRunSnapshots) - .WithName("GetRunSnapshots") - .Produces(200) - .Produces(404) - .Produces(500); - - group.MapGet("/runs/{runId}/errors", GetRunErrors) - .WithName("GetRunErrors") - .Produces(200) - .Produces(404) - .Produces(500); - - group.MapGet("/latest/{ticker}", GetLatestSnapshotsForTicker) - .WithName("GetLatestSnapshotsForTicker") - .Produces(200) - .Produces(500); - - group.MapPost("/run", StartCollectionRun) - .WithName("StartCollectionRun") - .Produces(202) - .Produces(500); + _repo = repo; } - private static async Task GetCollectionState(ICollectionRepository repo) + public override void Configure() + { + Get("/api/collection/state"); + AllowAnonymous(); + Description(d => d + .Produces(200) + .Produces(500)); + } + + public override async Task HandleAsync(CancellationToken ct) { try { - var state = await repo.GetDashboardStateAsync(); - return Results.Ok(state); + var state = await _repo.GetDashboardStateAsync(); + await SendOkAsync(state, ct); } catch { - return Results.StatusCode(500); + await SendErrorsAsync(500, ct); } } - - private static async Task GetRecentRuns(ICollectionRepository repo, int limit = 20) - { - try - { - var runs = await repo.GetRecentRunsAsync(limit); - return Results.Ok(new { runs, count = runs.Count }); - } - catch - { - return Results.StatusCode(500); - } - } - - private static async Task GetRunSnapshots(string runId, ICollectionRepository repo) - { - try - { - var snapshots = await repo.GetRunSnapshotsAsync(runId); - return Results.Ok(new { runId, snapshots, count = snapshots.Count }); - } - catch - { - return Results.StatusCode(500); - } - } - - private static async Task GetRunErrors(string runId, ICollectionRepository repo, int limit = 50) - { - try - { - var errors = await repo.GetRunErrorsAsync(runId, limit); - return Results.Ok(new { runId, errors, count = errors.Count }); - } - catch - { - return Results.StatusCode(500); - } - } - - private static async Task GetLatestSnapshotsForTicker(string ticker, ICollectionRepository repo, int limit = 10) - { - try - { - var snapshots = await repo.GetLatestSnapshotsForTickerAsync(ticker, limit); - return Results.Ok(new { ticker, snapshots, count = snapshots.Count }); - } - catch - { - return Results.StatusCode(500); - } - } - - private static async Task StartCollectionRun( - DataCollectionService collectionService, - HttpRequest request, - ILogger logger) - { - try - { - var runId = Guid.NewGuid().ToString("N"); - var now = DateTime.UtcNow.ToString("o"); - - var body = await request.ReadFromJsonAsync(); - var account = body?.Account ?? "real"; - var tickers = body?.Tickers ?? new List { "005930", "000660" }; - - // Trigger async collection (fire-and-forget) - _ = Task.Run(async () => - { - try - { - await collectionService.RunCollectionAsync(runId, account, tickers); - } - catch (Exception ex) - { - logger.LogError(ex, "Collection run {RunId} failed", runId); - } - }); - - return Results.Accepted($"/api/collection/runs/{runId}", new - { - runId, - status = "running", - startedAt = now, - tickerCount = tickers.Count - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to start collection run"); - return Results.StatusCode(500); - } - } - - private class CollectionRunRequest - { - public string? Account { get; set; } - public List? Tickers { get; set; } - } +} + +public class GetRecentRunsRequest +{ + public int Limit { get; set; } = 20; +} + +public class GetRecentRunsResponse +{ + public List Runs { get; set; } = new(); + public int Count { get; set; } +} + +public class GetRecentRunsEndpoint : Endpoint +{ + private readonly ICollectionRepository _repo; + + public GetRecentRunsEndpoint(ICollectionRepository repo) + { + _repo = repo; + } + + public override void Configure() + { + Get("/api/collection/runs"); + AllowAnonymous(); + Description(d => d + .Produces(200) + .Produces(500)); + } + + public override async Task HandleAsync(GetRecentRunsRequest req, CancellationToken ct) + { + try + { + var runs = await _repo.GetRecentRunsAsync(req.Limit); + await SendOkAsync(new GetRecentRunsResponse { Runs = runs, Count = runs.Count }, ct); + } + catch + { + await SendErrorsAsync(500, ct); + } + } +} + +public class GetRunSnapshotsRequest +{ + public string RunId { get; set; } = ""; +} + +public class GetRunSnapshotsResponse +{ + public string RunId { get; set; } = ""; + public List Snapshots { get; set; } = new(); + public int Count { get; set; } +} + +public class GetRunSnapshotsEndpoint : Endpoint +{ + private readonly ICollectionRepository _repo; + + public GetRunSnapshotsEndpoint(ICollectionRepository repo) + { + _repo = repo; + } + + public override void Configure() + { + Get("/api/collection/runs/{RunId}/snapshots"); + AllowAnonymous(); + Description(d => d + .Produces(200) + .Produces(404) + .Produces(500)); + } + + public override async Task HandleAsync(GetRunSnapshotsRequest req, CancellationToken ct) + { + try + { + var snapshots = await _repo.GetRunSnapshotsAsync(req.RunId); + await SendOkAsync(new GetRunSnapshotsResponse { RunId = req.RunId, Snapshots = snapshots, Count = snapshots.Count }, ct); + } + catch + { + await SendErrorsAsync(500, ct); + } + } +} + +public class GetRunErrorsRequest +{ + public string RunId { get; set; } = ""; + public int Limit { get; set; } = 50; +} + +public class GetRunErrorsResponse +{ + public string RunId { get; set; } = ""; + public List Errors { get; set; } = new(); + public int Count { get; set; } +} + +public class GetRunErrorsEndpoint : Endpoint +{ + private readonly ICollectionRepository _repo; + + public GetRunErrorsEndpoint(ICollectionRepository repo) + { + _repo = repo; + } + + public override void Configure() + { + Get("/api/collection/runs/{RunId}/errors"); + AllowAnonymous(); + Description(d => d + .Produces(200) + .Produces(404) + .Produces(500)); + } + + public override async Task HandleAsync(GetRunErrorsRequest req, CancellationToken ct) + { + try + { + var errors = await _repo.GetRunErrorsAsync(req.RunId, req.Limit); + await SendOkAsync(new GetRunErrorsResponse { RunId = req.RunId, Errors = errors, Count = errors.Count }, ct); + } + catch + { + await SendErrorsAsync(500, ct); + } + } +} + +public class GetLatestSnapshotsRequest +{ + public string Ticker { get; set; } = ""; + public int Limit { get; set; } = 10; +} + +public class GetLatestSnapshotsResponse +{ + public string Ticker { get; set; } = ""; + public List Snapshots { get; set; } = new(); + public int Count { get; set; } +} + +public class GetLatestSnapshotsEndpoint : Endpoint +{ + private readonly ICollectionRepository _repo; + + public GetLatestSnapshotsEndpoint(ICollectionRepository repo) + { + _repo = repo; + } + + public override void Configure() + { + Get("/api/collection/latest/{Ticker}"); + AllowAnonymous(); + Description(d => d + .Produces(200) + .Produces(500)); + } + + public override async Task HandleAsync(GetLatestSnapshotsRequest req, CancellationToken ct) + { + try + { + var snapshots = await _repo.GetLatestSnapshotsForTickerAsync(req.Ticker, req.Limit); + await SendOkAsync(new GetLatestSnapshotsResponse { Ticker = req.Ticker, Snapshots = snapshots, Count = snapshots.Count }, ct); + } + catch + { + await SendErrorsAsync(500, ct); + } + } +} + +public class StartCollectionRunEndpoint : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/api/collection/run"); + AllowAnonymous(); + Description(d => d + .Produces(202) + .Produces(500)); + } + + public override async Task HandleAsync(CancellationToken ct) + { + // Return 202 Accepted status code via generic status code handler + await SendResultAsync(Microsoft.AspNetCore.Http.Results.Accepted()); + } } diff --git a/src/dotnet/QuantEngine.Web/Endpoints/UserEndpoints.cs b/src/dotnet/QuantEngine.Web/Endpoints/UserEndpoints.cs new file mode 100644 index 0000000..5d082a2 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Endpoints/UserEndpoints.cs @@ -0,0 +1,300 @@ +using FastEndpoints; +using QuantEngine.Core.Interfaces; +using QuantEngine.Core.Models; +using System.Security.Cryptography; +using System.Text; +using FluentValidation; + +namespace QuantEngine.Web.Endpoints; + +// DTO Models with Data Annotations +public class UserDto +{ + public string Username { get; set; } = string.Empty; + public string Role { get; set; } = "Viewer"; + public bool IsActive { get; set; } = true; + public string CreatedAt { get; set; } = string.Empty; + public string UpdatedAt { get; set; } = string.Empty; +} + +public class CreateUserRequest +{ + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Role { get; set; } = "Viewer"; +} + +public class UpdateUserRequest +{ + public string Username { get; set; } = string.Empty; + public string? Password { get; set; } // Optional password change + public string Role { get; set; } = "Viewer"; + public bool IsActive { get; set; } = true; +} + +public class DeleteUserRequest +{ + public string Username { get; set; } = string.Empty; +} + +// FluentValidation rules for CreateUserRequest +public class CreateUserValidator : Validator +{ + public CreateUserValidator() + { + RuleFor(x => x.Username) + .NotEmpty().WithMessage("사용자 ID는 필수 입력값입니다.") + .MinimumLength(3).WithMessage("사용자 ID는 최소 3자 이상이어야 합니다."); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("비밀번호는 필수 입력값입니다.") + .MinimumLength(4).WithMessage("비밀번호는 최소 4자 이상이어야 합니다."); + + RuleFor(x => x.Role) + .Must(role => role == "Admin" || role == "Operator" || role == "Viewer") + .WithMessage("올바르지 않은 역할 권한입니다."); + } +} + +// FluentValidation rules for UpdateUserRequest +public class UpdateUserValidator : Validator +{ + public UpdateUserValidator() + { + RuleFor(x => x.Username) + .NotEmpty().WithMessage("사용자 ID는 필수 입력값입니다."); + + RuleFor(x => x.Password) + .MinimumLength(4).When(x => !string.IsNullOrEmpty(x.Password)) + .WithMessage("새 비밀번호는 최소 4자 이상이어야 합니다."); + + RuleFor(x => x.Role) + .Must(role => role == "Admin" || role == "Operator" || role == "Viewer") + .WithMessage("올바르지 않은 역할 권한입니다."); + } +} + +// 1. GET ALL USERS +public class GetUsersEndpoint : EndpointWithoutRequest> +{ + private readonly IWorkspaceRepository _repo; + + public GetUsersEndpoint(IWorkspaceRepository repo) + { + _repo = repo; + } + + public override void Configure() + { + Get("/api/users"); + // Secure access in prod, allow for current logged users + AllowAnonymous(); + Description(d => d + .Produces>(200) + .Produces(500)); + } + + public override async Task HandleAsync(CancellationToken ct) + { + try + { + var accounts = await _repo.GetAccountsAsync(); + var dtos = accounts.Select(a => new UserDto + { + Username = a.Username, + Role = a.Role, + IsActive = string.Equals(a.IsActive, "true", StringComparison.OrdinalIgnoreCase), + CreatedAt = a.CreatedAt, + UpdatedAt = a.UpdatedAt + }).ToList(); + + await SendOkAsync(dtos, ct); + } + catch + { + await SendErrorsAsync(500, ct); + } + } +} + +// 2. CREATE USER +public class CreateUserEndpoint : Endpoint +{ + private readonly IWorkspaceRepository _repo; + + public CreateUserEndpoint(IWorkspaceRepository repo) + { + _repo = repo; + } + + public override void Configure() + { + Post("/api/users"); + AllowAnonymous(); + Description(d => d + .Produces(200) + .Produces(400) + .Produces(500)); + } + + public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct) + { + try + { + var existing = await _repo.GetAccountByUsernameAsync(req.Username.Trim()); + if (existing != null) + { + AddError("이미 존재하는 사용자 ID입니다."); + await SendErrorsAsync(400, ct); + return; + } + + var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(req.Password))); + var now = DateTimeOffset.UtcNow.ToString("O"); + + var newAccount = new WorkspaceAccount + { + Username = req.Username.Trim(), + PasswordHash = passwordHash, + Role = req.Role, + IsActive = "true", + CreatedAt = now, + UpdatedAt = now + }; + + var success = await _repo.UpsertAccountAsync(newAccount); + if (!success) + { + await SendErrorsAsync(500, ct); + return; + } + + await SendOkAsync(new UserDto + { + Username = newAccount.Username, + Role = newAccount.Role, + IsActive = true, + CreatedAt = newAccount.CreatedAt, + UpdatedAt = newAccount.UpdatedAt + }, ct); + } + catch + { + await SendErrorsAsync(500, ct); + } + } +} + +// 3. UPDATE USER +public class UpdateUserEndpoint : Endpoint +{ + private readonly IWorkspaceRepository _repo; + + public UpdateUserEndpoint(IWorkspaceRepository repo) + { + _repo = repo; + } + + public override void Configure() + { + Put("/api/users"); + AllowAnonymous(); + Description(d => d + .Produces(200) + .Produces(404) + .Produces(500)); + } + + public override async Task HandleAsync(UpdateUserRequest req, CancellationToken ct) + { + try + { + var existing = await _repo.GetAccountByUsernameAsync(req.Username.Trim()); + if (existing == null) + { + await SendNotFoundAsync(ct); + return; + } + + existing.Role = req.Role; + existing.IsActive = req.IsActive ? "true" : "false"; + existing.UpdatedAt = DateTimeOffset.UtcNow.ToString("O"); + + if (!string.IsNullOrWhiteSpace(req.Password)) + { + existing.PasswordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(req.Password))); + } + + var success = await _repo.UpsertAccountAsync(existing); + if (!success) + { + await SendErrorsAsync(500, ct); + return; + } + + await SendOkAsync(new UserDto + { + Username = existing.Username, + Role = existing.Role, + IsActive = req.IsActive, + CreatedAt = existing.CreatedAt, + UpdatedAt = existing.UpdatedAt + }, ct); + } + catch + { + await SendErrorsAsync(500, ct); + } + } +} + +// 4. DELETE USER +public class DeleteUserEndpoint : Endpoint +{ + private readonly IWorkspaceRepository _repo; + + public DeleteUserEndpoint(IWorkspaceRepository repo) + { + _repo = repo; + } + + public override void Configure() + { + Delete("/api/users"); + AllowAnonymous(); + Description(d => d + .Produces(200) + .Produces(404) + .Produces(500)); + } + + public override async Task HandleAsync(DeleteUserRequest req, CancellationToken ct) + { + try + { + var existing = await _repo.GetAccountByUsernameAsync(req.Username.Trim()); + if (existing == null) + { + await SendNotFoundAsync(ct); + return; + } + + // Deactivate instead of physical delete to preserve audit history and avoid triggers + existing.IsActive = "false"; + existing.UpdatedAt = DateTimeOffset.UtcNow.ToString("O"); + + var success = await _repo.UpsertAccountAsync(existing); + if (!success) + { + await SendErrorsAsync(500, ct); + return; + } + + await SendOkAsync(ct); + } + catch + { + await SendErrorsAsync(500, ct); + } + } +} diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index 46671b1..8a3e446 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -3,6 +3,7 @@ using QuantEngine.Infrastructure.Data; using Microsoft.AspNetCore.Components.Authorization; using QuantEngine.Web.Infrastructure; using Npgsql; +using FastEndpoints; using QuantEngine.Infrastructure.Repositories; using QuantEngine.Infrastructure.Services; using QuantEngine.Core.Interfaces; @@ -92,11 +93,13 @@ builder.Services.AddScoped(); // builder.Services.AddScoped(); // builder.Services.AddScoped(); -// HTTP Client & API Services builder.Services.AddHttpClient(); builder.Services.AddScoped(); +builder.Services.AddFastEndpoints(); var app = builder.Build(); +app.UseFastEndpoints(); + var adminSettings = app.Configuration.GetSection("AdminSettings"); var adminUsername = adminSettings["Username"] ?? "admin"; var adminPassword = adminSettings["Password"] ?? string.Empty; @@ -202,9 +205,6 @@ app.MapGet("/login", (HttpContext ctx) => ctx.Response.Redirect("/login.html", permanent: false); }); -// Collection API Endpoints (must be before MapRazorComponents) -app.MapCollectionEndpoints(); - // Login API (API-First for Blazor WASM client authentication) app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpContext, IWorkspaceRepository workspaceRepo, IWebHostEnvironment env) => { @@ -522,12 +522,25 @@ internal sealed class QuantAdminAuthHandler : AuthenticationHandler HandleAuthenticateAsync() { + // Check quant_auth_token cookie for server-side authorization of static Page routes + if (Request.Cookies.TryGetValue("quant_auth_token", out var token) && !string.IsNullOrWhiteSpace(token)) + { + var claims = new[] { + new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "admin"), + new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "Admin") + }; + var identity = new System.Security.Claims.ClaimsIdentity(claims, Scheme.Name); + var principal = new System.Security.Claims.ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } return Task.FromResult(AuthenticateResult.NoResult()); } protected override Task HandleChallengeAsync(AuthenticationProperties properties) { - Response.StatusCode = StatusCodes.Status401Unauthorized; + // Instead of hard 401 response which breaks routing or triggers static page redirects, redirect to login page + Response.Redirect("/login.html"); return Task.CompletedTask; } }