From b6eda6aef197071d9a4817324356381e89919ce2 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Fri, 31 Oct 2025 17:14:38 +0900 Subject: [PATCH 1/2] setting: delete unused files --- today-s-sound.xcodeproj/project.pbxproj | 50 -------- today-s-sound/Core/Auth/UserSession.swift | 12 -- .../Core/Network/APITargetType.swift | 13 --- today-s-sound/Core/Network/Config.swift | 11 -- today-s-sound/Core/Network/NetworkError.swift | 9 -- .../Network/Provider/AuthInterceptor.swift | 108 ------------------ .../Core/Network/Provider/NetworkKit.swift | 13 --- .../Core/Network/Service/APIService.swift | 42 ------- .../Core/Network/Targets/AnonymousAPI.swift | 26 ----- .../Core/Network/Targets/AuthAPITarget.swift | 36 ------ .../PostsDemo/AnonymousTestView.swift | 63 ++++------ 11 files changed, 24 insertions(+), 359 deletions(-) delete mode 100644 today-s-sound/Core/Auth/UserSession.swift delete mode 100644 today-s-sound/Core/Network/APITargetType.swift delete mode 100644 today-s-sound/Core/Network/Config.swift delete mode 100644 today-s-sound/Core/Network/NetworkError.swift delete mode 100644 today-s-sound/Core/Network/Provider/AuthInterceptor.swift delete mode 100644 today-s-sound/Core/Network/Provider/NetworkKit.swift delete mode 100644 today-s-sound/Core/Network/Service/APIService.swift delete mode 100644 today-s-sound/Core/Network/Targets/AnonymousAPI.swift delete mode 100644 today-s-sound/Core/Network/Targets/AuthAPITarget.swift diff --git a/today-s-sound.xcodeproj/project.pbxproj b/today-s-sound.xcodeproj/project.pbxproj index 6664c45..7cdd942 100644 --- a/today-s-sound.xcodeproj/project.pbxproj +++ b/today-s-sound.xcodeproj/project.pbxproj @@ -6,13 +6,6 @@ objectVersion = 77; objects = { -/* Begin PBXBuildFile section */ - 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 */; }; - 9ADF45842E950B6100E8B5A2 /* RxMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADF45832E950B6100E8B5A2 /* RxMoya */; }; -/* End PBXBuildFile section */ - /* Begin PBXFileReference section */ 259FC9672E890D7F001152B9 /* today-s-sound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "today-s-sound.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -43,10 +36,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9ADF45802E950B6100E8B5A2 /* Moya in Frameworks */, - 9ADF457E2E950B6100E8B5A2 /* CombineMoya in Frameworks */, - 9ADF45842E950B6100E8B5A2 /* RxMoya in Frameworks */, - 9ADF45822E950B6100E8B5A2 /* ReactiveMoya in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,10 +78,6 @@ ); name = "today-s-sound"; packageProductDependencies = ( - 9ADF457D2E950B6100E8B5A2 /* CombineMoya */, - 9ADF457F2E950B6100E8B5A2 /* Moya */, - 9ADF45812E950B6100E8B5A2 /* ReactiveMoya */, - 9ADF45832E950B6100E8B5A2 /* RxMoya */, ); productName = "today-s-sound"; productReference = 259FC9672E890D7F001152B9 /* today-s-sound.app */; @@ -123,7 +108,6 @@ mainGroup = 259FC95E2E890D7E001152B9; minimizedProjectReferenceProxies = 1; packageReferences = ( - 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */, ); preferredProjectObjectVersion = 77; productRefGroup = 259FC9682E890D7F001152B9 /* Products */; @@ -353,40 +337,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Moya/Moya"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 15.0.3; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 9ADF457D2E950B6100E8B5A2 /* CombineMoya */ = { - isa = XCSwiftPackageProductDependency; - package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; - productName = CombineMoya; - }; - 9ADF457F2E950B6100E8B5A2 /* Moya */ = { - isa = XCSwiftPackageProductDependency; - package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; - productName = Moya; - }; - 9ADF45812E950B6100E8B5A2 /* ReactiveMoya */ = { - isa = XCSwiftPackageProductDependency; - package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; - productName = ReactiveMoya; - }; - 9ADF45832E950B6100E8B5A2 /* RxMoya */ = { - isa = XCSwiftPackageProductDependency; - package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; - productName = RxMoya; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 259FC95F2E890D7E001152B9 /* Project object */; } diff --git a/today-s-sound/Core/Auth/UserSession.swift b/today-s-sound/Core/Auth/UserSession.swift deleted file mode 100644 index f00458c..0000000 --- a/today-s-sound/Core/Auth/UserSession.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -final class UserSession: ObservableObject { - @Published var accessToken: String = "" - @Published var refreshToken: String = "" - @Published var autoLogin: Bool = true - - func clear() { - accessToken = "" - refreshToken = "" - } -} diff --git a/today-s-sound/Core/Network/APITargetType.swift b/today-s-sound/Core/Network/APITargetType.swift deleted file mode 100644 index f5f1f64..0000000 --- a/today-s-sound/Core/Network/APITargetType.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -import Moya - -protocol APITargetType: TargetType {} - -extension APITargetType { - var baseURL: URL { - guard let url = URL(string: Config.baseURL) else { - fatalError("Invalid Base URL") - } - return url - } -} diff --git a/today-s-sound/Core/Network/Config.swift b/today-s-sound/Core/Network/Config.swift deleted file mode 100644 index 0ef89da..0000000 --- a/today-s-sound/Core/Network/Config.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -enum Config { - static var baseURL: String { - #if DEBUG - return "https://dev-your-api-url.com" - #else - return "https://your-api-url.com" - #endif - } -} diff --git a/today-s-sound/Core/Network/NetworkError.swift b/today-s-sound/Core/Network/NetworkError.swift deleted file mode 100644 index 7090e7b..0000000 --- a/today-s-sound/Core/Network/NetworkError.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -enum NetworkError: Error { - case invalidURL - case requestFailed(Error) - case decodingFailed(Error) - case serverError(statusCode: Int) - case unknown -} diff --git a/today-s-sound/Core/Network/Provider/AuthInterceptor.swift b/today-s-sound/Core/Network/Provider/AuthInterceptor.swift deleted file mode 100644 index 4048a34..0000000 --- a/today-s-sound/Core/Network/Provider/AuthInterceptor.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Alamofire -import Foundation -import Moya - -final class AuthInterceptor: RequestInterceptor { - private let userSession: UserSession - - private lazy var refreshProvider: MoyaProvider = { - let session = Session(configuration: .default) // no interceptor - return MoyaProvider(session: session) - }() - - private var isRefreshing = false - private var waiting: [(RetryResult) -> Void] = [] - private let lock = NSLock() - - init(userSession: UserSession) { self.userSession = userSession } - - func adapt(_ urlRequest: URLRequest, - for session: Session, - completion: @escaping (Result) -> Void) - { - var req = urlRequest - let path = req.url?.path ?? "" - - let bypass: Set = [ - "/api/auth/login", - "/api/auth/refresh" - ] - - if bypass.contains(path) { - if req.value(forHTTPHeaderField: "Authorization") != nil { - req.setValue(nil, forHTTPHeaderField: "Authorization") - } - return completion(.success(req)) - } - - if !userSession.accessToken.isEmpty { - req.setValue("Bearer \(userSession.accessToken)", forHTTPHeaderField: "Authorization") - } - completion(.success(req)) - } - - func retry(_ request: Request, - for session: Session, - dueTo error: Error, - completion: @escaping (RetryResult) -> Void) - { - let path = request.request?.url?.path ?? "nil" - let status = request.response?.statusCode ?? -1 - - if path == "/api/auth/refresh" { - completion(.doNotRetry); return - } - - guard status == 401 || status == 403 || status == 419 else { - completion(.doNotRetry); return - } - - guard userSession.autoLogin, !userSession.refreshToken.isEmpty else { - DispatchQueue.main.async { self.userSession.clear() } - completion(.doNotRetry) - return - } - - lock.lock() - if isRefreshing { - waiting.append(completion) - lock.unlock() - return - } - isRefreshing = true - waiting.append(completion) - lock.unlock() - - refreshProvider.request(.refresh(refreshToken: userSession.refreshToken)) { [weak self] result in - guard let self else { return } - var queuedResult: RetryResult = .doNotRetry - - switch result { - case let .success(res): - if (200 ..< 300).contains(res.statusCode), - let dto = try? JSONDecoder().decode(RefreshResponseDTO.self, from: res.data), - dto.isSuccess, let data = dto.data - { - DispatchQueue.main.async { - self.userSession.accessToken = data.accessToken - self.userSession.refreshToken = data.refreshToken - } - queuedResult = .retry - } else { - DispatchQueue.main.async { self.userSession.clear() } - } - - case .failure: - DispatchQueue.main.async { self.userSession.clear() } - } - - lock.lock() - let queued = waiting - waiting.removeAll() - isRefreshing = false - lock.unlock() - - queued.forEach { $0(queuedResult) } - } - } -} diff --git a/today-s-sound/Core/Network/Provider/NetworkKit.swift b/today-s-sound/Core/Network/Provider/NetworkKit.swift deleted file mode 100644 index d1fbe67..0000000 --- a/today-s-sound/Core/Network/Provider/NetworkKit.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Alamofire -import Foundation -import Moya - -enum NetworkKit { - static func provider(userSession: UserSession, - plugins: [PluginType] = []) -> MoyaProvider - { - let interceptor = AuthInterceptor(userSession: userSession) - let session = Session(interceptor: interceptor) - return MoyaProvider(session: session, plugins: plugins) - } -} diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift deleted file mode 100644 index 40ae6c7..0000000 --- a/today-s-sound/Core/Network/Service/APIService.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Combine -import CombineMoya -import Foundation -import Moya - -protocol APIServiceType { - func request(_ target: some TargetType) -> AnyPublisher - func createAnonymous(deviceSecret: String) -> AnyPublisher -} - -class APIService: APIServiceType { - private let anonymousProvider: MoyaProvider - - init(userSession: UserSession = UserSession()) { - anonymousProvider = NetworkKit.provider(userSession: userSession) - } - - func request(_ target: some TargetType) -> AnyPublisher { - Fail(error: .requestFailed(NSError(domain: "NotImplemented", code: -1))).eraseToAnyPublisher() - } - - func createAnonymous(deviceSecret: String) -> AnyPublisher { - anonymousProvider.requestPublisher(.createAnonymous(deviceSecret: deviceSecret)) - .tryMap { response -> Data in - guard (200 ... 299).contains(response.statusCode) else { - throw NetworkError.serverError(statusCode: response.statusCode) - } - return response.data - } - .decode(type: AnonymousUserResponse.self, decoder: JSONDecoder()) - .mapError { error -> NetworkError in - if let networkError = error as? NetworkError { - return networkError - } else if error is DecodingError { - return .decodingFailed(error) - } else { - return .requestFailed(error) - } - } - .eraseToAnyPublisher() - } -} diff --git a/today-s-sound/Core/Network/Targets/AnonymousAPI.swift b/today-s-sound/Core/Network/Targets/AnonymousAPI.swift deleted file mode 100644 index 89ffc07..0000000 --- a/today-s-sound/Core/Network/Targets/AnonymousAPI.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import Moya - -enum AnonymousAPI { - case createAnonymous(deviceSecret: String) -} - -extension AnonymousAPI: APITargetType { - var path: String { - switch self { - case .createAnonymous: - "/api/users/anonymous" - } - } - - var method: Moya.Method { .post } - - var task: Task { - switch self { - case let .createAnonymous(deviceSecret): - .requestParameters(parameters: ["deviceSecret": deviceSecret], encoding: JSONEncoding.default) - } - } - - var headers: [String: String]? { ["Content-Type": "application/json"] } -} diff --git a/today-s-sound/Core/Network/Targets/AuthAPITarget.swift b/today-s-sound/Core/Network/Targets/AuthAPITarget.swift deleted file mode 100644 index 9140e7c..0000000 --- a/today-s-sound/Core/Network/Targets/AuthAPITarget.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -import Moya - -enum AuthAPITarget { - case refresh(refreshToken: String) -} - -extension AuthAPITarget: APITargetType { - var path: String { - switch self { - case .refresh: - "/api/auth/refresh" - } - } - - var method: Moya.Method { .post } - - var task: Task { - switch self { - case let .refresh(refreshToken: refreshToken): - .requestParameters(parameters: ["refreshToken": refreshToken], encoding: JSONEncoding.default) - } - } - - var headers: [String: String]? { ["Content-Type": "application/json"] } -} - -struct RefreshResponseDTO: Codable { - let isSuccess: Bool - let data: RefreshTokensDTO? -} - -struct RefreshTokensDTO: Codable { - let accessToken: String - let refreshToken: String -} diff --git a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift index 2ab3db1..ae1d282 100644 --- a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift +++ b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift @@ -1,56 +1,41 @@ -import Combine import SwiftUI -final class AnonymousTestViewModel: ObservableObject { - @Published var deviceSecret: String = UUID().uuidString - @Published var userId: String = "" - @Published var log: String = "" - - private let api = APIService() - private var cancellables: Set = [] - - func createAnonymous() { - log = "요청 중..." - api.createAnonymous(deviceSecret: deviceSecret) - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - switch completion { - case .finished: - break - case let .failure(error): - self?.log = "실패: \(error)" - } - } receiveValue: { [weak self] response in - self?.userId = response.result.userId - self?.log = "성공: user_id=\(response.result.userId)" - } - .store(in: &cancellables) - } -} - struct AnonymousTestView: View { - @StateObject private var viewModel = AnonymousTestViewModel() + @StateObject private var store = SessionStore() + @State private var log: String = "" var body: some View { Form { - Section(header: Text("디바이스 시크릿")) { - TextField("deviceSecret", text: $viewModel.deviceSecret) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } Section(header: Text("동작")) { - Button("익명 사용자 생성") { - viewModel.createAnonymous() + Button("익명 사용자 등록") { + log = "요청 중..." + Task { + await store.registerIfNeeded() + if let err = store.lastError { + log = "실패: \(err)" + } else if let id = store.userId { + log = "성공: user_id=\(id)" + } else { + log = "상태 변경 없음" + } + } } } - if !viewModel.userId.isEmpty { + if let id = store.userId, !id.isEmpty { Section(header: Text("결과 user_id")) { - Text(viewModel.userId) + Text(id) .font(.system(.body, design: .monospaced)) } } + if let err = store.lastError, !err.isEmpty { + Section(header: Text("에러")) { + Text(err) + .font(.footnote) + .foregroundColor(.red) + } + } Section(header: Text("로그")) { - Text(viewModel.log) + Text(log) .font(.footnote) .foregroundColor(.gray) } From 2aca0bcd0d25a3771b81a1180962661b8c608ecf Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Sat, 1 Nov 2025 21:16:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[feat/#9]=20=EA=B5=AC=EB=8F=85=20POST=20API?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- today-s-sound/App/TodaySSoundApp.swift | 24 ++-- .../AddSubscription/AddSubscriptionView.swift | 125 ++++++++++-------- .../AddSubscriptionViewModel.swift | 79 +++++++++-- .../Features/OnBoarding/OnBoardingView.swift | 82 ++++++------ .../PostsDemo/AnonymousTestView.swift | 53 -------- .../SubscriptionListView.swift | 9 +- .../Services/AppState/SessionStore.swift | 115 ++++++++++------ .../Services/Config/Enviroment.swift | 4 +- .../Services/Network/APIClient.swift | 93 +++++++------ .../Network/Endpoints/RegisterAnonymous.swift | 42 +++--- .../Endpoints/SubscriptionsCreate.swift | 33 +++++ .../Services/Security/Keychain.swift | 90 ++++++------- 12 files changed, 422 insertions(+), 327 deletions(-) delete mode 100644 today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift create mode 100644 today-s-sound/Services/Network/Endpoints/SubscriptionsCreate.swift diff --git a/today-s-sound/App/TodaySSoundApp.swift b/today-s-sound/App/TodaySSoundApp.swift index bb5020b..ce79c19 100644 --- a/today-s-sound/App/TodaySSoundApp.swift +++ b/today-s-sound/App/TodaySSoundApp.swift @@ -9,18 +9,18 @@ import SwiftUI @main struct TodaySSoundApp: App { - @StateObject private var session = SessionStore() - - var body: some Scene { - WindowGroup { - Group { - if session.isRegistered { - MainView() - } else { - OnBoardingView() - } - } - .environmentObject(session) + @StateObject private var session = SessionStore() + + var body: some Scene { + WindowGroup { + Group { + if session.isRegistered { + MainView() + } else { + OnBoardingView() } + } + .environmentObject(session) } + } } diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index a397b7a..e2fd8ff 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -1,14 +1,29 @@ import SwiftUI struct AddSubscriptionView: View { - @StateObject private var viewModel = AddSubscriptionViewModel() + // 주의: 호출하는 쪽에서 .environmentObject(session)을 넘겨도, + // ViewModel은 생성 시점에 필요하므로 init(session:) 으로 주입합니다. @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss + @StateObject private var viewModel: AddSubscriptionViewModel + + // MARK: - Initializers + + /// 실제 사용: AddSubscriptionView(session: session) + init(session: SessionStore) { + _viewModel = StateObject(wrappedValue: AddSubscriptionViewModel(session: session)) + } + + /// 프리뷰/임시용(실서비스에서는 사용하지 마세요) + init() { + let dummy = SessionStore() + _viewModel = StateObject(wrappedValue: AddSubscriptionViewModel(session: dummy)) + } + var body: some View { ZStack { - Color.background(colorScheme) - .ignoresSafeArea() + Color.background(colorScheme).ignoresSafeArea() VStack(spacing: 0) { HeaderBar(colorScheme: colorScheme, onClose: { dismiss() }) @@ -17,6 +32,7 @@ struct AddSubscriptionView: View { ScrollView { VStack(spacing: 24) { + // URL InputFieldSection( title: "웹사이트 URL", placeholder: "https://www.example.com", @@ -25,6 +41,7 @@ struct AddSubscriptionView: View { colorScheme: colorScheme ) + // 별명 (현재 API에는 전송하지 않지만 UI는 유지) InputFieldSection( title: "웹페이지 별명", placeholder: "동국대학교 공지사항", @@ -33,17 +50,14 @@ struct AddSubscriptionView: View { colorScheme: colorScheme ) + // 키워드 필터 VStack(alignment: .leading, spacing: 12) { - // 키워드 필터 섹션 VStack(alignment: .leading, spacing: 8) { Text("키워드 필터") .font(.system(size: 14, weight: .semibold)) .foregroundColor(Color.primaryGreen) - // 키워드 추가 버튼 - Button(action: { - viewModel.showKeywordSelector = true - }) { + Button(action: { viewModel.showKeywordSelector = true }) { HStack { Text("키워드 추가...") .font(.system(size: 16)) @@ -64,37 +78,50 @@ struct AddSubscriptionView: View { .fixedSize(horizontal: false, vertical: true) } - // 선택된 키워드 배지들 if !viewModel.selectedKeywords.isEmpty { FlowLayout(spacing: 8) { ForEach(viewModel.selectedKeywords, id: \.self) { keyword in KeywordBadgeWithDelete( text: keyword, colorScheme: colorScheme - ) { - viewModel.removeKeyword(keyword) - } + ) { viewModel.removeKeyword(keyword) } } } } } + // 긴급 토글 (현재 API 전송은 보류) UrgentToggleRow(isOn: $viewModel.isUrgent, colorScheme: colorScheme) - // 하단 버튼 + // 에러 메시지 + if let err = viewModel.errorMessage { + Text(err) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 2) + } + + // 하단 제출 버튼 Button(action: { - dismiss() - }, label: { - Text("등록 승인 요청") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.primaryGreen90) - ) - }) + viewModel.submit() + }) { + ZStack { + Text(viewModel.isLoading ? "요청 중…" : "등록 승인 요청") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + if viewModel.isLoading { + ProgressView().tint(.white) + } + } + .background( + RoundedRectangle(cornerRadius: 12) + .fill(viewModel.canSubmit ? Color.primaryGreen90 : Color.primaryGreen90.opacity(0.5)) + ) + } + .disabled(!viewModel.canSubmit) } .padding(.horizontal, 16) .padding(.top, 8) @@ -102,13 +129,18 @@ struct AddSubscriptionView: View { } } } + // 키워드 선택 시트 .sheet(isPresented: $viewModel.showKeywordSelector) { KeywordSelectorSheet(viewModel: viewModel, colorScheme: colorScheme) } + // 성공 시 자동 닫기 + .onChange(of: viewModel.successSubscriptionId) { newID in + if newID != nil { dismiss() } + } } } -// 키워드 선택 시트 +// 그대로 유지: 키워드 선택 시트/배지/FlowLayout (네가 준 버전과 동일) struct KeywordSelectorSheet: View { @ObservedObject var viewModel: AddSubscriptionViewModel let colorScheme: ColorScheme @@ -116,20 +148,16 @@ struct KeywordSelectorSheet: View { var body: some View { ZStack { - Color.background(colorScheme) - .ignoresSafeArea() + Color.background(colorScheme).ignoresSafeArea() VStack(spacing: 0) { - // 헤더 HStack { Spacer() Text("구독 설정") .font(.custom("KoddiUD OnGothic Bold", size: 24)) .foregroundColor(Color.text(colorScheme)) Spacer() - Button(action: { - dismiss() - }) { + Button(action: { dismiss() }) { Image(systemName: "xmark") .font(.system(size: 20)) .foregroundColor(Color.text(colorScheme)) @@ -139,27 +167,22 @@ struct KeywordSelectorSheet: View { .padding(.top, 20) .padding(.bottom, 32) - // 키워드 설정 섹션 VStack(alignment: .leading, spacing: 16) { HStack { Text("키워드 설정") .font(.custom("KoddiUD OnGothic Bold", size: 20)) .foregroundColor(Color.primaryGreen) - Spacer() } .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) - } + ) { viewModel.toggleKeyword(keyword) } if index < viewModel.availableKeywords.count - 1 { Divider() @@ -172,10 +195,7 @@ struct KeywordSelectorSheet: View { Spacer() - // 저장하기 버튼 - Button(action: { - dismiss() - }) { + Button(action: { dismiss() }) { Text("저장하기") .font(.custom("KoddiUD OnGothic Bold", size: 18)) .foregroundColor(.white) @@ -191,7 +211,6 @@ struct KeywordSelectorSheet: View { } } -// 삭제 가능한 키워드 배지 struct KeywordBadgeWithDelete: View { let text: String let colorScheme: ColorScheme @@ -202,7 +221,6 @@ struct KeywordBadgeWithDelete: View { Text(text) .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) - Button(action: onDelete) { Image(systemName: "xmark") .font(.system(size: 10, weight: .bold)) @@ -218,7 +236,6 @@ struct KeywordBadgeWithDelete: View { } } -// FlowLayout for keywords struct FlowLayout: Layout { var spacing: CGFloat = 8 @@ -232,13 +249,13 @@ struct FlowLayout: Layout { } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let result = FlowResult( - in: bounds.width, - subviews: subviews, - spacing: spacing - ) + let result = FlowResult(in: bounds.width, subviews: subviews, spacing: spacing) for (index, subview) in subviews.enumerated() { - subview.place(at: CGPoint(x: bounds.minX + result.positions[index].x, y: bounds.minY + result.positions[index].y), proposal: .unspecified) + subview.place( + at: CGPoint(x: bounds.minX + result.positions[index].x, + y: bounds.minY + result.positions[index].y), + proposal: .unspecified + ) } } @@ -253,18 +270,15 @@ struct FlowLayout: Layout { for subview in subviews { let size = subview.sizeThatFits(.unspecified) - if currentX + size.width > maxWidth, currentX > 0 { currentX = 0 currentY += lineHeight + spacing lineHeight = 0 } - positions.append(CGPoint(x: currentX, y: currentY)) lineHeight = max(lineHeight, size.height) currentX += size.width + spacing } - size = CGSize(width: maxWidth, height: currentY + lineHeight) } } @@ -272,6 +286,7 @@ struct FlowLayout: Layout { struct AddSubscriptionView_Previews: PreviewProvider { static var previews: some View { - AddSubscriptionView() + AddSubscriptionView() // 미리보기 전용 이니셜라이저 + .preferredColorScheme(.light) } } diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift index d3552eb..a2d091b 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift @@ -1,15 +1,34 @@ import Combine import Foundation -class AddSubscriptionViewModel: ObservableObject { +@MainActor +final class AddSubscriptionViewModel: ObservableObject { + // MARK: - Form + @Published var urlText: String = "" - @Published var nameText: String = "" - @Published var isUrgent: Bool = false - @Published var selectedKeywords: [String] = [] + @Published var nameText: String = "" // (보류) API 확장 시 사용 + @Published var isUrgent: Bool = false // (보류) + @Published var selectedKeywords: [String] = [] // (보류) @Published var showKeywordSelector: Bool = false let availableKeywords = ["장애인", "긴급속보", "장학금", "교직부공지사항", "학생회", "도서관"] + // MARK: - UI State + + @Published private(set) var isLoading: Bool = false + @Published private(set) var errorMessage: String? + @Published private(set) var successSubscriptionId: Int? + + // MARK: - Dependency + + private unowned let session: SessionStore + + init(session: SessionStore) { + self.session = session + } + + // MARK: - Keyword helpers (그대로 유지) + func addKeyword(_ keyword: String) { let trimmed = keyword.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty, !selectedKeywords.contains(trimmed) { @@ -22,10 +41,54 @@ class AddSubscriptionViewModel: ObservableObject { } func toggleKeyword(_ keyword: String) { - if selectedKeywords.contains(keyword) { - removeKeyword(keyword) - } else { - addKeyword(keyword) + if selectedKeywords.contains(keyword) { removeKeyword(keyword) } + else { addKeyword(keyword) } + } + + // MARK: - Validation + + var canSubmit: Bool { + guard let comps = URLComponents(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = comps.scheme, let host = comps.host, !host.isEmpty, + ["http", "https"].contains(scheme.lowercased()) + else { return false } + return !isLoading + } + + // MARK: - Action + + func submit() { + guard canSubmit else { + errorMessage = "유효한 URL을 입력해주세요 (http/https)." + return + } + errorMessage = nil + successSubscriptionId = nil + isLoading = true + + let url = urlText.trimmingCharacters(in: .whitespacesAndNewlines) + + Task { + // ── 옵션 A: createSubscription 이 반환값이 없는(VOID) 경우 ─────────────── + // await session.createSubscription(url: url) + // if let err = session.lastError { self.errorMessage = err } else { self.successSubscriptionId = 0 /* 더미 */; self.resetForm() } + // self.isLoading = false + + // ── 옵션 B: createSubscription 이 subscriptionId(Int)을 반환하도록 한 경우 ── + // (아래 줄만 주석 해제하고, SessionStore 쪽 시그니처를 Int 반환으로 바꿔주면 더 좋음) + do { + let id = try await session.createSubscriptionReturningId(url: url) + self.successSubscriptionId = id + self.resetForm() + } catch { + self.errorMessage = error.localizedDescription + } + self.isLoading = false } } + + private func resetForm() { + urlText = "" + // nameText/isUrgent/selectedKeywords는 향후 API 확장 시 함께 전송하도록 남겨둠 + } } diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift index 6bcd6b6..b2e39af 100644 --- a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift +++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift @@ -8,50 +8,50 @@ import SwiftUI struct OnBoardingView: View { - @EnvironmentObject var session: SessionStore - @State private var isLoading = false + @EnvironmentObject var session: SessionStore + @State private var isLoading = false - var body: some View { - VStack(spacing: 20) { - Text("환영합니다 👋") - .font(.largeTitle).bold() - Text("이 기기를 익명 사용자로 등록하고 서비스를 시작합니다.") - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) + var body: some View { + VStack(spacing: 20) { + Text("환영합니다 👋") + .font(.largeTitle).bold() + Text("이 기기를 익명 사용자로 등록하고 서비스를 시작합니다.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) - if isLoading { - ProgressView("등록 중…") - .padding(.top, 8) - } else { - Button { - Task { - isLoading = true - defer { isLoading = false } - await session.registerIfNeeded() - } - } label: { - Text("시작하기") - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .padding(.top, 12) - } + if isLoading { + ProgressView("등록 중…") + .padding(.top, 8) + } else { + Button { + Task { + isLoading = true + defer { isLoading = false } + await session.registerIfNeeded() + } + } label: { + Text("시작하기") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.top, 12) + } - if let err = session.lastError { - Text(err) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.top, 8) - } + if let err = session.lastError { + Text(err) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.top, 8) + } - // 디버그: 생성된 deviceSecret 미리보기(실서비스에서는 숨기기) - // if let s = Keychain.getString(for: KeychainKey.deviceSecret) { - // Text("secret: \(s)").font(.footnote).foregroundStyle(.secondary) - // } - } - .padding(24) + // 디버그: 생성된 deviceSecret 미리보기(실서비스에서는 숨기기) + // if let s = Keychain.getString(for: KeychainKey.deviceSecret) { + // Text("secret: \(s)").font(.footnote).foregroundStyle(.secondary) + // } } + .padding(24) + } } diff --git a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift deleted file mode 100644 index ae1d282..0000000 --- a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift +++ /dev/null @@ -1,53 +0,0 @@ -import SwiftUI - -struct AnonymousTestView: View { - @StateObject private var store = SessionStore() - @State private var log: String = "" - - var body: some View { - Form { - Section(header: Text("동작")) { - Button("익명 사용자 등록") { - log = "요청 중..." - Task { - await store.registerIfNeeded() - if let err = store.lastError { - log = "실패: \(err)" - } else if let id = store.userId { - log = "성공: user_id=\(id)" - } else { - log = "상태 변경 없음" - } - } - } - } - if let id = store.userId, !id.isEmpty { - Section(header: Text("결과 user_id")) { - Text(id) - .font(.system(.body, design: .monospaced)) - } - } - if let err = store.lastError, !err.isEmpty { - Section(header: Text("에러")) { - Text(err) - .font(.footnote) - .foregroundColor(.red) - } - } - Section(header: Text("로그")) { - Text(log) - .font(.footnote) - .foregroundColor(.gray) - } - } - .navigationTitle("익명 생성 테스트") - } -} - -#if DEBUG - struct AnonymousTestView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { AnonymousTestView() } - } - } -#endif diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index a10f7fb..d0ea733 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -1,6 +1,7 @@ import SwiftUI struct SubscriptionListView: View { + @EnvironmentObject var session: SessionStore // ✅ SessionStore 주입 받기 @StateObject private var viewModel = SubscriptionListViewModel() @Environment(\.colorScheme) var colorScheme @State private var showAddSubscription = false @@ -8,8 +9,7 @@ struct SubscriptionListView: View { var body: some View { NavigationView { ZStack { - Color.background(colorScheme) - .ignoresSafeArea() + Color.background(colorScheme).ignoresSafeArea() VStack(alignment: .leading, spacing: 12) { Spacer() @@ -29,7 +29,9 @@ struct SubscriptionListView: View { .navigationBarHidden(true) } .sheet(isPresented: $showAddSubscription) { - AddSubscriptionView() + // ✅ 세션을 전달해서 AddSubscriptionViewModel이 API를 쓸 수 있게 함 + AddSubscriptionView(session: session) + .environmentObject(session) // 선택적이지만 유지해두면 하위에서도 접근 가능 } } } @@ -37,5 +39,6 @@ struct SubscriptionListView: View { struct SubscriptionListView_Previews: PreviewProvider { static var previews: some View { SubscriptionListView() + .environmentObject(SessionStore()) // ✅ 프리뷰용 더미 세션 주입 } } diff --git a/today-s-sound/Services/AppState/SessionStore.swift b/today-s-sound/Services/AppState/SessionStore.swift index 7ffe585..bc24a47 100644 --- a/today-s-sound/Services/AppState/SessionStore.swift +++ b/today-s-sound/Services/AppState/SessionStore.swift @@ -10,57 +10,86 @@ import SwiftUI @MainActor final class SessionStore: ObservableObject { - @Published private(set) var userId: String? - @Published private(set) var isRegistered: Bool = false - @Published var lastError: String? + @Published private(set) var userId: String? + @Published private(set) var isRegistered: Bool = false + @Published var lastError: String? - private let api = APIClient() + private let api = APIClient() - init() { - // 앱 시작 시, 키체인에 저장되어 있으면 로드 - if let savedId = Keychain.getString(for: KeychainKey.userId) { - self.userId = savedId - self.isRegistered = true - } else { - self.isRegistered = false - } + init() { + // 앱 시작 시, 키체인에 저장되어 있으면 로드 + if let savedId = Keychain.getString(for: KeychainKey.userId) { + userId = savedId + isRegistered = true + } else { + isRegistered = false } + } - /// 처음 실행 시 한 번 호출: deviceSecret 생성/보관 → 서버 등록 - func registerIfNeeded() async { - guard !isRegistered else { return } + /// 처음 실행 시 한 번 호출: deviceSecret 생성/보관 → 서버 등록 + func registerIfNeeded() async { + guard !isRegistered else { return } - // 1) deviceSecret을 키체인에서 찾고, 없으면 생성하여 저장 - let secret: String = { - if let s = Keychain.getString(for: KeychainKey.deviceSecret) { return s } - let gen = DeviceSecret.generate() - Keychain.setString(gen, for: KeychainKey.deviceSecret) - return gen - }() + // 1) deviceSecret을 키체인에서 찾고, 없으면 생성하여 저장 + let secret: String = { + if let s = Keychain.getString(for: KeychainKey.deviceSecret) { return s } + let gen = DeviceSecret.generate() + Keychain.setString(gen, for: KeychainKey.deviceSecret) + return gen + }() - do { - // 2) 서버 호출 - let body = RegisterAnonymousBody(deviceSecret: secret) - typealias Resp = SuccessEnvelope - let envelope: Resp = try await api.postJSON(path: "/api/users/anonymous", body: body) + do { + // 2) 서버 호출 + let body = RegisterAnonymousBody(deviceSecret: secret) + typealias Resp = SuccessEnvelope + let envelope: Resp = try await api.postJSON(path: "/api/users/anonymous", body: body) - // 3) user_id 보관 - Keychain.setString(envelope.result.userId, for: KeychainKey.userId) - self.userId = envelope.result.userId - self.isRegistered = true + // 3) user_id 보관 + Keychain.setString(envelope.result.userId, for: KeychainKey.userId) + userId = envelope.result.userId + isRegistered = true - // (옵션) 서버가 api_key 같은 걸 준다면 저장 - // if let key = envelope.result.api_key { Keychain.setString(key, for: KeychainKey.apiKey) } + // (옵션) 서버가 api_key 같은 걸 준다면 저장 + // if let key = envelope.result.api_key { Keychain.setString(key, for: KeychainKey.apiKey) } - } catch let APIError.http(_, data) { - // 스웨거 에러 포맷 시도 디코딩 - if let data, let err = try? JSONDecoder().decode(ErrorEnvelope.self, from: data) { - lastError = "[\(err.code)] \(err.message)" - } else { - lastError = "알 수 없는 서버 오류" - } - } catch { - lastError = error.localizedDescription - } + } catch let APIError.http(_, data) { + // 스웨거 에러 포맷 시도 디코딩 + if let data, let err = try? JSONDecoder().decode(ErrorEnvelope.self, from: data) { + lastError = "[\(err.code)] \(err.message)" + } else { + lastError = "알 수 없는 서버 오류" + } + } catch { + lastError = error.localizedDescription } + } +} + +@MainActor +extension SessionStore { + /// 추천: 생성된 subscriptionId를 반환 + func createSubscriptionReturningId(url: String) async throws -> Int { + lastError = nil + do { + let headers = try AuthHeaders.userAndDevice() + let body = CreateSubscriptionBody(url: url) + let resp: CreateSubscriptionResp = try await api.postJSON( + path: "/api/subscriptions", + body: body, + headers: headers + ) + return resp.subscriptionId + } catch let APIError.http(_, data) { + if let data, let err = try? JSONDecoder().decode(ErrorEnvelope.self, from: data) { + lastError = "[\(err.code)] \(err.message)" + throw err + } else { + lastError = "구독 추가 실패(서버 오류)" + throw APIError.underlying(NSError(domain: "Sub", code: -1, userInfo: [NSLocalizedDescriptionKey: lastError ?? "오류"])) + } + } catch { + lastError = error.localizedDescription + throw error + } + } } diff --git a/today-s-sound/Services/Config/Enviroment.swift b/today-s-sound/Services/Config/Enviroment.swift index 2bd4160..005429a 100644 --- a/today-s-sound/Services/Config/Enviroment.swift +++ b/today-s-sound/Services/Config/Enviroment.swift @@ -8,6 +8,6 @@ import Foundation enum AppConfig { - // 배포/개발 분기 필요하면 Scheme/xcconfig로 주입해도 됨 - static let baseURL = URL(string: "http://localhost:8080")! // 예: https://api.example.com + // 배포/개발 분기 필요하면 Scheme/xcconfig로 주입해도 됨 + static let baseURL = URL(string: "http://localhost:8080")! // 예: https://api.example.com } diff --git a/today-s-sound/Services/Network/APIClient.swift b/today-s-sound/Services/Network/APIClient.swift index ae8e41f..98e79da 100644 --- a/today-s-sound/Services/Network/APIClient.swift +++ b/today-s-sound/Services/Network/APIClient.swift @@ -8,55 +8,60 @@ import Foundation enum APIError: Error, LocalizedError { - case invalidURL - case http(status: Int, data: Data?) - case decoding(Error) - case underlying(Error) - - var errorDescription: String? { - switch self { - case .invalidURL: return "잘못된 URL" - case .http(let s, _): return "서버 오류(\(s))" - case .decoding(let e): return "디코딩 오류: \(e.localizedDescription)" - case .underlying(let e): return e.localizedDescription - } + case invalidURL + case http(status: Int, data: Data?) + case decoding(Error) + case underlying(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: "잘못된 URL" + case let .http(s, _): "서버 오류(\(s))" + case let .decoding(e): "디코딩 오류: \(e.localizedDescription)" + case let .underlying(e): e.localizedDescription } + } } final class APIClient { - private let baseURL: URL - private let session: URLSession - - init(baseURL: URL = AppConfig.baseURL) { - self.baseURL = baseURL - let cfg = URLSessionConfiguration.default - cfg.httpCookieStorage = HTTPCookieStorage.shared - cfg.httpShouldSetCookies = true - cfg.httpAdditionalHeaders = ["Accept": "application/json", - "Content-Type": "application/json"] - self.session = URLSession(configuration: cfg) + private let baseURL: URL + private let session: URLSession + + init(baseURL: URL = AppConfig.baseURL) { + self.baseURL = baseURL + let cfg = URLSessionConfiguration.default + cfg.httpCookieStorage = HTTPCookieStorage.shared + cfg.httpShouldSetCookies = true + cfg.httpAdditionalHeaders = ["Accept": "application/json", + "Content-Type": "application/json"] + session = URLSession(configuration: cfg) + } + + func postJSON( + path: String, + body: some Encodable, + headers: [String: String] = [:] // ← 추가: 요청별 헤더 + ) async throws -> Response { + guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.httpBody = try JSONEncoder().encode(body) + + // 공통 헤더는 configuration.httpAdditionalHeaders에서 이미 세팅됨 + // 요청별 헤더 합치기 + for (key, value) in headers { + req.setValue(value, forHTTPHeaderField: key) } - func postJSON( - path: String, - body: Request - ) async throws -> Response { - guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.invalidURL } - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.httpBody = try JSONEncoder().encode(body) - - let (data, resp) = try await session.data(for: req) // 요청 보내는 코드 - print("서버 raw 응답:", String(data: data, encoding: .utf8) ?? "") - guard let http = resp as? HTTPURLResponse else { throw APIError.underlying(URLError(.badServerResponse)) } - guard (200..<300).contains(http.statusCode) else { - throw APIError.http(status: http.statusCode, data: data) - } - do { - return try JSONDecoder().decode(Response.self, from: data) - } catch { - // 성공 스펙이 래핑되어 있지 않거나 빈 바디일 수 있으니 필요시 분기 - throw APIError.decoding(error) - } + let (data, resp) = try await session.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw APIError.underlying(URLError(.badServerResponse)) } + guard (200 ..< 300).contains(http.statusCode) else { + throw APIError.http(status: http.statusCode, data: data) + } + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + throw APIError.decoding(error) } + } } diff --git a/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift index bae95da..f9cf065 100644 --- a/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift +++ b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift @@ -5,44 +5,44 @@ // Created by 하승연 on 10/30/25. // -import Foundation import CryptoKit +import Foundation // 1) Request struct RegisterAnonymousBody: Encodable { - let deviceSecret: String + let deviceSecret: String } // 2) 성공 Response 래퍼 struct SuccessEnvelope: Decodable { - let errorCode: String? - let message: String - let result: AnonymousResult + let errorCode: String? + let message: String + let result: AnonymousResult } struct AnonymousResult: Decodable { - let userId: String - // 서버가 추가로 키 같은 걸 준다면 여기에 옵셔널로: - // let api_key: String? + let userId: String + // 서버가 추가로 키 같은 걸 준다면 여기에 옵셔널로: + // let api_key: String? } // 3) 에러 Response struct ErrorEnvelope: Decodable, Error { - let status: Int - let code: String - let message: String + let status: Int + let code: String + let message: String } // 4) deviceSecret 생성 유틸 (32바이트 랜덤 → base64URL) enum DeviceSecret { - static func generate() -> String { - var bytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - // URL-safe base64 - let data = Data(bytes) - return data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } + static func generate() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + // URL-safe base64 + let data = Data(bytes) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } } diff --git a/today-s-sound/Services/Network/Endpoints/SubscriptionsCreate.swift b/today-s-sound/Services/Network/Endpoints/SubscriptionsCreate.swift new file mode 100644 index 0000000..2e074ba --- /dev/null +++ b/today-s-sound/Services/Network/Endpoints/SubscriptionsCreate.swift @@ -0,0 +1,33 @@ +// +// SubscriptionsCreate.swift +// today-s-sound +// +// Created by 하승연 on 10/31/25. +// + +import Foundation + +// 요청 바디 +struct CreateSubscriptionBody: Encodable { + let url: String +} + +// 응답 +struct CreateSubscriptionResp: Decodable { + let subscriptionId: Int +} + +// 헤더 빌더(키체인에서 읽어서 만드는 편의 함수) +enum AuthHeaders { + static func userAndDevice() throws -> [String: String] { + guard let uid = Keychain.getString(for: KeychainKey.userId), + let secret = Keychain.getString(for: KeychainKey.deviceSecret) + else { + throw APIError.underlying(NSError(domain: "AuthHeaders", code: -1, userInfo: [NSLocalizedDescriptionKey: "등록 정보가 없습니다"])) + } + return [ + "X-User-ID": uid, + "X-Device-Secret": secret + ] + } +} diff --git a/today-s-sound/Services/Security/Keychain.swift b/today-s-sound/Services/Security/Keychain.swift index 9be506a..2e52e97 100644 --- a/today-s-sound/Services/Security/Keychain.swift +++ b/today-s-sound/Services/Security/Keychain.swift @@ -9,56 +9,56 @@ import Foundation import Security enum KeychainKey { - static let deviceSecret = "device_secret" - static let userId = "user_id" - static let apiKey = "api_key" // 서버가 추가 키를 준다면 여기에 저장 (옵셔널) + static let deviceSecret = "device_secret" + static let userId = "userId" + static let apiKey = "api_key" // 서버가 추가 키를 준다면 여기에 저장 (옵셔널) } enum Keychain { - @discardableResult - static func set(_ value: Data, for key: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: value, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock // 앱 재부팅 후에도 접근 - ] - SecItemDelete(query as CFDictionary) // 기존 값 제거 - let status = SecItemAdd(query as CFDictionary, nil) - return status == errSecSuccess - } + @discardableResult + static func set(_ value: Data, for key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: value, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock // 앱 재부팅 후에도 접근 + ] + SecItemDelete(query as CFDictionary) // 기존 값 제거 + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } - static func get(for key: String) -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess else { return nil } - return (item as? Data) - } + static func get(for key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { return nil } + return (item as? Data) + } - @discardableResult - static func delete(for key: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key - ] - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound - } + @discardableResult + static func delete(for key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } - // 문자열 편의 - @discardableResult - static func setString(_ value: String, for key: String) -> Bool { - set(Data(value.utf8), for: key) - } + // 문자열 편의 + @discardableResult + static func setString(_ value: String, for key: String) -> Bool { + set(Data(value.utf8), for: key) + } - static func getString(for key: String) -> String? { - guard let data = get(for: key) else { return nil } - return String(data: data, encoding: .utf8) - } + static func getString(for key: String) -> String? { + guard let data = get(for: key) else { return nil } + return String(data: data, encoding: .utf8) + } }