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] [Required]
[JsonPropertyName("device_id")] [JsonPropertyName("device_id")]
public long DeviceId { get; set; } public string DeviceId { get; set; } = string.Empty;
[Required] [Required]
[JsonPropertyName("push_agreed")] [JsonPropertyName("push_agreed")]

View File

@ -7,5 +7,5 @@ public class DeviceDeleteRequestDto
{ {
[Required] [Required]
[JsonPropertyName("device_id")] [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] [Required]
[JsonPropertyName("device_id")] [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 public class DeviceInfoResponseDto
{ {
[JsonPropertyName("device_id")] [JsonPropertyName("device_id")]
public long DeviceId { get; set; } public string DeviceId { get; set; } = string.Empty;
[JsonPropertyName("device_token")] [JsonPropertyName("device_token")]
public string DeviceToken { get; set; } = string.Empty; public string DeviceToken { get; set; } = string.Empty;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ namespace SPMS.Domain.Entities;
public class Device : BaseEntity public class Device : BaseEntity
{ {
public long ServiceId { get; set; } public long ServiceId { get; set; }
public string ExternalDeviceId { get; set; } = string.Empty;
public string DeviceToken { get; set; } = string.Empty; public string DeviceToken { get; set; } = string.Empty;
public Platform Platform { get; set; } public Platform Platform { get; set; }
public string? AppVersion { 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?> GetByServiceAndTokenAsync(long serviceId, string deviceToken);
Task<Device?> GetByIdAndServiceAsync(long id, long serviceId); Task<Device?> GetByIdAndServiceAsync(long id, long serviceId);
Task<Device?> GetByExternalIdAndServiceAsync(string externalId, long serviceId);
Task<int> GetActiveCountByServiceAsync(long serviceId); Task<int> GetActiveCountByServiceAsync(long serviceId);
Task<IReadOnlyList<Device>> GetByPlatformAsync(long serviceId, Platform platform); Task<IReadOnlyList<Device>> GetByPlatformAsync(long serviceId, Platform platform);
Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync( Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync(

View File

@ -25,7 +25,7 @@ public class TokenCacheService : ITokenCacheService
_logger = logger; _logger = logger;
} }
public async Task<CachedDeviceInfo?> GetDeviceInfoAsync(long serviceId, long deviceId) public async Task<CachedDeviceInfo?> GetDeviceInfoAsync(long serviceId, string deviceId)
{ {
try 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 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 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}"; $"{_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) .HasMaxLength(255)
.HasColumnType("varchar(255)"); .HasColumnType("varchar(255)");
b.Property<string>("ExternalDeviceId")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("varchar(36)");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
@ -316,6 +321,9 @@ namespace SPMS.Infrastructure.Migrations
b.HasIndex("ServiceId", "DeviceToken"); b.HasIndex("ServiceId", "DeviceToken");
b.HasIndex("ServiceId", "ExternalDeviceId")
.IsUnique();
b.ToTable("Device", (string)null); 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.Id).ValueGeneratedOnAdd();
builder.Property(e => e.ServiceId).IsRequired(); 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.DeviceToken).HasMaxLength(255).IsRequired();
builder.Property(e => e.Platform).HasColumnType("tinyint").IsRequired(); builder.Property(e => e.Platform).HasColumnType("tinyint").IsRequired();
builder.Property(e => e.AppVersion).HasMaxLength(20); builder.Property(e => e.AppVersion).HasMaxLength(20);
@ -33,6 +34,7 @@ public class DeviceConfiguration : IEntityTypeConfiguration<Device>
.HasForeignKey(e => e.ServiceId) .HasForeignKey(e => e.ServiceId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.ServiceId, e.ExternalDeviceId }).IsUnique();
builder.HasIndex(e => new { e.ServiceId, e.DeviceToken }); 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); 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) public async Task<int> GetActiveCountByServiceAsync(long serviceId)
{ {
return await _dbSet.CountAsync(d => d.ServiceId == serviceId && d.IsActive); 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 var query = _dbSet
.Include(l => l.Message) .Include(l => l.Message)
.Include(l => l.Device)
.Where(l => l.ServiceId == serviceId); .Where(l => l.ServiceId == serviceId);
if (messageId.HasValue) if (messageId.HasValue)

View File

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

View File

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