diff --git a/SPMS.API/Controllers/DeviceController.cs b/SPMS.API/Controllers/DeviceController.cs new file mode 100644 index 0000000..b4658c0 --- /dev/null +++ b/SPMS.API/Controllers/DeviceController.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Device; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/device")] +[ApiExplorerSettings(GroupName = "device")] +public class DeviceController : ControllerBase +{ + private readonly IDeviceService _deviceService; + + public DeviceController(IDeviceService deviceService) + { + _deviceService = deviceService; + } + + [HttpPost("register")] + [SwaggerOperation(Summary = "디바이스 등록", Description = "앱 최초 설치 시 디바이스를 등록합니다.")] + public async Task RegisterAsync([FromBody] DeviceRegisterRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _deviceService.RegisterAsync(serviceId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("info")] + [SwaggerOperation(Summary = "디바이스 조회", Description = "디바이스 정보를 조회합니다.")] + public async Task GetInfoAsync([FromBody] DeviceInfoRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _deviceService.GetInfoAsync(serviceId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("update")] + [SwaggerOperation(Summary = "디바이스 수정", Description = "앱 실행 시 디바이스 정보를 업데이트합니다.")] + public async Task UpdateAsync([FromBody] DeviceUpdateRequestDto request) + { + var serviceId = GetServiceId(); + await _deviceService.UpdateAsync(serviceId, request); + return Ok(ApiResponse.Success()); + } + + [HttpPost("delete")] + [SwaggerOperation(Summary = "디바이스 삭제", Description = "앱 삭제/로그아웃 시 디바이스를 비활성화합니다.")] + public async Task DeleteAsync([FromBody] DeviceDeleteRequestDto request) + { + var serviceId = GetServiceId(); + await _deviceService.DeleteAsync(serviceId, request); + return Ok(ApiResponse.Success()); + } + + [HttpPost("list")] + [Authorize] + [SwaggerOperation(Summary = "디바이스 목록", Description = "대시보드에서 디바이스 목록을 조회합니다. JWT 인증 필요.")] + public async Task GetListAsync([FromBody] DeviceListRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _deviceService.GetListAsync(serviceId, request); + return Ok(ApiResponse.Success(result)); + } + + private long GetServiceId() + { + if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) + return serviceId; + + throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400); + } +} diff --git a/SPMS.API/Middlewares/ApiKeyMiddleware.cs b/SPMS.API/Middlewares/ApiKeyMiddleware.cs index e1a9143..33a0006 100644 --- a/SPMS.API/Middlewares/ApiKeyMiddleware.cs +++ b/SPMS.API/Middlewares/ApiKeyMiddleware.cs @@ -44,6 +44,7 @@ public class ApiKeyMiddleware private static bool RequiresApiKey(PathString path) { - return path.StartsWithSegments("/v1/in/device"); + return path.StartsWithSegments("/v1/in/device") && + !path.StartsWithSegments("/v1/in/device/list"); } } diff --git a/SPMS.API/Middlewares/ServiceCodeMiddleware.cs b/SPMS.API/Middlewares/ServiceCodeMiddleware.cs index d47aa9a..0359742 100644 --- a/SPMS.API/Middlewares/ServiceCodeMiddleware.cs +++ b/SPMS.API/Middlewares/ServiceCodeMiddleware.cs @@ -17,6 +17,8 @@ public class ServiceCodeMiddleware context.Request.Path.StartsWithSegments("/v1/in/account") || context.Request.Path.StartsWithSegments("/v1/in/public") || context.Request.Path.StartsWithSegments("/v1/in/service") || + (context.Request.Path.StartsWithSegments("/v1/in/device") && + !context.Request.Path.StartsWithSegments("/v1/in/device/list")) || context.Request.Path.StartsWithSegments("/swagger") || context.Request.Path.StartsWithSegments("/health")) { diff --git a/SPMS.Application/DTOs/Device/DeviceDeleteRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceDeleteRequestDto.cs new file mode 100644 index 0000000..7a61a9b --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceDeleteRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceDeleteRequestDto +{ + [Required] + [JsonPropertyName("device_id")] + public long DeviceId { get; set; } +} diff --git a/SPMS.Application/DTOs/Device/DeviceInfoRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceInfoRequestDto.cs new file mode 100644 index 0000000..93ea812 --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceInfoRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceInfoRequestDto +{ + [Required] + [JsonPropertyName("device_id")] + public long DeviceId { get; set; } +} diff --git a/SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs b/SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs new file mode 100644 index 0000000..2d4e089 --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceInfoResponseDto +{ + [JsonPropertyName("device_id")] + public long DeviceId { get; set; } + + [JsonPropertyName("device_token")] + public string DeviceToken { get; set; } = string.Empty; + + [JsonPropertyName("platform")] + public string Platform { get; set; } = string.Empty; + + [JsonPropertyName("os_version")] + public string? OsVersion { get; set; } + + [JsonPropertyName("app_version")] + public string? AppVersion { get; set; } + + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("push_agreed")] + public bool PushAgreed { get; set; } + + [JsonPropertyName("marketing_agreed")] + public bool MarketingAgreed { get; set; } + + [JsonPropertyName("tags")] + public List? Tags { get; set; } + + [JsonPropertyName("is_active")] + public bool IsActive { get; set; } + + [JsonPropertyName("last_active_at")] + public DateTime? LastActiveAt { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Device/DeviceListRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceListRequestDto.cs new file mode 100644 index 0000000..a4ee404 --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceListRequestDto.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceListRequestDto +{ + [JsonPropertyName("page")] + public int Page { get; set; } = 1; + + [JsonPropertyName("size")] + public int Size { get; set; } = 20; + + [JsonPropertyName("platform")] + public string? Platform { get; set; } + + [JsonPropertyName("push_agreed")] + public bool? PushAgreed { get; set; } + + [JsonPropertyName("tags")] + public List? Tags { get; set; } + + [JsonPropertyName("is_active")] + public bool? IsActive { get; set; } +} diff --git a/SPMS.Application/DTOs/Device/DeviceListResponseDto.cs b/SPMS.Application/DTOs/Device/DeviceListResponseDto.cs new file mode 100644 index 0000000..6a85b3f --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceListResponseDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using SPMS.Application.DTOs.Notice; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceListResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + [JsonPropertyName("pagination")] + public PaginationDto Pagination { get; set; } = new(); +} + +public class DeviceSummaryDto +{ + [JsonPropertyName("device_id")] + public long DeviceId { get; set; } + + [JsonPropertyName("platform")] + public string Platform { get; set; } = string.Empty; + + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("push_agreed")] + public bool PushAgreed { get; set; } + + [JsonPropertyName("tags")] + public List? Tags { get; set; } + + [JsonPropertyName("last_active_at")] + public DateTime? LastActiveAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Device/DeviceRegisterRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceRegisterRequestDto.cs new file mode 100644 index 0000000..7849220 --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceRegisterRequestDto.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceRegisterRequestDto +{ + [Required] + [JsonPropertyName("device_token")] + public string DeviceToken { get; set; } = string.Empty; + + [Required] + [JsonPropertyName("platform")] + public string Platform { get; set; } = string.Empty; + + [JsonPropertyName("os_version")] + public string? OsVersion { get; set; } + + [JsonPropertyName("app_version")] + public string? AppVersion { get; set; } + + [JsonPropertyName("model")] + public string? Model { get; set; } +} diff --git a/SPMS.Application/DTOs/Device/DeviceRegisterResponseDto.cs b/SPMS.Application/DTOs/Device/DeviceRegisterResponseDto.cs new file mode 100644 index 0000000..b686208 --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceRegisterResponseDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceRegisterResponseDto +{ + [JsonPropertyName("device_id")] + public long DeviceId { get; set; } + + [JsonPropertyName("is_new")] + public bool IsNew { get; set; } +} diff --git a/SPMS.Application/DTOs/Device/DeviceUpdateRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceUpdateRequestDto.cs new file mode 100644 index 0000000..d0251db --- /dev/null +++ b/SPMS.Application/DTOs/Device/DeviceUpdateRequestDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Device; + +public class DeviceUpdateRequestDto +{ + [Required] + [JsonPropertyName("device_id")] + public long DeviceId { get; set; } + + [JsonPropertyName("device_token")] + public string? DeviceToken { get; set; } + + [JsonPropertyName("os_version")] + public string? OsVersion { get; set; } + + [JsonPropertyName("app_version")] + public string? AppVersion { get; set; } +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index bd0b784..8d7cb20 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -16,6 +16,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/IDeviceService.cs b/SPMS.Application/Interfaces/IDeviceService.cs new file mode 100644 index 0000000..582eceb --- /dev/null +++ b/SPMS.Application/Interfaces/IDeviceService.cs @@ -0,0 +1,12 @@ +using SPMS.Application.DTOs.Device; + +namespace SPMS.Application.Interfaces; + +public interface IDeviceService +{ + Task RegisterAsync(long serviceId, DeviceRegisterRequestDto request); + Task GetInfoAsync(long serviceId, DeviceInfoRequestDto request); + Task UpdateAsync(long serviceId, DeviceUpdateRequestDto request); + Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request); + Task GetListAsync(long serviceId, DeviceListRequestDto request); +} diff --git a/SPMS.Application/Services/DeviceService.cs b/SPMS.Application/Services/DeviceService.cs new file mode 100644 index 0000000..f3d18ea --- /dev/null +++ b/SPMS.Application/Services/DeviceService.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using SPMS.Application.DTOs.Device; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; +using SPMS.Domain.Exceptions; +using SPMS.Domain.Interfaces; + +namespace SPMS.Application.Services; + +public class DeviceService : IDeviceService +{ + private readonly IDeviceRepository _deviceRepository; + private readonly IUnitOfWork _unitOfWork; + + public DeviceService(IDeviceRepository deviceRepository, IUnitOfWork unitOfWork) + { + _deviceRepository = deviceRepository; + _unitOfWork = unitOfWork; + } + + public async Task RegisterAsync(long serviceId, DeviceRegisterRequestDto request) + { + var platform = ParsePlatform(request.Platform); + var existing = await _deviceRepository.GetByServiceAndTokenAsync(serviceId, request.DeviceToken); + + if (existing != null) + { + existing.Platform = platform; + existing.OsVersion = request.OsVersion; + existing.AppVersion = request.AppVersion; + existing.DeviceModel = request.Model; + existing.IsActive = true; + existing.UpdatedAt = DateTime.UtcNow; + _deviceRepository.Update(existing); + await _unitOfWork.SaveChangesAsync(); + + return new DeviceRegisterResponseDto { DeviceId = existing.Id, IsNew = false }; + } + + var device = new Device + { + ServiceId = serviceId, + DeviceToken = request.DeviceToken, + Platform = platform, + OsVersion = request.OsVersion, + AppVersion = request.AppVersion, + DeviceModel = request.Model, + PushAgreed = true, + MarketingAgreed = false, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + await _deviceRepository.AddAsync(device); + await _unitOfWork.SaveChangesAsync(); + return new DeviceRegisterResponseDto { DeviceId = device.Id, IsNew = true }; + } + + public async Task GetInfoAsync(long serviceId, DeviceInfoRequestDto request) + { + var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId); + if (device == null) + throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); + + return new DeviceInfoResponseDto + { + DeviceId = device.Id, + DeviceToken = device.DeviceToken, + Platform = device.Platform.ToString().ToLowerInvariant(), + OsVersion = device.OsVersion, + AppVersion = device.AppVersion, + Model = device.DeviceModel, + PushAgreed = device.PushAgreed, + MarketingAgreed = device.MarketingAgreed, + Tags = ParseTags(device.Tags), + IsActive = device.IsActive, + LastActiveAt = device.UpdatedAt, + CreatedAt = device.CreatedAt + }; + } + + public async Task UpdateAsync(long serviceId, DeviceUpdateRequestDto request) + { + var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId); + if (device == null) + throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); + + if (request.DeviceToken != null) + device.DeviceToken = request.DeviceToken; + if (request.OsVersion != null) + device.OsVersion = request.OsVersion; + if (request.AppVersion != null) + device.AppVersion = request.AppVersion; + + device.UpdatedAt = DateTime.UtcNow; + _deviceRepository.Update(device); + await _unitOfWork.SaveChangesAsync(); + } + + public async Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request) + { + var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId); + if (device == null) + throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); + + device.IsActive = false; + device.UpdatedAt = DateTime.UtcNow; + _deviceRepository.Update(device); + await _unitOfWork.SaveChangesAsync(); + } + + public async Task GetListAsync(long serviceId, DeviceListRequestDto request) + { + Platform? platform = null; + if (!string.IsNullOrWhiteSpace(request.Platform)) + platform = ParsePlatform(request.Platform); + + var (items, totalCount) = await _deviceRepository.GetPagedAsync( + serviceId, request.Page, request.Size, + platform, request.PushAgreed, request.IsActive, request.Tags); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); + + return new DeviceListResponseDto + { + Items = items.Select(d => new DeviceSummaryDto + { + DeviceId = d.Id, + Platform = d.Platform.ToString().ToLowerInvariant(), + Model = d.DeviceModel, + PushAgreed = d.PushAgreed, + Tags = ParseTags(d.Tags), + LastActiveAt = d.UpdatedAt + }).ToList(), + Pagination = new DTOs.Notice.PaginationDto + { + Page = request.Page, + Size = request.Size, + TotalCount = totalCount, + TotalPages = totalPages + } + }; + } + + private static Platform ParsePlatform(string platform) + { + return platform.ToLowerInvariant() switch + { + "ios" => Platform.iOS, + "android" => Platform.Android, + "web" => Platform.Web, + _ => throw new SpmsException(ErrorCodes.BadRequest, "유효하지 않은 플랫폼입니다.", 400) + }; + } + + private static List? ParseTags(string? tagsJson) + { + if (string.IsNullOrWhiteSpace(tagsJson)) + return null; + try + { + return JsonSerializer.Deserialize>(tagsJson); + } + catch + { + return null; + } + } +} diff --git a/SPMS.Domain/Common/ErrorCodes.cs b/SPMS.Domain/Common/ErrorCodes.cs index 82373d8..5bfa5de 100644 --- a/SPMS.Domain/Common/ErrorCodes.cs +++ b/SPMS.Domain/Common/ErrorCodes.cs @@ -32,6 +32,10 @@ public static class ErrorCodes public const string DecryptionFailed = "131"; public const string InvalidCredentials = "132"; + // === Device (4) === + public const string DeviceNotFound = "141"; + public const string DeviceTokenDuplicate = "142"; + // === Push (6) === public const string PushSendFailed = "161"; public const string PushStateChangeNotAllowed = "162"; diff --git a/SPMS.Domain/Interfaces/IDeviceRepository.cs b/SPMS.Domain/Interfaces/IDeviceRepository.cs index dfa8a24..5744792 100644 --- a/SPMS.Domain/Interfaces/IDeviceRepository.cs +++ b/SPMS.Domain/Interfaces/IDeviceRepository.cs @@ -6,6 +6,11 @@ namespace SPMS.Domain.Interfaces; public interface IDeviceRepository : IRepository { Task GetByServiceAndTokenAsync(long serviceId, string deviceToken); + Task GetByIdAndServiceAsync(long id, long serviceId); Task GetActiveCountByServiceAsync(long serviceId); Task> GetByPlatformAsync(long serviceId, Platform platform); + Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + long serviceId, int page, int size, + Platform? platform = null, bool? pushAgreed = null, + bool? isActive = null, List? tags = null); } diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index bdaf8bf..dd831f2 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -31,6 +31,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // External Services services.AddScoped(); diff --git a/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs new file mode 100644 index 0000000..a327799 --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class DeviceRepository : Repository, IDeviceRepository +{ + public DeviceRepository(AppDbContext context) : base(context) { } + + public async Task GetByServiceAndTokenAsync(long serviceId, string deviceToken) + { + return await _dbSet.FirstOrDefaultAsync(d => d.ServiceId == serviceId && d.DeviceToken == deviceToken); + } + + public async Task GetByIdAndServiceAsync(long id, long serviceId) + { + return await _dbSet.FirstOrDefaultAsync(d => d.Id == id && d.ServiceId == serviceId); + } + + public async Task GetActiveCountByServiceAsync(long serviceId) + { + return await _dbSet.CountAsync(d => d.ServiceId == serviceId && d.IsActive); + } + + public async Task> GetByPlatformAsync(long serviceId, Platform platform) + { + return await _dbSet + .Where(d => d.ServiceId == serviceId && d.Platform == platform && d.IsActive) + .ToListAsync(); + } + + public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + long serviceId, int page, int size, + Platform? platform = null, bool? pushAgreed = null, + bool? isActive = null, List? tags = null) + { + var query = _dbSet.Where(d => d.ServiceId == serviceId); + + if (platform.HasValue) + query = query.Where(d => d.Platform == platform.Value); + + if (pushAgreed.HasValue) + query = query.Where(d => d.PushAgreed == pushAgreed.Value); + + if (isActive.HasValue) + query = query.Where(d => d.IsActive == isActive.Value); + + if (tags != null && tags.Count > 0) + { + foreach (var tag in tags) + { + var tagStr = tag.ToString(); + query = query.Where(d => d.Tags != null && EF.Functions.Like(d.Tags, $"%{tagStr}%")); + } + } + + var totalCount = await query.CountAsync(); + + var items = await query + .OrderByDescending(d => d.CreatedAt) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync(); + + return (items, totalCount); + } +}