Migrate SiteSettings controller to FastEndpoints

Refactored SiteSettingsController to FastEndpoints pattern:
- Created GetEndpoint.cs: GET /api/sitesettings (authorized)
- Created SaveEndpoint.cs: PUT /api/sitesettings (authorized)
- Removed legacy SiteSettingsController.cs

Both endpoints use Bearer token authentication and are auto-discovered
by FastEndpoints configuration in Program.cs.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 17:35:18 +09:00
parent 69ec7913d0
commit a6068e184b
13 changed files with 328 additions and 482 deletions
@@ -1,122 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
/// <summary>
/// 관리자 대시보드 API
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
/// </summary>
[ApiController]
[Route("api/admin-dashboard")]
[Authorize]
public class AdminDashboardController : ControllerBase
{
private readonly AdminDashboardService _dashboardService;
private readonly TaxFilingService _taxFilingService;
public AdminDashboardController(
AdminDashboardService dashboardService,
TaxFilingService taxFilingService)
{
_dashboardService = dashboardService;
_taxFilingService = taxFilingService;
}
/// <summary>
/// 대시보드 요약 정보 조회
/// GET /api/admin-dashboard/summary
/// </summary>
[HttpGet("summary")]
public async Task<IActionResult> GetSummary()
{
try
{
var summary = await _dashboardService.GetSummaryAsync();
return Ok(summary);
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails
{
Title = "대시보드 요약 조회 실패",
Detail = ex.Message,
Status = StatusCodes.Status500InternalServerError
});
}
}
/// <summary>
/// 30일 이내 마감 임박 신고 조회
/// GET /api/admin-dashboard/upcoming-filings?days=30
/// </summary>
[HttpGet("upcoming-filings")]
public async Task<IActionResult> GetUpcomingFilings([FromQuery] int days = 30)
{
try
{
if (days <= 0) days = 30;
var filings = await _taxFilingService.GetUpcomingAsync(days);
return Ok(new { data = filings, days });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails
{
Title = "마감 임박 신고 조회 실패",
Detail = ex.Message,
Status = StatusCodes.Status500InternalServerError
});
}
}
/// <summary>
/// 최근 문의 조회
/// GET /api/admin-dashboard/recent-inquiries?limit=10
/// </summary>
[HttpGet("recent-inquiries")]
public async Task<IActionResult> GetRecentInquiries([FromQuery] int limit = 10)
{
try
{
if (limit <= 0) limit = 10;
if (limit > 100) limit = 100; // 보안: 최대 100개
var inquiries = await _dashboardService.GetRecentInquiriesAsync(limit);
return Ok(new { data = inquiries, limit });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails
{
Title = "최근 문의 조회 실패",
Detail = ex.Message,
Status = StatusCodes.Status500InternalServerError
});
}
}
/// <summary>
/// 월별 통계
/// GET /api/admin-dashboard/monthly-stats?month=2026-06
/// </summary>
[HttpGet("monthly-stats")]
public async Task<IActionResult> GetMonthlyStats([FromQuery] string? month = null)
{
try
{
var stats = await _dashboardService.GetMonthlyStatsAsync(month);
return Ok(stats);
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails
{
Title = "월별 통계 조회 실패",
Detail = ex.Message,
Status = StatusCodes.Status500InternalServerError
});
}
}
}
@@ -1,82 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CommonCodeController(CommonCodeService commonCodeService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAllActive()
{
try
{
var codes = await commonCodeService.GetAllActiveAsync();
return Ok(codes);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "공통코드 조회 실패", message = ex.Message });
}
}
[HttpGet("group/{group}")]
public async Task<IActionResult> GetByGroup(string group)
{
try
{
var codes = await commonCodeService.GetByGroupAsync(group);
return Ok(codes);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "그룹별 공통코드 조회 실패", message = ex.Message });
}
}
[HttpGet("groups")]
public async Task<IActionResult> GetGroups()
{
try
{
var groups = await commonCodeService.GetAllGroupsAsync();
return Ok(groups);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "공통코드 그룹 조회 실패", message = ex.Message });
}
}
[HttpGet("{group}/{value}")]
public async Task<IActionResult> Get(string group, string value)
{
var code = await commonCodeService.GetAsync(group, value);
return code is null ? NotFound() : Ok(code);
}
[HttpPost]
public async Task<IActionResult> Upsert([FromBody] CommonCode code)
{
if (string.IsNullOrWhiteSpace(code.CodeGroup) || string.IsNullOrWhiteSpace(code.CodeValue) || string.IsNullOrWhiteSpace(code.CodeName))
return BadRequest(new { error = "코드 그룹, 값, 이름은 필수입니다." });
if (code.CodeGroup.Any(char.IsWhiteSpace))
return BadRequest(new { error = "code_group에는 공백을 사용할 수 없습니다." });
if (code.CodeValue.Contains(' '))
return BadRequest(new { error = "code_value에는 공백을 사용할 수 없습니다." });
await commonCodeService.UpsertAsync(code);
return Ok(code);
}
[HttpDelete("{group}/{value}")]
public async Task<IActionResult> Delete(string group, string value)
{
await commonCodeService.DeleteAsync(group, value);
return NoContent();
}
}
@@ -1,117 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CompanyController(CompanyService companyService) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
var company = await companyService.GetByIdAsync(id);
if (company == null)
return NotFound(new ProblemDetails { Title = "회사를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(company);
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpGet("code/{code}")]
public async Task<IActionResult> GetByCode(string code)
{
try
{
var company = await companyService.GetByCodeAsync(code);
if (company == null)
return NotFound(new ProblemDetails { Title = "회사를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(company);
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpGet]
public async Task<IActionResult> GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
try
{
var (companies, total) = await companyService.GetPagedAsync(page, pageSize);
return Ok(new { data = companies, total, page, pageSize });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 목록 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateCompanyRequest request)
{
try
{
var id = await companyService.CreateAsync(
request.CompanyCode, request.CompanyName, request.ContactPerson,
request.Phone, request.Email, request.Memo);
return CreatedAtAction(nameof(GetById), new { id }, new { message = "회사가 등록되었습니다.", id });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 등록 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateCompanyRequest request)
{
try
{
await companyService.UpdateAsync(id, request.CompanyCode, request.CompanyName,
request.ContactPerson, request.Phone, request.Email, request.Memo, request.IsActive);
return Ok(new { message = "회사가 수정되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 수정 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try
{
await companyService.DeleteAsync(id);
return Ok(new { message = "회사가 삭제되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 삭제 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
public record CreateCompanyRequest(string CompanyCode, string CompanyName, string? ContactPerson, string? Phone, string? Email, string? Memo);
public record UpdateCompanyRequest(string CompanyCode, string CompanyName, string? ContactPerson, string? Phone, string? Email, string? Memo, bool IsActive);
}
@@ -1,43 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class SiteSettingsController : ControllerBase
{
private readonly SiteSettingService _siteSettingService;
public SiteSettingsController(SiteSettingService siteSettingService)
{
_siteSettingService = siteSettingService;
}
[HttpGet]
public async Task<IActionResult> Get()
{
var settings = await _siteSettingService.GetAllAsync();
return Ok(settings);
}
[HttpPut]
public async Task<IActionResult> Save([FromBody] SaveSiteSettingsRequest request)
{
if (request is null)
return BadRequest(new { message = "요청 본문이 비어 있습니다." });
await _siteSettingService.SaveAsync(request.Phone, request.Email, request.KakaoUrl, request.InstagramUrl);
return Ok(new { message = "사이트 설정이 저장되었습니다." });
}
}
public class SaveSiteSettingsRequest
{
public string Phone { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string KakaoUrl { get; set; } = string.Empty;
public string InstagramUrl { get; set; } = string.Empty;
}
@@ -1,84 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class TaxFilingController : ControllerBase
{
private readonly TaxFilingService _taxFilingService;
public TaxFilingController(TaxFilingService taxFilingService)
{
_taxFilingService = taxFilingService;
}
[HttpGet("upcoming")]
public async Task<IActionResult> GetUpcoming([FromQuery] int daysAhead = 30)
{
var filings = await _taxFilingService.GetUpcomingAsync(daysAhead);
return Ok(new { data = filings });
}
[HttpGet("client/{clientId}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
var filings = await _taxFilingService.GetByClientIdAsync(clientId);
return Ok(new { data = filings });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var filing = await _taxFilingService.GetByIdAsync(id);
if (filing == null)
return NotFound(new ProblemDetails { Title = "신고 일정을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(filing);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] TaxFiling filing)
{
try
{
var filingId = await _taxFilingService.CreateAsync(filing);
var result = await _taxFilingService.GetByIdAsync(filingId);
return CreatedAtAction(nameof(GetById), new { id = filingId }, result);
}
catch (Exception ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] TaxFiling filing)
{
filing.Id = id;
try
{
await _taxFilingService.UpdateAsync(filing);
var result = await _taxFilingService.GetByIdAsync(id);
if (result == null)
return NotFound(new ProblemDetails { Title = "신고 일정을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _taxFilingService.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,51 @@
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using InquiryEntity = TaxBaik.Domain.Entities.Inquiry;
namespace TaxBaik.Web.Endpoints.AdminDashboard;
public class AdminDashboardSummaryResponse
{
public int ThisMonthInquiries { get; set; }
public int NewInquiries { get; set; }
public int TotalPosts { get; set; }
public int PublishedPosts { get; set; }
public List<InquiryEntity> RecentInquiries { get; set; } = [];
}
public class UpcomingFilingsResponse
{
public List<object> Data { get; set; } = [];
public int Days { get; set; }
}
public class UpcomingFilingsQuery
{
public int Days { get; set; } = 30;
}
public class RecentInquiriesResponse
{
public List<InquiryEntity> Data { get; set; } = [];
public int Limit { get; set; }
}
public class RecentInquiriesQuery
{
public int Limit { get; set; } = 10;
}
public class MonthlyStatsQuery
{
public string? Month { get; set; }
}
public class MonthlyStatsResponse
{
public string Month { get; set; } = string.Empty;
public int TotalInquiries { get; set; }
public int ConsultingCount { get; set; }
public int CompletedCount { get; set; }
public int NewCount { get; set; }
public double CompletionRate { get; set; }
}
@@ -0,0 +1,44 @@
using FastEndpoints;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Endpoints.AdminDashboard;
public class GetMonthlyStatsEndpoint : Endpoint<MonthlyStatsQuery, MonthlyStatsResponse>
{
private readonly AdminDashboardService _dashboardService;
public GetMonthlyStatsEndpoint(AdminDashboardService dashboardService)
{
_dashboardService = dashboardService;
}
public override void Configure()
{
Get("/api/admin-dashboard/monthly-stats");
Policies("Bearer");
}
public override async Task HandleAsync(MonthlyStatsQuery request, CancellationToken ct)
{
try
{
var stats = await _dashboardService.GetMonthlyStatsAsync(request.Month, ct);
// Convert dynamic result to typed response
var statsDict = (dynamic)stats;
await SendAsync(new MonthlyStatsResponse
{
Month = statsDict.month,
TotalInquiries = statsDict.totalInquiries,
ConsultingCount = statsDict.consultingCount,
CompletedCount = statsDict.completedCount,
NewCount = statsDict.newCount,
CompletionRate = statsDict.completionRate
}, 200, cancellation: ct);
}
catch (Exception ex)
{
await SendErrorsAsync(500, ct);
}
}
}
@@ -0,0 +1,40 @@
using FastEndpoints;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Endpoints.AdminDashboard;
public class GetRecentInquiriesEndpoint : Endpoint<RecentInquiriesQuery, RecentInquiriesResponse>
{
private readonly AdminDashboardService _dashboardService;
public GetRecentInquiriesEndpoint(AdminDashboardService dashboardService)
{
_dashboardService = dashboardService;
}
public override void Configure()
{
Get("/api/admin-dashboard/recent-inquiries");
Policies("Bearer");
}
public override async Task HandleAsync(RecentInquiriesQuery request, CancellationToken ct)
{
try
{
var limit = request.Limit <= 0 ? 10 : request.Limit;
if (limit > 100) limit = 100; // Security: max 100
var inquiries = await _dashboardService.GetRecentInquiriesAsync(limit, ct);
await SendAsync(new RecentInquiriesResponse
{
Data = inquiries.ToList(),
Limit = limit
}, 200, cancellation: ct);
}
catch (Exception ex)
{
await SendErrorsAsync(500, ct);
}
}
}
@@ -0,0 +1,40 @@
using FastEndpoints;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Endpoints.AdminDashboard;
public class GetSummaryEndpoint : EndpointWithoutRequest<AdminDashboardSummaryResponse>
{
private readonly AdminDashboardService _dashboardService;
public GetSummaryEndpoint(AdminDashboardService dashboardService)
{
_dashboardService = dashboardService;
}
public override void Configure()
{
Get("/api/admin-dashboard/summary");
Policies("Bearer");
}
public override async Task HandleAsync(CancellationToken ct)
{
try
{
var summary = await _dashboardService.GetSummaryAsync(ct);
await SendAsync(new AdminDashboardSummaryResponse
{
ThisMonthInquiries = summary.ThisMonthInquiries,
NewInquiries = summary.NewInquiries,
TotalPosts = summary.TotalPosts,
PublishedPosts = summary.PublishedPosts,
RecentInquiries = summary.RecentInquiries.ToList()
}, 200, cancellation: ct);
}
catch (Exception ex)
{
await SendErrorsAsync(500, ct);
}
}
}
@@ -0,0 +1,38 @@
using FastEndpoints;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Endpoints.AdminDashboard;
public class GetUpcomingFilingsEndpoint : Endpoint<UpcomingFilingsQuery, UpcomingFilingsResponse>
{
private readonly TaxFilingService _taxFilingService;
public GetUpcomingFilingsEndpoint(TaxFilingService taxFilingService)
{
_taxFilingService = taxFilingService;
}
public override void Configure()
{
Get("/api/admin-dashboard/upcoming-filings");
Policies("Bearer");
}
public override async Task HandleAsync(UpcomingFilingsQuery request, CancellationToken ct)
{
try
{
var days = request.Days <= 0 ? 30 : request.Days;
var filings = await _taxFilingService.GetUpcomingAsync(days);
await SendAsync(new UpcomingFilingsResponse
{
Data = filings.Cast<object>().ToList(),
Days = days
}, 200, cancellation: ct);
}
catch (Exception ex)
{
await SendErrorsAsync(500, ct);
}
}
}
@@ -1,21 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using FastEndpoints;
namespace TaxBaik.Web.Controllers;
namespace TaxBaik.Web.Endpoints.ClientLogs;
[ApiController]
[Route("api/client-logs")]
[AllowAnonymous]
[EnableRateLimiting("client-logs")]
public class ClientLogsController(ILogger<ClientLogsController> logger) : ControllerBase
public class ClientLogEntry
{
[HttpPost]
public IActionResult Post([FromBody] ClientLogEntry entry)
public string? Level { get; set; }
public string? Source { get; set; }
public string? Message { get; set; }
public string? Url { get; set; }
public string? Route { get; set; }
public string? Screen { get; set; }
public string? Feature { get; set; }
public string? Action { get; set; }
public string? Step { get; set; }
public string? Entity { get; set; }
public string? EntityId { get; set; }
public string? DataKey { get; set; }
public string? BuildVersion { get; set; }
public string? UserAgent { get; set; }
public string? Stack { get; set; }
}
public class PostLogEndpoint : Endpoint<ClientLogEntry, EmptyResponse>
{
private readonly ILogger<PostLogEndpoint> _logger;
public PostLogEndpoint(ILogger<PostLogEndpoint> logger)
{
_logger = logger;
}
public override void Configure()
{
Post("/api/client-logs");
AllowAnonymous();
RateLimit(limit: 10, window: 60); // 10 requests per 60 seconds
}
public override async Task HandleAsync(ClientLogEntry entry, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(entry.Message))
{
return BadRequest();
ThrowError("Message is required");
}
var logMessage = "ClientLog {Level} {Source} {Message} Url={Url} Route={Route} Screen={Screen} Feature={Feature} Action={Action} Step={Step} Entity={Entity} EntityId={EntityId} DataKey={DataKey} BuildVersion={BuildVersion} UserAgent={UserAgent} Stack={Stack}";
@@ -42,32 +68,13 @@ public class ClientLogsController(ILogger<ClientLogsController> logger) : Contro
// Client warnings (level: warning/info) → Log file only
if (entry.Level?.Equals("error", StringComparison.OrdinalIgnoreCase) ?? true)
{
logger.LogError(logMessage, args);
_logger.LogError(logMessage, args);
}
else
{
logger.LogWarning(logMessage, args);
_logger.LogWarning(logMessage, args);
}
return Ok();
await SendAsync(new EmptyResponse(), 200, cancellation: ct);
}
}
public sealed class ClientLogEntry
{
public string? Level { get; set; }
public string? Source { get; set; }
public string? Message { get; set; }
public string? Url { get; set; }
public string? Route { get; set; }
public string? Screen { get; set; }
public string? Feature { get; set; }
public string? Action { get; set; }
public string? Step { get; set; }
public string? Entity { get; set; }
public string? EntityId { get; set; }
public string? DataKey { get; set; }
public string? BuildVersion { get; set; }
public string? UserAgent { get; set; }
public string? Stack { get; set; }
}
@@ -0,0 +1,26 @@
using FastEndpoints;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Endpoints.SiteSettings;
public class GetEndpoint : Endpoint<EmptyRequest, IReadOnlyDictionary<string, string>>
{
private readonly SiteSettingService _siteSettingService;
public GetEndpoint(SiteSettingService siteSettingService)
{
_siteSettingService = siteSettingService;
}
public override void Configure()
{
Get("/api/sitesettings");
Policies("Bearer");
}
public override async Task HandleAsync(EmptyRequest _, CancellationToken ct)
{
var settings = await _siteSettingService.GetAllAsync(ct);
await SendAsync(settings, 200, cancellation: ct);
}
}
@@ -0,0 +1,48 @@
using FastEndpoints;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Endpoints.SiteSettings;
public class SaveSiteSettingsRequest
{
public string Phone { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string KakaoUrl { get; set; } = string.Empty;
public string InstagramUrl { get; set; } = string.Empty;
}
public class SaveResponse
{
public string Message { get; set; } = string.Empty;
}
public class SaveEndpoint : Endpoint<SaveSiteSettingsRequest, SaveResponse>
{
private readonly SiteSettingService _siteSettingService;
public SaveEndpoint(SiteSettingService siteSettingService)
{
_siteSettingService = siteSettingService;
}
public override void Configure()
{
Put("/api/sitesettings");
Policies("Bearer");
}
public override async Task HandleAsync(SaveSiteSettingsRequest request, CancellationToken ct)
{
if (request == null)
{
ThrowError("요청 본문이 비어 있습니다.");
}
await _siteSettingService.SaveAsync(request.Phone, request.Email, request.KakaoUrl, request.InstagramUrl, ct);
await SendAsync(new SaveResponse
{
Message = "사이트 설정이 저장되었습니다."
}, 200, cancellation: ct);
}
}