feat: 메시지 유효성 검사 서비스 구현 (#118) #119

Merged
seonkyu.kim merged 1 commits from feature/#118-message-validation into develop 2026-02-10 08:18:12 +00:00
6 changed files with 185 additions and 0 deletions

View File

@ -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<MessageValidationResultDto>.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);
}
}

View File

@ -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; }
}

View File

@ -0,0 +1,13 @@
namespace SPMS.Application.DTOs.Message;
public class MessageValidationResultDto
{
public bool IsValid { get; set; }
public List<ValidationErrorDto> Errors { get; set; } = [];
}
public class ValidationErrorDto
{
public string Field { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}

View File

@ -19,6 +19,7 @@ public static class DependencyInjection
services.AddScoped<IDeviceService, DeviceService>(); services.AddScoped<IDeviceService, DeviceService>();
services.AddScoped<IFileService, FileService>(); services.AddScoped<IFileService, FileService>();
services.AddScoped<IPushService, PushService>(); services.AddScoped<IPushService, PushService>();
services.AddSingleton<IMessageValidationService, MessageValidationService>();
return services; return services;
} }

View File

@ -0,0 +1,8 @@
using SPMS.Application.DTOs.Message;
namespace SPMS.Application.Interfaces;
public interface IMessageValidationService
{
MessageValidationResultDto Validate(MessageValidateRequestDto request);
}

View File

@ -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<ValidationErrorDto>();
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<ValidationErrorDto> 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<ValidationErrorDto> 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<ValidationErrorDto> 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<ValidationErrorDto> 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<ValidationErrorDto> 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<ValidationErrorDto> 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를 초과할 수 없습니다." });
}
}