feat: 디바이스 CRUD + 목록 API 구현 (#94)

This commit is contained in:
SEAN 2026-02-10 14:44:16 +09:00
parent 7db6099cbe
commit e9bcd5358f
18 changed files with 520 additions and 1 deletions

View File

@ -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<IActionResult> RegisterAsync([FromBody] DeviceRegisterRequestDto request)
{
var serviceId = GetServiceId();
var result = await _deviceService.RegisterAsync(serviceId, request);
return Ok(ApiResponse<DeviceRegisterResponseDto>.Success(result));
}
[HttpPost("info")]
[SwaggerOperation(Summary = "디바이스 조회", Description = "디바이스 정보를 조회합니다.")]
public async Task<IActionResult> GetInfoAsync([FromBody] DeviceInfoRequestDto request)
{
var serviceId = GetServiceId();
var result = await _deviceService.GetInfoAsync(serviceId, request);
return Ok(ApiResponse<DeviceInfoResponseDto>.Success(result));
}
[HttpPost("update")]
[SwaggerOperation(Summary = "디바이스 수정", Description = "앱 실행 시 디바이스 정보를 업데이트합니다.")]
public async Task<IActionResult> UpdateAsync([FromBody] DeviceUpdateRequestDto request)
{
var serviceId = GetServiceId();
await _deviceService.UpdateAsync(serviceId, request);
return Ok(ApiResponse.Success());
}
[HttpPost("delete")]
[SwaggerOperation(Summary = "디바이스 삭제", Description = "앱 삭제/로그아웃 시 디바이스를 비활성화합니다.")]
public async Task<IActionResult> 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<IActionResult> GetListAsync([FromBody] DeviceListRequestDto request)
{
var serviceId = GetServiceId();
var result = await _deviceService.GetListAsync(serviceId, request);
return Ok(ApiResponse<DeviceListResponseDto>.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);
}
}

View File

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

View File

@ -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"))
{

View File

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

View File

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

View File

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

View File

@ -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<int>? Tags { get; set; }
[JsonPropertyName("is_active")]
public bool? IsActive { get; set; }
}

View File

@ -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<DeviceSummaryDto> 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<int>? Tags { get; set; }
[JsonPropertyName("last_active_at")]
public DateTime? LastActiveAt { get; set; }
}

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ public static class DependencyInjection
services.AddScoped<IBannerService, BannerService>();
services.AddScoped<IFaqService, FaqService>();
services.AddScoped<IAppConfigService, AppConfigService>();
services.AddScoped<IDeviceService, DeviceService>();
return services;
}

View File

@ -0,0 +1,12 @@
using SPMS.Application.DTOs.Device;
namespace SPMS.Application.Interfaces;
public interface IDeviceService
{
Task<DeviceRegisterResponseDto> RegisterAsync(long serviceId, DeviceRegisterRequestDto request);
Task<DeviceInfoResponseDto> GetInfoAsync(long serviceId, DeviceInfoRequestDto request);
Task UpdateAsync(long serviceId, DeviceUpdateRequestDto request);
Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request);
Task<DeviceListResponseDto> GetListAsync(long serviceId, DeviceListRequestDto request);
}

View File

@ -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<DeviceRegisterResponseDto> 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<DeviceInfoResponseDto> 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<DeviceListResponseDto> 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<int>? ParseTags(string? tagsJson)
{
if (string.IsNullOrWhiteSpace(tagsJson))
return null;
try
{
return JsonSerializer.Deserialize<List<int>>(tagsJson);
}
catch
{
return null;
}
}
}

View File

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

View File

@ -6,6 +6,11 @@ namespace SPMS.Domain.Interfaces;
public interface IDeviceRepository : IRepository<Device>
{
Task<Device?> GetByServiceAndTokenAsync(long serviceId, string deviceToken);
Task<Device?> GetByIdAndServiceAsync(long id, long serviceId);
Task<int> GetActiveCountByServiceAsync(long serviceId);
Task<IReadOnlyList<Device>> GetByPlatformAsync(long serviceId, Platform platform);
Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync(
long serviceId, int page, int size,
Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? tags = null);
}

View File

@ -31,6 +31,7 @@ public static class DependencyInjection
services.AddScoped<IBannerRepository, BannerRepository>();
services.AddScoped<IFaqRepository, FaqRepository>();
services.AddScoped<IAppConfigRepository, AppConfigRepository>();
services.AddScoped<IDeviceRepository, DeviceRepository>();
// External Services
services.AddScoped<IJwtService, JwtService>();

View File

@ -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<Device>, IDeviceRepository
{
public DeviceRepository(AppDbContext context) : base(context) { }
public async Task<Device?> GetByServiceAndTokenAsync(long serviceId, string deviceToken)
{
return await _dbSet.FirstOrDefaultAsync(d => d.ServiceId == serviceId && d.DeviceToken == deviceToken);
}
public async Task<Device?> GetByIdAndServiceAsync(long id, long serviceId)
{
return await _dbSet.FirstOrDefaultAsync(d => d.Id == id && d.ServiceId == serviceId);
}
public async Task<int> GetActiveCountByServiceAsync(long serviceId)
{
return await _dbSet.CountAsync(d => d.ServiceId == serviceId && d.IsActive);
}
public async Task<IReadOnlyList<Device>> GetByPlatformAsync(long serviceId, Platform platform)
{
return await _dbSet
.Where(d => d.ServiceId == serviceId && d.Platform == platform && d.IsActive)
.ToListAsync();
}
public async Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync(
long serviceId, int page, int size,
Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? 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);
}
}