KIM-JINO5 2 mesi fa
parent
commit
f242856c62
100 ha cambiato i file con 3237 aggiunte e 405 eliminazioni
  1. 4 3
      Admin/Admin.csproj
  2. 8 0
      Admin/Pages/Config/Register.cshtml
  3. 1 1
      Admin/Pages/Forum/Attachments/CommentImage/Index.cshtml
  4. 5 1
      Admin/Pages/Forum/Attachments/CommentImage/Index.cshtml.cs
  5. 1 1
      Admin/Pages/Forum/Attachments/PostImage/Index.cshtml
  6. 5 1
      Admin/Pages/Forum/Attachments/PostImage/Index.cshtml.cs
  7. 10 12
      Admin/Pages/Forum/Board/List/Index.cshtml
  8. 37 0
      Admin/Pages/Forum/Board/List/Index.cshtml.cs
  9. 3 3
      Admin/Pages/Forum/Board/Meta/Comment.cshtml
  10. 3 3
      Admin/Pages/Forum/Board/Meta/Comment.cshtml.cs
  11. 3 3
      Admin/Pages/Forum/Board/Meta/View.cshtml
  12. 3 3
      Admin/Pages/Forum/Board/Meta/View.cshtml.cs
  13. 1 1
      Admin/Pages/Forum/Comments/List/Edit.cshtml.cs
  14. 99 6
      Admin/Pages/Forum/Posts/List/Edit.cshtml
  15. 30 1
      Admin/Pages/Forum/Posts/List/Edit.cshtml.cs
  16. 63 19
      Admin/Pages/Forum/Posts/List/Index.cshtml
  17. 12 2
      Admin/Pages/Forum/Posts/List/Index.cshtml.cs
  18. 166 0
      Admin/Pages/Forum/Posts/List/View.cshtml
  19. 78 0
      Admin/Pages/Forum/Posts/List/View.cshtml.cs
  20. 71 8
      Admin/Pages/Forum/Posts/List/Write.cshtml
  21. 23 1
      Admin/Pages/Forum/Posts/List/Write.cshtml.cs
  22. 46 42
      Admin/Pages/Forum/Reports/Comment/Index.cshtml
  23. 44 42
      Admin/Pages/Forum/Reports/Post/Index.cshtml
  24. 12 4
      Admin/Pages/Member/List/Index.cshtml
  25. 5 2
      Admin/Pages/Member/List/Index.cshtml.cs
  26. 2 0
      Admin/Pages/Member/List/View.cshtml
  27. 1 2
      Admin/Pages/Member/Log/Login/Index.cshtml
  28. 5 5
      Admin/Pages/Member/Log/Login/Index.cshtml.cs
  29. 2 2
      Admin/Pages/Member/Log/Login/Info.cshtml
  30. 189 0
      Admin/Pages/Member/Visitor.cshtml
  31. 229 0
      Admin/Pages/Member/Visitor.cshtml.cs
  32. 17 19
      Admin/Pages/Member/Wallet/List/Index.cshtml
  33. 28 30
      Admin/Pages/Member/Wallet/Transactions/Index.cshtml
  34. 140 0
      Admin/Pages/Server/Cache.cshtml
  35. 29 0
      Admin/Pages/Server/Cache.cshtml.cs
  36. 0 7
      Admin/Program.cs
  37. 2 1
      Admin/Properties/launchSettings.json
  38. 63 7
      Admin/appsettings.Development.json
  39. BIN
      Admin/wwwroot/editors/post/1/5/41b090e3d9f84c14876ef87a71ea7b17.jpg
  40. 4 3
      Admin/wwwroot/js/site.js
  41. BIN
      Admin/wwwroot/uploads/member/thumb/5/40bdf1a22233404ea191b21ff4904948.jpg
  42. 2 0
      Application/Abstractions/Cache/CacheKeys.cs
  43. 12 0
      Application/Abstractions/Chat/ConnectedUser.cs
  44. 9 0
      Application/Abstractions/Chat/IChatConnectionTracker.cs
  45. 4 1
      Application/Abstractions/Chat/IChatHubClient.cs
  46. 1 0
      Application/Abstractions/Chat/IChatHubService.cs
  47. 2 0
      Application/Abstractions/Data/IAppDbContext.cs
  48. 23 0
      Application/Abstractions/Forum/IBoardPermissionService.cs
  49. 6 1
      Application/Application.csproj
  50. 34 0
      Application/Common/UserAgentParser.cs
  51. 8 0
      Application/Features/Admin/Cache/ClearCache/Command.cs
  52. 22 0
      Application/Features/Admin/Cache/ClearCache/Handler.cs
  53. 7 4
      Application/Features/Admin/Forum/Comment/Delete/Handler.cs
  54. 14 12
      Application/Features/Admin/Forum/Post/Create/Command.cs
  55. 88 6
      Application/Features/Admin/Forum/Post/Create/Handler.cs
  56. 19 2
      Application/Features/Admin/Forum/Post/Get/Handler.cs
  57. 17 1
      Application/Features/Admin/Forum/Post/Get/Response.cs
  58. 17 0
      Application/Features/Admin/Forum/Post/Search/Handler.cs
  59. 2 0
      Application/Features/Admin/Forum/Post/Search/Query.cs
  60. 3 0
      Application/Features/Admin/Forum/Post/Search/Response.cs
  61. 14 12
      Application/Features/Admin/Forum/Post/Update/Command.cs
  62. 99 4
      Application/Features/Admin/Forum/Post/Update/Handler.cs
  63. 1 20
      Application/Features/Admin/Forum/Trash/Comment/PermanentDelete/Handler.cs
  64. 29 0
      Application/Features/Admin/Forum/Trash/Comment/Restore/Handler.cs
  65. 16 13
      Application/Features/Admin/Forum/Trash/Post/PermanentDelete/Handler.cs
  66. 18 10
      Application/Features/Admin/Member/List/Search/Handler.cs
  67. 9 0
      Application/Features/Admin/Member/List/Search/Response.cs
  68. 8 0
      Application/Features/Api/Auth/ForgotPassword/Command.cs
  69. 71 0
      Application/Features/Api/Auth/ForgotPassword/Handler.cs
  70. 38 3
      Application/Features/Api/Auth/GetProfile/Handler.cs
  71. 43 4
      Application/Features/Api/Auth/GetProfile/Response.cs
  72. 4 2
      Application/Features/Api/Auth/Login/Command.cs
  73. 67 5
      Application/Features/Api/Auth/Login/Handler.cs
  74. 1 1
      Application/Features/Api/Auth/Logout/Command.cs
  75. 17 15
      Application/Features/Api/Auth/Logout/Handler.cs
  76. 4 5
      Application/Features/Api/Auth/RefreshToken/Handler.cs
  77. 6 1
      Application/Features/Api/Auth/Register/Command.cs
  78. 92 10
      Application/Features/Api/Auth/Register/Handler.cs
  79. 9 0
      Application/Features/Api/Auth/Registration/Command.cs
  80. 72 0
      Application/Features/Api/Auth/Registration/Handler.cs
  81. 10 0
      Application/Features/Api/Auth/ResendEmail/Command.cs
  82. 88 0
      Application/Features/Api/Auth/ResendEmail/Handler.cs
  83. 10 0
      Application/Features/Api/Auth/ResetPassword/Command.cs
  84. 57 0
      Application/Features/Api/Auth/ResetPassword/Handler.cs
  85. 11 0
      Application/Features/Api/Auth/VerifyEmail/Command.cs
  86. 58 0
      Application/Features/Api/Auth/VerifyEmail/Handler.cs
  87. 76 7
      Application/Features/Api/Forum/Board/Get/Handler.cs
  88. 1 1
      Application/Features/Api/Forum/Board/Get/Query.cs
  89. 35 2
      Application/Features/Api/Forum/Board/Get/Response.cs
  90. 7 2
      Application/Features/Api/Forum/Board/Search/Handler.cs
  91. 1 1
      Application/Features/Api/Forum/Board/Search/Query.cs
  92. 6 9
      Application/Features/Api/Forum/BoardMeta/Get/Handler.cs
  93. 2 1
      Application/Features/Api/Forum/BoardMeta/Get/Response.cs
  94. 7 3
      Application/Features/Api/Forum/Comment/Create/Command.cs
  95. 312 5
      Application/Features/Api/Forum/Comment/Create/Handler.cs
  96. 11 2
      Application/Features/Api/Forum/Comment/Delete/Handler.cs
  97. 2 5
      Application/Features/Api/Forum/Comment/Get/Handler.cs
  98. 152 0
      Application/Features/Api/Forum/Comment/List/Handler.cs
  99. 12 0
      Application/Features/Api/Forum/Comment/List/Query.cs
  100. 27 0
      Application/Features/Api/Forum/Comment/List/Response.cs

+ 4 - 3
Admin/Admin.csproj

@@ -4,13 +4,13 @@
 		<TargetFramework>net10.0</TargetFramework>
 		<Nullable>enable</Nullable>
 		<ImplicitUsings>enable</ImplicitUsings>
-		<HotReloadAutoRestart>false</HotReloadAutoRestart>
+		<HotReloadAutoRestart>true</HotReloadAutoRestart>
 	</PropertyGroup>
 
 	<ItemGroup>
 	  <None Remove="nul" />
 	</ItemGroup>
-
+	
 	<ItemGroup>
 		<None Include=".github\copilot-instructions.md" />
 	</ItemGroup>
@@ -27,8 +27,8 @@
 			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
 			<PrivateAssets>all</PrivateAssets>
 		</PackageReference>
-		<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.2" />
 		<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
+		<PackageReference Include="MimeKit" Version="4.15.1" />
 		<PackageReference Include="System.Management" Version="10.0.2" />
 	</ItemGroup>
 
@@ -40,6 +40,7 @@
 	<ItemGroup>
 		<Folder Include="wwwroot\uploads\basic\" />
 		<Folder Include="wwwroot\uploads\banner\" />
+		<Folder Include="wwwroot\uploads\thumb\" />
 	</ItemGroup>
 
 </Project>

+ 8 - 0
Admin/Pages/Config/Register.cshtml

@@ -211,6 +211,14 @@
             <summary class="fs-5">로그인 시</summary>
             <hr />
 
+            <div class="row mb-2">
+                <label asp-for="Input.Account.IsLoginEmailVerifiedOnly" class="col-sm-2 col-form-label">로그인 허용</label>
+                <div class="col-sm-10 align-content-center">
+                    <input asp-for="Input.Account.IsLoginEmailVerifiedOnly" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.Account.IsLoginEmailVerifiedOnly">가입 시 이메일 인증을 한 사용자만 로그인이 허용됩니다.</label>
+                </div>
+            </div>
+
             <div class="row mb-2">
                 <label asp-for="Input.Account.MaxLoginTryCount" class="col-sm-2 col-form-label">로그인 시도</label>
                 <div class="col-sm-10">

+ 1 - 1
Admin/Pages/Forum/Attachments/CommentImage/Index.cshtml

@@ -122,7 +122,7 @@
                                 </div>
                             </td>
                             <td>
-                                <img src="@row.Url" alt="@row.FileName" style="width:40px;height:40px;object-fit:cover;border-radius:.25rem;" /><br/>
+                                <img src="@(Model.ApiURL + '/' + row.Url)" alt="@row.FileName" style="width:40px;height:40px;object-fit:cover;border-radius:.25rem;" /><br />
                                 @row.FileName
                             </td>
                             <td class="text-start">

+ 5 - 1
Admin/Pages/Forum/Attachments/CommentImage/Index.cshtml.cs

@@ -1,16 +1,20 @@
+using SharedKernel;
 using SharedKernel.Helpers;
 using SharedKernel.Extensions;
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.Extensions.Options;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
 namespace Admin.Pages.Forum.Attachments.CommentImage
 {
-    public class IndexModel(IMediator mediator) : PageModel
+    public class IndexModel(IMediator mediator, IOptions<AppSettings> options) : PageModel
     {
+        public readonly string ApiURL = options.Value.App.ApiURL;
+
         [BindProperty(SupportsGet = true)]
         public QueryParams Query { get; set; } = new();
 

+ 1 - 1
Admin/Pages/Forum/Attachments/PostImage/Index.cshtml

@@ -120,7 +120,7 @@
                                 </div>
                             </td>
                             <td>
-                                <img src="@row.Url" alt="@row.FileName" style="width:40px;height:40px;object-fit:cover;border-radius:.25rem;" /><br/>
+                                <img src="@(Model.ApiURL + '/' + row.Url)" alt="@row.FileName" style="width:40px;height:40px;object-fit:cover;border-radius:.25rem;" /><br/>
                                 @row.FileName
                             </td>
                             <td class="text-start">

+ 5 - 1
Admin/Pages/Forum/Attachments/PostImage/Index.cshtml.cs

@@ -1,3 +1,4 @@
+using SharedKernel;
 using SharedKernel.Helpers;
 using SharedKernel.Extensions;
 using MediatR;
@@ -6,11 +7,14 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
 using Microsoft.AspNetCore.Mvc.Rendering;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
+using Microsoft.Extensions.Options;
 
 namespace Admin.Pages.Forum.Attachments.PostImage
 {
-    public class IndexModel(IMediator mediator) : PageModel
+    public class IndexModel(IMediator mediator, IOptions<AppSettings> options) : PageModel
     {
+        public readonly string ApiURL = options.Value.App.ApiURL;
+
         [BindProperty(SupportsGet = true)]
         public QueryParams Query { get; set; } = new();
 

+ 10 - 12
Admin/Pages/Forum/Board/List/Index.cshtml

@@ -15,8 +15,8 @@
             Total : @Model.Total
         </div>
         <div class="col text-end">
-            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
-            <button type="button" id="btnListUpdate" class="btn btn-primary" disabled>수정</button>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled form="fAdminList">삭제</button>
+            <button type="button" id="btnListUpdate" class="btn btn-primary" disabled form="fAdminList">수정</button>
             <a class="btn btn-success" asp-page="/Forum/Board/List/Write">추가</a>
         </div>
     </div>
@@ -77,27 +77,25 @@
                         <tbody class="striped">
                             <tr>
                                 <td rowspan="2">
-                                    <input type="hidden" name="request.Index" value="@index" />
-
                                     <div class="form-check-inline me-0">
-                                        <input type="checkbox" name="request[@index].ID" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" />
-                                        <label for="chk_@row.ID" class="form-check-inline">@row.ID</label>
+                                        <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" class="form-check-inline">@row.ID</label>
                                     </div>
                                 </td>
                                 <td rowspan="2">
-                                    <select name="request[@index].BoardGroupID" class="form-select" required asp-items="@row.SelectBoardGroup"></select>
+                                    <select name="request[@index].BoardGroupID" class="form-select" required asp-items="@row.SelectBoardGroup" form="fAdminList"></select>
                                 </td>
                                 <td rowspan="2">
-                                    <input type="text" name="request[@index].Code" class="form-control" required maxlength="70" value="@row.Code" />
+                                    <input type="text" name="request[@index].Code" class="form-control" required maxlength="70" value="@row.Code" form="fAdminList" />
                                 </td>
                                 <td rowspan="2">
-                                    <input type="text" name="request[@index].Name" class="form-control" required maxlength="70" value="@row.Name" />
+                                    <input type="text" name="request[@index].Name" class="form-control" required maxlength="70" value="@row.Name" form="fAdminList" />
                                 </td>
                                 <td rowspan="2">
-                                    <input type="number" name="request[@index].Order" class="form-control" required min="-999" max="999" value="@row.Order" />
+                                    <input type="number" name="request[@index].Order" class="form-control" required min="-999" max="9999" value="@row.Order" form="fAdminList" />
                                 </td>
                                 <td>
-                                    <input type="checkbox" name="request[@index].IsSearch" class="form-check-input" checked="@row.IsSearch" value="true" />
+                                    <input type="checkbox" name="request[@index].IsSearch" class="form-check-input" checked="@row.IsSearch" value="true" form="fAdminList" />
                                 </td>
                                 <td>@row.Posts</td>
                                 <td>@row.CreatedAt</td>
@@ -110,7 +108,7 @@
                             </tr>
                             <tr>
                                 <td>
-                                    <input type="checkbox" name="request[@index].IsActive" class="form-check-input" checked="@row.IsActive" value="true" />
+                                    <input type="checkbox" name="request[@index].IsActive" class="form-check-input" checked="@row.IsActive" value="true" form="fAdminList" />
                                 </td>
                                 <td>@row.Comments</td>
                                 <td>@row.UpdatedAt</td>

+ 37 - 0
Admin/Pages/Forum/Board/List/Index.cshtml.cs

@@ -100,6 +100,43 @@ public class IndexModel(IMediator mediator) : PageModel
         Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
     }
 
+    public sealed class UpdateRequest
+    {
+        public int ID { get; set; }
+        public int BoardGroupID { get; set; }
+        public string Code { get; set; } = "";
+        public string Name { get; set; } = "";
+        public short Order { get; set; }
+        public bool IsSearch { get; set; }
+        public bool IsActive { get; set; }
+    }
+
+    public async Task<IActionResult> OnPostUpdateAsync(int[] ids, Dictionary<int, UpdateRequest> request, CancellationToken ct)
+    {
+        try
+        {
+            int count = 0;
+            foreach (var id in ids)
+            {
+                if (request.TryGetValue(id, out var item))
+                {
+                    await mediator.Send(new UpdateBoard.Command(
+                        id, item.BoardGroupID, item.Code, item.Name, item.Order, item.IsSearch, item.IsActive
+                    ), ct);
+
+                    count++;
+                }
+            }
+            TempData["SuccessMessage"] = $"{count}건이 수정되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Forum/Board/List/Index", Query);
+    }
+
     public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
     {
         try

+ 3 - 3
Admin/Pages/Forum/Board/Meta/Comment.cshtml

@@ -59,11 +59,11 @@
             </div>
         </div>
         <div class="row mb-3">
-            <label for="Input_ShowMemberPhoto" class="col-md-3">회원 사진 공개</label>
+            <label for="Input_ShowMemberThumb" class="col-md-3">회원 사진 공개</label>
             <div class="col-md-9 pt-2 pt-md-0">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="Input.ShowMemberPhoto" class="form-check-input" />
-                    <label asp-for="Input.ShowMemberPhoto" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowMemberThumb" class="form-check-input" />
+                    <label asp-for="Input.ShowMemberThumb" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">회원이 등록한 사진을 좌측에 보일지를 결정합니다.</small>
             </div>

+ 3 - 3
Admin/Pages/Forum/Board/Meta/Comment.cshtml.cs

@@ -23,7 +23,7 @@ public class CommentModel(IMediator mediator) : PageModel
         public ushort PerPage { get; set; }
         public bool AllowLike { get; set; }
         public bool AllowDisLike { get; set; }
-        public bool ShowMemberPhoto { get; set; }
+        public bool ShowMemberThumb { get; set; }
         public bool ShowMemberIcon { get; set; }
         public string? ContentPlaceholder { get; set; }
         public ushort MinContentLength { get; set; }
@@ -56,7 +56,7 @@ public class CommentModel(IMediator mediator) : PageModel
             PerPage = meta.Comment.PerPage,
             AllowLike = meta.Comment.AllowLike,
             AllowDisLike = meta.Comment.AllowDisLike,
-            ShowMemberPhoto = meta.Comment.ShowMemberPhoto,
+            ShowMemberThumb = meta.Comment.ShowMemberThumb,
             ShowMemberIcon = meta.Comment.ShowMemberIcon,
             ContentPlaceholder = meta.Comment.ContentPlaceholder,
             MinContentLength = meta.Comment.MinContentLength,
@@ -93,7 +93,7 @@ public class CommentModel(IMediator mediator) : PageModel
                     PerPage = Input.PerPage,
                     AllowLike = Input.AllowLike,
                     AllowDisLike = Input.AllowDisLike,
-                    ShowMemberPhoto = Input.ShowMemberPhoto,
+                    ShowMemberThumb = Input.ShowMemberThumb,
                     ShowMemberIcon = Input.ShowMemberIcon,
                     ContentPlaceholder = Input.ContentPlaceholder,
                     MinContentLength = Input.MinContentLength,

+ 3 - 3
Admin/Pages/Forum/Board/Meta/View.cshtml

@@ -130,11 +130,11 @@
             </div>
         </div>
         <div class="row mb-3">
-            <label for="Input_ShowMemberPhoto" class="col-md-2 col-form-label">회원 사진 공개</label>
+            <label for="Input_ShowMemberThumb" class="col-md-2 col-form-label">회원 사진 공개</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="Input.ShowMemberPhoto" class="form-check-input" />
-                    <label asp-for="Input.ShowMemberPhoto" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowMemberThumb" class="form-check-input" />
+                    <label asp-for="Input.ShowMemberThumb" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 상단에 회원 사진이 노출됩니다.</small>
             </div>

+ 3 - 3
Admin/Pages/Forum/Board/Meta/View.cshtml.cs

@@ -30,7 +30,7 @@ public class ViewMetaModel(IMediator mediator) : PageModel
         public bool AllowContentLinkTargetBlank { get; set; }
         public bool AllowPostUrlCopy { get; set; }
         public bool AllowPostUrlQrCode { get; set; }
-        public bool ShowMemberPhoto { get; set; }
+        public bool ShowMemberThumb { get; set; }
         public bool ShowMemberIcon { get; set; }
         public bool ShowMemberRegDate { get; set; }
         public bool ShowMemberSummary { get; set; }
@@ -61,7 +61,7 @@ public class ViewMetaModel(IMediator mediator) : PageModel
             AllowContentLinkTargetBlank = meta.View.AllowContentLinkTargetBlank,
             AllowPostUrlCopy = meta.View.AllowPostUrlCopy,
             AllowPostUrlQrCode = meta.View.AllowPostUrlQrCode,
-            ShowMemberPhoto = meta.View.ShowMemberPhoto,
+            ShowMemberThumb = meta.View.ShowMemberThumb,
             ShowMemberIcon = meta.View.ShowMemberIcon,
             ShowMemberRegDate = meta.View.ShowMemberRegDate,
             ShowMemberSummary = meta.View.ShowMemberSummary
@@ -94,7 +94,7 @@ public class ViewMetaModel(IMediator mediator) : PageModel
                     AllowContentLinkTargetBlank = Input.AllowContentLinkTargetBlank,
                     AllowPostUrlCopy = Input.AllowPostUrlCopy,
                     AllowPostUrlQrCode = Input.AllowPostUrlQrCode,
-                    ShowMemberPhoto = Input.ShowMemberPhoto,
+                    ShowMemberThumb = Input.ShowMemberThumb,
                     ShowMemberIcon = Input.ShowMemberIcon,
                     ShowMemberRegDate = Input.ShowMemberRegDate,
                     ShowMemberSummary = Input.ShowMemberSummary

+ 1 - 1
Admin/Pages/Forum/Comments/List/Edit.cshtml.cs

@@ -13,7 +13,7 @@ namespace Admin.Pages.Forum.Comments.List
         public string BoardName { get; set; } = "";
         public int PostID { get; set; }
         public string PostSubject { get; set; } = "";
-        public new string CommentContent { get; set; } = "";
+        public string CommentContent { get; set; } = "";
         public string? Name { get; set; }
         public string? SID { get; set; }
         public bool IsReply { get; set; }

+ 99 - 6
Admin/Pages/Forum/Posts/List/Edit.cshtml

@@ -4,6 +4,8 @@
     ViewData["Title"] = "게시글 수정";
 }
 
+<partial name="_Editor" />
+
 <div class="container">
     <h3 class="mb-3">@ViewData["Title"]</h3>
     <hr />
@@ -15,6 +17,16 @@
         <input type="hidden" asp-for="Input.BoardID" />
         <input type="hidden" asp-for="Input.ReturnUrl" />
 
+        <!-- 말머리 -->
+        <div class="row mb-2" id="prefixRow">
+            <label class="col-sm-2 col-form-label">말머리</label>
+            <div class="col-sm-10">
+                <select asp-for="Input.BoardPrefixID" class="form-select w-auto" id="boardPrefixSelect">
+                    <option value="">- 선택 -</option>
+                </select>
+            </div>
+        </div>
+
         <!-- 제목 -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label"><span class="text-danger">*</span> 제목</label>
@@ -23,11 +35,11 @@
             </div>
         </div>
 
-        <!-- 내용 -->
+        <!-- 내용 (CKEditor) -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">내용</label>
             <div class="col-sm-10">
-                <textarea asp-for="Input.Content" class="form-control" rows="10" maxlength="8000"></textarea>
+                <textarea asp-for="Input.Content" class="ck-editor" id="Input_Content" rows="12"></textarea>
             </div>
         </div>
 
@@ -36,16 +48,62 @@
             <label class="col-sm-2 col-form-label">대표 이미지</label>
             <div class="col-sm-10">
                 <div id="thumbPrev" @(string.IsNullOrWhiteSpace(Model.CurrentThumbnail) ? "hidden" : "")>
-                    <img class="img-fluid img-thumbnail" alt="대표 이미지 미리보기" src="@(Model.CurrentThumbnail ?? "")" />
+                    <img class="img-fluid img-thumbnail" alt="대표 이미지 미리보기" src="@(Model.CurrentThumbnail ?? "")" style="max-width: 300px;" />
                 </div>
                 <input type="file" id="Input_ThumbnailFile" asp-for="Input.ThumbnailFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp" />
                 <span class="form-text text-muted">
-                    지원 확장자: <code>.jpg</code>, <code>.jpeg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>, <code>.bmp</code><br />
-                    권장 크기: 가로 300px 이상 (최대 10MB)
+                    지원 확장자: <code>.jpg</code>, <code>.jpeg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>, <code>.bmp</code>
                 </span>
             </div>
         </div>
 
+        <!-- 기존 첨부파일 -->
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">기존 첨부파일</label>
+            <div class="col-sm-10">
+                @if (Model.ExistingFiles.Count > 0)
+                {
+                    <ul class="list-unstyled mb-1">
+                        @foreach (var file in Model.ExistingFiles)
+                        {
+                            <li>
+                                <a href="@file.Url" target="_blank">@file.FileName</a>
+                                @if (file.Size.HasValue)
+                                {
+                                    <small class="text-muted">(@(file.Size.Value > 1048576 ? $"{file.Size.Value / 1048576.0:F1}MB" : $"{file.Size.Value / 1024.0:F1}KB"))</small>
+                                }
+                                <small class="text-muted">다운로드: @file.Downloads</small>
+                            </li>
+                        }
+                    </ul>
+                }
+                else
+                {
+                    <span class="text-muted">없음</span>
+                }
+            </div>
+        </div>
+
+        <!-- 새 첨부파일 -->
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">첨부파일 추가</label>
+            <div class="col-sm-10">
+                <input type="file" asp-for="Input.Files" class="form-control" multiple
+                       accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar,.7z,.hwp,.hwpx,.csv" />
+                <span class="form-text text-muted">여러 파일을 선택할 수 있습니다.</span>
+            </div>
+        </div>
+
+        <!-- 태그 -->
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">태그</label>
+            <div class="col-sm-10">
+                <input type="text" asp-for="Input.TagsInput" class="form-control"
+                       placeholder="태그를 쉼표(,)로 구분하여 입력하세요" />
+                <span class="form-text text-muted">예: 비트코인, 이더리움, 블록체인</span>
+            </div>
+        </div>
+
         <!-- 상태 -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">상태</label>
@@ -88,5 +146,40 @@
 @section Scripts {
     <script>
         setupImagePreview("Input_ThumbnailFile", "thumbPrev");
+
+        // 페이지 로드 시 현재 게시판의 말머리 로드
+        (async function () {
+            const boardIDInput = document.querySelector("input[name='Input.BoardID']");
+            const boardID = boardIDInput ? boardIDInput.value : "";
+            const currentPrefixID = "@(Model.Input.BoardPrefixID?.ToString() ?? "")";
+            const postID = "@Model.Input.ID";
+
+            if (boardID) {
+                try {
+                    const res = await fetch(`/Forum/Posts/List/Edit/${postID}?handler=Prefixes&boardID=${boardID}`);
+                    const items = await res.json();
+                    const prefixSelect = document.getElementById("boardPrefixSelect");
+
+                    if (items.length > 0) {
+                        items.forEach(item => {
+                            const opt = document.createElement("option");
+                            opt.value = item.id;
+                            opt.textContent = item.name;
+                            if (item.id.toString() === currentPrefixID) {
+                                opt.selected = true;
+                            }
+                            prefixSelect.appendChild(opt);
+                        });
+                        document.getElementById("prefixRow").hidden = false;
+                    } else {
+                        document.getElementById("prefixRow").hidden = true;
+                    }
+                } catch {
+                    document.getElementById("prefixRow").hidden = true;
+                }
+            } else {
+                document.getElementById("prefixRow").hidden = true;
+            }
+        })();
     </script>
-}
+}

+ 30 - 1
Admin/Pages/Forum/Posts/List/Edit.cshtml.cs

@@ -18,6 +18,8 @@ namespace Admin.Pages.Forum.Posts.List
         public InputModel Input { get; set; } = new();
 
         public string? CurrentThumbnail { get; private set; }
+        public List<(int ID, string FileName, string Url, string? Extension, long? Size, int Downloads)> ExistingFiles { get; set; } = [];
+        public List<(int TagID, string Name)> ExistingTags { get; set; } = [];
 
         public sealed class InputModel
         {
@@ -27,18 +29,26 @@ namespace Admin.Pages.Forum.Posts.List
 
             public int BoardID { get; set; }
 
+            [DisplayName("말머리")]
+            public int? BoardPrefixID { get; set; }
+
             [DisplayName("제목")]
             [Required(ErrorMessage = "{0}은 필수입니다.")]
             [StringLength(255, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
             public string Subject { get; set; } = null!;
 
             [DisplayName("내용")]
-            [StringLength(8000, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
             public string? Content { get; set; }
 
             [DisplayName("대표 이미지")]
             public IFormFile? ThumbnailFile { get; set; }
 
+            [DisplayName("첨부파일")]
+            public List<IFormFile>? Files { get; set; }
+
+            [DisplayName("태그")]
+            public string? TagsInput { get; set; }
+
             [DisplayName("공지")]
             public bool IsNotice { get; set; } = false;
 
@@ -62,17 +72,29 @@ namespace Admin.Pages.Forum.Posts.List
             {
                 ID = result.ID,
                 BoardID = result.BoardID,
+                BoardPrefixID = result.BoardPrefixID,
                 Subject = result.Subject,
                 Content = result.Content,
                 IsNotice = result.IsNotice,
                 IsSecret = result.IsSecret,
                 IsAnonymous = result.IsAnonymous,
+                TagsInput = result.Tags.Count > 0 ? string.Join(", ", result.Tags.Select(t => t.Name)) : null,
                 ReturnUrl = ReturnUrl
             };
 
+            ExistingFiles = [..result.Files.Where(f => !f.IsDisabled).Select(f => (f.ID, f.FileName, f.Url, f.Extension, f.Size, f.Downloads))];
+            ExistingTags = [..result.Tags.Select(t => (t.TagID, t.Name))];
+
             QueryString = Request.QueryString.ToString();
         }
 
+        public async Task<IActionResult> OnGetPrefixesAsync(int id, int boardID, CancellationToken ct)
+        {
+            var result = await mediator.Send(new GetBoardPrefixes.Query(boardID), ct);
+            var items = result.List.Where(c => c.IsActive).Select(c => new { id = c.ID, name = c.Name });
+            return new JsonResult(items);
+        }
+
         public async Task<IActionResult> OnPostAsync(CancellationToken ct)
         {
             try
@@ -82,11 +104,18 @@ namespace Admin.Pages.Forum.Posts.List
                     throw new Exception(ModelState.GetErrorMessages());
                 }
 
+                var tags = string.IsNullOrWhiteSpace(Input.TagsInput)
+                    ? null
+                    : Input.TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
+
                 await mediator.Send(new UpdatePost.Command(
                     Input.ID,
+                    Input.BoardPrefixID,
                     Input.Subject,
                     Input.Content,
                     Input.ThumbnailFile,
+                    Input.Files,
+                    tags,
                     Input.IsNotice,
                     Input.IsSecret,
                     Input.IsAnonymous

+ 63 - 19
Admin/Pages/Forum/Posts/List/Index.cshtml

@@ -59,6 +59,10 @@
                 <input class="form-check-input" type="checkbox" id="isDeleted" checked="@(Model?.Query.IsDeleted == true)" />
                 <label class="form-check-label" for="isDeleted">삭제</label>
             </div>
+            <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" id="isQnA" checked="@(Model?.Query.IsQnA == true)" />
+                <label class="form-check-label" for="isQnA">1:1 문의</label>
+            </div>
         </div>
     </div>
 
@@ -106,7 +110,9 @@
                 <col style="width: 5%;" />
                 <col style="width: 5%;" />
                 <col style="width: 5%;" />
-                <col style="width: 12%;" />
+                <col style="width: 5%;" />
+                <col style="width: 5%;" />
+                <col style="width: 5%;" />
                 <col style="width: 12%;" />
             </colgroup>
             <thead>
@@ -122,8 +128,10 @@
                     <th>조회</th>
                     <th>공감</th>
                     <th>비공감</th>
-                    <th>댓글</th>
-                    <th>첨부</th>
+                    <th>이미지</th>
+                    <th>미디어</th>
+                    <th>파일</th>
+                    <th>태그</th>
                     <th>작성일</th>
                 </tr>
             </thead>
@@ -146,25 +154,59 @@
                                 </div>
                             </td>
                             <td class="text-start">
-                                @if (item.IsNotice) { <span class="badge text-bg-warning me-1">공지</span> }
-                                @if (item.IsSecret) { <span class="badge text-bg-secondary me-1">비밀</span> }
-                                @if (item.IsReply) { <span class="badge text-bg-info me-1">답글</span> }
-                                @if (item.IsSpeaker) { <span class="badge text-bg-primary me-1">작성자</span> }
-                                @if (item.IsAnonymous) { <span class="badge text-bg-dark me-1">익명</span> }
-                                @if (item.IsDeleted) { <span class="badge text-bg-danger me-1">삭제</span> }
-                                <a class="text-decoration-none" href="@item.EditURL">@item.Subject</a>
+                                @if (item.BoardPrefixID != null)
+                                {
+                                    <a href="?BoardPrefixID=@item.BoardPrefixID" class="text-success text-decoration-none fw-bold">[@item.BoardPrefixName]</a>
+                                }
+                                <a class="text-decoration-none" href="@item.ViewURL">
+                                    @if (item.IsDeleted)
+                                    {
+                                        <del>@item.Subject</del>
+                                    }
+                                    else
+                                    {
+                                        @item.Subject
+                                    }
+                                </a>
+                                <span class="text-danger">[@item.Comments]</span>
+                                @if (item.IsSpeaker)
+                                {
+                                    <i class="bi bi-megaphone-fill"></i>
+                                }
+                                @if (item.IsNotice)
+                                {
+                                    <i class="bi bi-bell-fill"></i>
+                                }
+                                @if (item.IsSecret)
+                                {
+                                    <i class="bi bi-lock-fill"></i>
+                                }
+                                @if (item.IsQnA)
+                                {
+                                    <i class="bi bi-question-circle-fill"></i>
+
+                                    @if (item.IsReply)
+                                    {
+                                        <i class="bi bi-person-raised-hand"></i>
+                                    }
+                                }
+                                @if (item.IsAnonymous)
+                                {
+                                    <i class="bi bi-person-fill"></i>
+                                }
+                                @if (item.IsDeleted)
+                                {
+                                    <i class="bi bi-trash3-fill"></i>
+                                }
                             </td>
                             <td>@(item.Name ?? item.SID ?? "-")</td>
                             <td class="text-end">@item.Views</td>
                             <td class="text-end">@item.Likes</td>
                             <td class="text-end">@item.Dislikes</td>
-                            <td class="text-end">@item.Comments</td>
-                            <td class="text-center">
-                                <span title="이미지">@item.Images</span> /
-                                <span title="미디어">@item.Medias</span> /
-                                <span title="파일">@item.Files</span> /
-                                <span title="태그">@item.Tags</span>
-                            </td>
+                            <td class="text-end">@item.Images</td>
+                            <td class="text-end">@item.Medias</td>
+                            <td class="text-end">@item.Files</td>
+                            <td class="text-end">@item.Tags</td>
                             <td>@item.CreatedAt</td>
                         </tr>
                     }
@@ -185,6 +227,7 @@
 <form id="fAdminList" method="post" accept-charset="utf-8" asp-page-handler="Delete">
     @Html.AntiForgeryToken()
     <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="boardPrefixID" value="@Model.Query.BoardPrefixID" />
     <input type="hidden" name="search" value="@Model.Query.Search" />
     <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
     <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
@@ -203,7 +246,7 @@
         let searchForm = document.getElementById("fAdminSearch");
 
         $(document).on("change", "#sort, #perPage", function () {
-            searchForm.submit();
+            document.getElementById("btnSearch").click();
         });
 
         document.getElementById("btnSearch")?.addEventListener("click", function (e) {
@@ -236,7 +279,7 @@
             }
 
             const sortVal = document.getElementById("sort").value;
-            if (sortVal !== "0") {
+            if (sortVal !== "") {
                 qp.set("Sort", sortVal);
             }
 
@@ -244,6 +287,7 @@
             if (document.getElementById("isSecret").checked) qp.set("IsSecret", "true");
             if (document.getElementById("isReply").checked) qp.set("IsReply", "true");
             if (document.getElementById("isDeleted").checked) qp.set("IsDeleted", "true");
+            if (document.getElementById("isQnA").checked) qp.set("isQnA", "true");
 
             qp.set("PerPage", document.getElementById("perPage").value || "20");
             qp.set("PageNum", "1");

+ 12 - 2
Admin/Pages/Forum/Posts/List/Index.cshtml.cs

@@ -17,6 +17,7 @@ namespace Admin.Pages.Forum.Posts.List
         public sealed class QueryParams
         {
             public int? BoardID { get; set; }
+            public int? BoardPrefixID { get; set; }
             public int? Search { get; set; }
             public string? Keyword { get; set; }
             public string? StartAt { get; set; }
@@ -26,6 +27,7 @@ namespace Admin.Pages.Forum.Posts.List
             public bool? IsSecret { get; set; }
             public bool? IsReply { get; set; }
             public bool? IsDeleted { get; set; }
+            public bool? IsQnA { get; set; }
 
             [Range(1, int.MaxValue)]
             [DisplayName("페이지 번호")]
@@ -45,12 +47,15 @@ namespace Admin.Pages.Forum.Posts.List
             int ID,
             int BoardID,
             string BoardName,
+            string? BoardPrefixName,
+            int? BoardPrefixID,
             string Subject,
             string? Thumbnail,
             string? Name,
             string? SID,
             bool IsNotice,
             bool IsSecret,
+            bool IsQnA,
             bool IsReply,
             bool IsSpeaker,
             bool IsAnonymous,
@@ -66,7 +71,7 @@ namespace Admin.Pages.Forum.Posts.List
             byte Tags,
             string? UpdatedAt,
             string CreatedAt,
-            string EditURL
+            string ViewURL
         )> Data { get; set; } = [];
 
         public Pagination? Pagination { get; set; }
@@ -89,6 +94,7 @@ namespace Admin.Pages.Forum.Posts.List
 
             var result = await mediator.Send(new SearchPosts.Query(
                 Query.BoardID,
+                Query.BoardPrefixID,
                 Query.Search,
                 Query.Keyword,
                 Query.StartAt,
@@ -98,6 +104,7 @@ namespace Admin.Pages.Forum.Posts.List
                 Query.IsSecret,
                 Query.IsReply,
                 Query.IsDeleted,
+                Query.IsQnA,
                 Query.PageNum,
                 Query.PerPage
             ), ct);
@@ -108,12 +115,15 @@ namespace Admin.Pages.Forum.Posts.List
                 c.ID,
                 c.BoardID,
                 c.BoardName,
+                c.BoardPrefixName,
+                c.BoardPrefixID,
                 c.Subject,
                 c.Thumbnail,
                 c.Name,
                 c.SID,
                 c.IsNotice,
                 c.IsSecret,
+                c.IsQnA,
                 c.IsReply,
                 c.IsSpeaker,
                 c.IsAnonymous,
@@ -129,7 +139,7 @@ namespace Admin.Pages.Forum.Posts.List
                 c.Tags,
                 c.UpdatedAt.GetDateAt() ?? "-",
                 c.CreatedAt.GetDateAt(),
-                EditURL: $"/Forum/Posts/List/Edit/{c.ID}{Request.QueryString}"
+                ViewURL: $"/Forum/Posts/List/View/{c.ID}{Request.QueryString}"
             ))];
 
             Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);

+ 166 - 0
Admin/Pages/Forum/Posts/List/View.cshtml

@@ -0,0 +1,166 @@
+@page "{id:int}"
+@model Admin.Pages.Forum.Posts.List.ViewModel
+@{
+    ViewData["Title"] = "게시글 상세";
+}
+
+<link rel="stylesheet" href="~/lib/ckeditor/browser/ckeditor5-content.css" asp-append-version="true" />
+
+<div class="container">
+    <h3 class="mb-3">@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered">
+            <colgroup>
+                <col style="width: 15%;" />
+                <col />
+            </colgroup>
+            <tr>
+                <th>ID</th>
+                <td>@Model.ID</td>
+            </tr>
+            <tr>
+                <th>게시판</th>
+                <td>@Model.BoardName</td>
+            </tr>
+            <tr>
+                <th>말머리</th>
+                <td>
+                    @if (Model.BoardPrefixName is not null)
+                    {
+                        <span class="fw-bold" style="color: @(Model.BoardPrefixColor ?? "green")">[@Model.BoardPrefixName]</span>
+                    }
+                    else
+                    {
+                        <text>-</text>
+                    }
+                </td>
+            </tr>
+            <tr>
+                <th>제목</th>
+                <td>@Model.Subject</td>
+            </tr>
+            <tr>
+                <th>작성자</th>
+                <td>@(Model.Name ?? Model.SID ?? "-")</td>
+            </tr>
+            <tr>
+                <th>내용</th>
+                <td>
+                    <div class="ck-content border rounded p-3" style="min-height: 100px;">@Html.Raw(Model.Content)</div>
+                </td>
+            </tr>
+            <tr>
+                <th>대표 이미지</th>
+                <td>
+                    @if (!string.IsNullOrWhiteSpace(Model.Thumbnail))
+                    {
+                        <img src="@Model.Thumbnail" class="img-fluid img-thumbnail" style="max-width: 300px;" alt="대표 이미지" />
+                    }
+                    else
+                    {
+                        <text>-</text>
+                    }
+                </td>
+            </tr>
+            <tr>
+                <th>태그</th>
+                <td>
+                    @if (Model.Tags.Count > 0)
+                    {
+                        foreach (var tag in Model.Tags)
+                        {
+                            <span class="badge bg-secondary me-1">@tag.Name</span>
+                        }
+                    }
+                    else
+                    {
+                        <text>-</text>
+                    }
+                </td>
+            </tr>
+            <tr>
+                <th>첨부파일</th>
+                <td>
+                    @if (Model.Files.Count > 0)
+                    {
+                        <ul class="list-unstyled mb-0">
+                            @foreach (var file in Model.Files)
+                            {
+                                <li>
+                                    <a href="@file.Url" target="_blank">@file.FileName</a>
+                                    @if (file.Size.HasValue)
+                                    {
+                                        <small class="text-muted">(@(file.Size.Value > 1048576 ? $"{file.Size.Value / 1048576.0:F1}MB" : $"{file.Size.Value / 1024.0:F1}KB"))</small>
+                                    }
+                                    <small class="text-muted">다운로드: @file.Downloads</small>
+                                </li>
+                            }
+                        </ul>
+                    }
+                    else
+                    {
+                        <text>-</text>
+                    }
+                </td>
+            </tr>
+            <tr>
+                <th>이미지</th>
+                <td>
+                    @if (Model.Images.Count > 0)
+                    {
+                        foreach (var img in Model.Images)
+                        {
+                            <a href="@img.Url" target="_blank">
+                                <img src="@img.Url" class="img-thumbnail me-1 mb-1" style="max-width: 120px; max-height: 120px;" alt="@img.FileName" />
+                            </a>
+                        }
+                    }
+                    else
+                    {
+                        <text>-</text>
+                    }
+                </td>
+            </tr>
+            <tr>
+                <th>상태</th>
+                <td>
+                    @if (Model.IsNotice) { <span class="badge bg-warning text-dark me-1">공지</span> }
+                    @if (Model.IsSecret) { <span class="badge bg-dark me-1">비밀</span> }
+                    @if (Model.IsAnonymous) { <span class="badge bg-info me-1">익명</span> }
+                    @if (Model.IsSpeaker) { <span class="badge bg-primary me-1">스피커</span> }
+                    @if (Model.IsDeleted) { <span class="badge bg-danger me-1">삭제됨</span> }
+                    @if (!Model.IsNotice && !Model.IsSecret && !Model.IsAnonymous && !Model.IsSpeaker && !Model.IsDeleted)
+                    {
+                        <text>일반</text>
+                    }
+                </td>
+            </tr>
+            <tr>
+                <th>조회 / 공감 / 비공감</th>
+                <td>@Model.Views / @Model.Likes / @Model.Dislikes</td>
+            </tr>
+            <tr>
+                <th>댓글</th>
+                <td>@Model.CommentCount</td>
+            </tr>
+            <tr>
+                <th>수정일</th>
+                <td>@(Model.UpdatedAt ?? "-")</td>
+            </tr>
+            <tr>
+                <th>등록일</th>
+                <td>@Model.CreatedAt</td>
+            </tr>
+        </table>
+    </div>
+
+    <div class="d-grid gap-2 text-center d-md-block">
+        <a href="/Forum/Posts/List/Edit/@(Model.ID)@(Model.QueryString)" class="btn btn-info">수정</a>
+        <a href="@(string.IsNullOrWhiteSpace(Model.ReturnUrl) ? "/Forum/Posts/List" : Model.ReturnUrl)" class="btn btn-secondary">목록</a>
+    </div>
+    <br />
+</div>

+ 78 - 0
Admin/Pages/Forum/Posts/List/View.cshtml.cs

@@ -0,0 +1,78 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Posts.List
+{
+    public class ViewModel(IMediator mediator) : PageModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public string BoardName { get; set; } = default!;
+        public int? BoardPrefixID { get; set; }
+        public string? BoardPrefixName { get; set; }
+        public string? BoardPrefixColor { get; set; }
+        public string Subject { get; set; } = default!;
+        public new string Content { get; set; } = default!;
+        public string? Thumbnail { get; set; }
+        public string? Name { get; set; }
+        public string? SID { get; set; }
+        public bool IsNotice { get; set; }
+        public bool IsSecret { get; set; }
+        public bool IsAnonymous { get; set; }
+        public bool IsSpeaker { get; set; }
+        public bool IsDeleted { get; set; }
+        public int Views { get; set; }
+        public int Likes { get; set; }
+        public int Dislikes { get; set; }
+        public int CommentCount { get; set; }
+        public string? UpdatedAt { get; set; }
+        public string CreatedAt { get; set; } = default!;
+
+        public List<FileItemView> Files { get; set; } = [];
+        public List<ImageItemView> Images { get; set; } = [];
+        public List<TagItemView> Tags { get; set; } = [];
+
+        public string? ReturnUrl { get; set; }
+        public string? QueryString { get; set; }
+
+        public sealed record FileItemView(int ID, string FileName, string Url, string? Extension, long? Size, int Downloads);
+        public sealed record ImageItemView(int ID, string FileName, string Url);
+        public sealed record TagItemView(int TagID, string Name);
+
+        public async Task OnGetAsync(int id, CancellationToken ct)
+        {
+            var result = await mediator.Send(new GetPost.Query(id), ct);
+
+            ID = result.ID;
+            BoardID = result.BoardID;
+            BoardName = result.BoardName;
+            BoardPrefixID = result.BoardPrefixID;
+            BoardPrefixName = result.BoardPrefixName;
+            BoardPrefixColor = result.BoardPrefixColor;
+            Subject = result.Subject;
+            Content = result.Content;
+            Thumbnail = result.Thumbnail;
+            Name = result.Name;
+            SID = result.SID;
+            IsNotice = result.IsNotice;
+            IsSecret = result.IsSecret;
+            IsAnonymous = result.IsAnonymous;
+            IsSpeaker = result.IsSpeaker;
+            IsDeleted = result.IsDeleted;
+            Views = result.Views;
+            Likes = result.Likes;
+            Dislikes = result.Dislikes;
+            CommentCount = result.Comments;
+            UpdatedAt = result.UpdatedAt.GetDateAt();
+            CreatedAt = result.CreatedAt.GetDateAt();
+
+            Files = [..result.Files.Where(f => !f.IsDisabled).Select(f => new FileItemView(f.ID, f.FileName, f.Url, f.Extension, f.Size, f.Downloads))];
+            Images = [..result.Images.Where(f => !f.IsDisabled).Select(f => new ImageItemView(f.ID, f.FileName, f.Url))];
+            Tags = [..result.Tags.Select(t => new TagItemView(t.TagID, t.Name))];
+
+            ReturnUrl = Request.Headers.Referer.ToString();
+            QueryString = Request.QueryString.ToString();
+        }
+    }
+}

+ 71 - 8
Admin/Pages/Forum/Posts/List/Write.cshtml

@@ -5,6 +5,8 @@
     ViewData["Title"] = "게시글 등록";
 }
 
+<partial name="_Editor" />
+
 <div class="container">
     <h3 class="mb-3">@ViewData["Title"]</h3>
     <hr />
@@ -19,7 +21,7 @@
             <div class="col-sm-10">
                 @if ((Model.BoardList?.Count ?? 0) > 0)
                 {
-                    <select asp-for="Input.BoardID" class="form-select w-auto" required asp-items="@Model.BoardList">
+                    <select asp-for="Input.BoardID" class="form-select w-auto" required asp-items="@Model.BoardList" id="Input_BoardID">
                         <option value="">- 선택 -</option>
                     </select>
                 }
@@ -30,6 +32,16 @@
             </div>
         </div>
 
+        <!-- 말머리 -->
+        <div class="row mb-2" id="prefixRow" hidden>
+            <label class="col-sm-2 col-form-label">말머리</label>
+            <div class="col-sm-10">
+                <select asp-for="Input.BoardPrefixID" class="form-select w-auto" id="boardPrefixSelect">
+                    <option value="">- 선택 -</option>
+                </select>
+            </div>
+        </div>
+
         <!-- 제목 -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label"><span class="text-danger">*</span> 제목</label>
@@ -39,23 +51,41 @@
             </div>
         </div>
 
-        <!-- 내용 -->
+        <!-- 내용 (CKEditor) -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">내용</label>
             <div class="col-sm-10">
-                <textarea asp-for="Input.Content" class="form-control" rows="12" maxlength="8000"
-                          placeholder="내용을 입력하세요 (최대 8000자)"></textarea>
+                <textarea asp-for="Input.Content" class="ck-editor" id="Input_Content" rows="12"
+                          placeholder="내용을 입력하세요"></textarea>
             </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">
                 <input type="file" asp-for="Input.ThumbnailFile" class="form-control" accept="image/*" />
             </div>
         </div>
 
+        <!-- 첨부파일 -->
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">첨부파일</label>
+            <div class="col-sm-10">
+                <input type="file" asp-for="Input.Files" class="form-control" multiple accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar,.7z,.hwp,.hwpx,.csv" />
+                <span class="form-text text-muted">여러 파일을 선택할 수 있습니다.</span>
+            </div>
+        </div>
+
+        <!-- 태그 -->
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">태그</label>
+            <div class="col-sm-10">
+                <input type="text" asp-for="Input.TagsInput" class="form-control" placeholder="태그를 쉼표(,)로 구분하여 입력하세요" />
+                <span class="form-text text-muted">예: 비트코인, 이더리움, 블록체인</span>
+            </div>
+        </div>
+
         <!-- 상태 -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">상태</label>
@@ -88,11 +118,44 @@
 
 @section Scripts {
     <script>
+        // 게시판 변경 시 말머리 동적 로드
+        document.getElementById("Input_BoardID")?.addEventListener("change", async function () {
+            const boardID = this.value;
+            const prefixRow = document.getElementById("prefixRow");
+            const prefixSelect = document.getElementById("boardPrefixSelect");
+
+            prefixSelect.innerHTML = '<option value="">- 선택 -</option>';
+
+            if (!boardID) {
+                prefixRow.hidden = true;
+                return;
+            }
+
+            try {
+                const res = await fetch(`/Forum/Posts/List/Write?handler=Prefixes&boardID=${boardID}`);
+                const items = await res.json();
+
+                if (items.length > 0) {
+                    items.forEach(item => {
+                        const opt = document.createElement("option");
+                        opt.value = item.id;
+                        opt.textContent = item.name;
+                        prefixSelect.appendChild(opt);
+                    });
+                    prefixRow.hidden = false;
+                } else {
+                    prefixRow.hidden = true;
+                }
+            } catch {
+                prefixRow.hidden = true;
+            }
+        });
+
+        // 취소 버튼 확인
         $(function () {
             $(".btn-cancel").on("click", function (e) {
                 const s = $("input[name='Input.Subject']").val()?.trim();
-                const c = $("textarea[name='Input.Content']").val()?.trim();
-                if (s || c) {
+                if (s) {
                     e.preventDefault();
                     if (confirm("내용이나 제목이 남아 있습니다. 글 작성을 취소하시겠습니까?")) {
                         location.href = $(this).attr("href");
@@ -101,4 +164,4 @@
             });
         });
     </script>
-}
+}

+ 23 - 1
Admin/Pages/Forum/Posts/List/Write.cshtml.cs

@@ -22,18 +22,26 @@ namespace Admin.Pages.Forum.Posts.List
             [Required(ErrorMessage = "{0}은 필수입니다.")]
             public int BoardID { get; set; }
 
+            [DisplayName("말머리")]
+            public int? BoardPrefixID { get; set; }
+
             [DisplayName("제목")]
             [Required(ErrorMessage = "{0}은 필수입니다.")]
             [StringLength(255, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
             public string Subject { get; set; } = null!;
 
             [DisplayName("내용")]
-            [StringLength(8000, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
             public string? Content { get; set; }
 
             [DisplayName("대표 이미지")]
             public IFormFile? ThumbnailFile { get; set; }
 
+            [DisplayName("첨부파일")]
+            public List<IFormFile>? Files { get; set; }
+
+            [DisplayName("태그")]
+            public string? TagsInput { get; set; }
+
             [DisplayName("공지")]
             public bool IsNotice { get; set; } = false;
 
@@ -58,6 +66,13 @@ namespace Admin.Pages.Forum.Posts.List
             ReturnUrl = Request.Headers.Referer.ToString();
         }
 
+        public async Task<IActionResult> OnGetPrefixesAsync(int boardID, CancellationToken ct)
+        {
+            var result = await mediator.Send(new GetBoardPrefixes.Query(boardID), ct);
+            var items = result.List.Where(c => c.IsActive).Select(c => new { id = c.ID, name = c.Name });
+            return new JsonResult(items);
+        }
+
         public async Task<IActionResult> OnPostAsync(CancellationToken ct)
         {
             try
@@ -67,11 +82,18 @@ namespace Admin.Pages.Forum.Posts.List
                     throw new Exception(ModelState.GetErrorMessages());
                 }
 
+                var tags = string.IsNullOrWhiteSpace(Input.TagsInput)
+                    ? null
+                    : Input.TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
+
                 await mediator.Send(new CreatePost.Command(
                     Input.BoardID,
+                    Input.BoardPrefixID,
                     Input.Subject,
                     Input.Content,
                     Input.ThumbnailFile,
+                    Input.Files,
+                    tags,
                     Input.IsNotice,
                     Input.IsSecret,
                     Input.IsAnonymous

+ 46 - 42
Admin/Pages/Forum/Reports/Comment/Index.cshtml

@@ -87,7 +87,7 @@
     </div>
 
     <div class="table-responsive">
-        <table class="table table-striped table-bordered table-hover mt-3">
+        <table class="table table-bordered mt-3">
             <colgroup>
                 <col style="width: 5%;" />   <!-- ID -->
                 <col style="width: 8%;" />   <!-- 게시판 -->
@@ -123,9 +123,11 @@
             <tbody>
                 @if (Model.List == null || Model.Total <= 0)
                 {
-                    <tr>
-                        <td colspan="9">No Data.</td>
-                    </tr>
+                    <tbody>
+                        <tr>
+                            <td colspan="9">No Data.</td>
+                        </tr>
+                    </tbody>
                 }
                 else
                 {
@@ -144,44 +146,46 @@
 
                     @foreach (var row in Model.List)
                     {
-                        <tr>
-                            <td rowspan="2">
-                                <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.ID</label>
-                                </div>
-                            </td>
-                            <td rowspan="2">@row.BoardName</td>
-                            <td colspan="3" class="text-start">
-                                [@row.CommentID] @row.Comment
-                            </td>
-                            <td rowspan="2">
-                                <a href="/Forum/Posts/List/Edit/@row.PostID">
-                                    @row.PostID
-                                </a>
-                            </td>
-                            <td rowspan="2">@(row.MemberName ?? $"ID:{row.MemberID}")</td>
-                            <td rowspan="2">@row.Reason</td>
-                            <td rowspan="2">@row.CreatedAt</td>
-                        </tr>
-                        <tr>
-                            <td><span class="badge text-bg-warning">@(typeLabels.GetValueOrDefault(row.Type, "-"))</span></td>
-                            <td>
-                                @if (row.Status == ReportStatus.Received)
-                                {
-                                    <span class="badge text-bg-danger">접수</span>
-                                }
-                                else if (row.Status == ReportStatus.Processing)
-                                {
-                                    <span class="badge text-bg-warning">처리중</span>
-                                }
-                                else if (row.Status == ReportStatus.Completed)
-                                {
-                                    <span class="badge text-bg-success">완료</span>
-                                }
-                            </td>
-                            <td>@row.Memo</td>
-                        </tr>
+                        <tbody class="striped">
+                            <tr>
+                                <td rowspan="2">
+                                    <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.ID</label>
+                                    </div>
+                                </td>
+                                <td rowspan="2">@row.BoardName</td>
+                                <td colspan="3" class="text-start">
+                                    [@row.CommentID] @row.Comment
+                                </td>
+                                <td rowspan="2">
+                                    <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                        @row.PostID
+                                    </a>
+                                </td>
+                                <td rowspan="2">@(row.MemberName ?? $"ID:{row.MemberID}")</td>
+                                <td rowspan="2">@row.Reason</td>
+                                <td rowspan="2">@row.CreatedAt</td>
+                            </tr>
+                            <tr>
+                                <td><span class="badge text-bg-warning">@(typeLabels.GetValueOrDefault(row.Type, "-"))</span></td>
+                                <td>
+                                    @if (row.Status == ReportStatus.Received)
+                                    {
+                                        <span class="badge text-bg-danger">접수</span>
+                                    }
+                                    else if (row.Status == ReportStatus.Processing)
+                                    {
+                                        <span class="badge text-bg-warning">처리중</span>
+                                    }
+                                    else if (row.Status == ReportStatus.Completed)
+                                    {
+                                        <span class="badge text-bg-success">완료</span>
+                                    }
+                                </td>
+                                <td>@row.Memo</td>
+                            </tr>
+                        </tbody>
                     }
                 }
             </tbody>

+ 44 - 42
Admin/Pages/Forum/Reports/Post/Index.cshtml

@@ -82,12 +82,12 @@
             </select>
         </div>
         <div class="col-auto">
-            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled form="fAdminList">삭제</button>
         </div>
     </div>
 
     <div class="table-responsive">
-        <table class="table table-striped table-bordered table-hover mt-3">
+        <table class="table table-bordered mt-3">
             <colgroup>
                 <col style="width: 5%;" />   <!-- ID -->
                 <col style="width: 8%;" />   <!-- 게시판 -->
@@ -118,12 +118,13 @@
                     <th>메모</th>
                 </tr>
             </thead>
-            <tbody>
                 @if (Model.List == null || Model.Total <= 0)
                 {
-                    <tr>
-                        <td colspan="8">No Data.</td>
-                    </tr>
+                    <tbody>
+                        <tr>
+                            <td colspan="8">No Data.</td>
+                        </tr>
+                    </tbody>
                 }
                 else
                 {
@@ -142,44 +143,45 @@
 
                     @foreach (var row in Model.List)
                     {
-                        <tr>
-                            <td rowspan="2">
-                                <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.ID</label>
-                                </div>
-                            </td>
-                            <td rowspan="2">@row.BoardName</td>
-                            <td colspan="3" class="text-start">
-                                <a href="/Forum/Posts/List/Edit/@row.PostID">
-                                    @(row.PostSubject.Length > 30 ? row.PostSubject[..30] + "..." : row.PostSubject)
-                                </a>
-                            </td>
-                            <td rowspan="2">@(row.MemberName ?? $"ID:{row.MemberID}")</td>
-                            <td rowspan="2">@row.Reason</td>
-                            <td rowspan="2">@row.CreatedAt</td>
-                        </tr>
-                        <tr>
-                            <td><span class="badge text-bg-warning">@(typeLabels.GetValueOrDefault(row.Type, "-"))</span></td>
-                            <td>
-                                @if (row.Status == ReportStatus.Received)
-                                {
-                                    <span class="badge text-bg-danger">접수</span>
-                                }
-                                else if (row.Status == ReportStatus.Processing)
-                                {
-                                    <span class="badge text-bg-warning">처리중</span>
-                                }
-                                else if (row.Status == ReportStatus.Completed)
-                                {
-                                    <span class="badge text-bg-success">완료</span>
-                                }
-                            </td>
-                            <td>@row.Memo</td>
-                        </tr>
+                        <tbody class="striped">
+                            <tr>
+                                <td rowspan="2">
+                                    <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.ID</label>
+                                    </div>
+                                </td>
+                                <td rowspan="2">@row.BoardName</td>
+                                <td colspan="3" class="text-start">
+                                    <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                        @(row.PostSubject.Length > 30 ? row.PostSubject[..30] + "..." : row.PostSubject)
+                                    </a>
+                                </td>
+                                <td rowspan="2">@(row.MemberName ?? $"ID:{row.MemberID}")</td>
+                                <td rowspan="2">@row.Reason</td>
+                                <td rowspan="2">@row.CreatedAt</td>
+                            </tr>
+                            <tr>
+                                <td><span class="badge text-bg-warning">@(typeLabels.GetValueOrDefault(row.Type, "-"))</span></td>
+                                <td>
+                                    @if (row.Status == ReportStatus.Received)
+                                    {
+                                        <span class="badge text-bg-danger">접수</span>
+                                    }
+                                    else if (row.Status == ReportStatus.Processing)
+                                    {
+                                        <span class="badge text-bg-warning">처리중</span>
+                                    }
+                                    else if (row.Status == ReportStatus.Completed)
+                                    {
+                                        <span class="badge text-bg-success">완료</span>
+                                    }
+                                </td>
+                                <td>@row.Memo</td>
+                            </tr>
+                        </tbody>
                     }
                 }
-            </tbody>
         </table>
 
         <partial name="_Pagination" model="@Model.Pagination" />

+ 12 - 4
Admin/Pages/Member/List/Index.cshtml

@@ -108,16 +108,24 @@
 
     <ul class="nav nav-tabs">
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.Tab == 0 ? "active" : null)" href="/Member/List?tab=0">전체</a>
+            <a class="nav-link @(Model.Query.Tab == 0 ? "active" : null)" href="/Member/List?tab=0">
+                전체(@Model.Counts.All.ToString("N0"))
+            </a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.Tab == 1 ? "active" : null)" href="/Member/List?tab=1">차단</a>
+            <a class="nav-link @(Model.Query.Tab == 1 ? "active" : null)" href="/Member/List?tab=1">
+                차단(@Model.Counts.Denied.ToString("N0"))
+            </a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.Tab == 2 ? "active" : null)" href="/Member/List?tab=2">탈퇴</a>
+            <a class="nav-link @(Model.Query.Tab == 2 ? "active" : null)" href="/Member/List?tab=2">
+                탈퇴(@Model.Counts.Withdraw.ToString("N0"))
+            </a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.Tab == 3 ? "active" : null)" href="/Member/List?tab=3">GM</a>
+            <a class="nav-link @(Model.Query.Tab == 3 ? "active" : null)" href="/Member/List?tab=3">
+                GM(@Model.Counts.Admin.ToString("N0"))
+            </a>
         </li>
     </ul>
 

+ 5 - 2
Admin/Pages/Member/List/Index.cshtml.cs

@@ -53,6 +53,8 @@ public class IndexModel(IMediator mediator) : PageModel
 
     public int Total { get; set; } = 0;
 
+    public SearchMembers.Response.TabCounts Counts { get; set; } = new();
+
     public List<(
         int ID,
         string Email,
@@ -101,6 +103,7 @@ public class IndexModel(IMediator mediator) : PageModel
         ), ct);
 
         Total = result.Total;
+        Counts = result.Counts;
 
         var qs = Request.QueryString.ToString();
 
@@ -109,8 +112,8 @@ public class IndexModel(IMediator mediator) : PageModel
             c.Email,
             Name: c.Name ?? "-",
             FullName: c.FullName ?? "-",
-            Icon: c.Icon ?? "-",
-            Phone: c.Phone ?? "-",
+            c.Icon,
+            c.Phone,
             Birthday: c.Birthday ?? "-",
             Gender: c.Gender ?? "-",
             GradeName: c.GradeName ?? "-",

+ 2 - 0
Admin/Pages/Member/List/View.cshtml

@@ -261,6 +261,8 @@
     <div class="d-grid gap-2 text-center d-md-block">
         <a href="/Member/List?@ViewData["QueryString"]" class="btn btn-secondary">확인</a>
     </div>
+    <br />
+    <br />
 </div>
 
 @section Scripts {

+ 1 - 2
Admin/Pages/Member/Log/Login/Index.cshtml

@@ -118,10 +118,9 @@
                                     <label for="ids_@row.ID">@row.ID</label>
                                 </div>
                             </td>
-                            <td>@row.ID</td>
                             <td>@row.Success</td>
                             <td>
-                                @row.Account @if(row.MemberName is not null) { <small>, @row.MemberName</small> }
+                                [@row.MemberID] @row.Account @if(row.MemberName is not null) { <small>, @row.MemberName</small> }
                             </td>
                             <td>@row.Reason</td>
                             <td>@row.IpAddress</td>

+ 5 - 5
Admin/Pages/Member/Log/Login/Index.cshtml.cs

@@ -46,7 +46,7 @@ public class IndexModel(IMediator mediator) : PageModel
         int? MemberID,
         string? MemberName,
         string Account,
-        bool Success,
+        char Success,
         string? Reason,
         string? IpAddress,
         string? UserAgent,
@@ -82,15 +82,15 @@ public class IndexModel(IMediator mediator) : PageModel
             c.MemberID,
             c.MemberName ?? "-",
             c.Account,
-            c.Success,
-            c.Reason,
-            c.IpAddress,
+            c.Success ? 'Y' : 'N',
+            c.Reason is null ? "-" : c.Reason,
+            c.IpAddress ?? "-",
             c.UserAgent ?? "-",
             c.Browser ?? "-",
             c.OS ?? "-",
             c.Device ?? "-",
             c.CreatedAt.GetDateAt(),
-            $"/Member/Log/Login/Info/{c.ID}${Request.QueryString}"
+            $"/Member/Log/Login/Info/{c.ID}{Request.QueryString}"
         ))];
 
         Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);

+ 2 - 2
Admin/Pages/Member/Log/Login/Info.cshtml

@@ -85,7 +85,7 @@
     }
     <hr />
     <div class="d-grid gap-2 text-center d-md-block">
-        <a asp-page="Index" class="btn btn-sm btn-secondary">목록</a>
+        <a asp-page="Index" class="btn btn-secondary">목록</a>
     </div>
     <br />
-</div>
+</div>

+ 189 - 0
Admin/Pages/Member/Visitor.cshtml

@@ -0,0 +1,189 @@
+@page
+@model Admin.Pages.Member.VisitorModel
+@{
+    ViewData["Title"] = "현재 접속자";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_search" id="fAdminSearch" method="get" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" name="Query.PageNum" value="1" />
+
+        <div class="row g-2 mb-2">
+            <div class="col col-lg-auto">
+                <div class="row g-2">
+                    <div class="col-auto col-md-auto">
+                        <select name="Query.Search" class="form-select">
+                            <option value="1" selected="@(Model.Query.Search == 1)">회원ID</option>
+                            <option value="2" selected="@(Model.Query.Search == 2)">회원 이메일</option>
+                        </select>
+                    </div>
+                    <div class="col col-md-auto">
+                        <input type="search" name="Query.Keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" />
+                    </div>
+                    <div class="col-auto col-md-auto text-center">
+                        <button type="submit" class="btn btn-primary w-100">검색</button>
+                    </div>
+                </div>
+            </div>
+            <div class="col-12 col-lg text-end">
+                <select name="Query.PerPage" class="form-select w-auto d-inline-block">
+                    <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                    <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                    <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                    <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+                </select>
+                <button type="button" id="btnListExecute" class="btn btn-danger" data-action="/Member/Visitor?handler=Kick" disabled>강제 종료</button>
+            </div>
+        </div>
+    </form>
+
+    <blockquote class="pt-3 pb-1">
+        <small>※ 현재 접속자는 접속 기기의 연결 상태를 보여줍니다.</small><br/>
+        <small>※ 검색은 Like이 아닌 = 으로 처리됩니다.</small><br/>
+        <small>※ 강제 종료는 회원을 강제로 로그아웃 시킵니다.</small>
+    </blockquote>
+
+    <form name="f_admin_list" id="fAdminList" method="post" accept-charset="utf-8" autocomplete="off"></form>
+
+    <ul class="nav nav-tabs mt-3">
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Tab == null ? "active" : null)" href="/Member/Visitor">전체(@Model.TotalRows)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Tab == 1 ? "active" : null)" href="/Member/Visitor?tab=1">정회원(@Model.TotalMember)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Tab == 2 ? "active" : null)" href="/Member/Visitor?tab=2">비회원(@Model.TotalGuest)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Tab == 3 ? "active" : null)" href="/Member/Visitor?tab=3">IP 목록(@Model.TotalIps)</a>
+        </li>
+    </ul>
+
+    <div class="table-responsive">
+        <table class="table table-bordered mt-3">
+
+            @if (Model.Query.Tab != 3) {
+                <thead>
+                    <tr>
+                        <th rowspan="2"><input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" /></th>
+                        <th rowspan="2">ID</th>
+                        <th colspan="2">Connection ID</th>
+                        <th rowspan="2">IP</th>
+                        <th rowspan="2">Browser</th>
+                        <th rowspan="2">OS</th>
+                        <th rowspan="2">접속 기기</th>
+                        <th rowspan="2">접속 일시</th>
+                        <th rowspan="2">비고</th>
+                    </tr>
+                    <tr>
+                        <th>회원ID</th>
+                        <th>이메일</th>
+                    </tr>
+                </thead>
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
+                    <tbody>
+                        <tr>
+                            <td colspan="10">No Data.</td>
+                        </tr>
+                    </tbody>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                    <tbody class="striped">
+                        <tr>
+                            <td rowspan="2">
+                                @if (!row.IsGuest) {
+                                    <input type="checkbox" name="ids[]" class="form-check-input list-check-box" value="@row.ConnectionID" form="fAdminList" />
+                                } else {
+                                    <text>-</text>
+                                }
+                            </td>
+                            <td rowspan="2">@row.Num</td>
+                            <td colspan="2">@row.ConnectionID</td>
+                            <td rowspan="2">@row.IpAddress</td>
+                            <td rowspan="2">@row.Browser</td>
+                            <td rowspan="2">@row.OS</td>
+                            <td rowspan="2">@row.Device</td>
+                            <td rowspan="2">@row.ConnectedAt</td>
+                            <td rowspan="2">
+                                @if (!row.IsGuest)
+                                {
+                                    <a class="btn btn-sm btn-outline-danger btn-row-execute" href="@row.LogoutURL">강제 종료</a>
+                                } else {
+                                    <text>-</text>
+                                }
+                            </td>
+                        </tr>
+                        @if (row.IsGuest) {
+                            <tr>
+                                <td colspan="2">비회원</td>
+                            </tr>
+                        } else {
+                            <tr>
+                                <td>@row.MemberID</td>
+                                <td>@row.Email</td>
+                            </tr>
+                        }
+                    </tbody>
+                    }
+                }
+            } else {
+                <thead>
+                    <tr>
+                        <th rowspan="2">No</th>
+                        <th rowspan="2">접속 일시</th>
+                        <th rowspan="2">IP</th>
+                        <th rowspan="2">Browser</th>
+                        <th rowspan="2">OS</th>
+                        <th rowspan="2">접속 기기</th>
+                        <th rowspan="2">연결 수</th>
+                    </tr>
+                </thead>
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
+                    <tbody>
+                        <tr>
+                            <td colspan="7">No Data.</td>
+                        </tr>
+                    </tbody>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tbody class="striped">
+                            <tr>
+                                <td>@row.Num</td>
+                                <td>@row.ConnectedAt</td>
+                                <td>@row.IpAddress</td>
+                                <td>@row.Browser</td>
+                                <td>@row.OS</td>
+                                <td>@row.Device</td>
+                                <td>@row.Connections</td>
+                            </tr>
+                        </tbody>
+                    }
+                }
+            }
+        </table>
+
+        <partial name="_Pagination" model="Model.Pagination" />
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        $(document).on("change", "[name='Query.PerPage']", function () {
+            document.getElementById("fAdminSearch").submit();
+        });
+    </script>
+}

+ 229 - 0
Admin/Pages/Member/Visitor.cshtml.cs

@@ -0,0 +1,229 @@
+using Application.Abstractions.Cache;
+using Application.Abstractions.Chat;
+using Application.Common;
+using SharedKernel.Helpers;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using StackExchange.Redis;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Member;
+
+public class VisitorModel(
+    IChatConnectionTracker tracker,
+    IConnectionMultiplexer redis
+) : PageModel {
+
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Query { get; set; } = new();
+
+    public sealed class QueryParams
+    {
+        [Range(1, int.MaxValue)]
+        [DisplayName("페이지 번호")]
+        public int PageNum { get; set; } = 1;
+
+        [Range(1, 100)]
+        [DisplayName("페이지 목록 수")]
+        public ushort PerPage { get; set; } = 20;
+
+        [Range(1, 2, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
+        [DisplayName("검색 조건")]
+        public int? Search { get; set; }
+
+        [MaxLength(100, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
+        [DisplayName("검색어")]
+        public string? Keyword { get; set; }
+
+        [DisplayName("검색 구분")]
+        public int? Tab { get; set; }
+    }
+
+    public int TotalRows { get; set; }
+    public int TotalMember { get; set; }
+    public int TotalGuest { get; set; }
+    public int TotalIps { get; set; }
+
+    public List<(
+        int Num,
+        string ConnectionID,
+        bool IsGuest,
+        string? MemberID,
+        string? Email,
+        string IpAddress,
+        string Browser,
+        string OS,
+        string Device,
+        string ConnectedAt,
+        string LogoutURL,
+        int Connections
+    )> List { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken _)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var all = await tracker.GetAllAsync();
+
+        TotalRows = all.Count;
+        TotalMember = all.Count(x => !x.IsGuest);
+        TotalGuest = all.Count(x => x.IsGuest);
+        TotalIps = all.Select(x => x.IpAddress).Distinct().Count();
+
+        if (Query.Tab == 3)
+        {
+            // IP 목록 탭
+            var ipGroups = all
+                .GroupBy(x => x.IpAddress)
+                .Select(g => new
+                {
+                    IpAddress = g.Key,
+                    ConnectedAt = g.Min(x => x.ConnectedAt),
+                    SampleUA = g.First().UserAgent,
+                    Connections = g.Count()
+                })
+                .OrderByDescending(x => x.Connections)
+                .ToList();
+
+            var skip = (Query.PageNum - 1) * Query.PerPage;
+            var paged = ipGroups.Skip(skip).Take(Query.PerPage).ToList();
+            int num = skip + 1;
+
+            List = [..paged.Select(c => (
+                Num: num++,
+                ConnectionID: "",
+                IsGuest: false,
+                MemberID: (string?)null,
+                Email: (string?)null,
+                c.IpAddress,
+                Browser: ParseUA(c.SampleUA, "browser"),
+                OS: ParseUA(c.SampleUA, "os"),
+                Device: ParseUA(c.SampleUA, "device"),
+                ConnectedAt: c.ConnectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
+                LogoutURL: "",
+                c.Connections
+            ))];
+
+            Pagination = new Pagination(ipGroups.Count, Query.PageNum, Query.PerPage);
+        }
+        else
+        {
+            // 검색 필터
+            IEnumerable<ConnectedUser> filtered = all;
+            if (!string.IsNullOrWhiteSpace(Query.Keyword))
+            {
+                filtered = Query.Search == 1
+                    ? filtered.Where(x => x.MemberID.HasValue && x.MemberID.Value.ToString() == Query.Keyword)
+                    : filtered.Where(x => x.Email != null && x.Email == Query.Keyword);
+            }
+
+            // 탭 필터
+            filtered = Query.Tab switch
+            {
+                1 => filtered.Where(x => !x.IsGuest),
+                2 => filtered.Where(x => x.IsGuest),
+                _ => filtered
+            };
+
+            var sorted = filtered.OrderByDescending(x => x.ConnectedAt).ToList();
+            var skip = (Query.PageNum - 1) * Query.PerPage;
+            var paged = sorted.Skip(skip).Take(Query.PerPage).ToList();
+            int num = skip + 1;
+
+            var qs = Request.QueryString.ToString();
+
+            List = [..paged.Select(c => (
+                Num: num++,
+                ConnectionID: c.ConnectionId,
+                c.IsGuest,
+                MemberID: c.MemberID?.ToString(),
+                c.Email,
+                c.IpAddress,
+                Browser: ParseUA(c.UserAgent, "browser"),
+                OS: ParseUA(c.UserAgent, "os"),
+                Device: ParseUA(c.UserAgent, "device"),
+                ConnectedAt: c.ConnectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
+                LogoutURL: $"/Member/Visitor?handler=Kick&connectionId={c.ConnectionId}",
+                Connections: 0
+            ))];
+
+            Pagination = new Pagination(sorted.Count, Query.PageNum, Query.PerPage);
+        }
+    }
+
+    public async Task<IActionResult> OnGetKickAsync(string connectionId, CancellationToken ct)
+    {
+        try
+        {
+            if (string.IsNullOrEmpty(connectionId))
+            {
+                throw new Exception("강제 종료할 대상이 없습니다.");
+            }
+
+            await PublishKick(connectionId);
+
+            TempData["SuccessMessage"] = "강제 종료되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage(Query);
+    }
+
+    public async Task<IActionResult> OnPostKickAsync(string[] ids, CancellationToken ct)
+    {
+        try
+        {
+            if (ids.Length == 0)
+            {
+                throw new Exception("강제 종료할 항목을 선택해주세요.");
+            }
+
+            foreach (var id in ids)
+            {
+                await PublishKick(id);
+            }
+
+            TempData["SuccessMessage"] = $"{ids.Length}건이 강제 종료되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage(Query);
+    }
+
+    // Redis Pub/Sub을 통해 해당 ConnectionID를 구독 중인 클라이언트에게 강제 종료 메시지를 보냄
+    private async Task PublishKick(string connectionId)
+    {
+        await redis.GetSubscriber().PublishAsync(
+            RedisChannel.Literal(CacheKeys.ChatKickChannel), connectionId
+        );
+    }
+
+    // User-Agent 문자열에서 브라우저, OS, 디바이스 정보를 추출하는 헬퍼 메서드
+    private static string ParseUA(string? ua, string type)
+    {
+        if (string.IsNullOrWhiteSpace(ua))
+        {
+            return "-";
+        }
+
+        return type switch
+        {
+            "browser" => UserAgentParser.ExtractBrowser(ua),
+            "os" => UserAgentParser.ExtractOS(ua),
+            "device" => UserAgentParser.ExtractDevice(ua),
+            _ => "-"
+        };
+    }
+}

+ 17 - 19
Admin/Pages/Member/Wallet/List/Index.cshtml

@@ -88,34 +88,32 @@
                     <th>비고</th>
                 </tr>
             </thead>
+            <tbody>
             @if (Model.List == null || Model.List.Count <= 0)
             {
-                <tbody>
-                    <tr>
-                        <td colspan="8">No Data.</td>
-                    </tr>
-                </tbody>
+                <tr>
+                    <td colspan="8">No Data.</td>
+                </tr>
             }
             else
             {
                 @foreach (var row in Model.List)
                 {
-                    <tbody>
-                        <tr>
-                            <td>@row.Num</td>
-                            <td>@row.ID</td>
-                            <td>[@row.MemberID] @row.MemberEmail, @row.MemberName</td>
-                            <td>@row.Balance</td>
-                            <td>@row.DonationBalance</td>
-                            <td>@row.UpdatedAt</td>
-                            <td>@row.CreatedAt</td>
-                            <td>
-                                <a class="btn btn-sm btn-outline-success" href="@row.ChargeURL">충전</a>
-                            </td>
-                        </tr>
-                    </tbody>
+                    <tr>
+                        <td>@row.Num</td>
+                        <td>@row.ID</td>
+                        <td>[@row.MemberID] @row.MemberEmail, @row.MemberName</td>
+                        <td>@row.Balance</td>
+                        <td>@row.DonationBalance</td>
+                        <td>@row.UpdatedAt</td>
+                        <td>@row.CreatedAt</td>
+                        <td>
+                            <a class="btn btn-sm btn-outline-success" href="@row.ChargeURL">충전</a>
+                        </td>
+                    </tr>
                 }
             }
+            </tbody>
         </table>
 
         <partial name="_Pagination" model="Model.Pagination" />

+ 28 - 30
Admin/Pages/Member/Wallet/Transactions/Index.cshtml

@@ -139,45 +139,43 @@
                     <th>비고</th>
                 </tr>
             </thead>
+            <tbody>
             @if (Model.List == null || Model.List.Count <= 0)
             {
-                <tbody>
-                    <tr>
-                        <td colspan="9">No Data.</td>
-                    </tr>
-                </tbody>
+                <tr>
+                    <td colspan="9">No Data.</td>
+                </tr>
             }
             else
             {
                 @foreach (var row in Model.List)
                 {
-                    <tbody>
-                        <tr>
-                            <td>@row.Num</td>
-                            <td>
-                                <div class="form-check form-check-inline">
-                                    <input type="checkbox" name="checkList[]" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
-                                    <label for="chk_@row.ID" class="form-check-label">@row.ID</label>
-                                </div>
-                            </td>
-                            <td>[@row.MemberID] @row.MemberEmail, @row.MemberName</td>
-                            <td>@row.TxType</td>
-                            <td>@row.BalanceType</td>
-                            <td>@row.Amount</td>
-                            <td>@row.BalanceAfter</td>
-                            <td>@row.CreatedAt</td>
-                            <td>
-                                <environment include="Local,Development">
-                                <div class="d-xl-flex gap-2 justify-content-center d-grid">
-                                    <a class="btn btn-sm btn-outline-info" href="@row.ViewURL">상세</a>
-                                    <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID">삭제</button>
-                                </div>
-                                </environment>
-                            </td>
-                        </tr>
-                    </tbody>
+                    <tr>
+                        <td>@row.Num</td>
+                        <td>
+                            <div class="form-check form-check-inline">
+                                <input type="checkbox" name="checkList[]" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                <label for="chk_@row.ID" class="form-check-label">@row.ID</label>
+                            </div>
+                        </td>
+                        <td>[@row.MemberID] @row.MemberEmail, @row.MemberName</td>
+                        <td>@row.TxType</td>
+                        <td>@row.BalanceType</td>
+                        <td>@row.Amount</td>
+                        <td>@row.BalanceAfter</td>
+                        <td>@row.CreatedAt</td>
+                        <td>
+                            <environment include="Local,Development">
+                            <div class="d-xl-flex gap-2 justify-content-center d-grid">
+                                <a class="btn btn-sm btn-outline-info" href="@row.ViewURL">상세</a>
+                                <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID">삭제</button>
+                            </div>
+                            </environment>
+                        </td>
+                    </tr>
                 }
             }
+            </tbody>
         </table>
 
         <partial name="_Pagination" model="Model.Pagination" />

+ 140 - 0
Admin/Pages/Server/Cache.cshtml

@@ -0,0 +1,140 @@
+@page
+@model Admin.Pages.Server.CacheModel
+@{
+    ViewData["Title"] = "캐시 관리";
+}
+
+<div class="container">
+    <h4 class="pb-2">@ViewData["Title"]</h4>
+
+    <partial name="_StatusMessage" />
+
+    <p class="text-muted mb-4">Redis 캐시를 삭제합니다. 삭제된 캐시는 다음 요청 시 DB에서 자동으로 재생성됩니다.</p>
+
+    <div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-3">
+        <!-- 전체 캐시 -->
+        <div class="col">
+            <div class="card border-danger">
+                <div class="card-body">
+                    <h5 class="card-title">전체 캐시</h5>
+                    <p class="card-text text-muted">모든 Redis 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <button type="submit" class="btn btn-danger" onclick="return confirm('전체 캐시를 삭제하시겠습니까?');">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        <!-- 환경설정 -->
+        <div class="col">
+            <div class="card">
+                <div class="card-body">
+                    <h5 class="card-title">환경설정</h5>
+                    <p class="card-text text-muted">사이트 기본 설정 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <input type="hidden" name="prefix" value="config" />
+                        <button type="submit" class="btn btn-outline-secondary">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        <!-- 게시판 설정 -->
+        <div class="col">
+            <div class="card">
+                <div class="card-body">
+                    <h5 class="card-title">게시판 설정</h5>
+                    <p class="card-text text-muted">게시판 메타 정보 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <input type="hidden" name="prefix" value="board:meta:" />
+                        <button type="submit" class="btn btn-outline-secondary">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        <!-- FAQ -->
+        <div class="col">
+            <div class="card">
+                <div class="card-body">
+                    <h5 class="card-title">FAQ</h5>
+                    <p class="card-text text-muted">FAQ 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <input type="hidden" name="prefix" value="faq:" />
+                        <button type="submit" class="btn btn-outline-secondary">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        <!-- 팝업 -->
+        <div class="col">
+            <div class="card">
+                <div class="card-body">
+                    <h5 class="card-title">팝업</h5>
+                    <p class="card-text text-muted">팝업 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <input type="hidden" name="prefix" value="popup:" />
+                        <button type="submit" class="btn btn-outline-secondary">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        <!-- 배너 -->
+        <div class="col">
+            <div class="card">
+                <div class="card-body">
+                    <h5 class="card-title">배너</h5>
+                    <p class="card-text text-muted">배너 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <input type="hidden" name="prefix" value="banner:" />
+                        <button type="submit" class="btn btn-outline-secondary">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        <!-- 문서 -->
+        <div class="col">
+            <div class="card">
+                <div class="card-body">
+                    <h5 class="card-title">문서</h5>
+                    <p class="card-text text-muted">문서 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <input type="hidden" name="prefix" value="document:" />
+                        <button type="submit" class="btn btn-outline-secondary">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        <!-- 코인 -->
+        <div class="col">
+            <div class="card">
+                <div class="card-body">
+                    <h5 class="card-title">코인</h5>
+                    <p class="card-text text-muted">코인/시세 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <input type="hidden" name="prefix" value="crypto:" />
+                        <button type="submit" class="btn btn-outline-secondary">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        <!-- 채팅 -->
+        <div class="col">
+            <div class="card">
+                <div class="card-body">
+                    <h5 class="card-title">채팅</h5>
+                    <p class="card-text text-muted">채팅 메시지/접속 캐시를 삭제합니다.</p>
+                    <form method="post">
+                        <input type="hidden" name="prefix" value="chat:" />
+                        <button type="submit" class="btn btn-outline-secondary">삭제</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>

+ 29 - 0
Admin/Pages/Server/Cache.cshtml.cs

@@ -0,0 +1,29 @@
+using Application.Features.Admin.Cache.ClearCache;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Server;
+
+public class CacheModel(IMediator mediator) : PageModel
+{
+    public void OnGet()
+    {
+    }
+
+    public async Task<IActionResult> OnPostAsync(string? prefix, CancellationToken ct)
+    {
+        try
+        {
+            await mediator.Send(new Command(prefix), ct);
+
+            TempData["SuccessMessage"] = string.IsNullOrEmpty(prefix) ? "전체 캐시가 삭제되었습니다." : $"'{prefix}' 캐시가 삭제되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage();
+    }
+}

+ 0 - 7
Admin/Program.cs

@@ -4,8 +4,6 @@ using Admin.Pages.Shared.Layout;
 using Admin.Middlewares;
 using Application;
 using Infrastructure;
-using Microsoft.AspNetCore.Identity;
-using Microsoft.EntityFrameworkCore;
 
 var builder = WebApplication.CreateBuilder(args);
 var settings = builder.Configuration.Get<AppSettings>()!;
@@ -21,11 +19,6 @@ var mvcBuilder = builder.Services.AddRazorPages(options =>
     options.Conventions.AllowAnonymousToAreaFolder("Identity", "/Account");
 });
 
-if (builder.Environment.IsDevelopment())
-{
-    mvcBuilder.AddRazorRuntimeCompilation();
-}
-
 // 프로그램 설정 값 배치
 builder.Services.Configure<AppSettings>(builder.Configuration);
 

+ 2 - 1
Admin/Properties/launchSettings.json

@@ -7,7 +7,8 @@
         "ASPNETCORE_ENVIRONMENT": "Development"
       },
       "dotnetRunMessages": true,
-      "applicationUrl": "http://localhost:5033"
+      "applicationUrl": "http://localhost:5033",
+      "hotReloadEnabled": true
     },
     "https": {
       "commandName": "Project",

+ 63 - 7
Admin/appsettings.Development.json

@@ -1,9 +1,65 @@
 {
-  "DetailedErrors": true,
-  "Logging": {
-    "LogLevel": {
-      "Default": "Information",
-      "Microsoft.AspNetCore": "Warning"
+    "DetailedErrors": true,
+    "Logging": {
+        "LogLevel": {
+            "Default": "Information",
+            "Microsoft.AspNetCore": "Warning",
+            "Microsoft.EntityFrameWorkCore.Database.Command": "Warning"
+        }
+    },
+
+    "AllowedHosts": "*",
+
+    "ConnectionStrings": {
+        "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=AdminContext-87208056-7d2d-412a-b7f8-8e6c942ea8c8;Trusted_Connection=True;MultipleActiveResultSets=true",
+        "IdentityDbContextConnection": "Server=(localdb)\\mssqllocaldb;Database=Admin;Trusted_Connection=True;MultipleActiveResultSets=true"
+    },
+
+    "Kestrel": {
+        "Endpoints": {
+            "Http": {
+                "Url": "https://localhost:5000"
+            }
+        },
+        "Limits": {
+            "MaxRequestBodySize": 52428800
+        }
+    },
+
+    "App": {
+        "Name": "bitforum Admin",
+        "Company": "PLAYR",
+        "BaseURL": "https://localhost:5000",
+        "ApiURL": "https://localhost:4000",
+        "FrontURL": "https://localhost:3000"
+    },
+
+    "Redis": {
+        "DefaultConnection": "192.168.0.100:6379,password=bluescreen!!,defaultDatabase=4",
+        "CachePrefix": "Admin:Cache",
+        "AuthTicketPrefix": "Admin:Auth:Ticket",
+        "DataProtectionKey": "Admin:DataProtection-Keys",
+        "DefaultKeyLifetime": "90.00:00:00"
+    },
+
+    "SMTP": {
+        "Host": "mail.web.or.kr",
+        "Port": 587,
+        "User": "dev@web.or.kr",
+        "Password": "@@17125942KKh",
+        "UseStartTls": true,
+        "FromEmail": "support@bitforum.io",
+        "FromName": "bitforum"
+    },
+
+    "ForwardedHeaders": {
+        "ForwardLimit": 5,
+        "KnownProxies": [
+            "192.168.0.100"
+        ],
+        "KnownNetworks": [
+            "172.18.0.0/16",
+            "192.168.0.0/24"
+        ]
     }
-  }
-}
+}

BIN
Admin/wwwroot/editors/post/1/5/41b090e3d9f84c14876ef87a71ea7b17.jpg


+ 4 - 3
Admin/wwwroot/js/site.js

@@ -154,13 +154,14 @@ class ActionButtons {
         switch (action) {
             case "Restore":
                 handler = "?handler=Restore";
+                break;
             case "Delete":
                 handler = "?handler=Delete";
+                break;
             case "Update":
                 handler = "?handler=Update";
-            default:
-                handler = null;
-        };
+                break;
+        }
 
         if (handler) {
             this.form.action = handler;

BIN
Admin/wwwroot/uploads/member/thumb/5/40bdf1a22233404ea191b21ff4904948.jpg


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

@@ -32,4 +32,6 @@ public static class CacheKeys
 
     // Chat
     public const string ChatGlobalMessages = "chat:global:messages";
+    public const string ChatConnections = "chat:connections";  // Hash — 실시간 접속자
+    public const string ChatKickChannel = "chat:kick";         // Pub/Sub — 강제 퇴장
 }

+ 12 - 0
Application/Abstractions/Chat/ConnectedUser.cs

@@ -0,0 +1,12 @@
+namespace Application.Abstractions.Chat;
+
+public sealed record ConnectedUser(
+    string ConnectionId,
+    int? MemberID,
+    string? Email,
+    string? MemberName,
+    string IpAddress,
+    string UserAgent,
+    bool IsGuest,
+    DateTime ConnectedAt
+);

+ 9 - 0
Application/Abstractions/Chat/IChatConnectionTracker.cs

@@ -0,0 +1,9 @@
+namespace Application.Abstractions.Chat
+{
+    public interface IChatConnectionTracker
+    {
+        Task AddAsync(ConnectedUser user);
+        Task RemoveAsync(string connectionId);
+        Task<IReadOnlyList<ConnectedUser>> GetAllAsync();
+    }
+}

+ 4 - 1
Application/Abstractions/Chat/IChatHubClient.cs

@@ -3,6 +3,9 @@
 public interface IChatHubClient
 {
     Task ReceiveMessage(ChatMessage message);
-    Task ReceiveHistory(IReadOnlyList <ChatMessage> messages);
+    Task ReceiveHistory(IReadOnlyList<ChatMessage> messages);
     Task ReceiveSystemMessage(string message);
+    Task Connected(string message);
+    Task Logout(string message);
+    Task Kick();
 }

+ 1 - 0
Application/Abstractions/Chat/IChatHubService.cs

@@ -4,4 +4,5 @@ public interface IChatHubService
 {
     Task BroadcastMessageAsync(ChatMessage message, CancellationToken ct = default);
     Task BroadcastSystemMessageAsync(string message, CancellationToken ct = default);
+    Task KickUserAsync(int memberId, CancellationToken ct = default);
 }

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

@@ -49,11 +49,13 @@ namespace Application.Abstractions.Data
         DbSet<MemberNameChangeLog> MemberNameChangeLog { get; set; }
         DbSet<MemberSummaryChangeLog> MemberSummaryChangeLog { get; set; }
         DbSet<MemberIntroChangeLog> MemberIntroChangeLog { get; set; }
+        DbSet<MemberExpLog> MemberExpLog { get; set; }
         DbSet<Channel> Channel { get; set; }
         DbSet<RefreshToken> RefreshToken { get; set; }
 
         // 이메일 인증
         DbSet<EmailVerifyToken> EmailVerifyToken { get; set; }
+        DbSet<EmailVerifyNumber> EmailVerifyNumber { get; set; }
 
         // 지갑
         DbSet<Wallet> Wallet { get; set; }

+ 23 - 0
Application/Abstractions/Forum/IBoardPermissionService.cs

@@ -0,0 +1,23 @@
+using Domain.Entities.Forum.Boards;
+using Domain.Entities.Members;
+
+namespace Application.Abstractions.Forum;
+
+public interface IBoardPermissionService
+{
+    /// <summary>
+    /// 회원의 유효 권한 레벨 계산
+    /// - IsAdmin → 1000, BoardManager → 99, 그 외 → memberGrade.Order (기본 0)
+    /// </summary>
+    Task<short> GetMemberPermissionLevelAsync(Member member, int boardID, CancellationToken ct);
+
+    /// <summary>
+    /// 특정 권한 레벨에 대해 회원이 접근 가능한지 확인
+    /// </summary>
+    Task<bool> HasPermissionAsync(Member member, int boardID, short requiredPermission, CancellationToken ct);
+
+    /// <summary>
+    /// 게시판 매니저 정보 조회 (CanEdit/CanDelete 확인용)
+    /// </summary>
+    Task<BoardManager?> GetBoardManagerAsync(int boardID, int memberID, CancellationToken ct);
+}

+ 6 - 1
Application/Application.csproj

@@ -6,10 +6,15 @@
     <Nullable>enable</Nullable>
   </PropertyGroup>
 
+  <ItemGroup>
+    <Compile Remove="Features\ReferenceData\**" />
+    <EmbeddedResource Remove="Features\ReferenceData\**" />
+    <None Remove="Features\ReferenceData\**" />
+  </ItemGroup>
+
   <ItemGroup>
     <Folder Include="Authentication\" />
     <Folder Include="Behaviors\" />
-    <Folder Include="Features\ReferenceData\Dtos\" />
   </ItemGroup>
 
   <ItemGroup>

+ 34 - 0
Application/Common/UserAgentParser.cs

@@ -0,0 +1,34 @@
+namespace Application.Common;
+
+public static class UserAgentParser
+{
+    public static string ExtractBrowser(string ua)
+    {
+        if (ua.Contains("Edg/")) return "Edge";
+        if (ua.Contains("Chrome/")) return "Chrome";
+        if (ua.Contains("Firefox/")) return "Firefox";
+        if (ua.Contains("Safari/") && !ua.Contains("Chrome")) return "Safari";
+        if (ua.Contains("MSIE") || ua.Contains("Trident/")) return "IE";
+        return "Unknown";
+    }
+
+    public static string ExtractOS(string ua)
+    {
+        if (ua.Contains("Windows NT 10")) return "Windows 10";
+        if (ua.Contains("Windows NT 6.3")) return "Windows 8.1";
+        if (ua.Contains("Windows NT 6.1")) return "Windows 7";
+        if (ua.Contains("Windows")) return "Windows";
+        if (ua.Contains("Mac OS X")) return "macOS";
+        if (ua.Contains("Android")) return "Android";
+        if (ua.Contains("iPhone") || ua.Contains("iPad")) return "iOS";
+        if (ua.Contains("Linux")) return "Linux";
+        return "Unknown";
+    }
+
+    public static string ExtractDevice(string ua)
+    {
+        if (ua.Contains("Mobile") || ua.Contains("Android") && !ua.Contains("Tablet")) return "Mobile";
+        if (ua.Contains("Tablet") || ua.Contains("iPad")) return "Tablet";
+        return "Desktop";
+    }
+}

+ 8 - 0
Application/Features/Admin/Cache/ClearCache/Command.cs

@@ -0,0 +1,8 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Admin.Cache.ClearCache;
+
+public sealed record Command(
+    string? Prefix
+) : ICommand<Result>;

+ 22 - 0
Application/Features/Admin/Cache/ClearCache/Handler.cs

@@ -0,0 +1,22 @@
+using Application.Abstractions.Cache;
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Admin.Cache.ClearCache;
+
+internal sealed class Handler(ICacheService cache) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        if (string.IsNullOrEmpty(request.Prefix))
+        {
+            await cache.RemoveByPrefixAsync("", ct);
+        }
+        else
+        {
+            await cache.RemoveByPrefixAsync(request.Prefix, ct);
+        }
+
+        return Result.Success();
+    }
+}

+ 7 - 4
Application/Features/Admin/Forum/Comment/Delete/Handler.cs

@@ -15,24 +15,27 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
             .Where(c => request.IDs.Contains(c.ID))
             .ToListAsync(ct);
 
+        // 미삭제 댓글만 카운트 감소 (소프트 삭제된 댓글은 이미 카운트가 감소됨)
+        var activeComments = comments.Where(c => !c.IsDeleted).ToList();
+
         // Post 댓글 카운트 감소
-        var postIDs = comments.Select(c => c.PostID).Distinct().ToList();
+        var postIDs = activeComments.Select(c => c.PostID).Distinct().ToList();
         var posts = await db.Post.Where(c => postIDs.Contains(c.ID)).ToListAsync(ct);
 
         foreach (var post in posts)
         {
-            var count = comments.Count(c => c.PostID == post.ID);
+            var count = activeComments.Count(c => c.PostID == post.ID);
             post.Comments -= count;
             post.UpdatedAt = DateTime.UtcNow;
         }
 
         // Board 댓글 카운트 감소
-        var boardIDs = comments.Select(c => c.BoardID).Distinct().ToList();
+        var boardIDs = activeComments.Select(c => c.BoardID).Distinct().ToList();
         var boards = await db.Board.Where(c => boardIDs.Contains(c.ID)).ToListAsync(ct);
 
         foreach (var board in boards)
         {
-            var count = comments.Count(c => c.BoardID == board.ID);
+            var count = activeComments.Count(c => c.BoardID == board.ID);
             board.Comments -= count;
             board.UpdatedAt = DateTime.UtcNow;
         }

+ 14 - 12
Application/Features/Admin/Forum/Post/Create/Command.cs

@@ -1,15 +1,17 @@
 using Application.Abstractions.Messaging;
 using Microsoft.AspNetCore.Http;
 
-namespace Application.Features.Admin.Forum.Post.Create
-{
-    public sealed record Command(
-        int BoardID,
-        string Subject,
-        string? Content,
-        IFormFile? ThumbnailFile,
-        bool IsNotice,
-        bool IsSecret,
-        bool IsAnonymous
-    ) : ICommand;
-}
+namespace Application.Features.Admin.Forum.Post.Create;
+
+public sealed record Command(
+    int BoardID,
+    int? BoardPrefixID,
+    string Subject,
+    string? Content,
+    IFormFile? ThumbnailFile,
+    List<IFormFile>? Files,
+    List<string>? Tags,
+    bool IsNotice,
+    bool IsSecret,
+    bool IsAnonymous
+) : ICommand;

+ 88 - 6
Application/Features/Admin/Forum/Post/Create/Handler.cs

@@ -7,6 +7,9 @@ namespace Application.Features.Admin.Forum.Post.Create;
 
 public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler<Command>
 {
+    private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
+    private static readonly string[] AllowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".zip", ".rar", ".7z", ".hwp", ".hwpx", ".csv"];
+
     public async Task Handle(Command request, CancellationToken ct)
     {
         if (!await db.Board.AnyAsync(x => x.ID == request.BoardID, ct))
@@ -17,6 +20,7 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         var post = new Domain.Entities.Forum.Posts.Post
         {
             BoardID = request.BoardID,
+            BoardPrefixID = request.BoardPrefixID,
             MemberID = null,
             Subject = request.Subject,
             Content = request.Content ?? string.Empty,
@@ -29,16 +33,94 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         await db.Post.AddAsync(post, ct);
         await db.SaveChangesAsync(ct);
 
+        var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
+
+        // 썸네일 처리
         if (request.ThumbnailFile is not null)
         {
-            FileStoragePath uploadPath = new(UploadTarget.Upload, UploadFolder.Post, post.ID);
-            string[] allowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
-
-            var result = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, allowedFileExtensions, ct);
+            var result = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, AllowedImageExtensions, ct);
             post.Thumbnail = result?.Url;
-            await db.SaveChangesAsync(ct);
         }
 
+        // 파일 처리
+        if (request.Files is { Count: > 0 })
+        {
+            byte fileCount = 0;
+
+            foreach (var file in request.Files)
+            {
+                var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
+                if (result is not null)
+                {
+                    var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
+
+                    await db.PostFile.AddAsync(new Domain.Entities.Forum.Posts.PostFile
+                    {
+                        BoardID = request.BoardID,
+                        PostID = post.ID,
+                        UUID = Guid.NewGuid(),
+                        FileName = file.FileName,
+                        HashedName = result.FileName,
+                        Path = uploadPath.ToRelativePath(),
+                        Url = result.Url,
+                        Extension = ext,
+                        ContentType = file.ContentType,
+                        Size = result.Size
+                    }, ct);
+
+                    fileCount++;
+                }
+            }
+
+            post.Files = fileCount;
+        }
+
+        // 태그 처리
+        if (request.Tags is { Count: > 0 })
+        {
+            byte tagCount = 0;
+
+            foreach (var tagName in request.Tags)
+            {
+                if (string.IsNullOrWhiteSpace(tagName))
+                {
+                    continue;
+                }
+
+                var name = tagName.Trim();
+                var slug = name.ToLowerInvariant().Replace(' ', '-');
+
+                var tag = await db.Tag.FirstOrDefaultAsync(x => x.Name == name, ct);
+                if (tag is null)
+                {
+                    tag = new Domain.Entities.Forum.Posts.Tag
+                    {
+                        Name = name,
+                        Slug = slug
+                    };
+
+                    await db.Tag.AddAsync(tag, ct);
+                    await db.SaveChangesAsync(ct);
+                }
+
+                tag.UsageCount++;
+                tag.UpdatedAt = DateTime.UtcNow;
+
+                await db.PostTag.AddAsync(new Domain.Entities.Forum.Posts.PostTag
+                {
+                    BoardID = request.BoardID,
+                    PostID = post.ID,
+                    TagID = tag.ID
+                }, ct);
+
+                tagCount++;
+            }
+
+            post.Tags = tagCount;
+        }
+
+        await db.SaveChangesAsync(ct);
+
         // Board 게시글 카운트 증가
         var board = await db.Board.FirstOrDefaultAsync(x => x.ID == request.BoardID, ct);
         if (board is not null)
@@ -57,4 +139,4 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
             await db.SaveChangesAsync(ct);
         }
     }
-}
+}

+ 19 - 2
Application/Features/Admin/Forum/Post/Get/Handler.cs

@@ -8,7 +8,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
 {
     public async Task<Response> Handle(Query request, CancellationToken ct)
     {
-        var item = await db.Post.AsNoTracking().Include(c => c.Board).FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        var item = await db.Post.AsNoTracking().Include(c => c.Board).Include(c => c.BoardPrefix).Include(c => c.PostFile).Include(c => c.PostImage).Include(c => c.PostTag).ThenInclude(c => c.Tag).FirstOrDefaultAsync(x => x.ID == request.ID, ct);
         if (item is null)
         {
             throw new KeyNotFoundException("게시글을 찾을 수 없습니다.");
@@ -18,6 +18,9 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             item.ID,
             item.BoardID,
             item.Board.Name,
+            item.BoardPrefixID,
+            item.BoardPrefix?.Name,
+            item.BoardPrefix?.Color,
             item.Subject,
             item.Content,
             item.Thumbnail,
@@ -26,13 +29,27 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             item.IsNotice,
             item.IsSecret,
             item.IsAnonymous,
+            item.IsSpeaker,
             item.IsDeleted,
             item.Views,
             item.Likes,
             item.Dislikes,
             item.Comments,
+            item.Images,
+            item.Medias,
+            item.Files,
+            item.Tags,
+            [..item.PostFile.OrderByDescending(f => f.ID).Select(f => new Response.FileItem(
+                f.ID, f.FileName, f.Url, f.Extension, f.Size, f.Downloads, f.IsDisabled, f.CreatedAt
+            ))],
+            [..item.PostImage.OrderByDescending(f => f.ID).Select(f => new Response.ImageItem(
+                f.ID, f.FileName, f.Url, f.Extension, f.Size, f.IsDisabled
+            ))],
+            [..item.PostTag.Select(t => new Response.TagItem(
+                t.TagID, t.Tag.Name, t.Tag.Slug
+            ))],
             item.UpdatedAt,
             item.CreatedAt
         );
     }
-}
+}

+ 17 - 1
Application/Features/Admin/Forum/Post/Get/Response.cs

@@ -4,6 +4,9 @@ public sealed record Response(
     int ID,
     int BoardID,
     string BoardName,
+    int? BoardPrefixID,
+    string? BoardPrefixName,
+    string? BoardPrefixColor,
     string Subject,
     string Content,
     string? Thumbnail,
@@ -12,11 +15,24 @@ public sealed record Response(
     bool IsNotice,
     bool IsSecret,
     bool IsAnonymous,
+    bool IsSpeaker,
     bool IsDeleted,
     int Views,
     int Likes,
     int Dislikes,
     int Comments,
+    byte ImageCount,
+    byte MediaCount,
+    byte FileCount,
+    byte TagCount,
+    List<Response.FileItem> Files,
+    List<Response.ImageItem> Images,
+    List<Response.TagItem> Tags,
     DateTime? UpdatedAt,
     DateTime CreatedAt
-);
+)
+{
+    public sealed record FileItem(int ID, string FileName, string Url, string? Extension, long? Size, int Downloads, bool IsDisabled, DateTime CreatedAt);
+    public sealed record ImageItem(int ID, string FileName, string Url, string? Extension, long? Size, bool IsDisabled);
+    public sealed record TagItem(int TagID, string Name, string Slug);
+}

+ 17 - 0
Application/Features/Admin/Forum/Post/Search/Handler.cs

@@ -1,5 +1,6 @@
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
+using Domain.Entities.Forum.ValueObject;
 using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Admin.Forum.Post.Search;
@@ -15,6 +16,11 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             query = query.Where(c => c.BoardID == request.BoardID.Value);
         }
 
+        if (request.BoardPrefixID.HasValue)
+        {
+            query = query.Where(c => c.BoardPrefixID == request.BoardPrefixID.Value);
+        }
+
         if (!string.IsNullOrWhiteSpace(request.Keyword))
         {
             var kw = request.Keyword.Trim();
@@ -57,6 +63,11 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             query = query.Where(c => c.IsDeleted);
         }
 
+        if (request.IsQnA == true)
+        {
+            query = query.Where(c => c.Board.BoardMeta.List.Layout == BoardLayout.QnA);
+        }
+
         query = request.Sort switch
         {
             1 => query.OrderByDescending(c => c.Views),
@@ -75,6 +86,8 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
                 c.ID,
                 c.BoardID,
                 BoardName = c.Board.Name,
+                BoardPrefixName = c.BoardPrefix != null ? c.BoardPrefix.Name : null,
+                c.BoardPrefixID,
                 c.Subject,
                 c.Thumbnail,
                 c.Name,
@@ -85,6 +98,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
                 c.IsSpeaker,
                 c.IsAnonymous,
                 c.IsDeleted,
+                IsQnA = c.Board.BoardMeta.List.Layout == BoardLayout.QnA,
                 c.Views,
                 c.Likes,
                 c.Dislikes,
@@ -107,6 +121,8 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
                 c.ID,
                 c.BoardID,
                 c.BoardName,
+                c.BoardPrefixName,
+                c.BoardPrefixID,
                 c.Subject,
                 c.Thumbnail,
                 c.Name,
@@ -117,6 +133,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
                 c.IsSpeaker,
                 c.IsAnonymous,
                 c.IsDeleted,
+                c.IsQnA,
                 c.Views,
                 c.Likes,
                 c.Dislikes,

+ 2 - 0
Application/Features/Admin/Forum/Post/Search/Query.cs

@@ -4,6 +4,7 @@ namespace Application.Features.Admin.Forum.Post.Search;
 
 public sealed record Query(
     int? BoardID,
+    int? BoardPrefixID,
     int? Search,
     string? Keyword,
     string? StartAt,
@@ -13,6 +14,7 @@ public sealed record Query(
     bool? IsSecret,
     bool? IsReply,
     bool? IsDeleted,
+    bool? IsQnA,
     int PageNum,
     ushort PerPage
 ) : IQuery<Response>;

+ 3 - 0
Application/Features/Admin/Forum/Post/Search/Response.cs

@@ -7,6 +7,8 @@ public sealed record Response(int Total, List<Response.Row> List)
         int ID,
         int BoardID,
         string BoardName,
+        string? BoardPrefixName,
+        int? BoardPrefixID,
         string Subject,
         string? Thumbnail,
         string? Name,
@@ -17,6 +19,7 @@ public sealed record Response(int Total, List<Response.Row> List)
         bool IsSpeaker,
         bool IsAnonymous,
         bool IsDeleted,
+        bool IsQnA,
         int Views,
         int Likes,
         int Dislikes,

+ 14 - 12
Application/Features/Admin/Forum/Post/Update/Command.cs

@@ -1,15 +1,17 @@
 using Application.Abstractions.Messaging;
 using Microsoft.AspNetCore.Http;
 
-namespace Application.Features.Admin.Forum.Post.Update
-{
-    public sealed record Command(
-        int ID,
-        string Subject,
-        string? Content,
-        IFormFile? ThumbnailFile,
-        bool IsNotice,
-        bool IsSecret,
-        bool IsAnonymous
-    ) : ICommand;
-}
+namespace Application.Features.Admin.Forum.Post.Update;
+
+public sealed record Command(
+    int ID,
+    int? BoardPrefixID,
+    string Subject,
+    string? Content,
+    IFormFile? ThumbnailFile,
+    List<IFormFile>? Files,
+    List<string>? Tags,
+    bool IsNotice,
+    bool IsSecret,
+    bool IsAnonymous
+) : ICommand;

+ 99 - 4
Application/Features/Admin/Forum/Post/Update/Handler.cs

@@ -7,6 +7,9 @@ namespace Application.Features.Admin.Forum.Post.Update;
 
 public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler<Command>
 {
+    private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
+    private static readonly string[] AllowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".zip", ".rar", ".7z", ".hwp", ".hwpx", ".csv"];
+
     public async Task Handle(Command request, CancellationToken ct)
     {
         var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
@@ -15,6 +18,7 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
             throw new KeyNotFoundException("게시글을 찾을 수 없습니다.");
         }
 
+        post.BoardPrefixID = request.BoardPrefixID;
         post.Subject = request.Subject;
         post.Content = request.Content ?? string.Empty;
         post.IsNotice = request.IsNotice;
@@ -22,20 +26,111 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         post.IsAnonymous = request.IsAnonymous;
         post.UpdatedAt = DateTime.UtcNow;
 
+        // 썸네일 처리
         if (request.ThumbnailFile is not null)
         {
-            FileStoragePath uploadPath = new(UploadTarget.Upload, UploadFolder.Post, post.ID);
-            string[] allowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
+            var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
 
             if (!string.IsNullOrEmpty(post.Thumbnail))
             {
                 fileStorage.DeleteByUrl(post.Thumbnail);
             }
 
-            var result = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, allowedFileExtensions, ct);
+            var result = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, AllowedImageExtensions, ct);
             post.Thumbnail = result?.Url;
         }
 
+        // 파일 처리 (새 파일 추가)
+        if (request.Files is { Count: > 0 })
+        {
+            var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
+
+            foreach (var file in request.Files)
+            {
+                var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
+                if (result is not null)
+                {
+                    var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
+
+                    await db.PostFile.AddAsync(new Domain.Entities.Forum.Posts.PostFile
+                    {
+                        BoardID = post.BoardID,
+                        PostID = post.ID,
+                        UUID = Guid.NewGuid(),
+                        FileName = file.FileName,
+                        HashedName = result.FileName,
+                        Path = uploadPath.ToRelativePath(),
+                        Url = result.Url,
+                        Extension = ext,
+                        ContentType = file.ContentType,
+                        Size = result.Size
+                    }, ct);
+                }
+            }
+
+            var fileCount = await db.PostFile.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
+            post.Files = (byte)Math.Min(fileCount + request.Files.Count, byte.MaxValue);
+        }
+
+        // 태그 처리 (diff 기반)
+        if (request.Tags is not null)
+        {
+            var existingPostTags = await db.PostTag.Include(x => x.Tag).Where(x => x.PostID == post.ID).ToListAsync(ct);
+            var newTagSlugs = request.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim().ToLowerInvariant().Replace(' ', '-')).ToHashSet();
+            var existingSlugs = existingPostTags.Select(x => x.Tag.Slug).ToHashSet();
+
+            // 삭제할 태그: 기존에 있지만 새 목록에 없는 것
+            var toRemove = existingPostTags.Where(x => !newTagSlugs.Contains(x.Tag.Slug)).ToList();
+
+            foreach (var postTag in toRemove)
+            {
+                db.PostTag.Remove(postTag);
+
+                if (postTag.Tag.UsageCount > 0)
+                {
+                    postTag.Tag.UsageCount--;
+                    postTag.Tag.UpdatedAt = DateTime.UtcNow;
+                }
+            }
+
+            // 추가할 태그: 새 목록에 있지만 기존에 없는 것
+            var toAdd = request.Tags
+                .Where(t => !string.IsNullOrWhiteSpace(t))
+                .Select(t => t.Trim())
+                .Where(t => !existingSlugs.Contains(t.ToLowerInvariant().Replace(' ', '-')))
+                .ToList();
+
+            foreach (var tagName in toAdd)
+            {
+                var slug = tagName.ToLowerInvariant().Replace(' ', '-');
+
+                var tag = await db.Tag.FirstOrDefaultAsync(x => x.Slug == slug, ct);
+                if (tag is null)
+                {
+                    tag = new Domain.Entities.Forum.Posts.Tag
+                    {
+                        Name = tagName,
+                        Slug = slug
+                    };
+
+                    await db.Tag.AddAsync(tag, ct);
+                    await db.SaveChangesAsync(ct);
+                }
+
+                tag.UsageCount++;
+                tag.UpdatedAt = DateTime.UtcNow;
+
+                await db.PostTag.AddAsync(new Domain.Entities.Forum.Posts.PostTag
+                {
+                    BoardID = post.BoardID,
+                    PostID = post.ID,
+                    TagID = tag.ID
+                }, ct);
+            }
+
+            post.Tags = (byte)Math.Min(newTagSlugs.Count, byte.MaxValue);
+        }
+
         await db.SaveChangesAsync(ct);
     }
-}
+}

+ 1 - 20
Application/Features/Admin/Forum/Trash/Comment/PermanentDelete/Handler.cs

@@ -15,26 +15,7 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
 
         var comments = await db.Comment.Where(c => request.IDs.Contains(c.ID) && c.IsDeleted).ToListAsync(ct);
 
-        var postIDs = comments.Select(c => c.PostID).Distinct().ToList();
-        var posts = await db.Post.Where(c => postIDs.Contains(c.ID)).ToListAsync(ct);
-
-        foreach (var post in posts)
-        {
-            var count = comments.Count(c => c.PostID == post.ID);
-            post.Comments -= count;
-            post.UpdatedAt = DateTime.UtcNow;
-        }
-
-        var boardIDs = comments.Select(c => c.BoardID).Distinct().ToList();
-        var boards = await db.Board.Where(c => boardIDs.Contains(c.ID)).ToListAsync(ct);
-
-        foreach (var board in boards)
-        {
-            var count = comments.Count(c => c.BoardID == board.ID);
-            board.Comments -= count;
-            board.UpdatedAt = DateTime.UtcNow;
-        }
-
+        // 소프트 삭제 시 이미 카운트가 감소되었으므로 영구 삭제에서는 카운트를 건드리지 않음
         db.Comment.RemoveRange(comments);
 
         await db.SaveChangesAsync(ct);

+ 29 - 0
Application/Features/Admin/Forum/Trash/Comment/Restore/Handler.cs

@@ -22,6 +22,35 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
             comment.UpdatedAt = DateTime.UtcNow;
         }
 
+        // Post 댓글 카운트 복원
+        var postCommentCounts = comments.GroupBy(c => c.PostID).ToDictionary(g => g.Key, g => g.Count());
+        var posts = await db.Post.Where(p => postCommentCounts.Keys.Contains(p.ID)).ToListAsync(ct);
+
+        foreach (var post in posts)
+        {
+            post.Comments += postCommentCounts[post.ID];
+            post.UpdatedAt = DateTime.UtcNow;
+        }
+
+        // Board 댓글 카운트 복원
+        var boardCommentCounts = comments.GroupBy(c => c.BoardID).ToDictionary(g => g.Key, g => g.Count());
+        var boards = await db.Board.Where(b => boardCommentCounts.Keys.Contains(b.ID)).ToListAsync(ct);
+
+        foreach (var board in boards)
+        {
+            board.Comments += boardCommentCounts[board.ID];
+            board.UpdatedAt = DateTime.UtcNow;
+        }
+
+        // MemberStats 댓글 수 복원
+        var memberCommentCounts = comments.GroupBy(c => c.MemberID).ToDictionary(g => g.Key, g => g.Count());
+        var memberStats = await db.MemberStats.Where(x => memberCommentCounts.Keys.Contains(x.MemberID)).ToListAsync(ct);
+
+        foreach (var stats in memberStats)
+        {
+            stats.CommentCount += memberCommentCounts[stats.MemberID];
+        }
+
         await db.SaveChangesAsync(ct);
     }
 }

+ 16 - 13
Application/Features/Admin/Forum/Trash/Post/PermanentDelete/Handler.cs

@@ -1,7 +1,7 @@
 using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
-using SharedKernel.Storage;
 using Microsoft.EntityFrameworkCore;
+using SharedKernel.Storage;
 
 namespace Application.Features.Admin.Forum.Trash.Post.PermanentDelete;
 
@@ -24,24 +24,27 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
             }
         }
 
-        var boardIDs = posts.Select(c => c.BoardID).Distinct().ToList();
-        var boards = await db.Board.Where(c => boardIDs.Contains(c.ID)).ToListAsync(ct);
-        var groupIDs = boards.Select(c => c.BoardGroupID).Distinct().ToList();
-        var groups = await db.BoardGroup.Where(c => groupIDs.Contains(c.ID)).ToListAsync(ct);
-
+        var boardPostCounts = posts.GroupBy(p => p.BoardID).ToDictionary(g => g.Key, g => g.Count());
+        var boards = await db.Board.Where(b => boardPostCounts.Keys.Contains(b.ID)).ToListAsync(ct);
         foreach (var board in boards)
         {
-            var count = posts.Count(c => c.BoardID == board.ID);
-            board.Posts -= count;
+            board.Posts -= boardPostCounts[board.ID];
             board.UpdatedAt = DateTime.UtcNow;
         }
 
-        foreach (var group in groups)
+        var boardGroupIDs = boards.Select(c => c.BoardGroupID).Distinct().ToList();
+        var boardGroups = await db.BoardGroup.Where(g => boardGroupIDs.Contains(g.ID)).ToListAsync(ct);
+
+        var boardToGroup = boards.ToDictionary(b => b.ID, b => b.BoardGroupID);
+        var groupPostCounts = posts.GroupBy(p => boardToGroup[p.BoardID]).ToDictionary(g => g.Key, g => g.Count());
+
+        foreach (var group in boardGroups)
         {
-            var relatedBoards = boards.Where(b => b.BoardGroupID == group.ID).Select(b => b.ID).ToList();
-            var count = posts.Count(c => relatedBoards.Contains(c.BoardID));
-            group.Posts -= count;
-            group.UpdatedAt = DateTime.UtcNow;
+            if (groupPostCounts.TryGetValue(group.ID, out var count))
+            {
+                group.Posts -= count;
+                group.UpdatedAt = DateTime.UtcNow;
+            }
         }
 
         db.Post.RemoveRange(posts);

+ 18 - 10
Application/Features/Admin/Member/List/Search/Handler.cs

@@ -1,5 +1,5 @@
-using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Admin.Member.List.Search;
@@ -10,15 +10,6 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
     {
         var query = db.Member.AsNoTracking().Include(x => x.MemberGrade).Include(x => x.MemberStats).AsQueryable();
 
-        // 탭 필터
-        query = request.Tab switch
-        {
-            1 => query.Where(x => x.IsDenied),
-            2 => query.Where(x => x.IsWithdraw),
-            3 => query.Where(x => x.IsAdmin),
-            _ => query
-        };
-
         // 키워드 검색
         if (!string.IsNullOrWhiteSpace(request.Keyword))
         {
@@ -62,6 +53,22 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             query = query.Where(x => x.IsAuthCertified);
         }
 
+        var counts = await query.GroupBy(c => 1).Select(c => new Response.TabCounts
+        {
+            All = c.Count(),
+            Denied = c.Count(x => x.IsDenied),
+            Withdraw = c.Count(x => x.IsWithdraw),
+            Admin = c.Count(x => x.IsAdmin)
+        }).FirstOrDefaultAsync(ct) ?? new Response.TabCounts();
+
+        query = request.Tab switch
+        {
+            1 => query.Where(x => x.IsDenied),
+            2 => query.Where(x => x.IsWithdraw),
+            3 => query.Where(x => x.IsAdmin),
+            _ => query
+        };
+
         var total = await query.CountAsync(ct);
         var skip = (request.PageNum - 1) * request.PerPage;
 
@@ -125,6 +132,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
         return new Response
         {
             Total = total,
+            Counts = counts,
             List = rows
         };
     }

+ 9 - 0
Application/Features/Admin/Member/List/Search/Response.cs

@@ -3,6 +3,15 @@ namespace Application.Features.Admin.Member.List.Search;
 public sealed class Response
 {
     public int Total { get; init; }
+    public required TabCounts Counts { get; init; }
+
+    public sealed class TabCounts
+    {
+        public int All { get; init; }
+        public int Denied { get; init; }
+        public int Withdraw { get; init; }
+        public int Admin { get; init; }
+    }
 
     public required IReadOnlyList<Row> List { get; init; }
 

+ 8 - 0
Application/Features/Api/Auth/ForgotPassword/Command.cs

@@ -0,0 +1,8 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.ForgotPassword;
+
+public sealed record Command(
+    string Email
+) : ICommand<Result>;

+ 71 - 0
Application/Features/Api/Auth/ForgotPassword/Handler.cs

@@ -0,0 +1,71 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Messaging.Email;
+using Domain.Entities.EmailVerification;
+using Domain.Entities.EmailVerification.ValueObject;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Auth.ForgotPassword;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    IMailService mailService
+) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        // 이메일 유효성 검사
+        if (string.IsNullOrWhiteSpace(request.Email))
+        {
+            return Result.Failure(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
+        }
+
+        var email = request.Email.Trim().ToLower();
+
+        // 회원 조회
+        var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
+        if (member is null)
+        {
+            return Result.Failure(Error.NotFound("Auth.MemberNotFound", "등록된 회원 정보를 찾을 수 없습니다."));
+        }
+
+        // 탈퇴 회원 거부
+        if (member.IsWithdraw)
+        {
+            return Result.Failure(Error.Problem("Auth.MemberWithdrawn", "탈퇴한 회원은 이용할 수 없습니다."));
+        }
+
+        // 차단 회원 거부
+        if (member.IsDenied)
+        {
+            return Result.Failure(Error.Problem("Auth.MemberDenied", "차단된 회원이므로 이용할 수 없습니다."));
+        }
+
+        // 기존 미인증 코드 삭제
+        var existing = await db.EmailVerifyNumber.Where(e => e.Email == email && e.Type == VerificationType.ForgotPassword && !e.IsVerified).ToListAsync(ct);
+
+        db.EmailVerifyNumber.RemoveRange(existing);
+
+        // 6자리 랜덤 숫자 코드 생성
+        var code = Random.Shared.Next(100000, 999999).ToString();
+        var verifyNumber = EmailVerifyNumber.Create(
+            VerificationType.ForgotPassword,
+            email,
+            code,
+            DateTime.UtcNow.AddMinutes(10)
+        );
+
+        await db.EmailVerifyNumber.AddAsync(verifyNumber, ct);
+        await db.SaveChangesAsync(ct);
+
+        // 인증번호 이메일 발송
+        await mailService.SendAsync(new SendData(
+            email,
+            "[bitforum] 비밀번호 재설정 인증번호",
+            $"<p>비밀번호 재설정 인증번호입니다.</p><p><strong>{code}</strong></p><p>이 코드는 10분간 유효합니다.</p>"
+        ), ct);
+
+        return Result.Success();
+    }
+}

+ 38 - 3
Application/Features/Api/Auth/GetProfile/Handler.cs

@@ -12,18 +12,53 @@ internal sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Result<
         var member = await db.Member
             .AsNoTracking()
             .Include(m => m.MemberGrade)
+            .Include(m => m.MemberApprove)
+            .Include(m => m.MemberStats)
             .Where(m => m.ID == request.MemberID)
             .Select(m => new Response(
                 m.ID,
                 m.SID,
                 m.Email,
                 m.Name,
-                m.Thumb,
+                m.Intro,
                 m.Summary,
-                m.MemberGrade != null ? m.MemberGrade.KorName : null,
+                m.Thumb,
+                m.Icon,
+                m.Gender != null ? (int?)m.Gender : null,
                 m.IsEmailVerified,
+                m.IsAuthCertified,
+                m.IsAdmin,
                 m.IsCreator,
-                m.CreatedAt))
+                m.IsDenied,
+                m.LastLoginAt,
+                m.PasswordUpdatedAt,
+                m.CreatedAt,
+                m.UpdatedAt,
+                m.MemberGrade != null ? new GradeDto(
+                    m.MemberGrade.ID,
+                    m.MemberGrade.KorName,
+                    m.MemberGrade.EngName,
+                    m.MemberGrade.Order,
+                    m.MemberGrade.Image
+                ) : null,
+                m.MemberApprove != null ? new ApproveDto(
+                    m.MemberApprove.IsReceiveSMS,
+                    m.MemberApprove.IsReceiveEmail,
+                    m.MemberApprove.IsReceiveNote,
+                    m.MemberApprove.IsDisclosureInvest
+                ) : new ApproveDto(false, false, false, false),
+                m.MemberStats != null ? new StatsDto(
+                    m.MemberStats.Exp,
+                    m.MemberStats.PostCount,
+                    m.MemberStats.CommentCount,
+                    m.MemberStats.LikeReceivedCount,
+                    m.MemberStats.LikeGivenCount,
+                    m.MemberStats.BookmarkGivenCount,
+                    m.MemberStats.LoginCount,
+                    m.MemberStats.AttendanceCount,
+                    m.MemberStats.FollowingCount,
+                    m.MemberStats.FollowerCount
+                ) : new StatsDto(0, 0, 0, 0, 0, 0, 0, 0, 0, 0)))
             .FirstOrDefaultAsync(ct);
 
         if (member is null)

+ 43 - 4
Application/Features/Api/Auth/GetProfile/Response.cs

@@ -5,10 +5,49 @@ public sealed record Response(
     string Sid,
     string Email,
     string? Name,
-    string? Thumb,
+    string? Intro,
     string? Summary,
-    string? GradeName,
+    string? Thumb,
+    string? Icon,
+    int? Gender,
     bool IsEmailVerified,
+    bool IsAuthCertified,
+    bool IsAdmin,
     bool IsCreator,
-    DateTime CreatedAt
-);
+    bool IsDenied,
+    DateTime? LastLoginAt,
+    DateTime? PasswordUpdatedAt,
+    DateTime CreatedAt,
+    DateTime? UpdatedAt,
+    GradeDto? MemberGrade,
+    ApproveDto MemberApprove,
+    StatsDto MemberStats
+);
+
+public sealed record GradeDto(
+    int ID,
+    string KorName,
+    string EngName,
+    short Order,
+    string? Image
+);
+
+public sealed record ApproveDto(
+    bool IsReceiveSMS,
+    bool IsReceiveEmail,
+    bool IsReceiveNote,
+    bool IsDisclosureInvest
+);
+
+public sealed record StatsDto(
+    long Exp,
+    long PostCount,
+    long CommentCount,
+    long LikeReceivedCount,
+    long LikeGivenCount,
+    long BookmarkGivenCount,
+    long LoginCount,
+    long AttendanceCount,
+    long FollowingCount,
+    long FollowerCount
+);

+ 4 - 2
Application/Features/Api/Auth/Login/Command.cs

@@ -5,5 +5,7 @@ namespace Application.Features.Api.Auth.Login;
 
 public sealed record Command(
     string Email,
-    string Password
-) : IRequest<Result<Response>>;
+    string Password,
+    string? IpAddress = null,
+    string? UserAgent = null
+) : IRequest<Result<Response>>;

+ 67 - 5
Application/Features/Api/Auth/Login/Handler.cs

@@ -1,11 +1,14 @@
-using SharedKernel;
-using SharedKernel.Results;
 using Application.Abstractions.Authentication;
+using Application.Abstractions.Cache;
 using Application.Abstractions.Data;
+using Application.Helpers;
+using Domain.Entities.Members.Logs;
+using MediatR;
+using SharedKernel;
+using SharedKernel.Results;
 using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Options;
 using Microsoft.Extensions.Logging;
-using MediatR;
+using Microsoft.Extensions.Options;
 
 namespace Application.Features.Api.Auth.Login;
 
@@ -13,7 +16,8 @@ internal sealed class Handler(
     IAppDbContext db,
     IJwtTokenProvider jwtTokenProvider,
     IOptions<AppSettings> options,
-    ILogger<Handler> logger
+    ILogger<Handler> logger,
+    ICacheService cache
 ) : IRequestHandler<Command, Result<Response>> {
 
     private readonly AppSettings.JwtSection _jwt = options.Value.JWT;
@@ -31,21 +35,61 @@ internal sealed class Handler(
             return Result.Failure<Response>(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다."));
         }
 
+        // Config 로드
+        var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct);
+
         // Member 조회 (비밀번호 검증을 위해 Tracking 모드)
         var email = request.Email.Trim().ToLower();
         var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
 
         if (member is null)
         {
+            // 실패 로그 기록 (회원 없음)
+            await LogLoginAttempt(null, false, email, "회원 정보 없음", request, ct);
             return Result.Failure<Response>(Error.Unauthorized("Auth.InvalidCredentials", "이메일 또는 비밀번호가 올바르지 않습니다."));
         }
 
+        // 탈퇴 회원 거부
+        if (member.IsWithdraw)
+        {
+            await LogLoginAttempt(member.ID, false, email, "탈퇴 회원", request, ct);
+            return Result.Failure<Response>(Error.Problem("Auth.MemberWithdrawn", "탈퇴한 회원은 이용할 수 없습니다."));
+        }
+
+        // 차단 회원 거부
+        if (member.IsDenied)
+        {
+            await LogLoginAttempt(member.ID, false, email, "차단 회원", request, ct);
+            return Result.Failure<Response>(Error.Problem("Auth.MemberDenied", "차단된 회원이므로 이용할 수 없습니다."));
+        }
+
+        // 로그인 시도 횟수 제한 확인
+        if (accountConfig.MaxLoginTryCount is > 0 && accountConfig.MaxLoginTryLimitSecond is > 0)
+        {
+            var limitTime = DateTime.UtcNow.AddSeconds(-accountConfig.MaxLoginTryLimitSecond.Value);
+            var failedCount = await db.MemberLoginLog.CountAsync(l => l.MemberID == member.ID && !l.Success && l.CreatedAt >= limitTime, ct);
+
+            if (failedCount >= accountConfig.MaxLoginTryCount.Value)
+            {
+                await LogLoginAttempt(member.ID, false, email, "로그인 시도 횟수 초과", request, ct);
+                return Result.Failure<Response>(Error.Problem("Auth.LoginTryExceeded", $"로그인 시도 횟수를 초과하였습니다. {accountConfig.MaxLoginTryLimitSecond}초 후에 다시 시도해주세요."));
+            }
+        }
+
         // 비밀번호 검증
         if (!member.VerifyPassword(request.Password))
         {
+            await LogLoginAttempt(member.ID, false, email, "비밀번호 불일치", request, ct);
             return Result.Failure<Response>(Error.Unauthorized("Auth.InvalidCredentials", "이메일 또는 비밀번호가 올바르지 않습니다."));
         }
 
+        // 로그인 시 이메일 인증자 여부 확인
+        if (accountConfig.IsLoginEmailVerifiedOnly && !member.IsEmailVerified)
+        {
+            await LogLoginAttempt(member.ID, false, email, "이메일 미인증", request, ct);
+            return Result.Failure<Response>(Error.Unauthorized("Auth.EmailNotVerified", "이메일 인증이 완료되지 않은 사용자는 로그인할 수 없습니다."));
+        }
+
         // JWT 토큰 생성
         var accessToken = jwtTokenProvider.CreateAccessToken(member.ID, member.Email, member.Name);
         var refreshToken = jwtTokenProvider.CreateRefreshToken();
@@ -67,10 +111,28 @@ internal sealed class Handler(
             memberStats.LoginCount++;
         }
 
+        // 성공 로그 기록
+        await LogLoginAttempt(member.ID, true, email, null, request, ct);
+
         await db.SaveChangesAsync(ct);
 
         logger.LogInformation("{0} 로그인", member.Email);
 
         return Result.Success(new Response(accessToken, refreshToken, expiresAt));
     }
+
+    private async Task LogLoginAttempt(int? memberID, bool success, string account, string? reason, Command request, CancellationToken ct)
+    {
+        var log = MemberLoginLog.Create(
+            memberID,
+            success,
+            account,
+            reason,
+            ipAddress: request.IpAddress,
+            userAgent: request.UserAgent
+        );
+
+        await db.MemberLoginLog.AddAsync(log, ct);
+        await db.SaveChangesAsync(ct);
+    }
 }

+ 1 - 1
Application/Features/Api/Auth/Logout/Command.cs

@@ -5,5 +5,5 @@ namespace Application.Features.Api.Auth.Logout;
 
 public sealed record Command(
     int MemberID,
-    string RefreshToken
+    string? RefreshToken
 ) : ICommand<Result>;

+ 17 - 15
Application/Features/Api/Auth/Logout/Handler.cs

@@ -1,8 +1,8 @@
 using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
+using SharedKernel.Results;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Logging;
-using SharedKernel.Results;
 
 namespace Application.Features.Api.Auth.Logout;
 
@@ -10,25 +10,27 @@ internal sealed class Handler(IAppDbContext db, ILogger<Handler> logger) : IComm
 {
     public async Task<Result> Handle(Command request, CancellationToken ct)
     {
-        if (string.IsNullOrWhiteSpace(request.RefreshToken))
+        // 개별 로그아웃
+        if (!string.IsNullOrWhiteSpace(request.RefreshToken))
         {
-            return Result.Failure(Error.Problem("Auth.TokenRequired", "리프레시 토큰은 필수입니다."));
-        }
-
-        var refreshToken = await db.RefreshToken.FirstOrDefaultAsync(t => t.Token == request.RefreshToken && t.MemberID == request.MemberID, ct);
-
-        if (refreshToken is null)
+            var refreshToken = await db.RefreshToken.FirstOrDefaultAsync(t => t.Token == request.RefreshToken && t.MemberID == request.MemberID, ct);
+
+            if (refreshToken is not null && !refreshToken.IsRevoked)
+            {
+                refreshToken.Revoke();
+            }
+        } 
+        else
         {
-            return Result.Failure(Error.NotFound("Auth.TokenNotFound", "유효하지 않은 리프레시 토큰입니다."));
-        }
+            // 전체 로그아웃
+            var activeTokens = await db.RefreshToken.Where(t => t.MemberID == request.MemberID && !t.IsRevoked).ToListAsync(ct);
 
-        if (refreshToken.IsRevoked)
-        {
-            return Result.Success();
+            foreach (var token in activeTokens)
+            {
+                token.Revoke();
+            }
         }
 
-        refreshToken.Revoke();
-
         await db.SaveChangesAsync(ct);
 
         logger.LogInformation("로그아웃 완료");

+ 4 - 5
Application/Features/Api/Auth/RefreshToken/Handler.cs

@@ -5,7 +5,6 @@ using Application.Abstractions.Data;
 using MediatR;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Options;
-using RefreshTokenEntity = Domain.Entities.Members.RefreshToken;
 
 namespace Application.Features.Api.Auth.RefreshToken;
 
@@ -21,7 +20,7 @@ internal sealed class Handler(
     {
         if (string.IsNullOrWhiteSpace(request.RefreshToken))
         {
-            return Result.Failure<Response>(Error.Problem("Auth.TokenRequired", "리프레시 토큰은 필수입니다."));
+            return Result.Failure<Response>(Error.Problem("Auth.TokenRequired", "refreshToken은 필수입니다."));
         }
 
         // 기존 RefreshToken 조회
@@ -29,12 +28,12 @@ internal sealed class Handler(
 
         if (existingToken is null)
         {
-            return Result.Failure<Response>(Error.NotFound("Auth.TokenNotFound", "유효하지 않은 리프레시 토큰입니다."));
+            return Result.Failure<Response>(Error.NotFound("Auth.TokenNotFound", "유효하지 않은 refreshToken 입니다."));
         }
 
         if (!existingToken.IsActive)
         {
-            return Result.Failure<Response>(Error.Problem("Auth.TokenExpired", "만료되었거나 취소된 리프레시 토큰입니다."));
+            return Result.Failure<Response>(Error.Problem("Auth.TokenExpired", "만료되었거나 취소된 refreshToken 입니다."));
         }
 
         if (existingToken.Member is null)
@@ -53,7 +52,7 @@ internal sealed class Handler(
         var expiresAt = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration);
 
         // 새 RefreshToken 저장
-        var refreshTokenEntity = RefreshTokenEntity.Create(
+        var refreshTokenEntity = Domain.Entities.Members.RefreshToken.Create(
             member.ID,
             newRefreshToken,
             DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiration)

+ 6 - 1
Application/Features/Api/Auth/Register/Command.cs

@@ -7,4 +7,9 @@ public sealed record Command(
     string Email,
     string Password,
     string? Name
-) : IRequest<Result<int>>;
+) : IRequest<Result<RegisterResponse>>;
+
+public sealed record RegisterResponse(
+    int Id,
+    bool IsRegisterEmailAuth
+);

+ 92 - 10
Application/Features/Api/Auth/Register/Handler.cs

@@ -1,30 +1,48 @@
 using SharedKernel.Results;
 using Application.Abstractions.Data;
+using Application.Abstractions.Cache;
+using Application.Abstractions.Messaging.Email;
+using Application.Helpers;
+using Domain.Entities.EmailVerification;
+using Domain.Entities.EmailVerification.ValueObject;
 using MediatR;
 using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Api.Auth.Register;
 
 internal sealed class Handler(
-    IAppDbContext db
-) : IRequestHandler<Command, Result<int>> {
+    IAppDbContext db,
+    ICacheService cache,
+    IMailService mailService
+) : IRequestHandler<Command, Result<RegisterResponse>> {
 
-    public async Task<Result<int>> Handle(Command request, CancellationToken ct)
+    public async Task<Result<RegisterResponse>> Handle(Command request, CancellationToken ct)
     {
         // 유효성 검사
         if (string.IsNullOrWhiteSpace(request.Email))
         {
-            return Result.Failure<int>(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
+            return Result.Failure<RegisterResponse>(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
         }
 
         if (string.IsNullOrWhiteSpace(request.Password))
         {
-            return Result.Failure<int>(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다."));
+            return Result.Failure<RegisterResponse>(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다."));
         }
 
-        if (request.Password.Length < 6)
+        // Config 로드
+        var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct);
+
+        // 회원가입 차단 확인
+        if (accountConfig.IsRegisterBlock)
         {
-            return Result.Failure<int>(Error.Problem("Auth.PasswordTooShort", "비밀번호는 6자 이상이어야 합니다."));
+            return Result.Failure<RegisterResponse>(Error.Problem("Auth.RegisterBlocked", "현재 회원가입이 차단되어 있습니다."));
+        }
+
+        // 비밀번호 복잡도 검증
+        var passwordResult = PasswordPolicyValidator.Validate(request.Password, accountConfig);
+        if (!passwordResult.IsSuccess)
+        {
+            return Result.Failure<RegisterResponse>(passwordResult.Error);
         }
 
         // 이메일 중복 체크
@@ -33,7 +51,42 @@ internal sealed class Handler(
 
         if (exists)
         {
-            return Result.Failure<int>(Error.Conflict("Auth.EmailExists", "이미 사용 중인 이메일입니다."));
+            return Result.Failure<RegisterResponse>(Error.Conflict("Auth.EmailExists", "이미 사용 중인 이메일입니다."));
+        }
+
+        // 금지 이메일 체크
+        if (!string.IsNullOrWhiteSpace(accountConfig.DeniedEmailList))
+        {
+            var deniedEmails = accountConfig.DeniedEmailList
+                .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+                .Select(e => e.ToLower());
+
+            foreach (var denied in deniedEmails)
+            {
+                // 도메인 매칭 (@domain.com) 또는 전체 이메일 매칭
+                if (denied.StartsWith('@') && email.EndsWith(denied))
+                {
+                    return Result.Failure<RegisterResponse>(Error.Problem("Auth.DeniedEmail", "사용할 수 없는 이메일입니다."));
+                }
+                else if (email == denied)
+                {
+                    return Result.Failure<RegisterResponse>(Error.Problem("Auth.DeniedEmail", "사용할 수 없는 이메일입니다."));
+                }
+            }
+        }
+
+        // 금지 별명 체크
+        if (!string.IsNullOrWhiteSpace(request.Name) && !string.IsNullOrWhiteSpace(accountConfig.DeniedNameList))
+        {
+            var name = request.Name.Trim().ToLower();
+            var deniedNames = accountConfig.DeniedNameList
+                .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+                .Select(n => n.ToLower());
+
+            if (deniedNames.Contains(name))
+            {
+                return Result.Failure<RegisterResponse>(Error.Problem("Auth.DeniedName", "사용할 수 없는 별명입니다."));
+            }
         }
 
         // Member 생성 (비밀번호 해싱 포함)
@@ -57,6 +110,35 @@ internal sealed class Handler(
 
         await db.SaveChangesAsync(ct);
 
-        return Result.Success(member.ID);
+        // 이메일 인증이 필요한 경우 인증번호 발송
+        if (accountConfig.IsRegisterEmailAuth)
+        {
+            // 기존 미인증 코드 삭제
+            var existing = await db.EmailVerifyNumber
+                .Where(e => e.Email == email && e.Type == VerificationType.Registration && !e.IsVerified)
+                .ToListAsync(ct);
+            db.EmailVerifyNumber.RemoveRange(existing);
+
+            // 6자리 랜덤 숫자 코드 생성
+            var code = Random.Shared.Next(100000, 999999).ToString();
+            var verifyNumber = EmailVerifyNumber.Create(
+                VerificationType.Registration,
+                email,
+                code,
+                DateTime.UtcNow.AddMinutes(5)
+            );
+
+            await db.EmailVerifyNumber.AddAsync(verifyNumber, ct);
+            await db.SaveChangesAsync(ct);
+
+            // 인증번호 이메일 발송
+            await mailService.SendAsync(new SendData(
+                email,
+                "[bitforum] 회원가입 이메일 인증번호",
+                $"<p>회원가입 이메일 인증번호입니다.</p><p><strong>{code}</strong></p><p>이 코드는 5분간 유효합니다.</p>"
+            ), ct);
+        }
+
+        return Result.Success(new RegisterResponse(member.ID, accountConfig.IsRegisterEmailAuth));
     }
-}
+}

+ 9 - 0
Application/Features/Api/Auth/Registration/Command.cs

@@ -0,0 +1,9 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.Registration;
+
+public sealed record Command(
+    string Email,
+    string CookieValue
+) : ICommand<Result>;

+ 72 - 0
Application/Features/Api/Auth/Registration/Handler.cs

@@ -0,0 +1,72 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Cache;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Messaging.Email;
+using Application.Features.Config.Get;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Auth.Registration;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    ICacheService cache,
+    IMailService mailService
+) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        // 이메일 유효성 검사
+        if (string.IsNullOrWhiteSpace(request.Email))
+        {
+            return Result.Failure(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
+        }
+
+        var email = request.Email.Trim().ToLower();
+
+        // 회원 조회
+        var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
+        if (member is null)
+        {
+            return Result.Failure(Error.NotFound("Auth.MemberNotFound", "등록된 회원 정보를 찾을 수 없습니다."));
+        }
+
+        // Config에서 IsRegisterEmailAuth 설정 조회
+        var isRegisterEmailAuth = false;
+        var config = await cache.GetAsync<Response>(CacheKeys.Config, ct);
+        if (config is not null)
+        {
+            isRegisterEmailAuth = config.Account.IsRegisterEmailAuth;
+        }
+        else
+        {
+            var configEntity = await db.Config.AsNoTracking().OrderByDescending(x => x.ID).FirstOrDefaultAsync(ct);
+            if (configEntity is not null)
+            {
+                isRegisterEmailAuth = configEntity.Account.IsRegisterEmailAuth;
+            }
+        }
+
+        // 이메일 인증이 필요한 경우 쿠키 확인 및 이메일 인증 처리
+        if (isRegisterEmailAuth)
+        {
+            if (string.IsNullOrWhiteSpace(request.CookieValue) || request.CookieValue != "true")
+            {
+                return Result.Failure(Error.Problem("Auth.VerificationRequired", "사전 인증을 먼저 수행하세요."));
+            }
+
+            // 이메일 인증 완료 처리
+            member.MarkEmailVerified();
+            await db.SaveChangesAsync(ct);
+        }
+
+        // 가입 완료 환영 이메일 발송
+        await mailService.SendAsync(new SendData(
+            email,
+            "[bitforum] 회원가입을 환영합니다",
+            $"<p>회원가입이 완료되었습니다.</p><p>bitforum을 이용해 주셔서 감사합니다.</p>"
+        ), ct);
+
+        return Result.Success();
+    }
+}

+ 10 - 0
Application/Features/Api/Auth/ResendEmail/Command.cs

@@ -0,0 +1,10 @@
+using Application.Abstractions.Messaging;
+using Domain.Entities.EmailVerification.ValueObject;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.ResendEmail;
+
+public sealed record Command(
+    string Email,
+    VerificationType Type
+) : ICommand<Result>;

+ 88 - 0
Application/Features/Api/Auth/ResendEmail/Handler.cs

@@ -0,0 +1,88 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Messaging.Email;
+using Domain.Entities.EmailVerification;
+using Domain.Entities.EmailVerification.ValueObject;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Auth.ResendEmail;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    IMailService mailService
+) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        // 이메일 유효성 검사
+        if (string.IsNullOrWhiteSpace(request.Email))
+        {
+            return Result.Failure(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
+        }
+
+        var email = request.Email.Trim().ToLower();
+
+        // 회원 조회
+        var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
+        if (member is null)
+        {
+            return Result.Failure(Error.NotFound("Auth.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
+        }
+
+        // 타입별 유효성 검사
+        switch (request.Type)
+        {
+            case VerificationType.Registration:
+                if (member.IsEmailVerified)
+                {
+                    return Result.Failure(Error.Conflict("Auth.AlreadyVerified", "이미 인증된 이메일입니다."));
+                }
+                break;
+
+            case VerificationType.ForgotPassword:
+                if (member.IsWithdraw)
+                {
+                    return Result.Failure(Error.Problem("Auth.MemberWithdrawn", "탈퇴한 회원은 이용할 수 없습니다."));
+                }
+                if (member.IsDenied)
+                {
+                    return Result.Failure(Error.Problem("Auth.MemberDenied", "차단된 회원이므로 이용할 수 없습니다."));
+                }
+                break;
+        }
+
+        // 기존 미인증 코드 삭제
+        var existing = await db.EmailVerifyNumber.Where(e => e.Email == email && e.Type == request.Type && !e.IsVerified).ToListAsync(ct);
+
+        db.EmailVerifyNumber.RemoveRange(existing);
+
+        // 6자리 랜덤 숫자 코드 생성
+        var code = Random.Shared.Next(100000, 999999).ToString();
+        var expiration = request.Type == VerificationType.Registration ? DateTime.UtcNow.AddMinutes(5) : DateTime.UtcNow.AddMinutes(10);
+
+        // 인증번호 생성
+        var verifyNumber = EmailVerifyNumber.Create(request.Type, email, code, expiration);
+
+        await db.EmailVerifyNumber.AddAsync(verifyNumber, ct);
+        await db.SaveChangesAsync(ct);
+
+        // 타입별 이메일 발송
+        var (subject, body) = request.Type switch
+        {
+            VerificationType.Registration => (
+                "[bitforum] 회원가입 이메일 인증번호",
+                $"<p>회원가입 이메일 인증번호입니다.</p><p><strong>{code}</strong></p><p>이 코드는 5분간 유효합니다.</p>"
+            ),
+            VerificationType.ForgotPassword => (
+                "[bitforum] 비밀번호 재설정 인증번호",
+                $"<p>비밀번호 재설정 인증번호입니다.</p><p><strong>{code}</strong></p><p>이 코드는 10분간 유효합니다.</p>"
+            ),
+            _ => throw new InvalidOperationException($"지원하지 않는 인증 타입입니다: {request.Type}")
+        };
+
+        await mailService.SendAsync(new SendData(email, subject, body), ct);
+
+        return Result.Success();
+    }
+}

+ 10 - 0
Application/Features/Api/Auth/ResetPassword/Command.cs

@@ -0,0 +1,10 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.ResetPassword;
+
+public sealed record Command(
+    string Email,
+    string Password,
+    string CookieValue
+) : ICommand<Result>;

+ 57 - 0
Application/Features/Api/Auth/ResetPassword/Handler.cs

@@ -0,0 +1,57 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Cache;
+using Application.Abstractions.Messaging;
+using Application.Helpers;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Auth.ResetPassword;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    ICacheService cache
+) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        // 쿠키 인증 확인
+        if (request.CookieValue != "true")
+        {
+            return Result.Failure(Error.Unauthorized("Auth.NotVerified", "사전 인증을 먼저 수행하세요."));
+        }
+
+        // 이메일 유효성 검사
+        if (string.IsNullOrWhiteSpace(request.Email))
+        {
+            return Result.Failure(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
+        }
+
+        // 비밀번호 유효성 검사 (복잡도 포함)
+        if (string.IsNullOrWhiteSpace(request.Password))
+        {
+            return Result.Failure(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다."));
+        }
+
+        var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct);
+        var passwordResult = PasswordPolicyValidator.Validate(request.Password, accountConfig);
+        if (!passwordResult.IsSuccess)
+        {
+            return passwordResult;
+        }
+
+        var email = request.Email.Trim().ToLower();
+
+        // 회원 조회
+        var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
+        if (member is null)
+        {
+            return Result.Failure(Error.NotFound("Auth.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
+        }
+
+        // 비밀번호 변경
+        member.SetPassword(request.Password);
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 11 - 0
Application/Features/Api/Auth/VerifyEmail/Command.cs

@@ -0,0 +1,11 @@
+using Application.Abstractions.Messaging;
+using Domain.Entities.EmailVerification.ValueObject;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.VerifyEmail;
+
+public sealed record Command(
+    string Email,
+    string Code,
+    VerificationType Type
+) : ICommand<Result<VerificationType>>;

+ 58 - 0
Application/Features/Api/Auth/VerifyEmail/Handler.cs

@@ -0,0 +1,58 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.EmailVerification.ValueObject;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Auth.VerifyEmail;
+
+internal sealed class Handler(
+    IAppDbContext db
+) : ICommandHandler<Command, Result<VerificationType>>
+{
+    public async Task<Result<VerificationType>> Handle(Command request, CancellationToken ct)
+    {
+        // 이메일 유효성 검사
+        if (string.IsNullOrWhiteSpace(request.Email))
+        {
+            return Result.Failure<VerificationType>(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
+        }
+
+        if (string.IsNullOrWhiteSpace(request.Code))
+        {
+            return Result.Failure<VerificationType>(Error.Problem("Auth.CodeRequired", "인증번호는 필수입니다."));
+        }
+
+        var email = request.Email.Trim().ToLower();
+
+        // 인증번호 조회 (미인증, 만료되지 않은 것)
+        var verifyNumber = await db.EmailVerifyNumber.Where(e => e.Email == email && e.Type == request.Type && !e.IsVerified && e.Expiration > DateTime.UtcNow).OrderByDescending(e => e.CreatedAt).FirstOrDefaultAsync(ct);
+        if (verifyNumber is null)
+        {
+            return Result.Failure<VerificationType>(Error.NotFound("Auth.CodeNotFound", "인증번호가 올바르지 않거나 만료되었습니다."));
+        }
+
+        // 코드 일치 확인
+        if (verifyNumber.Code != request.Code.Trim())
+        {
+            return Result.Failure<VerificationType>(Error.Problem("Auth.CodeMismatch", "인증번호가 일치하지 않습니다."));
+        }
+
+        // 인증 완료 처리
+        verifyNumber.MarkVerified();
+
+        // 회원가입 인증인 경우 이메일 인증 완료 처리
+        if (request.Type == VerificationType.Registration)
+        {
+            var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
+            if (member is not null && !member.IsEmailVerified)
+            {
+                member.MarkEmailVerified();
+            }
+        }
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success(request.Type);
+    }
+}

+ 76 - 7
Application/Features/Api/Forum/Board/Get/Handler.cs

@@ -1,30 +1,99 @@
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
-using Microsoft.EntityFrameworkCore;
+using Application.Abstractions.Cache;
 using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+using BoardMetaResponse = Application.Features.Api.Forum.BoardMeta.Get.Response;
 
 namespace Application.Features.Api.Forum.Board.Get;
 
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Response>>
+public sealed class Handler(IAppDbContext db, ICacheService cache) : IQueryHandler<Query, Result<Response>>
 {
     public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
     {
-        var item = await db.Board.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        var item = await db.Board
+            .AsNoTracking()
+            .Include(x => x.BoardGroup)
+            .Include(x => x.BoardPrefix.Where(p => p.IsActive).OrderBy(p => p.Order))
+            .Include(x => x.BoardManager)
+            .ThenInclude(m => m.Member)
+            .FirstOrDefaultAsync(x => x.Code == request.Code, ct);
+
         if (item is null)
         {
             return Result.Failure<Response>(Error.NotFound("Board.NotFound", "게시판을 찾을 수 없습니다."));
         }
 
+        // BoardMeta 조회 (캐시 우선)
+        BoardMetaResponse? boardMeta = null;
+        var cacheKey = CacheKeys.BoardMeta(item.ID);
+        var cached = await cache.GetAsync<BoardMetaResponse>(cacheKey, ct);
+
+        if (cached is not null)
+        {
+            boardMeta = cached;
+        }
+        else
+        {
+            var meta = await db.BoardMeta.FirstOrDefaultAsync(x => x.BoardID == item.ID, ct);
+            if (meta is null)
+            {
+                meta = new Domain.Entities.Forum.Boards.BoardMeta { 
+                    BoardID = item.ID 
+                };
+
+                await db.BoardMeta.AddAsync(meta, ct);
+                await db.SaveChangesAsync(ct);
+            }
+
+            boardMeta = new BoardMetaResponse(
+                meta.ID,
+                meta.BoardID,
+                item.Code,
+                item.Name,
+                meta.List,
+                meta.View,
+                meta.Write,
+                meta.Comment,
+                meta.General,
+                meta.Permission,
+                meta.Notify,
+                meta.NotifyTemplate,
+                meta.Exp);
+
+            await cache.SetAsync(cacheKey, boardMeta, ct);
+        }
+
         return new Response(
             item.ID,
             item.BoardGroupID,
             item.Code,
             item.Name,
-            item.Order,
             item.IsSearch,
             item.IsActive,
-            item.UpdatedAt,
-            item.CreatedAt
+            item.Posts,
+            new BoardGroupDto(
+                item.BoardGroup.ID,
+                item.BoardGroup.Code,
+                item.BoardGroup.Name
+            ),
+            [..item.BoardPrefix.Select(p => new BoardPrefixDto(
+                p.ID,
+                p.BoardID,
+                p.Name,
+                p.Color,
+                p.Posts
+            ))],
+            [..item.BoardManager.Select(m => new BoardManagerDto(
+                m.ID,
+                m.BoardID,
+                new BoardManagerUserDto(m.MemberID, m.Member.Email),
+                m.CanEdit,
+                m.CanDelete,
+                m.UpdatedAt,
+                m.CreatedAt
+            ))],
+            boardMeta
         );
     }
-}
+}

+ 1 - 1
Application/Features/Api/Forum/Board/Get/Query.cs

@@ -3,4 +3,4 @@ using SharedKernel.Results;
 
 namespace Application.Features.Api.Forum.Board.Get;
 
-public sealed record Query(int ID) : IQuery<Result<Response>>;
+public sealed record Query(string Code) : IQuery<Result<Response>>;

+ 35 - 2
Application/Features/Api/Forum/Board/Get/Response.cs

@@ -1,3 +1,5 @@
+using BoardMetaResponse = Application.Features.Api.Forum.BoardMeta.Get.Response;
+
 namespace Application.Features.Api.Forum.Board.Get;
 
 public sealed record Response(
@@ -5,9 +7,40 @@ public sealed record Response(
     int BoardGroupID,
     string Code,
     string Name,
-    short Order,
     bool IsSearch,
     bool IsActive,
+    int Posts,
+    BoardGroupDto BoardGroup,
+    List<BoardPrefixDto> BoardPrefix,
+    List<BoardManagerDto> BoardManager,
+    BoardMetaResponse? BoardMeta
+);
+
+public sealed record BoardGroupDto(
+    int ID,
+    string Code,
+    string Name
+);
+
+public sealed record BoardPrefixDto(
+    int ID,
+    int BoardID,
+    string Name,
+    string? Color,
+    int Posts
+);
+
+public sealed record BoardManagerDto(
+    int ID,
+    int BoardID,
+    BoardManagerUserDto User,
+    bool CanEdit,
+    bool CanDelete,
     DateTime? UpdatedAt,
     DateTime CreatedAt
-);
+);
+
+public sealed record BoardManagerUserDto(
+    int ID,
+    string Email
+);

+ 7 - 2
Application/Features/Api/Forum/Board/Search/Handler.cs

@@ -15,6 +15,11 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             query = query.Where(c => c.BoardGroupID == request.BoardGroupID.Value);
         }
 
+        if (!string.IsNullOrWhiteSpace(request.BoardGroupCode))
+        {
+            query = query.Where(c => c.BoardGroup.Code == request.BoardGroupCode.Trim());
+        }
+
         if (!string.IsNullOrWhiteSpace(request.Keyword))
         {
             var kw = request.Keyword.Trim();
@@ -26,7 +31,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
         var list = await query
             .OrderBy(c => c.Order)
             .ThenByDescending(c => c.ID)
-            .Skip((request.PageNum - 1) * request.PerPage)
+            .Skip((request.Page - 1) * request.PerPage)
             .Take(request.PerPage)
             .Select(c => new
             {
@@ -45,7 +50,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             })
             .ToListAsync(ct);
 
-        var startNum = total - ((request.PageNum - 1) * request.PerPage);
+        var startNum = total - ((request.Page - 1) * request.PerPage);
 
         return new Response(
             total,

+ 1 - 1
Application/Features/Api/Forum/Board/Search/Query.cs

@@ -2,4 +2,4 @@ using Application.Abstractions.Messaging;
 
 namespace Application.Features.Api.Forum.Board.Search;
 
-public sealed record Query(int? BoardGroupID, string? Keyword, int PageNum, ushort PerPage) : IQuery<Response>;
+public sealed record Query(int? BoardGroupID, string? BoardGroupCode, string? Keyword, int Page, ushort PerPage) : IQuery<Response>;

+ 6 - 9
Application/Features/Api/Forum/BoardMeta/Get/Handler.cs

@@ -17,22 +17,19 @@ public sealed class Handler(IAppDbContext db, ICacheService cache) : IQueryHandl
             return cached;
         }
 
-        var board = await db.Board
-            .AsNoTracking()
-            .FirstOrDefaultAsync(x => x.ID == request.BoardID, ct);
-
+        var board = await db.Board.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.BoardID, ct);
         if (board is null)
         {
             return Result.Failure<Response>(Error.NotFound("BoardMeta.NotFound", "게시판을 찾을 수 없습니다."));
         }
 
-        var meta = await db.BoardMeta
-            .FirstOrDefaultAsync(x => x.BoardID == request.BoardID, ct);
-
+        var meta = await db.BoardMeta.FirstOrDefaultAsync(x => x.BoardID == request.BoardID, ct);
         if (meta is null)
         {
-            meta = new Domain.Entities.Forum.Boards.BoardMeta { BoardID = request.BoardID };
-            db.BoardMeta.Add(meta);
+            meta = new Domain.Entities.Forum.Boards.BoardMeta { 
+                BoardID = request.BoardID 
+            };
+            await db.BoardMeta.AddAsync(meta, ct);
             await db.SaveChangesAsync(ct);
         }
 

+ 2 - 1
Application/Features/Api/Forum/BoardMeta/Get/Response.cs

@@ -15,4 +15,5 @@ public sealed record Response(
     BoardMetaPermission Permission,
     BoardMetaNotify Notify,
     BoardMetaNotifyTemplate NotifyTemplate,
-    BoardMetaExp Exp);
+    BoardMetaExp Exp
+);

+ 7 - 3
Application/Features/Api/Forum/Comment/Create/Command.cs

@@ -1,13 +1,17 @@
 using Application.Abstractions.Messaging;
 using SharedKernel.Results;
+using Microsoft.AspNetCore.Http;
 
 namespace Application.Features.Api.Forum.Comment.Create;
 
 public sealed record Command(
     int MemberID,
-    int BoardID,
     int PostID,
     int? ParentID,
+    string? Mention,
     string Content,
-    bool IsSecret
-) : ICommand<Result<int>>;
+    bool IsSecret,
+    List<IFormFile>? Images,
+    List<string>? Medias,
+    List<IFormFile>? Files
+) : ICommand<Result<int>>;

+ 312 - 5
Application/Features/Api/Forum/Comment/Create/Handler.cs

@@ -1,12 +1,21 @@
 using Application.Abstractions.Messaging;
+using Application.Abstractions.Messaging.Email;
 using Application.Abstractions.Data;
-using Microsoft.EntityFrameworkCore;
+using Application.Abstractions.Forum;
+using Domain.Entities.Forum.Comments;
+using Domain.Entities.Forum.ValueObject;
 using SharedKernel.Results;
+using SharedKernel.Storage;
+using Microsoft.EntityFrameworkCore;
+using System.Text.RegularExpressions;
 
 namespace Application.Features.Api.Forum.Comment.Create;
 
-public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<int>>
+public sealed class Handler(IAppDbContext db, IFileStorage fileStorage, IBoardPermissionService permissionService, IMailService mailService) : ICommandHandler<Command, Result<int>>
 {
+    private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
+    private static readonly string[] AllowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".zip", ".rar", ".7z", ".hwp", ".hwpx", ".csv"];
+
     public async Task<Result<int>> Handle(Command request, CancellationToken ct)
     {
         if (string.IsNullOrWhiteSpace(request.Content))
@@ -26,9 +35,60 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<
             return Result.Failure<int>(Error.NotFound("Comment.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
         }
 
+        // 댓글 권한 확인
+        var boardMeta = await db.BoardMeta.AsNoTracking().FirstOrDefaultAsync(x => x.BoardID == post.BoardID, ct);
+        var isQnABoard = boardMeta?.List.Layout == BoardLayout.QnA;
+
+        if (boardMeta is not null)
+        {
+            // 댓글 내용 길이 검증
+            var contentLength = request.Content.Trim().Length;
+
+            if (boardMeta.Comment.MinContentLength > 0 && contentLength < boardMeta.Comment.MinContentLength)
+            {
+                return Result.Failure<int>(Error.Problem("Comment.MinContentLength", $"내용은 {boardMeta.Comment.MinContentLength}자 이상 작성해주세요."));
+            }
+
+            if (boardMeta.Comment.MaxContentLength > 0 && contentLength > boardMeta.Comment.MaxContentLength)
+            {
+                return Result.Failure<int>(Error.Problem("Comment.MaxContentLength", $"내용은 {boardMeta.Comment.MaxContentLength}자 이내로 작성해주세요."));
+            }
+
+            // 1:1 문의 게시판: 최고관리자·매니저만 댓글/대댓글 작성 가능
+            if (isQnABoard)
+            {
+                if (!member.IsAdmin)
+                {
+                    var mgr = await permissionService.GetBoardManagerAsync(post.BoardID, member.ID, ct);
+                    if (mgr is null)
+                    {
+                        return Result.Failure<int>(Error.Forbidden("Comment.QnAPermissionDenied", "1:1 문의 게시판은 관리자만 답변할 수 있습니다."));
+                    }
+                }
+            }
+            else
+            {
+                var requiredPermission = request.ParentID.HasValue ? boardMeta.Permission.ReplyWrite : boardMeta.Permission.CommentWrite;
+                var requiredPermissionName = request.ParentID.HasValue ? "답글 작성 권한" : "댓글 작성 권한";
+
+                if (!await permissionService.HasPermissionAsync(member, post.BoardID, requiredPermission, ct))
+                {
+                    return Result.Failure<int>(Error.Forbidden("Comment.PermissionDenied", $"{requiredPermissionName}이 없습니다."));
+                }
+            }
+
+            // 파일 업로드 권한 확인
+            if ((request.Images is { Count: > 0 } || request.Files is { Count: > 0 }) &&
+                !await permissionService.HasPermissionAsync(member, post.BoardID, boardMeta.Permission.FileUpload, ct)
+            ) {
+                return Result.Failure<int>(Error.Forbidden("Comment.FileUploadDenied", "파일 업로드 권한이 없습니다."));
+            }
+        }
+
         sbyte depth = 0;
         var isReply = false;
 
+        // 부모 댓글이 있을 경우 답글로 처리
         if (request.ParentID.HasValue)
         {
             var parent = await db.Comment.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ParentID.Value, ct);
@@ -50,7 +110,7 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<
 
         var comment = new Domain.Entities.Forum.Comments.Comment
         {
-            BoardID = request.BoardID,
+            BoardID = post.BoardID,
             PostID = request.PostID,
             MemberID = request.MemberID,
             ParentID = request.ParentID,
@@ -63,14 +123,40 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<
             Email = member.Email
         };
 
+        // Mention 처리
+        if (!string.IsNullOrWhiteSpace(request.Mention))
+        {
+            var rawHandle = request.Mention.Trim();
+            var handleValue = rawHandle.TrimStart('@').Trim();
+            var mentionMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.Name == handleValue || x.SID == handleValue, ct);
+
+            if (mentionMember is not null)
+            {
+                comment.MentionMemberID = mentionMember.ID;
+                comment.CommentMention = new CommentMention
+                {
+                    BoardID = post.BoardID,
+                    PostID = request.PostID,
+                    MemberID = mentionMember.ID,
+                    RawHandle = rawHandle
+                };
+            }
+        }
+
         await db.Comment.AddAsync(comment, ct);
 
         // Post 댓글 카운트 증가
         post.Comments++;
         post.LastCommentUpdatedAt = DateTime.UtcNow;
 
+        // 1:1 문의 게시판: 댓글 작성 시 답변 완료 표시
+        if (isQnABoard && !request.ParentID.HasValue)
+        {
+            post.IsReply = true;
+        }
+
         // Board 댓글 카운트 증가
-        var board = await db.Board.FirstOrDefaultAsync(x => x.ID == request.BoardID, ct);
+        var board = await db.Board.FirstOrDefaultAsync(x => x.ID == post.BoardID, ct);
         if (board is not null)
         {
             board.Comments++;
@@ -86,6 +172,227 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<
 
         await db.SaveChangesAsync(ct);
 
+        // 파일 처리 (comment.ID 확보 후)
+        var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Comment, comment.ID);
+
+        // 이미지 처리
+        if (request.Images is { Count: > 0 })
+        {
+            byte imageCount = 0;
+            var savedImageUrls = new List<string>();
+
+            foreach (var image in request.Images)
+            {
+                var result = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExtensions, ct);
+                if (result is not null)
+                {
+                    var ext = Path.GetExtension(image.FileName).ToLowerInvariant();
+
+                    await db.CommentImage.AddAsync(new CommentImage
+                    {
+                        BoardID = post.BoardID,
+                        PostID = request.PostID,
+                        CommentID = comment.ID,
+                        FileName = image.FileName,
+                        HashedName = result.FileName,
+                        Path = uploadPath.ToRelativePath(),
+                        Url = result.Url,
+                        Extension = ext,
+                        ContentType = image.ContentType,
+                        Size = result.Size,
+                        Width = result.Width,
+                        Height = result.Height
+                    }, ct);
+
+                    savedImageUrls.Add(result.Url);
+                    imageCount++;
+                }
+            }
+
+            // content의 data:image/ 플레이스홀더를 실제 이미지 경로로 순서대로 치환
+            var content = comment.Content;
+            foreach (var url in savedImageUrls)
+            {
+                var idx = content.IndexOf("data:image/", StringComparison.Ordinal);
+                if (idx >= 0)
+                {
+                    var endIdx = content.IndexOfAny(['"', '\''], idx);
+                    if (endIdx > idx)
+                    {
+                        content = string.Concat(content.AsSpan(0, idx), url, content.AsSpan(endIdx));
+                    }
+                }
+            }
+
+            comment.Content = content;
+            comment.Images = imageCount;
+        }
+
+        // 파일 처리
+        if (request.Files is { Count: > 0 })
+        {
+            // content HTML에서 file-embed의 data-uuid와 data-name 매핑 추출
+            var fileUuidMap = new List<(string Name, Guid Uuid)>();
+            var uuidMatches = Regex.Matches(comment.Content, @"data-uuid=""([^""]+)""\s+data-name=""([^""]+)""");
+            foreach (Match match in uuidMatches)
+            {
+                if (Guid.TryParse(match.Groups[1].Value, out var uuid))
+                {
+                    fileUuidMap.Add((match.Groups[2].Value, uuid));
+                }
+            }
+
+            byte fileCount = 0;
+
+            foreach (var file in request.Files)
+            {
+                var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
+                if (result is not null)
+                {
+                    var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
+
+                    // content의 file-embed에서 매칭되는 UUID 사용, 없으면 새로 생성
+                    var mapIdx = fileUuidMap.FindIndex(x => x.Name == file.FileName);
+                    Guid fileUuid;
+                    if (mapIdx >= 0)
+                    {
+                        fileUuid = fileUuidMap[mapIdx].Uuid;
+                        fileUuidMap.RemoveAt(mapIdx);
+                    }
+                    else
+                    {
+                        fileUuid = Guid.NewGuid();
+                    }
+
+                    await db.CommentFile.AddAsync(new Domain.Entities.Forum.Comments.CommentFile
+                    {
+                        BoardID = post.BoardID,
+                        PostID = request.PostID,
+                        CommentID = comment.ID,
+                        UUID = fileUuid,
+                        FileName = file.FileName,
+                        HashedName = result.FileName,
+                        Path = uploadPath.ToRelativePath(),
+                        Url = result.Url,
+                        Extension = ext,
+                        ContentType = file.ContentType,
+                        Size = result.Size
+                    }, ct);
+
+                    fileCount++;
+                }
+            }
+
+            comment.Files = fileCount;
+        }
+
+        // 미디어 처리
+        if (request.Medias is { Count: > 0 })
+        {
+            byte mediaCount = 0;
+
+            foreach (var mediaUrl in request.Medias)
+            {
+                if (!string.IsNullOrWhiteSpace(mediaUrl))
+                {
+                    await db.CommentMedia.AddAsync(new CommentMedia
+                    {
+                        BoardID = post.BoardID,
+                        PostID = request.PostID,
+                        CommentID = comment.ID,
+                        Url = mediaUrl.Trim()
+                    }, ct);
+
+                    mediaCount++;
+                }
+            }
+
+            comment.Medias = mediaCount;
+        }
+
+        await db.SaveChangesAsync(ct);
+
+        // 이메일 알림 발송
+        if (boardMeta?.NotifyTemplate is not null && boardMeta.Notify is not null)
+        {
+            try
+            {
+                var notify = boardMeta.Notify;
+                var template = boardMeta.NotifyTemplate;
+                var notifyFlags = request.ParentID.HasValue ? notify.ReplyWriteNotifyEnum : notify.CommentWriteNotifyEnum;
+                var emailSubject = request.ParentID.HasValue ? template.ReplyWriteEmailNotifySubject : template.CommentWriteEmailNotifySubject;
+                var emailContent = request.ParentID.HasValue ? template.ReplyWriteEmailNotifyContent : template.CommentWriteEmailNotifyContent;
+
+                if (notifyFlags != 0 && !string.IsNullOrWhiteSpace(emailSubject) && !string.IsNullOrWhiteSpace(emailContent))
+                {
+                    var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+                    // 게시글 작성자
+                    if (notifyFlags.HasFlag(BoardNotify.PostAuthor))
+                    {
+                        var postAuthor = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == post.MemberID, ct);
+                        if (postAuthor is not null && !string.IsNullOrWhiteSpace(postAuthor.Email))
+                        {
+                            recipients.Add(postAuthor.Email);
+                        }
+                    }
+
+                    // 부모 댓글 작성자 (대댓글일 때)
+                    if (notifyFlags.HasFlag(BoardNotify.CommentAuthor) && request.ParentID.HasValue)
+                    {
+                        var parentComment = await db.Comment.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ParentID.Value, ct);
+                        if (parentComment is not null)
+                        {
+                            var commentAuthor = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == parentComment.MemberID, ct);
+                            if (commentAuthor is not null && !string.IsNullOrWhiteSpace(commentAuthor.Email))
+                            {
+                                recipients.Add(commentAuthor.Email);
+                            }
+                        }
+                    }
+
+                    // 최고관리자
+                    if (notifyFlags.HasFlag(BoardNotify.Admin))
+                    {
+                        var admins = await db.Member.AsNoTracking().Where(x => x.IsAdmin).ToListAsync(ct);
+                        foreach (var admin in admins)
+                        {
+                            if (!string.IsNullOrWhiteSpace(admin.Email))
+                            {
+                                recipients.Add(admin.Email);
+                            }
+                        }
+                    }
+
+                    // 게시판 매니저
+                    if (notifyFlags.HasFlag(BoardNotify.Manager))
+                    {
+                        var managers = await db.BoardManager.AsNoTracking().Where(x => x.BoardID == post.BoardID).ToListAsync(ct);
+                        foreach (var mgr in managers)
+                        {
+                            var mgrMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == mgr.MemberID, ct);
+                            if (mgrMember is not null && !string.IsNullOrWhiteSpace(mgrMember.Email))
+                            {
+                                recipients.Add(mgrMember.Email);
+                            }
+                        }
+                    }
+
+                    // 본인에게는 발송하지 않음
+                    recipients.Remove(member.Email);
+
+                    foreach (var email in recipients)
+                    {
+                        await mailService.SendAsync(new SendData(email, emailSubject, emailContent), ct);
+                    }
+                }
+            }
+            catch
+            {
+                // 이메일 발송 실패 시 댓글 등록은 성공 처리
+            }
+        }
+
         return comment.ID;
     }
-}
+}

+ 11 - 2
Application/Features/Api/Forum/Comment/Delete/Handler.cs

@@ -1,11 +1,12 @@
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
+using Application.Abstractions.Forum;
 using Microsoft.EntityFrameworkCore;
 using SharedKernel.Results;
 
 namespace Application.Features.Api.Forum.Comment.Delete;
 
-public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+public sealed class Handler(IAppDbContext db, IBoardPermissionService permissionService) : ICommandHandler<Command, Result>
 {
     public async Task<Result> Handle(Command request, CancellationToken ct)
     {
@@ -17,7 +18,15 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
 
         if (comment.MemberID != request.MemberID)
         {
-            return Result.Failure(Error.Forbidden("Comment.Forbidden", "삭제 권한이 없습니다."));
+            var reqMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID, ct);
+            if (reqMember is null || !reqMember.IsAdmin)
+            {
+                var mgr = await permissionService.GetBoardManagerAsync(comment.BoardID, request.MemberID, ct);
+                if (mgr is null || !mgr.CanDelete)
+                {
+                    return Result.Failure(Error.Forbidden("Comment.Forbidden", "삭제 권한이 없습니다."));
+                }
+            }
         }
 
         comment.IsDeleted = true;

+ 2 - 5
Application/Features/Api/Forum/Comment/Get/Handler.cs

@@ -1,7 +1,7 @@
 using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
 using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Api.Forum.Comment.Get;
 
@@ -9,10 +9,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Resp
 {
     public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
     {
-        var item = await db.Comment.AsNoTracking()
-            .Include(c => c.Board)
-            .Include(c => c.Post)
-            .FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        var item = await db.Comment.AsNoTracking().Include(c => c.Board).Include(c => c.Post).FirstOrDefaultAsync(x => x.ID == request.ID, ct);
 
         if (item is null)
         {

+ 152 - 0
Application/Features/Api/Forum/Comment/List/Handler.cs

@@ -0,0 +1,152 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Domain.Entities.Forum.ValueObject;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Forum.Comment.List;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Response>>
+{
+    public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
+    {
+        // 1. 댓글 총 개수 (표시용: 루트+대댓글) & 루트 댓글 개수 (페이지네이션용)
+        var total = await db.Comment.AsNoTracking().CountAsync(c => c.PostID == request.PostID && !c.IsDeleted, ct);
+        var totalRoots = await db.Comment.AsNoTracking().CountAsync(c => c.PostID == request.PostID && !c.IsDeleted && c.ParentID == null, ct);
+
+        if (totalRoots == 0)
+        {
+            return new Response(total, 0, []);
+        }
+
+        // 비밀글 열람 권한 판별을 위한 게시글 정보 조회
+        var post = await db.Post.AsNoTracking().Where(p => p.ID == request.PostID).Select(p => new { p.MemberID, p.BoardID }).FirstOrDefaultAsync(ct);
+
+        var canViewAllSecrets = false;
+        if (request.MemberID.HasValue && post is not null)
+        {
+            var reqMember = await db.Member.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.MemberID.Value, ct);
+            if (reqMember is not null && reqMember.IsAdmin)
+            {
+                canViewAllSecrets = true;
+            }
+            else if (reqMember is not null)
+            {
+                canViewAllSecrets = await db.BoardManager.AsNoTracking().AnyAsync(x => x.BoardID == post.BoardID && x.MemberID == request.MemberID.Value, ct);
+            }
+        }
+
+        // 2. 루트 댓글 페이지네이션 + 정렬
+        var rootQuery = db.Comment.AsNoTracking().Include(c => c.Member).ThenInclude(m => m.MemberGrade).Include(c => c.CommentMention).Where(c => c.PostID == request.PostID && !c.IsDeleted && c.ParentID == null);
+
+        rootQuery = request.Sort switch
+        {
+            // 인기순
+            1 => rootQuery.OrderByDescending(c => c.Likes).ThenByDescending(c => c.ID),
+
+            // 최신순
+            _ => rootQuery.OrderByDescending(c => c.ID)
+        };
+
+        var roots = await rootQuery.Skip((request.Page - 1) * request.PerPage).Take(request.PerPage).ToListAsync(ct);
+
+        // 3. 루트 댓글의 자식 댓글 일괄 로드
+        var rootIDs = roots.Select(c => c.ID).ToHashSet();
+
+        var children = await db.Comment.AsNoTracking()
+            .Include(c => c.Member).ThenInclude(m => m.MemberGrade)
+            .Include(c => c.CommentMention)
+            .Where(c => c.PostID == request.PostID && !c.IsDeleted && c.ParentID != null && rootIDs.Contains(c.ParentID.Value))
+            .OrderBy(c => c.ID)
+            .ToListAsync(ct);
+
+        var allComments = roots.Concat(children).ToList();
+
+        // 4. 사용자별 상태 벌크 로드
+        HashSet<int> likeIDs = [], dislikeIDs = [], reportedIDs = [];
+
+        if (request.MemberID is int memberID)
+        {
+            var allCommentIds = allComments.Select(c => c.ID).ToHashSet();
+
+            likeIDs = (await db.CommentReaction.AsNoTracking()
+                .Where(x => x.PostID == request.PostID && x.MemberID == memberID && x.Reaction == Reaction.Like && allCommentIds.Contains(x.CommentID))
+                .Select(x => x.CommentID)
+                .ToListAsync(ct)).ToHashSet();
+
+            dislikeIDs = (await db.CommentReaction.AsNoTracking()
+                .Where(x => x.PostID == request.PostID && x.MemberID == memberID && x.Reaction == Reaction.Dislike && allCommentIds.Contains(x.CommentID))
+                .Select(x => x.CommentID)
+                .ToListAsync(ct)).ToHashSet();
+
+            reportedIDs = (await db.CommentReport.AsNoTracking()
+                .Where(x => x.PostID == request.PostID && x.MemberID == memberID && allCommentIds.Contains(x.CommentID))
+                .Select(x => x.CommentID)
+                .ToListAsync(ct)).ToHashSet();
+        }
+
+        // 5. CommentItem 매핑
+        var itemMap = new Dictionary<int, Response.CommentItem>();
+        var rootItems = new List<Response.CommentItem>();
+
+        foreach (var c in allComments)
+        {
+            var writer = new Response.WriterDto(
+                c.Member.ID,
+                c.Member.SID,
+                c.Member.Name,
+                c.Member.Thumb,
+                c.Member.Icon,
+                c.Member.MemberGrade?.Image,
+                c.Member.CreatedAt
+            );
+
+            Response.MentionDto? mention = c.CommentMention is not null ? new Response.MentionDto(c.CommentMention.MemberID, c.CommentMention.RawHandle) : null;
+
+            // 비밀글: 본인, 게시글 작성자, 관리자/매니저만 열람 가능
+            var content = c.Content;
+            if (c.IsSecret && !canViewAllSecrets && request.MemberID != c.MemberID && request.MemberID != post?.MemberID)
+            {
+                content = "";
+            }
+
+            var item = new Response.CommentItem(
+                c.ID,
+                c.PostID,
+                c.MemberID,
+                c.ParentID,
+                writer,
+                mention,
+                content,
+                c.IsReply,
+                c.IsSecret,
+                c.Likes,
+                c.Dislikes,
+                c.Reports,
+                c.Replies,
+                likeIDs.Contains(c.ID),
+                dislikeIDs.Contains(c.ID),
+                reportedIDs.Contains(c.ID),
+                c.CreatedAt,
+                []
+            );
+
+            itemMap[c.ID] = item;
+        }
+
+        // 6. 트리 빌드
+        foreach (var item in itemMap.Values)
+        {
+            if (item.ParentID is int parentID && itemMap.TryGetValue(parentID, out var parent))
+            {
+                parent.Children.Add(item);
+            }
+            else
+            {
+                rootItems.Add(item);
+            }
+        }
+
+        return new Response(total, totalRoots, rootItems);
+    }
+}

+ 12 - 0
Application/Features/Api/Forum/Comment/List/Query.cs

@@ -0,0 +1,12 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Forum.Comment.List;
+
+public sealed record Query(
+    int PostID,
+    int? MemberID = null,
+    int Page = 1,
+    ushort PerPage = 20,
+    int Sort = 0        // 0: 최신순(ID desc), 1: 인기순(Likes desc)
+) : IQuery<Result<Response>>;

+ 27 - 0
Application/Features/Api/Forum/Comment/List/Response.cs

@@ -0,0 +1,27 @@
+namespace Application.Features.Api.Forum.Comment.List;
+
+public sealed record Response(int Total, int TotalRoots, List<Response.CommentItem> List)
+{
+    public sealed record WriterDto(int ID, string SID, string? Name, string? Thumbnail, string? Icon, string? GradeImage, DateTime CreatedAt);
+    public sealed record MentionDto(int ID, string RawHandle);
+    public sealed record CommentItem(
+        int ID,
+        int PostID,
+        int MemberID,
+        int? ParentID,
+        WriterDto Writer,
+        MentionDto? Mention,
+        string Content,
+        bool IsReply,
+        bool IsSecret,
+        int Likes,
+        int Dislikes,
+        int Reports,
+        int Replies,
+        bool HasLike,
+        bool HasDislike,
+        bool HasReport,
+        DateTime CreatedAt,
+        List<CommentItem> Children
+    );
+}

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