using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using GameServer.Data; // AppDbContext using GameServer.Models; // ApiResponse using GameServer.Models.Portfolio; // 포트폴리오 모델 / DTO using GameServer.Services; // PortfolioAdminAuth namespace GameServer.Controllers.Portfolio { // 포트폴리오 게시물 API // 조회는 누구나 가능하고, 생성/수정/삭제는 관리자 쿠키 인증이 필요합니다. [ApiController] [Route("api/portfolio/posts")] public class PortfolioPostController : ControllerBase { private const int DefaultPageSize = 12; private const int MinPageSize = 1; private const int MaxPageSize = 50; private const int TitleMaxLength = 200; private const int SummaryMaxLength = 500; private const int ThumbnailUrlMaxLength = 500; private const int ImageUrlMaxLength = 500; private const int CaptionMaxLength = 255; // 업로드된 이미지가 놓이는 사이트 내부 경로 접두사. private const string UploadsPrefix = "/uploads/"; private readonly AppDbContext _context; public PortfolioPostController(AppDbContext context) { _context = context; } // ------------------------------------------------------------------ // GET /api/portfolio/posts?category=&page=&size= (공개) // Count 에는 현재 페이지 길이가 아니라 조건에 맞는 "전체" 개수를 담습니다. // 정렬은 CreatedAt 내림차순 → PostNo 내림차순. // ------------------------------------------------------------------ [HttpGet("")] [AllowAnonymous] public async Task GetPosts( [FromQuery] string? category = null, [FromQuery] int page = 1, [FromQuery] int size = DefaultPageSize) { // 페이지 값 보정 if (page < 1) { page = 1; } if (size < MinPageSize) { size = MinPageSize; } if (size > MaxPageSize) { size = MaxPageSize; } // 카테고리 슬러그 필터: 비어 있거나 all 이면 필터를 걸지 않습니다. int? categoryNo = null; string? slug = Clean(category); if (slug != null && !string.Equals(slug, "all", StringComparison.OrdinalIgnoreCase)) { int? found = await _context.PortfolioCategories .AsNoTracking() .Where(c => c.Slug == slug) .Select(c => (int?)c.CategoryNo) .FirstOrDefaultAsync(); if (found == null) { // 존재하지 않는 슬러그는 404 가 아니라 "빈 페이지" 로 돌려줍니다. return Ok(new ApiResponse> { Data = new List(), Count = 0, Message = "존재하지 않는 카테고리입니다." }); } categoryNo = found.Value; } IQueryable query = _context.PortfolioPosts.AsNoTracking(); if (categoryNo.HasValue) { int filter = categoryNo.Value; query = query.Where(p => p.CategoryNo == filter); } int total = await query.CountAsync(); List posts = await query .OrderByDescending(p => p.CreatedAt) .ThenByDescending(p => p.PostNo) .Skip((page - 1) * size) .Take(size) .ToListAsync(); List list = new List(posts.Count); if (posts.Count > 0) { // 이미지 개수는 그룹 쿼리 한 번, 카테고리는 IN 조회 한 번으로 끝냅니다. (N+1 없음) List postNos = posts.Select(p => p.PostNo).ToList(); List categoryNos = posts.Select(p => p.CategoryNo).Distinct().ToList(); var groupedImages = await _context.PortfolioPostImages .AsNoTracking() .Where(i => postNos.Contains(i.PostNo)) .GroupBy(i => i.PostNo) .Select(g => new { PostNo = g.Key, Total = g.Count() }) .ToListAsync(); Dictionary imageCounts = new Dictionary(); foreach (var row in groupedImages) { imageCounts[row.PostNo] = row.Total; } List categories = await _context.PortfolioCategories .AsNoTracking() .Where(c => categoryNos.Contains(c.CategoryNo)) .ToListAsync(); Dictionary categoryMap = new Dictionary(); foreach (PortfolioCategoryModel c in categories) { categoryMap[c.CategoryNo] = c; } // 여기서부터는 메모리 조인입니다. foreach (PortfolioPostModel post in posts) { categoryMap.TryGetValue(post.CategoryNo, out PortfolioCategoryModel? postCategory); int imageCount = imageCounts.TryGetValue(post.PostNo, out int n) ? n : 0; list.Add(ToSummary(post, postCategory, imageCount)); } } return Ok(new ApiResponse> { Data = list, Count = total, // 페이지 길이가 아니라 전체 개수입니다. Message = "Success" }); } // ------------------------------------------------------------------ // GET /api/portfolio/posts/{postNo} (공개) // ------------------------------------------------------------------ [HttpGet("{postNo:int}")] [AllowAnonymous] public async Task GetPost(int postNo) { PortfolioPostModel? post = await _context.PortfolioPosts .AsNoTracking() .FirstOrDefaultAsync(p => p.PostNo == postNo); if (post == null) { return NotFound(Error("게시물을 찾을 수 없습니다.")); } PortfolioCategoryModel? category = await _context.PortfolioCategories .AsNoTracking() .FirstOrDefaultAsync(c => c.CategoryNo == post.CategoryNo); List images = await LoadImagesAsync(postNo, tracking: false); return Ok(new ApiResponse { Data = ToDetail(post, category, images), Message = "Success" }); } // ------------------------------------------------------------------ // POST /api/portfolio/posts (관리자) // 게시물과 이미지 행을 함께 만듭니다. // ------------------------------------------------------------------ [HttpPost("")] [Authorize(Policy = PortfolioAdminAuth.PolicyName)] public async Task CreatePost([FromBody] PostUpsertDto? dto) { if (dto == null) { return BadRequest(Error("요청 본문이 비어 있습니다.")); } var input = NormalizePost(dto); if (input.Error != null) { return BadRequest(Error(input.Error)); } PortfolioCategoryModel? category = await _context.PortfolioCategories .AsNoTracking() .FirstOrDefaultAsync(c => c.CategoryNo == dto.CategoryNo); if (category == null) { return BadRequest(Error("존재하지 않는 카테고리입니다.")); } DateTime now = DateTime.UtcNow; PortfolioPostModel post = new PortfolioPostModel { CategoryNo = category.CategoryNo, Title = input.Title, Summary = input.Summary, ContentMd = input.ContentMd, ThumbnailUrl = input.ThumbnailUrl, CreatedAt = now, UpdatedAt = now }; // 이미지 행은 자동 증가로 생성되는 PostNo 를 알아야 채울 수 있으므로 // 저장을 두 단계로 나눕니다. 트랜잭션으로 묶어 원자성을 지킵니다. using (var transaction = await _context.Database.BeginTransactionAsync()) { _context.PortfolioPosts.Add(post); await _context.SaveChangesAsync(); if (input.Images.Count > 0) { foreach (PortfolioPostImageModel image in input.Images) { image.PostNo = post.PostNo; image.CreatedAt = now; } _context.PortfolioPostImages.AddRange(input.Images); await _context.SaveChangesAsync(); } await transaction.CommitAsync(); } List saved = SortImages(input.Images); return Ok(new ApiResponse { Data = ToDetail(post, category, saved), Message = "게시물을 등록했습니다." }); } // ------------------------------------------------------------------ // PUT /api/portfolio/posts/{postNo} (관리자) // 스칼라 필드를 바꾸고 이미지 목록은 통째로 교체합니다. // CreatedAt 은 그대로 두고 UpdatedAt 만 갱신합니다. // ------------------------------------------------------------------ [HttpPut("{postNo:int}")] [Authorize(Policy = PortfolioAdminAuth.PolicyName)] public async Task UpdatePost(int postNo, [FromBody] PostUpsertDto? dto) { if (dto == null) { return BadRequest(Error("요청 본문이 비어 있습니다.")); } var input = NormalizePost(dto); if (input.Error != null) { return BadRequest(Error(input.Error)); } PortfolioPostModel? post = await _context.PortfolioPosts .FirstOrDefaultAsync(p => p.PostNo == postNo); if (post == null) { return NotFound(Error("게시물을 찾을 수 없습니다.")); } PortfolioCategoryModel? category = await _context.PortfolioCategories .AsNoTracking() .FirstOrDefaultAsync(c => c.CategoryNo == dto.CategoryNo); if (category == null) { return BadRequest(Error("존재하지 않는 카테고리입니다.")); } DateTime now = DateTime.UtcNow; post.CategoryNo = category.CategoryNo; post.Title = input.Title; post.Summary = input.Summary; post.ContentMd = input.ContentMd; post.ThumbnailUrl = input.ThumbnailUrl; post.UpdatedAt = now; // CreatedAt 은 건드리지 않습니다. // 기존 이미지 행을 지우고 새 목록을 넣습니다. // PostNo 를 이미 알고 있으므로 한 번의 SaveChangesAsync 안에서 원자적으로 처리됩니다. List existing = await LoadImagesAsync(postNo, tracking: true); if (existing.Count > 0) { _context.PortfolioPostImages.RemoveRange(existing); } if (input.Images.Count > 0) { foreach (PortfolioPostImageModel image in input.Images) { image.PostNo = postNo; image.CreatedAt = now; } _context.PortfolioPostImages.AddRange(input.Images); } await _context.SaveChangesAsync(); List saved = SortImages(input.Images); return Ok(new ApiResponse { Data = ToDetail(post, category, saved), Message = "게시물을 수정했습니다." }); } // ------------------------------------------------------------------ // DELETE /api/portfolio/posts/{postNo} (관리자) // 이미지 행을 먼저 지우고 게시물을 지웁니다. // ------------------------------------------------------------------ [HttpDelete("{postNo:int}")] [Authorize(Policy = PortfolioAdminAuth.PolicyName)] public async Task DeletePost(int postNo) { PortfolioPostModel? post = await _context.PortfolioPosts .FirstOrDefaultAsync(p => p.PostNo == postNo); if (post == null) { return NotFound(Error("게시물을 찾을 수 없습니다.")); } // 이미지 → 게시물 순서로 저장을 두 번 나눈다. // 모델에 탐색 속성이 없어 EF 는 두 테이블의 의존 관계를 모른다. // 한 번의 SaveChanges 로 묶으면 게시물 DELETE 가 먼저 나갈 수 있고, // 그러면 DB 의 ON DELETE CASCADE 가 이미지 행을 이미 지워 버려서 // 뒤따르는 이미지 DELETE 가 0행이 되고 DbUpdateConcurrencyException 이 난다. await using var tx = await _context.Database.BeginTransactionAsync(); List images = await LoadImagesAsync(postNo, tracking: true); if (images.Count > 0) { _context.PortfolioPostImages.RemoveRange(images); await _context.SaveChangesAsync(); } _context.PortfolioPosts.Remove(post); await _context.SaveChangesAsync(); await tx.CommitAsync(); return Ok(new ApiResponse { Data = null, Message = "게시물을 삭제했습니다." }); } // ------------------------------------------------------------------ // 내부 도우미 // ------------------------------------------------------------------ // 게시물 이미지들을 SortOrder → ImageNo 순으로 읽습니다. private async Task> LoadImagesAsync(int postNo, bool tracking) { IQueryable query = _context.PortfolioPostImages; if (!tracking) { query = query.AsNoTracking(); } return await query .Where(i => i.PostNo == postNo) .OrderBy(i => i.SortOrder) .ThenBy(i => i.ImageNo) .ToListAsync(); } // 방금 저장한 이미지 목록을 응답용 순서로 정렬합니다. private static List SortImages(List images) { return images .OrderBy(i => i.SortOrder) .ThenBy(i => i.ImageNo) .ToList(); } private static PostSummaryDto ToSummary( PortfolioPostModel post, PortfolioCategoryModel? category, int imageCount) { return new PostSummaryDto { PostNo = post.PostNo, CategoryNo = post.CategoryNo, CategorySlug = category?.Slug ?? string.Empty, CategoryName = category?.Name ?? string.Empty, CategoryAccent = category?.Accent, Title = post.Title, Summary = post.Summary, ThumbnailUrl = post.ThumbnailUrl, ImageCount = imageCount, CreatedAt = post.CreatedAt, UpdatedAt = post.UpdatedAt }; } private static PostDetailDto ToDetail( PortfolioPostModel post, PortfolioCategoryModel? category, List images) { PostDetailDto dto = new PostDetailDto { PostNo = post.PostNo, CategoryNo = post.CategoryNo, CategorySlug = category?.Slug ?? string.Empty, CategoryName = category?.Name ?? string.Empty, CategoryAccent = category?.Accent, Title = post.Title, Summary = post.Summary, ThumbnailUrl = post.ThumbnailUrl, ImageCount = images.Count, CreatedAt = post.CreatedAt, UpdatedAt = post.UpdatedAt, ContentMd = post.ContentMd, Images = new List(images.Count) }; foreach (PortfolioPostImageModel image in images) { dto.Images.Add(new PostImageDto { ImageNo = image.ImageNo, ImageUrl = image.ImageUrl, Caption = image.Caption, SortOrder = image.SortOrder }); } return dto; } // 들어온 값을 다듬고 검증합니다. Error 가 null 이 아니면 400 으로 응답합니다. // 길이를 넘기면 잘라내지 않고 거절합니다. private static (string? Error, string Title, string? Summary, string? ContentMd, string? ThumbnailUrl, List Images) NormalizePost(PostUpsertDto dto) { List images = new List(); string title = Clean(dto.Title) ?? string.Empty; string? summary = Clean(dto.Summary); string? contentMd = Clean(dto.ContentMd); string? thumbnailUrl = Clean(dto.ThumbnailUrl); if (title.Length == 0) { return ("제목을 입력해 주세요.", title, summary, contentMd, thumbnailUrl, images); } if (title.Length > TitleMaxLength) { return ($"제목은 {TitleMaxLength}자 이하여야 합니다.", title, summary, contentMd, thumbnailUrl, images); } if (summary != null && summary.Length > SummaryMaxLength) { return ($"요약은 {SummaryMaxLength}자 이하여야 합니다.", title, summary, contentMd, thumbnailUrl, images); } if (thumbnailUrl != null) { if (thumbnailUrl.Length > ThumbnailUrlMaxLength) { return ($"대표 이미지 주소는 {ThumbnailUrlMaxLength}자 이하여야 합니다.", title, summary, contentMd, thumbnailUrl, images); } if (!IsAllowedMediaUrl(thumbnailUrl)) { return ("대표 이미지 주소는 /uploads/ 로 시작하는 경로이거나 http(s) 주소여야 합니다.", title, summary, contentMd, thumbnailUrl, images); } } List incoming = dto.Images ?? new List(); foreach (PostImageUpsertDto item in incoming) { if (item == null) { continue; } string? imageUrl = Clean(item.ImageUrl); string? caption = Clean(item.Caption); if (imageUrl == null) { return ("이미지 주소를 입력해 주세요.", title, summary, contentMd, thumbnailUrl, images); } if (imageUrl.Length > ImageUrlMaxLength) { return ($"이미지 주소는 {ImageUrlMaxLength}자 이하여야 합니다.", title, summary, contentMd, thumbnailUrl, images); } if (!IsAllowedMediaUrl(imageUrl)) { return ("이미지 주소는 /uploads/ 로 시작하는 경로이거나 http(s) 주소여야 합니다.", title, summary, contentMd, thumbnailUrl, images); } if (caption != null && caption.Length > CaptionMaxLength) { return ($"이미지 설명은 {CaptionMaxLength}자 이하여야 합니다.", title, summary, contentMd, thumbnailUrl, images); } images.Add(new PortfolioPostImageModel { ImageUrl = imageUrl, Caption = caption, SortOrder = item.SortOrder }); } return (null, title, summary, contentMd, thumbnailUrl, images); } // 허용하는 이미지 주소인지 확인합니다. // 1) /uploads/ 로 시작하는 사이트 내부 경로 2) http:// 또는 https:// 절대 주소 private static bool IsAllowedMediaUrl(string url) { if (url.StartsWith(UploadsPrefix, StringComparison.Ordinal)) { // 상위 경로 탈출(..)은 막습니다. return url.Length > UploadsPrefix.Length && !url.Contains("..", StringComparison.Ordinal); } if (Uri.TryCreate(url, UriKind.Absolute, out Uri? parsed)) { return parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps; } return false; } // 앞뒤 공백을 제거하고, 빈 문자열은 null 로 바꿉니다. private static string? Clean(string? value) { if (value == null) { return null; } string trimmed = value.Trim(); return trimmed.Length == 0 ? null : trimmed; } // 오류 응답도 ApiResponse 형태를 유지합니다. private static ApiResponse Error(string message) { return new ApiResponse { Data = null, Message = message }; } } }