From ce7b8b3d352371d4012cd2aeb22d2272728d319d Mon Sep 17 00:00:00 2001 From: SEAN Date: Tue, 10 Feb 2026 17:15:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MessageValidationService: title/body/image_url/link_url/link_type/data 검증 - POST /v1/in/message/validate 엔드포인트 추가 - MessageController 기반 구성 Closes #118 --- SPMS.API/Controllers/MessageController.cs | 36 ++++++ .../DTOs/Message/MessageValidateRequestDto.cs | 20 ++++ .../Message/MessageValidationResultDto.cs | 13 +++ SPMS.Application/DependencyInjection.cs | 1 + .../Interfaces/IMessageValidationService.cs | 8 ++ .../Services/MessageValidationService.cs | 107 ++++++++++++++++++ 6 files changed, 185 insertions(+) create mode 100644 SPMS.API/Controllers/MessageController.cs create mode 100644 SPMS.Application/DTOs/Message/MessageValidateRequestDto.cs create mode 100644 SPMS.Application/DTOs/Message/MessageValidationResultDto.cs create mode 100644 SPMS.Application/Interfaces/IMessageValidationService.cs create mode 100644 SPMS.Application/Services/MessageValidationService.cs diff --git a/SPMS.API/Controllers/MessageController.cs b/SPMS.API/Controllers/MessageController.cs new file mode 100644 index 0000000..00ecc5f --- /dev/null +++ b/SPMS.API/Controllers/MessageController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Message; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/message")] +[ApiExplorerSettings(GroupName = "message")] +public class MessageController : ControllerBase +{ + private readonly IMessageValidationService _validationService; + + public MessageController(IMessageValidationService validationService) + { + _validationService = validationService; + } + + [HttpPost("validate")] + [SwaggerOperation(Summary = "메시지 유효성 검사", Description = "메시지 내용의 유효성을 검사합니다.")] + public IActionResult ValidateAsync([FromBody] MessageValidateRequestDto request) + { + var result = _validationService.Validate(request); + return Ok(ApiResponse.Success(result)); + } + + private long GetServiceId() + { + if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) + return serviceId; + + throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400); + } +} diff --git a/SPMS.Application/DTOs/Message/MessageValidateRequestDto.cs b/SPMS.Application/DTOs/Message/MessageValidateRequestDto.cs new file mode 100644 index 0000000..b5a9fbb --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageValidateRequestDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Message; + +public class MessageValidateRequestDto +{ + [Required] + public string Title { get; set; } = string.Empty; + + [Required] + public string Body { get; set; } = string.Empty; + + public string? ImageUrl { get; set; } + + public string? LinkUrl { get; set; } + + public string? LinkType { get; set; } + + public string? Data { get; set; } +} diff --git a/SPMS.Application/DTOs/Message/MessageValidationResultDto.cs b/SPMS.Application/DTOs/Message/MessageValidationResultDto.cs new file mode 100644 index 0000000..67296db --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageValidationResultDto.cs @@ -0,0 +1,13 @@ +namespace SPMS.Application.DTOs.Message; + +public class MessageValidationResultDto +{ + public bool IsValid { get; set; } + public List Errors { get; set; } = []; +} + +public class ValidationErrorDto +{ + public string Field { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index 5d01307..91cbc95 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -19,6 +19,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); return services; } diff --git a/SPMS.Application/Interfaces/IMessageValidationService.cs b/SPMS.Application/Interfaces/IMessageValidationService.cs new file mode 100644 index 0000000..683ac6a --- /dev/null +++ b/SPMS.Application/Interfaces/IMessageValidationService.cs @@ -0,0 +1,8 @@ +using SPMS.Application.DTOs.Message; + +namespace SPMS.Application.Interfaces; + +public interface IMessageValidationService +{ + MessageValidationResultDto Validate(MessageValidateRequestDto request); +} diff --git a/SPMS.Application/Services/MessageValidationService.cs b/SPMS.Application/Services/MessageValidationService.cs new file mode 100644 index 0000000..24ec45e --- /dev/null +++ b/SPMS.Application/Services/MessageValidationService.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using SPMS.Application.DTOs.Message; +using SPMS.Application.Interfaces; + +namespace SPMS.Application.Services; + +public class MessageValidationService : IMessageValidationService +{ + private const int MaxTitleLength = 100; + private const int MaxBodyLength = 2000; + private const int MaxDataSizeBytes = 4096; + private static readonly string[] AllowedLinkTypes = ["deeplink", "web", "none"]; + + public MessageValidationResultDto Validate(MessageValidateRequestDto request) + { + var errors = new List(); + + ValidateTitle(request.Title, errors); + ValidateBody(request.Body, errors); + ValidateImageUrl(request.ImageUrl, errors); + ValidateLinkUrl(request.LinkUrl, errors); + ValidateLinkType(request.LinkType, errors); + ValidateData(request.Data, errors); + + return new MessageValidationResultDto + { + IsValid = errors.Count == 0, + Errors = errors + }; + } + + private static void ValidateTitle(string title, List errors) + { + if (string.IsNullOrWhiteSpace(title)) + { + errors.Add(new ValidationErrorDto { Field = "title", Message = "제목은 필수입니다." }); + return; + } + + if (title.Length > MaxTitleLength) + errors.Add(new ValidationErrorDto { Field = "title", Message = $"제목은 {MaxTitleLength}자를 초과할 수 없습니다." }); + } + + private static void ValidateBody(string body, List errors) + { + if (string.IsNullOrWhiteSpace(body)) + { + errors.Add(new ValidationErrorDto { Field = "body", Message = "본문은 필수입니다." }); + return; + } + + if (body.Length > MaxBodyLength) + errors.Add(new ValidationErrorDto { Field = "body", Message = $"본문은 {MaxBodyLength}자를 초과할 수 없습니다." }); + } + + private static void ValidateImageUrl(string? imageUrl, List errors) + { + if (string.IsNullOrWhiteSpace(imageUrl)) + return; + + if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri) || + (uri.Scheme != "http" && uri.Scheme != "https")) + errors.Add(new ValidationErrorDto { Field = "image_url", Message = "유효한 URL 형식이 아닙니다." }); + } + + private static void ValidateLinkUrl(string? linkUrl, List errors) + { + if (string.IsNullOrWhiteSpace(linkUrl)) + return; + + if (!Uri.TryCreate(linkUrl, UriKind.Absolute, out _)) + errors.Add(new ValidationErrorDto { Field = "link_url", Message = "유효한 URL 형식이 아닙니다." }); + } + + private static void ValidateLinkType(string? linkType, List errors) + { + if (string.IsNullOrWhiteSpace(linkType)) + return; + + if (!AllowedLinkTypes.Contains(linkType.ToLowerInvariant())) + errors.Add(new ValidationErrorDto { Field = "link_type", Message = "link_type은 deeplink, web, none 중 하나여야 합니다." }); + } + + private static void ValidateData(string? data, List errors) + { + if (string.IsNullOrWhiteSpace(data)) + return; + + try + { + using var doc = JsonDocument.Parse(data); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + errors.Add(new ValidationErrorDto { Field = "data", Message = "data는 JSON 객체여야 합니다." }); + return; + } + } + catch (JsonException) + { + errors.Add(new ValidationErrorDto { Field = "data", Message = "유효한 JSON 형식이 아닙니다." }); + return; + } + + if (System.Text.Encoding.UTF8.GetByteCount(data) > MaxDataSizeBytes) + errors.Add(new ValidationErrorDto { Field = "data", Message = $"data는 {MaxDataSizeBytes / 1024}KB를 초과할 수 없습니다." }); + } +}