diff --git a/src/Projects/BKData/Sources/API/UserAPI.swift b/src/Projects/BKData/Sources/API/UserAPI.swift index 10379a82..d6d7cb41 100644 --- a/src/Projects/BKData/Sources/API/UserAPI.swift +++ b/src/Projects/BKData/Sources/API/UserAPI.swift @@ -5,7 +5,7 @@ import Foundation enum UserAPI { case me case termsAgreement(termsAgreed: Bool) - case upsertFCMToken(fcmToken: String) + case upsertFCMToken(fcmToken: String, deviceId: String) case upsertNotificationSettings(notificationEnabled: Bool) } @@ -51,8 +51,8 @@ extension UserAPI: RequestTarget { switch self { case .termsAgreement(let termsAgreed): return TermsAgreementRequestDTO(termsAgreed: termsAgreed) - case .upsertFCMToken(let fcmToken): - return UpsertFCMTokenRequestDTO(fcmToken: fcmToken) + case .upsertFCMToken(let fcmToken, let deviceId): + return UpsertFCMTokenRequestDTO(fcmToken: fcmToken, deviceId: deviceId) case .upsertNotificationSettings(let notificationEnabled): return NotificationStatusRequestDTO(notificationEnabled: notificationEnabled) case .me: diff --git a/src/Projects/BKData/Sources/DTO/Request/UpsertFCMTokenRequestDTO.swift b/src/Projects/BKData/Sources/DTO/Request/UpsertFCMTokenRequestDTO.swift index ef2788db..70777681 100644 --- a/src/Projects/BKData/Sources/DTO/Request/UpsertFCMTokenRequestDTO.swift +++ b/src/Projects/BKData/Sources/DTO/Request/UpsertFCMTokenRequestDTO.swift @@ -4,4 +4,5 @@ import Foundation struct UpsertFCMTokenRequestDTO: Encodable { let fcmToken: String + let deviceId: String } diff --git a/src/Projects/BKData/Sources/DataAssembly.swift b/src/Projects/BKData/Sources/DataAssembly.swift index a46b84dc..28bb79ff 100644 --- a/src/Projects/BKData/Sources/DataAssembly.swift +++ b/src/Projects/BKData/Sources/DataAssembly.swift @@ -134,7 +134,11 @@ public struct DataAssembly: Assembly { type: NotificationRepository.self ) { _ in @Autowired(name: "OAuth") var networkProvider: NetworkProvider - return DefaultNotificationRepository(networkProvider: networkProvider) + @Autowired var deviceIDProvider: DeviceIDProvider + return DefaultNotificationRepository( + networkProvider: networkProvider, + deviceIDProvider: deviceIDProvider + ) } container.register( diff --git a/src/Projects/BKData/Sources/Interface/Storage/DeviceIDProvider.swift b/src/Projects/BKData/Sources/Interface/Storage/DeviceIDProvider.swift new file mode 100644 index 00000000..b0500665 --- /dev/null +++ b/src/Projects/BKData/Sources/Interface/Storage/DeviceIDProvider.swift @@ -0,0 +1,6 @@ +// Copyright © 2025 Booket. All rights reserved + +public protocol DeviceIDProvider { + var deviceID: String? { get } + func clearCache() +} diff --git a/src/Projects/BKData/Sources/Interface/Storage/DeviceIDStore.swift b/src/Projects/BKData/Sources/Interface/Storage/DeviceIDStore.swift new file mode 100644 index 00000000..6d7d2755 --- /dev/null +++ b/src/Projects/BKData/Sources/Interface/Storage/DeviceIDStore.swift @@ -0,0 +1,9 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKDomain +import Combine + +public protocol DeviceIDStore { + func getOrCreate() -> AnyPublisher + func clear() -> AnyPublisher +} diff --git a/src/Projects/BKData/Sources/Repository/DefaultNotificationRepository.swift b/src/Projects/BKData/Sources/Repository/DefaultNotificationRepository.swift index 99a88aea..cc0c6cfa 100644 --- a/src/Projects/BKData/Sources/Repository/DefaultNotificationRepository.swift +++ b/src/Projects/BKData/Sources/Repository/DefaultNotificationRepository.swift @@ -6,16 +6,27 @@ import Combine public struct DefaultNotificationRepository: NotificationRepository { private let networkProvider: NetworkProvider - - public init(networkProvider: NetworkProvider) { + private let deviceIDProvider: DeviceIDProvider + + public init( + networkProvider: NetworkProvider, + deviceIDProvider: DeviceIDProvider + ) { self.networkProvider = networkProvider + self.deviceIDProvider = deviceIDProvider } public func upsertFCMToken( fcmToken: String ) -> AnyPublisher { - networkProvider.request( - target: UserAPI.upsertFCMToken(fcmToken: fcmToken), + guard let deviceID = deviceIDProvider.deviceID else { + Log.error("Device ID not available", logger: AppLogger.storage) + return Fail(error: DomainError.unknown) + .eraseToAnyPublisher() + } + + return networkProvider.request( + target: UserAPI.upsertFCMToken(fcmToken: fcmToken, deviceId: deviceID), type: UserProfileResponseDTO.self ) .debugError(logger: AppLogger.network) diff --git a/src/Projects/BKStorage/Sources/Constant/StorageKeys.swift b/src/Projects/BKStorage/Sources/Constant/StorageKeys.swift index 197743f6..15dc4c92 100644 --- a/src/Projects/BKStorage/Sources/Constant/StorageKeys.swift +++ b/src/Projects/BKStorage/Sources/Constant/StorageKeys.swift @@ -5,4 +5,5 @@ public enum StorageKeys { public static let refreshTokenKey = "refreshToken" public static let fcmTokenKey = "fcmToken" public static let isSyncNeededKey = "isSyncNeeded" + public static let deviceIDKey = "deviceID" } diff --git a/src/Projects/BKStorage/Sources/DeviceStorage/KeychainDeviceIDProvider.swift b/src/Projects/BKStorage/Sources/DeviceStorage/KeychainDeviceIDProvider.swift new file mode 100644 index 00000000..66dbb955 --- /dev/null +++ b/src/Projects/BKStorage/Sources/DeviceStorage/KeychainDeviceIDProvider.swift @@ -0,0 +1,32 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKCore +import BKData +import OSLog + +public final class KeychainDeviceIDProvider: DeviceIDProvider { + private let storage: KeyValueStorage + private var cachedDeviceID: String? + + public init(storage: KeyValueStorage) { + self.storage = storage + } + + public var deviceID: String? { + if let cachedDeviceID { + return cachedDeviceID + } + do { + let id: String = try storage.load(for: StorageKeys.deviceIDKey) + self.cachedDeviceID = id + return id + } catch { + Log.error("Failed to load deviceID: \(error)", logger: AppLogger.storage) + return nil + } + } + + public func clearCache() { + cachedDeviceID = nil + } +} diff --git a/src/Projects/BKStorage/Sources/DeviceStorage/KeychainDeviceIDStore.swift b/src/Projects/BKStorage/Sources/DeviceStorage/KeychainDeviceIDStore.swift new file mode 100644 index 00000000..54754b0c --- /dev/null +++ b/src/Projects/BKStorage/Sources/DeviceStorage/KeychainDeviceIDStore.swift @@ -0,0 +1,51 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKData +import BKDomain +import Combine +import Foundation + +public struct KeychainDeviceIDStore: DeviceIDStore { + private let storage: KeyValueStorage + + /// AppDelegate나 다른 곳에서 DI 없이 사용할 수 있도록 shared instance 제공 + public static let shared = KeychainDeviceIDStore(storage: KeychainKeyValueStorage()) + + public init(storage: KeyValueStorage) { + self.storage = storage + } + + /// 디바이스 ID를 가져오거나, 없으면 새로 생성하여 저장 + /// 키체인에 저장되므로 앱 삭제 후 재설치해도 유지됨 + public func getOrCreate() -> AnyPublisher { + do { + if let existingID: String = try? storage.load(for: StorageKeys.deviceIDKey) { + return Just(existingID) + .setFailureType(to: TokenError.self) + .eraseToAnyPublisher() + } + + let newDeviceID = UUID().uuidString + try storage.save(newDeviceID, for: StorageKeys.deviceIDKey) + + return Just(newDeviceID) + .setFailureType(to: TokenError.self) + .eraseToAnyPublisher() + } catch { + return Fail(error: TokenError.saveFailed(underlying: error)) + .eraseToAnyPublisher() + } + } + + public func clear() -> AnyPublisher { + do { + try storage.delete(for: StorageKeys.deviceIDKey) + return Just(()) + .setFailureType(to: TokenError.self) + .eraseToAnyPublisher() + } catch { + return Fail(error: TokenError.clearFailed(underlying: error)) + .eraseToAnyPublisher() + } + } +} diff --git a/src/Projects/BKStorage/Sources/StorageAssembly.swift b/src/Projects/BKStorage/Sources/StorageAssembly.swift index 6e81fc15..a6579cbe 100644 --- a/src/Projects/BKStorage/Sources/StorageAssembly.swift +++ b/src/Projects/BKStorage/Sources/StorageAssembly.swift @@ -59,5 +59,24 @@ public struct StorageAssembly: Assembly { storage: keyValueStorage ) } + + container.register( + type: DeviceIDProvider.self, + scope: .singleton + ) { _ in + @Autowired(name: "Keychain") var keyValueStorage: KeyValueStorage + return KeychainDeviceIDProvider( + storage: keyValueStorage + ) + } + + container.register( + type: DeviceIDStore.self + ) { _ in + @Autowired(name: "Keychain") var keyValueStorage: KeyValueStorage + return KeychainDeviceIDStore( + storage: keyValueStorage + ) + } } } diff --git a/src/Projects/Booket/Sources/AppDelegate.swift b/src/Projects/Booket/Sources/AppDelegate.swift index f8cdb349..5e5eb42b 100644 --- a/src/Projects/Booket/Sources/AppDelegate.swift +++ b/src/Projects/Booket/Sources/AppDelegate.swift @@ -34,6 +34,21 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self Messaging.messaging().delegate = self + // 앱 실행 시 DeviceID 생성 (없으면) + // 키체인에 저장되므로 앱 삭제 후 재설치해도 유지됨 + KeychainDeviceIDStore.shared.getOrCreate() + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + AppLogger.auth.error("Failed to get or create device ID: \(error)") + } + }, + receiveValue: { deviceID in + AppLogger.auth.info("Device ID ready: \(deviceID)") + } + ) + .store(in: &cancellables) + return true }