improvement: 공통 응답/에러 포맷 고정 (#164) #198

Merged
seonkyu.kim merged 1 commits from improvement/#164-common-error-format into develop 2026-02-24 07:50:16 +00:00
8 changed files with 73 additions and 40 deletions
Showing only changes of commit 4bc08715fa - Show all commits

View File

@ -4,6 +4,7 @@ using Swashbuckle.AspNetCore.Annotations;
using SPMS.Application.DTOs.Auth;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Exceptions;
namespace SPMS.API.Controllers;
@ -83,9 +84,7 @@ public class AuthController : ControllerBase
{
var adminIdClaim = User.FindFirst("adminId")?.Value;
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
{
return Unauthorized(ApiResponse<object>.Fail("101", "인증 정보가 유효하지 않습니다."));
}
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
await _authService.LogoutAsync(adminId);
return Ok(ApiResponse.Success());
@ -116,9 +115,7 @@ public class AuthController : ControllerBase
{
var adminIdClaim = User.FindFirst("adminId")?.Value;
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
{
return Unauthorized(ApiResponse<object>.Fail("101", "인증 정보가 유효하지 않습니다."));
}
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
await _authService.ChangePasswordAsync(adminId, request);
return Ok(ApiResponse.Success());

View File

@ -4,6 +4,7 @@ using Swashbuckle.AspNetCore.Annotations;
using SPMS.Application.DTOs.Account;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Exceptions;
namespace SPMS.API.Controllers;
@ -30,9 +31,7 @@ public class ProfileController : ControllerBase
{
var adminIdClaim = User.FindFirst("adminId")?.Value;
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
{
return Unauthorized(ApiResponse<object>.Fail("101", "인증 정보가 유효하지 않습니다."));
}
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
var result = await _authService.GetProfileAsync(adminId);
return Ok(ApiResponse<ProfileResponseDto>.Success(result));
@ -49,9 +48,7 @@ public class ProfileController : ControllerBase
{
var adminIdClaim = User.FindFirst("adminId")?.Value;
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
{
return Unauthorized(ApiResponse<object>.Fail("101", "인증 정보가 유효하지 않습니다."));
}
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
var result = await _authService.UpdateProfileAsync(adminId, request);
return Ok(ApiResponse<ProfileResponseDto>.Success(result));

View File

@ -4,6 +4,7 @@ using Swashbuckle.AspNetCore.Annotations;
using SPMS.Application.DTOs.Service;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Exceptions;
namespace SPMS.API.Controllers;
@ -33,9 +34,7 @@ public class ServiceController : ControllerBase
{
var adminIdClaim = User.FindFirst("adminId")?.Value;
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
{
return Unauthorized(ApiResponse<object>.Fail("101", "인증 정보가 유효하지 않습니다."));
}
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
var result = await _serviceManagementService.CreateAsync(request, adminId);
return Ok(ApiResponse<CreateServiceResponseDto>.Success(result));

View File

@ -20,7 +20,26 @@ builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// ===== 3. Presentation =====
builder.Services.AddControllers();
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var fieldErrors = context.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.SelectMany(e => e.Value!.Errors.Select(err => new FieldError
{
Field = e.Key,
Message = err.ErrorMessage
}))
.ToList();
var response = ApiResponseExtensions.ValidationFail(
ErrorCodes.BadRequest, "입력값 검증 실패", fieldErrors);
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(response);
};
});
builder.Services.AddSwaggerDocumentation();
builder.Services.AddJwtAuthentication(builder.Configuration);
builder.Services.AddAuthorizationPolicies();

View File

@ -1,13 +1,9 @@
using SPMS.Domain.Common;
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;
public List<FieldError> Errors { get; set; } = [];
}

View File

@ -1,6 +1,7 @@
using System.Text.Json;
using SPMS.Application.DTOs.Message;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
namespace SPMS.Application.Services;
@ -13,7 +14,7 @@ public class MessageValidationService : IMessageValidationService
public MessageValidationResultDto Validate(MessageValidateRequestDto request)
{
var errors = new List<ValidationErrorDto>();
var errors = new List<FieldError>();
ValidateTitle(request.Title, errors);
ValidateBody(request.Body, errors);
@ -29,59 +30,59 @@ public class MessageValidationService : IMessageValidationService
};
}
private static void ValidateTitle(string title, List<ValidationErrorDto> errors)
private static void ValidateTitle(string title, List<FieldError> errors)
{
if (string.IsNullOrWhiteSpace(title))
{
errors.Add(new ValidationErrorDto { Field = "title", Message = "제목은 필수입니다." });
errors.Add(new FieldError { Field = "title", Message = "제목은 필수입니다." });
return;
}
if (title.Length > MaxTitleLength)
errors.Add(new ValidationErrorDto { Field = "title", Message = $"제목은 {MaxTitleLength}자를 초과할 수 없습니다." });
errors.Add(new FieldError { Field = "title", Message = $"제목은 {MaxTitleLength}자를 초과할 수 없습니다." });
}
private static void ValidateBody(string body, List<ValidationErrorDto> errors)
private static void ValidateBody(string body, List<FieldError> errors)
{
if (string.IsNullOrWhiteSpace(body))
{
errors.Add(new ValidationErrorDto { Field = "body", Message = "본문은 필수입니다." });
errors.Add(new FieldError { Field = "body", Message = "본문은 필수입니다." });
return;
}
if (body.Length > MaxBodyLength)
errors.Add(new ValidationErrorDto { Field = "body", Message = $"본문은 {MaxBodyLength}자를 초과할 수 없습니다." });
errors.Add(new FieldError { Field = "body", Message = $"본문은 {MaxBodyLength}자를 초과할 수 없습니다." });
}
private static void ValidateImageUrl(string? imageUrl, List<ValidationErrorDto> errors)
private static void ValidateImageUrl(string? imageUrl, List<FieldError> 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 형식이 아닙니다." });
errors.Add(new FieldError { Field = "image_url", Message = "유효한 URL 형식이 아닙니다." });
}
private static void ValidateLinkUrl(string? linkUrl, List<ValidationErrorDto> errors)
private static void ValidateLinkUrl(string? linkUrl, List<FieldError> errors)
{
if (string.IsNullOrWhiteSpace(linkUrl))
return;
if (!Uri.TryCreate(linkUrl, UriKind.Absolute, out _))
errors.Add(new ValidationErrorDto { Field = "link_url", Message = "유효한 URL 형식이 아닙니다." });
errors.Add(new FieldError { Field = "link_url", Message = "유효한 URL 형식이 아닙니다." });
}
private static void ValidateLinkType(string? linkType, List<ValidationErrorDto> errors)
private static void ValidateLinkType(string? linkType, List<FieldError> errors)
{
if (string.IsNullOrWhiteSpace(linkType))
return;
if (!AllowedLinkTypes.Contains(linkType.ToLowerInvariant()))
errors.Add(new ValidationErrorDto { Field = "link_type", Message = "link_type은 deeplink, web, none 중 하나여야 합니다." });
errors.Add(new FieldError { Field = "link_type", Message = "link_type은 deeplink, web, none 중 하나여야 합니다." });
}
private static void ValidateData(string? data, List<ValidationErrorDto> errors)
private static void ValidateData(string? data, List<FieldError> errors)
{
if (string.IsNullOrWhiteSpace(data))
return;
@ -91,17 +92,17 @@ public class MessageValidationService : IMessageValidationService
using var doc = JsonDocument.Parse(data);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
errors.Add(new ValidationErrorDto { Field = "data", Message = "data는 JSON 객체여야 합니다." });
errors.Add(new FieldError { Field = "data", Message = "data는 JSON 객체여야 합니다." });
return;
}
}
catch (JsonException)
{
errors.Add(new ValidationErrorDto { Field = "data", Message = "유효한 JSON 형식이 아닙니다." });
errors.Add(new FieldError { Field = "data", Message = "유효한 JSON 형식이 아닙니다." });
return;
}
if (System.Text.Encoding.UTF8.GetByteCount(data) > MaxDataSizeBytes)
errors.Add(new ValidationErrorDto { Field = "data", Message = $"data는 {MaxDataSizeBytes / 1024}KB를 초과할 수 없습니다." });
errors.Add(new FieldError { Field = "data", Message = $"data는 {MaxDataSizeBytes / 1024}KB를 초과할 수 없습니다." });
}
}

View File

@ -34,3 +34,15 @@ public class ApiResponse<T> : ApiResponse
public new static ApiResponse<T> Fail(string code, string msg)
=> new() { Result = false, Code = code, Msg = msg };
}
public class ValidationErrorData
{
[JsonPropertyName("fieldErrors")]
public List<FieldError> FieldErrors { get; init; } = [];
}
public static class ApiResponseExtensions
{
public static ApiResponse<ValidationErrorData> ValidationFail(string code, string msg, List<FieldError> fieldErrors)
=> new() { Result = false, Code = code, Msg = msg, Data = new ValidationErrorData { FieldErrors = fieldErrors } };
}

View File

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SPMS.Domain.Common;
public class FieldError
{
[JsonPropertyName("field")]
public string Field { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
}