From 76d989a4fac81499f30a40decf2675524529c3d5 Mon Sep 17 00:00:00 2001 From: Seonkyu_Kim Date: Wed, 5 Mar 2025 15:30:06 +0900 Subject: [PATCH] =?UTF-8?q?[=E2=99=BB=EF=B8=8F]=20PUSH=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EA=B2=BD=20-=20uid=20=EC=99=80=20pid=20?= =?UTF-8?q?=EB=A5=BC=20=ED=99=9C=EC=9A=A9=ED=95=B4=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=EA=B0=80=EB=8A=A5,=20=EB=B1=83=EC=A7=80=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5,=20=EC=BB=A8=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=B6=94=EA=B0=80=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Program/Common/Data/AppDbContext.cs | 7 +- Program/V1/Controllers/PushController.cs | 139 ++++++++++++++--------- Program/V1/Controllers/UserController.cs | 3 +- Program/V1/Models/AcaException.cs | 11 ++ Program/V1/Models/PushPayload.cs | 105 ++++++++++++++++- Program/V1/Models/User.cs | 2 + Program/V1/Services/InMemoryPushQueue.cs | 24 ++-- Program/V1/Services/RepositoryService.cs | 52 ++++++++- 8 files changed, 270 insertions(+), 73 deletions(-) diff --git a/Program/Common/Data/AppDbContext.cs b/Program/Common/Data/AppDbContext.cs index 3fddb4f..677f0d9 100644 --- a/Program/Common/Data/AppDbContext.cs +++ b/Program/Common/Data/AppDbContext.cs @@ -25,11 +25,16 @@ public class AppDbContext: DbContext public DbSet Location { get; set; } public DbSet Contact { get; set; } + //MARK: PUSH + public DbSet DBPayload { get; set; } + public DbSet PushCabinet { get; set; } - protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasKey(ua => new { ua.uid, ua.bid }); + + modelBuilder.Entity() + .HasKey(p => new { p.uid, p.pid }); } } \ No newline at end of file diff --git a/Program/V1/Controllers/PushController.cs b/Program/V1/Controllers/PushController.cs index 12c209e..99d2468 100644 --- a/Program/V1/Controllers/PushController.cs +++ b/Program/V1/Controllers/PushController.cs @@ -2,6 +2,10 @@ using AcaMate.Common.Models; using AcaMate.V1.Models; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Update.Internal; + +using AcaMate.Common.Data; using AcaMate.V1.Services; namespace AcaMate.V1.Controllers; @@ -14,10 +18,14 @@ public class PushController : ControllerBase { private readonly ILogger _logger; private readonly IPushQueue _pushQueue; - public PushController(ILogger logger, IPushQueue pushQueue) + private readonly AppDbContext _dbContext; + private readonly IRepositoryService _repositoryService; + public PushController(ILogger logger, IPushQueue pushQueue, AppDbContext dbContext, IRepositoryService repositoryService) { _logger = logger; _pushQueue = pushQueue; + _dbContext = dbContext; + _repositoryService = repositoryService; } [HttpGet()] @@ -27,13 +35,12 @@ public class PushController : ControllerBase return Ok("SEND"); } - - + + /// /// Sends a push notification to the specified device token with the provided payload. /// - /// The device token to send the notification to. - /// The payload of the push notification. + /// /// An IActionResult indicating the result of the operation. /// Push notification sent successfully. /// Invalid input parameters. @@ -42,58 +49,78 @@ public class PushController : ControllerBase [HttpPost("send")] [CustomOperation("푸시전송", "저장된 양식으로, 사용자에게 푸시를 전송한다.(로컬 테스트 불가)", "푸시")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(APIResponseStatus))] - public async Task SendPush(string deviceToken, [FromBody] Payload payload) + public async Task SendPush([FromBody] PushRequest pushRequest) { - - if (string.IsNullOrWhiteSpace(deviceToken) || payload == null) - return BadRequest(APIResponse.InvalidInputError()); - - var pushRequest = new PushRequest + if (!ModelState.IsValid) return BadRequest(APIResponse.InvalidInputError()); + + try { - deviceToken = deviceToken, - payload = payload - }; - _pushQueue.Enqueue(pushRequest); - return Ok("[푸시] 접수 완료"); + var payload = await _dbContext.DBPayload + .Where(p => p.pid == pushRequest.pid) + .Select(p => new Payload + { + aps = new Aps + { + alert = new Alert { title = p.title, body = p.body, subtitle = p.subtitle ?? "" }, + category = p.category + }, + pid = pushRequest.pid, + content = p.content ?? "", + }) + .FirstOrDefaultAsync(); - // - // - // var isDev = _env.IsDevelopment(); - // - // var pushFileSetting = new PushFileSetting() - // { - // uri = isDev ? "https://api.sandbox.push.apple.com/" : "https://api.push.apple.com/", - // p12Path = isDev ? "/src/private/AM_Push_Sandbox.p12" : "/src/private/AM_Push.p12", - // p12PWPath = "/src/private/appleKeys.json", - // apnsTopic = "me.myds.ipstein.acamate.AcaMate" - // }; - // - // try - // { - // if (await new ApnsPushService().SendPushNotificationAsync(deviceToken, pushFileSetting, payload)) - // { - // return Ok(APIResponse.Send("000", "정상", Empty)); - // } - // else - // { - // return StatusCode(500, APIResponse.UnknownError()); - // } - // - // } - // catch (ServiceConnectionFailedException failEx) - // { - // _logger.LogError($"[푸시][에러] : {failEx}"); - // return StatusCode(300, APIResponse.InternalSeverError()); - // } - // catch (HttpRequestException httpEx) - // { - // _logger.LogError($"[푸시][에러] : {httpEx}"); - // return StatusCode(300, APIResponse.InternalSeverError()); - // } - // catch (Exception ex) - // { - // _logger.LogError($"[푸시][에러] : {ex}"); - // return StatusCode(500, APIResponse.UnknownError()); - // } + await Task.Run(async () => + { + foreach (var uid in pushRequest.uids) + { + var badge = await _dbContext.PushCabinet + .Where(c => c.uid == uid && c.check_yn == false && c.pid != pushRequest.pid) + .CountAsync(); + + var pushToken = await _dbContext.User + .Where(u => u.uid == uid) + .Select(u => u.push_token) + .FirstOrDefaultAsync() ?? ""; + + var pushCabinet = new PushCabinet + { + uid = uid, + pid = pushRequest.pid, + send_date = DateTime.Now, + }; + + if (payload != null) payload.aps.badge = badge + 1; + + var pushData = new PushData + { + pushToken = pushToken, + payload = payload ?? throw new PushInvalidException("payload is NULL") + }; + await _repositoryService.SaveDataFK(pushCabinet); + + _pushQueue.Enqueue(pushData); + } + }); + + return Ok(APIResponse.Send("000", "정상", Empty)); + + } + catch (PushInvalidException ex) + { + _logger.LogError(ex.Message); + return Ok(APIResponse.Send("001", "푸시 송신: 푸시 전송 중 문제 발생",Empty)); + + } + catch (Exception ex) + { + _logger.LogError($"[푸시] {ex.Message}"); + return StatusCode(500, APIResponse.UnknownError()); + } } -} \ No newline at end of file + +} + + + + + diff --git a/Program/V1/Controllers/UserController.cs b/Program/V1/Controllers/UserController.cs index 00b68a6..902a104 100644 --- a/Program/V1/Controllers/UserController.cs +++ b/Program/V1/Controllers/UserController.cs @@ -205,7 +205,8 @@ public class UserController : ControllerBase type = request.type, device_id = request.device_id, auto_login_yn = request.auto_login_yn, - login_date = request.login_date + login_date = request.login_date, + push_token = request.push_token }; var login = new Login { diff --git a/Program/V1/Models/AcaException.cs b/Program/V1/Models/AcaException.cs index 2fac8b1..3002f2a 100644 --- a/Program/V1/Models/AcaException.cs +++ b/Program/V1/Models/AcaException.cs @@ -50,5 +50,16 @@ public class ServiceConnectionFailedException : Exception public ServiceConnectionFailedException(string message) : base(message) { + } +} + +/// +/// PUSH 서비스 중 데이터 사용에 문제가 발생했을시 +/// +public class PushInvalidException : Exception +{ + public PushInvalidException(string message) : base(message) + { + } } \ No newline at end of file diff --git a/Program/V1/Models/PushPayload.cs b/Program/V1/Models/PushPayload.cs index eac6d51..9a46d19 100644 --- a/Program/V1/Models/PushPayload.cs +++ b/Program/V1/Models/PushPayload.cs @@ -1,11 +1,112 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using System.Security.Cryptography.X509Certificates; namespace AcaMate.V1.Models; +/* + * iOS Payload 정의 + * aps 딕셔너리 구성 + * 1. alert : 실질적으로 사용자에게 노출되는 항목 + * 1.1. title : 제목 + * 1.2. subtitle : 부제목 + * 1.3. body : 본문 내용 + * 2. badge : 앱 아이콘에 표시할 숫자 + * 3. sound : 소리인데 "default" 가능 + * 4. content-available : 백그라운드 업데이트나 사일런트 푸시 송신시 사용한다. + * 1로 설정해 사용자가 직접 보지 않아도 앱이 업데이트 되게 할 수 있으며, + * UI에 알림이 표시되지 않고 데이터만 전달할 때 필요하다. + * 5. mutable-content : 값을 1로 설정해두면 Notification Service Extension을 통해 알림의 내용을 변경할 수 있다. + * 6. category : 사용자 인터렉션 구성시에 사용하는 식별자이다. + * 7. thread-id : 관련 알림들을 그룹화해 관리할 때 사용한다. +*/ + +/* + * FCM Payload + * https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?hl=ko&_gl=1*3awp3i*_up*MQ..*_ga*MTMyNDk4ODU5MC4xNzQxMTM1MzI3*_ga_CW55HF8NVT*MTc0MTEzNTMyNy4xLjAuMTc0MTEzNTM4My4wLjAuMA..#Notification + * { + "name": string, + "data": { // 입력 전용으로 임의의 가 + string: string, + ... + }, + "notification": { + object (Notification) + }, + "android": { + object (AndroidConfig) + }, + "webpush": { + object (WebpushConfig) + }, + "apns": { + object (ApnsConfig) + }, + "fcm_options": { + object (FcmOptions) + }, + + // Union field target can be only one of the following: + "token": string, + "topic": string, + "condition": string + // End of list of possible types for union field target. + } + * 1. Notification 영역 + * 1.1. title: 알림 제목 + * 1.2. body: 알림 내용 + * 1.3. image: 알림에 표시할 이미지 URL + * 1.4. sound: 알림 재생 시 사용할 사운드 + * 1.5. click_action: 알림 클릭 시 실행할 액션(인텐트 액션 문자열) + * 1.6. tag: 동일 태그를 가진 알림끼리 대체 또는 그룹화할 때 사용 + * 1.7. color: 알림 아이콘 배경색 (16진수 코드 등) + * 1.8. body_loc_key 및 body_loc_args: 본문에 사용할 지역화 키와 인자 배열 + * 1.9. title_loc_key 및 title_loc_args: 제목에 사용할 지역화 키와 인자 배열 + * 1.10. channel_id: Android Oreo 이상에서 알림 채널 식별자 + * 1.11. ticker, sticky, event_time, notification_priority, visibility, notification_count, light_settings, vibrate_timings 등: 사용자 경험이나 알림의 동작 방식을 세밀하게 제어할 때 사용 + * 2. Data 영역 : 임의의 키-값 쌍을 포함하고 UI에 표시되는 내용이 아닌 앱의 로직에 활용할 데이터를 보낼 때 사용한다. + * 3. 기타 전송 옵션 + * 3.1. priority: 메시지 전송 우선순위 (“high” 또는 “normal”) + * 3.2. time_to_live (ttl): 메시지 유효 시간(초 단위) + * 3.3. collapse_key: 동일 collapse_key를 가진 메시지는 최신 하나로 교체됨 + * 3.4. restricted_package_name: 메시지를 수신할 앱의 패키지 이름 (Android 전용) + */ + +[Table("payload")] +public class DBPayload +{ + [Key] + [MaxLength(22)] + public string pid { get; set; } + public string title {get; set;} + public string? subtitle {get; set;} + public string body {get; set;} + public bool alert_yn {get; set;} + public string category {get; set;} + public string content {get; set;} +} + +[Table("push_cabinet")] +public class PushCabinet +{ + public string uid { get; set; } + public string pid { get; set; } + public DateTime send_date { get; set; } + public bool check_yn { get; set; } +} + +public class PushRequest +{ + public List uids { get; set; } + public string pid { get; set; } +} + + public class Payload { public Aps aps { get; set; } + public string pid { get; set; } + public string content { get; set; } // public string customKey { get; set; } 이런식으로 추가도 가능 public string ToJson() @@ -51,8 +152,8 @@ public class PushFileSetting public string apnsTopic { get; set; } } -public class PushRequest +public class PushData { - public string deviceToken { get; set; } + public string pushToken { get; set; } public Payload payload { get; set; } } \ No newline at end of file diff --git a/Program/V1/Models/User.cs b/Program/V1/Models/User.cs index 2eab154..6e08fba 100644 --- a/Program/V1/Models/User.cs +++ b/Program/V1/Models/User.cs @@ -59,6 +59,7 @@ public class User [Required(ErrorMessage = "필수 항목 누락")] public DateTime login_date { get; set; } + public string? push_token { get; set; } } @@ -128,6 +129,7 @@ public class UserAll public bool auto_login_yn { get; set; } public DateTime login_date { get; set; } + public string? push_token { get; set; } [Required(ErrorMessage = "필수 항목 누락")] public string email { get; set; } diff --git a/Program/V1/Services/InMemoryPushQueue.cs b/Program/V1/Services/InMemoryPushQueue.cs index 2489771..ab7cbd8 100644 --- a/Program/V1/Services/InMemoryPushQueue.cs +++ b/Program/V1/Services/InMemoryPushQueue.cs @@ -9,28 +9,28 @@ namespace AcaMate.V1.Services; public interface IPushQueue { - void Enqueue(PushRequest request); - Task DequeueAsync(CancellationToken cancellationToken); + void Enqueue(PushData pushData); + Task DequeueAsync(CancellationToken cancellationToken); } public class InMemoryPushQueue: IPushQueue { - private readonly ConcurrentQueue _queue = new ConcurrentQueue(); + private readonly ConcurrentQueue _queue = new ConcurrentQueue(); private readonly SemaphoreSlim _signal = new SemaphoreSlim(0); - public void Enqueue(PushRequest request) + public void Enqueue(PushData pushData) { - if( request is null ) - throw new ArgumentNullException(nameof(request)); - _queue.Enqueue(request); + if( pushData is null ) + throw new ArgumentNullException(nameof(pushData)); + _queue.Enqueue(pushData); _signal.Release(); } - public async Task DequeueAsync(CancellationToken cancellationToken) + public async Task DequeueAsync(CancellationToken cancellationToken) { await _signal.WaitAsync(cancellationToken); - _queue.TryDequeue(out var request); - return request; + _queue.TryDequeue(out var pushData); + return pushData; } } @@ -49,10 +49,10 @@ public class PushBackgroundService : BackgroundService { while (!stoppingToken.IsCancellationRequested) { - var pushRequest = await _queue.DequeueAsync(stoppingToken); + var pushData = await _queue.DequeueAsync(stoppingToken); try { - await _pushService.SendPushNotificationAsync(pushRequest.deviceToken, pushRequest.payload); + await _pushService.SendPushNotificationAsync(pushData.pushToken, pushData.payload); } catch (Exception ex) { diff --git a/Program/V1/Services/RepositoryService.cs b/Program/V1/Services/RepositoryService.cs index 7b608cb..b71dc28 100644 --- a/Program/V1/Services/RepositoryService.cs +++ b/Program/V1/Services/RepositoryService.cs @@ -17,6 +17,7 @@ public interface IRepositoryService { Task SaveData(T entity, Expression> key) where T : class; Task ValidateToken(string token, string refresh); + Task SaveDataFK(T entity) where T : class; } public class RepositoryService: IRepositoryService @@ -140,5 +141,54 @@ public class RepositoryService: IRepositoryService } } } - + + // public async Task SaveData(T pushCabinet) + // { + // throw new NotImplementedException(); + // } + + + public async Task SaveDataFK(T entity) where T : class + { + try + { + // EF Core 메타데이터를 통해 엔티티 T의 기본 키 속성 정보를 가져옴 + var keyProperties = _dbContext.Model.FindEntityType(typeof(T)).FindPrimaryKey().Properties; + + // 각 키 속성에 대해, entity에서 실제 키 값을 추출 + var keyValues = keyProperties + .Select(p => typeof(T).GetProperty(p.Name).GetValue(entity)) + .ToArray(); + + // 기본 키 값을 이용해서 기존 엔티티를 찾음 (복합 키도 자동으로 처리됨) + var existingEntity = await _dbContext.Set().FindAsync(keyValues); + + if (existingEntity != null) + { + _logger.LogInformation($"[{typeof(T)}] 기존 데이터 발견: 기본 키 값({string.Join(", ", keyValues)})"); + // 기존 엔티티를 업데이트: 새 entity의 값으로 교체 + _dbContext.Entry(existingEntity).CurrentValues.SetValues(entity); + } + else + { + _logger.LogInformation($"[{typeof(T)}] 신규 데이터 등록: 기본 키 값({string.Join(", ", keyValues)})"); + // 데이터가 없으면 새 엔티티 추가 + _dbContext.Set().Add(entity); + } + + // 변경 사항 저장 + await _dbContext.SaveChangesAsync(); + _logger.LogInformation($"[{typeof(T)}] DB 저장 완료"); + return true; + } + catch (Exception ex) + { + _logger.LogError($"[{typeof(T)}] 저장 중 오류 발생: {ex}"); + return false; + } + } + + + + } \ No newline at end of file