feat(admin): restore Blazor WebAssembly architecture for admin pages with hybrid Server routing
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m40s

This commit is contained in:
2026-07-01 11:21:10 +09:00
parent 62ce89359a
commit 2a046d0393
30 changed files with 168 additions and 10 deletions
+2
View File
@@ -0,0 +1,2 @@
global using System.Net.Http;
global using System.Net.Http.Json;
+13
View File
@@ -0,0 +1,13 @@
@* WASM 기반(M3) 검증용 컴포넌트. 라우팅/렌더모드 전면 적용은 M4에서 처리한다. *@
@rendermode InteractiveWebAssembly
<MudPaper Class="pa-6 ma-4" Elevation="2">
<MudText Typo="Typo.h5" GutterBottom="true">WebAssembly 렌더 모드 점검</MudText>
<MudText Typo="Typo.body2" Class="mb-4">이 컴포넌트가 클릭에 반응하면 Interactive WebAssembly 기반이 정상 동작하는 것입니다.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Increment">카운트: @count</MudButton>
</MudPaper>
@code {
private int count;
private void Increment() => count++;
}
+51
View File
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
using TaxBaik.Web.Services.AdminClients;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// MudBlazor (WASM 측 인터랙티브 컴포넌트용)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
});
// API Base Url 동적 구성 (호스트 기준 /taxbaik/api/)
var apiBaseUrl = builder.HostEnvironment.BaseAddress.TrimEnd('/') + "/taxbaik/api/";
// HTTP Client for API (with automatic token refresh)
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped<TokenRefreshHandler>();
builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
// 각 Browser API Client 등록
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
// Blazor 인증 (WASM 측 클라이언트)
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();
await builder.Build().RunAsync();
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Services;
@@ -8,18 +9,18 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly ITokenStore _tokenStore;
private readonly AuthService _authService;
private readonly IApiClient _apiClient;
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
public CustomAuthenticationStateProvider(
ILocalStorageService localStorage,
ITokenStore tokenStore,
AuthService authService,
IApiClient apiClient,
ILogger<CustomAuthenticationStateProvider> logger)
{
_localStorage = localStorage;
_tokenStore = tokenStore;
_authService = authService;
_apiClient = apiClient;
_logger = logger;
}
@@ -64,8 +65,9 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
{
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
var newTokenPair = await _authService.RefreshAccessTokenAsync(_tokenStore.RefreshToken);
if (newTokenPair != null)
var request = new { RefreshToken = _tokenStore.RefreshToken };
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
{
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
_logger.LogInformation("토큰 자동 갱신 성공");
@@ -79,7 +81,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
var principal = _authService.ValidateToken(accessToken ?? string.Empty);
var principal = ValidateTokenWithoutDb(accessToken ?? string.Empty);
if (principal == null)
{
await LogoutAsync();
@@ -95,6 +97,22 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
private ClaimsPrincipal? ValidateTokenWithoutDb(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
var identity = new ClaimsIdentity(jwtToken.Claims, "jwt");
return new ClaimsPrincipal(identity);
}
catch
{
return null;
}
}
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
{
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
@@ -158,3 +176,17 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
}
public class WasmAuthTokenPair
{
public WasmAuthTokenPair() { }
public WasmAuthTokenPair(string accessToken, string refreshToken, int expiresIn)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresIn = expiresIn;
}
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
}
@@ -62,7 +62,7 @@ public class TokenRefreshHandler : DelegatingHandler
return response;
}
private async Task<AuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
private async Task<WasmAuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
{
try
{
@@ -87,7 +87,7 @@ public class TokenRefreshHandler : DelegatingHandler
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result != null
? new AuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
? new WasmAuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
: null;
}
catch (Exception ex)
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>TaxBaik.WasmClient</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
</ItemGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using MudBlazor
@using TaxBaik.WasmClient
@using static Microsoft.AspNetCore.Components.Web.RenderMode
+1 -1
View File
@@ -32,7 +32,7 @@
</div>
</div>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<Routes />
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/admin-session.js"></script>
<script src="_framework/blazor.web.js"></script>
@@ -2,6 +2,7 @@
@inject NavigationManager Navigation
@inject IJSRuntime JS
@implements IDisposable
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
<MudPopoverProvider />
<MudDialogProvider />
@@ -1,5 +1,6 @@
@page "/admin/blog/create"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@@ -1,5 +1,6 @@
@page "/admin/blog/{id:int}/edit"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
+5 -1
View File
@@ -54,7 +54,9 @@ builder.Services.AddHealthChecks();
// Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DetailedErrors = true;
@@ -354,6 +356,8 @@ app.MapRazorPages();
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(TaxBaik.WasmClient._Imports).Assembly)
.AllowAnonymous();
// 애플리케이션 시작/종료 로깅
+2
View File
@@ -3,6 +3,7 @@
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
<ProjectReference Include="..\TaxBaik.Infrastructure\TaxBaik.Infrastructure.csproj" />
<ProjectReference Include="..\TaxBaik.Web.Client\TaxBaik.Web.Client.csproj" />
</ItemGroup>
<PropertyGroup>
@@ -12,6 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9" />
<PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
+14
View File
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Web", "TaxBaik.Web\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Application.Tests", "TaxBaik.Application.Tests\TaxBaik.Application.Tests.csproj", "{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Web.Client", "TaxBaik.Web.Client\TaxBaik.Web.Client.csproj", "{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,6 +85,18 @@ Global
{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x64.Build.0 = Release|Any CPU
{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x86.ActiveCfg = Release|Any CPU
{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x86.Build.0 = Release|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Debug|x64.ActiveCfg = Debug|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Debug|x64.Build.0 = Debug|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Debug|x86.ActiveCfg = Debug|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Debug|x86.Build.0 = Debug|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Release|Any CPU.Build.0 = Release|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Release|x64.ActiveCfg = Release|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Release|x64.Build.0 = Release|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Release|x86.ActiveCfg = Release|Any CPU
{C46C51D4-9E87-47DF-AB76-2E794F64FD5F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE