瀏覽代碼

Phase B: remove donation/settlement/developer domains, deactivate channel/youtube

- Delete donation+settlement+crew (349 files) and developer API+StoreRevenue (224 files)
- Deactivate Channel/YouTube behind Features:Channel flag (12 endpoints gated, 7 hosted services off)
- Migration RemoveDonationSettlementDeveloperDomains: drop 33 tables + Game.ApiCommissionRate
- Build 0 errors, Application.Tests 15/15

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 3 周之前
父節點
當前提交
ec6a9e8549
共有 100 個文件被更改,包括 11 次插入4029 次删除
  1. 3 32
      Admin/Pages/Channel/List/Edit.cshtml
  2. 2 9
      Admin/Pages/Channel/List/Edit.cshtml.cs
  3. 3 28
      Admin/Pages/Channel/List/Write.cshtml
  4. 3 9
      Admin/Pages/Channel/List/Write.cshtml.cs
  5. 0 109
      Admin/Pages/Developers/Accounts/Detail.cshtml
  6. 0 83
      Admin/Pages/Developers/Accounts/Detail.cshtml.cs
  7. 0 117
      Admin/Pages/Developers/Accounts/Index.cshtml
  8. 0 50
      Admin/Pages/Developers/Accounts/Index.cshtml.cs
  9. 0 90
      Admin/Pages/Developers/Apps/Detail.cshtml
  10. 0 95
      Admin/Pages/Developers/Apps/Detail.cshtml.cs
  11. 0 97
      Admin/Pages/Developers/Apps/Index.cshtml
  12. 0 78
      Admin/Pages/Developers/Apps/Index.cshtml.cs
  13. 0 174
      Admin/Pages/Developers/Purchases/Index.cshtml
  14. 0 61
      Admin/Pages/Developers/Purchases/Index.cshtml.cs
  15. 0 233
      Admin/Pages/Donation/Alert/Index.cshtml
  16. 0 111
      Admin/Pages/Donation/Alert/Index.cshtml.cs
  17. 0 240
      Admin/Pages/Donation/List/Index.cshtml
  18. 0 99
      Admin/Pages/Donation/List/Index.cshtml.cs
  19. 0 207
      Admin/Pages/Donation/Rank/Index.cshtml
  20. 0 76
      Admin/Pages/Donation/Rank/Index.cshtml.cs
  21. 0 109
      Admin/Pages/Store/Game/Catalog.cshtml
  22. 0 102
      Admin/Pages/Store/Game/Catalog.cshtml.cs
  23. 0 19
      Admin/Pages/Store/Game/Edit.cshtml
  24. 0 6
      Admin/Pages/Store/Game/Edit.cshtml.cs
  25. 0 16
      Admin/Pages/Store/Game/Write.cshtml
  26. 0 5
      Admin/Pages/Store/Game/Write.cshtml.cs
  27. 0 14
      Admin/using.cs
  28. 0 41
      Application/Abstractions/Data/IAppDbContext.cs
  29. 0 14
      Application/Abstractions/Hub/IAppHubClient.cs
  30. 0 14
      Application/Abstractions/Hub/ICrewHubBroadcaster.cs
  31. 0 20
      Application/Abstractions/Hub/IDonationHubClient.cs
  32. 0 41
      Application/Abstractions/Kyc/INiceAuthClient.cs
  33. 0 38
      Application/Abstractions/Kyc/NiceAuthModels.cs
  34. 0 14
      Application/Abstractions/Notification/IAdminAlertService.cs
  35. 0 11
      Application/Abstractions/RateLimit/IRateLimiter.cs
  36. 0 21
      Application/Features/Admin/Channel/List/Delete/Handler.cs
  37. 0 47
      Application/Features/Admin/Channel/List/GenerateDonationCode/Handler.cs
  38. 0 6
      Application/Features/Admin/Channel/List/GenerateDonationCode/Query.cs
  39. 0 6
      Application/Features/Admin/Developers/Accounts/ApproveDeveloper/Command.cs
  40. 0 35
      Application/Features/Admin/Developers/Accounts/ApproveDeveloper/Handler.cs
  41. 0 3
      Application/Features/Admin/Developers/Accounts/ApproveDeveloper/Response.cs
  42. 0 61
      Application/Features/Admin/Developers/Accounts/ListDevelopers/Handler.cs
  43. 0 12
      Application/Features/Admin/Developers/Accounts/ListDevelopers/Query.cs
  44. 0 23
      Application/Features/Admin/Developers/Accounts/ListDevelopers/Response.cs
  45. 0 6
      Application/Features/Admin/Developers/Accounts/RejectDeveloper/Command.cs
  46. 0 25
      Application/Features/Admin/Developers/Accounts/RejectDeveloper/Handler.cs
  47. 0 3
      Application/Features/Admin/Developers/Accounts/RejectDeveloper/Response.cs
  48. 0 6
      Application/Features/Admin/Developers/Accounts/SuspendDeveloper/Command.cs
  49. 0 25
      Application/Features/Admin/Developers/Accounts/SuspendDeveloper/Handler.cs
  50. 0 3
      Application/Features/Admin/Developers/Accounts/SuspendDeveloper/Response.cs
  51. 0 5
      Application/Features/Admin/Developers/ApproveApp/Command.cs
  52. 0 24
      Application/Features/Admin/Developers/ApproveApp/Handler.cs
  53. 0 3
      Application/Features/Admin/Developers/ApproveApp/Response.cs
  54. 0 37
      Application/Features/Admin/Developers/GlobalStats/Handler.cs
  55. 0 5
      Application/Features/Admin/Developers/GlobalStats/Query.cs
  56. 0 12
      Application/Features/Admin/Developers/GlobalStats/Response.cs
  57. 0 48
      Application/Features/Admin/Developers/ListApps/Handler.cs
  58. 0 11
      Application/Features/Admin/Developers/ListApps/Query.cs
  59. 0 20
      Application/Features/Admin/Developers/ListApps/Response.cs
  60. 0 6
      Application/Features/Admin/Developers/OverrideRateLimit/Command.cs
  61. 0 40
      Application/Features/Admin/Developers/OverrideRateLimit/Handler.cs
  62. 0 3
      Application/Features/Admin/Developers/OverrideRateLimit/Response.cs
  63. 0 106
      Application/Features/Admin/Developers/Purchases/Search/Handler.cs
  64. 0 19
      Application/Features/Admin/Developers/Purchases/Search/Query.cs
  65. 0 41
      Application/Features/Admin/Developers/Purchases/Search/Response.cs
  66. 0 5
      Application/Features/Admin/Developers/SuspendApp/Command.cs
  67. 0 24
      Application/Features/Admin/Developers/SuspendApp/Handler.cs
  68. 0 3
      Application/Features/Admin/Developers/SuspendApp/Response.cs
  69. 0 5
      Application/Features/Admin/Donation/Alert/Delete/Command.cs
  70. 0 20
      Application/Features/Admin/Donation/Alert/Delete/Handler.cs
  71. 0 5
      Application/Features/Admin/Donation/Alert/Resend/Command.cs
  72. 0 33
      Application/Features/Admin/Donation/Alert/Resend/Handler.cs
  73. 0 113
      Application/Features/Admin/Donation/Alert/Search/Handler.cs
  74. 0 13
      Application/Features/Admin/Donation/Alert/Search/Query.cs
  75. 0 32
      Application/Features/Admin/Donation/Alert/Search/Response.cs
  76. 0 5
      Application/Features/Admin/Donation/List/Delete/Command.cs
  77. 0 20
      Application/Features/Admin/Donation/List/Delete/Handler.cs
  78. 0 92
      Application/Features/Admin/Donation/List/Export/Handler.cs
  79. 0 10
      Application/Features/Admin/Donation/List/Export/Query.cs
  80. 0 6
      Application/Features/Admin/Donation/List/Export/Response.cs
  81. 0 119
      Application/Features/Admin/Donation/List/Search/Handler.cs
  82. 0 12
      Application/Features/Admin/Donation/List/Search/Query.cs
  83. 0 31
      Application/Features/Admin/Donation/List/Search/Response.cs
  84. 0 144
      Application/Features/Admin/Donation/Rank/Search/Handler.cs
  85. 0 19
      Application/Features/Admin/Donation/Rank/Search/Query.cs
  86. 0 23
      Application/Features/Admin/Donation/Rank/Search/Response.cs
  87. 0 1
      Application/Features/Admin/Store/Game/Create/Command.cs
  88. 0 6
      Application/Features/Admin/Store/Game/Create/Handler.cs
  89. 0 2
      Application/Features/Admin/Store/Game/Get/Handler.cs
  90. 0 1
      Application/Features/Admin/Store/Game/Get/Response.cs
  91. 0 1
      Application/Features/Admin/Store/Game/Update/Command.cs
  92. 0 6
      Application/Features/Admin/Store/Game/Update/Handler.cs
  93. 0 10
      Application/Features/Admin/Store/GameCatalog/Create/Command.cs
  94. 0 40
      Application/Features/Admin/Store/GameCatalog/Create/Handler.cs
  95. 0 6
      Application/Features/Admin/Store/GameCatalog/Delete/Command.cs
  96. 0 24
      Application/Features/Admin/Store/GameCatalog/Delete/Handler.cs
  97. 0 38
      Application/Features/Admin/Store/GameCatalog/GetByGame/Handler.cs
  98. 0 7
      Application/Features/Admin/Store/GameCatalog/GetByGame/Query.cs
  99. 0 19
      Application/Features/Admin/Store/GameCatalog/GetByGame/Response.cs
  100. 0 10
      Application/Features/Admin/Store/GameCatalog/Update/Command.cs

+ 3 - 32
Admin/Pages/Channel/List/Edit.cshtml

@@ -61,14 +61,11 @@
                 <div class="row">
                     <div class="col col-md-auto">
                         <div class="input-group">
-                            <input type="text" asp-for="Input.DonationCode" id="channelDonationCode" class="form-control" maxlength="7" placeholder="자동 생성" autocomplete="off" />
-                            <button type="button" id="btnRegenerateDonationCode" class="btn btn-outline-secondary" title="새 코드 재생성">
-                                <i class="fa-solid fa-rotate"></i> 재생성
-                            </button>
+                            <input type="text" asp-for="Input.DonationCode" id="channelDonationCode" class="form-control" maxlength="7" autocomplete="off" />
                         </div>
                     </div>
                 </div>
-                <small class="form-text text-muted">자동 생성된 6자 영숫자 코드 (UNIQUE, 대문자). 직접 4~7자 영문/숫자로 입력하거나 재생성 버튼으로 갱신할 수 있습니다. 비워두면 후원 코드를 해제합니다.</small>
+                <small class="form-text text-muted">4~7자 영문/숫자 (UNIQUE, 대문자). 비워두면 후원 코드를 해제합니다.</small>
                 <span asp-validation-for="Input.DonationCode" class="text-danger"></span>
             </div>
         </div>
@@ -98,7 +95,7 @@
                         </div>
                     </div>
                 </div>
-                <small class="form-text text-muted">상점 후원 시 채널주에게 보상으로 지급하는 비율 (StoreRevenue 잔액에 입금)</small>
+                <small class="form-text text-muted">[비활성] 채널 보상 적립 제거됨(2026-07-03) — 과거 설정값 표시용</small>
                 <span asp-validation-for="Input.StoreCommissionRate" class="text-danger"></span>
             </div>
         </div>
@@ -154,29 +151,3 @@
     </form>
 </div>
 
-@section Scripts {
-<script>
-    // 후원 코드 재생성
-    document.getElementById('btnRegenerateDonationCode').addEventListener('click', async function () {
-        const btn = this;
-        btn.disabled = true;
-        try {
-            const res = await fetch('?handler=GenerateDonationCode', {
-                method: 'GET',
-                headers: { 'Accept': 'application/json' }
-            });
-            if (!res.ok) {
-                throw new Error('코드 생성 실패');
-            }
-            const data = await res.json();
-            if (data && data.code) {
-                document.getElementById('channelDonationCode').value = data.code;
-            }
-        } catch (e) {
-            alert('후원 코드 재생성 중 오류가 발생했습니다.');
-        } finally {
-            btn.disabled = false;
-        }
-    });
-</script>
-}

+ 2 - 9
Admin/Pages/Channel/List/Edit.cshtml.cs

@@ -76,9 +76,8 @@ public class EditModel(IMediator mediator) : PageModel
 
         MemberInfo = $"{result.MemberEmail}, {result.MemberName ?? result.MemberSID ?? "-"}";
 
-        // 기존 코드가 없으면 자동 추천값 생성 (저장 안 하면 NULL 유지)
-        var donationCode = result.DonationCode
-            ?? await mediator.Send(new GenerateChannelDonationCode.Query(), ct);
+        // 후원 코드 자동 생성 기능 제거됨(2026-07-03) — 기존 코드 표시/수동 입력만 지원
+        var donationCode = result.DonationCode;
 
         Input = new InputModel
         {
@@ -99,12 +98,6 @@ public class EditModel(IMediator mediator) : PageModel
         QueryString = Request.QueryString.ToString();
     }
 
-    public async Task<IActionResult> OnGetGenerateDonationCodeAsync(CancellationToken ct)
-    {
-        var code = await mediator.Send(new GenerateChannelDonationCode.Query(), ct);
-        return new JsonResult(new { code });
-    }
-
     public async Task<IActionResult> OnPostAsync(CancellationToken ct)
     {
         try

+ 3 - 28
Admin/Pages/Channel/List/Write.cshtml

@@ -74,14 +74,11 @@
                 <div class="row">
                     <div class="col col-md-auto">
                         <div class="input-group">
-                            <input type="text" asp-for="Input.DonationCode" id="channelDonationCode" class="form-control" maxlength="7" placeholder="자동 생성" autocomplete="off" />
-                            <button type="button" id="btnRegenerateDonationCode" class="btn btn-outline-secondary" title="새 코드 재생성">
-                                <i class="fa-solid fa-rotate"></i> 재생성
-                            </button>
+                            <input type="text" asp-for="Input.DonationCode" id="channelDonationCode" class="form-control" maxlength="7" autocomplete="off" />
                         </div>
                     </div>
                 </div>
-                <small class="form-text text-muted">자동 생성된 6자 영숫자 코드 (UNIQUE, 대문자). 직접 4~7자 영문/숫자로 입력하거나 재생성 버튼으로 갱신할 수 있습니다. 비워두면 후원 코드를 사용하지 않습니다.</small>
+                <small class="form-text text-muted">4~7자 영문/숫자 (UNIQUE, 대문자). 비워두면 후원 코드를 사용하지 않습니다.</small>
                 <span asp-validation-for="Input.DonationCode" class="text-danger"></span>
             </div>
         </div>
@@ -111,7 +108,7 @@
                         </div>
                     </div>
                 </div>
-                <small class="form-text text-muted">상점 후원 시 채널주에게 보상으로 지급하는 비율 (StoreRevenue 잔액에 입금)</small>
+                <small class="form-text text-muted">[비활성] 채널 보상 적립 제거됨(2026-07-03) — 과거 설정값 표시용</small>
                 <span asp-validation-for="Input.StoreCommissionRate" class="text-danger"></span>
             </div>
         </div>
@@ -227,28 +224,6 @@
             }
         });
 
-        // 후원 코드 재생성
-        $('#btnRegenerateDonationCode').on('click', async function () {
-            const btn = this;
-            btn.disabled = true;
-            try {
-                const res = await fetch('?handler=GenerateDonationCode', {
-                    method: 'GET',
-                    headers: { 'Accept': 'application/json' }
-                });
-                if (!res.ok) {
-                    throw new Error('코드 생성 실패');
-                }
-                const data = await res.json();
-                if (data && data.code) {
-                    document.getElementById('channelDonationCode').value = data.code;
-                }
-            } catch (e) {
-                alert('후원 코드 재생성 중 오류가 발생했습니다.');
-            } finally {
-                btn.disabled = false;
-            }
-        });
     });
 </script>
 }

+ 3 - 9
Admin/Pages/Channel/List/Write.cshtml.cs

@@ -60,18 +60,12 @@ public class WriteModel(IMediator mediator, IAppDbContext db) : PageModel
         public string? DonationCode { get; set; }
     }
 
-    public async Task OnGetAsync(CancellationToken ct)
+    public Task OnGetAsync(CancellationToken ct)
     {
         QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
 
-        // 후원 코드 자동 생성 (사용자가 그대로 사용하거나 재생성 버튼으로 갱신)
-        Input.DonationCode = await mediator.Send(new GenerateChannelDonationCode.Query(), ct);
-    }
-
-    public async Task<IActionResult> OnGetGenerateDonationCodeAsync(CancellationToken ct)
-    {
-        var code = await mediator.Send(new GenerateChannelDonationCode.Query(), ct);
-        return new JsonResult(new { code });
+        // 후원 코드 자동 생성 기능 제거됨(2026-07-03) — 수동 입력만 지원
+        return Task.CompletedTask;
     }
 
     public async Task<IActionResult> OnGetSearchMemberAsync(string keyword, CancellationToken ct)

+ 0 - 109
Admin/Pages/Developers/Accounts/Detail.cshtml

@@ -1,109 +0,0 @@
-@page "{id:int}"
-@using Domain.Entities.Developers.ValueObject
-@model Admin.Pages.Developers.Accounts.DetailModel
-@{
-    ViewData["Title"] = "개발자 계정 상세";
-}
-
-<div class="container">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    @if (Model.Dev == null)
-    {
-        <div class="alert alert-warning">계정을 찾을 수 없습니다.</div>
-        return;
-    }
-
-    <div class="row">
-        <div class="col-12 col-md-8">
-            <table class="table table-bordered">
-                <tr><th style="width:160px">ID</th><td class="text-start">@Model.Dev.ID</td></tr>
-                <tr><th>회사명</th><td class="text-start">@Model.Dev.CompanyName</td></tr>
-                <tr><th>사업자번호</th><td class="text-start"><code>@Model.Dev.BusinessNumber</code></td></tr>
-                <tr><th>대표자</th><td class="text-start">@Model.Dev.CeoName</td></tr>
-                <tr><th>담당자</th><td class="text-start">@Model.Dev.ContactName (@Model.Dev.ContactPhone)</td></tr>
-                <tr><th>이메일</th><td class="text-start">@Model.MemberEmail</td></tr>
-                <tr><th>회원 이름</th><td class="text-start">@(Model.MemberName ?? "-")</td></tr>
-                <tr>
-                    <th>본인인증</th>
-                    <td class="text-start">
-                        @if (Model.Dev.KycStatus == KycStatus.None)
-                        {
-                            <span class="badge bg-secondary">미진행</span>
-                        }
-                        else if (Model.Dev.KycStatus == KycStatus.Pending)
-                        {
-                            <span class="badge bg-warning text-dark">대기중</span>
-                        }
-                        else if (Model.Dev.KycStatus == KycStatus.Verified)
-                        {
-                            <span class="badge bg-success">완료</span>
-                        }
-                        else
-                        {
-                            <span class="badge bg-danger">거절</span>
-                        }
-                        @if (Model.Dev.KycVerifiedAt.HasValue)
-                        {
-                            <span class="text-muted ms-2">@Model.Dev.KycVerifiedAt.Value.ToString("yyyy-MM-dd HH:mm")</span>
-                        }
-                    </td>
-                </tr>
-                <tr>
-                    <th>승인 상태</th>
-                    <td class="text-start">
-                        @if (Model.Dev.ApprovalStatus == DeveloperApprovalStatus.Approved)
-                        {
-                            <span class="badge bg-success">승인됨</span>
-                        }
-                        else if (Model.Dev.ApprovalStatus == DeveloperApprovalStatus.Pending)
-                        {
-                            <span class="badge bg-warning text-dark">대기중</span>
-                        }
-                        else
-                        {
-                            <span class="badge bg-danger">거절됨</span>
-                        }
-                    </td>
-                </tr>
-                @if (!string.IsNullOrEmpty(Model.Dev.ApprovalReason))
-                {
-                    <tr><th>승인 처리 사유</th><td class="text-start">@Model.Dev.ApprovalReason</td></tr>
-                }
-                @if (Model.Dev.ApprovedAt.HasValue)
-                {
-                    <tr><th>승인일</th><td class="text-start">@Model.Dev.ApprovedAt.Value.ToString("yyyy-MM-dd HH:mm")</td></tr>
-                }
-                <tr><th>가입일</th><td class="text-start">@Model.Dev.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td></tr>
-            </table>
-        </div>
-        <div class="col-12 col-md-4">
-            <form method="post" class="mb-3 ">
-                <h5>승인 / 거절 / 정지</h5>
-                <textarea name="Reason" class="form-control mb-2" rows="3" placeholder="거절/정지 사유 (선택)">@Model.Reason</textarea>
-
-                @if (Model.Dev.ApprovalStatus != DeveloperApprovalStatus.Approved)
-                {
-                    <button type="submit" asp-page-handler="Approve" class="btn btn-success w-100 mb-2">승인</button>
-                }
-                @if (Model.Dev.ApprovalStatus != DeveloperApprovalStatus.Rejected)
-                {
-                    <button type="submit" asp-page-handler="Reject" class="btn btn-danger w-100 mb-2">거절</button>
-                }
-                @if (Model.Dev.ApprovalStatus == DeveloperApprovalStatus.Approved)
-                {
-                    <button type="submit" asp-page-handler="Suspend" class="btn btn-warning w-100">정지</button>
-                }
-            </form>
-        </div>
-    </div>
-
-    <hr/>
-
-    <div class="text-center">
-        <a asp-page="/Developers/Accounts/Index" class="btn btn-secondary">목록으로</a>
-    </div>
-</div>

+ 0 - 83
Admin/Pages/Developers/Accounts/Detail.cshtml.cs

@@ -1,83 +0,0 @@
-using System.Security.Claims;
-using Application.Abstractions.Data;
-using Domain.Entities.Developers;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using Microsoft.EntityFrameworkCore;
-
-namespace Admin.Pages.Developers.Accounts;
-
-public class DetailModel(IMediator mediator, IAppDbContext db) : PageModel
-{
-    [BindProperty(SupportsGet = true)]
-    public int ID { get; set; }
-
-    public Developer? Dev { get; set; }
-    public string? MemberEmail { get; set; }
-    public string? MemberName { get; set; }
-
-    [BindProperty]
-    public string? Reason { get; set; }
-
-    public async Task<IActionResult> OnGetAsync(CancellationToken ct)
-    {
-        Dev = await db.Developer
-            .Include(c => c.Member)
-            .FirstOrDefaultAsync(c => c.ID == ID, ct);
-
-        if (Dev is null)
-        {
-            return NotFound();
-        }
-
-        MemberEmail = Dev.Member?.Email;
-        MemberName = Dev.Member?.Name;
-        return Page();
-    }
-
-    private string CurrentAdminID() =>
-        User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
-
-    public async Task<IActionResult> OnPostApproveAsync(CancellationToken ct)
-    {
-        var result = await mediator.Send(new Application.Features.Admin.Developers.Accounts.ApproveDeveloper.Command(ID, CurrentAdminID()), ct);
-        if (result.IsSuccess)
-        {
-            TempData["SuccessMessage"] = "개발자 계정을 승인했습니다.";
-        }
-        else
-        {
-            TempData["ErrorMessages"] = result.Error.Description;
-        }
-        return RedirectToPage("/Developers/Accounts/Detail", new { ID });
-    }
-
-    public async Task<IActionResult> OnPostRejectAsync(CancellationToken ct)
-    {
-        var result = await mediator.Send(new Application.Features.Admin.Developers.Accounts.RejectDeveloper.Command(ID, CurrentAdminID(), Reason), ct);
-        if (result.IsSuccess)
-        {
-            TempData["SuccessMessage"] = "개발자 계정을 거절 처리했습니다.";
-        }
-        else
-        {
-            TempData["ErrorMessages"] = result.Error.Description;
-        }
-        return RedirectToPage("/Developers/Accounts/Detail", new { ID });
-    }
-
-    public async Task<IActionResult> OnPostSuspendAsync(CancellationToken ct)
-    {
-        var result = await mediator.Send(new Application.Features.Admin.Developers.Accounts.SuspendDeveloper.Command(ID, CurrentAdminID(), Reason), ct);
-        if (result.IsSuccess)
-        {
-            TempData["SuccessMessage"] = "개발자 계정을 정지 처리했습니다.";
-        }
-        else
-        {
-            TempData["ErrorMessages"] = result.Error.Description;
-        }
-        return RedirectToPage("/Developers/Accounts/Detail", new { ID });
-    }
-}

+ 0 - 117
Admin/Pages/Developers/Accounts/Index.cshtml

@@ -1,117 +0,0 @@
-@page
-@using Domain.Entities.Developers.ValueObject
-@model Admin.Pages.Developers.Accounts.IndexModel
-@{
-    ViewData["Title"] = "개발자 계정 관리";
-}
-
-<div class="container-fluid">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    <form id="fAdminSearch" method="get">
-        <div class="row g-2 align-items-end">
-            <div class="col-6 col-sm-auto">
-                <select name="approvalStatus" id="approvalStatus" class="form-select">
-                    <option value="">승인 상태 전체</option>
-                    <option value="@((int)DeveloperApprovalStatus.Pending)" selected="@(Model.Query.ApprovalStatus == DeveloperApprovalStatus.Pending)">대기</option>
-                    <option value="@((int)DeveloperApprovalStatus.Approved)" selected="@(Model.Query.ApprovalStatus == DeveloperApprovalStatus.Approved)">승인</option>
-                    <option value="@((int)DeveloperApprovalStatus.Suspended)" selected="@(Model.Query.ApprovalStatus == DeveloperApprovalStatus.Suspended)">정지</option>
-                    <option value="@((int)DeveloperApprovalStatus.Rejected)" selected="@(Model.Query.ApprovalStatus == DeveloperApprovalStatus.Rejected)">거절</option>
-                </select>
-            </div>
-            <div class="col-6 col-sm-auto">
-                <select name="kycStatus" id="kycStatus" class="form-select">
-                    <option value="">KYC 상태 전체</option>
-                    <option value="@((int)KycStatus.None)" selected="@(Model.Query.KycStatus == KycStatus.None)">미진행</option>
-                    <option value="@((int)KycStatus.Pending)" selected="@(Model.Query.KycStatus == KycStatus.Pending)">대기</option>
-                    <option value="@((int)KycStatus.Verified)" selected="@(Model.Query.KycStatus == KycStatus.Verified)">완료</option>
-                    <option value="@((int)KycStatus.Rejected)" selected="@(Model.Query.KycStatus == KycStatus.Rejected)">거절</option>
-                </select>
-            </div>
-            <div class="col-12 col-sm col-md col-lg-auto">
-                <input type="text" name="keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" placeholder="회사명/사업자번호/대표/이메일" />
-            </div>
-            <div class="col-12 col-lg-auto">
-                <button type="submit" class="btn btn-primary">검색</button>
-            </div>
-        </div>
-    </form>
-
-    <hr />
-
-    <div class="row g-2 align-items-end mb-2">
-        <div class="col">Total: @Model.Total.ToString("N0")</div>
-    </div>
-
-    <div class="table-responsive">
-        <table class="table table-bordered table-hover">
-            <thead>
-                <tr>
-                    <th>ID</th>
-                    <th>회사명</th>
-                    <th>사업자번호</th>
-                    <th>대표</th>
-                    <th>담당자</th>
-                    <th>이메일</th>
-                    <th>KYC</th>
-                    <th>승인</th>
-                    <th>가입일</th>
-                </tr>
-            </thead>
-            <tbody>
-                @foreach (var row in Model.List)
-                {
-                    <tr>
-                        <td>@row.ID</td>
-                        <td><a asp-page="/Developers/Accounts/Detail" asp-route-id="@row.ID">@row.CompanyName</a></td>
-                        <td>@row.BusinessNumber</td>
-                        <td>@row.CeoName</td>
-                        <td>@row.ContactName (@row.ContactPhone)</td>
-                        <td>@row.Email</td>
-                        <td>
-                            @if(@row.KycStatus == KycStatus.None)
-                            {
-                                <span class="badge bg-secondary">미진행</span>
-                            }
-                            else if(@row.KycStatus == KycStatus.Pending)
-                            {
-                                <span class="badge bg-warning text-dark">대기중</span>
-                            }
-                            else if(@row.KycStatus == KycStatus.Verified)
-                            {
-                                <span class="badge bg-success">완료</span>
-                            }
-                            else
-                            {
-                                <span class="badge bg-danger">거절</span>
-                            }
-                        </td>
-                        <td>
-                            @if (row.ApprovalStatus == DeveloperApprovalStatus.Approved)
-                            {
-                                <span class="badge bg-success">승인됨</span>
-                            }
-                            else if (row.ApprovalStatus == DeveloperApprovalStatus.Pending)
-                            {
-                                <span class="badge bg-warning text-dark">대기중</span>
-                            }
-                            else
-                            {
-                                <span class="badge bg-danger">거절됨</span>
-                            }
-                        </td>
-                        <td>@row.CreatedAt.ToString("yyyy-MM-dd")</td>
-                    </tr>
-                }
-            </tbody>
-        </table>
-    </div>
-
-    @if (Model.Pagination != null)
-    {
-        <partial name="_Pagination" model="Model.Pagination" />
-    }
-</div>

+ 0 - 50
Admin/Pages/Developers/Accounts/Index.cshtml.cs

@@ -1,50 +0,0 @@
-using SharedKernel.Helpers;
-using Domain.Entities.Developers.ValueObject;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
-
-namespace Admin.Pages.Developers.Accounts;
-
-public class IndexModel(IMediator mediator) : PageModel
-{
-    [BindProperty(SupportsGet = true)]
-    public QueryParams Query { get; set; } = new();
-
-    public sealed class QueryParams
-    {
-        public DeveloperApprovalStatus? ApprovalStatus { get; set; }
-        public KycStatus? KycStatus { get; set; }
-        public string? Keyword { get; set; }
-
-        [Range(1, int.MaxValue)]
-        [DisplayName("페이지 번호")]
-        public int PageNum { get; set; } = 1;
-
-        [Range(1, 100)]
-        [DisplayName("페이지 목록 수")]
-        public ushort PerPage { get; set; } = 20;
-    }
-
-    public int Total { get; set; }
-    public List<Application.Features.Admin.Developers.Accounts.ListDevelopers.DeveloperRow> List { get; set; } = [];
-    public Pagination? Pagination { get; set; }
-
-    public async Task OnGetAsync(CancellationToken ct)
-    {
-        if (!ModelState.IsValid)
-        {
-            return;
-        }
-
-        var result = await mediator.Send(new Application.Features.Admin.Developers.Accounts.ListDevelopers.Query(
-            Query.ApprovalStatus, Query.KycStatus, Query.Keyword, Query.PageNum, Query.PerPage
-        ), ct);
-
-        Total = result.Total;
-        List = [..result.List];
-        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
-    }
-}

+ 0 - 90
Admin/Pages/Developers/Apps/Detail.cshtml

@@ -1,90 +0,0 @@
-@page "{id:int}"
-@using Domain.Entities.Developers.ValueObject
-@model Admin.Pages.Developers.Apps.DetailModel
-@{
-    ViewData["Title"] = "개발자 앱 상세";
-}
-
-<div class="container">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    @if (Model.App == null)
-    {
-        <div class="alert alert-warning">앱이 존재하지 않습니다.</div>
-        return;
-    }
-
-    <div class="row">
-        <div class="col-12 col-md-8">
-            <table class="table table-bordered">
-                <tr><th style="width:160px">ID</th><td class="text-start">@Model.App.ID</td></tr>
-                <tr><th>이름</th><td class="text-start">@Model.App.Name</td></tr>
-                <tr><th>설명</th><td class="text-start">@(Model.App.Description ?? "-")</td></tr>
-                <tr><th>홈페이지</th><td class="text-start"><a href="@Model.App.HomepageUrl" target="_blank">@(Model.App.HomepageUrl ?? "-")</a></td></tr>
-                <tr><th>소유자</th><td class="text-start">@Model.App.Owner?.Email (@(Model.App.Owner?.Name ?? "-"))</td></tr>
-                <tr><th>상태</th><td class="text-start"><span class="badge bg-secondary">@Model.App.Status</span></td></tr>
-                <tr><th>요청 Scope</th><td class="text-start">@string.Join(", ", Model.Scopes)</td></tr>
-                <tr><th>생성일</th><td class="text-start">@Model.App.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td></tr>
-                <tr><th>승인일</th><td class="text-start">@(Model.App.ApprovedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-")</td></tr>
-                <tr><th>정지일</th><td class="text-start">@(Model.App.SuspendedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-")</td></tr>
-            </table>
-        </div>
-        <div class="col-12 col-md-4">
-            <form method="post" class="mb-3">
-                @if (Model.App.Status != ApplicationStatus.Active)
-                {
-                    <button type="submit" asp-page-handler="Approve" class="btn btn-success w-100 mb-2">승인</button>
-                }
-                @if (Model.App.Status != ApplicationStatus.Suspended)
-                {
-                    <button type="submit" asp-page-handler="Suspend" class="btn btn-warning w-100 mb-2">정지</button>
-                }
-            </form>
-
-            <h5>속도 제한</h5>
-            <form method="post">
-                <div class="input-group">
-                    <input type="number" name="LimitPerMinute" value="@Model.LimitPerMinute" min="1" max="100000" class="form-control" />
-                    <span class="input-group-text">/분</span>
-                    <button type="submit" asp-page-handler="OverrideRateLimit" class="btn btn-primary">적용</button>
-                </div>
-                <small class="text-muted">기본 60/분. 덮어쓰기 값만 저장됩니다.</small>
-            </form>
-        </div>
-    </div>
-
-    <hr />
-    <h4>인증 정보 <small>(Client ID / Secret)</small></h4>
-    <table class="table table-bordered table-hover">
-        <thead>
-            <tr>
-                <th>ClientID</th>
-                <th>SecretHint</th>
-                <th>상태</th>
-                <th>생성</th>
-                <th>최근 사용</th>
-            </tr>
-        </thead>
-        <tbody>
-            @foreach (var c in Model.Credentials)
-            {
-                <tr>
-                    <td><code>@c.ClientID</code></td>
-                    <td>****@c.SecretHint</td>
-                    <td>@c.Status</td>
-                    <td>@c.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
-                    <td>@(c.LastUsedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-")</td>
-                </tr>
-            }
-        </tbody>
-    </table>
-
-    <hr/>
-
-    <div class="d-flex justify-content-center">
-        <a asp-page="/Developers/Apps/Index" class="btn btn-secondary">목록으로</a>
-    </div>
-</div>

+ 0 - 95
Admin/Pages/Developers/Apps/Detail.cshtml.cs

@@ -1,95 +0,0 @@
-using Application.Abstractions.Data;
-using Domain.Entities.Developers;
-using Domain.Entities.Developers.ValueObject;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using Microsoft.EntityFrameworkCore;
-
-namespace Admin.Pages.Developers.Apps;
-
-public class DetailModel(IMediator mediator, IAppDbContext db) : PageModel
-{
-    [BindProperty(SupportsGet = true)]
-    public int ID { get; set; }
-
-    public ApiApplication? App { get; set; }
-    public List<ApiCredential> Credentials { get; set; } = [];
-    public List<string> Scopes { get; set; } = [];
-    public int? RateLimitOverride { get; set; }
-
-    [BindProperty]
-    public int LimitPerMinute { get; set; }
-
-    public async Task<IActionResult> OnGetAsync(CancellationToken ct)
-    {
-        App = await db.ApiApplication
-            .Include(c => c.Owner)
-            .FirstOrDefaultAsync(c => c.ID == ID, ct);
-
-        if (App is null)
-        {
-            return NotFound();
-        }
-
-        Credentials = await db.ApiCredential
-            .Where(c => c.ApplicationID == ID)
-            .OrderByDescending(c => c.CreatedAt)
-            .ToListAsync(ct);
-
-        Scopes = await db.ApiApplicationScope
-            .Where(c => c.ApplicationID == ID)
-            .Select(c => c.Scope)
-            .ToListAsync(ct);
-
-        RateLimitOverride = await db.ApiRateLimit
-            .Where(c => c.ApplicationID == ID)
-            .Select(c => (int?)c.LimitPerMinute)
-            .FirstOrDefaultAsync(ct);
-
-        LimitPerMinute = RateLimitOverride ?? 60;
-        return Page();
-    }
-
-    public async Task<IActionResult> OnPostApproveAsync(CancellationToken ct)
-    {
-        try
-        {
-            await mediator.Send(new Application.Features.Admin.Developers.ApproveApp.Command([ID]), ct);
-            TempData["SuccessMessage"] = "앱이 승인되었습니다.";
-        }
-        catch (Exception e)
-        {
-            TempData["ErrorMessages"] = e.Message;
-        }
-        return RedirectToPage("/Developers/Apps/Detail", new { ID });
-    }
-
-    public async Task<IActionResult> OnPostSuspendAsync(CancellationToken ct)
-    {
-        try
-        {
-            await mediator.Send(new Application.Features.Admin.Developers.SuspendApp.Command([ID]), ct);
-            TempData["SuccessMessage"] = "앱이 정지되었습니다.";
-        }
-        catch (Exception e)
-        {
-            TempData["ErrorMessages"] = e.Message;
-        }
-        return RedirectToPage("/Developers/Apps/Detail", new { ID });
-    }
-
-    public async Task<IActionResult> OnPostOverrideRateLimitAsync(CancellationToken ct)
-    {
-        var result = await mediator.Send(new Application.Features.Admin.Developers.OverrideRateLimit.Command(ID, LimitPerMinute), ct);
-        if (result.IsSuccess)
-        {
-            TempData["SuccessMessage"] = $"Rate limit 를 {LimitPerMinute}/min 으로 설정했습니다.";
-        }
-        else
-        {
-            TempData["ErrorMessages"] = result.Error.Description;
-        }
-        return RedirectToPage("/Developers/Apps/Detail", new { ID });
-    }
-}

+ 0 - 97
Admin/Pages/Developers/Apps/Index.cshtml

@@ -1,97 +0,0 @@
-@page
-@using Domain.Entities.Developers.ValueObject
-@model Admin.Pages.Developers.Apps.IndexModel
-@{
-    ViewData["Title"] = "개발자 앱 관리";
-}
-
-<div class="container-fluid">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    <form id="fAdminSearch" method="get">
-        <div class="row g-2 align-items-end">
-            <div class="col-6 col-sm-auto">
-                <select name="status" id="status" class="form-select">
-                    <option value="">상태 전체</option>
-                    <option value="@((int)ApplicationStatus.Pending)" selected="@(Model.Query.Status == ApplicationStatus.Pending)">대기</option>
-                    <option value="@((int)ApplicationStatus.Active)" selected="@(Model.Query.Status == ApplicationStatus.Active)">활성</option>
-                    <option value="@((int)ApplicationStatus.Suspended)" selected="@(Model.Query.Status == ApplicationStatus.Suspended)">정지</option>
-                    <option value="@((int)ApplicationStatus.Rejected)" selected="@(Model.Query.Status == ApplicationStatus.Rejected)">거부</option>
-                </select>
-            </div>
-            <div class="col-12 col-sm col-md col-lg-auto">
-                <input type="text" name="keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" placeholder="앱 이름/설명" />
-            </div>
-            <div class="col-12 col-lg-auto">
-                <button type="submit" class="btn btn-primary">검색</button>
-            </div>
-        </div>
-    </form>
-
-    <hr />
-
-    <form id="fAdminList" method="post">
-        <div class="row g-2 align-items-end mb-2">
-            <div class="col">Total: @Model.Total.ToString("N0")</div>
-            <div class="col-auto">
-                <button type="submit" asp-page-handler="Approve" class="btn btn-success">선택 승인</button>
-                <button type="submit" asp-page-handler="Suspend" class="btn btn-warning">선택 정지</button>
-            </div>
-        </div>
-
-        <div class="table-responsive">
-            <table class="table table-bordered table-hover">
-                <thead>
-                    <tr>
-                        <th style="width:40px"><input type="checkbox" id="checkedAll" class="form-check-input" /></th>
-                        <th>ID</th>
-                        <th>앱 이름</th>
-                        <th>소유자</th>
-                        <th>Cred</th>
-                        <th>상태</th>
-                        <th>생성일</th>
-                        <th>승인일</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    @if (Model.List.Count <= 0)
-                    {
-                        <tr>
-                            <td colspan="8" class="text-center text-muted py-4">No Data.</td>
-                        </tr>
-                    }
-                    else
-                    {
-                        @foreach (var row in Model.List)
-                        {
-                            <tr>
-                                <td><input type="checkbox" name="ids" value="@row.ID" class="form-check-input"/></td>
-                                <td>@row.ID</td>
-                                <td><a asp-page="/Developers/Apps/Detail" asp-route-id="@row.ID">@row.Name</a></td>
-                                <td>@row.OwnerEmail (@(row.OwnerName ?? "-"))</td>
-                                <td>@row.CredentialCount</td>
-                                <td>@row.Status</td>
-                                <td>@row.CreatedAt.ToString("yyyy-MM-dd")</td>
-                                <td>@(row.ApprovedAt?.ToString("yyyy-MM-dd") ?? "-")</td>
-                            </tr>
-                        }
-                    }
-                </tbody>
-            </table>
-        </div>
-
-        @if (Model.Pagination != null)
-        {
-            <partial name="_Pagination" model="Model.Pagination" />
-        }
-    </form>
-</div>
-
-<script>
-    document.getElementById('checkedAll')?.addEventListener('change', function() {
-        document.querySelectorAll('input[name="ids"]').forEach(c => c.checked = this.checked);
-    });
-</script>

+ 0 - 78
Admin/Pages/Developers/Apps/Index.cshtml.cs

@@ -1,78 +0,0 @@
-using SharedKernel.Helpers;
-using SharedKernel.Extensions;
-using Domain.Entities.Developers.ValueObject;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
-
-namespace Admin.Pages.Developers.Apps;
-
-public class IndexModel(IMediator mediator) : PageModel
-{
-    [BindProperty(SupportsGet = true)]
-    public QueryParams Query { get; set; } = new();
-
-    public sealed class QueryParams
-    {
-        public ApplicationStatus? Status { get; set; }
-        public string? Keyword { get; set; }
-
-        [Range(1, int.MaxValue)]
-        [DisplayName("페이지 번호")]
-        public int PageNum { get; set; } = 1;
-
-        [Range(1, 100)]
-        [DisplayName("페이지 목록 수")]
-        public ushort PerPage { get; set; } = 20;
-    }
-
-    public int Total { get; set; }
-    public List<Application.Features.Admin.Developers.ListApps.AppRow> List { get; set; } = [];
-    public Pagination? Pagination { get; set; }
-
-    public async Task OnGetAsync(CancellationToken ct)
-    {
-        if (!ModelState.IsValid)
-        {
-            return;
-        }
-
-        var result = await mediator.Send(new Application.Features.Admin.Developers.ListApps.Query(
-            Query.Status, Query.Keyword, Query.PageNum, Query.PerPage
-        ), ct);
-
-        Total = result.Total;
-        List = [..result.List];
-        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
-    }
-
-    public async Task<IActionResult> OnPostApproveAsync(int[] ids, CancellationToken ct)
-    {
-        try
-        {
-            var result = await mediator.Send(new Application.Features.Admin.Developers.ApproveApp.Command(ids), ct);
-            TempData["SuccessMessage"] = $"{result.AffectedCount}개 앱이 승인되었습니다.";
-        }
-        catch (Exception e)
-        {
-            TempData["ErrorMessages"] = e.Message;
-        }
-        return RedirectToPage("/Developers/Apps/Index", Query);
-    }
-
-    public async Task<IActionResult> OnPostSuspendAsync(int[] ids, CancellationToken ct)
-    {
-        try
-        {
-            var result = await mediator.Send(new Application.Features.Admin.Developers.SuspendApp.Command(ids), ct);
-            TempData["SuccessMessage"] = $"{result.AffectedCount}개 앱이 정지되었습니다.";
-        }
-        catch (Exception e)
-        {
-            TempData["ErrorMessages"] = e.Message;
-        }
-        return RedirectToPage("/Developers/Apps/Index", Query);
-    }
-}

+ 0 - 174
Admin/Pages/Developers/Purchases/Index.cshtml

@@ -1,174 +0,0 @@
-@page
-@using Domain.Entities.Developers.ValueObject
-@model Admin.Pages.Developers.Purchases.IndexModel
-@{
-    ViewData["Title"] = "결제 보고 내역";
-
-    static string MarketLabel(ApiPurchaseMarketplace m) => m switch
-    {
-        ApiPurchaseMarketplace.GooglePlay => "구글",
-        ApiPurchaseMarketplace.AppStore => "애플",
-        ApiPurchaseMarketplace.Microsoft => "MS",
-        ApiPurchaseMarketplace.GalaxyStore => "갤럭시",
-        ApiPurchaseMarketplace.OneStore => "원스토어",
-        _ => "기타"
-    };
-}
-
-<div class="container-fluid">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    <form id="fAdminSearch" method="get">
-        <div class="row g-2 align-items-end">
-            <div class="col-6 col-sm-auto">
-                <select name="status" id="status" class="form-select">
-                    <option value="">상태 전체</option>
-                    <option value="@((int)ApiPurchaseStatus.Pending)" selected="@(Model.Query.Status == ApiPurchaseStatus.Pending)">보류</option>
-                    <option value="@((int)ApiPurchaseStatus.Confirmed)" selected="@(Model.Query.Status == ApiPurchaseStatus.Confirmed)">확정</option>
-                    <option value="@((int)ApiPurchaseStatus.Canceled)" selected="@(Model.Query.Status == ApiPurchaseStatus.Canceled)">취소</option>
-                </select>
-            </div>
-            <div class="col-6 col-sm-auto">
-                <input type="date" name="from" class="form-control" value="@(Model.Query.From?.ToString("yyyy-MM-dd"))" />
-            </div>
-            <div class="col-6 col-sm-auto">
-                <input type="date" name="to" class="form-control" value="@(Model.Query.To?.ToString("yyyy-MM-dd"))" />
-            </div>
-            <div class="col-12 col-sm col-md col-lg-auto">
-                <input type="text" name="keyword" class="form-control" maxlength="255" value="@Model.Query.Keyword" placeholder="orderID/후원코드/게임코드/채널명" />
-            </div>
-            <div class="col-auto">
-                <div class="form-check">
-                    <input type="checkbox" name="onlyMismatch" id="onlyMismatch" value="true" class="form-check-input" checked="@Model.Query.OnlyMismatch" />
-                    <label for="onlyMismatch" class="form-check-label">금액 불일치만</label>
-                </div>
-            </div>
-            <div class="col-12 col-lg-auto">
-                <button type="submit" class="btn btn-primary">검색</button>
-            </div>
-        </div>
-    </form>
-
-    <hr />
-
-    @if (Model.Summary != null)
-    {
-        <div class="row g-2 mb-3">
-            <div class="col-auto"><span class="badge bg-light text-dark border">보고액 합계 @Model.Summary.TotalOrderPrice.ToString("N0")원</span></div>
-            <div class="col-auto"><span class="badge bg-light text-dark border">수수료 합계 @Model.Summary.TotalCommission.ToString("N0")원</span></div>
-            <div class="col-auto"><span class="badge bg-secondary">보류 @Model.Summary.PendingCount.ToString("N0")</span></div>
-            <div class="col-auto"><span class="badge bg-success">확정 @Model.Summary.ConfirmedCount.ToString("N0")</span></div>
-            <div class="col-auto"><span class="badge bg-dark">취소 @Model.Summary.CanceledCount.ToString("N0")</span></div>
-            @if (Model.Summary.MismatchCount > 0)
-            {
-                <div class="col-auto"><span class="badge bg-danger">금액 불일치 @Model.Summary.MismatchCount.ToString("N0")</span></div>
-            }
-            @if (Model.Summary.TotalShortfall > 0)
-            {
-                <div class="col-auto"><span class="badge bg-warning text-dark">미회수 @Model.Summary.TotalShortfall.ToString("N0")원</span></div>
-            }
-        </div>
-    }
-
-    <div class="row g-2 align-items-end mb-2">
-        <div class="col">Total: @Model.Total.ToString("N0")</div>
-    </div>
-
-    <div class="table-responsive">
-        <table class="table table-bordered table-hover">
-            <thead>
-                <tr>
-                    <th>ID</th>
-                    <th>등록일</th>
-                    <th>앱</th>
-                    <th>게임</th>
-                    <th>채널 (후원코드)</th>
-                    <th>마켓</th>
-                    <th>orderID</th>
-                    <th>SKU</th>
-                    <th class="text-end">보고액</th>
-                    <th class="text-end">수수료 (율)</th>
-                    <th>상태</th>
-                    <th>확정 예정/일시</th>
-                </tr>
-            </thead>
-            <tbody>
-                @if (Model.List.Count <= 0)
-                {
-                    <tr>
-                        <td colspan="12" class="text-center text-muted py-4">No Data.</td>
-                    </tr>
-                }
-                else
-                {
-                    @foreach (var row in Model.List)
-                    {
-                        <tr>
-                            <td>@row.ID</td>
-                            <td>@row.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
-                            <td>@row.AppName</td>
-                            <td>@row.GameName</td>
-                            <td>@row.ChannelName (@(row.ChannelCode ?? "-"))</td>
-                            <td>@MarketLabel(row.Marketplace)</td>
-                            <td><code>@row.OrderID</code></td>
-                            <td>
-                                @(row.ProductID ?? "-")
-                                @if (row.PriceMismatched)
-                                {
-                                    <span class="badge bg-danger" title="카탈로그 정가 @(row.CatalogPrice?.ToString("N0") ?? "-")원과 불일치">불일치</span>
-                                }
-                            </td>
-                            <td class="text-end">@row.OrderPrice.ToString("N0")원</td>
-                            <td class="text-end">@row.CommissionAmount.ToString("N0")원 (@row.CommissionRate%)</td>
-                            <td>
-                                @switch (row.Status)
-                                {
-                                    case ApiPurchaseStatus.Pending:
-                                        <span class="badge bg-secondary">보류</span>
-                                        break;
-                                    case ApiPurchaseStatus.Confirmed:
-                                        <span class="badge bg-success">확정</span>
-                                        break;
-                                    case ApiPurchaseStatus.Canceled:
-                                        <span class="badge bg-dark">취소</span>
-                                        if (row.CancelWasConfirmed == true)
-                                        {
-                                            <small class="text-muted d-block">회수 @(row.RecoveredAmount?.ToString("N0") ?? "0")원</small>
-                                        }
-                                        if (row.ShortfallAmount > 0)
-                                        {
-                                            <span class="badge bg-warning text-dark">미회수 @(row.ShortfallAmount?.ToString("N0"))원</span>
-                                        }
-                                        break;
-                                }
-                            </td>
-                            <td>
-                                @if (row.Status == ApiPurchaseStatus.Pending)
-                                {
-                                    @row.ConfirmDueAt.ToString("yyyy-MM-dd")
-                                    <small class="text-muted">예정</small>
-                                }
-                                else if (row.ConfirmedAt != null)
-                                {
-                                    @row.ConfirmedAt.Value.ToString("yyyy-MM-dd HH:mm")
-                                }
-                                else if (row.CanceledAt != null)
-                                {
-                                    <small class="text-muted">@row.CanceledAt.Value.ToString("yyyy-MM-dd HH:mm") 취소</small>
-                                }
-                            </td>
-                        </tr>
-                    }
-                }
-            </tbody>
-        </table>
-    </div>
-
-    @if (Model.Pagination != null)
-    {
-        <partial name="_Pagination" model="Model.Pagination" />
-    }
-</div>

+ 0 - 61
Admin/Pages/Developers/Purchases/Index.cshtml.cs

@@ -1,61 +0,0 @@
-using SharedKernel.Helpers;
-using Domain.Entities.Developers.ValueObject;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
-
-namespace Admin.Pages.Developers.Purchases;
-
-public class IndexModel(IMediator mediator) : PageModel
-{
-    [BindProperty(SupportsGet = true)]
-    public QueryParams Query { get; set; } = new();
-
-    public sealed class QueryParams
-    {
-        public ApiPurchaseStatus? Status { get; set; }
-
-        public string? Keyword { get; set; }
-
-        [DataType(DataType.Date)]
-        public DateTime? From { get; set; }
-
-        [DataType(DataType.Date)]
-        public DateTime? To { get; set; }
-
-        [DisplayName("금액 불일치만")]
-        public bool OnlyMismatch { get; set; }
-
-        [Range(1, int.MaxValue)]
-        [DisplayName("페이지 번호")]
-        public int PageNum { get; set; } = 1;
-
-        [Range(1, 100)]
-        [DisplayName("페이지 목록 수")]
-        public ushort PerPage { get; set; } = 20;
-    }
-
-    public int Total { get; set; }
-    public Application.Features.Admin.Developers.Purchases.Search.Response.SummaryData? Summary { get; set; }
-    public List<Application.Features.Admin.Developers.Purchases.Search.Response.Row> List { get; set; } = [];
-    public Pagination? Pagination { get; set; }
-
-    public async Task OnGetAsync(CancellationToken ct)
-    {
-        if (!ModelState.IsValid)
-        {
-            return;
-        }
-
-        var result = await mediator.Send(new Application.Features.Admin.Developers.Purchases.Search.Query(
-            Query.Status, Query.Keyword, Query.From, Query.To, Query.OnlyMismatch, Query.PageNum, Query.PerPage
-        ), ct);
-
-        Total = result.Total;
-        Summary = result.Summary;
-        List = [..result.List];
-        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
-    }
-}

+ 0 - 233
Admin/Pages/Donation/Alert/Index.cshtml

@@ -1,233 +0,0 @@
-@page
-@model Admin.Pages.Donation.Alert.IndexModel
-@using SharedKernel.Extensions
-@{
-    ViewData["Title"] = "후원 알림";
-}
-
-<div class="container-fluid">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    <div class="row g-2 mb-2">
-        <div class="col-12 col-lg-auto">
-            <div class="row g-2">
-                <div class="col-auto col-md-auto">
-                    <select id="search" class="form-select">
-                        <option value="">검색 조건</option>
-                        <option value="1" selected="@(Model.Query.Search == 1)">후원자 ID</option>
-                        <option value="2" selected="@(Model.Query.Search == 2)">후원자 SID</option>
-                        <option value="3" selected="@(Model.Query.Search == 3)">후원자 이름</option>
-                        <option value="4" selected="@(Model.Query.Search == 4)">피후원자 ID</option>
-                        <option value="5" selected="@(Model.Query.Search == 5)">피후원자 SID</option>
-                        <option value="6" selected="@(Model.Query.Search == 6)">피후원자 이름</option>
-                    </select>
-                </div>
-                <div class="col col-md-auto">
-                    <input type="search" id="keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" />
-                </div>
-            </div>
-        </div>
-        <div class="col-12 col-sm">
-            <div class="row g-2">
-                <div class="col-12 col-md-auto">
-                    <div class="row row-cols-2 g-2">
-                        <div class="col">
-                            <input type="datetime-local" name="startAt" id="startAt" class="form-control" value="@Model.Query.StartAt" />
-                        </div>
-                        <div class="col d-none">~</div>
-                        <div class="col">
-                            <input type="datetime-local" name="endAt" id="endAt" class="form-control" value="@Model.Query.EndAt" />
-                        </div>
-                    </div>
-                </div>
-                <div class="col col-md-auto text-center">
-                    <button type="submit" id="btnSearch" class="btn btn-primary w-100">검색</button>
-                </div>
-            </div>
-        </div>
-    </div>
-
-    <hr />
-
-    <div class="row g-2 align-items-end mb-3">
-        <div class="col">
-            Total : @Model.Total.ToString("N0")
-        </div>
-        <div class="col text-end">
-            <select id="perPage" class="form-select w-auto d-inline-block" form="fAdminList">
-                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
-                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
-                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
-                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
-            </select>
-            <button type="submit" id="btnListDelete" class="btn btn-danger" form="fAdminList" formaction="?handler=Delete" formmethod="post" disabled>삭제</button>
-        </div>
-    </div>
-
-    <ul class="nav nav-tabs">
-        <li class="nav-item">
-            <a class="nav-link @(Model.Query.Tab == 0 ? "active" : null)" href="/Donation/Alert?tab=0">전체</a>
-        </li>
-        <li class="nav-item">
-            <a class="nav-link @(Model.Query.Tab == 1 ? "active" : null)" href="/Donation/Alert?tab=1">수신(@Model.Received)</a>
-        </li>
-        <li class="nav-item">
-            <a class="nav-link @(Model.Query.Tab == 2 ? "active" : null)" href="/Donation/Alert?tab=2">미수신(@Model.NotReceived)</a>
-        </li>
-    </ul>
-
-    <form name="f_admin_list" id="fAdminList" method="post" accept-charset="utf-8" autocomplete="off">
-        @Html.AntiForgeryToken()
-    </form>
-
-    <div class="table-responsive">
-        <table class="table table-bordered table-hover mt-3">
-            <colgroup>
-                <col style="width: 5%;" />
-                <col />
-                <col />
-                <col />
-                <col />
-                <col />
-                <col />
-                <col style="width: 12%;" />
-            </colgroup>
-            <thead>
-                <tr>
-                    <th>
-                        <div class="form-check">
-                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
-                            <label for="checkedAll" class="form-check-label">ID</label>
-                        </div>
-                    </th>
-                    <th>후원자</th>
-                    <th>피후원자</th>
-                    <th>수신 여부</th>
-                    <th>시작 시간</th>
-                    <th>수신 시간</th>
-                    <th>IP</th>
-                    <th>관리</th>
-                </tr>
-            </thead>
-            <tbody>
-                @if (Model.List == null || Model.List.Count <= 0)
-                {
-                    <tr>
-                        <td colspan="8">No Data.</td>
-                    </tr>
-                }
-                else
-                {
-                    @foreach (var row in Model.List)
-                    {
-                        <tr>
-                            <td>
-                                <div class="form-check">
-                                    <input type="checkbox" name="ids" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
-                                    <label for="chk_@row.ID" class="form-check-label">@row.ID</label>
-                                </div>
-                            </td>
-                            <td>[@row.Sponsor.ID] @row.Sponsor.Email, @(row.Sponsor.Name ?? "-")</td>
-                            <td>[@row.Receiver.ID] @row.Receiver.Email, @(row.Receiver.Name ?? "-")</td>
-                            <td>
-                                @if (row.IsReceived)
-                                {
-                                    <span class="badge bg-success">수신</span>
-                                }
-                                else
-                                {
-                                    <span class="badge bg-secondary">@row.Status</span>
-                                }
-                            </td>
-                            <td>@row.StartAt.GetDateAt()</td>
-                            <td>@(row.ArrivedAt.GetDateAt() ?? "-")</td>
-                            <td>@(row.IpAddress ?? "-")</td>
-                            <td>
-                                <div class="d-xl-flex gap-2 justify-content-center d-grid">
-                                    <form method="post" asp-page-handler="Resend" class="d-inline">
-                                        @Html.AntiForgeryToken()
-                                        <input type="hidden" name="id" value="@row.ID" />
-                                        <button type="submit" class="btn btn-sm btn-outline-secondary btn-alert-resend w-100" data-disabled="@(row.IsReceived ? "Y" : "N")">재전송</button>
-                                    </form>
-                                    <form method="post" asp-page-handler="Delete" class="d-inline">
-                                        @Html.AntiForgeryToken()
-                                        <input type="hidden" name="ids" value="@row.ID" />
-                                        <button type="submit" class="btn btn-sm btn-outline-danger btn-row-delete w-100">삭제</button>
-                                    </form>
-                                </div>
-                            </td>
-                        </tr>
-                    }
-                }
-            </tbody>
-        </table>
-
-        <partial name="_Pagination" model="Model.Pagination" />
-    </div>
-</div>
-
-@section Scripts {
-    <script>
-        function updateQueryString() {
-            let queryParams = new URLSearchParams();
-
-            const search = document.getElementById("search").value;
-            const keyword = document.getElementById("keyword").value;
-            const startAt = document.getElementById("startAt").value;
-            const endAt = document.getElementById("endAt").value;
-
-            if (search) queryParams.set("search", search);
-            if (keyword) queryParams.set("keyword", keyword);
-            if (startAt) queryParams.set("startAt", startAt);
-            if (endAt) queryParams.set("endAt", endAt);
-            queryParams.set("perPage", document.getElementById("perPage").value);
-            queryParams.set("tab", @Model.Query.Tab);
-
-            window.location.href = window.location.pathname + "?" + queryParams.toString();
-        }
-
-        $(document).on("change", "#perPage", function () { updateQueryString(); });
-
-        $(document).on("click", "#btnSearch", function (e) {
-            e.preventDefault();
-            updateQueryString();
-        });
-
-        $(document).on("keyup", "#keyword, #startAt, #endAt", function (e) {
-            if (e.which === 13 || e.key === "Enter") {
-                e.preventDefault();
-                updateQueryString();
-            }
-        });
-
-        $(document).on("change", ".list-check-box, #checkedAll", function () {
-            if (this.id === "checkedAll") {
-                $(".list-check-box").prop("checked", this.checked);
-            }
-            const anyChecked = $(".list-check-box:checked").length > 0;
-            $("#btnListDelete").prop("disabled", !anyChecked);
-        });
-
-        $(document).on("click", ".btn-alert-resend", function (e) {
-            if ($(this).data("disabled") === "Y") {
-                e.preventDefault();
-                alert("이미 수신된 알림입니다.");
-                return false;
-            }
-            if (!confirm("알림을 재전송 하시겠습니까?")) {
-                e.preventDefault();
-                return false;
-            }
-        });
-
-        $(document).on("click", ".btn-row-delete", function (e) {
-            if (!confirm("이 알림을 삭제하시겠습니까?")) {
-                e.preventDefault();
-                return false;
-            }
-        });
-    </script>
-}

+ 0 - 111
Admin/Pages/Donation/Alert/Index.cshtml.cs

@@ -1,111 +0,0 @@
-using SharedKernel.Helpers;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
-
-namespace Admin.Pages.Donation.Alert;
-
-public class IndexModel(IMediator mediator) : PageModel
-{
-    [BindProperty(SupportsGet = true)]
-    public QueryParams Query { get; set; } = new();
-
-    public sealed class QueryParams
-    {
-        [Range(1, int.MaxValue)]
-        [DisplayName("페이지 번호")]
-        public int PageNum { get; set; } = 1;
-
-        [Range(1, 100)]
-        [DisplayName("페이지 목록 수")]
-        public ushort PerPage { get; set; } = 10;
-
-        [DisplayName("검색 조건")]
-        [Range(1, 6, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
-        public int? Search { get; set; }
-
-        [DisplayName("검색어")]
-        [MaxLength(100, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
-        public string? Keyword { get; set; }
-
-        [DisplayName("시작일")]
-        public string? StartAt { get; set; }
-
-        [DisplayName("종료일")]
-        public string? EndAt { get; set; }
-
-        [DisplayName("탭")]
-        [Range(0, 2)]
-        public int Tab { get; set; } = 0;
-    }
-
-    public int Total { get; set; } = 0;
-    public int Received { get; set; } = 0;
-    public int NotReceived { get; set; } = 0;
-
-    public List<SearchDonationAlerts.Response.Row> List { get; set; } = [];
-
-    public Pagination? Pagination { get; set; }
-
-    public async Task OnGetAsync(CancellationToken ct)
-    {
-        if (!ModelState.IsValid)
-        {
-            return;
-        }
-
-        var result = await mediator.Send(new SearchDonationAlerts.Query(
-            Query.PageNum,
-            Query.PerPage,
-            Query.Search,
-            Query.Keyword,
-            Query.StartAt,
-            Query.EndAt,
-            Query.Tab
-        ), ct);
-
-        Total = result.Total;
-        Received = result.Received;
-        NotReceived = result.NotReceived;
-        List = [.. result.List];
-        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage, Request.QueryString.ToString());
-    }
-
-    public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
-    {
-        try
-        {
-            if (ids is null || ids.Length == 0)
-            {
-                throw new Exception("삭제할 항목을 선택해주세요.");
-            }
-
-            await mediator.Send(new DeleteDonationAlert.Command(ids), ct);
-
-            TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
-        }
-        catch (Exception e)
-        {
-            TempData["ErrorMessages"] = e.Message;
-        }
-
-        return Redirect($"{Request.Path}{Request.QueryString}");
-    }
-
-    public async Task<IActionResult> OnPostResendAsync(int id, CancellationToken ct)
-    {
-        try
-        {
-            await mediator.Send(new ResendDonationAlert.Command(id), ct);
-            TempData["SuccessMessage"] = "후원 알림을 재전송 대기열에 추가했습니다.";
-        }
-        catch (Exception e)
-        {
-            TempData["ErrorMessages"] = e.Message;
-        }
-
-        return Redirect($"{Request.Path}{Request.QueryString}");
-    }
-}

+ 0 - 240
Admin/Pages/Donation/List/Index.cshtml

@@ -1,240 +0,0 @@
-@page
-@model Admin.Pages.Donation.List.IndexModel
-@using SharedKernel.Extensions
-@{
-    ViewData["Title"] = "후원 내역";
-}
-
-<div class="container-fluid">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    <div class="row g-2 mb-2">
-        <div class="col-12 col-lg-auto">
-            <div class="row g-2">
-                <div class="col-auto col-md-auto">
-                    <select id="search" class="form-select">
-                        <option value="">검색 조건</option>
-                        <option value="1" selected="@(Model.Query.Search == 1)">후원자 ID</option>
-                        <option value="2" selected="@(Model.Query.Search == 2)">후원자 SID</option>
-                        <option value="3" selected="@(Model.Query.Search == 3)">후원자 이름</option>
-                        <option value="4" selected="@(Model.Query.Search == 4)">피후원자 ID</option>
-                        <option value="5" selected="@(Model.Query.Search == 5)">피후원자 SID</option>
-                        <option value="6" selected="@(Model.Query.Search == 6)">피후원자 이름</option>
-                        <option value="7" selected="@(Model.Query.Search == 7)">보낸 이름</option>
-                    </select>
-                </div>
-                <div class="col col-md-auto">
-                    <input type="search" id="keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" />
-                </div>
-            </div>
-        </div>
-        <div class="col-12 col-sm">
-            <div class="row g-2">
-                <div class="col-12 col-md-auto">
-                    <div class="row row-cols-2 g-2">
-                        <div class="col">
-                            <input type="datetime-local" name="startAt" id="startAt" class="form-control" value="@Model.Query.StartAt" />
-                        </div>
-                        <div class="col d-none">~</div>
-                        <div class="col">
-                            <input type="datetime-local" name="endAt" id="endAt" class="form-control" value="@Model.Query.EndAt" />
-                        </div>
-                    </div>
-                </div>
-                <div class="col col-md-auto text-center">
-                    <button type="submit" id="btnSearch" class="btn btn-primary w-100">검색</button>
-                </div>
-            </div>
-        </div>
-    </div>
-
-    <hr />
-
-    <div class="row g-2 align-items-end">
-        <div class="col">
-            Total : @Model.Total.ToString("N0")
-        </div>
-        <div class="col text-end">
-            <select id="perPage" class="form-select w-auto d-inline-block" form="fAdminList">
-                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
-                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
-                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
-                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
-            </select>
-            <button type="submit" id="btnListDelete" class="btn btn-danger" form="fAdminList" formaction="?handler=Delete" formmethod="post" disabled>삭제</button>
-            <button type="button" id="btnExport" class="btn btn-outline-success">엑셀 다운로드</button>
-        </div>
-    </div>
-
-    <form name="f_admin_list" id="fAdminList" method="post" accept-charset="utf-8" autocomplete="off">
-        @Html.AntiForgeryToken()
-    </form>
-
-    <div class="table-responsive">
-        <table class="table table-bordered table-hover mt-3">
-            <colgroup>
-                <col style="width: 5%;" />
-                <col />
-                <col />
-                <col />
-                <col />
-                <col />
-                <col />
-                <col />
-                <col />
-            </colgroup>
-            <thead>
-                <tr>
-                    <th>
-                        <div class="form-check">
-                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
-                            <label for="checkedAll" class="form-check-label">ID</label>
-                        </div>
-                    </th>
-                    <th>후원 금액</th>
-                    <th>보낸 이름</th>
-                    <th>후원자</th>
-                    <th>후원자 잔고</th>
-                    <th>피후원자</th>
-                    <th>피후원자 잔고</th>
-                    <th>전달 내용</th>
-                    <th>등록 일시</th>
-                </tr>
-            </thead>
-            <tbody>
-                @if (Model.List == null || Model.List.Count <= 0)
-                {
-                    <tr>
-                        <td colspan="9">No Data.</td>
-                    </tr>
-                }
-                else
-                {
-                    @foreach (var row in Model.List)
-                    {
-                        <tr>
-                            <td>
-                                <div class="form-check">
-                                    <input type="checkbox" name="ids" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
-                                    <label for="chk_@row.ID" class="form-check-label">@row.ID</label>
-                                </div>
-                            </td>
-                            <td>@row.Amount.ToString("N0")</td>
-                            <td>@row.SendName</td>
-                            <td>[@row.Sponsor.ID] @row.Sponsor.Email, @(row.Sponsor.Name ?? "-")</td>
-                            <td>@row.SponsorBalance.ToString("N0")</td>
-                            <td>[@row.Receiver.ID] @row.Receiver.Email, @(row.Receiver.Name ?? "-")</td>
-                            <td>@row.ReceiverDonationBalance.ToString("N0")</td>
-                            <td>
-                                @if (!string.IsNullOrEmpty(row.Message))
-                                {
-                                    <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#messageModal_@row.ID">
-                                        보기
-                                    </button>
-
-                                    <div class="modal fade" id="messageModal_@row.ID" tabindex="-1" aria-labelledby="messageModalLabel_@row.ID" aria-hidden="true">
-                                        <div class="modal-dialog">
-                                            <div class="modal-content">
-                                                <div class="modal-header">
-                                                    <h1 class="modal-title fs-5" id="messageModalLabel_@row.ID">전달 내용</h1>
-                                                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
-                                                </div>
-                                                <div class="modal-body">@row.Message</div>
-                                                <div class="modal-footer justify-content-center">
-                                                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
-                                                </div>
-                                            </div>
-                                        </div>
-                                    </div>
-                                }
-                                else
-                                {
-                                    <text>-</text>
-                                }
-                            </td>
-                            <td>@row.CreatedAt.GetDateAt()</td>
-                        </tr>
-                    }
-                }
-            </tbody>
-        </table>
-
-        <partial name="_Pagination" model="Model.Pagination" />
-    </div>
-</div>
-
-@section Scripts {
-    <script src="~/lib/xlsx/xlsx.full.min.js"></script>
-    <script>
-        $(document).on("click", "#btnExport", async function () {
-            try {
-                const qs = location.search || "";
-                const res = await fetch(`/Donation/List?handler=ExportData${qs ? "&" + qs.substring(1) : ""}`, { credentials: "same-origin" });
-                if (!res.ok) throw new Error("데이터 조회 실패");
-
-                const { rows } = await res.json();
-                if (!rows || rows.length === 0) {
-                    alert("내보낼 데이터가 없습니다.");
-                    return;
-                }
-
-                const headers = ["ID", "후원금액", "보낸이름", "후원자", "후원자잔고", "피후원자", "피후원자잔고", "전달내용", "등록일시KST"];
-                const rowsForSheet = rows.map(r => headers.map(h => r[h]));
-                const data = [headers, ...rowsForSheet];
-
-                const ws = XLSX.utils.aoa_to_sheet(data);
-                const wb = XLSX.utils.book_new();
-                XLSX.utils.book_append_sheet(wb, ws, "후원내역");
-
-                const kstNow = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19).replaceAll(":", "");
-                XLSX.writeFile(wb, `후원내역_${kstNow}.xlsx`);
-            } catch (e) {
-                console.error(e);
-                alert("엑셀 생성 중 오류가 발생했습니다.");
-            }
-        });
-
-        function updateQueryString() {
-            let queryParams = new URLSearchParams();
-
-            const search = document.getElementById("search").value;
-            const keyword = document.getElementById("keyword").value;
-            const startAt = document.getElementById("startAt").value;
-            const endAt = document.getElementById("endAt").value;
-
-            if (search) queryParams.set("search", search);
-            if (keyword) queryParams.set("keyword", keyword);
-            if (startAt) queryParams.set("startAt", startAt);
-            if (endAt) queryParams.set("endAt", endAt);
-            queryParams.set("perPage", document.getElementById("perPage").value);
-
-            window.location.href = window.location.pathname + "?" + queryParams.toString();
-        }
-
-        $(document).on("change", "#perPage", function () { updateQueryString(); });
-
-        $(document).on("click", "#btnSearch", function (e) {
-            e.preventDefault();
-            updateQueryString();
-        });
-
-        $(document).on("keyup", "#keyword, #startAt, #endAt", function (e) {
-            if (e.which === 13 || e.key === "Enter") {
-                e.preventDefault();
-                updateQueryString();
-            }
-        });
-
-        // 체크박스 선택에 따른 삭제 버튼 활성/비활성
-        $(document).on("change", ".list-check-box, #checkedAll", function () {
-            if (this.id === "checkedAll") {
-                $(".list-check-box").prop("checked", this.checked);
-            }
-            const anyChecked = $(".list-check-box:checked").length > 0;
-            $("#btnListDelete").prop("disabled", !anyChecked);
-        });
-    </script>
-}

+ 0 - 99
Admin/Pages/Donation/List/Index.cshtml.cs

@@ -1,99 +0,0 @@
-using SharedKernel.Helpers;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
-
-namespace Admin.Pages.Donation.List;
-
-public class IndexModel(IMediator mediator) : PageModel
-{
-    [BindProperty(SupportsGet = true)]
-    public QueryParams Query { get; set; } = new();
-
-    public sealed class QueryParams
-    {
-        [Range(1, int.MaxValue)]
-        [DisplayName("페이지 번호")]
-        public int PageNum { get; set; } = 1;
-
-        [Range(1, 100)]
-        [DisplayName("페이지 목록 수")]
-        public ushort PerPage { get; set; } = 10;
-
-        [DisplayName("검색 조건")]
-        [Range(1, 7, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
-        public int? Search { get; set; }
-
-        [DisplayName("검색어")]
-        [MaxLength(100, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
-        public string? Keyword { get; set; }
-
-        [DisplayName("시작일")]
-        public string? StartAt { get; set; }
-
-        [DisplayName("종료일")]
-        public string? EndAt { get; set; }
-    }
-
-    public int Total { get; set; } = 0;
-
-    public List<SearchDonations.Response.Row> List { get; set; } = [];
-
-    public Pagination? Pagination { get; set; }
-
-    public async Task OnGetAsync(CancellationToken ct)
-    {
-        if (!ModelState.IsValid)
-        {
-            return;
-        }
-
-        var result = await mediator.Send(new SearchDonations.Query(
-            Query.PageNum,
-            Query.PerPage,
-            Query.Search,
-            Query.Keyword,
-            Query.StartAt,
-            Query.EndAt
-        ), ct);
-
-        Total = result.Total;
-        List = [.. result.List];
-        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage, Request.QueryString.ToString());
-    }
-
-    public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
-    {
-        try
-        {
-            if (ids is null || ids.Length == 0)
-            {
-                throw new Exception("삭제할 항목을 선택해주세요.");
-            }
-
-            await mediator.Send(new DeleteDonations.Command(ids), ct);
-
-            TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
-        }
-        catch (Exception e)
-        {
-            TempData["ErrorMessages"] = e.Message;
-        }
-
-        return Redirect($"{Request.Path}{Request.QueryString}");
-    }
-
-    public async Task<IActionResult> OnGetExportDataAsync(CancellationToken ct)
-    {
-        var result = await mediator.Send(new ExportDonations.Query(
-            Query.Search,
-            Query.Keyword,
-            Query.StartAt,
-            Query.EndAt
-        ), ct);
-
-        return new JsonResult(new { rows = result.Rows });
-    }
-}

+ 0 - 207
Admin/Pages/Donation/Rank/Index.cshtml

@@ -1,207 +0,0 @@
-@page
-@model Admin.Pages.Donation.Rank.IndexModel
-@using SharedKernel.Extensions
-@{
-    ViewData["Title"] = "후원 순위";
-
-    string TabLabel(int tab) => tab switch
-    {
-        0 => "오늘",
-        1 => "3일",
-        2 => "1주일",
-        3 => "1개월",
-        4 => "3개월",
-        5 => "6개월",
-        6 => "1년",
-        _ => "오늘"
-    };
-
-    string TabHref(int tab)
-    {
-        var qs = new List<string> { $"tab={tab}" };
-        if (Model.Query.Search.HasValue) qs.Add($"search={Model.Query.Search}");
-        if (!string.IsNullOrEmpty(Model.Query.Keyword)) qs.Add($"keyword={Model.Query.Keyword}");
-        if (!string.IsNullOrEmpty(Model.Query.StartAt)) qs.Add($"startAt={Model.Query.StartAt}");
-        if (!string.IsNullOrEmpty(Model.Query.EndAt)) qs.Add($"endAt={Model.Query.EndAt}");
-        qs.Add($"perPage={Model.Query.PerPage}");
-        qs.Add($"sort={Model.Query.Sort}");
-        return "/Donation/Rank?" + string.Join("&", qs);
-    }
-}
-
-<div class="container-fluid">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    <div class="row g-2 mb-2">
-        <div class="col-12 col-lg-auto">
-            <div class="row g-2">
-                <div class="col-auto col-md-auto">
-                    <select id="search" class="form-select">
-                        <option value="">검색 조건</option>
-                        <option value="1" selected="@(Model.Query.Search == 1)">후원자 ID</option>
-                        <option value="2" selected="@(Model.Query.Search == 2)">후원자 SID</option>
-                        <option value="3" selected="@(Model.Query.Search == 3)">후원자 이름</option>
-                        <option value="4" selected="@(Model.Query.Search == 4)">피후원자 ID</option>
-                        <option value="5" selected="@(Model.Query.Search == 5)">피후원자 SID</option>
-                        <option value="6" selected="@(Model.Query.Search == 6)">피후원자 이름</option>
-                        <option value="7" selected="@(Model.Query.Search == 7)">보낸 이름</option>
-                    </select>
-                </div>
-                <div class="col col-md-auto">
-                    <input type="search" id="keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" />
-                </div>
-            </div>
-        </div>
-        <div class="col-12 col-sm">
-            <div class="row g-2">
-                <div class="col-12 col-md-auto">
-                    <div class="row row-cols-2 g-2">
-                        <div class="col">
-                            <input type="datetime-local" name="startAt" id="startAt" class="form-control" value="@Model.Query.StartAt" />
-                        </div>
-                        <div class="col d-none">~</div>
-                        <div class="col">
-                            <input type="datetime-local" name="endAt" id="endAt" class="form-control" value="@Model.Query.EndAt" />
-                        </div>
-                    </div>
-                </div>
-                <div class="col col-md-auto text-center">
-                    <button type="submit" id="btnSearch" class="btn btn-primary w-100">검색</button>
-                </div>
-            </div>
-        </div>
-    </div>
-
-    <hr />
-
-    <div class="row g-2 align-items-end mb-3">
-        <div class="col">
-            Total : @Model.Total.ToString("N0")
-        </div>
-        <div class="col text-end">
-            <select id="perPage" class="form-select w-auto d-inline-block">
-                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
-                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
-                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
-                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
-            </select>
-            <select id="sort" class="form-select w-auto d-inline-block">
-                <option value="1" selected="@(Model.Query.Sort == 1)">누적 금액 ↓</option>
-                <option value="2" selected="@(Model.Query.Sort == 2)">누적 금액 ↑</option>
-                <option value="3" selected="@(Model.Query.Sort == 3)">후원 횟수 ↓</option>
-                <option value="4" selected="@(Model.Query.Sort == 4)">후원 횟수 ↑</option>
-            </select>
-        </div>
-    </div>
-
-    <ul class="nav nav-tabs">
-        @for (int t = 0; t <= 6; t++)
-        {
-            <li class="nav-item">
-                <a class="nav-link @(Model.Query.Tab == t ? "active" : null)" href="@TabHref(t)">@TabLabel(t)</a>
-            </li>
-        }
-    </ul>
-
-    <div class="table-responsive">
-        <table class="table table-bordered table-hover mt-3">
-            <colgroup>
-                <col style="width: 5%;" />
-                <col style="width: 6%;" />
-                <col />
-                <col />
-                <col />
-                <col />
-                <col />
-            </colgroup>
-            <thead>
-                <tr>
-                    <th>No.</th>
-                    <th>순위</th>
-                    <th>회원</th>
-                    <th>누적 후원 금액</th>
-                    <th>후원 횟수</th>
-                    <th>마지막 피후원자</th>
-                    <th>마지막 후원 일시</th>
-                </tr>
-            </thead>
-            <tbody>
-                @if (Model.List == null || Model.List.Count <= 0)
-                {
-                    <tr>
-                        <td colspan="7">No Data.</td>
-                    </tr>
-                }
-                else
-                {
-                    @foreach (var row in Model.List)
-                    {
-                        <tr>
-                            <td>@row.Num</td>
-                            <td>@row.Rank</td>
-                            <td>
-                                <a href="/Member/List?search=1&keyword=@row.SponsorMemberID">[@row.SponsorMemberID]</a>
-                                @row.SponsorEmail / @(row.SponsorName ?? "-")
-                            </td>
-                            <td>@row.TotalAmount.ToString("N0")</td>
-                            <td>@row.DonationCount.ToString("N0")</td>
-                            <td>
-                                @if (row.LastReceiverID.HasValue)
-                                {
-                                    <text>[@row.LastReceiverID] @row.LastReceiverEmail / @(row.LastReceiverName ?? "-")</text>
-                                }
-                                else
-                                {
-                                    <text>-</text>
-                                }
-                            </td>
-                            <td>@(row.LastDonatedAt.GetDateAt() ?? "-")</td>
-                        </tr>
-                    }
-                }
-            </tbody>
-        </table>
-
-        <partial name="_Pagination" model="Model.Pagination" />
-    </div>
-</div>
-
-@section Scripts {
-    <script>
-        function updateQueryString() {
-            let queryParams = new URLSearchParams();
-
-            const search = document.getElementById("search").value;
-            const keyword = document.getElementById("keyword").value;
-            const startAt = document.getElementById("startAt").value;
-            const endAt = document.getElementById("endAt").value;
-
-            if (search) queryParams.set("search", search);
-            if (keyword) queryParams.set("keyword", keyword);
-            if (startAt) queryParams.set("startAt", startAt);
-            if (endAt) queryParams.set("endAt", endAt);
-            queryParams.set("perPage", document.getElementById("perPage").value);
-            queryParams.set("sort", document.getElementById("sort").value);
-            queryParams.set("tab", @Model.Query.Tab);
-
-            window.location.href = window.location.pathname + "?" + queryParams.toString();
-        }
-
-        $(document).on("change", "#perPage, #sort", function () { updateQueryString(); });
-
-        $(document).on("click", "#btnSearch", function (e) {
-            e.preventDefault();
-            updateQueryString();
-        });
-
-        $(document).on("keyup", "#keyword, #startAt, #endAt", function (e) {
-            if (e.which === 13 || e.key === "Enter") {
-                e.preventDefault();
-                updateQueryString();
-            }
-        });
-    </script>
-}

+ 0 - 76
Admin/Pages/Donation/Rank/Index.cshtml.cs

@@ -1,76 +0,0 @@
-using SharedKernel.Helpers;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
-
-namespace Admin.Pages.Donation.Rank;
-
-public class IndexModel(IMediator mediator) : PageModel
-{
-    [BindProperty(SupportsGet = true)]
-    public QueryParams Query { get; set; } = new();
-
-    public sealed class QueryParams
-    {
-        [Range(1, int.MaxValue)]
-        [DisplayName("페이지 번호")]
-        public int PageNum { get; set; } = 1;
-
-        [Range(1, 100)]
-        [DisplayName("페이지 목록 수")]
-        public ushort PerPage { get; set; } = 10;
-
-        [DisplayName("검색 조건")]
-        [Range(1, 7, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
-        public int? Search { get; set; }
-
-        [DisplayName("검색어")]
-        [MaxLength(100, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
-        public string? Keyword { get; set; }
-
-        [DisplayName("시작일")]
-        public string? StartAt { get; set; }
-
-        [DisplayName("종료일")]
-        public string? EndAt { get; set; }
-
-        [DisplayName("탭")]
-        [Range(0, 6)]
-        public int Tab { get; set; } = 0;
-
-        [DisplayName("정렬")]
-        [Range(1, 4)]
-        public int Sort { get; set; } = 1;
-    }
-
-    public int Total { get; set; } = 0;
-
-    public List<SearchDonationRanks.Response.Row> List { get; set; } = [];
-
-    public Pagination? Pagination { get; set; }
-
-    public async Task OnGetAsync(CancellationToken ct)
-    {
-        if (!ModelState.IsValid)
-        {
-            return;
-        }
-
-        var result = await mediator.Send(new SearchDonationRanks.Query(
-            Query.PageNum,
-            Query.PerPage,
-            Query.Search,
-            Query.Keyword,
-            Query.StartAt,
-            Query.EndAt,
-            Query.Tab,
-            Query.Sort
-        ), ct);
-
-        Total = result.Total;
-        List = [.. result.List];
-        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage, Request.QueryString.ToString());
-    }
-}

+ 0 - 109
Admin/Pages/Store/Game/Catalog.cshtml

@@ -1,109 +0,0 @@
-@page "{id:int}"
-@model Admin.Pages.Store.Game.CatalogModel
-@{
-    ViewData["Title"] = "SKU 정가 카탈로그";
-}
-
-<div class="container-fluid">
-    <h3>@ViewData["Title"] @(Model.Data != null ? $"— {Model.Data.GameName}" : "")</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    @if (Model.Data == null)
-    {
-        <div class="alert alert-warning">게임 정보를 불러올 수 없습니다.</div>
-    }
-    else
-    {
-        <div class="alert alert-light border">
-            <div>게임 코드: <code>@(Model.Data.GameCode ?? "-")</code> / API 수수료율: <strong>@Model.Data.ApiCommissionRate%</strong></div>
-            <small class="text-muted">
-                파트너가 결제 보고(/v1/purchases) 시 보낸 productID(SKU)가 이 카탈로그에 있으면 보고 금액을 정가와 대조합니다.
-                불일치 건은 등록은 되지만 <span class="badge bg-danger">불일치</span> 표시되어 결제 보고 내역에서 검토할 수 있습니다.
-                카탈로그에 없는 SKU 는 검증 없이 통과합니다.
-            </small>
-        </div>
-
-        <form method="post" asp-page-handler="Add">
-            <input type="hidden" asp-for="Add.GameID" />
-            <div class="row g-2 align-items-end mb-3">
-                <div class="col-12 col-sm-auto">
-                    <label asp-for="Add.ProductID" class="form-label">SKU(상품 ID)</label>
-                    <input asp-for="Add.ProductID" class="form-control" maxlength="100" placeholder="예) diamond_100" />
-                </div>
-                <div class="col-6 col-sm-auto">
-                    <label asp-for="Add.Price" class="form-label">정가(원)</label>
-                    <input asp-for="Add.Price" class="form-control" type="number" min="1" />
-                </div>
-                <div class="col-auto">
-                    <button type="submit" class="btn btn-primary">추가</button>
-                </div>
-            </div>
-        </form>
-
-        <div class="table-responsive">
-            <table class="table table-bordered table-hover align-middle">
-                <thead>
-                    <tr>
-                        <th>ID</th>
-                        <th>SKU</th>
-                        <th style="width:180px">정가(원)</th>
-                        <th style="width:100px">사용</th>
-                        <th>등록/수정일</th>
-                        <th style="width:160px">관리</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    @if (Model.Data.List.Count <= 0)
-                    {
-                        <tr>
-                            <td colspan="6" class="text-center text-muted py-4">등록된 SKU 가 없습니다.</td>
-                        </tr>
-                    }
-                    else
-                    {
-                        @foreach (var row in Model.Data.List)
-                        {
-                            <tr>
-                                <td>@row.ID</td>
-                                <td><code>@row.ProductID</code></td>
-                                <td>
-                                    <input type="number" name="price" value="@row.Price" min="1" class="form-control form-control-sm" form="fUpdate-@row.ID" />
-                                </td>
-                                <td>
-                                    <input type="checkbox" name="isActive" value="true" class="form-check-input" checked="@row.IsActive" form="fUpdate-@row.ID" />
-                                </td>
-                                <td>
-                                    @row.CreatedAt.ToString("yyyy-MM-dd")
-                                    @if (row.UpdatedAt != null)
-                                    {
-                                        <small class="text-muted">(수정 @row.UpdatedAt.Value.ToString("yyyy-MM-dd"))</small>
-                                    }
-                                </td>
-                                <td>
-                                    <form id="fUpdate-@row.ID" method="post" asp-page-handler="Update" class="d-inline">
-                                        <input type="hidden" name="gameID" value="@Model.Data.GameID" />
-                                        <input type="hidden" name="id" value="@row.ID" />
-                                        <button type="submit" class="btn btn-sm btn-outline-primary">저장</button>
-                                    </form>
-                                    <form method="post" asp-page-handler="Delete" class="d-inline" onsubmit="return confirm('SKU @row.ProductID 를 삭제할까요?');">
-                                        <input type="hidden" name="gameID" value="@Model.Data.GameID" />
-                                        <input type="hidden" name="id" value="@row.ID" />
-                                        <button type="submit" class="btn btn-sm btn-outline-danger">삭제</button>
-                                    </form>
-                                </td>
-                            </tr>
-                        }
-                    }
-                </tbody>
-            </table>
-        </div>
-
-        <hr />
-
-        <div class="d-flex justify-content-center">
-            <a href="/Store/Game/Edit/@Model.Data.GameID" class="btn btn-secondary">게임 수정으로</a>
-        </div>
-    }
-</div>

+ 0 - 102
Admin/Pages/Store/Game/Catalog.cshtml.cs

@@ -1,102 +0,0 @@
-using SharedKernel.Extensions;
-using Application.Abstractions.Messaging;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
-
-namespace Admin.Pages.Store.Game;
-
-/// <summary>
-/// 게임별 인앱 상품 SKU 정가 카탈로그 관리.
-/// 파트너 결제 보고(/v1/purchases) 금액 검증용 — 보고가가 정가와 다르면 불일치 flag.
-/// </summary>
-public class CatalogModel(IMediator mediator) : PageModel
-{
-    public Application.Features.Admin.Store.GameCatalog.GetByGame.Response? Data { get; private set; }
-
-    [BindProperty]
-    public AddInput Add { get; set; } = new();
-
-    public sealed class AddInput
-    {
-        public int GameID { get; set; }
-
-        [DisplayName("SKU(상품 ID)")]
-        [Required(ErrorMessage = "{0}은(는) 필수입니다.")]
-        [StringLength(100, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
-        public string ProductID { get; set; } = null!;
-
-        [DisplayName("정가(원)")]
-        [Range(1, int.MaxValue, ErrorMessage = "{0}은(는) 1 이상이어야 합니다.")]
-        public int Price { get; set; }
-    }
-
-    public async Task OnGetAsync(int id, CancellationToken ct)
-    {
-        var result = await mediator.Send(new Application.Features.Admin.Store.GameCatalog.GetByGame.Query(id), ct);
-
-        if (result.IsFailure)
-        {
-            TempData["ErrorMessages"] = result.Error.Description;
-            return;
-        }
-
-        Data = result.Value;
-        Add.GameID = id;
-    }
-
-    public async Task<IActionResult> OnPostAddAsync(CancellationToken ct)
-    {
-        if (!ModelState.IsValid)
-        {
-            TempData["ErrorMessages"] = ModelState.GetErrorMessages();
-            return Redirect($"/Store/Game/Catalog/{Add.GameID}");
-        }
-
-        var result = await mediator.Send(new Application.Features.Admin.Store.GameCatalog.Create.Command(Add.GameID, Add.ProductID, Add.Price), ct);
-
-        if (result.IsFailure)
-        {
-            TempData["ErrorMessages"] = result.Error.Description;
-        }
-        else
-        {
-            TempData["SuccessMessage"] = $"SKU {Add.ProductID} 가 등록되었습니다.";
-        }
-
-        return Redirect($"/Store/Game/Catalog/{Add.GameID}");
-    }
-
-    public async Task<IActionResult> OnPostUpdateAsync(int gameID, int id, int price, bool isActive, CancellationToken ct)
-    {
-        var result = await mediator.Send(new Application.Features.Admin.Store.GameCatalog.Update.Command(id, price, isActive), ct);
-
-        if (result.IsFailure)
-        {
-            TempData["ErrorMessages"] = result.Error.Description;
-        }
-        else
-        {
-            TempData["SuccessMessage"] = "카탈로그 항목이 수정되었습니다.";
-        }
-
-        return Redirect($"/Store/Game/Catalog/{gameID}");
-    }
-
-    public async Task<IActionResult> OnPostDeleteAsync(int gameID, int id, CancellationToken ct)
-    {
-        var result = await mediator.Send(new Application.Features.Admin.Store.GameCatalog.Delete.Command(id), ct);
-
-        if (result.IsFailure)
-        {
-            TempData["ErrorMessages"] = result.Error.Description;
-        }
-        else
-        {
-            TempData["SuccessMessage"] = "카탈로그 항목이 삭제되었습니다.";
-        }
-
-        return Redirect($"/Store/Game/Catalog/{gameID}");
-    }
-}

+ 0 - 19
Admin/Pages/Store/Game/Edit.cshtml

@@ -289,25 +289,6 @@
             </div>
         </div>
 
-        <div class="row mb-2">
-            <label asp-for="Input.ApiCommissionRate" class="col-sm-2 col-form-label">API 수수료율(%)</label>
-            <div class="col-sm-10">
-                <div class="row">
-                    <div class="col col-md-auto">
-                        <div class="input-group">
-                            <input asp-for="Input.ApiCommissionRate" id="apiCommissionRate" class="form-control" type="number" step="any" min="0" max="100" />
-                            <span class="input-group-text">%</span>
-                        </div>
-                    </div>
-                </div>
-                <small class="form-text text-muted">게임 내 결제 보고(공개 API /v1/purchases) 시 채널에 적립되는 수수료율. 0 이면 해당 게임 결제 등록이 거부됩니다.</small>
-                <div class="mt-1">
-                    <a href="/Store/Game/Catalog/@Model.Input.ID" class="btn btn-sm btn-outline-secondary">SKU 정가 카탈로그 관리</a>
-                </div>
-                <span asp-validation-for="Input.ApiCommissionRate" class="text-danger"></span>
-            </div>
-        </div>
-
         <hr />
 
         <div class="row mb-2">

+ 0 - 6
Admin/Pages/Store/Game/Edit.cshtml.cs

@@ -76,10 +76,6 @@ public class EditModel(IMediator mediator) : PageModel
         [Range(0, 100, ErrorMessage = "{0} 값의 범위는 {1} ~ {2} 입니다.")]
         public decimal CommissionRate { get; set; }
 
-        [DisplayName("API 수수료율(%)")]
-        [Range(0, 100, ErrorMessage = "{0} 값의 범위는 {1} ~ {2} 입니다.")]
-        public decimal ApiCommissionRate { get; set; }
-
         [DisplayName("게임 사이트 주소")]
         [StringLength(255, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
         public string? Link { get; set; }
@@ -159,7 +155,6 @@ public class EditModel(IMediator mediator) : PageModel
             Publisher = data.Publisher,
             Description = data.Description,
             CommissionRate = data.CommissionRate,
-            ApiCommissionRate = data.ApiCommissionRate,
             Link = data.Link,
             Order = data.Order,
             IsActive = data.IsActive,
@@ -217,7 +212,6 @@ public class EditModel(IMediator mediator) : PageModel
             Input.RemoveBigImage,
             Input.Description,
             Input.CommissionRate,
-            Input.ApiCommissionRate,
             Input.Link,
             Input.Order,
             Input.IsActive,

+ 0 - 16
Admin/Pages/Store/Game/Write.cshtml

@@ -183,22 +183,6 @@
             </div>
         </div>
 
-        <div class="row mb-2">
-            <label asp-for="Input.ApiCommissionRate" class="col-sm-2 col-form-label">API 수수료율(%)</label>
-            <div class="col-sm-10">
-                <div class="row">
-                    <div class="col col-md-auto">
-                        <div class="input-group">
-                            <input asp-for="Input.ApiCommissionRate" id="apiCommissionRate" class="form-control" type="number" step="any" min="0" max="100" />
-                            <span class="input-group-text">%</span>
-                        </div>
-                    </div>
-                </div>
-                <small class="form-text text-muted">게임 내 결제 보고(공개 API /v1/purchases) 시 채널에 적립되는 수수료율. 0 이면 해당 게임 결제 등록이 거부됩니다.</small>
-                <span asp-validation-for="Input.ApiCommissionRate" class="text-danger"></span>
-            </div>
-        </div>
-
         <hr />
 
         <div class="row mb-2">

+ 0 - 5
Admin/Pages/Store/Game/Write.cshtml.cs

@@ -54,10 +54,6 @@ public class WriteModel(IMediator mediator) : PageModel
         [Range(0, 100, ErrorMessage = "{0} 값의 범위는 {1} ~ {2} 입니다.")]
         public decimal CommissionRate { get; set; }
 
-        [DisplayName("API 수수료율(%)")]
-        [Range(0, 100, ErrorMessage = "{0} 값의 범위는 {1} ~ {2} 입니다.")]
-        public decimal ApiCommissionRate { get; set; }
-
         [DisplayName("게임 사이트 주소")]
         [StringLength(255, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
         public string? Link { get; set; }
@@ -135,7 +131,6 @@ public class WriteModel(IMediator mediator) : PageModel
             Input.BigImageFile,
             Input.Description,
             Input.CommissionRate,
-            Input.ApiCommissionRate,
             Input.Link,
             Input.Order,
             Input.IsActive,

+ 0 - 14
Admin/using.cs

@@ -196,7 +196,6 @@ global using GetChannel = Application.Features.Admin.Channel.List.Get;
 global using CreateChannel = Application.Features.Admin.Channel.List.Create;
 global using UpdateChannel = Application.Features.Admin.Channel.List.Update;
 global using DeleteChannel = Application.Features.Admin.Channel.List.Delete;
-global using GenerateChannelDonationCode = Application.Features.Admin.Channel.List.GenerateDonationCode;
 
 // 채널 - 경험치 관리
 global using GetChannelExpConfig = Application.Features.Admin.Channel.ExpConfig.Get;
@@ -213,19 +212,6 @@ global using TestYouTubeApiKey = Application.Features.Admin.Channel.YouTubePubSu
 global using ForceYouTubeLive = Application.Features.Admin.Channel.YouTubePubSub.ForceLive;
 global using ForceYouTubeOffline = Application.Features.Admin.Channel.YouTubePubSub.ForceOffline;
 
-// 후원 내역
-global using SearchDonations = Application.Features.Admin.Donation.List.Search;
-global using DeleteDonations = Application.Features.Admin.Donation.List.Delete;
-global using ExportDonations = Application.Features.Admin.Donation.List.Export;
-
-// 후원 알림
-global using SearchDonationAlerts = Application.Features.Admin.Donation.Alert.Search;
-global using DeleteDonationAlert = Application.Features.Admin.Donation.Alert.Delete;
-global using ResendDonationAlert = Application.Features.Admin.Donation.Alert.Resend;
-
-// 후원 순위
-global using SearchDonationRanks = Application.Features.Admin.Donation.Rank.Search;
-
 // 다날 결제 내역
 global using SearchDanalConfirms = Application.Features.Admin.Payment.Danal.Confirm.Search;
 global using GetDanalConfirm = Application.Features.Admin.Payment.Danal.Confirm.Get;

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

@@ -13,12 +13,10 @@ using Domain.Entities.Director;
 using Domain.Entities.EmailVerification;
 using Domain.Entities.Forum.Logs;
 using Domain.Entities.Page.Popup;
-using Domain.Entities.Donations;
 using Domain.Entities.Notes;
 using Domain.Entities.Payments.Danal;
 using Domain.Entities.Feed;
 using Domain.Entities.Store;
-using Domain.Entities.Developers;
 
 namespace Application.Abstractions.Data;
 
@@ -105,32 +103,6 @@ public interface IAppDbContext
     DbSet<CommentFileDownLog> CommentFileDownLog { 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<SettlementAccount> SettlementAccount { get; set; }
-    DbSet<WithdrawalRequest> WithdrawalRequest { get; set; }
-    DbSet<DonationGoalConfig> DonationGoalConfig { get; set; }
-    DbSet<DonationRankConfig> DonationRankConfig { get; set; }
-    DbSet<DonationRanking> DonationRanking { get; set; }
-    DbSet<RankingSnapshot> RankingSnapshot { get; set; }
-    DbSet<ChannelTitle> ChannelTitle { get; set; }
-    DbSet<DonorChannelStats> DonorChannelStats { get; set; }
-    DbSet<DonorTitleSelection> DonorTitleSelection { get; set; }
-
-    // 크루
-    DbSet<Crew> Crew { get; set; }
-    DbSet<CrewMember> CrewMember { get; set; }
-    DbSet<CrewInvitation> CrewInvitation { get; set; }
-    DbSet<CrewSession> CrewSession { get; set; }
-    DbSet<CrewSessionConsent> CrewSessionConsent { get; set; }
-    DbSet<CrewDonationSummary> CrewDonationSummary { get; set; }
-    DbSet<CrewWidgetConfig> CrewWidgetConfig { get; set; }
-
     // 피드 (X/Twitter + Reddit 스타일 — Forum과 완전 분리)
     DbSet<FeedPost> FeedPost { get; set; }
     DbSet<FeedComment> FeedComment { get; set; }
@@ -176,19 +148,6 @@ public interface IAppDbContext
     DbSet<Shipment> Shipment { get; set; }
     DbSet<GameSettlement> GameSettlement { get; set; }
     DbSet<ProductLimitConfig> ProductLimitConfig { get; set; }
-    DbSet<GameProductCatalog> GameProductCatalog { get; set; }
-
-    // 개발자 포털 (Developers API)
-    DbSet<Developer> Developer { get; set; }
-    DbSet<NiceAuthSession> NiceAuthSession { get; set; }
-    DbSet<ApiApplication> ApiApplication { get; set; }
-    DbSet<ApiCredential> ApiCredential { get; set; }
-    DbSet<ApiApplicationScope> ApiApplicationScope { get; set; }
-    DbSet<ApiPersonalAccessToken> ApiPersonalAccessToken { get; set; }
-    DbSet<ApiRequestLog> ApiRequestLog { get; set; }
-    DbSet<ApiRateLimit> ApiRateLimit { get; set; }
-    DbSet<ApiPurchase> ApiPurchase { get; set; }
-    DbSet<ApiPurchaseCancel> ApiPurchaseCancel { get; set; }
 
     Task<int> SaveChangesAsync(CancellationToken ct = default);
 }

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

@@ -26,20 +26,6 @@ public interface IAppHubClient
     // ── 채널 상태 (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);
-
-    /// <summary>
-    /// 크루원 리스트 변경 (추가/수정/삭제/초대응답).
-    /// payload: { kind: "added" | "updated" | "removed" | "invitationStatusChanged", data: object }
-    /// 운영자(JoinCrew 그룹 구독자)에게만 broadcast.
-    /// </summary>
-    Task ReceiveCrewMemberChange(object payload);
-
     // ── 피드 ─────────────────────────────────────────────────────
     Task ReceiveFeedPost(object toast);
 

+ 0 - 14
Application/Abstractions/Hub/ICrewHubBroadcaster.cs

@@ -1,14 +0,0 @@
-namespace Application.Abstractions.Hub;
-
-/// <summary>
-/// 크루원 리스트 변경을 운영자 그룹(`crew:{crewID}`)에 broadcast.
-/// 운영자는 SignalR <c>JoinCrew(crewID)</c> 로 그룹 가입.
-/// </summary>
-public interface ICrewHubBroadcaster
-{
-    /// <summary>크루원 변경 이벤트 broadcast.</summary>
-    /// <param name="crewID">대상 크루 ID.</param>
-    /// <param name="kind">"added" | "updated" | "removed" | "invitationStatusChanged".</param>
-    /// <param name="data">kind 별 페이로드 객체. SignalR JSON 직렬화 시 camelCase.</param>
-    Task BroadcastMemberChangeAsync(int crewID, string kind, object data, CancellationToken ct = default);
-}

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

@@ -1,20 +0,0 @@
-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 ReceiveAlertStatusUpdate(object update);
-    Task ReceiveQueueReorder(object reorderData);
-
-    // ── 목표 + 순위 ─────────────────────────────────────────────
-    Task ReceiveGoalUpdate(object progressData);
-    Task ReceiveRankUpdate(object rankingData);
-    Task ReceiveCrewUpdate(object crewRankData);
-}

+ 0 - 41
Application/Abstractions/Kyc/INiceAuthClient.cs

@@ -1,41 +0,0 @@
-using SharedKernel.Results;
-
-namespace Application.Abstractions.Kyc;
-
-/// <summary>
-/// NICE 통합인증 API 클라이언트. (token / url / result 3개 endpoint 호출 + 복호화)
-///
-/// 가이드: .claude/plan/nice-auth-integration.md
-/// </summary>
-public interface INiceAuthClient
-{
-    /// <summary>
-    /// access_token 발급 (24시간 유효 — 내부에서 Redis 캐시).
-    /// 운영자는 직접 호출할 일 거의 없음 — RequestAuthUrl/GetResult 가 내부에서 호출.
-    /// </summary>
-    Task<Result<NiceTokenInfo>> GetTokenAsync(CancellationToken ct);
-
-    /// <summary>
-    /// 표준창에 띄울 auth_url 발급. transaction_id 는 10분 유효.
-    /// 가맹점은 응답의 (AuthUrl, TransactionId, RequestNo, Ticket, Iterators) 를 NiceAuthSession 에 저장.
-    /// </summary>
-    /// <param name="sessionToken">
-    /// 가맹점 NiceAuthSession.SessionToken. NICE return_url path segment 로 embed 되어 callback 매칭에 사용.
-    /// (Backend 가 NiceAuthOptions.ReturnUrl base 에 자동 append)
-    /// </param>
-    Task<Result<NiceUrlInfo>> RequestAuthUrlAsync(string sessionToken, CancellationToken ct);
-
-    /// <summary>
-    /// NICE 표준창 인증 완료 후 result 호출 + 복호화. 성공 시 사용자 정보 반환.
-    ///
-    /// ticket / iterators 는 <see cref="RequestAuthUrlAsync"/> 시점 token 발급의 값
-    /// (NiceAuthSession 에 보관한 값) 을 그대로 전달해야 함.
-    /// </summary>
-    Task<Result<NiceUserInfo>> GetResultAsync(
-        string requestNo,
-        string transactionId,
-        string webTransactionId,
-        string ticket,
-        int iterators,
-        CancellationToken ct);
-}

+ 0 - 38
Application/Abstractions/Kyc/NiceAuthModels.cs

@@ -1,38 +0,0 @@
-namespace Application.Abstractions.Kyc;
-
-/// <summary>
-/// NICE 통합인증 access_token 발급 응답.
-/// access_token 자체는 캐시 내부에서만 사용. ticket / iterators 는 result 복호화 시 KDF 입력.
-/// </summary>
-public sealed record NiceTokenInfo(
-    string AccessToken,
-    string Ticket,
-    int Iterators,
-    DateTimeOffset ExpiresAt);
-
-/// <summary>
-/// NICE 인증 URL 발급 응답 — 표준창에 띄울 URL + 이후 result 호출에 필요한 값들.
-/// RequestNo / Ticket / Iterators 는 result 호출 시 동일 값 사용 — NiceAuthSession 에 row 단위 보관.
-/// </summary>
-public sealed record NiceUrlInfo(
-    string AuthUrl,
-    string TransactionId,
-    string RequestNo,
-    string Ticket,
-    int Iterators);
-
-/// <summary>
-/// NICE 인증 결과 복호화 후 사용자 정보.
-/// 가이드 §2-3 의 enc_data 평문 JSON 과 1:1 매핑.
-/// 휴대폰 인증이 아닐 경우 MobileCo / MobileNo 는 null.
-/// </summary>
-public sealed record NiceUserInfo(
-    string Name,
-    string Birthdate,
-    string Gender,
-    string NationalInfo,
-    string Ci,
-    string? Ci2,
-    string Di,
-    string? MobileCo,
-    string? MobileNo);

+ 0 - 14
Application/Abstractions/Notification/IAdminAlertService.cs

@@ -1,14 +0,0 @@
-namespace Application.Abstractions.Notification;
-
-/// <summary>
-/// 운영자용 외부 메신저 알림 (Nextcloud Talk).
-/// 발송은 fire-and-forget — 실패해도 호출 흐름(파트너 API 응답)에 영향을 주지 않는다.
-/// </summary>
-public interface IAdminAlertService
-{
-    /// <summary>API 사용 로그성 알림 (모든 결제 등록/취소) — 활동 방.</summary>
-    Task SendActivityAsync(string message, CancellationToken ct = default);
-
-    /// <summary>이상 징후 알림 (정가 불일치, 미회수 shortfall, 배치 오류) — 알림 방.</summary>
-    Task SendAlertAsync(string message, CancellationToken ct = default);
-}

+ 0 - 11
Application/Abstractions/RateLimit/IRateLimiter.cs

@@ -1,11 +0,0 @@
-namespace Application.Abstractions.RateLimit;
-
-/// <summary>
-/// Sliding window rate limiter abstraction (구현은 Redis).
-/// </summary>
-public interface IRateLimiter
-{
-    Task<RateLimitResult> CheckAsync(string key, int limit, TimeSpan window, CancellationToken ct = default);
-}
-
-public sealed record RateLimitResult(bool Allowed, int Limit, int Remaining, DateTime ResetAt);

+ 0 - 21
Application/Features/Admin/Channel/List/Delete/Handler.cs

@@ -20,30 +20,9 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
             return;
         }
 
-        if (await db.Donation.AnyAsync(c => request.IDs.Contains(c.ChannelID), ct))
-            throw new InvalidOperationException("후원 내역이 존재하는 채널은 삭제할 수 없습니다.");
-
-        if (await db.DonorChannelStats.AnyAsync(c => request.IDs.Contains(c.ChannelID), ct))
-            throw new InvalidOperationException("후원 통계 데이터가 존재하는 채널은 삭제할 수 없습니다.");
-
-        if (await db.DonorTitleSelection.AnyAsync(c => request.IDs.Contains(c.ChannelID), ct))
-            throw new InvalidOperationException("후원자 칭호 데이터가 존재하는 채널은 삭제할 수 없습니다.");
-
-        if (await db.Settlement.AnyAsync(c => request.IDs.Contains(c.ChannelID), ct))
-            throw new InvalidOperationException("정산 내역이 존재하는 채널은 삭제할 수 없습니다.");
-
-        if (await db.SettlementAccount.AnyAsync(c => request.IDs.Contains(c.ChannelID), ct))
-            throw new InvalidOperationException("정산 계좌 정보가 존재하는 채널은 삭제할 수 없습니다.");
-
-        if (await db.WithdrawalRequest.AnyAsync(c => request.IDs.Contains(c.ChannelID), ct))
-            throw new InvalidOperationException("출금 요청 내역이 존재하는 채널은 삭제할 수 없습니다.");
-
         if (await db.Order.AnyAsync(c => c.ChannelID.HasValue && request.IDs.Contains(c.ChannelID.Value), ct))
             throw new InvalidOperationException("상점 주문 내역이 존재하는 채널은 삭제할 수 없습니다.");
 
-        if (await db.CrewMember.AnyAsync(c => c.ChannelID.HasValue && request.IDs.Contains(c.ChannelID.Value), ct))
-            throw new InvalidOperationException("크루 멤버로 등록된 채널은 삭제할 수 없습니다. 크루에서 먼저 탈퇴하거나 제외해 주세요.");
-
         db.Channel.RemoveRange(list);
 
         await db.SaveChangesAsync(ct);

+ 0 - 47
Application/Features/Admin/Channel/List/GenerateDonationCode/Handler.cs

@@ -1,47 +0,0 @@
-using System.Security.Cryptography;
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Channel.List.GenerateDonationCode;
-
-internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, string>
-{
-    // 혼동되는 문자(0/O, 1/I/L) 제외 31자. 6자리 조합 31^6 ≈ 8.8억.
-    private const string Alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
-
-    private const int CodeLength = 6;
-
-    private const int MaxAttempts = 10;
-
-    public async Task<string> Handle(Query request, CancellationToken ct)
-    {
-        for (var attempt = 0; attempt < MaxAttempts; attempt++)
-        {
-            var candidate = Generate();
-            var exists = await db.Channel.AnyAsync(c => c.DonationCode == candidate, ct);
-
-            if (!exists)
-            {
-                return candidate;
-            }
-        }
-
-        // 31^6 ≈ 8.8억 중 10회 모두 충돌은 극히 낮은 확률. 호출자 측 유효성 검증으로 한 번 더 안전망.
-        return Generate();
-    }
-
-    private static string Generate()
-    {
-        var chars = new char[CodeLength];
-        var bytes = new byte[CodeLength];
-        RandomNumberGenerator.Fill(bytes);
-
-        for (var i = 0; i < CodeLength; i++)
-        {
-            chars[i] = Alphabet[bytes[i] % Alphabet.Length];
-        }
-
-        return new string(chars);
-    }
-}

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

@@ -1,6 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Channel.List.GenerateDonationCode;
-
-/// <summary>채널 후원 코드(6자 영숫자) 자동 생성. 기존 Channel.DonationCode 와 중복되지 않는 값을 반환.</summary>
-public sealed record Query() : IQuery<string>;

+ 0 - 6
Application/Features/Admin/Developers/Accounts/ApproveDeveloper/Command.cs

@@ -1,6 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Developers.Accounts.ApproveDeveloper;
-
-public sealed record Command(int DeveloperID, string AdminID) : ICommand<Result>;

+ 0 - 35
Application/Features/Admin/Developers/Accounts/ApproveDeveloper/Handler.cs

@@ -1,35 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Developers.ValueObject;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Configuration;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Developers.Accounts.ApproveDeveloper;
-
-internal sealed class Handler(IAppDbContext db, IConfiguration config) : ICommandHandler<Command, Result>
-{
-    public async Task<Result> Handle(Command request, CancellationToken ct)
-    {
-        var dev = await db.Developer
-            .AsTracking()
-            .FirstOrDefaultAsync(c => c.ID == request.DeveloperID, ct);
-
-        if (dev is null)
-        {
-            return Result.Failure(Error.NotFound("Developer.NotFound", "개발자 정보를 찾을 수 없습니다."));
-        }
-
-        // dev 환경: Developers:SkipKyc=true 이면 KYC 검증 우회 (NICE/다날 미연동 상태에서도 진행 가능).
-        // production 에는 이 설정을 두지 말 것.
-        var skipKyc = config.GetValue<bool>("Developers:SkipKyc", false);
-        if (!skipKyc && dev.KycStatus != KycStatus.Verified)
-        {
-            return Result.Failure(Error.Problem("Developer.KycRequired", "본인인증이 완료되지 않은 계정은 승인할 수 없습니다."));
-        }
-
-        dev.Approve(request.AdminID);
-        await db.SaveChangesAsync(ct);
-        return Result.Success();
-    }
-}

+ 0 - 3
Application/Features/Admin/Developers/Accounts/ApproveDeveloper/Response.cs

@@ -1,3 +0,0 @@
-namespace Application.Features.Admin.Developers.Accounts.ApproveDeveloper;
-
-public sealed record Response;

+ 0 - 61
Application/Features/Admin/Developers/Accounts/ListDevelopers/Handler.cs

@@ -1,61 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Developers.Accounts.ListDevelopers;
-
-internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var page = Math.Max(1, request.PageNum);
-        var size = Math.Clamp(request.PerPage <= 0 ? 20 : request.PerPage, 1, 100);
-        var keyword = request.Keyword?.Trim();
-
-        var query = db.Developer.AsQueryable();
-        if (request.ApprovalStatus.HasValue)
-        {
-            query = query.Where(c => c.ApprovalStatus == request.ApprovalStatus.Value);
-        }
-        if (request.KycStatus.HasValue)
-        {
-            query = query.Where(c => c.KycStatus == request.KycStatus.Value);
-        }
-        if (!string.IsNullOrEmpty(keyword))
-        {
-            query = query.Where(c =>
-                c.CompanyName.Contains(keyword) ||
-                c.BusinessNumber.Contains(keyword) ||
-                c.CeoName.Contains(keyword) ||
-                c.ContactName.Contains(keyword) ||
-                (c.Member != null && c.Member.Email.Contains(keyword))
-            );
-        }
-
-        var total = await query.CountAsync(ct);
-        var list = await query
-            .OrderByDescending(c => c.CreatedAt)
-            .Skip((page - 1) * size)
-            .Take(size)
-            .Select(c => new DeveloperRow(
-                c.ID,
-                c.MemberID,
-                c.Member!.Email,
-                c.Member.Name,
-                c.CompanyName,
-                c.BusinessNumber,
-                c.CeoName,
-                c.ContactName,
-                c.ContactPhone,
-                c.KycStatus,
-                c.KycVerifiedAt,
-                c.ApprovalStatus,
-                c.ApprovalReason,
-                c.ApprovedAt,
-                c.CreatedAt
-            ))
-            .ToListAsync(ct);
-
-        return new Response(total, list);
-    }
-}

+ 0 - 12
Application/Features/Admin/Developers/Accounts/ListDevelopers/Query.cs

@@ -1,12 +0,0 @@
-using Application.Abstractions.Messaging;
-using Domain.Entities.Developers.ValueObject;
-
-namespace Application.Features.Admin.Developers.Accounts.ListDevelopers;
-
-public sealed record Query(
-    DeveloperApprovalStatus? ApprovalStatus,
-    KycStatus? KycStatus,
-    string? Keyword,
-    int PageNum,
-    int PerPage
-) : IQuery<Response>;

+ 0 - 23
Application/Features/Admin/Developers/Accounts/ListDevelopers/Response.cs

@@ -1,23 +0,0 @@
-using Domain.Entities.Developers.ValueObject;
-
-namespace Application.Features.Admin.Developers.Accounts.ListDevelopers;
-
-public sealed record DeveloperRow(
-    int ID,
-    int MemberID,
-    string Email,
-    string? Name,
-    string CompanyName,
-    string BusinessNumber,
-    string CeoName,
-    string ContactName,
-    string ContactPhone,
-    KycStatus KycStatus,
-    DateTime? KycVerifiedAt,
-    DeveloperApprovalStatus ApprovalStatus,
-    string? ApprovalReason,
-    DateTime? ApprovedAt,
-    DateTime CreatedAt
-);
-
-public sealed record Response(int Total, IReadOnlyList<DeveloperRow> List);

+ 0 - 6
Application/Features/Admin/Developers/Accounts/RejectDeveloper/Command.cs

@@ -1,6 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Developers.Accounts.RejectDeveloper;
-
-public sealed record Command(int DeveloperID, string AdminID, string? Reason) : ICommand<Result>;

+ 0 - 25
Application/Features/Admin/Developers/Accounts/RejectDeveloper/Handler.cs

@@ -1,25 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Developers.Accounts.RejectDeveloper;
-
-internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
-{
-    public async Task<Result> Handle(Command request, CancellationToken ct)
-    {
-        var dev = await db.Developer
-            .AsTracking()
-            .FirstOrDefaultAsync(c => c.ID == request.DeveloperID, ct);
-
-        if (dev is null)
-        {
-            return Result.Failure(Error.NotFound("Developer.NotFound", "개발자 정보를 찾을 수 없습니다."));
-        }
-
-        dev.Reject(request.AdminID, request.Reason);
-        await db.SaveChangesAsync(ct);
-        return Result.Success();
-    }
-}

+ 0 - 3
Application/Features/Admin/Developers/Accounts/RejectDeveloper/Response.cs

@@ -1,3 +0,0 @@
-namespace Application.Features.Admin.Developers.Accounts.RejectDeveloper;
-
-public sealed record Response;

+ 0 - 6
Application/Features/Admin/Developers/Accounts/SuspendDeveloper/Command.cs

@@ -1,6 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Developers.Accounts.SuspendDeveloper;
-
-public sealed record Command(int DeveloperID, string AdminID, string? Reason) : ICommand<Result>;

+ 0 - 25
Application/Features/Admin/Developers/Accounts/SuspendDeveloper/Handler.cs

@@ -1,25 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Developers.Accounts.SuspendDeveloper;
-
-internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
-{
-    public async Task<Result> Handle(Command request, CancellationToken ct)
-    {
-        var dev = await db.Developer
-            .AsTracking()
-            .FirstOrDefaultAsync(c => c.ID == request.DeveloperID, ct);
-
-        if (dev is null)
-        {
-            return Result.Failure(Error.NotFound("Developer.NotFound", "개발자 정보를 찾을 수 없습니다."));
-        }
-
-        dev.Suspend(request.AdminID, request.Reason);
-        await db.SaveChangesAsync(ct);
-        return Result.Success();
-    }
-}

+ 0 - 3
Application/Features/Admin/Developers/Accounts/SuspendDeveloper/Response.cs

@@ -1,3 +0,0 @@
-namespace Application.Features.Admin.Developers.Accounts.SuspendDeveloper;
-
-public sealed record Response;

+ 0 - 5
Application/Features/Admin/Developers/ApproveApp/Command.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Developers.ApproveApp;
-
-public sealed record Command(int[] AppIDs) : ICommand<Response>;

+ 0 - 24
Application/Features/Admin/Developers/ApproveApp/Handler.cs

@@ -1,24 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Developers.ApproveApp;
-
-internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
-{
-    public async Task<Response> Handle(Command request, CancellationToken ct)
-    {
-        var apps = await db.ApiApplication
-            .AsTracking()
-            .Where(c => request.AppIDs.Contains(c.ID))
-            .ToListAsync(ct);
-
-        foreach (var app in apps)
-        {
-            app.Approve();
-        }
-
-        await db.SaveChangesAsync(ct);
-        return new Response(apps.Count);
-    }
-}

+ 0 - 3
Application/Features/Admin/Developers/ApproveApp/Response.cs

@@ -1,3 +0,0 @@
-namespace Application.Features.Admin.Developers.ApproveApp;
-
-public sealed record Response(int AffectedCount);

+ 0 - 37
Application/Features/Admin/Developers/GlobalStats/Handler.cs

@@ -1,37 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Developers.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Developers.GlobalStats;
-
-internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    private const int DefaultDays = 7;
-    private const int MaxDays = 90;
-
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var days = Math.Clamp(request.Days <= 0 ? DefaultDays : request.Days, 1, MaxDays);
-        var since = DateTime.UtcNow.AddDays(-days);
-
-        var totalApps = await db.ApiApplication.CountAsync(ct);
-        var activeApps = await db.ApiApplication.CountAsync(c => c.Status == ApplicationStatus.Active, ct);
-        var pendingApps = await db.ApiApplication.CountAsync(c => c.Status == ApplicationStatus.Pending, ct);
-        var totalPats = await db.ApiPersonalAccessToken.CountAsync(ct);
-        var activePats = await db.ApiPersonalAccessToken.CountAsync(c => c.RevokedAt == null, ct);
-
-        var daily = await db.ApiRequestLog
-            .Where(c => c.RequestedAt >= since)
-            .GroupBy(c => DateOnly.FromDateTime(c.RequestedAt))
-            .Select(g => new DailyStat(
-                g.Key,
-                g.Count(),
-                g.Count(c => c.StatusCode >= 400)
-            ))
-            .OrderBy(c => c.Date)
-            .ToListAsync(ct);
-
-        return new Response(totalApps, activeApps, pendingApps, totalPats, activePats, daily);
-    }
-}

+ 0 - 5
Application/Features/Admin/Developers/GlobalStats/Query.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Developers.GlobalStats;
-
-public sealed record Query(int Days) : IQuery<Response>;

+ 0 - 12
Application/Features/Admin/Developers/GlobalStats/Response.cs

@@ -1,12 +0,0 @@
-namespace Application.Features.Admin.Developers.GlobalStats;
-
-public sealed record DailyStat(DateOnly Date, int TotalCalls, int ErrorCalls);
-
-public sealed record Response(
-    int TotalApps,
-    int ActiveApps,
-    int PendingApps,
-    int TotalPats,
-    int ActivePats,
-    IReadOnlyList<DailyStat> Daily
-);

+ 0 - 48
Application/Features/Admin/Developers/ListApps/Handler.cs

@@ -1,48 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Developers.ListApps;
-
-internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var page = Math.Max(1, request.PageNum);
-        var size = Math.Clamp(request.PerPage <= 0 ? 20 : request.PerPage, 1, 100);
-        var keyword = request.Keyword?.Trim();
-
-        var query = db.ApiApplication.AsQueryable();
-        if (request.Status.HasValue)
-        {
-            query = query.Where(c => c.Status == request.Status.Value);
-        }
-        if (!string.IsNullOrEmpty(keyword))
-        {
-            query = query.Where(c => c.Name.Contains(keyword) || (c.Description != null && c.Description.Contains(keyword)));
-        }
-
-        var total = await query.CountAsync(ct);
-        var list = await query
-            .OrderByDescending(c => c.CreatedAt)
-            .Skip((page - 1) * size)
-            .Take(size)
-            .Select(c => new AppRow(
-                c.ID,
-                c.Name,
-                c.Description,
-                c.HomepageUrl,
-                c.Status,
-                c.OwnerMemberID,
-                c.Owner!.Email,
-                c.Owner.Name,
-                c.Credentials.Count,
-                c.CreatedAt,
-                c.ApprovedAt,
-                c.SuspendedAt
-            ))
-            .ToListAsync(ct);
-
-        return new Response(total, list);
-    }
-}

+ 0 - 11
Application/Features/Admin/Developers/ListApps/Query.cs

@@ -1,11 +0,0 @@
-using Application.Abstractions.Messaging;
-using Domain.Entities.Developers.ValueObject;
-
-namespace Application.Features.Admin.Developers.ListApps;
-
-public sealed record Query(
-    ApplicationStatus? Status,
-    string? Keyword,
-    int PageNum,
-    int PerPage
-) : IQuery<Response>;

+ 0 - 20
Application/Features/Admin/Developers/ListApps/Response.cs

@@ -1,20 +0,0 @@
-using Domain.Entities.Developers.ValueObject;
-
-namespace Application.Features.Admin.Developers.ListApps;
-
-public sealed record AppRow(
-    int ID,
-    string Name,
-    string? Description,
-    string? HomepageUrl,
-    ApplicationStatus Status,
-    int OwnerMemberID,
-    string OwnerEmail,
-    string? OwnerName,
-    int CredentialCount,
-    DateTime CreatedAt,
-    DateTime? ApprovedAt,
-    DateTime? SuspendedAt
-);
-
-public sealed record Response(int Total, IReadOnlyList<AppRow> List);

+ 0 - 6
Application/Features/Admin/Developers/OverrideRateLimit/Command.cs

@@ -1,6 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Developers.OverrideRateLimit;
-
-public sealed record Command(int AppID, int LimitPerMinute) : ICommand<Result>;

+ 0 - 40
Application/Features/Admin/Developers/OverrideRateLimit/Handler.cs

@@ -1,40 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Developers;
-using Microsoft.EntityFrameworkCore;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Developers.OverrideRateLimit;
-
-internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
-{
-    public async Task<Result> Handle(Command request, CancellationToken ct)
-    {
-        if (request.LimitPerMinute <= 0 || request.LimitPerMinute > 100000)
-        {
-            return Result.Failure(Error.Problem("RateLimit.Invalid", "LimitPerMinute must be 1-100000."));
-        }
-
-        var appExists = await db.ApiApplication.AnyAsync(c => c.ID == request.AppID, ct);
-        if (!appExists)
-        {
-            return Result.Failure(Error.NotFound("App.NotFound", "App not found."));
-        }
-
-        var existing = await db.ApiRateLimit
-            .AsTracking()
-            .FirstOrDefaultAsync(c => c.ApplicationID == request.AppID, ct);
-
-        if (existing is null)
-        {
-            await db.ApiRateLimit.AddAsync(ApiRateLimit.Create(request.AppID, request.LimitPerMinute), ct);
-        }
-        else
-        {
-            existing.SetLimit(request.LimitPerMinute);
-        }
-
-        await db.SaveChangesAsync(ct);
-        return Result.Success();
-    }
-}

+ 0 - 3
Application/Features/Admin/Developers/OverrideRateLimit/Response.cs

@@ -1,3 +0,0 @@
-namespace Application.Features.Admin.Developers.OverrideRateLimit;
-
-public sealed record Response;

+ 0 - 106
Application/Features/Admin/Developers/Purchases/Search/Handler.cs

@@ -1,106 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Developers.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Developers.Purchases.Search;
-
-internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var page = Math.Max(1, request.PageNum);
-        var size = Math.Clamp(request.PerPage, 1, 100);
-
-        var query = db.ApiPurchase.AsNoTracking().AsQueryable();
-
-        if (request.Status.HasValue)
-        {
-            query = query.Where(c => c.Status == request.Status.Value);
-        }
-
-        if (!string.IsNullOrWhiteSpace(request.Keyword))
-        {
-            var keyword = request.Keyword.Trim();
-            var upper = keyword.ToUpperInvariant();
-            query = query.Where(c => c.OrderID == keyword || c.Channel!.DonationCode == upper || c.Game!.Code == keyword || c.Channel!.Name.Contains(keyword));
-        }
-
-        if (request.From.HasValue)
-        {
-            query = query.Where(c => c.CreatedAt >= request.From.Value);
-        }
-
-        if (request.To.HasValue)
-        {
-            var to = request.To.Value.Date.AddDays(1);
-            query = query.Where(c => c.CreatedAt < to);
-        }
-
-        if (request.OnlyMismatch)
-        {
-            query = query.Where(c => c.PriceMismatched);
-        }
-
-        var total = await query.CountAsync(ct);
-
-        // 집계 (검색 조건 범위) — 이상 패턴 검토용
-        var summaryRaw = await query
-            .GroupBy(c => 1)
-            .Select(g => new
-            {
-                TotalOrderPrice = g.Sum(c => (long)c.OrderPrice),
-                TotalCommission = g.Sum(c => (long)c.CommissionAmount),
-                PendingCount = g.Count(c => c.Status == ApiPurchaseStatus.Pending),
-                ConfirmedCount = g.Count(c => c.Status == ApiPurchaseStatus.Confirmed),
-                CanceledCount = g.Count(c => c.Status == ApiPurchaseStatus.Canceled),
-                MismatchCount = g.Count(c => c.PriceMismatched)
-            })
-            .FirstOrDefaultAsync(ct);
-
-        var totalShortfall = await db.ApiPurchaseCancel.AsNoTracking()
-            .Where(x => query.Any(c => c.ID == x.PurchaseID))
-            .SumAsync(x => (long?)x.ShortfallAmount, ct) ?? 0;
-
-        var summary = new Response.SummaryData(
-            summaryRaw?.TotalOrderPrice ?? 0,
-            summaryRaw?.TotalCommission ?? 0,
-            summaryRaw?.PendingCount ?? 0,
-            summaryRaw?.ConfirmedCount ?? 0,
-            summaryRaw?.CanceledCount ?? 0,
-            summaryRaw?.MismatchCount ?? 0,
-            totalShortfall
-        );
-
-        var list = await query
-            .OrderByDescending(c => c.ID)
-            .Skip((page - 1) * size)
-            .Take(size)
-            .Select(c => new Response.Row(
-                c.ID,
-                c.Application!.Name,
-                c.Channel!.Name,
-                c.Channel!.DonationCode,
-                c.Game!.KorName,
-                c.OrderID,
-                c.Marketplace,
-                c.ProductID,
-                c.OrderPrice,
-                c.CommissionRate,
-                c.CommissionAmount,
-                c.CatalogPrice,
-                c.PriceMismatched,
-                c.Status,
-                c.ConfirmDueAt,
-                c.ConfirmedAt,
-                c.CanceledAt,
-                c.CreatedAt,
-                db.ApiPurchaseCancel.Where(x => x.PurchaseID == c.ID).Select(x => (bool?)x.WasConfirmed).FirstOrDefault(),
-                db.ApiPurchaseCancel.Where(x => x.PurchaseID == c.ID).Select(x => (int?)x.RecoveredAmount).FirstOrDefault(),
-                db.ApiPurchaseCancel.Where(x => x.PurchaseID == c.ID).Select(x => (int?)x.ShortfallAmount).FirstOrDefault()
-            ))
-            .ToListAsync(ct);
-
-        return new Response(total, summary, list);
-    }
-}

+ 0 - 19
Application/Features/Admin/Developers/Purchases/Search/Query.cs

@@ -1,19 +0,0 @@
-using Application.Abstractions.Messaging;
-using Domain.Entities.Developers.ValueObject;
-
-namespace Application.Features.Admin.Developers.Purchases.Search;
-
-/// <summary>
-/// API 결제 보고 내역 검색.
-/// Keyword: orderID(정확)/채널 후원코드(정확)/게임 코드(정확)/채널명(부분).
-/// OnlyMismatch: SKU 카탈로그 정가 불일치 건만 (과소 보고 검토).
-/// </summary>
-public sealed record Query(
-    ApiPurchaseStatus? Status,
-    string? Keyword,
-    DateTime? From,
-    DateTime? To,
-    bool OnlyMismatch,
-    int PageNum,
-    int PerPage
-) : IQuery<Response>;

+ 0 - 41
Application/Features/Admin/Developers/Purchases/Search/Response.cs

@@ -1,41 +0,0 @@
-using Domain.Entities.Developers.ValueObject;
-
-namespace Application.Features.Admin.Developers.Purchases.Search;
-
-public sealed record Response(int Total, Response.SummaryData Summary, List<Response.Row> List)
-{
-    /// <summary>검색 조건 범위의 집계 — 이상 패턴(평균단가 급변/취소율) 검토용</summary>
-    public sealed record SummaryData(
-        long TotalOrderPrice,
-        long TotalCommission,
-        int PendingCount,
-        int ConfirmedCount,
-        int CanceledCount,
-        int MismatchCount,
-        long TotalShortfall
-    );
-
-    public sealed record Row(
-        int ID,
-        string AppName,
-        string ChannelName,
-        string? ChannelCode,
-        string GameName,
-        string OrderID,
-        ApiPurchaseMarketplace Marketplace,
-        string? ProductID,
-        int OrderPrice,
-        decimal CommissionRate,
-        int CommissionAmount,
-        int? CatalogPrice,
-        bool PriceMismatched,
-        ApiPurchaseStatus Status,
-        DateTime ConfirmDueAt,
-        DateTime? ConfirmedAt,
-        DateTime? CanceledAt,
-        DateTime CreatedAt,
-        bool? CancelWasConfirmed,
-        int? RecoveredAmount,
-        int? ShortfallAmount
-    );
-}

+ 0 - 5
Application/Features/Admin/Developers/SuspendApp/Command.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Developers.SuspendApp;
-
-public sealed record Command(int[] AppIDs) : ICommand<Response>;

+ 0 - 24
Application/Features/Admin/Developers/SuspendApp/Handler.cs

@@ -1,24 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Developers.SuspendApp;
-
-internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
-{
-    public async Task<Response> Handle(Command request, CancellationToken ct)
-    {
-        var apps = await db.ApiApplication
-            .AsTracking()
-            .Where(c => request.AppIDs.Contains(c.ID))
-            .ToListAsync(ct);
-
-        foreach (var app in apps)
-        {
-            app.Suspend();
-        }
-
-        await db.SaveChangesAsync(ct);
-        return new Response(apps.Count);
-    }
-}

+ 0 - 3
Application/Features/Admin/Developers/SuspendApp/Response.cs

@@ -1,3 +0,0 @@
-namespace Application.Features.Admin.Developers.SuspendApp;
-
-public sealed record Response(int AffectedCount);

+ 0 - 5
Application/Features/Admin/Donation/Alert/Delete/Command.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Donation.Alert.Delete;
-
-public sealed record Command(int[] IDs) : ICommand;

+ 0 - 20
Application/Features/Admin/Donation/Alert/Delete/Handler.cs

@@ -1,20 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Donation.Alert.Delete;
-
-public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
-{
-    public async Task Handle(Command request, CancellationToken ct)
-    {
-        if (request.IDs is null || request.IDs.Length == 0)
-        {
-            return;
-        }
-
-        // 재시도 이력부터 삭제 (FK)
-        await db.DonationAlertAttempt.Where(a => request.IDs.Contains(a.DonationAlertID)).ExecuteDeleteAsync(ct);
-        await db.DonationAlert.Where(x => request.IDs.Contains(x.ID)).ExecuteDeleteAsync(ct);
-    }
-}

+ 0 - 5
Application/Features/Admin/Donation/Alert/Resend/Command.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Donation.Alert.Resend;
-
-public sealed record Command(int ID) : ICommand;

+ 0 - 33
Application/Features/Admin/Donation/Alert/Resend/Handler.cs

@@ -1,33 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Donations.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Donation.Alert.Resend;
-
-public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
-{
-    public async Task Handle(Command request, CancellationToken ct)
-    {
-        var alert = await db.DonationAlert.FirstOrDefaultAsync(x => x.ID == request.ID, ct)
-            ?? throw new InvalidOperationException("후원 알림을 찾을 수 없습니다.");
-
-        if (alert.Status == AlertStatus.Delivered)
-        {
-            throw new InvalidOperationException("이미 수신된 알림은 재전송할 수 없습니다.");
-        }
-
-        // Domain은 Failed/Skipped만 Resend() 허용 — 그 외 상태는 Queued로 재설정
-        if (alert.Status is AlertStatus.Failed or AlertStatus.Skipped)
-        {
-            alert.Resend();
-        }
-        else
-        {
-            alert.MarkFailed();
-            alert.Resend();
-        }
-
-        await db.SaveChangesAsync(ct);
-    }
-}

+ 0 - 113
Application/Features/Admin/Donation/Alert/Search/Handler.cs

@@ -1,113 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Donations.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Donation.Alert.Search;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var query = db.DonationAlert.AsNoTracking().Include(x => x.Sponsor).Include(x => x.Receiver).AsQueryable();
-
-        if (!string.IsNullOrWhiteSpace(request.Keyword))
-        {
-            query = request.Search switch
-            {
-                1 => query.Where(x => x.Sponsor != null && x.Sponsor.ID.ToString().Contains(request.Keyword)),
-                2 => query.Where(x => x.Sponsor != null && x.Sponsor.SID.Contains(request.Keyword)),
-                3 => query.Where(x => x.Sponsor != null && x.Sponsor.Name != null && x.Sponsor.Name.Contains(request.Keyword)),
-                4 => query.Where(x => x.Receiver != null && x.Receiver.ID.ToString().Contains(request.Keyword)),
-                5 => query.Where(x => x.Receiver != null && x.Receiver.SID.Contains(request.Keyword)),
-                6 => query.Where(x => x.Receiver != null && x.Receiver.Name != null && x.Receiver.Name.Contains(request.Keyword)),
-                _ => query
-            };
-        }
-
-        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
-        {
-            query = query.Where(x => x.CreatedAt >= startAt);
-        }
-
-        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
-        {
-            query = query.Where(x => x.CreatedAt <= endAt);
-        }
-
-        // 탭별 건수 집계 (탭 필터 적용 전)
-        var counts = await query.GroupBy(c => 1).Select(c => new
-        {
-            Received = c.Count(x => x.Status == AlertStatus.Delivered),
-            NotReceived = c.Count(x => x.Status != AlertStatus.Delivered)
-        }).FirstOrDefaultAsync(ct);
-
-        var received = counts?.Received ?? 0;
-        var notReceived = counts?.NotReceived ?? 0;
-
-        query = request.Tab switch
-        {
-            1 => query.Where(x => x.Status == AlertStatus.Delivered),
-            2 => query.Where(x => x.Status != AlertStatus.Delivered),
-            _ => query
-        };
-
-        var total = await query.CountAsync(ct);
-        var skip = (request.Page - 1) * request.PerPage;
-
-        var list = await query
-            .OrderByDescending(x => x.ID)
-            .Skip(skip)
-            .Take(request.PerPage)
-            .Select(x => new
-            {
-                x.ID,
-                x.DonationID,
-                x.Status,
-                x.StartAt,
-                x.ArrivedAt,
-                x.IpAddress,
-                x.CreatedAt,
-                SponsorID = x.Sponsor != null ? x.Sponsor.ID : 0,
-                SponsorEmail = x.Sponsor != null ? x.Sponsor.Email : string.Empty,
-                SponsorName = x.Sponsor != null ? x.Sponsor.Name : null,
-                ReceiverID = x.Receiver != null ? x.Receiver.ID : 0,
-                ReceiverEmail = x.Receiver != null ? x.Receiver.Email : string.Empty,
-                ReceiverName = x.Receiver != null ? x.Receiver.Name : null
-            })
-            .ToListAsync(ct);
-
-        var rows = list.Select((x, idx) => new Response.Row
-        {
-            Num = total - skip - idx,
-            ID = x.ID,
-            DonationID = x.DonationID,
-            Sponsor = new Response.MemberInfo
-            {
-                ID = x.SponsorID,
-                Email = x.SponsorEmail,
-                Name = x.SponsorName
-            },
-            Receiver = new Response.MemberInfo
-            {
-                ID = x.ReceiverID,
-                Email = x.ReceiverEmail,
-                Name = x.ReceiverName
-            },
-            Status = x.Status.ToString(),
-            IsReceived = x.Status == AlertStatus.Delivered,
-            StartAt = x.StartAt,
-            ArrivedAt = x.ArrivedAt,
-            IpAddress = x.IpAddress,
-            CreatedAt = x.CreatedAt
-        }).ToList();
-
-        return new Response
-        {
-            Total = total,
-            Received = received,
-            NotReceived = notReceived,
-            List = rows
-        };
-    }
-}

+ 0 - 13
Application/Features/Admin/Donation/Alert/Search/Query.cs

@@ -1,13 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Donation.Alert.Search;
-
-public sealed record Query(
-    int Page,
-    ushort PerPage,
-    int? Search = null,
-    string? Keyword = null,
-    string? StartAt = null,
-    string? EndAt = null,
-    int Tab = 0
-) : IQuery<Response>;

+ 0 - 32
Application/Features/Admin/Donation/Alert/Search/Response.cs

@@ -1,32 +0,0 @@
-namespace Application.Features.Admin.Donation.Alert.Search;
-
-public sealed class Response
-{
-    public int Total { get; init; }
-    public int Received { get; init; }
-    public int NotReceived { get; init; }
-
-    public required IReadOnlyList<Row> List { get; init; }
-
-    public sealed class Row
-    {
-        public int Num { get; init; }
-        public int ID { get; init; }
-        public int DonationID { get; init; }
-        public required MemberInfo Sponsor { get; init; }
-        public required MemberInfo Receiver { get; init; }
-        public required string Status { get; init; }
-        public bool IsReceived { get; init; }
-        public DateTime StartAt { get; init; }
-        public DateTime? ArrivedAt { get; init; }
-        public string? IpAddress { get; init; }
-        public required DateTime CreatedAt { get; init; }
-    }
-
-    public sealed class MemberInfo
-    {
-        public int ID { get; init; }
-        public required string Email { get; init; }
-        public string? Name { get; init; }
-    }
-}

+ 0 - 5
Application/Features/Admin/Donation/List/Delete/Command.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Donation.List.Delete;
-
-public sealed record Command(int[] IDs) : ICommand;

+ 0 - 20
Application/Features/Admin/Donation/List/Delete/Handler.cs

@@ -1,20 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Donation.List.Delete;
-
-public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
-{
-    public async Task Handle(Command request, CancellationToken ct)
-    {
-        if (request.IDs is null || request.IDs.Length == 0)
-        {
-            return;
-        }
-
-        // DonationAlert (1:1)이 붙어 있으면 FK 제약 회피 위해 먼저 삭제
-        await db.DonationAlert.Where(a => request.IDs.Contains(a.DonationID)).ExecuteDeleteAsync(ct);
-        await db.Donation.Where(x => request.IDs.Contains(x.ID)).ExecuteDeleteAsync(ct);
-    }
-}

+ 0 - 92
Application/Features/Admin/Donation/List/Export/Handler.cs

@@ -1,92 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Wallets.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Donation.List.Export;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var query = db.Donation.AsNoTracking().Include(x => x.Sponsor).Include(x => x.Receiver).AsQueryable();
-
-        if (!string.IsNullOrWhiteSpace(request.Keyword))
-        {
-            query = request.Search switch
-            {
-                1 => query.Where(x => x.Sponsor != null && x.Sponsor.ID.ToString().Contains(request.Keyword)),
-                2 => query.Where(x => x.Sponsor != null && x.Sponsor.SID.Contains(request.Keyword)),
-                3 => query.Where(x => x.Sponsor != null && x.Sponsor.Name != null && x.Sponsor.Name.Contains(request.Keyword)),
-                4 => query.Where(x => x.Receiver != null && x.Receiver.ID.ToString().Contains(request.Keyword)),
-                5 => query.Where(x => x.Receiver != null && x.Receiver.SID.Contains(request.Keyword)),
-                6 => query.Where(x => x.Receiver != null && x.Receiver.Name != null && x.Receiver.Name.Contains(request.Keyword)),
-                7 => query.Where(x => x.SendName.Contains(request.Keyword)),
-                _ => query
-            };
-        }
-
-        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
-        {
-            query = query.Where(x => x.CreatedAt >= startAt);
-        }
-
-        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
-        {
-            query = query.Where(x => x.CreatedAt <= endAt);
-        }
-
-        var list = await query.OrderByDescending(x => x.ID).Select(x => new
-        {
-            x.ID,
-            x.Amount,
-            x.SendName,
-            x.Message,
-            x.CreatedAt,
-            x.SponsorMemberID,
-            x.ReceiverMemberID,
-            SponsorEmail = x.Sponsor != null ? x.Sponsor.Email : string.Empty,
-            SponsorName = x.Sponsor != null ? x.Sponsor.Name : null,
-            SponsorID = x.Sponsor != null ? x.Sponsor.ID : 0,
-            ReceiverEmail = x.Receiver != null ? x.Receiver.Email : string.Empty,
-            ReceiverName = x.Receiver != null ? x.Receiver.Name : null,
-            ReceiverID = x.Receiver != null ? x.Receiver.ID : 0
-        }).ToListAsync(ct);
-
-        var memberIDs = list.Select(x => x.SponsorMemberID).Concat(list.Select(x => x.ReceiverMemberID)).Distinct().ToArray();
-
-        var wallets = await db.Wallet.AsNoTracking().Where(w => memberIDs.Contains(w.MemberID)).Include(w => w.Balances).ToListAsync(ct);
-
-        var balanceByMember = wallets.ToDictionary(
-            w => w.MemberID,
-            w => new
-            {
-                Total = (long)w.Balances.Where(b => b.Type != WalletBalanceType.Locked).Sum(b => b.Amount.Value),
-                Donation = (long)(w.Balances.FirstOrDefault(b => b.Type == WalletBalanceType.Donation)?.Amount.Value ?? 0)
-            });
-
-        var rows = list.Select(x =>
-        {
-            var sponsorBalance = balanceByMember.TryGetValue(x.SponsorMemberID, out var sb) ? sb.Total : 0L;
-            var receiverBalance = balanceByMember.TryGetValue(x.ReceiverMemberID, out var rb) ? rb.Donation : 0L;
-
-            return new Dictionary<string, object?>
-            {
-                ["ID"] = x.ID,
-                ["후원금액"] = x.Amount,
-                ["보낸이름"] = x.SendName,
-                ["후원자"] = $"[{x.SponsorID}] {x.SponsorEmail}, {x.SponsorName ?? "-"}",
-                ["후원자잔고"] = sponsorBalance,
-                ["피후원자"] = $"[{x.ReceiverID}] {x.ReceiverEmail}, {x.ReceiverName ?? "-"}",
-                ["피후원자잔고"] = receiverBalance,
-                ["전달내용"] = x.Message ?? string.Empty,
-                ["등록일시KST"] = x.CreatedAt.AddHours(9).ToString("yyyy-MM-dd HH:mm:ss")
-            };
-        }).ToList();
-
-        return new Response
-        {
-            Rows = rows
-        };
-    }
-}

+ 0 - 10
Application/Features/Admin/Donation/List/Export/Query.cs

@@ -1,10 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Donation.List.Export;
-
-public sealed record Query(
-    int? Search = null,
-    string? Keyword = null,
-    string? StartAt = null,
-    string? EndAt = null
-) : IQuery<Response>;

+ 0 - 6
Application/Features/Admin/Donation/List/Export/Response.cs

@@ -1,6 +0,0 @@
-namespace Application.Features.Admin.Donation.List.Export;
-
-public sealed class Response
-{
-    public required IReadOnlyList<Dictionary<string, object?>> Rows { get; init; }
-}

+ 0 - 119
Application/Features/Admin/Donation/List/Search/Handler.cs

@@ -1,119 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Wallets.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Donation.List.Search;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var query = db.Donation.AsNoTracking().Include(x => x.Sponsor).Include(x => x.Receiver).AsQueryable();
-
-        // 키워드 검색
-        if (!string.IsNullOrWhiteSpace(request.Keyword))
-        {
-            query = request.Search switch
-            {
-                1 => query.Where(x => x.Sponsor != null && x.Sponsor.ID.ToString().Contains(request.Keyword)),
-                2 => query.Where(x => x.Sponsor != null && x.Sponsor.SID.Contains(request.Keyword)),
-                3 => query.Where(x => x.Sponsor != null && x.Sponsor.Name != null && x.Sponsor.Name.Contains(request.Keyword)),
-                4 => query.Where(x => x.Receiver != null && x.Receiver.ID.ToString().Contains(request.Keyword)),
-                5 => query.Where(x => x.Receiver != null && x.Receiver.SID.Contains(request.Keyword)),
-                6 => query.Where(x => x.Receiver != null && x.Receiver.Name != null && x.Receiver.Name.Contains(request.Keyword)),
-                7 => query.Where(x => x.SendName.Contains(request.Keyword)),
-                _ => query
-            };
-        }
-
-        // 날짜 필터
-        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
-        {
-            query = query.Where(x => x.CreatedAt >= startAt);
-        }
-
-        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
-        {
-            query = query.Where(x => x.CreatedAt <= endAt);
-        }
-
-        var total = await query.CountAsync(ct);
-        var skip = (request.Page - 1) * request.PerPage;
-
-        var list = await query
-            .OrderByDescending(x => x.ID)
-            .Skip(skip)
-            .Take(request.PerPage)
-            .Select(x => new
-            {
-                x.ID,
-                x.Amount,
-                x.Message,
-                x.SendName,
-                x.IsTest,
-                x.CreatedAt,
-                x.SponsorMemberID,
-                x.ReceiverMemberID,
-                SponsorID = x.Sponsor != null ? x.Sponsor.ID : 0,
-                SponsorEmail = x.Sponsor != null ? x.Sponsor.Email : string.Empty,
-                SponsorName = x.Sponsor != null ? x.Sponsor.Name : null,
-                SponsorSID = x.Sponsor != null ? x.Sponsor.SID : null,
-                ReceiverID = x.Receiver != null ? x.Receiver.ID : 0,
-                ReceiverEmail = x.Receiver != null ? x.Receiver.Email : string.Empty,
-                ReceiverName = x.Receiver != null ? x.Receiver.Name : null,
-                ReceiverSID = x.Receiver != null ? x.Receiver.SID : null
-            })
-            .ToListAsync(ct);
-
-        // 관련 회원의 지갑 잔액 일괄 조회 (행별 N+1 방지)
-        var memberIDs = list.Select(x => x.SponsorMemberID).Concat(list.Select(x => x.ReceiverMemberID)).Distinct().ToArray();
-
-        var wallets = await db.Wallet
-            .AsNoTracking()
-            .Where(w => memberIDs.Contains(w.MemberID))
-            .Include(w => w.Balances)
-            .ToListAsync(ct);
-
-        var balanceByMember = wallets.ToDictionary(
-            w => w.MemberID,
-            w => new
-            {
-                Total = (long)w.Balances.Where(b => b.Type != WalletBalanceType.Locked).Sum(b => b.Amount.Value),
-                Donation = (long)(w.Balances.FirstOrDefault(b => b.Type == WalletBalanceType.Donation)?.Amount.Value ?? 0)
-            });
-
-        var rows = list.Select((x, idx) => new Response.Row
-        {
-            Num = total - skip - idx,
-            ID = x.ID,
-            Amount = x.Amount,
-            Message = x.Message,
-            SendName = x.SendName,
-            IsTest = x.IsTest,
-            Sponsor = new Response.MemberInfo
-            {
-                ID = x.SponsorID,
-                Email = x.SponsorEmail,
-                Name = x.SponsorName,
-                SID = x.SponsorSID
-            },
-            Receiver = new Response.MemberInfo
-            {
-                ID = x.ReceiverID,
-                Email = x.ReceiverEmail,
-                Name = x.ReceiverName,
-                SID = x.ReceiverSID
-            },
-            SponsorBalance = balanceByMember.TryGetValue(x.SponsorMemberID, out var sb) ? sb.Total : 0L,
-            ReceiverDonationBalance = balanceByMember.TryGetValue(x.ReceiverMemberID, out var rb) ? rb.Donation : 0L,
-            CreatedAt = x.CreatedAt
-        }).ToList();
-
-        return new Response
-        {
-            Total = total,
-            List = rows
-        };
-    }
-}

+ 0 - 12
Application/Features/Admin/Donation/List/Search/Query.cs

@@ -1,12 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Donation.List.Search;
-
-public sealed record Query(
-    int Page,
-    ushort PerPage,
-    int? Search = null,
-    string? Keyword = null,
-    string? StartAt = null,
-    string? EndAt = null
-) : IQuery<Response>;

+ 0 - 31
Application/Features/Admin/Donation/List/Search/Response.cs

@@ -1,31 +0,0 @@
-namespace Application.Features.Admin.Donation.List.Search;
-
-public sealed class Response
-{
-    public int Total { get; init; }
-
-    public required IReadOnlyList<Row> List { get; init; }
-
-    public sealed class Row
-    {
-        public int Num { get; init; }
-        public int ID { get; init; }
-        public int Amount { get; init; }
-        public string? Message { get; init; }
-        public required string SendName { get; init; }
-        public bool IsTest { get; init; }
-        public required MemberInfo Sponsor { get; init; }
-        public required MemberInfo Receiver { get; init; }
-        public long SponsorBalance { get; init; }
-        public long ReceiverDonationBalance { get; init; }
-        public required DateTime CreatedAt { get; init; }
-    }
-
-    public sealed class MemberInfo
-    {
-        public int ID { get; init; }
-        public required string Email { get; init; }
-        public string? Name { get; init; }
-        public string? SID { get; init; }
-    }
-}

+ 0 - 144
Application/Features/Admin/Donation/Rank/Search/Handler.cs

@@ -1,144 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Donation.Rank.Search;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        // 탭 → 기간 범위 (롤링 윈도우)
-        var now = DateTime.UtcNow;
-        var periodStart = request.Tab switch
-        {
-            0 => now.AddHours(-24),   // 오늘
-            1 => now.AddHours(-72),   // 3일
-            2 => now.AddDays(-7),     // 1주일
-            3 => now.AddDays(-30),    // 1개월
-            4 => now.AddDays(-90),    // 3개월
-            5 => now.AddDays(-180),   // 6개월
-            6 => now.AddDays(-365),   // 1년
-            _ => now.AddHours(-24)
-        };
-
-        var donations = db.Donation.AsNoTracking().Where(d => d.CreatedAt >= periodStart && d.CreatedAt <= now);
-
-        // 키워드 검색 (검색 창은 후원 내역과 동일)
-        if (!string.IsNullOrWhiteSpace(request.Keyword))
-        {
-            donations = request.Search switch
-            {
-                1 => donations.Where(x => x.Sponsor != null && x.Sponsor.ID.ToString().Contains(request.Keyword)),
-                2 => donations.Where(x => x.Sponsor != null && x.Sponsor.SID.Contains(request.Keyword)),
-                3 => donations.Where(x => x.Sponsor != null && x.Sponsor.Name != null && x.Sponsor.Name.Contains(request.Keyword)),
-                4 => donations.Where(x => x.Receiver != null && x.Receiver.ID.ToString().Contains(request.Keyword)),
-                5 => donations.Where(x => x.Receiver != null && x.Receiver.SID.Contains(request.Keyword)),
-                6 => donations.Where(x => x.Receiver != null && x.Receiver.Name != null && x.Receiver.Name.Contains(request.Keyword)),
-                7 => donations.Where(x => x.SendName.Contains(request.Keyword)),
-                _ => donations
-            };
-        }
-
-        // 기간 추가 필터 (StartAt/EndAt) — 탭 기간 내에서 추가로 좁히기
-        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
-        {
-            donations = donations.Where(x => x.CreatedAt >= startAt);
-        }
-
-        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
-        {
-            donations = donations.Where(x => x.CreatedAt <= endAt);
-        }
-
-        // 후원자별 집계
-        var grouped = donations.GroupBy(d => d.SponsorMemberID).Select(g => new
-        {
-            SponsorMemberID = g.Key,
-            TotalAmount = g.Sum(x => (long)x.Amount),
-            DonationCount = g.Count(),
-            LastDonatedAt = g.Max(x => (DateTime?)x.CreatedAt)
-        });
-
-        // 정렬 + 순위
-        grouped = request.Sort switch
-        {
-            1 => grouped.OrderByDescending(x => x.TotalAmount).ThenByDescending(x => x.DonationCount),
-            2 => grouped.OrderBy(x => x.TotalAmount).ThenBy(x => x.DonationCount),
-            3 => grouped.OrderByDescending(x => x.DonationCount).ThenByDescending(x => x.TotalAmount),
-            4 => grouped.OrderBy(x => x.DonationCount).ThenBy(x => x.TotalAmount),
-            _ => grouped.OrderByDescending(x => x.TotalAmount).ThenByDescending(x => x.DonationCount)
-        };
-
-        var total = await grouped.CountAsync(ct);
-        var skip = (request.Page - 1) * request.PerPage;
-
-        var paged = await grouped.Skip(skip).Take(request.PerPage).ToListAsync(ct);
-
-        // 마지막 피후원자 lookup — 후원자별 마지막 Donation의 Receiver
-        var sponsorIDs = paged.Select(p => p.SponsorMemberID).ToArray();
-
-        var lastReceivers = await donations
-            .Where(d => sponsorIDs.Contains(d.SponsorMemberID))
-            .GroupBy(d => d.SponsorMemberID)
-            .Select(g => new
-            {
-                SponsorMemberID = g.Key,
-                LastDonationID = g.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.ID).First().ID
-            })
-            .ToListAsync(ct);
-
-        var lastDonationIDs = lastReceivers.Select(r => r.LastDonationID).ToArray();
-
-        var lastDonations = await db.Donation.AsNoTracking()
-            .Where(d => lastDonationIDs.Contains(d.ID))
-            .Include(d => d.Receiver)
-            .Select(d => new
-            {
-                d.ID,
-                d.SponsorMemberID,
-                ReceiverID = d.Receiver != null ? d.Receiver.ID : (int?)null,
-                ReceiverEmail = d.Receiver != null ? d.Receiver.Email : null,
-                ReceiverName = d.Receiver != null ? d.Receiver.Name : null
-            })
-            .ToListAsync(ct);
-
-        var lastBySponsor = lastDonations.ToDictionary(x => x.SponsorMemberID);
-
-        // Sponsor 정보 lookup
-        var sponsors = await db.Member.AsNoTracking()
-            .Where(m => sponsorIDs.Contains(m.ID))
-            .Select(m => new { m.ID, m.Email, m.Name })
-            .ToListAsync(ct);
-
-        var sponsorByID = sponsors.ToDictionary(x => x.ID);
-
-        // Rank 번호 — 현재 페이지에서 skip+1 부터 순서대로
-        var rows = paged.Select((x, idx) =>
-        {
-            sponsorByID.TryGetValue(x.SponsorMemberID, out var sponsor);
-            lastBySponsor.TryGetValue(x.SponsorMemberID, out var last);
-
-            return new Response.Row
-            {
-                Num = total - skip - idx,
-                Rank = skip + idx + 1,
-                SponsorMemberID = x.SponsorMemberID,
-                SponsorEmail = sponsor?.Email ?? string.Empty,
-                SponsorName = sponsor?.Name,
-                TotalAmount = x.TotalAmount,
-                DonationCount = x.DonationCount,
-                LastReceiverID = last?.ReceiverID,
-                LastReceiverEmail = last?.ReceiverEmail,
-                LastReceiverName = last?.ReceiverName,
-                LastDonatedAt = x.LastDonatedAt
-            };
-        }).ToList();
-
-        return new Response
-        {
-            Total = total,
-            List = rows
-        };
-    }
-}

+ 0 - 19
Application/Features/Admin/Donation/Rank/Search/Query.cs

@@ -1,19 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Donation.Rank.Search;
-
-/// <summary>
-/// 후원 순위 검색.
-/// Tab: 0=오늘(24h), 1=3일(72h), 2=1주일, 3=1개월, 4=3개월, 5=6개월, 6=1년 (롤링 윈도우, UtcNow 기준)
-/// Sort: 1=금액 desc(기본), 2=금액 asc, 3=횟수 desc, 4=횟수 asc
-/// </summary>
-public sealed record Query(
-    int Page,
-    ushort PerPage,
-    int? Search = null,
-    string? Keyword = null,
-    string? StartAt = null,
-    string? EndAt = null,
-    int Tab = 0,
-    int Sort = 1
-) : IQuery<Response>;

+ 0 - 23
Application/Features/Admin/Donation/Rank/Search/Response.cs

@@ -1,23 +0,0 @@
-namespace Application.Features.Admin.Donation.Rank.Search;
-
-public sealed class Response
-{
-    public int Total { get; init; }
-
-    public required IReadOnlyList<Row> List { get; init; }
-
-    public sealed class Row
-    {
-        public int Num { get; init; }
-        public int Rank { get; init; }
-        public int SponsorMemberID { get; init; }
-        public required string SponsorEmail { get; init; }
-        public string? SponsorName { get; init; }
-        public long TotalAmount { get; init; }
-        public int DonationCount { get; init; }
-        public int? LastReceiverID { get; init; }
-        public string? LastReceiverEmail { get; init; }
-        public string? LastReceiverName { get; init; }
-        public DateTime? LastDonatedAt { get; init; }
-    }
-}

+ 0 - 1
Application/Features/Admin/Store/Game/Create/Command.cs

@@ -13,7 +13,6 @@ public sealed record Command(
     IFormFile? BigImageFile,
     string? Description,
     decimal CommissionRate,
-    decimal ApiCommissionRate,
     string? Link,
     int Order,
     bool IsActive,

+ 0 - 6
Application/Features/Admin/Store/Game/Create/Handler.cs

@@ -52,12 +52,6 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
             game.Deactivate();
         }
 
-        if (request.ApiCommissionRate < 0 || request.ApiCommissionRate > 100)
-        {
-            return Result.Failure<int>(Error.Problem("Game.InvalidApiCommissionRate", "API 수수료율은 0~100% 사이여야 합니다."));
-        }
-
-        game.SetApiCommissionRate(request.ApiCommissionRate);
         game.SetDetail(request.TrailerUrl, request.MinSpec, request.RecommendedSpec, request.ReleaseDate);
 
         db.Game.Add(game);

+ 0 - 2
Application/Features/Admin/Store/Game/Get/Handler.cs

@@ -23,7 +23,6 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Resp
                 c.BigImage,
                 c.Description,
                 c.CommissionRate,
-                c.ApiCommissionRate,
                 c.Link,
                 c.IsActive,
                 c.Order,
@@ -56,7 +55,6 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Resp
             game.BigImage,
             game.Description,
             game.CommissionRate,
-            game.ApiCommissionRate,
             game.Link,
             game.IsActive,
             game.Order,

+ 0 - 1
Application/Features/Admin/Store/Game/Get/Response.cs

@@ -10,7 +10,6 @@ public sealed record Response(
     string? BigImage,
     string? Description,
     decimal CommissionRate,
-    decimal ApiCommissionRate,
     string? Link,
     bool IsActive,
     int Order,

+ 0 - 1
Application/Features/Admin/Store/Game/Update/Command.cs

@@ -16,7 +16,6 @@ public sealed record Command(
     bool RemoveBigImage,
     string? Description,
     decimal CommissionRate,
-    decimal ApiCommissionRate,
     string? Link,
     int Order,
     bool IsActive,

+ 0 - 6
Application/Features/Admin/Store/Game/Update/Handler.cs

@@ -93,12 +93,6 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
             isActive: request.IsActive
         );
 
-        if (request.ApiCommissionRate < 0 || request.ApiCommissionRate > 100)
-        {
-            return Result.Failure(Error.Problem("Game.InvalidApiCommissionRate", "API 수수료율은 0~100% 사이여야 합니다."));
-        }
-
-        game.SetApiCommissionRate(request.ApiCommissionRate);
         game.SetDetail(request.TrailerUrl, request.MinSpec, request.RecommendedSpec, request.ReleaseDate);
 
         var urlsToDelete = await SyncDetailAsync(game.ID, request, ct);

+ 0 - 10
Application/Features/Admin/Store/GameCatalog/Create/Command.cs

@@ -1,10 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Store.GameCatalog.Create;
-
-public sealed record Command(
-    int GameID,
-    string ProductID,
-    int Price
-) : ICommand<Result>;

+ 0 - 40
Application/Features/Admin/Store/GameCatalog/Create/Handler.cs

@@ -1,40 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Store;
-using Microsoft.EntityFrameworkCore;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Store.GameCatalog.Create;
-
-internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
-{
-    public async Task<Result> Handle(Command request, CancellationToken ct)
-    {
-        if (string.IsNullOrWhiteSpace(request.ProductID))
-        {
-            return Result.Failure(Error.Problem("GameCatalog.ProductIDRequired", "SKU(상품 ID)는 필수입니다."));
-        }
-
-        if (request.Price <= 0)
-        {
-            return Result.Failure(Error.Problem("GameCatalog.PriceInvalid", "정가는 1 이상이어야 합니다."));
-        }
-
-        if (!await db.Game.AnyAsync(c => c.ID == request.GameID, ct))
-        {
-            return Result.Failure(Error.NotFound("Game.NotFound", "게임을 찾을 수 없습니다."));
-        }
-
-        var productID = request.ProductID.Trim();
-
-        if (await db.GameProductCatalog.AnyAsync(c => c.GameID == request.GameID && c.ProductID == productID, ct))
-        {
-            return Result.Failure(Error.Conflict("GameCatalog.Duplicate", "이미 등록된 SKU 입니다."));
-        }
-
-        db.GameProductCatalog.Add(GameProductCatalog.Create(request.GameID, productID, request.Price));
-        await db.SaveChangesAsync(ct);
-
-        return Result.Success();
-    }
-}

+ 0 - 6
Application/Features/Admin/Store/GameCatalog/Delete/Command.cs

@@ -1,6 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Store.GameCatalog.Delete;
-
-public sealed record Command(int ID) : ICommand<Result>;

+ 0 - 24
Application/Features/Admin/Store/GameCatalog/Delete/Handler.cs

@@ -1,24 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Store.GameCatalog.Delete;
-
-internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
-{
-    public async Task<Result> Handle(Command request, CancellationToken ct)
-    {
-        var row = await db.GameProductCatalog.FirstOrDefaultAsync(c => c.ID == request.ID, ct);
-
-        if (row is null)
-        {
-            return Result.Failure(Error.NotFound("GameCatalog.NotFound", "카탈로그 항목을 찾을 수 없습니다."));
-        }
-
-        db.GameProductCatalog.Remove(row);
-        await db.SaveChangesAsync(ct);
-
-        return Result.Success();
-    }
-}

+ 0 - 38
Application/Features/Admin/Store/GameCatalog/GetByGame/Handler.cs

@@ -1,38 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Store.GameCatalog.GetByGame;
-
-internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Response>>
-{
-    public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
-    {
-        var game = await db.Game
-            .AsNoTracking()
-            .Where(c => c.ID == request.GameID)
-            .Select(c => new
-            {
-                c.ID,
-                c.KorName,
-                c.Code,
-                c.ApiCommissionRate
-            })
-            .FirstOrDefaultAsync(ct);
-
-        if (game is null)
-        {
-            return Result.Failure<Response>(Error.NotFound("Game.NotFound", "게임을 찾을 수 없습니다."));
-        }
-
-        var list = await db.GameProductCatalog
-            .AsNoTracking()
-            .Where(c => c.GameID == request.GameID)
-            .OrderBy(c => c.ProductID)
-            .Select(c => new Response.Row(c.ID, c.ProductID, c.Price, c.IsActive, c.CreatedAt, c.UpdatedAt))
-            .ToListAsync(ct);
-
-        return Result.Success(new Response(game.ID, game.KorName, game.Code, game.ApiCommissionRate, list));
-    }
-}

+ 0 - 7
Application/Features/Admin/Store/GameCatalog/GetByGame/Query.cs

@@ -1,7 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Store.GameCatalog.GetByGame;
-
-/// <summary>게임별 SKU 정가 카탈로그 목록 (게임 정보 포함)</summary>
-public sealed record Query(int GameID) : IQuery<Result<Response>>;

+ 0 - 19
Application/Features/Admin/Store/GameCatalog/GetByGame/Response.cs

@@ -1,19 +0,0 @@
-namespace Application.Features.Admin.Store.GameCatalog.GetByGame;
-
-public sealed record Response(
-    int GameID,
-    string GameName,
-    string? GameCode,
-    decimal ApiCommissionRate,
-    List<Response.Row> List
-)
-{
-    public sealed record Row(
-        int ID,
-        string ProductID,
-        int Price,
-        bool IsActive,
-        DateTime CreatedAt,
-        DateTime? UpdatedAt
-    );
-}

+ 0 - 10
Application/Features/Admin/Store/GameCatalog/Update/Command.cs

@@ -1,10 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Admin.Store.GameCatalog.Update;
-
-public sealed record Command(
-    int ID,
-    int Price,
-    bool IsActive
-) : ICommand<Result>;

Some files were not shown because too many files changed in this diff