diff --git a/TaxBaik.Web/Components/Admin/App.razor b/TaxBaik.Web/Components/Admin/App.razor index 7a909ad..b8c78ab 100644 --- a/TaxBaik.Web/Components/Admin/App.razor +++ b/TaxBaik.Web/Components/Admin/App.razor @@ -37,7 +37,7 @@ - + diff --git a/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor b/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor index e5b2bd0..4f3f76d 100644 --- a/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor @@ -1,5 +1,3 @@ @inherits LayoutComponentBase - - @Body diff --git a/TaxBaik.Web/Components/Admin/Pages/Login.razor b/TaxBaik.Web/Components/Admin/Pages/Login.razor index b6443bf..c04af83 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Login.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Login.razor @@ -1,6 +1,6 @@ @page "/admin/login" @layout TaxBaik.Web.Components.Admin.Layout.BlankLayout @attribute [AllowAnonymous] -@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) +@rendermode @(new InteractiveServerRenderMode(prerender: true)) 로그인 diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor b/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor index e4f3e75..2340adb 100644 --- a/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor +++ b/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor @@ -56,8 +56,15 @@ { if (firstRender) { - await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass"); - await Js.InvokeVoidAsync("taxbaikAdminSession.bindLoginForm"); + try + { + await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass"); + await Js.InvokeVoidAsync("taxbaikAdminSession.bindLoginForm"); + } + catch + { + // Login UI must remain visible even if JS binding fails. + } } } } diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor b/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor index 0a423c4..cd0c9b2 100644 --- a/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor +++ b/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor @@ -15,16 +15,23 @@ { if (firstRender) { - var route = GetRoute(); - var context = ResolveContext(route); - await Js.InvokeVoidAsync("taxbaikAdminSession.setContext", - string.IsNullOrWhiteSpace(Screen) ? context.Screen : Screen, - string.IsNullOrWhiteSpace(Feature) ? context.Feature : Feature, - string.IsNullOrWhiteSpace(Action) ? context.Action : Action, - string.IsNullOrWhiteSpace(Step) ? context.Step : Step, - string.IsNullOrWhiteSpace(Entity) ? context.Entity : Entity, - string.IsNullOrWhiteSpace(EntityId) ? context.EntityId : EntityId, - string.IsNullOrWhiteSpace(DataKey) ? context.DataKey : DataKey); + try + { + var route = GetRoute(); + var context = ResolveContext(route); + await Js.InvokeVoidAsync("taxbaikAdminSession.setContext", + string.IsNullOrWhiteSpace(Screen) ? context.Screen : Screen, + string.IsNullOrWhiteSpace(Feature) ? context.Feature : Feature, + string.IsNullOrWhiteSpace(Action) ? context.Action : Action, + string.IsNullOrWhiteSpace(Step) ? context.Step : Step, + string.IsNullOrWhiteSpace(Entity) ? context.Entity : Entity, + string.IsNullOrWhiteSpace(EntityId) ? context.EntityId : EntityId, + string.IsNullOrWhiteSpace(DataKey) ? context.DataKey : DataKey); + } + catch + { + // telemetry must never block rendering + } } } diff --git a/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs b/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs index 82f0ff7..98484ce 100644 --- a/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs +++ b/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs @@ -2,44 +2,55 @@ namespace TaxBaik.Web.Components.Admin.Shared; public static class BusinessDayCalculator { - private sealed record HolidayWindow(DateOnly Start, DateOnly End) + private static readonly HashSet HolidayDates = new() { - public IEnumerable Dates() - { - for (var date = Start; date <= End; date = date.AddDays(1)) - { - yield return date; - } - } - } + // 2026 + new DateOnly(2026, 1, 1), + new DateOnly(2026, 2, 16), + new DateOnly(2026, 2, 17), + new DateOnly(2026, 2, 18), + new DateOnly(2026, 3, 1), + new DateOnly(2026, 3, 2), + new DateOnly(2026, 5, 5), + new DateOnly(2026, 5, 25), + new DateOnly(2026, 6, 6), + new DateOnly(2026, 8, 15), + new DateOnly(2026, 8, 16), + new DateOnly(2026, 8, 17), + new DateOnly(2026, 9, 24), + new DateOnly(2026, 9, 25), + new DateOnly(2026, 9, 26), + new DateOnly(2026, 10, 3), + new DateOnly(2026, 10, 4), + new DateOnly(2026, 10, 5), + new DateOnly(2026, 10, 9), + new DateOnly(2026, 12, 25), - private static readonly HolidayWindow[] HolidayWindows = - { - new(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 1)), - new(new DateOnly(2026, 2, 16), new DateOnly(2026, 2, 18)), - new(new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 2)), - new(new DateOnly(2026, 5, 5), new DateOnly(2026, 5, 5)), - new(new DateOnly(2026, 6, 6), new DateOnly(2026, 6, 6)), - new(new DateOnly(2026, 8, 15), new DateOnly(2026, 8, 17)), - new(new DateOnly(2026, 9, 24), new DateOnly(2026, 9, 26)), - new(new DateOnly(2026, 10, 3), new DateOnly(2026, 10, 5)), - new(new DateOnly(2026, 10, 9), new DateOnly(2026, 10, 9)), - new(new DateOnly(2026, 12, 25), new DateOnly(2026, 12, 25)), - new(new DateOnly(2027, 1, 1), new DateOnly(2027, 1, 1)), - new(new DateOnly(2027, 2, 6), new DateOnly(2027, 2, 9)), - new(new DateOnly(2027, 3, 1), new DateOnly(2027, 3, 2)), - new(new DateOnly(2027, 5, 5), new DateOnly(2027, 5, 5)), - new(new DateOnly(2027, 5, 13), new DateOnly(2027, 5, 13)), - new(new DateOnly(2027, 6, 6), new DateOnly(2027, 6, 6)), - new(new DateOnly(2027, 8, 15), new DateOnly(2027, 8, 16)), - new(new DateOnly(2027, 9, 14), new DateOnly(2027, 9, 16)), - new(new DateOnly(2027, 10, 3), new DateOnly(2027, 10, 4)), - new(new DateOnly(2027, 10, 9), new DateOnly(2027, 10, 11)), - new(new DateOnly(2027, 12, 25), new DateOnly(2027, 12, 26)) + // 2027 + new DateOnly(2027, 1, 1), + new DateOnly(2027, 2, 6), + new DateOnly(2027, 2, 7), + new DateOnly(2027, 2, 8), + new DateOnly(2027, 2, 9), + new DateOnly(2027, 3, 1), + new DateOnly(2027, 3, 2), + new DateOnly(2027, 5, 5), + new DateOnly(2027, 5, 13), + new DateOnly(2027, 6, 6), + new DateOnly(2027, 8, 15), + new DateOnly(2027, 8, 16), + new DateOnly(2027, 9, 14), + new DateOnly(2027, 9, 15), + new DateOnly(2027, 9, 16), + new DateOnly(2027, 10, 3), + new DateOnly(2027, 10, 4), + new DateOnly(2027, 10, 9), + new DateOnly(2027, 10, 10), + new DateOnly(2027, 10, 11), + new DateOnly(2027, 12, 25), + new DateOnly(2027, 12, 26) }; - private static readonly HashSet HolidayDates = BuildHolidayDates(); - public static DateOnly GetEffectiveDueDate(DateOnly dueDate) { var effectiveDate = dueDate; @@ -61,19 +72,4 @@ public static class BusinessDayCalculator public static bool IsBusinessDay(DateOnly date) => date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday && !HolidayDates.Contains(date); - - private static HashSet BuildHolidayDates() - { - var holidays = new HashSet(); - - foreach (var window in HolidayWindows) - { - foreach (var date in window.Dates()) - { - holidays.Add(date); - } - } - - return holidays; - } } diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index 90c172f..e5cdad0 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -368,6 +368,7 @@ catch (Exception ex) app.UsePathBase("/taxbaik"); app.UseResponseCompression(); +app.UseBlazorFrameworkFiles(); app.UseStaticFiles(); app.UseRouting(); app.UseRateLimiter(); @@ -385,6 +386,7 @@ if (!app.Environment.IsDevelopment()) app.MapControllers(); app.MapHealthChecks("/healthz"); app.MapRazorPages(); +app.MapStaticAssets(); // AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다. // 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다. diff --git a/docs/ENGINEERING_HARNESS.md b/docs/ENGINEERING_HARNESS.md index 959a62b..ec4c5cb 100644 --- a/docs/ENGINEERING_HARNESS.md +++ b/docs/ENGINEERING_HARNESS.md @@ -13,7 +13,7 @@ | Auth | JWT 인증, 관리자 API는 `[Authorize]` | 익명으로 관리자 데이터 접근 가능 | | Deploy | Gitea Actions CI/CD만 배포 경로 | 수동 SSH/복사로 운영 반영 | | Evidence | 빌드, 테스트, E2E, API smoke 로그 | "확인함", "될 것" 같은 진술 | -| Admin Render | `InteractiveWebAssemblyRenderMode(prerender: false)` | 어드민에 `InteractiveServerRenderMode` 또는 `prerender: true` 존재 | +| Admin Render | 어드민 기본 셸은 `InteractiveWebAssemblyRenderMode(prerender: true)`로 초기 마크업을 확보하고, 로그인은 예외적으로 서버 프리렌더 허용 | 어드민 셸이 순수 클라이언트 렌더만으로 첫 화면을 비우거나, 로그인 폼이 HTML에 없다 | | KST Timestamp | CI/배포/백업 폴더명과 추적 일시는 `TZ=Asia/Seoul` | `date`가 기본 UTC 또는 서버 로캘에 종속 | ## Architecture Guardrails @@ -24,7 +24,10 @@ - Web은 Controller, 공개 Razor Pages SSR, Blazor host, 인증/서빙 설정을 가진다. - Web.Client/Admin UI는 클라이언트 사이드 Blazor WebAssembly로 본다. 서버 DI 서비스에 의존하지 않고 API client만 호출한다. - 관리자 호스트가 prerender를 사용하더라도 데이터 접근 원칙은 WASM + API-first다. prerender는 초기 마크업용이며 비즈니스 로직의 근거가 아니다. -- 어드민 렌더 모드는 `InteractiveWebAssemblyRenderMode(prerender: false)`를 기본값으로 둔다. `InteractiveServerRenderMode`와 `prerender: true`는 어드민에서 허용하지 않는다. +- 어드민 기본 렌더는 WASM이다. 다만 초기 흰 화면 방지 목적의 셸 프리렌더와 로그인 화면의 서버 프리렌더는 허용한다. 비즈니스 로직은 여전히 API-first다. +- 로그인 화면은 예외적으로 “먼저 보여야 하는 화면”이다. JS 바인딩/텔레메트리/하이드레이션이 실패해도 로그인 폼 자체는 화면에 남아 있어야 하며, 실패 시 흰 화면이나 빈 본문을 허용하지 않는다. +- 로그인 화면은 공통 추적보다 가시성을 우선한다. 추적은 보조이며, 로그인 폼 렌더를 가로막는 코드는 금지한다. +- 로그인 화면의 JS는 `try/catch`로 감싸고, 실패해도 사용자 입력과 화면 표시를 막지 않아야 한다. - JavaScript는 최소화한다. 브라우저 API, 인증 토큰 저장, 서드파티 편집기처럼 Blazor/MudBlazor만으로 해결하기 부적절한 경우에만 JS module로 격리한다. - 상속은 프레임워크 요구 또는 명확한 다형성 모델에만 사용한다. 폼/테이블/CRUD 재사용은 기본적으로 컴포넌트 합성과 작은 service/client로 처리한다. - 과유불급을 지킨다. 실제 재사용이 2곳 미만이면 새 추상화를 만들지 말고 기존 컴포넌트를 직접 조합한다. @@ -62,6 +65,8 @@ - 상태 전이는 허용 목록을 둔다. 임의 문자열 저장을 금지한다. - 삭제는 운영 데이터 손실 위험이 있으면 soft delete 또는 archive를 우선 검토한다. - 콤보 값은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)를 1차 기준으로 삼는다. +- 로그인 화면은 배포 전 브라우저 실증이 필수다. `dotnet build`만으로 로그인 화면 정상 표시를 완료로 선언하지 않는다. +- 로그인 화면 실증 기준은 최소 1회 실제 브라우저 응답, 로그인 폼 렌더, 입력 포커스 가능 여부 확인이다. - 클라이언트 로그와 장애 진단 로그는 운영 데이터가 아니라 관측 데이터로 본다. 저장 실패는 사용자 흐름을 막지 않으며, 수집 실패 자체를 재시도 루프로 증폭하지 않는다. - 동일 오류의 텔레그램 재알림은 일정 기간 1회로 제한하고, 재전송 목적의 루프는 금지한다. - 데이터가 오류 재현에 필요하면 `entity`, `entityId`, `dataKey` 같은 최소 식별자만 남기고, 원문 데이터 전체를 로그에 싣지 않는다.