From 5ad71ac5b90cdc791f2cee4fc979db80be9cba2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=84?= <102128060+wlgusqkr@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:24:42 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=B2=98=EB=A6=AC=20+=20keyword=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Provider/AuthInterceptor.swift | 3 +- .../Core/Network/Service/APIService.swift | 151 +++++++----------- .../Core/Network/Targets/KeywordAPI.swift | 44 +++++ today-s-sound/Data/Models/APIResponse.swift | 12 ++ today-s-sound/Data/Models/Alarm.swift | 8 +- today-s-sound/Data/Models/Keyword.swift | 19 +++ today-s-sound/Data/Models/Subscription.swift | 9 +- today-s-sound/Data/Models/User.swift | 9 +- .../AddSubscription/AddSubscriptionView.swift | 79 +++++++-- .../AddSubscriptionViewModel.swift | 59 ++++++- 10 files changed, 273 insertions(+), 120 deletions(-) create mode 100644 today-s-sound/Core/Network/Targets/KeywordAPI.swift create mode 100644 today-s-sound/Data/Models/APIResponse.swift create mode 100644 today-s-sound/Data/Models/Keyword.swift diff --git a/today-s-sound/Core/Network/Provider/AuthInterceptor.swift b/today-s-sound/Core/Network/Provider/AuthInterceptor.swift index 4048a34..d1025d9 100644 --- a/today-s-sound/Core/Network/Provider/AuthInterceptor.swift +++ b/today-s-sound/Core/Network/Provider/AuthInterceptor.swift @@ -25,7 +25,8 @@ final class AuthInterceptor: RequestInterceptor { let bypass: Set = [ "/api/auth/login", - "/api/auth/refresh" + "/api/auth/refresh", + "/api/subscriptions/keywords" ] if bypass.contains(path) { diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift index e4d594f..1754333 100644 --- a/today-s-sound/Core/Network/Service/APIService.swift +++ b/today-s-sound/Core/Network/Service/APIService.swift @@ -12,6 +12,7 @@ protocol APIServiceType { func getAlarms( userId: String, deviceSecret: String, page: Int, size: Int ) -> AnyPublisher + func getKeywords() -> AnyPublisher } class APIService: APIServiceType { @@ -19,6 +20,7 @@ class APIService: APIServiceType { private let authProvider: MoyaProvider private let subscriptionProvider: MoyaProvider private let alarmProvider: MoyaProvider + private let keywordProvider: MoyaProvider init(userSession: UserSession = UserSession()) { #if DEBUG @@ -30,11 +32,13 @@ class APIService: APIServiceType { authProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) subscriptionProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) alarmProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) + keywordProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) #else userProvider = NetworkKit.provider(userSession: userSession) authProvider = NetworkKit.provider(userSession: userSession) subscriptionProvider = NetworkKit.provider(userSession: userSession) alarmProvider = NetworkKit.provider(userSession: userSession) + keywordProvider = NetworkKit.provider(userSession: userSession) #endif } @@ -45,10 +49,15 @@ class APIService: APIServiceType { .eraseToAnyPublisher() } - // MARK: - User API + // MARK: - 공통 응답 처리 헬퍼 - func registerAnonymous(deviceSecret: String) -> AnyPublisher { - userProvider.requestPublisher(.registerAnonymous(deviceSecret: deviceSecret)) + /// 공통 응답 처리: 상태 코드 체크, 디코딩, 에러 처리 + private func handleResponse( + _ response: Response, + decodeTo type: T.Type, + debugLabel: String = "" + ) -> AnyPublisher { + return Just(response) .tryMap { response -> Data in // 상태 코드 체크 guard (200 ... 299).contains(response.statusCode) else { @@ -58,15 +67,15 @@ class APIService: APIServiceType { // 🐛 디버깅: 서버 응답 출력 #if DEBUG if let jsonString = String(data: response.data, encoding: .utf8) { - print("📥 서버 응답 (Raw JSON):") + let label = debugLabel.isEmpty ? "서버 응답" : debugLabel + print("📥 \(label) (Raw JSON):") print(jsonString) } #endif return response.data } - // JSON 데이터를 RegisterAnonymousResponse 구조체로 자동 변환 - .decode(type: RegisterAnonymousResponse.self, decoder: JSONDecoder()) + .decode(type: type, decoder: JSONDecoder()) .mapError { error -> NetworkError in // 🐛 디버깅: 디코딩 에러 상세 출력 #if DEBUG @@ -103,6 +112,23 @@ class APIService: APIServiceType { .eraseToAnyPublisher() } + // MARK: - User API + + func registerAnonymous(deviceSecret: String) -> AnyPublisher { + userProvider.requestPublisher(.registerAnonymous(deviceSecret: deviceSecret)) + .mapError { moyaError -> NetworkError in + .requestFailed(moyaError) + } + .flatMap { [weak self] response -> AnyPublisher in + guard let self else { + return Fail(error: NetworkError.unknown) + .eraseToAnyPublisher() + } + return self.handleResponse(response, decodeTo: RegisterAnonymousResponse.self, debugLabel: "익명 사용자 등록 응답") + } + .eraseToAnyPublisher() + } + // MARK: - Subscription API func getSubscriptions( @@ -114,48 +140,15 @@ class APIService: APIServiceType { page: page, size: size )) - .tryMap { response -> Data in - // 상태 코드 체크 - guard (200 ... 299).contains(response.statusCode) else { - throw NetworkError.serverError(statusCode: response.statusCode) - } - - // 🐛 디버깅: 서버 응답 출력 - #if DEBUG - if let jsonString = String(data: response.data, encoding: .utf8) { - print("📥 구독 목록 응답 (Raw JSON):") - print(jsonString) - } - #endif - - return response.data + .mapError { moyaError -> NetworkError in + .requestFailed(moyaError) } - .decode(type: SubscriptionListResponse.self, decoder: JSONDecoder()) - .mapError { error -> NetworkError in - // 🐛 디버깅: 디코딩 에러 상세 출력 - #if DEBUG - if let decodingError = error as? DecodingError { - print("❌ 디코딩 에러 발생:") - switch decodingError { - case let .keyNotFound(key, context): - print(" - 키를 찾을 수 없음: \(key.stringValue)") - print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") - case let .typeMismatch(type, context): - print(" - 타입 불일치: \(type)") - print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") - @unknown default: - print(" - 알 수 없는 디코딩 에러") - } - } - #endif - - if let networkError = error as? NetworkError { - return networkError - } else if error is DecodingError { - return .decodingFailed(error) - } else { - return .requestFailed(error) + .flatMap { [weak self] response -> AnyPublisher in + guard let self else { + return Fail(error: NetworkError.unknown) + .eraseToAnyPublisher() } + return self.handleResponse(response, decodeTo: SubscriptionListResponse.self, debugLabel: "구독 목록 응답") } .eraseToAnyPublisher() } @@ -171,53 +164,33 @@ class APIService: APIServiceType { page: page, size: size )) - .tryMap { response -> Data in - // 상태 코드 체크 - guard (200 ... 299).contains(response.statusCode) else { - throw NetworkError.serverError(statusCode: response.statusCode) + .mapError { moyaError -> NetworkError in + .requestFailed(moyaError) + } + .flatMap { [weak self] response -> AnyPublisher in + guard let self else { + return Fail(error: NetworkError.unknown) + .eraseToAnyPublisher() } + return self.handleResponse(response, decodeTo: AlarmListResponse.self, debugLabel: "알림 목록 응답") + } + .eraseToAnyPublisher() + } - // 🐛 디버깅: 서버 응답 출력 - #if DEBUG - if let jsonString = String(data: response.data, encoding: .utf8) { - print("📥 알림 목록 응답 (Raw JSON):") - print(jsonString) - } - #endif + // MARK: - Keyword API - return response.data - } - .decode(type: AlarmListResponse.self, decoder: JSONDecoder()) - .mapError { error -> NetworkError in - // 🐛 디버깅: 디코딩 에러 상세 출력 - #if DEBUG - if let decodingError = error as? DecodingError { - print("❌ 디코딩 에러 발생:") - switch decodingError { - case let .keyNotFound(key, context): - print(" - 키를 찾을 수 없음: \(key.stringValue)") - print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") - case let .typeMismatch(type, context): - print(" - 타입 불일치: \(type)") - print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") - case let .valueNotFound(type, context): - print(" - 값을 찾을 수 없음: \(type)") - case let .dataCorrupted(context): - print(" - 데이터 손상") - @unknown default: - print(" - 알 수 없는 디코딩 에러") - } + func getKeywords() -> AnyPublisher { + keywordProvider.requestPublisher(.getKeywords) + .mapError { moyaError -> NetworkError in + .requestFailed(moyaError) + } + .flatMap { [weak self] response -> AnyPublisher in + guard let self else { + return Fail(error: NetworkError.unknown) + .eraseToAnyPublisher() } - #endif - - if let networkError = error as? NetworkError { - return networkError - } else if error is DecodingError { - return .decodingFailed(error) - } else { - return .requestFailed(error) + return self.handleResponse(response, decodeTo: KeywordsResponse.self, debugLabel: "키워드 목록 응답") } - } - .eraseToAnyPublisher() + .eraseToAnyPublisher() } } diff --git a/today-s-sound/Core/Network/Targets/KeywordAPI.swift b/today-s-sound/Core/Network/Targets/KeywordAPI.swift new file mode 100644 index 0000000..e07851a --- /dev/null +++ b/today-s-sound/Core/Network/Targets/KeywordAPI.swift @@ -0,0 +1,44 @@ +// +// KeywordAPI.swift +// today-s-sound +// +// Created by Assistant +// + +import Foundation +import Moya + +enum KeywordAPI { + case getKeywords +} + +extension KeywordAPI: APITargetType { + var path: String { + switch self { + case .getKeywords: + "/api/subscriptions/keywords" + } + } + + var method: Moya.Method { + switch self { + case .getKeywords: + .get + } + } + + var task: Task { + switch self { + case .getKeywords: + .requestPlain + } + } + + var headers: [String: String]? { + [ + "Content-Type": "application/json", + "Accept": "application/json" + ] + } +} + diff --git a/today-s-sound/Data/Models/APIResponse.swift b/today-s-sound/Data/Models/APIResponse.swift new file mode 100644 index 0000000..a452934 --- /dev/null +++ b/today-s-sound/Data/Models/APIResponse.swift @@ -0,0 +1,12 @@ +import Foundation + +// MARK: - 공통 API 응답 DTO + +/// 서버의 공통 응답 구조 +/// 모든 API 응답은 이 구조를 따릅니다: { "errorCode": Int?, "message": String, "result": T } +struct APIResponse: Codable { + let errorCode: Int? + let message: String + let result: T +} + diff --git a/today-s-sound/Data/Models/Alarm.swift b/today-s-sound/Data/Models/Alarm.swift index 47a7efe..7974fd8 100644 --- a/today-s-sound/Data/Models/Alarm.swift +++ b/today-s-sound/Data/Models/Alarm.swift @@ -9,12 +9,10 @@ import Foundation // MARK: - Alarm Response Models -/// 알림 목록 응답 (서버 Envelope 구조) -struct AlarmListResponse: Codable { - let errorCode: Int? - let message: String - let result: [AlarmItem] +/// 알림 목록 응답 +typealias AlarmListResponse = APIResponse<[AlarmItem]> +extension AlarmListResponse { // 편의 속성: result를 alarms로 접근 var alarms: [AlarmItem] { result diff --git a/today-s-sound/Data/Models/Keyword.swift b/today-s-sound/Data/Models/Keyword.swift new file mode 100644 index 0000000..9e96733 --- /dev/null +++ b/today-s-sound/Data/Models/Keyword.swift @@ -0,0 +1,19 @@ +import Foundation + +// MARK: - Keyword Response Models + +/// 키워드 결과 데이터 +struct KeywordsResult: Codable { + let keywords: [KeywordItem] +} + +/// 키워드 목록 응답 +typealias KeywordsResponse = APIResponse + +extension KeywordsResponse { + // 편의 속성: result.keywords를 keywords로 접근 + var keywords: [KeywordItem] { + result.keywords + } +} + diff --git a/today-s-sound/Data/Models/Subscription.swift b/today-s-sound/Data/Models/Subscription.swift index e957dbd..98134b9 100644 --- a/today-s-sound/Data/Models/Subscription.swift +++ b/today-s-sound/Data/Models/Subscription.swift @@ -2,12 +2,10 @@ import Foundation // MARK: - Subscription Response Models -/// 구독 목록 응답 (서버 Envelope 구조) -struct SubscriptionListResponse: Codable { - let errorCode: Int? - let message: String - let result: [SubscriptionItem] +/// 구독 목록 응답 +typealias SubscriptionListResponse = APIResponse<[SubscriptionItem]> +extension SubscriptionListResponse { // 편의 속성: result를 subscriptions로 접근 var subscriptions: [SubscriptionItem] { result @@ -31,7 +29,6 @@ struct SubscriptionItem: Codable, Identifiable { } } -/// 키워드 아이템 struct KeywordItem: Codable, Identifiable { let id: Int64 let name: String diff --git a/today-s-sound/Data/Models/User.swift b/today-s-sound/Data/Models/User.swift index 0580af0..921f8bc 100644 --- a/today-s-sound/Data/Models/User.swift +++ b/today-s-sound/Data/Models/User.swift @@ -13,16 +13,13 @@ struct RegisterAnonymousRequest: Codable { let deviceSecret: String } -struct RegisterAnonymousResponse: Codable { - let errorCode: String? - let message: String - let result: AnonymousUserResult -} - struct AnonymousUserResult: Codable { let userId: String } +/// 익명 사용자 등록 응답 +typealias RegisterAnonymousResponse = APIResponse + // MARK: - 에러 응답 struct APIErrorResponse: Codable, Error { diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index a397b7a..1346422 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -105,6 +105,12 @@ struct AddSubscriptionView: View { .sheet(isPresented: $viewModel.showKeywordSelector) { KeywordSelectorSheet(viewModel: viewModel, colorScheme: colorScheme) } + .onAppear { + // 화면이 나타날 때 키워드 목록 로드 + if viewModel.availableKeywords.isEmpty { + viewModel.loadKeywords() + } + } } } @@ -151,20 +157,63 @@ struct KeywordSelectorSheet: View { .padding(.horizontal, 20) // 키워드 체크박스 리스트 - VStack(spacing: 0) { - ForEach(Array(viewModel.availableKeywords.enumerated()), id: \.offset) { index, keyword in - KeywordCheckboxRow( - keyword: keyword, - isSelected: viewModel.selectedKeywords.contains(keyword), - colorScheme: colorScheme - ) { - viewModel.toggleKeyword(keyword) + if viewModel.isLoadingKeywords { + VStack(spacing: 16) { + ProgressView() + .padding(.top, 40) + Text("키워드를 불러오는 중...") + .font(.system(size: 14)) + .foregroundColor(Color.secondaryText(colorScheme)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else if let errorMessage = viewModel.keywordErrorMessage { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundColor(.red) + Text(errorMessage) + .font(.system(size: 14)) + .foregroundColor(Color.secondaryText(colorScheme)) + .multilineTextAlignment(.center) + Button(action: { + viewModel.loadKeywords() + }) { + Text("다시 시도") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color.primaryGreen) + .cornerRadius(8) } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else if viewModel.availableKeywords.isEmpty { + VStack(spacing: 16) { + Text("등록된 키워드가 없습니다") + .font(.system(size: 14)) + .foregroundColor(Color.secondaryText(colorScheme)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + VStack(spacing: 0) { + ForEach(Array(viewModel.availableKeywords.enumerated()), id: \.offset) { index, keyword in + KeywordCheckboxRow( + keyword: keyword, + isSelected: viewModel.selectedKeywords.contains(keyword), + colorScheme: colorScheme + ) { + viewModel.toggleKeyword(keyword) + } - if index < viewModel.availableKeywords.count - 1 { - Divider() - .background(Color.border(colorScheme)) - .padding(.horizontal, 20) + if index < viewModel.availableKeywords.count - 1 { + Divider() + .background(Color.border(colorScheme)) + .padding(.horizontal, 20) + } } } } @@ -188,6 +237,12 @@ struct KeywordSelectorSheet: View { .padding(.bottom, 34) } } + .onAppear { + // 시트가 나타날 때 키워드 목록이 비어있으면 로드 + if viewModel.availableKeywords.isEmpty && !viewModel.isLoadingKeywords { + viewModel.loadKeywords() + } + } } } diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift index d3552eb..acee42b 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift @@ -7,8 +7,65 @@ class AddSubscriptionViewModel: ObservableObject { @Published var isUrgent: Bool = false @Published var selectedKeywords: [String] = [] @Published var showKeywordSelector: Bool = false + @Published var availableKeywords: [String] = [] + @Published var isLoadingKeywords: Bool = false + @Published var keywordErrorMessage: String? - let availableKeywords = ["장애인", "긴급속보", "장학금", "교직부공지사항", "학생회", "도서관"] + private let apiService: APIService + private var cancellables = Set() + + init(apiService: APIService = APIService()) { + self.apiService = apiService + } + + /// 서버에서 키워드 목록 불러오기 + func loadKeywords() { + guard !isLoadingKeywords else { return } + + isLoadingKeywords = true + keywordErrorMessage = nil + + apiService.getKeywords() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + isLoadingKeywords = false + + switch completion { + case .finished: + break + + case let .failure(error): + switch error { + case let .serverError(statusCode): + keywordErrorMessage = "서버 오류 (상태: \(statusCode))" + + case .decodingFailed: + keywordErrorMessage = "응답 처리 실패" + + case let .requestFailed(requestError): + keywordErrorMessage = "요청 실패: \(requestError.localizedDescription)" + + case .invalidURL: + keywordErrorMessage = "잘못된 URL" + + case .unknown: + keywordErrorMessage = "알 수 없는 오류" + } + + print("❌ 키워드 목록 조회 실패: \(keywordErrorMessage ?? "")") + } + }, + receiveValue: { [weak self] response in + guard let self else { return } + // 서버에서 받은 KeywordItem 배열을 String 배열로 변환 + availableKeywords = response.keywords.map { $0.name } + print("✅ 키워드 목록 조회 성공: \(availableKeywords.count)개") + } + ) + .store(in: &cancellables) + } func addKeyword(_ keyword: String) { let trimmed = keyword.trimmingCharacters(in: .whitespaces) From c90f6a9ce09dd3edf01093d2d14d7d28d5aee163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=84?= <102128060+wlgusqkr@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:09:08 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20subscription=20Delete,=20Keyword=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Network/Service/APIService.swift | 27 +++++++++ .../Network/Targets/SubscriptionAPI.swift | 14 +++++ today-s-sound/Data/Models/Subscription.swift | 18 ++++++ .../Component/SubscriptionsListSection.swift | 57 +++++++++++-------- .../SubscriptionListView.swift | 3 + .../SubscriptionListViewModel.swift | 56 ++++++++++++++++++ 6 files changed, 152 insertions(+), 23 deletions(-) diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift index 1754333..ba982e2 100644 --- a/today-s-sound/Core/Network/Service/APIService.swift +++ b/today-s-sound/Core/Network/Service/APIService.swift @@ -9,6 +9,9 @@ protocol APIServiceType { func getSubscriptions( userId: String, deviceSecret: String, page: Int, size: Int ) -> AnyPublisher + func deleteSubscription( + userId: String, deviceSecret: String, subscriptionId: Int64 + ) -> AnyPublisher func getAlarms( userId: String, deviceSecret: String, page: Int, size: Int ) -> AnyPublisher @@ -153,6 +156,30 @@ class APIService: APIServiceType { .eraseToAnyPublisher() } + + // MARK: - Subscription API (Delete) + + func deleteSubscription( + userId: String, deviceSecret: String, subscriptionId: Int64 + ) -> AnyPublisher { + subscriptionProvider.requestPublisher(.deleteSubscription( + userId: userId, + deviceSecret: deviceSecret, + subscriptionId: subscriptionId + )) + .mapError { moyaError -> NetworkError in + .requestFailed(moyaError) + } + .flatMap { [weak self] response -> AnyPublisher in + guard let self else { + return Fail(error: NetworkError.unknown) + .eraseToAnyPublisher() + } + return self.handleResponse(response, decodeTo: DeleteSubscriptionResponse.self, debugLabel: "구독 삭제 응답") + } + .eraseToAnyPublisher() + } + // MARK: - Alarm API func getAlarms( diff --git a/today-s-sound/Core/Network/Targets/SubscriptionAPI.swift b/today-s-sound/Core/Network/Targets/SubscriptionAPI.swift index a24108f..5eee38f 100644 --- a/today-s-sound/Core/Network/Targets/SubscriptionAPI.swift +++ b/today-s-sound/Core/Network/Targets/SubscriptionAPI.swift @@ -10,6 +10,7 @@ import Moya enum SubscriptionAPI { case getSubscriptions(userId: String, deviceSecret: String, page: Int, size: Int) + case deleteSubscription(userId: String, deviceSecret: String, subscriptionId: Int64) } extension SubscriptionAPI: APITargetType { @@ -17,6 +18,8 @@ extension SubscriptionAPI: APITargetType { switch self { case .getSubscriptions: "/api/subscriptions" + case let .deleteSubscription(_, _, subscriptionId): + "/api/subscriptions/\(subscriptionId)" } } @@ -24,6 +27,8 @@ extension SubscriptionAPI: APITargetType { switch self { case .getSubscriptions: .get + case .deleteSubscription: + .delete } } @@ -37,6 +42,8 @@ extension SubscriptionAPI: APITargetType { ], encoding: URLEncoding.queryString ) + case .deleteSubscription: + .requestPlain } } @@ -49,6 +56,13 @@ extension SubscriptionAPI: APITargetType { "X-User-ID": userId, "X-Device-Secret": deviceSecret ] + case let .deleteSubscription(userId, deviceSecret, _): + [ + "Content-Type": "application/json", + "Accept": "application/json", + "X-User-ID": userId, + "X-Device-Secret": deviceSecret + ] } } } diff --git a/today-s-sound/Data/Models/Subscription.swift b/today-s-sound/Data/Models/Subscription.swift index 98134b9..da69e9b 100644 --- a/today-s-sound/Data/Models/Subscription.swift +++ b/today-s-sound/Data/Models/Subscription.swift @@ -1,10 +1,28 @@ import Foundation +// MARK: - Subscription Request Models + +/// 구독 생성 요청 +struct CreateSubscriptionRequest: Codable { + let url: String + let keywords: [String] +} + // MARK: - Subscription Response Models /// 구독 목록 응답 typealias SubscriptionListResponse = APIResponse<[SubscriptionItem]> +/// 구독 생성 응답 +struct CreateSubscriptionResponse: Codable { + let subscriptionId: Int64 +} + +/// 구독 삭제 응답 +struct DeleteSubscriptionResponse: Codable { + let message: String +} + extension SubscriptionListResponse { // 편의 속성: result를 subscriptions로 접근 var subscriptions: [SubscriptionItem] { diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift index a822752..7f12ad4 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift @@ -11,40 +11,51 @@ struct SubscriptionsListSection: View { let subscriptions: [SubscriptionItem] let colorScheme: ColorScheme let onLoadMore: (SubscriptionItem) -> Void + let onDelete: (SubscriptionItem) -> Void let isLoadingMore: Bool var body: some View { if subscriptions.isEmpty { EmptyStateView(message: "구독 중인 페이지가 없어요.", colorScheme: colorScheme) } else { - ScrollView { - VStack(spacing: 12) { - ForEach(subscriptions) { subscription in - SubscriptionCardView(subscription: subscription, colorScheme: colorScheme) - .onAppear { - // 마지막에서 5번째 아이템이 보일 때만 트리거 - if let lastIndex = subscriptions.indices.last, - let currentIndex = subscriptions.firstIndex(where: { $0.id == subscription.id }), - currentIndex >= lastIndex - 4 - { // 마지막에서 5번째부터 - onLoadMore(subscription) - } + List { + ForEach(subscriptions) { subscription in + SubscriptionCardView(subscription: subscription, colorScheme: colorScheme) + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + onDelete(subscription) + } label: { + Label(systemImage: "trash") + } + } + .onAppear { + // 마지막에서 5번째 아이템이 보일 때만 트리거 + if let lastIndex = subscriptions.indices.last, + let currentIndex = subscriptions.firstIndex(where: { $0.id == subscription.id }), + currentIndex >= lastIndex - 4 + { // 마지막에서 5번째부터 + onLoadMore(subscription) } - } - - // 더 불러오는 중 인디케이터 - if isLoadingMore { - HStack { - Spacer() - ProgressView() - .padding() - Spacer() } + } + + // 더 불러오는 중 인디케이터 + if isLoadingMore { + HStack { + Spacer() + ProgressView() + .padding() + Spacer() } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) } - .padding(.horizontal, 16) - .padding(.top, 8) } + .listStyle(.plain) + .scrollContentBackground(.hidden) } } } diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index c962048..b8f09fd 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -51,6 +51,9 @@ struct SubscriptionListView: View { onLoadMore: { item in viewModel.loadMoreIfNeeded(currentItem: item) }, + onDelete: { item in + viewModel.deleteSubscription(item) + }, isLoadingMore: viewModel.isLoadingMore ) } diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift index e1d3f54..435b1e5 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift @@ -127,4 +127,60 @@ class SubscriptionListViewModel: ObservableObject { // View에서 이미 threshold 체크했으므로 바로 로드 loadSubscriptions() } + + /// 구독 삭제 + func deleteSubscription(_ subscription: SubscriptionItem) { + guard let userId = Keychain.getString(for: KeychainKey.userId), + let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) + else { + errorMessage = "사용자 정보가 없습니다" + return + } + + print("🗑️ 구독 삭제 요청: subscriptionId=\(subscription.id)") + + apiService.deleteSubscription( + userId: userId, + deviceSecret: deviceSecret, + subscriptionId: subscription.id + ) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + + switch completion { + case .finished: + // 삭제 성공 시 목록에서 제거 + self.subscriptions.removeAll { $0.id == subscription.id } + print("✅ 구독 삭제 성공: subscriptionId=\(subscription.id)") + + case let .failure(error): + switch error { + case let .serverError(statusCode): + self.errorMessage = "서버 오류 (상태: \(statusCode))" + + case .decodingFailed: + self.errorMessage = "응답 처리 실패" + + case let .requestFailed(requestError): + self.errorMessage = "요청 실패: \(requestError.localizedDescription)" + + case .invalidURL: + self.errorMessage = "잘못된 URL" + + case .unknown: + self.errorMessage = "알 수 없는 오류" + } + + print("❌ 구독 삭제 실패: \(self.errorMessage ?? "")") + } + }, + receiveValue: { [weak self] response in + guard let self else { return } + print("📥 구독 삭제 응답: \(response.message)") + } + ) + .store(in: &cancellables) + } } From 0177ad60c09f5e863f82f85dada99a8fb8e27f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=84?= <102128060+wlgusqkr@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:40:32 +0900 Subject: [PATCH 3/6] fix:lint --- .../Core/Network/Service/APIService.swift | 13 ++++++------- .../Core/Network/Targets/KeywordAPI.swift | 1 - today-s-sound/Data/Models/APIResponse.swift | 1 - today-s-sound/Data/Models/Keyword.swift | 1 - .../AddSubscription/AddSubscriptionView.swift | 2 +- .../AddSubscription/AddSubscriptionViewModel.swift | 4 ++-- .../Component/SubscriptionsListSection.swift | 2 +- .../SubscriptionListViewModel.swift | 14 +++++++------- 8 files changed, 17 insertions(+), 21 deletions(-) diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift index ba982e2..d9827eb 100644 --- a/today-s-sound/Core/Network/Service/APIService.swift +++ b/today-s-sound/Core/Network/Service/APIService.swift @@ -60,7 +60,7 @@ class APIService: APIServiceType { decodeTo type: T.Type, debugLabel: String = "" ) -> AnyPublisher { - return Just(response) + Just(response) .tryMap { response -> Data in // 상태 코드 체크 guard (200 ... 299).contains(response.statusCode) else { @@ -127,7 +127,7 @@ class APIService: APIServiceType { return Fail(error: NetworkError.unknown) .eraseToAnyPublisher() } - return self.handleResponse(response, decodeTo: RegisterAnonymousResponse.self, debugLabel: "익명 사용자 등록 응답") + return handleResponse(response, decodeTo: RegisterAnonymousResponse.self, debugLabel: "익명 사용자 등록 응답") } .eraseToAnyPublisher() } @@ -151,12 +151,11 @@ class APIService: APIServiceType { return Fail(error: NetworkError.unknown) .eraseToAnyPublisher() } - return self.handleResponse(response, decodeTo: SubscriptionListResponse.self, debugLabel: "구독 목록 응답") + return handleResponse(response, decodeTo: SubscriptionListResponse.self, debugLabel: "구독 목록 응답") } .eraseToAnyPublisher() } - // MARK: - Subscription API (Delete) func deleteSubscription( @@ -175,7 +174,7 @@ class APIService: APIServiceType { return Fail(error: NetworkError.unknown) .eraseToAnyPublisher() } - return self.handleResponse(response, decodeTo: DeleteSubscriptionResponse.self, debugLabel: "구독 삭제 응답") + return handleResponse(response, decodeTo: DeleteSubscriptionResponse.self, debugLabel: "구독 삭제 응답") } .eraseToAnyPublisher() } @@ -199,7 +198,7 @@ class APIService: APIServiceType { return Fail(error: NetworkError.unknown) .eraseToAnyPublisher() } - return self.handleResponse(response, decodeTo: AlarmListResponse.self, debugLabel: "알림 목록 응답") + return handleResponse(response, decodeTo: AlarmListResponse.self, debugLabel: "알림 목록 응답") } .eraseToAnyPublisher() } @@ -216,7 +215,7 @@ class APIService: APIServiceType { return Fail(error: NetworkError.unknown) .eraseToAnyPublisher() } - return self.handleResponse(response, decodeTo: KeywordsResponse.self, debugLabel: "키워드 목록 응답") + return handleResponse(response, decodeTo: KeywordsResponse.self, debugLabel: "키워드 목록 응답") } .eraseToAnyPublisher() } diff --git a/today-s-sound/Core/Network/Targets/KeywordAPI.swift b/today-s-sound/Core/Network/Targets/KeywordAPI.swift index e07851a..480c62a 100644 --- a/today-s-sound/Core/Network/Targets/KeywordAPI.swift +++ b/today-s-sound/Core/Network/Targets/KeywordAPI.swift @@ -41,4 +41,3 @@ extension KeywordAPI: APITargetType { ] } } - diff --git a/today-s-sound/Data/Models/APIResponse.swift b/today-s-sound/Data/Models/APIResponse.swift index a452934..cf09814 100644 --- a/today-s-sound/Data/Models/APIResponse.swift +++ b/today-s-sound/Data/Models/APIResponse.swift @@ -9,4 +9,3 @@ struct APIResponse: Codable { let message: String let result: T } - diff --git a/today-s-sound/Data/Models/Keyword.swift b/today-s-sound/Data/Models/Keyword.swift index 9e96733..429ef45 100644 --- a/today-s-sound/Data/Models/Keyword.swift +++ b/today-s-sound/Data/Models/Keyword.swift @@ -16,4 +16,3 @@ extension KeywordsResponse { result.keywords } } - diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index 1346422..4665cd3 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -239,7 +239,7 @@ struct KeywordSelectorSheet: View { } .onAppear { // 시트가 나타날 때 키워드 목록이 비어있으면 로드 - if viewModel.availableKeywords.isEmpty && !viewModel.isLoadingKeywords { + if viewModel.availableKeywords.isEmpty, !viewModel.isLoadingKeywords { viewModel.loadKeywords() } } diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift index acee42b..9a58d13 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift @@ -21,7 +21,7 @@ class AddSubscriptionViewModel: ObservableObject { /// 서버에서 키워드 목록 불러오기 func loadKeywords() { guard !isLoadingKeywords else { return } - + isLoadingKeywords = true keywordErrorMessage = nil @@ -60,7 +60,7 @@ class AddSubscriptionViewModel: ObservableObject { receiveValue: { [weak self] response in guard let self else { return } // 서버에서 받은 KeywordItem 배열을 String 배열로 변환 - availableKeywords = response.keywords.map { $0.name } + availableKeywords = response.keywords.map(\.name) print("✅ 키워드 목록 조회 성공: \(availableKeywords.count)개") } ) diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift index 7f12ad4..fbd8b4b 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift @@ -28,7 +28,7 @@ struct SubscriptionsListSection: View { Button(role: .destructive) { onDelete(subscription) } label: { - Label(systemImage: "trash") + Label("삭제", systemImage: "trash") } } .onAppear { diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift index 435b1e5..99a79f6 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift @@ -152,28 +152,28 @@ class SubscriptionListViewModel: ObservableObject { switch completion { case .finished: // 삭제 성공 시 목록에서 제거 - self.subscriptions.removeAll { $0.id == subscription.id } + subscriptions.removeAll { $0.id == subscription.id } print("✅ 구독 삭제 성공: subscriptionId=\(subscription.id)") case let .failure(error): switch error { case let .serverError(statusCode): - self.errorMessage = "서버 오류 (상태: \(statusCode))" + errorMessage = "서버 오류 (상태: \(statusCode))" case .decodingFailed: - self.errorMessage = "응답 처리 실패" + errorMessage = "응답 처리 실패" case let .requestFailed(requestError): - self.errorMessage = "요청 실패: \(requestError.localizedDescription)" + errorMessage = "요청 실패: \(requestError.localizedDescription)" case .invalidURL: - self.errorMessage = "잘못된 URL" + errorMessage = "잘못된 URL" case .unknown: - self.errorMessage = "알 수 없는 오류" + errorMessage = "알 수 없는 오류" } - print("❌ 구독 삭제 실패: \(self.errorMessage ?? "")") + print("❌ 구독 삭제 실패: \(errorMessage ?? "")") } }, receiveValue: { [weak self] response in From d82d0b38a7fa907c09eab710007e4bb45a2a5dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=84?= <102128060+wlgusqkr@users.noreply.github.com> Date: Sun, 9 Nov 2025 23:56:15 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20FCM=20Token=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- today-s-sound.xcodeproj/project.pbxproj | 29 +++++++++ today-s-sound/App/TodaySSoundApp.swift | 63 ++++++++++++++++++- .../Core/AppState/SessionStore.swift | 41 ++++++++++-- today-s-sound/Core/Network/Config.swift | 3 +- .../Core/Network/Service/APIService.swift | 6 +- .../Core/Network/Targets/UserAPI.swift | 9 +-- today-s-sound/Core/Security/Keychain.swift | 1 + today-s-sound/Data/Models/User.swift | 2 + today-s-sound/Info.plist | 4 ++ .../PostsDemo/AnonymousTestView.swift | 22 ++++++- today-s-sound/today-s-sound.entitlements | 8 +++ 11 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 today-s-sound/today-s-sound.entitlements diff --git a/today-s-sound.xcodeproj/project.pbxproj b/today-s-sound.xcodeproj/project.pbxproj index 6664c45..7b691f4 100644 --- a/today-s-sound.xcodeproj/project.pbxproj +++ b/today-s-sound.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 9AAB90052EBDD7C50062B6B6 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 9AAB90042EBDD7C50062B6B6 /* FirebaseAnalytics */; }; + 9AAB90072EBDD7C50062B6B6 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 9AAB90062EBDD7C50062B6B6 /* FirebaseMessaging */; }; 9ADF457E2E950B6100E8B5A2 /* CombineMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADF457D2E950B6100E8B5A2 /* CombineMoya */; }; 9ADF45802E950B6100E8B5A2 /* Moya in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADF457F2E950B6100E8B5A2 /* Moya */; }; 9ADF45822E950B6100E8B5A2 /* ReactiveMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADF45812E950B6100E8B5A2 /* ReactiveMoya */; }; @@ -43,9 +45,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9AAB90072EBDD7C50062B6B6 /* FirebaseMessaging in Frameworks */, 9ADF45802E950B6100E8B5A2 /* Moya in Frameworks */, 9ADF457E2E950B6100E8B5A2 /* CombineMoya in Frameworks */, 9ADF45842E950B6100E8B5A2 /* RxMoya in Frameworks */, + 9AAB90052EBDD7C50062B6B6 /* FirebaseAnalytics in Frameworks */, 9ADF45822E950B6100E8B5A2 /* ReactiveMoya in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -93,6 +97,8 @@ 9ADF457F2E950B6100E8B5A2 /* Moya */, 9ADF45812E950B6100E8B5A2 /* ReactiveMoya */, 9ADF45832E950B6100E8B5A2 /* RxMoya */, + 9AAB90042EBDD7C50062B6B6 /* FirebaseAnalytics */, + 9AAB90062EBDD7C50062B6B6 /* FirebaseMessaging */, ); productName = "today-s-sound"; productReference = 259FC9672E890D7F001152B9 /* today-s-sound.app */; @@ -124,6 +130,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */, + 9AAB90032EBDD7C50062B6B6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 259FC9682E890D7F001152B9 /* Products */; @@ -280,8 +287,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "today-s-sound/today-s-sound.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BT89Y8GTMN; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "today-s-sound/Info.plist"; @@ -308,8 +317,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "today-s-sound/today-s-sound.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BT89Y8GTMN; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "today-s-sound/Info.plist"; @@ -355,6 +366,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 9AAB90032EBDD7C50062B6B6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.5.0; + }; + }; 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Moya/Moya"; @@ -366,6 +385,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 9AAB90042EBDD7C50062B6B6 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 9AAB90032EBDD7C50062B6B6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 9AAB90062EBDD7C50062B6B6 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 9AAB90032EBDD7C50062B6B6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; 9ADF457D2E950B6100E8B5A2 /* CombineMoya */ = { isa = XCSwiftPackageProductDependency; package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; diff --git a/today-s-sound/App/TodaySSoundApp.swift b/today-s-sound/App/TodaySSoundApp.swift index ce79c19..426f90c 100644 --- a/today-s-sound/App/TodaySSoundApp.swift +++ b/today-s-sound/App/TodaySSoundApp.swift @@ -6,10 +6,71 @@ // import SwiftUI +import FirebaseCore +import FirebaseMessaging +import UserNotifications + + +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate { // 3. Delegate 프로토콜 3개 추가 + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + + FirebaseApp.configure() + + + UNUserNotificationCenter.current().delegate = self + + let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + UNUserNotificationCenter.current().requestAuthorization( + options: authOptions, + completionHandler: { granted, _ in + print("알림 권한 허용: \(granted)") + } + ) + + // 6. APNs에 기기 등록 요청 + application.registerForRemoteNotifications() + + // 7. FCM 메시징 대리자 설정 + Messaging.messaging().delegate = self + + + return true + } + + // 8. FCM 토큰을 수신했을 때 호출되는 함수 (이 토큰을 Firebase 콘솔에 입력!) + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + + print("====================================") + print("Firebase (FCM) 등록 토큰: \(fcmToken ?? "토큰 없음")") + print("====================================") + + // FCM 토큰을 키체인에 저장 + if let fcmToken = fcmToken { + Keychain.setString(fcmToken, for: KeychainKey.fcmToken) + print("✅ FCM 토큰을 키체인에 저장했습니다") + } + } + + // 9. APNs 등록에 성공하여 deviceToken을 받았을 때 + // (FCM이 APNs 토큰을 자동으로 FCM 토큰으로 매핑하므로 이 함수 자체는 필수) + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + print("APNs device token: \(deviceToken)") + Messaging.messaging().apnsToken = deviceToken + } + + // 10. APNs 등록에 실패했을 때 + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("APNs 등록 실패: \(error.localizedDescription)") + } +} @main struct TodaySSoundApp: App { - @StateObject private var session = SessionStore() + @StateObject private var session = SessionStore() + + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { WindowGroup { diff --git a/today-s-sound/Core/AppState/SessionStore.swift b/today-s-sound/Core/AppState/SessionStore.swift index 05827b4..a2b823c 100644 --- a/today-s-sound/Core/AppState/SessionStore.swift +++ b/today-s-sound/Core/AppState/SessionStore.swift @@ -8,6 +8,8 @@ import Combine import Foundation import SwiftUI +import UIKit +import FirebaseMessaging @MainActor final class SessionStore: ObservableObject { @@ -36,13 +38,23 @@ final class SessionStore: ObservableObject { } else { print("❌ userId: (없음)") } + if let fcmToken = Keychain.getString(for: KeychainKey.fcmToken) { + print("✅ fcmToken: \(fcmToken)") + } else { + print("❌ fcmToken: (없음)") + } print("━━━━━━━━━━━━━━━━━━━━━━━━━━\n") #else print("⚠️ RELEASE 모드로 실행 중 - DEBUG 로그 비활성화") #endif - if let savedId = Keychain.getString(for: KeychainKey.userId) { - userId = savedId + // deviceSecret, userId, fcmToken 모두 있어야 등록된 것으로 간주 + let hasDeviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) != nil + let hasUserId = Keychain.getString(for: KeychainKey.userId) != nil + let hasFcmToken = Keychain.getString(for: KeychainKey.fcmToken) != nil + + if hasDeviceSecret && hasUserId && hasFcmToken { + userId = Keychain.getString(for: KeychainKey.userId) isRegistered = true } else { isRegistered = false @@ -63,9 +75,27 @@ final class SessionStore: ObservableObject { return generated }() - // 2) Combine을 사용한 비동기 API 호출 + // 2) 디바이스 모델 정보 가져오기 + let deviceModel = UIDevice.current.model + + // 3) FCM 토큰 가져오기 + let fcmToken = Messaging.messaging().fcmToken + + // 4) FCM 토큰이 있으면 키체인에 저장 + if let fcmToken = fcmToken { + Keychain.setString(fcmToken, for: KeychainKey.fcmToken) + } + + // 5) 요청 객체 생성 + let request = RegisterAnonymousRequest( + deviceSecret: secret, + model: deviceModel, + fcmToken: fcmToken + ) + + // 6) Combine을 사용한 비동기 API 호출 await withCheckedContinuation { continuation in - apiService.registerAnonymous(deviceSecret: secret) + apiService.registerAnonymous(request: request) .sink( receiveCompletion: { [weak self] completion in guard let self else { return } @@ -101,7 +131,7 @@ final class SessionStore: ObservableObject { receiveValue: { [weak self] response in guard let self else { return } - // 3) userId 저장 + // 6) userId 저장 let userId = response.result.userId Keychain.setString(userId, for: KeychainKey.userId) @@ -120,6 +150,7 @@ final class SessionStore: ObservableObject { func logout() { Keychain.delete(for: KeychainKey.userId) Keychain.delete(for: KeychainKey.deviceSecret) + Keychain.delete(for: KeychainKey.fcmToken) userId = nil isRegistered = false diff --git a/today-s-sound/Core/Network/Config.swift b/today-s-sound/Core/Network/Config.swift index 03cfb00..b476640 100644 --- a/today-s-sound/Core/Network/Config.swift +++ b/today-s-sound/Core/Network/Config.swift @@ -3,7 +3,8 @@ import Foundation enum Config { static var baseURL: String { #if DEBUG - return "http://localhost:8080" // 개발 서버 + return "https://www.today-sound.com" +// return "http://localhost:8080" // 개발 서버 #else return "https://api.todays-sound.com" // 운영 서버 #endif diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift index d9827eb..01da517 100644 --- a/today-s-sound/Core/Network/Service/APIService.swift +++ b/today-s-sound/Core/Network/Service/APIService.swift @@ -5,7 +5,7 @@ import Moya protocol APIServiceType { func request(_ target: some TargetType) -> AnyPublisher - func registerAnonymous(deviceSecret: String) -> AnyPublisher + func registerAnonymous(request: RegisterAnonymousRequest) -> AnyPublisher func getSubscriptions( userId: String, deviceSecret: String, page: Int, size: Int ) -> AnyPublisher @@ -117,8 +117,8 @@ class APIService: APIServiceType { // MARK: - User API - func registerAnonymous(deviceSecret: String) -> AnyPublisher { - userProvider.requestPublisher(.registerAnonymous(deviceSecret: deviceSecret)) + func registerAnonymous(request: RegisterAnonymousRequest) -> AnyPublisher { + userProvider.requestPublisher(.registerAnonymous(request: request)) .mapError { moyaError -> NetworkError in .requestFailed(moyaError) } diff --git a/today-s-sound/Core/Network/Targets/UserAPI.swift b/today-s-sound/Core/Network/Targets/UserAPI.swift index f162c05..1f90504 100644 --- a/today-s-sound/Core/Network/Targets/UserAPI.swift +++ b/today-s-sound/Core/Network/Targets/UserAPI.swift @@ -9,7 +9,7 @@ import Foundation import Moya enum UserAPI { - case registerAnonymous(deviceSecret: String) + case registerAnonymous(request: RegisterAnonymousRequest) // 향후 추가 가능: // case getUserProfile(userId: String) // case updateProfile(userId: String, name: String) @@ -32,11 +32,8 @@ extension UserAPI: APITargetType { var task: Task { switch self { - case let .registerAnonymous(deviceSecret): - .requestParameters( - parameters: ["deviceSecret": deviceSecret], - encoding: JSONEncoding.default - ) + case let .registerAnonymous(request): + .requestJSONEncodable(request) } } diff --git a/today-s-sound/Core/Security/Keychain.swift b/today-s-sound/Core/Security/Keychain.swift index eb4786a..004b479 100644 --- a/today-s-sound/Core/Security/Keychain.swift +++ b/today-s-sound/Core/Security/Keychain.swift @@ -11,6 +11,7 @@ import Security enum KeychainKey { static let deviceSecret = "device_secret" static let userId = "user_id" + static let fcmToken = "fcm_token" static let apiKey = "api_key" // 서버가 추가 키를 준다면 여기에 저장 (옵셔널) } diff --git a/today-s-sound/Data/Models/User.swift b/today-s-sound/Data/Models/User.swift index 921f8bc..b452e6b 100644 --- a/today-s-sound/Data/Models/User.swift +++ b/today-s-sound/Data/Models/User.swift @@ -11,6 +11,8 @@ import Foundation struct RegisterAnonymousRequest: Codable { let deviceSecret: String + let model: String? + let fcmToken: String? } struct AnonymousUserResult: Codable { diff --git a/today-s-sound/Info.plist b/today-s-sound/Info.plist index 2155055..b36e82a 100644 --- a/today-s-sound/Info.plist +++ b/today-s-sound/Info.plist @@ -8,5 +8,9 @@ KoddiUDOnGothic-Regular.otf KoddiUDOnGothic-Bold.otf + UIBackgroundModes + + remote-notification + diff --git a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift index d932ceb..1a4bbe0 100644 --- a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift +++ b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift @@ -1,5 +1,7 @@ import Combine import SwiftUI +import UIKit +import FirebaseMessaging @MainActor final class AnonymousTestViewModel: ObservableObject { @@ -59,7 +61,25 @@ final class AnonymousTestViewModel: ObservableObject { userId = "" log = "📤 익명 사용자 등록 요청 중...\ndeviceSecret: \(deviceSecret.prefix(20))..." - apiService.registerAnonymous(deviceSecret: deviceSecret) + // 디바이스 모델과 FCM 토큰 가져오기 + let deviceModel = UIDevice.current.model + let fcmToken = Messaging.messaging().fcmToken + + // 요청 객체 생성 + let request = RegisterAnonymousRequest( + deviceSecret: deviceSecret, + model: deviceModel, + fcmToken: fcmToken + ) + + log += "\nModel: \(deviceModel)" + if let fcmToken = fcmToken { + log += "\nFCM Token: \(fcmToken.prefix(20))..." + } else { + log += "\nFCM Token: (없음)" + } + + apiService.registerAnonymous(request: request) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] completion in diff --git a/today-s-sound/today-s-sound.entitlements b/today-s-sound/today-s-sound.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/today-s-sound/today-s-sound.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + From de6eefb3d6f0157e307a9d36abd7c6f06a9938fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=84?= <102128060+wlgusqkr@users.noreply.github.com> Date: Sun, 9 Nov 2025 23:58:39 +0900 Subject: [PATCH 5/6] fix : lint --- today-s-sound/App/TodaySSoundApp.swift | 34 ++++++++----------- .../Core/AppState/SessionStore.swift | 6 ++-- .../PostsDemo/AnonymousTestView.swift | 4 +-- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/today-s-sound/App/TodaySSoundApp.swift b/today-s-sound/App/TodaySSoundApp.swift index 426f90c..52ec4bd 100644 --- a/today-s-sound/App/TodaySSoundApp.swift +++ b/today-s-sound/App/TodaySSoundApp.swift @@ -5,19 +5,17 @@ // Created by 하승연 on 9/28/25. // -import SwiftUI import FirebaseCore -import FirebaseMessaging -import UserNotifications - +import FirebaseMessaging +import SwiftUI +import UserNotifications class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate { // 3. Delegate 프로토콜 3개 추가 func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool + { FirebaseApp.configure() - UNUserNotificationCenter.current().delegate = self @@ -34,43 +32,41 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele // 7. FCM 메시징 대리자 설정 Messaging.messaging().delegate = self - return true } // 8. FCM 토큰을 수신했을 때 호출되는 함수 (이 토큰을 Firebase 콘솔에 입력!) func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - print("====================================") print("Firebase (FCM) 등록 토큰: \(fcmToken ?? "토큰 없음")") print("====================================") - + // FCM 토큰을 키체인에 저장 - if let fcmToken = fcmToken { + if let fcmToken { Keychain.setString(fcmToken, for: KeychainKey.fcmToken) print("✅ FCM 토큰을 키체인에 저장했습니다") } } - + // 9. APNs 등록에 성공하여 deviceToken을 받았을 때 // (FCM이 APNs 토큰을 자동으로 FCM 토큰으로 매핑하므로 이 함수 자체는 필수) func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - print("APNs device token: \(deviceToken)") - Messaging.messaging().apnsToken = deviceToken + print("APNs device token: \(deviceToken)") + Messaging.messaging().apnsToken = deviceToken } - + // 10. APNs 등록에 실패했을 때 func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("APNs 등록 실패: \(error.localizedDescription)") + print("APNs 등록 실패: \(error.localizedDescription)") } } @main struct TodaySSoundApp: App { - @StateObject private var session = SessionStore() - - @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + @StateObject private var session = SessionStore() + + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { WindowGroup { diff --git a/today-s-sound/Core/AppState/SessionStore.swift b/today-s-sound/Core/AppState/SessionStore.swift index a2b823c..b9a7660 100644 --- a/today-s-sound/Core/AppState/SessionStore.swift +++ b/today-s-sound/Core/AppState/SessionStore.swift @@ -6,10 +6,10 @@ // import Combine +import FirebaseMessaging import Foundation import SwiftUI import UIKit -import FirebaseMessaging @MainActor final class SessionStore: ObservableObject { @@ -53,7 +53,7 @@ final class SessionStore: ObservableObject { let hasUserId = Keychain.getString(for: KeychainKey.userId) != nil let hasFcmToken = Keychain.getString(for: KeychainKey.fcmToken) != nil - if hasDeviceSecret && hasUserId && hasFcmToken { + if hasDeviceSecret, hasUserId, hasFcmToken { userId = Keychain.getString(for: KeychainKey.userId) isRegistered = true } else { @@ -82,7 +82,7 @@ final class SessionStore: ObservableObject { let fcmToken = Messaging.messaging().fcmToken // 4) FCM 토큰이 있으면 키체인에 저장 - if let fcmToken = fcmToken { + if let fcmToken { Keychain.setString(fcmToken, for: KeychainKey.fcmToken) } diff --git a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift index 1a4bbe0..c057001 100644 --- a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift +++ b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift @@ -1,7 +1,7 @@ import Combine +import FirebaseMessaging import SwiftUI import UIKit -import FirebaseMessaging @MainActor final class AnonymousTestViewModel: ObservableObject { @@ -73,7 +73,7 @@ final class AnonymousTestViewModel: ObservableObject { ) log += "\nModel: \(deviceModel)" - if let fcmToken = fcmToken { + if let fcmToken { log += "\nFCM Token: \(fcmToken.prefix(20))..." } else { log += "\nFCM Token: (없음)" From 7ebb4dfa14556ffe7d458e98b75d3945fbaef1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=84?= <102128060+wlgusqkr@users.noreply.github.com> Date: Mon, 10 Nov 2025 00:00:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:gitignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4b81f11..7057171 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ fastlane/screenshots/**/*.png fastlane/test_output/ .DS_Store xcuserdata/ + +# Firebase +GoogleService-Info.plist