feat(ops): 배포 알림과 텔레그램 리포트 추가

This commit is contained in:
2026-06-28 18:39:28 +09:00
parent d2cfcd90f0
commit 033883aac5
10 changed files with 333 additions and 4 deletions
+86
View File
@@ -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<GzipCompressionProvider>();
});
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
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));
@@ -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 = $"<b>❌ {title}</b>\n\n{details}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
await SendMessageAsync(message, ct);
await SendToChat(_systemChatId, message, ct);
}
public async Task SendInfoAsync(string title, string message, CancellationToken ct = default)
@@ -0,0 +1,82 @@
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));
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<TelegramReportService>();
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
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<TelegramReportService>();
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
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");
}
}
}