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