Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a04592499c | |||
| f569211967 | |||
| c8306e2ac7 | |||
| bad2f47ffe | |||
| 943fe9c819 | |||
| 7b819f4ab0 | |||
| 3c8f30af6d |
+16
-16
@@ -425,9 +425,9 @@ Todo:
|
||||
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
||||
|
||||
Todo:
|
||||
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [ ] 일간/주간 리포트 메시지 템플릿
|
||||
- [ ] TelegramNotificationService에 리포트 메서드 추가
|
||||
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [x] 일간/주간 리포트 메시지 템플릿
|
||||
- [x] TelegramNotificationService에 리포트 메서드 추가
|
||||
|
||||
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
||||
|
||||
@@ -439,9 +439,9 @@ Todo:
|
||||
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
||||
|
||||
Todo:
|
||||
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [ ] 고객 전용 Razor Pages 추가
|
||||
- [ ] 세무사 허용 권한 설정 UI
|
||||
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [x] 고객 전용 Razor Pages 추가
|
||||
- [x] 세무사 허용 권한 설정 UI
|
||||
|
||||
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
||||
|
||||
@@ -485,16 +485,16 @@ DB 스키마:
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
Todo:
|
||||
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||
- [ ] V011__CreatePortalUsers.sql 마이그레이션
|
||||
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||
- [ ] 네이버 OAuth Handler 구현
|
||||
- [ ] 카카오·구글 패키지 추가 및 설정
|
||||
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨)
|
||||
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||
- [x] 네이버 OAuth Handler 구현
|
||||
- [x] 카카오·구글 패키지 추가 및 설정
|
||||
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||
- [ ] Gitea Secrets에 OAuth 키 추가
|
||||
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
|
||||
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ else
|
||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
|
||||
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 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("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -52,5 +52,5 @@ public class LoginModel : PageModel
|
||||
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
|
||||
|
||||
private static AuthenticationProperties BuildProps(string provider) =>
|
||||
new() { RedirectUri = $"/portal/external-callback?provider={provider}" };
|
||||
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
|
||||
}
|
||||
|
||||
+12
-5
@@ -38,6 +38,13 @@ builder.Host.UseSerilog((context, config) =>
|
||||
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)
|
||||
@@ -89,8 +96,8 @@ var authenticationBuilder = builder.Services.AddAuthentication(opts =>
|
||||
opts.Cookie.HttpOnly = true;
|
||||
opts.Cookie.SameSite = SameSiteMode.Lax;
|
||||
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||
opts.LoginPath = "/portal/login";
|
||||
opts.AccessDeniedPath = "/portal/login";
|
||||
opts.LoginPath = "/taxbaik/portal/login";
|
||||
opts.AccessDeniedPath = "/taxbaik/portal/login";
|
||||
opts.SlidingExpiration = true;
|
||||
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||
})
|
||||
@@ -111,7 +118,7 @@ if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(goo
|
||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||
opts.ClientId = googleClientId;
|
||||
opts.ClientSecret = googleClientSecret;
|
||||
opts.CallbackPath = "/portal/signin-google";
|
||||
opts.CallbackPath = "/taxbaik/portal/signin-google";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +131,7 @@ if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(nave
|
||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||
opts.ClientId = naverClientId;
|
||||
opts.ClientSecret = naverClientSecret;
|
||||
opts.CallbackPath = "/portal/signin-naver";
|
||||
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";
|
||||
@@ -156,7 +163,7 @@ if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kaka
|
||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||
opts.ClientId = kakaoClientId;
|
||||
opts.ClientSecret = kakaoClientSecret;
|
||||
opts.CallbackPath = "/portal/signin-kakao";
|
||||
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";
|
||||
|
||||
@@ -13,6 +13,7 @@ public interface ITelegramNotificationService
|
||||
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
|
||||
@@ -96,4 +97,10 @@ public class TelegramNotificationService : ITelegramNotificationService
|
||||
var text = $"<b>ℹ️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
||||
await SendMessageAsync(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>";
|
||||
await SendToChat(_systemChatId, text, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ public class TelegramReportBackgroundService(
|
||||
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||
|
||||
var report = await reportService.BuildDailyReportAsync(date, ct);
|
||||
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatDailyMessage(report), ct);
|
||||
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct);
|
||||
_lastDailyReportDate = date;
|
||||
logger.LogInformation("Daily telegram report sent for {Date}", date);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public class TelegramReportBackgroundService(
|
||||
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||
|
||||
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
|
||||
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatWeeklyMessage(report), ct);
|
||||
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct);
|
||||
_lastWeeklyReportWeekStart = weekStart;
|
||||
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user