diff --git a/SPMS.API/Controllers/NoticeController.cs b/SPMS.API/Controllers/NoticeController.cs new file mode 100644 index 0000000..011f1bf --- /dev/null +++ b/SPMS.API/Controllers/NoticeController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Notice; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/public/notice")] +[ApiExplorerSettings(GroupName = "public")] +public class NoticeController : ControllerBase +{ + private readonly INoticeService _noticeService; + + public NoticeController(INoticeService noticeService) + { + _noticeService = noticeService; + } + + [HttpPost("list")] + [SwaggerOperation(Summary = "공지사항 목록", Description = "활성화된 공지사항 목록을 페이징으로 조회합니다. 상단 고정 우선 정렬.")] + public async Task GetListAsync([FromBody] NoticeListRequestDto request) + { + var serviceCode = Request.Headers["X-Service-Code"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(serviceCode)) + return BadRequest(ApiResponse.Fail(ErrorCodes.BadRequest, "X-Service-Code 헤더가 필요합니다.")); + + var result = await _noticeService.GetListAsync(serviceCode, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("info")] + [SwaggerOperation(Summary = "공지사항 상세", Description = "공지사항 ID로 상세 정보를 조회합니다.")] + public async Task GetInfoAsync([FromBody] NoticeInfoRequestDto request) + { + var serviceCode = Request.Headers["X-Service-Code"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(serviceCode)) + return BadRequest(ApiResponse.Fail(ErrorCodes.BadRequest, "X-Service-Code 헤더가 필요합니다.")); + + var result = await _noticeService.GetInfoAsync(serviceCode, request); + return Ok(ApiResponse.Success(result)); + } +} diff --git a/SPMS.Application/DTOs/Notice/NoticeInfoRequestDto.cs b/SPMS.Application/DTOs/Notice/NoticeInfoRequestDto.cs new file mode 100644 index 0000000..c3f5a54 --- /dev/null +++ b/SPMS.Application/DTOs/Notice/NoticeInfoRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notice; + +public class NoticeInfoRequestDto +{ + [Required(ErrorMessage = "공지사항 ID는 필수입니다.")] + [JsonPropertyName("notice_id")] + public long NoticeId { get; set; } +} diff --git a/SPMS.Application/DTOs/Notice/NoticeInfoResponseDto.cs b/SPMS.Application/DTOs/Notice/NoticeInfoResponseDto.cs new file mode 100644 index 0000000..d2c3b75 --- /dev/null +++ b/SPMS.Application/DTOs/Notice/NoticeInfoResponseDto.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notice; + +public class NoticeInfoResponseDto +{ + [JsonPropertyName("notice_id")] + public long NoticeId { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + [JsonPropertyName("is_important")] + public bool IsImportant { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Notice/NoticeListRequestDto.cs b/SPMS.Application/DTOs/Notice/NoticeListRequestDto.cs new file mode 100644 index 0000000..85d9f15 --- /dev/null +++ b/SPMS.Application/DTOs/Notice/NoticeListRequestDto.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Notice; + +public class NoticeListRequestDto +{ + [Range(1, int.MaxValue, ErrorMessage = "페이지 번호는 1 이상이어야 합니다.")] + public int Page { get; set; } = 1; + + [Range(1, 100, ErrorMessage = "페이지 크기는 1~100 사이여야 합니다.")] + public int Size { get; set; } = 20; +} diff --git a/SPMS.Application/DTOs/Notice/NoticeListResponseDto.cs b/SPMS.Application/DTOs/Notice/NoticeListResponseDto.cs new file mode 100644 index 0000000..20eb1fc --- /dev/null +++ b/SPMS.Application/DTOs/Notice/NoticeListResponseDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notice; + +public class NoticeListResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + [JsonPropertyName("pagination")] + public PaginationDto Pagination { get; set; } = new(); +} + +public class NoticeSummaryDto +{ + [JsonPropertyName("notice_id")] + public long NoticeId { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("is_important")] + public bool IsImportant { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } +} + +public class PaginationDto +{ + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("total_pages")] + public int TotalPages { get; set; } +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index 6fa1a60..0296867 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -12,6 +12,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/INoticeService.cs b/SPMS.Application/Interfaces/INoticeService.cs new file mode 100644 index 0000000..123972d --- /dev/null +++ b/SPMS.Application/Interfaces/INoticeService.cs @@ -0,0 +1,9 @@ +using SPMS.Application.DTOs.Notice; + +namespace SPMS.Application.Interfaces; + +public interface INoticeService +{ + Task GetListAsync(string serviceCode, NoticeListRequestDto request); + Task GetInfoAsync(string serviceCode, NoticeInfoRequestDto request); +} diff --git a/SPMS.Application/Services/NoticeService.cs b/SPMS.Application/Services/NoticeService.cs new file mode 100644 index 0000000..70f28cb --- /dev/null +++ b/SPMS.Application/Services/NoticeService.cs @@ -0,0 +1,68 @@ +using SPMS.Application.DTOs.Notice; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Exceptions; +using SPMS.Domain.Interfaces; + +namespace SPMS.Application.Services; + +public class NoticeService : INoticeService +{ + private readonly INoticeRepository _noticeRepository; + private readonly IServiceRepository _serviceRepository; + + public NoticeService(INoticeRepository noticeRepository, IServiceRepository serviceRepository) + { + _noticeRepository = noticeRepository; + _serviceRepository = serviceRepository; + } + + public async Task GetListAsync(string serviceCode, NoticeListRequestDto request) + { + var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); + if (service == null) + throw new SpmsException(ErrorCodes.NotFound, "존재하지 않는 서비스입니다.", 404); + + var (items, totalCount) = await _noticeRepository.GetActivePagedAsync(service.Id, request.Page, request.Size); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); + + return new NoticeListResponseDto + { + Items = items.Select(n => new NoticeSummaryDto + { + NoticeId = n.Id, + Title = n.Title, + IsImportant = n.IsPinned, + CreatedAt = n.CreatedAt + }).ToList(), + Pagination = new PaginationDto + { + Page = request.Page, + Size = request.Size, + TotalCount = totalCount, + TotalPages = totalPages + } + }; + } + + public async Task GetInfoAsync(string serviceCode, NoticeInfoRequestDto request) + { + var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); + if (service == null) + throw new SpmsException(ErrorCodes.NotFound, "존재하지 않는 서비스입니다.", 404); + + var notice = await _noticeRepository.GetActiveByIdAsync(request.NoticeId, service.Id); + if (notice == null) + throw new SpmsException(ErrorCodes.NotFound, "존재하지 않는 공지사항입니다.", 404); + + return new NoticeInfoResponseDto + { + NoticeId = notice.Id, + Title = notice.Title, + Content = notice.Content, + IsImportant = notice.IsPinned, + CreatedAt = notice.CreatedAt + }; + } +} diff --git a/SPMS.Domain/Interfaces/INoticeRepository.cs b/SPMS.Domain/Interfaces/INoticeRepository.cs new file mode 100644 index 0000000..4a6ef5b --- /dev/null +++ b/SPMS.Domain/Interfaces/INoticeRepository.cs @@ -0,0 +1,9 @@ +using SPMS.Domain.Entities; + +namespace SPMS.Domain.Interfaces; + +public interface INoticeRepository : IRepository +{ + Task<(IReadOnlyList Items, int TotalCount)> GetActivePagedAsync(long serviceId, int page, int size); + Task GetActiveByIdAsync(long id, long serviceId); +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 599cfb8..d4aabff 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -27,6 +27,7 @@ public static class DependencyInjection services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // External Services services.AddScoped(); diff --git a/SPMS.Infrastructure/Persistence/Repositories/NoticeRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/NoticeRepository.cs new file mode 100644 index 0000000..3d3c275 --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/NoticeRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using SPMS.Domain.Entities; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class NoticeRepository : Repository, INoticeRepository +{ + public NoticeRepository(AppDbContext context) : base(context) { } + + public async Task<(IReadOnlyList Items, int TotalCount)> GetActivePagedAsync(long serviceId, int page, int size) + { + var query = _dbSet.Where(n => n.ServiceId == serviceId && n.IsActive); + + var totalCount = await query.CountAsync(); + + var items = await query + .OrderByDescending(n => n.IsPinned) + .ThenByDescending(n => n.CreatedAt) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync(); + + return (items, totalCount); + } + + public async Task GetActiveByIdAsync(long id, long serviceId) + { + return await _dbSet.FirstOrDefaultAsync(n => n.Id == id && n.ServiceId == serviceId && n.IsActive); + } +}