using Application.Abstractions.Messaging; using Application.Abstractions.Crypto; using Application.Abstractions.Data; using Application.Abstractions.Cache; using Domain.Entities.Common; using SharedKernel.Storage; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; namespace Application.Features.Config.Update; public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImageService editorImage, ICacheService cache, IFieldEncryptor encryptor) : ICommandHandler { public async Task Handle(Command request, CancellationToken ct) { var config = await db.Config.OrderByDescending(x => x.ID).FirstOrDefaultAsync(ct); if (config is null) { config = Domain.Entities.Common.Config.Create(); db.Config.Add(config); } var basic = new BasicConfig { }; var images = new ImagesConfig { }; var meta = new MetaConfig { }; var company = new CompanyConfig { }; var account = new AccountConfig { }; var emailTemplate = new EmailTemplateConfig { }; var external = new ExternalApiConfig { }; if (request.Basic != null) { basic.SiteName = request.Basic.SiteName; basic.SiteURL = request.Basic.SiteURL; basic.RootID = request.Basic.RootID; basic.FromEmail = request.Basic.FromEmail; basic.FromName = request.Basic.FromName; basic.SmtpServer = request.Basic.SmtpServer; basic.SmtpPort = request.Basic.SmtpPort; basic.SmtpEnableSSL = request.Basic.SmtpEnableSSL; basic.SmtpUsername = request.Basic.SmtpUsername; basic.SmtpPassword = request.Basic.SmtpPassword; basic.AdminWhiteIPList = request.Basic.AdminWhiteIPList; basic.FrontWhiteIPList = request.Basic.FrontWhiteIPList; basic.BlockAlertTitle = request.Basic.BlockAlertTitle; basic.BlockAlertContent = await SaveBytesAsync(request.Basic.BlockAlertContent, UploadFolder.Basic, UploadAddition.BlockAlertContent, ct); basic.IsMaintenance = request.Basic.IsMaintenance; basic.MaintenanceContent = await SaveBytesAsync(request.Basic.MaintenanceContent, UploadFolder.Basic, UploadAddition.MaintenanceContent, ct); basic.NoteDailySendLimit = request.Basic.NoteDailySendLimit; } else { basic = config.Basic; } if (request.Images != null) { var del = request.ImagesDelete ?? new Command.ImagesDeleteFlags(); images.Favicon = await ApplyImageAsync(del.Favicon, config.Images.Favicon, request.Images.FaviconFile, [".ico"], ct); images.LogoSquare = await ApplyImageAsync(del.LogoSquare, config.Images.LogoSquare, request.Images.LogoSquareFile, [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"], ct); images.LogoHorizontal = await ApplyImageAsync(del.LogoHorizontal, config.Images.LogoHorizontal, request.Images.LogoHorizontalFile, [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"], ct); images.OgDefault = await ApplyImageAsync(del.OgDefault, config.Images.OgDefault, request.Images.OgDefaultFile, [".jpg", ".jpeg", ".png", ".gif", ".webp"], ct); images.TwitterImage = await ApplyImageAsync(del.TwitterImage, config.Images.TwitterImage, request.Images.TwitterImageFile, [".jpg", ".jpeg", ".png", ".gif", ".webp"], ct); images.AppleTouchIcon = await ApplyImageAsync(del.AppleTouchIcon, config.Images.AppleTouchIcon, request.Images.AppleTouchIconFile, [".jpg", ".jpeg", ".png", ".gif", ".webp"], ct); images.AppIcon_192 = await ApplyImageAsync(del.AppIcon192, config.Images.AppIcon_192, request.Images.AppIcon192File, [".jpg", ".jpeg", ".png", ".gif", ".webp"], ct); images.AppIcon_512 = await ApplyImageAsync(del.AppIcon512, config.Images.AppIcon_512, request.Images.AppIcon512File, [".jpg", ".jpeg", ".png", ".gif", ".webp"], ct); } else { images = config.Images; } if (request.Meta != null) { meta.Keywords = request.Meta.Keywords; meta.Description = request.Meta.Description; meta.Author = request.Meta.Author; meta.Viewport = request.Meta.Viewport; meta.ApplicationName = request.Meta.ApplicationName; meta.Generator = request.Meta.Generator; meta.Robots = request.Meta.Robots; meta.Adds = request.Meta.Adds; } else { meta = config.Meta; } if (request.Company != null) { company.Name = request.Company.Name; company.RegNo = request.Company.RegNo; company.Address = request.Company.Address; company.ZipCode = request.Company.ZipCode; company.Owner = request.Company.Owner; company.Tel = request.Company.Tel; company.Fax = request.Company.Fax; company.RetailSaleNo = request.Company.RetailSaleNo; company.AddedSaleNo = request.Company.AddedSaleNo; company.Hosting = request.Company.Hosting; company.AdminName = request.Company.AdminName; company.AdminEmail = request.Company.AdminEmail; company.SiteUrl = request.Company.SiteUrl; company.BankCode = request.Company.BankCode; company.BankOwner = request.Company.BankOwner; company.BankNumber = request.Company.BankNumber; } else { company = config.Company; } if (request.Account != null) { account.IsRegisterBlock = request.Account.IsRegisterBlock; account.IsRegisterEmailAuth = request.Account.IsRegisterEmailAuth; account.PasswordMinLength = request.Account.PasswordMinLength; account.PasswordUppercaseLength = request.Account.PasswordUppercaseLength; account.PasswordNumbersLength = request.Account.PasswordNumbersLength; account.PasswordSpecialcharsLength = request.Account.PasswordSpecialcharsLength; account.DeniedEmailList = request.Account.DeniedEmailList; account.DeniedNameList = request.Account.DeniedNameList; account.ChangeEmailDay = request.Account.ChangeEmailDay; account.ChangeNameDay = request.Account.ChangeNameDay; account.ChangeSummaryDay = request.Account.ChangeSummaryDay; account.ChangeIntroDay = request.Account.ChangeIntroDay; account.ChangePasswordDay = request.Account.ChangePasswordDay; account.IsLoginEmailVerifiedOnly = request.Account.IsLoginEmailVerifiedOnly; account.MaxLoginTryCount = request.Account.MaxLoginTryCount; account.MaxLoginTryLimitSecond = request.Account.MaxLoginTryLimitSecond; } else { account = config.Account; } if (request.EmailTemplate != null) { emailTemplate.RegisterEmailFormTitle = request.EmailTemplate.RegisterEmailFormTitle; emailTemplate.RegisterEmailFormContent = await SaveBytesAsync(request.EmailTemplate.RegisterEmailFormContent, UploadFolder.EmailTemplate, UploadAddition.RegisterEmailFormContent, ct); emailTemplate.RegistrationEmailFormTitle = request.EmailTemplate.RegistrationEmailFormTitle; emailTemplate.RegistrationEmailFormContent = await SaveBytesAsync(request.EmailTemplate.RegistrationEmailFormContent, UploadFolder.EmailTemplate, UploadAddition.RegistrationEmailFormContent, ct); emailTemplate.ResetPasswordEmailFormTitle = request.EmailTemplate.ResetPasswordEmailFormTitle; emailTemplate.ResetPasswordEmailFormContent = await SaveBytesAsync(request.EmailTemplate.ResetPasswordEmailFormContent, UploadFolder.EmailTemplate, UploadAddition.ResetPasswordEmailFormContent, ct); emailTemplate.ChangedPasswordEmailFormTitle = request.EmailTemplate.ChangedPasswordEmailFormTitle; emailTemplate.ChangedPasswordEmailFormContent = await SaveBytesAsync(request.EmailTemplate.ChangedPasswordEmailFormContent, UploadFolder.EmailTemplate, UploadAddition.ChangedPasswordEmailFormContent, ct); emailTemplate.WithdrawEmailFormTitle = request.EmailTemplate.WithdrawEmailFormTitle; emailTemplate.WithdrawEmailFormContent = await SaveBytesAsync(request.EmailTemplate.WithdrawEmailFormContent, UploadFolder.EmailTemplate, UploadAddition.WithdrawEmailFormContent, ct); emailTemplate.WithdrawVerifyEmailFormTitle = request.EmailTemplate.WithdrawVerifyEmailFormTitle; emailTemplate.WithdrawVerifyEmailFormContent = await SaveBytesAsync(request.EmailTemplate.WithdrawVerifyEmailFormContent, UploadFolder.EmailTemplate, UploadAddition.WithdrawVerifyEmailFormContent, ct); emailTemplate.EmailVerifyFormTitle = request.EmailTemplate.EmailVerifyFormTitle; emailTemplate.EmailVerifyFormContent = await SaveBytesAsync(request.EmailTemplate.EmailVerifyFormContent, UploadFolder.EmailTemplate, UploadAddition.EmailVerifyFormContent, ct); emailTemplate.ChangedEmailFormTitle = request.EmailTemplate.ChangedEmailFormTitle; emailTemplate.ChangedEmailFormContent = await SaveBytesAsync(request.EmailTemplate.ChangedEmailFormContent, UploadFolder.EmailTemplate, UploadAddition.ChangedEmailFormContent, ct); } else { emailTemplate = config.EmailTemplate; } if (request.External != null) { external.YouTubeApiKeyEnc = request.External.YouTubeApiKeyEnc; external.YouTubeApiName = request.External.YouTubeApiName; external.GoogleClientId = request.External.GoogleClientId; external.GoogleClientSecretEnc = request.External.GoogleClientSecretEnc; external.GoogleAppId = request.External.GoogleAppId; // 소셜 로그인 on/off (체크박스) external.NaverLoginEnabled = request.External.NaverLoginEnabled; external.KakaoLoginEnabled = request.External.KakaoLoginEnabled; external.GoogleLoginEnabled = request.External.GoogleLoginEnabled; // 소셜 로그인(OAuth) — ID/JS 키는 직접 저장, Secret 은 ProtectOrKeep(빈 값=유지, 입력=암호화) external.NaverClientId = request.External.NaverClientId; external.NaverClientSecretEnc = ProtectOrKeep(request.External.NaverClientSecret, config.External.NaverClientSecretEnc); external.KakaoRestApiKeyEnc = ProtectOrKeep(request.External.KakaoRestApiKey, config.External.KakaoRestApiKeyEnc); external.KakaoJavascriptKey = request.External.KakaoJavascriptKey; external.KakaoAdminKeyEnc = ProtectOrKeep(request.External.KakaoAdminKey, config.External.KakaoAdminKeyEnc); external.NaverSearchClientId = request.External.NaverSearchClientId; external.NaverSearchClientSecretEnc = ProtectOrKeep(request.External.NaverSearchClientSecret, config.External.NaverSearchClientSecretEnc); // 토스 필드는 결제 설정 화면(request.Toss) 소관 — External 저장 시 보존 external.TossPayMode = config.External.TossPayMode; external.TossTestClientKeyEnc = config.External.TossTestClientKeyEnc; external.TossTestSecretKeyEnc = config.External.TossTestSecretKeyEnc; external.TossLiveClientKeyEnc = config.External.TossLiveClientKeyEnc; external.TossLiveSecretKeyEnc = config.External.TossLiveSecretKeyEnc; } else { external = config.External; } if (request.Toss != null) { // 키는 빈 값 = 기존 유지, 입력 시 암호화 저장 (이미 암호문 포맷이면 이중 암호화 방지 위해 그대로 저장) external.TossPayMode = request.Toss.TossPayMode; external.TossTestClientKeyEnc = ProtectOrKeep(request.Toss.TestClientKey, external.TossTestClientKeyEnc); external.TossTestSecretKeyEnc = ProtectOrKeep(request.Toss.TestSecretKey, external.TossTestSecretKeyEnc); external.TossLiveClientKeyEnc = ProtectOrKeep(request.Toss.LiveClientKey, external.TossLiveClientKeyEnc); external.TossLiveSecretKeyEnc = ProtectOrKeep(request.Toss.LiveSecretKey, external.TossLiveSecretKeyEnc); } var attendance = new AttendanceConfig { }; if (request.Attendance != null) { attendance.IsEnabled = request.Attendance.IsEnabled; attendance.BaseExp = request.Attendance.BaseExp; attendance.BasePoint = request.Attendance.BasePoint; attendance.UseStreakBonus = request.Attendance.UseStreakBonus; attendance.StreakBonusPerDay = request.Attendance.StreakBonusPerDay; attendance.StreakBonusPointPerDay = request.Attendance.StreakBonusPointPerDay; attendance.StreakBonusMaxDays = request.Attendance.StreakBonusMaxDays; attendance.UseRankBonus = request.Attendance.UseRankBonus; attendance.RankBonusConfig = request.Attendance.RankBonusConfig; } else { attendance = config.Attendance; } var signupReward = new SignupRewardConfig { }; if (request.SignupReward != null) { signupReward.Enabled = request.SignupReward.Enabled; signupReward.CoinAmount = request.SignupReward.CoinAmount; signupReward.CashAmount = request.SignupReward.CashAmount; signupReward.ExpAmount = request.SignupReward.ExpAmount; } else { signupReward = config.SignupReward; } config.Update( basic, images, meta, company, account, emailTemplate, external, config.Crypto, attendance, signupReward ); // 보상 엔진 설정은 별도 메서드로 갱신 — 다른 섹션 저장 시 null 덮어쓰기 방지 (ChatExp/Paper 패턴) if (request.Reward != null) { config.UpdateReward(new RewardConfig { PostExp = request.Reward.PostExp, PostPoint = request.Reward.PostPoint, CommentExp = request.Reward.CommentExp, CommentPoint = request.Reward.CommentPoint, LikeGivenExp = request.Reward.LikeGivenExp, LikeReceivedPoint = request.Reward.LikeReceivedPoint, MinPostLength = request.Reward.MinPostLength, MinCommentLength = request.Reward.MinCommentLength, NewMemberHoldDays = request.Reward.NewMemberHoldDays, PostDailyCount = request.Reward.PostDailyCount, PostDailyExp = request.Reward.PostDailyExp, PostDailyPoint = request.Reward.PostDailyPoint, CommentDailyCount = request.Reward.CommentDailyCount, CommentDailyExp = request.Reward.CommentDailyExp, CommentDailyPoint = request.Reward.CommentDailyPoint, LikeGivenDailyCount = request.Reward.LikeGivenDailyCount, LikeGivenDailyExp = request.Reward.LikeGivenDailyExp, LikeReceivedDailyCount = request.Reward.LikeReceivedDailyCount, LikeReceivedDailyPoint = request.Reward.LikeReceivedDailyPoint, ChatDailyCount = request.Reward.ChatDailyCount, ChatDailyExp = request.Reward.ChatDailyExp, CheerFeePercent = request.Reward.CheerFeePercent, CheerMinAmount = request.Reward.CheerMinAmount, CheerDailyMax = request.Reward.CheerDailyMax }); } // 모의투자 설정도 별도 메서드로 갱신 — 부분 저장 시 null 덮어쓰기 방지 (d4 M4) if (request.Paper != null) { config.UpdatePaper(new PaperConfig { Enabled = request.Paper.Enabled, FeeRateBp = request.Paper.FeeRateBp, TaxRateBp = request.Paper.TaxRateBp, MinDeposit = request.Paper.MinDeposit, MaxHolding = request.Paper.MaxHolding, WithdrawProfitBurnBp = request.Paper.WithdrawProfitBurnBp, OrderMaxPctBp = request.Paper.OrderMaxPctBp, MinFillsForRank = request.Paper.MinFillsForRank }); } // 데이터 수집 API 키 + 수집기 활성화 — 별도 메서드(부분 저장 시 null 덮어쓰기 방지). 키는 ProtectOrKeep(빈 값=유지). if (request.DataCollection != null) { config.UpdateDataCollection(new DataCollectionConfig { DataGoKrServiceKeyEnc = ProtectOrKeep(request.DataCollection.DataGoKrServiceKey, config.DataCollection.DataGoKrServiceKeyEnc), KrxApiKeyEnc = ProtectOrKeep(request.DataCollection.KrxApiKey, config.DataCollection.KrxApiKeyEnc), OpenDartApiKeyEnc = ProtectOrKeep(request.DataCollection.OpenDartApiKey, config.DataCollection.OpenDartApiKeyEnc), KoreaEximExchangeKeyEnc = ProtectOrKeep(request.DataCollection.KoreaEximExchangeKey, config.DataCollection.KoreaEximExchangeKeyEnc), KoreaEximLoanRateKeyEnc = ProtectOrKeep(request.DataCollection.KoreaEximLoanRateKey, config.DataCollection.KoreaEximLoanRateKeyEnc), KoreaEximIntlRateKeyEnc = ProtectOrKeep(request.DataCollection.KoreaEximIntlRateKey, config.DataCollection.KoreaEximIntlRateKeyEnc), SeibroApiKeyEnc = ProtectOrKeep(request.DataCollection.SeibroApiKey, config.DataCollection.SeibroApiKeyEnc), KosisApiKeyEnc = ProtectOrKeep(request.DataCollection.KosisApiKey, config.DataCollection.KosisApiKeyEnc), StockDataMasterSync = request.DataCollection.StockDataMasterSync, StockDataDailyPriceSync = request.DataCollection.StockDataDailyPriceSync, KrxIndexSync = request.DataCollection.KrxIndexSync, KrxBondIndexSync = request.DataCollection.KrxBondIndexSync, KrxStockSync = request.DataCollection.KrxStockSync, KrxEtpSync = request.DataCollection.KrxEtpSync, KrxWarrantSync = request.DataCollection.KrxWarrantSync, KrxBondSync = request.DataCollection.KrxBondSync, KrxDerivativeSync = request.DataCollection.KrxDerivativeSync, KrxCommoditySync = request.DataCollection.KrxCommoditySync, KrxEsgSync = request.DataCollection.KrxEsgSync, OpenDartDisclosureSync = request.DataCollection.OpenDartDisclosureSync, KoreaEximMacroSync = request.DataCollection.KoreaEximMacroSync, KosisSync = request.DataCollection.KosisSync, WorldIndexEnabled = request.DataCollection.WorldIndexEnabled, MarketQuoteEnabled = request.DataCollection.MarketQuoteEnabled, SeibroIssuerSync = request.DataCollection.SeibroIssuerSync, SeibroDividendSync = request.DataCollection.SeibroDividendSync, SeibroSupplySync = request.DataCollection.SeibroSupplySync, SeibroCorpActionSync = request.DataCollection.SeibroCorpActionSync, SeibroBondSync = request.DataCollection.SeibroBondSync, SeibroDerivSync = request.DataCollection.SeibroDerivSync, SeibroForeignSync = request.DataCollection.SeibroForeignSync }); } await db.SaveChangesAsync(ct); await cache.RemoveAsync(CacheKeys.Config, ct); } /// 키 입력값이 비어있으면 기존 값 유지, 입력됐으면 암호화 저장. 이미 암호문 포맷이면 재암호화하지 않는다. private string? ProtectOrKeep(string? input, string? current) { if (string.IsNullOrWhiteSpace(input)) { return current; } var trimmed = input.Trim(); return EncryptedConfigValue.IsProtected(trimmed) ? trimmed : EncryptedConfigValue.Protect(encryptor, trimmed); } private async Task ApplyImageAsync(bool deleteRequested, string? currentUrl, IFormFile? newFile, string[] allowedExtensions, CancellationToken ct) { if (deleteRequested) { storage.DeleteByUrl(currentUrl); return null; } var uploaded = await SaveFileAsync(newFile, allowedExtensions, ct); return uploaded ?? currentUrl; } private async Task SaveFileAsync(IFormFile? file, string[] allowedExtensions, CancellationToken ct) { var path = new FileStoragePath(UploadTarget.Upload, UploadFolder.Basic, null, null); var result = await storage.SaveFileAsync(file, path, allowedExtensions, ct); return result?.Url; } private async Task SaveBytesAsync(string? html, UploadFolder folder, UploadAddition? addition = null, CancellationToken ct = default) { var path = new FileStoragePath(UploadTarget.Editor, folder, null, addition); return await editorImage.UploadAsync(html, path, ct); } }