서버 게시글
This commit is contained in:
575
GameServer/Controllers/Portfolio/PortfolioPostController.cs
Normal file
575
GameServer/Controllers/Portfolio/PortfolioPostController.cs
Normal file
@@ -0,0 +1,575 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GameServer.Data; // AppDbContext
|
||||
using GameServer.Models; // ApiResponse<T>
|
||||
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<IActionResult> 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<List<PostSummaryDto>>
|
||||
{
|
||||
Data = new List<PostSummaryDto>(),
|
||||
Count = 0,
|
||||
Message = "존재하지 않는 카테고리입니다."
|
||||
});
|
||||
}
|
||||
|
||||
categoryNo = found.Value;
|
||||
}
|
||||
|
||||
IQueryable<PortfolioPostModel> query = _context.PortfolioPosts.AsNoTracking();
|
||||
if (categoryNo.HasValue)
|
||||
{
|
||||
int filter = categoryNo.Value;
|
||||
query = query.Where(p => p.CategoryNo == filter);
|
||||
}
|
||||
|
||||
int total = await query.CountAsync();
|
||||
|
||||
List<PortfolioPostModel> posts = await query
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ThenByDescending(p => p.PostNo)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToListAsync();
|
||||
|
||||
List<PostSummaryDto> list = new List<PostSummaryDto>(posts.Count);
|
||||
|
||||
if (posts.Count > 0)
|
||||
{
|
||||
// 이미지 개수는 그룹 쿼리 한 번, 카테고리는 IN 조회 한 번으로 끝냅니다. (N+1 없음)
|
||||
List<int> postNos = posts.Select(p => p.PostNo).ToList();
|
||||
List<int> 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<int, int> imageCounts = new Dictionary<int, int>();
|
||||
foreach (var row in groupedImages)
|
||||
{
|
||||
imageCounts[row.PostNo] = row.Total;
|
||||
}
|
||||
|
||||
List<PortfolioCategoryModel> categories = await _context.PortfolioCategories
|
||||
.AsNoTracking()
|
||||
.Where(c => categoryNos.Contains(c.CategoryNo))
|
||||
.ToListAsync();
|
||||
|
||||
Dictionary<int, PortfolioCategoryModel> categoryMap =
|
||||
new Dictionary<int, PortfolioCategoryModel>();
|
||||
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<List<PostSummaryDto>>
|
||||
{
|
||||
Data = list,
|
||||
Count = total, // 페이지 길이가 아니라 전체 개수입니다.
|
||||
Message = "Success"
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GET /api/portfolio/posts/{postNo} (공개)
|
||||
// ------------------------------------------------------------------
|
||||
[HttpGet("{postNo:int}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> 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<PortfolioPostImageModel> images = await LoadImagesAsync(postNo, tracking: false);
|
||||
|
||||
return Ok(new ApiResponse<PostDetailDto>
|
||||
{
|
||||
Data = ToDetail(post, category, images),
|
||||
Message = "Success"
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// POST /api/portfolio/posts (관리자)
|
||||
// 게시물과 이미지 행을 함께 만듭니다.
|
||||
// ------------------------------------------------------------------
|
||||
[HttpPost("")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> 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<PortfolioPostImageModel> saved = SortImages(input.Images);
|
||||
|
||||
return Ok(new ApiResponse<PostDetailDto>
|
||||
{
|
||||
Data = ToDetail(post, category, saved),
|
||||
Message = "게시물을 등록했습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// PUT /api/portfolio/posts/{postNo} (관리자)
|
||||
// 스칼라 필드를 바꾸고 이미지 목록은 통째로 교체합니다.
|
||||
// CreatedAt 은 그대로 두고 UpdatedAt 만 갱신합니다.
|
||||
// ------------------------------------------------------------------
|
||||
[HttpPut("{postNo:int}")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> 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<PortfolioPostImageModel> 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<PortfolioPostImageModel> saved = SortImages(input.Images);
|
||||
|
||||
return Ok(new ApiResponse<PostDetailDto>
|
||||
{
|
||||
Data = ToDetail(post, category, saved),
|
||||
Message = "게시물을 수정했습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// DELETE /api/portfolio/posts/{postNo} (관리자)
|
||||
// 이미지 행을 먼저 지우고 게시물을 지웁니다.
|
||||
// ------------------------------------------------------------------
|
||||
[HttpDelete("{postNo:int}")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> 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<PortfolioPostImageModel> 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<object>
|
||||
{
|
||||
Data = null,
|
||||
Message = "게시물을 삭제했습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 내부 도우미
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// 게시물 이미지들을 SortOrder → ImageNo 순으로 읽습니다.
|
||||
private async Task<List<PortfolioPostImageModel>> LoadImagesAsync(int postNo, bool tracking)
|
||||
{
|
||||
IQueryable<PortfolioPostImageModel> 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<PortfolioPostImageModel> SortImages(List<PortfolioPostImageModel> 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<PortfolioPostImageModel> 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<PostImageDto>(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<PortfolioPostImageModel> Images) NormalizePost(PostUpsertDto dto)
|
||||
{
|
||||
List<PortfolioPostImageModel> images = new List<PortfolioPostImageModel>();
|
||||
|
||||
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<PostImageUpsertDto> incoming = dto.Images ?? new List<PostImageUpsertDto>();
|
||||
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<T> 형태를 유지합니다.
|
||||
private static ApiResponse<object> Error(string message)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
Data = null,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user