refactor: move buildable .NET source into src/, update CI/doc paths
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m7s

Groups the repo root into src (buildable source), docs (already existed),
and everything else (db/, scripts/, tests/, deploy/ - deployment/ops/test
assets that aren't compiled, already organized as their own folders). CI
now only needs src/ to build: dotnet restore/build/test/publish all point
at src/TaxBaik.sln, src/TaxBaik.Web/, src/TaxBaik.Proxy/.

- git mv every project (Domain, Infrastructure, Application,
  Application.Tests, Web, Web.Client, Proxy) and TaxBaik.sln into src/ as a
  unit, so relative ProjectReference/.sln paths stay valid unchanged.
- .gitea/workflows/deploy.yml: 6 dotnet restore/clean/build/test/publish
  invocations now point at src/. db/migrations and scripts/ stay at root
  (deploy_gb.sh and browser-e2e.yml only touch published output and the
  deployed URL, not source paths - verified, no changes needed there).
- scripts/validate_admin_render.sh: admin render-mode file paths now
  src/TaxBaik.Web.Client/...
- scripts/validate_kst_timestamps.sh: dropped deploy.sh from its target
  list - that script was removed in the prior cleanup commit (dead, no
  CI workflow referenced it) but this validator still expected it to exist.
- CLAUDE.md, docs/ENGINEERING_HARNESS.md, docs/ADMIN_PATTERN_CRITIQUE_WBS.md:
  updated project-structure diagram, dotnet run/build commands, and grep
  targets to the new src/ paths (also fixed a pre-existing stale path in
  ADMIN_PATTERN_CRITIQUE_WBS.md that still said TaxBaik.Web/Components/Admin
  from before that ever moved to TaxBaik.Web.Client).
- Added a Repo Root harness rule + Architecture Guardrail entries: new files
  belong under src/docs/tests/scripts/db/deploy, not loose at root; temp
  work stays outside the repo (or under a gitignored .scratch/) and is
  never committed.

Verified locally: dotnet build/test src/TaxBaik.sln (26/26 tests), and all
three scripts/validate_*.sh pass against the new layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 10:37:37 +09:00
parent c00d002972
commit ea447495d3
277 changed files with 36 additions and 29 deletions
@@ -0,0 +1,122 @@
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
});
}
}
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AnnouncementController : ControllerBase
{
private readonly AnnouncementService _announcementService;
public AnnouncementController(AnnouncementService announcementService)
{
_announcementService = announcementService;
}
[HttpGet("active")]
public async Task<IActionResult> GetActive()
{
var announcements = await _announcementService.GetActiveAsync();
return Ok(new { data = announcements });
}
[HttpGet]
[Authorize]
public async Task<IActionResult> GetAll()
{
var announcements = await _announcementService.GetAllAsync();
return Ok(new { data = announcements });
}
[HttpGet("{id}")]
[Authorize]
public async Task<IActionResult> GetById(int id)
{
var announcement = await _announcementService.GetByIdAsync(id);
if (announcement == null)
return NotFound(new ProblemDetails { Title = "공지사항을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(announcement);
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Create([FromBody] AnnouncementDto dto)
{
try
{
var announcementId = await _announcementService.CreateAsync(dto);
var result = await _announcementService.GetByIdAsync(announcementId);
return CreatedAtAction(nameof(GetById), new { id = announcementId }, result);
}
catch (Exception ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] AnnouncementDto dto)
{
dto.Id = id;
try
{
await _announcementService.UpdateAsync(dto);
var result = await _announcementService.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}")]
[Authorize]
public async Task<IActionResult> Delete(int id)
{
await _announcementService.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,126 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly AuthService _authService;
public AuthController(AuthService authService)
{
_authService = authService;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ProblemDetails { Title = "로그인 정보가 필요합니다.", Status = StatusCodes.Status400BadRequest });
var tokenPair = await _authService.AuthenticateAndGenerateTokenPairAsync(request.Username, request.Password);
if (tokenPair == null)
return Unauthorized(new ProblemDetails { Title = "아이디 또는 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new
{
token = tokenPair.AccessToken,
accessToken = tokenPair.AccessToken,
refreshToken = tokenPair.RefreshToken,
expiresIn = tokenPair.ExpiresIn
});
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new ProblemDetails { Title = "Refresh token이 필요합니다.", Status = StatusCodes.Status400BadRequest });
var tokenPair = await _authService.RefreshAccessTokenAsync(request.RefreshToken);
if (tokenPair == null)
return Unauthorized(new ProblemDetails { Title = "Refresh token이 유효하지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new
{
token = tokenPair.AccessToken,
accessToken = tokenPair.AccessToken,
refreshToken = tokenPair.RefreshToken,
expiresIn = tokenPair.ExpiresIn
});
}
[HttpPost("change-password")]
[Microsoft.AspNetCore.Authorization.Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
return Unauthorized(new ProblemDetails { Title = "인증 정보가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
try
{
var changed = await _authService.ChangePasswordAsync(username, request.CurrentPassword, request.NewPassword);
if (!changed)
return Unauthorized(new ProblemDetails { Title = "현재 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new { message = "비밀번호가 변경되었습니다." });
}
catch (ArgumentException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPost("reset-password")]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{
try
{
var reset = await _authService.ResetPasswordAsync(request.Username, request.NewPassword, request.ResetToken);
if (!reset)
return Unauthorized(new ProblemDetails { Title = "재설정 토큰 또는 사용자 정보가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new { message = "비밀번호가 재설정되었습니다." });
}
catch (InvalidOperationException)
{
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails
{
Title = "비밀번호 재설정 토큰이 서버에 설정되어 있지 않습니다.",
Status = StatusCodes.Status503ServiceUnavailable
});
}
catch (ArgumentException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
}
public class LoginRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class ChangePasswordRequest
{
public string CurrentPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
public class ResetPasswordRequest
{
public string Username { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
public string ResetToken { get; set; } = string.Empty;
}
public class RefreshTokenRequest
{
public string RefreshToken { get; set; } = string.Empty;
}
@@ -0,0 +1,116 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
using TaxBaik.Application.DTOs;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BlogController : ControllerBase
{
private readonly BlogService _blogService;
public BlogController(BlogService blogService)
{
_blogService = blogService;
}
[HttpGet]
public async Task<IActionResult> GetPublished([FromQuery] int page = 1, [FromQuery] int pageSize = 12)
{
var (items, total) = await _blogService.GetPublishedPagedAsync(page, pageSize);
return Ok(new { data = items, total, page, pageSize });
}
[HttpGet("{slug}")]
public async Task<IActionResult> GetBySlug(string slug)
{
var post = await _blogService.GetBySlugAsync(slug);
if (post == null)
return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(post);
}
[HttpGet("admin/{id:int}")]
[Authorize]
public async Task<IActionResult> GetById(int id)
{
var post = await _blogService.GetByIdAsync(id);
if (post == null)
return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(post);
}
[HttpGet("admin/all")]
[Authorize]
public async Task<IActionResult> GetAll()
{
var posts = await _blogService.GetAllAsync();
return Ok(posts);
}
[HttpGet("admin")]
[Authorize]
public async Task<IActionResult> GetAdminPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var (items, total) = await _blogService.GetAdminPagedAsync(page, pageSize);
return Ok(new { data = items, total, page, pageSize });
}
[HttpGet("admin/archived")]
[Authorize]
public async Task<IActionResult> GetArchivedPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var (items, total) = await _blogService.GetArchivedPagedAsync(page, pageSize);
return Ok(new { data = items, total, page, pageSize });
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto)
{
try
{
var result = await _blogService.CreateAsync(dto);
return CreatedAtAction(nameof(GetBySlug), new { slug = result.Slug }, result);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] CreateBlogPostDto dto)
{
try
{
var result = await _blogService.UpdateAsync(id, dto);
if (result == null)
return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(result);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpDelete("{id}")]
[Authorize]
public async Task<IActionResult> Delete(int id)
{
await _blogService.ArchiveAsync(id);
return NoContent();
}
[HttpPost("{id}/restore")]
[Authorize]
public async Task<IActionResult> Restore(int id)
{
await _blogService.RestoreAsync(id);
return NoContent();
}
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CategoryController : ControllerBase
{
private readonly CategoryService _categoryService;
public CategoryController(CategoryService categoryService)
{
_categoryService = categoryService;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var categories = await _categoryService.GetAllAsync();
return Ok(categories);
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreateCategoryRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { message = "Category name is required" });
var category = await _categoryService.CreateAsync(request.Name, request.Description);
return CreatedAtAction(nameof(GetAll), category);
}
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] CreateCategoryRequest request)
{
var category = await _categoryService.UpdateAsync(id, request.Name, request.Description);
if (category == null)
return NotFound(new { message = "Category not found" });
return Ok(category);
}
[HttpDelete("{id}")]
[Authorize]
public async Task<IActionResult> Delete(int id)
{
await _categoryService.DeleteAsync(id);
return NoContent();
}
}
public class CreateCategoryRequest
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
}
@@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ClientController : ControllerBase
{
private readonly ClientService _clientService;
public ClientController(ClientService clientService)
{
_clientService = clientService;
}
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? status = null,
[FromQuery] string? search = null)
{
var (clients, total) = await _clientService.GetPagedAsync(page, pageSize, status, search);
return Ok(new { data = clients, total, page, pageSize });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var client = await _clientService.GetByIdAsync(id);
if (client == null)
return NotFound(new ProblemDetails { Title = "고객을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(client);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateClientDto dto)
{
try
{
var clientId = await _clientService.CreateAsync(dto);
var client = await _clientService.GetByIdAsync(clientId);
return CreatedAtAction(nameof(GetById), new { id = clientId }, client);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] CreateClientDto dto)
{
try
{
await _clientService.UpdateAsync(id, dto);
var client = await _clientService.GetByIdAsync(id);
if (client == null)
return NotFound(new ProblemDetails { Title = "고객을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(client);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _clientService.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/client-logs")]
[AllowAnonymous]
[EnableRateLimiting("client-logs")]
public class ClientLogsController(ILogger<ClientLogsController> logger) : ControllerBase
{
[HttpPost]
public IActionResult Post([FromBody] ClientLogEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.Message))
{
return BadRequest();
}
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}";
var args = new object?[]
{
entry.Level ?? "error",
entry.Source ?? "unknown",
entry.Message,
entry.Url ?? string.Empty,
entry.Route ?? string.Empty,
entry.Screen ?? string.Empty,
entry.Feature ?? string.Empty,
entry.Action ?? string.Empty,
entry.Step ?? string.Empty,
entry.Entity ?? string.Empty,
entry.EntityId ?? string.Empty,
entry.DataKey ?? string.Empty,
entry.BuildVersion ?? string.Empty,
entry.UserAgent ?? string.Empty,
entry.Stack ?? string.Empty
};
// Client errors (level: error) → Telegram alert
// Client warnings (level: warning/info) → Log file only
if (entry.Level?.Equals("error", StringComparison.OrdinalIgnoreCase) ?? true)
{
logger.LogError(logMessage, args);
}
else
{
logger.LogWarning(logMessage, args);
}
return Ok();
}
}
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,82 @@
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();
}
}
@@ -0,0 +1,117 @@
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);
}
@@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ConsultingActivityController(ConsultingActivityService service) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateConsultingActivityRequest request)
{
try
{
var id = await service.CreateAsync(request.ClientId, request.ActivityType, request.ActivityDate,
request.Description, request.ConsultantId, request.NextFollowupDate);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var activities = await service.GetAllAsync();
return Ok(activities);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
var activity = await service.GetByClientIdAsync(id);
if (activity == null)
return NotFound(new { error = "상담 활동을 찾을 수 없습니다." });
return Ok(activity);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
try
{
var activities = await service.GetByClientIdAsync(clientId);
return Ok(new { data = activities });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("pending-followups")]
public async Task<IActionResult> GetPendingFollowups()
{
try
{
var activities = await service.GetPendingFollowupsAsync();
return Ok(new { data = activities });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("consultant/{consultantId:int}")]
public async Task<IActionResult> GetByConsultant(int consultantId, [FromQuery] int daysBack = 30)
{
try
{
var fromDate = DateTime.Today.AddDays(-daysBack);
var activities = await service.GetConsultantActivityAsync(consultantId, fromDate);
return Ok(new { data = activities, daysBack });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateConsultingActivityRequest request)
{
try
{
await service.UpdateAsync(id, request.Outcome, request.NextFollowupDate);
return Ok(new { message = "상담 활동이 수정되었습니다." });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
}
}
public record CreateConsultingActivityRequest(
int ClientId, string ActivityType, DateTime ActivityDate, string Description,
int? ConsultantId = null, DateTime? NextFollowupDate = null);
public record UpdateConsultingActivityRequest(
string? Outcome = null, DateTime? NextFollowupDate = null);
}
@@ -0,0 +1,116 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ContractController(ContractService service) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateContractRequest request)
{
try
{
var id = await service.CreateAsync(request.ClientId, request.ContractNumber, request.ServiceType,
request.StartDate, request.MonthlyFee, request.TotalAmount);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var contracts = await service.GetAllAsync();
return Ok(contracts);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
var contract = await service.GetByIdAsync(id);
if (contract == null)
return NotFound(new { error = "계약을 찾을 수 없습니다." });
return Ok(contract);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
try
{
var contracts = await service.GetByClientIdAsync(clientId);
return Ok(new { data = contracts });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("active")]
public async Task<IActionResult> GetActiveContracts()
{
try
{
var contracts = await service.GetActiveContractsAsync();
return Ok(new { data = contracts });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("expiring")]
public async Task<IActionResult> GetExpiringContracts([FromQuery] int daysAhead = 30)
{
try
{
var contracts = await service.GetExpiringContractsAsync(daysAhead);
return Ok(new { data = contracts, daysAhead });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("mrr")]
public async Task<IActionResult> GetMonthlyRecurringRevenue()
{
try
{
var mrr = await service.GetMonthlyRecurringRevenueAsync();
return Ok(new { mrr });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
public record CreateContractRequest(
int ClientId, string ContractNumber, string ServiceType, DateTime StartDate,
decimal? MonthlyFee = null, decimal? TotalAmount = null);
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class FaqController : ControllerBase
{
private readonly FaqService _faqService;
public FaqController(FaqService faqService)
{
_faqService = faqService;
}
[HttpGet("active")]
public async Task<IActionResult> GetActive()
{
var faqs = await _faqService.GetActiveAsync();
return Ok(new { data = faqs });
}
[HttpGet]
[Authorize]
public async Task<IActionResult> GetAll()
{
var faqs = await _faqService.GetAllAsync();
return Ok(new { data = faqs });
}
[HttpGet("{id}")]
[Authorize]
public async Task<IActionResult> GetById(int id)
{
var faq = await _faqService.GetByIdAsync(id);
if (faq == null)
return NotFound(new ProblemDetails { Title = "FAQ를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(faq);
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Create([FromBody] Faq faq)
{
try
{
var faqId = await _faqService.CreateAsync(faq);
var result = await _faqService.GetByIdAsync(faqId);
return CreatedAtAction(nameof(GetById), new { id = faqId }, result);
}
catch (Exception ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] Faq faq)
{
faq.Id = id;
try
{
await _faqService.UpdateAsync(faq);
var result = await _faqService.GetByIdAsync(id);
if (result == null)
return NotFound(new ProblemDetails { Title = "FAQ를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpDelete("{id}")]
[Authorize]
public async Task<IActionResult> Delete(int id)
{
await _faqService.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,165 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class InquiryController : ControllerBase
{
private readonly InquiryService _inquiryService;
private readonly ClientService _clientService;
public InquiryController(InquiryService inquiryService, ClientService clientService)
{
_inquiryService = inquiryService;
_clientService = clientService;
}
[HttpPost]
public async Task<IActionResult> Submit([FromBody] SubmitInquiryDto request)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone))
return BadRequest(new ProblemDetails { Title = "이름과 전화번호를 입력하세요.", Status = StatusCodes.Status400BadRequest });
try
{
await _inquiryService.SubmitAsync(
request.Name,
request.Phone,
request.ServiceType,
request.Message,
request.Email,
HttpContext.Connection.RemoteIpAddress?.ToString(),
request.SuppressNotification);
return Ok(new { message = "상담 신청이 접수되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpGet]
[Authorize]
public async Task<IActionResult> GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var (inquiries, total) = await _inquiryService.GetPagedAsync(page, pageSize);
return Ok(new { data = inquiries, total, page, pageSize });
}
[HttpGet("{id}")]
[Authorize]
public async Task<IActionResult> GetById(int id)
{
var inquiry = await _inquiryService.GetByIdAsync(id);
if (inquiry == null)
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(inquiry);
}
[HttpPut("{id}/status")]
[Authorize]
public async Task<IActionResult> UpdateStatus(int id, [FromBody] UpdateStatusRequest request)
{
var inquiry = await _inquiryService.GetByIdAsync(id);
if (inquiry == null)
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
try
{
var changedBy = User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name;
await _inquiryService.UpdateStatusAsync(id, request.Status, changedBy);
return Ok(new { message = "상태가 변경되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPut("{id}/memo")]
[Authorize]
public async Task<IActionResult> UpdateAdminMemo(int id, [FromBody] UpdateAdminMemoRequest request)
{
var inquiry = await _inquiryService.GetByIdAsync(id);
if (inquiry == null)
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
try
{
await _inquiryService.UpdateAdminMemoAsync(id, request.AdminMemo);
return Ok(new { message = "메모가 저장되었습니다." });
}
catch (Exception ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] UpdateInquiryDto request)
{
try
{
var result = await _inquiryService.UpdateAsync(id, request);
if (result == null)
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(result);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPost("{id}/convert-to-client")]
[Authorize]
public async Task<IActionResult> ConvertToClient(int id, [FromBody] ConvertToClientRequest request)
{
var inquiry = await _inquiryService.GetByIdAsync(id);
if (inquiry == null)
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
if (inquiry.ClientId != null)
return BadRequest(new ProblemDetails { Title = "이미 고객 카드가 연결되어 있습니다.", Status = StatusCodes.Status400BadRequest });
try
{
var clientId = await _clientService.CreateFromInquiryAsync(
request.Name ?? inquiry.Name,
request.Phone ?? inquiry.Phone,
request.ServiceType ?? inquiry.ServiceType);
await _inquiryService.LinkClientAsync(inquiry.Id, clientId);
await _inquiryService.UpdateStatusAsync(inquiry.Id, "consulting", User.FindFirstValue(ClaimTypes.Name) ?? "system");
return Ok(new { clientId, message = "고객 카드가 생성되었습니다." });
}
catch (Exception ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
}
public class UpdateStatusRequest
{
public string Status { get; set; } = string.Empty;
}
public class UpdateAdminMemoRequest
{
public string? AdminMemo { get; set; }
}
public class ConvertToClientRequest
{
public string? Name { get; set; }
public string? Phone { get; set; }
public string? ServiceType { get; set; }
}
@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class RevenueTrackingController(RevenueTrackingService service) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateRevenueTrackingRequest request)
{
try
{
var id = await service.CreateAsync(request.ClientId, request.InvoiceNumber, request.InvoiceDate,
request.Amount, request.ServiceType, request.DueDate);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var revenues = await service.GetAllAsync();
return Ok(revenues);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
// GetByIdAsync가 없으면 GetByClientIdAsync를 사용하거나 별도 구현 필요
// 임시로 구현 - 실제로는 repository에 GetByIdAsync 추가 필요
return Ok(new { message = "조회됨" });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
try
{
var revenues = await service.GetByClientIdAsync(clientId);
return Ok(new { data = revenues });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("pending")]
public async Task<IActionResult> GetPendingPayments()
{
try
{
var revenues = await service.GetPendingPaymentsAsync();
return Ok(new { data = revenues });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("monthly")]
public async Task<IActionResult> GetMonthlyRevenue([FromQuery] int year, [FromQuery] int month)
{
try
{
var monthDate = new DateTime(year, month, 1);
var revenues = await service.GetMonthlyRevenueAsync(monthDate);
return Ok(new { data = revenues, year, month });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("total")]
public async Task<IActionResult> GetTotalRevenue([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
{
try
{
var total = await service.GetTotalRevenueAsync(startDate, endDate);
return Ok(new { total, startDate, endDate });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpPut("{id:int}/paid")]
public async Task<IActionResult> MarkPaid(int id, [FromBody] MarkPaidRequest request)
{
try
{
await service.MarkPaidAsync(id, request.PaymentDate);
return Ok(new { message = "결제가 완료됨으로 표시되었습니다." });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
}
}
public record CreateRevenueTrackingRequest(
int ClientId, string InvoiceNumber, DateTime InvoiceDate, decimal Amount,
string? ServiceType = null, DateTime? DueDate = null);
public record MarkPaidRequest(DateTime PaymentDate);
}
@@ -0,0 +1,43 @@
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;
}
@@ -0,0 +1,84 @@
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,116 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class TaxFilingScheduleController(TaxFilingScheduleService service) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTaxFilingScheduleRequest request)
{
try
{
var id = await service.CreateAsync(request.ClientId, request.FilingType, request.DueDate,
request.FilingYear, request.AssignedTo);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var schedules = await service.GetAllAsync();
return Ok(schedules);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
var schedule = await service.GetByIdAsync(id);
if (schedule == null)
return NotFound(new { error = "신고 일정을 찾을 수 없습니다." });
return Ok(schedule);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
try
{
var schedules = await service.GetByClientIdAsync(clientId);
return Ok(new { data = schedules });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("upcoming")]
public async Task<IActionResult> GetUpcomingDues([FromQuery] int daysAhead = 30)
{
try
{
var schedules = await service.GetUpcomingDuesAsync(daysAhead);
return Ok(new { data = schedules, daysAhead });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("pending-count")]
public async Task<IActionResult> GetPendingCount()
{
try
{
var count = await service.GetPendingCountAsync();
return Ok(new { count });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpPut("{id:int}/complete")]
public async Task<IActionResult> MarkCompleted(int id)
{
try
{
await service.MarkCompletedAsync(id);
return Ok(new { message = "신고 일정이 완료되었습니다." });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
}
}
public record CreateTaxFilingScheduleRequest(
int ClientId, string FilingType, DateTime DueDate, int FilingYear,
int? AssignedTo = null);
}
@@ -0,0 +1,111 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class TaxProfileController(TaxProfileService taxProfileService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTaxProfileRequest request)
{
try
{
var id = await taxProfileService.CreateAsync(request.ClientId, request.BusinessType,
request.BusinessRegistration, request.AccountingMethod, request.EstablishmentDate);
return CreatedAtAction(nameof(GetByClientId), new { clientId = request.ClientId }, new { id });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var profiles = await taxProfileService.GetAllAsync();
return Ok(profiles);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
try
{
var profile = await taxProfileService.GetByClientIdAsync(clientId);
if (profile == null)
return NotFound(new { error = "세무 프로필을 찾을 수 없습니다." });
return Ok(profile);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("high-risk")]
public async Task<IActionResult> GetHighRiskProfiles()
{
try
{
var profiles = await taxProfileService.GetHighRiskProfilesAsync();
return Ok(new { data = profiles });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("upcoming-filings")]
public async Task<IActionResult> GetUpcomingFiliings([FromQuery] int daysAhead = 30)
{
try
{
var profiles = await taxProfileService.GetUpcomingFilingDuesAsync(daysAhead);
return Ok(new { data = profiles, daysAhead });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateTaxProfileRequest request)
{
try
{
await taxProfileService.UpdateAsync(id, request.BusinessType, request.AccountingMethod,
request.NextFilingDueDate, request.TaxRiskLevel);
return Ok(new { message = "세무 프로필이 수정되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
}
}
public record CreateTaxProfileRequest(
int ClientId, string BusinessType, string? BusinessRegistration = null,
string? AccountingMethod = null, DateTime? EstablishmentDate = null);
public record UpdateTaxProfileRequest(
string? BusinessType = null, string? AccountingMethod = null,
DateTime? NextFilingDueDate = null, string TaxRiskLevel = "normal");
}
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using Serilog.Core;
using Serilog.Events;
namespace TaxBaik.Web.Logging;
public class TelegramSink : ILogEventSink
{
private readonly string _botToken;
private readonly string _chatId;
private readonly HttpClient _httpClient;
public TelegramSink(string botToken, string chatId)
{
_botToken = botToken;
_chatId = chatId;
_httpClient = new HttpClient();
}
public void Emit(LogEvent logEvent)
{
if (logEvent.Level < LogEventLevel.Error)
{
return;
}
// Filter out harmless client disconnect and task cancellation exceptions
if (logEvent.Exception != null)
{
var exTypeName = logEvent.Exception.GetType().FullName ?? "";
var exMessage = logEvent.Exception.Message ?? "";
if (exTypeName.Contains("JSDisconnectedException") ||
exTypeName.Contains("TaskCanceledException") ||
exMessage.Contains("JavaScript interop calls cannot be issued") ||
exMessage.Contains("circuit has disconnected"))
{
return;
}
}
// Emit is a synchronous method, so we dispatch the network call asynchronously
Task.Run(async () =>
{
try
{
var timestamp = logEvent.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff zzz");
var level = logEvent.Level.ToString().ToUpper();
var message = logEvent.RenderMessage();
var exceptionDetails = logEvent.Exception?.ToString();
var fingerprint = $"{level}|{message}|{exceptionDetails ?? string.Empty}";
if (!TaxBaik.Web.Services.TelegramAlertGate.ShouldSend("telegram:sink:error", fingerprint, TimeSpan.FromMinutes(10)))
{
return;
}
var sb = new StringBuilder();
sb.AppendLine($"<b>🚨 [{level}] 에러 발생</b>");
sb.AppendLine($"<b>시간:</b> {timestamp}");
sb.AppendLine($"<b>메시지:</b> {EscapeHtml(message)}");
if (!string.IsNullOrEmpty(exceptionDetails))
{
var escapedException = EscapeHtml(exceptionDetails);
if (escapedException.Length > 3000)
{
escapedException = escapedException.Substring(0, 3000) + "\n[이하 생략]";
}
sb.AppendLine($"<b>Exception 상세:</b>\n<pre>{escapedException}</pre>");
}
var url = $"https://api.telegram.org/bot{_botToken}/sendMessage";
var payload = new
{
chat_id = _chatId,
text = sb.ToString(),
parse_mode = "HTML"
};
var response = await _httpClient.PostAsJsonAsync(url, payload);
if (!response.IsSuccessStatusCode)
{
var errorResponse = await response.Content.ReadAsStringAsync();
Console.WriteLine($"[TelegramSink] Failed to send log to Telegram: {response.StatusCode} - {errorResponse}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[TelegramSink] Error in TelegramSink: {ex.Message}");
}
});
}
private static string EscapeHtml(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
}
+173
View File
@@ -0,0 +1,173 @@
@page
@{
ViewData["Title"] = "소개 | 백원숙 세무회계";
}
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb" class="py-3" style="background: #F9F7F3; border-bottom: 1px solid #D9D3C4;">
<div class="container">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="/taxbaik/" class="text-decoration-none">홈</a></li>
<li class="breadcrumb-item active">소개</li>
</ol>
</div>
</nav>
<div class="container py-5">
<!-- 돌아가기 버튼 -->
<div class="mb-4">
<a href="/taxbaik/" class="btn btn-sm btn-outline-secondary">← 홈으로 돌아가기</a>
</div>
<!-- Hero Section -->
<section class="mb-5 pb-5 border-bottom">
<h1 class="fw-bold mb-4" style="font-size: 2.5rem;">안녕하세요, 백원숙 세무사입니다.</h1>
<div class="row g-5">
<div class="col-lg-6">
<p class="lead">사업자 세무, 부동산 거래, 가족 자산 관리 등 종합적인 세무 컨설팅을 제공합니다.</p>
<p>10년 이상의 풍부한 경험과 3개의 국가자격증을 바탕으로, 각 클라이언트의 상황에 맞는 맞춤형 솔루션을 제시합니다.</p>
<p class="text-muted">저도 작게 시작하는 사업가였습니다. 처음 사업을 시작할 때의 막막함을 잘 알고 있습니다. 그 경험이 오늘날 고객분들과 소통하는 원동력입니다.</p>
</div>
<div class="col-lg-6">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">보유 자격증</h5>
<div class="mb-3">
<p class="mb-1">🎓 <strong>세무사</strong></p>
<small class="text-muted">2015년 자격취득 · 10년 경력</small>
</div>
<div class="mb-3">
<p class="mb-1">🏠 <strong>부동산중개사</strong></p>
<small class="text-muted">부동산 거래 구조 이해 · 실무 전문성</small>
</div>
<div>
<p class="mb-1">📊 <strong>보험설계사</strong></p>
<small class="text-muted">자산관리·상속 대비 전문성</small>
</div>
</div>
</div>
</div>
</section>
<!-- Expertise Section -->
<section class="mb-5 pb-5 border-bottom">
<h2 class="fw-bold mb-4">세 가지 자격의 시너지</h2>
<p class="text-muted mb-4">단순히 세금을 계산하는 것이 아니라, 사업 구조와 자산 흐름을 종합적으로 이해합니다.</p>
<div class="row g-4">
<div class="col-md-6">
<div class="p-4 border rounded-3" style="border-color: #D9D3C4;">
<div style="font-size: 2rem; margin-bottom: 1rem;">⚖️</div>
<h5 class="fw-bold mb-2">공인 세무사</h5>
<p class="text-muted small mb-0">
세무신고·장부관리·조세 자문 등 세무 업무 전반을 공식 대리합니다. 신고 기한 내 불이익 없는 신고를 기본으로 합니다.
</p>
</div>
</div>
<div class="col-md-6">
<div class="p-4 border rounded-3" style="border-color: #D9D3C4;">
<div style="font-size: 2rem; margin-bottom: 1rem;">🏠</div>
<h5 class="fw-bold mb-2">공인 부동산중개사</h5>
<p class="text-muted small mb-0">
부동산 거래 구조를 이해해 양도·증여·임대 세무상담에 현실감을 더합니다. 계약 전 사전검토로 선택지를 최대화합니다.
</p>
</div>
</div>
<div class="col-md-6">
<div class="p-4 border rounded-3" style="border-color: #D9D3C4;">
<div style="font-size: 2rem; margin-bottom: 1rem;">🛡️</div>
<h5 class="fw-bold mb-2">보험설계사 자격</h5>
<p class="text-muted small mb-0">
상속·증여·대표자 리스크 관점에서 가족 현금흐름과 보험 구조를 함께 설명합니다. 절세와 리스크 관리를 동시에 다룹니다.
</p>
</div>
</div>
</div>
</section>
<!-- Philosophy Section -->
<section class="mb-5 pb-5 border-bottom">
<h2 class="fw-bold mb-4">상담 철학</h2>
<div class="row g-4">
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">🎯</div>
<h5>명확한 설명</h5>
<p class="small text-muted">어려운 세법을 쉽게 설명하여 이해를 높입니다. 전문용어로 일방적 설명하지 않습니다.</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">💰</div>
<h5>최대 절세</h5>
<p class="small text-muted">법적 범위 내에서 세금을 최소화합니다. 초기 세무 전략이 연간 수백만 원의 차이를 만듭니다.</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">🤝</div>
<h5>신뢰 파트너</h5>
<p class="small text-muted">장기적 파트너로서 성장을 함께 합니다. 일회성 상담이 아닌 지속적 관계를 지향합니다.</p>
</div>
</div>
</section>
<!-- Online Consultation Section -->
<section class="mb-5 pb-5 border-bottom">
<h2 class="fw-bold mb-4">전국 비대면 온라인 상담</h2>
<div class="row g-4">
<div class="col-md-6">
<h5 class="fw-bold mb-3">왜 온라인인가?</h5>
<ul class="list-unstyled">
<li class="mb-2"><strong>✓ 시간 절약</strong><br/><small class="text-muted">서울로 올 필요 없이 카카오·이메일로 진행</small></li>
<li class="mb-2"><strong>✓ 자료 공유 편의</strong><br/><small class="text-muted">온라인으로 자료 검토 후 맞춤 상담</small></li>
<li class="mb-2"><strong>✓ 기록 남음</strong><br/><small class="text-muted">채팅·메일로 모든 내용을 기록 관리</small></li>
<li class="mb-2"><strong>✓ 비용 절감</strong><br/><small class="text-muted">방문 비용 없이 효율적 상담 제공</small></li>
</ul>
</div>
<div class="col-md-6">
<h5 class="fw-bold mb-3">상담 방식</h5>
<div class="p-3 bg-light rounded-3 mb-3">
<p class="fw-bold mb-2">📞 전화 상담</p>
<small class="text-muted">즉시 상황 파악 필요 시 · 010-4122-8268</small>
</div>
<div class="p-3 bg-light rounded-3 mb-3">
<p class="fw-bold mb-2">💬 카카오채널</p>
<small class="text-muted">당일 응답 · 편한 시간에 문의</small>
</div>
<div class="p-3 bg-light rounded-3 mb-3">
<p class="fw-bold mb-2">✉️ 이메일</p>
<small class="text-muted">자료 첨부 상담 · taxbaik5668@gmail.com</small>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="text-center mb-5 pb-5 border-bottom">
<h3 class="fw-bold mb-3">세금 고민, 이제 끝내세요</h3>
<p class="text-muted mb-5">무료 상담으로 현재 상황을 진단하고 맞춤형 절세 전략을 받아보세요.</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-warning btn-lg">카카오채널로 문의</a>
</div>
</section>
<!-- 관련 페이지 네비게이션 -->
<section class="text-center py-5">
<h4 class="fw-bold mb-4">다른 페이지 보기</h4>
<div class="row g-3 justify-content-center">
<div class="col-md-4">
<a href="/taxbaik/" class="btn btn-outline-primary btn-sm w-100 py-3">
🏠 홈으로<br/>
<small class="text-muted">서비스 및 최신 정보</small>
</a>
</div>
<div class="col-md-4">
<a href="/taxbaik/services" class="btn btn-outline-primary btn-sm w-100 py-3">
📊 전문 서비스<br/>
<small class="text-muted">사업자·부동산·자산 관리</small>
</a>
</div>
<div class="col-md-4">
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-sm w-100 py-3">
📝 세무 정보 블로그<br/>
<small class="text-muted">절세팁 및 신고 가이드</small>
</a>
</div>
</div>
</section>
</div>
@@ -0,0 +1,4 @@
@page "/announcement"
@{
Response.Redirect("/taxbaik/#top");
}
+59
View File
@@ -0,0 +1,59 @@
@page
@model TaxBaik.Web.Pages.Blog.BlogIndexModel
@{
ViewData["Title"] = "블로그 | 백원숙 세무회계";
}
<div class="container py-5">
<h1 class="fw-bold mb-5">세무 블로그</h1>
<!-- Category Tabs -->
<div class="mb-4 d-flex flex-wrap gap-2">
<a href="/taxbaik/blog" class="btn btn-sm @(Model.SelectedCategoryId == null ? "btn-primary" : "btn-outline-primary")">전체</a>
@foreach (var cat in Model.Categories)
{
<a href="/taxbaik/blog?categoryId=@cat.Id" class="btn btn-sm @(Model.SelectedCategoryId == cat.Id ? "btn-primary" : "btn-outline-primary")">@cat.Name</a>
}
</div>
<!-- Posts Grid -->
<div class="row g-4">
@foreach (var post in Model.Posts)
{
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<small class="badge bg-primary">@post.CategoryName</small>
<h5 class="card-title mt-2">@post.Title</h5>
<p class="card-text small">@post.CreatedAt.ToString("yyyy-MM-dd")</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">글 내용 보기</a>
</div>
</div>
</div>
}
</div>
<!-- Pagination -->
<nav aria-label="Page navigation" class="mt-5">
<ul class="pagination justify-content-center">
@if (Model.CurrentPage > 1)
{
<li class="page-item">
<a class="page-link" href="/taxbaik/blog?page=@(Model.CurrentPage - 1)">이전</a>
</li>
}
@for (int i = 1; i <= Model.TotalPages; i++)
{
<li class="page-item @(i == Model.CurrentPage ? "active" : "")">
<a class="page-link" href="/taxbaik/blog?page=@i">@i</a>
</li>
}
@if (Model.CurrentPage < Model.TotalPages)
{
<li class="page-item">
<a class="page-link" href="/taxbaik/blog?page=@(Model.CurrentPage + 1)">다음</a>
</li>
}
</ul>
</nav>
</div>
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Pages.Blog;
public class BlogIndexModel : PageModel
{
private readonly BlogService _blogService;
private readonly CategoryService _categoryService;
public List<BlogPost> Posts { get; set; } = [];
public List<Category> Categories { get; set; } = [];
public int CurrentPage { get; set; } = 1;
public int TotalPages { get; set; }
public int? SelectedCategoryId { get; set; }
private const int PageSize = 12;
public BlogIndexModel(BlogService blogService, CategoryService categoryService)
{
_blogService = blogService;
_categoryService = categoryService;
}
public async Task OnGetAsync(int page = 1, int? categoryId = null)
{
try
{
CurrentPage = page;
SelectedCategoryId = categoryId;
Categories = (await _categoryService.GetAllAsync()).ToList();
var (posts, total) = await _blogService.GetPublishedPagedAsync(page, PageSize, categoryId);
Posts = posts.ToList();
TotalPages = (total + PageSize - 1) / PageSize;
}
catch
{
CurrentPage = page;
SelectedCategoryId = categoryId;
Categories = [];
Posts = [];
TotalPages = 0;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
@page "/blog/{slug}"
@model TaxBaik.Web.Pages.Blog.BlogPostModel
@{
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
ViewData["Description"] = Model.Post?.SeoDescription ?? "";
ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? "";
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/blog/{Model.Post?.Slug}";
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["OgUrl"] = canonicalUrl;
}
@if (Model.Post != null)
{
<article class="container section" style="max-width: 720px;">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/taxbaik">홈</a></li>
<li class="breadcrumb-item"><a href="/taxbaik/blog">블로그</a></li>
<li class="breadcrumb-item active">@Model.Post.CategoryName</li>
</ol>
</nav>
<div class="mb-4">
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-sm">← 블로그 목록으로 돌아가기</a>
</div>
@if (!string.IsNullOrEmpty(Model.Post.ThumbnailUrl))
{
<img src="@Model.Post.ThumbnailUrl" alt="@Model.Post.Title"
loading="lazy" class="img-fluid rounded mb-4" style="max-height: 400px; object-fit: cover; width: 100%;" />
}
<h1 class="fw-bold mb-3">@Model.Post.Title</h1>
<div class="text-muted small mb-4 d-flex flex-wrap gap-3">
<span>📅 @Model.Post.CreatedAt.ToString("yyyy년 MM월 dd일")</span>
<span>👁️ @Model.Post.ViewCount 조회</span>
<span class="badge bg-primary">@Model.Post.CategoryName</span>
</div>
<hr class="my-4" />
<div class="article-body lh-lg markdown-body">
@Html.Raw(Model.HtmlContent)
</div>
<hr class="my-4" />
<!-- CTA -->
<section class="bg-primary-light p-4 rounded mb-5">
<h5 class="fw-bold mb-2 text-primary">상담이 필요하신가요?</h5>
<p class="mb-3 text-muted">이 글과 관련된 상담이 필요하면 언제든 연락주세요.</p>
<a href="/taxbaik/contact" class="btn btn-primary">📞 상담 신청하기</a>
</section>
<!-- Share -->
<section class="text-center mb-5">
<p class="small text-muted mb-3">이 글을 공유하세요:</p>
<button class="btn btn-sm btn-outline-primary" onclick="copyUrl()" title="링크 복사">📋 링크복사</button>
</section>
</article>
}
else
{
<section class="container section text-center">
<p class="fs-5">포스트를 찾을 수 없습니다.</p>
<a href="/taxbaik/blog" class="btn btn-primary">블로그 돌아가기</a>
</section>
}
<script>
function copyUrl() {
navigator.clipboard.writeText(window.location.href).then(() => {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = '✅ 복사됨';
setTimeout(() => btn.textContent = originalText, 2000);
});
}
</script>
+29
View File
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using System.Net;
namespace TaxBaik.Web.Pages.Blog;
public class BlogPostModel : PageModel
{
private readonly BlogService _blogService;
public BlogPost? Post { get; set; }
public string? HtmlContent { get; set; }
public BlogPostModel(BlogService blogService)
{
_blogService = blogService;
}
public async Task OnGetAsync(string slug)
{
Post = await _blogService.GetBySlugAsync(slug);
if (Post != null)
{
HtmlContent = WebUtility.HtmlEncode(Post.Content ?? "").Replace("\r\n", "<br />").Replace("\n", "<br />");
_ = _blogService.IncrementViewCountAsync(Post.Id);
}
}
}
+93
View File
@@ -0,0 +1,93 @@
@page
@model TaxBaik.Web.Pages.ContactModel
@{
ViewData["Title"] = "상담 신청 | 백원숙 세무회계";
}
<div class="container py-5" style="max-width: 600px;">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h1 class="fw-bold mb-0">상담 신청</h1>
<a href="/taxbaik" class="btn btn-outline-secondary btn-sm"
onclick="if (history.length > 1) { history.back(); return false; }">
뒤로가기
</a>
</div>
@if (TempData["Success"] != null)
{
<div id="contact-success" class="alert alert-success alert-dismissible fade show" role="alert" role="status" style="font-size: 1.05rem;">
<strong>✅ 성공!</strong> @TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<script>
// 성공 메시지를 3초 후 자동 숨김 (사용자 클릭 가능)
setTimeout(() => {
const alert = document.getElementById('contact-success');
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
// 폼 자동 초기화
setTimeout(() => {
document.querySelector('form').reset();
document.getElementById('agree').checked = false;
}, 1000);
</script>
}
<form method="post" id="contactForm">
@Html.AntiForgeryToken()
<div asp-validation-summary="All" class="text-danger mb-3"></div>
<div class="mb-3">
<label for="name" class="form-label">이름 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="Name" required />
</div>
<div class="mb-3">
<label for="phone" class="form-label">전화번호 <span class="text-danger">*</span></label>
<input type="tel" class="form-control" id="phone" name="Phone" placeholder="010-0000-0000" required />
<small class="form-text text-muted">형식: 010-0000-0000</small>
</div>
<div class="mb-3">
<label for="email" class="form-label">이메일</label>
<input type="email" class="form-control" id="email" name="Email" />
</div>
<div class="mb-3">
<label for="service" class="form-label">상담분야</label>
<select class="form-select" id="service" name="ServiceType">
<option value="기장">사업자 기장</option>
<option value="양도세">부동산 양도세</option>
<option value="종소세">종합소득세</option>
<option value="증여상속">증여상속세</option>
<option value="기타">기타</option>
</select>
</div>
<div class="mb-3">
<label for="message" class="form-label">문의내용 <span class="text-danger">*</span></label>
<textarea class="form-control" id="message" name="Message" rows="5" required></textarea>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="agree" name="Agree" value="true" required />
<label class="form-check-label" for="agree">
개인정보 수집·이용에 동의합니다
</label>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">상담신청</button>
</form>
<hr class="my-5" />
<h5 class="fw-bold mb-3">빠른 상담을 원하시나요?</h5>
<p>카카오톡 채널을 통해 더 빠르게 상담받을 수 있습니다.</p>
<div class="gap-2 d-flex flex-wrap">
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-warning">카카오톡 채널 문의</a>
<a href="tel:010-4122-8268" class="btn btn-outline-primary">전화 상담</a>
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Pages;
public class ContactModel : PageModel
{
private readonly InquiryService _inquiryService;
[BindProperty]
public string Name { get; set; } = "";
[BindProperty]
public string Phone { get; set; } = "";
[BindProperty]
public string? Email { get; set; }
[BindProperty]
public string ServiceType { get; set; } = "기타";
[BindProperty]
public string Message { get; set; } = "";
[BindProperty]
public bool Agree { get; set; }
public ContactModel(InquiryService inquiryService)
{
_inquiryService = inquiryService;
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid || !Agree)
return Page();
try
{
await _inquiryService.SubmitAsync(
Name,
Phone,
ServiceType,
Message,
Email,
HttpContext.Connection.RemoteIpAddress?.ToString());
TempData["Success"] = "상담 신청이 접수되었습니다. 빠른 시간 내에 연락드리겠습니다.";
return RedirectToPage();
}
catch (ValidationException ex)
{
ModelState.AddModelError("", ex.Message);
return Page();
}
catch
{
ModelState.AddModelError("", "시스템 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
return Page();
}
}
}
+4
View File
@@ -0,0 +1,4 @@
@page "/faq"
@{
Response.Redirect("/taxbaik/#faq");
}
+381
View File
@@ -0,0 +1,381 @@
@page
@model TaxBaik.Web.Pages.IndexModel
@{
var season = Model.CurrentSeason;
ViewData["Title"] = season != null
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
: "백원숙 세무회계 | 사업자·부동산·증여상속 세무 상담";
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여상속, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
}
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
@if (Model.ActiveAnnouncements.Count > 0)
{
foreach (var notice in Model.ActiveAnnouncements)
{
<div class="announcement-bar announcement-bar--@notice.DisplayType">
<div class="container d-flex align-items-center gap-2 py-2">
<span class="announcement-icon">
@if (notice.DisplayType == "urgent") { <text>🚨</text> }
else if (notice.DisplayType == "banner") { <text>📢</text> }
else { <text>️</text> }
</span>
<span class="announcement-text fw-semibold">@notice.Title</span>
@if (!string.IsNullOrEmpty(notice.Content))
{
<span class="d-none d-md-inline text-muted small ms-2">— @notice.Content</span>
}
</div>
</div>
}
}
@* ─── Hero Section ─── *@
@if (season != null)
{
<section class="hero-section hero-section--seasonal text-white pt-5 pb-4">
<div class="container">
<div class="row align-items-center py-4">
<div class="col-lg-7">
<span class="badge bg-danger-badge mb-3 fs-6 px-3 py-2">
@season.UrgencyBadge
</span>
<h1 class="mb-3" style="white-space: pre-line;">@season.HeroHeadline</h1>
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
@season.HeroSubtext
</p>
<div class="d-flex gap-3 flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg fw-bold">
⏰ @season.CtaText
</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오채널 문의
</a>
</div>
@if (season.DaysUntilDeadline <= 7)
{
<p class="mt-3 small" style="opacity: 0.8;">
마감까지 <strong>@(season.DaysUntilDeadline)일</strong> 남았습니다.
지금 바로 상담 신청하세요.
</p>
}
</div>
<div class="col-lg-5 d-none d-lg-block text-center">
<div class="seasonal-deadline-badge">
<div class="deadline-label">마감</div>
<div class="deadline-date">@season.Deadline.ToString("M월 d일")</div>
<div class="deadline-days">D-@season.DaysUntilDeadline</div>
</div>
</div>
</div>
</div>
</section>
}
else
{
<section class="hero-section text-white pt-5 pb-4">
<div class="container">
<div class="row align-items-center py-4">
<div class="col-lg-7">
<span class="badge bg-primary-badge mb-3">경험 있는 세무사의 맞춤 전략</span>
<h1 class="mb-3">
세금과 자산<br/>
<span style="color: #E8E4D8;">한 번에 해결하는</span>
</h1>
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
사업자 세무, 부동산 거래, 가족자산 관리를 위한<br/>
통합 솔루션을 제공합니다.
</p>
<div class="d-flex gap-3 flex-wrap">
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오채널 문의
</a>
</div>
</div>
<div class="col-lg-5 d-none d-lg-block text-center">
<div style="font-size: 120px; opacity: 0.15;">📋</div>
</div>
</div>
</div>
</section>
}
<!-- About 링크 배너 -->
<section class="py-3" style="background: rgba(46, 92, 78, 0.05); border-bottom: 1px solid rgba(46, 92, 78, 0.1);">
<div class="container">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<p class="mb-0 small text-muted">세무사의 경력, 자격, 상담 철학을 알아보세요</p>
</div>
<a href="/taxbaik/about" class="btn btn-sm btn-outline-primary">백원숙 세무사 소개 →</a>
</div>
</div>
</section>
<!-- 서비스 영역 -->
<section class="py-5">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">전문 서비스</h2>
<p class="fs-6 text-muted" style="max-width: 600px; margin: 0 auto;">
각 분야의 복잡한 세무 이슈를 경험과 노하우로 해결합니다
</p>
</div>
@{
var focusService = season?.FocusService ?? "";
// 시즌에 따라 서비스 카드 순서 결정
var cardOrder = focusService switch
{
"real-estate-tax" => new[] { "real-estate-tax", "business-tax", "family-asset" },
"family-asset" => new[] { "family-asset", "business-tax", "real-estate-tax" },
_ => new[] { "business-tax", "real-estate-tax", "family-asset" }
};
}
<div class="row g-4">
@foreach (var cardKey in cardOrder)
{
var isFeatured = cardKey == focusService;
if (cardKey == "business-tax")
{
<div class="col-lg-4 col-md-6">
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
<div class="service-icon">📊</div>
<div class="card-body pt-0">
<h3 class="card-title">사업자 세무</h3>
<p class="text-muted small">월 기장부터 종합소득세, 신규 사업자 세무까지 — 사업 초기부터 체계적인 세무 관리.</p>
<a href="/taxbaik/services#business-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
</div>
}
else if (cardKey == "real-estate-tax")
{
<div class="col-lg-4 col-md-6">
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
<div class="service-icon">🏠</div>
<div class="card-body pt-0">
<h3 class="card-title">부동산 세금</h3>
<p class="text-muted small">양도세·취득세·임대소득세 — 부동산 거래 시 세금 부담을 줄이는 전략.</p>
<a href="/taxbaik/services#real-estate-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
</div>
}
else
{
<div class="col-lg-4 col-md-6">
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
<div class="service-icon">👨‍👩‍👧‍👦</div>
<div class="card-body pt-0">
<h3 class="card-title">가족자산 관리</h3>
<p class="text-muted small">증여상속 사전 계획부터 대표자 리스크 관리까지 - 가족 자산을 지키는 전략.</p>
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
</div>
}
}
</div>
</div>
</section>
<!-- 블로그 & 시즌 포스트 (상단으로 올림) -->
<section class="py-5">
<div class="container">
<div class="text-center mb-5">
@if (season != null)
{
<div class="seasonal-blog-header mb-2">
<span class="seasonal-blog-tag">📅 @season.Name 시즌</span>
</div>
<h2 class="section-title">이번 시즌 세무 정보</h2>
<p class="text-muted">@season.Name 관련 절세 팁과 신고 가이드를 확인하세요</p>
}
else
{
<h2 class="section-title">세무 정보 & 절세 팁</h2>
<p class="text-muted">최신 세법 변화와 실무 팁을 공유합니다</p>
}
</div>
@{
var hasSeasonalPosts = Model.SeasonalPosts?.Count > 0;
var hasRecentPosts = Model.RecentPosts?.Count > 0;
}
@if (hasSeasonalPosts || hasRecentPosts)
{
<div class="row g-4">
@* 시즌 관련 글 (배지 강조) *@
@if (hasSeasonalPosts)
{
@foreach (var post in Model.SeasonalPosts!)
{
<div class="col-lg-4 col-md-6">
<div class="card blog-card h-100 blog-card--seasonal">
<div class="blog-seasonal-ribbon">이번 시즌 추천</div>
<div class="blog-placeholder">🗓️</div>
<div class="card-body">
<small class="badge bg-season-badge">@post.CategoryName</small>
<h4 class="card-title mt-3">@post.Title</h4>
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-seasonal">읽기</a>
</div>
</div>
</div>
}
}
@* 최신 글 (나머지 채우기) *@
@if (hasRecentPosts)
{
@foreach (var post in Model.RecentPosts!)
{
<div class="col-lg-4 col-md-6">
<div class="card blog-card h-100">
<div class="blog-placeholder">📝</div>
<div class="card-body">
<small class="badge bg-primary-badge">@post.CategoryName</small>
<h4 class="card-title mt-3">@post.Title</h4>
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">읽기</a>
</div>
</div>
</div>
}
}
</div>
<div class="text-center mt-5 d-flex justify-content-center gap-3 flex-wrap">
@if (season != null && !string.IsNullOrEmpty(season.RelatedCategorySlug))
{
<a href="/taxbaik/blog?category=@season.RelatedCategorySlug" class="btn btn-outline-secondary btn-lg">
📅 @season.Name 전체 글 보기
</a>
}
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
</div>
}
else
{
<div class="text-center py-5">
<div class="mb-3" style="font-size: 3rem;">📝</div>
<h3 class="h5 mb-2">현재 표시할 블로그 글이 없습니다.</h3>
<p class="text-muted mb-4">최신 세무 정보는 블로그에서 확인할 수 있습니다.</p>
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">블로그 바로가기</a>
</div>
}
</div>
</section>
<!-- 상담 프로세스 -->
<section class="py-5" style="background: #F9F7F3;">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">상담 과정</h2>
</div>
<div class="row align-items-center">
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
📞
</div>
<h4>1단계: 무료 상담</h4>
<p class="text-muted small">상황 파악 및<br/>현재 문제점 확인</p>
</div>
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
📋
</div>
<h4>2단계: 세무진단</h4>
<p class="text-muted small">자료 분석 및<br/>최적 방안 도출</p>
</div>
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
💡
</div>
<h4>3단계: 맞춤제안</h4>
<p class="text-muted small">절세 전략 및<br/>실행 계획 제시</p>
</div>
<div class="col-md-3 text-center">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
</div>
<h4>4단계: 실행지원</h4>
<p class="text-muted small">지속적 관리 및<br/>추가 상담 제공</p>
</div>
</div>
<div class="text-center mt-5">
<p class="text-muted mb-3">상담은 온라인 또는 오프라인으로 진행되며, 완전히 비밀이 보장됩니다.</p>
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
</div>
</div>
</section>
<!-- 자주 묻는 질문 (DB 연동) -->
@if (Model.ActiveFaqs.Count > 0)
{
<section class="py-5" style="background: #F9F7F3;">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">자주 묻는 질문</h2>
<p class="text-muted">상담 전 궁금하신 사항을 먼저 확인해 보세요</p>
</div>
<div class="faq-accordion">
@foreach (var faqItem in Model.ActiveFaqs)
{
<details class="faq-item">
<summary class="faq-question">@faqItem.Question</summary>
<div class="faq-answer">
@faqItem.Answer
</div>
</details>
}
</div>
<div class="text-center mt-5">
<p class="text-muted mb-3">더 궁금한 점이 있으시면 바로 문의해 주세요</p>
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 문의하기</a>
</div>
</div>
</section>
}
<!-- 최종 CTA -->
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
<div class="container text-center">
@if (season != null)
{
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">@season.Name 마감이 다가옵니다!</h2>
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
마감 <strong>D-@(season.DaysUntilDeadline)일</strong> — 지금 바로 상담을 신청하세요.<br/>
빠른 검토로 불이익 없이 신고를 완료합니다.
</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">⏰ @season.CtaText</a>
<a href="https://pf.kakao.com/_xoxchTX" target="_blank" rel="noopener noreferrer" class="btn btn-light btn-lg">카카오채널로 문의</a>
</div>
}
else
{
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">세금 고민은 이제 끝!</h2>
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
무료 상담으로 현재 상황을 진단하고<br/>
맞춤형 절세 전략을 받아보세요.
</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
<a href="https://pf.kakao.com/_xoxchTX" target="_blank" rel="noopener noreferrer" class="btn btn-light btn-lg">카카오채널로 문의</a>
</div>
}
</div>
</section>
+76
View File
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Seasonal;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Pages;
public class IndexModel : PageModel
{
private readonly BlogService _blogService;
private readonly SeasonalMarketingService _seasonalMarketingService;
private readonly AnnouncementService _announcementService;
private readonly FaqService _faqService;
public List<BlogPost> RecentPosts { get; set; } = [];
public List<BlogPost> SeasonalPosts { get; set; } = [];
public CurrentSeasonDto? CurrentSeason { get; set; }
public List<Announcement> ActiveAnnouncements { get; set; } = [];
public List<Faq> ActiveFaqs { get; set; } = [];
public IndexModel(
BlogService blogService,
SeasonalMarketingService seasonalMarketingService,
AnnouncementService announcementService,
FaqService faqService)
{
_blogService = blogService;
_seasonalMarketingService = seasonalMarketingService;
_announcementService = announcementService;
_faqService = faqService;
}
public async Task OnGetAsync()
{
CurrentSeason = _seasonalMarketingService.GetCurrentSeason();
var announcementsTask = LoadSafeAsync(() => _announcementService.GetActiveAsync());
var faqsTask = LoadSafeAsync(() => _faqService.GetActiveAsync());
var blogTask = LoadBlogAsync();
await Task.WhenAll(announcementsTask, faqsTask, blogTask);
ActiveAnnouncements = (await announcementsTask)?.ToList() ?? [];
ActiveFaqs = (await faqsTask)?.ToList() ?? [];
}
private async Task LoadBlogAsync()
{
try
{
if (CurrentSeason is not null && !string.IsNullOrEmpty(CurrentSeason.RelatedCategorySlug))
{
var (seasonal, latest) = await _blogService.GetSeasonalPostsAsync(
CurrentSeason.RelatedCategorySlug, seasonalCount: 2, totalCount: 3);
SeasonalPosts = seasonal.ToList();
RecentPosts = latest.ToList();
}
else
{
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3);
RecentPosts = posts.ToList();
}
}
catch
{
RecentPosts = [];
SeasonalPosts = [];
}
}
private static async Task<IEnumerable<T>?> LoadSafeAsync<T>(Func<Task<IEnumerable<T>>> loader)
{
try { return await loader(); }
catch { return null; }
}
}
+4
View File
@@ -0,0 +1,4 @@
@page "/inquiry"
@{
Response.Redirect("/taxbaik/contact");
}
@@ -0,0 +1,9 @@
@page "/portal/external-callback"
@model TaxBaik.Web.Pages.Portal.ExternalCallbackModel
@{
ViewData["Title"] = "포털 인증 처리";
}
<section class="container py-5">
<div class="alert alert-info">인증을 처리하는 중입니다...</div>
</section>
@@ -0,0 +1,97 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
public class ExternalCallbackModel : PageModel
{
private readonly PortalUserService _portalUserService;
private readonly ClientService _clientService;
public ExternalCallbackModel(PortalUserService portalUserService, ClientService clientService)
{
_portalUserService = portalUserService;
_clientService = clientService;
}
public async Task<IActionResult> OnGetAsync(string provider)
{
var external = await HttpContext.AuthenticateAsync(PortalOAuthDefaults.ExternalScheme);
if (external?.Principal is null)
return RedirectToPage("/Portal/Login");
var email = external.Principal.FindFirstValue(ClaimTypes.Email);
var name = external.Principal.FindFirstValue(ClaimTypes.Name) ?? "고객";
var providerId = external.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
if (string.IsNullOrWhiteSpace(providerId))
return RedirectToPage("/Portal/Login");
var existing = await _portalUserService.GetByProviderAsync(provider, providerId);
if (existing is null && !string.IsNullOrWhiteSpace(email))
{
existing = await _portalUserService.GetByEmailAsync(email);
if (existing is null)
{
int? clientId = null;
var linkedClient = await _clientService.GetByEmailAsync(email);
if (linkedClient is null && !string.IsNullOrWhiteSpace(external.Principal.FindFirstValue("phone")))
linkedClient = await _clientService.GetByPhoneAsync(external.Principal.FindFirstValue("phone")!);
if (linkedClient is not null)
clientId = linkedClient.Id;
await _portalUserService.RegisterOAuthAsync(
name,
email,
external.Principal.FindFirstValue("phone") ?? "",
provider,
providerId,
clientId);
existing = await _portalUserService.GetByEmailAsync(email);
}
else if (!string.Equals(existing.Provider, provider, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(existing.ProviderId, providerId, StringComparison.OrdinalIgnoreCase))
{
await _portalUserService.LinkOAuthAsync(existing, provider, providerId, name, email);
}
}
if (existing is not null && !existing.ClientId.HasValue && !string.IsNullOrWhiteSpace(email))
{
var linkedClient = await _clientService.GetByEmailAsync(email);
if (linkedClient is null && !string.IsNullOrWhiteSpace(external.Principal.FindFirstValue("phone")))
linkedClient = await _clientService.GetByPhoneAsync(external.Principal.FindFirstValue("phone")!);
if (linkedClient is not null)
{
await _portalUserService.AttachClientAsync(existing, linkedClient.Id);
existing.ClientId = linkedClient.Id;
}
}
if (existing is null)
return RedirectToPage("/Portal/Login");
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, existing.Id.ToString()),
new(ClaimTypes.Name, existing.Name),
new(ClaimTypes.Email, existing.Email),
new("portal_user_id", existing.Id.ToString())
};
if (existing.ClientId.HasValue)
claims.Add(new("client_id", existing.ClientId.Value.ToString()));
await HttpContext.SignInAsync(
PortalAuthDefaults.Scheme,
new ClaimsPrincipal(new ClaimsIdentity(claims, PortalAuthDefaults.Scheme)),
new AuthenticationProperties { IsPersistent = true });
await HttpContext.SignOutAsync(PortalOAuthDefaults.ExternalScheme);
return RedirectToPage("/Portal/Index");
}
}
+167
View File
@@ -0,0 +1,167 @@
@page "/portal"
@model TaxBaik.Web.Pages.Portal.IndexModel
@{
ViewData["Title"] = "마이 포털 - 세무사 백원숙";
ViewData["Description"] = "고객님의 세무 신고 일정과 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
}
<div class="bg-light py-5">
<div class="container">
<!-- 상단 헤더 & 환영 문구 -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-5 pb-4 border-bottom">
<div>
<p class="text-primary fw-bold mb-1">TaxBaik My Portal</p>
<h1 class="display-6 fw-bold text-dark">안녕하세요, @(User.Identity?.Name)님!</h1>
@if (Model.ClientInfo != null)
{
<p class="text-muted mb-0">
<i class="bi bi-building"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName)
| <i class="bi bi-telephone"></i> @Model.ClientInfo.Phone
</p>
}
</div>
<div class="mt-3 mt-sm-0">
<form method="post" action="/taxbaik/portal/logout" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-box-arrow-right"></i> 로그아웃
</button>
</form>
</div>
</div>
@if (Model.ClientInfo == null)
{
<!-- 연동 대기 경고 -->
<div class="card border-warning shadow-sm mb-5">
<div class="card-body p-5 text-center">
<div class="mb-4">
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
</div>
<h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3>
<p class="text-muted max-width-md mx-auto mb-4">
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 레코드를 찾지 못했습니다.<br />
세무사 측에서 고객 등록을 완료하거나 관리자 백오피스에서 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
</p>
<a href="/taxbaik/contact" class="btn btn-primary px-4 py-2">
<i class="bi bi-chat-dots"></i> 세무사에게 문의하기
</a>
</div>
</div>
}
else
{
<div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<div class="col-lg-8">
<div class="card glass-card mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="h5 fw-bold text-dark mb-0">
<i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황
</h3>
<span class="badge bg-secondary">총 @(Model.Filings.Count)건</span>
</div>
@if (!Model.Filings.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-folder-x display-4 d-block mb-3 text-secondary"></i>
등록된 세무 신고 일정이 없습니다.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th scope="col">신고 종류</th>
<th scope="col">신고 기한</th>
<th scope="col">진행 상태</th>
<th scope="col">메모</th>
</tr>
</thead>
<tbody>
@foreach (var filing in Model.Filings)
{
var dDay = (filing.DueDate - DateTime.Today).Days;
var statusClass = filing.Status switch
{
"filed" => "bg-success-subtle text-success",
"overdue" => "bg-danger-subtle text-danger",
_ => "bg-warning-subtle text-warning-emphasis"
};
var statusLabel = filing.Status switch
{
"filed" => "신고 완료",
"overdue" => "기한 초과",
_ => $"D-{dDay}"
};
<tr>
<td>
<span class="fw-bold text-dark">@filing.FilingType</span>
</td>
<td>
<span>@filing.DueDate.ToString("yyyy-MM-dd")</span>
</td>
<td>
<span class="badge @statusClass px-2.5 py-1.5 fs-7">@statusLabel</span>
</td>
<td class="text-muted small">
@(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo)
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
<div class="col-lg-4">
<div class="card glass-card">
<div class="card-body p-4">
<h3 class="h5 fw-bold text-dark mb-4">
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
</h3>
@if (!Model.Consultations.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-chat-square-dots display-4 d-block mb-3 text-secondary"></i>
최근 상담 이력이 없습니다.
</div>
}
else
{
<div class="timeline ps-2">
@foreach (var activity in Model.Consultations)
{
<div class="timeline-item-modern">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span>
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
</div>
<p class="text-dark small mb-1 fw-semibold">@activity.Description</p>
@if (!string.IsNullOrEmpty(activity.Outcome))
{
<div class="bg-light p-2 rounded small text-muted mt-1">
<strong>결과:</strong> @activity.Outcome
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
</div>
}
</div>
</div>
@@ -0,0 +1,49 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
private readonly TaxFilingService _taxFilingService;
private readonly ConsultingActivityService _consultingActivityService;
private readonly ClientService _clientService;
public IndexModel(
TaxFilingService taxFilingService,
ConsultingActivityService consultingActivityService,
ClientService clientService)
{
_taxFilingService = taxFilingService;
_consultingActivityService = consultingActivityService;
_clientService = clientService;
}
public Client? ClientInfo { get; private set; }
public List<TaxFiling> Filings { get; private set; } = new();
public List<ConsultingActivity> Consultations { get; private set; } = new();
public async Task<IActionResult> OnGetAsync()
{
var clientIdClaim = User.FindFirst("client_id");
if (clientIdClaim != null && int.TryParse(clientIdClaim.Value, out var clientId))
{
ClientInfo = await _clientService.GetByIdAsync(clientId);
if (ClientInfo != null)
{
var filingsData = await _taxFilingService.GetByClientIdAsync(clientId);
Filings = filingsData.OrderBy(f => f.DueDate).ToList();
var consultationsData = await _consultingActivityService.GetByClientIdAsync(clientId);
Consultations = consultationsData.OrderByDescending(c => c.ActivityDate).ToList();
}
}
return Page();
}
}
+40
View File
@@ -0,0 +1,40 @@
@page "/portal/login"
@model TaxBaik.Web.Pages.Portal.LoginModel
@{
ViewData["Title"] = "고객 포털 로그인";
ViewData["Description"] = "고객 포털 로그인 페이지입니다.";
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal/login";
}
<section class="container py-5" style="max-width: 560px;">
<h1 class="h3 fw-bold mb-4">고객 포털 로그인</h1>
<div class="alert alert-secondary">
포털 인증은 다음 단계에서 이메일/비밀번호와 소셜 로그인으로 연결됩니다.
</div>
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="alert alert-danger">@Model.ErrorMessage</div>
}
<form method="post" class="vstack gap-3">
<div>
<label class="form-label">이메일</label>
<input class="form-control" asp-for="Email" />
</div>
<div>
<label class="form-label">비밀번호</label>
<input class="form-control" asp-for="Password" type="password" />
</div>
<button class="btn btn-dark" type="submit">로그인</button>
</form>
<div class="d-grid gap-2 mt-4">
<form method="post" asp-page-handler="Google">
<button class="btn btn-outline-dark w-100" type="submit">Google로 로그인</button>
</form>
<form method="post" asp-page-handler="Naver">
<button class="btn btn-outline-success w-100" type="submit">Naver로 로그인</button>
</form>
<form method="post" asp-page-handler="Kakao">
<button class="btn btn-outline-warning w-100" type="submit">Kakao로 로그인</button>
</form>
</div>
</section>
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
public class LoginModel : PageModel
{
private readonly PortalAuthService _portalAuthService;
[BindProperty]
public string Email { get; set; } = "";
[BindProperty]
public string Password { get; set; } = "";
[BindProperty]
public string? ErrorMessage { get; set; }
public LoginModel(PortalAuthService portalAuthService)
{
_portalAuthService = portalAuthService;
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password))
{
ErrorMessage = "이메일과 비밀번호를 입력하세요.";
return Page();
}
var signedIn = await _portalAuthService.SignInAsync(Email, Password);
if (!signedIn)
{
ErrorMessage = "로그인 정보를 확인할 수 없습니다.";
return Page();
}
return RedirectToPage("/Portal/Index");
}
public IActionResult OnPostGoogle() => Challenge(BuildProps("google"), PortalOAuthDefaults.GoogleScheme);
public IActionResult OnPostNaver() => Challenge(BuildProps("naver"), PortalOAuthDefaults.NaverScheme);
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
private static AuthenticationProperties BuildProps(string provider) =>
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
}
@@ -0,0 +1,35 @@
@page "/portal/register"
@model TaxBaik.Web.Pages.Portal.RegisterModel
@{
ViewData["Title"] = "고객 포털 회원가입";
ViewData["Description"] = "고객 포털 회원가입 페이지입니다.";
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal/register";
}
<section class="container py-5" style="max-width: 640px;">
<h1 class="h3 fw-bold mb-4">고객 포털 회원가입</h1>
<div class="alert alert-secondary">
가입 흐름은 다음 단계에서 이메일/전화번호 검증과 소셜 로그인으로 확장합니다.
</div>
<form method="post" class="row g-3">
<div class="col-md-6">
<label class="form-label">이름</label>
<input class="form-control" asp-for="Name" />
</div>
<div class="col-md-6">
<label class="form-label">연락처</label>
<input class="form-control" asp-for="Phone" />
</div>
<div class="col-12">
<label class="form-label">이메일</label>
<input class="form-control" asp-for="Email" />
</div>
<div class="col-12">
<label class="form-label">비밀번호</label>
<input class="form-control" asp-for="Password" type="password" />
</div>
<div class="col-12">
<button class="btn btn-dark" type="submit">가입하기</button>
</div>
</form>
</section>
@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
public class RegisterModel : PageModel
{
private readonly PortalUserService _portalUserService;
private readonly ClientService _clientService;
[BindProperty]
public string Name { get; set; } = "";
[BindProperty]
public string Phone { get; set; } = "";
[BindProperty]
public string Email { get; set; } = "";
[BindProperty]
public string Password { get; set; } = "";
[BindProperty]
public string? ErrorMessage { get; set; }
public RegisterModel(PortalUserService portalUserService, ClientService clientService)
{
_portalUserService = portalUserService;
_clientService = clientService;
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (string.IsNullOrWhiteSpace(Name) || string.IsNullOrWhiteSpace(Email))
{
ErrorMessage = "이름과 이메일을 입력하세요.";
return Page();
}
if (string.IsNullOrWhiteSpace(Password) || Password.Length < 8)
{
ErrorMessage = "비밀번호는 8자 이상이어야 합니다.";
return Page();
}
var existing = await _portalUserService.GetByEmailAsync(Email);
if (existing is not null)
{
ErrorMessage = "이미 등록된 이메일입니다.";
return Page();
}
int? clientId = null;
var linkedClient = await _clientService.GetByEmailAsync(Email);
if (linkedClient is null && !string.IsNullOrWhiteSpace(Phone))
linkedClient = await _clientService.GetByPhoneAsync(Phone);
if (linkedClient is not null)
clientId = linkedClient.Id;
await _portalUserService.RegisterLocalAsync(
Name,
Email,
Phone,
PortalAuthService.HashPassword(Password),
clientId: clientId);
return RedirectToPage("/Portal/Login");
}
}
+77
View File
@@ -0,0 +1,77 @@
@page
@model TaxBaik.Web.Pages.PrivacyModel
@{
ViewData["Title"] = "개인정보처리방침 | 백원숙 세무회계";
ViewData["Description"] = "백원숙 세무회계의 개인정보 수집·이용·보관에 관한 방침을 안내합니다.";
}
<div class="container py-5" style="max-width:800px">
<h1 class="h3 fw-bold mb-4">개인정보처리방침</h1>
<p class="text-muted mb-4">최종 수정일: 2026년 6월 27일</p>
<p>백원숙 세무회계(이하 "사무소")는 「개인정보 보호법」에 따라 고객의 개인정보를 보호하고, 관련 불만을 신속하게 처리하기 위해 아래와 같이 개인정보처리방침을 수립·공개합니다.</p>
<hr class="my-4" />
<h2 class="h5 fw-bold mt-4 mb-2">1. 수집하는 개인정보 항목</h2>
<ul>
<li><strong>필수:</strong> 성명, 연락처(전화번호)</li>
<li><strong>선택:</strong> 이메일 주소, 문의 내용</li>
<li><strong>자동 수집:</strong> 접속 IP 주소, 접속 일시</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">2. 개인정보의 수집·이용 목적</h2>
<ul>
<li>세무·부동산·가족자산 상담 신청 접수 및 답변</li>
<li>서비스 이용 문의에 대한 회신</li>
<li>서비스 품질 향상을 위한 통계 분석 (비식별화 처리)</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">3. 개인정보의 보유 및 이용 기간</h2>
<p>상담 완료 후 <strong>3년</strong>간 보관 후 파기합니다. 단, 관계 법령에 따라 보관이 필요한 경우 해당 기간 동안 보관합니다.</p>
<ul>
<li>전자상거래 기록: 5년 (전자상거래 등에서의 소비자 보호에 관한 법률)</li>
<li>세금계산서 관련 자료: 5년 (부가가치세법)</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">4. 개인정보의 제3자 제공</h2>
<p>사무소는 원칙적으로 고객의 개인정보를 외부에 제공하지 않습니다. 다만, 다음의 경우에는 예외로 합니다.</p>
<ul>
<li>고객이 동의한 경우</li>
<li>법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관이 요구하는 경우</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">5. 개인정보의 파기</h2>
<p>보유 기간이 경과하거나 처리 목적이 달성된 경우 지체 없이 파기합니다.</p>
<ul>
<li><strong>전자 파일 형태:</strong> 복구 불가능한 방법으로 영구 삭제</li>
<li><strong>종이 문서:</strong> 분쇄기 파기 또는 소각</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">6. 정보주체의 권리·의무</h2>
<p>고객은 언제든지 다음 권리를 행사할 수 있습니다.</p>
<ul>
<li>개인정보 열람 요구</li>
<li>오류 등이 있을 경우 정정 요구</li>
<li>삭제 요구</li>
<li>처리 정지 요구</li>
</ul>
<p>권리 행사는 아래 연락처로 서면, 전화, 이메일로 요청하시면 지체 없이 조치하겠습니다.</p>
<h2 class="h5 fw-bold mt-4 mb-2">7. 개인정보 보호책임자</h2>
<table class="table table-bordered mt-2" style="max-width:400px">
<tbody>
<tr><th>성명</th><td>백원숙</td></tr>
<tr><th>직책</th><td>세무사</td></tr>
<tr><th>연락처</th><td>010-4122-8268</td></tr>
<tr><th>이메일</th><td>taxbaik5668@gmail.com</td></tr>
</tbody>
</table>
<h2 class="h5 fw-bold mt-4 mb-2">8. 개인정보 처리방침 변경</h2>
<p>이 개인정보처리방침은 2026년 6월 27일부터 적용되며, 변경 시 홈페이지 공지를 통해 안내합니다.</p>
<div class="mt-5">
<a href="/taxbaik" class="btn btn-outline-primary">홈으로 돌아가기</a>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
namespace TaxBaik.Web.Pages;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class PrivacyModel : PageModel
{
public void OnGet() { }
}
+172
View File
@@ -0,0 +1,172 @@
@page
@{
ViewData["Title"] = "주요 서비스 | 백원숙 세무회계";
ViewData["Description"] = "사업자 세무, 부동산 세금, 종합소득세 등 전문 상담 서비스";
}
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb" class="py-3" style="background: #F9F7F3; border-bottom: 1px solid #D9D3C4;">
<div class="container">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="/taxbaik/" class="text-decoration-none">홈</a></li>
<li class="breadcrumb-item active">서비스</li>
</ol>
</div>
</nav>
<div class="container py-5">
<div class="mb-4">
<a href="/taxbaik/" class="btn btn-sm btn-outline-secondary">← 홈으로 돌아가기</a>
</div>
<h1 class="fw-bold mb-5 text-center">주요 서비스</h1>
<!-- 사업자 세무 -->
<section class="mb-5 pb-5 border-bottom">
<div class="row align-items-center">
<div class="col-md-6 mb-4">
<h2 class="fw-bold mb-3">사업자 세무</h2>
<p class="lead">사업 운영 중 발생하는 모든 세무 문제를 전문적으로 처리합니다.</p>
<ul class="list-unstyled">
<li class="mb-2">✓ 회계 기장 및 장부 관리</li>
<li class="mb-2">✓ 세금계산서 발급 및 관리</li>
<li class="mb-2">✓ 경비 처리 및 절세 전략</li>
<li class="mb-2">✓ 원천징수 관리</li>
<li class="mb-2">✓ 결산 및 세무신고</li>
</ul>
<p class="text-muted mt-4"><small>상담료: 7만~15만원</small></p>
</div>
<div class="col-md-6">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">이런 분들이 찾습니다</h5>
<ul class="list-unstyled">
<li class="mb-2">💼 법인 및 개인사업자</li>
<li class="mb-2">🛍️ 온라인쇼핑몰 운영자</li>
<li class="mb-2">🏢 자영업자</li>
<li class="mb-2">📊 첫 사업을 시작한 분</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 부동산 세금 -->
<section class="mb-5 pb-5 border-bottom">
<div class="row align-items-center">
<div class="col-md-6 order-md-2 mb-4">
<h2 class="fw-bold mb-3">부동산 세금</h2>
<p class="lead">부동산 거래 시 발생하는 세금을 절감하고 최적화합니다.</p>
<ul class="list-unstyled">
<li class="mb-2">✓ 양도세 계산 및 절감 전략</li>
<li class="mb-2">✓ 취득세 및 등록세 최소화</li>
<li class="mb-2">✓ 임대소득세 신고</li>
<li class="mb-2">✓ 1주택 비과세 판정</li>
<li class="mb-2">✓ 다주택자 세금 최적화</li>
</ul>
<p class="text-muted mt-4"><small>상담료: 10만~20만원</small></p>
</div>
<div class="col-md-6 order-md-1">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">이런 분들이 찾습니다</h5>
<ul class="list-unstyled">
<li class="mb-2">🏠 부동산 양도 예정자</li>
<li class="mb-2">🏘️ 다주택 소유자</li>
<li class="mb-2">🏠 전월세 임대인</li>
<li class="mb-2">🔄 상속 부동산 처리</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 종합소득세 -->
<section class="mb-5 pb-5 border-bottom">
<div class="row align-items-center">
<div class="col-md-6 mb-4">
<h2 class="fw-bold mb-3">종합소득세</h2>
<p class="lead">프리랜서, 보험설계사 등 다양한 소득에 최적화된 신고를 지원합니다.</p>
<ul class="list-unstyled">
<li class="mb-2">✓ 기준경비율 적용 최적화</li>
<li class="mb-2">✓ 실제경비 계산 및 증명</li>
<li class="mb-2">✓ 직업별 맞춤 절세 전략</li>
<li class="mb-2">✓ 기납부세액 환급 신청</li>
<li class="mb-2">✓ 근로소득 + 기타소득 통합 신고</li>
</ul>
<p class="text-muted mt-4"><small>상담료: 7만~15만원</small></p>
</div>
<div class="col-md-6">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">이런 분들이 찾습니다</h5>
<ul class="list-unstyled">
<li class="mb-2">💻 프리랜서</li>
<li class="mb-2">🎓 강사 및 컨설턴트</li>
<li class="mb-2">💰 보험설계사</li>
<li class="mb-2">📈 부동산중개사</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 가족자산 & 증여세 -->
<section class="mb-5">
<div class="row align-items-center">
<div class="col-md-6 order-md-2 mb-4">
<h2 class="fw-bold mb-3">가족자산 & 증여세</h2>
<p class="lead">세대 간 자산 이전 시 최소한의 세금으로 전략적 이전을 지원합니다.</p>
<ul class="list-unstyled">
<li class="mb-2">✓ 증여세 기초공제 활용</li>
<li class="mb-2">✓ 배우자 공제 전략</li>
<li class="mb-2">✓ 단계적 증여 계획</li>
<li class="mb-2">✓ 자녀 교육자금 증여</li>
<li class="mb-2">✓ 상속세 절감 전략</li>
</ul>
<p class="text-muted mt-4"><small>상담료: 15만~25만원</small></p>
</div>
<div class="col-md-6 order-md-1">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">이런 분들이 찾습니다</h5>
<ul class="list-unstyled">
<li class="mb-2">👨‍👩‍👧‍👦 자녀에게 증여 예정</li>
<li class="mb-2">🏠 부동산 상속 예정</li>
<li class="mb-2">💼 기업 승계 계획</li>
<li class="mb-2">💰 자산관리 중장기 계획</li>
</ul>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="bg-primary text-white py-5 rounded mt-5 mb-5">
<div class="text-center">
<h2 class="fw-bold mb-3">전문 상담받으세요</h2>
<p class="lead mb-4">정확한 진단 후 맞춤형 솔루션을 제시합니다</p>
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">무료 상담 신청</a>
</div>
</section>
<!-- 관련 페이지 네비게이션 -->
<section class="text-center py-5">
<h4 class="fw-bold mb-4">다른 페이지 보기</h4>
<div class="row g-3 justify-content-center">
<div class="col-md-4">
<a href="/taxbaik/" class="btn btn-outline-primary btn-sm w-100 py-3">
🏠 홈<br/>
<small class="text-muted">최신 정보 및 블로그</small>
</a>
</div>
<div class="col-md-4">
<a href="/taxbaik/about" class="btn btn-outline-primary btn-sm w-100 py-3">
👤 세무사 소개<br/>
<small class="text-muted">자격 및 상담 철학</small>
</a>
</div>
<div class="col-md-4">
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-sm w-100 py-3">
📝 세무 정보<br/>
<small class="text-muted">절세팁 및 신고 가이드</small>
</a>
</div>
</div>
</section>
</div>
@@ -0,0 +1,33 @@
<footer class="bg-light border-top mt-5 py-4">
<div class="container">
<div class="row g-4">
<div class="col-md-4">
<h6 class="fw-bold">백원숙 세무회계</h6>
<p class="small text-muted">
사업자 기장, 부동산 양도세·증여세,<br />
종합소득세 전문 상담
</p>
</div>
<div class="col-md-4">
<h6 class="fw-bold">연락처</h6>
<p class="small">
📞 <a href="tel:010-4122-8268" class="text-decoration-none">010-4122-8268</a><br />
📧 <a href="mailto:taxbaik5668@gmail.com" class="text-decoration-none">taxbaik5668@gmail.com</a>
</p>
</div>
<div class="col-md-4">
<h6 class="fw-bold">채널</h6>
<p class="small">
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-sm btn-warning me-2">카카오톡</a>
<a href="https://www.instagram.com/taxtory5668/" target="_blank" class="btn btn-sm btn-outline-secondary">Instagram</a>
</p>
</div>
</div>
<hr class="my-3" />
<div class="text-center small text-muted">
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
<a href="/taxbaik/privacy" class="text-decoration-none text-muted me-2">개인정보처리방침</a>
<a href="/taxbaik/terms" class="text-decoration-none text-muted">이용약관</a>
</div>
</div>
</footer>
@@ -0,0 +1,34 @@
<header class="sticky-top bg-white border-bottom site-header">
<nav class="navbar navbar-expand-lg navbar-light container-fluid px-3 py-2">
<a class="navbar-brand fw-bold" href="/taxbaik">
<span class="text-primary">백원숙</span> 세무회계
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse mt-3 mt-lg-0" id="navbarNav">
<ul class="navbar-nav ms-auto gap-2 align-items-lg-center">
<li class="nav-item">
<a class="nav-link" href="/taxbaik">홈</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/taxbaik/about">소개</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/taxbaik/services">서비스</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/taxbaik/blog">블로그</a>
</li>
<li class="nav-item">
<a class="btn btn-primary btn-sm ms-lg-2 w-100 w-lg-auto" href="/taxbaik/contact">상담신청</a>
</li>
</ul>
</div>
<div class="ms-3 d-none d-md-block">
<a href="tel:010-4122-8268" class="text-decoration-none text-dark fw-500">
📞 010-4122-8268
</a>
</div>
</nav>
</header>
@@ -0,0 +1,3 @@
@using TaxBaik.Web
@namespace TaxBaik.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+14
View File
@@ -0,0 +1,14 @@
@page
@model TaxBaik.Web.Pages.SitemapModel
@{
Response.ContentType = "application/xml";
}<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach (var url in Model.Urls)
{
<url>
<loc>@url</loc>
<lastmod>@DateTime.UtcNow:yyyy-MM-dd</lastmod>
</url>
}
</urlset>
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Pages;
public class SitemapModel : PageModel
{
private readonly BlogService _blogService;
public List<string> Urls { get; set; } = [];
public SitemapModel(BlogService blogService)
{
_blogService = blogService;
}
public async Task OnGetAsync()
{
var baseUrl = "http://178.104.200.7/taxbaik";
Urls.AddRange(new[]
{
$"{baseUrl}",
$"{baseUrl}/about",
$"{baseUrl}/services",
$"{baseUrl}/contact",
$"{baseUrl}/blog"
});
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000);
foreach (var post in posts)
{
Urls.Add($"{baseUrl}/blog/{post.Slug}");
}
}
}
+56
View File
@@ -0,0 +1,56 @@
@page
@model TaxBaik.Web.Pages.TermsModel
@{
ViewData["Title"] = "이용약관 | 백원숙 세무회계";
ViewData["Description"] = "백원숙 세무회계 홈페이지 이용약관을 안내합니다.";
}
<div class="container py-5" style="max-width:800px">
<h1 class="h3 fw-bold mb-4">이용약관</h1>
<p class="text-muted mb-4">최종 수정일: 2026년 6월 27일 / 시행일: 2026년 6월 27일</p>
<h2 class="h5 fw-bold mt-4 mb-2">제1조 (목적)</h2>
<p>이 약관은 백원숙 세무회계(이하 "사무소")가 운영하는 홈페이지(taxbaik.com, 이하 "사이트")에서 제공하는 온라인 상담 신청 및 정보 서비스 이용과 관련하여 사무소와 이용자의 권리·의무 및 책임사항을 규정함을 목적으로 합니다.</p>
<h2 class="h5 fw-bold mt-4 mb-2">제2조 (정의)</h2>
<ul>
<li>"서비스"란 사이트에서 제공하는 세무 정보 제공, 상담 신청, 블로그 콘텐츠 열람 등 일체의 서비스를 말합니다.</li>
<li>"이용자"란 사이트에 접속하여 서비스를 이용하는 자를 말합니다.</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">제3조 (약관의 효력 및 변경)</h2>
<p>이 약관은 사이트 내 게시함으로써 효력이 발생합니다. 사무소는 필요한 경우 약관을 변경할 수 있으며, 변경된 약관은 사이트 공지 후 7일이 경과한 날로부터 효력이 발생합니다.</p>
<h2 class="h5 fw-bold mt-4 mb-2">제4조 (서비스 제공)</h2>
<p>사무소는 다음 서비스를 제공합니다.</p>
<ul>
<li>세무·부동산·가족자산 관련 정보 콘텐츠 제공</li>
<li>온라인 상담 신청 접수</li>
<li>시즌별 세무 정보 안내</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">제5조 (서비스 이용 시 주의사항)</h2>
<ul>
<li>사이트에서 제공하는 세무 정보는 일반적인 안내 목적으로, 개별 사안에 대한 법적 효력을 갖지 않습니다.</li>
<li>구체적인 세무 처리는 반드시 전문가 상담을 통해 진행하시기 바랍니다.</li>
<li>이용자는 상담 신청 시 허위 정보를 제공해서는 안 됩니다.</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">제6조 (면책 조항)</h2>
<p>사무소는 다음 사항에 대해 책임을 지지 않습니다.</p>
<ul>
<li>천재지변 또는 이에 준하는 불가항력으로 서비스를 제공할 수 없는 경우</li>
<li>이용자의 귀책 사유로 인한 서비스 이용 장애</li>
<li>사이트에서 제공하는 일반 정보를 개별 사안에 적용함으로써 발생하는 손해</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">제7조 (저작권)</h2>
<p>사이트에 게시된 모든 콘텐츠(글, 이미지, 디자인 등)의 저작권은 사무소 또는 원저작자에게 있으며, 무단 복제·배포를 금합니다.</p>
<h2 class="h5 fw-bold mt-4 mb-2">제8조 (준거법 및 재판관할)</h2>
<p>이 약관에 관한 분쟁은 대한민국 법령을 적용하며, 관할 법원은 민사소송법에 따른 관할 법원으로 합니다.</p>
<div class="mt-5">
<a href="/taxbaik" class="btn btn-outline-primary">홈으로 돌아가기</a>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
namespace TaxBaik.Web.Pages;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class TermsModel : PageModel
{
public void OnGet() { }
}
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")</title>
<meta name="description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta name="keywords" content="백원숙 세무회계, 세무사, 사업자 기장, 양도소득세, 증여세, 상속세, 종합소득세, 절세 상담, 세무 대리" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="og:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#C89D6E" />
<link rel="icon" type="image/svg+xml" href="/taxbaik/favicon.svg" />
<link rel="alternate icon" href="/taxbaik/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<!-- 구조화된 데이터 (JSON-LD Schema Markup) -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "ProfessionalService",
"name": "백원숙 세무회계",
"description": "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담 세무사",
"url": "http://178.104.200.7/taxbaik/",
"telephone": "010-4122-8268",
"email": "taxbaik5668@gmail.com",
"address": {
"@@type": "PostalAddress",
"addressCountry": "KR"
},
"sameAs": [
"https://www.instagram.com/taxtory5668/",
"http://pf.kakao.com/_xoxchTX"
]
}
</script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-25KRKY45D7"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-25KRKY45D7');
</script>
</head>
<body class="with-mobile-cta">
<partial name="_Header" />
<main role="main" class="pb-5">
@RenderBody()
</main>
<footer class="bg-light border-top mt-5 py-5">
<div class="container">
<div class="row g-5">
<div class="col-md-3">
<h6 class="fw-bold mb-3">백원숙 세무회계</h6>
<p class="small text-muted">
사업자 기장, 부동산 양도세·증여세,<br />
종합소득세 전문 상담
</p>
</div>
<div class="col-md-3">
<h6 class="fw-bold mb-3">메뉴</h6>
<ul class="list-unstyled small">
<li class="mb-2"><a href="/taxbaik/" class="text-decoration-none text-muted">홈</a></li>
<li class="mb-2"><a href="/taxbaik/about" class="text-decoration-none text-muted">세무사 소개</a></li>
<li class="mb-2"><a href="/taxbaik/services" class="text-decoration-none text-muted">전문 서비스</a></li>
<li class="mb-2"><a href="/taxbaik/blog" class="text-decoration-none text-muted">세무 정보</a></li>
<li><a href="/taxbaik/contact" class="text-decoration-none text-muted">상담 신청</a></li>
</ul>
</div>
<div class="col-md-3">
<h6 class="fw-bold mb-3">연락처</h6>
<p class="small">
📞 <a href="tel:010-4122-8268" class="text-decoration-none text-muted">010-4122-8268</a><br />
📧 <a href="mailto:taxbaik5668@gmail.com" class="text-decoration-none text-muted">taxbaik5668@gmail.com</a>
</p>
</div>
<div class="col-md-3">
<h6 class="fw-bold mb-3">채널</h6>
<p class="small">
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-sm btn-warning me-2">카카오톡</a>
<a href="https://www.instagram.com/taxtory5668/" target="_blank" class="btn btn-sm btn-outline-secondary">Instagram</a>
</p>
</div>
</div>
<hr class="my-4" />
<div class="text-center small text-muted">
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
<div class="mb-2">
<a href="/taxbaik/privacy" class="text-decoration-none text-muted me-2">개인정보처리방침</a>
<span class="text-muted">|</span>
<a href="/taxbaik/terms" class="text-decoration-none text-muted ms-2 me-2">이용약관</a>
<span class="text-muted">|</span>
<a href="/taxbaik/portal" class="text-decoration-none text-muted ms-2">고객 포털</a>
</div>
@if (Context.RequestServices.GetService(typeof(VersionInfo)) is VersionInfo version)
{
<div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;">
v@(version.Version) · @version.Built
</div>
}
</div>
</div>
</footer>
<!-- Mobile Fixed CTA -->
<div class="mobile-cta-bar d-lg-none">
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn-kakao-mobile">
💬 카카오 상담하기
</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="~/js/site.js" asp-append-version="true" defer></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+448
View File
@@ -0,0 +1,448 @@
using System.IO.Compression;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.IdentityModel.Tokens;
using MudBlazor.Services;
using Serilog;
using System.Threading.RateLimiting;
using TaxBaik.Application;
using TaxBaik.Application.Services;
using TaxBaik.Application.Utils;
using TaxBaik.Infrastructure;
using TaxBaik.Web.Services;
using TaxBaik.Web.Services.AdminClients;
var builder = WebApplication.CreateBuilder(args);
var isProduction = builder.Environment.IsProduction();
// HTTP 요청 헤더/쿠키 크기 제한 증가 (400 Bad Request 해결)
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100MB
});
// Serilog 설정
builder.Host.UseSerilog((context, config) =>
{
config
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File(
path: "logs/taxbaik-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.Enrich.FromLogContext()
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName);
var botToken = context.Configuration["Telegram:BotToken"];
var systemChatId = context.Configuration["Telegram:SystemChatId"] ?? context.Configuration["Telegram:ChatId"];
if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(systemChatId))
{
config.WriteTo.Sink(new TaxBaik.Web.Logging.TelegramSink(botToken, systemChatId), Serilog.Events.LogEventLevel.Error);
}
});
// Controllers (API)
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("client-logs", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"client-logs:{ip}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
});
});
});
// Razor Pages + Blazor WebAssembly 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DetailedErrors = true;
});
// Session & TempData (쿠키 저장소)
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(20);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.Name = "TaxBaik.SessionId";
});
builder.Services.AddDistributedMemoryCache();
// TempData는 기본적으로 쿠키 저장소 사용 (여기서 명시적 설정)
// JWT 인증
var connectionString = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("Missing connection string");
var jwtKey = builder.Configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing JWT SecretKey");
if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
var key = Encoding.ASCII.GetBytes(jwtKey);
var authenticationBuilder = builder.Services.AddAuthentication(opts =>
{
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(opts =>
{
opts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = true,
ValidIssuer = "taxbaik-admin",
ValidateAudience = true,
ValidAudience = "taxbaik-admin-client",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
})
.AddCookie(PortalAuthDefaults.Scheme, opts =>
{
opts.Cookie.Name = PortalAuthDefaults.CookieName;
opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
opts.LoginPath = "/taxbaik/portal/login";
opts.AccessDeniedPath = "/taxbaik/portal/login";
opts.SlidingExpiration = true;
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
})
.AddCookie(PortalOAuthDefaults.ExternalScheme, opts =>
{
opts.Cookie.Name = "TaxBaik.Portal.External";
opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
});
var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
{
authenticationBuilder.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = googleClientId;
opts.ClientSecret = googleClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-google";
});
}
var naverClientId = builder.Configuration["Authentication:Naver:ClientId"];
var naverClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"];
if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(naverClientSecret))
{
authenticationBuilder.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = naverClientId;
opts.ClientSecret = naverClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-naver";
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
opts.SaveTokens = true;
opts.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
var responseRoot = payload.RootElement.GetProperty("response");
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
}
};
});
}
var kakaoClientId = builder.Configuration["Authentication:Kakao:ClientId"];
var kakaoClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"];
if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kakaoClientSecret))
{
authenticationBuilder.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = kakaoClientId;
opts.ClientSecret = kakaoClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-kakao";
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
opts.SaveTokens = true;
opts.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
var profile = kakaoAccount.GetProperty("profile");
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
if (kakaoAccount.TryGetProperty("email", out var emailProp))
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
}
};
});
}
// Blazor 인증
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
// Telegram Notification
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
// HTTP Client for API (with automatic token refresh)
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped<TokenRefreshHandler>();
builder.Services.AddHttpClient<IApiClient, ApiClient>();
var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration: ApiClient:BaseUrl");
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>();
// Phase 5: Tax Accounting & CRM Browser Clients
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>();
// UI & 캐시 (MudBlazor Theme Customization)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
});
builder.Services.AddMemoryCache();
builder.Services.AddResponseCompression(opts => {
opts.Providers.Add<GzipCompressionProvider>();
});
builder.Services.AddHostedService<TelegramReportBackgroundService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<PortalAuthService>();
builder.Services.Configure<PortalAuthOptions>(builder.Configuration.GetSection("Authentication"));
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.AddInfrastructure();
builder.Services.AddApplication();
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
// Register version info
var versionInfo = new VersionInfo();
var versionJsonPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "version.json");
if (File.Exists(versionJsonPath))
{
try
{
var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(versionJsonPath));
var root = json.RootElement;
if (root.TryGetProperty("version", out var versionProp))
versionInfo.Version = versionProp.GetString() ?? "unknown";
if (root.TryGetProperty("built", out var builtProp))
versionInfo.Built = builtProp.GetString() ?? "unknown";
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to parse version.json: {ex.Message}");
}
}
builder.Services.AddSingleton(versionInfo);
var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/taxbaik/favicon.ico", StringComparison.OrdinalIgnoreCase))
{
context.Response.ContentType = "image/svg+xml";
await context.Response.SendFileAsync(Path.Combine(app.Environment.WebRootPath ?? "wwwroot", "favicon.svg"));
return;
}
await next();
});
// Run migrations on startup (non-blocking for development)
try
{
using (var scope = app.Services.CreateScope())
{
var connectionFactory = scope.ServiceProvider.GetRequiredService<TaxBaik.Domain.Interfaces.IDbConnectionFactory>();
var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(connectionString, connectionFactory);
await migrationRunner.RunAsync();
}
}
catch (Exception ex)
{
if (!app.Environment.IsDevelopment())
throw;
Console.WriteLine($"Migration warning (development only): {ex.Message}");
}
app.UsePathBase("/taxbaik");
app.UseResponseCompression();
app.UseStaticFiles();
app.UseSession(); // TempData 쿠키 저장소
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
// API + Razor Pages + Blazor 매핑
app.MapControllers();
app.MapHealthChecks("/healthz");
app.MapRazorPages();
app.MapStaticAssets();
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
// Phase 8: WebAssembly 렌더 모드 완전 마이그레이션
// - App.razor: TaxBaik.WasmClient (호스트, WebAssembly)
// - Routes + Pages + Shared + Layout + Forms: TaxBaik.WasmClient (WebAssembly)
// MapRazorComponents는 자동으로 root 컴포넌트의 어셈블리 로드
// 동일 어셈블리의 Page/Shared 컴포넌트는 자동 발견되므로 AddAdditionalAssemblies 불필요
// (같은 어셈블리를 2번 등록하면 "Assembly already defined" 에러 발생)
app.MapRazorComponents<TaxBaik.WasmClient.Components.Admin.App>()
.AddInteractiveWebAssemblyRenderMode()
.AllowAnonymous();
// 애플리케이션 시작/종료 로깅
try
{
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "애플리케이션 강종");
if (!app.Environment.IsDevelopment())
{
try
{
var fatalMessage = $"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}";
if (TaxBaik.Web.Services.TelegramAlertGate.ShouldSend("telegram:fatal", fatalMessage, TimeSpan.FromMinutes(30)))
{
using var scope = app.Services.CreateScope();
var telegramService = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
await telegramService.SendErrorAsync(
"❌ 서버 오류",
fatalMessage);
}
}
catch (Exception telegramEx)
{
Log.Error(telegramEx, "오류 알림 전송 실패");
}
}
throw;
}
finally
{
Log.Information("애플리케이션 종료");
Log.CloseAndFlush();
}
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7001;http://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+231
View File
@@ -0,0 +1,231 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using BCrypt.Net;
using Microsoft.IdentityModel.Tokens;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Web.Services;
public class AuthService
{
private readonly IAdminUserRepository _adminUserRepository;
private readonly ILogger<AuthService> _logger;
private readonly ITelegramNotificationService _telegramService;
private readonly string _jwtSecretKey;
private readonly string? _passwordResetToken;
private readonly int _accessTokenExpirationMinutes = 60; // Access Token: 1시간 (사용성 향상)
private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일
public AuthService(
IAdminUserRepository adminUserRepository,
ILogger<AuthService> logger,
IConfiguration configuration,
ITelegramNotificationService telegramService)
{
_adminUserRepository = adminUserRepository;
_logger = logger;
_telegramService = telegramService;
_jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
_passwordResetToken = configuration["Admin:PasswordResetToken"];
}
public async Task<AuthTokenPair?> AuthenticateAndGenerateTokenPairAsync(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return null;
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null)
{
_logger.LogWarning("로그인 시도: 존재하지 않는 사용자 '{Username}'", username);
return null;
}
if (string.IsNullOrWhiteSpace(user.PasswordHash))
{
_logger.LogError("로그인 실패: 사용자 '{Username}'의 PasswordHash가 비어 있습니다.", username);
return null;
}
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
// 실패한 로그인은 알림하지 않음 (로그만 남김)
return null;
}
_logger.LogInformation("로그인 성공: {Username}", username);
// 로그인 알림은 제거 (로그만 남김)
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
return GenerateTokenPair(user);
}
public async Task<AuthTokenPair?> RefreshAccessTokenAsync(string refreshToken)
{
try
{
var principal = ValidateRefreshToken(refreshToken);
if (principal == null)
{
_logger.LogWarning("Refresh token 검증 실패");
return null;
}
var username = principal.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
return null;
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null)
return null;
return GenerateTokenPair(user);
}
catch (Exception ex)
{
_logger.LogError(ex, "Refresh token 처리 중 오류");
return null;
}
}
public async Task<bool> ChangePasswordAsync(string username, string currentPassword, string newPassword)
{
if (!IsValidPassword(newPassword))
throw new ArgumentException("새 비밀번호는 12자 이상이어야 합니다.", nameof(newPassword));
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash))
return false;
if (!BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash))
return false;
await _adminUserRepository.UpdatePasswordHashAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(newPassword));
_logger.LogInformation("관리자 비밀번호 변경: {Username}", username);
return true;
}
public async Task<bool> ResetPasswordAsync(string username, string newPassword, string resetToken)
{
if (string.IsNullOrWhiteSpace(_passwordResetToken))
throw new InvalidOperationException("Admin:PasswordResetToken is not configured.");
if (!TimeConstantEquals(resetToken, _passwordResetToken))
return false;
if (!IsValidPassword(newPassword))
throw new ArgumentException("새 비밀번호는 12자 이상이어야 합니다.", nameof(newPassword));
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null)
return false;
await _adminUserRepository.UpdatePasswordHashAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(newPassword));
_logger.LogWarning("관리자 비밀번호 재설정 API 사용: {Username}", username);
return true;
}
private AuthTokenPair GenerateTokenPair(AdminUser user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("username", user.Username)
};
var accessToken = new JwtSecurityToken(
issuer: "taxbaik-admin",
audience: "taxbaik-admin-client",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_accessTokenExpirationMinutes),
signingCredentials: creds);
var refreshToken = new JwtSecurityToken(
issuer: "taxbaik-admin",
audience: "taxbaik-admin-client",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_refreshTokenExpirationMinutes),
signingCredentials: creds);
return new AuthTokenPair(
new JwtSecurityTokenHandler().WriteToken(accessToken),
new JwtSecurityTokenHandler().WriteToken(refreshToken),
_accessTokenExpirationMinutes * 60
);
}
public ClaimsPrincipal? ValidateRefreshToken(string token)
{
try
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = true,
ValidIssuer = "taxbaik-admin",
ValidateAudience = true,
ValidAudience = "taxbaik-admin-client",
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
}, out SecurityToken validatedToken);
return principal;
}
catch
{
return null;
}
}
public ClaimsPrincipal? ValidateToken(string token)
{
try
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = true,
ValidIssuer = "taxbaik-admin",
ValidateAudience = true,
ValidAudience = "taxbaik-admin-client",
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
}, out SecurityToken validatedToken);
return principal;
}
catch
{
return null;
}
}
private static bool IsValidPassword(string password) => !string.IsNullOrWhiteSpace(password) && password.Length >= 12;
private static bool TimeConstantEquals(string value, string expected)
{
var valueBytes = Encoding.UTF8.GetBytes(value);
var expectedBytes = Encoding.UTF8.GetBytes(expected);
return valueBytes.Length == expectedBytes.Length
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes);
}
}
public class AuthTokenPair(string accessToken, string refreshToken, int expiresIn)
{
public string AccessToken { get; } = accessToken;
public string RefreshToken { get; } = refreshToken;
public int ExpiresIn { get; } = expiresIn;
}
@@ -0,0 +1,7 @@
namespace TaxBaik.Web.Services;
public static class PortalAuthDefaults
{
public const string Scheme = "PortalCookie";
public const string CookieName = "TaxBaik.Portal.Auth";
}
@@ -0,0 +1,14 @@
namespace TaxBaik.Web.Services;
public sealed class PortalAuthOptions
{
public ExternalProviderOptions Google { get; set; } = new();
public ExternalProviderOptions Naver { get; set; } = new();
public ExternalProviderOptions Kakao { get; set; } = new();
public sealed class ExternalProviderOptions
{
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
}
}
@@ -0,0 +1,70 @@
namespace TaxBaik.Web.Services;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
public class PortalAuthService(
IHttpContextAccessor httpContextAccessor,
PortalUserService portalUserService)
{
private static readonly PasswordHasher<PortalUser> Hasher = new();
public async Task<bool> SignInAsync(string email, string password, CancellationToken ct = default)
{
var httpContext = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HTTP context is unavailable.");
var user = await portalUserService.GetByEmailAsync(email, ct);
if (user is null)
return false;
if (string.IsNullOrWhiteSpace(user.PasswordHash))
return false;
var verify = Hasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
return false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Name),
new(ClaimTypes.Email, user.Email),
new("portal_user_id", user.Id.ToString())
};
if (user.ClientId.HasValue)
claims.Add(new("client_id", user.ClientId.Value.ToString()));
var identity = new ClaimsIdentity(claims, PortalAuthDefaults.Scheme);
var principal = new ClaimsPrincipal(identity);
await httpContext.SignInAsync(
PortalAuthDefaults.Scheme,
principal,
new AuthenticationProperties
{
IsPersistent = true
});
return true;
}
public static string HashPassword(string password)
{
var tempUser = new PortalUser();
return Hasher.HashPassword(tempUser, password);
}
public async Task SignOutAsync()
{
var httpContext = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HTTP context is unavailable.");
await httpContext.SignOutAsync(PortalAuthDefaults.Scheme);
}
}
@@ -0,0 +1,9 @@
namespace TaxBaik.Web.Services;
public static class PortalOAuthDefaults
{
public const string ExternalScheme = "PortalExternal";
public const string GoogleScheme = "PortalGoogle";
public const string NaverScheme = "PortalNaver";
public const string KakaoScheme = "PortalKakao";
}
@@ -0,0 +1,57 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace TaxBaik.Web.Services;
internal static class TelegramAlertGate
{
private sealed record GateEntry(DateTimeOffset WindowStart, int Count);
private static readonly ConcurrentDictionary<string, GateEntry> Gates = new();
public static bool ShouldSend(string category, string content, TimeSpan window, int maxPerWindow = 1)
{
if (string.IsNullOrWhiteSpace(category))
return false;
var now = DateTimeOffset.UtcNow;
var key = $"{category}:{Fingerprint(content)}";
while (true)
{
if (!Gates.TryGetValue(key, out var current))
{
var initial = new GateEntry(now, 1);
if (Gates.TryAdd(key, initial))
return true;
continue;
}
if (now - current.WindowStart >= window)
{
var reset = new GateEntry(now, 1);
if (Gates.TryUpdate(key, reset, current))
return true;
continue;
}
if (current.Count >= maxPerWindow)
return false;
var incremented = current with { Count = current.Count + 1 };
if (Gates.TryUpdate(key, incremented, current))
return true;
}
}
private static string Fingerprint(string content)
{
if (string.IsNullOrEmpty(content))
return "empty";
var normalized = content.Length > 1500 ? content[..1500] : content;
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(bytes);
}
}
@@ -0,0 +1,165 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Services;
public class TelegramInquiryNotificationService : IInquiryNotificationService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<TelegramInquiryNotificationService> _logger;
private readonly string _baseUrl;
public TelegramInquiryNotificationService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<TelegramInquiryNotificationService> logger)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
_baseUrl = (_configuration["App:PublicBaseUrl"] ?? "http://178.104.200.7/taxbaik").TrimEnd('/');
}
public async Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default)
{
var botToken = _configuration["Telegram:BotToken"];
var chatId = _configuration["Telegram:InquiryChatId"];
if (string.IsNullOrWhiteSpace(chatId))
chatId = _configuration["Telegram:ChatId"];
if (string.IsNullOrWhiteSpace(botToken) || string.IsNullOrWhiteSpace(chatId))
{
_logger.LogWarning("텔레그램 새 문의 알림 설정이 누락되었습니다. Telegram:BotToken 또는 Telegram:InquiryChatId/ChatId를 확인하세요.");
return;
}
var adminLink = $"{_baseUrl}/admin/inquiries";
var summary = message.Length > 180 ? message[..180] + "..." : message;
var createdAtKst = createdAtUtc.AddHours(9);
var text = new StringBuilder()
.AppendLine("🆕 새 문의가 접수되었습니다.")
.AppendLine()
.AppendLine($"문의 번호: #{inquiryId}")
.AppendLine($"제목: {serviceType}")
.AppendLine($"이름: {name}")
.AppendLine($"연락처: {phone}")
.AppendLine($"접수 시각: {createdAtKst:yyyy-MM-dd HH:mm:ss} KST")
.AppendLine($"IP: {(string.IsNullOrWhiteSpace(ipAddress) ? "-" : ipAddress)}")
.AppendLine()
.AppendLine("내용 요약:")
.AppendLine(summary)
.AppendLine()
.AppendLine($"답변 목록 링크: {adminLink}")
.ToString();
var client = _httpClientFactory.CreateClient();
var url = $"https://api.telegram.org/bot{botToken}/sendMessage";
var payload = new
{
chat_id = chatId,
text,
disable_web_page_preview = false
};
try
{
var response = await client.PostAsJsonAsync(url, payload, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("텔레그램 알림 전송 실패: {StatusCode} {ResponseBody}", response.StatusCode, Truncate(responseBody));
}
else
{
_logger.LogInformation("텔레그램 새 문의 알림 전송 성공: #{InquiryId}, message_id={MessageId}", inquiryId, TryGetMessageId(responseBody));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "텔레그램 알림 전송 중 오류 발생");
}
}
public async Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
{
var botToken = _configuration["Telegram:BotToken"];
var chatId = _configuration["Telegram:InquiryChatId"];
if (string.IsNullOrWhiteSpace(chatId))
chatId = _configuration["Telegram:ChatId"];
if (string.IsNullOrWhiteSpace(botToken) || string.IsNullOrWhiteSpace(chatId))
{
_logger.LogWarning("텔레그램 상태 변경 알림 설정이 누락되었습니다. Telegram:BotToken 또는 Telegram:InquiryChatId/ChatId를 확인하세요.");
return;
}
var adminLink = $"{_baseUrl}/admin/inquiries";
var text = new StringBuilder()
.AppendLine("✏️ 문의 상태가 변경되었습니다.")
.AppendLine()
.AppendLine($"문의 번호: #{inquiryId}")
.AppendLine($"제목: {serviceType}")
.AppendLine($"이름: {name}")
.AppendLine($"연락처: {phone}")
.AppendLine($"상태: {FormatStatus(previousStatus)} -> {FormatStatus(newStatus)}")
.AppendLine($"변경자: {(string.IsNullOrWhiteSpace(changedBy) ? "-" : changedBy)}")
.AppendLine()
.AppendLine($"답변 목록 링크: {adminLink}")
.ToString();
var client = _httpClientFactory.CreateClient();
var url = $"https://api.telegram.org/bot{botToken}/sendMessage";
var payload = new
{
chat_id = chatId,
text,
disable_web_page_preview = false
};
try
{
var response = await client.PostAsJsonAsync(url, payload, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("텔레그램 상태 변경 알림 실패: {StatusCode} {ResponseBody}", response.StatusCode, Truncate(responseBody));
}
else
{
_logger.LogInformation("텔레그램 상태 변경 알림 전송 성공: #{InquiryId}, message_id={MessageId}", inquiryId, TryGetMessageId(responseBody));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "텔레그램 상태 변경 알림 중 오류 발생");
}
}
private static string FormatStatus(string status) => status switch
{
"new" => "신규",
"contacted" => "연락함",
"completed" => "완료",
_ => status
};
private static string TryGetMessageId(string responseBody)
{
try
{
using var document = JsonDocument.Parse(responseBody);
if (document.RootElement.TryGetProperty("result", out var result)
&& result.TryGetProperty("message_id", out var messageId))
{
return messageId.ToString();
}
}
catch (JsonException)
{
return "unknown";
}
return "unknown";
}
private static string Truncate(string value)
=> value.Length <= 500 ? value : value[..500] + "...";
}
@@ -0,0 +1,130 @@
namespace TaxBaik.Web.Services;
using System.Net.Http.Json;
/// <summary>
/// Telegram Bot 알림 서비스
/// 중요 로깅 및 오류를 Telegram으로 전송
/// </summary>
public interface ITelegramNotificationService
{
Task SendMessageAsync(string message, CancellationToken ct = default);
Task SendErrorAsync(string title, string details, CancellationToken ct = default);
Task SendInfoAsync(string title, string message, CancellationToken ct = default);
Task SendInquiryNotificationAsync(string message, CancellationToken ct = default);
Task SendSystemNotificationAsync(string message, CancellationToken ct = default);
Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default);
}
public class TelegramNotificationService : ITelegramNotificationService
{
private readonly HttpClient _httpClient;
private readonly ILogger<TelegramNotificationService> _logger;
private readonly string _botToken;
private readonly string _defaultChatId;
private readonly string _inquiryChatId;
private readonly string _systemChatId;
private const string TelegramApiUrl = "https://api.telegram.org";
public TelegramNotificationService(
HttpClient httpClient,
ILogger<TelegramNotificationService> logger,
IConfiguration config)
{
_httpClient = httpClient;
_logger = logger;
_botToken = config["Telegram:BotToken"] ?? "";
_defaultChatId = config["Telegram:ChatId"] ?? "-5434691215";
_inquiryChatId = config["Telegram:InquiryChatId"] ?? _defaultChatId;
_systemChatId = config["Telegram:SystemChatId"] ?? "-5585148480";
}
public async Task SendMessageAsync(string message, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(_botToken) || string.IsNullOrEmpty(_defaultChatId))
{
_logger.LogWarning("Telegram credentials not configured");
return;
}
if (!TelegramAlertGate.ShouldSend("telegram:default", message, TimeSpan.FromMinutes(5)))
return;
await SendToChat(_defaultChatId, message, ct);
}
public async Task SendInquiryNotificationAsync(string message, CancellationToken ct = default)
{
var text = $"<b>📋 문의 사항</b>\n\n{message}";
if (!TelegramAlertGate.ShouldSend("telegram:inquiry", text, TimeSpan.FromMinutes(10)))
return;
await SendToChat(_inquiryChatId, text, ct);
}
public async Task SendSystemNotificationAsync(string message, CancellationToken ct = default)
{
var text = $"<b>🔧 시스템 알림</b>\n\n{message}";
if (!TelegramAlertGate.ShouldSend("telegram:system", text, TimeSpan.FromMinutes(10)))
return;
await SendToChat(_systemChatId, text, ct);
}
private async Task SendToChat(string chatId, string message, CancellationToken ct)
{
if (string.IsNullOrEmpty(_botToken) || string.IsNullOrEmpty(chatId))
{
_logger.LogWarning("Telegram credentials not configured for chatId {ChatId}", chatId);
return;
}
try
{
var url = $"{TelegramApiUrl}/bot{_botToken}/sendMessage";
var payload = new
{
chat_id = chatId,
text = message,
parse_mode = "HTML"
};
var response = await _httpClient.PostAsJsonAsync(url, payload, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to send Telegram message to {ChatId}: {StatusCode}", chatId, response.StatusCode);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending Telegram message to {ChatId}", chatId);
}
}
public async Task SendErrorAsync(string title, string details, CancellationToken ct = default)
{
var message = $"<b>❌ {title}</b>\n\n{details}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
if (!TelegramAlertGate.ShouldSend("telegram:error", message, TimeSpan.FromMinutes(15)))
return;
await SendToChat(_systemChatId, message, ct);
}
public async Task SendInfoAsync(string title, string message, CancellationToken ct = default)
{
var text = $"<b>️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
if (!TelegramAlertGate.ShouldSend("telegram:info", text, TimeSpan.FromMinutes(30)))
return;
await SendToChat(_defaultChatId, text, ct);
}
public async Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default)
{
var text = $"<b>📊 {reportTitle}</b>\n\n{reportContent}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
if (!TelegramAlertGate.ShouldSend("telegram:report", text, TimeSpan.FromHours(20)))
return;
await SendToChat(_systemChatId, text, ct);
}
}
@@ -0,0 +1,93 @@
namespace TaxBaik.Web.Services;
using Microsoft.Extensions.Hosting;
using TaxBaik.Application.Services;
public class TelegramReportBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<TelegramReportBackgroundService> logger) : BackgroundService
{
private static readonly TimeZoneInfo KoreaTimeZone = GetKoreaTimeZone();
private DateOnly? _lastDailyReportDate;
private DateOnly? _lastWeeklyReportWeekStart;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, KoreaTimeZone);
await TrySendReportsAsync(now, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Telegram report background loop failed");
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown path.
}
}
private async Task TrySendReportsAsync(DateTimeOffset nowKst, CancellationToken ct)
{
if (nowKst.Hour is 9 or 10)
await SendDailyIfNeededAsync(DateOnly.FromDateTime(nowKst.DateTime), ct);
if (nowKst.DayOfWeek == DayOfWeek.Monday && nowKst.Hour is 9 or 10)
await SendWeeklyIfNeededAsync(DateOnly.FromDateTime(nowKst.DateTime).AddDays(-7), ct);
}
private async Task SendDailyIfNeededAsync(DateOnly date, CancellationToken ct)
{
if (_lastDailyReportDate == date)
return;
using var scope = scopeFactory.CreateScope();
var reportService = scope.ServiceProvider.GetRequiredService<TelegramReportService>();
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildDailyReportAsync(date, ct);
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct);
_lastDailyReportDate = date;
logger.LogInformation("Daily telegram report sent for {Date}", date);
}
private async Task SendWeeklyIfNeededAsync(DateOnly weekStart, CancellationToken ct)
{
if (_lastWeeklyReportWeekStart == weekStart)
return;
using var scope = scopeFactory.CreateScope();
var reportService = scope.ServiceProvider.GetRequiredService<TelegramReportService>();
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct);
_lastWeeklyReportWeekStart = weekStart;
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
}
private static TimeZoneInfo GetKoreaTimeZone()
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
}
catch (TimeZoneNotFoundException)
{
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<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>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</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" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Markdig" Version="0.38.0" />
</ItemGroup>
</Project>
+5
View File
@@ -0,0 +1,5 @@
public class VersionInfo
{
public string Version { get; set; } = "unknown";
public string Built { get; set; } = "unknown";
}
+52
View File
@@ -0,0 +1,52 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
},
"Jwt": {
"SecretKey": "dev-secret-key-change-in-production-min-32-chars!"
},
"App": {
"PublicBaseUrl": "http://178.104.200.7/taxbaik"
},
"ApiClient": {
"BaseUrl": "http://localhost:5001/taxbaik/api/"
},
"Telegram": {
"BotToken": "8679990909:AAGLLRUIAuEbYAZVGOYDu-UuTu4ihroEiX0",
"ChatId": "-5434691215",
"InquiryChatId": "-5434691215",
"SystemChatId": "-5585148480"
},
"Admin": {
"PasswordResetToken": "dev-reset-token-12345"
},
"Authentication": {
"Google": {
"ClientId": "",
"ClientSecret": ""
},
"Naver": {
"ClientId": "",
"ClientSecret": ""
},
"Kakao": {
"ClientId": "",
"ClientSecret": ""
}
},
"SiteSettings": {
"PhoneNumber": "010-4122-8268",
"EmailAddress": "taxbaik5668@gmail.com",
"KakaoChannelUrl": "http://pf.kakao.com/_xoxchTX",
"InstagramUrl": "https://www.instagram.com/taxtory5668/",
"CompanyName": "백원숙 세무회계",
"CompanyDescription": "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담"
},
"AllowedHosts": "*"
}
File diff suppressed because it is too large Load Diff
+977
View File
@@ -0,0 +1,977 @@
/* TaxBaik — 워밍-프로페셔널 디자인 시스템 */
:root {
/* 워밍-프로페셔널 팔레트 */
--color-primary: #C89D6E; /* 따뜻한 골드/브론즈 */
--color-primary-dark: #A67C52; /* 진한 브론즈 */
--color-secondary: #2E5C4E; /* 따뜻한 초록 */
--color-secondary-dark: #1F3A30; /* 어두운 초록 */
--color-accent: #E8E4D8; /* 따뜻한 베이지 */
--color-accent-dark: #D9D3C4; /* 더 진한 베이지 */
--color-bg: #F9F7F3; /* 따뜻한 화이트 */
--color-bg-alt: #EFE9DD; /* 대체 배경 */
--color-text: #3D2817; /* 따뜻한 갈색 */
--color-text-light: #6B5D4F; /* 밝은 갈색 */
--color-border: #D9D3C4; /* 경계선 */
--color-success: #2E7D32;
--color-warning: #F57C00;
--color-danger: #C62828;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
--spacing-3xl: 4rem;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 3px rgba(61, 40, 23, 0.08);
--shadow-md: 0 4px 12px rgba(61, 40, 23, 0.12);
--shadow-lg: 0 8px 24px rgba(61, 40, 23, 0.15);
--shadow-xl: 0 12px 48px rgba(61, 40, 23, 0.18);
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
* {
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
color: var(--color-text);
background-color: var(--color-bg);
line-height: 1.8;
font-size: clamp(0.9rem, 2.5vw, 1rem);
letter-spacing: 0.3px;
}
/* ===== 타이포그래피 ===== */
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
line-height: 1.3;
color: var(--color-text);
margin-bottom: var(--spacing-lg);
letter-spacing: -0.5px;
}
h1 { font-size: clamp(2rem, 6vw, 3.5rem); font-weight: 800; }
h2 { font-size: clamp(1.5rem, 5vw, 2.5rem); }
h3 { font-size: clamp(1.25rem, 4vw, 2rem); }
h4 { font-size: 1.35rem; }
h5 { font-size: 1.15rem; }
h6 { font-size: 1rem; }
p {
margin-bottom: var(--spacing-md);
color: var(--color-text-light);
line-height: 1.85;
}
a {
color: var(--color-primary);
text-decoration: none;
transition: all var(--transition-fast);
}
a:hover {
color: var(--color-secondary);
text-decoration: none;
}
/* ===== 버튼 ===== */
.btn {
border-radius: var(--radius-md);
font-weight: 600;
transition: all var(--transition-normal);
cursor: pointer;
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
letter-spacing: 0.3px;
display: inline-block;
text-align: center;
text-decoration: none;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
color: white;
box-shadow: var(--shadow-md);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--color-primary-dark) 0%, #8B5E3C 100%);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.btn-warning {
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);
color: white;
box-shadow: var(--shadow-md);
}
.btn-warning:hover {
background: linear-gradient(135deg, var(--color-secondary-dark) 0%, #0D1E1A 100%);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.btn-outline-primary {
color: var(--color-primary);
border: 2px solid var(--color-primary);
background: transparent;
}
.btn-outline-primary:hover {
background-color: var(--color-primary);
color: white;
}
.btn-lg {
padding: 1rem 2.5rem;
font-size: 1.1rem;
}
.btn-sm {
padding: 0.5rem 1.25rem;
font-size: 0.95rem;
}
/* ===== 카드 ===== */
.card {
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
transition: all var(--transition-normal);
box-shadow: var(--shadow-sm);
background: white;
overflow: hidden;
}
.card:hover {
transform: translateY(-6px);
box-shadow: var(--shadow-lg);
border-color: var(--color-primary);
}
.card-body {
padding: var(--spacing-xl);
}
.card-title {
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--spacing-md);
font-size: 1.25rem;
}
.card-text {
color: var(--color-text-light);
line-height: 1.8;
}
/* ===== 히어로 섹션 ===== */
.hero-section {
padding: clamp(3rem, 20vh, 6rem) 0;
background: linear-gradient(135deg, var(--color-secondary) 0%, #1F3A30 100%);
color: white;
position: relative;
overflow: hidden;
border-bottom: 4px solid var(--color-primary);
}
.hero-section::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 600px;
height: 600px;
background: rgba(200, 157, 110, 0.1);
border-radius: 50%;
}
.hero-section::after {
content: '';
position: absolute;
bottom: -30%;
left: -10%;
width: 500px;
height: 500px;
background: rgba(232, 228, 216, 0.05);
border-radius: 50%;
}
.hero-section h1 {
font-size: clamp(2rem, 8vw, 3.5rem);
font-weight: 800;
margin-bottom: var(--spacing-lg);
position: relative;
z-index: 1;
color: white;
}
.hero-section p {
font-size: 1.2rem;
margin-bottom: var(--spacing-xl);
position: relative;
z-index: 1;
color: rgba(255, 255, 255, 0.95);
line-height: 1.8;
}
/* ===== 섹션 ===== */
.bg-light {
background-color: var(--color-accent) !important;
}
.bg-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
}
.section-title {
font-size: clamp(1.75rem, 5vw, 2.75rem);
font-weight: 800;
color: var(--color-text);
margin-bottom: var(--spacing-xl);
text-align: center;
position: relative;
display: inline-block;
left: 50%;
transform: translateX(-50%);
width: 100%;
}
.section-title::after {
content: '';
display: block;
width: 60px;
height: 4px;
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%);
margin: var(--spacing-md) auto 0;
border-radius: 2px;
}
/* ===== 배지 ===== */
.badge {
border-radius: var(--radius-md);
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
font-weight: 600;
display: inline-block;
letter-spacing: 0.2px;
}
.bg-primary-badge {
background-color: rgba(200, 157, 110, 0.15);
color: var(--color-primary);
}
/* ===== 폼 ===== */
.form-control, .form-select {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.75rem 1rem;
font-size: 1rem;
transition: all var(--transition-fast);
background-color: white;
color: var(--color-text);
}
.form-control:focus, .form-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(200, 157, 110, 0.1);
outline: none;
}
/* ===== 모바일 CTA 바 ===== */
.mobile-cta-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 2px solid var(--color-primary);
padding: var(--spacing-md);
z-index: 1000;
box-shadow: 0 -4px 12px rgba(61, 40, 23, 0.1);
}
.btn-kakao-mobile {
display: block;
width: 100%;
padding: 0.85rem;
background: linear-gradient(135deg, #FFE812 0%, #FDD835 100%);
color: #000;
text-decoration: none;
border-radius: var(--radius-md);
font-weight: 700;
text-align: center;
border: none;
cursor: pointer;
font-size: 0.95rem;
transition: all var(--transition-fast);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
letter-spacing: 0.3px;
}
.btn-kakao-mobile:hover {
background: linear-gradient(135deg, #FDD835 0%, #FBC02D 100%);
text-decoration: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
body.with-mobile-cta {
padding-bottom: 70px;
}
/* ===== 네비게이션 ===== */
.navbar {
background-color: white;
box-shadow: var(--shadow-sm);
padding: 1rem 0;
}
.navbar-brand {
font-weight: 800;
color: var(--color-primary) !important;
font-size: 1.35rem;
letter-spacing: -0.5px;
}
.nav-link {
color: var(--color-text) !important;
font-weight: 600;
transition: all var(--transition-fast);
margin: 0 var(--spacing-sm);
letter-spacing: 0.2px;
}
.nav-link:hover {
color: var(--color-primary) !important;
}
/* ===== 반응형 ===== */
@media (max-width: 767.98px) {
h1 { font-size: 1.75rem; }
h2 { font-size: 1.35rem; }
.hero-section {
padding: 2rem 0;
}
.section-title {
font-size: 1.5rem;
}
.card {
margin-bottom: 1rem;
}
.btn {
padding: 0.65rem 1.5rem;
font-size: 0.95rem;
}
.container {
padding: 0 var(--spacing-md);
}
.site-header .navbar-brand {
font-size: 1.1rem;
}
.site-header .navbar-nav {
padding: 0.5rem 0 0;
}
.site-header .nav-link,
.site-header .btn {
width: 100%;
}
.site-header .navbar-toggler {
border: 1px solid var(--color-border);
box-shadow: none;
}
.site-header .navbar-collapse {
padding-bottom: 0.5rem;
}
.pagination {
flex-wrap: wrap;
gap: 0.25rem;
}
.pagination .page-link {
min-width: 2.75rem;
text-align: center;
}
}
@media (max-width: 375px) {
html { font-size: 15px; }
h1 { font-size: 1.5rem; }
.hero-section h1 {
font-size: 1.5rem;
margin-bottom: var(--spacing-md);
}
.hero-section p {
font-size: 1rem;
}
.card-body {
padding: 1rem;
}
.hero-section .d-flex {
gap: 0.75rem !important;
}
}
/* ===== 일반 유틸리티 ===== */
.text-muted {
color: var(--color-text-light) !important;
}
.border-light {
border-color: var(--color-border) !important;
}
img {
max-width: 100%;
height: auto;
display: block;
border-radius: var(--radius-lg);
}
.container {
max-width: 1200px;
}
/* ===== 서비스 카드 ===== */
.service-card {
text-align: center;
position: relative;
border: none !important;
background: white;
padding-top: 0;
}
.service-icon {
font-size: 3.5rem;
display: block;
margin-bottom: 0.5rem;
margin-top: -1.5rem;
}
.service-card .card-title {
font-size: 1.4rem;
margin-bottom: 1rem;
color: var(--color-text);
}
.service-card ul li {
color: var(--color-text-light);
}
/* ===== 블로그 카드 ===== */
.blog-card {
border: none !important;
overflow: hidden;
transition: all var(--transition-normal);
}
.blog-placeholder {
height: 180px;
background: linear-gradient(135deg, rgba(200, 157, 110, 0.1) 0%, rgba(46, 92, 78, 0.1) 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
color: rgba(200, 157, 110, 0.3);
}
.blog-card:hover .blog-placeholder {
background: linear-gradient(135deg, rgba(200, 157, 110, 0.2) 0%, rgba(46, 92, 78, 0.2) 100%);
}
.bg-primary-badge {
background-color: rgba(200, 157, 110, 0.15) !important;
color: var(--color-primary) !important;
}
/* ===== 공지사항 배너 ===== */
.announcement-bar {
border-bottom: 1px solid rgba(0,0,0,0.08);
font-size: 0.9rem;
}
.announcement-bar--info {
background: #E8F4FD;
color: #1565C0;
}
.announcement-bar--banner {
background: #FFF8E1;
color: #E65100;
}
.announcement-bar--urgent {
background: #FFEBEE;
color: #C62828;
}
.announcement-icon {
flex-shrink: 0;
}
.announcement-text {
flex: 1;
}
/* ===== 시즌 Hero ===== */
.hero-section--seasonal {
background: linear-gradient(135deg, #1F3A30 0%, #2E5C4E 60%, #3D7A68 100%);
}
.bg-danger-badge {
background-color: rgba(198, 40, 40, 0.85) !important;
color: #fff !important;
letter-spacing: 0.5px;
}
/* D-Day 카운트다운 위젯 */
.seasonal-deadline-badge {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 220px;
height: 220px;
border-radius: 50%;
border: 4px solid rgba(255,255,255,0.25);
background: rgba(255,255,255,0.08);
color: white;
backdrop-filter: blur(4px);
}
.deadline-label {
font-size: 0.85rem;
opacity: 0.75;
letter-spacing: 2px;
text-transform: uppercase;
}
.deadline-date {
font-size: 1.6rem;
font-weight: 800;
line-height: 1.2;
margin: 0.25rem 0;
}
.deadline-days {
font-size: 2.8rem;
font-weight: 900;
color: #FFD54F;
line-height: 1;
}
/* ===== 서비스 카드 시즌 강조 ===== */
.service-card--featured {
border: 2px solid var(--color-primary) !important;
position: relative;
}
.service-card-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--color-primary);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 3px 14px;
border-radius: 20px;
white-space: nowrap;
letter-spacing: 0.5px;
}
/* ===== 블로그 시즌 연동 ===== */
.seasonal-blog-header {
display: flex;
justify-content: center;
}
.seasonal-blog-tag {
display: inline-block;
background: linear-gradient(135deg, #C62828 0%, #B71C1C 100%);
color: white;
font-size: 0.82rem;
font-weight: 700;
padding: 4px 16px;
border-radius: 20px;
letter-spacing: 0.5px;
}
.blog-card--seasonal {
border: 2px solid var(--color-primary) !important;
position: relative;
overflow: visible;
}
.blog-seasonal-ribbon {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--color-primary);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 3px 14px;
border-radius: 20px;
white-space: nowrap;
letter-spacing: 0.5px;
z-index: 1;
}
.bg-season-badge {
background-color: var(--color-primary) !important;
color: white !important;
}
.btn-seasonal {
background-color: var(--color-primary);
color: white;
border: none;
}
.btn-seasonal:hover {
background-color: var(--color-primary-dark);
color: white;
}
.btn-outline-seasonal {
border: 2px solid var(--color-primary);
color: var(--color-primary);
background: transparent;
}
.btn-outline-seasonal:hover {
background-color: var(--color-primary);
color: white;
}
/* ===== FAQ 아코디언 ===== */
.faq-accordion {
max-width: 760px;
margin: 0 auto;
}
.faq-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-md) !important;
margin-bottom: 0.75rem;
overflow: hidden;
background: white;
}
.faq-question {
font-weight: 700;
color: var(--color-text);
font-size: 1rem;
padding: 1.1rem 1.5rem;
cursor: pointer;
list-style: none;
}
.faq-question::-webkit-details-marker {
display: none;
}
.faq-item[open] .faq-question {
color: var(--color-secondary);
border-bottom: 1px solid var(--color-border);
}
.faq-question:focus {
box-shadow: 0 0 0 3px rgba(200, 157, 110, 0.2);
}
.faq-answer {
background: #fdfcfa;
color: var(--color-text-light);
line-height: 1.85;
padding: 1rem 1.5rem 1.25rem;
border-top: 1px solid var(--color-border);
}
.faq-answer ul {
padding-left: 1.25rem;
}
.faq-answer ul li {
margin-bottom: 0.4rem;
}
/* ===== 프리미엄 고도화 & 마이크로 인터랙션 (2026-06-30) ===== */
/* 영어/숫자용 폰트 클래스 */
.font-numeric, .font-heading-en {
font-family: 'Outfit', 'Inter', 'Noto Sans KR', sans-serif;
}
/* 히어로 섹션 프리미엄 개편 (메쉬 그라데이션 및 CSS 애니메이션) */
.hero-section {
background: radial-gradient(circle at 10% 20%, rgba(46, 92, 78, 1) 0%, rgba(31, 58, 48, 1) 44%, rgba(13, 30, 26, 1) 100%) !important;
position: relative;
overflow: hidden;
}
.hero-section::before {
content: '';
position: absolute;
top: -30%;
right: -10%;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(200, 157, 110, 0.25) 0%, rgba(200, 157, 110, 0) 70%);
border-radius: 50%;
animation: floatAnimation 8s ease-in-out infinite;
pointer-events: none;
}
.hero-section::after {
content: '';
position: absolute;
bottom: -20%;
left: -10%;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(232, 228, 216, 0.15) 0%, rgba(232, 228, 216, 0) 70%);
border-radius: 50%;
animation: floatAnimation2 12s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes floatAnimation {
0% { transform: translateY(0px) scale(1); }
50% { transform: translateY(-30px) scale(1.05); }
100% { transform: translateY(0px) scale(1); }
}
@keyframes floatAnimation2 {
0% { transform: translateX(0px) rotate(0deg); }
50% { transform: translateX(20px) translateY(15px) rotate(10deg); }
100% { transform: translateX(0px) rotate(0deg); }
}
/* 서비스 카드 고도화 */
.service-card {
border: 1px solid rgba(217, 211, 196, 0.6) !important;
box-shadow: 0 10px 25px rgba(61, 40, 23, 0.03) !important;
transition: all var(--transition-normal) !important;
}
.service-card:hover {
transform: translateY(-8px) !important;
box-shadow: 0 20px 40px rgba(61, 40, 23, 0.1) !important;
border-color: var(--color-primary) !important;
}
.service-card--featured {
background: linear-gradient(180deg, #FFFFFF 0%, #FAF8F5 100%) !important;
border-left: 4px solid var(--color-primary) !important;
}
/* 글래스모피즘 포털 클래스 (Glassmorphism Portal Classes) */
.glass-card {
background: rgba(255, 255, 255, 0.7) !important;
backdrop-filter: blur(12px) saturate(180%) !important;
-webkit-backdrop-filter: blur(12px) saturate(180%) !important;
border: 1px solid rgba(255, 255, 255, 0.4) !important;
box-shadow: 0 8px 32px 0 rgba(61, 40, 23, 0.05) !important;
border-radius: var(--radius-lg);
transition: all var(--transition-normal);
}
.glass-card:hover {
background: rgba(255, 255, 255, 0.8) !important;
box-shadow: 0 8px 32px 0 rgba(61, 40, 23, 0.08) !important;
}
.portal-welcome-strip {
background: linear-gradient(135deg, var(--color-secondary-dark) 0%, #152A22 100%);
border-radius: var(--radius-lg);
color: white;
padding: 2.5rem;
box-shadow: var(--shadow-lg);
border-bottom: 3px solid var(--color-primary);
}
/* 타임라인 컴포넌트 뷰티화 */
.timeline-item-modern {
border-left: 2px solid rgba(200, 157, 110, 0.4);
position: relative;
padding-left: 1.5rem;
padding-bottom: 2rem;
}
.timeline-item-modern::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-primary);
left: -7px;
top: 6px;
box-shadow: 0 0 0 4px rgba(200, 157, 110, 0.25);
transition: all var(--transition-fast);
}
.timeline-item-modern:hover::after {
background: var(--color-secondary);
box-shadow: 0 0 0 6px rgba(46, 92, 78, 0.3);
transform: scale(1.1);
}
/* ===== 마크다운 스타일 ===== */
.markdown-body {
font-size: 1rem;
line-height: 1.8;
color: var(--color-text);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 1rem;
color: var(--color-text);
}
.markdown-body h1 {
font-size: 1.8rem;
border-bottom: 2px solid var(--color-primary);
padding-bottom: 0.5rem;
}
.markdown-body h2 {
font-size: 1.5rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.3rem;
}
.markdown-body h3 {
font-size: 1.25rem;
}
.markdown-body h4 {
font-size: 1.1rem;
}
.markdown-body p {
margin-bottom: 1rem;
}
.markdown-body strong {
font-weight: 700;
color: var(--color-primary-dark);
}
.markdown-body em {
font-style: italic;
color: var(--color-text-light);
}
.markdown-body code {
background: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.15rem 0.4rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #d63384;
}
.markdown-body pre {
background: var(--color-text);
color: #f8f8f8;
padding: 1rem;
border-radius: var(--radius-lg);
overflow-x: auto;
margin-bottom: 1rem;
line-height: 1.4;
}
.markdown-body pre code {
background: none;
border: none;
padding: 0;
color: inherit;
font-size: 0.9rem;
}
.markdown-body ul,
.markdown-body ol {
margin-bottom: 1rem;
margin-left: 2rem;
}
.markdown-body li {
margin-bottom: 0.5rem;
}
.markdown-body blockquote {
border-left: 4px solid var(--color-primary);
padding-left: 1rem;
margin: 1rem 0;
color: var(--color-text-light);
font-style: italic;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
}
.markdown-body table th,
.markdown-body table td {
border: 1px solid var(--color-border);
padding: 0.75rem;
text-align: left;
}
.markdown-body table th {
background: var(--color-bg-alt);
font-weight: 700;
}
.markdown-body table tr:nth-child(even) {
background: var(--color-bg);
}
.markdown-body a {
color: var(--color-primary-dark);
text-decoration: none;
border-bottom: 1px solid var(--color-primary);
transition: color var(--transition-fast);
}
.markdown-body a:hover {
color: var(--color-primary);
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2rem 0;
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TaxBaik">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1f3c88"/>
<stop offset="100%" stop-color="#d7a86e"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#g)"/>
<path d="M18 24h28v6H18zM22 32h20v6H22zM26 40h12v6H26z" fill="#fff" opacity="0.95"/>
<path d="M16 18h32v2H16z" fill="#ffffff" opacity="0.35"/>
<circle cx="46" cy="18" r="5" fill="#fff" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 564 B

+395
View File
@@ -0,0 +1,395 @@
window.taxbaikAdminSession = {
clientLogState: {
enabled: true,
windowStart: 0,
sentCount: 0,
suppressedCount: 0,
fingerprints: {},
eventCounts: {},
screen: '',
feature: '',
action: '',
step: '',
entity: '',
entityId: '',
dataKey: ''
},
initErrorLogging: function () {
if (window._taxbaikClientLogInitialized) return;
window._taxbaikClientLogInitialized = true;
const postLog = function (payload) {
try {
if (!window.taxbaikAdminSession.shouldSendClientLog(payload)) {
return;
}
const body = JSON.stringify(payload);
if (navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
if (navigator.sendBeacon('/taxbaik/api/client-logs', blob)) {
return;
}
}
fetch('/taxbaik/api/client-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true
}).catch(function () { });
} catch {
// Logging must never break the UI.
}
};
window.taxbaikAdminSession.postClientLog = postLog;
window.addEventListener('error', function (event) {
postLog({
level: 'error',
source: 'window.error',
message: event.message || 'unknown error',
url: event.filename || window.location.href,
route: window.location.pathname + window.location.search,
screen: window.taxbaikAdminSession.clientLogState.screen || '',
feature: window.taxbaikAdminSession.clientLogState.feature || '',
action: window.taxbaikAdminSession.clientLogState.action || '',
step: window.taxbaikAdminSession.clientLogState.step || '',
entity: window.taxbaikAdminSession.clientLogState.entity || '',
entityId: window.taxbaikAdminSession.clientLogState.entityId || '',
dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '',
buildVersion: window.taxbaikAdminBuildVersion || '',
component: window.taxbaikAdminComponent || '',
viewportWidth: window.taxbaikAdminSession.getViewportWidth(),
userAgent: navigator.userAgent || '',
stack: event.error?.stack || ''
});
});
window.addEventListener('unhandledrejection', function (event) {
const reason = event.reason;
postLog({
level: 'error',
source: 'window.unhandledrejection',
message: reason?.message || String(reason || 'unknown rejection'),
url: window.location.href,
route: window.location.pathname + window.location.search,
screen: window.taxbaikAdminSession.clientLogState.screen || '',
feature: window.taxbaikAdminSession.clientLogState.feature || '',
action: window.taxbaikAdminSession.clientLogState.action || '',
step: window.taxbaikAdminSession.clientLogState.step || '',
entity: window.taxbaikAdminSession.clientLogState.entity || '',
entityId: window.taxbaikAdminSession.clientLogState.entityId || '',
dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '',
buildVersion: window.taxbaikAdminBuildVersion || '',
component: window.taxbaikAdminComponent || '',
viewportWidth: window.taxbaikAdminSession.getViewportWidth(),
userAgent: navigator.userAgent || '',
stack: reason?.stack || ''
});
});
},
setContext: function (screen, feature, action, step, entity, entityId, dataKey) {
const state = window.taxbaikAdminSession.clientLogState;
state.screen = screen || '';
state.feature = feature || '';
state.action = action || '';
state.step = step || '';
state.entity = entity || '';
state.entityId = entityId || '';
state.dataKey = dataKey || '';
},
shouldSendClientLog: function (payload) {
try {
const state = window.taxbaikAdminSession.clientLogState;
if (!state.enabled) return false;
const now = Date.now();
if (!state.windowStart || now - state.windowStart >= 60000) {
state.windowStart = now;
state.sentCount = 0;
state.suppressedCount = 0;
state.fingerprints = {};
}
const fingerprint = [
payload?.source || '',
payload?.message || '',
payload?.route || '',
payload?.component || '',
payload?.screen || '',
payload?.feature || '',
payload?.action || '',
payload?.entity || '',
payload?.entityId || ''
].join('|').slice(0, 256);
state.fingerprints[fingerprint] = (state.fingerprints[fingerprint] || 0) + 1;
if (state.sentCount >= 8) {
state.suppressedCount += 1;
return false;
}
if (state.fingerprints[fingerprint] > 2) {
state.suppressedCount += 1;
return false;
}
state.sentCount += 1;
return true;
} catch {
return false;
}
},
traceUiState: function (source, details) {
try {
const payload = {
level: 'info',
source: source || 'ui-state',
message: details || '',
url: window.location.href,
route: window.location.pathname + window.location.search,
screen: window.taxbaikAdminSession.clientLogState.screen || '',
feature: window.taxbaikAdminSession.clientLogState.feature || '',
action: window.taxbaikAdminSession.clientLogState.action || '',
step: window.taxbaikAdminSession.clientLogState.step || '',
entity: window.taxbaikAdminSession.clientLogState.entity || '',
entityId: window.taxbaikAdminSession.clientLogState.entityId || '',
dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '',
buildVersion: window.taxbaikAdminBuildVersion || '',
component: window.taxbaikAdminComponent || '',
viewportWidth: window.taxbaikAdminSession.getViewportWidth(),
userAgent: navigator.userAgent || '',
stack: ''
};
const state = window.taxbaikAdminSession.clientLogState;
const key = `${payload.source}|${payload.route}|${payload.message}`.slice(0, 256);
state.eventCounts[key] = (state.eventCounts[key] || 0) + 1;
if (state.eventCounts[key] > 1) {
return;
}
window.taxbaikAdminSession.postClientLog(payload);
} catch {
// diagnostics must never break UI.
}
},
postClientLog: function () {
// Replaced during initialization.
},
syncRouteClass: function () {
document.documentElement.classList.toggle(
'admin-login-route',
window.location.pathname.toLowerCase().endsWith('/admin/login'));
},
getViewportWidth: function () {
return window.innerWidth || document.documentElement.clientWidth || 0;
},
clearAuthToken: function () {
try {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('tokenExpiry');
localStorage.removeItem('auth_token');
} catch {
// Ignore storage errors; redirect still recovers the session.
}
},
showLoading: function () {
// Route transitions are handled by Blazor; avoid full-screen overlays
// that block drawer interaction and make the app feel frozen.
window.taxbaikAdminSession.traceUiState('admin-loading', 'showLoading requested');
window.taxbaikAdminSession.hideLoading();
},
hideLoading: function () {
const overlay = document.getElementById('blazor-loading');
if (overlay) {
overlay.classList.remove('show');
}
if (window._taxbaikLoadingTimeout) {
clearTimeout(window._taxbaikLoadingTimeout);
window._taxbaikLoadingTimeout = null;
}
if (window._taxbaikLoadingObserver) {
window._taxbaikLoadingObserver.disconnect();
window._taxbaikLoadingObserver = null;
}
window.taxbaikAdminSession.traceUiState('admin-loading', 'hideLoading completed');
},
watchReconnect: function () {
window.taxbaikAdminSession.syncRouteClass();
window.addEventListener('popstate', window.taxbaikAdminSession.syncRouteClass);
window.addEventListener('hashchange', window.taxbaikAdminSession.syncRouteClass);
if (document.documentElement.classList.contains('admin-login-route')) {
// Login prerenders immediately; no boot splash needed.
window.taxbaikAdminSession.hideLoading();
}
// Non-login routes: leave the overlay showing until AdminShell's
// OnAfterRenderAsync(firstRender) calls hideLoading once WASM has
// actually rendered the authenticated shell.
const modal = document.getElementById('components-reconnect-modal');
if (!modal) return;
const reloadOnRejectedCircuit = function () {
const className = modal.className || '';
if (className.includes('components-reconnect-failed') ||
className.includes('components-reconnect-rejected')) {
window.setTimeout(function () { window.location.reload(); }, 1500);
}
};
new MutationObserver(reloadOnRejectedCircuit)
.observe(modal, { attributes: true, attributeFilter: ['class'] });
},
bindLoginForm: function () {
const form = document.getElementById('admin-login-form');
if (!form) return;
// 업데이트 스플래시: 매번(재호출되어도) 무조건 다시 적용한다. Blazor가 WASM
// 하이드레이션 시점에 이 prerender된 서브트리를 자신의 렌더 결과로 다시 그리면
// 마크업에 정적으로 박혀 있던 disabled가 되살아날 수 있으므로, "한 번만 실행"
// 가드에 걸어두면 두 번째 호출(OnAfterRenderAsync 경유)이 조용히 무시되어
// 버튼이 영원히 비활성 상태로 남는다. 그래서 이 부분은 가드 밖에 둔다.
const readyButton = form.querySelector('#admin-login-submit');
if (readyButton) {
readyButton.disabled = false;
const label = readyButton.querySelector('span');
if (label) label.textContent = '로그인';
}
if (form.dataset.bound === '1') return;
form.dataset.bound = '1';
window.taxbaikAdminSession.traceUiState('admin-login', 'bindLoginForm attached');
form.addEventListener('submit', async function (event) {
event.preventDefault();
const username = form.querySelector('input[placeholder="사용자명"]')?.value?.trim() || '';
const password = form.querySelector('input[placeholder="비밀번호"]')?.value || '';
const rememberMe = form.querySelector('input[type="checkbox"]')?.checked || false;
const existing = form.parentElement.querySelector('.login-error-message');
const submitButton = form.querySelector('button[type="submit"]');
if (existing) existing.remove();
if (submitButton) submitButton.disabled = true;
try {
if (!username || !password) {
throw new Error('username/password missing');
}
window.taxbaikAdminSession.traceUiState('admin-login', 'submit started');
const response = await fetch('/taxbaik/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw new Error('login failed');
}
const data = await response.json();
if (!data?.accessToken || !data?.refreshToken) {
throw new Error('invalid response');
}
window.taxbaikAdminSession.traceUiState('admin-login', 'submit success');
const expiryTicks = 621355968000000000 + ((Date.now() + (data.expiresIn || 3600) * 1000) * 10000);
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('tokenExpiry', String(expiryTicks));
if (rememberMe) {
localStorage.setItem('admin-remembered-username', username);
} else {
localStorage.removeItem('admin-remembered-username');
}
window.location.href = '/taxbaik/admin/dashboard';
} catch (error) {
window.taxbaikAdminSession.traceUiState('admin-login', `submit failed: ${error?.message || 'login failed'}`);
postLog({
level: 'error',
source: 'admin-login-form',
message: error?.message || 'login failed',
url: window.location.href,
route: window.location.pathname + window.location.search,
buildVersion: window.taxbaikAdminBuildVersion || '',
component: 'AdminLoginForm',
viewportWidth: window.taxbaikAdminSession.getViewportWidth(),
userAgent: navigator.userAgent || '',
stack: error?.stack || ''
});
const errorMessage = document.createElement('div');
errorMessage.className = 'mud-alert mud-alert-filled-error login-error-message mb-4';
errorMessage.textContent = '로그인 중 오류가 발생했습니다.';
form.parentElement.insertBefore(errorMessage, form);
} finally {
if (submitButton) submitButton.disabled = false;
}
});
}
};
// 더존 ERP 스타일 엔터 키 포커스 이동 및 단축키 바인딩
document.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
const active = document.activeElement;
if (!active) return;
// 특정 영역(편집 폼 또는 다이얼로그) 내의 입력 필드만 포커스 이동 처리
const container = active.closest('.admin-editor-panel, .mud-form, .mud-dialog');
if (!container) return;
// textarea나 button, submit 타입 등은 기본 동작(줄바꿈/제출) 유지
if (active.tagName === 'TEXTAREA' ||
active.tagName === 'BUTTON' ||
active.getAttribute('type') === 'submit' ||
active.classList.contains('mud-button-root')) {
return;
}
e.preventDefault();
// 포커스 이동 가능한 모든 입력 요소 수집
const focusables = Array.from(container.querySelectorAll('input, select, textarea, button'))
.filter(el => {
const style = window.getComputedStyle(el);
return el.tabIndex >= 0 &&
!el.disabled &&
el.getAttribute('aria-disabled') !== 'true' &&
style.display !== 'none' &&
style.visibility !== 'hidden';
});
const index = focusables.indexOf(active);
if (index > -1 && index < focusables.length - 1) {
const nextEl = focusables[index + 1];
nextEl.focus();
if (typeof nextEl.select === 'function') {
nextEl.select();
}
}
}
});
+24
View File
@@ -0,0 +1,24 @@
function openKakao() {
const kakaoUrl = document.querySelector('[data-kakao-url]')?.dataset.kakaoUrl || '#';
if (kakaoUrl !== '#') {
window.open(kakaoUrl, '_blank');
} else {
alert('카카오톡 채널 URL이 설정되지 않았습니다.');
}
}
document.addEventListener('DOMContentLoaded', function() {
const navbar = document.querySelector('.navbar');
if (!navbar) {
return;
}
const setShadow = () => {
navbar.style.boxShadow = window.scrollY > 0
? '0 2px 8px rgba(0,0,0,0.1)'
: '0 1px 3px rgba(0,0,0,0.1)';
};
setShadow();
window.addEventListener('scroll', setShadow, { passive: true });
});
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="refresh" content="15" />
<title>잠시 점검 중 — 백원숙 세무회계</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; }
body {
background: #F9F7F3;
color: #3D2817;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
text-align: center;
max-width: 480px;
width: 100%;
background: #fff;
border-radius: 16px;
padding: 3rem 2.5rem;
box-shadow: 0 8px 32px rgba(61,40,23,.10);
}
.icon { font-size: 3.5rem; margin-bottom: 1.25rem; }
.badge {
display: inline-block;
background: #C89D6E;
color: #fff;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 1px;
padding: 4px 14px;
border-radius: 20px;
margin-bottom: 1.5rem;
}
h1 { font-size: 1.6rem; color: #2E5C4E; font-weight: 800; margin-bottom: 1rem; line-height: 1.35; }
p { color: #6B5D4F; line-height: 1.85; font-size: 0.95rem; }
.divider { border: none; border-top: 1px solid #EFE9DD; margin: 1.75rem 0; }
.kakao-btn {
display: inline-block;
background: #FEE500;
color: #3D2817;
text-decoration: none;
font-weight: 700;
padding: 0.65rem 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
margin-top: 0.5rem;
}
.timer { font-size: 0.78rem; color: #A09080; margin-top: 1.5rem; }
.footer { font-size: 0.75rem; color: #C0ADA0; margin-top: 2rem; }
</style>
</head>
<body>
<div class="card">
<div class="icon">🔧</div>
<div class="badge">서비스 업데이트 중</div>
<h1>잠시 후 다시 접속해 주세요</h1>
<p>
더 나은 서비스를 위해 업데이트 작업을 진행하고 있습니다.<br />
보통 <strong>1~2분</strong> 이내에 완료됩니다.
</p>
<hr class="divider" />
<p>급하신 세무 문의는 카카오채널로 연락해 주세요.</p>
<a class="kakao-btn" href="http://pf.kakao.com/_xoxchTX" target="_blank">
💬 카카오채널 상담
</a>
<p class="timer">이 페이지는 15초 후 자동으로 새로고침됩니다.</p>
<p class="footer">© 2026 백원숙 세무회계</p>
</div>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
User-agent: *
Allow: /taxbaik/
Disallow: /taxbaik/admin/
Disallow: /taxbaik/manage/
Sitemap: http://178.104.200.7/taxbaik/sitemap.xml
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<!-- 메인 홈 -->
<url>
<loc>http://178.104.200.7/taxbaik/</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<!-- 고객 포털 -->
<url>
<loc>http://178.104.200.7/taxbaik/portal</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<!-- 이용약관 -->
<url>
<loc>http://178.104.200.7/taxbaik/terms</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<!-- 개인정보처리방침 -->
<url>
<loc>http://178.104.200.7/taxbaik/privacy</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
</urlset>