KIM-JINO5 2 місяців тому
батько
коміт
40c3a4f023
100 змінених файлів з 2453 додано та 8 видалено
  1. 2 2
      Admin/Pages/Channel/List/View.cshtml
  2. 7 1
      Admin/Pages/Channel/List/View.cshtml.cs
  3. 67 0
      Admin/Pages/Config/External.cshtml
  4. 2 2
      Admin/Properties/launchSettings.json
  5. 70 0
      Admin/logs/admin-20260325.log
  6. 33 0
      Application/Abstractions/Data/IAppDbContext.cs
  7. 35 0
      Application/Abstractions/Hub/IAppHubClient.cs
  8. 18 0
      Application/Abstractions/Hub/IDonationHubClient.cs
  9. 30 0
      Application/Abstractions/Notification/INotificationService.cs
  10. 55 0
      Application/Abstractions/Payment/DanalPayModels.cs
  11. 19 0
      Application/Abstractions/Payment/IDanalPayService.cs
  12. 4 1
      Application/Abstractions/YouTube/IGoogleOAuthService.cs
  13. 6 0
      Application/Abstractions/YouTube/IYouTubeApiService.cs
  14. 21 0
      Application/Abstractions/YouTube/IYouTubeChannelCache.cs
  15. 5 2
      Application/Abstractions/YouTube/YouTubeChannelInfo.cs
  16. 39 0
      Application/Features/Api/Channel/Get/Handler.cs
  17. 6 0
      Application/Features/Api/Channel/Get/Query.cs
  18. 17 0
      Application/Features/Api/Channel/Get/Response.cs
  19. 58 0
      Application/Features/Api/Channel/List/Handler.cs
  20. 6 0
      Application/Features/Api/Channel/List/Query.cs
  21. 14 0
      Application/Features/Api/Channel/List/Response.cs
  22. 6 0
      Application/Features/Api/Channel/ResetWidgetToken/Command.cs
  23. 25 0
      Application/Features/Api/Channel/ResetWidgetToken/Handler.cs
  24. 5 0
      Application/Features/Api/Crew/ConsentSession/Command.cs
  25. 40 0
      Application/Features/Api/Crew/ConsentSession/Handler.cs
  26. 3 0
      Application/Features/Api/Crew/ConsentSession/Response.cs
  27. 5 0
      Application/Features/Api/Crew/EndSession/Command.cs
  28. 25 0
      Application/Features/Api/Crew/EndSession/Handler.cs
  29. 40 0
      Application/Features/Api/Crew/GetCrewList/Handler.cs
  30. 11 0
      Application/Features/Api/Crew/GetCrewList/Query.cs
  31. 9 0
      Application/Features/Api/Crew/GetCrewList/Response.cs
  32. 36 0
      Application/Features/Api/Crew/GetCrewRanking/Handler.cs
  33. 5 0
      Application/Features/Api/Crew/GetCrewRanking/Query.cs
  34. 8 0
      Application/Features/Api/Crew/GetCrewRanking/Response.cs
  35. 13 0
      Application/Features/Api/Crew/SaveCrew/Command.cs
  36. 31 0
      Application/Features/Api/Crew/SaveCrew/Handler.cs
  37. 5 0
      Application/Features/Api/Crew/StartSession/Command.cs
  38. 56 0
      Application/Features/Api/Crew/StartSession/Handler.cs
  39. 3 0
      Application/Features/Api/Crew/StartSession/Response.cs
  40. 47 0
      Application/Features/Api/Donation/History/Handler.cs
  41. 10 0
      Application/Features/Api/Donation/History/Query.cs
  42. 23 0
      Application/Features/Api/Donation/History/Response.cs
  43. 14 0
      Application/Features/Api/Donation/Send/Command.cs
  44. 172 0
      Application/Features/Api/Donation/Send/Handler.cs
  45. 9 0
      Application/Features/Api/Donation/Send/Response.cs
  46. 39 0
      Application/Features/Api/DonationAlert/BatchSaveConfig/Command.cs
  47. 80 0
      Application/Features/Api/DonationAlert/BatchSaveConfig/Handler.cs
  48. 5 0
      Application/Features/Api/DonationAlert/DeleteConfig/Command.cs
  49. 22 0
      Application/Features/Api/DonationAlert/DeleteConfig/Handler.cs
  50. 37 0
      Application/Features/Api/DonationAlert/GetConfig/Handler.cs
  51. 5 0
      Application/Features/Api/DonationAlert/GetConfig/Query.cs
  52. 32 0
      Application/Features/Api/DonationAlert/GetConfig/Response.cs
  53. 42 0
      Application/Features/Api/DonationAlert/GetConfigByToken/Handler.cs
  54. 5 0
      Application/Features/Api/DonationAlert/GetConfigByToken/Query.cs
  55. 32 0
      Application/Features/Api/DonationAlert/GetConfigByToken/Response.cs
  56. 34 0
      Application/Features/Api/DonationAlert/SaveConfig/Command.cs
  57. 63 0
      Application/Features/Api/DonationAlert/SaveConfig/Handler.cs
  58. 12 0
      Application/Features/Api/DonationAlert/UploadMedia/Command.cs
  59. 37 0
      Application/Features/Api/DonationAlert/UploadMedia/Handler.cs
  60. 5 0
      Application/Features/Api/DonationGoal/DeleteConfig/Command.cs
  61. 22 0
      Application/Features/Api/DonationGoal/DeleteConfig/Handler.cs
  62. 29 0
      Application/Features/Api/DonationGoal/GetConfig/Handler.cs
  63. 28 0
      Application/Features/Api/DonationGoal/GetConfig/Query.cs
  64. 29 0
      Application/Features/Api/DonationGoal/GetProgress/Handler.cs
  65. 5 0
      Application/Features/Api/DonationGoal/GetProgress/Query.cs
  66. 3 0
      Application/Features/Api/DonationGoal/GetProgress/Response.cs
  67. 26 0
      Application/Features/Api/DonationGoal/SaveConfig/Command.cs
  68. 65 0
      Application/Features/Api/DonationGoal/SaveConfig/Handler.cs
  69. 5 0
      Application/Features/Api/DonationRank/DeleteConfig/Command.cs
  70. 22 0
      Application/Features/Api/DonationRank/DeleteConfig/Handler.cs
  71. 30 0
      Application/Features/Api/DonationRank/GetConfig/Handler.cs
  72. 36 0
      Application/Features/Api/DonationRank/GetConfig/Query.cs
  73. 22 0
      Application/Features/Api/DonationRank/GetRanking/Handler.cs
  74. 6 0
      Application/Features/Api/DonationRank/GetRanking/Query.cs
  75. 5 0
      Application/Features/Api/DonationRank/GetRanking/Response.cs
  76. 35 0
      Application/Features/Api/DonationRank/SaveConfig/Command.cs
  77. 90 0
      Application/Features/Api/DonationRank/SaveConfig/Handler.cs
  78. 33 0
      Application/Features/Api/DonationRemote/GetState/Handler.cs
  79. 5 0
      Application/Features/Api/DonationRemote/GetState/Query.cs
  80. 14 0
      Application/Features/Api/DonationRemote/GetState/Response.cs
  81. 5 0
      Application/Features/Api/DonationRemote/IgnoreAlert/Command.cs
  82. 25 0
      Application/Features/Api/DonationRemote/IgnoreAlert/Handler.cs
  83. 5 0
      Application/Features/Api/DonationRemote/ResendAlert/Command.cs
  84. 24 0
      Application/Features/Api/DonationRemote/ResendAlert/Handler.cs
  85. 5 0
      Application/Features/Api/DonationRemote/SkipCurrent/Command.cs
  86. 21 0
      Application/Features/Api/DonationRemote/SkipCurrent/Handler.cs
  87. 7 0
      Application/Features/Api/DonationRemote/UpdateState/Command.cs
  88. 32 0
      Application/Features/Api/DonationRemote/UpdateState/Handler.cs
  89. 48 0
      Application/Features/Api/MyPage/ChargeLogs/Handler.cs
  90. 11 0
      Application/Features/Api/MyPage/ChargeLogs/Query.cs
  91. 20 0
      Application/Features/Api/MyPage/ChargeLogs/Response.cs
  92. 69 0
      Application/Features/Api/MyPage/Dropdown/Handler.cs
  93. 6 0
      Application/Features/Api/MyPage/Dropdown/Query.cs
  94. 11 0
      Application/Features/Api/MyPage/Dropdown/Response.cs
  95. 36 0
      Application/Features/Api/Note/GetInbox/Handler.cs
  96. 5 0
      Application/Features/Api/Note/GetInbox/Query.cs
  97. 8 0
      Application/Features/Api/Note/GetInbox/Response.cs
  98. 13 0
      Application/Features/Api/Note/SendNote/Command.cs
  99. 24 0
      Application/Features/Api/Note/SendNote/Handler.cs
  100. 3 0
      Application/Features/Api/Note/SendNote/Response.cs

+ 2 - 2
Admin/Pages/Channel/List/View.cshtml

@@ -129,8 +129,8 @@
             <div class="row g-0 border-bottom">
             <div class="row g-0 border-bottom">
                 <div class="col-12 col-md-2 fw-bold p-2 bg-light">채널 ID</div>
                 <div class="col-12 col-md-2 fw-bold p-2 bg-light">채널 ID</div>
                 <div class="col-12 col-md-10 p-2">
                 <div class="col-12 col-md-10 p-2">
-                    <code>@yt.ChannelId</code>
-                    <a href="https://youtube.com/channel/@yt.ChannelId" target="_blank" class="ms-2 small">
+                    <code>@yt.ChannelID</code>
+                    <a href="https://youtube.com/channel/@yt.ChannelID" target="_blank" class="ms-2 small">
                         <i class="bi bi-box-arrow-up-right"></i>
                         <i class="bi bi-box-arrow-up-right"></i>
                     </a>
                     </a>
                 </div>
                 </div>

+ 7 - 1
Admin/Pages/Channel/List/View.cshtml.cs

@@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
 
 
 namespace Admin.Pages.Channel.List
 namespace Admin.Pages.Channel.List
 {
 {
-    public class ViewModel(IMediator mediator, IYouTubeApiService youTubeApi, IYouTubeLiveStateStore liveStateStore) : PageModel
+    public class ViewModel(IMediator mediator, IYouTubeApiService youTubeApi, IYouTubeLiveStateStore liveStateStore, IYouTubeChannelCache channelCache) : PageModel
     {
     {
         // ── DB 데이터 ────────────────────────────────────────────────
         // ── DB 데이터 ────────────────────────────────────────────────
         public int ID { get; set; }
         public int ID { get; set; }
@@ -63,6 +63,12 @@ namespace Admin.Pages.Channel.List
             {
             {
                 YouTubeChannel = await youTubeApi.GetChannelByIdAsync(channelId, ct);
                 YouTubeChannel = await youTubeApi.GetChannelByIdAsync(channelId, ct);
 
 
+                // YouTube API 조회 성공 시 Redis 캐시에 저장 (24시간 TTL)
+                if (YouTubeChannel is not null)
+                {
+                    await channelCache.SetAsync(YouTubeChannel);
+                }
+
                 if (YouTubeChannel is null)
                 if (YouTubeChannel is null)
                 {
                 {
                     YouTubeApiFailed = true;
                     YouTubeApiFailed = true;

+ 67 - 0
Admin/Pages/Config/External.cshtml

@@ -74,6 +74,73 @@
 
 
         <hr />
         <hr />
 
 
+        <!-- 다날 PG -->
+        <details open>
+            <summary class="fs-5 mb-3">다날 PG (DanalPay)</summary>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.DanalPayMode" class="col-sm-2 col-form-label">결제 환경</label>
+                <div class="col-sm-10">
+                    <select asp-for="Input.External.DanalPayMode" class="form-select">
+                        <option value="">-- 선택 --</option>
+                        <option value="test">테스트 (Test)</option>
+                        <option value="live">운영 (Live)</option>
+                    </select>
+                    <span asp-validation-for="Input.External.DanalPayMode" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.DanalTestCpid" class="col-sm-2 col-form-label">Test CPID</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.DanalTestCpid" type="text" class="form-control" placeholder="예: T0000" />
+                    <span asp-validation-for="Input.External.DanalTestCpid" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.DanalTestClientKeyEnc" class="col-sm-2 col-form-label">Test Client Key</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.DanalTestClientKeyEnc" type="text" class="form-control" autocomplete="off" />
+                    <span asp-validation-for="Input.External.DanalTestClientKeyEnc" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.DanalTestSecretKeyEnc" class="col-sm-2 col-form-label">Test Secret Key</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.DanalTestSecretKeyEnc" type="text" class="form-control" autocomplete="off" />
+                    <span asp-validation-for="Input.External.DanalTestSecretKeyEnc" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.DanalLiveCpid" class="col-sm-2 col-form-label">Live CPID</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.DanalLiveCpid" type="text" class="form-control" />
+                    <span asp-validation-for="Input.External.DanalLiveCpid" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.DanalLiveClientKeyEnc" class="col-sm-2 col-form-label">Live Client Key</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.DanalLiveClientKeyEnc" type="text" class="form-control" autocomplete="off" />
+                    <span asp-validation-for="Input.External.DanalLiveClientKeyEnc" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.DanalLiveSecretKeyEnc" class="col-sm-2 col-form-label">Live Secret Key</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.DanalLiveSecretKeyEnc" type="text" class="form-control" autocomplete="off" />
+                    <span asp-validation-for="Input.External.DanalLiveSecretKeyEnc" class="text-danger"></span>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
         <div class="row">
         <div class="row">
             <div class="col text-center p-3">
             <div class="col text-center p-3">
                 <button type="submit" class="btn btn-success">저장하기</button>
                 <button type="submit" class="btn btn-success">저장하기</button>

+ 2 - 2
Admin/Properties/launchSettings.json

@@ -2,7 +2,7 @@
     "profiles": {
     "profiles": {
         "http": {
         "http": {
             "commandName": "Project",
             "commandName": "Project",
-            "launchBrowser": true,
+            "launchBrowser": false,
             "environmentVariables": {
             "environmentVariables": {
                 "ASPNETCORE_ENVIRONMENT": "Development"
                 "ASPNETCORE_ENVIRONMENT": "Development"
                 //"ASPNETCORE_URLS": ""
                 //"ASPNETCORE_URLS": ""
@@ -13,7 +13,7 @@
         },
         },
         "https": {
         "https": {
             "commandName": "Project",
             "commandName": "Project",
-            "launchBrowser": true,
+            "launchBrowser": false,
             "environmentVariables": {
             "environmentVariables": {
                 "ASPNETCORE_ENVIRONMENT": "Development"
                 "ASPNETCORE_ENVIRONMENT": "Development"
                 //"ASPNETCORE_URLS": ""
                 //"ASPNETCORE_URLS": ""

+ 70 - 0
Admin/logs/admin-20260325.log

@@ -0,0 +1,70 @@
+2026-03-25 05:40:10.625 [ERR] Failed executing DbCommand (25ms) [Parameters=[@normalizedName='?' (Size = 256)], CommandType='"Text"', CommandTimeout='30']
+SELECT TOP(1) [a].[Id], [a].[ConcurrencyStamp], [a].[Name], [a].[NormalizedName]
+FROM [AspNetRoles] AS [a]
+WHERE [a].[NormalizedName] = @normalizedName
+2026-03-25 05:40:10.689 [ERR] An exception occurred while iterating over the results of a query for context type 'Infrastructure.Persistence.IdentityDbContext'.
+Microsoft.Data.SqlClient.SqlException (0x80131904): Invalid object name 'AspNetRoles'.
+   at Microsoft.Data.SqlClient.SqlCommand.<>c.<ExecuteDbDataReaderAsync>b__195_0(Task`1 result)
+   at System.Threading.Tasks.ContinuationResultTaskFromResultTask`2.InnerInvoke()
+   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
+--- End of stack trace from previous location ---
+   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
+   at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
+--- End of stack trace from previous location ---
+   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.InitializeReaderAsync(AsyncEnumerator enumerator, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()
+ClientConnectionId:bd595796-5003-436d-9505-f3734ff8d822
+Error Number:208,State:1,Class:16
+Microsoft.Data.SqlClient.SqlException (0x80131904): Invalid object name 'AspNetRoles'.
+   at Microsoft.Data.SqlClient.SqlCommand.<>c.<ExecuteDbDataReaderAsync>b__195_0(Task`1 result)
+   at System.Threading.Tasks.ContinuationResultTaskFromResultTask`2.InnerInvoke()
+   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
+--- End of stack trace from previous location ---
+   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
+   at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
+--- End of stack trace from previous location ---
+   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.InitializeReaderAsync(AsyncEnumerator enumerator, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()
+ClientConnectionId:bd595796-5003-436d-9505-f3734ff8d822
+Error Number:208,State:1,Class:16
+2026-03-25 05:57:19.469 [ERR] Failed executing DbCommand (25ms) [Parameters=[@normalizedName='?' (Size = 256)], CommandType='"Text"', CommandTimeout='30']
+SELECT TOP(1) [a].[Id], [a].[ConcurrencyStamp], [a].[Name], [a].[NormalizedName]
+FROM [AspNetRoles] AS [a]
+WHERE [a].[NormalizedName] = @normalizedName
+2026-03-25 05:57:19.494 [ERR] An exception occurred while iterating over the results of a query for context type 'Infrastructure.Persistence.IdentityDbContext'.
+Microsoft.Data.SqlClient.SqlException (0x80131904): Invalid object name 'AspNetRoles'.
+   at Microsoft.Data.SqlClient.SqlCommand.<>c.<ExecuteDbDataReaderAsync>b__195_0(Task`1 result)
+   at System.Threading.Tasks.ContinuationResultTaskFromResultTask`2.InnerInvoke()
+   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
+--- End of stack trace from previous location ---
+   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
+   at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
+--- End of stack trace from previous location ---
+   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.InitializeReaderAsync(AsyncEnumerator enumerator, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()
+ClientConnectionId:5b4db9f5-430a-4b1f-a67b-16dc9dcacfae
+Error Number:208,State:1,Class:16
+Microsoft.Data.SqlClient.SqlException (0x80131904): Invalid object name 'AspNetRoles'.
+   at Microsoft.Data.SqlClient.SqlCommand.<>c.<ExecuteDbDataReaderAsync>b__195_0(Task`1 result)
+   at System.Threading.Tasks.ContinuationResultTaskFromResultTask`2.InnerInvoke()
+   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
+--- End of stack trace from previous location ---
+   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
+   at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
+--- End of stack trace from previous location ---
+   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.InitializeReaderAsync(AsyncEnumerator enumerator, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
+   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()
+ClientConnectionId:5b4db9f5-430a-4b1f-a67b-16dc9dcacfae
+Error Number:208,State:1,Class:16

+ 33 - 0
Application/Abstractions/Data/IAppDbContext.cs

@@ -13,6 +13,9 @@ using Domain.Entities.Director;
 using Domain.Entities.EmailVerification;
 using Domain.Entities.EmailVerification;
 using Domain.Entities.Forum.Logs;
 using Domain.Entities.Forum.Logs;
 using Domain.Entities.Page.Popup;
 using Domain.Entities.Page.Popup;
+using Domain.Entities.Donations;
+using Domain.Entities.Notes;
+using Domain.Entities.Payments.Danal;
 
 
 namespace Application.Abstractions.Data
 namespace Application.Abstractions.Data
 {
 {
@@ -93,6 +96,36 @@ namespace Application.Abstractions.Data
         DbSet<CommentFileDownLog> CommentFileDownLog { get; set; }
         DbSet<CommentFileDownLog> CommentFileDownLog { get; set; }
         DbSet<CommentLinkClickLog> CommentLinkClickLog { get; set; }
         DbSet<CommentLinkClickLog> CommentLinkClickLog { get; set; }
 
 
+        // 후원
+        DbSet<Donation> Donation { get; set; }
+        DbSet<DonationAlert> DonationAlert { get; set; }
+        DbSet<DonationAlertAttempt> DonationAlertAttempt { get; set; }
+        DbSet<DonationAlertConfig> DonationAlertConfig { get; set; }
+        DbSet<DonationMeta> DonationMeta { get; set; }
+        DbSet<Settlement> Settlement { get; set; }
+        DbSet<DonationGoalConfig> DonationGoalConfig { get; set; }
+        DbSet<DonationRankConfig> DonationRankConfig { get; set; }
+        DbSet<DonationRanking> DonationRanking { get; set; }
+
+        // 크루
+        DbSet<Crew> Crew { get; set; }
+        DbSet<CrewMember> CrewMember { get; set; }
+        DbSet<CrewSession> CrewSession { get; set; }
+        DbSet<CrewSessionConsent> CrewSessionConsent { get; set; }
+        DbSet<CrewDonationSummary> CrewDonationSummary { get; set; }
+
+        // 쪽지
+        DbSet<Note> Note { get; set; }
+
+        // 알림
+        DbSet<Domain.Entities.Notifications.Notification> Notification { get; set; }
+
+        // 결제
+        DbSet<Domain.Entities.Payments.PaymentOrder> PaymentOrder { get; set; }
+        DbSet<DanalConfirm> DanalConfirm { get; set; }
+        DbSet<DanalCancel> DanalCancel { get; set; }
+        DbSet<DanalLog> DanalLog { get; set; }
+
         Task<int> SaveChangesAsync(CancellationToken ct = default);
         Task<int> SaveChangesAsync(CancellationToken ct = default);
     }
     }
 }
 }

+ 35 - 0
Application/Abstractions/Hub/IAppHubClient.cs

@@ -0,0 +1,35 @@
+using Application.Abstractions.Chat;
+
+namespace Application.Abstractions.Hub;
+
+/// <summary>
+/// AppHub 클라이언트 인터페이스 — 모든 페이지에서 사용하는 공통 Hub
+/// 접속자 추적 + 채팅 + 알림 + 쪽지 + 크루 초대/동의/toast
+/// </summary>
+public interface IAppHubClient
+{
+    // ── 채팅 (기존 ChatHub 통합) ──────────────────────────────────
+    Task ReceiveMessage(ChatMessage message);
+    Task ReceiveHistory(IReadOnlyList<ChatMessage> messages);
+    Task ReceiveSystemMessage(string message);
+    Task ReceiveParticipantCount(int count);
+    Task ReceiveParticipants(IReadOnlyList<ChatParticipant> participants);
+    Task Connected(string message);
+    Task Logout(string message);
+    Task Kick();
+
+    // ── 알림 + 쪽지 ──────────────────────────────────────────────
+    Task ReceiveNotification(object notification);
+    Task ReceiveNote(object notePreview);
+    Task ReceiveUnreadCounts(int notificationCount, int noteCount);
+
+    // ── 채널 상태 (PubSub 기반 실시간) ─────────────────────────
+    Task ReceiveChannelStatus(object channelStatus);
+
+    // ── 크루 ─────────────────────────────────────────────────────
+    Task ReceiveCrewInvitation(object invitation);
+    Task ReceiveCrewConsentUpdate(object consentStatus);
+    Task ReceiveCrewStarted(object sessionInfo);
+    Task ReceiveCrewEnded(object sessionResult);
+    Task ReceiveCrewToast(object toastData);
+}

+ 18 - 0
Application/Abstractions/Hub/IDonationHubClient.cs

@@ -0,0 +1,18 @@
+namespace Application.Abstractions.Hub;
+
+/// <summary>
+/// DonationHub 클라이언트 인터페이스 — OBS 위젯, 리모콘 전용
+/// </summary>
+public interface IDonationHubClient
+{
+    // ── 후원 알림 ────────────────────────────────────────────────
+    Task ReceiveAlert(object alertData);
+    Task ReceiveSkip();
+    Task ReceivePause(bool isPaused);
+    Task ReceiveState(object stateData);
+
+    // ── 목표 + 순위 ─────────────────────────────────────────────
+    Task ReceiveGoalUpdate(object progressData);
+    Task ReceiveRankUpdate(object rankingData);
+    Task ReceiveCrewUpdate(object crewRankData);
+}

+ 30 - 0
Application/Abstractions/Notification/INotificationService.cs

@@ -0,0 +1,30 @@
+using Domain.Entities.Notifications.ValueObject;
+
+namespace Application.Abstractions.Notification;
+
+public interface INotificationService
+{
+    Task SendAsync(
+        int memberID,
+        NotificationType type,
+        string title,
+        string message,
+        string? actionUrl = null,
+        string? relatedType = null,
+        int? relatedID = null,
+        string? imageUrl = null,
+        CancellationToken ct = default
+    );
+
+    Task SendToManyAsync(
+        IEnumerable<int> memberIDs,
+        NotificationType type,
+        string title,
+        string message,
+        string? actionUrl = null,
+        string? relatedType = null,
+        int? relatedID = null,
+        string? imageUrl = null,
+        CancellationToken ct = default
+    );
+}

+ 55 - 0
Application/Abstractions/Payment/DanalPayModels.cs

@@ -0,0 +1,55 @@
+namespace Application.Abstractions.Payment;
+
+/// <summary>프론트엔드에 전달할 클라이언트 설정</summary>
+public sealed record DanalClientConfig(
+    string ClientKey,
+    string MerchantID
+);
+
+/// <summary>결제 승인 결과 (다날 API 응답 전체)</summary>
+public sealed record DanalConfirmResult(
+    bool Success,
+    string? Code,
+    string? Message,
+    string? TransactionID,
+    string? OrderName,
+    int? TotalAmount,
+    int? DiscountAmount,
+    string? UserName,
+    // 카드
+    string? TransDate,
+    string? TransTime,
+    string? CardCode,
+    string? CardName,
+    string? CardNo,
+    byte? InstallmentMonths,
+    string? ApproveNo,
+    // 휴대폰
+    string? ApprovalDateTime,
+    string? AuthKey,
+    // 계좌이체
+    string? AccountNumber,
+    string? BankCode,
+    string? UserId,
+    string? UserEmail,
+    // 가상계좌
+    string? BankName,
+    string? ExpireDate,
+    string? ExpireTime,
+    string? VirtualAccountNumber,
+    string? UseCashReceipt
+);
+
+/// <summary>결제 취소 결과 (다날 API 응답 전체)</summary>
+public sealed record DanalCancelResult(
+    bool Success,
+    string? Code,
+    string? Message,
+    string? OriginalTransactionID,
+    int? CancelledAmount,
+    string? TransDate,
+    string? TransTime,
+    string? Balance,
+    string? RemainedAmount,
+    string? ApprovalDateTime
+);

+ 19 - 0
Application/Abstractions/Payment/IDanalPayService.cs

@@ -0,0 +1,19 @@
+namespace Application.Abstractions.Payment;
+
+/// <summary>
+/// 다날 PG 결제 서비스
+/// - Config DB에서 테스트/라이브 자동 분기
+/// - Basic Auth 헤더 생성
+/// - 승인/취소 API 호출
+/// </summary>
+public interface IDanalPayService
+{
+    /// <summary>프론트엔드에 전달할 ClientKey, MerchantID</summary>
+    Task<DanalClientConfig> GetClientConfigAsync(CancellationToken ct);
+
+    /// <summary>결제 승인 (successUrl 콜백 후 서버에서 호출)</summary>
+    Task<DanalConfirmResult> ConfirmAsync(string method, string transactionID, string orderID, int amount, CancellationToken ct);
+
+    /// <summary>결제 취소</summary>
+    Task<DanalCancelResult> CancelAsync(string method, string transactionID, int amount, string cancelType, CancellationToken ct);
+}

+ 4 - 1
Application/Abstractions/YouTube/IGoogleOAuthService.cs

@@ -10,8 +10,11 @@ public interface IGoogleOAuthService
     string GetAuthorizationUrl(string state, string redirectUri, string[] scopes);
     string GetAuthorizationUrl(string state, string redirectUri, string[] scopes);
 
 
     /// <summary>Authorization Code → Access Token + Refresh Token 교환</summary>
     /// <summary>Authorization Code → Access Token + Refresh Token 교환</summary>
-    Task<GoogleOAuthTokens?> ExchangeCodeAsync(string code, string redirectUri, CancellationToken ct);
+    Task<GoogleOAuthTokens?> ExchangeCodeAsync(string code, string redirectUri, string clientId, string clientSecret, CancellationToken ct);
 
 
     /// <summary>Refresh Token으로 Access Token 갱신</summary>
     /// <summary>Refresh Token으로 Access Token 갱신</summary>
     Task<GoogleOAuthTokens?> RefreshTokenAsync(string refreshToken, CancellationToken ct);
     Task<GoogleOAuthTokens?> RefreshTokenAsync(string refreshToken, CancellationToken ct);
+
+    /// <summary>OAuth 토큰 revoke (앱 접근 권한 해제)</summary>
+    Task RevokeTokenAsync(string token, CancellationToken ct);
 }
 }

+ 6 - 0
Application/Abstractions/YouTube/IYouTubeApiService.cs

@@ -10,6 +10,9 @@ public interface IYouTubeApiService
     /// <summary>채널 ID로 채널 정보 조회</summary>
     /// <summary>채널 ID로 채널 정보 조회</summary>
     Task<YouTubeChannelInfo?> GetChannelByIdAsync(string channelId, CancellationToken ct);
     Task<YouTubeChannelInfo?> GetChannelByIdAsync(string channelId, CancellationToken ct);
 
 
+    /// <summary>여러 채널 ID를 한 번에 조회 (최대 50개, 1 unit)</summary>
+    Task<IReadOnlyList<YouTubeChannelInfo>> GetChannelsByIdsAsync(IReadOnlyList<string> channelIds, CancellationToken ct);
+
     /// <summary>핸들(@username)로 채널 정보 조회</summary>
     /// <summary>핸들(@username)로 채널 정보 조회</summary>
     Task<YouTubeChannelInfo?> GetChannelByHandleAsync(string handle, CancellationToken ct);
     Task<YouTubeChannelInfo?> GetChannelByHandleAsync(string handle, CancellationToken ct);
 
 
@@ -23,4 +26,7 @@ public interface IYouTubeApiService
 
 
     /// <summary>현재 사용자의 특정 채널 멤버십 상태 확인</summary>
     /// <summary>현재 사용자의 특정 채널 멤버십 상태 확인</summary>
     Task<YouTubeMembershipStatus> CheckMembershipAsync(string accessToken, string channelId, CancellationToken ct);
     Task<YouTubeMembershipStatus> CheckMembershipAsync(string accessToken, string channelId, CancellationToken ct);
+
+    /// <summary>OAuth Access Token으로 인증된 사용자 본인의 채널 조회 (mine=true)</summary>
+    Task<YouTubeChannelInfo?> GetMyChannelAsync(string accessToken, CancellationToken ct);
 }
 }

+ 21 - 0
Application/Abstractions/YouTube/IYouTubeChannelCache.cs

@@ -0,0 +1,21 @@
+namespace Application.Abstractions.YouTube;
+
+/// <summary>
+/// YouTube 채널 정보 캐시 (Redis)
+/// Admin에서 채널 View → YouTube API 조회 시 자동 캐시
+/// Frontend에서 채널 목록 조회 시 캐시만 읽음 (API 호출 0)
+/// </summary>
+public interface IYouTubeChannelCache
+{
+    /// <summary>채널 정보 캐시 저장 (TTL: 24시간)</summary>
+    Task SetAsync(YouTubeChannelInfo info);
+
+    /// <summary>채널 정보 캐시 조회 (null = 캐시 없음)</summary>
+    Task<YouTubeChannelInfo?> GetAsync(string channelId);
+
+    /// <summary>여러 채널 정보 일괄 조회</summary>
+    Task<Dictionary<string, YouTubeChannelInfo>> GetManyAsync(IEnumerable<string> channelIds);
+
+    /// <summary>채널 정보 캐시 삭제</summary>
+    Task RemoveAsync(string channelId);
+}

+ 5 - 2
Application/Abstractions/YouTube/YouTubeChannelInfo.cs

@@ -1,12 +1,15 @@
 namespace Application.Abstractions.YouTube;
 namespace Application.Abstractions.YouTube;
 
 
 public sealed record YouTubeChannelInfo(
 public sealed record YouTubeChannelInfo(
-    string ChannelId,
+    string ChannelID,
     string Title,
     string Title,
     string Description,
     string Description,
     string ThumbnailUrl,
     string ThumbnailUrl,
+    string? BannerUrl,
     string? CustomUrl,
     string? CustomUrl,
     long SubscriberCount,
     long SubscriberCount,
     long VideoCount,
     long VideoCount,
-    long ViewCount
+    long ViewCount,
+    string? Email,
+    DateTime? PublishedAt
 );
 );

+ 39 - 0
Application/Features/Api/Channel/Get/Handler.cs

@@ -0,0 +1,39 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.YouTube;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Channel.Get;
+
+public sealed class Handler(IAppDbContext db, IYouTubeLiveStateStore liveStateStore, IYouTubeChannelCache channelCache) : IQueryHandler<Query, Result<Response>>
+{
+    public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
+    {
+        var channel = await db.Channel.AsNoTracking().FirstOrDefaultAsync(c => c.SID == request.ChannelSID && c.IsActive, ct);
+
+        if (channel is null)
+        {
+            return Result.Failure<Response>(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다."));
+        }
+
+        var liveInfo = await liveStateStore.GetLiveAsync(channel.SID);
+        var ytInfo = await channelCache.GetAsync(channel.SID);
+
+        return new Response(
+            channel.SID,
+            channel.Name,
+            channel.Handle,
+            channel.YouTubeUrl,
+            ytInfo?.ThumbnailUrl,
+            ytInfo?.BannerUrl,
+            ytInfo?.Description,
+            ytInfo?.SubscriberCount ?? 0,
+            ytInfo?.VideoCount ?? 0,
+            channel.IsVerified,
+            liveInfo is not null,
+            liveInfo?.VideoId,
+            liveInfo?.Title
+        );
+    }
+}

+ 6 - 0
Application/Features/Api/Channel/Get/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Channel.Get;
+
+public sealed record Query(string ChannelSID) : IQuery<Result<Response>>;

+ 17 - 0
Application/Features/Api/Channel/Get/Response.cs

@@ -0,0 +1,17 @@
+namespace Application.Features.Api.Channel.Get;
+
+public sealed record Response(
+    string ChannelSID,
+    string Name,
+    string? Handle,
+    string YouTubeUrl,
+    string? ThumbnailUrl,
+    string? BannerUrl,
+    string? Description,
+    long SubscriberCount,
+    long VideoCount,
+    bool IsVerified,
+    bool IsLive,
+    string? VideoId,
+    string? LiveTitle
+);

+ 58 - 0
Application/Features/Api/Channel/List/Handler.cs

@@ -0,0 +1,58 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.YouTube;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Channel.List;
+
+public sealed class Handler(IAppDbContext db, IYouTubeLiveStateStore liveStateStore, IYouTubeChannelCache channelCache) : IQueryHandler<Query, Result<Response>>
+{
+    public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
+    {
+        // 1. 활성 채널 목록 조회
+        var channels = await db.Channel.AsNoTracking().Where(c => c.IsActive).Select(c => new { c.SID, c.Name, c.Handle }).ToListAsync(ct);
+
+        if (channels.Count == 0)
+        {
+            return new Response([]);
+        }
+
+        // 2. YouTube 채널 정보 캐시 일괄 조회 (Redis — API 호출 0)
+        var channelIds = channels.Select(c => c.SID).ToList();
+        var ytCache = await channelCache.GetManyAsync(channelIds);
+
+        // 3. 현재 라이브 중인 채널 상태 조회 (Redis — 0 unit)
+        var allLive = await liveStateStore.GetAllLiveAsync();
+        var liveMap = allLive.ToDictionary(l => l.ChannelId);
+
+        // 4. 채널별 정보 조합
+        var items = new List<ChannelItem>();
+        foreach (var ch in channels)
+        {
+            var isLive = liveMap.TryGetValue(ch.SID, out var liveInfo);
+            var hasYtInfo = ytCache.TryGetValue(ch.SID, out var ytInfo);
+
+            items.Add(new ChannelItem(
+                ch.SID,
+                ch.Name,
+                ch.Handle,
+                hasYtInfo ? ytInfo!.ThumbnailUrl : null,
+                hasYtInfo ? ytInfo!.SubscriberCount : 0,
+                isLive,
+                0, // viewerCount — 추후 사이트 자체 접속자 수로 대체
+                isLive ? liveInfo!.VideoId : null
+            ));
+        }
+
+        // 5. 정렬: 온라인(시청자 많은 순) → 오프라인(이름순)
+        items.Sort((a, b) => {
+            if (a.IsLive && !b.IsLive) return -1;
+            if (!a.IsLive && b.IsLive) return 1;
+            if (a.IsLive && b.IsLive) return b.ViewerCount.CompareTo(a.ViewerCount);
+            return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
+        });
+
+        return new Response(items);
+    }
+}

+ 6 - 0
Application/Features/Api/Channel/List/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Channel.List;
+
+public sealed record Query() : IQuery<Result<Response>>;

+ 14 - 0
Application/Features/Api/Channel/List/Response.cs

@@ -0,0 +1,14 @@
+namespace Application.Features.Api.Channel.List;
+
+public sealed record Response(IReadOnlyList<ChannelItem> Channels);
+
+public sealed record ChannelItem(
+    string ChannelSID,
+    string Name,
+    string? Handle,
+    string? ThumbnailUrl,
+    long SubscriberCount,
+    bool IsLive,
+    int ViewerCount,
+    string? VideoId
+);

+ 6 - 0
Application/Features/Api/Channel/ResetWidgetToken/Command.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Channel.ResetWidgetToken;
+
+public sealed record Command(int ChannelID, int MemberID) : ICommand<Result<string>>;

+ 25 - 0
Application/Features/Api/Channel/ResetWidgetToken/Handler.cs

@@ -0,0 +1,25 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Channel.ResetWidgetToken;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<string>>
+{
+    public async Task<Result<string>> Handle(Command request, CancellationToken ct)
+    {
+        var channel = await db.Channel
+            .FirstOrDefaultAsync(c => c.ID == request.ChannelID && c.MemberID == request.MemberID, ct);
+
+        if (channel is null)
+        {
+            return Result.Failure<string>(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다."));
+        }
+
+        channel.ResetWidgetToken();
+        await db.SaveChangesAsync(ct);
+
+        return channel.WidgetToken;
+    }
+}

+ 5 - 0
Application/Features/Api/Crew/ConsentSession/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Crew.ConsentSession;
+
+public sealed record Command(int CrewSessionID, int CrewMemberID) : ICommand<Response>;

+ 40 - 0
Application/Features/Api/Crew/ConsentSession/Handler.cs

@@ -0,0 +1,40 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Crew.ConsentSession;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
+{
+    public async Task<Response> Handle(Command request, CancellationToken ct)
+    {
+        var consent = await db.CrewSessionConsent
+            .FirstOrDefaultAsync(c => c.CrewSessionID == request.CrewSessionID && c.CrewMemberID == request.CrewMemberID, ct);
+
+        if (consent is null)
+        {
+            throw new KeyNotFoundException("동의 정보를 찾을 수 없습니다.");
+        }
+
+        if (consent.IsConsented)
+        {
+            throw new InvalidOperationException("이미 동의했습니다.");
+        }
+
+        consent.Consent();
+
+        // 전원 동의 확인
+        var allConsented = await db.CrewSessionConsent
+            .Where(c => c.CrewSessionID == request.CrewSessionID)
+            .AllAsync(c => c.IsConsented || c.CrewMemberID == request.CrewMemberID, ct);
+
+        if (allConsented)
+        {
+            var session = await db.CrewSession.FindAsync([request.CrewSessionID], ct);
+            session?.Activate();
+        }
+
+        await db.SaveChangesAsync(ct);
+        return new Response(allConsented);
+    }
+}

+ 3 - 0
Application/Features/Api/Crew/ConsentSession/Response.cs

@@ -0,0 +1,3 @@
+namespace Application.Features.Api.Crew.ConsentSession;
+
+public sealed record Response(bool AllConsented);

+ 5 - 0
Application/Features/Api/Crew/EndSession/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Crew.EndSession;
+
+public sealed record Command(int CrewSessionID) : ICommand;

+ 25 - 0
Application/Features/Api/Crew/EndSession/Handler.cs

@@ -0,0 +1,25 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Crew.EndSession;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var session = await db.CrewSession
+            .FirstOrDefaultAsync(s => s.ID == request.CrewSessionID && s.Status == CrewSessionStatus.Active, ct);
+
+        if (session is null)
+        {
+            throw new KeyNotFoundException("활성 세션을 찾을 수 없습니다.");
+        }
+
+        session.End();
+        await db.SaveChangesAsync(ct);
+
+        // 쪽지 발송은 Endpoint에서 처리
+    }
+}

+ 40 - 0
Application/Features/Api/Crew/GetCrewList/Handler.cs

@@ -0,0 +1,40 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Crew.GetCrewList;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.Crew.AsNoTracking().Where(c => c.ChannelID == request.ChannelID);
+
+        var total = await query.CountAsync(ct);
+
+        var projected = query.Select(c => new CrewItem(
+            c.ID, c.Name, c.Description, c.MinAmount, c.IsActive,
+            c.Members.Count(m => m.IsActive),
+            c.Sessions.Count,
+            c.Sessions.Sum(s => s.TotalAmount),
+            c.CreatedAt
+        ));
+
+        projected = (request.SortBy, request.SortDir) switch
+        {
+            ("amount", "asc") => projected.OrderBy(c => c.TotalDonationAmount),
+            ("amount", _) => projected.OrderByDescending(c => c.TotalDonationAmount),
+            ("members", "asc") => projected.OrderBy(c => c.MemberCount),
+            ("members", _) => projected.OrderByDescending(c => c.MemberCount),
+            ("date", "asc") => projected.OrderBy(c => c.CreatedAt),
+            _ => projected.OrderByDescending(c => c.CreatedAt)
+        };
+
+        var list = await projected
+            .Skip((request.PageNum - 1) * request.PerPage)
+            .Take(request.PerPage)
+            .ToListAsync(ct);
+
+        return new Response(total, list);
+    }
+}

+ 11 - 0
Application/Features/Api/Crew/GetCrewList/Query.cs

@@ -0,0 +1,11 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Crew.GetCrewList;
+
+public sealed record Query(
+    int ChannelID,
+    string? SortBy = "date",   // date, amount, members
+    string? SortDir = "desc",  // asc, desc
+    int PageNum = 1,
+    ushort PerPage = 20
+) : IQuery<Response>;

+ 9 - 0
Application/Features/Api/Crew/GetCrewList/Response.cs

@@ -0,0 +1,9 @@
+namespace Application.Features.Api.Crew.GetCrewList;
+
+public sealed record Response(int Total, IReadOnlyList<CrewItem> List);
+
+public sealed record CrewItem(
+    int ID, string Name, string? Description, int? MinAmount,
+    bool IsActive, int MemberCount, int SessionCount,
+    int TotalDonationAmount, DateTime CreatedAt
+);

+ 36 - 0
Application/Features/Api/Crew/GetCrewRanking/Handler.cs

@@ -0,0 +1,36 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Crew.GetCrewRanking;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var session = await db.CrewSession.AsNoTracking()
+            .FirstOrDefaultAsync(s => s.ID == request.CrewSessionID, ct);
+
+        if (session is null)
+        {
+            throw new KeyNotFoundException("세션을 찾을 수 없습니다.");
+        }
+
+        var list = await db.CrewDonationSummary.AsNoTracking()
+            .Where(s => s.CrewSessionID == request.CrewSessionID)
+            .OrderBy(s => s.Rank)
+            .Select(s => new CrewRankItem(
+                s.Rank,
+                s.CrewMemberID,
+                s.CrewMember!.Nickname,
+                s.CrewMember.Member != null ? s.CrewMember.Member.Thumb : null,
+                s.CrewMember.Channel != null ? s.CrewMember.Channel.Name : null,
+                s.TotalAmount,
+                s.DonationCount,
+                s.ContributionRate
+            ))
+            .ToListAsync(ct);
+
+        return new Response(list, session.TotalAmount);
+    }
+}

+ 5 - 0
Application/Features/Api/Crew/GetCrewRanking/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Crew.GetCrewRanking;
+
+public sealed record Query(int CrewSessionID) : IQuery<Response>;

+ 8 - 0
Application/Features/Api/Crew/GetCrewRanking/Response.cs

@@ -0,0 +1,8 @@
+namespace Application.Features.Api.Crew.GetCrewRanking;
+
+public sealed record Response(IReadOnlyList<CrewRankItem> List, int TotalAmount);
+
+public sealed record CrewRankItem(
+    int Rank, int CrewMemberID, string Nickname, string? Icon,
+    string? ChannelName, int TotalAmount, int DonationCount, decimal ContributionRate
+);

+ 13 - 0
Application/Features/Api/Crew/SaveCrew/Command.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Crew.SaveCrew;
+
+public sealed record Command(
+    int? ID,
+    int ChannelID,
+    int MemberID,
+    string Name,
+    string? Description,
+    int? MinAmount,
+    bool IsActive
+) : ICommand;

+ 31 - 0
Application/Features/Api/Crew/SaveCrew/Handler.cs

@@ -0,0 +1,31 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Crew.SaveCrew;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command r, CancellationToken ct)
+    {
+        if (r.ID.HasValue)
+        {
+            var crew = await db.Crew
+                .FirstOrDefaultAsync(c => c.ID == r.ID.Value && c.ChannelID == r.ChannelID, ct);
+
+            if (crew is null)
+            {
+                throw new KeyNotFoundException("크루를 찾을 수 없습니다.");
+            }
+
+            crew.Update(r.Name, r.Description, r.MinAmount, r.IsActive);
+        }
+        else
+        {
+            var crew = Domain.Entities.Donations.Crew.Create(r.ChannelID, r.MemberID, r.Name, r.Description, r.MinAmount);
+            db.Crew.Add(crew);
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 5 - 0
Application/Features/Api/Crew/StartSession/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Crew.StartSession;
+
+public sealed record Command(int CrewID, string Title) : ICommand<Response>;

+ 56 - 0
Application/Features/Api/Crew/StartSession/Handler.cs

@@ -0,0 +1,56 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Crew.StartSession;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
+{
+    public async Task<Response> Handle(Command request, CancellationToken ct)
+    {
+        var crew = await db.Crew
+            .Include(c => c.Members.Where(m => m.IsActive))
+            .FirstOrDefaultAsync(c => c.ID == request.CrewID && c.IsActive, ct);
+
+        if (crew is null)
+        {
+            throw new KeyNotFoundException("크루를 찾을 수 없습니다.");
+        }
+
+        if (!crew.Members.Any())
+        {
+            throw new InvalidOperationException("크루원이 없습니다.");
+        }
+
+        // 이미 진행 중인 세션이 있는지 확인
+        var activeSession = await db.CrewSession
+            .AnyAsync(s => s.CrewID == request.CrewID && s.Status != Domain.Entities.Donations.ValueObject.CrewSessionStatus.Ended, ct);
+
+        if (activeSession)
+        {
+            throw new InvalidOperationException("이미 진행 중인 크루 방송이 있습니다.");
+        }
+
+        // 세션 생성 (Inviting)
+        var session = CrewSession.Create(request.CrewID, request.Title);
+        db.CrewSession.Add(session);
+        await db.SaveChangesAsync(ct);
+
+        // 각 크루원에 대해 동의 레코드 생성
+        foreach (var member in crew.Members)
+        {
+            var consent = CrewSessionConsent.Create(session.ID, member.ID);
+            db.CrewSessionConsent.Add(consent);
+
+            // 크루원별 집계 초기화
+            var summary = CrewDonationSummary.Create(session.ID, member.ID);
+            db.CrewDonationSummary.Add(summary);
+        }
+
+        await db.SaveChangesAsync(ct);
+
+        // 알림/쪽지 발송은 Endpoint에서 SignalR + NotificationService로 처리
+        return new Response(session.ID);
+    }
+}

+ 3 - 0
Application/Features/Api/Crew/StartSession/Response.cs

@@ -0,0 +1,3 @@
+namespace Application.Features.Api.Crew.StartSession;
+
+public sealed record Response(int CrewSessionID);

+ 47 - 0
Application/Features/Api/Donation/History/Handler.cs

@@ -0,0 +1,47 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Donation.History;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.Donation.AsNoTracking().Where(d => !d.IsTest);
+
+        query = request.Type switch
+        {
+            "sent" => query.Where(d => d.SponsorMemberID == request.MemberID),
+            "received" => query.Where(d => d.ReceiverMemberID == request.MemberID),
+            _ => query.Where(d => d.SponsorMemberID == request.MemberID)
+        };
+
+        var total = await query.CountAsync(ct);
+
+        var list = await query
+            .OrderByDescending(d => d.CreatedAt)
+            .Skip((request.PageNum - 1) * request.PerPage)
+            .Take(request.PerPage)
+            .Select(d => new DonationItem(
+                d.ID,
+                d.SponsorMemberID,
+                d.SendName,
+                d.ReceiverMemberID,
+                d.ChannelID,
+                d.Channel != null ? d.Channel.Name : "",
+                d.Amount,
+                d.NetAmount,
+                d.Message,
+                d.SendName,
+                d.CrewMemberID,
+                d.CrewMember != null ? d.CrewMember.Nickname : null,
+                d.IsTest,
+                d.CreatedAt
+            ))
+            .ToListAsync(ct);
+
+        return new Response(total, list);
+    }
+
+}

+ 10 - 0
Application/Features/Api/Donation/History/Query.cs

@@ -0,0 +1,10 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Donation.History;
+
+public sealed record Query(
+    int MemberID,
+    string Type,   // "sent" | "received"
+    int PageNum = 1,
+    ushort PerPage = 20
+) : IQuery<Response>;

+ 23 - 0
Application/Features/Api/Donation/History/Response.cs

@@ -0,0 +1,23 @@
+namespace Application.Features.Api.Donation.History;
+
+public sealed record Response(
+    int Total,
+    IReadOnlyList<DonationItem> List
+);
+
+public sealed record DonationItem(
+    int ID,
+    int SponsorMemberID,
+    string SponsorName,
+    int ReceiverMemberID,
+    int ChannelID,
+    string ChannelName,
+    int Amount,
+    int NetAmount,
+    string? Message,
+    string SendName,
+    int? CrewMemberID,
+    string? CrewMemberNickname,
+    bool IsTest,
+    DateTime CreatedAt
+);

+ 14 - 0
Application/Features/Api/Donation/Send/Command.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Donation.Send;
+
+public sealed record Command(
+    int ChannelID,
+    int Amount,
+    string? Message,
+    string SendName,
+    int? CrewSessionID = null,
+    int? CrewMemberID = null,
+    bool IsTest = false,
+    int SponsorMemberID = 0
+) : ICommand<Response>;

+ 172 - 0
Application/Features/Api/Donation/Send/Handler.cs

@@ -0,0 +1,172 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Common.ValueObject;
+using Domain.Entities.Donations;
+using Domain.Entities.Donations.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Donation.Send;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
+{
+    public async Task<Response> Handle(Command request, CancellationToken ct)
+    {
+        // 1. 채널 조회 + 수수료율
+        var channel = await db.Channel
+            .AsNoTracking()
+            .Where(c => c.ID == request.ChannelID && c.IsActive)
+            .Select(c => new { c.ID, c.MemberID, c.PlatformFeeRate })
+            .FirstOrDefaultAsync(ct);
+
+        if (channel is null)
+        {
+            throw new KeyNotFoundException("채널을 찾을 수 없습니다.");
+        }
+
+        // 2. 자기 자신에게 후원 불가
+        if (request.SponsorMemberID == channel.MemberID && !request.IsTest)
+        {
+            throw new InvalidOperationException("본인 채널에 후원할 수 없습니다.");
+        }
+
+        // 3. 후원 수락 상태 확인
+        var meta = await db.DonationMeta
+            .AsNoTracking()
+            .FirstOrDefaultAsync(m => m.ChannelID == request.ChannelID, ct);
+
+        if (meta is not null && !meta.IsAccepting)
+        {
+            throw new InvalidOperationException("현재 후원을 받지 않는 채널입니다.");
+        }
+
+        var minAmount = meta?.MinAmount ?? DonationConstants.MinAmount;
+        if (request.Amount < minAmount)
+        {
+            throw new InvalidOperationException($"최소 후원 금액은 {minAmount}원입니다.");
+        }
+
+        if (request.Amount > DonationConstants.MaxAmount)
+        {
+            throw new InvalidOperationException($"최대 후원 금액은 {DonationConstants.MaxAmount:N0}원입니다.");
+        }
+
+        // 4. 크루 세션 검증 (크루 후원인 경우)
+        if (request.CrewSessionID.HasValue)
+        {
+            var session = await db.CrewSession
+                .AsNoTracking()
+                .FirstOrDefaultAsync(s => s.ID == request.CrewSessionID.Value && s.Status == CrewSessionStatus.Active, ct);
+
+            if (session is null)
+            {
+                throw new InvalidOperationException("활성화된 크루 후원 방송이 아닙니다.");
+            }
+        }
+
+        // 5. 후원자 지갑 조회 + 잔액 차감
+        var sponsorWallet = await db.Wallet
+            .Include(w => w.Balances)
+            .FirstOrDefaultAsync(w => w.MemberID == request.SponsorMemberID, ct);
+
+        if (sponsorWallet is null)
+        {
+            throw new KeyNotFoundException("지갑을 찾을 수 없습니다.");
+        }
+
+        var spendAmount = Money.KRW(request.Amount);
+        var totalAvailable = sponsorWallet.GetTotalAvailable();
+
+        if (totalAvailable.Value < spendAmount.Value)
+        {
+            throw new InvalidOperationException("잔액이 부족합니다.");
+        }
+
+        // 6. Donation 생성 (수수료 스냅샷)
+        var donation = Domain.Entities.Donations.Donation.Create(
+            sponsorMemberID: request.SponsorMemberID,
+            receiverMemberID: channel.MemberID,
+            channelID: channel.ID,
+            amount: request.Amount,
+            feeRate: channel.PlatformFeeRate,
+            message: request.Message,
+            sendName: request.SendName,
+            crewSessionID: request.CrewSessionID,
+            crewMemberID: request.CrewMemberID,
+            isTest: request.IsTest
+        );
+
+        db.Donation.Add(donation);
+
+        // 7. 후원자 지갑 차감
+        if (!request.IsTest)
+        {
+            sponsorWallet.Spend(spendAmount, "DONATION_OUT", donation.ID.ToString());
+        }
+
+        // 8. 수신자 지갑에 입금 (NetAmount)
+        if (!request.IsTest)
+        {
+            var receiverWallet = await db.Wallet
+                .Include(w => w.Balances)
+                .FirstOrDefaultAsync(w => w.MemberID == channel.MemberID, ct);
+
+            if (receiverWallet is not null)
+            {
+                receiverWallet.CreditDonationIn(Money.KRW(donation.NetAmount), "DONATION_IN", donation.ID.ToString());
+            }
+        }
+
+        // SaveChanges로 Donation ID 확보
+        await db.SaveChangesAsync(ct);
+
+        // 9. DonationAlert 생성
+        var alert = Domain.Entities.Donations.DonationAlert.Create(
+            donationID: donation.ID,
+            sponsorMemberID: request.SponsorMemberID,
+            receiverMemberID: channel.MemberID
+        );
+
+        db.DonationAlert.Add(alert);
+
+        // 10. 크루 후원인 경우 집계 갱신
+        if (request.CrewSessionID.HasValue && request.CrewMemberID.HasValue)
+        {
+            var summary = await db.CrewDonationSummary
+                .FirstOrDefaultAsync(s => s.CrewSessionID == request.CrewSessionID.Value
+                                       && s.CrewMemberID == request.CrewMemberID.Value, ct);
+
+            if (summary is null)
+            {
+                summary = CrewDonationSummary.Create(request.CrewSessionID.Value, request.CrewMemberID.Value);
+                db.CrewDonationSummary.Add(summary);
+            }
+
+            summary.AddDonation(request.Amount);
+
+            // 세션 전체 합계 갱신
+            var session = await db.CrewSession.FindAsync([request.CrewSessionID.Value], ct);
+            session?.AddDonation(request.Amount);
+
+            // 기여도 재계산
+            if (session is not null && session.TotalAmount > 0)
+            {
+                var allSummaries = await db.CrewDonationSummary
+                    .Where(s => s.CrewSessionID == session.ID)
+                    .OrderByDescending(s => s.TotalAmount)
+                    .ToListAsync(ct);
+
+                var rank = 1;
+                foreach (var s in allSummaries)
+                {
+                    var rate = (decimal)s.TotalAmount / session.TotalAmount * 100;
+                    s.UpdateContribution(rate, rank++);
+                }
+            }
+        }
+
+        await db.SaveChangesAsync(ct);
+
+        // SignalR Push는 Endpoint 레이어 또는 별도 서비스에서 처리
+        return new Response(donation.ID, alert.CorrelationID, donation.Amount, donation.FeeAmount, donation.NetAmount);
+    }
+}

+ 9 - 0
Application/Features/Api/Donation/Send/Response.cs

@@ -0,0 +1,9 @@
+namespace Application.Features.Api.Donation.Send;
+
+public sealed record Response(
+    int DonationID,
+    Guid CorrelationID,
+    int Amount,
+    int FeeAmount,
+    int NetAmount
+);

+ 39 - 0
Application/Features/Api/DonationAlert/BatchSaveConfig/Command.cs

@@ -0,0 +1,39 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationAlert.BatchSaveConfig;
+
+public sealed record Command(
+    int ChannelID,
+    int MemberID,
+    List<ConfigItem> Items,
+    List<int> DeleteIDs
+) : ICommand;
+
+public sealed record ConfigItem(
+    int? ID,
+    string Title,
+    int Amount,
+    int MatchType,
+    string Message,
+    double PlayDelaySec,
+    double DisplayDurationSec,
+    string? PopupEffect,
+    string? TextEffect,
+    string? NicknameFontFamily,
+    int NicknameFontSize,
+    string NicknameFontColor,
+    string? AmountFontFamily,
+    int AmountFontSize,
+    string AmountFontColor,
+    string? MessageFontFamily,
+    int MessageFontSize,
+    string MessageFontColor,
+    string? TemplateFontFamily,
+    int TemplateFontSize,
+    string TemplateFontColor,
+    bool EnableImage,
+    string? ImageUrl,
+    bool EnableSound,
+    string? SoundUrl,
+    bool IsActive
+);

+ 80 - 0
Application/Features/Api/DonationAlert/BatchSaveConfig/Handler.cs

@@ -0,0 +1,80 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationAlert.BatchSaveConfig;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command r, CancellationToken ct)
+    {
+        // 삭제
+        if (r.DeleteIDs is { Count: > 0 })
+        {
+            var toDelete = await db.DonationAlertConfig
+                .Where(c => r.DeleteIDs.Contains(c.ID) && c.ChannelID == r.ChannelID)
+                .ToListAsync(ct);
+
+            db.DonationAlertConfig.RemoveRange(toDelete);
+        }
+
+        // 추가/수정
+        foreach (var item in r.Items)
+        {
+            if (item.ID.HasValue)
+            {
+                var config = await db.DonationAlertConfig
+                    .FirstOrDefaultAsync(c => c.ID == item.ID.Value && c.ChannelID == r.ChannelID, ct);
+
+                if (config is null)
+                {
+                    continue;
+                }
+
+                config.Update(
+                    item.Title, item.Amount, item.MatchType, item.Message,
+                    item.PlayDelaySec, item.DisplayDurationSec,
+                    item.PopupEffect, item.TextEffect,
+                    item.NicknameFontFamily, item.NicknameFontSize, item.NicknameFontColor,
+                    item.AmountFontFamily, item.AmountFontSize, item.AmountFontColor,
+                    item.MessageFontFamily, item.MessageFontSize, item.MessageFontColor,
+                    item.TemplateFontFamily, item.TemplateFontSize, item.TemplateFontColor,
+                    item.EnableImage, item.ImageUrl,
+                    item.EnableSound, item.SoundUrl,
+                    item.IsActive
+                );
+            }
+            else
+            {
+                var config = DonationAlertConfig.Create(
+                    r.ChannelID, r.MemberID,
+                    item.Title, item.Amount, item.MatchType, item.Message,
+                    item.PlayDelaySec, item.DisplayDurationSec,
+                    popupEffect: item.PopupEffect,
+                    textEffect: item.TextEffect,
+                    nicknameFontFamily: item.NicknameFontFamily,
+                    nicknameFontSize: item.NicknameFontSize,
+                    nicknameFontColor: item.NicknameFontColor,
+                    amountFontFamily: item.AmountFontFamily,
+                    amountFontSize: item.AmountFontSize,
+                    amountFontColor: item.AmountFontColor,
+                    messageFontFamily: item.MessageFontFamily,
+                    messageFontSize: item.MessageFontSize,
+                    messageFontColor: item.MessageFontColor,
+                    templateFontFamily: item.TemplateFontFamily,
+                    templateFontSize: item.TemplateFontSize,
+                    templateFontColor: item.TemplateFontColor,
+                    enableImage: item.EnableImage,
+                    imageUrl: item.ImageUrl,
+                    enableSound: item.EnableSound,
+                    soundUrl: item.SoundUrl
+                );
+
+                db.DonationAlertConfig.Add(config);
+            }
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 5 - 0
Application/Features/Api/DonationAlert/DeleteConfig/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationAlert.DeleteConfig;
+
+public sealed record Command(int ID, int ChannelID) : ICommand;

+ 22 - 0
Application/Features/Api/DonationAlert/DeleteConfig/Handler.cs

@@ -0,0 +1,22 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationAlert.DeleteConfig;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var config = await db.DonationAlertConfig
+            .FirstOrDefaultAsync(c => c.ID == request.ID && c.ChannelID == request.ChannelID, ct);
+
+        if (config is null)
+        {
+            return;
+        }
+
+        db.DonationAlertConfig.Remove(config);
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 37 - 0
Application/Features/Api/DonationAlert/GetConfig/Handler.cs

@@ -0,0 +1,37 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationAlert.GetConfig;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var widgetToken = await db.Channel
+            .AsNoTracking()
+            .Where(c => c.ID == request.ChannelID && c.IsActive)
+            .Select(c => c.WidgetToken)
+            .FirstOrDefaultAsync(ct);
+
+        var list = await db.DonationAlertConfig
+            .AsNoTracking()
+            .Where(c => c.ChannelID == request.ChannelID)
+            .OrderBy(c => c.Amount)
+            .Select(c => new AlertConfigItem(
+                c.ID, c.Title, c.Amount, c.MatchType, c.Message,
+                c.PlayDelaySec, c.DisplayDurationSec,
+                c.PopupEffect, c.TextEffect,
+                c.NicknameFontFamily, c.NicknameFontSize, c.NicknameFontColor,
+                c.AmountFontFamily, c.AmountFontSize, c.AmountFontColor,
+                c.MessageFontFamily, c.MessageFontSize, c.MessageFontColor,
+                c.TemplateFontFamily, c.TemplateFontSize, c.TemplateFontColor,
+                c.EnableImage, c.ImageUrl,
+                c.EnableSound, c.SoundUrl,
+                c.IsActive
+            ))
+            .ToListAsync(ct);
+
+        return new Response(list, widgetToken);
+    }
+}

+ 5 - 0
Application/Features/Api/DonationAlert/GetConfig/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationAlert.GetConfig;
+
+public sealed record Query(int ChannelID) : IQuery<Response>;

+ 32 - 0
Application/Features/Api/DonationAlert/GetConfig/Response.cs

@@ -0,0 +1,32 @@
+namespace Application.Features.Api.DonationAlert.GetConfig;
+
+public sealed record Response(IReadOnlyList<AlertConfigItem> List, string? WidgetToken);
+
+public sealed record AlertConfigItem(
+    int ID,
+    string Title,
+    int Amount,
+    int MatchType,
+    string Message,
+    double PlayDelaySec,
+    double DisplayDurationSec,
+    string? PopupEffect,
+    string? TextEffect,
+    string? NicknameFontFamily,
+    int NicknameFontSize,
+    string NicknameFontColor,
+    string? AmountFontFamily,
+    int AmountFontSize,
+    string AmountFontColor,
+    string? MessageFontFamily,
+    int MessageFontSize,
+    string MessageFontColor,
+    string? TemplateFontFamily,
+    int TemplateFontSize,
+    string TemplateFontColor,
+    bool EnableImage,
+    string? ImageUrl,
+    bool EnableSound,
+    string? SoundUrl,
+    bool IsActive
+);

+ 42 - 0
Application/Features/Api/DonationAlert/GetConfigByToken/Handler.cs

@@ -0,0 +1,42 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationAlert.GetConfigByToken;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var channelID = await db.Channel
+            .AsNoTracking()
+            .Where(c => c.WidgetToken == request.WidgetToken && c.IsActive)
+            .Select(c => c.ID)
+            .FirstOrDefaultAsync(ct);
+
+        if (channelID == 0)
+        {
+            return new Response([]);
+        }
+
+        var list = await db.DonationAlertConfig
+            .AsNoTracking()
+            .Where(c => c.ChannelID == channelID && c.IsActive)
+            .OrderBy(c => c.Amount)
+            .Select(c => new ConfigItem(
+                c.ID, c.Title, c.Amount, c.MatchType, c.Message,
+                c.PlayDelaySec, c.DisplayDurationSec,
+                c.PopupEffect, c.TextEffect,
+                c.NicknameFontFamily, c.NicknameFontSize, c.NicknameFontColor,
+                c.AmountFontFamily, c.AmountFontSize, c.AmountFontColor,
+                c.MessageFontFamily, c.MessageFontSize, c.MessageFontColor,
+                c.TemplateFontFamily, c.TemplateFontSize, c.TemplateFontColor,
+                c.EnableImage, c.ImageUrl,
+                c.EnableSound, c.SoundUrl,
+                c.IsActive
+            ))
+            .ToListAsync(ct);
+
+        return new Response(list);
+    }
+}

+ 5 - 0
Application/Features/Api/DonationAlert/GetConfigByToken/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationAlert.GetConfigByToken;
+
+public sealed record Query(string WidgetToken) : IQuery<Response>;

+ 32 - 0
Application/Features/Api/DonationAlert/GetConfigByToken/Response.cs

@@ -0,0 +1,32 @@
+namespace Application.Features.Api.DonationAlert.GetConfigByToken;
+
+public sealed record Response(IReadOnlyList<ConfigItem> List);
+
+public sealed record ConfigItem(
+    int ID,
+    string Title,
+    int Amount,
+    int MatchType,
+    string Message,
+    double PlayDelaySec,
+    double DisplayDurationSec,
+    string? PopupEffect,
+    string? TextEffect,
+    string? NicknameFontFamily,
+    int NicknameFontSize,
+    string NicknameFontColor,
+    string? AmountFontFamily,
+    int AmountFontSize,
+    string AmountFontColor,
+    string? MessageFontFamily,
+    int MessageFontSize,
+    string MessageFontColor,
+    string? TemplateFontFamily,
+    int TemplateFontSize,
+    string TemplateFontColor,
+    bool EnableImage,
+    string? ImageUrl,
+    bool EnableSound,
+    string? SoundUrl,
+    bool IsActive
+);

+ 34 - 0
Application/Features/Api/DonationAlert/SaveConfig/Command.cs

@@ -0,0 +1,34 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationAlert.SaveConfig;
+
+public sealed record Command(
+    int ChannelID,
+    int MemberID,
+    int? ID,
+    string Title,
+    int Amount,
+    int MatchType,
+    string Message,
+    double PlayDelaySec,
+    double DisplayDurationSec,
+    string? PopupEffect,
+    string? TextEffect,
+    string? NicknameFontFamily,
+    int NicknameFontSize,
+    string NicknameFontColor,
+    string? AmountFontFamily,
+    int AmountFontSize,
+    string AmountFontColor,
+    string? MessageFontFamily,
+    int MessageFontSize,
+    string MessageFontColor,
+    string? TemplateFontFamily,
+    int TemplateFontSize,
+    string TemplateFontColor,
+    bool EnableImage,
+    string? ImageUrl,
+    bool EnableSound,
+    string? SoundUrl,
+    bool IsActive
+) : ICommand;

+ 63 - 0
Application/Features/Api/DonationAlert/SaveConfig/Handler.cs

@@ -0,0 +1,63 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationAlert.SaveConfig;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command r, CancellationToken ct)
+    {
+        if (r.ID.HasValue)
+        {
+            var config = await db.DonationAlertConfig.FirstOrDefaultAsync(c => c.ID == r.ID.Value && c.ChannelID == r.ChannelID, ct);
+            if (config is null)
+            {
+                throw new KeyNotFoundException("설정을 찾을 수 없습니다.");
+            }
+
+            config.Update(
+                r.Title, r.Amount, r.MatchType, r.Message,
+                r.PlayDelaySec, r.DisplayDurationSec,
+                r.PopupEffect, r.TextEffect,
+                r.NicknameFontFamily, r.NicknameFontSize, r.NicknameFontColor,
+                r.AmountFontFamily, r.AmountFontSize, r.AmountFontColor,
+                r.MessageFontFamily, r.MessageFontSize, r.MessageFontColor,
+                r.TemplateFontFamily, r.TemplateFontSize, r.TemplateFontColor,
+                r.EnableImage, r.ImageUrl,
+                r.EnableSound, r.SoundUrl,
+                r.IsActive
+            );
+        }
+        else
+        {
+            var config = DonationAlertConfig.Create(
+                r.ChannelID, r.MemberID, r.Title, r.Amount, r.MatchType, r.Message,
+                r.PlayDelaySec, r.DisplayDurationSec,
+                popupEffect: r.PopupEffect,
+                textEffect: r.TextEffect,
+                nicknameFontFamily: r.NicknameFontFamily,
+                nicknameFontSize: r.NicknameFontSize,
+                nicknameFontColor: r.NicknameFontColor,
+                amountFontFamily: r.AmountFontFamily,
+                amountFontSize: r.AmountFontSize,
+                amountFontColor: r.AmountFontColor,
+                messageFontFamily: r.MessageFontFamily,
+                messageFontSize: r.MessageFontSize,
+                messageFontColor: r.MessageFontColor,
+                templateFontFamily: r.TemplateFontFamily,
+                templateFontSize: r.TemplateFontSize,
+                templateFontColor: r.TemplateFontColor,
+                enableImage: r.EnableImage,
+                imageUrl: r.ImageUrl,
+                enableSound: r.EnableSound,
+                soundUrl: r.SoundUrl
+            );
+
+            db.DonationAlertConfig.Add(config);
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 12 - 0
Application/Features/Api/DonationAlert/UploadMedia/Command.cs

@@ -0,0 +1,12 @@
+using Application.Abstractions.Messaging;
+using Microsoft.AspNetCore.Http;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.DonationAlert.UploadMedia;
+
+public sealed record Command(
+    int ChannelID,
+    int MemberID,
+    string Type,
+    IFormFile File
+) : ICommand<Result<string>>;

+ 37 - 0
Application/Features/Api/DonationAlert/UploadMedia/Handler.cs

@@ -0,0 +1,37 @@
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations.ValueObject;
+using SharedKernel.Results;
+using SharedKernel.Storage;
+
+namespace Application.Features.Api.DonationAlert.UploadMedia;
+
+internal sealed class Handler(IFileStorage fileStorage) : ICommandHandler<Command, Result<string>>
+{
+    private static readonly string[] ImageExtensions = [".jpg", ".jpeg", ".png", ".gif"];
+    private static readonly string[] SoundExtensions = [".mp3", ".ogg", ".wav", ".m4a"];
+
+    public async Task<Result<string>> Handle(Command r, CancellationToken ct) 
+    {
+        var (extensions, maxSizeMB) = r.Type switch
+        {
+            "image" => (ImageExtensions, DonationConstants.Alert.MaxImageFileSizeMB),
+            "sound" => (SoundExtensions, DonationConstants.Alert.MaxSoundFileSizeMB),
+            _ => throw new ArgumentException("유효하지 않은 파일 타입입니다.")
+        };
+
+        if (r.File.Length > maxSizeMB * 1024 * 1024)
+        {
+            return Result.Failure<string>(Error.Problem("UploadMedia.FileTooLarge", $"파일 크기는 {maxSizeMB}MB 이하여야 합니다."));
+        }
+
+        var path = new FileStoragePath(UploadTarget.Upload, UploadFolder.DonationAlert, r.ChannelID);
+        var result = await fileStorage.SaveFileAsync(r.File, path, extensions, ct);
+
+        if (result is null)
+        {
+            return Result.Failure<string>(Error.Problem("UploadMedia.Failed", "파일 업로드에 실패했습니다. 허용된 확장자를 확인하세요."));
+        }
+
+        return Result.Success(result.Url);
+    }
+}

+ 5 - 0
Application/Features/Api/DonationGoal/DeleteConfig/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationGoal.DeleteConfig;
+
+public sealed record Command(int ID, int ChannelID) : ICommand;

+ 22 - 0
Application/Features/Api/DonationGoal/DeleteConfig/Handler.cs

@@ -0,0 +1,22 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationGoal.DeleteConfig;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var config = await db.DonationGoalConfig
+            .FirstOrDefaultAsync(c => c.ID == request.ID && c.ChannelID == request.ChannelID, ct);
+
+        if (config is null)
+        {
+            return;
+        }
+
+        db.DonationGoalConfig.Remove(config);
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 29 - 0
Application/Features/Api/DonationGoal/GetConfig/Handler.cs

@@ -0,0 +1,29 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationGoal.GetConfig;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var list = await db.DonationGoalConfig
+            .AsNoTracking()
+            .Where(c => c.ChannelID == request.ChannelID)
+            .OrderByDescending(c => c.CreatedAt)
+            .Select(c => new GoalConfigItem(
+                c.ID, c.Title, (int)c.Style,
+                c.StartAmount, c.TargetAmount,
+                c.StartAt, c.EndAt, c.IsShowPercent,
+                c.BarColor, c.BarBackgroundColor, c.BarHeightPx,
+                c.TitleFontSizePx, c.TitleFontColor,
+                c.AmountFontSizePx, c.AmountFontColor,
+                c.TitleFontFamily, c.AmountFontFamily,
+                c.IsActive
+            ))
+            .ToListAsync(ct);
+
+        return new Response(list);
+    }
+}

+ 28 - 0
Application/Features/Api/DonationGoal/GetConfig/Query.cs

@@ -0,0 +1,28 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationGoal.GetConfig;
+
+public sealed record Query(int ChannelID) : IQuery<Response>;
+
+public sealed record Response(IReadOnlyList<GoalConfigItem> List);
+
+public sealed record GoalConfigItem(
+    int ID,
+    string Title,
+    int Style,
+    int StartAmount,
+    int TargetAmount,
+    DateTime? StartAt,
+    DateTime? EndAt,
+    bool IsShowPercent,
+    string BarColor,
+    string BarBackgroundColor,
+    int BarHeightPx,
+    int TitleFontSizePx,
+    string TitleFontColor,
+    int AmountFontSizePx,
+    string AmountFontColor,
+    string? TitleFontFamily,
+    string? AmountFontFamily,
+    bool IsActive
+);

+ 29 - 0
Application/Features/Api/DonationGoal/GetProgress/Handler.cs

@@ -0,0 +1,29 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationGoal.GetProgress;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var goal = await db.DonationGoalConfig.AsNoTracking().FirstOrDefaultAsync(g => g.ID == request.GoalConfigID && g.ChannelID == request.ChannelID && g.IsActive, ct);
+        if (goal is null)
+        {
+            throw new KeyNotFoundException("목표 설정을 찾을 수 없습니다.");
+        }
+
+        var currentAmount = await db.Donation.AsNoTracking()
+            .Where(d => d.ChannelID == request.ChannelID
+                     && !d.IsTest
+                     && (goal.StartAt == null || d.CreatedAt >= goal.StartAt)
+                     && (goal.EndAt == null || d.CreatedAt <= goal.EndAt))
+            .SumAsync(d => d.Amount, ct);
+
+        var adjusted = currentAmount + goal.StartAmount;
+        var percent = goal.TargetAmount > 0 ? Math.Min((decimal)adjusted / goal.TargetAmount * 100, 100) : 0;
+
+        return new Response(goal.ID, goal.Title, goal.StartAmount, goal.TargetAmount, adjusted, Math.Round(percent, 1));
+    }
+}

+ 5 - 0
Application/Features/Api/DonationGoal/GetProgress/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationGoal.GetProgress;
+
+public sealed record Query(int ChannelID, int GoalConfigID) : IQuery<Response>;

+ 3 - 0
Application/Features/Api/DonationGoal/GetProgress/Response.cs

@@ -0,0 +1,3 @@
+namespace Application.Features.Api.DonationGoal.GetProgress;
+
+public sealed record Response(int GoalConfigID, string Title, int StartAmount, int TargetAmount, int CurrentAmount, decimal Percent);

+ 26 - 0
Application/Features/Api/DonationGoal/SaveConfig/Command.cs

@@ -0,0 +1,26 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationGoal.SaveConfig;
+
+public sealed record Command(
+    int ChannelID,
+    int MemberID,
+    int? ID,
+    string Title,
+    int Style,
+    int StartAmount,
+    int TargetAmount,
+    DateTime? StartAt,
+    DateTime? EndAt,
+    bool IsShowPercent,
+    string BarColor,
+    string BarBackgroundColor,
+    int BarHeightPx,
+    int TitleFontSizePx,
+    string TitleFontColor,
+    int AmountFontSizePx,
+    string AmountFontColor,
+    string? TitleFontFamily,
+    string? AmountFontFamily,
+    bool IsActive
+) : ICommand;

+ 65 - 0
Application/Features/Api/DonationGoal/SaveConfig/Handler.cs

@@ -0,0 +1,65 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations;
+using Domain.Entities.Donations.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationGoal.SaveConfig;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command r, CancellationToken ct)
+    {
+        // 기간 중복 검증 (활성 + 기간 설정된 경우)
+        if (r.IsActive && r.StartAt.HasValue && r.EndAt.HasValue)
+        {
+            var overlap = await db.DonationGoalConfig
+                .AnyAsync(c => c.ChannelID == r.ChannelID
+                    && c.IsActive
+                    && c.ID != (r.ID ?? 0)
+                    && c.StartAt != null && c.EndAt != null
+                    && c.StartAt < r.EndAt && c.EndAt > r.StartAt, ct
+                );
+
+            if (overlap)
+            {
+                throw new InvalidOperationException("해당 기간에 이미 활성화된 목표가 존재합니다.");
+            }
+        }
+
+        if (r.ID.HasValue)
+        {
+            var config = await db.DonationGoalConfig.FirstOrDefaultAsync(c => c.ID == r.ID.Value && c.ChannelID == r.ChannelID, ct);
+            if (config is null)
+            {
+                throw new KeyNotFoundException("설정을 찾을 수 없습니다.");
+            }
+
+            config.Update(
+                r.Title, (GoalWidgetStyle)r.Style, r.StartAmount, r.TargetAmount,
+                r.StartAt, r.EndAt, r.IsShowPercent,
+                r.BarColor, r.BarBackgroundColor, r.BarHeightPx,
+                r.TitleFontSizePx, r.TitleFontColor,
+                r.AmountFontSizePx, r.AmountFontColor,
+                r.TitleFontFamily, r.AmountFontFamily,
+                r.IsActive
+            );
+        }
+        else
+        {
+            var config = DonationGoalConfig.Create(r.ChannelID, r.MemberID, r.Title, r.TargetAmount);
+            config.Update(
+                r.Title, (GoalWidgetStyle)r.Style, r.StartAmount, r.TargetAmount,
+                r.StartAt, r.EndAt, r.IsShowPercent,
+                r.BarColor, r.BarBackgroundColor, r.BarHeightPx,
+                r.TitleFontSizePx, r.TitleFontColor,
+                r.AmountFontSizePx, r.AmountFontColor,
+                r.TitleFontFamily, r.AmountFontFamily,
+                r.IsActive
+            );
+            db.DonationGoalConfig.Add(config);
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 5 - 0
Application/Features/Api/DonationRank/DeleteConfig/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationRank.DeleteConfig;
+
+public sealed record Command(int ID, int ChannelID) : ICommand;

+ 22 - 0
Application/Features/Api/DonationRank/DeleteConfig/Handler.cs

@@ -0,0 +1,22 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationRank.DeleteConfig;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var config = await db.DonationRankConfig
+            .FirstOrDefaultAsync(c => c.ID == request.ID && c.ChannelID == request.ChannelID, ct);
+
+        if (config is null)
+        {
+            return;
+        }
+
+        db.DonationRankConfig.Remove(config);
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 30 - 0
Application/Features/Api/DonationRank/GetConfig/Handler.cs

@@ -0,0 +1,30 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationRank.GetConfig;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var list = await db.DonationRankConfig
+            .AsNoTracking()
+            .Where(c => c.ChannelID == request.ChannelID)
+            .OrderByDescending(c => c.CreatedAt)
+            .Select(c => new RankConfigItem(
+                c.ID, c.Title, (int)c.Theme, (int)c.Period,
+                c.StartAt, c.EndAt,
+                c.IsShowAmount, c.MaxRankCount, c.NameMode, c.IsActive,
+                c.TitleFontFamily, c.TitleFontSizePx, c.TitleFontColor,
+                (int)c.NameDisplayType,
+                c.IsShowDonationCount, c.IsShowGradeIcon, c.IsShowMemberIcon,
+                c.Rank1FontFamily, c.Rank1FontSizePx, c.Rank1FontColor,
+                c.Rank2FontFamily, c.Rank2FontSizePx, c.Rank2FontColor,
+                c.Rank3FontFamily, c.Rank3FontSizePx, c.Rank3FontColor
+            ))
+            .ToListAsync(ct);
+
+        return new Response(list);
+    }
+}

+ 36 - 0
Application/Features/Api/DonationRank/GetConfig/Query.cs

@@ -0,0 +1,36 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationRank.GetConfig;
+
+public sealed record Query(int ChannelID) : IQuery<Response>;
+
+public sealed record Response(IReadOnlyList<RankConfigItem> List);
+
+public sealed record RankConfigItem(
+    int ID,
+    string Title,
+    int Theme,
+    int Period,
+    DateTime? StartAt,
+    DateTime? EndAt,
+    bool IsShowAmount,
+    int MaxRankCount,
+    bool? NameMode,
+    bool IsActive,
+    string? TitleFontFamily,
+    int TitleFontSizePx,
+    string TitleFontColor,
+    int NameDisplayType,
+    bool IsShowDonationCount,
+    bool IsShowGradeIcon,
+    bool IsShowMemberIcon,
+    string? Rank1FontFamily,
+    int Rank1FontSizePx,
+    string Rank1FontColor,
+    string? Rank2FontFamily,
+    int Rank2FontSizePx,
+    string Rank2FontColor,
+    string? Rank3FontFamily,
+    int Rank3FontSizePx,
+    string Rank3FontColor
+);

+ 22 - 0
Application/Features/Api/DonationRank/GetRanking/Handler.cs

@@ -0,0 +1,22 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationRank.GetRanking;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var list = await db.DonationRanking.AsNoTracking()
+            .Where(r => r.ChannelID == request.ChannelID
+                     && r.PeriodType == request.PeriodType
+                     && r.IsActive
+                     && r.Rank <= request.MaxRank)
+            .OrderBy(r => r.Rank)
+            .Select(r => new RankItem(r.Rank, r.SponsorMemberID, r.SponsorName, r.TotalAmount, r.DonationCount))
+            .ToListAsync(ct);
+
+        return new Response(list);
+    }
+}

+ 6 - 0
Application/Features/Api/DonationRank/GetRanking/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations.ValueObject;
+
+namespace Application.Features.Api.DonationRank.GetRanking;
+
+public sealed record Query(int ChannelID, RankPeriodType PeriodType, int MaxRank = 5) : IQuery<Response>;

+ 5 - 0
Application/Features/Api/DonationRank/GetRanking/Response.cs

@@ -0,0 +1,5 @@
+namespace Application.Features.Api.DonationRank.GetRanking;
+
+public sealed record Response(IReadOnlyList<RankItem> List);
+
+public sealed record RankItem(int Rank, int SponsorMemberID, string SponsorName, int TotalAmount, int DonationCount);

+ 35 - 0
Application/Features/Api/DonationRank/SaveConfig/Command.cs

@@ -0,0 +1,35 @@
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations.ValueObject;
+
+namespace Application.Features.Api.DonationRank.SaveConfig;
+
+public sealed record Command(
+    int ChannelID,
+    int MemberID,
+    int? ID,
+    string Title,
+    RankThemeType Theme,
+    RankPeriodType Period,
+    DateTime? StartAt,
+    DateTime? EndAt,
+    bool IsShowAmount,
+    int MaxRankCount,
+    bool? NameMode,
+    bool IsActive,
+    string? TitleFontFamily,
+    int TitleFontSizePx,
+    string TitleFontColor,
+    RankNameDisplayType NameDisplayType,
+    bool IsShowDonationCount,
+    bool IsShowGradeIcon,
+    bool IsShowMemberIcon,
+    string? Rank1FontFamily,
+    int Rank1FontSizePx,
+    string Rank1FontColor,
+    string? Rank2FontFamily,
+    int Rank2FontSizePx,
+    string Rank2FontColor,
+    string? Rank3FontFamily,
+    int Rank3FontSizePx,
+    string Rank3FontColor
+) : ICommand;

+ 90 - 0
Application/Features/Api/DonationRank/SaveConfig/Handler.cs

@@ -0,0 +1,90 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationRank.SaveConfig;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command r, CancellationToken ct)
+    {
+        // 같은 기간 타입 동시 활성 검증
+        if (r.IsActive)
+        {
+            if (r.Period == Domain.Entities.Donations.ValueObject.RankPeriodType.Custom
+                && r.StartAt.HasValue && r.EndAt.HasValue)
+            {
+                // 사용자 지정: 기간 겹침 검증
+                var overlap = await db.DonationRankConfig
+                    .AnyAsync(c => c.ChannelID == r.ChannelID
+                        && c.IsActive
+                        && c.ID != (r.ID ?? 0)
+                        && c.Period == Domain.Entities.Donations.ValueObject.RankPeriodType.Custom
+                        && c.StartAt != null && c.EndAt != null
+                        && c.StartAt < r.EndAt && c.EndAt > r.StartAt, ct);
+
+                if (overlap)
+                {
+                    throw new InvalidOperationException("해당 기간에 이미 활성화된 순위 설정이 존재합니다.");
+                }
+            }
+            else
+            {
+                // 고정 기간(일간/주간/월간/연간/전체): 같은 타입 1개만
+                var duplicate = await db.DonationRankConfig
+                    .AnyAsync(c => c.ChannelID == r.ChannelID
+                        && c.IsActive
+                        && c.ID != (r.ID ?? 0)
+                        && c.Period == r.Period, ct);
+
+                if (duplicate)
+                {
+                    throw new InvalidOperationException("같은 기간 유형의 활성화된 순위 설정이 이미 존재합니다.");
+                }
+            }
+        }
+
+        if (r.ID.HasValue)
+        {
+            var config = await db.DonationRankConfig
+                .FirstOrDefaultAsync(c => c.ID == r.ID.Value && c.ChannelID == r.ChannelID, ct);
+
+            if (config is null)
+            {
+                throw new KeyNotFoundException("설정을 찾을 수 없습니다.");
+            }
+
+            config.Update(
+                r.Title, r.Theme, r.Period,
+                r.StartAt, r.EndAt,
+                r.IsShowAmount, r.MaxRankCount, r.NameMode, r.IsActive,
+                r.TitleFontFamily, r.TitleFontSizePx, r.TitleFontColor,
+                r.NameDisplayType,
+                r.IsShowDonationCount, r.IsShowGradeIcon, r.IsShowMemberIcon,
+                r.Rank1FontFamily, r.Rank1FontSizePx, r.Rank1FontColor,
+                r.Rank2FontFamily, r.Rank2FontSizePx, r.Rank2FontColor,
+                r.Rank3FontFamily, r.Rank3FontSizePx, r.Rank3FontColor
+            );
+        }
+        else
+        {
+            var config = DonationRankConfig.Create(
+                r.ChannelID, r.MemberID, r.Title,
+                r.Theme, r.Period,
+                r.StartAt, r.EndAt,
+                r.IsShowAmount, r.MaxRankCount, r.NameMode, r.IsActive,
+                r.TitleFontFamily, r.TitleFontSizePx, r.TitleFontColor,
+                r.NameDisplayType,
+                r.IsShowDonationCount, r.IsShowGradeIcon, r.IsShowMemberIcon,
+                r.Rank1FontFamily, r.Rank1FontSizePx, r.Rank1FontColor,
+                r.Rank2FontFamily, r.Rank2FontSizePx, r.Rank2FontColor,
+                r.Rank3FontFamily, r.Rank3FontSizePx, r.Rank3FontColor
+            );
+
+            db.DonationRankConfig.Add(config);
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 33 - 0
Application/Features/Api/DonationRemote/GetState/Handler.cs

@@ -0,0 +1,33 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationRemote.GetState;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var meta = await db.DonationMeta.AsNoTracking().FirstOrDefaultAsync(m => m.ChannelID == request.ChannelID, ct);
+
+        var queue = await db.DonationAlert.AsNoTracking()
+            .Where(a => a.Donation!.ChannelID == request.ChannelID
+                     && a.Status <= AlertStatus.Playing)
+            .OrderBy(a => a.CreatedAt)
+            .Select(a => new AlertQueueItem(
+                a.ID, a.DonationID, a.Status,
+                a.SponsorMemberID, a.Donation!.SendName, a.Donation.Amount,
+                a.Donation.Message, a.CreatedAt
+            ))
+            .ToListAsync(ct);
+
+        return new Response(
+            meta?.IsPaused ?? false,
+            meta?.IsAccepting ?? true,
+            meta?.IsAudioOnly ?? false,
+            meta?.IsVideoOnly ?? false,
+            queue
+        );
+    }
+}

+ 5 - 0
Application/Features/Api/DonationRemote/GetState/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationRemote.GetState;
+
+public sealed record Query(int ChannelID) : IQuery<Response>;

+ 14 - 0
Application/Features/Api/DonationRemote/GetState/Response.cs

@@ -0,0 +1,14 @@
+using Domain.Entities.Donations.ValueObject;
+
+namespace Application.Features.Api.DonationRemote.GetState;
+
+public sealed record Response(
+    bool IsPaused, bool IsAccepting, bool IsAudioOnly, bool IsVideoOnly,
+    IReadOnlyList<AlertQueueItem> Queue
+);
+
+public sealed record AlertQueueItem(
+    int AlertID, int DonationID, AlertStatus Status,
+    int SponsorMemberID, string SendName, int Amount,
+    string? Message, DateTime CreatedAt
+);

+ 5 - 0
Application/Features/Api/DonationRemote/IgnoreAlert/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationRemote.IgnoreAlert;
+
+public sealed record Command(int AlertID) : ICommand;

+ 25 - 0
Application/Features/Api/DonationRemote/IgnoreAlert/Handler.cs

@@ -0,0 +1,25 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations.ValueObject;
+
+namespace Application.Features.Api.DonationRemote.IgnoreAlert;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var alert = await db.DonationAlert.FindAsync([request.AlertID], ct);
+        if (alert is null)
+        {
+            throw new KeyNotFoundException("알림을 찾을 수 없습니다.");
+        }
+
+        if (alert.Status == AlertStatus.Playing)
+        {
+            throw new InvalidOperationException("재생 중인 알림은 무시할 수 없습니다.");
+        }
+
+        alert.MarkIgnored();
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 5 - 0
Application/Features/Api/DonationRemote/ResendAlert/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationRemote.ResendAlert;
+
+public sealed record Command(int AlertID) : ICommand;

+ 24 - 0
Application/Features/Api/DonationRemote/ResendAlert/Handler.cs

@@ -0,0 +1,24 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations;
+
+namespace Application.Features.Api.DonationRemote.ResendAlert;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var alert = await db.DonationAlert.FindAsync([request.AlertID], ct);
+        if (alert is null)
+        {
+            throw new KeyNotFoundException("알림을 찾을 수 없습니다.");
+        }
+
+        alert.Resend();
+
+        var attempt = DonationAlertAttempt.Create(alert.DonationID, alert.ID);
+
+        await db.DonationAlertAttempt.AddAsync(attempt, ct);
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 5 - 0
Application/Features/Api/DonationRemote/SkipCurrent/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationRemote.SkipCurrent;
+
+public sealed record Command(int ChannelID) : ICommand;

+ 21 - 0
Application/Features/Api/DonationRemote/SkipCurrent/Handler.cs

@@ -0,0 +1,21 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationRemote.SkipCurrent;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var playing = await db.DonationAlert.Where(a => a.Donation!.ChannelID == request.ChannelID && a.Status == AlertStatus.Playing).FirstOrDefaultAsync(ct);
+        if (playing is null)
+        {
+            throw new KeyNotFoundException("재생 중인 알림이 없습니다.");
+        }
+
+        playing.MarkSkipped();
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 7 - 0
Application/Features/Api/DonationRemote/UpdateState/Command.cs

@@ -0,0 +1,7 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.DonationRemote.UpdateState;
+
+public sealed record Command(
+    int ChannelID, int MemberID, bool IsPaused, bool IsAccepting, bool IsAudioOnly, bool IsVideoOnly
+) : ICommand;

+ 32 - 0
Application/Features/Api/DonationRemote/UpdateState/Handler.cs

@@ -0,0 +1,32 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Donations;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.DonationRemote.UpdateState;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command r, CancellationToken ct)
+    {
+        var meta = await db.DonationMeta.FirstOrDefaultAsync(m => m.ChannelID == r.ChannelID, ct);
+
+        if (meta is null)
+        {
+            meta = DonationMeta.Create(r.ChannelID, r.MemberID);
+
+            await db.DonationMeta.AddAsync(meta, ct);
+            await db.SaveChangesAsync(ct);
+        }
+
+        meta.UpdateRemoteState(r.IsPaused, r.IsAudioOnly, r.IsVideoOnly);
+
+        if (r.IsAccepting) { 
+            meta.StartAccepting(); 
+        } else {
+            meta.StopAccepting(); 
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 48 - 0
Application/Features/Api/MyPage/ChargeLogs/Handler.cs

@@ -0,0 +1,48 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Common;
+using Domain.Entities.Payments.ValueObject;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.ChargeLogs;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Response>>
+{
+    public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
+    {
+        var page = request.Page < 1 ? 1 : request.Page;
+        var perPage = request.PerPage is < 1 or > 50 ? 20 : request.PerPage;
+
+        var now = DateTime.UtcNow;
+        var startDate = request.Type switch
+        {
+            SearchDateType.Week => now.AddDays(-7),
+            SearchDateType.Month => now.AddMonths(-1),
+            SearchDateType.QuarterYear => now.AddMonths(-3),
+            SearchDateType.HalfYear => now.AddMonths(-6),
+            _ => now.Date
+        };
+
+        var query = db.PaymentOrder.AsNoTracking().Where(o => o.MemberID == request.MemberID && o.Status == PaymentStatus.Paid && o.PaidAt >= startDate).OrderByDescending(o => o.PaidAt);
+        var total = await query.CountAsync(ct);
+
+        var list = await query
+            .Skip((page - 1) * perPage)
+            .Take(perPage)
+            .Select(o => new ChargeLogItem(
+                o.ID,
+                o.OrderID,
+                o.Amount,
+                o.PointAmount,
+                o.VatAmount,
+                o.PaymentMethod.ToString(),
+                o.Status.ToString(),
+                o.CreatedAt,
+                o.PaidAt
+            ))
+            .ToListAsync(ct);
+
+        return Result.Success(new Response(list, total, page, perPage));
+    }
+}

+ 11 - 0
Application/Features/Api/MyPage/ChargeLogs/Query.cs

@@ -0,0 +1,11 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.ChargeLogs;
+
+public sealed record Query(
+    int MemberID,
+    string Type,
+    int Page,
+    int PerPage
+) : IQuery<Result<Response>>;

+ 20 - 0
Application/Features/Api/MyPage/ChargeLogs/Response.cs

@@ -0,0 +1,20 @@
+namespace Application.Features.Api.MyPage.ChargeLogs;
+
+public sealed record Response(
+    List<ChargeLogItem> List,
+    int Total,
+    int Page,
+    int PerPage
+);
+
+public sealed record ChargeLogItem(
+    int ID,
+    string OrderID,
+    int Amount,
+    int PointAmount,
+    int VatAmount,
+    string PaymentMethod,
+    string Status,
+    DateTime CreatedAt,
+    DateTime? PaidAt
+);

+ 69 - 0
Application/Features/Api/MyPage/Dropdown/Handler.cs

@@ -0,0 +1,69 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Wallets.Policy;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.Dropdown;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Response>>
+{
+    public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
+    {
+        var member = await db.Member.AsNoTracking()
+            .Where(m => m.ID == request.MemberID)
+            .Select(m => new { m.SID, m.Name, m.Thumb, m.IsCreator })
+            .FirstOrDefaultAsync(ct);
+
+        if (member is null)
+        {
+            return Result.Failure<Response>(Error.NotFound("Member.NotFound", "회원을 찾을 수 없습니다."));
+        }
+
+        // 후원 가능 잔액 (SpendPolicy 순서의 잔액 합산)
+        var spendableBalance = 0m;
+        var wallet = await db.Wallet.AsNoTracking()
+            .Include(w => w.Balances)
+            .FirstOrDefaultAsync(w => w.MemberID == request.MemberID, ct);
+
+        if (wallet is not null)
+        {
+            foreach (var balanceType in SpendPolicy.DefaultSpendOrder)
+            {
+                var balance = wallet.Balances.FirstOrDefault(b => b.Type == balanceType);
+                if (balance is not null)
+                {
+                    spendableBalance += balance.Amount.Value;
+                }
+            }
+        }
+
+        // 채널 조회
+        int? channelID = null;
+        decimal? withdrawableBalance = null;
+        var channel = await db.Channel.AsNoTracking()
+            .FirstOrDefaultAsync(c => c.MemberID == request.MemberID && c.IsActive, ct);
+
+        if (channel is not null)
+        {
+            channelID = channel.ID;
+
+            if (wallet is not null)
+            {
+                var donationBalance = wallet.Balances
+                    .FirstOrDefault(b => b.Type == Domain.Entities.Wallets.ValueObject.WalletBalanceType.Donation);
+                withdrawableBalance = donationBalance?.Amount.Value ?? 0;
+            }
+        }
+
+        return new Response(
+            member.SID,
+            member.Name,
+            member.Thumb,
+            member.IsCreator,
+            channelID,
+            (int)spendableBalance,
+            withdrawableBalance.HasValue ? (int)withdrawableBalance.Value : null
+        );
+    }
+}

+ 6 - 0
Application/Features/Api/MyPage/Dropdown/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.Dropdown;
+
+public sealed record Query(int MemberID) : IQuery<Result<Response>>;

+ 11 - 0
Application/Features/Api/MyPage/Dropdown/Response.cs

@@ -0,0 +1,11 @@
+namespace Application.Features.Api.MyPage.Dropdown;
+
+public sealed record Response(
+    string SID,
+    string? Name,
+    string? Thumb,
+    bool IsCreator,
+    int? ChannelID,
+    int SpendableBalance,
+    int? WithdrawableBalance
+);

+ 36 - 0
Application/Features/Api/Note/GetInbox/Handler.cs

@@ -0,0 +1,36 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Note.GetInbox;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.Note.AsNoTracking()
+            .Where(n => n.ReceiverMemberID == request.MemberID && !n.IsDeletedByReceiver);
+
+        if (request.IsRead.HasValue)
+        {
+            query = query.Where(n => n.IsRead == request.IsRead.Value);
+        }
+
+        var total = await query.CountAsync(ct);
+        var unread = await db.Note.AsNoTracking()
+            .CountAsync(n => n.ReceiverMemberID == request.MemberID && !n.IsDeletedByReceiver && !n.IsRead, ct);
+
+        var list = await query
+            .OrderByDescending(n => n.CreatedAt)
+            .Skip((request.PageNum - 1) * request.PerPage)
+            .Take(request.PerPage)
+            .Select(n => new NoteItem(
+                n.ID, n.SenderMemberID,
+                n.SenderMemberID == 0 ? "시스템" : (n.Sender != null ? n.Sender.Name : null),
+                n.Title, n.IsRead, n.IsSystem, n.CreatedAt
+            ))
+            .ToListAsync(ct);
+
+        return new Response(total, unread, list);
+    }
+}

+ 5 - 0
Application/Features/Api/Note/GetInbox/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Note.GetInbox;
+
+public sealed record Query(int MemberID, bool? IsRead = null, int PageNum = 1, ushort PerPage = 20) : IQuery<Response>;

+ 8 - 0
Application/Features/Api/Note/GetInbox/Response.cs

@@ -0,0 +1,8 @@
+namespace Application.Features.Api.Note.GetInbox;
+
+public sealed record Response(int Total, int UnreadCount, IReadOnlyList<NoteItem> List);
+
+public sealed record NoteItem(
+    int ID, int SenderMemberID, string? SenderName, string Title,
+    bool IsRead, bool IsSystem, DateTime CreatedAt
+);

+ 13 - 0
Application/Features/Api/Note/SendNote/Command.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Note.SendNote;
+
+public sealed record Command(
+    int ReceiverMemberID,
+    string Title,
+    string Content,
+    bool IsSystem = false,
+    string? RelatedType = null,
+    int? RelatedID = null,
+    int SenderMemberID = 0
+) : ICommand<Response>;

+ 24 - 0
Application/Features/Api/Note/SendNote/Handler.cs

@@ -0,0 +1,24 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Note.SendNote;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
+{
+    public async Task<Response> Handle(Command r, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(r.Title) || string.IsNullOrWhiteSpace(r.Content))
+        {
+            throw new InvalidOperationException("제목과 내용을 입력해주세요.");
+        }
+
+        var note = r.IsSystem
+            ? Domain.Entities.Notes.Note.CreateSystem(r.ReceiverMemberID, r.Title, r.Content, r.RelatedType, r.RelatedID)
+            : Domain.Entities.Notes.Note.Create(r.SenderMemberID, r.ReceiverMemberID, r.Title, r.Content, false, r.RelatedType, r.RelatedID);
+
+        db.Note.Add(note);
+        await db.SaveChangesAsync(ct);
+
+        return new Response(note.ID);
+    }
+}

+ 3 - 0
Application/Features/Api/Note/SendNote/Response.cs

@@ -0,0 +1,3 @@
+namespace Application.Features.Api.Note.SendNote;
+
+public sealed record Response(int NoteID);

Деякі файли не було показано, через те що забагато файлів було змінено