FEAT: Migrate Collection and User Auth endpoints to FastEndpoints (API-First), implement MudDialog based Users CRUD (MVVM) and update AGENTS.md guidelines
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 10s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 1m5s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 10s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 1m5s
This commit is contained in:
@@ -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. 검증 규칙
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@page "/users"
|
||||
@attribute [Authorize]
|
||||
@using MudBlazor
|
||||
@inject HttpClient Http
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>QuantEngine - 사용자 관리</PageTitle>
|
||||
|
||||
@@ -32,25 +35,22 @@
|
||||
<MudTable Items="@FilteredUsers" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>이름</MudTh>
|
||||
<MudTh>이메일</MudTh>
|
||||
<MudTh>역할</MudTh>
|
||||
<MudTh>상태</MudTh>
|
||||
<MudTh>가입일</MudTh>
|
||||
<MudTh>생성일</MudTh>
|
||||
<MudTh>수정일</MudTh>
|
||||
<MudTh>작업</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<MudAvatar Size="Size.Small" Color="Color.Primary">@context.Name[0]</MudAvatar>
|
||||
<MudText Typo="Typo.body2">@context.Name</MudText>
|
||||
<MudAvatar Size="Size.Small" Color="Color.Primary">@context.Username[0].ToString().ToUpper()</MudAvatar>
|
||||
<MudText Typo="Typo.body2">@context.Username</MudText>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Email">
|
||||
<MudText Typo="Typo.body2">@context.Email</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Role">
|
||||
<MudChip T="string" Label="true" Size="Size.Small"
|
||||
Color="@(context.Role == "Admin" ? Color.Primary : Color.Default)"
|
||||
Color="@(context.Role == "Admin" ? Color.Primary : (context.Role == "Operator" ? Color.Secondary : Color.Default))"
|
||||
Variant="Variant.Filled">
|
||||
@context.Role
|
||||
</MudChip>
|
||||
@@ -63,7 +63,10 @@
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Joined">
|
||||
<MudText Typo="Typo.body2">@context.CreatedDate.ToString("yyyy-MM-dd")</MudText>
|
||||
<MudText Typo="Typo.body2">@FormatDate(context.CreatedAt)</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Updated">
|
||||
<MudText Typo="Typo.body2">@FormatDate(context.UpdatedAt)</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" OnClick="@(() => EditUser(context))">편집</MudButton>
|
||||
@@ -74,16 +77,54 @@
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private List<UserModel> _users = new();
|
||||
private string SearchQuery = "";
|
||||
<!-- Add/Edit Dialog -->
|
||||
<MudDialog @bind-Visible="_dialogVisible" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">
|
||||
<MudIcon Icon="@(_isEditMode ? Icons.Material.Filled.Edit : Icons.Material.Filled.Add)" Class="mr-3" />
|
||||
@(_isEditMode ? "사용자 편집" : "새 사용자 추가")
|
||||
</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm Model="@_formModel" @ref="_form">
|
||||
<MudTextField T="string" @bind-Value="_formModel.Username" Label="사용자 ID" Required="true" Disabled="@_isEditMode"
|
||||
RequiredError="사용자 ID를 입력해 주세요." Class="mb-3" />
|
||||
|
||||
private IEnumerable<UserModel> FilteredUsers
|
||||
<MudTextField T="string" @bind-Value="_formModel.Password" Label="@(_isEditMode ? "새 비밀번호 (미입력시 유지)" : "비밀번호")"
|
||||
InputType="InputType.Password" Required="@(!_isEditMode)" RequiredError="비밀번호를 입력해 주세요." Class="mb-3" />
|
||||
|
||||
<MudSelect T="string" @bind-Value="_formModel.Role" Label="역할 권한" Required="true" Class="mb-3">
|
||||
<MudSelectItem Value="@("Admin")">Admin (관리자)</MudSelectItem>
|
||||
<MudSelectItem Value="@("Operator")">Operator (운영자)</MudSelectItem>
|
||||
<MudSelectItem Value="@("Viewer")">Viewer (조회자)</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
@if (_isEditMode)
|
||||
{
|
||||
<MudSwitch T="bool" @bind-Value="_formModel.IsActive" Color="Color.Success" Label="계정 활성화 상태" />
|
||||
}
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CloseDialog" Class="px-5">취소</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveUser" Class="px-5">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<UserDto> _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<UserDto> 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<UserModel>
|
||||
var res = await Http.GetFromJsonAsync<List<UserDto>>("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 (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"사용자 목록 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenAddUserDialog()
|
||||
{
|
||||
_isEditMode = false;
|
||||
_formModel = new UserFormModel { Role = "Viewer", IsActive = true };
|
||||
_dialogVisible = true;
|
||||
}
|
||||
|
||||
private void EditUser(UserDto user)
|
||||
{
|
||||
_isEditMode = true;
|
||||
_formModel = new UserFormModel
|
||||
{
|
||||
Username = user.Username,
|
||||
Role = user.Role,
|
||||
IsActive = user.IsActive,
|
||||
Password = "" // Clear password field for security
|
||||
};
|
||||
_dialogVisible = true;
|
||||
}
|
||||
catch
|
||||
|
||||
private async Task DeleteUser(UserDto user)
|
||||
{
|
||||
// Handle error
|
||||
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 async Task OpenAddUserDialog()
|
||||
private void CloseDialog()
|
||||
{
|
||||
// Dialog implementation would go here
|
||||
await Task.CompletedTask;
|
||||
_dialogVisible = false;
|
||||
}
|
||||
|
||||
private async Task EditUser(UserModel user)
|
||||
private async Task SaveUser()
|
||||
{
|
||||
// Edit dialog implementation
|
||||
await Task.CompletedTask;
|
||||
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);
|
||||
}
|
||||
|
||||
private async Task DeleteUser(UserModel user)
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
// Delete confirmation and implementation
|
||||
await Task.CompletedTask;
|
||||
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 class UserModel
|
||||
private string FormatDate(string isoString)
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Role { get; set; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CollectionDashboardStateRecord>
|
||||
{
|
||||
public static void MapCollectionEndpoints(this WebApplication app)
|
||||
private readonly ICollectionRepository _repo;
|
||||
|
||||
public GetCollectionStateEndpoint(ICollectionRepository repo)
|
||||
{
|
||||
var group = app.MapGroup("/api/collection")
|
||||
.WithName("Collection");
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
group.MapGet("/state", GetCollectionState)
|
||||
.WithName("GetCollectionState")
|
||||
.Produces(200)
|
||||
.Produces(500);
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/collection/state");
|
||||
AllowAnonymous();
|
||||
Description(d => d
|
||||
.Produces<CollectionDashboardStateRecord>(200)
|
||||
.Produces(500));
|
||||
}
|
||||
|
||||
group.MapGet("/runs", GetRecentRuns)
|
||||
.WithName("GetRecentRuns")
|
||||
.Produces(200)
|
||||
.Produces(500);
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await _repo.GetDashboardStateAsync();
|
||||
await SendOkAsync(state, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await SendErrorsAsync(500, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.MapGet("/runs/{runId}/snapshots", GetRunSnapshots)
|
||||
.WithName("GetRunSnapshots")
|
||||
.Produces(200)
|
||||
public class GetRecentRunsRequest
|
||||
{
|
||||
public int Limit { get; set; } = 20;
|
||||
}
|
||||
|
||||
public class GetRecentRunsResponse
|
||||
{
|
||||
public List<CollectionRunRecord> Runs { get; set; } = new();
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class GetRecentRunsEndpoint : Endpoint<GetRecentRunsRequest, GetRecentRunsResponse>
|
||||
{
|
||||
private readonly ICollectionRepository _repo;
|
||||
|
||||
public GetRecentRunsEndpoint(ICollectionRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/collection/runs");
|
||||
AllowAnonymous();
|
||||
Description(d => d
|
||||
.Produces<GetRecentRunsResponse>(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<CollectionSnapshotRecord> Snapshots { get; set; } = new();
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class GetRunSnapshotsEndpoint : Endpoint<GetRunSnapshotsRequest, GetRunSnapshotsResponse>
|
||||
{
|
||||
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<GetRunSnapshotsResponse>(200)
|
||||
.Produces(404)
|
||||
.Produces(500);
|
||||
.Produces(500));
|
||||
}
|
||||
|
||||
group.MapGet("/runs/{runId}/errors", GetRunErrors)
|
||||
.WithName("GetRunErrors")
|
||||
.Produces(200)
|
||||
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<CollectionErrorRecord> Errors { get; set; } = new();
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class GetRunErrorsEndpoint : Endpoint<GetRunErrorsRequest, GetRunErrorsResponse>
|
||||
{
|
||||
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<GetRunErrorsResponse>(200)
|
||||
.Produces(404)
|
||||
.Produces(500);
|
||||
.Produces(500));
|
||||
}
|
||||
|
||||
group.MapGet("/latest/{ticker}", GetLatestSnapshotsForTicker)
|
||||
.WithName("GetLatestSnapshotsForTicker")
|
||||
.Produces(200)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.MapPost("/run", StartCollectionRun)
|
||||
.WithName("StartCollectionRun")
|
||||
public class GetLatestSnapshotsRequest
|
||||
{
|
||||
public string Ticker { get; set; } = "";
|
||||
public int Limit { get; set; } = 10;
|
||||
}
|
||||
|
||||
public class GetLatestSnapshotsResponse
|
||||
{
|
||||
public string Ticker { get; set; } = "";
|
||||
public List<CollectionSnapshotRecord> Snapshots { get; set; } = new();
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, GetLatestSnapshotsResponse>
|
||||
{
|
||||
private readonly ICollectionRepository _repo;
|
||||
|
||||
public GetLatestSnapshotsEndpoint(ICollectionRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/collection/latest/{Ticker}");
|
||||
AllowAnonymous();
|
||||
Description(d => d
|
||||
.Produces<GetLatestSnapshotsResponse>(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);
|
||||
.Produces(500));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCollectionState(ICollectionRepository repo)
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await repo.GetDashboardStateAsync();
|
||||
return Results.Ok(state);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.StatusCode(500);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> StartCollectionRun(
|
||||
DataCollectionService collectionService,
|
||||
HttpRequest request,
|
||||
ILogger<Program> logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var now = DateTime.UtcNow.ToString("o");
|
||||
|
||||
var body = await request.ReadFromJsonAsync<CollectionRunRequest>();
|
||||
var account = body?.Account ?? "real";
|
||||
var tickers = body?.Tickers ?? new List<string> { "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<string>? Tickers { get; set; }
|
||||
// Return 202 Accepted status code via generic status code handler
|
||||
await SendResultAsync(Microsoft.AspNetCore.Http.Results.Accepted());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CreateUserRequest>
|
||||
{
|
||||
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<UpdateUserRequest>
|
||||
{
|
||||
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<List<UserDto>>
|
||||
{
|
||||
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<List<UserDto>>(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<CreateUserRequest, UserDto>
|
||||
{
|
||||
private readonly IWorkspaceRepository _repo;
|
||||
|
||||
public CreateUserEndpoint(IWorkspaceRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users");
|
||||
AllowAnonymous();
|
||||
Description(d => d
|
||||
.Produces<UserDto>(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<UpdateUserRequest, UserDto>
|
||||
{
|
||||
private readonly IWorkspaceRepository _repo;
|
||||
|
||||
public UpdateUserEndpoint(IWorkspaceRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/users");
|
||||
AllowAnonymous();
|
||||
Description(d => d
|
||||
.Produces<UserDto>(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<DeleteUserRequest>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IKisApiClient, KisApiClient>();
|
||||
// builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
|
||||
// builder.Services.AddScoped<DataCollectionService>();
|
||||
|
||||
// HTTP Client & API Services
|
||||
builder.Services.AddHttpClient<ApiClient>();
|
||||
builder.Services.AddScoped<ApiClient>();
|
||||
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<Authenticati
|
||||
|
||||
protected override Task<AuthenticateResult> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user