diff --git a/SPMS.API/Controllers/MessageController.cs b/SPMS.API/Controllers/MessageController.cs index 22806fd..f0f8579 100644 --- a/SPMS.API/Controllers/MessageController.cs +++ b/SPMS.API/Controllers/MessageController.cs @@ -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 SaveAsync([FromBody] MessageSaveRequestDto request) + { + var serviceId = GetServiceId(); + var adminId = GetAdminId(); + var result = await _messageService.SaveAsync(serviceId, adminId, request); + return Ok(ApiResponse.Success(result, "메시지 저장 성공")); + } + + [HttpPost("list")] + [SwaggerOperation(Summary = "메시지 목록 조회", Description = "서비스별 메시지 목록을 페이지 단위로 조회합니다.")] + public async Task GetListAsync([FromBody] MessageListRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _messageService.GetListAsync(serviceId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("info")] + [SwaggerOperation(Summary = "메시지 상세 조회", Description = "메시지 코드로 상세 정보를 조회합니다. 템플릿 변수 목록을 포함합니다.")] + public async Task GetInfoAsync([FromBody] MessageInfoRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _messageService.GetInfoAsync(serviceId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("delete")] + [SwaggerOperation(Summary = "메시지 삭제", Description = "메시지를 소프트 삭제합니다. 30일 후 스케줄러에 의해 완전 삭제됩니다.")] + public async Task DeleteAsync([FromBody] MessageDeleteRequestDto request) + { + var serviceId = GetServiceId(); + await _messageService.DeleteAsync(serviceId, request); + return Ok(ApiResponse.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; } } diff --git a/SPMS.Application/DTOs/Message/MessageDeleteRequestDto.cs b/SPMS.Application/DTOs/Message/MessageDeleteRequestDto.cs new file mode 100644 index 0000000..d42334c --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageDeleteRequestDto.cs @@ -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; +} diff --git a/SPMS.Application/DTOs/Message/MessageInfoRequestDto.cs b/SPMS.Application/DTOs/Message/MessageInfoRequestDto.cs new file mode 100644 index 0000000..0e534ad --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageInfoRequestDto.cs @@ -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; +} diff --git a/SPMS.Application/DTOs/Message/MessageInfoResponseDto.cs b/SPMS.Application/DTOs/Message/MessageInfoResponseDto.cs new file mode 100644 index 0000000..66669fd --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageInfoResponseDto.cs @@ -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 Variables { get; set; } = new(); + + [JsonPropertyName("is_active")] + public bool IsActive { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Message/MessageListRequestDto.cs b/SPMS.Application/DTOs/Message/MessageListRequestDto.cs new file mode 100644 index 0000000..5e44cbe --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageListRequestDto.cs @@ -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; } +} diff --git a/SPMS.Application/DTOs/Message/MessageListResponseDto.cs b/SPMS.Application/DTOs/Message/MessageListResponseDto.cs new file mode 100644 index 0000000..74f2b04 --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageListResponseDto.cs @@ -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 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; } +} diff --git a/SPMS.Application/DTOs/Message/MessageSaveRequestDto.cs b/SPMS.Application/DTOs/Message/MessageSaveRequestDto.cs new file mode 100644 index 0000000..5b12760 --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageSaveRequestDto.cs @@ -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; } +} diff --git a/SPMS.Application/DTOs/Message/MessageSaveResponseDto.cs b/SPMS.Application/DTOs/Message/MessageSaveResponseDto.cs new file mode 100644 index 0000000..8324cee --- /dev/null +++ b/SPMS.Application/DTOs/Message/MessageSaveResponseDto.cs @@ -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; } +} diff --git a/SPMS.Application/Interfaces/IMessageService.cs b/SPMS.Application/Interfaces/IMessageService.cs index ecdf8cb..b410ab2 100644 --- a/SPMS.Application/Interfaces/IMessageService.cs +++ b/SPMS.Application/Interfaces/IMessageService.cs @@ -4,5 +4,9 @@ namespace SPMS.Application.Interfaces; public interface IMessageService { + Task SaveAsync(long serviceId, long adminId, MessageSaveRequestDto request); + Task GetListAsync(long serviceId, MessageListRequestDto request); + Task GetInfoAsync(long serviceId, MessageInfoRequestDto request); + Task DeleteAsync(long serviceId, MessageDeleteRequestDto request); Task PreviewAsync(long serviceId, MessagePreviewRequestDto request); } diff --git a/SPMS.Application/Services/MessageService.cs b/SPMS.Application/Services/MessageService.cs index 6848dbc..792b34f 100644 --- a/SPMS.Application/Services/MessageService.cs +++ b/SPMS.Application/Services/MessageService.cs @@ -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 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 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 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(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 PreviewAsync(long serviceId, MessagePreviewRequestDto request) @@ -33,6 +159,41 @@ public class MessageService : IMessageService }; } + private async Task 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 ExtractVariables(string title, string body) + { + var variables = new HashSet(); + 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? variables) { if (variables == null || variables.Count == 0) diff --git a/SPMS.Domain/Interfaces/IMessageRepository.cs b/SPMS.Domain/Interfaces/IMessageRepository.cs index 4f590dd..0e71b48 100644 --- a/SPMS.Domain/Interfaces/IMessageRepository.cs +++ b/SPMS.Domain/Interfaces/IMessageRepository.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using SPMS.Domain.Entities; namespace SPMS.Domain.Interfaces; @@ -6,4 +7,8 @@ public interface IMessageRepository : IRepository { Task GetByMessageCodeAsync(string messageCode); Task GetByMessageCodeAndServiceAsync(string messageCode, long serviceId); + Task GetTodaySequenceAsync(long serviceId); + Task<(IReadOnlyList Items, int TotalCount)> GetPagedByServiceAsync( + long serviceId, int page, int size, + Expression>? predicate = null); } diff --git a/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs index 31c4356..cbcff91 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs @@ -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, IMessageRepository return await _dbSet .FirstOrDefaultAsync(m => m.MessageCode == messageCode && m.ServiceId == serviceId && !m.IsDeleted); } + + public async Task 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 Items, int TotalCount)> GetPagedByServiceAsync( + long serviceId, int page, int size, + Expression>? 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); + } }