feat: 메시지 CRUD API 구현 (#128) #129
|
|
@ -3,6 +3,7 @@ using Swashbuckle.AspNetCore.Annotations;
|
|||
using SPMS.Application.DTOs.Message;
|
||||
using SPMS.Application.Interfaces;
|
||||
using SPMS.Domain.Common;
|
||||
using SPMS.Domain.Exceptions;
|
||||
|
||||
namespace SPMS.API.Controllers;
|
||||
|
||||
|
|
@ -20,6 +21,43 @@ public class MessageController : ControllerBase
|
|||
_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")]
|
||||
[SwaggerOperation(Summary = "메시지 유효성 검사", Description = "메시지 내용의 유효성을 검사합니다.")]
|
||||
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)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
SPMS.Application/DTOs/Message/MessageDeleteRequestDto.cs
Normal file
9
SPMS.Application/DTOs/Message/MessageDeleteRequestDto.cs
Normal 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;
|
||||
}
|
||||
9
SPMS.Application/DTOs/Message/MessageInfoRequestDto.cs
Normal file
9
SPMS.Application/DTOs/Message/MessageInfoRequestDto.cs
Normal 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;
|
||||
}
|
||||
36
SPMS.Application/DTOs/Message/MessageInfoResponseDto.cs
Normal file
36
SPMS.Application/DTOs/Message/MessageInfoResponseDto.cs
Normal 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; }
|
||||
}
|
||||
18
SPMS.Application/DTOs/Message/MessageListRequestDto.cs
Normal file
18
SPMS.Application/DTOs/Message/MessageListRequestDto.cs
Normal 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; }
|
||||
}
|
||||
28
SPMS.Application/DTOs/Message/MessageListResponseDto.cs
Normal file
28
SPMS.Application/DTOs/Message/MessageListResponseDto.cs
Normal 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; }
|
||||
}
|
||||
24
SPMS.Application/DTOs/Message/MessageSaveRequestDto.cs
Normal file
24
SPMS.Application/DTOs/Message/MessageSaveRequestDto.cs
Normal 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; }
|
||||
}
|
||||
12
SPMS.Application/DTOs/Message/MessageSaveResponseDto.cs
Normal file
12
SPMS.Application/DTOs/Message/MessageSaveResponseDto.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -4,5 +4,9 @@ namespace SPMS.Application.Interfaces;
|
|||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using SPMS.Application.DTOs.Message;
|
||||
using SPMS.Application.DTOs.Notice;
|
||||
using SPMS.Application.Interfaces;
|
||||
using SPMS.Domain.Common;
|
||||
using SPMS.Domain.Exceptions;
|
||||
|
|
@ -9,10 +12,133 @@ namespace SPMS.Application.Services;
|
|||
public class MessageService : IMessageService
|
||||
{
|
||||
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;
|
||||
_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)
|
||||
|
|
@ -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)
|
||||
{
|
||||
if (variables == null || variables.Count == 0)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq.Expressions;
|
||||
using SPMS.Domain.Entities;
|
||||
|
||||
namespace SPMS.Domain.Interfaces;
|
||||
|
|
@ -6,4 +7,8 @@ public interface IMessageRepository : IRepository<Message>
|
|||
{
|
||||
Task<Message?> GetByMessageCodeAsync(string messageCode);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SPMS.Domain.Entities;
|
||||
using SPMS.Domain.Interfaces;
|
||||
|
|
@ -19,4 +20,36 @@ public class MessageRepository : Repository<Message>, IMessageRepository
|
|||
return await _dbSet
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user