improvement: Notification 도메인 구축 (#247) #248

Merged
seonkyu.kim merged 1 commits from improvement/#247-notification-domain into develop 2026-02-26 00:47:34 +00:00
21 changed files with 1801 additions and 0 deletions
Showing only changes of commit c29a48163d - Show all commits

View File

@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using SPMS.Application.DTOs.Notification;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Exceptions;
namespace SPMS.API.Controllers;
[ApiController]
[Route("v1/in/notification")]
[Authorize]
[ApiExplorerSettings(GroupName = "notification")]
public class NotificationController : ControllerBase
{
private readonly INotificationService _notificationService;
public NotificationController(INotificationService notificationService)
{
_notificationService = notificationService;
}
[HttpPost("summary")]
[SwaggerOperation(Summary = "알림 요약 조회", Description = "최근 N건의 알림과 미읽 건수를 반환합니다. 헤더 뱃지용.")]
public async Task<IActionResult> GetSummaryAsync([FromBody] NotificationSummaryRequestDto request)
{
var adminId = GetAdminId();
var result = await _notificationService.GetSummaryAsync(adminId, request);
return Ok(ApiResponse<NotificationSummaryResponseDto>.Success(result));
}
[HttpPost("list")]
[SwaggerOperation(Summary = "알림 목록 조회", Description = "알림 목록을 페이지 단위로 조회합니다. 카테고리/기간/읽음 필터를 지원합니다.")]
public async Task<IActionResult> GetListAsync([FromBody] NotificationListRequestDto request)
{
var adminId = GetAdminId();
var result = await _notificationService.GetListAsync(adminId, request);
return Ok(ApiResponse<NotificationListResponseDto>.Success(result));
}
[HttpPost("read")]
[SwaggerOperation(Summary = "알림 읽음 처리", Description = "단건 알림을 읽음 처리합니다. 이미 읽은 알림은 무시(멱등).")]
public async Task<IActionResult> MarkAsReadAsync([FromBody] NotificationReadRequestDto request)
{
var adminId = GetAdminId();
var result = await _notificationService.MarkAsReadAsync(adminId, request);
return Ok(ApiResponse<NotificationReadResponseDto>.Success(result));
}
[HttpPost("read-all")]
[SwaggerOperation(Summary = "알림 전체 읽음", Description = "해당 관리자의 모든 미읽 알림을 일괄 읽음 처리합니다.")]
public async Task<IActionResult> MarkAllAsReadAsync()
{
var adminId = GetAdminId();
var result = await _notificationService.MarkAllAsReadAsync(adminId);
return Ok(ApiResponse<NotificationReadResponseDto>.Success(result));
}
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,30 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Notification;
public class NotificationItemDto
{
[JsonPropertyName("notification_id")]
public long NotificationId { get; set; }
[JsonPropertyName("category")]
public string Category { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
[JsonPropertyName("link_url")]
public string? LinkUrl { get; set; }
[JsonPropertyName("is_read")]
public bool IsRead { get; set; }
[JsonPropertyName("read_at")]
public DateTime? ReadAt { 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.Notification;
public class NotificationListRequestDto
{
[JsonPropertyName("page")]
public int Page { get; set; } = 1;
[JsonPropertyName("size")]
public int Size { get; set; } = 20;
[JsonPropertyName("category")]
public string? Category { get; set; }
[JsonPropertyName("from")]
public DateTime? From { get; set; }
[JsonPropertyName("to")]
public DateTime? To { get; set; }
[JsonPropertyName("is_read")]
public bool? IsRead { get; set; }
}

View File

@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
using SPMS.Application.DTOs.Notice;
namespace SPMS.Application.DTOs.Notification;
public class NotificationListResponseDto
{
[JsonPropertyName("items")]
public List<NotificationItemDto> Items { get; set; } = new();
[JsonPropertyName("pagination")]
public PaginationDto Pagination { get; set; } = new();
[JsonPropertyName("unread_count")]
public int UnreadCount { get; set; }
}

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Notification;
public class NotificationReadRequestDto
{
[Required]
[JsonPropertyName("notification_id")]
public long NotificationId { get; set; }
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Notification;
public class NotificationReadResponseDto
{
[JsonPropertyName("unread_count")]
public int UnreadCount { get; set; }
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Notification;
public class NotificationSummaryRequestDto
{
[JsonPropertyName("limit")]
public int Limit { get; set; } = 5;
}

View File

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Notification;
public class NotificationSummaryResponseDto
{
[JsonPropertyName("recent_items")]
public List<NotificationItemDto> RecentItems { get; set; } = new();
[JsonPropertyName("unread_count")]
public int UnreadCount { get; set; }
}

View File

@ -23,6 +23,7 @@ public static class DependencyInjection
services.AddScoped<IMessageService, MessageService>(); services.AddScoped<IMessageService, MessageService>();
services.AddScoped<IStatsService, StatsService>(); services.AddScoped<IStatsService, StatsService>();
services.AddScoped<ITagService, TagService>(); services.AddScoped<ITagService, TagService>();
services.AddScoped<INotificationService, NotificationService>();
return services; return services;
} }

View File

@ -0,0 +1,11 @@
using SPMS.Application.DTOs.Notification;
namespace SPMS.Application.Interfaces;
public interface INotificationService
{
Task<NotificationSummaryResponseDto> GetSummaryAsync(long adminId, NotificationSummaryRequestDto request);
Task<NotificationListResponseDto> GetListAsync(long adminId, NotificationListRequestDto request);
Task<NotificationReadResponseDto> MarkAsReadAsync(long adminId, NotificationReadRequestDto request);
Task<NotificationReadResponseDto> MarkAllAsReadAsync(long adminId);
}

View File

@ -0,0 +1,104 @@
using SPMS.Application.DTOs.Notice;
using SPMS.Application.DTOs.Notification;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Enums;
using SPMS.Domain.Exceptions;
using SPMS.Domain.Interfaces;
namespace SPMS.Application.Services;
public class NotificationService : INotificationService
{
private readonly INotificationRepository _notificationRepository;
private readonly IUnitOfWork _unitOfWork;
public NotificationService(
INotificationRepository notificationRepository,
IUnitOfWork unitOfWork)
{
_notificationRepository = notificationRepository;
_unitOfWork = unitOfWork;
}
public async Task<NotificationSummaryResponseDto> GetSummaryAsync(long adminId, NotificationSummaryRequestDto request)
{
var limit = request.Limit > 0 ? request.Limit : 5;
var recentItems = await _notificationRepository.GetRecentAsync(adminId, limit);
var unreadCount = await _notificationRepository.GetUnreadCountAsync(adminId);
return new NotificationSummaryResponseDto
{
RecentItems = recentItems.Select(MapToItemDto).ToList(),
UnreadCount = unreadCount
};
}
public async Task<NotificationListResponseDto> GetListAsync(long adminId, NotificationListRequestDto request)
{
// category 문자열 → enum 파싱
NotificationCategory? category = null;
if (!string.IsNullOrWhiteSpace(request.Category))
{
if (!Enum.TryParse<NotificationCategory>(request.Category, true, out var parsed))
throw new SpmsException(ErrorCodes.BadRequest, $"유효하지 않은 카테고리: {request.Category}");
category = parsed;
}
var (items, totalCount) = await _notificationRepository.GetPagedAsync(
adminId, category, request.From, request.To, request.IsRead, request.Page, request.Size);
var unreadCount = await _notificationRepository.GetUnreadCountAsync(adminId);
var totalPages = (int)Math.Ceiling((double)totalCount / request.Size);
return new NotificationListResponseDto
{
Items = items.Select(MapToItemDto).ToList(),
Pagination = new PaginationDto
{
Page = request.Page,
Size = request.Size,
TotalCount = totalCount,
TotalPages = totalPages
},
UnreadCount = unreadCount
};
}
public async Task<NotificationReadResponseDto> MarkAsReadAsync(long adminId, NotificationReadRequestDto request)
{
var notification = await _notificationRepository.GetByIdAndAdminAsync(request.NotificationId, adminId);
if (notification == null)
throw new SpmsException(ErrorCodes.NotFound, "알림을 찾을 수 없습니다.", 404);
// 멱등성: 이미 읽은 알림은 스킵
if (!notification.IsRead)
{
notification.IsRead = true;
notification.ReadAt = DateTime.UtcNow;
_notificationRepository.Update(notification);
await _unitOfWork.SaveChangesAsync();
}
var unreadCount = await _notificationRepository.GetUnreadCountAsync(adminId);
return new NotificationReadResponseDto { UnreadCount = unreadCount };
}
public async Task<NotificationReadResponseDto> MarkAllAsReadAsync(long adminId)
{
await _notificationRepository.MarkAllAsReadAsync(adminId);
return new NotificationReadResponseDto { UnreadCount = 0 };
}
private static NotificationItemDto MapToItemDto(Domain.Entities.Notification n) => new()
{
NotificationId = n.Id,
Category = n.Category.ToString().ToLower(),
Title = n.Title,
Content = n.Content,
LinkUrl = n.LinkUrl,
IsRead = n.IsRead,
ReadAt = n.ReadAt,
CreatedAt = n.CreatedAt
};
}

View File

@ -0,0 +1,18 @@
using SPMS.Domain.Enums;
namespace SPMS.Domain.Entities;
public class Notification : BaseEntity
{
public long TargetAdminId { get; set; }
public NotificationCategory Category { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public string? LinkUrl { get; set; }
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
public DateTime CreatedAt { get; set; }
// Navigation
public Admin TargetAdmin { get; set; } = null!;
}

View File

@ -0,0 +1,10 @@
namespace SPMS.Domain.Enums;
public enum NotificationCategory : byte
{
Send = 0, // 발송
Certificate = 1, // 인증서
Service = 2, // 서비스
Failure = 3, // 실패
System = 4 // 시스템
}

View File

@ -0,0 +1,15 @@
using SPMS.Domain.Entities;
using SPMS.Domain.Enums;
namespace SPMS.Domain.Interfaces;
public interface INotificationRepository : IRepository<Notification>
{
Task<(IReadOnlyList<Notification> Items, int TotalCount)> GetPagedAsync(
long adminId, NotificationCategory? category, DateTime? from, DateTime? to,
bool? isRead, int page, int size);
Task<int> GetUnreadCountAsync(long adminId);
Task<IReadOnlyList<Notification>> GetRecentAsync(long adminId, int limit);
Task<Notification?> GetByIdAndAdminAsync(long id, long adminId);
Task<int> MarkAllAsReadAsync(long adminId);
}

View File

@ -26,6 +26,7 @@ public class AppDbContext : DbContext
public DbSet<Faq> Faqs => Set<Faq>(); public DbSet<Faq> Faqs => Set<Faq>();
public DbSet<AppConfig> AppConfigs => Set<AppConfig>(); public DbSet<AppConfig> AppConfigs => Set<AppConfig>();
public DbSet<Tag> Tags => Set<Tag>(); public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Notification> Notifications => Set<Notification>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {

View File

@ -44,6 +44,7 @@ public static class DependencyInjection
services.AddScoped<IDailyStatRepository, DailyStatRepository>(); services.AddScoped<IDailyStatRepository, DailyStatRepository>();
services.AddScoped<IWebhookLogRepository, WebhookLogRepository>(); services.AddScoped<IWebhookLogRepository, WebhookLogRepository>();
services.AddScoped<ITagRepository, TagRepository>(); services.AddScoped<ITagRepository, TagRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
// External Services // External Services
services.AddScoped<IJwtService, JwtService>(); services.AddScoped<IJwtService, JwtService>();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SPMS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNotificationTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notification",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TargetAdminId = table.Column<long>(type: "bigint", nullable: false),
Category = table.Column<byte>(type: "tinyint unsigned", nullable: false),
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Content = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
LinkUrl = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
ReadAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notification", x => x.Id);
table.ForeignKey(
name: "FK_Notification_Admin_TargetAdminId",
column: x => x.TargetAdminId,
principalTable: "Admin",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Notification_TargetAdminId_CreatedAt",
table: "Notification",
columns: new[] { "TargetAdminId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Notification_TargetAdminId_IsRead",
table: "Notification",
columns: new[] { "TargetAdminId", "IsRead" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Notification");
}
}
}

View File

@ -536,6 +536,54 @@ namespace SPMS.Infrastructure.Migrations
b.ToTable("Notice", (string)null); b.ToTable("Notice", (string)null);
}); });
modelBuilder.Entity("SPMS.Domain.Entities.Notification", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<byte>("Category")
.HasColumnType("tinyint unsigned");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<string>("LinkUrl")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReadAt")
.HasColumnType("datetime(6)");
b.Property<long>("TargetAdminId")
.HasColumnType("bigint");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.HasKey("Id");
b.HasIndex("TargetAdminId", "CreatedAt");
b.HasIndex("TargetAdminId", "IsRead");
b.ToTable("Notification", (string)null);
});
modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => modelBuilder.Entity("SPMS.Domain.Entities.Payment", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -1029,6 +1077,17 @@ namespace SPMS.Infrastructure.Migrations
b.Navigation("Service"); b.Navigation("Service");
}); });
modelBuilder.Entity("SPMS.Domain.Entities.Notification", b =>
{
b.HasOne("SPMS.Domain.Entities.Admin", "TargetAdmin")
.WithMany()
.HasForeignKey("TargetAdminId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("TargetAdmin");
});
modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => modelBuilder.Entity("SPMS.Domain.Entities.Payment", b =>
{ {
b.HasOne("SPMS.Domain.Entities.Admin", "Admin") b.HasOne("SPMS.Domain.Entities.Admin", "Admin")

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SPMS.Domain.Entities;
namespace SPMS.Infrastructure.Persistence.Configurations;
public class NotificationConfiguration : IEntityTypeConfiguration<Notification>
{
public void Configure(EntityTypeBuilder<Notification> builder)
{
builder.ToTable("Notification");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.Title).HasMaxLength(200).IsRequired();
builder.Property(e => e.Content).HasMaxLength(1000).IsRequired();
builder.Property(e => e.LinkUrl).HasMaxLength(500);
builder.Property(e => e.IsRead).IsRequired().HasDefaultValue(false);
builder.Property(e => e.CreatedAt).IsRequired();
builder.HasOne(e => e.TargetAdmin)
.WithMany()
.HasForeignKey(e => e.TargetAdminId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.TargetAdminId, e.CreatedAt });
builder.HasIndex(e => new { e.TargetAdminId, e.IsRead });
}
}

View File

@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using SPMS.Domain.Entities;
using SPMS.Domain.Enums;
using SPMS.Domain.Interfaces;
namespace SPMS.Infrastructure.Persistence.Repositories;
public class NotificationRepository : Repository<Notification>, INotificationRepository
{
public NotificationRepository(AppDbContext context) : base(context) { }
public async Task<(IReadOnlyList<Notification> Items, int TotalCount)> GetPagedAsync(
long adminId, NotificationCategory? category, DateTime? from, DateTime? to,
bool? isRead, int page, int size)
{
IQueryable<Notification> query = _dbSet.Where(n => n.TargetAdminId == adminId);
if (category.HasValue)
query = query.Where(n => n.Category == category.Value);
if (from.HasValue)
query = query.Where(n => n.CreatedAt >= from.Value);
if (to.HasValue)
query = query.Where(n => n.CreatedAt <= to.Value);
if (isRead.HasValue)
query = query.Where(n => n.IsRead == isRead.Value);
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(n => n.CreatedAt)
.Skip((page - 1) * size)
.Take(size)
.ToListAsync();
return (items, totalCount);
}
public async Task<int> GetUnreadCountAsync(long adminId)
=> await _dbSet.CountAsync(n => n.TargetAdminId == adminId && !n.IsRead);
public async Task<IReadOnlyList<Notification>> GetRecentAsync(long adminId, int limit)
=> await _dbSet
.Where(n => n.TargetAdminId == adminId)
.OrderByDescending(n => n.CreatedAt)
.Take(limit)
.ToListAsync();
public async Task<Notification?> GetByIdAndAdminAsync(long id, long adminId)
=> await _dbSet.FirstOrDefaultAsync(n => n.Id == id && n.TargetAdminId == adminId);
public async Task<int> MarkAllAsReadAsync(long adminId)
=> await _dbSet
.Where(n => n.TargetAdminId == adminId && !n.IsRead)
.ExecuteUpdateAsync(s => s
.SetProperty(n => n.IsRead, true)
.SetProperty(n => n.ReadAt, DateTime.UtcNow));
}