feat: 공지사항 목록/상세 API 구현 (#78) #79

Merged
seonkyu.kim merged 1 commits from feature/#78-notice-api into develop 2026-02-10 04:38:03 +00:00
11 changed files with 249 additions and 0 deletions

View File

@ -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<IActionResult> 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<NoticeListResponseDto>.Success(result));
}
[HttpPost("info")]
[SwaggerOperation(Summary = "공지사항 상세", Description = "공지사항 ID로 상세 정보를 조회합니다.")]
public async Task<IActionResult> 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<NoticeInfoResponseDto>.Success(result));
}
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Notice;
public class NoticeListResponseDto
{
[JsonPropertyName("items")]
public List<NoticeSummaryDto> 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; }
}

View File

@ -12,6 +12,7 @@ public static class DependencyInjection
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IServiceManagementService, ServiceManagementService>();
services.AddScoped<INoticeService, NoticeService>();
return services;
}

View File

@ -0,0 +1,9 @@
using SPMS.Application.DTOs.Notice;
namespace SPMS.Application.Interfaces;
public interface INoticeService
{
Task<NoticeListResponseDto> GetListAsync(string serviceCode, NoticeListRequestDto request);
Task<NoticeInfoResponseDto> GetInfoAsync(string serviceCode, NoticeInfoRequestDto request);
}

View File

@ -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<NoticeListResponseDto> 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<NoticeInfoResponseDto> 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
};
}
}

View File

@ -0,0 +1,9 @@
using SPMS.Domain.Entities;
namespace SPMS.Domain.Interfaces;
public interface INoticeRepository : IRepository<Notice>
{
Task<(IReadOnlyList<Notice> Items, int TotalCount)> GetActivePagedAsync(long serviceId, int page, int size);
Task<Notice?> GetActiveByIdAsync(long id, long serviceId);
}

View File

@ -27,6 +27,7 @@ public static class DependencyInjection
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<IServiceRepository, ServiceRepository>();
services.AddScoped<IAdminRepository, AdminRepository>();
services.AddScoped<INoticeRepository, NoticeRepository>();
// External Services
services.AddScoped<IJwtService, JwtService>();

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using SPMS.Domain.Entities;
using SPMS.Domain.Interfaces;
namespace SPMS.Infrastructure.Persistence.Repositories;
public class NoticeRepository : Repository<Notice>, INoticeRepository
{
public NoticeRepository(AppDbContext context) : base(context) { }
public async Task<(IReadOnlyList<Notice> 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<Notice?> GetActiveByIdAsync(long id, long serviceId)
{
return await _dbSet.FirstOrDefaultAsync(n => n.Id == id && n.ServiceId == serviceId && n.IsActive);
}
}