feat: 메시지 CRUD API 구현 (#128) #129

Merged
seonkyu.kim merged 2 commits from feature/#128-message-crud-api into develop 2026-02-10 13:43:30 +00:00
13 changed files with 2302 additions and 2 deletions

View File

@ -3,6 +3,7 @@ using Swashbuckle.AspNetCore.Annotations;
using SPMS.Application.DTOs.Message; using SPMS.Application.DTOs.Message;
using SPMS.Application.Interfaces; using SPMS.Application.Interfaces;
using SPMS.Domain.Common; using SPMS.Domain.Common;
using SPMS.Domain.Exceptions;
namespace SPMS.API.Controllers; namespace SPMS.API.Controllers;
@ -20,6 +21,43 @@ public class MessageController : ControllerBase
_messageService = messageService; _messageService = messageService;
} }
[HttpPost("save")]
[SwaggerOperation(Summary = "메시지 저장", Description = "메시지 템플릿을 저장합니다. 메시지 코드가 자동 생성됩니다.")]
public async Task<IActionResult> SaveAsync([FromBody] MessageSaveRequestDto request)
{
var serviceId = GetServiceId();
var adminId = GetAdminId();
var result = await _messageService.SaveAsync(serviceId, adminId, request);
return Ok(ApiResponse<MessageSaveResponseDto>.Success(result, "메시지 저장 성공"));
}
[HttpPost("list")]
[SwaggerOperation(Summary = "메시지 목록 조회", Description = "서비스별 메시지 목록을 페이지 단위로 조회합니다.")]
public async Task<IActionResult> GetListAsync([FromBody] MessageListRequestDto request)
{
var serviceId = GetServiceId();
var result = await _messageService.GetListAsync(serviceId, request);
return Ok(ApiResponse<MessageListResponseDto>.Success(result));
}
[HttpPost("info")]
[SwaggerOperation(Summary = "메시지 상세 조회", Description = "메시지 코드로 상세 정보를 조회합니다. 템플릿 변수 목록을 포함합니다.")]
public async Task<IActionResult> GetInfoAsync([FromBody] MessageInfoRequestDto request)
{
var serviceId = GetServiceId();
var result = await _messageService.GetInfoAsync(serviceId, request);
return Ok(ApiResponse<MessageInfoResponseDto>.Success(result));
}
[HttpPost("delete")]
[SwaggerOperation(Summary = "메시지 삭제", Description = "메시지를 소프트 삭제합니다. 30일 후 스케줄러에 의해 완전 삭제됩니다.")]
public async Task<IActionResult> DeleteAsync([FromBody] MessageDeleteRequestDto request)
{
var serviceId = GetServiceId();
await _messageService.DeleteAsync(serviceId, request);
return Ok(ApiResponse<object?>.Success(null, "메시지 삭제 성공"));
}
[HttpPost("validate")] [HttpPost("validate")]
[SwaggerOperation(Summary = "메시지 유효성 검사", Description = "메시지 내용의 유효성을 검사합니다.")] [SwaggerOperation(Summary = "메시지 유효성 검사", Description = "메시지 내용의 유효성을 검사합니다.")]
public IActionResult ValidateAsync([FromBody] MessageValidateRequestDto request) public IActionResult ValidateAsync([FromBody] MessageValidateRequestDto request)
@ -42,6 +80,15 @@ public class MessageController : ControllerBase
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
return serviceId; return serviceId;
throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400); throw new SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400);
}
private long GetAdminId()
{
var adminIdClaim = User.FindFirst("adminId")?.Value;
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
throw new SpmsException(ErrorCodes.Unauthorized, "인증 정보가 올바르지 않습니다.", 401);
return adminId;
} }
} }

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Message;
public class MessageDeleteRequestDto
{
[JsonPropertyName("message_code")]
public string MessageCode { get; set; } = string.Empty;
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Message;
public class MessageInfoRequestDto
{
[JsonPropertyName("message_code")]
public string MessageCode { get; set; } = string.Empty;
}

View File

@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Message;
public class MessageInfoResponseDto
{
[JsonPropertyName("message_code")]
public string MessageCode { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("body")]
public string Body { get; set; } = string.Empty;
[JsonPropertyName("image_url")]
public string? ImageUrl { get; set; }
[JsonPropertyName("link_url")]
public string? LinkUrl { get; set; }
[JsonPropertyName("link_type")]
public string? LinkType { get; set; }
[JsonPropertyName("data")]
public object? Data { get; set; }
[JsonPropertyName("variables")]
public List<string> Variables { get; set; } = new();
[JsonPropertyName("is_active")]
public bool IsActive { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}

View File

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Message;
public class MessageListRequestDto
{
[JsonPropertyName("page")]
public int Page { get; set; } = 1;
[JsonPropertyName("size")]
public int Size { get; set; } = 20;
[JsonPropertyName("keyword")]
public string? Keyword { get; set; }
[JsonPropertyName("is_active")]
public bool? IsActive { get; set; }
}

View File

@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
using SPMS.Application.DTOs.Notice;
namespace SPMS.Application.DTOs.Message;
public class MessageListResponseDto
{
[JsonPropertyName("items")]
public List<MessageSummaryDto> Items { get; set; } = new();
[JsonPropertyName("pagination")]
public PaginationDto Pagination { get; set; } = new();
}
public class MessageSummaryDto
{
[JsonPropertyName("message_code")]
public string MessageCode { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("is_active")]
public bool IsActive { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}

View File

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Message;
public class MessageSaveRequestDto
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("body")]
public string Body { get; set; } = string.Empty;
[JsonPropertyName("image_url")]
public string? ImageUrl { get; set; }
[JsonPropertyName("link_url")]
public string? LinkUrl { get; set; }
[JsonPropertyName("link_type")]
public string? LinkType { get; set; }
[JsonPropertyName("data")]
public object? Data { get; set; }
}

View File

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Message;
public class MessageSaveResponseDto
{
[JsonPropertyName("message_code")]
public string MessageCode { get; set; } = string.Empty;
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}

View File

@ -4,5 +4,9 @@ namespace SPMS.Application.Interfaces;
public interface IMessageService public interface IMessageService
{ {
Task<MessageSaveResponseDto> SaveAsync(long serviceId, long adminId, MessageSaveRequestDto request);
Task<MessageListResponseDto> GetListAsync(long serviceId, MessageListRequestDto request);
Task<MessageInfoResponseDto> GetInfoAsync(long serviceId, MessageInfoRequestDto request);
Task DeleteAsync(long serviceId, MessageDeleteRequestDto request);
Task<MessagePreviewResponseDto> PreviewAsync(long serviceId, MessagePreviewRequestDto request); Task<MessagePreviewResponseDto> PreviewAsync(long serviceId, MessagePreviewRequestDto request);
} }

View File

@ -1,4 +1,7 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using SPMS.Application.DTOs.Message; using SPMS.Application.DTOs.Message;
using SPMS.Application.DTOs.Notice;
using SPMS.Application.Interfaces; using SPMS.Application.Interfaces;
using SPMS.Domain.Common; using SPMS.Domain.Common;
using SPMS.Domain.Exceptions; using SPMS.Domain.Exceptions;
@ -9,10 +12,133 @@ namespace SPMS.Application.Services;
public class MessageService : IMessageService public class MessageService : IMessageService
{ {
private readonly IMessageRepository _messageRepository; private readonly IMessageRepository _messageRepository;
private readonly IUnitOfWork _unitOfWork;
private static readonly Regex VariablePattern = new(@"\{\{(\w+)\}\}", RegexOptions.Compiled);
public MessageService(IMessageRepository messageRepository) public MessageService(IMessageRepository messageRepository, IUnitOfWork unitOfWork)
{ {
_messageRepository = messageRepository; _messageRepository = messageRepository;
_unitOfWork = unitOfWork;
}
public async Task<MessageSaveResponseDto> SaveAsync(long serviceId, long adminId, MessageSaveRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.Title))
throw new SpmsException(ErrorCodes.BadRequest, "제목은 필수입니다.", 400);
if (string.IsNullOrWhiteSpace(request.Body))
throw new SpmsException(ErrorCodes.BadRequest, "본문은 필수입니다.", 400);
var messageCode = await GenerateMessageCodeAsync(serviceId);
string? customData = null;
if (request.Data != null)
{
customData = request.Data is JsonElement jsonElement
? jsonElement.GetRawText()
: JsonSerializer.Serialize(request.Data);
}
var message = new Domain.Entities.Message
{
ServiceId = serviceId,
MessageCode = messageCode,
Title = request.Title,
Body = request.Body,
ImageUrl = request.ImageUrl,
LinkUrl = request.LinkUrl,
LinkType = request.LinkType,
CustomData = customData,
CreatedAt = DateTime.UtcNow,
CreatedBy = adminId,
IsDeleted = false
};
await _messageRepository.AddAsync(message);
await _unitOfWork.SaveChangesAsync();
return new MessageSaveResponseDto
{
MessageCode = messageCode,
CreatedAt = message.CreatedAt
};
}
public async Task<MessageListResponseDto> GetListAsync(long serviceId, MessageListRequestDto request)
{
var page = Math.Max(1, request.Page);
var size = Math.Clamp(request.Size, 1, 100);
var (items, totalCount) = await _messageRepository.GetPagedByServiceAsync(
serviceId, page, size,
m => (request.Keyword == null || m.Title.Contains(request.Keyword) || m.Body.Contains(request.Keyword))
&& (request.IsActive == null || m.IsDeleted != request.IsActive));
var totalPages = (int)Math.Ceiling((double)totalCount / size);
return new MessageListResponseDto
{
Items = items.Select(m => new MessageSummaryDto
{
MessageCode = m.MessageCode,
Title = m.Title,
IsActive = !m.IsDeleted,
CreatedAt = m.CreatedAt
}).ToList(),
Pagination = new PaginationDto
{
Page = page,
Size = size,
TotalCount = totalCount,
TotalPages = totalPages
}
};
}
public async Task<MessageInfoResponseDto> GetInfoAsync(long serviceId, MessageInfoRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.MessageCode))
throw new SpmsException(ErrorCodes.BadRequest, "메시지 코드는 필수입니다.", 400);
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
if (message == null)
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
var variables = ExtractVariables(message.Title, message.Body);
object? data = null;
if (!string.IsNullOrEmpty(message.CustomData))
{
data = JsonSerializer.Deserialize<JsonElement>(message.CustomData);
}
return new MessageInfoResponseDto
{
MessageCode = message.MessageCode,
Title = message.Title,
Body = message.Body,
ImageUrl = message.ImageUrl,
LinkUrl = message.LinkUrl,
LinkType = message.LinkType,
Data = data,
Variables = variables,
IsActive = !message.IsDeleted,
CreatedAt = message.CreatedAt
};
}
public async Task DeleteAsync(long serviceId, MessageDeleteRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.MessageCode))
throw new SpmsException(ErrorCodes.BadRequest, "메시지 코드는 필수입니다.", 400);
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
if (message == null)
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
message.IsDeleted = true;
message.DeletedAt = DateTime.UtcNow;
_messageRepository.Update(message);
await _unitOfWork.SaveChangesAsync();
} }
public async Task<MessagePreviewResponseDto> PreviewAsync(long serviceId, MessagePreviewRequestDto request) public async Task<MessagePreviewResponseDto> PreviewAsync(long serviceId, MessagePreviewRequestDto request)
@ -33,6 +159,41 @@ public class MessageService : IMessageService
}; };
} }
private async Task<string> GenerateMessageCodeAsync(long serviceId)
{
var today = DateTime.UtcNow.Date;
var seed = (int)(today.Ticks ^ serviceId);
var random = new Random(seed);
var prefix = GenerateRandomChars(random, 3);
var suffix = GenerateRandomChars(new Random(seed + 1), 3);
var sequence = await _messageRepository.GetTodaySequenceAsync(serviceId) + 1;
if (sequence > 9999)
throw new SpmsException(ErrorCodes.LimitExceeded, "일일 메시지 생성 한도(9999건)를 초과했습니다.", 429);
return $"{prefix}{sequence:D4}{suffix}";
}
private static string GenerateRandomChars(Random random, int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var result = new char[length];
for (var i = 0; i < length; i++)
result[i] = chars[random.Next(chars.Length)];
return new string(result);
}
private static List<string> ExtractVariables(string title, string body)
{
var variables = new HashSet<string>();
foreach (Match match in VariablePattern.Matches(title))
variables.Add(match.Groups[1].Value);
foreach (Match match in VariablePattern.Matches(body))
variables.Add(match.Groups[1].Value);
return variables.ToList();
}
private static string ApplyVariables(string template, Dictionary<string, string>? variables) private static string ApplyVariables(string template, Dictionary<string, string>? variables)
{ {
if (variables == null || variables.Count == 0) if (variables == null || variables.Count == 0)

View File

@ -1,3 +1,4 @@
using System.Linq.Expressions;
using SPMS.Domain.Entities; using SPMS.Domain.Entities;
namespace SPMS.Domain.Interfaces; namespace SPMS.Domain.Interfaces;
@ -6,4 +7,8 @@ public interface IMessageRepository : IRepository<Message>
{ {
Task<Message?> GetByMessageCodeAsync(string messageCode); Task<Message?> GetByMessageCodeAsync(string messageCode);
Task<Message?> GetByMessageCodeAndServiceAsync(string messageCode, long serviceId); Task<Message?> GetByMessageCodeAndServiceAsync(string messageCode, long serviceId);
Task<int> GetTodaySequenceAsync(long serviceId);
Task<(IReadOnlyList<Message> Items, int TotalCount)> GetPagedByServiceAsync(
long serviceId, int page, int size,
Expression<Func<Message, bool>>? predicate = null);
} }

View File

@ -1,3 +1,4 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SPMS.Domain.Entities; using SPMS.Domain.Entities;
using SPMS.Domain.Interfaces; using SPMS.Domain.Interfaces;
@ -19,4 +20,36 @@ public class MessageRepository : Repository<Message>, IMessageRepository
return await _dbSet return await _dbSet
.FirstOrDefaultAsync(m => m.MessageCode == messageCode && m.ServiceId == serviceId && !m.IsDeleted); .FirstOrDefaultAsync(m => m.MessageCode == messageCode && m.ServiceId == serviceId && !m.IsDeleted);
} }
public async Task<int> GetTodaySequenceAsync(long serviceId)
{
var todayStart = DateTime.UtcNow.Date;
var todayEnd = todayStart.AddDays(1);
return await _dbSet
.CountAsync(m => m.ServiceId == serviceId
&& m.CreatedAt >= todayStart
&& m.CreatedAt < todayEnd);
}
public async Task<(IReadOnlyList<Message> Items, int TotalCount)> GetPagedByServiceAsync(
long serviceId, int page, int size,
Expression<Func<Message, bool>>? predicate = null)
{
var query = _dbSet
.Where(m => m.ServiceId == serviceId && !m.IsDeleted);
if (predicate != null)
query = query.Where(predicate);
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(m => m.CreatedAt)
.Skip((page - 1) * size)
.Take(size)
.ToListAsync();
return (items, totalCount);
}
} }

1914
TASKS.md Normal file

File diff suppressed because it is too large Load Diff