improvement: Device External ID (UUID) 도입 (#275) #276

Merged
seonkyu.kim merged 1 commits from improvement/#275-external-device-id into develop 2026-03-03 03:57:52 +00:00
27 changed files with 1391 additions and 55 deletions

View File

@ -7,7 +7,7 @@ public class DeviceAgreeRequestDto
{
[Required]
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
[Required]
[JsonPropertyName("push_agreed")]

View File

@ -7,5 +7,5 @@ public class DeviceDeleteRequestDto
{
[Required]
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
}

View File

@ -7,5 +7,5 @@ public class DeviceInfoRequestDto
{
[Required]
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
}

View File

@ -5,7 +5,7 @@ namespace SPMS.Application.DTOs.Device;
public class DeviceInfoResponseDto
{
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
[JsonPropertyName("device_token")]
public string DeviceToken { get; set; } = string.Empty;

View File

@ -15,7 +15,7 @@ public class DeviceListResponseDto
public class DeviceSummaryDto
{
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
[JsonPropertyName("platform")]
public string Platform { get; set; } = string.Empty;

View File

@ -5,6 +5,10 @@ namespace SPMS.Application.DTOs.Device;
public class DeviceRegisterRequestDto
{
[Required]
[JsonPropertyName("device_id")]
public string DeviceId { get; set; } = string.Empty;
[Required]
[JsonPropertyName("device_token")]
public string DeviceToken { get; set; } = string.Empty;

View File

@ -5,7 +5,7 @@ namespace SPMS.Application.DTOs.Device;
public class DeviceRegisterResponseDto
{
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
[JsonPropertyName("is_new")]
public bool IsNew { get; set; }

View File

@ -7,7 +7,7 @@ public class DeviceTagsRequestDto
{
[Required]
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
[Required]
[JsonPropertyName("tags")]

View File

@ -7,7 +7,7 @@ public class DeviceUpdateRequestDto
{
[Required]
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
[JsonPropertyName("device_token")]
public string? DeviceToken { get; set; }

View File

@ -21,7 +21,7 @@ public class PushLogItemDto
public string MessageCode { get; set; } = string.Empty;
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;

View File

@ -10,7 +10,7 @@ public class PushScheduleRequestDto
[Required]
public string SendType { get; set; } = string.Empty;
public long? DeviceId { get; set; }
public string? DeviceId { get; set; }
public List<string>? Tags { get; set; }

View File

@ -8,7 +8,7 @@ public class PushSendRequestDto
public string MessageCode { get; set; } = string.Empty;
[Required]
public long DeviceId { get; set; }
public string DeviceId { get; set; } = string.Empty;
public Dictionary<string, string>? Variables { get; set; }
}

View File

@ -8,7 +8,7 @@ public interface IDeviceService
Task<DeviceInfoResponseDto> GetInfoAsync(long serviceId, DeviceInfoRequestDto request);
Task UpdateAsync(long serviceId, DeviceUpdateRequestDto request);
Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request);
Task AdminDeleteAsync(long deviceId);
Task AdminDeleteAsync(string externalDeviceId);
Task<DeviceListResponseDto> GetListAsync(long? serviceId, DeviceListRequestDto request);
Task<byte[]> ExportAsync(long? serviceId, DeviceExportRequestDto request);
Task SetTagsAsync(long serviceId, DeviceTagsRequestDto request);

View File

@ -2,9 +2,9 @@ namespace SPMS.Application.Interfaces;
public interface ITokenCacheService
{
Task<CachedDeviceInfo?> GetDeviceInfoAsync(long serviceId, long deviceId);
Task SetDeviceInfoAsync(long serviceId, long deviceId, CachedDeviceInfo info);
Task InvalidateAsync(long serviceId, long deviceId);
Task<CachedDeviceInfo?> GetDeviceInfoAsync(long serviceId, string deviceId);
Task SetDeviceInfoAsync(long serviceId, string deviceId, CachedDeviceInfo info);
Task InvalidateAsync(long serviceId, string deviceId);
Task InvalidateByServiceAsync(long serviceId);
}

View File

@ -35,6 +35,7 @@ public class DeviceService : IDeviceService
if (existing != null)
{
existing.ExternalDeviceId = request.DeviceId;
existing.Platform = platform;
existing.OsVersion = request.OsVersion;
existing.AppVersion = request.AppVersion;
@ -43,14 +44,15 @@ public class DeviceService : IDeviceService
existing.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(existing);
await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(serviceId, existing.Id);
await _tokenCache.InvalidateAsync(serviceId, existing.ExternalDeviceId);
return new DeviceRegisterResponseDto { DeviceId = existing.Id, IsNew = false };
return new DeviceRegisterResponseDto { DeviceId = existing.ExternalDeviceId, IsNew = false };
}
var device = new Device
{
ServiceId = serviceId,
ExternalDeviceId = request.DeviceId,
DeviceToken = request.DeviceToken,
Platform = platform,
OsVersion = request.OsVersion,
@ -64,18 +66,18 @@ public class DeviceService : IDeviceService
await _deviceRepository.AddAsync(device);
await _unitOfWork.SaveChangesAsync();
return new DeviceRegisterResponseDto { DeviceId = device.Id, IsNew = true };
return new DeviceRegisterResponseDto { DeviceId = device.ExternalDeviceId, IsNew = true };
}
public async Task<DeviceInfoResponseDto> GetInfoAsync(long serviceId, DeviceInfoRequestDto request)
{
var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId);
var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId);
if (device == null)
throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404);
return new DeviceInfoResponseDto
{
DeviceId = device.Id,
DeviceId = device.ExternalDeviceId,
DeviceToken = device.DeviceToken,
Platform = device.Platform.ToString().ToLowerInvariant(),
OsVersion = device.OsVersion,
@ -92,7 +94,7 @@ public class DeviceService : IDeviceService
public async Task UpdateAsync(long serviceId, DeviceUpdateRequestDto request)
{
var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId);
var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId);
if (device == null)
throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404);
@ -106,12 +108,12 @@ public class DeviceService : IDeviceService
device.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device);
await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(serviceId, device.Id);
await _tokenCache.InvalidateAsync(serviceId, device.ExternalDeviceId);
}
public async Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request)
{
var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId);
var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId);
if (device == null)
throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404);
@ -119,12 +121,13 @@ public class DeviceService : IDeviceService
device.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device);
await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(serviceId, device.Id);
await _tokenCache.InvalidateAsync(serviceId, device.ExternalDeviceId);
}
public async Task AdminDeleteAsync(long deviceId)
public async Task AdminDeleteAsync(string externalDeviceId)
{
var device = await _deviceRepository.GetByIdAsync(deviceId);
var results = await _deviceRepository.FindAsync(d => d.ExternalDeviceId == externalDeviceId);
var device = results.FirstOrDefault();
if (device == null)
throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404);
@ -132,7 +135,7 @@ public class DeviceService : IDeviceService
device.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device);
await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(device.ServiceId, device.Id);
await _tokenCache.InvalidateAsync(device.ServiceId, device.ExternalDeviceId);
}
public async Task<DeviceListResponseDto> GetListAsync(long? serviceId, DeviceListRequestDto request)
@ -169,7 +172,7 @@ public class DeviceService : IDeviceService
{
Items = items.Select(d => new DeviceSummaryDto
{
DeviceId = d.Id,
DeviceId = d.ExternalDeviceId,
Platform = d.Platform.ToString().ToLowerInvariant(),
Model = d.DeviceModel,
PushAgreed = d.PushAgreed,
@ -259,7 +262,7 @@ public class DeviceService : IDeviceService
public async Task SetTagsAsync(long serviceId, DeviceTagsRequestDto request)
{
var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId);
var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId);
if (device == null)
throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404);
@ -275,7 +278,7 @@ public class DeviceService : IDeviceService
public async Task SetAgreeAsync(long serviceId, DeviceAgreeRequestDto request)
{
var device = await _deviceRepository.GetByIdAndServiceAsync(request.DeviceId, serviceId);
var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId);
if (device == null)
throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404);
@ -286,7 +289,7 @@ public class DeviceService : IDeviceService
device.MktAgreeUpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device);
await _unitOfWork.SaveChangesAsync();
await _tokenCache.InvalidateAsync(serviceId, device.Id);
await _tokenCache.InvalidateAsync(serviceId, device.ExternalDeviceId);
}
private static Platform ParsePlatform(string platform)

View File

@ -58,7 +58,7 @@ public class PushService : IPushService
Target = new PushTargetDto
{
Type = "device_ids",
Value = JsonSerializer.SerializeToElement(new[] { request.DeviceId })
Value = JsonSerializer.SerializeToElement(new[] { request.DeviceId }) // UUID string
},
CreatedBy = message.CreatedBy,
CreatedAt = DateTime.UtcNow.ToString("o")
@ -126,7 +126,7 @@ public class PushService : IPushService
if (sendType != "single" && sendType != "tag")
throw new SpmsException(ErrorCodes.BadRequest, "send_type은 single 또는 tag만 허용됩니다.", 400);
if (sendType == "single" && request.DeviceId == null)
if (sendType == "single" && string.IsNullOrWhiteSpace(request.DeviceId))
throw new SpmsException(ErrorCodes.BadRequest, "send_type=single 시 device_id는 필수입니다.", 400);
if (sendType == "tag" && (request.Tags == null || request.Tags.Count == 0))
@ -144,7 +144,7 @@ public class PushService : IPushService
target = new PushTargetDto
{
Type = "device_ids",
Value = JsonSerializer.SerializeToElement(new[] { request.DeviceId!.Value })
Value = JsonSerializer.SerializeToElement(new[] { request.DeviceId! }) // UUID string
};
}
else
@ -252,7 +252,7 @@ public class PushService : IPushService
{
SendId = l.Id,
MessageCode = l.Message?.MessageCode ?? string.Empty,
DeviceId = l.DeviceId,
DeviceId = l.Device?.ExternalDeviceId ?? l.DeviceId.ToString(),
Status = l.Status.ToString().ToLowerInvariant(),
FailReason = l.FailReason,
SentAt = l.SentAt
@ -301,7 +301,7 @@ public class PushService : IPushService
Target = new PushTargetDto
{
Type = "device_ids",
Value = JsonSerializer.SerializeToElement(new[] { row.DeviceId })
Value = JsonSerializer.SerializeToElement(new[] { row.DeviceId }) // UUID string
},
CreatedBy = message.CreatedBy,
CreatedAt = DateTime.UtcNow.ToString("o"),
@ -450,7 +450,8 @@ public class PushService : IPushService
if (values.Length <= deviceIdIndex)
continue;
if (!long.TryParse(values[deviceIdIndex], out var deviceId))
var deviceId = values[deviceIdIndex];
if (string.IsNullOrWhiteSpace(deviceId))
continue;
var variables = new Dictionary<string, string>();
@ -468,7 +469,7 @@ public class PushService : IPushService
private class CsvRow
{
public long DeviceId { get; init; }
public string DeviceId { get; init; } = string.Empty;
public Dictionary<string, string> Variables { get; init; } = new();
}

View File

@ -5,6 +5,7 @@ namespace SPMS.Domain.Entities;
public class Device : BaseEntity
{
public long ServiceId { get; set; }
public string ExternalDeviceId { get; set; } = string.Empty;
public string DeviceToken { get; set; } = string.Empty;
public Platform Platform { get; set; }
public string? AppVersion { get; set; }

View File

@ -7,6 +7,7 @@ public interface IDeviceRepository : IRepository<Device>
{
Task<Device?> GetByServiceAndTokenAsync(long serviceId, string deviceToken);
Task<Device?> GetByIdAndServiceAsync(long id, long serviceId);
Task<Device?> GetByExternalIdAndServiceAsync(string externalId, long serviceId);
Task<int> GetActiveCountByServiceAsync(long serviceId);
Task<IReadOnlyList<Device>> GetByPlatformAsync(long serviceId, Platform platform);
Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync(

View File

@ -25,7 +25,7 @@ public class TokenCacheService : ITokenCacheService
_logger = logger;
}
public async Task<CachedDeviceInfo?> GetDeviceInfoAsync(long serviceId, long deviceId)
public async Task<CachedDeviceInfo?> GetDeviceInfoAsync(long serviceId, string deviceId)
{
try
{
@ -45,7 +45,7 @@ public class TokenCacheService : ITokenCacheService
}
}
public async Task SetDeviceInfoAsync(long serviceId, long deviceId, CachedDeviceInfo info)
public async Task SetDeviceInfoAsync(long serviceId, string deviceId, CachedDeviceInfo info)
{
try
{
@ -60,7 +60,7 @@ public class TokenCacheService : ITokenCacheService
}
}
public async Task InvalidateAsync(long serviceId, long deviceId)
public async Task InvalidateAsync(long serviceId, string deviceId)
{
try
{
@ -97,6 +97,6 @@ public class TokenCacheService : ITokenCacheService
}
}
private string BuildKey(long serviceId, long deviceId) =>
private string BuildKey(long serviceId, string deviceId) =>
$"{_settings.InstanceName}device:token:{serviceId}:{deviceId}";
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SPMS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddExternalDeviceId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExternalDeviceId",
table: "Device",
type: "varchar(36)",
maxLength: 36,
nullable: false,
defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Device_ServiceId_ExternalDeviceId",
table: "Device",
columns: new[] { "ServiceId", "ExternalDeviceId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Device_ServiceId_ExternalDeviceId",
table: "Device");
migrationBuilder.DropColumn(
name: "ExternalDeviceId",
table: "Device");
}
}
}

View File

@ -282,6 +282,11 @@ namespace SPMS.Infrastructure.Migrations
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("ExternalDeviceId")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("varchar(36)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
@ -316,6 +321,9 @@ namespace SPMS.Infrastructure.Migrations
b.HasIndex("ServiceId", "DeviceToken");
b.HasIndex("ServiceId", "ExternalDeviceId")
.IsUnique();
b.ToTable("Device", (string)null);
});

View File

@ -14,6 +14,7 @@ public class DeviceConfiguration : IEntityTypeConfiguration<Device>
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.ServiceId).IsRequired();
builder.Property(e => e.ExternalDeviceId).HasMaxLength(36).IsRequired();
builder.Property(e => e.DeviceToken).HasMaxLength(255).IsRequired();
builder.Property(e => e.Platform).HasColumnType("tinyint").IsRequired();
builder.Property(e => e.AppVersion).HasMaxLength(20);
@ -33,6 +34,7 @@ public class DeviceConfiguration : IEntityTypeConfiguration<Device>
.HasForeignKey(e => e.ServiceId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.ServiceId, e.ExternalDeviceId }).IsUnique();
builder.HasIndex(e => new { e.ServiceId, e.DeviceToken });
}
}

View File

@ -19,6 +19,11 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
return await _dbSet.FirstOrDefaultAsync(d => d.Id == id && d.ServiceId == serviceId);
}
public async Task<Device?> GetByExternalIdAndServiceAsync(string externalId, long serviceId)
{
return await _dbSet.FirstOrDefaultAsync(d => d.ExternalDeviceId == externalId && d.ServiceId == serviceId);
}
public async Task<int> GetActiveCountByServiceAsync(long serviceId)
{
return await _dbSet.CountAsync(d => d.ServiceId == serviceId && d.IsActive);

View File

@ -17,6 +17,7 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
{
var query = _dbSet
.Include(l => l.Message)
.Include(l => l.Device)
.Where(l => l.ServiceId == serviceId);
if (messageId.HasValue)

View File

@ -110,7 +110,7 @@ public class DeadTokenCleanupWorker : BackgroundService
{
var batch = await context.Set<Device>()
.Where(d => !d.IsActive && d.UpdatedAt < cutoffUtc)
.Select(d => new { d.Id, d.ServiceId })
.Select(d => new { d.Id, d.ServiceId, d.ExternalDeviceId })
.Take(BatchSize)
.ToListAsync(stoppingToken);
@ -130,7 +130,7 @@ public class DeadTokenCleanupWorker : BackgroundService
// 삭제된 디바이스의 Redis 캐시 무효화
foreach (var item in batch)
await _tokenCache.InvalidateAsync(item.ServiceId, item.Id);
await _tokenCache.InvalidateAsync(item.ServiceId, item.ExternalDeviceId);
_logger.LogInformation("DeadTokenCleanupWorker 배치 삭제: {BatchCount}건 (누적: {TotalDeleted}건), 캐시 무효화 완료",
deletedInBatch, totalDeleted);

View File

@ -310,21 +310,21 @@ public class PushWorker : BackgroundService
{
case "single":
{
var deviceIds = ParseDeviceIds(message.Target);
if (deviceIds.Count == 0) return [];
var externalIds = ParseExternalDeviceIds(message.Target);
if (externalIds.Count == 0) return [];
var devices = new List<Device>();
foreach (var id in deviceIds)
foreach (var externalId in externalIds)
{
// Redis 캐시 우선 조회
var cached = await _tokenCache.GetDeviceInfoAsync(message.ServiceId, id);
// Redis 캐시 우선 조회 (ExternalDeviceId 기준)
var cached = await _tokenCache.GetDeviceInfoAsync(message.ServiceId, externalId);
if (cached != null)
{
if (cached.IsActive && cached.PushAgreed)
{
devices.Add(new Device
{
Id = id,
ExternalDeviceId = externalId,
ServiceId = message.ServiceId,
DeviceToken = cached.Token,
Platform = (Platform)cached.Platform,
@ -336,10 +336,10 @@ public class PushWorker : BackgroundService
}
// 캐시 미스 → DB 조회 후 캐시 저장
var device = await deviceRepo.GetByIdAndServiceAsync(id, message.ServiceId);
var device = await deviceRepo.GetByExternalIdAndServiceAsync(externalId, message.ServiceId);
if (device != null)
{
await _tokenCache.SetDeviceInfoAsync(message.ServiceId, id,
await _tokenCache.SetDeviceInfoAsync(message.ServiceId, externalId,
new CachedDeviceInfo(device.DeviceToken, (int)device.Platform,
device.IsActive, device.PushAgreed));
@ -383,7 +383,7 @@ public class PushWorker : BackgroundService
}
}
private static List<long> ParseDeviceIds(PushTargetDto target)
private static List<string> ParseExternalDeviceIds(PushTargetDto target)
{
if (target.Value == null) return [];
@ -392,8 +392,8 @@ public class PushWorker : BackgroundService
if (target.Value.Value.ValueKind == JsonValueKind.Array)
{
return target.Value.Value.EnumerateArray()
.Where(e => e.ValueKind == JsonValueKind.Number)
.Select(e => e.GetInt64())
.Where(e => e.ValueKind == JsonValueKind.String)
.Select(e => e.GetString()!)
.ToList();
}
}