From 033883aac5f67bd925092acab30de700ac8decb6 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 28 Jun 2026 18:39:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=EB=B0=B0=ED=8F=AC=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EA=B3=BC=20=ED=85=94=EB=A0=88=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 47 +++++++++- TaxBaik.Application/DependencyInjection.cs | 2 + TaxBaik.Application/Services/ClientService.cs | 9 ++ .../Services/TelegramReportService.cs | 74 ++++++++++++++++ .../Interfaces/IClientRepository.cs | 3 + TaxBaik.Infrastructure/DependencyInjection.cs | 1 + .../Repositories/ClientRepository.cs | 27 ++++++ TaxBaik.Web/Program.cs | 86 +++++++++++++++++++ .../Services/TelegramNotificationService.cs | 6 +- .../TelegramReportBackgroundService.cs | 82 ++++++++++++++++++ 10 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 TaxBaik.Application/Services/TelegramReportService.cs create mode 100644 TaxBaik.Web/Services/TelegramReportBackgroundService.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c67c64b..4e06a10 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -38,18 +38,29 @@ jobs: JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}" TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}" TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}" + TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}" + TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}" [ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; } [ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; } [ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; } + [ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID" + [ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480" JWT_SECRET_KEY="$JWT_SECRET_KEY" \ TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \ TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \ + TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \ + TELEGRAM_SYSTEM_CHAT_ID="$TELEGRAM_SYSTEM_CHAT_ID" \ python3 -c ' import json, os, pathlib pathlib.Path("./publish/appsettings.Production.json").write_text( json.dumps({ "Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]}, - "Telegram": {"BotToken": os.environ["TELEGRAM_BOT_TOKEN"], "ChatId": os.environ["TELEGRAM_CHAT_ID"]} + "Telegram": { + "BotToken": os.environ["TELEGRAM_BOT_TOKEN"], + "ChatId": os.environ["TELEGRAM_CHAT_ID"], + "InquiryChatId": os.environ["TELEGRAM_INQUIRY_CHAT_ID"], + "SystemChatId": os.environ["TELEGRAM_SYSTEM_CHAT_ID"] + } }, ensure_ascii=False, indent=2), encoding="utf-8" )' @@ -98,6 +109,34 @@ jobs: COMMIT=$(git rev-parse --short HEAD) DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}" DEPLOY_USER="${{ secrets.DEPLOY_USER }}" + TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}" + TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}" + TELEGRAM_CHAT_ID="${TELEGRAM_SYSTEM_CHAT_ID:--5585148480}" + + send_telegram() { + local text="$1" + if [ -z "$TELEGRAM_BOT_TOKEN" ]; then + echo "Skipping Telegram notification: missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2 + return 0 + fi + + curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${text}" \ + -d "parse_mode=HTML" >/dev/null || true + } + + notify_failure() { + local exit_code=$? + send_telegram "❌ TaxBaik 배포 실패 + +커밋: ${COMMIT} +시간: ${TIMESTAMP} +단계: CI/CD deploy" + exit "$exit_code" + } + + trap notify_failure ERR echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ===" @@ -179,3 +218,9 @@ jobs: REMOTE echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST" + send_telegram "✅ TaxBaik 배포 완료 + +커밋: ${COMMIT} +시간: ${TIMESTAMP} +대상: ${DEPLOY_HOST} +채널: ${TELEGRAM_CHAT_ID}" diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs index 05f1af9..6c1ca3b 100644 --- a/TaxBaik.Application/DependencyInjection.cs +++ b/TaxBaik.Application/DependencyInjection.cs @@ -25,6 +25,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/TaxBaik.Application/Services/ClientService.cs b/TaxBaik.Application/Services/ClientService.cs index b026a4d..a46a6f7 100644 --- a/TaxBaik.Application/Services/ClientService.cs +++ b/TaxBaik.Application/Services/ClientService.cs @@ -22,6 +22,15 @@ public class ClientService(IClientRepository repository) public async Task GetByIdAsync(int id, CancellationToken ct = default) => await repository.GetByIdAsync(id, ct); + public async Task GetByEmailAsync(string email, CancellationToken ct = default) => + await repository.GetByEmailAsync(email, ct); + + public async Task GetByPhoneAsync(string phone, CancellationToken ct = default) => + await repository.GetByPhoneAsync(phone, ct); + + public async Task CountCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default) => + await repository.CountByCreatedAtRangeAsync(startDateUtc, endDateUtc, ct); + public async Task CreateAsync(CreateClientDto dto, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(dto.Name)) diff --git a/TaxBaik.Application/Services/TelegramReportService.cs b/TaxBaik.Application/Services/TelegramReportService.cs new file mode 100644 index 0000000..5cb6c78 --- /dev/null +++ b/TaxBaik.Application/Services/TelegramReportService.cs @@ -0,0 +1,74 @@ +namespace TaxBaik.Application.Services; + +public record TelegramDailyReport( + DateOnly Date, + int NewInquiries, + int PendingInquiries, + int NewClients, + int PendingTaxFilings, + int PendingPayments); + +public record TelegramWeeklyReport( + DateOnly WeekStart, + DateOnly WeekEnd, + int NewInquiries, + int NewClients, + int UpcomingTaxFilings, + decimal RevenueThisWeek); + +public class TelegramReportService( + InquiryService inquiryService, + ClientService clientService, + TaxFilingScheduleService taxFilingScheduleService, + RevenueTrackingService revenueTrackingService) +{ + public async Task BuildDailyReportAsync(DateOnly date, CancellationToken ct = default) + { + var start = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var end = date.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + + return new TelegramDailyReport( + Date: date, + NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct), + PendingInquiries: await inquiryService.CountByStatusAsync("new", ct), + NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct), + PendingTaxFilings: await taxFilingScheduleService.GetPendingCountAsync(ct), + PendingPayments: (await revenueTrackingService.GetPendingPaymentsAsync(ct)).Count()); + } + + public async Task BuildWeeklyReportAsync(DateOnly weekStart, CancellationToken ct = default) + { + var weekEnd = weekStart.AddDays(6); + var start = weekStart.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var end = weekEnd.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + var upcomingEnd = weekEnd.AddDays(7).ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + + var revenue = await revenueTrackingService.GetTotalRevenueAsync(start, end, ct); + + return new TelegramWeeklyReport( + WeekStart: weekStart, + WeekEnd: weekEnd, + NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct), + NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct), + UpcomingTaxFilings: (await taxFilingScheduleService.GetUpcomingDuesAsync(14, ct)) + .Count(x => x.DueDate >= start && x.DueDate <= upcomingEnd), + RevenueThisWeek: revenue); + } + + public static string FormatDailyMessage(TelegramDailyReport report) => + $"📊 일간 리포트\n\n" + + $"기준일: {report.Date:yyyy-MM-dd}\n" + + $"신규 문의: {report.NewInquiries}\n" + + $"처리 대기 문의: {report.PendingInquiries}\n" + + $"신규 고객: {report.NewClients}\n" + + $"신고 대기: {report.PendingTaxFilings}\n" + + $"미수 청구: {report.PendingPayments}"; + + public static string FormatWeeklyMessage(TelegramWeeklyReport report) => + $"📈 주간 리포트\n\n" + + $"기간: {report.WeekStart:yyyy-MM-dd} ~ {report.WeekEnd:yyyy-MM-dd}\n" + + $"신규 문의: {report.NewInquiries}\n" + + $"신규 고객: {report.NewClients}\n" + + $"다가오는 신고: {report.UpcomingTaxFilings}\n" + + $"주간 매출: ₩{report.RevenueThisWeek:N0}"; +} diff --git a/TaxBaik.Domain/Interfaces/IClientRepository.cs b/TaxBaik.Domain/Interfaces/IClientRepository.cs index 4d756de..02b4055 100644 --- a/TaxBaik.Domain/Interfaces/IClientRepository.cs +++ b/TaxBaik.Domain/Interfaces/IClientRepository.cs @@ -8,6 +8,9 @@ public interface IClientRepository int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default); Task GetByIdAsync(int id, CancellationToken ct = default); + Task GetByEmailAsync(string email, CancellationToken ct = default); + Task GetByPhoneAsync(string phone, CancellationToken ct = default); + Task CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default); Task CreateAsync(Client client, CancellationToken ct = default); Task UpdateAsync(Client client, CancellationToken ct = default); Task DeleteAsync(int id, CancellationToken ct = default); diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs index 19610c1..35e8482 100644 --- a/TaxBaik.Infrastructure/DependencyInjection.cs +++ b/TaxBaik.Infrastructure/DependencyInjection.cs @@ -19,6 +19,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/TaxBaik.Infrastructure/Repositories/ClientRepository.cs b/TaxBaik.Infrastructure/Repositories/ClientRepository.cs index 05e4330..c230163 100644 --- a/TaxBaik.Infrastructure/Repositories/ClientRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/ClientRepository.cs @@ -40,6 +40,33 @@ public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepo new { Id = id }); } + public async Task GetByEmailAsync(string email, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + $"SELECT {SelectColumns} FROM clients WHERE email = @Email", + new { Email = email }); + } + + public async Task GetByPhoneAsync(string phone, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + $"SELECT {SelectColumns} FROM clients WHERE phone = @Phone", + new { Phone = phone }); + } + + public async Task CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.ExecuteScalarAsync( + @"SELECT COUNT(*) + FROM clients + WHERE created_at >= @StartDateUtc + AND created_at <= @EndDateUtc", + new { StartDateUtc = startDateUtc, EndDateUtc = endDateUtc }); + } + public async Task CreateAsync(Client client, CancellationToken ct = default) { using var conn = Conn(); diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index 6caf35d..c94bd7c 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -3,6 +3,8 @@ 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.ResponseCompression; @@ -80,6 +82,85 @@ builder.Services.AddAuthentication(opts => 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; +}) +.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts => +{ + opts.SignInScheme = PortalOAuthDefaults.ExternalScheme; + opts.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? ""; + opts.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? ""; + opts.CallbackPath = "/taxbaik/portal/signin-google"; +}) +.AddOAuth(PortalOAuthDefaults.NaverScheme, opts => +{ + opts.SignInScheme = PortalOAuthDefaults.ExternalScheme; + opts.ClientId = builder.Configuration["Authentication:Naver:ClientId"] ?? ""; + opts.ClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"] ?? ""; + 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() ?? "")); + } + }; +}) +.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts => +{ + opts.SignInScheme = PortalOAuthDefaults.ExternalScheme; + opts.ClientId = builder.Configuration["Authentication:Kakao:ClientId"] ?? ""; + opts.ClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"] ?? ""; + 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 인증 @@ -178,6 +259,11 @@ builder.Services.AddResponseCompression(opts => { opts.Providers.Add(); }); builder.Services.AddScoped(); +builder.Services.AddHostedService(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +builder.Services.Configure(builder.Configuration.GetSection("Authentication")); // 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정 builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All)); diff --git a/TaxBaik.Web/Services/TelegramNotificationService.cs b/TaxBaik.Web/Services/TelegramNotificationService.cs index 14d1f69..d1f56cc 100644 --- a/TaxBaik.Web/Services/TelegramNotificationService.cs +++ b/TaxBaik.Web/Services/TelegramNotificationService.cs @@ -33,8 +33,8 @@ public class TelegramNotificationService : ITelegramNotificationService _httpClient = httpClient; _logger = logger; _botToken = config["Telegram:BotToken"] ?? ""; - _defaultChatId = config["Telegram:ChatId"] ?? ""; - _inquiryChatId = config["Telegram:InquiryChatId"] ?? "-5434691215"; + _defaultChatId = config["Telegram:ChatId"] ?? "-5434691215"; + _inquiryChatId = config["Telegram:InquiryChatId"] ?? _defaultChatId; _systemChatId = config["Telegram:SystemChatId"] ?? "-5585148480"; } @@ -88,7 +88,7 @@ public class TelegramNotificationService : ITelegramNotificationService public async Task SendErrorAsync(string title, string details, CancellationToken ct = default) { var message = $"❌ {title}\n\n{details}\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"; - await SendMessageAsync(message, ct); + await SendToChat(_systemChatId, message, ct); } public async Task SendInfoAsync(string title, string message, CancellationToken ct = default) diff --git a/TaxBaik.Web/Services/TelegramReportBackgroundService.cs b/TaxBaik.Web/Services/TelegramReportBackgroundService.cs new file mode 100644 index 0000000..419ee8f --- /dev/null +++ b/TaxBaik.Web/Services/TelegramReportBackgroundService.cs @@ -0,0 +1,82 @@ +namespace TaxBaik.Web.Services; + +using Microsoft.Extensions.Hosting; +using TaxBaik.Application.Services; + +public class TelegramReportBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger 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)); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, KoreaTimeZone); + await TrySendReportsAsync(now, stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Telegram report background loop failed"); + } + } + } + + 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(); + var telegram = scope.ServiceProvider.GetRequiredService(); + + var report = await reportService.BuildDailyReportAsync(date, ct); + await telegram.SendSystemNotificationAsync(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(); + var telegram = scope.ServiceProvider.GetRequiredService(); + + var report = await reportService.BuildWeeklyReportAsync(weekStart, ct); + await telegram.SendSystemNotificationAsync(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"); + } + } +}