KIM-JINO5 3 месяцев назад
Родитель
Сommit
9f565c3ea3
100 измененных файлов с 3035 добавлено и 352 удалено
  1. 12 1
      .claude/settings.local.json
  2. 2 2
      Admin/Pages/Banner/List/Edit.cshtml.cs
  3. 1 1
      Admin/Pages/Banner/List/Index.cshtml
  4. 4 3
      Admin/Pages/Banner/List/Index.cshtml.cs
  5. 1 1
      Admin/Pages/Channel/List/Edit.cshtml
  6. 2 2
      Admin/Pages/Channel/List/Edit.cshtml.cs
  7. 73 73
      Admin/Pages/Channel/List/Write.cshtml
  8. 1 1
      Admin/Pages/Config/Company.cshtml.cs
  9. 112 0
      Admin/Pages/Crypto/Board/Index.cshtml
  10. 69 0
      Admin/Pages/Crypto/Board/Index.cshtml.cs
  11. 204 0
      Admin/Pages/Crypto/Category.cshtml
  12. 113 0
      Admin/Pages/Crypto/Category.cshtml.cs
  13. 113 0
      Admin/Pages/Crypto/Curation.cshtml
  14. 81 0
      Admin/Pages/Crypto/Curation.cshtml.cs
  15. 189 0
      Admin/Pages/Crypto/List/Edit.cshtml
  16. 148 0
      Admin/Pages/Crypto/List/Edit.cshtml.cs
  17. 192 0
      Admin/Pages/Crypto/List/Index.cshtml
  18. 120 0
      Admin/Pages/Crypto/List/Index.cshtml.cs
  19. 168 0
      Admin/Pages/Crypto/List/Write.cshtml
  20. 113 0
      Admin/Pages/Crypto/List/Write.cshtml.cs
  21. 60 0
      Admin/Pages/Crypto/TickerConfig.cshtml
  22. 71 0
      Admin/Pages/Crypto/TickerConfig.cshtml.cs
  23. 1 1
      Admin/Pages/Director/AccessLog/Index.cshtml.cs
  24. 1 7
      Admin/Pages/Document/Edit.cshtml
  25. 1 1
      Admin/Pages/Document/Edit.cshtml.cs
  26. 1 7
      Admin/Pages/Document/Write.cshtml
  27. 1 1
      Admin/Pages/Faq/Category.cshtml.cs
  28. 1 1
      Admin/Pages/Faq/List/Index.cshtml
  29. 1 1
      Admin/Pages/Faq/List/Write.cshtml.cs
  30. 1 1
      Admin/Pages/Member/Grade/Edit.cshtml.cs
  31. 1 1
      Admin/Pages/Member/Grade/Index.cshtml
  32. 1 1
      Admin/Pages/Member/Grade/Index.cshtml.cs
  33. 4 1
      Admin/Pages/Member/List/Edit.cshtml.cs
  34. 1 1
      Admin/Pages/Member/Log/Intro.cshtml.cs
  35. 1 1
      Admin/Pages/Member/Log/Login/Info.cshtml.cs
  36. 28 15
      Admin/Pages/Popup/Edit.cshtml
  37. 36 14
      Admin/Pages/Popup/Edit.cshtml.cs
  38. 26 22
      Admin/Pages/Popup/Index.cshtml
  39. 7 3
      Admin/Pages/Popup/Index.cshtml.cs
  40. 194 0
      Admin/Pages/Popup/Position.cshtml
  41. 105 0
      Admin/Pages/Popup/Position.cshtml.cs
  42. 26 13
      Admin/Pages/Popup/Write.cshtml
  43. 35 15
      Admin/Pages/Popup/Write.cshtml.cs
  44. 9 0
      Admin/Pages/Popup/_NavTabs.cshtml
  45. 1 1
      Admin/Pages/Server/Env.cshtml.cs
  46. 2 1
      Admin/Pages/Shared/_MenuItem.cshtml
  47. 10 9
      Admin/Properties/launchSettings.json
  48. 133 105
      Admin/using.cs
  49. BIN
      Admin/wwwroot/uploads/crypto/2/abb2d1bbdae14b0bb07bbc2dab91b5bd.jpeg
  50. 32 0
      Application/Abstractions/Cache/CacheKeys.cs
  51. 10 0
      Application/Abstractions/Cache/ICacheService.cs
  52. 107 0
      Application/Abstractions/Crypto/IUpbitClient.cs
  53. 9 0
      Application/Abstractions/Data/IAppDbContext.cs
  54. 4 0
      Application/Application.csproj
  55. 1 1
      Application/Features/Admin/Banner/Item/Create/Command.cs
  56. 5 3
      Application/Features/Admin/Banner/Item/Create/Handler.cs
  57. 6 0
      Application/Features/Admin/Banner/Item/Delete/Command.cs
  58. 4 2
      Application/Features/Admin/Banner/Item/Delete/Handler.cs
  59. 1 1
      Application/Features/Admin/Banner/Item/Get/Handler.cs
  60. 1 1
      Application/Features/Admin/Banner/Item/Get/Query.cs
  61. 1 1
      Application/Features/Admin/Banner/Item/Get/Response.cs
  62. 1 1
      Application/Features/Admin/Banner/Item/Search/Handler.cs
  63. 1 1
      Application/Features/Admin/Banner/Item/Search/Query.cs
  64. 1 1
      Application/Features/Admin/Banner/Item/Search/Response.cs
  65. 1 1
      Application/Features/Admin/Banner/Item/Update/Command.cs
  66. 6 4
      Application/Features/Admin/Banner/Item/Update/Handler.cs
  67. 1 1
      Application/Features/Admin/Banner/Position/GetAll/Handler.cs
  68. 1 1
      Application/Features/Admin/Banner/Position/GetAll/Query.cs
  69. 1 1
      Application/Features/Admin/Banner/Position/GetAll/Response.cs
  70. 1 1
      Application/Features/Admin/Banner/Position/Save/Command.cs
  71. 4 2
      Application/Features/Admin/Banner/Position/Save/Handler.cs
  72. 1 1
      Application/Features/Admin/Banner/Position/Save/Response.cs
  73. 1 1
      Application/Features/Admin/Channel/List/Create/Command.cs
  74. 1 1
      Application/Features/Admin/Channel/List/Create/Handler.cs
  75. 1 1
      Application/Features/Admin/Channel/List/Delete/Command.cs
  76. 1 1
      Application/Features/Admin/Channel/List/Delete/Handler.cs
  77. 1 1
      Application/Features/Admin/Channel/List/Get/Handler.cs
  78. 1 1
      Application/Features/Admin/Channel/List/Get/Query.cs
  79. 1 1
      Application/Features/Admin/Channel/List/Get/Response.cs
  80. 1 1
      Application/Features/Admin/Channel/List/Search/Handler.cs
  81. 1 1
      Application/Features/Admin/Channel/List/Search/Query.cs
  82. 1 1
      Application/Features/Admin/Channel/List/Search/Response.cs
  83. 1 1
      Application/Features/Admin/Channel/List/Update/Command.cs
  84. 1 1
      Application/Features/Admin/Channel/List/Update/Handler.cs
  85. 47 0
      Application/Features/Admin/Crypto/Board/GetBoards/Handler.cs
  86. 6 0
      Application/Features/Admin/Crypto/Board/GetBoards/Query.cs
  87. 18 0
      Application/Features/Admin/Crypto/Board/GetBoards/Response.cs
  88. 6 0
      Application/Features/Admin/Crypto/Board/LinkBoard/Command.cs
  89. 32 0
      Application/Features/Admin/Crypto/Board/LinkBoard/Handler.cs
  90. 6 0
      Application/Features/Admin/Crypto/Board/UnlinkBoard/Command.cs
  91. 22 0
      Application/Features/Admin/Crypto/Board/UnlinkBoard/Handler.cs
  92. 45 0
      Application/Features/Admin/Crypto/Category/GetAll/Handler.cs
  93. 1 1
      Application/Features/Admin/Crypto/Category/GetAll/Query.cs
  94. 4 7
      Application/Features/Admin/Crypto/Category/GetAll/Response.cs
  95. 15 0
      Application/Features/Admin/Crypto/Category/Save/Command.cs
  96. 78 0
      Application/Features/Admin/Crypto/Category/Save/Handler.cs
  97. 1 1
      Application/Features/Admin/Crypto/Category/Save/Response.cs
  98. 44 0
      Application/Features/Admin/Crypto/Curation/Get/Handler.cs
  99. 5 0
      Application/Features/Admin/Crypto/Curation/Get/Query.cs
  100. 16 0
      Application/Features/Admin/Crypto/Curation/Get/Response.cs

+ 12 - 1
.claude/settings.local.json

@@ -13,9 +13,20 @@
       "FileCreate(E:\\workspace\\bitforum.io:*)",
       "FileDelete(E:\\workspace\\bitforum.io:*)",
       "Bash(findstr:*)",
-      "Bash(find:*)"
+      "Bash(find:*)",
+      "Bash(done)",
+      "Bash(Select-String -Pattern \"error|Error\")",
+      "Bash(Where-Object { $_ -notmatch \"MSB3027|MSB3021|MSB3026|NU1903|warning\" })",
+      "WebFetch(domain:docs.upbit.com)",
+      "WebFetch(domain:github.com)",
+      "WebFetch(domain:raw.githubusercontent.com)",
+      "Bash(taskkill:*)",
+      "WebFetch(domain:developercommunity.visualstudio.com)",
+      "Bash(if exist .vs rmdir /s /q .vs)",
+      "Bash(grep:*)"
     ]
   },
+  "model": "opusplan",
   "dangerouslySkipPermissions": true,
   "allowedPaths": [
     "E:/workspace/bitforum.io"

+ 2 - 2
Admin/Pages/Banner/List/Edit.cshtml.cs

@@ -1,9 +1,9 @@
+using SharedKernel.Attributes;
+using SharedKernel.Extensions;
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 using Microsoft.AspNetCore.Mvc.Rendering;
-using SharedKernel.Attributes;
-using SharedKernel.Extensions;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 

+ 1 - 1
Admin/Pages/Banner/List/Index.cshtml

@@ -94,7 +94,7 @@
                                     <label for="ids_@row.ID">@row.ID</label>
                                 </div>
                             </td>
-                            <td>@row.PositionSubject</td>
+                            <td>[@row.PositionCode] @row.PositionSubject</td>
                             <td>@row.Subject</td>
                             <td>@row.Order</td>
                             <td>@row.IsActive</td>

+ 4 - 3
Admin/Pages/Banner/List/Index.cshtml.cs

@@ -1,9 +1,8 @@
+using SharedKernel.Extensions;
+using SharedKernel.Helpers;
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
-using Microsoft.AspNetCore.Mvc.Rendering;
-using SharedKernel.Extensions;
-using SharedKernel.Helpers;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
@@ -38,6 +37,7 @@ public class IndexModel(IMediator mediator) : PageModel
     public List<(
         int Num,
         int ID,
+        string PositionCode,
         string PositionSubject,
         string Subject,
         short Order,
@@ -64,6 +64,7 @@ public class IndexModel(IMediator mediator) : PageModel
         List = [..result.List.Select(c => (
             c.Num,
             c.ID,
+            c.PositionCode,
             c.PositionSubject,
             c.Subject,
             c.Order,

+ 1 - 1
Admin/Pages/Channel/List/Edit.cshtml

@@ -119,4 +119,4 @@
 
         <br />
     </form>
-</div>
+</div>

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

@@ -71,12 +71,12 @@ namespace Admin.Pages.Channel.List
                 ID = result.ID,
                 MemberID = result.MemberID,
                 Name = result.Name,
-                Handle = result.Handle,
+                Handle = result.Handle ?? "-",
                 YouTubeUrl = result.YouTubeUrl,
                 PlatformFeeRate = result.PlatformFeeRate,
                 IsVerified = result.IsVerified,
                 IsActive = result.IsActive,
-                UpdatedAt = result.UpdatedAt.GetDateAt(),
+                UpdatedAt = result.UpdatedAt.GetDateAt() ?? "-",
                 CreatedAt = result.CreatedAt.GetDateAt()
             };
 

+ 73 - 73
Admin/Pages/Channel/List/Write.cshtml

@@ -114,85 +114,85 @@
 
 @section Scripts {
 <script>
-$(function () {
-    let timer = null;
-    const $input = $('#memberSearchInput');
-    const $results = $('#memberSearchResults');
-    const $hidden = $('#memberIdHidden');
-    const $selected = $('#memberSelected');
-    const $badgeText = $('#memberBadgeText');
-    const $searchWrap = $('#memberSearchWrap');
+    $(function () {
+        let timer = null;
+        const $input = $('#memberSearchInput');
+        const $results = $('#memberSearchResults');
+        const $hidden = $('#memberIdHidden');
+        const $selected = $('#memberSelected');
+        const $badgeText = $('#memberBadgeText');
+        const $searchWrap = $('#memberSearchWrap');
 
-    function showSelected(id, email, name, sid) {
-        const display = id + ' (' + email + ')' + (name ? ' ' + name : '') + (sid ? ' ' + sid : '');
-        $badgeText.text(display);
-        $hidden.val(id);
-        $selected.show();
-        $searchWrap.hide();
-        $results.hide();
-    }
+        function showSelected(id, email, name, sid) {
+            const display = id + ' (' + email + ')' + (name ? ' ' + name : '') + (sid ? ' ' + sid : '');
+            $badgeText.text(display);
+            $hidden.val(id);
+            $selected.show();
+            $searchWrap.hide();
+            $results.hide();
+        }
 
-    function clearSelected() {
-        $hidden.val('');
-        $selected.hide();
-        $searchWrap.show();
-        $input.val('').focus();
-    }
+        function clearSelected() {
+            $hidden.val('');
+            $selected.hide();
+            $searchWrap.show();
+            $input.val('').focus();
+        }
 
-    $('#memberRemoveBtn').on('click', function () {
-        clearSelected();
-    });
+        $('#memberRemoveBtn').on('click', function () {
+            clearSelected();
+        });
 
-    $input.on('input', function () {
-        clearTimeout(timer);
-        const val = $(this).val().trim();
-        if (val.length < 1) {
-            $results.hide().empty();
-            return;
-        }
-        timer = setTimeout(function () {
-            $.getJSON('/Channel/List/Write?handler=SearchMember&keyword=' + encodeURIComponent(val), function (data) {
-                $results.empty();
-                if (data.length === 0) {
-                    $results.append('<div class="list-group-item text-muted">검색 결과가 없습니다.</div>');
-                } else {
-                    $.each(data, function (i, m) {
-                        const text = m.id + ' (' + m.email + ')' + (m.name ? ' ' + m.name : '') + (m.sid ? ' ' + m.sid : '');
-                        $results.append(
-                            $('<a href="#" class="list-group-item list-group-item-action"></a>')
-                                .text(text)
-                                .on('click', function (e) {
-                                    e.preventDefault();
-                                    showSelected(m.id, m.email, m.name, m.sid);
-                                })
-                        );
-                    });
-                }
-                $results.show();
-            });
-        }, 300);
-    });
+        $input.on('input', function () {
+            clearTimeout(timer);
+            const val = $(this).val().trim();
+            if (val.length < 1) {
+                $results.hide().empty();
+                return;
+            }
+            timer = setTimeout(function () {
+                $.getJSON('/Channel/List/Write?handler=SearchMember&keyword=' + encodeURIComponent(val), function (data) {
+                    $results.empty();
+                    if (data.length === 0) {
+                        $results.append('<div class="list-group-item text-muted">검색 결과가 없습니다.</div>');
+                    } else {
+                        $.each(data, function (i, m) {
+                            const text = m.id + ' (' + m.email + ')' + (m.name ? ' ' + m.name : '') + (m.sid ? ' ' + m.sid : '');
+                            $results.append(
+                                $('<a href="#" class="list-group-item list-group-item-action"></a>')
+                                    .text(text)
+                                    .on('click', function (e) {
+                                        e.preventDefault();
+                                        showSelected(m.id, m.email, m.name, m.sid);
+                                    })
+                            );
+                        });
+                    }
+                    $results.show();
+                });
+            }, 300);
+        });
 
-    $(document).on('click', function (e) {
-        if (!$(e.target).closest('#memberSearchWrap').length) {
-            $results.hide();
-        }
-    });
+        $(document).on('click', function (e) {
+            if (!$(e.target).closest('#memberSearchWrap').length) {
+                $results.hide();
+            }
+        });
 
-    $input.on('focus', function () {
-        if ($results.children().length > 0) {
-            $results.show();
-        }
-    });
+        $input.on('focus', function () {
+            if ($results.children().length > 0) {
+                $results.show();
+            }
+        });
 
-    // 폼 제출 시 회원 선택 여부 확인
-    $('#fAdminWrite').on('submit', function (e) {
-        if (!$hidden.val() || $hidden.val() === '0') {
-            e.preventDefault();
-            alert('회원(소유자)을 선택해주세요.');
-            $input.focus();
-        }
+        // 폼 제출 시 회원 선택 여부 확인
+        $('#fAdminWrite').on('submit', function (e) {
+            if (!$hidden.val() || $hidden.val() === '0') {
+                e.preventDefault();
+                alert('회원(소유자)을 선택해주세요.');
+                $input.focus();
+            }
+        });
     });
-});
 </script>
-}
+}

+ 1 - 1
Admin/Pages/Config/Company.cshtml.cs

@@ -1,4 +1,4 @@
-using GetBank = Application.Features.ReferenceData.GetBank;
+using GetBank = Application.Features.Admin.ReferenceData.GetBank;
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;

+ 112 - 0
Admin/Pages/Crypto/Board/Index.cshtml

@@ -0,0 +1,112 @@
+@page
+@model Admin.Pages.Crypto.Board.IndexModel
+@{
+    ViewData["Title"] = $"게시판 연결 - {Model.CoinSymbol}";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <p class="text-muted">@Model.CoinName</p>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <ul class="nav nav-tabs mb-3">
+        <li class="nav-item">
+            <a class="nav-link" href="/Crypto/List/Edit/@Model.CoinID">기본 정보</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link active" href="/Crypto/Board/Index?coinID=@Model.CoinID">게시판 연결</a>
+        </li>
+    </ul>
+
+    <div class="row g-4">
+        <div class="col-md-6">
+            <h5 class="mb-3">연결된 게시판 (@Model.Linked.Count)</h5>
+            <table class="table table-sm table-bordered table-hover">
+                <thead class="table-success">
+                    <tr>
+                        <th>게시판</th>
+                        <th>분류</th>
+                        <th>활성</th>
+                        <th>관리</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    @if (Model.Linked.Count == 0)
+                    {
+                        <tr>
+                            <td colspan="4" class="text-muted">연결된 게시판이 없습니다.</td>
+                        </tr>
+                    }
+                    else
+                    {
+                        @foreach (var board in Model.Linked)
+                        {
+                            <tr>
+                                <td>[@board.Code] @board.Name</td>
+                                <td>@board.GroupName</td>
+                                <td>@board.IsActive</td>
+                                <td>
+                                    <form method="post" asp-page-handler="Unlink" style="display:inline;">
+                                        @Html.AntiForgeryToken()
+                                        <input type="hidden" name="coinID" value="@Model.CoinID" />
+                                        <input type="hidden" name="boardID" value="@board.ID" />
+                                        <button type="submit" class="btn btn-sm btn-outline-danger"
+                                                onclick="return confirm('게시판 연결을 해제하시겠습니까?')">해제</button>
+                                    </form>
+                                </td>
+                            </tr>
+                        }
+                    }
+                </tbody>
+            </table>
+        </div>
+
+        <div class="col-md-6">
+            <h5 class="mb-3">연결 가능한 게시판 (@Model.Unlinked.Count)</h5>
+            <table class="table table-sm table-bordered table-hover">
+                <thead class="table-light">
+                    <tr>
+                        <th>게시판</th>
+                        <th>분류</th>
+                        <th>활성</th>
+                        <th>관리</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    @if (Model.Unlinked.Count == 0)
+                    {
+                        <tr>
+                            <td colspan="4" class="text-muted">연결 가능한 게시판이 없습니다.</td>
+                        </tr>
+                    }
+                    else
+                    {
+                        @foreach (var board in Model.Unlinked)
+                        {
+                            <tr>
+                                <td>[@board.Code] @board.Name</td>
+                                <td>@board.GroupName</td>
+                                <td>@board.IsActive</td>
+                                <td>
+                                    <form method="post" asp-page-handler="Link" style="display:inline;">
+                                        @Html.AntiForgeryToken()
+                                        <input type="hidden" name="coinID" value="@Model.CoinID" />
+                                        <input type="hidden" name="boardID" value="@board.ID" />
+                                        <button type="submit" class="btn btn-sm btn-outline-success"
+                                                onclick="return confirm('이 게시판을 코인에 연결하시겠습니까?')">연결</button>
+                                    </form>
+                                </td>
+                            </tr>
+                        }
+                    }
+                </tbody>
+            </table>
+        </div>
+    </div>
+
+    <div class="mt-3">
+        <a class="btn btn-secondary" asp-page="/Crypto/List/Index">목록으로</a>
+    </div>
+</div>

+ 69 - 0
Admin/Pages/Crypto/Board/Index.cshtml.cs

@@ -0,0 +1,69 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Crypto.Board
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        public int CoinID { get; private set; }
+        public string CoinSymbol { get; private set; } = string.Empty;
+        public string CoinName { get; private set; } = string.Empty;
+
+        public List<(int ID, string Code, string Name, string GroupName, char IsActive)> Linked { get; set; } = [];
+        public List<(int ID, string Code, string Name, string GroupName, char IsActive)> Unlinked { get; set; } = [];
+
+        public async Task<IActionResult> OnGetAsync(int coinID, CancellationToken ct)
+        {
+            try
+            {
+                var result = await mediator.Send(new GetCoinBoards.Query(coinID), ct);
+
+                CoinID = coinID;
+                CoinSymbol = result.CoinSymbol;
+                CoinName = result.CoinKorName;
+
+                Linked = [..result.Linked.Select(b => (b.ID, b.Code, b.Name, b.GroupName, b.IsActive ? 'Y' : 'N'))];
+                Unlinked = [..result.Unlinked.Select(b => (b.ID, b.Code, b.Name, b.GroupName, b.IsActive ? 'Y' : 'N'))];
+
+                return Page();
+            }
+            catch (KeyNotFoundException)
+            {
+                return NotFound();
+            }
+        }
+
+        public async Task<IActionResult> OnPostLinkAsync(int coinID, int boardID, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new LinkCoinBoard.Command(coinID, boardID), ct);
+
+                TempData["SuccessMessage"] = "게시판이 연결되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Crypto/Board/Index", new { coinID });
+        }
+
+        public async Task<IActionResult> OnPostUnlinkAsync(int coinID, int boardID, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new UnlinkCoinBoard.Command(boardID), ct);
+
+                TempData["SuccessMessage"] = "게시판 연결이 해제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Crypto/Board/Index", new { coinID });
+        }
+    }
+}

+ 204 - 0
Admin/Pages/Crypto/Category.cshtml

@@ -0,0 +1,204 @@
+@page
+@model Admin.Pages.Crypto.CategoryModel
+@{
+    ViewData["Title"] = "코인 카테고리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end mt-2">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <button type="button" id="btnAdd" class="btn btn-primary" form="fAdminWrite">추가</button>
+            <button type="submit" id="btnSave" class="btn btn-success" form="fAdminWrite">저장</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off"></form>
+
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <caption>
+                코인이 등록된 카테고리는 삭제할 수 없습니다.<br />
+                카테고리를 삭제하려면 해당 코인의 카테고리를 먼저 변경해 주세요.
+            </caption>
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr class="text-center">
+                    <th>ID</th>
+                    <th>Code</th>
+                    <th>카테고리 명</th>
+                    <th>코인 수</th>
+                    <th>순서</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody id="categories">
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        var index = row.Index;
+
+                        <tr>
+                            <td>
+                                <input type="text" readonly class="form-control-plaintext text-center @(row.CoinCount > 0 ? "text-white bg-danger" : "")" value="@row.Num" />
+                                <input type="hidden" name="request[@index].ID" readonly class="form-control-plaintext text-center" required form="fAdminWrite" value="@row.ID" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Code" class="form-control" maxlength="30" required form="fAdminWrite" value="@row.Code" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Name" class="form-control" maxlength="100" required form="fAdminWrite" value="@row.Name" />
+                            </td>
+                            <td>@row.CoinCount</td>
+                            <td>
+                                <input type="number" name="request[@index].Order" class="form-control" min="-999" max="999" required form="fAdminWrite" value="@row.Order" />
+                            </td>
+                            <td>
+                                <div class="form-check-inline">
+                                    <input class="form-check-input" type="checkbox" id="request_@(index)_IsActive" name="request[@index].IsActive" checked="@Model.Data[index].IsActive" form="fAdminWrite" value="true" />
+                                    <label class="form-check-label" for="request_@(index)_IsActive">
+                                        사용
+                                    </label>
+                                </div>
+                            </td>
+                            <td><input type="text" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.CreatedAt" /></td>
+                            <td><input type="text" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.UpdatedAt" /></td>
+                            <td>
+                                <button type="button" class="btn btn-sm btn-danger btn-delete">삭제</button>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        $(function() {
+            let $categories = $("#categories");
+            let total = Number(@Model.Total);
+
+            $(document).on("click", "#btnAdd", function() {
+                if (total <= 0) {
+                    $categories.empty();
+                }
+
+                let tableRow = `
+                    <tr>
+                        <td>-</td>
+                        <td>
+                            <input type="text" name="request[${total}].Code" class="form-control" maxlength="30" required form="fAdminWrite" />
+                        </td>
+                        <td>
+                            <input type="text" name="request[${total}].Name" class="form-control" maxlength="100" required form="fAdminWrite" />
+                        </td>
+                        <td>-</td>
+                        <td>
+                            <input type="number" name="request[${total}].Order" class="form-control" min="-999" max="999" required form="fAdminWrite" />
+                        </td>
+                        <td>
+                            <div class="form-check-inline">
+                                <input class="form-check-input" type="checkbox" id="request_${total}_IsActive" name="request[${total}].IsActive" checked form="fAdminWrite" value="true" />
+                                <label class="form-check-label" for="request_${total}_IsActive">
+                                    사용
+                                </label>
+                            </div>
+                        </td>
+                        <td>-</td>
+                        <td>-</td>
+                        <td>
+                            <button type="button" class="btn btn-danger btn-sm btn-delete">삭제</button>
+                        </td>
+                    </tr>
+                `;
+
+                $categories.append(tableRow);
+                total++;
+                recalculateIndices();
+            });
+
+            $(document).on("click", "button.btn-delete", function(e) {
+                e.target.closest("tr").remove();
+                total--;
+
+                if (total <= 0) {
+                    $categories.append(
+                        `<tr><td colspan="9">No Data.</td></tr>`
+                    );
+                    total = 0;
+                } else {
+                    recalculateIndices();
+                }
+            });
+
+            $(document).on("click", "#btnSave", function() {
+                if (confirm("저장 하시겠습니까?")) {
+                    let form = document.getElementById("fAdminWrite");
+                    if (form.checkValidity()) {
+                        form.submit();
+                    } else {
+                        form.reportValidity();
+                    }
+                }
+                return false;
+            });
+
+            function recalculateIndices() {
+                $categories.find("tr").each(function(index, tr) {
+                    $(tr)
+                        .find("input, label")
+                        .each(function() {
+                            let name = $(this).attr("name");
+                            let id = $(this).attr("id");
+
+                            if (name) {
+                                $(this).attr("name", name.replace(/\[\d+\]/, `[${index}]`));
+                            }
+
+                            if (id) {
+                                $(this).attr("id", id.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+
+                    $(tr)
+                        .find("label")
+                        .each(function() {
+                            let labelFor = $(this).attr("for");
+                            if (labelFor) {
+                                $(this).attr("for", labelFor.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+                });
+            }
+        });
+    </script>
+}

+ 113 - 0
Admin/Pages/Crypto/Category.cshtml.cs

@@ -0,0 +1,113 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Crypto
+{
+    public class CategoryModel(IMediator mediator) : PageModel
+    {
+        public int Total { get; private set; } = 0;
+
+        public List<(
+            int Num,
+            int ID,
+            int Index,
+            string Code,
+            string Name,
+            short Order,
+            char IsActive,
+            int CoinCount,
+            string? UpdatedAt,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        [BindProperty(Name = "request")]
+        public List<InputModel> Input { get; private set; } = [];
+
+        public List<InputModel> Data { get; private set; } = [];
+
+        public sealed class InputModel
+        {
+            public int? ID { get; set; }
+
+            [Required]
+            [StringLength(30)]
+            public required string Code { get; set; }
+
+            [Required]
+            [StringLength(100)]
+            public required string Name { get; set; }
+
+            [Range(-9999, 9999)]
+            public short Order { get; set; }
+
+            public bool IsActive { get; set; } = false;
+        }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var result = await mediator.Send(new GetCryptoCategories.Query(), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.Index,
+                c.Code,
+                c.Name,
+                c.Order,
+                c.IsActive ? 'Y' : 'N',
+                (int)c.CoinCount,
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Data = [..result.List.Select(x => new InputModel
+            {
+                ID = x.ID,
+                Code = x.Code,
+                Name = x.Name,
+                Order = x.Order,
+                IsActive = x.IsActive
+            })];
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception();
+                }
+
+                var cmd = new SaveCryptoCategories.Command(
+                    [..Input.Select(x => new SaveCryptoCategories.Command.Row(
+                        x.ID,
+                        x.Code,
+                        x.Name,
+                        x.Order,
+                        x.IsActive
+                    ))]
+                );
+
+                var response = await mediator.Send(cmd, ct);
+
+                TempData["SuccessMessage"] = $"저장 완료 (추가: {response.Inserted}, 수정: {response.Updated}, 삭제: {response.Deleted})";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Crypto/Category");
+        }
+    }
+}

+ 113 - 0
Admin/Pages/Crypto/Curation.cshtml

@@ -0,0 +1,113 @@
+@page
+@model Admin.Pages.Crypto.CurationModel
+@{
+    ViewData["Title"] = "큐레이션";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end mt-2">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <button type="submit" id="btnSave" class="btn btn-success" form="fAdminWrite">저장</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off"></form>
+
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <caption>
+                메인 페이지에 노출할 코인을 선택하고 순서를 지정하세요.<br />
+                Featured 체크된 코인만 메인 페이지에 노출됩니다.
+            </caption>
+            <colgroup>
+                <col style="width: 5%;" />
+                <col style="width: 8%;" />
+                <col style="width: 10%;" />
+                <col />
+                <col style="width: 10%;" />
+                <col style="width: 10%;" />
+                <col style="width: 10%;" />
+            </colgroup>
+            <thead>
+                <tr class="text-center">
+                    <th>No</th>
+                    <th>로고</th>
+                    <th>심볼</th>
+                    <th>한글명</th>
+                    <th>영문명</th>
+                    <th>사용</th>
+                    <th>Featured</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)
+                    {
+                        var index = row.Index;
+
+                        <tr class="@(row.IsFeatured ? "table-warning" : "")">
+                            <td class="text-center">
+                                @row.Num
+                                <input type="hidden" name="request[@index].CoinID" form="fAdminWrite" value="@row.ID" />
+                            </td>
+                            <td class="text-center">
+                                @if (!string.IsNullOrEmpty(row.LogoImage))
+                                {
+                                    <img src="@row.LogoImage" alt="@row.Symbol" style="width: 24px; height: 24px;" />
+                                }
+                            </td>
+                            <td class="text-center fw-bold">@row.Symbol</td>
+                            <td>@row.KorName</td>
+                            <td>@row.EngName</td>
+                            <td class="text-center">
+                                @(row.IsActive ? "Y" : "N")
+                            </td>
+                            <td class="text-center">
+                                <div class="form-check d-flex justify-content-center">
+                                    <input class="form-check-input" type="checkbox" id="request_@(index)_IsFeatured" name="request[@index].IsFeatured" checked="@row.IsFeatured" form="fAdminWrite" value="true" />
+                                </div>
+                            </td>
+                            <td>
+                                <input type="number" name="request[@index].DisplayOrder" class="form-control text-center" min="-999" max="999" form="fAdminWrite" value="@row.DisplayOrder" />
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        $(function() {
+            $(document).on("click", "#btnSave", function() {
+                if (confirm("저장 하시겠습니까?")) {
+                    let form = document.getElementById("fAdminWrite");
+                    if (form.checkValidity()) {
+                        form.submit();
+                    } else {
+                        form.reportValidity();
+                    }
+                }
+                return false;
+            });
+        });
+    </script>
+}

+ 81 - 0
Admin/Pages/Crypto/Curation.cshtml.cs

@@ -0,0 +1,81 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Crypto;
+
+public class CurationModel(IMediator mediator) : PageModel
+{
+    public int Total { get; private set; } = 0;
+
+    public List<(
+        int Num,
+        int ID,
+        int Index,
+        string Symbol,
+        string KorName,
+        string EngName,
+        string? LogoImage,
+        bool IsFeatured,
+        short DisplayOrder,
+        bool IsActive
+    )> List { get; set; } = [];
+
+    [BindProperty(Name = "request")]
+    public List<InputModel> Input { get; private set; } = [];
+
+    public sealed class InputModel
+    {
+        public int CoinID { get; set; }
+        public bool IsFeatured { get; set; } = false;
+        public short DisplayOrder { get; set; } = 0;
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        var result = await mediator.Send(new GetCoinCuration.Query(), ct);
+
+        Total = result.Total;
+        List = [..result.List.Select((c, i) => (
+            c.Num,
+            c.ID,
+            i,
+            c.Symbol,
+            c.KorName,
+            c.EngName,
+            c.LogoImage,
+            c.IsFeatured,
+            c.DisplayOrder,
+            c.IsActive
+        ))];
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception();
+            }
+
+            var cmd = new SaveCoinCuration.Command(
+                [..Input.Select(x => new SaveCoinCuration.Command.Row(
+                    x.CoinID,
+                    x.IsFeatured,
+                    x.DisplayOrder
+                ))]
+            );
+
+            await mediator.Send(cmd, ct);
+
+            TempData["SuccessMessage"] = "저장 완료";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Crypto/Curation");
+    }
+}

+ 189 - 0
Admin/Pages/Crypto/List/Edit.cshtml

@@ -0,0 +1,189 @@
+@page "{id:int}"
+@model Admin.Pages.Crypto.List.EditModel
+@{
+    ViewData["Title"] = "코인 수정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <ul class="nav nav-tabs mb-3">
+        <li class="nav-item">
+            <a class="nav-link active" href="/Crypto/List/Edit/@Model.CoinID">기본 정보</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link" href="/Crypto/Board/Index?coinID=@Model.CoinID">게시판 연결</a>
+        </li>
+    </ul>
+
+    <form id="fAdminWrite" method="post" enctype="multipart/form-data" accept-charset="utf-8" autocomplete="off" class="mt-3">
+        @Html.AntiForgeryToken()
+
+        <div class="row mb-2">
+            <label asp-for="Input.Symbol" class="col-sm-2 col-form-label">
+                <span class="text-danger">*</span> 심볼
+            </label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Symbol" class="form-control text-uppercase" maxlength="30" required placeholder="BTC" />
+                <div class="form-text">대문자로 자동 변환됩니다 (예: BTC, ETH)</div>
+                <span asp-validation-for="Input.Symbol" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.KorName" class="col-sm-2 col-form-label">
+                <span class="text-danger">*</span> 한글명
+            </label>
+            <div class="col">
+                <input asp-for="Input.KorName" class="form-control" maxlength="200" required placeholder="비트코인" />
+                <span asp-validation-for="Input.KorName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.EngName" class="col-sm-2 col-form-label">
+                <span class="text-danger">*</span> 영문명
+            </label>
+            <div class="col">
+                <input asp-for="Input.EngName" class="form-control" maxlength="200" required placeholder="Bitcoin" />
+                <span asp-validation-for="Input.EngName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.LogoImageFile" class="col-sm-2 col-form-label">로고 이미지</label>
+            <div class="col-sm-10">
+                @if (!string.IsNullOrEmpty(Model.CurrentLogoImage))
+                {
+                    <div class="mb-2">
+                        <img src="@Model.CurrentLogoImage" alt="현재 로고" style="max-width:128px;max-height:128px;object-fit:contain;border:1px solid #ddd;border-radius:4px;" />
+                        <div class="form-check mt-1">
+                            <input asp-for="Input.DeleteLogoImage" class="form-check-input" />
+                            <label asp-for="Input.DeleteLogoImage" class="form-check-label text-danger">현재 이미지 삭제</label>
+                        </div>
+                    </div>
+                }
+                <div id="LogoImagePrev" hidden>
+                    <img class="img-fluid img-thumbnail" alt="로고 이미지 미리보기" style="max-width:128px;max-height:128px;" /><br />
+                    <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                </div>
+                <input asp-for="Input.LogoImageFile" class="form-control" accept="image/*,.svg" />
+                <div class="form-text">jpg, jpeg, png, gif, webp, svg</div>
+                <span asp-validation-for="Input.LogoImageFile" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.CategoryIDs" class="col-sm-2 col-form-label">카테고리</label>
+            <div class="col-sm-10 align-self-center">
+                @if (Model.Categories.Count > 0)
+                {
+                    <div class="border rounded p-2" style="max-height: 150px; overflow-y: auto;">
+                        @foreach (var cat in Model.Categories)
+                        {
+                            <div class="form-check">
+                                <input class="form-check-input" type="checkbox" name="Input.CategoryIDs" id="cat_@(cat.Value)" value="@cat.Value" @(Model.Input.CategoryIDs.Contains(int.Parse(cat.Value)) ? "checked" : "") />
+                                <label class="form-check-label" for="cat_@(cat.Value)">@cat.Text</label>
+                            </div>
+                        }
+                    </div>
+                }
+                else
+                {
+                    <span>-</span>
+                }
+                <span asp-validation-for="Input.CategoryIDs" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Description" class="col-sm-2 col-form-label">설명</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Description" class="form-control" rows="4" maxlength="5000" placeholder="코인에 대한 설명을 입력하세요."></textarea>
+                <span asp-validation-for="Input.Description" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.ContractAddress" class="col-sm-2 col-form-label">컨트랙트 주소</label>
+            <div class="col-md-10">
+                <input asp-for="Input.ContractAddress" class="form-control" maxlength="100" placeholder="0x..." />
+                <span asp-validation-for="Input.ContractAddress" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.WebsiteUrl" class="col-sm-2 col-form-label">Site URL</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.WebsiteUrl" class="form-control" maxlength="500" placeholder="https://..." />
+                <span asp-validation-for="Input.WebsiteUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.WhitepaperUrl" class="col-sm-2 col-form-label">Whitepaper URL</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.WhitepaperUrl" class="form-control" maxlength="500" placeholder="https://..." />
+                <span asp-validation-for="Input.WhitepaperUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.TwitterUrl" class="col-sm-2 col-form-label">Twitter URL</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.TwitterUrl" class="form-control" maxlength="500" placeholder="https://twitter.com/..." />
+                <span asp-validation-for="Input.TwitterUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-3">
+            <label asp-for="Input.TelegramUrl" class="col-sm-2 col-form-label">Telegram URL</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.TelegramUrl" class="form-control" maxlength="500" placeholder="https://t.me/..." />
+                <span asp-validation-for="Input.TelegramUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row">
+            <label class="col-sm-2">상태</label>
+            <div class="col-sm-10">
+                <div class="d-flex gap-3 flex-wrap">
+                    <div class="form-check">
+                        <input asp-for="Input.IsActive" class="form-check-input" />
+                        <label asp-for="Input.IsActive" class="form-check-label">활성화</label>
+                    </div>
+                    <div class="form-check">
+                        <input asp-for="Input.IsNew" class="form-check-input" />
+                        <label asp-for="Input.IsNew" class="form-check-label text-primary">신규 상장</label>
+                    </div>
+                    <div class="form-check">
+                        <input asp-for="Input.IsWarning" class="form-check-input" />
+                        <label asp-for="Input.IsWarning" class="form-check-label text-danger">위험 경고</label>
+                    </div>
+                    <div class="form-check">
+                        <input asp-for="Input.IsDelisted" class="form-check-input" />
+                        <label asp-for="Input.IsDelisted" class="form-check-label text-warning">상장 폐지</label>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Crypto/List/Index" class="btn btn-secondary">취소</a>
+        </div>
+
+        <br/>
+    </form>
+</div>
+
+@section Scripts {
+    <script>
+        setupImagePreview("Input_LogoImageFile", "LogoImagePrev");
+    </script>
+}

+ 148 - 0
Admin/Pages/Crypto/List/Edit.cshtml.cs

@@ -0,0 +1,148 @@
+using SharedKernel.Attributes;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Crypto.List
+{
+    public class EditModel(IMediator mediator) : PageModel
+    {
+        public int CoinID { get; private set; }
+        public string? CurrentLogoImage { get; private set; }
+        public List<SelectListItem> Categories { get; set; } = [];
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public sealed class InputModel
+        {
+            [Required(ErrorMessage = "심볼은 필수입니다.")]
+            [StringLength(30, ErrorMessage = "심볼은 {1}자 이하로 입력하세요.")]
+            public string Symbol { get; set; } = null!;
+
+            [Required(ErrorMessage = "한글명은 필수입니다.")]
+            [StringLength(200, ErrorMessage = "한글명은 {1}자 이하로 입력하세요.")]
+            public string KorName { get; set; } = null!;
+
+            [Required(ErrorMessage = "영문명은 필수입니다.")]
+            [StringLength(200, ErrorMessage = "영문명은 {1}자 이하로 입력하세요.")]
+            public string EngName { get; set; } = null!;
+
+            [AllowedExtensions("jpg,jpeg,png,gif,webp,svg", ErrorMessage = "이미지 확장자는 jpg, jpeg, png, gif, webp, svg 이어야 합니다.")]
+            public IFormFile? LogoImageFile { get; set; }
+
+            public bool DeleteLogoImage { get; set; } = false;
+
+            [StringLength(5000)]
+            public string? Description { get; set; }
+
+            [StringLength(100)]
+            public string? ContractAddress { get; set; }
+
+            [StringLength(500)]
+            [DataType(DataType.Url)]
+            public string? WebsiteUrl { get; set; }
+
+            [StringLength(500)]
+            [DataType(DataType.Url)]
+            public string? WhitepaperUrl { get; set; }
+
+            [StringLength(500)]
+            [DataType(DataType.Url)]
+            public string? TwitterUrl { get; set; }
+
+            [StringLength(500)]
+            [DataType(DataType.Url)]
+            public string? TelegramUrl { get; set; }
+
+            public bool IsActive { get; set; } = false;
+            public bool IsWarning { get; set; } = false;
+            public bool IsNew { get; set; } = false;
+            public bool IsDelisted { get; set; } = false;
+
+            public int[] CategoryIDs { get; set; } = [];
+        }
+
+        public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
+        {
+            try
+            {
+                var coin = await mediator.Send(new GetCoin.Query(id), ct);
+
+                CoinID = coin.ID;
+                CurrentLogoImage = coin.LogoImage;
+
+                var categories = await mediator.Send(new GetCryptoCategories.Query(), ct);
+                Categories = [..categories.List.Select(c => new SelectListItem(c.Name, c.ID.ToString()))];
+
+                Input = new InputModel
+                {
+                    Symbol = coin.Symbol,
+                    KorName = coin.KorName,
+                    EngName = coin.EngName,
+                    Description = coin.Description,
+                    ContractAddress = coin.ContractAddress,
+                    WebsiteUrl = coin.WebsiteUrl,
+                    WhitepaperUrl = coin.WhitepaperUrl,
+                    TwitterUrl = coin.TwitterUrl,
+                    TelegramUrl = coin.TelegramUrl,
+                    IsActive = coin.IsActive,
+                    IsWarning = coin.IsWarning,
+                    IsNew = coin.IsNew,
+                    IsDelisted = coin.IsDelisted,
+                    CategoryIDs = [..coin.CategoryIDs]
+                };
+
+                return Page();
+            }
+            catch (KeyNotFoundException)
+            {
+                return NotFound();
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(int id, CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception(ModelState.GetErrorMessages());
+                }
+
+                await mediator.Send(new UpdateCoin.Command(
+                    id,
+                    Input.Symbol,
+                    Input.KorName,
+                    Input.EngName,
+                    Input.LogoImageFile,
+                    Input.DeleteLogoImage,
+                    Input.Description,
+                    Input.ContractAddress,
+                    Input.WebsiteUrl,
+                    Input.WhitepaperUrl,
+                    Input.TwitterUrl,
+                    Input.TelegramUrl,
+                    Input.IsActive,
+                    Input.IsWarning,
+                    Input.IsNew,
+                    Input.IsDelisted,
+                    Input.CategoryIDs
+                ), ct);
+
+                TempData["SuccessMessage"] = $"{Input.Symbol} 코인이 수정되었습니다.";
+
+                return RedirectToPage("/Crypto/List/Edit", new { id });
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return RedirectToPage("/Crypto/List/Edit", new { id });
+            }
+        }
+    }
+}

+ 192 - 0
Admin/Pages/Crypto/List/Index.cshtml

@@ -0,0 +1,192 @@
+@page
+@model Admin.Pages.Crypto.List.IndexModel
+@{
+    ViewData["Title"] = "코인 목록";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 mb-2 mb-sm-0 mt-2">
+        <div class="col-6 col-sm-4 col-lg-auto">
+            <select name="categoryID" id="categoryID" class="form-select" form="fAdminSearch">
+                <option value="">카테고리 전체</option>
+                @foreach (var item in Model.Categories)
+                {
+                    <option value="@item.Value" selected="@(item.Value == Model.Query.CategoryID?.ToString())">@item.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-4 col-lg-auto">
+            <select name="isActive" id="isActive" class="form-select" form="fAdminSearch">
+                <option value="">활성 전체</option>
+                <option value="true" selected="@(Model.Query.IsActive == true)">활성</option>
+                <option value="false" selected="@(Model.Query.IsActive == false)">비활성</option>
+            </select>
+        </div>
+        <div class="col-6 col-sm-4 col-lg-auto">
+            <select name="isWarning" id="isWarning" class="form-select" form="fAdminSearch">
+                <option value="">경고 전체</option>
+                <option value="true" selected="@(Model.Query.IsWarning == true)">경고</option>
+                <option value="false" selected="@(Model.Query.IsWarning == false)">정상</option>
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="isDelisted" id="isDelisted" class="form-select" form="fAdminSearch">
+                <option value="">상폐 전체</option>
+                <option value="true" selected="@(Model.Query.IsDelisted == true)">상장폐지</option>
+                <option value="false" selected="@(Model.Query.IsDelisted == false)">상장중</option>
+            </select>
+        </div>
+        <div class="col col-sm col-lg-auto">
+            <input type="search" name="keyword" class="form-control" value="@Model.Query.Keyword" placeholder="심볼, 이름" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr/>
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <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>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Crypto/List/Write">추가</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col />
+                <col style="width: 6%;" />
+                <col />
+                <col style="width: 12%;" />
+                <col style="width: 12%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>로고</th>
+                    <th>심볼</th>
+                    <th>한글명</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="12">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.Num</label>
+                                </div>
+                            </td>
+                            <td class="text-center">
+                                @if (!string.IsNullOrEmpty(row.LogoImage))
+                                {
+                                    <img src="@row.LogoImage" alt="@row.Symbol" style="width:100%;height:auto;object-fit:contain;" />
+                                }
+                                else
+                                {
+                                    <span class="text-muted">-</span>
+                                }
+                            </td>
+                            <td class="fw-bold">@row.Symbol</td>
+                            <td>@row.KorName</td>
+                            <td>@row.EngName</td>
+                            <td>
+                                @if (row.CategoryNames != null && row.CategoryNames.Count > 0)
+                                {
+                                    @string.Join(", ", row.CategoryNames)
+                                }
+                                else
+                                {
+                                    <span class="text-muted">-</span>
+                                }
+                            </td>
+                            <td class="text-center @(row.IsActive == 'Y' ? "text-success" : "text-muted")">@row.IsActive</td>
+                            <td class="text-center @(row.IsWarning == 'Y' ? "text-danger" : "text-muted")">@row.IsWarning</td>
+                            <td class="text-center @(row.IsNew == 'Y' ? "text-primary" : "text-muted")">@row.IsNew</td>
+                            <td class="text-center @(row.IsDelisted == 'Y' ? "text-warning" : "text-muted")">@row.IsDelisted</td>
+                            <td>@row.CreatedAt</td>
+                            <td>
+                                <div class="d-grid gap-2 d-block d-xxl-inline">
+                                    <a class="btn btn-sm btn-outline-info" href="@row.EditURL">편집</a>
+                                    <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID">삭제</button>
+                                </div>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<!-- 검색용 폼 -->
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<!-- 목록용 폼 -->
+<form id="fAdminList" method="post" accept-charset="utf-8" asp-page-handler="Delete">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="categoryID" value="@Model.Query.CategoryID" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#categoryID, #isActive, #isWarning, #isDelisted, #perPage", function () {
+            searchForm.submit();
+        });
+    </script>
+}

+ 120 - 0
Admin/Pages/Crypto/List/Index.cshtml.cs

@@ -0,0 +1,120 @@
+using SharedKernel.Extensions;
+using SharedKernel.Helpers;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Crypto.List
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public List<SelectListItem> Categories { get; set; } = [];
+
+        public sealed class QueryParams
+        {
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 당 수")]
+            public ushort PerPage { get; set; } = 20;
+
+            [DisplayName("카테고리")]
+            public int? CategoryID { get; set; }
+
+            [DisplayName("검색어")]
+            public string? Keyword { get; set; }
+
+            public bool? IsActive { get; set; }
+            public bool? IsWarning { get; set; }
+            public bool? IsNew { get; set; }
+            public bool? IsDelisted { get; set; }
+        }
+
+        public int Total { get; set; }
+
+        public List<(
+            int Num,
+            int ID,
+            string Symbol,
+            string KorName,
+            string EngName,
+            string? LogoImage,
+            IReadOnlyList<string> CategoryNames,
+            char IsActive,
+            char IsWarning,
+            char IsNew,
+            char IsDelisted,
+            string? UpdatedAt,
+            string CreatedAt,
+            string EditURL
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var categories = await mediator.Send(new GetCryptoCategories.Query(), ct);
+            Categories = [..categories.List.Select(c => new SelectListItem(c.Name, c.ID.ToString()))];
+
+            var result = await mediator.Send(new SearchCoins.Query(
+                Query.CategoryID,
+                Query.Keyword,
+                Query.IsActive,
+                Query.IsWarning,
+                Query.IsNew,
+                Query.IsDelisted,
+                Query.PageNum,
+                Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.Symbol,
+                c.KorName,
+                c.EngName,
+                c.LogoImage,
+                c.CategoryNames,
+                c.IsActive ? 'Y' : 'N',
+                c.IsWarning ? 'Y' : 'N',
+                c.IsNew ? 'Y' : 'N',
+                c.IsDelisted ? 'Y' : 'N',
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt(),
+                EditURL: $"/Crypto/List/Edit/{c.ID}{Request.QueryString}"
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteCoin.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}개 코인이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Crypto/List/Index", Query);
+        }
+    }
+}

+ 168 - 0
Admin/Pages/Crypto/List/Write.cshtml

@@ -0,0 +1,168 @@
+@page
+@model Admin.Pages.Crypto.List.WriteModel
+@{
+    ViewData["Title"] = "코인 등록";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <form id="fAdminWrite" method="post" enctype="multipart/form-data" accept-charset="utf-8" autocomplete="off" class="mt-3">
+        @Html.AntiForgeryToken()
+
+        <div class="row mb-2">
+            <label asp-for="Input.Symbol" class="col-sm-2 col-form-label">
+                <span class="text-danger">*</span> 심볼
+            </label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Symbol" class="form-control text-uppercase" maxlength="30" required placeholder="BTC" />
+                <div class="form-text">대문자로 자동 변환됩니다 (예: BTC, ETH)</div>
+                <span asp-validation-for="Input.Symbol" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.KorName" class="col-sm-2 col-form-label">
+                <span class="text-danger">*</span> 한글명
+            </label>
+            <div class="col">
+                <input asp-for="Input.KorName" class="form-control" maxlength="200" required placeholder="비트코인" />
+                <span asp-validation-for="Input.KorName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.EngName" class="col-sm-2 col-form-label">
+                <span class="text-danger">*</span> 영문명
+            </label>
+            <div class="col">
+                <input asp-for="Input.EngName" class="form-control" maxlength="200" required placeholder="Bitcoin" />
+                <span asp-validation-for="Input.EngName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.LogoImageFile" class="col-sm-2 col-form-label">로고 이미지</label>
+            <div class="col-sm-10">
+                <div id="LogoImagePrev" hidden>
+                    <img class="img-fluid img-thumbnail" alt="로고 이미지 미리보기" style="max-width:128px;max-height:128px;" /><br />
+                    <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                </div>
+                <input asp-for="Input.LogoImageFile" class="form-control" accept="image/*,.svg" />
+                <div class="form-text">jpg, jpeg, png, gif, webp, svg</div>
+                <span asp-validation-for="Input.LogoImageFile" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.CategoryIDs" class="col-sm-2 col-form-label">카테고리</label>
+            <div class="col-sm-10 align-self-center">
+                @if (Model.Categories.Count > 0)
+                {
+                    <div class="border rounded p-2" style="max-height: 150px; overflow-y: auto;">
+                        @foreach (var cat in Model.Categories)
+                        {
+                            <div class="form-check">
+                                <input class="form-check-input" type="checkbox" name="Input.CategoryIDs" id="cat_@(cat.Value)" value="@cat.Value" />
+                                <label class="form-check-label" for="cat_@(cat.Value)">@cat.Text</label>
+                            </div>
+                        }
+                    </div>
+                    <span asp-validation-for="Input.CategoryIDs" class="text-danger"></span>
+                } else {
+                    <span>-</span>
+                }
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Description" class="col-sm-2 col-form-label">설명</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Description" class="form-control" rows="4" maxlength="5000" placeholder="코인에 대한 설명을 입력하세요."></textarea>
+                <span asp-validation-for="Input.Description" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.ContractAddress" class="col-sm-2 col-form-label">컨트랙트 주소</label>
+            <div class="col-md-10">
+                <input asp-for="Input.ContractAddress" class="form-control" maxlength="100" placeholder="0x..." />
+                <span asp-validation-for="Input.ContractAddress" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.WebsiteUrl" class="col-sm-2 col-form-label">Site URL</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.WebsiteUrl" class="form-control" maxlength="500" placeholder="https://..." />
+                <span asp-validation-for="Input.WebsiteUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.WhitepaperUrl" class="col-sm-2 col-form-label">Whitepaper URL</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.WhitepaperUrl" class="form-control" maxlength="500" placeholder="https://..." />
+                <span asp-validation-for="Input.WhitepaperUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.TwitterUrl" class="col-sm-2 col-form-label">Twitter URL</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.TwitterUrl" class="form-control" maxlength="500" placeholder="https://twitter.com/..." />
+                <span asp-validation-for="Input.TwitterUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-3">
+            <label asp-for="Input.TelegramUrl" class="col-sm-2 col-form-label">Telegram URL</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.TelegramUrl" class="form-control" maxlength="500" placeholder="https://t.me/..." />
+                <span asp-validation-for="Input.TelegramUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row">
+            <label class="col-sm-2">상태</label>
+            <div class="col-sm-10">
+                <div class="d-flex gap-3 flex-wrap">
+                    <div class="form-check">
+                        <input asp-for="Input.IsActive" class="form-check-input" />
+                        <label asp-for="Input.IsActive" class="form-check-label">활성화</label>
+                    </div>
+                    <div class="form-check">
+                        <input asp-for="Input.IsNew" class="form-check-input" />
+                        <label asp-for="Input.IsNew" class="form-check-label text-primary">신규 상장</label>
+                    </div>
+                    <div class="form-check">
+                        <input asp-for="Input.IsWarning" class="form-check-input" />
+                        <label asp-for="Input.IsWarning" class="form-check-label text-danger">위험 경고</label>
+                    </div>
+                    <div class="form-check">
+                        <input asp-for="Input.IsDelisted" class="form-check-input" />
+                        <label asp-for="Input.IsDelisted" class="form-check-label text-warning">상장 폐지</label>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Crypto/List/Index" class="btn btn-secondary">취소</a>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    <script>
+        setupImagePreview("Input_LogoImageFile", "LogoImagePrev");
+    </script>
+}

+ 113 - 0
Admin/Pages/Crypto/List/Write.cshtml.cs

@@ -0,0 +1,113 @@
+using SharedKernel.Attributes;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Crypto.List
+{
+    public class WriteModel(IMediator mediator) : PageModel
+    {
+        public string? QueryString { get; set; }
+        public List<SelectListItem> Categories { get; set; } = [];
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public sealed class InputModel
+        {
+            [Required(ErrorMessage = "심볼은 필수입니다.")]
+            [StringLength(30, ErrorMessage = "심볼은 {1}자 이하로 입력하세요.")]
+            public string Symbol { get; set; } = null!;
+
+            [Required(ErrorMessage = "한글명은 필수입니다.")]
+            [StringLength(200, ErrorMessage = "한글명은 {1}자 이하로 입력하세요.")]
+            public string KorName { get; set; } = null!;
+
+            [Required(ErrorMessage = "영문명은 필수입니다.")]
+            [StringLength(200, ErrorMessage = "영문명은 {1}자 이하로 입력하세요.")]
+            public string EngName { get; set; } = null!;
+
+            [AllowedExtensions("jpg,jpeg,png,gif,webp,svg", ErrorMessage = "이미지 확장자는 jpg, jpeg, png, gif, webp, svg 이어야 합니다.")]
+            public IFormFile? LogoImageFile { get; set; }
+
+            [StringLength(5000)]
+            public string? Description { get; set; }
+
+            [StringLength(100)]
+            public string? ContractAddress { get; set; }
+
+            [StringLength(500)]
+            [DataType(DataType.Url)]
+            public string? WebsiteUrl { get; set; }
+
+            [StringLength(500)]
+            [DataType(DataType.Url)]
+            public string? WhitepaperUrl { get; set; }
+
+            [StringLength(500)]
+            [DataType(DataType.Url)]
+            public string? TwitterUrl { get; set; }
+
+            [StringLength(500)]
+            [DataType(DataType.Url)]
+            public string? TelegramUrl { get; set; }
+
+            public bool IsActive { get; set; } = false;
+            public bool IsWarning { get; set; } = false;
+            public bool IsNew { get; set; } = false;
+            public bool IsDelisted { get; set; } = false;
+
+            public int[] CategoryIDs { get; set; } = [];
+        }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            var categories = await mediator.Send(new GetCryptoCategories.Query(), ct);
+            Categories = [..categories.List.Select(c => new SelectListItem(c.Name, c.ID.ToString()))];
+
+            QueryString = Request.QueryString.ToString();
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception(ModelState.GetErrorMessages());
+                }
+
+                await mediator.Send(new CreateCoin.Command(
+                    Input.Symbol,
+                    Input.KorName,
+                    Input.EngName,
+                    Input.LogoImageFile,
+                    Input.Description,
+                    Input.ContractAddress,
+                    Input.WebsiteUrl,
+                    Input.WhitepaperUrl,
+                    Input.TwitterUrl,
+                    Input.TelegramUrl,
+                    Input.IsActive,
+                    Input.IsWarning,
+                    Input.IsNew,
+                    Input.IsDelisted,
+                    Input.CategoryIDs
+                ), ct);
+
+                TempData["SuccessMessage"] = $"{Input.Symbol} 코인이 등록되었습니다.";
+
+                return RedirectToPage("/Crypto/List/Index");
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Redirect($"/Crypto/List/Write?{Request.QueryString}");
+            }
+        }
+    }
+}

+ 60 - 0
Admin/Pages/Crypto/TickerConfig.cshtml

@@ -0,0 +1,60 @@
+@page
+@model Admin.Pages.Crypto.TickerConfigModel
+@{
+    ViewData["Title"] = "시세 설정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+
+    <form id="fAdminWrite" class="mt-3" method="post" autocomplete="off" accept-charset="UTF-8">
+        <div class="row mb-3">
+            <label asp-for="Input.TickerRefreshSeconds" class="col-sm-3 col-form-label">시세 업데이트 주기 (초)</label>
+            <div class="col-sm-9">
+                <input asp-for="Input.TickerRefreshSeconds" type="number" class="form-control" min="1" max="300" />
+                <div class="form-text text-muted">WebSocket PING 전송 주기입니다. 기본값: 5초</div>
+                <span asp-validation-for="Input.TickerRefreshSeconds" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-3">
+            <label asp-for="Input.SurgeThreshold" class="col-sm-3 col-form-label">급등 임계값 (%)</label>
+            <div class="col-sm-9">
+                <input asp-for="Input.SurgeThreshold" type="number" class="form-control" step="0.1" min="0.1" max="100" />
+                <div class="form-text text-muted">24시간 기준 변동률이 이 값 이상이면 급등으로 표시합니다. 기본값: 5.0%</div>
+                <span asp-validation-for="Input.SurgeThreshold" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-3">
+            <label asp-for="Input.PlungeThreshold" class="col-sm-3 col-form-label">급락 임계값 (%)</label>
+            <div class="col-sm-9">
+                <input asp-for="Input.PlungeThreshold" type="number" class="form-control" step="0.1" min="-100" max="-0.1" />
+                <div class="form-text text-muted">24시간 기준 변동률이 이 값 이하이면 급락으로 표시합니다. 기본값: -5.0%</div>
+                <span asp-validation-for="Input.PlungeThreshold" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-3">
+            <label asp-for="Input.MainPageCoinCount" class="col-sm-3 col-form-label">메인 페이지 기본 표시 코인 수</label>
+            <div class="col-sm-9">
+                <input asp-for="Input.MainPageCoinCount" type="number" class="form-control" min="1" max="100" />
+                <div class="form-text text-muted">메인 페이지에 기본으로 표시할 코인 개수입니다. 기본값: 10</div>
+                <span asp-validation-for="Input.MainPageCoinCount" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr/>
+
+         <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-success">저장</button>
+        </div>
+
+        <br/>
+    </form>
+</div>

+ 71 - 0
Admin/Pages/Crypto/TickerConfig.cshtml.cs

@@ -0,0 +1,71 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Crypto;
+
+public class TickerConfigModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("시세 업데이트 주기 (초)")]
+        [Range(1, 300)]
+        public int TickerRefreshSeconds { get; set; } = 5;
+
+        [DisplayName("급등 임계값 (%)")]
+        [Range(0.1, 100)]
+        public decimal SurgeThreshold { get; set; } = 5.0m;
+
+        [DisplayName("급락 임계값 (%)")]
+        [Range(-100, -0.1)]
+        public decimal PlungeThreshold { get; set; } = -5.0m;
+
+        [DisplayName("메인 페이지 기본 표시 코인 수")]
+        [Range(1, 100)]
+        public int MainPageCoinCount { get; set; } = 10;
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        var result = await mediator.Send(new GetTickerConfig.Query(), ct);
+
+        Input = new InputModel
+        {
+            TickerRefreshSeconds = result.TickerRefreshSeconds,
+            SurgeThreshold = result.SurgeThreshold,
+            PlungeThreshold = result.PlungeThreshold,
+            MainPageCoinCount = result.MainPageCoinCount
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return Page();
+        }
+
+        try
+        {
+            await mediator.Send(new SaveTickerConfig.Command(
+                Input.TickerRefreshSeconds,
+                Input.SurgeThreshold,
+                Input.PlungeThreshold,
+                Input.MainPageCoinCount
+            ), ct);
+
+            TempData["SuccessMessage"] = "저장 완료";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Crypto/TickerConfig");
+    }
+}

+ 1 - 1
Admin/Pages/Director/AccessLog/Index.cshtml.cs

@@ -116,4 +116,4 @@ public class IndexModel(IMediator mediator) : PageModel
 
         return RedirectToPage(Query);
     }
-}
+}

+ 1 - 7
Admin/Pages/Document/Edit.cshtml

@@ -73,10 +73,4 @@
         </div>
         <br />
     </form>
-</div>
-
-@section Scripts {
-    @{
-
-    }
-}
+</div>

+ 1 - 1
Admin/Pages/Document/Edit.cshtml.cs

@@ -60,7 +60,7 @@ namespace Admin.Pages.Document
                 Input.Subject = document.Subject;
                 Input.Content = document.Content;
                 Input.IsActive = document.IsActive;
-                Input.UpdatedAt = document.UpdatedAt.GetDateAt();
+                Input.UpdatedAt = document.UpdatedAt.GetDateAt() ?? "-";
                 Input.CreatedAt = document.CreatedAt.GetDateAt();
             }
         }

+ 1 - 7
Admin/Pages/Document/Write.cshtml

@@ -53,10 +53,4 @@
         </div>
         <br />
     </form>
-</div>
-
-@section Scripts {
-    @{
-
-    }
-}
+</div>

+ 1 - 1
Admin/Pages/Faq/Category.cshtml.cs

@@ -65,7 +65,7 @@ namespace Admin.Pages.Faq
                 c.Order,
                 c.IsActive ? 'Y' : 'N',
                 c.FaqItemRows,
-                c.UpdatedAt.GetDateAt(),
+                c.UpdatedAt.GetDateAt() ?? "-",
                 c.CreatedAt.GetDateAt()
             ))];
 

+ 1 - 1
Admin/Pages/Faq/List/Index.cshtml

@@ -94,7 +94,7 @@
                                     <label for="ids_@row.ID">@row.ID</label>
                                 </div>
                             </td>
-                            <td>@row.CategorySubject</td>
+                            <td>[@row.CategoryCode] @row.CategorySubject</td>
                             <td>@row.Question</td>
                             <td>@row.Order</td>
                             <td>@row.IsActive</td>

+ 1 - 1
Admin/Pages/Faq/List/Write.cshtml.cs

@@ -1,8 +1,8 @@
+using SharedKernel.Extensions;
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 using Microsoft.AspNetCore.Mvc.Rendering;
-using SharedKernel.Extensions;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 

+ 1 - 1
Admin/Pages/Member/Grade/Edit.cshtml.cs

@@ -85,7 +85,7 @@ namespace Admin.Pages.Member.Grade
                 TotalDonationAmount = result.RequiredExp,
                 TextColor = result.TextColor ?? "#000000",
                 IsActive = result.IsActive,
-                UpdatedAt = result.UpdatedAt.GetDateAt(),
+                UpdatedAt = result.UpdatedAt.GetDateAt() ?? "-",
                 CreatedAt = result.CreatedAt.GetDateAt()
             };
         }

+ 1 - 1
Admin/Pages/Member/Grade/Index.cshtml

@@ -89,7 +89,7 @@
                         <td>@row.ID</td>
                         <td>@row.MemberRows</td>
                         <td>@row.TotalDonationAmount</td>
-                        <td>@(row.UpdatedAt ?? "-")</td>
+                        <td>@row.UpdatedAt</td>
                     </tr>
                 </tbody>
                 }

+ 1 - 1
Admin/Pages/Member/Grade/Index.cshtml.cs

@@ -45,7 +45,7 @@ namespace Admin.Pages.Member.Grade
                 TotalDonationAmount = c.RequiredExp,
                 MemberRows = c.MemberRows,
                 IsActive = c.IsActive ? 'Y' : 'N',
-                UpdatedAt = c.UpdatedAt.GetDateAt(),
+                UpdatedAt = c.UpdatedAt.GetDateAt() ?? "-",
                 CreatedAt = c.CreatedAt.GetDateAt(),
                 EditURL = $"/Member/Grade/Edit/{c.ID}"
             })];

+ 4 - 1
Admin/Pages/Member/List/Edit.cshtml.cs

@@ -110,7 +110,10 @@ public class EditModel(IMediator mediator, IFileStorage fileStorage) : PageModel
         await LoadMemberGradeList(ct);
 
         var result = await mediator.Send(new GetMember.Query(id), ct);
-        if (result is null) return;
+        if (result is null)
+        {
+            return;
+        }
 
         Thumb = result.Thumb;
         Icon = result.Icon;

+ 1 - 1
Admin/Pages/Member/Log/Intro.cshtml.cs

@@ -1,10 +1,10 @@
 using SharedKernel.Helpers;
+using SharedKernel.Extensions;
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
-using SharedKernel.Extensions;
 
 namespace Admin.Pages.Member.Log;
 

+ 1 - 1
Admin/Pages/Member/Log/Login/Info.cshtml.cs

@@ -1,7 +1,7 @@
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
-using GetLoginLog = Application.Features.Member.Logs.Login.Get;
+using GetLoginLog = Application.Features.Admin.Member.Logs.Login.Get;
 
 namespace Admin.Pages.Member.Log.Login;
 

+ 28 - 15
Admin/Pages/Popup/Edit.cshtml

@@ -1,7 +1,7 @@
 @page "{id:int}"
 @model Admin.Pages.Popup.EditModel
 @{
-    ViewData["Title"] = "ÆË¾÷ ¼öÁ¤";
+    ViewData["Title"] = "�업 수정";
 }
 
 <div class="container">
@@ -16,31 +16,44 @@
         <input type="hidden" asp-for="QueryString" />
 
         <div class="row mb-2">
-            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span class="text-danger">*</span> Á¦¸ñ</label>
+            <label asp-for="Input.PositionID" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 위치</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <select asp-for="Input.PositionID" asp-items="Model.Positions" class="form-select" required>
+                            <option value="">-- 선� --</option>
+                        </select>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.PositionID" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 제목</label>
             <div class="col-sm-10">
                 <input asp-for="Input.Subject" class="form-control" required />
                 <span asp-validation-for="Input.Subject" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label asp-for="Input.Content" class="col-sm-2 col-form-label">³»¿ë</label>
+            <label asp-for="Input.Content" class="col-sm-2 col-form-label">ë‚´ìš©</label>
             <div class="col-sm-10">
                 <textarea asp-for="Input.Content" class="form-control ck-editor"></textarea>
                 <span asp-validation-for="Input.Content" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label asp-for="Input.Link" class="col-sm-2 col-form-label">ÁÖ¼Ò</label>
+            <label asp-for="Input.Link" class="col-sm-2 col-form-label">주소</label>
             <div class="col-sm-10">
                 <input asp-for="Input.Link" class="form-control" />
                 <span asp-validation-for="Input.Link" class="text-danger"></span>
                 <span class="text-muted form-text">
-                    ÆË¾÷ Ŭ¸¯ ½Ã ÆäÀÌÁö À̵¿ ÁÖ¼Ò¸¦ ÁöÁ¤ÇÕ´Ï´Ù.
+                    �업 �릭 시 ��할 주소를 설정합니다.
                 </span>
             </div>
         </div>
         <div class="row mb-2">
-            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> ¼ø¼­</label>
+            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 순서</label>
             <div class="col-sm-10">
                 <div class="row">
                     <div class="col col-md-auto">
@@ -51,7 +64,7 @@
             </div>
         </div>
         <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">»ç¿ë ±â°£</label>
+            <label class="col-sm-2 col-form-label">사용 기간</label>
             <div class="col-sm-10">
                 <div class="row g-2">
                     <div class="col col-md-auto">
@@ -65,16 +78,16 @@
                     </div>
                 </div>
                 <span class="text-muted form-text">
-                    »ç¿ë ±â°£À» ¼³Á¤ÇÏÁö ¾ÊÀ¸¸é ¹«Á¦ÇÑÀ¸·Î »ç¿ëµË´Ï´Ù.
+                    사용 기간� 설정하지 않으면 무기한으로 노출�니다.
                 </span>
             </div>
         </div>
         <div class="row mb-2">
-            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">»ç¿ë ¿©ºÎ</label>
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
             <div class="col-sm-10 align-content-center">
                 <div class="form-check-inline">
                     <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
-                    <label class="form-check-label" asp-for="Input.IsActive">»ç¿ëÇÕ´Ï´Ù.</label>
+                    <label class="form-check-label" asp-for="Input.IsActive">사용합니다.</label>
                     <span asp-validation-for="Input.IsActive" class="text-danger"></span>
                 </div>
             </div>
@@ -83,7 +96,7 @@
         @if (Model.Input.UpdatedAt is not null)
         {
             <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">¼öÁ¤ÀϽÃ</label>
+                <label class="col-sm-2 col-form-label">수정�시</label>
                 <div class="col-sm-10">
                     <input asp-for="Input.UpdatedAt" class="form-control-plaintext" type="text" readonly />
                 </div>
@@ -92,7 +105,7 @@
         @if (Model.Input.CreatedAt is not null)
         {
             <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">µî·ÏÀϽÃ</label>
+                <label class="col-sm-2 col-form-label">등��시</label>
                 <div class="col-sm-10">
                     <input asp-for="Input.CreatedAt" class="form-control-plaintext" type="text" readonly />
                 </div>
@@ -101,8 +114,8 @@
 
         <hr />
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-success">ÀúÀå</button>
-            <a href="/Popup?@Model.QueryString" class="btn btn-secondary">Ãë¼Ò</a>
+            <button type="submit" class="btn btn-success">수정</button>
+            <a href="/Popup?@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
@@ -112,4 +125,4 @@
     @{
 
     }
-}
+}

+ 36 - 14
Admin/Pages/Popup/Edit.cshtml.cs

@@ -1,7 +1,8 @@
+using SharedKernel.Extensions;
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
-using SharedKernel.Extensions;
+using Microsoft.AspNetCore.Mvc.Rendering;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
@@ -12,44 +13,50 @@ public class EditModel(IMediator mediator) : PageModel
     [BindProperty]
     public string? QueryString { get; set; }
 
+    public List<SelectListItem> Positions { get; private set; } = [];
+
     [BindProperty]
     public InputModel Input { get; set; } = new();
 
     public sealed class InputModel
     {
         [DisplayName("ID")]
-        [Required(ErrorMessage = "{0}´Â ÇʼöÀÔ´Ï´Ù.")]
+        [Required(ErrorMessage = "{0}� 필수입니다.")]
         public int ID { get; set; }
 
-        [DisplayName("Á¦¸ñ")]
+        [DisplayName("위치")]
+        [Required(ErrorMessage = "{0}� 필수입니다.")]
+        public int PositionID { get; set; }
+
+        [DisplayName("제목")]
         [DataType(DataType.Text)]
-        [Required(ErrorMessage = "{0}´Â ÇʼöÀÔ´Ï´Ù.")]
-        [StringLength(255, ErrorMessage = "{0}Àº {1}ÀÚ ÀÌÇÏ·Î ÀÔ·ÂÇϼ¼¿ä.")]
+        [Required(ErrorMessage = "{0}� 필수입니다.")]
+        [StringLength(255, ErrorMessage = "{0}� {1}� �하로 입력하세요.")]
         public string Subject { get; set; } = default!;
 
-        [DisplayName("³»¿ë")]
+        [DisplayName("ë‚´ìš©")]
         [DataType(DataType.Html)]
-        [StringLength(4000, ErrorMessage = "{0}Àº {1}ÀÚ ÀÌÇÏ·Î ÀÔ·ÂÇϼ¼¿ä.")]
+        [StringLength(4000, ErrorMessage = "{0}� {1}� �하로 입력하세요.")]
         public string? Content { get; set; }
 
-        [DisplayName("ÁÖ¼Ò")]
+        [DisplayName("주소")]
         [DataType(DataType.Url)]
-        [StringLength(255, ErrorMessage = "{0}Àº {1}ÀÚ ÀÌÇÏ·Î ÀÔ·ÂÇϼ¼¿ä.")]
+        [StringLength(255, ErrorMessage = "{0}� {1}� �하로 입력하세요.")]
         public string? Link { get; set; }
 
-        [DisplayName("½ÃÀÛ ÀϽÃ")]
+        [DisplayName("시작 �시")]
         [DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)]
         public DateTime? StartAt { get; set; }
 
-        [DisplayName("Á¾·á ÀϽÃ")]
+        [DisplayName("종료 �시")]
         [DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)]
         public DateTime? EndAt { get; set; }
 
-        [DisplayName("¼ø¼­")]
+        [DisplayName("순서")]
         [Range(-9999, 9999)]
         public short Order { get; set; }
 
-        [DisplayName("»ç¿ë ¿©ºÎ")]
+        [DisplayName("사용 여부")]
         public bool IsActive { get; set; }
 
         public string? UpdatedAt { get; set; }
@@ -60,6 +67,8 @@ public class EditModel(IMediator mediator) : PageModel
     {
         QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
 
+        await LoadPositionsAsync(ct);
+
         var popup = await mediator.Send(new GetPopup.Query(id), ct);
         if (popup is null)
         {
@@ -67,6 +76,7 @@ public class EditModel(IMediator mediator) : PageModel
         }
 
         Input.ID = popup.ID;
+        Input.PositionID = popup.PositionID;
         Input.Subject = popup.Subject;
         Input.Content = popup.Content;
         Input.Link = popup.Link;
@@ -84,11 +94,13 @@ public class EditModel(IMediator mediator) : PageModel
         {
             if (!ModelState.IsValid)
             {
+                await LoadPositionsAsync(ct);
                 return Page();
             }
 
             var command = new UpdatePopup.Command(
                 Input.ID,
+                Input.PositionID,
                 Input.Subject,
                 Input.Content,
                 Input.Link,
@@ -100,7 +112,7 @@ public class EditModel(IMediator mediator) : PageModel
 
             await mediator.Send(command, ct);
 
-            TempData["SuccessMessage"] = $"{Input.Subject} ÆË¾÷ÀÌ ¼öÁ¤µÇ¾ú½À´Ï´Ù.";
+            TempData["SuccessMessage"] = $"{Input.Subject} �업� 수정�었습니다.";
         }
         catch (Exception e)
         {
@@ -109,4 +121,14 @@ public class EditModel(IMediator mediator) : PageModel
 
         return Redirect($"/Popup/Edit/{Input.ID}?{QueryString}");
     }
+
+    private async Task LoadPositionsAsync(CancellationToken ct)
+    {
+        var result = await mediator.Send(new GetPopupPositions.Query(), ct);
+
+        Positions = [..result.List.Select(x => new SelectListItem(
+            x.Subject,
+            x.ID.ToString())
+        )];
+    }
 }

+ 26 - 22
Admin/Pages/Popup/Index.cshtml

@@ -1,7 +1,7 @@
 @page
 @model Admin.Pages.Popup.IndexModel
 @{
-    ViewData["Title"] = "ÆË¾÷ °ü¸®";
+    ViewData["Title"] = "�업 목�";
 }
 
 <div class="container-fluid">
@@ -9,8 +9,9 @@
     <hr />
 
     <partial name="_StatusMessage" />
+    <partial name="_NavTabs" />
 
-    <div class="row g-2 align-items-end">
+    <div class="row g-2 align-items-end mt-2">
         <div class="col">
             Total : @Model.Total.ToString("N0")
         </div>
@@ -23,8 +24,8 @@
             </select>
         </div>
         <div class="col-auto">
-            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>»èÁ¦</button>
-            <a class="btn btn-success" asp-page="/Popup/Write">Ãß°¡</a>
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Popup/Write">추가</a>
         </div>
     </div>
 
@@ -32,13 +33,14 @@
         <table class="table table-striped table-bordered table-hover mt-3">
             <colgroup>
                 <col style="width: 5%;" />
-                <col style="width: 25%;" />
+                <col style="width: 10%;" />
+                <col style="width: 20%;" />
                 <col />
-                <col style="width: 12%;" />
-                <col style="width: 12%;" />
-                <col style="width: 6%;" />
-                <col style="width: 6%;" />
-                <col style="width: 12%;" />
+                <col style="width: 10%;" />
+                <col style="width: 10%;" />
+                <col style="width: 5%;" />
+                <col style="width: 5%;" />
+                <col style="width: 10%;" />
                 <col style="width: 10%;" />
             </colgroup>
             <thead>
@@ -49,21 +51,22 @@
                             <label for="checkedAll">ID</label>
                         </div>
                     </th>
-                    <th>Á¦¸ñ</th>
-                    <th>ÁÖ¼Ò</th>
-                    <th>½ÃÀÛ</th>
-                    <th>Á¾·á</th>
-                    <th>¼ø¼­</th>
-                    <th>»ç¿ë</th>
-                    <th>µî·ÏÀϽÃ</th>
-                    <th>ºñ°í</th>
+                    <th>위치</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.Total <= 0)
                 {
                     <tr>
-                        <td colspan="9">No Data.</td>
+                        <td colspan="10">No Data.</td>
                     </tr>
                 }
                 else
@@ -77,6 +80,7 @@
                                     <label for="ids_@row.ID">@row.ID</label>
                                 </div>
                             </td>
+                            <td>[@row.PositionCode] @row.PositionSubject</td>
                             <td class="text-start">@row.Subject</td>
                             <td>
                                 @if (!string.IsNullOrWhiteSpace(row.Link) && row.Link != "-")
@@ -97,8 +101,8 @@
                             <td>@row.CreatedAt</td>
                             <td>
                                 <div class="d-xl-flex gap-2 justify-content-center d-grid">
-                                    <a class="btn btn-sm btn-outline-info" href="@row.EditURL">¼öÁ¤</a>
-                                    <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID" data-subject="@row.Subject">»èÁ¦</button>
+                                    <a class="btn btn-sm btn-outline-info" href="@row.EditURL">수정</a>
+                                    <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID" data-subject="@row.Subject">삭제</button>
                                 </div>
                             </td>
                         </tr>
@@ -129,4 +133,4 @@
            searchForm.submit();
         });
     </script>
-}
+}

+ 7 - 3
Admin/Pages/Popup/Index.cshtml.cs

@@ -15,11 +15,11 @@ public class IndexModel(IMediator mediator) : PageModel
     public sealed class QueryParams
     {
         [Range(1, int.MaxValue)]
-        [DisplayName("ÆäÀÌÁö ¹øÈ£")]
+        [DisplayName("페�지 번호")]
         public int PageNum { get; set; } = 1;
 
         [Range(1, 100)]
-        [DisplayName("ÆäÀÌÁö ¸ñ·Ï ¼ö")]
+        [DisplayName("페�지 당 수")]
         public ushort PerPage { get; set; } = 10;
     }
 
@@ -28,6 +28,8 @@ public class IndexModel(IMediator mediator) : PageModel
     public List<(
         int Num,
         int ID,
+        string PositionCode,
+        string PositionSubject,
         string Subject,
         string? Link,
         string? StartAt,
@@ -55,6 +57,8 @@ public class IndexModel(IMediator mediator) : PageModel
         List = [.. result.List.Select(c => (
             c.Num,
             c.ID,
+            c.PositionCode,
+            c.PositionSubject,
             c.Subject,
             c.Link ?? "-",
             c.StartAt ?? "-",
@@ -76,7 +80,7 @@ public class IndexModel(IMediator mediator) : PageModel
         {
             await mediator.Send(new DeletePopup.Command(ids), ct);
 
-            TempData["SuccessMessage"] = $"{ids.Length}°³ ÆË¾÷ÀÌ »èÁ¦µÇ¾ú½À´Ï´Ù.";
+            TempData["SuccessMessage"] = $"{ids.Length}개 �업� 삭제�었습니다.";
         }
         catch (Exception e)
         {

+ 194 - 0
Admin/Pages/Popup/Position.cshtml

@@ -0,0 +1,194 @@
+@page
+@model Admin.Pages.Popup.PositionModel
+@{
+    ViewData["Title"] = "팝업 위치";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_NavTabs" />
+
+    <div class="row g-2 align-items-end mt-2">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <button type="button" id="btnAdd" class="btn btn-primary" form="fAdminWrite">추가</button>
+            <button type="submit" id="btnSave" class="btn btn-success" form="fAdminWrite">저장</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off"></form>
+
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <caption>
+                팝업이 등록된 위치는 삭제가 불가합니다.<br />
+                팝업 위치를 삭제하려면 해당 팝업을 먼저 삭제해주세요.
+            </caption>
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr class="text-center">
+                    <th>ID</th>
+                    <th>Code</th>
+                    <th>위치 명</th>
+                    <th>팝업 수</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>관리</th>
+                </tr>
+            </thead>
+            <tbody id="positions">
+                @if (Model.List is null || Model.List.Count <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        var index = row.Index;
+
+                        <tr>
+                            <td>
+                                <input type="text" readonly class="form-control-plaintext text-center @(row.PopupRows > 0 ? "text-white bg-danger" : "")" value="@row.Num" />
+                                <input type="hidden" name="request[@index].ID" readonly class="form-control-plaintext text-center" required form="fAdminWrite" value="@row.ID" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Code" class="form-control" maxlength="30" required form="fAdminWrite" value="@row.Code" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Subject" class="form-control" maxlength="255" required form="fAdminWrite" value="@row.Subject" />
+                            </td>
+                            <td>@row.PopupRows</td>
+                            <td>
+                                <div class="form-check-inline">
+                                    <input class="form-check-input" type="checkbox" id="request_@(index)_IsActive" name="request[@index].IsActive" checked="@Model.Data[index].IsActive" form="fAdminWrite" value="true" />
+                                    <label class="form-check-label" for="request_@(index)_IsActive">사용</label>
+                                </div>
+                            </td>
+                            <td><input type="text" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.CreatedAt" /></td>
+                            <td><input type="text" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.UpdatedAt" /></td>
+                            <td>
+                                <button type="button" class="btn btn-sm btn-danger btn-delete">삭제</button>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        $(function() {
+            let $positions = $("#positions");
+            let total = Number(@Model.Total);
+
+            // 추가
+            $(document).on("click", "#btnAdd", function() {
+                if (total <= 0) {
+                    $positions.empty();
+                }
+
+                let tableRow = `
+                    <tr>
+                        <td>-</td>
+                        <td>
+                            <input type="text" name="request[${total}].Code" class="form-control" maxlength="30" required form="fAdminWrite" />
+                        </td>
+                        <td>
+                            <input type="text" name="request[${total}].Subject" class="form-control" maxlength="255" required form="fAdminWrite" />
+                        </td>
+                        <td>-</td>
+                        <td>
+                            <div class="form-check-inline">
+                                <input class="form-check-input" type="checkbox" id="request_${total}_IsActive" name="request[${total}].IsActive" checked form="fAdminWrite" value="true" />
+                                <label class="form-check-label" for="request_${total}_IsActive">사용</label>
+                            </div>
+                        </td>
+                        <td>-</td>
+                        <td>-</td>
+                        <td>
+                            <button type="button" class="btn btn-danger btn-sm btn-delete">삭제</button>
+                        </td>
+                    </tr>
+                `;
+
+                $positions.append(tableRow);
+                total++;
+                recalculateIndices();
+            });
+
+            // 삭제
+            $(document).on("click", "button.btn-delete", function(e) {
+                e.target.closest("tr").remove();
+                total--;
+
+                if (total <= 0) {
+                    $positions.append(`<tr><td colspan="8">No Data.</td></tr>`);
+                    total = 0;
+                } else {
+                    recalculateIndices();
+                }
+            });
+
+            // 저장
+            $(document).on("click", "#btnSave", function() {
+                if (confirm("저장 하시겠습니까?")) {
+                    let form = document.getElementById("fAdminWrite");
+                    if (form.checkValidity()) {
+                        form.submit();
+                    } else {
+                        form.reportValidity();
+                    }
+                }
+                return false;
+            });
+
+            function recalculateIndices() {
+                $positions.find("tr").each(function(index, tr) {
+                    $(tr)
+                        .find("input, label")
+                        .each(function() {
+                            let name = $(this).attr("name");
+                            let id = $(this).attr("id");
+
+                            if (name) {
+                                $(this).attr("name", name.replace(/\[\d+\]/, `[${index}]`));
+                            }
+
+                            if (id) {
+                                $(this).attr("id", id.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+
+                    $(tr)
+                        .find("label")
+                        .each(function() {
+                            let labelFor = $(this).attr("for");
+                            if (labelFor) {
+                                $(this).attr("for", labelFor.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+                });
+            }
+        });
+    </script>
+}

+ 105 - 0
Admin/Pages/Popup/Position.cshtml.cs

@@ -0,0 +1,105 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Popup;
+
+public class PositionModel(IMediator mediator) : PageModel
+{
+    public int Total { get; private set; }
+
+    public List<(
+        int Num,
+        int ID,
+        int Index,
+        string Code,
+        string Subject,
+        char IsActive,
+        int PopupRows,
+        string? UpdatedAt,
+        string CreatedAt
+    )> List { get; set; } = [];
+
+    [BindProperty(Name = "request")]
+    public List<InputModel> Input { get; private set; } = [];
+
+    public List<InputModel> Data { get; private set; } = [];
+
+    public sealed class InputModel
+    {
+        public int? ID { get; set; }
+
+        [Required]
+        [StringLength(30)]
+        public required string Code { get; set; }
+
+        [Required]
+        [StringLength(255)]
+        public required string Subject { get; set; }
+
+        public bool IsActive { get; set; }
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var result = await mediator.Send(new GetPopupPositions.Query(), ct);
+
+        Total = result.Total;
+        List = [..result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.Index,
+            c.Code,
+            c.Subject,
+            c.IsActive ? 'Y' : 'N',
+            c.PopupRows,
+            c.UpdatedAt.GetDateAt() ?? "-",
+            c.CreatedAt.GetDateAt()
+        ))];
+
+        Data = [..result.List.Select(x => new InputModel
+        {
+            ID = x.ID,
+            Code = x.Code,
+            Subject = x.Subject,
+            IsActive = x.IsActive
+        })];
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception();
+            }
+
+            var cmd = new SavePopupPositions.Command(
+                [..Input.Select(x => new SavePopupPositions.Command.Row(
+                    x.ID,
+                    x.Code,
+                    x.Subject,
+                    x.IsActive
+                ))]
+            );
+
+            var response = await mediator.Send(cmd, ct);
+
+            TempData["SuccessMessage"] = $"저장 완료 (추가: {response.Inserted}, 수정: {response.Updated}, 삭제: {response.Deleted})";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Popup/Position");
+    }
+}

+ 26 - 13
Admin/Pages/Popup/Write.cshtml

@@ -1,7 +1,7 @@
 @page
 @model Admin.Pages.Popup.WriteModel
 @{
-    ViewData["Title"] = "ÆË¾÷ µî·Ï";
+    ViewData["Title"] = "�업 등�";
 }
 
 <div class="container">
@@ -13,31 +13,44 @@
 
     <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
         <div class="row mb-2">
-            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span class="text-danger">*</span> Á¦¸ñ</label>
+            <label asp-for="Input.PositionID" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 위치</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <select asp-for="Input.PositionID" asp-items="Model.Positions" class="form-select" required>
+                            <option value="">-- 선� --</option>
+                        </select>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.PositionID" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 제목</label>
             <div class="col-sm-10">
                 <input asp-for="Input.Subject" class="form-control" required />
                 <span asp-validation-for="Input.Subject" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label asp-for="Input.Content" class="col-sm-2 col-form-label">³»¿ë</label>
+            <label asp-for="Input.Content" class="col-sm-2 col-form-label">ë‚´ìš©</label>
             <div class="col-sm-10">
                 <textarea asp-for="Input.Content" class="form-control ck-editor"></textarea>
                 <span asp-validation-for="Input.Content" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label asp-for="Input.Link" class="col-sm-2 col-form-label">ÁÖ¼Ò</label>
+            <label asp-for="Input.Link" class="col-sm-2 col-form-label">주소</label>
             <div class="col-sm-10">
                 <input asp-for="Input.Link" class="form-control" />
                 <span asp-validation-for="Input.Link" class="text-danger"></span>
                 <span class="text-muted form-text">
-                    ÆË¾÷ Ŭ¸¯ ½Ã ÆäÀÌÁö À̵¿ ÁÖ¼Ò¸¦ ÁöÁ¤ÇÕ´Ï´Ù.
+                    �업 �릭 시 ��할 주소를 설정합니다.
                 </span>
             </div>
         </div>
         <div class="row mb-2">
-            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> ¼ø¼­</label>
+            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 순서</label>
             <div class="col-sm-10">
                 <div class="row">
                     <div class="col col-md-auto">
@@ -48,7 +61,7 @@
             </div>
         </div>
         <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">»ç¿ë ±â°£</label>
+            <label class="col-sm-2 col-form-label">사용 기간</label>
             <div class="col-sm-10">
                 <div class="row g-2">
                     <div class="col col-md-auto">
@@ -62,16 +75,16 @@
                     </div>
                 </div>
                 <span class="text-muted form-text">
-                    »ç¿ë ±â°£À» ¼³Á¤ÇÏÁö ¾ÊÀ¸¸é ¹«Á¦ÇÑÀ¸·Î »ç¿ëµË´Ï´Ù.
+                    사용 기간� 설정하지 않으면 무기한으로 노출�니다.
                 </span>
             </div>
         </div>
         <div class="row mb-2">
-            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">»ç¿ë ¿©ºÎ</label>
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
             <div class="col-sm-10 align-content-center">
                 <div class="form-check-inline">
                     <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
-                    <label class="form-check-label" asp-for="Input.IsActive">»ç¿ëÇÕ´Ï´Ù.</label>
+                    <label class="form-check-label" asp-for="Input.IsActive">사용합니다.</label>
                     <span asp-validation-for="Input.IsActive" class="text-danger"></span>
                 </div>
             </div>
@@ -79,8 +92,8 @@
 
         <hr />
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-success">ÀúÀå</button>
-            <a href="/Popup?@Model.QueryString" class="btn btn-secondary">Ãë¼Ò</a>
+            <button type="submit" class="btn btn-success">등�</button>
+            <a href="/Popup?@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
@@ -90,4 +103,4 @@
     @{
 
     }
-}
+}

+ 35 - 15
Admin/Pages/Popup/Write.cshtml.cs

@@ -1,6 +1,7 @@
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
@@ -10,48 +11,54 @@ public class WriteModel(IMediator mediator) : PageModel
 {
     public string QueryString { get; private set; } = "";
 
+    public List<SelectListItem> Positions { get; private set; } = [];
+
     [BindProperty]
     public InputModel Input { get; set; } = new();
 
     public sealed class InputModel
     {
-        [DisplayName("Á¦¸ñ")]
+        [DisplayName("위치")]
+        [Required(ErrorMessage = "{0}� 필수입니다.")]
+        public int PositionID { get; set; }
+
+        [DisplayName("제목")]
         [DataType(DataType.Text)]
-        [Required(ErrorMessage = "{0}´Â ÇʼöÀÔ´Ï´Ù.")]
-        [StringLength(255, ErrorMessage = "{0}Àº {1}ÀÚ ÀÌÇÏ·Î ÀÔ·ÂÇϼ¼¿ä.")]
+        [Required(ErrorMessage = "{0}� 필수입니다.")]
+        [StringLength(255, ErrorMessage = "{0}� {1}� �하로 입력하세요.")]
         public string Subject { get; set; } = default!;
 
-        [DisplayName("³»¿ë")]
+        [DisplayName("ë‚´ìš©")]
         [DataType(DataType.Html)]
-        [StringLength(4000, ErrorMessage = "{0}Àº {1}ÀÚ ÀÌÇÏ·Î ÀÔ·ÂÇϼ¼¿ä.")]
+        [StringLength(4000, ErrorMessage = "{0}� {1}� �하로 입력하세요.")]
         public string? Content { get; set; }
 
-        [DisplayName("ÁÖ¼Ò")]
+        [DisplayName("주소")]
         [DataType(DataType.Url)]
-        [StringLength(255, ErrorMessage = "{0}Àº {1}ÀÚ ÀÌÇÏ·Î ÀÔ·ÂÇϼ¼¿ä.")]
+        [StringLength(255, ErrorMessage = "{0}� {1}� �하로 입력하세요.")]
         public string? Link { get; set; }
 
-        [DisplayName("½ÃÀÛ ÀϽÃ")]
+        [DisplayName("시작 �시")]
         [DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)]
         public DateTime? StartAt { get; set; }
 
-        [DisplayName("Á¾·á ÀϽÃ")]
+        [DisplayName("종료 �시")]
         [DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)]
         public DateTime? EndAt { get; set; }
 
-        [DisplayName("¼ø¼­")]
+        [DisplayName("순서")]
         [Range(-9999, 9999)]
         public short Order { get; set; }
 
-        [DisplayName("»ç¿ë ¿©ºÎ")]
+        [DisplayName("사용 여부")]
         public bool IsActive { get; set; } = false;
     }
 
-    public Task OnGetAsync(CancellationToken _)
+    public async Task OnGetAsync(CancellationToken ct)
     {
         QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
 
-        return Task.CompletedTask;
+        await LoadPositionsAsync(ct);
     }
 
     public async Task<IActionResult> OnPostAsync(CancellationToken ct)
@@ -60,10 +67,11 @@ public class WriteModel(IMediator mediator) : PageModel
         {
             if (!ModelState.IsValid)
             {
-                throw new Exception("À¯È¿¼º °Ë»ç¿¡ ½ÇÆÐÇß½À´Ï´Ù.");
+                throw new Exception("유효성 검사� 실패했습니다.");
             }
 
             var command = new CreatePopup.Command(
+                Input.PositionID,
                 Input.Subject,
                 Input.Content,
                 Input.Link,
@@ -75,7 +83,7 @@ public class WriteModel(IMediator mediator) : PageModel
 
             await mediator.Send(command, ct);
 
-            TempData["SuccessMessage"] = $"{Input.Subject} ÆË¾÷ÀÌ µî·ÏµÇ¾ú½À´Ï´Ù.";
+            TempData["SuccessMessage"] = $"{Input.Subject} �업� 등��었습니다.";
 
             return RedirectToPage("/Popup/Index");
         }
@@ -83,7 +91,19 @@ public class WriteModel(IMediator mediator) : PageModel
         {
             TempData["ErrorMessages"] = e.Message;
 
+            await LoadPositionsAsync(ct);
+
             return Redirect($"/Popup/Write?{QueryString}");
         }
     }
+
+    private async Task LoadPositionsAsync(CancellationToken ct)
+    {
+        var result = await mediator.Send(new GetPopupPositions.Query(), ct);
+
+        Positions = [..result.List.Select(x => new SelectListItem(
+            x.Subject,
+            x.ID.ToString())
+        )];
+    }
 }

+ 9 - 0
Admin/Pages/Popup/_NavTabs.cshtml

@@ -0,0 +1,9 @@
+@*NavTabs*@
+<ul class="nav nav-tabs">
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Popup/Index")" asp-page="/Popup/Index">팝업 목록</a>
+    </li>
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Popup/Position")" asp-page="/Popup/Position">팝업 위치</a>
+    </li>
+</ul>

+ 1 - 1
Admin/Pages/Server/Env.cshtml.cs

@@ -18,4 +18,4 @@ namespace Admin.Pages.Server
                .ToArray();
         }
     }
-}
+}

+ 2 - 1
Admin/Pages/Shared/_MenuItem.cshtml

@@ -15,7 +15,8 @@
     {
         { "게시판 관리", new[] { "/forum/board/meta", "/forum/board/prefix", "/forum/board/manager", "/forum/board/list" } },
         { "FAQ 관리", new[] { "/faq/list", "/faq/category" } },
-        { "배너 관리", new[] { "/banner/list", "/banner/position" } }
+        { "배너 관리", new[] { "/banner/list", "/banner/position" } },
+        { "코인 목록", new[] { "/crypto/list", "/crypto/board" } }
     };
 
     if (multiPathMenus.TryGetValue(Model.Name, out var activePrefixes))

+ 10 - 9
Admin/Properties/launchSettings.json

@@ -1,23 +1,24 @@
 {
-  "$schema": "https://json.schemastore.org/launchsettings.json",
   "profiles": {
     "http": {
       "commandName": "Project",
-      "dotnetRunMessages": true,
       "launchBrowser": true,
-      "applicationUrl": "http://localhost:5033",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"
-      }
+      },
+      "dotnetRunMessages": true,
+      "applicationUrl": "http://localhost:5033"
     },
     "https": {
       "commandName": "Project",
-      "dotnetRunMessages": true,
       "launchBrowser": true,
-      "applicationUrl": "https://localhost:7205;http://localhost:5033",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"
-      }
+      },
+      "dotnetRunMessages": true,
+      "applicationUrl": "https://localhost:7205;http://localhost:5033",
+      "hotReloadEnabled": true
     }
-  }
-}
+  },
+  "$schema": "https://json.schemastore.org/launchsettings.json"
+}

+ 133 - 105
Admin/using.cs

@@ -21,172 +21,200 @@ global using SearchAdminAccessLogs = Application.Features.Director.AccessLog.Sea
 global using DeleteAdminAccessLog = Application.Features.Director.AccessLog.Delete;
 
 // 문서
-global using SearchDocuments = Application.Features.Document.Search;
-global using GetDocument = Application.Features.Document.Get;
-global using CreateDocument = Application.Features.Document.Create;
-global using UpdateDocument = Application.Features.Document.Update;
-global using DeleteDocument = Application.Features.Document.Delete;
+global using SearchDocuments = Application.Features.Admin.Document.Search;
+global using GetDocument = Application.Features.Admin.Document.Get;
+global using CreateDocument = Application.Features.Admin.Document.Create;
+global using UpdateDocument = Application.Features.Admin.Document.Update;
+global using DeleteDocument = Application.Features.Admin.Document.Delete;
 
 // 팝업
-global using SearchPopups = Application.Features.Popup.Search;
-global using GetPopup = Application.Features.Popup.Get;
-global using CreatePopup = Application.Features.Popup.Create;
-global using UpdatePopup = Application.Features.Popup.Update;
-global using DeletePopup = Application.Features.Popup.Delete;
+global using SearchPopups = Application.Features.Admin.Popup.Search;
+global using GetPopup = Application.Features.Admin.Popup.Get;
+global using CreatePopup = Application.Features.Admin.Popup.Create;
+global using UpdatePopup = Application.Features.Admin.Popup.Update;
+global using DeletePopup = Application.Features.Admin.Popup.Delete;
+
+// 팝업 위치
+global using GetPopupPositions = Application.Features.Admin.Popup.Position.GetAll;
+global using SavePopupPositions = Application.Features.Admin.Popup.Position.Save;
 
 // FAQ 분류
-global using GetFaqCategories = Application.Features.Faq.Category.GetAll;
-global using SaveFaqCategories = Application.Features.Faq.Category.Save;
+global using GetFaqCategories = Application.Features.Admin.Faq.Category.GetAll;
+global using SaveFaqCategories = Application.Features.Admin.Faq.Category.Save;
 
 // FAQ 목록
-global using SearchFaqItems = Application.Features.Faq.Item.Search;
-global using GetFaqItem = Application.Features.Faq.Item.Get;
-global using CreateFaqItem = Application.Features.Faq.Item.Create;
-global using UpdateFaqItem = Application.Features.Faq.Item.Update;
-global using DeleteFaqItem = Application.Features.Faq.Item.Delete;
+global using SearchFaqItems = Application.Features.Admin.Faq.Item.Search;
+global using GetFaqItem = Application.Features.Admin.Faq.Item.Get;
+global using CreateFaqItem = Application.Features.Admin.Faq.Item.Create;
+global using UpdateFaqItem = Application.Features.Admin.Faq.Item.Update;
+global using DeleteFaqItem = Application.Features.Admin.Faq.Item.Delete;
 
 // 배너 위치
-global using GetBannerPositions = Application.Features.Banner.Position.GetAll;
-global using SaveBannerPositions = Application.Features.Banner.Position.Save;
+global using GetBannerPositions = Application.Features.Admin.Banner.Position.GetAll;
+global using SaveBannerPositions = Application.Features.Admin.Banner.Position.Save;
 
 // 배너 목록
-global using SearchBannerItems = Application.Features.Banner.Item.Search;
-global using GetBannerItem = Application.Features.Banner.Item.Get;
-global using CreateBannerItem = Application.Features.Banner.Item.Create;
-global using UpdateBannerItem = Application.Features.Banner.Item.Update;
-global using DeleteBannerItem = Application.Features.Banner.Item.Delete;
+global using SearchBannerItems = Application.Features.Admin.Banner.Item.Search;
+global using GetBannerItem = Application.Features.Admin.Banner.Item.Get;
+global using CreateBannerItem = Application.Features.Admin.Banner.Item.Create;
+global using UpdateBannerItem = Application.Features.Admin.Banner.Item.Update;
+global using DeleteBannerItem = Application.Features.Admin.Banner.Item.Delete;
 
 // 회원 목록
-global using SearchMembers = Application.Features.Member.List.Search;
-global using GetMember = Application.Features.Member.List.Get;
-global using CreateMember = Application.Features.Member.List.Create;
-global using UpdateMember = Application.Features.Member.List.Update;
-global using DeleteMember = Application.Features.Member.List.Delete;
-global using ApproveMember = Application.Features.Member.List.Approve;
+global using SearchMembers = Application.Features.Admin.Member.List.Search;
+global using GetMember = Application.Features.Admin.Member.List.Get;
+global using CreateMember = Application.Features.Admin.Member.List.Create;
+global using UpdateMember = Application.Features.Admin.Member.List.Update;
+global using DeleteMember = Application.Features.Admin.Member.List.Delete;
+global using ApproveMember = Application.Features.Admin.Member.List.Approve;
 
 // 회원 등급
-global using GetMemberGrades = Application.Features.MemberGrade.GetAll;
-global using GetMemberGrade = Application.Features.MemberGrade.Get;
-global using CreateMemberGrade = Application.Features.MemberGrade.Create;
-global using UpdateMemberGrade = Application.Features.MemberGrade.Update;
-global using DeleteMemberGrade = Application.Features.MemberGrade.Delete;
+global using GetMemberGrades = Application.Features.Admin.MemberGrade.GetAll;
+global using GetMemberGrade = Application.Features.Admin.MemberGrade.Get;
+global using CreateMemberGrade = Application.Features.Admin.MemberGrade.Create;
+global using UpdateMemberGrade = Application.Features.Admin.MemberGrade.Update;
+global using DeleteMemberGrade = Application.Features.Admin.MemberGrade.Delete;
 
 // 로그인 내역
-global using SearchLoginLogs = Application.Features.Member.Logs.Login.Search;
-global using DeleteLoginLog = Application.Features.Member.Logs.Login.Delete;
+global using SearchLoginLogs = Application.Features.Admin.Member.Logs.Login.Search;
+global using DeleteLoginLog = Application.Features.Admin.Member.Logs.Login.Delete;
 
 // 이메일 변경 내역
-global using SearchEmailChangeLogs = Application.Features.Member.Logs.EmailChange.Search;
-global using DeleteEmailChangeLog = Application.Features.Member.Logs.EmailChange.Delete;
+global using SearchEmailChangeLogs = Application.Features.Admin.Member.Logs.EmailChange.Search;
+global using DeleteEmailChangeLog = Application.Features.Admin.Member.Logs.EmailChange.Delete;
 
 // 자기소개 변경 내역
-global using SearchIntroChangeLogs = Application.Features.Member.Logs.IntroChange.Search;
-global using DeleteIntroChangeLog = Application.Features.Member.Logs.IntroChange.Delete;
+global using SearchIntroChangeLogs = Application.Features.Admin.Member.Logs.IntroChange.Search;
+global using DeleteIntroChangeLog = Application.Features.Admin.Member.Logs.IntroChange.Delete;
 
 // 별명 변경 내역
-global using SearchNameChangeLogs = Application.Features.Member.Logs.NameChange.Search;
-global using DeleteNameChangeLog = Application.Features.Member.Logs.NameChange.Delete;
+global using SearchNameChangeLogs = Application.Features.Admin.Member.Logs.NameChange.Search;
+global using DeleteNameChangeLog = Application.Features.Admin.Member.Logs.NameChange.Delete;
 
 // 한마디 변경 내역
-global using SearchSummaryChangeLogs = Application.Features.Member.Logs.SummaryChange.Search;
-global using DeleteSummaryChangeLog = Application.Features.Member.Logs.SummaryChange.Delete;
+global using SearchSummaryChangeLogs = Application.Features.Admin.Member.Logs.SummaryChange.Search;
+global using DeleteSummaryChangeLog = Application.Features.Admin.Member.Logs.SummaryChange.Delete;
 
 // 지갑 관리
-global using SearchWallets = Application.Features.Member.Wallet.List.Search;
-global using GetWallet = Application.Features.Member.Wallet.List.Get;
-global using ChargeWallet = Application.Features.Member.Wallet.List.Charge;
+global using SearchWallets = Application.Features.Admin.Member.Wallet.List.Search;
+global using GetWallet = Application.Features.Admin.Member.Wallet.List.Get;
+global using ChargeWallet = Application.Features.Admin.Member.Wallet.List.Charge;
 
 // 거래 장부
-global using SearchWalletTransactions = Application.Features.Member.Wallet.Transactions.Search;
-global using GetWalletTransaction = Application.Features.Member.Wallet.Transactions.Get;
-global using DeleteWalletTransaction = Application.Features.Member.Wallet.Transactions.Delete;
+global using SearchWalletTransactions = Application.Features.Admin.Member.Wallet.Transactions.Search;
+global using GetWalletTransaction = Application.Features.Admin.Member.Wallet.Transactions.Get;
+global using DeleteWalletTransaction = Application.Features.Admin.Member.Wallet.Transactions.Delete;
 
 // 게시판 분류
-global using GetBoardGroups = Application.Features.Forum.BoardGroup.GetAll;
-global using SaveBoardGroups = Application.Features.Forum.BoardGroup.Save;
+global using GetBoardGroups = Application.Features.Admin.Forum.BoardGroup.GetAll;
+global using SaveBoardGroups = Application.Features.Admin.Forum.BoardGroup.Save;
 
 // 게시판 목록
-global using SearchBoards = Application.Features.Forum.Board.Search;
-global using GetBoard = Application.Features.Forum.Board.Get;
-global using CreateBoard = Application.Features.Forum.Board.Create;
-global using UpdateBoard = Application.Features.Forum.Board.Update;
-global using DeleteBoard = Application.Features.Forum.Board.Delete;
+global using SearchBoards = Application.Features.Admin.Forum.Board.Search;
+global using GetBoard = Application.Features.Admin.Forum.Board.Get;
+global using CreateBoard = Application.Features.Admin.Forum.Board.Create;
+global using UpdateBoard = Application.Features.Admin.Forum.Board.Update;
+global using DeleteBoard = Application.Features.Admin.Forum.Board.Delete;
 
 // 게시글 목록
-global using SearchPosts = Application.Features.Forum.Post.Search;
-global using GetPost = Application.Features.Forum.Post.Get;
-global using CreatePost = Application.Features.Forum.Post.Create;
-global using UpdatePost = Application.Features.Forum.Post.Update;
-global using DeletePost = Application.Features.Forum.Post.Delete;
+global using SearchPosts = Application.Features.Admin.Forum.Post.Search;
+global using GetPost = Application.Features.Admin.Forum.Post.Get;
+global using CreatePost = Application.Features.Admin.Forum.Post.Create;
+global using UpdatePost = Application.Features.Admin.Forum.Post.Update;
+global using DeletePost = Application.Features.Admin.Forum.Post.Delete;
 
 // 게시판 설정
-global using GetBoardMeta = Application.Features.Forum.BoardMeta.Get;
-global using UpdateBoardMeta = Application.Features.Forum.BoardMeta.Update;
+global using GetBoardMeta = Application.Features.Admin.Forum.BoardMeta.Get;
+global using UpdateBoardMeta = Application.Features.Admin.Forum.BoardMeta.Update;
 
 // 게시판 관리자
-global using GetBoardManagers = Application.Features.Forum.BoardManager.GetAll;
-global using SaveBoardManagers = Application.Features.Forum.BoardManager.Save;
+global using GetBoardManagers = Application.Features.Admin.Forum.BoardManager.GetAll;
+global using SaveBoardManagers = Application.Features.Admin.Forum.BoardManager.Save;
 
 // 게시판 말머리
-global using GetBoardPrefixes = Application.Features.Forum.BoardPrefix.GetAll;
-global using SaveBoardPrefixes = Application.Features.Forum.BoardPrefix.Save;
+global using GetBoardPrefixes = Application.Features.Admin.Forum.BoardPrefix.GetAll;
+global using SaveBoardPrefixes = Application.Features.Admin.Forum.BoardPrefix.Save;
 
 // 댓글 목록
-global using SearchComments = Application.Features.Forum.Comment.Search;
-global using GetComment = Application.Features.Forum.Comment.Get;
-global using DeleteComment = Application.Features.Forum.Comment.Delete;
+global using SearchComments = Application.Features.Admin.Forum.Comment.Search;
+global using GetComment = Application.Features.Admin.Forum.Comment.Get;
+global using DeleteComment = Application.Features.Admin.Forum.Comment.Delete;
 
 // 휴지통 - 게시글
-global using SearchTrashPosts = Application.Features.Forum.Trash.Post.Search;
-global using RestoreTrashPost = Application.Features.Forum.Trash.Post.Restore;
-global using PermanentDeleteTrashPost = Application.Features.Forum.Trash.Post.PermanentDelete;
+global using SearchTrashPosts = Application.Features.Admin.Forum.Trash.Post.Search;
+global using RestoreTrashPost = Application.Features.Admin.Forum.Trash.Post.Restore;
+global using PermanentDeleteTrashPost = Application.Features.Admin.Forum.Trash.Post.PermanentDelete;
 
 // 휴지통 - 댓글
-global using SearchTrashComments = Application.Features.Forum.Trash.Comment.Search;
-global using RestoreTrashComment = Application.Features.Forum.Trash.Comment.Restore;
-global using PermanentDeleteTrashComment = Application.Features.Forum.Trash.Comment.PermanentDelete;
+global using SearchTrashComments = Application.Features.Admin.Forum.Trash.Comment.Search;
+global using RestoreTrashComment = Application.Features.Admin.Forum.Trash.Comment.Restore;
+global using PermanentDeleteTrashComment = Application.Features.Admin.Forum.Trash.Comment.PermanentDelete;
 
 // 게시글 이미지
-global using SearchPostImages = Application.Features.Forum.PostImage.Search;
-global using DeletePostImage = Application.Features.Forum.PostImage.Delete;
-global using ToggleDisablePostImage = Application.Features.Forum.PostImage.ToggleDisable;
+global using SearchPostImages = Application.Features.Admin.Forum.PostImage.Search;
+global using DeletePostImage = Application.Features.Admin.Forum.PostImage.Delete;
+global using ToggleDisablePostImage = Application.Features.Admin.Forum.PostImage.ToggleDisable;
 
 // 게시글 첨부파일
-global using SearchPostFiles = Application.Features.Forum.PostFile.Search;
-global using DeletePostFile = Application.Features.Forum.PostFile.Delete;
-global using ToggleDisablePostFile = Application.Features.Forum.PostFile.ToggleDisable;
+global using SearchPostFiles = Application.Features.Admin.Forum.PostFile.Search;
+global using DeletePostFile = Application.Features.Admin.Forum.PostFile.Delete;
+global using ToggleDisablePostFile = Application.Features.Admin.Forum.PostFile.ToggleDisable;
 
 // 댓글 이미지
-global using SearchCommentImages = Application.Features.Forum.CommentImage.Search;
-global using DeleteCommentImage = Application.Features.Forum.CommentImage.Delete;
-global using ToggleDisableCommentImage = Application.Features.Forum.CommentImage.ToggleDisable;
+global using SearchCommentImages = Application.Features.Admin.Forum.CommentImage.Search;
+global using DeleteCommentImage = Application.Features.Admin.Forum.CommentImage.Delete;
+global using ToggleDisableCommentImage = Application.Features.Admin.Forum.CommentImage.ToggleDisable;
 
 // 댓글 첨부파일
-global using SearchCommentFiles = Application.Features.Forum.CommentFile.Search;
-global using DeleteCommentFile = Application.Features.Forum.CommentFile.Delete;
-global using ToggleDisableCommentFile = Application.Features.Forum.CommentFile.ToggleDisable;
+global using SearchCommentFiles = Application.Features.Admin.Forum.CommentFile.Search;
+global using DeleteCommentFile = Application.Features.Admin.Forum.CommentFile.Delete;
+global using ToggleDisableCommentFile = Application.Features.Admin.Forum.CommentFile.ToggleDisable;
 
 // 게시글 반응
-global using SearchPostReactions = Application.Features.Forum.PostReaction.Search;
-global using DeletePostReaction = Application.Features.Forum.PostReaction.Delete;
+global using SearchPostReactions = Application.Features.Admin.Forum.PostReaction.Search;
+global using DeletePostReaction = Application.Features.Admin.Forum.PostReaction.Delete;
 
 // 댓글 반응
-global using SearchCommentReactions = Application.Features.Forum.CommentReaction.Search;
-global using DeleteCommentReaction = Application.Features.Forum.CommentReaction.Delete;
+global using SearchCommentReactions = Application.Features.Admin.Forum.CommentReaction.Search;
+global using DeleteCommentReaction = Application.Features.Admin.Forum.CommentReaction.Delete;
 
 // 게시글 신고
-global using SearchPostReports = Application.Features.Forum.PostReport.Search;
-global using UpdatePostReportStatus = Application.Features.Forum.PostReport.UpdateStatus;
-global using DeletePostReport = Application.Features.Forum.PostReport.Delete;
+global using SearchPostReports = Application.Features.Admin.Forum.PostReport.Search;
+global using UpdatePostReportStatus = Application.Features.Admin.Forum.PostReport.UpdateStatus;
+global using DeletePostReport = Application.Features.Admin.Forum.PostReport.Delete;
 
 // 댓글 신고
-global using SearchCommentReports = Application.Features.Forum.CommentReport.Search;
-global using UpdateCommentReportStatus = Application.Features.Forum.CommentReport.UpdateStatus;
-global using DeleteCommentReport = Application.Features.Forum.CommentReport.Delete;
+global using SearchCommentReports = Application.Features.Admin.Forum.CommentReport.Search;
+global using UpdateCommentReportStatus = Application.Features.Admin.Forum.CommentReport.UpdateStatus;
+global using DeleteCommentReport = Application.Features.Admin.Forum.CommentReport.Delete;
 
 // 채널 목록
-global using SearchChannels = Application.Features.Channel.List.Search;
-global using GetChannel = Application.Features.Channel.List.Get;
-global using CreateChannel = Application.Features.Channel.List.Create;
-global using UpdateChannel = Application.Features.Channel.List.Update;
-global using DeleteChannel = Application.Features.Channel.List.Delete;
+global using SearchChannels = Application.Features.Admin.Channel.List.Search;
+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 GetCryptoCategories = Application.Features.Admin.Crypto.Category.GetAll;
+global using SaveCryptoCategories = Application.Features.Admin.Crypto.Category.Save;
+
+// 코인 목록
+global using SearchCoins = Application.Features.Admin.Crypto.List.Search;
+global using GetCoin = Application.Features.Admin.Crypto.List.Get;
+global using CreateCoin = Application.Features.Admin.Crypto.List.Create;
+global using UpdateCoin = Application.Features.Admin.Crypto.List.Update;
+global using DeleteCoin = Application.Features.Admin.Crypto.List.Delete;
+
+// 코인 게시판 연결
+global using GetCoinBoards = Application.Features.Admin.Crypto.Board.GetBoards;
+global using LinkCoinBoard = Application.Features.Admin.Crypto.Board.LinkBoard;
+global using UnlinkCoinBoard = Application.Features.Admin.Crypto.Board.UnlinkBoard;
+
+// 코인 큐레이션
+global using GetCoinCuration = Application.Features.Admin.Crypto.Curation.Get;
+global using SaveCoinCuration = Application.Features.Admin.Crypto.Curation.Save;
+
+// 코인 시세 설정
+global using GetTickerConfig = Application.Features.Admin.Crypto.TickerConfig.Get;
+global using SaveTickerConfig = Application.Features.Admin.Crypto.TickerConfig.Save;

BIN
Admin/wwwroot/uploads/crypto/2/abb2d1bbdae14b0bb07bbc2dab91b5bd.jpeg


+ 32 - 0
Application/Abstractions/Cache/CacheKeys.cs

@@ -0,0 +1,32 @@
+namespace Application.Abstractions.Cache;
+
+public static class CacheKeys
+{
+    public const string Config = "config";
+    public const string FaqCategoryActive = "faq:category:active";
+    public const string PopupPositionActive = "popup:position:active";
+
+    public static string FaqItemByCode(string code) => $"faq:item:{code}";
+    public static string PopupByCode(string code) => $"popup:{code}";
+    public static string BannerByCode(string code) => $"banner:{code}";
+    public static string DocumentByCode(string code) => $"document:{code}";
+    public static string BoardMeta(int boardID) => $"board:meta:{boardID}";
+    public const string CryptoCategoryActive = "crypto:category:active";
+    public static string CoinBySymbol(string symbol) => $"crypto:coin:{symbol.ToLower()}";
+
+    // Crypto Ticker
+    public const string CryptoTickers = "crypto:tickers";
+    public static string CryptoTicker(string symbol) => $"crypto:ticker:{symbol.ToLower()}";
+    public static string CryptoCandle(string symbol, string type) => $"crypto:candle:{symbol.ToLower()}:{type}";
+
+    // Crypto REST (초기 로딩용)
+    public const string CryptoMarkets = "crypto:markets";
+    public static string CryptoTickerDetail(string symbol) => $"crypto:ticker:detail:{symbol.ToLower()}";
+    public static string CryptoTrades(string symbol) => $"crypto:trades:{symbol.ToLower()}";
+    public static string CryptoOrderbook(string symbol) => $"crypto:orderbook:{symbol.ToLower()}";
+
+    // Crypto WebSocket Live (실시간용)
+    public static string CryptoCandleLive(string symbol, string interval) => $"crypto:candle:live:{symbol.ToLower()}:{interval}";
+    public static string CryptoTradeLive(string symbol) => $"crypto:trade:live:{symbol.ToLower()}";
+    public static string CryptoOrderbookLive(string symbol) => $"crypto:orderbook:live:{symbol.ToLower()}";
+}

+ 10 - 0
Application/Abstractions/Cache/ICacheService.cs

@@ -0,0 +1,10 @@
+namespace Application.Abstractions.Cache;
+
+public interface ICacheService
+{
+    Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
+    Task SetAsync<T>(string key, T value, CancellationToken ct = default);
+    Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct = default);
+    Task RemoveAsync(string key, CancellationToken ct = default);
+    Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default);
+}

+ 107 - 0
Application/Abstractions/Crypto/IUpbitClient.cs

@@ -0,0 +1,107 @@
+namespace Application.Abstractions.Crypto;
+
+public interface IUpbitClient
+{
+    // Candle
+    Task<IReadOnlyList<UpbitCandle>> GetSecondCandlesAsync(string market, int count, CancellationToken ct = default);
+    Task<IReadOnlyList<UpbitCandle>> GetMinuteCandlesAsync(string market, int unit, int count, CancellationToken ct = default);
+    Task<IReadOnlyList<UpbitCandle>> GetDayCandlesAsync(string market, int count, CancellationToken ct = default);
+    Task<IReadOnlyList<UpbitCandle>> GetWeekCandlesAsync(string market, int count, CancellationToken ct = default);
+    Task<IReadOnlyList<UpbitCandle>> GetMonthCandlesAsync(string market, int count, CancellationToken ct = default);
+    Task<IReadOnlyList<UpbitCandle>> GetYearCandlesAsync(string market, int count, CancellationToken ct = default);
+
+    // Market
+    Task<IReadOnlyList<UpbitMarket>> GetMarketsAsync(CancellationToken ct = default);
+
+    // Ticker (상세)
+    Task<IReadOnlyList<UpbitTickerDetail>> GetTickersAsync(string[] markets, CancellationToken ct = default);
+
+    // Trade (체결)
+    Task<IReadOnlyList<UpbitTrade>> GetTradesAsync(string market, int count, CancellationToken ct = default);
+
+    // Orderbook (호가)
+    Task<IReadOnlyList<UpbitOrderbook>> GetOrderbookAsync(string[] markets, CancellationToken ct = default);
+}
+
+// WebSocket Ticker (SIMPLE 포맷, 기존)
+public sealed record UpbitTicker(
+    string Market,
+    decimal TradePrice,
+    string Change,
+    decimal SignedChangePrice,
+    decimal SignedChangeRate,
+    decimal AccTradePrice24h
+);
+
+// Candle
+public sealed record UpbitCandle(
+    string Market,
+    DateTime CandleDateTimeUtc,
+    decimal OpeningPrice,
+    decimal HighPrice,
+    decimal LowPrice,
+    decimal TradePrice,
+    long Timestamp,
+    decimal CandleAccTradePrice,
+    decimal CandleAccTradeVolume,
+    string? FirstDayOfPeriod
+);
+
+// 마켓 목록
+public sealed record UpbitMarket(
+    string Market,
+    string KorName,
+    string EngName,
+    string? MarketWarning
+);
+
+// Ticker 상세 (REST)
+public sealed record UpbitTickerDetail(
+    string Market,
+    decimal TradePrice,
+    string Change,
+    decimal SignedChangePrice,
+    decimal SignedChangeRate,
+    decimal OpeningPrice,
+    decimal HighPrice,
+    decimal LowPrice,
+    decimal PrevClosingPrice,
+    decimal AccTradePrice,
+    decimal AccTradePrice24h,
+    decimal AccTradeVolume,
+    decimal AccTradeVolume24h,
+    decimal Highest52WeekPrice,
+    string Highest52WeekDate,
+    decimal Lowest52WeekPrice,
+    string Lowest52WeekDate,
+    long Timestamp
+);
+
+// 체결 내역
+public sealed record UpbitTrade(
+    string Market,
+    string TradeDate,
+    string TradeTime,
+    long Timestamp,
+    decimal TradePrice,
+    decimal TradeVolume,
+    decimal PrevClosingPrice,
+    string AskBid,
+    long SequentialId
+);
+
+// 호가
+public sealed record UpbitOrderbook(
+    string Market,
+    decimal TotalAskSize,
+    decimal TotalBidSize,
+    IReadOnlyList<UpbitOrderbookUnit> OrderbookUnits,
+    long Timestamp
+);
+
+public sealed record UpbitOrderbookUnit(
+    decimal AskPrice,
+    decimal BidPrice,
+    decimal AskSize,
+    decimal BidSize
+);

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

@@ -1,5 +1,6 @@
 using Microsoft.EntityFrameworkCore;
 using Domain.Entities.Common;
+using Domain.Entities.Crypto;
 using Domain.Entities.Members;
 using Domain.Entities.Page;
 using Domain.Entities.Page.Faq;
@@ -11,6 +12,7 @@ using Domain.Entities.Forum.Posts;
 using Domain.Entities.Forum.Comments;
 using Domain.Entities.Director;
 using Domain.Entities.Forum.Logs;
+using Domain.Entities.Page.Popup;
 
 namespace Application.Abstractions.Data
 {
@@ -20,9 +22,15 @@ namespace Application.Abstractions.Data
         DbSet<AdminLoginLog> AdminLoginLog { get; set; }
         DbSet<AdminAccessLog> AdminAccessLog { get; set; }
 
+        // 코인/토큰
+        DbSet<CoinCategory> CoinCategory { get; set; }
+        DbSet<Coin> Coin { get; set; }
+        DbSet<CoinCategoryMap> CoinCategoryMap { get; set; }
+
         // 각종 설정 및 페이지
         DbSet<Config> Config { get; set;  }
         DbSet<Document> Document { get; set;  }
+        DbSet<PopupPosition> PopupPosition { get; set; }
         DbSet<Popup> Popup { get; set;  }
         DbSet<FaqCategory> FaqCategory { get; set;  }
         DbSet<FaqItem> FaqItem { get; set;  }
@@ -31,6 +39,7 @@ namespace Application.Abstractions.Data
 
         // 회원
         DbSet<Member> Member { get; set; }
+        DbSet<MemberStats> MemberStats { get; set; }
         DbSet<MemberApprove> MemberApprove { get; set; }
         DbSet<MemberGrade> MemberGrade { get; set; }
         DbSet<MemberLoginLog> MemberLoginLog { get; set; }

+ 4 - 0
Application/Application.csproj

@@ -12,6 +12,10 @@
     <Folder Include="Features\ReferenceData\Dtos\" />
   </ItemGroup>
 
+  <ItemGroup>
+    <FrameworkReference Include="Microsoft.AspNetCore.App" />
+  </ItemGroup>
+
   <ItemGroup>
     <PackageReference Include="MediatR" Version="14.0.0" />
     <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />

+ 1 - 1
Application/Features/Banner/Item/Create/Command.cs → Application/Features/Admin/Banner/Item/Create/Command.cs

@@ -1,7 +1,7 @@
 using Application.Abstractions.Messaging;
 using Microsoft.AspNetCore.Http;
 
-namespace Application.Features.Banner.Item.Create
+namespace Application.Features.Admin.Banner.Item.Create
 {
     public sealed record Command(
         int PositionID,

+ 5 - 3
Application/Features/Banner/Item/Create/Handler.cs → Application/Features/Admin/Banner/Item/Create/Handler.cs

@@ -1,18 +1,19 @@
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
+using Application.Abstractions.Cache;
 using Domain.Entities.Page.Banner;
 using SharedKernel.Storage;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Banner.Item.Create;
+namespace Application.Features.Admin.Banner.Item.Create;
 
-public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler<Command>
+public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, ICacheService cache) : ICommandHandler<Command>
 {
     public async Task Handle(Command request, CancellationToken ct)
     {
         if (!await db.BannerPosition.AnyAsync(x => x.ID == request.PositionID, ct))
         {
-            throw new KeyNotFoundException("배너 위치를 찾을 수 없습니다.");
+            throw new KeyNotFoundException("占쏙옙占� 占쏙옙치占쏙옙 찾占쏙옙 占쏙옙 占쏙옙占쏙옙占싹댐옙.");
         }
 
         var bannerItem = BannerItem.Create(
@@ -48,5 +49,6 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         }
 
         await db.SaveChangesAsync(ct);
+        await cache.RemoveByPrefixAsync("banner:", ct);
     }
 }

+ 6 - 0
Application/Features/Admin/Banner/Item/Delete/Command.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Banner.Item.Delete
+{
+    public sealed record Command(int[] IDs) : ICommand;
+}

+ 4 - 2
Application/Features/Banner/Item/Delete/Handler.cs → Application/Features/Admin/Banner/Item/Delete/Handler.cs

@@ -1,11 +1,12 @@
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
+using Application.Abstractions.Cache;
 using SharedKernel.Storage;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Banner.Item.Delete;
+namespace Application.Features.Admin.Banner.Item.Delete;
 
-public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler<Command>
+public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, ICacheService cache) : ICommandHandler<Command>
 {
     public async Task Handle(Command request, CancellationToken ct)
     {
@@ -30,5 +31,6 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         }
 
         await db.BannerItem.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
+        await cache.RemoveByPrefixAsync("banner:", ct);
     }
 }

+ 1 - 1
Application/Features/Banner/Item/Get/Handler.cs → Application/Features/Admin/Banner/Item/Get/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Banner.Item.Get;
+namespace Application.Features.Admin.Banner.Item.Get;
 
 public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
 {

+ 1 - 1
Application/Features/Forum/Board/Get/Query.cs → Application/Features/Admin/Banner/Item/Get/Query.cs

@@ -1,6 +1,6 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.Forum.Board.Get
+namespace Application.Features.Admin.Banner.Item.Get
 {
     public sealed record Query(int ID) : IQuery<Response>;
 }

+ 1 - 1
Application/Features/Banner/Item/Get/Response.cs → Application/Features/Admin/Banner/Item/Get/Response.cs

@@ -1,4 +1,4 @@
-namespace Application.Features.Banner.Item.Get
+namespace Application.Features.Admin.Banner.Item.Get
 {
     public sealed record Response(
         int ID,

+ 1 - 1
Application/Features/Banner/Item/Search/Handler.cs → Application/Features/Admin/Banner/Item/Search/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Banner.Item.Search;
+namespace Application.Features.Admin.Banner.Item.Search;
 
 public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
 {

+ 1 - 1
Application/Features/Banner/Item/Search/Query.cs → Application/Features/Admin/Banner/Item/Search/Query.cs

@@ -1,6 +1,6 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.Banner.Item.Search
+namespace Application.Features.Admin.Banner.Item.Search
 {
     public sealed record Query(int? PositionID, string? Keyword, int PageNum, ushort PerPage) : IQuery<Response>;
 }

+ 1 - 1
Application/Features/Banner/Item/Search/Response.cs → Application/Features/Admin/Banner/Item/Search/Response.cs

@@ -1,4 +1,4 @@
-namespace Application.Features.Banner.Item.Search
+namespace Application.Features.Admin.Banner.Item.Search
 {
     public sealed record Response(int Total, List<Response.Row> List)
     {

+ 1 - 1
Application/Features/Banner/Item/Update/Command.cs → Application/Features/Admin/Banner/Item/Update/Command.cs

@@ -1,7 +1,7 @@
 using Application.Abstractions.Messaging;
 using Microsoft.AspNetCore.Http;
 
-namespace Application.Features.Banner.Item.Update
+namespace Application.Features.Admin.Banner.Item.Update
 {
     public sealed record Command(
         int ID,

+ 6 - 4
Application/Features/Banner/Item/Update/Handler.cs → Application/Features/Admin/Banner/Item/Update/Handler.cs

@@ -1,23 +1,24 @@
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
+using Application.Abstractions.Cache;
 using SharedKernel.Storage;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Banner.Item.Update;
+namespace Application.Features.Admin.Banner.Item.Update;
 
-public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler<Command>
+public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, ICacheService cache) : ICommandHandler<Command>
 {
     public async Task Handle(Command request, CancellationToken ct)
     {
         var bannerItem = await db.BannerItem.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
         if (bannerItem is null)
         {
-            throw new KeyNotFoundException("배너를 찾을 수 없습니다.");
+            throw new KeyNotFoundException("占쏙옙訶占� 찾占쏙옙 占쏙옙 占쏙옙占쏙옙占싹댐옙.");
         }
 
         if (!await db.BannerPosition.AnyAsync(x => x.ID == request.PositionID, ct))
         {
-            throw new KeyNotFoundException("배너 위치를 찾을 수 없습니다.");
+            throw new KeyNotFoundException("占쏙옙占� 占쏙옙치占쏙옙 찾占쏙옙 占쏙옙 占쏙옙占쏙옙占싹댐옙.");
         }
 
         FileStoragePath uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Banner, bannerItem.ID);
@@ -57,5 +58,6 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         );
 
         await db.SaveChangesAsync(ct);
+        await cache.RemoveByPrefixAsync("banner:", ct);
     }
 }

+ 1 - 1
Application/Features/Banner/Position/GetAll/Handler.cs → Application/Features/Admin/Banner/Position/GetAll/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Banner.Position.GetAll;
+namespace Application.Features.Admin.Banner.Position.GetAll;
 
 public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
 {

+ 1 - 1
Application/Features/MemberGrade/GetAll/Query.cs → Application/Features/Admin/Banner/Position/GetAll/Query.cs

@@ -1,6 +1,6 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.MemberGrade.GetAll
+namespace Application.Features.Admin.Banner.Position.GetAll
 {
     public sealed record Query : IQuery<Response>;
 }

+ 1 - 1
Application/Features/Banner/Position/GetAll/Response.cs → Application/Features/Admin/Banner/Position/GetAll/Response.cs

@@ -1,4 +1,4 @@
-namespace Application.Features.Banner.Position.GetAll
+namespace Application.Features.Admin.Banner.Position.GetAll
 {
     public sealed record Response(int Total, List<Response.Row> List)
     {

+ 1 - 1
Application/Features/Banner/Position/Save/Command.cs → Application/Features/Admin/Banner/Position/Save/Command.cs

@@ -1,6 +1,6 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.Banner.Position.Save
+namespace Application.Features.Admin.Banner.Position.Save
 {
     public sealed record Command(List<Command.Row> Items) : ICommand<Response>
     {

+ 4 - 2
Application/Features/Banner/Position/Save/Handler.cs → Application/Features/Admin/Banner/Position/Save/Handler.cs

@@ -1,11 +1,12 @@
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
+using Application.Abstractions.Cache;
 using Domain.Entities.Page.Banner;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Banner.Position.Save;
+namespace Application.Features.Admin.Banner.Position.Save;
 
-public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
+public sealed class Handler(IAppDbContext db, ICacheService cache) : ICommandHandler<Command, Response>
 {
     public async Task<Response> Handle(Command request, CancellationToken ct)
     {
@@ -63,6 +64,7 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Respons
         if (inserted + updated + deleted > 0)
         {
             await db.SaveChangesAsync(ct);
+            await cache.RemoveByPrefixAsync("banner:", ct);
         }
 
         return new Response(inserted, updated, deleted);

+ 1 - 1
Application/Features/Banner/Position/Save/Response.cs → Application/Features/Admin/Banner/Position/Save/Response.cs

@@ -1,3 +1,3 @@
-namespace Application.Features.Banner.Position.Save;
+namespace Application.Features.Admin.Banner.Position.Save;
 
 public sealed record Response(int Inserted, int Updated, int Deleted);

+ 1 - 1
Application/Features/Channel/List/Create/Command.cs → Application/Features/Admin/Channel/List/Create/Command.cs

@@ -1,6 +1,6 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.Channel.List.Create;
+namespace Application.Features.Admin.Channel.List.Create;
 
 public sealed record Command(
     int MemberID,

+ 1 - 1
Application/Features/Channel/List/Create/Handler.cs → Application/Features/Admin/Channel/List/Create/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Channel.List.Create;
+namespace Application.Features.Admin.Channel.List.Create;
 
 public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, int>
 {

+ 1 - 1
Application/Features/Channel/List/Delete/Command.cs → Application/Features/Admin/Channel/List/Delete/Command.cs

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

+ 1 - 1
Application/Features/Channel/List/Delete/Handler.cs → Application/Features/Admin/Channel/List/Delete/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Channel.List.Delete;
+namespace Application.Features.Admin.Channel.List.Delete;
 
 public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
 {

+ 1 - 1
Application/Features/Channel/List/Get/Handler.cs → Application/Features/Admin/Channel/List/Get/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Channel.List.Get;
+namespace Application.Features.Admin.Channel.List.Get;
 
 public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response?>
 {

+ 1 - 1
Application/Features/Channel/List/Get/Query.cs → Application/Features/Admin/Channel/List/Get/Query.cs

@@ -1,5 +1,5 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.Channel.List.Get;
+namespace Application.Features.Admin.Channel.List.Get;
 
 public sealed record Query(int ID) : IQuery<Response?>;

+ 1 - 1
Application/Features/Channel/List/Get/Response.cs → Application/Features/Admin/Channel/List/Get/Response.cs

@@ -1,4 +1,4 @@
-namespace Application.Features.Channel.List.Get;
+namespace Application.Features.Admin.Channel.List.Get;
 
 public sealed class Response
 {

+ 1 - 1
Application/Features/Channel/List/Search/Handler.cs → Application/Features/Admin/Channel/List/Search/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Channel.List.Search;
+namespace Application.Features.Admin.Channel.List.Search;
 
 public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
 {

+ 1 - 1
Application/Features/Channel/List/Search/Query.cs → Application/Features/Admin/Channel/List/Search/Query.cs

@@ -1,6 +1,6 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.Channel.List.Search;
+namespace Application.Features.Admin.Channel.List.Search;
 
 public sealed record Query(
     int? Search,

+ 1 - 1
Application/Features/Channel/List/Search/Response.cs → Application/Features/Admin/Channel/List/Search/Response.cs

@@ -1,4 +1,4 @@
-namespace Application.Features.Channel.List.Search;
+namespace Application.Features.Admin.Channel.List.Search;
 
 public sealed record Response(int Total, IReadOnlyList<Response.Row> List)
 {

+ 1 - 1
Application/Features/Channel/List/Update/Command.cs → Application/Features/Admin/Channel/List/Update/Command.cs

@@ -1,6 +1,6 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.Channel.List.Update;
+namespace Application.Features.Admin.Channel.List.Update;
 
 public sealed record Command(
     int ID,

+ 1 - 1
Application/Features/Channel/List/Update/Handler.cs → Application/Features/Admin/Channel/List/Update/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Channel.List.Update;
+namespace Application.Features.Admin.Channel.List.Update;
 
 public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
 {

+ 47 - 0
Application/Features/Admin/Crypto/Board/GetBoards/Handler.cs

@@ -0,0 +1,47 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Crypto.Board.GetBoards
+{
+    public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+    {
+        public async Task<Response> Handle(Query request, CancellationToken ct)
+        {
+            var coin = await db.Coin.AsNoTracking().FirstOrDefaultAsync(c => c.ID == request.CoinID, ct);
+
+            if (coin is null)
+            {
+                throw new KeyNotFoundException("코인을 찾을 수 없습니다.");
+            }
+
+            var allBoards = await db.Board
+                .AsNoTracking()
+                .Include(b => b.BoardGroup)
+                .OrderBy(b => b.BoardGroup.Order)
+                .ThenBy(b => b.Order)
+                .Select(b => new
+                {
+                    b.ID,
+                    b.Code,
+                    b.Name,
+                    GroupName = b.BoardGroup.Name,
+                    b.IsActive,
+                    b.CoinID
+                })
+                .ToListAsync(ct);
+
+            var linked = allBoards
+                .Where(b => b.CoinID == request.CoinID)
+                .Select(b => new Response.Row(b.ID, b.Code, b.Name, b.GroupName, b.IsActive))
+                .ToList();
+
+            var unlinked = allBoards
+                .Where(b => b.CoinID is null)
+                .Select(b => new Response.Row(b.ID, b.Code, b.Name, b.GroupName, b.IsActive))
+                .ToList();
+
+            return new Response(coin.Symbol, coin.KorName, linked, unlinked);
+        }
+    }
+}

+ 6 - 0
Application/Features/Admin/Crypto/Board/GetBoards/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Crypto.Board.GetBoards
+{
+    public sealed record Query(int CoinID) : IQuery<Response>;
+}

+ 18 - 0
Application/Features/Admin/Crypto/Board/GetBoards/Response.cs

@@ -0,0 +1,18 @@
+namespace Application.Features.Admin.Crypto.Board.GetBoards
+{
+    public sealed record Response(
+        string CoinSymbol,
+        string CoinKorName,
+        IReadOnlyList<Response.Row> Linked,
+        IReadOnlyList<Response.Row> Unlinked
+    )
+    {
+        public sealed record Row(
+            int ID,
+            string Code,
+            string Name,
+            string GroupName,
+            bool IsActive
+        );
+    }
+}

+ 6 - 0
Application/Features/Admin/Crypto/Board/LinkBoard/Command.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Crypto.Board.LinkBoard
+{
+    public sealed record Command(int CoinID, int BoardID) : ICommand;
+}

+ 32 - 0
Application/Features/Admin/Crypto/Board/LinkBoard/Handler.cs

@@ -0,0 +1,32 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Crypto.Board.LinkBoard
+{
+    public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+    {
+        public async Task Handle(Command request, CancellationToken ct)
+        {
+            if (!await db.Coin.AnyAsync(c => c.ID == request.CoinID, ct))
+            {
+                throw new KeyNotFoundException("코인을 찾을 수 없습니다.");
+            }
+
+            var board = await db.Board.FirstOrDefaultAsync(b => b.ID == request.BoardID, ct);
+
+            if (board is null)
+            {
+                throw new KeyNotFoundException("게시판을 찾을 수 없습니다.");
+            }
+
+            if (board.CoinID is not null)
+            {
+                throw new InvalidOperationException("이미 다른 코인에 연결된 게시판입니다.");
+            }
+
+            board.CoinID = request.CoinID;
+            await db.SaveChangesAsync(ct);
+        }
+    }
+}

+ 6 - 0
Application/Features/Admin/Crypto/Board/UnlinkBoard/Command.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Crypto.Board.UnlinkBoard
+{
+    public sealed record Command(int BoardID) : ICommand;
+}

+ 22 - 0
Application/Features/Admin/Crypto/Board/UnlinkBoard/Handler.cs

@@ -0,0 +1,22 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Crypto.Board.UnlinkBoard
+{
+    public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+    {
+        public async Task Handle(Command request, CancellationToken ct)
+        {
+            var board = await db.Board.FirstOrDefaultAsync(b => b.ID == request.BoardID, ct);
+
+            if (board is null)
+            {
+                throw new KeyNotFoundException("게시판을 찾을 수 없습니다.");
+            }
+
+            board.CoinID = null;
+            await db.SaveChangesAsync(ct);
+        }
+    }
+}

+ 45 - 0
Application/Features/Admin/Crypto/Category/GetAll/Handler.cs

@@ -0,0 +1,45 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Crypto.Category.GetAll
+{
+    public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+    {
+        public async Task<Response> Handle(Query request, CancellationToken ct)
+        {
+            var items = await db.CoinCategory
+                .AsNoTracking()
+                .Include(c => c.CoinCategoryMap)
+                .OrderBy(c => c.Order)
+                .ThenByDescending(c => c.ID)
+                .Select(c => new
+                {
+                    c.ID,
+                    c.Code,
+                    c.Name,
+                    c.Order,
+                    c.IsActive,
+                    CoinCount = c.CoinCategoryMap.Count,
+                    c.UpdatedAt,
+                    c.CreatedAt
+                })
+                .ToListAsync(ct);
+
+            return new Response(
+                items.Count,
+                [..items.Select((c, i) => new Response.Row(
+                    i + 1,
+                    c.ID,
+                    i,
+                    c.Code,
+                    c.Name,
+                    c.Order,
+                    c.IsActive,
+                    (ushort)c.CoinCount,
+                    c.UpdatedAt,
+                    c.CreatedAt
+                ))]);
+        }
+    }
+}

+ 1 - 1
Application/Features/Forum/BoardGroup/GetAll/Query.cs → Application/Features/Admin/Crypto/Category/GetAll/Query.cs

@@ -1,6 +1,6 @@
 using Application.Abstractions.Messaging;
 
-namespace Application.Features.Forum.BoardGroup.GetAll
+namespace Application.Features.Admin.Crypto.Category.GetAll
 {
     public sealed record Query : IQuery<Response>;
 }

+ 4 - 7
Application/Features/Forum/Board/Search/Response.cs → Application/Features/Admin/Crypto/Category/GetAll/Response.cs

@@ -1,19 +1,16 @@
-namespace Application.Features.Forum.Board.Search
+namespace Application.Features.Admin.Crypto.Category.GetAll
 {
-    public sealed record Response(int Total, List<Response.Row> List)
+    public sealed record Response(int Total, IReadOnlyList<Response.Row> List)
     {
         public sealed record Row(
             int Num,
             int ID,
-            int BoardGroupID,
-            string BoardGroupName,
+            int Index,
             string Code,
             string Name,
             short Order,
-            bool IsSearch,
             bool IsActive,
-            int Posts,
-            int Comments,
+            ushort CoinCount,
             DateTime? UpdatedAt,
             DateTime CreatedAt
         );

+ 15 - 0
Application/Features/Admin/Crypto/Category/Save/Command.cs

@@ -0,0 +1,15 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Crypto.Category.Save
+{
+    public sealed record Command(IReadOnlyList<Command.Row> Items) : ICommand<Response>
+    {
+        public sealed record Row(
+            int? ID,
+            string Code,
+            string Name,
+            short Order,
+            bool IsActive
+        );
+    }
+}

+ 78 - 0
Application/Features/Admin/Crypto/Category/Save/Handler.cs

@@ -0,0 +1,78 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Application.Abstractions.Cache;
+using Microsoft.EntityFrameworkCore;
+using Domain.Entities.Crypto;
+
+namespace Application.Features.Admin.Crypto.Category.Save
+{
+    public sealed class Handler(IAppDbContext db, ICacheService cache) : ICommandHandler<Command, Response>
+    {
+        public async Task<Response> Handle(Command request, CancellationToken ct)
+        {
+            var items = request.Items;
+
+            var dbRows = await db.CoinCategory.ToListAsync(ct);
+            var dbByID = dbRows.ToDictionary(c => c.ID);
+
+            var requestIDs = items.Where(c => c.ID.HasValue && c.ID.Value > 0).Select(c => c.ID!.Value).ToHashSet();
+
+            var deleteTargets = dbRows.Where(x => !requestIDs.Contains(x.ID)).ToList();
+
+            if (deleteTargets.Count > 0)
+            {
+                var deleteIDs = deleteTargets.Select(c => c.ID).ToList();
+                var hasCoins = await db.CoinCategoryMap.AsNoTracking().AnyAsync(c => deleteIDs.Contains(c.CategoryID), ct);
+
+                if (hasCoins)
+                {
+                    throw new InvalidOperationException("코인이 등록된 카테고리는 삭제할 수 없습니다. 먼저 해당 코인의 카테고리를 변경하세요.");
+                }
+
+                db.CoinCategory.RemoveRange(deleteTargets);
+            }
+
+            ushort inserted = 0;
+            ushort updated = 0;
+
+            foreach (var row in items)
+            {
+                if (!row.ID.HasValue || row.ID.Value <= 0)
+                {
+                    if (await db.CoinCategory.AnyAsync(c => c.Code == row.Code, ct))
+                    {
+                        throw new InvalidOperationException($"`{row.Code}`는 이미 등록되었습니다.");
+                    }
+
+                    db.CoinCategory.Add(
+                        CoinCategory.Create(row.Code, row.Name, row.Order, row.IsActive)
+                    );
+
+                    inserted++;
+                    continue;
+                }
+
+                if (!dbByID.TryGetValue(row.ID.Value, out var existing))
+                {
+                    throw new InvalidOperationException($"존재하지 않는 ID: {row.ID.Value}");
+                }
+
+                if (existing.Code != row.Code && await db.CoinCategory.AnyAsync(c => c.Code == row.Code, ct))
+                {
+                    throw new InvalidOperationException($"`{row.Code}`는 이미 등록되었습니다.");
+                }
+
+                existing.Update(row.Code, row.Name, row.Order, row.IsActive);
+                updated++;
+            }
+
+            ushort deleted = (ushort)deleteTargets.Count;
+
+            await db.SaveChangesAsync(ct);
+
+            await cache.RemoveAsync(CacheKeys.CryptoCategoryActive, ct);
+
+            return new Response(inserted, updated, deleted);
+        }
+    }
+}

+ 1 - 1
Application/Features/Faq/Category/Save/Response.cs → Application/Features/Admin/Crypto/Category/Save/Response.cs

@@ -1,4 +1,4 @@
-namespace Application.Features.Faq.Category.Save
+namespace Application.Features.Admin.Crypto.Category.Save
 {
     public sealed record Response(ushort Inserted = 0, ushort Updated = 0, ushort Deleted = 0);
 }

+ 44 - 0
Application/Features/Admin/Crypto/Curation/Get/Handler.cs

@@ -0,0 +1,44 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Crypto.Curation.Get;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var items = await db.Coin
+            .AsNoTracking()
+            .Where(c => c.IsActive && !c.IsDelisted)
+            .OrderByDescending(c => c.IsFeatured)
+            .ThenBy(c => c.DisplayOrder)
+            .ThenBy(c => c.Symbol)
+            .Select(c => new
+            {
+                c.ID,
+                c.Symbol,
+                c.KorName,
+                c.EngName,
+                c.LogoImage,
+                c.IsFeatured,
+                c.DisplayOrder,
+                c.IsActive
+            })
+            .ToListAsync(ct);
+
+        return new Response(
+            items.Count,
+            [..items.Select((c, i) => new Response.Row(
+                i + 1,
+                c.ID,
+                c.Symbol,
+                c.KorName,
+                c.EngName,
+                c.LogoImage,
+                c.IsFeatured,
+                c.DisplayOrder,
+                c.IsActive
+            ))]);
+    }
+}

+ 5 - 0
Application/Features/Admin/Crypto/Curation/Get/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Crypto.Curation.Get;
+
+public sealed record Query : IQuery<Response>;

+ 16 - 0
Application/Features/Admin/Crypto/Curation/Get/Response.cs

@@ -0,0 +1,16 @@
+namespace Application.Features.Admin.Crypto.Curation.Get;
+
+public sealed record Response(int Total, IReadOnlyList<Response.Row> List)
+{
+    public sealed record Row(
+        int Num,
+        int ID,
+        string Symbol,
+        string KorName,
+        string EngName,
+        string? LogoImage,
+        bool IsFeatured,
+        short DisplayOrder,
+        bool IsActive
+    );
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов