From 3b98f95243ac637811d875685294bc9cd9dc1fc1 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:20:49 +0900 Subject: [PATCH 01/37] =?UTF-8?q?[#39]=20CodvieAPI=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 5 +- Tuist/Package.resolved | 101 ++++++++++++++++++++++++++++++++++++++++- Tuist/Package.swift | 10 +++- 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/Project.swift b/Project.swift index f3fa272c..04acfd73 100644 --- a/Project.swift +++ b/Project.swift @@ -123,7 +123,10 @@ let project = Project( .external(name: "KakaoSDKUser"), // 네트워킹 - .external(name: "Moya") + .external(name: "Moya"), + + // CodiveAPI + .external(name: "CodiveAPI") ], settings: .settings( base: [ diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 37259c97..3fddfe7b 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6cbaf0561fc687651adcac45da73a2ab8140456a303eecb6dd360ec98a778fdb", + "originHash" : "0128c1c3d8a560559e47d07436bc29af86292d147f8faf88348f2c3fbba36777", "pins" : [ { "identity" : "alamofire", @@ -10,6 +10,15 @@ "version" : "5.10.2" } }, + { + "identity" : "codiveapi", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Clokey-dev/CodiveAPI", + "state" : { + "branch" : "main", + "revision" : "a2759fdf22f5c593cf66af83498eaa602918c077" + } + }, { "identity" : "kakao-ios-sdk", "kind" : "remoteSourceControl", @@ -28,6 +37,15 @@ "version" : "15.0.3" } }, + { + "identity" : "openapikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/OpenAPIKit", + "state" : { + "revision" : "343b2c1793058fcc53c1bd7e2907f8e3a4d640fb", + "version" : "3.9.0" + } + }, { "identity" : "reactiveswift", "kind" : "remoteSourceControl", @@ -45,6 +63,87 @@ "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", "version" : "6.9.0" } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-openapi-generator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-generator", + "state" : { + "revision" : "d74223cc5595a8165181c4d9579243c932e5cd07", + "version" : "1.10.3" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "51b5127c7fb6ffac106ad6d199aaa33c5024895f", + "version" : "6.2.0" + } } ], "version" : 3 diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 05f6e448..2da229cb 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -8,7 +8,12 @@ import PackageDescription // Customize the product types for specific package product // Default is .staticFramework // productTypes: ["Alamofire": .framework,] - productTypes: [:] + productTypes: [ + "KakaoSDKUser": .framework, + "KakaoSDKAuth": .framework, + "KakaoSDKCommon": .framework, + "Alamofire": .framework, + ] ) #endif @@ -17,6 +22,7 @@ let package = Package( dependencies: [ // 카카오 SDK .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.22.5"), - .package(url: "https://github.com/Moya/Moya.git", from: "15.0.0") + .package(url: "https://github.com/Moya/Moya.git", from: "15.0.0"), + .package(url: "https://github.com/Clokey-dev/CodiveAPI", branch: "main") ] ) From 063880d0fb73f722b2c50adf22798358200cc10e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:42:01 +0900 Subject: [PATCH 02/37] =?UTF-8?q?[#39]=20CodvieAPI=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tuist/Package.resolved | 70 +++++------------------------------------- 1 file changed, 8 insertions(+), 62 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 3fddfe7b..1ae73fa0 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "0128c1c3d8a560559e47d07436bc29af86292d147f8faf88348f2c3fbba36777", + "originHash" : "003f4af15c02bf1cca26de772279423462b20465025ec4af00cf1d13daadd66c", "pins" : [ { "identity" : "alamofire", "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" + "revision" : "7be73f6c2b5cd90e40798b06ebd5da8f9f79cf88", + "version" : "5.11.0" } }, { @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "a2759fdf22f5c593cf66af83498eaa602918c077" + "revision" : "7e7082d9e2dbf14c4c4974fb20122fdca759276f" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kakao/kakao-ios-sdk", "state" : { - "revision" : "f17e30773062d9df4c8e0e211da9ac22dfb2e5c1", - "version" : "2.24.6" + "revision" : "f04b5655f3528e8c21a0ff7db047eeb138135394", + "version" : "2.26.0" } }, { @@ -37,15 +37,6 @@ "version" : "15.0.3" } }, - { - "identity" : "openapikit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattpolzin/OpenAPIKit", - "state" : { - "revision" : "343b2c1793058fcc53c1bd7e2907f8e3a4d640fb", - "version" : "3.9.0" - } - }, { "identity" : "reactiveswift", "kind" : "remoteSourceControl", @@ -60,26 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ReactiveX/RxSwift.git", "state" : { - "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", - "version" : "6.9.0" - } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms", - "state" : { - "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "5004a18539bd68905c5939aa893075f578f4f03d", + "version" : "6.9.1" } }, { @@ -100,24 +73,6 @@ "version" : "1.5.1" } }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-openapi-generator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-openapi-generator", - "state" : { - "revision" : "d74223cc5595a8165181c4d9579243c932e5cd07", - "version" : "1.10.3" - } - }, { "identity" : "swift-openapi-runtime", "kind" : "remoteSourceControl", @@ -135,15 +90,6 @@ "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", "version" : "1.2.0" } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/Yams", - "state" : { - "revision" : "51b5127c7fb6ffac106ad6d199aaa33c5024895f", - "version" : "6.2.0" - } } ], "version" : 3 From f57089214b3f1cdf391de94695675097a55d448f Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:00:29 +0900 Subject: [PATCH 03/37] =?UTF-8?q?[#39]=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20OID?= =?UTF-8?q?C=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/Data/SocialAuthService.swift | 142 ++++++++++-------- .../Auth/Domain/Models/AuthModels.swift | 14 +- .../ViewModel/OnboardingViewModel.swift | 8 +- .../Shared/Data/Storage/KeychainManager.swift | 129 ++++++++++++++++ Project.swift | 4 + 5 files changed, 231 insertions(+), 66 deletions(-) create mode 100644 Codive/Shared/Data/Storage/KeychainManager.swift diff --git a/Codive/Features/Auth/Data/SocialAuthService.swift b/Codive/Features/Auth/Data/SocialAuthService.swift index e0ebcce7..0ea1b943 100644 --- a/Codive/Features/Auth/Data/SocialAuthService.swift +++ b/Codive/Features/Auth/Data/SocialAuthService.swift @@ -10,6 +10,7 @@ import KakaoSDKUser import KakaoSDKAuth import KakaoSDKCommon import AuthenticationServices +import UIKit // MARK: - Social Auth Service Protocol protocol SocialAuthServiceProtocol { @@ -24,70 +25,73 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol { private var appleContinuation: CheckedContinuation? - // MARK: - Kakao Login + // MARK: - Kakao Login (OIDC via ASWebAuthenticationSession) func kakaoLogin() async -> AuthResult { + let authURL = URL(string: "https://prod.clokey.store/oauth2/authorization/kakao")! + return await withCheckedContinuation { continuation in - if UserApi.isKakaoTalkLoginAvailable() { - UserApi.shared.loginWithKakaoTalk { _, error in - if let error = error { - self.handleKakaoError(error, continuation: continuation) + let session = ASWebAuthenticationSession( + url: authURL, + callbackURLScheme: "codive" + ) { callbackURL, error in + // 에러 처리 + if let error = error { + if let authError = error as? ASWebAuthenticationSessionError { + switch authError.code { + case .canceledLogin: + continuation.resume(returning: .failure(.cancelled)) + default: + continuation.resume(returning: .failure(.networkError(error.localizedDescription))) + } } else { - self.fetchKakaoUserInfo(continuation: continuation) + continuation.resume(returning: .failure(.networkError(error.localizedDescription))) } + return } - } else { - UserApi.shared.loginWithKakaoAccount { _, error in - if let error = error { - self.handleKakaoError(error, continuation: continuation) - } else { - self.fetchKakaoUserInfo(continuation: continuation) - } + + // 콜백 URL에서 토큰 파싱 + guard let callbackURL = callbackURL else { + continuation.resume(returning: .failure(.tokenParsingError)) + return + } + + // URL 파라미터에서 토큰 추출 + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + continuation.resume(returning: .failure(.tokenParsingError)) + return + } + + let accessToken = queryItems.first(where: { $0.name == "accessToken" })?.value + let refreshToken = queryItems.first(where: { $0.name == "refreshToken" })?.value + + guard let accessToken = accessToken, + let refreshToken = refreshToken else { + continuation.resume(returning: .failure(.tokenParsingError)) + return + } + + // Keychain에 토큰 저장 + do { + try KeychainManager.shared.saveAccessToken(accessToken) + try KeychainManager.shared.saveRefreshToken(refreshToken) + + // 성공 시 임시 사용자 정보 반환 (나중에 서버에서 받아야 함) + let authUser = AuthUser( + id: "temp_kakao_user", + email: nil, + name: nil, + provider: .kakao + ) + continuation.resume(returning: .success(authUser)) + } catch { + continuation.resume(returning: .failure(.keychainError(error.localizedDescription))) } } - } - } - - private func fetchKakaoUserInfo(continuation: CheckedContinuation) { - UserApi.shared.me { user, error in - if error != nil { - continuation.resume(returning: .failure(.userInfoError)) - } else if let user = user { - let authUser = AuthUser( - id: "\(user.id ?? 0)", - email: user.kakaoAccount?.email, - name: user.kakaoAccount?.profile?.nickname, - provider: .kakao - ) - continuation.resume(returning: .success(authUser)) - } else { - continuation.resume(returning: .failure(.userInfoError)) - } - } - } - - private func handleKakaoError(_ error: Error, continuation: CheckedContinuation) { - if let sdkError = error as? SdkError { - switch sdkError { - case .ClientFailed(reason: .Cancelled, _): - continuation.resume(returning: .failure(.cancelled)) - return - default: - break - } - } - - let errorMessage = error.localizedDescription.lowercased() - - if errorMessage.contains("cancelled") || - errorMessage.contains("cancel") || - errorMessage.contains("user_cancelled") || - errorMessage.contains("취소") || - errorMessage.contains("the operation couldn't be completed") || - errorMessage.contains("sdkerror error 0") { - continuation.resume(returning: .failure(.cancelled)) - } else { - print("카카오 로그인 에러: \(error.localizedDescription)") - continuation.resume(returning: .failure(.networkError(error.localizedDescription))) + + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + session.start() } } @@ -123,15 +127,15 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol { // MARK: - Apple Sign In Delegate extension SocialAuthService: ASAuthorizationControllerDelegate { - + func authorizationController( controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { defer { appleContinuation = nil } - + guard let appleContinuation = appleContinuation else { return } - + if let credential = authorization.credential as? ASAuthorizationAppleIDCredential { let authUser = AuthUser( id: credential.user, @@ -144,15 +148,15 @@ extension SocialAuthService: ASAuthorizationControllerDelegate { appleContinuation.resume(returning: .failure(.userInfoError)) } } - + func authorizationController( controller: ASAuthorizationController, didCompleteWithError error: Error ) { defer { appleContinuation = nil } - + guard let appleContinuation = appleContinuation else { return } - + if let authError = error as? ASAuthorizationError { switch authError.code { case .canceled: @@ -165,3 +169,15 @@ extension SocialAuthService: ASAuthorizationControllerDelegate { } } } + +// MARK: - ASWebAuthenticationPresentationContextProviding +extension SocialAuthService: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + // 현재 활성 윈도우 반환 + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + fatalError("No window found") + } + return window + } +} diff --git a/Codive/Features/Auth/Domain/Models/AuthModels.swift b/Codive/Features/Auth/Domain/Models/AuthModels.swift index e649cd56..c0967ddc 100644 --- a/Codive/Features/Auth/Domain/Models/AuthModels.swift +++ b/Codive/Features/Auth/Domain/Models/AuthModels.swift @@ -42,7 +42,9 @@ enum AuthError: Error, LocalizedError { case networkError(String) case userInfoError case unknown(String) - + case tokenParsingError + case keychainError(String) + var errorDescription: String? { switch self { case .cancelled: @@ -53,6 +55,16 @@ enum AuthError: Error, LocalizedError { return "사용자 정보를 가져오는데 실패했습니다" case .unknown(let message): return "알 수 없는 오류: \(message)" + case .tokenParsingError: + return "토큰 파싱 오류" + case .keychainError(let message): + return "토큰 저장 오류: \(message)" } } } + +// MARK: - Auth Token Response (서버 응답) +struct AuthTokenResponse: Decodable { + let accessToken: String + let refreshToken: String +} diff --git a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift index f08210c9..efc766da 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift @@ -41,14 +41,18 @@ final class OnboardingViewModel: ObservableObject { switch result { case .success(let user): - print("카카오 로그인 성공: \(user.name ?? "Unknown") (\(user.id))") + print("카카오 로그인 성공: \(user.id)") appRouter.navigateToMain() - + case .failure(let error): switch error { case .cancelled: print("카카오 로그인 취소됨") return + case .tokenParsingError: + errorMessage = "로그인 처리 중 오류가 발생했습니다. 다시 시도해주세요." + case .keychainError: + errorMessage = "토큰 저장 중 오류가 발생했습니다. 다시 시도해주세요." case .networkError(let message): if message.contains("The operation couldn't be completed") || message.contains("KakaoSDKCommon.SdkError error 0") { diff --git a/Codive/Shared/Data/Storage/KeychainManager.swift b/Codive/Shared/Data/Storage/KeychainManager.swift new file mode 100644 index 00000000..8d761e3e --- /dev/null +++ b/Codive/Shared/Data/Storage/KeychainManager.swift @@ -0,0 +1,129 @@ +import Foundation +import Security + +enum KeychainError: Error { + case itemNotFound + case duplicateItem + case invalidData + case unexpectedStatus(OSStatus) + + var localizedDescription: String { + switch self { + case .itemNotFound: + return "토큰을 찾을 수 없습니다." + case .duplicateItem: + return "이미 존재하는 토큰입니다." + case .invalidData: + return "잘못된 데이터 형식입니다." + case .unexpectedStatus(let status): + return "Keychain 오류: \(status)" + } + } +} + +final class KeychainManager { + static let shared = KeychainManager() + + private init() {} + + private let accessTokenKey = "com.codive.accessToken" + private let refreshTokenKey = "com.codive.refreshToken" + + // MARK: - Access Token + + func saveAccessToken(_ token: String) throws { + try save(token, forKey: accessTokenKey) + } + + func getAccessToken() throws -> String { + try get(forKey: accessTokenKey) + } + + func deleteAccessToken() throws { + try delete(forKey: accessTokenKey) + } + + // MARK: - Refresh Token + + func saveRefreshToken(_ token: String) throws { + try save(token, forKey: refreshTokenKey) + } + + func getRefreshToken() throws -> String { + try get(forKey: refreshTokenKey) + } + + func deleteRefreshToken() throws { + try delete(forKey: refreshTokenKey) + } + + // MARK: - Clear All + + func clearAllTokens() throws { + try? deleteAccessToken() + try? deleteRefreshToken() + } + + // MARK: - Private Methods + + private func save(_ value: String, forKey key: String) throws { + guard let data = value.data(using: .utf8) else { + throw KeychainError.invalidData + } + + // 기존 항목 삭제 (중복 방지) + try? delete(forKey: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + guard status == errSecSuccess else { + throw KeychainError.unexpectedStatus(status) + } + } + + private func get(forKey key: String) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + throw KeychainError.itemNotFound + } + throw KeychainError.unexpectedStatus(status) + } + + guard let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + throw KeychainError.invalidData + } + + return value + } + + private func delete(forKey key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + } +} diff --git a/Project.swift b/Project.swift index 04acfd73..54c9ca23 100644 --- a/Project.swift +++ b/Project.swift @@ -95,6 +95,10 @@ let project = Project( [ "CFBundleURLName": "KAKAO", "CFBundleURLSchemes": ["kakao$(KAKAO_APP_KEY)"] + ], + [ + "CFBundleURLName": "CODIVE", + "CFBundleURLSchemes": ["codive"] ] ], "LSApplicationQueriesSchemes": [ From f2f772be7c935157135d583c1e1b5323df9da85a Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:19:02 +0900 Subject: [PATCH 04/37] =?UTF-8?q?[#39]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=9B=84,=20=EC=95=BD=EA=B4=80=EB=8F=99=EC=9D=98=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Auth/Data/AuthAPIService.swift | 71 +++++++++++++++++++ .../Auth/Data/KeychainTokenProvider.swift | 23 ++++++ .../Repositories/AuthRepositoryImpl.swift | 17 +++-- .../Auth/Domain/Models/AuthModels.swift | 12 ++++ .../Domain/Protocols/AuthRepository.swift | 3 +- .../ViewModel/OnboardingViewModel.swift | 32 +++++++-- Codive/Router/AppDestination.swift | 1 + .../Router/ViewFactory/AuthViewFactory.swift | 2 + Tuist/Package.resolved | 6 +- 9 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 Codive/Features/Auth/Data/AuthAPIService.swift create mode 100644 Codive/Features/Auth/Data/KeychainTokenProvider.swift diff --git a/Codive/Features/Auth/Data/AuthAPIService.swift b/Codive/Features/Auth/Data/AuthAPIService.swift new file mode 100644 index 00000000..040ff6fe --- /dev/null +++ b/Codive/Features/Auth/Data/AuthAPIService.swift @@ -0,0 +1,71 @@ +// +// AuthAPIService.swift +// Codive +// +// Created by 황상환 on 1/4/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - Auth API Service Protocol +protocol AuthAPIServiceProtocol { + func checkAuthStatus() async -> AuthStatusResult +} + +// MARK: - Auth API Service Implementation +final class AuthAPIService: AuthAPIServiceProtocol { + + private let client: Client + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + // CodiveAPI Client 생성 with AuthMiddleware + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + } + + func checkAuthStatus() async -> AuthStatusResult { + do { + // API 호출 + let response = try await client.Auth_getUserStatus( + Operations.Auth_getUserStatus.Input() + ) + + // 응답 처리 + switch response { + case .ok(let okResponse): + // Body 추출 + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + // JSON 디코딩 + let apiResponse = try JSONDecoder().decode( + Components.Schemas.BaseResponseUserStatusResponse.self, + from: data + ) + + // registerStatus 확인 + guard let result = apiResponse.result, + let registerStatus = result.registerStatus else { + return .failure(.networkError("회원 상태 정보 없음")) + } + + // RegisterStatus 변환 + switch registerStatus { + case .NOT_AGREED: + return .success(.notAgreed) + case .REGISTERED: + return .success(.registered) + } + + case .undocumented(statusCode: let statusCode, _): + return .failure(.networkError("예상치 못한 응답 코드: \(statusCode)")) + } + + } catch { + return .failure(.networkError(error.localizedDescription)) + } + } +} diff --git a/Codive/Features/Auth/Data/KeychainTokenProvider.swift b/Codive/Features/Auth/Data/KeychainTokenProvider.swift new file mode 100644 index 00000000..790aa256 --- /dev/null +++ b/Codive/Features/Auth/Data/KeychainTokenProvider.swift @@ -0,0 +1,23 @@ +// +// KeychainTokenProvider.swift +// Codive +// +// Created by 황상환 on 1/4/26. +// + +import Foundation +import CodiveAPI + +// MARK: - Keychain Token Provider +final class KeychainTokenProvider: TokenProvider { + + func getValidToken() async -> String? { + do { + let token = try KeychainManager.shared.getAccessToken() + return token + } catch { + print("토큰 조회 실패: \(error)") + return nil + } + } +} diff --git a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift index 4919ebe7..28167fae 100644 --- a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift +++ b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift @@ -10,13 +10,18 @@ import Foundation // MARK: - Auth Repository Implementation @MainActor final class AuthRepositoryImpl: AuthRepository { - + // MARK: - Properties private let socialAuthService: SocialAuthServiceProtocol - + private let authAPIService: AuthAPIServiceProtocol + // MARK: - Initializer - init(socialAuthService: SocialAuthServiceProtocol) { + init( + socialAuthService: SocialAuthServiceProtocol, + authAPIService: AuthAPIServiceProtocol = AuthAPIService() + ) { self.socialAuthService = socialAuthService + self.authAPIService = authAPIService } // MARK: - AuthRepository Implementation @@ -35,9 +40,13 @@ final class AuthRepositoryImpl: AuthRepository { // 4. 최종 AuthResult 반환 } + func checkAuthStatus() async -> AuthStatusResult { + return await authAPIService.checkAuthStatus() + } + func logout() async { await socialAuthService.logout() - + // 향후 서버 연결 시 추가될 로직: // 1. 서버에 로그아웃 요청 // 2. 로컬 토큰 삭제 diff --git a/Codive/Features/Auth/Domain/Models/AuthModels.swift b/Codive/Features/Auth/Domain/Models/AuthModels.swift index c0967ddc..445c4746 100644 --- a/Codive/Features/Auth/Domain/Models/AuthModels.swift +++ b/Codive/Features/Auth/Domain/Models/AuthModels.swift @@ -68,3 +68,15 @@ struct AuthTokenResponse: Decodable { let accessToken: String let refreshToken: String } + +// MARK: - Register Status +enum RegisterStatus { + case notAgreed // 약관 동의 필요 + case registered // 약관 동의 완료 +} + +// MARK: - Auth Status Result +enum AuthStatusResult { + case success(RegisterStatus) + case failure(AuthError) +} diff --git a/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift b/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift index 66c8cc31..6cf58fde 100644 --- a/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift +++ b/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift @@ -10,8 +10,9 @@ import Foundation // MARK: - Auth Repository Protocol protocol AuthRepository { func socialLogin(provider: AuthProvider) async -> AuthResult + func checkAuthStatus() async -> AuthStatusResult func logout() async - + // 향후 서버 연결 시 추가될 메서드들 // func refreshToken() async -> AuthResult // func deleteAccount() async -> Bool diff --git a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift index efc766da..29f10a98 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift @@ -34,17 +34,39 @@ final class OnboardingViewModel: ObservableObject { func kakaoLoginButtonTapped() async { isLoading = true errorMessage = nil - + let result = await authRepository.socialLogin(provider: .kakao) // Repository 사용 - - isLoading = false - + switch result { case .success(let user): print("카카오 로그인 성공: \(user.id)") - appRouter.navigateToMain() + + // 로그인 성공 후 회원 상태 체크 + let statusResult = await authRepository.checkAuthStatus() + + isLoading = false + + switch statusResult { + case .success(let registerStatus): + switch registerStatus { + case .notAgreed: + // 약관 동의 필요 -> 약관 동의 화면으로 이동 + print("약관 동의 필요 -> 약관 동의 화면으로 이동") + navigationRouter.navigate(to: .termsAgreement) + + case .registered: + // 약관 동의 완료 -> 메인 화면으로 이동 + print("약관 동의 완료 -> 메인 화면으로 이동") + appRouter.navigateToMain() + } + + case .failure(let error): + errorMessage = "회원 정보 확인 중 오류가 발생했습니다: \(error.localizedDescription)" + } case .failure(let error): + isLoading = false + switch error { case .cancelled: print("카카오 로그인 취소됨") diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index 4f15213b..63c55422 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -10,6 +10,7 @@ import Foundation enum AppDestination: Hashable, Identifiable { case login case signup + case termsAgreement case main case recordAdd case clothPhotoSelect diff --git a/Codive/Router/ViewFactory/AuthViewFactory.swift b/Codive/Router/ViewFactory/AuthViewFactory.swift index 0f3bb392..0e041e1d 100644 --- a/Codive/Router/ViewFactory/AuthViewFactory.swift +++ b/Codive/Router/ViewFactory/AuthViewFactory.swift @@ -28,6 +28,8 @@ final class AuthViewFactory { case .signup: // SignUpView(viewModel: authDIContainer.makeSignUpViewModel()) Text("회원가입 화면") // 임시 + case .termsAgreement: + TermsAgreementView() default: EmptyView() } diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 1ae73fa0..916196dd 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "7e7082d9e2dbf14c4c4974fb20122fdca759276f" + "revision" : "b07f082c006752c9dec83e774dda68b17fbb40d9" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kakao/kakao-ios-sdk", "state" : { - "revision" : "f04b5655f3528e8c21a0ff7db047eeb138135394", - "version" : "2.26.0" + "revision" : "bebbd50e40843f5f54d2fe87d4f415771736f8ab", + "version" : "2.27.0" } }, { From 9ac04c772f98f11c689af326b61f408ab975ff0e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:08:33 +0900 Subject: [PATCH 05/37] =?UTF-8?q?[#39]=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9B=B9=EB=B7=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=94=A5=EB=A7=81=ED=81=AC=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Application/AppRootView.swift | 68 +++++++++++++++++-- Codive/DIContainer/AuthDIContainer.swift | 5 +- .../Repositories/AuthRepositoryImpl.swift | 20 +++--- .../Domain/Protocols/AuthRepository.swift | 5 +- .../Presentation/View/OnboardingView.swift | 10 +++ .../ViewModel/OnboardingViewModel.swift | 58 ++-------------- .../Shared/Data/Storage/KeychainManager.swift | 42 ++++++++++-- .../DesignSystem/Views/SafariView.swift | 21 ++++++ 8 files changed, 147 insertions(+), 82 deletions(-) create mode 100644 Codive/Shared/DesignSystem/Views/SafariView.swift diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index 7e99bf1a..d51ff067 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine /// 앱의 최상위 RootView struct AppRootView: View { @@ -14,22 +15,75 @@ struct AppRootView: View { private let authDIContainer: AuthDIContainer private let appDIContainer: AppDIContainer + @State private var authRepository: AuthRepository + init(appDIContainer: AppDIContainer) { self._appRouter = StateObject(wrappedValue: appDIContainer.appRouter) self.authDIContainer = appDIContainer.makeAuthDIContainer() self.appDIContainer = appDIContainer + self._authRepository = State(wrappedValue: self.authDIContainer.authRepository) } var body: some View { - switch appRouter.currentAppState { - case .splash: - SplashContainerView(appRouter: appRouter) + Group { + switch appRouter.currentAppState { + case .splash: + SplashContainerView(appRouter: appRouter) + + case .auth: + authDIContainer.makeAuthFlowView() + + case .main: + MainTabView(appDIContainer: appDIContainer) + } + } + .onOpenURL { url in + handleDeepLink(url: url) + } + } + + // MARK: - Deep Link Handler + private func handleDeepLink(url: URL) { + guard url.scheme == "codive", + url.host == "oauth", + url.path == "/callback" else { + print("Invalid deep link format: \(url)") + return + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + print("Failed to parse URL components or query items.") + return + } + + var accessToken: String? + var refreshToken: String? - case .auth: - authDIContainer.makeAuthFlowView() + for item in queryItems { + if item.name == "accessToken" { + accessToken = item.value + } else if item.name == "refreshToken" { + refreshToken = item.value + } + } + + guard let unwrappedAccessToken = accessToken, + let unwrappedRefreshToken = refreshToken else { + print("Access token or refresh token missing in deep link.") + // Potentially show an error to the user or log + return + } - case .main: - MainTabView(appDIContainer: appDIContainer) + Task { + do { + try await authRepository.saveTokens(accessToken: unwrappedAccessToken, refreshToken: unwrappedRefreshToken) + print("Tokens saved successfully from deep link.") + appRouter.navigateToMain() + } catch { + print("Failed to save tokens from deep link: \(error.localizedDescription)") + // Handle error, e.g., show an alert + } } } } diff --git a/Codive/DIContainer/AuthDIContainer.swift b/Codive/DIContainer/AuthDIContainer.swift index bc5c8618..62880406 100644 --- a/Codive/DIContainer/AuthDIContainer.swift +++ b/Codive/DIContainer/AuthDIContainer.swift @@ -17,10 +17,13 @@ final class AuthDIContainer { // MARK: - Services (Data Layer) lazy var socialAuthService: SocialAuthServiceProtocol = SocialAuthService() + lazy var authAPIService: AuthAPIServiceProtocol = AuthAPIService() // MARK: - Repositories (Domain Layer) lazy var authRepository: AuthRepository = AuthRepositoryImpl( - socialAuthService: socialAuthService + socialAuthService: socialAuthService, + authAPIService: authAPIService, + keychainTokenProvider: KeychainTokenProvider() ) // MARK: - Initializer diff --git a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift index 28167fae..1d7d29f1 100644 --- a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift +++ b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift @@ -6,6 +6,7 @@ // import Foundation +import CodiveAPI // MARK: - Auth Repository Implementation @MainActor @@ -14,14 +15,17 @@ final class AuthRepositoryImpl: AuthRepository { // MARK: - Properties private let socialAuthService: SocialAuthServiceProtocol private let authAPIService: AuthAPIServiceProtocol + private let keychainTokenProvider: TokenProvider // MARK: - Initializer init( socialAuthService: SocialAuthServiceProtocol, - authAPIService: AuthAPIServiceProtocol = AuthAPIService() + authAPIService: AuthAPIServiceProtocol = AuthAPIService(), + keychainTokenProvider: TokenProvider = KeychainTokenProvider() ) { self.socialAuthService = socialAuthService self.authAPIService = authAPIService + self.keychainTokenProvider = keychainTokenProvider } // MARK: - AuthRepository Implementation @@ -32,12 +36,6 @@ final class AuthRepositoryImpl: AuthRepository { case .apple: return await socialAuthService.appleLogin() } - - // 향후 서버 연결 시 확장될 로직: - // 1. 소셜 로그인 성공 - // 2. 서버에 사용자 정보 전송 - // 3. JWT 토큰 받아서 로컬 저장 - // 4. 최종 AuthResult 반환 } func checkAuthStatus() async -> AuthStatusResult { @@ -46,10 +44,10 @@ final class AuthRepositoryImpl: AuthRepository { func logout() async { await socialAuthService.logout() + } - // 향후 서버 연결 시 추가될 로직: - // 1. 서버에 로그아웃 요청 - // 2. 로컬 토큰 삭제 - // 3. 소셜 플랫폼 로그아웃 + func saveTokens(accessToken: String, refreshToken: String) async throws { + try KeychainManager.shared.saveAccessToken(accessToken) + try KeychainManager.shared.saveRefreshToken(refreshToken) } } diff --git a/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift b/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift index 6cf58fde..0e0d2bbc 100644 --- a/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift +++ b/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift @@ -12,8 +12,5 @@ protocol AuthRepository { func socialLogin(provider: AuthProvider) async -> AuthResult func checkAuthStatus() async -> AuthStatusResult func logout() async - - // 향후 서버 연결 시 추가될 메서드들 - // func refreshToken() async -> AuthResult - // func deleteAccount() async -> Bool + func saveTokens(accessToken: String, refreshToken: String) async throws } diff --git a/Codive/Features/Auth/Presentation/View/OnboardingView.swift b/Codive/Features/Auth/Presentation/View/OnboardingView.swift index 078cd838..b4fd0ca4 100644 --- a/Codive/Features/Auth/Presentation/View/OnboardingView.swift +++ b/Codive/Features/Auth/Presentation/View/OnboardingView.swift @@ -7,6 +7,12 @@ import SwiftUI +// MARK: - Identifiable URL Wrapper +struct IdentifiableURL: Identifiable { + let id = UUID() + let url: URL +} + // MARK: - Onboarding Data Model struct OnboardingPage: Identifiable { let id = UUID() @@ -183,6 +189,10 @@ struct OnboardingContainerView: View { errorMessage: viewModel.errorMessage, onErrorDismiss: viewModel.clearError ) + .sheet(item: $viewModel.identifiableLoginURL) { item in + SafariView(url: item.url) + .ignoresSafeArea() + } } } diff --git a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift index 29f10a98..8e471f16 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift @@ -18,6 +18,7 @@ final class OnboardingViewModel: ObservableObject { // MARK: - Published Properties @Published var isLoading = false @Published var errorMessage: String? + @Published var identifiableLoginURL: IdentifiableURL? // MARK: - Initializer init( @@ -32,59 +33,8 @@ final class OnboardingViewModel: ObservableObject { // MARK: - Actions func kakaoLoginButtonTapped() async { - isLoading = true - errorMessage = nil - - let result = await authRepository.socialLogin(provider: .kakao) // Repository 사용 - - switch result { - case .success(let user): - print("카카오 로그인 성공: \(user.id)") - - // 로그인 성공 후 회원 상태 체크 - let statusResult = await authRepository.checkAuthStatus() - - isLoading = false - - switch statusResult { - case .success(let registerStatus): - switch registerStatus { - case .notAgreed: - // 약관 동의 필요 -> 약관 동의 화면으로 이동 - print("약관 동의 필요 -> 약관 동의 화면으로 이동") - navigationRouter.navigate(to: .termsAgreement) - - case .registered: - // 약관 동의 완료 -> 메인 화면으로 이동 - print("약관 동의 완료 -> 메인 화면으로 이동") - appRouter.navigateToMain() - } - - case .failure(let error): - errorMessage = "회원 정보 확인 중 오류가 발생했습니다: \(error.localizedDescription)" - } - - case .failure(let error): - isLoading = false - - switch error { - case .cancelled: - print("카카오 로그인 취소됨") - return - case .tokenParsingError: - errorMessage = "로그인 처리 중 오류가 발생했습니다. 다시 시도해주세요." - case .keychainError: - errorMessage = "토큰 저장 중 오류가 발생했습니다. 다시 시도해주세요." - case .networkError(let message): - if message.contains("The operation couldn't be completed") || - message.contains("KakaoSDKCommon.SdkError error 0") { - print("카카오 로그인 취소됨 (네트워크 에러로 분류된 취소)") - return - } - errorMessage = error.localizedDescription - default: - errorMessage = error.localizedDescription - } + if let url = URL(string: "https://prod.clokey.store/oauth2/authorization/kakao") { + identifiableLoginURL = IdentifiableURL(url: url) } } @@ -92,7 +42,7 @@ final class OnboardingViewModel: ObservableObject { isLoading = true errorMessage = nil - let result = await authRepository.socialLogin(provider: .apple) // Repository 사용 + let result = await authRepository.socialLogin(provider: .apple) isLoading = false diff --git a/Codive/Shared/Data/Storage/KeychainManager.swift b/Codive/Shared/Data/Storage/KeychainManager.swift index 8d761e3e..a39fe691 100644 --- a/Codive/Shared/Data/Storage/KeychainManager.swift +++ b/Codive/Shared/Data/Storage/KeychainManager.swift @@ -32,25 +32,57 @@ final class KeychainManager { // MARK: - Access Token func saveAccessToken(_ token: String) throws { - try save(token, forKey: accessTokenKey) + do { + try save(token, forKey: accessTokenKey) + print("Keychain: Access token saved successfully.") + } catch { + print("Keychain: Failed to save access token: \(error.localizedDescription)") + throw error + } } func getAccessToken() throws -> String { - try get(forKey: accessTokenKey) + do { + let token = try get(forKey: accessTokenKey) + print("Keychain: Access token retrieved successfully.") + return token + } catch { + print("Keychain: Failed to retrieve access token: \(error.localizedDescription)") + throw error + } } func deleteAccessToken() throws { - try delete(forKey: accessTokenKey) + do { + try delete(forKey: accessTokenKey) + print("Keychain: Access token deleted successfully.") + } catch { + print("Keychain: Failed to delete access token: \(error.localizedDescription)") + throw error + } } // MARK: - Refresh Token func saveRefreshToken(_ token: String) throws { - try save(token, forKey: refreshTokenKey) + do { + try save(token, forKey: refreshTokenKey) + print("Keychain: Refresh token saved successfully.") + } catch { + print("Keychain: Failed to save refresh token: \(error.localizedDescription)") + throw error + } } func getRefreshToken() throws -> String { - try get(forKey: refreshTokenKey) + do { + let token = try get(forKey: refreshTokenKey) + print("Keychain: Refresh token retrieved successfully.") + return token + } catch { + print("Keychain: Failed to retrieve refresh token: \(error.localizedDescription)") + throw error + } } func deleteRefreshToken() throws { diff --git a/Codive/Shared/DesignSystem/Views/SafariView.swift b/Codive/Shared/DesignSystem/Views/SafariView.swift new file mode 100644 index 00000000..2243ab7c --- /dev/null +++ b/Codive/Shared/DesignSystem/Views/SafariView.swift @@ -0,0 +1,21 @@ +// +// SafariView.swift +// Codive +// +// Created by Hrepay on 2026/01/11. +// + +import SwiftUI +import SafariServices + +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { + let safariViewController = SFSafariViewController(url: url) + return safariViewController + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) { + } +} From 43262093bbed1d6d8e1177e8080d85e95cd6037a Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:43:07 +0900 Subject: [PATCH 06/37] =?UTF-8?q?[#39]=20CodvieAPI=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tuist/Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 916196dd..bc98135d 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "b07f082c006752c9dec83e774dda68b17fbb40d9" + "revision" : "62672e060cb64fbe3bb5f4b9760f05c58d3a99c1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kakao/kakao-ios-sdk", "state" : { - "revision" : "bebbd50e40843f5f54d2fe87d4f415771736f8ab", - "version" : "2.27.0" + "revision" : "1a2b530921ab9d1f4385ced84109b7a86d42cff8", + "version" : "2.27.1" } }, { From d3e6a919e8eddff05ecd0a26923bd0be5f44ff5f Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:56:59 +0900 Subject: [PATCH 07/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?API=201=EC=B0=A8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/ClosetDIContainer.swift | 7 +- .../Auth/Data/SocialAuthService.swift | 11 + .../Closet/Data/ClothAPIService.swift | 412 ++++++++++++++++++ .../Data/DataSources/ClothDataSource.swift | 86 +++- .../Repositories/ClothRepositoryImpl.swift | 42 +- 5 files changed, 520 insertions(+), 38 deletions(-) create mode 100644 Codive/Features/Closet/Data/ClothAPIService.swift diff --git a/Codive/DIContainer/ClosetDIContainer.swift b/Codive/DIContainer/ClosetDIContainer.swift index 50bbeee1..5ea0dac8 100644 --- a/Codive/DIContainer/ClosetDIContainer.swift +++ b/Codive/DIContainer/ClosetDIContainer.swift @@ -20,9 +20,14 @@ final class ClosetDIContainer { self.navigationRouter = navigationRouter } + // MARK: - Services + private lazy var clothAPIService: ClothAPIServiceProtocol = { + return ClothAPIService() + }() + // MARK: - DataSources private lazy var clothDataSource: ClothDataSource = { - return DefaultClothDataSource() + return DefaultClothDataSource(apiService: clothAPIService) }() // MARK: - Repositories diff --git a/Codive/Features/Auth/Data/SocialAuthService.swift b/Codive/Features/Auth/Data/SocialAuthService.swift index 0ea1b943..b61c825e 100644 --- a/Codive/Features/Auth/Data/SocialAuthService.swift +++ b/Codive/Features/Auth/Data/SocialAuthService.swift @@ -75,6 +75,17 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol { do { try KeychainManager.shared.saveAccessToken(accessToken) try KeychainManager.shared.saveRefreshToken(refreshToken) + + // 🔑 디버그용 토큰 출력 + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("🔑 [로그인 성공] JWT 토큰 저장 완료") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📌 Access Token:") + print(accessToken) + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📌 Refresh Token:") + print(refreshToken) + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") // 성공 시 임시 사용자 정보 반환 (나중에 서버에서 받아야 함) let authUser = AuthUser( diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift new file mode 100644 index 00000000..a3f6c293 --- /dev/null +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -0,0 +1,412 @@ +// +// ClothAPIService.swift +// Codive +// +// Created by Assistant on 1/12/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime +import CryptoKit + +// MARK: - ClothAPIService Protocol + +protocol ClothAPIServiceProtocol { + /// Presigned URL 발급 요청 + func getPresignedUrls(for images: [Data]) async throws -> [PresignedUrlInfo] + + /// S3에 이미지 업로드 + func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String) async throws + + /// 옷 생성 API 호출 + func createClothes(requests: [ClothCreateAPIRequest]) async throws -> [Int64] +} + +// MARK: - Supporting Types + +/// Presigned URL 정보 +struct PresignedUrlInfo { + let presignedUrl: String // 전체 presigned URL (업로드용) + let finalUrl: String // 최종 S3 URL (쿼리 파라미터 제거됨) + let md5Hash: String // MD5 해시 (업로드 시 헤더에 필요) +} + +/// 옷 생성 요청 데이터 +struct ClothCreateAPIRequest { + let clothImageUrl: String // S3에 업로드된 이미지 URL + let clothUrl: String? // 구매 링크 (선택) + let name: String? // 옷 이름 (선택) + let brand: String? // 브랜드 (선택) + let season: Season // 계절 (필수) + let categoryId: Int64 // 카테고리 ID (필수) +} + +// MARK: - ClothAPIService Implementation + +final class ClothAPIService: ClothAPIServiceProtocol { + + // MARK: - Properties + + private let client: Client + private let jsonDecoder: JSONDecoder + + // MARK: - Initializer + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + + // 서버의 timeStamp 형식 (나노초 포함) 처리를 위한 커스텀 DateFormatter + self.jsonDecoder = JSONDecoder() + self.jsonDecoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + // 여러 ISO8601 형식 시도 + let formatters: [ISO8601DateFormatter] = { + let formatter1 = ISO8601DateFormatter() + formatter1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let formatter2 = ISO8601DateFormatter() + formatter2.formatOptions = [.withInternetDateTime] + + return [formatter1, formatter2] + }() + + for formatter in formatters { + if let date = formatter.date(from: dateString) { + return date + } + } + + // ISO8601로 파싱 실패 시 DateFormatter 사용 + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + if let date = dateFormatter.date(from: dateString) { + return date + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "날짜 형식을 파싱할 수 없습니다: \(dateString)" + ) + } + } + + // MARK: - Public Methods + + /// Step 1: Presigned URL 발급 요청 + func getPresignedUrls(for images: [Data]) async throws -> [PresignedUrlInfo] { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📤 [API] Presigned URL 발급 요청 시작") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + // 각 이미지에 대한 MD5 해시 계산 및 payload 생성 + let payloads = images.enumerated().map { index, imageData in + let md5Hash = calculateMD5(from: imageData) + print(" 📦 이미지 \(index + 1):") + print(" - 크기: \(imageData.count) bytes") + print(" - MD5: \(md5Hash)") + return ( + payload: Components.Schemas.ClothImagesUploadRequestPayload( + fileExtension: .JPEG, + md5Hashes: md5Hash + ), + md5Hash: md5Hash + ) + } + + // API 요청 + let requestBody = Components.Schemas.ClothImagesUploadRequest( + payloads: payloads.map { $0.payload } + ) + + print(" 📨 요청 Body:") + print(" - payloads 개수: \(payloads.count)") + for (index, payload) in payloads.enumerated() { + print(" - [\(index)] fileExtension: JPEG, md5Hashes: \(payload.md5Hash)") + } + + let input = Operations.Cloth_getClothUploadPresignedUrl.Input( + body: .json(requestBody) + ) + + do { + let response = try await client.Cloth_getClothUploadPresignedUrl(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + print(" ✅ 응답 수신 (성공)") + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 응답 Body: \(jsonString)") + } + + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseClothImagesPresignedUrlResponse.self, + from: data + ) + + print(" 📋 파싱 결과:") + print(" - isSuccess: \(decoded.isSuccess ?? false)") + print(" - code: \(decoded.code ?? "nil")") + print(" - message: \(decoded.message ?? "nil")") + print(" - urls 개수: \(decoded.result?.urls?.count ?? 0)") + + guard let urls = decoded.result?.urls, urls.count == images.count else { + print(" ❌ URL 개수 불일치! 요청: \(images.count), 응답: \(decoded.result?.urls?.count ?? 0)") + throw ClothAPIError.presignedUrlMismatch + } + + // Presigned URL과 MD5 해시를 함께 반환 + let result = zip(urls, payloads).map { url, payloadInfo in + let finalUrl = extractFinalUrl(from: url) + print(" - Presigned URL: \(url.prefix(80))...") + print(" - Final URL: \(finalUrl)") + return PresignedUrlInfo( + presignedUrl: url, + finalUrl: finalUrl, + md5Hash: payloadInfo.md5Hash + ) + } + + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + return result + + case .undocumented(statusCode: let code, let payload): + print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") + if let body = payload.body { + let data = try await Data(collecting: body, upTo: .max) + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 에러 응답 Body: \(jsonString)") + } + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw ClothAPIError.serverError(statusCode: code, message: "Presigned URL 발급 실패") + } + } catch { + print(" ❌ 예외 발생: \(error)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw error + } + } + + /// Step 1.5: S3에 이미지 직접 업로드 + func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String) async throws { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📤 [S3] 이미지 업로드 시작") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + guard let url = URL(string: presignedUrl) else { + print(" ❌ URL 파싱 실패: \(presignedUrl)") + throw ClothAPIError.invalidUrl + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") + request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") + request.httpBody = imageData + + print(" 📨 요청 정보:") + print(" - Method: PUT") + print(" - URL: \(presignedUrl)") + print(" 📋 요청 헤더:") + print(" - Content-Type: image/jpeg") + print(" - Content-MD5: \(contentMD5)") + print(" 📦 요청 Body:") + print(" - Image Size: \(imageData.count) bytes (\(String(format: "%.2f", Double(imageData.count) / 1024.0)) KB)") + + do { + let (responseData, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + print(" ❌ HTTP 응답 아님") + throw ClothAPIError.invalidResponse + } + + print(" 📩 응답 수신:") + print(" - 상태코드: \(httpResponse.statusCode)") + print(" 📋 응답 헤더:") + for (key, value) in httpResponse.allHeaderFields { + print(" - \(key): \(value)") + } + + if !responseData.isEmpty { + if let responseString = String(data: responseData, encoding: .utf8) { + print(" 📩 응답 Body: \(responseString)") + } else { + print(" 📩 응답 Body: (바이너리 데이터 \(responseData.count) bytes)") + } + } else { + print(" 📩 응답 Body: (비어있음)") + } + + guard (200...299).contains(httpResponse.statusCode) else { + print(" ❌ S3 업로드 실패!") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw ClothAPIError.s3UploadFailed(statusCode: httpResponse.statusCode) + } + + print(" ✅ S3 업로드 성공!") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + } catch { + print(" ❌ 예외 발생: \(error)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw error + } + } + + /// Step 2: 옷 생성 API 호출 + func createClothes(requests: [ClothCreateAPIRequest]) async throws -> [Int64] { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📤 [API] 옷 생성 요청 시작") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + // ClothCreateAPIRequest → Components.Schemas.ClothCreateRequest 변환 + let apiRequests = requests.enumerated().map { index, request in + print(" 📦 옷 \(index + 1):") + print(" - clothImageUrl: \(request.clothImageUrl)") + print(" - clothUrl: \(request.clothUrl ?? "nil")") + print(" - name: \(request.name ?? "nil")") + print(" - brand: \(request.brand ?? "nil")") + print(" - season: \(request.season.rawValue)") + print(" - categoryId: \(request.categoryId)") + + return Components.Schemas.ClothCreateRequest( + clothImageUrl: request.clothImageUrl, + clothUrl: request.clothUrl, + name: request.name, + brand: request.brand, + season: mapSeasonToAPI(request.season), + categoryId: request.categoryId + ) + } + + let requestBody = Components.Schemas.ClothCreateRequests(content: apiRequests) + + // JSON으로 변환해서 출력 + if let jsonData = try? JSONEncoder().encode(requestBody), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(" 📨 요청 Body (JSON): \(jsonString)") + } + + let input = Operations.Cloth_createClothes.Input( + body: .json(requestBody) + ) + + do { + let response = try await client.Cloth_createClothes(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + print(" ✅ 응답 수신 (성공)") + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 응답 Body: \(jsonString)") + } + + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseClothCreateResponse.self, + from: data + ) + + print(" 📋 파싱 결과:") + print(" - isSuccess: \(decoded.isSuccess ?? false)") + print(" - code: \(decoded.code ?? "nil")") + print(" - message: \(decoded.message ?? "nil")") + print(" - clothIds: \(decoded.result?.clothIds ?? [])") + + guard let clothIds = decoded.result?.clothIds else { + print(" ❌ clothIds가 nil!") + throw ClothAPIError.noClothIdsReturned + } + + print(" ✅ 옷 생성 완료! IDs: \(clothIds)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + return clothIds + + case .undocumented(statusCode: let code, let payload): + print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") + if let body = payload.body { + let data = try await Data(collecting: body, upTo: .max) + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 에러 응답 Body: \(jsonString)") + } + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw ClothAPIError.serverError(statusCode: code, message: "옷 생성 실패") + } + } catch { + print(" ❌ 예외 발생: \(error)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw error + } + } + + // MARK: - Private Methods + + /// MD5 해시 계산 (Base64 인코딩) + private func calculateMD5(from data: Data) -> String { + let digest = Insecure.MD5.hash(data: data) + return Data(digest).base64EncodedString() + } + + /// Presigned URL에서 최종 S3 URL 추출 (쿼리 파라미터 제거) + private func extractFinalUrl(from presignedUrl: String) -> String { + guard let url = URL(string: presignedUrl), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return presignedUrl + } + components.query = nil + return components.string ?? presignedUrl + } + + /// Season enum → API enum 변환 + private func mapSeasonToAPI(_ season: Season) -> Components.Schemas.ClothCreateRequest.seasonPayload { + switch season { + case .spring: return .SPRING + case .summer: return .SUMMER + case .fall: return .FALL + case .winter: return .WINTER + } + } +} + +// MARK: - ClothAPIError + +enum ClothAPIError: LocalizedError { + case presignedUrlMismatch + case invalidUrl + case invalidResponse + case s3UploadFailed(statusCode: Int) + case noClothIdsReturned + case serverError(statusCode: Int, message: String) + + var errorDescription: String? { + switch self { + case .presignedUrlMismatch: + return "Presigned URL 개수가 요청한 이미지 개수와 일치하지 않습니다." + case .invalidUrl: + return "유효하지 않은 URL입니다." + case .invalidResponse: + return "서버 응답을 처리할 수 없습니다." + case .s3UploadFailed(let statusCode): + return "S3 업로드 실패 (상태 코드: \(statusCode))" + case .noClothIdsReturned: + return "서버에서 생성된 옷 ID를 반환하지 않았습니다." + case .serverError(let statusCode, let message): + return "서버 오류 (\(statusCode)): \(message)" + } + } +} diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index 738daaa0..e8bdbb76 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -8,9 +8,12 @@ import Foundation // MARK: - ClothDataSource Protocol + protocol ClothDataSource { func fetchClothItems(category: String?) async throws -> [ProductItem] - func uploadClothes(_ dtos: [ClothRequestDTO]) async throws -> [ClothResponseDTO] + + /// 옷 저장 (Presigned URL 발급 → S3 업로드 → 옷 생성 API 호출) + func saveClothes(inputs: [ClothInput], images: [Data]) async throws -> [Cloth] // MyCloset 전용 메서드 func fetchMyClosetClothItems( @@ -24,9 +27,21 @@ protocol ClothDataSource { } // MARK: - DefaultClothDataSource + final class DefaultClothDataSource: ClothDataSource { + // MARK: - Properties + + private let apiService: ClothAPIServiceProtocol + + // MARK: - Initializer + + init(apiService: ClothAPIServiceProtocol = ClothAPIService()) { + self.apiService = apiService + } + // MARK: - Mock Data + private let mockClothItems: [ProductItem] = [ ProductItem(id: 1, imageName: "sample1", isTodayCloth: true, brand: "Nike", name: "에어포스 1"), ProductItem(id: 2, imageName: "sample2", isTodayCloth: true, brand: "Adidas", name: "후디"), @@ -65,6 +80,7 @@ final class DefaultClothDataSource: ClothDataSource { ] // MARK: - Methods + func fetchClothItems(category: String?) async throws -> [ProductItem] { // TODO: 실제 API 호출로 대체 @@ -79,9 +95,58 @@ final class DefaultClothDataSource: ClothDataSource { return mockClothItems } - func uploadClothes(_ dtos: [ClothRequestDTO]) async throws -> [ClothResponseDTO] { - // TODO: 서버 API 호출 구현 - fatalError("Server API not implemented yet") + /// 옷 저장 (전체 흐름: Presigned URL → S3 업로드 → 옷 생성) + func saveClothes(inputs: [ClothInput], images: [Data]) async throws -> [Cloth] { + guard inputs.count == images.count else { + throw ClothDataSourceError.inputImageCountMismatch + } + + // Step 1: Presigned URL 발급 + print("📤 [ClothDataSource] Step 1: Presigned URL 발급 요청...") + let presignedInfos = try await apiService.getPresignedUrls(for: images) + print("✅ [ClothDataSource] Presigned URL \(presignedInfos.count)개 발급 완료") + + // Step 2: S3에 이미지 업로드 + print("📤 [ClothDataSource] Step 2: S3 업로드 시작...") + for (index, (imageData, presignedInfo)) in zip(images, presignedInfos).enumerated() { + print(" - 이미지 \(index + 1)/\(images.count) 업로드 중...") + try await apiService.uploadImageToS3( + presignedUrl: presignedInfo.presignedUrl, + imageData: imageData, + contentMD5: presignedInfo.md5Hash + ) + } + print("✅ [ClothDataSource] S3 업로드 완료") + + // Step 3: 옷 생성 API 호출 + print("📤 [ClothDataSource] Step 3: 옷 생성 API 호출...") + let createRequests = zip(inputs, presignedInfos).map { input, presignedInfo in + ClothCreateAPIRequest( + clothImageUrl: presignedInfo.finalUrl, + clothUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, + name: input.name.isEmpty ? nil : input.name, + brand: input.brand.isEmpty ? nil : input.brand, + season: input.seasons.first ?? .spring, // 첫 번째 계절 사용 + categoryId: Int64(input.categoryId ?? 0) + ) + } + + let clothIds = try await apiService.createClothes(requests: createRequests) + print("✅ [ClothDataSource] 옷 생성 완료: \(clothIds)") + + // 결과 변환: clothIds + inputs → Cloth 엔티티 + return zip(clothIds, zip(inputs, presignedInfos)).map { clothId, pair in + let (input, presignedInfo) = pair + return Cloth( + id: Int(clothId), + imageUrl: presignedInfo.finalUrl, + name: input.name.isEmpty ? nil : input.name, + brand: input.brand.isEmpty ? nil : input.brand, + purchaseUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, + categoryId: input.categoryId, + seasons: input.seasons + ) + } } func fetchMyClosetClothItems( @@ -129,3 +194,16 @@ final class DefaultClothDataSource: ClothDataSource { print("Mock: \(clothIds) 삭제 성공") } } + +// MARK: - ClothDataSourceError + +enum ClothDataSourceError: LocalizedError { + case inputImageCountMismatch + + var errorDescription: String? { + switch self { + case .inputImageCountMismatch: + return "입력 데이터와 이미지 개수가 일치하지 않습니다." + } + } +} diff --git a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift index dfa31b16..4e3efd89 100644 --- a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift +++ b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift @@ -8,55 +8,31 @@ import Foundation // MARK: - ClothRepositoryImpl + final class ClothRepositoryImpl: ClothRepository { // MARK: - Properties + private let dataSource: ClothDataSource // MARK: - Initializer + init(dataSource: ClothDataSource) { self.dataSource = dataSource } // MARK: - Methods + func fetchClothItems(category: String?) async throws -> [ProductItem] { return try await dataSource.fetchClothItems(category: category) } func saveClothes(_ inputs: [ClothInput], images: [Data]) async throws -> [Cloth] { - // TODO: 서버 연결 시 아래 로직으로 구현 - // 1. ClothInput + Data → ClothRequestDTO 변환 - // 2. dataSource.uploadClothes(dtos) 호출 - // 3. 서버 응답 ClothResponseDTO → Cloth Entity 변환 후 반환 - - // 임시 구현: 더미 데이터 반환 - return inputs.enumerated().map { index, input in - Cloth( - id: index, - imageUrl: "", // 서버 연결 시 Presigned URL로 업로드 후 받은 URL - name: input.name.isEmpty ? nil : input.name, - brand: input.brand.isEmpty ? nil : input.brand, - purchaseUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, - categoryId: input.categoryId, - seasons: input.seasons - ) - } - - /* 서버 연결 시 실제 구현: - let dtos = zip(inputs, images).map { input, imageData in - ClothRequestDTO( - image: imageData, - name: input.name.isEmpty ? nil : input.name, - brand: input.brand.isEmpty ? nil : input.brand, - purchaseUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, - categoryId: input.categoryId, - seasons: input.seasons.map { $0.rawValue } - ) - } - - let responseDTOs = try await dataSource.uploadClothes(dtos) - return responseDTOs.map { $0.toEntity() } - */ + // DataSource를 통해 전체 흐름 실행: + // 1. Presigned URL 발급 + // 2. S3 업로드 + // 3. 옷 생성 API 호출 + return try await dataSource.saveClothes(inputs: inputs, images: images) } func fetchMyClosetClothItems( From 65b8ee66ddd40816e22035fbacc48dd3db2cbf17 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:19:29 +0900 Subject: [PATCH 08/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C/=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20AP?= =?UTF-8?q?I=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Application/AppRootView.swift | 12 ++ .../Closet/Data/ClothAPIService.swift | 200 ++++++++++++++++++ .../Data/DataSources/ClothDataSource.swift | 53 +++++ .../Repositories/ClothRepositoryImpl.swift | 20 ++ .../Domain/Protocols/ClothRepository.swift | 11 + 5 files changed, 296 insertions(+) diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index d51ff067..62cff645 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -78,6 +78,18 @@ struct AppRootView: View { Task { do { try await authRepository.saveTokens(accessToken: unwrappedAccessToken, refreshToken: unwrappedRefreshToken) + + // 🔑 디버그용 토큰 출력 + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("🔑 [로그인 성공] JWT 토큰 저장 완료") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📌 Access Token:") + print(unwrappedAccessToken) + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📌 Refresh Token:") + print(unwrappedRefreshToken) + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("Tokens saved successfully from deep link.") appRouter.navigateToMain() } catch { diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index a3f6c293..6d0129a0 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -21,6 +21,17 @@ protocol ClothAPIServiceProtocol { /// 옷 생성 API 호출 func createClothes(requests: [ClothCreateAPIRequest]) async throws -> [Int64] + + /// 옷 목록 조회 + func fetchClothes( + lastClothId: Int64?, + size: Int32, + categoryId: Int64?, + seasons: [Season] + ) async throws -> ClothListResult + + /// 옷 상세 조회 + func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult } // MARK: - Supporting Types @@ -42,6 +53,30 @@ struct ClothCreateAPIRequest { let categoryId: Int64 // 카테고리 ID (필수) } +/// 옷 목록 조회 결과 +struct ClothListResult { + let clothes: [ClothListItem] + let isLast: Bool +} + +/// 옷 목록 아이템 (목록 조회용) +struct ClothListItem { + let clothId: Int64 + let imageUrl: String + let brand: String? + let name: String? +} + +/// 옷 상세 조회 결과 +struct ClothDetailResult { + let clothImageUrl: String + let parentCategory: String? + let category: String? + let name: String? + let brand: String? + let clothUrl: String? +} + // MARK: - ClothAPIService Implementation final class ClothAPIService: ClothAPIServiceProtocol { @@ -381,6 +416,171 @@ final class ClothAPIService: ClothAPIServiceProtocol { case .winter: return .WINTER } } + + /// Season enum → Query param enum 변환 + private func mapSeasonToQueryParam(_ season: Season) -> Operations.Cloth_getClothes.Input.Query.seasonsPayloadPayload { + switch season { + case .spring: return .SPRING + case .summer: return .SUMMER + case .fall: return .FALL + case .winter: return .WINTER + } + } + + // MARK: - 옷 목록 조회 + + func fetchClothes( + lastClothId: Int64?, + size: Int32, + categoryId: Int64?, + seasons: [Season] + ) async throws -> ClothListResult { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📤 [API] 옷 목록 조회 요청") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print(" 📋 요청 파라미터:") + print(" - lastClothId: \(lastClothId?.description ?? "nil")") + print(" - size: \(size)") + print(" - categoryId: \(categoryId?.description ?? "nil")") + print(" - seasons: \(seasons.map { $0.rawValue })") + + let seasonsParam: [Operations.Cloth_getClothes.Input.Query.seasonsPayloadPayload]? = seasons.isEmpty ? nil : seasons.map { mapSeasonToQueryParam($0) } + + let input = Operations.Cloth_getClothes.Input( + query: .init( + lastClothId: lastClothId, + size: size, + direction: .DESC, + categoryId: categoryId, + seasons: seasonsParam + ) + ) + + do { + let response = try await client.Cloth_getClothes(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + print(" ✅ 응답 수신 (성공)") + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 응답 Body: \(jsonString.prefix(500))...") + } + + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseClothListResponse.self, + from: data + ) + + print(" 📋 파싱 결과:") + print(" - isSuccess: \(decoded.isSuccess ?? false)") + print(" - code: \(decoded.code ?? "nil")") + print(" - 옷 개수: \(decoded.result?.content?.count ?? 0)") + print(" - isLast: \(decoded.result?.isLast ?? false)") + + let clothes = decoded.result?.content?.map { item in + ClothListItem( + clothId: item.clothId ?? 0, + imageUrl: item.ImageUrl ?? "", // API 응답의 대문자 I 주의 + brand: item.brand, + name: item.name + ) + } ?? [] + + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + return ClothListResult( + clothes: clothes, + isLast: decoded.result?.isLast ?? true + ) + + case .undocumented(statusCode: let code, let payload): + print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") + if let body = payload.body { + let data = try await Data(collecting: body, upTo: .max) + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 에러 응답 Body: \(jsonString)") + } + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw ClothAPIError.serverError(statusCode: code, message: "옷 목록 조회 실패") + } + } catch { + print(" ❌ 예외 발생: \(error)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw error + } + } + + // MARK: - 옷 상세 조회 + + func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📤 [API] 옷 상세 조회 요청") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print(" 📋 요청 파라미터: clothId = \(clothId)") + + let input = Operations.Cloth_getClothDetails.Input( + path: .init(clothId: clothId) + ) + + do { + let response = try await client.Cloth_getClothDetails(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + print(" ✅ 응답 수신 (성공)") + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 응답 Body: \(jsonString)") + } + + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseClothDetailsResponse.self, + from: data + ) + + print(" 📋 파싱 결과:") + print(" - isSuccess: \(decoded.isSuccess ?? false)") + print(" - code: \(decoded.code ?? "nil")") + print(" - name: \(decoded.result?.name ?? "nil")") + print(" - brand: \(decoded.result?.brand ?? "nil")") + print(" - category: \(decoded.result?.category ?? "nil")") + + guard let result = decoded.result else { + throw ClothAPIError.serverError(statusCode: 0, message: "result가 nil입니다") + } + + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + return ClothDetailResult( + clothImageUrl: result.clothImageUrl ?? "", + parentCategory: result.parentCategory, + category: result.category, + name: result.name, + brand: result.brand, + clothUrl: result.clothUrl + ) + + case .undocumented(statusCode: let code, let payload): + print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") + if let body = payload.body { + let data = try await Data(collecting: body, upTo: .max) + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 에러 응답 Body: \(jsonString)") + } + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw ClothAPIError.serverError(statusCode: code, message: "옷 상세 조회 실패") + } + } catch { + print(" ❌ 예외 발생: \(error)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw error + } + } } // MARK: - ClothAPIError diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index e8bdbb76..77571c77 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -22,6 +22,17 @@ protocol ClothDataSource { seasons: Set, searchText: String? ) async throws -> [Cloth] + + /// 옷 목록 조회 (API 연동) + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) + + /// 옷 상세 조회 (API 연동) + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult func deleteClothItems(_ clothIds: [Int]) async throws } @@ -193,6 +204,48 @@ final class DefaultClothDataSource: ClothDataSource { // Mock 환경: 성공만 반환 print("Mock: \(clothIds) 삭제 성공") } + + // MARK: - API 연동 메서드 + + /// 옷 목록 조회 (실제 API 호출) + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) { + print("📤 [ClothDataSource] 옷 목록 조회 API 호출...") + + let result = try await apiService.fetchClothes( + lastClothId: lastClothId.map { Int64($0) }, + size: Int32(size), + categoryId: categoryId.map { Int64($0) }, + seasons: Array(seasons) + ) + + // ClothListItem → Cloth 변환 + let clothes = result.clothes.map { item in + Cloth( + id: Int(item.clothId), + imageUrl: item.imageUrl, + name: item.name, + brand: item.brand + ) + } + + print("✅ [ClothDataSource] 옷 목록 조회 완료: \(clothes.count)개, isLast: \(result.isLast)") + return (clothes: clothes, isLast: result.isLast) + } + + /// 옷 상세 조회 (실제 API 호출) + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult { + print("📤 [ClothDataSource] 옷 상세 조회 API 호출... clothId: \(clothId)") + + let result = try await apiService.fetchClothDetails(clothId: Int64(clothId)) + + print("✅ [ClothDataSource] 옷 상세 조회 완료: \(result.name ?? "이름없음")") + return result + } } // MARK: - ClothDataSourceError diff --git a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift index 4e3efd89..0dfd5513 100644 --- a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift +++ b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift @@ -52,4 +52,24 @@ final class ClothRepositoryImpl: ClothRepository { func deleteClothItems(_ clothIds: [Int]) async throws { try await dataSource.deleteClothItems(clothIds) } + + // MARK: - API 연동 메서드 + + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) { + return try await dataSource.fetchClothList( + lastClothId: lastClothId, + size: size, + categoryId: categoryId, + seasons: seasons + ) + } + + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult { + return try await dataSource.fetchClothDetail(clothId: clothId) + } } diff --git a/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift b/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift index 3e239392..ead34b68 100644 --- a/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift +++ b/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift @@ -19,6 +19,17 @@ protocol ClothRepository { seasons: Set, searchText: String? ) async throws -> [Cloth] + + /// 옷 목록 조회 (API 연동) + func fetchClothList( + lastClothId: Int?, + size: Int, + categoryId: Int?, + seasons: Set + ) async throws -> (clothes: [Cloth], isLast: Bool) + + /// 옷 상세 조회 (API 연동) + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult func deleteClothItems(_ clothIds: [Int]) async throws } From 8b18f7b92a1c15c639d170105f7efe301ee8816a Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:22:52 +0900 Subject: [PATCH 09/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Closet/Data/ClothAPIService.swift | 131 ++++++++++++++++++ .../Data/DataSources/ClothDataSource.swift | 24 ++++ .../Repositories/ClothRepositoryImpl.swift | 8 ++ .../Domain/Protocols/ClothRepository.swift | 6 + 4 files changed, 169 insertions(+) diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index 6d0129a0..d5e1155b 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -32,6 +32,12 @@ protocol ClothAPIServiceProtocol { /// 옷 상세 조회 func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult + + /// 옷 수정 + func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws + + /// 옷 삭제 + func deleteCloth(clothId: Int64) async throws } // MARK: - Supporting Types @@ -77,6 +83,16 @@ struct ClothDetailResult { let clothUrl: String? } +/// 옷 수정 요청 데이터 +struct ClothUpdateAPIRequest { + let clothImageUrl: String? // 이미지 URL (변경 시) + let clothUrl: String? // 구매 링크 (선택) + let name: String? // 옷 이름 (선택) + let brand: String? // 브랜드 (선택) + let season: Season? // 계절 (선택) + let categoryId: Int64? // 카테고리 ID (선택) +} + // MARK: - ClothAPIService Implementation final class ClothAPIService: ClothAPIServiceProtocol { @@ -581,6 +597,121 @@ final class ClothAPIService: ClothAPIServiceProtocol { throw error } } + + // MARK: - 옷 수정 + + func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📤 [API] 옷 수정 요청") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print(" 📋 요청 파라미터:") + print(" - clothId: \(clothId)") + print(" - clothImageUrl: \(request.clothImageUrl ?? "nil")") + print(" - name: \(request.name ?? "nil")") + print(" - brand: \(request.brand ?? "nil")") + print(" - season: \(request.season?.rawValue ?? "nil")") + print(" - categoryId: \(request.categoryId?.description ?? "nil")") + + // Season → API enum 변환 + let seasonParam: Components.Schemas.ClothUpdateRequest.seasonPayload? = request.season.map { season in + switch season { + case .spring: return .SPRING + case .summer: return .SUMMER + case .fall: return .FALL + case .winter: return .WINTER + } + } + + let requestBody = Components.Schemas.ClothUpdateRequest( + clothImageUrl: request.clothImageUrl, + clothUrl: request.clothUrl, + name: request.name, + brand: request.brand, + season: seasonParam, + categoryId: request.categoryId + ) + + let input = Operations.Cloth_updateCloth.Input( + path: .init(clothId: clothId), + body: .json(requestBody) + ) + + do { + let response = try await client.Cloth_updateCloth(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + print(" ✅ 응답 수신 (성공)") + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 응답 Body: \(jsonString)") + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + case .undocumented(statusCode: let code, let payload): + print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") + if let body = payload.body { + let data = try await Data(collecting: body, upTo: .max) + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 에러 응답 Body: \(jsonString)") + } + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw ClothAPIError.serverError(statusCode: code, message: "옷 수정 실패") + } + } catch { + print(" ❌ 예외 발생: \(error)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw error + } + } + + // MARK: - 옷 삭제 + + func deleteCloth(clothId: Int64) async throws { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📤 [API] 옷 삭제 요청") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print(" 📋 요청 파라미터: clothId = \(clothId)") + + let input = Operations.Cloth_deleteCloth.Input( + path: .init(clothId: clothId) + ) + + do { + let response = try await client.Cloth_deleteCloth(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + print(" ✅ 응답 수신 (성공)") + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 응답 Body: \(jsonString)") + } + print(" ✅ 옷 삭제 완료!") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + case .undocumented(statusCode: let code, let payload): + print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") + if let body = payload.body { + let data = try await Data(collecting: body, upTo: .max) + if let jsonString = String(data: data, encoding: .utf8) { + print(" 📩 에러 응답 Body: \(jsonString)") + } + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw ClothAPIError.serverError(statusCode: code, message: "옷 삭제 실패") + } + } catch { + print(" ❌ 예외 발생: \(error)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + throw error + } + } } // MARK: - ClothAPIError diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index 77571c77..a2c52bd4 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -33,6 +33,12 @@ protocol ClothDataSource { /// 옷 상세 조회 (API 연동) func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult + + /// 옷 수정 (API 연동) + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws + + /// 옷 삭제 (API 연동) - 단일 + func deleteCloth(clothId: Int) async throws func deleteClothItems(_ clothIds: [Int]) async throws } @@ -246,6 +252,24 @@ final class DefaultClothDataSource: ClothDataSource { print("✅ [ClothDataSource] 옷 상세 조회 완료: \(result.name ?? "이름없음")") return result } + + /// 옷 수정 (실제 API 호출) + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws { + print("📤 [ClothDataSource] 옷 수정 API 호출... clothId: \(clothId)") + + try await apiService.updateCloth(clothId: Int64(clothId), request: request) + + print("✅ [ClothDataSource] 옷 수정 완료") + } + + /// 옷 삭제 (실제 API 호출) - 단일 + func deleteCloth(clothId: Int) async throws { + print("📤 [ClothDataSource] 옷 삭제 API 호출... clothId: \(clothId)") + + try await apiService.deleteCloth(clothId: Int64(clothId)) + + print("✅ [ClothDataSource] 옷 삭제 완료") + } } // MARK: - ClothDataSourceError diff --git a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift index 0dfd5513..36562861 100644 --- a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift +++ b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift @@ -72,4 +72,12 @@ final class ClothRepositoryImpl: ClothRepository { func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult { return try await dataSource.fetchClothDetail(clothId: clothId) } + + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws { + try await dataSource.updateCloth(clothId: clothId, request: request) + } + + func deleteCloth(clothId: Int) async throws { + try await dataSource.deleteCloth(clothId: clothId) + } } diff --git a/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift b/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift index ead34b68..798ba373 100644 --- a/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift +++ b/Codive/Features/Closet/Domain/Protocols/ClothRepository.swift @@ -30,6 +30,12 @@ protocol ClothRepository { /// 옷 상세 조회 (API 연동) func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult + + /// 옷 수정 (API 연동) + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws + + /// 옷 삭제 (API 연동) - 단일 + func deleteCloth(clothId: Int) async throws func deleteClothItems(_ clothIds: [Int]) async throws } From 53171422a438e0a322309f7b8c10975cb1a4f030 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:20:48 +0900 Subject: [PATCH 10/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?API=202=EC=B0=A8=20=EA=B5=AC=ED=98=84=20-=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=84=EC=8B=9C=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Closet/Data/ClothAPIService.swift | 814 +++++------------- .../Data/Constants/CategoryConstants.swift | 105 ++- .../Closet/Domain/Entities/CategoryItem.swift | 7 +- .../Presentation/View/ClothAddView.swift | 2 +- .../Presentation/View/ClothEditView.swift | 2 +- .../View/myCloth/MyClosetView.swift | 10 +- .../ViewModel/ClothAddViewModel.swift | 10 +- .../ViewModel/ClothEditViewModel.swift | 12 +- .../ViewModel/MyClosetViewModel.swift | 2 +- .../Sheets/CustomCategoryBottomSheet.swift | 14 +- 10 files changed, 357 insertions(+), 621 deletions(-) diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index d5e1155b..7c42aacc 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -13,59 +13,37 @@ import CryptoKit // MARK: - ClothAPIService Protocol protocol ClothAPIServiceProtocol { - /// Presigned URL 발급 요청 func getPresignedUrls(for images: [Data]) async throws -> [PresignedUrlInfo] - - /// S3에 이미지 업로드 func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String) async throws - - /// 옷 생성 API 호출 func createClothes(requests: [ClothCreateAPIRequest]) async throws -> [Int64] - - /// 옷 목록 조회 - func fetchClothes( - lastClothId: Int64?, - size: Int32, - categoryId: Int64?, - seasons: [Season] - ) async throws -> ClothListResult - - /// 옷 상세 조회 + func fetchClothes(lastClothId: Int64?, size: Int32, categoryId: Int64?, seasons: [Season]) async throws -> ClothListResult func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult - - /// 옷 수정 func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws - - /// 옷 삭제 func deleteCloth(clothId: Int64) async throws } // MARK: - Supporting Types -/// Presigned URL 정보 struct PresignedUrlInfo { - let presignedUrl: String // 전체 presigned URL (업로드용) - let finalUrl: String // 최종 S3 URL (쿼리 파라미터 제거됨) - let md5Hash: String // MD5 해시 (업로드 시 헤더에 필요) + let presignedUrl: String + let finalUrl: String + let md5Hash: String } -/// 옷 생성 요청 데이터 struct ClothCreateAPIRequest { - let clothImageUrl: String // S3에 업로드된 이미지 URL - let clothUrl: String? // 구매 링크 (선택) - let name: String? // 옷 이름 (선택) - let brand: String? // 브랜드 (선택) - let season: Season // 계절 (필수) - let categoryId: Int64 // 카테고리 ID (필수) + let clothImageUrl: String + let clothUrl: String? + let name: String? + let brand: String? + let season: Season + let categoryId: Int64 } -/// 옷 목록 조회 결과 struct ClothListResult { let clothes: [ClothListItem] let isLast: Bool } -/// 옷 목록 아이템 (목록 조회용) struct ClothListItem { let clothId: Int64 let imageUrl: String @@ -73,7 +51,6 @@ struct ClothListItem { let name: String? } -/// 옷 상세 조회 결과 struct ClothDetailResult { let clothImageUrl: String let parentCategory: String? @@ -83,338 +60,272 @@ struct ClothDetailResult { let clothUrl: String? } -/// 옷 수정 요청 데이터 struct ClothUpdateAPIRequest { - let clothImageUrl: String? // 이미지 URL (변경 시) - let clothUrl: String? // 구매 링크 (선택) - let name: String? // 옷 이름 (선택) - let brand: String? // 브랜드 (선택) - let season: Season? // 계절 (선택) - let categoryId: Int64? // 카테고리 ID (선택) + let clothImageUrl: String? + let clothUrl: String? + let name: String? + let brand: String? + let season: Season // API에서 required + let categoryId: Int64 // API에서 required } // MARK: - ClothAPIService Implementation final class ClothAPIService: ClothAPIServiceProtocol { - - // MARK: - Properties - + private let client: Client private let jsonDecoder: JSONDecoder - - // MARK: - Initializer - + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { self.client = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] ) - - // 서버의 timeStamp 형식 (나노초 포함) 처리를 위한 커스텀 DateFormatter - self.jsonDecoder = JSONDecoder() - self.jsonDecoder.dateDecodingStrategy = .custom { decoder in + self.jsonDecoder = Self.createJSONDecoder() + } + + private static func createJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) - - // 여러 ISO8601 형식 시도 - let formatters: [ISO8601DateFormatter] = { - let formatter1 = ISO8601DateFormatter() - formatter1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - let formatter2 = ISO8601DateFormatter() - formatter2.formatOptions = [.withInternetDateTime] - - return [formatter1, formatter2] - }() - - for formatter in formatters { - if let date = formatter.date(from: dateString) { - return date - } - } - - // ISO8601로 파싱 실패 시 DateFormatter 사용 + + let formatter1 = ISO8601DateFormatter() + formatter1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter1.date(from: dateString) { return date } + + let formatter2 = ISO8601DateFormatter() + formatter2.formatOptions = [.withInternetDateTime] + if let date = formatter2.date(from: dateString) { return date } + let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS" dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - - if let date = dateFormatter.date(from: dateString) { - return date - } - - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "날짜 형식을 파싱할 수 없습니다: \(dateString)" - ) + if let date = dateFormatter.date(from: dateString) { return date } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "날짜 파싱 실패: \(dateString)") } + return decoder } - - // MARK: - Public Methods - - /// Step 1: Presigned URL 발급 요청 +} + +// MARK: - Presigned URL & S3 Upload + +extension ClothAPIService { + func getPresignedUrls(for images: [Data]) async throws -> [PresignedUrlInfo] { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📤 [API] Presigned URL 발급 요청 시작") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - - // 각 이미지에 대한 MD5 해시 계산 및 payload 생성 - let payloads = images.enumerated().map { index, imageData in + let payloads = images.map { imageData in let md5Hash = calculateMD5(from: imageData) - print(" 📦 이미지 \(index + 1):") - print(" - 크기: \(imageData.count) bytes") - print(" - MD5: \(md5Hash)") return ( - payload: Components.Schemas.ClothImagesUploadRequestPayload( - fileExtension: .JPEG, - md5Hashes: md5Hash - ), + payload: Components.Schemas.ClothImagesUploadRequestPayload(fileExtension: .JPEG, md5Hashes: md5Hash), md5Hash: md5Hash ) } - - // API 요청 - let requestBody = Components.Schemas.ClothImagesUploadRequest( - payloads: payloads.map { $0.payload } - ) - - print(" 📨 요청 Body:") - print(" - payloads 개수: \(payloads.count)") - for (index, payload) in payloads.enumerated() { - print(" - [\(index)] fileExtension: JPEG, md5Hashes: \(payload.md5Hash)") - } - - let input = Operations.Cloth_getClothUploadPresignedUrl.Input( - body: .json(requestBody) - ) - - do { - let response = try await client.Cloth_getClothUploadPresignedUrl(input) - - switch response { - case .ok(let okResponse): - let httpBody = try okResponse.body.any - let data = try await Data(collecting: httpBody, upTo: .max) - - print(" ✅ 응답 수신 (성공)") - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 응답 Body: \(jsonString)") - } - - let decoded = try jsonDecoder.decode( - Components.Schemas.BaseResponseClothImagesPresignedUrlResponse.self, - from: data - ) - - print(" 📋 파싱 결과:") - print(" - isSuccess: \(decoded.isSuccess ?? false)") - print(" - code: \(decoded.code ?? "nil")") - print(" - message: \(decoded.message ?? "nil")") - print(" - urls 개수: \(decoded.result?.urls?.count ?? 0)") - - guard let urls = decoded.result?.urls, urls.count == images.count else { - print(" ❌ URL 개수 불일치! 요청: \(images.count), 응답: \(decoded.result?.urls?.count ?? 0)") - throw ClothAPIError.presignedUrlMismatch - } - - // Presigned URL과 MD5 해시를 함께 반환 - let result = zip(urls, payloads).map { url, payloadInfo in - let finalUrl = extractFinalUrl(from: url) - print(" - Presigned URL: \(url.prefix(80))...") - print(" - Final URL: \(finalUrl)") - return PresignedUrlInfo( - presignedUrl: url, - finalUrl: finalUrl, - md5Hash: payloadInfo.md5Hash - ) - } - - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - return result - - case .undocumented(statusCode: let code, let payload): - print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") - if let body = payload.body { - let data = try await Data(collecting: body, upTo: .max) - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 에러 응답 Body: \(jsonString)") - } - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw ClothAPIError.serverError(statusCode: code, message: "Presigned URL 발급 실패") + + let requestBody = Components.Schemas.ClothImagesUploadRequest(payloads: payloads.map { $0.payload }) + let input = Operations.Cloth_getClothUploadPresignedUrl.Input(body: .json(requestBody)) + let response = try await client.Cloth_getClothUploadPresignedUrl(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseClothImagesPresignedUrlResponse.self, from: data) + + guard let urls = decoded.result?.urls, urls.count == images.count else { + throw ClothAPIError.presignedUrlMismatch + } + + return zip(urls, payloads).map { url, payloadInfo in + PresignedUrlInfo(presignedUrl: url, finalUrl: extractFinalUrl(from: url), md5Hash: payloadInfo.md5Hash) } - } catch { - print(" ❌ 예외 발생: \(error)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw error + + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "Presigned URL 발급 실패") } } - - /// Step 1.5: S3에 이미지 직접 업로드 + func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String) async throws { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📤 [S3] 이미지 업로드 시작") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - guard let url = URL(string: presignedUrl) else { - print(" ❌ URL 파싱 실패: \(presignedUrl)") throw ClothAPIError.invalidUrl } - + var request = URLRequest(url: url) request.httpMethod = "PUT" request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") request.httpBody = imageData - - print(" 📨 요청 정보:") - print(" - Method: PUT") - print(" - URL: \(presignedUrl)") - print(" 📋 요청 헤더:") - print(" - Content-Type: image/jpeg") - print(" - Content-MD5: \(contentMD5)") - print(" 📦 요청 Body:") - print(" - Image Size: \(imageData.count) bytes (\(String(format: "%.2f", Double(imageData.count) / 1024.0)) KB)") - - do { - let (responseData, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - print(" ❌ HTTP 응답 아님") - throw ClothAPIError.invalidResponse - } - - print(" 📩 응답 수신:") - print(" - 상태코드: \(httpResponse.statusCode)") - print(" 📋 응답 헤더:") - for (key, value) in httpResponse.allHeaderFields { - print(" - \(key): \(value)") - } - - if !responseData.isEmpty { - if let responseString = String(data: responseData, encoding: .utf8) { - print(" 📩 응답 Body: \(responseString)") - } else { - print(" 📩 응답 Body: (바이너리 데이터 \(responseData.count) bytes)") - } - } else { - print(" 📩 응답 Body: (비어있음)") - } - - guard (200...299).contains(httpResponse.statusCode) else { - print(" ❌ S3 업로드 실패!") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw ClothAPIError.s3UploadFailed(statusCode: httpResponse.statusCode) - } - - print(" ✅ S3 업로드 성공!") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - - } catch { - print(" ❌ 예외 발생: \(error)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw error + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ClothAPIError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw ClothAPIError.s3UploadFailed(statusCode: httpResponse.statusCode) } } - - /// Step 2: 옷 생성 API 호출 +} + +// MARK: - Create Clothes + +extension ClothAPIService { + func createClothes(requests: [ClothCreateAPIRequest]) async throws -> [Int64] { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📤 [API] 옷 생성 요청 시작") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - - // ClothCreateAPIRequest → Components.Schemas.ClothCreateRequest 변환 - let apiRequests = requests.enumerated().map { index, request in - print(" 📦 옷 \(index + 1):") - print(" - clothImageUrl: \(request.clothImageUrl)") - print(" - clothUrl: \(request.clothUrl ?? "nil")") - print(" - name: \(request.name ?? "nil")") - print(" - brand: \(request.brand ?? "nil")") - print(" - season: \(request.season.rawValue)") - print(" - categoryId: \(request.categoryId)") - - return Components.Schemas.ClothCreateRequest( + // 디버그: 요청 데이터 출력 + for (index, req) in requests.enumerated() { + print("📦 [createClothes] 옷 \(index + 1):") + print(" - clothImageUrl: \(req.clothImageUrl)") + print(" - name: \(req.name ?? "nil")") + print(" - brand: \(req.brand ?? "nil")") + print(" - season: \(req.season.rawValue)") + print(" - categoryId: \(req.categoryId)") + } + + let apiRequests = requests.map { request in + Components.Schemas.ClothCreateRequest( clothImageUrl: request.clothImageUrl, clothUrl: request.clothUrl, name: request.name, brand: request.brand, - season: mapSeasonToAPI(request.season), + season: mapSeasonToCreateAPI(request.season), categoryId: request.categoryId ) } - + let requestBody = Components.Schemas.ClothCreateRequests(content: apiRequests) - - // JSON으로 변환해서 출력 - if let jsonData = try? JSONEncoder().encode(requestBody), - let jsonString = String(data: jsonData, encoding: .utf8) { - print(" 📨 요청 Body (JSON): \(jsonString)") + let input = Operations.Cloth_createClothes.Input(body: .json(requestBody)) + let response = try await client.Cloth_createClothes(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseClothCreateResponse.self, from: data) + + guard let clothIds = decoded.result?.clothIds else { + throw ClothAPIError.noClothIdsReturned + } + return clothIds + + case .undocumented(statusCode: let code, let payload): + // 디버그: 에러 응답 출력 + print("❌ [createClothes] 서버 에러 - 상태코드: \(code)") + if let body = payload.body { + let errorData = try await Data(collecting: body, upTo: .max) + if let errorString = String(data: errorData, encoding: .utf8) { + print("❌ [createClothes] 에러 응답: \(errorString)") + } + } + throw ClothAPIError.serverError(statusCode: code, message: "옷 생성 실패") } - - let input = Operations.Cloth_createClothes.Input( - body: .json(requestBody) + } +} + +// MARK: - Fetch Clothes + +extension ClothAPIService { + + func fetchClothes(lastClothId: Int64?, size: Int32, categoryId: Int64?, seasons: [Season]) async throws -> ClothListResult { + let seasonsParam = seasons.isEmpty ? nil : seasons.map { mapSeasonToQueryParam($0) } + + let input = Operations.Cloth_getClothes.Input( + query: .init(lastClothId: lastClothId, size: size, direction: .DESC, categoryId: categoryId, seasons: seasonsParam) ) - - do { - let response = try await client.Cloth_createClothes(input) - - switch response { - case .ok(let okResponse): - let httpBody = try okResponse.body.any - let data = try await Data(collecting: httpBody, upTo: .max) - - print(" ✅ 응답 수신 (성공)") - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 응답 Body: \(jsonString)") - } - - let decoded = try jsonDecoder.decode( - Components.Schemas.BaseResponseClothCreateResponse.self, - from: data - ) - - print(" 📋 파싱 결과:") - print(" - isSuccess: \(decoded.isSuccess ?? false)") - print(" - code: \(decoded.code ?? "nil")") - print(" - message: \(decoded.message ?? "nil")") - print(" - clothIds: \(decoded.result?.clothIds ?? [])") - - guard let clothIds = decoded.result?.clothIds else { - print(" ❌ clothIds가 nil!") - throw ClothAPIError.noClothIdsReturned - } - - print(" ✅ 옷 생성 완료! IDs: \(clothIds)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - return clothIds - - case .undocumented(statusCode: let code, let payload): - print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") - if let body = payload.body { - let data = try await Data(collecting: body, upTo: .max) - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 에러 응답 Body: \(jsonString)") - } - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw ClothAPIError.serverError(statusCode: code, message: "옷 생성 실패") + + let response = try await client.Cloth_getClothes(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseSliceResponseClothListResponse.self, from: data) + + let clothes = decoded.result?.content?.map { item in + ClothListItem(clothId: item.clothId ?? 0, imageUrl: item.ImageUrl ?? "", brand: item.brand, name: item.name) + } ?? [] + + return ClothListResult(clothes: clothes, isLast: decoded.result?.isLast ?? true) + + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 목록 조회 실패") + } + } + + func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult { + let input = Operations.Cloth_getClothDetails.Input(path: .init(clothId: clothId)) + let response = try await client.Cloth_getClothDetails(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseClothDetailsResponse.self, from: data) + + guard let result = decoded.result else { + throw ClothAPIError.serverError(statusCode: 0, message: "result가 nil입니다") } - } catch { - print(" ❌ 예외 발생: \(error)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw error + + return ClothDetailResult( + clothImageUrl: result.clothImageUrl ?? "", + parentCategory: result.parentCategory, + category: result.category, + name: result.name, + brand: result.brand, + clothUrl: result.clothUrl + ) + + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 상세 조회 실패") } } - - // MARK: - Private Methods - - /// MD5 해시 계산 (Base64 인코딩) - private func calculateMD5(from data: Data) -> String { +} + +// MARK: - Update & Delete + +extension ClothAPIService { + + func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws { + let requestBody = Components.Schemas.ClothUpdateRequest( + clothImageUrl: request.clothImageUrl, + clothUrl: request.clothUrl, + name: request.name, + brand: request.brand, + season: mapSeasonToUpdateAPI(request.season), + categoryId: request.categoryId + ) + + let input = Operations.Cloth_updateCloth.Input(path: .init(clothId: clothId), body: .json(requestBody)) + let response = try await client.Cloth_updateCloth(input) + + switch response { + case .ok: + return + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 수정 실패") + } + } + + func deleteCloth(clothId: Int64) async throws { + let input = Operations.Cloth_deleteCloth.Input(path: .init(clothId: clothId)) + let response = try await client.Cloth_deleteCloth(input) + + switch response { + case .ok: + return + case .undocumented(statusCode: let code, _): + throw ClothAPIError.serverError(statusCode: code, message: "옷 삭제 실패") + } + } +} + +// MARK: - Private Helpers + +private extension ClothAPIService { + + func calculateMD5(from data: Data) -> String { let digest = Insecure.MD5.hash(data: data) return Data(digest).base64EncodedString() } - - /// Presigned URL에서 최종 S3 URL 추출 (쿼리 파라미터 제거) - private func extractFinalUrl(from presignedUrl: String) -> String { + + func extractFinalUrl(from presignedUrl: String) -> String { guard let url = URL(string: presignedUrl), var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return presignedUrl @@ -422,9 +333,8 @@ final class ClothAPIService: ClothAPIServiceProtocol { components.query = nil return components.string ?? presignedUrl } - - /// Season enum → API enum 변환 - private func mapSeasonToAPI(_ season: Season) -> Components.Schemas.ClothCreateRequest.seasonPayload { + + func mapSeasonToCreateAPI(_ season: Season) -> Components.Schemas.ClothCreateRequest.seasonPayload { switch season { case .spring: return .SPRING case .summer: return .SUMMER @@ -432,9 +342,8 @@ final class ClothAPIService: ClothAPIServiceProtocol { case .winter: return .WINTER } } - - /// Season enum → Query param enum 변환 - private func mapSeasonToQueryParam(_ season: Season) -> Operations.Cloth_getClothes.Input.Query.seasonsPayloadPayload { + + func mapSeasonToUpdateAPI(_ season: Season) -> Components.Schemas.ClothUpdateRequest.seasonPayload { switch season { case .spring: return .SPRING case .summer: return .SUMMER @@ -442,274 +351,13 @@ final class ClothAPIService: ClothAPIServiceProtocol { case .winter: return .WINTER } } - - // MARK: - 옷 목록 조회 - - func fetchClothes( - lastClothId: Int64?, - size: Int32, - categoryId: Int64?, - seasons: [Season] - ) async throws -> ClothListResult { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📤 [API] 옷 목록 조회 요청") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print(" 📋 요청 파라미터:") - print(" - lastClothId: \(lastClothId?.description ?? "nil")") - print(" - size: \(size)") - print(" - categoryId: \(categoryId?.description ?? "nil")") - print(" - seasons: \(seasons.map { $0.rawValue })") - - let seasonsParam: [Operations.Cloth_getClothes.Input.Query.seasonsPayloadPayload]? = seasons.isEmpty ? nil : seasons.map { mapSeasonToQueryParam($0) } - - let input = Operations.Cloth_getClothes.Input( - query: .init( - lastClothId: lastClothId, - size: size, - direction: .DESC, - categoryId: categoryId, - seasons: seasonsParam - ) - ) - - do { - let response = try await client.Cloth_getClothes(input) - - switch response { - case .ok(let okResponse): - let httpBody = try okResponse.body.any - let data = try await Data(collecting: httpBody, upTo: .max) - - print(" ✅ 응답 수신 (성공)") - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 응답 Body: \(jsonString.prefix(500))...") - } - - let decoded = try jsonDecoder.decode( - Components.Schemas.BaseResponseSliceResponseClothListResponse.self, - from: data - ) - - print(" 📋 파싱 결과:") - print(" - isSuccess: \(decoded.isSuccess ?? false)") - print(" - code: \(decoded.code ?? "nil")") - print(" - 옷 개수: \(decoded.result?.content?.count ?? 0)") - print(" - isLast: \(decoded.result?.isLast ?? false)") - - let clothes = decoded.result?.content?.map { item in - ClothListItem( - clothId: item.clothId ?? 0, - imageUrl: item.ImageUrl ?? "", // API 응답의 대문자 I 주의 - brand: item.brand, - name: item.name - ) - } ?? [] - - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - return ClothListResult( - clothes: clothes, - isLast: decoded.result?.isLast ?? true - ) - - case .undocumented(statusCode: let code, let payload): - print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") - if let body = payload.body { - let data = try await Data(collecting: body, upTo: .max) - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 에러 응답 Body: \(jsonString)") - } - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw ClothAPIError.serverError(statusCode: code, message: "옷 목록 조회 실패") - } - } catch { - print(" ❌ 예외 발생: \(error)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw error - } - } - - // MARK: - 옷 상세 조회 - - func fetchClothDetails(clothId: Int64) async throws -> ClothDetailResult { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📤 [API] 옷 상세 조회 요청") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print(" 📋 요청 파라미터: clothId = \(clothId)") - - let input = Operations.Cloth_getClothDetails.Input( - path: .init(clothId: clothId) - ) - - do { - let response = try await client.Cloth_getClothDetails(input) - - switch response { - case .ok(let okResponse): - let httpBody = try okResponse.body.any - let data = try await Data(collecting: httpBody, upTo: .max) - - print(" ✅ 응답 수신 (성공)") - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 응답 Body: \(jsonString)") - } - - let decoded = try jsonDecoder.decode( - Components.Schemas.BaseResponseClothDetailsResponse.self, - from: data - ) - - print(" 📋 파싱 결과:") - print(" - isSuccess: \(decoded.isSuccess ?? false)") - print(" - code: \(decoded.code ?? "nil")") - print(" - name: \(decoded.result?.name ?? "nil")") - print(" - brand: \(decoded.result?.brand ?? "nil")") - print(" - category: \(decoded.result?.category ?? "nil")") - - guard let result = decoded.result else { - throw ClothAPIError.serverError(statusCode: 0, message: "result가 nil입니다") - } - - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - return ClothDetailResult( - clothImageUrl: result.clothImageUrl ?? "", - parentCategory: result.parentCategory, - category: result.category, - name: result.name, - brand: result.brand, - clothUrl: result.clothUrl - ) - - case .undocumented(statusCode: let code, let payload): - print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") - if let body = payload.body { - let data = try await Data(collecting: body, upTo: .max) - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 에러 응답 Body: \(jsonString)") - } - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw ClothAPIError.serverError(statusCode: code, message: "옷 상세 조회 실패") - } - } catch { - print(" ❌ 예외 발생: \(error)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw error - } - } - - // MARK: - 옷 수정 - - func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📤 [API] 옷 수정 요청") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print(" 📋 요청 파라미터:") - print(" - clothId: \(clothId)") - print(" - clothImageUrl: \(request.clothImageUrl ?? "nil")") - print(" - name: \(request.name ?? "nil")") - print(" - brand: \(request.brand ?? "nil")") - print(" - season: \(request.season?.rawValue ?? "nil")") - print(" - categoryId: \(request.categoryId?.description ?? "nil")") - - // Season → API enum 변환 - let seasonParam: Components.Schemas.ClothUpdateRequest.seasonPayload? = request.season.map { season in - switch season { - case .spring: return .SPRING - case .summer: return .SUMMER - case .fall: return .FALL - case .winter: return .WINTER - } - } - - let requestBody = Components.Schemas.ClothUpdateRequest( - clothImageUrl: request.clothImageUrl, - clothUrl: request.clothUrl, - name: request.name, - brand: request.brand, - season: seasonParam, - categoryId: request.categoryId - ) - - let input = Operations.Cloth_updateCloth.Input( - path: .init(clothId: clothId), - body: .json(requestBody) - ) - - do { - let response = try await client.Cloth_updateCloth(input) - - switch response { - case .ok(let okResponse): - let httpBody = try okResponse.body.any - let data = try await Data(collecting: httpBody, upTo: .max) - - print(" ✅ 응답 수신 (성공)") - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 응답 Body: \(jsonString)") - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - - case .undocumented(statusCode: let code, let payload): - print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") - if let body = payload.body { - let data = try await Data(collecting: body, upTo: .max) - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 에러 응답 Body: \(jsonString)") - } - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw ClothAPIError.serverError(statusCode: code, message: "옷 수정 실패") - } - } catch { - print(" ❌ 예외 발생: \(error)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw error - } - } - - // MARK: - 옷 삭제 - - func deleteCloth(clothId: Int64) async throws { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📤 [API] 옷 삭제 요청") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print(" 📋 요청 파라미터: clothId = \(clothId)") - - let input = Operations.Cloth_deleteCloth.Input( - path: .init(clothId: clothId) - ) - - do { - let response = try await client.Cloth_deleteCloth(input) - - switch response { - case .ok(let okResponse): - let httpBody = try okResponse.body.any - let data = try await Data(collecting: httpBody, upTo: .max) - - print(" ✅ 응답 수신 (성공)") - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 응답 Body: \(jsonString)") - } - print(" ✅ 옷 삭제 완료!") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - - case .undocumented(statusCode: let code, let payload): - print(" ❌ 응답 수신 (실패) - 상태코드: \(code)") - if let body = payload.body { - let data = try await Data(collecting: body, upTo: .max) - if let jsonString = String(data: data, encoding: .utf8) { - print(" 📩 에러 응답 Body: \(jsonString)") - } - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw ClothAPIError.serverError(statusCode: code, message: "옷 삭제 실패") - } - } catch { - print(" ❌ 예외 발생: \(error)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - throw error + + func mapSeasonToQueryParam(_ season: Season) -> Operations.Cloth_getClothes.Input.Query.seasonsPayloadPayload { + switch season { + case .spring: return .SPRING + case .summer: return .SUMMER + case .fall: return .FALL + case .winter: return .WINTER } } } @@ -723,7 +371,7 @@ enum ClothAPIError: LocalizedError { case s3UploadFailed(statusCode: Int) case noClothIdsReturned case serverError(statusCode: Int, message: String) - + var errorDescription: String? { switch self { case .presignedUrlMismatch: diff --git a/Codive/Features/Closet/Data/Constants/CategoryConstants.swift b/Codive/Features/Closet/Data/Constants/CategoryConstants.swift index 031f9f45..68925995 100644 --- a/Codive/Features/Closet/Data/Constants/CategoryConstants.swift +++ b/Codive/Features/Closet/Data/Constants/CategoryConstants.swift @@ -13,49 +13,132 @@ struct CategoryConstants { CategoryItem( id: 1, name: "상의", - subcategories: ["티셔츠", "니트/스웨터", "맨투맨", "후드티", "반팔티", "셔츠/블라우스", "나시", "기타"] + subcategories: [ + SubcategoryItem(id: 8, name: "티셔츠"), + SubcategoryItem(id: 9, name: "니트/스웨터"), + SubcategoryItem(id: 10, name: "맨투맨"), + SubcategoryItem(id: 11, name: "후드티"), + SubcategoryItem(id: 12, name: "셔츠/블라우스"), + SubcategoryItem(id: 13, name: "반팔티"), + SubcategoryItem(id: 14, name: "나시"), + SubcategoryItem(id: 15, name: "기타") + ] ), CategoryItem( id: 2, name: "바지", - subcategories: ["청바지", "반바지", "트레이닝/조거팬츠", "슈트팬츠/슬랙스", "레깅스", "기타"] + subcategories: [ + SubcategoryItem(id: 16, name: "청바지"), + SubcategoryItem(id: 17, name: "반바지"), + SubcategoryItem(id: 18, name: "트레이닝/조거팬츠"), + SubcategoryItem(id: 19, name: "면바지"), + SubcategoryItem(id: 20, name: "슈트팬츠/슬랙스"), + SubcategoryItem(id: 21, name: "레깅스"), + SubcategoryItem(id: 22, name: "기타") + ] ), CategoryItem( id: 3, - name: "치마", - subcategories: ["미니스커트", "미디스커트", "롱스커트", "원피스", "투피스", "기타"] + name: "스커트", + subcategories: [ + SubcategoryItem(id: 23, name: "미니스커트"), + SubcategoryItem(id: 24, name: "미디스커트"), + SubcategoryItem(id: 25, name: "롱스커트"), + SubcategoryItem(id: 26, name: "원피스"), + SubcategoryItem(id: 27, name: "투피스"), + SubcategoryItem(id: 28, name: "기타") + ] ), CategoryItem( id: 4, name: "아우터", - subcategories: ["숏패딩/헤비 아우터", "무스탕/퍼", "후드집업", "점퍼/바람막이", "가죽자켓", "청자켓", "슈트/블레이져", "가디건", "아노락", "후리스/양털", "코트", "롱패딩", "패딩조끼", "기타"] + subcategories: [ + SubcategoryItem(id: 29, name: "숏패딩/헤비 아우터"), + SubcategoryItem(id: 30, name: "무스탕/퍼"), + SubcategoryItem(id: 31, name: "후드집업"), + SubcategoryItem(id: 32, name: "점퍼/바람막이"), + SubcategoryItem(id: 33, name: "가죽자켓"), + SubcategoryItem(id: 34, name: "청자켓"), + SubcategoryItem(id: 35, name: "슈트/블레이저"), + SubcategoryItem(id: 36, name: "가디건"), + SubcategoryItem(id: 37, name: "아노락"), + SubcategoryItem(id: 38, name: "후리스/양털"), + SubcategoryItem(id: 39, name: "코트"), + SubcategoryItem(id: 40, name: "롱패딩"), + SubcategoryItem(id: 41, name: "패딩조끼"), + SubcategoryItem(id: 42, name: "기타") + ] ), CategoryItem( id: 5, name: "신발", - subcategories: ["스니커즈", "부츠/워커", "구두", "샌들/슬리퍼", "기타"] + subcategories: [ + SubcategoryItem(id: 43, name: "스니커즈"), + SubcategoryItem(id: 44, name: "부츠/워커"), + SubcategoryItem(id: 45, name: "구두"), + SubcategoryItem(id: 46, name: "샌들/슬리퍼"), + SubcategoryItem(id: 47, name: "기타") + ] ), CategoryItem( id: 6, name: "가방", - subcategories: ["메신저/크로스백", "숄더백", "백팩", "토트백", "에코백", "기타"] + subcategories: [ + SubcategoryItem(id: 48, name: "메신저/크로스백"), + SubcategoryItem(id: 49, name: "숄더백"), + SubcategoryItem(id: 50, name: "백팩"), + SubcategoryItem(id: 51, name: "토트백"), + SubcategoryItem(id: 52, name: "에코백"), + SubcategoryItem(id: 53, name: "기타") + ] ), CategoryItem( id: 7, - name: "패션소품", - subcategories: ["모자", "머플러", "양말/레그웨어", "시계", "주얼리", "벨트", "선글라스/안경", "기타"] + name: "패션 소품", + subcategories: [ + SubcategoryItem(id: 54, name: "모자"), + SubcategoryItem(id: 55, name: "머플러"), + SubcategoryItem(id: 56, name: "양말/레그웨어"), + SubcategoryItem(id: 57, name: "시계"), + SubcategoryItem(id: 58, name: "주얼리"), + SubcategoryItem(id: 59, name: "벨트"), + SubcategoryItem(id: 60, name: "선글라스/안경"), + SubcategoryItem(id: 61, name: "기타") + ] ) ] // MARK: - Helper Methods - /// ID로 카테고리 조회 + /// ID로 상위 카테고리 조회 static func category(byId id: Int) -> CategoryItem? { return all.first { $0.id == id } } - /// 이름으로 카테고리 조회 + /// 이름으로 상위 카테고리 조회 static func category(byName name: String) -> CategoryItem? { return all.first { $0.name == name } } + + /// 하위 카테고리 ID로 상위 카테고리 조회 + static func category(bySubcategoryId id: Int) -> CategoryItem? { + return all.first { category in + category.subcategories.contains { $0.id == id } + } + } + + /// 하위 카테고리 ID로 하위 카테고리 조회 + static func subcategory(byId id: Int) -> SubcategoryItem? { + for category in all { + if let subcategory = category.subcategories.first(where: { $0.id == id }) { + return subcategory + } + } + return nil + } + + /// 하위 카테고리 이름으로 하위 카테고리 조회 (상위 카테고리 내에서) + static func subcategory(byName name: String, in category: CategoryItem) -> SubcategoryItem? { + return category.subcategories.first { $0.name == name } + } } diff --git a/Codive/Features/Closet/Domain/Entities/CategoryItem.swift b/Codive/Features/Closet/Domain/Entities/CategoryItem.swift index ecfe9221..70c6abed 100644 --- a/Codive/Features/Closet/Domain/Entities/CategoryItem.swift +++ b/Codive/Features/Closet/Domain/Entities/CategoryItem.swift @@ -10,5 +10,10 @@ import Foundation struct CategoryItem: Identifiable, Hashable { let id: Int let name: String - let subcategories: [String] + let subcategories: [SubcategoryItem] +} + +struct SubcategoryItem: Identifiable, Hashable { + let id: Int + let name: String } diff --git a/Codive/Features/Closet/Presentation/View/ClothAddView.swift b/Codive/Features/Closet/Presentation/View/ClothAddView.swift index c4993454..beb0fb05 100644 --- a/Codive/Features/Closet/Presentation/View/ClothAddView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothAddView.swift @@ -107,7 +107,7 @@ struct ClothAddView: View { return ClothingItem( image: photo.croppedImage, category: form.category?.name ?? "", - subcategory: form.subcategory ?? "", + subcategory: form.subcategory?.name ?? "", season: seasonText, name: form.name, brand: form.brand, diff --git a/Codive/Features/Closet/Presentation/View/ClothEditView.swift b/Codive/Features/Closet/Presentation/View/ClothEditView.swift index 81539cd6..2eef6d9d 100644 --- a/Codive/Features/Closet/Presentation/View/ClothEditView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothEditView.swift @@ -108,7 +108,7 @@ struct ClothEditView: View { imageName: viewModel.cloth.imageUrl, image: nil, category: form.category?.name ?? "", - subcategory: form.subcategory ?? "", + subcategory: form.subcategory?.name ?? "", season: seasonText, name: form.name, brand: form.brand, diff --git a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift index 7509c362..24499a4b 100644 --- a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift +++ b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift @@ -172,21 +172,21 @@ struct MyClosetView: View { return ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(subcategories, id: \.self) { sub in - Text(sub) + ForEach(subcategories) { sub in + Text(sub.name) .font(.codive_body2_medium) .padding(.horizontal, 14) .padding(.vertical, 7) .background(Color.white) - .foregroundStyle(viewModel.selectedSubCategory == sub ? Color.Codive.point1 : Color.Codive.grayscale1) + .foregroundStyle(viewModel.selectedSubCategory == sub.name ? Color.Codive.point1 : Color.Codive.grayscale1) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(viewModel.selectedSubCategory == sub ? Color.Codive.point1 : Color.Codive.grayscale6, lineWidth: 1) + .stroke(viewModel.selectedSubCategory == sub.name ? Color.Codive.point1 : Color.Codive.grayscale6, lineWidth: 1) ) .contentShape(Rectangle()) .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { - viewModel.updateSubCategory(sub) + viewModel.updateSubCategory(sub.name) } } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift index b74ce650..13322df2 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift @@ -15,7 +15,7 @@ struct ClothFormData { var brand: String = "" var purchaseUrl: String = "" var category: CategoryItem? - var subcategory: String? + var subcategory: SubcategoryItem? var selectedSeasons: Set = [] } @@ -27,7 +27,7 @@ protocol ClothAddViewModelInput { func updatePurchaseUrl(_ url: String) func showCategorySheet() func showSeasonSheet() - func selectCategory(_ category: CategoryItem, subcategory: String) + func selectCategory(_ category: CategoryItem, subcategory: SubcategoryItem) func selectSeasons(_ seasons: Set) func moveToPrevious() func moveToNext() @@ -91,7 +91,7 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd var categoryDisplayText: String { if let category = currentForm.category, let subcategory = currentForm.subcategory { - return "\(category.name) > \(subcategory)" + return "\(category.name) > \(subcategory.name)" } return "" } @@ -165,7 +165,7 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd isSeasonSheetPresented = true } - func selectCategory(_ category: CategoryItem, subcategory: String) { + func selectCategory(_ category: CategoryItem, subcategory: SubcategoryItem) { clothForms[currentIndex].category = category clothForms[currentIndex].subcategory = subcategory isCategorySheetPresented = false @@ -209,7 +209,7 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd name: form.name, brand: form.brand, purchaseUrl: form.purchaseUrl, - categoryId: nil, // TODO: 서버 연결 시 category name → server ID 매핑 필요 + categoryId: form.subcategory?.id, // 하위 카테고리 ID 사용! seasons: form.selectedSeasons ) } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift index 706b1c54..93a0e7c1 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift @@ -17,7 +17,7 @@ protocol ClothEditViewModelInput { func updatePurchaseUrl(_ url: String) func showCategorySheet() func showSeasonSheet() - func selectCategory(_ category: CategoryItem, subcategory: String) + func selectCategory(_ category: CategoryItem, subcategory: SubcategoryItem) func selectSeasons(_ seasons: Set) func dismissView() func completeEditing() @@ -57,7 +57,7 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth // MARK: - Computed Properties var categoryDisplayText: String { if let category = clothForm.category, let subcategory = clothForm.subcategory { - return "\(category.name) > \(subcategory)" + return "\(category.name) > \(subcategory.name)" } return "" } @@ -85,9 +85,9 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth self.navigationRouter = navigationRouter // 기존 Cloth 데이터로 폼 초기화 - let category = cloth.categoryId.flatMap { CategoryConstants.category(byId: $0) } - // TODO: 서버에서 subcategory 정보가 오면 설정 - let subcategory = category?.subcategories.first + // categoryId는 하위 카테고리 ID이므로 그걸로 상위/하위 카테고리 모두 조회 + let subcategory = cloth.categoryId.flatMap { CategoryConstants.subcategory(byId: $0) } + let category = cloth.categoryId.flatMap { CategoryConstants.category(bySubcategoryId: $0) } self.clothForm = ClothFormData( name: cloth.name ?? "", @@ -122,7 +122,7 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth isSeasonSheetPresented = true } - func selectCategory(_ category: CategoryItem, subcategory: String) { + func selectCategory(_ category: CategoryItem, subcategory: SubcategoryItem) { clothForm.category = category clothForm.subcategory = subcategory isCategorySheetPresented = false diff --git a/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift index 4898587b..26f2815f 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift @@ -141,7 +141,7 @@ final class MyClosetViewModel: ObservableObject { // 메인 카테고리 변경 시 첫 번째 서브 카테고리 자동 선택 if let firstSub = CategoryConstants.all.first(where: { $0.name == category })?.subcategories.first { - selectedSubCategory = firstSub + selectedSubCategory = firstSub.name } else { selectedSubCategory = "" } diff --git a/Codive/Shared/DesignSystem/Sheets/CustomCategoryBottomSheet.swift b/Codive/Shared/DesignSystem/Sheets/CustomCategoryBottomSheet.swift index e07c58a5..f1b2bc99 100644 --- a/Codive/Shared/DesignSystem/Sheets/CustomCategoryBottomSheet.swift +++ b/Codive/Shared/DesignSystem/Sheets/CustomCategoryBottomSheet.swift @@ -15,14 +15,14 @@ struct CustomCategoryBottomSheet: View { /// 현재 선택된 주 카테고리를 외부와 동기화 @Binding var selectedCategory: CategoryItem? - /// 최종 선택 완료 시 호출될 클로저 - let onApply: (CategoryItem, String) -> Void + /// 최종 선택 완료 시 호출될 클로저 (상위카테고리, 하위카테고리) + let onApply: (CategoryItem, SubcategoryItem) -> Void /// 뷰 내부에서만 사용할 선택된 서브 카테고리 상태 - @State private var selectedSubcategory: String? + @State private var selectedSubcategory: SubcategoryItem? /// 초기 선택된 서브 카테고리 (뷰 초기화 시 전달) - let initialSubcategory: String? + let initialSubcategory: SubcategoryItem? var body: some View { VStack(spacing: 0) { @@ -71,14 +71,14 @@ struct CustomCategoryBottomSheet: View { // 오른쪽 (하위 카테고리, 스크롤) ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(selectedCategory?.subcategories ?? [], id: \.self) { sub in + ForEach(selectedCategory?.subcategories ?? []) { sub in Button { selectedSubcategory = sub if let category = selectedCategory { onApply(category, sub) } } label: { - Text(sub) + Text(sub.name) .font(.codive_title3) .foregroundStyle(Color("Grayscale1")) .frame(maxWidth: .infinity, alignment: .center) @@ -138,7 +138,7 @@ struct CustomCategoryBottomSheet: View { allCategories: CategoryConstants.all, selectedCategory: $selectedCategory, onApply: { mainCategory, subCategory in - print("최종 선택 완료: \(mainCategory.name) -> \(subCategory)") + print("최종 선택 완료: \(mainCategory.name) -> \(subCategory.name)") }, initialSubcategory: nil ) From 4f646843418a5b0aa30e9c70da8cedca88c06ea6 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:23:30 +0900 Subject: [PATCH 11/37] =?UTF-8?q?[#39]=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Application/AppRootView.swift | 71 ++++--- .../Features/Auth/Data/AuthAPIService.swift | 96 ++++++++-- .../Features/Auth/Data/TermsAPIService.swift | 154 +++++++++++++++ .../View/TermsAgreementView.swift | 178 +++++++++++++----- Codive/Router/AppRouter.swift | 22 ++- .../Router/ViewFactory/AuthViewFactory.swift | 3 +- Tuist/Package.resolved | 2 +- 7 files changed, 432 insertions(+), 94 deletions(-) create mode 100644 Codive/Features/Auth/Data/TermsAPIService.swift diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index 62cff645..fc82c91f 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -25,16 +25,34 @@ struct AppRootView: View { } var body: some View { - Group { - switch appRouter.currentAppState { - case .splash: - SplashContainerView(appRouter: appRouter) + ZStack { + Group { + switch appRouter.currentAppState { + case .splash: + SplashContainerView(appRouter: appRouter) - case .auth: - authDIContainer.makeAuthFlowView() + case .auth: + authDIContainer.makeAuthFlowView() - case .main: - MainTabView(appDIContainer: appDIContainer) + case .termsAgreement: + TermsAgreementView( + onComplete: { + appRouter.navigateToMain() + } + ) + + case .main: + MainTabView(appDIContainer: appDIContainer) + } + } + + // 로딩 오버레이 + if appRouter.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) } } .onOpenURL { url in @@ -78,23 +96,32 @@ struct AppRootView: View { Task { do { try await authRepository.saveTokens(accessToken: unwrappedAccessToken, refreshToken: unwrappedRefreshToken) - - // 🔑 디버그용 토큰 출력 - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print("🔑 [로그인 성공] JWT 토큰 저장 완료") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📌 Access Token:") - print(unwrappedAccessToken) - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📌 Refresh Token:") - print(unwrappedRefreshToken) - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - - print("Tokens saved successfully from deep link.") - appRouter.navigateToMain() + + // 로딩 표시 + appRouter.showLoading() + + // 회원 상태 확인 + let statusResult = await authRepository.checkAuthStatus() + + switch statusResult { + case .success(let status): + switch status { + case .notAgreed: + print("📋 약관 동의 필요 → TermsAgreementView로 이동") + appRouter.navigateToTerms() + case .registered: + print("✅ 가입 완료 → 메인으로 이동") + appRouter.navigateToMain() + } + case .failure(let error): + print("❌ 상태 확인 실패: \(error.localizedDescription)") + // 실패 시에도 일단 메인으로 (또는 에러 처리) + appRouter.navigateToMain() + } } catch { print("Failed to save tokens from deep link: \(error.localizedDescription)") - // Handle error, e.g., show an alert + appRouter.hideLoading() } } } diff --git a/Codive/Features/Auth/Data/AuthAPIService.swift b/Codive/Features/Auth/Data/AuthAPIService.swift index 040ff6fe..f76e7191 100644 --- a/Codive/Features/Auth/Data/AuthAPIService.swift +++ b/Codive/Features/Auth/Data/AuthAPIService.swift @@ -18,15 +18,61 @@ protocol AuthAPIServiceProtocol { final class AuthAPIService: AuthAPIServiceProtocol { private let client: Client + private let jsonDecoder: JSONDecoder init(tokenProvider: TokenProvider = KeychainTokenProvider()) { // CodiveAPI Client 생성 with AuthMiddleware self.client = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] ) + self.jsonDecoder = Self.createJSONDecoder() + } + + private static func createJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + // 방법 1: ISO8601DateFormatter (표준 형식) + let formatter1 = ISO8601DateFormatter() + formatter1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter1.date(from: dateString) { return date } + + let formatter2 = ISO8601DateFormatter() + formatter2.formatOptions = [.withInternetDateTime] + if let date = formatter2.date(from: dateString) { return date } + + // 방법 2: DateFormatter로 나노초 포함 형식 처리 + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + // 나노초 형식 (소수점 이하 자릿수 다양) + let formats = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS", // 9자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", // 8자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", // 7자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", // 6자리 + "yyyy-MM-dd'T'HH:mm:ss.SSS", // 3자리 + "yyyy-MM-dd'T'HH:mm:ss" // 소수점 없음 + ] + + for format in formats { + dateFormatter.dateFormat = format + if let date = dateFormatter.date(from: dateString) { return date } + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "날짜 파싱 실패: \(dateString)") + } + return decoder } func checkAuthStatus() async -> AuthStatusResult { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📡 [AuthAPI] checkAuthStatus 호출") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + do { // API 호출 let response = try await client.Auth_getUserStatus( @@ -40,31 +86,49 @@ final class AuthAPIService: AuthAPIServiceProtocol { let httpBody = try okResponse.body.any let data = try await Data(collecting: httpBody, upTo: .max) - // JSON 디코딩 - let apiResponse = try JSONDecoder().decode( - Components.Schemas.BaseResponseUserStatusResponse.self, - from: data - ) - - // registerStatus 확인 - guard let result = apiResponse.result, - let registerStatus = result.registerStatus else { - return .failure(.networkError("회원 상태 정보 없음")) + // 🔍 디버그: 원본 응답 출력 + if let jsonString = String(data: data, encoding: .utf8) { + print("📩 [AuthAPI] 응답 원본:") + print(jsonString) } - // RegisterStatus 변환 - switch registerStatus { - case .NOT_AGREED: - return .success(.notAgreed) - case .REGISTERED: - return .success(.registered) + // JSON 디코딩 (커스텀 디코더 사용) + do { + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseUserStatusResponse.self, + from: data + ) + + print("✅ [AuthAPI] 디코딩 성공") + + // registerStatus 확인 + guard let result = apiResponse.result, + let registerStatus = result.registerStatus else { + print("❌ [AuthAPI] result 또는 registerStatus 없음") + return .failure(.networkError("회원 상태 정보 없음")) + } + + print("📋 [AuthAPI] registerStatus: \(registerStatus)") + + // RegisterStatus 변환 + switch registerStatus { + case .NOT_AGREED: + return .success(.notAgreed) + case .REGISTERED: + return .success(.registered) + } + } catch { + print("❌ [AuthAPI] 디코딩 실패: \(error)") + return .failure(.networkError(error.localizedDescription)) } case .undocumented(statusCode: let statusCode, _): + print("❌ [AuthAPI] undocumented 응답: \(statusCode)") return .failure(.networkError("예상치 못한 응답 코드: \(statusCode)")) } } catch { + print("❌ [AuthAPI] 네트워크 에러: \(error)") return .failure(.networkError(error.localizedDescription)) } } diff --git a/Codive/Features/Auth/Data/TermsAPIService.swift b/Codive/Features/Auth/Data/TermsAPIService.swift new file mode 100644 index 00000000..05ec4def --- /dev/null +++ b/Codive/Features/Auth/Data/TermsAPIService.swift @@ -0,0 +1,154 @@ +// +// TermsAPIService.swift +// Codive +// +// Created by Assistant on 1/13/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - Terms API Service Protocol + +protocol TermsAPIServiceProtocol { + func fetchTerms() async throws -> [TermItem] + func agreeTerms(agreements: [TermAgreement]) async throws +} + +// MARK: - Supporting Types + +struct TermItem { + let termId: Int64 + let title: String + let isOptional: Bool +} + +struct TermAgreement { + let termId: Int64 + let agreed: Bool +} + +// MARK: - Custom Response Types (서버 응답 디코딩용) + +private struct TermsResponse: Decodable { + let isSuccess: Bool? + let code: String? + let message: String? + let result: TermsResult? +} + +private struct TermsResult: Decodable { + let payloads: [TermPayload]? +} + +private struct TermPayload: Decodable { + let termId: Int64? + let title: String? + let body: String? + let optional: Bool? +} + +// MARK: - Terms API Service Implementation + +final class TermsAPIService: TermsAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = Self.createJSONDecoder() + } + + private static func createJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + let formatter1 = ISO8601DateFormatter() + formatter1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter1.date(from: dateString) { return date } + + let formatter2 = ISO8601DateFormatter() + formatter2.formatOptions = [.withInternetDateTime] + if let date = formatter2.date(from: dateString) { return date } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "날짜 파싱 실패") + } + return decoder + } + + // MARK: - GET /terms + + func fetchTerms() async throws -> [TermItem] { + let input = Operations.Term_getTerms.Input() + let response = try await client.Term_getTerms(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + // 커스텀 타입으로 디코딩 (서버 응답에 title, optional 필드 있음) + let decoded = try jsonDecoder.decode(TermsResponse.self, from: data) + + guard let payloads = decoded.result?.payloads else { + throw TermsAPIError.noData + } + + return payloads.compactMap { payload in + guard let termId = payload.termId else { return nil } + return TermItem( + termId: termId, + title: payload.title ?? "", + isOptional: payload.optional ?? false + ) + } + + case .undocumented(statusCode: let code, _): + throw TermsAPIError.serverError(statusCode: code) + } + } + + // MARK: - POST /terms + + func agreeTerms(agreements: [TermAgreement]) async throws { + let payloads = agreements.map { agreement in + Components.Schemas.Payload( + termId: agreement.termId, + agreed: agreement.agreed + ) + } + + let requestBody = Components.Schemas.TermAgreeRequest(payloads: payloads) + let input = Operations.Term_agreeTerm.Input(body: .json(requestBody)) + let response = try await client.Term_agreeTerm(input) + + switch response { + case .ok: + print("✅ 약관 동의 완료") + return + case .undocumented(statusCode: let code, _): + throw TermsAPIError.serverError(statusCode: code) + } + } +} + +// MARK: - TermsAPIError + +enum TermsAPIError: LocalizedError { + case noData + case serverError(statusCode: Int) + + var errorDescription: String? { + switch self { + case .noData: + return "약관 데이터가 없습니다." + case .serverError(let code): + return "서버 오류 (\(code))" + } + } +} diff --git a/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift b/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift index 411f47aa..61f66e7c 100644 --- a/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift +++ b/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift @@ -9,85 +9,122 @@ import SwiftUI struct TermsAgreementView: View { @Environment(\.dismiss) private var dismiss - - // 약관 상태 관리 - @State private var isServiceAgreed = false - @State private var isPrivacyAgreed = false - @State private var isLocationAgreed = false - @State private var isMarketingAgreed = false - - // 전체 동의 계산 프로퍼티 + + // 완료 콜백 + let onComplete: () -> Void + + // API Service + private let termsAPIService: TermsAPIServiceProtocol + + // 약관 상태 관리 (termId 매핑) + @State private var agreements: [Int64: Bool] = [ + 1: false, // 서비스 이용약관 (필수) + 2: false, // 개인정보 처리방침 (필수) + 3: false, // 위치기반 서비스 이용약관 (필수) + 4: false, // 마케팅 정보 수신 동의 (선택) + 5: false // 푸시 알림 수신 동의 (선택) + ] + + // 로딩 상태 + @State private var isLoading = false + @State private var errorMessage: String? + + // 전체 동의 여부 private var isAllAgreed: Bool { - get { - isServiceAgreed && isPrivacyAgreed && isLocationAgreed && isMarketingAgreed - } - set { - isServiceAgreed = newValue - isPrivacyAgreed = newValue - isLocationAgreed = newValue - isMarketingAgreed = newValue - } + agreements.values.allSatisfy { $0 } } - - // 필수 항목 동의 여부 확인 + + // 필수 항목 동의 여부 (termId 1, 2, 3) private var canProceed: Bool { - isServiceAgreed && isPrivacyAgreed && isLocationAgreed + (agreements[1] ?? false) && (agreements[2] ?? false) && (agreements[3] ?? false) + } + + init( + onComplete: @escaping () -> Void, + termsAPIService: TermsAPIServiceProtocol = TermsAPIService() + ) { + self.onComplete = onComplete + self.termsAPIService = termsAPIService } - + var body: some View { VStack(alignment: .leading, spacing: 0) { - // 상단 뒤로가기 버튼 - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .font(.codive_title1) - .foregroundColor(.Codive.main1) - } - .padding(.top, 10) - .padding(.horizontal, 20) - // 타이틀 Text("약관에 동의하시면\n회원가입이 완료됩니다.") .font(.codive_title1) .lineSpacing(6) .padding(.top, 30) .padding(.horizontal, 20) - + Spacer() - + // 약관 리스트 섹션 VStack(spacing: 0) { // 전체 동의 AgreementRow( title: "전체 동의", isAgreed: Binding( - get: { self.isAllAgreed }, + get: { isAllAgreed }, set: { newValue in - self.isServiceAgreed = newValue - self.isPrivacyAgreed = newValue - self.isLocationAgreed = newValue - self.isMarketingAgreed = newValue + for key in agreements.keys { + agreements[key] = newValue + } } ), isBold: true, showChevron: false ) - + Divider() .background(Color.Codive.grayscale2) .padding(.vertical, 10) - + // 개별 항목들 - AgreementRow(title: "서비스 이용약관", isAgreed: $isServiceAgreed, isRequired: true) - AgreementRow(title: "개인정보 수집/이용 동의", isAgreed: $isPrivacyAgreed, isRequired: true) - AgreementRow(title: "위치 기반 서비스 이용약관 동의", isAgreed: $isLocationAgreed, isRequired: true) - AgreementRow(title: "마케팅 정보수신 동의", isAgreed: $isMarketingAgreed, isRequired: false) + AgreementRow( + title: "서비스 이용약관", + isAgreed: binding(for: 1), + isRequired: true + ) + AgreementRow( + title: "개인정보 수집/이용 동의", + isAgreed: binding(for: 2), + isRequired: true + ) + AgreementRow( + title: "위치 기반 서비스 이용약관 동의", + isAgreed: binding(for: 3), + isRequired: true + ) + AgreementRow( + title: "마케팅 정보수신 동의", + isAgreed: binding(for: 4), + isRequired: false + ) + AgreementRow( + title: "푸시 알림 수신 동의", + isAgreed: binding(for: 5), + isRequired: false + ) } .padding(.horizontal, 20) .padding(.bottom, 50) - + + // 에러 메시지 + if let errorMessage = errorMessage { + Text(errorMessage) + .font(.codive_body3_regular) + .foregroundColor(.red) + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + // 가입 완료 버튼 - CustomButton(text: "가입 완료", widthType: .fixed, isEnabled: canProceed) { - // 회원가입 완료 로직 + CustomButton( + text: isLoading ? "처리 중..." : "가입 완료", + widthType: .fixed, + isEnabled: canProceed && !isLoading + ) { + submitAgreements() } .frame(height: 56) .padding(.horizontal, 20) @@ -95,6 +132,45 @@ struct TermsAgreementView: View { } .navigationBarHidden(true) } + + // MARK: - Helper Methods + + private func binding(for termId: Int64) -> Binding { + Binding( + get: { agreements[termId] ?? false }, + set: { agreements[termId] = $0 } + ) + } + + private func submitAgreements() { + isLoading = true + errorMessage = nil + + Task { + do { + // 동의 정보 생성 + let termAgreements = agreements.map { termId, agreed in + TermAgreement(termId: termId, agreed: agreed) + } + + // POST /terms 호출 + try await termsAPIService.agreeTerms(agreements: termAgreements) + + print("✅ 약관 동의 완료!") + + // 메인으로 이동 + await MainActor.run { + isLoading = false + onComplete() + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = "약관 동의에 실패했습니다: \(error.localizedDescription)" + } + } + } + } } // 개별 약관 로우 컴포넌트 @@ -104,7 +180,7 @@ struct AgreementRow: View { var isBold: Bool = false var isRequired: Bool? = nil var showChevron: Bool = true - + var body: some View { HStack(spacing: 12) { // 체크박스 @@ -113,7 +189,7 @@ struct AgreementRow: View { .font(.codive_title1) .foregroundColor(isAgreed ? .Codive.point1 : .Codive.point4) } - + // 제목 (필수/선택 강조 포함) HStack(spacing: 4) { if let isRequired = isRequired { @@ -124,9 +200,9 @@ struct AgreementRow: View { } .font(isBold ? .codive_body1_bold : .codive_body1_regular) .foregroundColor(isBold ? .Codive.grayscale1 : .Codive.grayscale4) - + Spacer() - + // 상세 보기 버튼 if showChevron { Button(action: { /* 상세 페이지 이동 */ }) { @@ -141,5 +217,5 @@ struct AgreementRow: View { } #Preview { - TermsAgreementView() + TermsAgreementView(onComplete: {}) } diff --git a/Codive/Router/AppRouter.swift b/Codive/Router/AppRouter.swift index 1bdf9bdf..f8c2ae59 100644 --- a/Codive/Router/AppRouter.swift +++ b/Codive/Router/AppRouter.swift @@ -10,9 +10,10 @@ import SwiftUI // 앱의 최상위 상태 정의 enum AppState { - case splash // 스플래시 화면 - case auth // 인증 플로우 (온보딩/로그인) - case main // 메인 플로우 + case splash // 스플래시 화면 + case auth // 인증 플로우 (온보딩/로그인) + case termsAgreement // 약관 동의 화면 + case main // 메인 플로우 } // 상태 전환 라우터 @@ -20,6 +21,7 @@ enum AppState { final class AppRouter: ObservableObject { @Published var currentAppState: AppState + @Published var isLoading: Bool = false // 로딩 상태 init() { // 앱 시작시 스플래시부터 시작 @@ -32,10 +34,24 @@ final class AppRouter: ObservableObject { currentAppState = .auth } + func navigateToTerms() { + isLoading = false + currentAppState = .termsAgreement + } + func navigateToMain() { + isLoading = false currentAppState = .main } + func showLoading() { + isLoading = true + } + + func hideLoading() { + isLoading = false + } + func logout() { currentAppState = .auth } diff --git a/Codive/Router/ViewFactory/AuthViewFactory.swift b/Codive/Router/ViewFactory/AuthViewFactory.swift index 0e041e1d..db025d9e 100644 --- a/Codive/Router/ViewFactory/AuthViewFactory.swift +++ b/Codive/Router/ViewFactory/AuthViewFactory.swift @@ -29,7 +29,8 @@ final class AuthViewFactory { // SignUpView(viewModel: authDIContainer.makeSignUpViewModel()) Text("회원가입 화면") // 임시 case .termsAgreement: - TermsAgreementView() + // AppRootView에서 직접 처리하므로 여기서는 사용되지 않음 + TermsAgreementView(onComplete: {}) default: EmptyView() } diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index bc98135d..dd6b1af3 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "62672e060cb64fbe3bb5f4b9760f05c58d3a99c1" + "revision" : "35da8eba6ff8db73b79a16a148a3ccf9d10b8f84" } }, { From 176458102fa6b2964d42b40c178e2aa0f7b3d50c Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:07:43 +0900 Subject: [PATCH 12/37] =?UTF-8?q?[#39]=20=EC=9E=90=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Auth/Data/AuthAPIService.swift | 86 +++++++++++++ .../Features/Auth/Data/TermsAPIService.swift | 2 +- Codive/Features/Auth/Data/TokenService.swift | 114 ++++++++++++++++++ .../Auth/Presentation/View/SplashView.swift | 108 ++++++++++++++++- .../Closet/Data/ClothAPIService.swift | 2 +- 5 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 Codive/Features/Auth/Data/TokenService.swift diff --git a/Codive/Features/Auth/Data/AuthAPIService.swift b/Codive/Features/Auth/Data/AuthAPIService.swift index f76e7191..98069416 100644 --- a/Codive/Features/Auth/Data/AuthAPIService.swift +++ b/Codive/Features/Auth/Data/AuthAPIService.swift @@ -12,6 +12,14 @@ import OpenAPIRuntime // MARK: - Auth API Service Protocol protocol AuthAPIServiceProtocol { func checkAuthStatus() async -> AuthStatusResult + func reissueTokens(refreshToken: String) async -> Result + func renewDeviceToken(deviceToken: String) async -> Result +} + +// MARK: - Token Pair +struct TokenPair { + let accessToken: String + let refreshToken: String } // MARK: - Auth API Service Implementation @@ -132,4 +140,82 @@ final class AuthAPIService: AuthAPIServiceProtocol { return .failure(.networkError(error.localizedDescription)) } } + + // MARK: - Token Reissue + + func reissueTokens(refreshToken: String) async -> Result { + print("🔄 [AuthAPI] 토큰 재발급 요청") + + do { + let requestBody = Components.Schemas.TokenReissueRequest(refreshToken: refreshToken) + let input = Operations.Auth_reissueTokens.Input(body: .json(requestBody)) + let response = try await client.Auth_reissueTokens(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode(TokenReissueResponse.self, from: data) + + guard let result = apiResponse.result, + let accessToken = result.accessToken, + let newRefreshToken = result.refreshToken else { + print("❌ [AuthAPI] 토큰 재발급 응답 파싱 실패") + return .failure(.networkError("토큰 재발급 응답 파싱 실패")) + } + + print("✅ [AuthAPI] 토큰 재발급 성공") + return .success(TokenPair(accessToken: accessToken, refreshToken: newRefreshToken)) + + case .undocumented(statusCode: let statusCode, _): + print("❌ [AuthAPI] 토큰 재발급 실패: \(statusCode)") + return .failure(.networkError("토큰 재발급 실패: \(statusCode)")) + } + + } catch { + print("❌ [AuthAPI] 토큰 재발급 에러: \(error)") + return .failure(.networkError(error.localizedDescription)) + } + } + + // MARK: - Device Token + + func renewDeviceToken(deviceToken: String) async -> Result { + print("📱 [AuthAPI] 디바이스 토큰 갱신 요청") + + do { + let requestBody = Components.Schemas.DeviceTokenRenewRequest(deviceToken: deviceToken) + let input = Operations.Auth_renewDeviceToken.Input(body: .json(requestBody)) + let response = try await client.Auth_renewDeviceToken(input) + + switch response { + case .ok: + print("✅ [AuthAPI] 디바이스 토큰 갱신 성공") + return .success(()) + + case .undocumented(statusCode: let statusCode, _): + print("❌ [AuthAPI] 디바이스 토큰 갱신 실패: \(statusCode)") + return .failure(.networkError("디바이스 토큰 갱신 실패: \(statusCode)")) + } + + } catch { + print("❌ [AuthAPI] 디바이스 토큰 갱신 에러: \(error)") + return .failure(.networkError(error.localizedDescription)) + } + } +} + +// MARK: - Custom Response Types + +private struct TokenReissueResponse: Decodable { + let isSuccess: Bool? + let code: String? + let message: String? + let result: TokenResult? + + struct TokenResult: Decodable { + let accessToken: String? + let refreshToken: String? + } } diff --git a/Codive/Features/Auth/Data/TermsAPIService.swift b/Codive/Features/Auth/Data/TermsAPIService.swift index 05ec4def..222eaaad 100644 --- a/Codive/Features/Auth/Data/TermsAPIService.swift +++ b/Codive/Features/Auth/Data/TermsAPIService.swift @@ -2,7 +2,7 @@ // TermsAPIService.swift // Codive // -// Created by Assistant on 1/13/26. +// Created by 황상환 on 1/13/26. // import Foundation diff --git a/Codive/Features/Auth/Data/TokenService.swift b/Codive/Features/Auth/Data/TokenService.swift new file mode 100644 index 00000000..58d7ce1f --- /dev/null +++ b/Codive/Features/Auth/Data/TokenService.swift @@ -0,0 +1,114 @@ +// +// TokenService.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation + +// MARK: - Token Service Protocol + +protocol TokenServiceProtocol { + func isAccessTokenExpired() -> Bool + func isRefreshTokenExpired() -> Bool + func hasValidTokens() -> Bool + func getAccessToken() -> String? + func getRefreshToken() -> String? +} + +// MARK: - Token Service Implementation + +final class TokenService: TokenServiceProtocol { + + private let keychainManager: KeychainManager + + // 토큰 만료 여유 시간 (5분 전에 만료로 간주) + private let expirationBuffer: TimeInterval = 300 + + init(keychainManager: KeychainManager = KeychainManager.shared) { + self.keychainManager = keychainManager + } + + // MARK: - Public Methods + + /// 키체인에 유효한 토큰이 있는지 확인 + func hasValidTokens() -> Bool { + guard let _ = getAccessToken(), let _ = getRefreshToken() else { + return false + } + return true + } + + /// Access Token 만료 여부 확인 + func isAccessTokenExpired() -> Bool { + guard let token = getAccessToken() else { return true } + return isTokenExpired(token) + } + + /// Refresh Token 만료 여부 확인 + func isRefreshTokenExpired() -> Bool { + guard let token = getRefreshToken() else { return true } + return isTokenExpired(token) + } + + /// Access Token 가져오기 + func getAccessToken() -> String? { + return try? keychainManager.getAccessToken() + } + + /// Refresh Token 가져오기 + func getRefreshToken() -> String? { + return try? keychainManager.getRefreshToken() + } + + // MARK: - Private Methods + + /// JWT 토큰 만료 여부 확인 + private func isTokenExpired(_ token: String) -> Bool { + guard let exp = extractExpiration(from: token) else { + // 파싱 실패 시 만료로 간주 + return true + } + + let currentTime = Date().timeIntervalSince1970 + let isExpired = currentTime > (exp - expirationBuffer) + + return isExpired + } + + /// JWT에서 만료 시간(exp) 추출 + private func extractExpiration(from token: String) -> TimeInterval? { + let parts = token.split(separator: ".") + guard parts.count == 3 else { return nil } + + let payloadPart = String(parts[1]) + + // Base64 URL Safe → 일반 Base64로 변환 + 패딩 추가 + guard let payloadData = base64URLDecode(payloadPart) else { + return nil + } + + guard let payload = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let exp = payload["exp"] as? TimeInterval else { + return nil + } + + return exp + } + + /// Base64 URL Safe 디코딩 + private func base64URLDecode(_ string: String) -> Data? { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + // 패딩 추가 + let remainder = base64.count % 4 + if remainder > 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + + return Data(base64Encoded: base64) + } +} diff --git a/Codive/Features/Auth/Presentation/View/SplashView.swift b/Codive/Features/Auth/Presentation/View/SplashView.swift index 699d3f12..8ce5eb89 100644 --- a/Codive/Features/Auth/Presentation/View/SplashView.swift +++ b/Codive/Features/Auth/Presentation/View/SplashView.swift @@ -61,7 +61,7 @@ struct SplashContainerView: View { } } -// MARK: - SplashViewModel (타이핑 애니메이션 로직) +// MARK: - SplashViewModel (타이핑 애니메이션 + 자동 로그인) @MainActor final class SplashViewModel: ObservableObject { @@ -71,8 +71,21 @@ final class SplashViewModel: ObservableObject { private let typingSpeed: Double = 0.4 private let appRouter: AppRouter - init(appRouter: AppRouter) { + // 자동 로그인 관련 서비스 + private let tokenService: TokenServiceProtocol + private let authAPIService: AuthAPIServiceProtocol + private let keychainManager: KeychainManager + + init( + appRouter: AppRouter, + tokenService: TokenServiceProtocol = TokenService(), + authAPIService: AuthAPIServiceProtocol = AuthAPIService(), + keychainManager: KeychainManager = KeychainManager.shared + ) { self.appRouter = appRouter + self.tokenService = tokenService + self.authAPIService = authAPIService + self.keychainManager = keychainManager } func startAnimation() async { @@ -91,11 +104,98 @@ final class SplashViewModel: ObservableObject { // 애니메이션 종료 후 0.5초 대기 do { try await Task.sleep(for: .seconds(0.5)) - appRouter.finishSplash() } catch { - // Task 취소됨 return } + + // 자동 로그인 체크 + await checkAutoLogin() + } + + // MARK: - Auto Login Logic + + private func checkAutoLogin() async { + print("🔐 [Splash] 자동 로그인 체크 시작") + + // 1. 키체인에 토큰이 있는지 확인 + guard tokenService.hasValidTokens() else { + print("📭 [Splash] 저장된 토큰 없음 → 로그인 화면으로") + appRouter.finishSplash() + return + } + + print("🔑 [Splash] 저장된 토큰 발견") + + // 2. Access Token 유효성 검사 + if !tokenService.isAccessTokenExpired() { + print("✅ [Splash] Access Token 유효 → 상태 확인") + await proceedWithValidToken() + return + } + + print("⏰ [Splash] Access Token 만료됨 → 재발급 시도") + + // 3. Refresh Token 유효성 검사 + if tokenService.isRefreshTokenExpired() { + print("⏰ [Splash] Refresh Token도 만료됨 → 로그인 화면으로") + clearTokensAndGoToAuth() + return + } + + // 4. 토큰 재발급 + await reissueTokens() + } + + private func reissueTokens() async { + guard let refreshToken = tokenService.getRefreshToken() else { + clearTokensAndGoToAuth() + return + } + + let result = await authAPIService.reissueTokens(refreshToken: refreshToken) + + switch result { + case .success(let tokenPair): + print("✅ [Splash] 토큰 재발급 성공") + // 새 토큰 저장 + try? keychainManager.saveAccessToken(tokenPair.accessToken) + try? keychainManager.saveRefreshToken(tokenPair.refreshToken) + // 상태 확인 진행 + await proceedWithValidToken() + + case .failure(let error): + print("❌ [Splash] 토큰 재발급 실패: \(error)") + clearTokensAndGoToAuth() + } + } + + private func proceedWithValidToken() async { + // 회원 상태 확인 + let statusResult = await authAPIService.checkAuthStatus() + + switch statusResult { + case .success(let status): + switch status { + case .notAgreed: + print("📋 [Splash] 약관 동의 필요 → TermsAgreementView로") + appRouter.navigateToTerms() + case .registered: + print("✅ [Splash] 가입 완료 → 메인으로") + appRouter.navigateToMain() + } + + case .failure(let error): + print("❌ [Splash] 상태 확인 실패: \(error)") + // 실패 시 로그인 화면으로 + appRouter.finishSplash() + } + } + + private func clearTokensAndGoToAuth() { + print("🗑️ [Splash] 토큰 삭제 → 로그인 화면으로") + try? keychainManager.deleteAccessToken() + try? keychainManager.deleteRefreshToken() + appRouter.finishSplash() } } diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index 7c42aacc..080a5a79 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -2,7 +2,7 @@ // ClothAPIService.swift // Codive // -// Created by Assistant on 1/12/26. +// Created by 황상환 on 1/12/26. // import Foundation From 3c9c51f203c30286ef6bfa5349966784dfa6b2cc Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 14 Jan 2026 00:34:16 +0900 Subject: [PATCH 13/37] =?UTF-8?q?[#39]=20=EA=B8=B0=EB=A1=9D=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Constants/SituationConstants.swift | 52 +++++ .../Feed/Data/Constants/StyleConstants.swift | 56 +++++ .../Data/DataSources/RecordDataSource.swift | 119 +++++++++- .../Feed/Data/HistoryAPIService.swift | 207 ++++++++++++++++++ .../Repositories/RecordRepositoryImpl.swift | 10 +- .../Domain/Protocols/RecordRepository.swift | 2 +- .../Domain/UseCases/CreateRecordUseCase.swift | 12 +- .../Add/ViewModel/RecordDetailViewModel.swift | 135 ++++++++++-- 8 files changed, 563 insertions(+), 30 deletions(-) create mode 100644 Codive/Features/Feed/Data/Constants/SituationConstants.swift create mode 100644 Codive/Features/Feed/Data/Constants/StyleConstants.swift create mode 100644 Codive/Features/Feed/Data/HistoryAPIService.swift diff --git a/Codive/Features/Feed/Data/Constants/SituationConstants.swift b/Codive/Features/Feed/Data/Constants/SituationConstants.swift new file mode 100644 index 00000000..8ba73c2a --- /dev/null +++ b/Codive/Features/Feed/Data/Constants/SituationConstants.swift @@ -0,0 +1,52 @@ +// +// SituationConstants.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation + +// MARK: - Situation Item + +struct SituationItem: Identifiable, Hashable { + let id: Int64 + let name: String + + var situationId: Int64 { id } +} + +// MARK: - Situation Constants + +enum SituationConstants { + + static let all: [SituationItem] = [ + SituationItem(id: 1, name: "데일리"), + SituationItem(id: 2, name: "여행"), + SituationItem(id: 3, name: "데이트"), + SituationItem(id: 4, name: "파티"), + SituationItem(id: 5, name: "출근룩"), + SituationItem(id: 6, name: "운동"), + SituationItem(id: 7, name: "축제") + ] + + /// 이름으로 SituationItem 찾기 + static func find(byName name: String) -> SituationItem? { + all.first { $0.name == name } + } + + /// ID로 SituationItem 찾기 + static func find(byId id: Int64) -> SituationItem? { + all.first { $0.id == id } + } + + /// 이름으로 ID 가져오기 + static func getId(from name: String) -> Int64? { + find(byName: name)?.id + } + + /// 이름 Set에서 첫 번째 ID 가져오기 + static func getFirstId(from names: Set) -> Int64? { + names.compactMap { find(byName: $0)?.id }.first + } +} diff --git a/Codive/Features/Feed/Data/Constants/StyleConstants.swift b/Codive/Features/Feed/Data/Constants/StyleConstants.swift new file mode 100644 index 00000000..2069ac52 --- /dev/null +++ b/Codive/Features/Feed/Data/Constants/StyleConstants.swift @@ -0,0 +1,56 @@ +// +// StyleConstants.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation + +// MARK: - Style Item + +struct StyleItem: Identifiable, Hashable { + let id: Int64 + let name: String + + var styleId: Int64 { id } +} + +// MARK: - Style Constants + +enum StyleConstants { + + static let all: [StyleItem] = [ + StyleItem(id: 1, name: "캐주얼"), + StyleItem(id: 2, name: "스트릿"), + StyleItem(id: 3, name: "미니멀"), + StyleItem(id: 4, name: "클래식"), + StyleItem(id: 5, name: "시크"), + StyleItem(id: 6, name: "빈티지"), + StyleItem(id: 7, name: "걸리시"), + StyleItem(id: 8, name: "스포티"), + StyleItem(id: 9, name: "러블리"), + StyleItem(id: 10, name: "오피스룩"), + StyleItem(id: 11, name: "하이틴") + ] + + /// 이름으로 StyleItem 찾기 + static func find(byName name: String) -> StyleItem? { + all.first { $0.name == name } + } + + /// ID로 StyleItem 찾기 + static func find(byId id: Int64) -> StyleItem? { + all.first { $0.id == id } + } + + /// 이름 배열로 ID 배열 변환 + static func getIds(from names: [String]) -> [Int64] { + names.compactMap { find(byName: $0)?.id } + } + + /// 이름 Set으로 ID 배열 변환 + static func getIds(from names: Set) -> [Int64] { + getIds(from: Array(names)) + } +} diff --git a/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift b/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift index 103576ec..eebd06c6 100644 --- a/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift @@ -6,14 +6,125 @@ // import Foundation +import UIKit + +// MARK: - Record Create Request + +struct RecordCreateRequest { + let content: String? + let situationId: Int64 + let styleIds: [Int64] + let hashtags: [String] + let photos: [RecordPhoto] +} + +struct RecordPhoto { + let image: UIImage + let clothTags: [RecordClothTag] +} + +struct RecordClothTag { + let clothId: Int64 + let locationX: Double + let locationY: Double +} + +// MARK: - Protocol protocol RecordDataSource { - func create(record: Record) async -> Bool + func createRecord(request: RecordCreateRequest) async throws -> Int64 } +// MARK: - Implementation + final class DefaultRecordDataSource: RecordDataSource { - func create(record: Record) async -> Bool { - print("Creating record on remote server: \(record)") - return true + + private let clothAPIService: ClothAPIServiceProtocol + private let historyAPIService: HistoryAPIServiceProtocol + + init( + clothAPIService: ClothAPIServiceProtocol = ClothAPIService(), + historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + ) { + self.clothAPIService = clothAPIService + self.historyAPIService = historyAPIService + } + + func createRecord(request: RecordCreateRequest) async throws -> Int64 { + print("📝 [RecordDataSource] 기록 생성 시작") + + // Step 1: 이미지 업로드 (Presigned URL → S3) + let imageUrls = try await uploadImages(photos: request.photos) + print("✅ [RecordDataSource] 이미지 \(imageUrls.count)개 업로드 완료") + + // Step 2: API 요청 생성 + let payloads = zip(imageUrls, request.photos).map { url, photo in + HistoryImagePayload( + imageUrl: url, + clothTags: photo.clothTags.map { tag in + HistoryClothTag( + clothId: tag.clothId, + locationX: tag.locationX, + locationY: tag.locationY + ) + } + ) + } + + let apiRequest = HistoryCreateAPIRequest( + content: request.content, + situationId: request.situationId, + styleIds: request.styleIds, + hashtags: request.hashtags, + payloads: payloads + ) + + // Step 3: 기록 생성 API 호출 + let historyId = try await historyAPIService.createHistory(request: apiRequest) + print("✅ [RecordDataSource] 기록 생성 완료 - historyId: \(historyId)") + + return historyId + } + + // MARK: - Private Methods + + private func uploadImages(photos: [RecordPhoto]) async throws -> [String] { + // 이미지 데이터 변환 + let imageDatas = photos.compactMap { photo -> Data? in + photo.image.jpegData(compressionQuality: 0.8) + } + + guard imageDatas.count == photos.count else { + throw RecordDataSourceError.imageConversionFailed + } + + // Presigned URL 발급 (옷 추가 API 재사용) + let presignedInfos = try await clothAPIService.getPresignedUrls(for: imageDatas) + + // S3 업로드 + for (index, (imageData, presignedInfo)) in zip(imageDatas, presignedInfos).enumerated() { + print("📤 [RecordDataSource] 이미지 \(index + 1)/\(imageDatas.count) 업로드 중...") + try await clothAPIService.uploadImageToS3( + presignedUrl: presignedInfo.presignedUrl, + imageData: imageData, + contentMD5: presignedInfo.md5Hash + ) + } + + // 최종 URL 반환 + return presignedInfos.map { $0.finalUrl } + } +} + +// MARK: - Error + +enum RecordDataSourceError: LocalizedError { + case imageConversionFailed + + var errorDescription: String? { + switch self { + case .imageConversionFailed: + return "이미지 변환에 실패했습니다." + } } } diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift new file mode 100644 index 00000000..5484fd66 --- /dev/null +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -0,0 +1,207 @@ +// +// HistoryAPIService.swift +// Codive +// +// Created by 황상환 on 1/13/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - History API Service Protocol + +protocol HistoryAPIServiceProtocol { + func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 +} + +// MARK: - Request Types + +struct HistoryCreateAPIRequest { + let content: String? + let situationId: Int64 + let styleIds: [Int64] + let hashtags: [String] + let payloads: [HistoryImagePayload] +} + +struct HistoryImagePayload { + let imageUrl: String + let clothTags: [HistoryClothTag] +} + +struct HistoryClothTag { + let clothId: Int64 + let locationX: Double + let locationY: Double +} + +// MARK: - History API Service Implementation + +final class HistoryAPIService: HistoryAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = Self.createJSONDecoder() + } + + private static func createJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + let formats = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss" + ] + + for format in formats { + dateFormatter.dateFormat = format + if let date = dateFormatter.date(from: dateString) { return date } + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "날짜 파싱 실패: \(dateString)" + ) + } + return decoder + } + + // MARK: - Create History + + func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 { + print("📝 [HistoryAPI] 기록 생성 요청") + + // API 요청 바디 생성 + let payloads = request.payloads.map { payload in + Components.Schemas.HistoryCreatePayload( + imageUrl: payload.imageUrl, + clothTags: payload.clothTags.map { tag in + Components.Schemas.ClothTag( + clothId: tag.clothId, + locationX: tag.locationX, + locationY: tag.locationY + ) + } + ) + } + + // hashtags를 OpenAPIValueContainer로 변환 + let hashtagContainers: [OpenAPIRuntime.OpenAPIValueContainer]? = request.hashtags.isEmpty + ? nil + : request.hashtags.compactMap { try? OpenAPIRuntime.OpenAPIValueContainer(unvalidatedValue: $0) } + + let requestBody = Components.Schemas.HistoryCreateRequest( + content: request.content, + situationId: request.situationId, + styleIds: request.styleIds, + hashtags: hashtagContainers, + payloads: payloads + ) + + // 디버그: 요청 바디 출력 + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📤 [HistoryAPI] 요청 바디:") + print(" - content: \(request.content ?? "nil")") + print(" - situationId: \(request.situationId)") + print(" - styleIds: \(request.styleIds)") + print(" - hashtags: \(request.hashtags)") + print(" - payloads 수: \(payloads.count)") + for (index, payload) in payloads.enumerated() { + print(" - payload[\(index)].imageUrl: \(payload.imageUrl ?? "nil")") + print(" - payload[\(index)].clothTags 수: \(payload.clothTags?.count ?? 0)") + if let tags = payload.clothTags { + for (tagIndex, tag) in tags.enumerated() { + print(" - tag[\(tagIndex)]: clothId=\(tag.clothId), x=\(tag.locationX), y=\(tag.locationY)") + } + } + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + // JSON으로 직접 인코딩해서 확인 + if let jsonData = try? JSONEncoder().encode(requestBody), + let jsonString = String(data: jsonData, encoding: .utf8) { + print("📤 [HistoryAPI] 실제 전송 JSON:") + print(jsonString) + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + } + + let input = Operations.History_createHistory.Input(body: .json(requestBody)) + let response = try await client.History_createHistory(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + // 디버그 로그 + if let jsonString = String(data: data, encoding: .utf8) { + print("📩 [HistoryAPI] 응답: \(jsonString)") + } + + let decoded = try jsonDecoder.decode(HistoryCreateResponse.self, from: data) + + guard let historyId = decoded.result?.historyId else { + throw HistoryAPIError.noData + } + + print("✅ [HistoryAPI] 기록 생성 성공 - historyId: \(historyId)") + return historyId + + case .undocumented(statusCode: let code, let undocPayload): + print("❌ [HistoryAPI] 기록 생성 실패: \(code)") + // 에러 응답 바디 출력 + if let body = undocPayload.body { + let errorData = try? await Data(collecting: body, upTo: .max) + if let errorData, let errorString = String(data: errorData, encoding: .utf8) { + print("❌ [HistoryAPI] 에러 응답: \(errorString)") + } + } + throw HistoryAPIError.serverError(statusCode: code) + } + } +} + +// MARK: - Response Types + +private struct HistoryCreateResponse: Decodable { + let isSuccess: Bool? + let code: String? + let message: String? + let result: HistoryResult? + + struct HistoryResult: Decodable { + let historyId: Int64? + } +} + +// MARK: - Error + +enum HistoryAPIError: LocalizedError { + case noData + case serverError(statusCode: Int) + + var errorDescription: String? { + switch self { + case .noData: + return "응답 데이터가 없습니다." + case .serverError(let code): + return "서버 오류 (\(code))" + } + } +} diff --git a/Codive/Features/Feed/Data/Repositories/RecordRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/RecordRepositoryImpl.swift index 24c1beb7..04e0389c 100644 --- a/Codive/Features/Feed/Data/Repositories/RecordRepositoryImpl.swift +++ b/Codive/Features/Feed/Data/Repositories/RecordRepositoryImpl.swift @@ -9,12 +9,12 @@ import Foundation final class DefaultRecordRepository: RecordRepository { private let dataSource: RecordDataSource - - init(dataSource: RecordDataSource) { + + init(dataSource: RecordDataSource = DefaultRecordDataSource()) { self.dataSource = dataSource } - - func create(record: Record) async -> Bool { - return await dataSource.create(record: record) + + func createRecord(request: RecordCreateRequest) async throws -> Int64 { + return try await dataSource.createRecord(request: request) } } diff --git a/Codive/Features/Feed/Domain/Protocols/RecordRepository.swift b/Codive/Features/Feed/Domain/Protocols/RecordRepository.swift index 0d77aac1..1e4b840e 100644 --- a/Codive/Features/Feed/Domain/Protocols/RecordRepository.swift +++ b/Codive/Features/Feed/Domain/Protocols/RecordRepository.swift @@ -8,5 +8,5 @@ import Foundation protocol RecordRepository { - func create(record: Record) async -> Bool + func createRecord(request: RecordCreateRequest) async throws -> Int64 } diff --git a/Codive/Features/Feed/Domain/UseCases/CreateRecordUseCase.swift b/Codive/Features/Feed/Domain/UseCases/CreateRecordUseCase.swift index 4eb22acb..057c6be4 100644 --- a/Codive/Features/Feed/Domain/UseCases/CreateRecordUseCase.swift +++ b/Codive/Features/Feed/Domain/UseCases/CreateRecordUseCase.swift @@ -8,17 +8,17 @@ import Foundation protocol CreateRecordUseCase { - func create(record: Record) async -> Bool + func execute(request: RecordCreateRequest) async throws -> Int64 } final class DefaultCreateRecordUseCase: CreateRecordUseCase { private let repository: RecordRepository - - init(repository: RecordRepository) { + + init(repository: RecordRepository = DefaultRecordRepository()) { self.repository = repository } - - func create(record: Record) async -> Bool { - return await repository.create(record: record) + + func execute(request: RecordCreateRequest) async throws -> Int64 { + return try await repository.createRecord(request: request) } } diff --git a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift index 30de794f..f63ab184 100644 --- a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift @@ -13,24 +13,29 @@ import Combine // MARK: - RecordDetailViewModel @MainActor final class RecordDetailViewModel: ObservableObject { - + // MARK: - Properties private var cancellables = Set() - + @Published var selectedPhotos: [SelectedPhoto] @Published var currentPhotoIndex: Int = 0 - + // MultiSelect Properties @Published var selectedStyles: Set = [] @Published var selectedSituations: Set = [] - + // TextField Property @Published var captionText: String = "" // Alert Property @Published var showExitAlert: Bool = false + // Loading & Error State + @Published var isLoading: Bool = false + @Published var errorMessage: String? + private let navigationRouter: NavigationRouter + private let recordDataSource: RecordDataSource // MARK: - Options let styleOptions = [ @@ -67,10 +72,15 @@ final class RecordDetailViewModel: ObservableObject { } // MARK: - Initializer - init(selectedPhotos: [SelectedPhoto], navigationRouter: NavigationRouter) { + init( + selectedPhotos: [SelectedPhoto], + navigationRouter: NavigationRouter, + recordDataSource: RecordDataSource = DefaultRecordDataSource() + ) { self.selectedPhotos = selectedPhotos self.navigationRouter = navigationRouter - + self.recordDataSource = recordDataSource + // 태그 업데이트 구독 setupPhotoTagSubscription() } @@ -81,14 +91,95 @@ final class RecordDetailViewModel: ObservableObject { } func completeRecord() { - // TODO: 기록 저장 로직 - print("기록 완료") - print("선택된 스타일: \(selectedStyles)") - print("선택된 상황: \(selectedSituations)") - print("캡션: \(captionText)") - - // 메인으로 돌아가기 - navigationRouter.navigateToRoot() + guard !isLoading else { return } + + isLoading = true + errorMessage = nil + + Task { + do { + // 디버그 로그 + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📝 [RecordDetail] 기록 생성 시작") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📋 선택된 스타일 (원본): \(selectedStyles)") + print("📋 선택된 상황 (원본): \(selectedSituations)") + print("📋 캡션: \(captionText)") + print("📋 사진 수: \(selectedPhotos.count)") + + // 1. 스타일 ID 변환 + let styleIds = StyleConstants.getIds(from: selectedStyles) + print("🔄 스타일 ID 변환 결과: \(styleIds)") + guard !styleIds.isEmpty else { + throw RecordError.noStyleSelected + } + + // 2. 상황 ID 변환 (첫 번째 선택) + print("🔄 상황 ID 변환 시도...") + print(" - SituationConstants.all: \(SituationConstants.all.map { "\($0.name)(\($0.id))" })") + guard let situationId = SituationConstants.getFirstId(from: selectedSituations) else { + print("❌ 상황 ID 변환 실패! selectedSituations: \(selectedSituations)") + throw RecordError.noSituationSelected + } + print("✅ 상황 ID: \(situationId)") + + // 3. 해시태그 추출 + let hashtags = extractHashtags(from: captionText) + + // 4. 사진 데이터 변환 + let photos = selectedPhotos.map { photo in + RecordPhoto( + image: photo.croppedImage, + clothTags: photo.clothTags.map { tag in + RecordClothTag( + clothId: Int64(tag.clothId), + locationX: Double(tag.locationX), + locationY: Double(tag.locationY) + ) + } + ) + } + + // 5. 요청 생성 + let request = RecordCreateRequest( + content: captionText.isEmpty ? nil : captionText, + situationId: situationId, + styleIds: styleIds, + hashtags: hashtags, + photos: photos + ) + + // 6. API 호출 + let historyId = try await recordDataSource.createRecord(request: request) + print("✅ 기록 생성 완료 - historyId: \(historyId)") + + // 7. 성공 시 메인으로 + isLoading = false + navigationRouter.navigateToRoot() + + } catch { + isLoading = false + errorMessage = error.localizedDescription + print("❌ 기록 생성 실패: \(error)") + } + } + } + + // MARK: - Helper Methods + + private func extractHashtags(from text: String) -> [String] { + let pattern = "#[가-힣a-zA-Z0-9_]+" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + + return matches.compactMap { match in + guard let range = Range(match.range, in: text) else { return nil } + return String(text[range]) + } } func dismissView() { @@ -122,3 +213,19 @@ final class RecordDetailViewModel: ObservableObject { navigationRouter.navigate(to: .photoTag(photo: currentPhoto, allPhotos: selectedPhotos)) } } + +// MARK: - Record Error + +enum RecordError: LocalizedError { + case noStyleSelected + case noSituationSelected + + var errorDescription: String? { + switch self { + case .noStyleSelected: + return "스타일을 선택해주세요." + case .noSituationSelected: + return "상황을 선택해주세요." + } + } +} From 091a0c248c6d6601a32d8b62b6aa1d1c9ab66648 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:51:11 +0900 Subject: [PATCH 14/37] =?UTF-8?q?[#39]=20=EC=98=B7=EC=9E=A5=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=20=EB=AA=A9=EB=A1=9D=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/ClosetDIContainer.swift | 3 +- .../Closet/Data/ClothAPIService.swift | 12 ++- .../Data/DataSources/ClothDataSource.swift | 47 ++++++---- .../Components/ClothingCardView.swift | 49 ++++++++-- .../View/main/MyClosetSectionView.swift | 94 +++++++++++++------ .../View/myCloth/ClothDetailView.swift | 82 ++++++++++++---- .../ViewModel/ClothDetailViewModel.swift | 44 +++++++-- .../ViewModel/MyClosetSectionViewModel.swift | 9 +- 8 files changed, 253 insertions(+), 87 deletions(-) diff --git a/Codive/DIContainer/ClosetDIContainer.swift b/Codive/DIContainer/ClosetDIContainer.swift index 5ea0dac8..c3c08123 100644 --- a/Codive/DIContainer/ClosetDIContainer.swift +++ b/Codive/DIContainer/ClosetDIContainer.swift @@ -72,7 +72,8 @@ final class ClosetDIContainer { return ClothDetailViewModel( cloth: cloth, navigationRouter: navigationRouter, - deleteClothItemsUseCase: makeDeleteClothItemsUseCase() + deleteClothItemsUseCase: makeDeleteClothItemsUseCase(), + clothRepository: clothRepository ) } diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index 080a5a79..ee9ccd8d 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -237,10 +237,18 @@ extension ClothAPIService { switch response { case .ok(let okResponse): let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + // 디버그: 원본 JSON 출력 + if let jsonString = String(data: data, encoding: .utf8) { + print("📩 [ClothAPI] 옷 목록 응답:") + print(jsonString.prefix(1000)) + } + let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseSliceResponseClothListResponse.self, from: data) - let clothes = decoded.result?.content?.map { item in - ClothListItem(clothId: item.clothId ?? 0, imageUrl: item.ImageUrl ?? "", brand: item.brand, name: item.name) + let clothes: [ClothListItem] = decoded.result?.content?.map { item -> ClothListItem in + print("🔍 [ClothAPI] item: clothId=\(item.clothId ?? 0), ImageUrl=\(item.ImageUrl ?? "nil")") + return ClothListItem(clothId: item.clothId ?? 0, imageUrl: item.ImageUrl ?? "", brand: item.brand, name: item.name) } ?? [] return ClothListResult(clothes: clothes, isLast: decoded.result?.isLast ?? true) diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index a2c52bd4..eed930cd 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -172,37 +172,50 @@ final class DefaultClothDataSource: ClothDataSource { seasons: Set, searchText: String? ) async throws -> [Cloth] { - // TODO: 실제 API 호출로 대체 - - var filteredItems = mockMyClosetClothItems + print("📤 [ClothDataSource] 내 옷장 조회 API 호출...") - // 1. 메인 카테고리 필터링 - if let mainCategory = mainCategory, mainCategory != "전체" { - if let category = CategoryConstants.category(byName: mainCategory) { - filteredItems = filteredItems.filter { $0.categoryId == category.id } + // 카테고리 ID 변환 + var categoryId: Int64? + if let subCategory = subCategory { + // 서브카테고리 이름으로 ID 찾기 (전체 카테고리에서) + for category in CategoryConstants.all { + if let sub = category.subcategories.first(where: { $0.name == subCategory }) { + categoryId = Int64(sub.id) + break + } } } - // 2. 서브 카테고리 필터링 - // TODO: Cloth에 subCategoryId 추가되면 구현 + // API 호출 (전체 조회, 최대 100개) + let result = try await apiService.fetchClothes( + lastClothId: nil, + size: 100, + categoryId: categoryId, + seasons: Array(seasons) + ) - // 3. 계절 필터링 - if !seasons.isEmpty { - filteredItems = filteredItems.filter { cloth in - !cloth.seasons.isDisjoint(with: seasons) - } + // ClothListItem → Cloth 변환 + var clothes = result.clothes.map { item in + print("🖼️ [ClothDataSource] clothId: \(item.clothId), imageUrl: \(item.imageUrl ?? "nil")") + return Cloth( + id: Int(item.clothId), + imageUrl: item.imageUrl, + name: item.name, + brand: item.brand + ) } - // 4. 검색어 필터링 + // 검색어 필터링 (클라이언트 사이드) if let searchText = searchText, !searchText.isEmpty { - filteredItems = filteredItems.filter { cloth in + clothes = clothes.filter { cloth in let nameMatch = cloth.name?.localizedCaseInsensitiveContains(searchText) ?? false let brandMatch = cloth.brand?.localizedCaseInsensitiveContains(searchText) ?? false return nameMatch || brandMatch } } - return filteredItems + print("✅ [ClothDataSource] 내 옷장 조회 완료: \(clothes.count)개") + return clothes } func deleteClothItems(_ clothIds: [Int]) async throws { diff --git a/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift b/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift index 8b62f209..6210fe4e 100644 --- a/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift +++ b/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift @@ -9,19 +9,54 @@ import SwiftUI struct ClothingCardView: View { + let imageUrl: String? let brand: String let name: String - + + init(imageUrl: String? = nil, brand: String, name: String) { + self.imageUrl = imageUrl + self.brand = brand + self.name = name + } + var body: some View { + let _ = print("🖼️ [ClothingCard] imageUrl: \(imageUrl ?? "nil")") VStack(alignment: .leading, spacing: 0) { // 이미지 영역 - Rectangle() - .fill(Color.Codive.grayscale4) - .overlay( - Image(systemName: "photo") - .foregroundStyle(Color.Codive.grayscale3) - ) + if let imageUrl = imageUrl, !imageUrl.isEmpty, let url = URL(string: imageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + Rectangle() + .fill(Color.Codive.grayscale5) + .overlay(ProgressView()) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure(let error): + let _ = print("❌ [ClothingCard] 이미지 로드 실패: \(error)") + Rectangle() + .fill(Color.Codive.grayscale4) + .overlay( + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(Color.red) + ) + @unknown default: + EmptyView() + } + } .frame(height: 124) + .clipped() + } else { + Rectangle() + .fill(Color.Codive.grayscale4) + .overlay( + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale3) + ) + .frame(height: 124) + } // 정보 영역 VStack(alignment: .leading, spacing: 4) { diff --git a/Codive/Features/Closet/Presentation/View/main/MyClosetSectionView.swift b/Codive/Features/Closet/Presentation/View/main/MyClosetSectionView.swift index 8f772945..e780c4ce 100644 --- a/Codive/Features/Closet/Presentation/View/main/MyClosetSectionView.swift +++ b/Codive/Features/Closet/Presentation/View/main/MyClosetSectionView.swift @@ -58,41 +58,77 @@ struct MyClosetSectionView: View { } .frame(height: 200) } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - // 2개씩 묶어서 세로로 표시 - ForEach(stride(from: 0, to: viewModel.clothItems.count, by: 2).map { $0 }, id: \.self) { i in - VStack(spacing: 12) { - // 첫 번째 아이템 - ClothingCardView( - brand: viewModel.clothItems[i].brand ?? "No brand", - name: viewModel.clothItems[i].name ?? "이름 없음" - ) - .onTapGesture { - viewModel.navigateToClothDetail(viewModel.clothItems[i]) - } - - // 두 번째 아이템 (있을 경우) - if i + 1 < viewModel.clothItems.count { - ClothingCardView( - brand: viewModel.clothItems[i + 1].brand ?? "No brand", - name: viewModel.clothItems[i + 1].name ?? "이름 없음" - ) - .onTapGesture { - viewModel.navigateToClothDetail(viewModel.clothItems[i + 1]) - } - } - } - } - } - .padding(.horizontal, 20) - } + clothGridSection } } .task { await viewModel.loadClothItems() } } + + // MARK: - Cloth Grid Section + + @ViewBuilder + private var clothGridSection: some View { + let itemCount = viewModel.clothItems.count + let showTwoRows = itemCount >= 8 + + ScrollView(.horizontal, showsIndicators: false) { + if showTwoRows { + // 8개 이상: 2행 x 4열 (총 8개) + twoRowGrid + } else { + // 7개 이하: 1행 (최대 4개) + oneRowGrid + } + } + } + + /// 1행 레이아웃 (7개 이하일 때, 최대 4개 표시) + @ViewBuilder + private var oneRowGrid: some View { + HStack(spacing: 12) { + ForEach(Array(viewModel.clothItems.prefix(4).enumerated()), id: \.element.id) { _, cloth in + clothCard(for: cloth) + } + } + .padding(.horizontal, 20) + } + + /// 2행 레이아웃 (8개 이상일 때, 총 8개 표시) + @ViewBuilder + private var twoRowGrid: some View { + let displayItems = Array(viewModel.clothItems.prefix(8)) + + HStack(spacing: 12) { + // 4개씩 묶어서 세로로 표시 + ForEach(stride(from: 0, to: displayItems.count, by: 2).map { $0 }, id: \.self) { i in + VStack(spacing: 12) { + // 첫 번째 아이템 (위) + clothCard(for: displayItems[i]) + + // 두 번째 아이템 (아래, 있을 경우) + if i + 1 < displayItems.count { + clothCard(for: displayItems[i + 1]) + } + } + } + } + .padding(.horizontal, 20) + } + + /// 개별 옷 카드 + @ViewBuilder + private func clothCard(for cloth: Cloth) -> some View { + ClothingCardView( + imageUrl: cloth.imageUrl, + brand: cloth.brand ?? "No brand", + name: cloth.name ?? "이름 없음" + ) + .onTapGesture { + viewModel.navigateToClothDetail(cloth) + } + } } #Preview { diff --git a/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift b/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift index 27c2917f..348ef8d5 100644 --- a/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift +++ b/Codive/Features/Closet/Presentation/View/myCloth/ClothDetailView.swift @@ -32,31 +32,34 @@ struct ClothDetailView: View { } ) - ScrollView { - VStack(spacing: 32) { - // 1. 상품 이미지 영역 - Image(viewModel.imageUrl) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .background(Color.Codive.grayscale7) - .clipShape(RoundedRectangle(cornerRadius: 12)) + if viewModel.isLoading { + Spacer() + ProgressView() + Spacer() + } else { + ScrollView { + VStack(spacing: 32) { + // 1. 상품 이미지 영역 + clothImageView - // 2. 정보 리스트 영역 - VStack(spacing: 20) { - infoRow(label: "카테고리", value: viewModel.categoryText) - infoRow(label: "계절", value: viewModel.seasonText) - infoRow(label: "옷 이름", value: viewModel.name) - infoRow(label: "브랜드", value: viewModel.brand) - infoRow(label: "구매 url", value: viewModel.purchaseUrl) + // 2. 정보 리스트 영역 + VStack(spacing: 20) { + infoRow(label: "카테고리", value: viewModel.categoryText) + infoRow(label: "계절", value: viewModel.seasonText) + infoRow(label: "옷 이름", value: viewModel.name) + infoRow(label: "브랜드", value: viewModel.brand) + infoRow(label: "구매 url", value: viewModel.purchaseUrl) + } } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 30) } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 30) } } + .task { + await viewModel.fetchDetail() + } .navigationBarHidden(true) .background(Color.white) .confirmationDialog("", isPresented: $viewModel.showActionSheet) { @@ -80,6 +83,45 @@ struct ClothDetailView: View { } } + // 이미지 뷰 + @ViewBuilder + private var clothImageView: some View { + let urlString = viewModel.imageUrl + if !urlString.isEmpty, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + Rectangle() + .fill(Color.Codive.grayscale7) + .aspectRatio(1, contentMode: .fit) + .overlay(ProgressView()) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + Rectangle() + .fill(Color.Codive.grayscale7) + .aspectRatio(1, contentMode: .fit) + .overlay( + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale4) + ) + @unknown default: + EmptyView() + } + } + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + Rectangle() + .fill(Color.Codive.grayscale7) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + // 공통 정보 행 컴포넌트 @ViewBuilder private func infoRow(label: String, value: String) -> some View { diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift index 1c0500df..f87795b4 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift @@ -15,29 +15,43 @@ final class ClothDetailViewModel: ObservableObject { let cloth: Cloth @Published var showDeleteAlert: Bool = false @Published var showActionSheet: Bool = false + @Published var isLoading: Bool = false + @Published var detailData: ClothDetailResult? // MARK: - Computed Properties var imageUrl: String { - cloth.imageUrl + detailData?.clothImageUrl ?? cloth.imageUrl } var name: String { - cloth.name ?? "이름 없음" + detailData?.name ?? cloth.name ?? "이름 없음" } var brand: String { - cloth.brand ?? "브랜드 없음" + detailData?.brand ?? cloth.brand ?? "브랜드 없음" } var categoryText: String { + // API 응답이 있으면 parentCategory > category 형식 사용 + if let detail = detailData { + let parent = detail.parentCategory ?? "" + let child = detail.category ?? "" + if !parent.isEmpty && !child.isEmpty { + return "\(parent) > \(child)" + } else if !parent.isEmpty { + return parent + } else if !child.isEmpty { + return child + } + } + + // fallback: 기존 로직 guard let categoryId = cloth.categoryId, let category = CategoryConstants.category(byId: categoryId) else { return "카테고리 없음" } - return category.name - // TODO: 서브 카테고리 추가되면 "상의 > 티셔츠" 형식으로 변경 } var seasonText: String { @@ -51,24 +65,40 @@ final class ClothDetailViewModel: ObservableObject { } var purchaseUrl: String { - cloth.purchaseUrl ?? "URL 없음" + detailData?.clothUrl ?? cloth.purchaseUrl ?? "URL 없음" } // MARK: - Private Properties private let navigationRouter: NavigationRouter private let deleteClothItemsUseCase: DeleteClothItemsUseCase + private let clothRepository: ClothRepository // MARK: - Initializer init( cloth: Cloth, navigationRouter: NavigationRouter, - deleteClothItemsUseCase: DeleteClothItemsUseCase + deleteClothItemsUseCase: DeleteClothItemsUseCase, + clothRepository: ClothRepository ) { self.cloth = cloth self.navigationRouter = navigationRouter self.deleteClothItemsUseCase = deleteClothItemsUseCase + self.clothRepository = clothRepository + } + + // MARK: - Fetch Detail + + func fetchDetail() async { + isLoading = true + do { + let result = try await clothRepository.fetchClothDetail(clothId: cloth.id) + detailData = result + } catch { + print("❌ 옷 상세 조회 실패: \(error)") + } + isLoading = false } // MARK: - Actions diff --git a/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift index 927542d5..7c6b322c 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift @@ -39,16 +39,17 @@ final class MyClosetSectionViewModel: ObservableObject { do { // 전체 옷 목록 가져오기 (필터 없음) let allItems = try await fetchMyClosetClothItemsUseCase.execute( - mainCategory: "전체", + mainCategory: nil, subCategory: nil, seasons: [], searchText: nil ) - // 최대 10개만 표시 - clothItems = Array(allItems.prefix(10)) + // 최대 8개만 표시 + clothItems = Array(allItems.prefix(8)) + print("✅ [MyClosetSection] 옷 \(clothItems.count)개 로드 완료") } catch { - print("Error loading cloth items: \(error)") + print("❌ [MyClosetSection] 옷 로딩 실패: \(error)") } isLoading = false From 606762551030e6a9a0cb526ca2d3225f629fdfff Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:11:56 +0900 Subject: [PATCH 15/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=ED=8E=B8=EC=A7=91,?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/ClosetDIContainer.swift | 3 +- .../Closet/Data/ClothAPIService.swift | 1 - .../Data/DataSources/ClothDataSource.swift | 11 ++- .../Components/ClothingCardView.swift | 1 - .../CustomAIRecommendationView.swift | 37 +++++++- .../Presentation/View/ClothAddView.swift | 2 + .../Presentation/View/ClothEditView.swift | 6 +- .../ViewModel/ClothEditViewModel.swift | 91 +++++++++++++++++-- Tuist/Package.resolved | 2 +- 9 files changed, 137 insertions(+), 17 deletions(-) diff --git a/Codive/DIContainer/ClosetDIContainer.swift b/Codive/DIContainer/ClosetDIContainer.swift index c3c08123..913df8ce 100644 --- a/Codive/DIContainer/ClosetDIContainer.swift +++ b/Codive/DIContainer/ClosetDIContainer.swift @@ -80,7 +80,8 @@ final class ClosetDIContainer { func makeClothEditViewModel(cloth: Cloth) -> ClothEditViewModel { return ClothEditViewModel( cloth: cloth, - navigationRouter: navigationRouter + navigationRouter: navigationRouter, + clothRepository: clothRepository ) } diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index ee9ccd8d..0f400ba8 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -247,7 +247,6 @@ extension ClothAPIService { let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseSliceResponseClothListResponse.self, from: data) let clothes: [ClothListItem] = decoded.result?.content?.map { item -> ClothListItem in - print("🔍 [ClothAPI] item: clothId=\(item.clothId ?? 0), ImageUrl=\(item.ImageUrl ?? "nil")") return ClothListItem(clothId: item.clothId ?? 0, imageUrl: item.ImageUrl ?? "", brand: item.brand, name: item.name) } ?? [] diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index eed930cd..f52f91ed 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -196,7 +196,6 @@ final class DefaultClothDataSource: ClothDataSource { // ClothListItem → Cloth 변환 var clothes = result.clothes.map { item in - print("🖼️ [ClothDataSource] clothId: \(item.clothId), imageUrl: \(item.imageUrl ?? "nil")") return Cloth( id: Int(item.clothId), imageUrl: item.imageUrl, @@ -219,9 +218,13 @@ final class DefaultClothDataSource: ClothDataSource { } func deleteClothItems(_ clothIds: [Int]) async throws { - // TODO: 실제 API 호출로 대체 - // Mock 환경: 성공만 반환 - print("Mock: \(clothIds) 삭제 성공") + print("📤 [ClothDataSource] 옷 삭제 API 호출... clothIds: \(clothIds)") + + for clothId in clothIds { + try await apiService.deleteCloth(clothId: Int64(clothId)) + } + + print("✅ [ClothDataSource] 옷 \(clothIds.count)개 삭제 완료") } // MARK: - API 연동 메서드 diff --git a/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift b/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift index 6210fe4e..7a17ca5b 100644 --- a/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift +++ b/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift @@ -20,7 +20,6 @@ struct ClothingCardView: View { } var body: some View { - let _ = print("🖼️ [ClothingCard] imageUrl: \(imageUrl ?? "nil")") VStack(alignment: .leading, spacing: 0) { // 이미지 영역 if let imageUrl = imageUrl, !imageUrl.isEmpty, let url = URL(string: imageUrl) { diff --git a/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift b/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift index e60dca0d..40b22b1d 100644 --- a/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift +++ b/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift @@ -12,6 +12,7 @@ struct ClothingItem: Identifiable { let id = UUID() let imageName: String? let image: UIImage? + let imageUrl: String? let category: String let subcategory: String let season: String @@ -22,6 +23,7 @@ struct ClothingItem: Identifiable { init( imageName: String? = nil, image: UIImage? = nil, + imageUrl: String? = nil, category: String, subcategory: String, season: String, @@ -31,6 +33,7 @@ struct ClothingItem: Identifiable { ) { self.imageName = imageName self.image = image + self.imageUrl = imageUrl self.category = category self.subcategory = subcategory self.season = season @@ -170,7 +173,33 @@ struct CustomAIRecommendationView: View { .frame(height: geometry.size.width) .background(Color.Codive.grayscale6) .clipShape(RoundedRectangle(cornerRadius: 10)) - } else if let imageName = item.imageName { + } else if let imageUrl = item.imageUrl, !imageUrl.isEmpty, let url = URL(string: imageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + Rectangle() + .fill(Color.Codive.grayscale6) + .overlay(ProgressView()) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + Rectangle() + .fill(Color.Codive.grayscale6) + .overlay( + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale4) + ) + @unknown default: + EmptyView() + } + } + .frame(maxWidth: .infinity) + .frame(height: geometry.size.width) + .background(Color.Codive.grayscale6) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else if let imageName = item.imageName, !imageName.isEmpty { Image(imageName) .resizable() .aspectRatio(contentMode: .fit) @@ -178,6 +207,12 @@ struct CustomAIRecommendationView: View { .frame(height: geometry.size.width) .background(Color.Codive.grayscale6) .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Rectangle() + .fill(Color.Codive.grayscale6) + .frame(maxWidth: .infinity) + .frame(height: geometry.size.width) + .clipShape(RoundedRectangle(cornerRadius: 10)) } // Edit button (조건부 표시) diff --git a/Codive/Features/Closet/Presentation/View/ClothAddView.swift b/Codive/Features/Closet/Presentation/View/ClothAddView.swift index beb0fb05..6f069d22 100644 --- a/Codive/Features/Closet/Presentation/View/ClothAddView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothAddView.swift @@ -105,7 +105,9 @@ struct ClothAddView: View { } return ClothingItem( + imageName: nil, image: photo.croppedImage, + imageUrl: nil, category: form.category?.name ?? "", subcategory: form.subcategory?.name ?? "", season: seasonText, diff --git a/Codive/Features/Closet/Presentation/View/ClothEditView.swift b/Codive/Features/Closet/Presentation/View/ClothEditView.swift index 2eef6d9d..153e6d28 100644 --- a/Codive/Features/Closet/Presentation/View/ClothEditView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothEditView.swift @@ -61,6 +61,9 @@ struct ClothEditView: View { } .padding(.top, 10) } + .task { + await viewModel.fetchDetail() + } .navigationBarHidden(true) .background(Color.white) .sheet(isPresented: $viewModel.isCategorySheetPresented) { @@ -105,8 +108,9 @@ struct ClothEditView: View { } return ClothingItem( - imageName: viewModel.cloth.imageUrl, + imageName: nil, image: nil, + imageUrl: viewModel.imageUrl, category: form.category?.name ?? "", subcategory: form.subcategory?.name ?? "", season: seasonText, diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift index 93a0e7c1..fe7b8fd9 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift @@ -44,6 +44,10 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth // MARK: - Output Properties let cloth: Cloth @Published var clothForm: ClothFormData + @Published var isLoading = false + @Published var isFetching = false + @Published var errorMessage: String? + @Published var imageUrl: String = "" // Sheet states @Published var isCategorySheetPresented = false @@ -52,7 +56,7 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth // MARK: - Dependencies private let navigationRouter: NavigationRouter - // TODO: UpdateClothUseCase 추가 필요 + private let clothRepository: ClothRepository // MARK: - Computed Properties var categoryDisplayText: String { @@ -79,13 +83,15 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth // MARK: - Initializer init( cloth: Cloth, - navigationRouter: NavigationRouter + navigationRouter: NavigationRouter, + clothRepository: ClothRepository ) { self.cloth = cloth self.navigationRouter = navigationRouter + self.clothRepository = clothRepository + self.imageUrl = cloth.imageUrl - // 기존 Cloth 데이터로 폼 초기화 - // categoryId는 하위 카테고리 ID이므로 그걸로 상위/하위 카테고리 모두 조회 + // 기존 Cloth 데이터로 폼 초기화 (목록에서 전달받은 기본 정보) let subcategory = cloth.categoryId.flatMap { CategoryConstants.subcategory(byId: $0) } let category = cloth.categoryId.flatMap { CategoryConstants.category(bySubcategoryId: $0) } @@ -99,6 +105,41 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth ) } + // MARK: - Fetch Detail + func fetchDetail() async { + isFetching = true + do { + let detail = try await clothRepository.fetchClothDetail(clothId: cloth.id) + + // 이미지 URL 업데이트 + imageUrl = detail.clothImageUrl + + // 카테고리 업데이트 (API 응답의 category 이름으로 찾기) + if let categoryName = detail.parentCategory, + let subcategoryName = detail.category { + if let parentCategory = CategoryConstants.all.first(where: { $0.name == categoryName }), + let subcategory = parentCategory.subcategories.first(where: { $0.name == subcategoryName }) { + clothForm.category = parentCategory + clothForm.subcategory = subcategory + } + } + + // 이름, 브랜드, URL 업데이트 (기존 값이 없으면) + if clothForm.name.isEmpty, let name = detail.name { + clothForm.name = name + } + if clothForm.brand.isEmpty, let brand = detail.brand { + clothForm.brand = brand + } + if clothForm.purchaseUrl.isEmpty, let url = detail.clothUrl { + clothForm.purchaseUrl = url + } + } catch { + print("❌ 옷 상세 조회 실패: \(error)") + } + isFetching = false + } + // MARK: - Input Methods func updateName(_ name: String) { @@ -138,10 +179,46 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth } func completeEditing() { + guard !isLoading else { return } + guard let subcategory = clothForm.subcategory else { + errorMessage = "카테고리를 선택해주세요" + return + } + guard !clothForm.selectedSeasons.isEmpty else { + errorMessage = "계절을 선택해주세요" + return + } + + isLoading = true + errorMessage = nil + Task { - // TODO: UpdateClothUseCase 구현 후 연결 - print("옷 수정 완료: \(cloth.id)") - navigationRouter.navigateBack() + do { + // Season Set → 단일 Season 변환 (API가 단일 season만 받는 경우) + let season = clothForm.selectedSeasons.first ?? .spring + + let request = ClothUpdateAPIRequest( + clothImageUrl: nil, // 이미지 변경 없음 + clothUrl: clothForm.purchaseUrl.isEmpty ? nil : clothForm.purchaseUrl, + name: clothForm.name.isEmpty ? nil : clothForm.name, + brand: clothForm.brand.isEmpty ? nil : clothForm.brand, + season: season, + categoryId: Int64(subcategory.id) + ) + + try await clothRepository.updateCloth(clothId: cloth.id, request: request) + + await MainActor.run { + isLoading = false + navigationRouter.navigateBack() + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = "수정 실패: \(error.localizedDescription)" + print("❌ 옷 수정 실패: \(error)") + } + } } } } diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index dd6b1af3..e939400c 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "35da8eba6ff8db73b79a16a148a3ccf9d10b8f84" + "revision" : "abcde5604a29956ec070f09459daa4428115daee" } }, { From e351edabfcb40e1e9f6da6dedc9ce4f1cb91050f Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:23:05 +0900 Subject: [PATCH 16/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20url=EC=9D=B4=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Closet/Data/ClothAPIService.swift | 22 ++++++++++++++++++- .../ViewModel/ClothEditViewModel.swift | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index 0f400ba8..da9d4d88 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -290,6 +290,18 @@ extension ClothAPIService { extension ClothAPIService { func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws { + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📝 [ClothAPI] 옷 수정 요청") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📋 요청 데이터:") + print(" - clothId: \(clothId)") + print(" - clothImageUrl: \(request.clothImageUrl ?? "nil")") + print(" - clothUrl: \(request.clothUrl ?? "nil")") + print(" - name: \(request.name ?? "nil")") + print(" - brand: \(request.brand ?? "nil")") + print(" - season: \(request.season.rawValue)") + print(" - categoryId: \(request.categoryId)") + let requestBody = Components.Schemas.ClothUpdateRequest( clothImageUrl: request.clothImageUrl, clothUrl: request.clothUrl, @@ -304,8 +316,16 @@ extension ClothAPIService { switch response { case .ok: + print("✅ [ClothAPI] 옷 수정 성공") return - case .undocumented(statusCode: let code, _): + case .undocumented(statusCode: let code, let payload): + print("❌ [ClothAPI] 옷 수정 실패 - 상태코드: \(code)") + if let body = payload.body { + let errorData = try await Data(collecting: body, upTo: .max) + if let errorString = String(data: errorData, encoding: .utf8) { + print("❌ [ClothAPI] 에러 응답: \(errorString)") + } + } throw ClothAPIError.serverError(statusCode: code, message: "옷 수정 실패") } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift index fe7b8fd9..958c76af 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift @@ -198,7 +198,7 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth let season = clothForm.selectedSeasons.first ?? .spring let request = ClothUpdateAPIRequest( - clothImageUrl: nil, // 이미지 변경 없음 + clothImageUrl: imageUrl, clothUrl: clothForm.purchaseUrl.isEmpty ? nil : clothForm.purchaseUrl, name: clothForm.name.isEmpty ? nil : clothForm.name, brand: clothForm.brand.isEmpty ? nil : clothForm.brand, From aa5b70e8ba1cefce14137bca8ebf9cd4ce1f347c Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:53:46 +0900 Subject: [PATCH 17/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=A1=B0=ED=9A=8C,=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/myCloth/MyClosetView.swift | 2 +- .../DesignSystem/Views/CustomClothCard.swift | 63 ++++++++++++++++--- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift index 24499a4b..538ac611 100644 --- a/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift +++ b/Codive/Features/Closet/Presentation/View/myCloth/MyClosetView.swift @@ -81,7 +81,7 @@ struct MyClosetView: View { LazyVGrid(columns: columns, spacing: 0) { ForEach(viewModel.clothItems) { cloth in CustomClothCard( - imageName: cloth.imageUrl, + imageUrl: cloth.imageUrl, brand: cloth.brand ?? "", title: cloth.name ?? "", isEditMode: viewModel.isEditMode, diff --git a/Codive/Shared/DesignSystem/Views/CustomClothCard.swift b/Codive/Shared/DesignSystem/Views/CustomClothCard.swift index 84421ef1..77442752 100644 --- a/Codive/Shared/DesignSystem/Views/CustomClothCard.swift +++ b/Codive/Shared/DesignSystem/Views/CustomClothCard.swift @@ -8,16 +8,35 @@ import SwiftUI struct CustomClothCard: View { - let imageName: String + let imageName: String? + let imageUrl: String? let brand: String let title: String - + // 편집 모드 관련 프로퍼티 추가 var isEditMode: Bool = false var isSelected: Bool = false - + var action: () -> Void = {} + init( + imageName: String? = nil, + imageUrl: String? = nil, + brand: String, + title: String, + isEditMode: Bool = false, + isSelected: Bool = false, + action: @escaping () -> Void = {} + ) { + self.imageName = imageName + self.imageUrl = imageUrl + self.brand = brand + self.title = title + self.isEditMode = isEditMode + self.isSelected = isSelected + self.action = action + } + var body: some View { Button(action: { action() }, label: { VStack(alignment: .leading, spacing: 0) { @@ -25,18 +44,16 @@ struct CustomClothCard: View { // 1. 상품 이미지 ZStack { Color.Codive.grayscale7 - - Image(imageName) - .resizable() - .aspectRatio(contentMode: .fill) - + + imageContent + // 선택 시 회색 오버레이 (삭제 선택.png 참고) if isEditMode && isSelected { Color.black.opacity(0.1) } } .clipped() - + // 2. 편집 모드일 때 나타나는 선택 원 if isEditMode { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") @@ -73,6 +90,34 @@ struct CustomClothCard: View { }) .buttonStyle(.plain) } + + @ViewBuilder + private var imageContent: some View { + if let imageUrl = imageUrl, !imageUrl.isEmpty, let url = URL(string: imageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale4) + @unknown default: + EmptyView() + } + } + } else if let imageName = imageName, !imageName.isEmpty { + Image(imageName) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "photo") + .foregroundStyle(Color.Codive.grayscale4) + } + } } struct ClothGridView: View { From 88ce488af01fecf33c326203c81a9991969a0837 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:09:57 +0900 Subject: [PATCH 18/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=ED=9B=84=20=EC=8A=A4=ED=94=8C=EB=9E=98=EC=8B=9C=20=EB=B7=B0=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/View/ClothAddView.swift | 80 +++++++++-------- .../ViewModel/ClothAddViewModel.swift | 22 ++++- Codive/Features/Main/View/MainTabView.swift | 86 +++++++++++-------- Codive/Router/NavigationRouter.swift | 38 ++++++++ 4 files changed, 154 insertions(+), 72 deletions(-) diff --git a/Codive/Features/Closet/Presentation/View/ClothAddView.swift b/Codive/Features/Closet/Presentation/View/ClothAddView.swift index 6f069d22..51626500 100644 --- a/Codive/Features/Closet/Presentation/View/ClothAddView.swift +++ b/Codive/Features/Closet/Presentation/View/ClothAddView.swift @@ -21,44 +21,56 @@ struct ClothAddView: View { // MARK: - Body var body: some View { - VStack(spacing: 0) { - // Navigation Bar - CustomNavigationBar( - title: TextLiteral.Closet.clothAddTitle, - onBack: { - viewModel.dismissView() - }, - rightButton: .text( - title: TextLiteral.Common.complete, - isEnabled: viewModel.isAllFormsValid - ) { - viewModel.completeAdding() - } - ) - - ScrollView { - CustomAIRecommendationView( - title: TextLiteral.Closet.clothLoadedTitle, - items: convertToClothingItems(), - selectedItemIndex: $viewModel.currentIndex, - onCategoryTap: { - viewModel.showCategorySheet() - }, - onSeasonTap: { - viewModel.showSeasonSheet() - }, - onNameChanged: { name in - viewModel.updateName(name) + ZStack { + // 메인 콘텐츠 + VStack(spacing: 0) { + // Navigation Bar + CustomNavigationBar( + title: TextLiteral.Closet.clothAddTitle, + onBack: { + viewModel.dismissView() }, - onBrandChanged: { brand in - viewModel.updateBrand(brand) - }, - onPurchaseUrlChanged: { url in - viewModel.updatePurchaseUrl(url) + rightButton: .text( + title: TextLiteral.Common.complete, + isEnabled: viewModel.isAllFormsValid && !viewModel.isLoading + ) { + viewModel.completeAdding() } ) + + ScrollView { + CustomAIRecommendationView( + title: TextLiteral.Closet.clothLoadedTitle, + items: convertToClothingItems(), + selectedItemIndex: $viewModel.currentIndex, + onCategoryTap: { + viewModel.showCategorySheet() + }, + onSeasonTap: { + viewModel.showSeasonSheet() + }, + onNameChanged: { name in + viewModel.updateName(name) + }, + onBrandChanged: { brand in + viewModel.updateBrand(brand) + }, + onPurchaseUrlChanged: { url in + viewModel.updatePurchaseUrl(url) + } + ) + } + .padding(.top, 10) + } + + // 로딩 인디케이터 + if viewModel.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView() + .scaleEffect(1.5) + .tint(.white) } - .padding(.top, 10) } .navigationBarHidden(true) .background(Color.white) diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift index 13322df2..759a2e50 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift @@ -70,6 +70,9 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd @Published var isSeasonSheetPresented = false @Published var tempSelectedCategory: CategoryItem? + // 완료 상태 + @Published var isLoading = false + // MARK: - Dependencies private let navigationRouter: NavigationRouter private let addClothUseCase: AddClothUseCase @@ -193,6 +196,9 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd } func completeAdding() { + guard !isLoading else { return } + isLoading = true + Task { do { // UIImage → Data 변환 @@ -220,11 +226,21 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd images: imageDatas ) - // TODO: 성공 후 화면 전환 + // 성공: 성공 오버레이 표시 + 뒤에서 탭 전환/네비게이션 + await MainActor.run { + isLoading = false + navigationRouter.showSuccessAndNavigate( + message: "옷장에 옷을 보관했어요!", + to: .closet, + destination: .myCloset, + duration: 1.5 + ) + } } catch { - // 에러 처리 + await MainActor.run { + isLoading = false + } print("옷 저장 실패: \(error.localizedDescription)") - // TODO: 에러 알럿 표시 } } } diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 61b360a7..f9c957d1 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -41,48 +41,64 @@ struct MainTabView: View { // MARK: - Body var body: some View { - NavigationStack(path: $navigationRouter.path) { - VStack(spacing: 0) { - if shouldShowTopBar { - TopNavigationBar( - showSearchButton: showSearchButton, - showNotificationButton: showNotificationButton, - onSearchTap: viewModel.handleSearchTap, - onNotificationTap: viewModel.handleNotificationTap - ) - } + ZStack { + NavigationStack(path: $navigationRouter.path) { + VStack(spacing: 0) { + if shouldShowTopBar { + TopNavigationBar( + showSearchButton: showSearchButton, + showNotificationButton: showNotificationButton, + onSearchTap: viewModel.handleSearchTap, + onNotificationTap: viewModel.handleNotificationTap + ) + } - ZStack(alignment: .bottom) { - Group { - switch viewModel.selectedTab { - case .home: - HomeView(homeDIContainer: homeDIContainer) - .ignoresSafeArea(.all, edges: .bottom) - case .closet: - ClosetView(closetDIContainer: closetDIContainer) - case .add: - AddView(addDIContainer: addDIContainer) - .ignoresSafeArea(.all, edges: .bottom) - case .feed: - FeedView(viewModel: feedDIContainer.makeFeedViewModel()) - case .profile: - ProfileView() + ZStack(alignment: .bottom) { + Group { + switch viewModel.selectedTab { + case .home: + HomeView(homeDIContainer: homeDIContainer) + .ignoresSafeArea(.all, edges: .bottom) + case .closet: + ClosetView(closetDIContainer: closetDIContainer) + case .add: + AddView(addDIContainer: addDIContainer) + .ignoresSafeArea(.all, edges: .bottom) + case .feed: + FeedView(viewModel: feedDIContainer.makeFeedViewModel()) + case .profile: + ProfileView() + } } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) - // MARK: - Tab Bar - TabBar(selectedTab: $viewModel.selectedTab) - .zIndex(shouldShowTabBar ? 1 : 0) - .allowsHitTesting(shouldShowTabBar) + // MARK: - Tab Bar + TabBar(selectedTab: $viewModel.selectedTab) + .zIndex(shouldShowTabBar ? 1 : 0) + .allowsHitTesting(shouldShowTabBar) + } + } + .navigationDestination(for: AppDestination.self) { destination in + destinationView(for: destination) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + } + .environmentObject(navigationRouter) + .onReceive(navigationRouter.$pendingTabSwitch) { tab in + if let tab = tab { + viewModel.selectedTab = tab + navigationRouter.pendingTabSwitch = nil } } - .navigationDestination(for: AppDestination.self) { destination in - destinationView(for: destination) + + // MARK: - Success Overlay + if let message = navigationRouter.successMessage { + CustomSuccessView(message: message) + .ignoresSafeArea() + .zIndex(100) + .transition(.opacity) } - .ignoresSafeArea(.keyboard, edges: .bottom) } - .environmentObject(navigationRouter) } // MARK: - Computed Properties diff --git a/Codive/Router/NavigationRouter.swift b/Codive/Router/NavigationRouter.swift index 98216b5f..e0dac961 100644 --- a/Codive/Router/NavigationRouter.swift +++ b/Codive/Router/NavigationRouter.swift @@ -16,6 +16,12 @@ final class NavigationRouter: ObservableObject { @Published var currentDestination: AppDestination? @Published var sheetDestination: AppDestination? + /// 탭 전환 요청 (MainTabView에서 구독) + @Published var pendingTabSwitch: TabBarType? + + /// 성공 오버레이 표시 (앱 레벨에서 관리) + @Published var successMessage: String? + // MARK: - Navigation Methods /// 새로운 화면으로 이동 (스택에 추가) @@ -52,6 +58,38 @@ final class NavigationRouter: ObservableObject { currentDestination = destination path.append(destination) } + + /// 탭 전환 후 특정 화면으로 이동 + func switchTabAndNavigate(to tab: TabBarType, destination: AppDestination? = nil) { + path = NavigationPath() + currentDestination = nil + pendingTabSwitch = tab + + if let destination = destination { + // 약간의 딜레이 후 네비게이션 (탭 전환이 완료된 후) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.navigate(to: destination) + } + } + } + + /// 성공 화면을 보여주면서 탭 전환 후 네비게이션 (성공 화면이 덮고 있는 동안 뒤에서 이동) + func showSuccessAndNavigate(message: String, to tab: TabBarType, destination: AppDestination? = nil, duration: TimeInterval = 1.5) { + // 1. 성공 오버레이 표시 + successMessage = message + + // 2. 뒤에서 탭 전환 + 네비게이션 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.switchTabAndNavigate(to: tab, destination: destination) + } + + // 3. duration 후 성공 오버레이 닫기 + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + withAnimation(.easeOut(duration: 0.3)) { + self?.successMessage = nil + } + } + } // MARK: - Sheet Presentation Methods From 0b7545c33a0ddb59150416b887b2181a18eecfd5 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:38:43 +0900 Subject: [PATCH 19/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=EC=84=B8=EB=B6=80?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B0=92=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/ViewModel/MyClosetViewModel.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift index 26f2815f..c17cf261 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/MyClosetViewModel.swift @@ -138,13 +138,8 @@ final class MyClosetViewModel: ObservableObject { func updateMainCategory(_ category: String) { selectedMainCategory = category - - // 메인 카테고리 변경 시 첫 번째 서브 카테고리 자동 선택 - if let firstSub = CategoryConstants.all.first(where: { $0.name == category })?.subcategories.first { - selectedSubCategory = firstSub.name - } else { - selectedSubCategory = "" - } + // 메인 카테고리 변경 시 서브 카테고리 선택 초기화 (전체 보기) + selectedSubCategory = "" } func updateSubCategory(_ subCategory: String) { From e6d97c92e21e0a2cdffaf30df3bb49ea34e043b3 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:20:12 +0900 Subject: [PATCH 20/37] =?UTF-8?q?[#39]=20JSONDecoder=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSONDecoderFactory 생성하여 날짜 파싱 로직 공통화 - 4개 API Service에서 중복 제거 --- .../Features/Auth/Data/AuthAPIService.swift | 201 +++++------------- .../Features/Auth/Data/TermsAPIService.swift | 22 +- .../Closet/Data/ClothAPIService.swift | 76 +------ .../Feed/Data/HistoryAPIService.swift | 90 +------- .../Data/Network/JSONDecoderFactory.swift | 55 +++++ 5 files changed, 115 insertions(+), 329 deletions(-) create mode 100644 Codive/Shared/Data/Network/JSONDecoderFactory.swift diff --git a/Codive/Features/Auth/Data/AuthAPIService.swift b/Codive/Features/Auth/Data/AuthAPIService.swift index 98069416..ac81fcd9 100644 --- a/Codive/Features/Auth/Data/AuthAPIService.swift +++ b/Codive/Features/Auth/Data/AuthAPIService.swift @@ -11,9 +11,9 @@ import OpenAPIRuntime // MARK: - Auth API Service Protocol protocol AuthAPIServiceProtocol { - func checkAuthStatus() async -> AuthStatusResult - func reissueTokens(refreshToken: String) async -> Result - func renewDeviceToken(deviceToken: String) async -> Result + func checkAuthStatus() async throws -> RegisterStatus + func reissueTokens(refreshToken: String) async throws -> TokenPair + func renewDeviceToken(deviceToken: String) async throws } // MARK: - Token Pair @@ -29,179 +29,84 @@ final class AuthAPIService: AuthAPIServiceProtocol { private let jsonDecoder: JSONDecoder init(tokenProvider: TokenProvider = KeychainTokenProvider()) { - // CodiveAPI Client 생성 with AuthMiddleware self.client = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] ) - self.jsonDecoder = Self.createJSONDecoder() + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() } - private static func createJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - - // 방법 1: ISO8601DateFormatter (표준 형식) - let formatter1 = ISO8601DateFormatter() - formatter1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter1.date(from: dateString) { return date } - - let formatter2 = ISO8601DateFormatter() - formatter2.formatOptions = [.withInternetDateTime] - if let date = formatter2.date(from: dateString) { return date } - - // 방법 2: DateFormatter로 나노초 포함 형식 처리 - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - - // 나노초 형식 (소수점 이하 자릿수 다양) - let formats = [ - "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS", // 9자리 - "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", // 8자리 - "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", // 7자리 - "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", // 6자리 - "yyyy-MM-dd'T'HH:mm:ss.SSS", // 3자리 - "yyyy-MM-dd'T'HH:mm:ss" // 소수점 없음 - ] - - for format in formats { - dateFormatter.dateFormat = format - if let date = dateFormatter.date(from: dateString) { return date } - } - - throw DecodingError.dataCorruptedError(in: container, debugDescription: "날짜 파싱 실패: \(dateString)") - } - return decoder - } + func checkAuthStatus() async throws -> RegisterStatus { + let response = try await client.Auth_getUserStatus( + Operations.Auth_getUserStatus.Input() + ) - func checkAuthStatus() async -> AuthStatusResult { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📡 [AuthAPI] checkAuthStatus 호출") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) - do { - // API 호출 - let response = try await client.Auth_getUserStatus( - Operations.Auth_getUserStatus.Input() + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseUserStatusResponse.self, + from: data ) - // 응답 처리 - switch response { - case .ok(let okResponse): - // Body 추출 - let httpBody = try okResponse.body.any - let data = try await Data(collecting: httpBody, upTo: .max) - - // 🔍 디버그: 원본 응답 출력 - if let jsonString = String(data: data, encoding: .utf8) { - print("📩 [AuthAPI] 응답 원본:") - print(jsonString) - } - - // JSON 디코딩 (커스텀 디코더 사용) - do { - let apiResponse = try jsonDecoder.decode( - Components.Schemas.BaseResponseUserStatusResponse.self, - from: data - ) - - print("✅ [AuthAPI] 디코딩 성공") - - // registerStatus 확인 - guard let result = apiResponse.result, - let registerStatus = result.registerStatus else { - print("❌ [AuthAPI] result 또는 registerStatus 없음") - return .failure(.networkError("회원 상태 정보 없음")) - } - - print("📋 [AuthAPI] registerStatus: \(registerStatus)") - - // RegisterStatus 변환 - switch registerStatus { - case .NOT_AGREED: - return .success(.notAgreed) - case .REGISTERED: - return .success(.registered) - } - } catch { - print("❌ [AuthAPI] 디코딩 실패: \(error)") - return .failure(.networkError(error.localizedDescription)) - } - - case .undocumented(statusCode: let statusCode, _): - print("❌ [AuthAPI] undocumented 응답: \(statusCode)") - return .failure(.networkError("예상치 못한 응답 코드: \(statusCode)")) + guard let result = apiResponse.result, + let registerStatus = result.registerStatus else { + throw AuthError.networkError("회원 상태 정보 없음") + } + + switch registerStatus { + case .NOT_AGREED: + return .notAgreed + case .REGISTERED: + return .registered } - } catch { - print("❌ [AuthAPI] 네트워크 에러: \(error)") - return .failure(.networkError(error.localizedDescription)) + case .undocumented(statusCode: let statusCode, _): + throw AuthError.networkError("예상치 못한 응답 코드: \(statusCode)") } } // MARK: - Token Reissue - func reissueTokens(refreshToken: String) async -> Result { - print("🔄 [AuthAPI] 토큰 재발급 요청") + func reissueTokens(refreshToken: String) async throws -> TokenPair { + let requestBody = Components.Schemas.TokenReissueRequest(refreshToken: refreshToken) + let input = Operations.Auth_reissueTokens.Input(body: .json(requestBody)) + let response = try await client.Auth_reissueTokens(input) - do { - let requestBody = Components.Schemas.TokenReissueRequest(refreshToken: refreshToken) - let input = Operations.Auth_reissueTokens.Input(body: .json(requestBody)) - let response = try await client.Auth_reissueTokens(input) + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) - switch response { - case .ok(let okResponse): - let httpBody = try okResponse.body.any - let data = try await Data(collecting: httpBody, upTo: .max) + let apiResponse = try jsonDecoder.decode(TokenReissueResponse.self, from: data) - let apiResponse = try jsonDecoder.decode(TokenReissueResponse.self, from: data) - - guard let result = apiResponse.result, - let accessToken = result.accessToken, - let newRefreshToken = result.refreshToken else { - print("❌ [AuthAPI] 토큰 재발급 응답 파싱 실패") - return .failure(.networkError("토큰 재발급 응답 파싱 실패")) - } - - print("✅ [AuthAPI] 토큰 재발급 성공") - return .success(TokenPair(accessToken: accessToken, refreshToken: newRefreshToken)) - - case .undocumented(statusCode: let statusCode, _): - print("❌ [AuthAPI] 토큰 재발급 실패: \(statusCode)") - return .failure(.networkError("토큰 재발급 실패: \(statusCode)")) + guard let result = apiResponse.result, + let accessToken = result.accessToken, + let newRefreshToken = result.refreshToken else { + throw AuthError.networkError("토큰 재발급 응답 파싱 실패") } - } catch { - print("❌ [AuthAPI] 토큰 재발급 에러: \(error)") - return .failure(.networkError(error.localizedDescription)) + return TokenPair(accessToken: accessToken, refreshToken: newRefreshToken) + + case .undocumented(statusCode: let statusCode, _): + throw AuthError.networkError("토큰 재발급 실패: \(statusCode)") } } // MARK: - Device Token - func renewDeviceToken(deviceToken: String) async -> Result { - print("📱 [AuthAPI] 디바이스 토큰 갱신 요청") + func renewDeviceToken(deviceToken: String) async throws { + let requestBody = Components.Schemas.DeviceTokenRenewRequest(deviceToken: deviceToken) + let input = Operations.Auth_renewDeviceToken.Input(body: .json(requestBody)) + let response = try await client.Auth_renewDeviceToken(input) - do { - let requestBody = Components.Schemas.DeviceTokenRenewRequest(deviceToken: deviceToken) - let input = Operations.Auth_renewDeviceToken.Input(body: .json(requestBody)) - let response = try await client.Auth_renewDeviceToken(input) - - switch response { - case .ok: - print("✅ [AuthAPI] 디바이스 토큰 갱신 성공") - return .success(()) - - case .undocumented(statusCode: let statusCode, _): - print("❌ [AuthAPI] 디바이스 토큰 갱신 실패: \(statusCode)") - return .failure(.networkError("디바이스 토큰 갱신 실패: \(statusCode)")) - } + switch response { + case .ok: + return - } catch { - print("❌ [AuthAPI] 디바이스 토큰 갱신 에러: \(error)") - return .failure(.networkError(error.localizedDescription)) + case .undocumented(statusCode: let statusCode, _): + throw AuthError.networkError("디바이스 토큰 갱신 실패: \(statusCode)") } } } diff --git a/Codive/Features/Auth/Data/TermsAPIService.swift b/Codive/Features/Auth/Data/TermsAPIService.swift index 222eaaad..dec3c3bd 100644 --- a/Codive/Features/Auth/Data/TermsAPIService.swift +++ b/Codive/Features/Auth/Data/TermsAPIService.swift @@ -60,26 +60,7 @@ final class TermsAPIService: TermsAPIServiceProtocol { self.client = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] ) - self.jsonDecoder = Self.createJSONDecoder() - } - - private static func createJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - - let formatter1 = ISO8601DateFormatter() - formatter1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter1.date(from: dateString) { return date } - - let formatter2 = ISO8601DateFormatter() - formatter2.formatOptions = [.withInternetDateTime] - if let date = formatter2.date(from: dateString) { return date } - - throw DecodingError.dataCorruptedError(in: container, debugDescription: "날짜 파싱 실패") - } - return decoder + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() } // MARK: - GET /terms @@ -129,7 +110,6 @@ final class TermsAPIService: TermsAPIServiceProtocol { switch response { case .ok: - print("✅ 약관 동의 완료") return case .undocumented(statusCode: let code, _): throw TermsAPIError.serverError(statusCode: code) diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index da9d4d88..d0a33219 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -80,32 +80,7 @@ final class ClothAPIService: ClothAPIServiceProtocol { self.client = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] ) - self.jsonDecoder = Self.createJSONDecoder() - } - - private static func createJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - - let formatter1 = ISO8601DateFormatter() - formatter1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter1.date(from: dateString) { return date } - - let formatter2 = ISO8601DateFormatter() - formatter2.formatOptions = [.withInternetDateTime] - if let date = formatter2.date(from: dateString) { return date } - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS" - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - if let date = dateFormatter.date(from: dateString) { return date } - - throw DecodingError.dataCorruptedError(in: container, debugDescription: "날짜 파싱 실패: \(dateString)") - } - return decoder + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() } } @@ -172,16 +147,6 @@ extension ClothAPIService { extension ClothAPIService { func createClothes(requests: [ClothCreateAPIRequest]) async throws -> [Int64] { - // 디버그: 요청 데이터 출력 - for (index, req) in requests.enumerated() { - print("📦 [createClothes] 옷 \(index + 1):") - print(" - clothImageUrl: \(req.clothImageUrl)") - print(" - name: \(req.name ?? "nil")") - print(" - brand: \(req.brand ?? "nil")") - print(" - season: \(req.season.rawValue)") - print(" - categoryId: \(req.categoryId)") - } - let apiRequests = requests.map { request in Components.Schemas.ClothCreateRequest( clothImageUrl: request.clothImageUrl, @@ -207,15 +172,7 @@ extension ClothAPIService { } return clothIds - case .undocumented(statusCode: let code, let payload): - // 디버그: 에러 응답 출력 - print("❌ [createClothes] 서버 에러 - 상태코드: \(code)") - if let body = payload.body { - let errorData = try await Data(collecting: body, upTo: .max) - if let errorString = String(data: errorData, encoding: .utf8) { - print("❌ [createClothes] 에러 응답: \(errorString)") - } - } + case .undocumented(statusCode: let code, _): throw ClothAPIError.serverError(statusCode: code, message: "옷 생성 실패") } } @@ -237,13 +194,6 @@ extension ClothAPIService { switch response { case .ok(let okResponse): let data = try await Data(collecting: okResponse.body.any, upTo: .max) - - // 디버그: 원본 JSON 출력 - if let jsonString = String(data: data, encoding: .utf8) { - print("📩 [ClothAPI] 옷 목록 응답:") - print(jsonString.prefix(1000)) - } - let decoded = try jsonDecoder.decode(Components.Schemas.BaseResponseSliceResponseClothListResponse.self, from: data) let clothes: [ClothListItem] = decoded.result?.content?.map { item -> ClothListItem in @@ -290,18 +240,6 @@ extension ClothAPIService { extension ClothAPIService { func updateCloth(clothId: Int64, request: ClothUpdateAPIRequest) async throws { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📝 [ClothAPI] 옷 수정 요청") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📋 요청 데이터:") - print(" - clothId: \(clothId)") - print(" - clothImageUrl: \(request.clothImageUrl ?? "nil")") - print(" - clothUrl: \(request.clothUrl ?? "nil")") - print(" - name: \(request.name ?? "nil")") - print(" - brand: \(request.brand ?? "nil")") - print(" - season: \(request.season.rawValue)") - print(" - categoryId: \(request.categoryId)") - let requestBody = Components.Schemas.ClothUpdateRequest( clothImageUrl: request.clothImageUrl, clothUrl: request.clothUrl, @@ -316,16 +254,8 @@ extension ClothAPIService { switch response { case .ok: - print("✅ [ClothAPI] 옷 수정 성공") return - case .undocumented(statusCode: let code, let payload): - print("❌ [ClothAPI] 옷 수정 실패 - 상태코드: \(code)") - if let body = payload.body { - let errorData = try await Data(collecting: body, upTo: .max) - if let errorString = String(data: errorData, encoding: .utf8) { - print("❌ [ClothAPI] 에러 응답: \(errorString)") - } - } + case .undocumented(statusCode: let code, _): throw ClothAPIError.serverError(statusCode: code, message: "옷 수정 실패") } } diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index 5484fd66..3e6817df 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -27,13 +27,7 @@ struct HistoryCreateAPIRequest { struct HistoryImagePayload { let imageUrl: String - let clothTags: [HistoryClothTag] -} - -struct HistoryClothTag { - let clothId: Int64 - let locationX: Double - let locationY: Double + let clothTags: [RecordClothTag] } // MARK: - History API Service Implementation @@ -47,47 +41,12 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { self.client = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] ) - self.jsonDecoder = Self.createJSONDecoder() - } - - private static func createJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - - let formats = [ - "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS", - "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", - "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", - "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", - "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ss" - ] - - for format in formats { - dateFormatter.dateFormat = format - if let date = dateFormatter.date(from: dateString) { return date } - } - - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "날짜 파싱 실패: \(dateString)" - ) - } - return decoder + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() } // MARK: - Create History func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 { - print("📝 [HistoryAPI] 기록 생성 요청") - - // API 요청 바디 생성 let payloads = request.payloads.map { payload in Components.Schemas.HistoryCreatePayload( imageUrl: payload.imageUrl, @@ -101,7 +60,6 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { ) } - // hashtags를 OpenAPIValueContainer로 변환 let hashtagContainers: [OpenAPIRuntime.OpenAPIValueContainer]? = request.hashtags.isEmpty ? nil : request.hashtags.compactMap { try? OpenAPIRuntime.OpenAPIValueContainer(unvalidatedValue: $0) } @@ -114,33 +72,6 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { payloads: payloads ) - // 디버그: 요청 바디 출력 - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📤 [HistoryAPI] 요청 바디:") - print(" - content: \(request.content ?? "nil")") - print(" - situationId: \(request.situationId)") - print(" - styleIds: \(request.styleIds)") - print(" - hashtags: \(request.hashtags)") - print(" - payloads 수: \(payloads.count)") - for (index, payload) in payloads.enumerated() { - print(" - payload[\(index)].imageUrl: \(payload.imageUrl ?? "nil")") - print(" - payload[\(index)].clothTags 수: \(payload.clothTags?.count ?? 0)") - if let tags = payload.clothTags { - for (tagIndex, tag) in tags.enumerated() { - print(" - tag[\(tagIndex)]: clothId=\(tag.clothId), x=\(tag.locationX), y=\(tag.locationY)") - } - } - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - - // JSON으로 직접 인코딩해서 확인 - if let jsonData = try? JSONEncoder().encode(requestBody), - let jsonString = String(data: jsonData, encoding: .utf8) { - print("📤 [HistoryAPI] 실제 전송 JSON:") - print(jsonString) - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - } - let input = Operations.History_createHistory.Input(body: .json(requestBody)) let response = try await client.History_createHistory(input) @@ -148,30 +79,15 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { case .ok(let okResponse): let httpBody = try okResponse.body.any let data = try await Data(collecting: httpBody, upTo: .max) - - // 디버그 로그 - if let jsonString = String(data: data, encoding: .utf8) { - print("📩 [HistoryAPI] 응답: \(jsonString)") - } - let decoded = try jsonDecoder.decode(HistoryCreateResponse.self, from: data) guard let historyId = decoded.result?.historyId else { throw HistoryAPIError.noData } - print("✅ [HistoryAPI] 기록 생성 성공 - historyId: \(historyId)") return historyId - case .undocumented(statusCode: let code, let undocPayload): - print("❌ [HistoryAPI] 기록 생성 실패: \(code)") - // 에러 응답 바디 출력 - if let body = undocPayload.body { - let errorData = try? await Data(collecting: body, upTo: .max) - if let errorData, let errorString = String(data: errorData, encoding: .utf8) { - print("❌ [HistoryAPI] 에러 응답: \(errorString)") - } - } + case .undocumented(statusCode: let code, _): throw HistoryAPIError.serverError(statusCode: code) } } diff --git a/Codive/Shared/Data/Network/JSONDecoderFactory.swift b/Codive/Shared/Data/Network/JSONDecoderFactory.swift new file mode 100644 index 00000000..f5e99eb3 --- /dev/null +++ b/Codive/Shared/Data/Network/JSONDecoderFactory.swift @@ -0,0 +1,55 @@ +// +// JSONDecoderFactory.swift +// Codive +// +// Created by 황상환 on 1/14/26. +// + +import Foundation + +// MARK: - JSONDecoderFactory + +enum JSONDecoderFactory { + + /// API 응답용 JSONDecoder (다양한 날짜 형식 지원) + static func makeAPIDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + // ISO8601 표준 형식 시도 + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso8601Formatter.date(from: dateString) { return date } + + iso8601Formatter.formatOptions = [.withInternetDateTime] + if let date = iso8601Formatter.date(from: dateString) { return date } + + // 나노초 포함 형식 처리 (서버에서 다양한 자릿수로 응답) + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + let formats = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS", // 9자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", // 8자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", // 7자리 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", // 6자리 + "yyyy-MM-dd'T'HH:mm:ss.SSS", // 3자리 + "yyyy-MM-dd'T'HH:mm:ss" // 소수점 없음 + ] + + for format in formats { + dateFormatter.dateFormat = format + if let date = dateFormatter.date(from: dateString) { return date } + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "날짜 파싱 실패: \(dateString)" + ) + } + return decoder + } +} From 4fc0da06868476bd6a93260d2afff56cbd5e77c5 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:21:15 +0900 Subject: [PATCH 21/37] =?UTF-8?q?[#39]=20Auth=20API=20Result=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20async=20throws=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthStatusResult enum 제거 - checkAuthStatus, reissueTokens 등 async throws로 변경 --- Codive/Application/AppRootView.swift | 24 ++++++------------- .../Repositories/AuthRepositoryImpl.swift | 4 ++-- .../Auth/Domain/Models/AuthModels.swift | 6 ----- .../Domain/Protocols/AuthRepository.swift | 2 +- 4 files changed, 10 insertions(+), 26 deletions(-) diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index fc82c91f..e1b1183d 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -96,32 +96,22 @@ struct AppRootView: View { Task { do { try await authRepository.saveTokens(accessToken: unwrappedAccessToken, refreshToken: unwrappedRefreshToken) - print("🔑 [로그인 성공] JWT 토큰 저장 완료") // 로딩 표시 appRouter.showLoading() // 회원 상태 확인 - let statusResult = await authRepository.checkAuthStatus() + let status = try await authRepository.checkAuthStatus() - switch statusResult { - case .success(let status): - switch status { - case .notAgreed: - print("📋 약관 동의 필요 → TermsAgreementView로 이동") - appRouter.navigateToTerms() - case .registered: - print("✅ 가입 완료 → 메인으로 이동") - appRouter.navigateToMain() - } - case .failure(let error): - print("❌ 상태 확인 실패: \(error.localizedDescription)") - // 실패 시에도 일단 메인으로 (또는 에러 처리) + switch status { + case .notAgreed: + appRouter.navigateToTerms() + case .registered: appRouter.navigateToMain() } } catch { - print("Failed to save tokens from deep link: \(error.localizedDescription)") - appRouter.hideLoading() + // 실패 시에도 일단 메인으로 + appRouter.navigateToMain() } } } diff --git a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift index 1d7d29f1..11e89e43 100644 --- a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift +++ b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift @@ -38,8 +38,8 @@ final class AuthRepositoryImpl: AuthRepository { } } - func checkAuthStatus() async -> AuthStatusResult { - return await authAPIService.checkAuthStatus() + func checkAuthStatus() async throws -> RegisterStatus { + return try await authAPIService.checkAuthStatus() } func logout() async { diff --git a/Codive/Features/Auth/Domain/Models/AuthModels.swift b/Codive/Features/Auth/Domain/Models/AuthModels.swift index 445c4746..13b73f6f 100644 --- a/Codive/Features/Auth/Domain/Models/AuthModels.swift +++ b/Codive/Features/Auth/Domain/Models/AuthModels.swift @@ -74,9 +74,3 @@ enum RegisterStatus { case notAgreed // 약관 동의 필요 case registered // 약관 동의 완료 } - -// MARK: - Auth Status Result -enum AuthStatusResult { - case success(RegisterStatus) - case failure(AuthError) -} diff --git a/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift b/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift index 0e0d2bbc..32760919 100644 --- a/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift +++ b/Codive/Features/Auth/Domain/Protocols/AuthRepository.swift @@ -10,7 +10,7 @@ import Foundation // MARK: - Auth Repository Protocol protocol AuthRepository { func socialLogin(provider: AuthProvider) async -> AuthResult - func checkAuthStatus() async -> AuthStatusResult + func checkAuthStatus() async throws -> RegisterStatus func logout() async func saveTokens(accessToken: String, refreshToken: String) async throws } From 1b2eed92ad655642f20fdcdad439cc1d5bf46f9e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:21:22 +0900 Subject: [PATCH 22/37] =?UTF-8?q?[#39]=20SplashViewModel=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20TokenConfig=20=EC=83=81=EC=88=98?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SplashViewModel을 별도 파일로 분리 - TokenConfig enum 추가하여 expirationBuffer 상수화 --- Codive/Features/Auth/Data/TokenService.swift | 14 +- .../Auth/Presentation/View/SplashView.swift | 143 +----------------- .../ViewModel/SplashViewModel.swift | 127 ++++++++++++++++ 3 files changed, 139 insertions(+), 145 deletions(-) create mode 100644 Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift diff --git a/Codive/Features/Auth/Data/TokenService.swift b/Codive/Features/Auth/Data/TokenService.swift index 58d7ce1f..3e61857b 100644 --- a/Codive/Features/Auth/Data/TokenService.swift +++ b/Codive/Features/Auth/Data/TokenService.swift @@ -7,6 +7,13 @@ import Foundation +// MARK: - Token Config + +enum TokenConfig { + /// 토큰 만료 여유 시간 (만료 전 이 시간부터 만료로 간주) + static let expirationBuffer: TimeInterval = 300 // 5분 +} + // MARK: - Token Service Protocol protocol TokenServiceProtocol { @@ -23,9 +30,6 @@ final class TokenService: TokenServiceProtocol { private let keychainManager: KeychainManager - // 토큰 만료 여유 시간 (5분 전에 만료로 간주) - private let expirationBuffer: TimeInterval = 300 - init(keychainManager: KeychainManager = KeychainManager.shared) { self.keychainManager = keychainManager } @@ -72,9 +76,7 @@ final class TokenService: TokenServiceProtocol { } let currentTime = Date().timeIntervalSince1970 - let isExpired = currentTime > (exp - expirationBuffer) - - return isExpired + return currentTime > (exp - TokenConfig.expirationBuffer) } /// JWT에서 만료 시간(exp) 추출 diff --git a/Codive/Features/Auth/Presentation/View/SplashView.swift b/Codive/Features/Auth/Presentation/View/SplashView.swift index 8ce5eb89..1e5fa624 100644 --- a/Codive/Features/Auth/Presentation/View/SplashView.swift +++ b/Codive/Features/Auth/Presentation/View/SplashView.swift @@ -8,6 +8,7 @@ import SwiftUI // MARK: - SplashView (순수 UI) + struct SplashView: View { let displayedText: String @@ -44,7 +45,8 @@ struct SplashView: View { } } -// MARK: - SplashContainerView +// MARK: - SplashContainerView + struct SplashContainerView: View { @StateObject private var viewModel: SplashViewModel @@ -61,145 +63,8 @@ struct SplashContainerView: View { } } -// MARK: - SplashViewModel (타이핑 애니메이션 + 자동 로그인) -@MainActor -final class SplashViewModel: ObservableObject { - - @Published var displayedText: String = "" - - private let fullText: String = "Codive" - private let typingSpeed: Double = 0.4 - private let appRouter: AppRouter - - // 자동 로그인 관련 서비스 - private let tokenService: TokenServiceProtocol - private let authAPIService: AuthAPIServiceProtocol - private let keychainManager: KeychainManager - - init( - appRouter: AppRouter, - tokenService: TokenServiceProtocol = TokenService(), - authAPIService: AuthAPIServiceProtocol = AuthAPIService(), - keychainManager: KeychainManager = KeychainManager.shared - ) { - self.appRouter = appRouter - self.tokenService = tokenService - self.authAPIService = authAPIService - self.keychainManager = keychainManager - } - - func startAnimation() async { - // 타이핑 애니메이션 - for i in 1...fullText.count { - let endIndex = fullText.index(fullText.startIndex, offsetBy: i) - displayedText = String(fullText[.. Date: Sat, 17 Jan 2026 23:21:28 +0900 Subject: [PATCH 23/37] =?UTF-8?q?[#39]=20=EB=B3=B4=EC=95=88:=20JWT=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=BD=98=EC=86=94=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Auth/Data/SocialAuthService.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Codive/Features/Auth/Data/SocialAuthService.swift b/Codive/Features/Auth/Data/SocialAuthService.swift index b61c825e..0ea1b943 100644 --- a/Codive/Features/Auth/Data/SocialAuthService.swift +++ b/Codive/Features/Auth/Data/SocialAuthService.swift @@ -75,17 +75,6 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol { do { try KeychainManager.shared.saveAccessToken(accessToken) try KeychainManager.shared.saveRefreshToken(refreshToken) - - // 🔑 디버그용 토큰 출력 - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("🔑 [로그인 성공] JWT 토큰 저장 완료") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📌 Access Token:") - print(accessToken) - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📌 Refresh Token:") - print(refreshToken) - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") // 성공 시 임시 사용자 정보 반환 (나중에 서버에서 받아야 함) let authUser = AuthUser( From 1307ae40b93282a6d086f5de3e61a1a74c79b41f Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:21:36 +0900 Subject: [PATCH 24/37] =?UTF-8?q?[#39]=20ClothDataSource=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20-=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mapToCloth 헬퍼 메서드 추출 - CategoryConstants 로직을 Repository로 이동 --- .../Data/DataSources/ClothDataSource.swift | 147 ++++-------------- .../Repositories/ClothRepositoryImpl.swift | 14 +- 2 files changed, 43 insertions(+), 118 deletions(-) diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index f52f91ed..127a9612 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -17,8 +17,7 @@ protocol ClothDataSource { // MyCloset 전용 메서드 func fetchMyClosetClothItems( - mainCategory: String?, - subCategory: String?, + categoryId: Int?, seasons: Set, searchText: String? ) async throws -> [Cloth] @@ -57,8 +56,8 @@ final class DefaultClothDataSource: ClothDataSource { self.apiService = apiService } - // MARK: - Mock Data - + // MARK: - Mock Data (TODO: API 연결 후 제거) + private let mockClothItems: [ProductItem] = [ ProductItem(id: 1, imageName: "sample1", isTodayCloth: true, brand: "Nike", name: "에어포스 1"), ProductItem(id: 2, imageName: "sample2", isTodayCloth: true, brand: "Adidas", name: "후디"), @@ -68,34 +67,6 @@ final class DefaultClothDataSource: ClothDataSource { ProductItem(id: 6, imageName: "sample6", isTodayCloth: false, brand: nil, name: nil) ] - // MyCloset용 Mock Cloth 데이터 - private let mockMyClosetClothItems: [Cloth] = [ - // 상의 - Cloth(id: 1, imageUrl: "sample_tshirt1", name: "오버핏 반팔티", brand: "Uniqlo", categoryId: 1, seasons: [.spring, .summer]), - Cloth(id: 2, imageUrl: "sample_knit1", name: "케이블 니트", brand: "Zara", categoryId: 1, seasons: [.fall, .winter]), - Cloth(id: 3, imageUrl: "sample_hoodie1", name: "후드티", brand: "Nike", categoryId: 1, seasons: [.spring, .fall]), - // 바지 - Cloth(id: 4, imageUrl: "sample_jeans1", name: "블루 청바지", brand: "Levi's", categoryId: 2, seasons: [.spring, .summer, .fall]), - Cloth(id: 5, imageUrl: "sample_slacks1", name: "슬랙스", brand: "Zara", categoryId: 2, seasons: [.spring, .summer, .fall, .winter]), - Cloth(id: 6, imageUrl: "sample_shorts1", name: "반바지", brand: nil, categoryId: 2, seasons: [.summer]), - // 치마 - Cloth(id: 7, imageUrl: "sample_skirt1", name: "미니스커트", brand: "H&M", categoryId: 3, seasons: [.spring, .summer]), - Cloth(id: 8, imageUrl: "sample_dress1", name: "원피스", brand: "Mango", categoryId: 3, seasons: [.summer]), - // 아우터 - Cloth(id: 9, imageUrl: "sample_padding1", name: "숏패딩", brand: "The North Face", categoryId: 4, seasons: [.winter]), - Cloth(id: 10, imageUrl: "sample_coat1", name: "울 코트", brand: "Zara", categoryId: 4, seasons: [.fall, .winter]), - Cloth(id: 11, imageUrl: "sample_cardigan1", name: "가디건", brand: "Uniqlo", categoryId: 4, seasons: [.spring, .fall]), - // 신발 - Cloth(id: 12, imageUrl: "sample_sneakers1", name: "에어포스 1", brand: "Nike", categoryId: 5, seasons: [.spring, .summer, .fall]), - Cloth(id: 13, imageUrl: "sample_boots1", name: "첼시부츠", brand: "Dr.Martens", categoryId: 5, seasons: [.fall, .winter]), - // 가방 - Cloth(id: 14, imageUrl: "sample_backpack1", name: "백팩", brand: "Eastpak", categoryId: 6, seasons: [.spring, .summer, .fall, .winter]), - Cloth(id: 15, imageUrl: "sample_totebag1", name: "토트백", brand: nil, categoryId: 6, seasons: [.spring, .summer]), - // 패션소품 - Cloth(id: 16, imageUrl: "sample_cap1", name: "볼캡", brand: "New Era", categoryId: 7, seasons: [.spring, .summer]), - Cloth(id: 17, imageUrl: "sample_muffler1", name: "머플러", brand: nil, categoryId: 7, seasons: [.fall, .winter]) - ] - // MARK: - Methods func fetchClothItems(category: String?) async throws -> [ProductItem] { @@ -117,39 +88,32 @@ final class DefaultClothDataSource: ClothDataSource { guard inputs.count == images.count else { throw ClothDataSourceError.inputImageCountMismatch } - + // Step 1: Presigned URL 발급 - print("📤 [ClothDataSource] Step 1: Presigned URL 발급 요청...") let presignedInfos = try await apiService.getPresignedUrls(for: images) - print("✅ [ClothDataSource] Presigned URL \(presignedInfos.count)개 발급 완료") - + // Step 2: S3에 이미지 업로드 - print("📤 [ClothDataSource] Step 2: S3 업로드 시작...") - for (index, (imageData, presignedInfo)) in zip(images, presignedInfos).enumerated() { - print(" - 이미지 \(index + 1)/\(images.count) 업로드 중...") + for (imageData, presignedInfo) in zip(images, presignedInfos) { try await apiService.uploadImageToS3( presignedUrl: presignedInfo.presignedUrl, imageData: imageData, contentMD5: presignedInfo.md5Hash ) } - print("✅ [ClothDataSource] S3 업로드 완료") - + // Step 3: 옷 생성 API 호출 - print("📤 [ClothDataSource] Step 3: 옷 생성 API 호출...") let createRequests = zip(inputs, presignedInfos).map { input, presignedInfo in ClothCreateAPIRequest( clothImageUrl: presignedInfo.finalUrl, clothUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, name: input.name.isEmpty ? nil : input.name, brand: input.brand.isEmpty ? nil : input.brand, - season: input.seasons.first ?? .spring, // 첫 번째 계절 사용 + season: input.seasons.first ?? .spring, categoryId: Int64(input.categoryId ?? 0) ) } - + let clothIds = try await apiService.createClothes(requests: createRequests) - print("✅ [ClothDataSource] 옷 생성 완료: \(clothIds)") // 결과 변환: clothIds + inputs → Cloth 엔티티 return zip(clothIds, zip(inputs, presignedInfos)).map { clothId, pair in @@ -167,44 +131,19 @@ final class DefaultClothDataSource: ClothDataSource { } func fetchMyClosetClothItems( - mainCategory: String?, - subCategory: String?, + categoryId: Int?, seasons: Set, searchText: String? ) async throws -> [Cloth] { - print("📤 [ClothDataSource] 내 옷장 조회 API 호출...") - - // 카테고리 ID 변환 - var categoryId: Int64? - if let subCategory = subCategory { - // 서브카테고리 이름으로 ID 찾기 (전체 카테고리에서) - for category in CategoryConstants.all { - if let sub = category.subcategories.first(where: { $0.name == subCategory }) { - categoryId = Int64(sub.id) - break - } - } - } - - // API 호출 (전체 조회, 최대 100개) let result = try await apiService.fetchClothes( lastClothId: nil, size: 100, - categoryId: categoryId, + categoryId: categoryId.map { Int64($0) }, seasons: Array(seasons) ) - // ClothListItem → Cloth 변환 - var clothes = result.clothes.map { item in - return Cloth( - id: Int(item.clothId), - imageUrl: item.imageUrl, - name: item.name, - brand: item.brand - ) - } + var clothes = result.clothes.map(mapToCloth) - // 검색어 필터링 (클라이언트 사이드) if let searchText = searchText, !searchText.isEmpty { clothes = clothes.filter { cloth in let nameMatch = cloth.name?.localizedCaseInsensitiveContains(searchText) ?? false @@ -213,78 +152,54 @@ final class DefaultClothDataSource: ClothDataSource { } } - print("✅ [ClothDataSource] 내 옷장 조회 완료: \(clothes.count)개") return clothes } func deleteClothItems(_ clothIds: [Int]) async throws { - print("📤 [ClothDataSource] 옷 삭제 API 호출... clothIds: \(clothIds)") - for clothId in clothIds { try await apiService.deleteCloth(clothId: Int64(clothId)) } - - print("✅ [ClothDataSource] 옷 \(clothIds.count)개 삭제 완료") } - + // MARK: - API 연동 메서드 - - /// 옷 목록 조회 (실제 API 호출) + func fetchClothList( lastClothId: Int?, size: Int, categoryId: Int?, seasons: Set ) async throws -> (clothes: [Cloth], isLast: Bool) { - print("📤 [ClothDataSource] 옷 목록 조회 API 호출...") - let result = try await apiService.fetchClothes( lastClothId: lastClothId.map { Int64($0) }, size: Int32(size), categoryId: categoryId.map { Int64($0) }, seasons: Array(seasons) ) - - // ClothListItem → Cloth 변환 - let clothes = result.clothes.map { item in - Cloth( - id: Int(item.clothId), - imageUrl: item.imageUrl, - name: item.name, - brand: item.brand - ) - } - - print("✅ [ClothDataSource] 옷 목록 조회 완료: \(clothes.count)개, isLast: \(result.isLast)") - return (clothes: clothes, isLast: result.isLast) + + return (clothes: result.clothes.map(mapToCloth), isLast: result.isLast) } - - /// 옷 상세 조회 (실제 API 호출) + + // MARK: - Private Helpers + + private func mapToCloth(_ item: ClothListItem) -> Cloth { + Cloth( + id: Int(item.clothId), + imageUrl: item.imageUrl, + name: item.name, + brand: item.brand + ) + } + func fetchClothDetail(clothId: Int) async throws -> ClothDetailResult { - print("📤 [ClothDataSource] 옷 상세 조회 API 호출... clothId: \(clothId)") - - let result = try await apiService.fetchClothDetails(clothId: Int64(clothId)) - - print("✅ [ClothDataSource] 옷 상세 조회 완료: \(result.name ?? "이름없음")") - return result + return try await apiService.fetchClothDetails(clothId: Int64(clothId)) } - - /// 옷 수정 (실제 API 호출) + func updateCloth(clothId: Int, request: ClothUpdateAPIRequest) async throws { - print("📤 [ClothDataSource] 옷 수정 API 호출... clothId: \(clothId)") - try await apiService.updateCloth(clothId: Int64(clothId), request: request) - - print("✅ [ClothDataSource] 옷 수정 완료") } - - /// 옷 삭제 (실제 API 호출) - 단일 + func deleteCloth(clothId: Int) async throws { - print("📤 [ClothDataSource] 옷 삭제 API 호출... clothId: \(clothId)") - try await apiService.deleteCloth(clothId: Int64(clothId)) - - print("✅ [ClothDataSource] 옷 삭제 완료") } } diff --git a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift index 36562861..af2baee9 100644 --- a/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift +++ b/Codive/Features/Closet/Data/Repositories/ClothRepositoryImpl.swift @@ -41,9 +41,19 @@ final class ClothRepositoryImpl: ClothRepository { seasons: Set, searchText: String? ) async throws -> [Cloth] { + // 카테고리 ID 변환 (Repository 레이어에서 처리) + var categoryId: Int? + if let subCategory = subCategory { + for category in CategoryConstants.all { + if let sub = category.subcategories.first(where: { $0.name == subCategory }) { + categoryId = sub.id + break + } + } + } + return try await dataSource.fetchMyClosetClothItems( - mainCategory: mainCategory, - subCategory: subCategory, + categoryId: categoryId, seasons: seasons, searchText: searchText ) From 6c973f7f76bce9db3d9afdd539b194d5d2cb517d Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:21:45 +0900 Subject: [PATCH 25/37] =?UTF-8?q?[#39]=20ViewModel=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20-=20MainActor.run=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20TODO=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @MainActor 클래스 내 불필요한 MainActor.run 제거 - 에러 처리 TODO 주석 추가 --- .../ViewModel/ClothAddViewModel.swift | 22 ++++++++----------- .../ViewModel/ClothDetailViewModel.swift | 6 ++--- .../ViewModel/ClothEditViewModel.swift | 17 +++++--------- .../Add/ViewModel/RecordDetailViewModel.swift | 18 +-------------- 4 files changed, 17 insertions(+), 46 deletions(-) diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift index 759a2e50..7afd6c03 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothAddViewModel.swift @@ -227,20 +227,16 @@ final class ClothAddViewModel: ObservableObject, ClothAddViewModelInput, ClothAd ) // 성공: 성공 오버레이 표시 + 뒤에서 탭 전환/네비게이션 - await MainActor.run { - isLoading = false - navigationRouter.showSuccessAndNavigate( - message: "옷장에 옷을 보관했어요!", - to: .closet, - destination: .myCloset, - duration: 1.5 - ) - } + isLoading = false + navigationRouter.showSuccessAndNavigate( + message: "옷장에 옷을 보관했어요!", + to: .closet, + destination: .myCloset, + duration: 1.5 + ) } catch { - await MainActor.run { - isLoading = false - } - print("옷 저장 실패: \(error.localizedDescription)") + isLoading = false + // TODO: 에러 메시지를 UI에 표시 (errorMessage 프로퍼티 추가 필요) } } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift index f87795b4..c1987b2d 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift @@ -96,7 +96,7 @@ final class ClothDetailViewModel: ObservableObject { let result = try await clothRepository.fetchClothDetail(clothId: cloth.id) detailData = result } catch { - print("❌ 옷 상세 조회 실패: \(error)") + // 에러는 무시 (UI에서 기존 데이터 사용) } isLoading = false } @@ -123,11 +123,9 @@ final class ClothDetailViewModel: ObservableObject { func confirmDelete() async { do { try await deleteClothItemsUseCase.execute(clothIds: [cloth.id]) - // 삭제 성공 시 뒤로가기 navigationRouter.navigateBack() } catch { - // TODO: 에러 처리 - print("삭제 실패: \(error)") + // TODO: 에러 메시지를 UI에 표시 (errorMessage 프로퍼티 추가 필요) } } } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift index 958c76af..559553d1 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift @@ -135,7 +135,7 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth clothForm.purchaseUrl = url } } catch { - print("❌ 옷 상세 조회 실패: \(error)") + // 에러는 무시 (기존 데이터 사용) } isFetching = false } @@ -194,7 +194,6 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth Task { do { - // Season Set → 단일 Season 변환 (API가 단일 season만 받는 경우) let season = clothForm.selectedSeasons.first ?? .spring let request = ClothUpdateAPIRequest( @@ -207,17 +206,11 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth ) try await clothRepository.updateCloth(clothId: cloth.id, request: request) - - await MainActor.run { - isLoading = false - navigationRouter.navigateBack() - } + isLoading = false + navigationRouter.navigateBack() } catch { - await MainActor.run { - isLoading = false - errorMessage = "수정 실패: \(error.localizedDescription)" - print("❌ 옷 수정 실패: \(error)") - } + isLoading = false + errorMessage = "수정 실패: \(error.localizedDescription)" } } } diff --git a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift index f63ab184..60f48bd2 100644 --- a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift @@ -98,30 +98,16 @@ final class RecordDetailViewModel: ObservableObject { Task { do { - // 디버그 로그 - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📝 [RecordDetail] 기록 생성 시작") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📋 선택된 스타일 (원본): \(selectedStyles)") - print("📋 선택된 상황 (원본): \(selectedSituations)") - print("📋 캡션: \(captionText)") - print("📋 사진 수: \(selectedPhotos.count)") - // 1. 스타일 ID 변환 let styleIds = StyleConstants.getIds(from: selectedStyles) - print("🔄 스타일 ID 변환 결과: \(styleIds)") guard !styleIds.isEmpty else { throw RecordError.noStyleSelected } // 2. 상황 ID 변환 (첫 번째 선택) - print("🔄 상황 ID 변환 시도...") - print(" - SituationConstants.all: \(SituationConstants.all.map { "\($0.name)(\($0.id))" })") guard let situationId = SituationConstants.getFirstId(from: selectedSituations) else { - print("❌ 상황 ID 변환 실패! selectedSituations: \(selectedSituations)") throw RecordError.noSituationSelected } - print("✅ 상황 ID: \(situationId)") // 3. 해시태그 추출 let hashtags = extractHashtags(from: captionText) @@ -150,8 +136,7 @@ final class RecordDetailViewModel: ObservableObject { ) // 6. API 호출 - let historyId = try await recordDataSource.createRecord(request: request) - print("✅ 기록 생성 완료 - historyId: \(historyId)") + _ = try await recordDataSource.createRecord(request: request) // 7. 성공 시 메인으로 isLoading = false @@ -160,7 +145,6 @@ final class RecordDetailViewModel: ObservableObject { } catch { isLoading = false errorMessage = error.localizedDescription - print("❌ 기록 생성 실패: \(error)") } } } From 1a3b6dc72b432b9948999a5dc14db8106fb2a273 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:21:52 +0900 Subject: [PATCH 26/37] =?UTF-8?q?[#39]=20NavigationRouter=20DispatchQueue?= =?UTF-8?q?=EB=A5=BC=20Task.sleep=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Router/NavigationRouter.swift | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Codive/Router/NavigationRouter.swift b/Codive/Router/NavigationRouter.swift index e0dac961..c6e74397 100644 --- a/Codive/Router/NavigationRouter.swift +++ b/Codive/Router/NavigationRouter.swift @@ -66,9 +66,9 @@ final class NavigationRouter: ObservableObject { pendingTabSwitch = tab if let destination = destination { - // 약간의 딜레이 후 네비게이션 (탭 전환이 완료된 후) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.navigate(to: destination) + Task { + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1초 + navigate(to: destination) } } } @@ -78,15 +78,15 @@ final class NavigationRouter: ObservableObject { // 1. 성공 오버레이 표시 successMessage = message - // 2. 뒤에서 탭 전환 + 네비게이션 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.switchTabAndNavigate(to: tab, destination: destination) - } + Task { + // 2. 뒤에서 탭 전환 + 네비게이션 + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1초 + switchTabAndNavigate(to: tab, destination: destination) - // 3. duration 후 성공 오버레이 닫기 - DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + // 3. duration 후 성공 오버레이 닫기 + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) withAnimation(.easeOut(duration: 0.3)) { - self?.successMessage = nil + successMessage = nil } } } From 6e820876cd44119d08d8c2253dc548d3c564cc32 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:21:58 +0900 Subject: [PATCH 27/37] =?UTF-8?q?[#39]=20HistoryClothTag=EB=A5=BC=20Record?= =?UTF-8?q?ClothTag=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/RecordDataSource.swift | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift b/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift index eebd06c6..c369afe5 100644 --- a/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/RecordDataSource.swift @@ -51,23 +51,14 @@ final class DefaultRecordDataSource: RecordDataSource { } func createRecord(request: RecordCreateRequest) async throws -> Int64 { - print("📝 [RecordDataSource] 기록 생성 시작") - // Step 1: 이미지 업로드 (Presigned URL → S3) let imageUrls = try await uploadImages(photos: request.photos) - print("✅ [RecordDataSource] 이미지 \(imageUrls.count)개 업로드 완료") // Step 2: API 요청 생성 let payloads = zip(imageUrls, request.photos).map { url, photo in HistoryImagePayload( imageUrl: url, - clothTags: photo.clothTags.map { tag in - HistoryClothTag( - clothId: tag.clothId, - locationX: tag.locationX, - locationY: tag.locationY - ) - } + clothTags: photo.clothTags ) } @@ -80,10 +71,7 @@ final class DefaultRecordDataSource: RecordDataSource { ) // Step 3: 기록 생성 API 호출 - let historyId = try await historyAPIService.createHistory(request: apiRequest) - print("✅ [RecordDataSource] 기록 생성 완료 - historyId: \(historyId)") - - return historyId + return try await historyAPIService.createHistory(request: apiRequest) } // MARK: - Private Methods @@ -102,8 +90,7 @@ final class DefaultRecordDataSource: RecordDataSource { let presignedInfos = try await clothAPIService.getPresignedUrls(for: imageDatas) // S3 업로드 - for (index, (imageData, presignedInfo)) in zip(imageDatas, presignedInfos).enumerated() { - print("📤 [RecordDataSource] 이미지 \(index + 1)/\(imageDatas.count) 업로드 중...") + for (imageData, presignedInfo) in zip(imageDatas, presignedInfos) { try await clothAPIService.uploadImageToS3( presignedUrl: presignedInfo.presignedUrl, imageData: imageData, From a6ac145920ec0198a9f654a85c7e4b0a662da4a4 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:22:05 +0900 Subject: [PATCH 28/37] =?UTF-8?q?[#39]=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EB=B7=B0=20=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Auth/Presentation/View/TermsAgreementView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift b/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift index 61f66e7c..832ecc8c 100644 --- a/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift +++ b/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift @@ -156,8 +156,6 @@ struct TermsAgreementView: View { // POST /terms 호출 try await termsAPIService.agreeTerms(agreements: termAgreements) - print("✅ 약관 동의 완료!") - // 메인으로 이동 await MainActor.run { isLoading = false From 5419df5c6167079b4e2020398ea9b594268930ca Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:59:52 +0900 Subject: [PATCH 29/37] =?UTF-8?q?[#39]=20=EC=98=B7=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?API=20=EC=9A=94=EC=B2=AD=EC=97=90=20season=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EB=B0=B0=EC=97=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Closet/Data/ClothAPIService.swift | 12 ++++++------ .../Closet/Data/DataSources/ClothDataSource.swift | 2 +- .../Presentation/ViewModel/ClothEditViewModel.swift | 4 +--- Tuist/Package.resolved | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index d0a33219..4c52dbc3 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -35,7 +35,7 @@ struct ClothCreateAPIRequest { let clothUrl: String? let name: String? let brand: String? - let season: Season + let seasons: [Season] let categoryId: Int64 } @@ -65,7 +65,7 @@ struct ClothUpdateAPIRequest { let clothUrl: String? let name: String? let brand: String? - let season: Season // API에서 required + let seasons: [Season] // API에서 required let categoryId: Int64 // API에서 required } @@ -153,7 +153,7 @@ extension ClothAPIService { clothUrl: request.clothUrl, name: request.name, brand: request.brand, - season: mapSeasonToCreateAPI(request.season), + seasons: request.seasons.map { mapSeasonToCreatePayload($0) }, categoryId: request.categoryId ) } @@ -245,7 +245,7 @@ extension ClothAPIService { clothUrl: request.clothUrl, name: request.name, brand: request.brand, - season: mapSeasonToUpdateAPI(request.season), + seasons: request.seasons.map { mapSeasonToUpdatePayload($0) }, categoryId: request.categoryId ) @@ -291,7 +291,7 @@ private extension ClothAPIService { return components.string ?? presignedUrl } - func mapSeasonToCreateAPI(_ season: Season) -> Components.Schemas.ClothCreateRequest.seasonPayload { + func mapSeasonToCreatePayload(_ season: Season) -> Components.Schemas.ClothCreateRequest.seasonsPayloadPayload { switch season { case .spring: return .SPRING case .summer: return .SUMMER @@ -300,7 +300,7 @@ private extension ClothAPIService { } } - func mapSeasonToUpdateAPI(_ season: Season) -> Components.Schemas.ClothUpdateRequest.seasonPayload { + func mapSeasonToUpdatePayload(_ season: Season) -> Components.Schemas.ClothUpdateRequest.seasonsPayloadPayload { switch season { case .spring: return .SPRING case .summer: return .SUMMER diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index 127a9612..bf19cc18 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -108,7 +108,7 @@ final class DefaultClothDataSource: ClothDataSource { clothUrl: input.purchaseUrl.isEmpty ? nil : input.purchaseUrl, name: input.name.isEmpty ? nil : input.name, brand: input.brand.isEmpty ? nil : input.brand, - season: input.seasons.first ?? .spring, + seasons: Array(input.seasons), categoryId: Int64(input.categoryId ?? 0) ) } diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift index 559553d1..681b63b8 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothEditViewModel.swift @@ -194,14 +194,12 @@ final class ClothEditViewModel: ObservableObject, ClothEditViewModelInput, Cloth Task { do { - let season = clothForm.selectedSeasons.first ?? .spring - let request = ClothUpdateAPIRequest( clothImageUrl: imageUrl, clothUrl: clothForm.purchaseUrl.isEmpty ? nil : clothForm.purchaseUrl, name: clothForm.name.isEmpty ? nil : clothForm.name, brand: clothForm.brand.isEmpty ? nil : clothForm.brand, - season: season, + seasons: Array(clothForm.selectedSeasons), categoryId: Int64(subcategory.id) ) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index e939400c..857e0670 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "abcde5604a29956ec070f09459daa4428115daee" + "revision" : "124c84772a8a0199b93c4ac81f2b98dc926f6430" } }, { From 8a33631cb4421c750111421a71fc95213b0e5665 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:01:26 +0900 Subject: [PATCH 30/37] =?UTF-8?q?[#39]=20=EC=9E=90=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9E=84=EC=8B=9C=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/Presentation/ViewModel/SplashViewModel.swift | 6 ++++++ Codive/Shared/Data/Storage/KeychainManager.swift | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift index 83fbefa4..e987e7c7 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift @@ -66,6 +66,11 @@ final class SplashViewModel: ObservableObject { } private func checkAutoLogin() async { + // 임시 자동 로그인 해제 (토큰이 있어도 로그인 화면으로 이동) + appRouter.finishSplash() + return + + /* // 1. 키체인에 토큰이 있는지 확인 guard tokenService.hasValidTokens() else { appRouter.finishSplash() @@ -86,6 +91,7 @@ final class SplashViewModel: ObservableObject { // 4. 토큰 재발급 await reissueTokens() + */ } private func reissueTokens() async { diff --git a/Codive/Shared/Data/Storage/KeychainManager.swift b/Codive/Shared/Data/Storage/KeychainManager.swift index a39fe691..466283ee 100644 --- a/Codive/Shared/Data/Storage/KeychainManager.swift +++ b/Codive/Shared/Data/Storage/KeychainManager.swift @@ -34,7 +34,10 @@ final class KeychainManager { func saveAccessToken(_ token: String) throws { do { try save(token, forKey: accessTokenKey) - print("Keychain: Access token saved successfully.") + print("----------------------------------------") + print("🔑 Access Token Saved:") + print(token) + print("----------------------------------------") } catch { print("Keychain: Failed to save access token: \(error.localizedDescription)") throw error From b178b292f0e756d5bb2769e48e09eaa624679436 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:29:29 +0900 Subject: [PATCH 31/37] =?UTF-8?q?[#39]=20develop=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=EC=99=80=20=EB=B3=91=ED=95=A9=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옷 추가 시, CustomSuccessView가 뜨지 않던 버그 --- .../Components/ClothingCardView.swift | 2 +- .../ViewModel/MyClosetSectionViewModel.swift | 2 +- Codive/Features/Main/View/MainTabView.swift | 148 +++++++++--------- 3 files changed, 77 insertions(+), 75 deletions(-) diff --git a/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift b/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift index 7a17ca5b..104f249c 100644 --- a/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift +++ b/Codive/Features/Closet/Presentation/Components/ClothingCardView.swift @@ -34,7 +34,7 @@ struct ClothingCardView: View { .resizable() .aspectRatio(contentMode: .fill) case .failure(let error): - let _ = print("❌ [ClothingCard] 이미지 로드 실패: \(error)") + let _ = print(" [ClothingCard] 이미지 로드 실패: \(error)") Rectangle() .fill(Color.Codive.grayscale4) .overlay( diff --git a/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift index 7c6b322c..9e2b46e6 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift @@ -49,7 +49,7 @@ final class MyClosetSectionViewModel: ObservableObject { clothItems = Array(allItems.prefix(8)) print("✅ [MyClosetSection] 옷 \(clothItems.count)개 로드 완료") } catch { - print("❌ [MyClosetSection] 옷 로딩 실패: \(error)") + print("[MyClosetSection] 옷 로딩 실패: \(error)") } isLoading = false diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 2ce805da..77e09dfa 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -43,84 +43,86 @@ struct MainTabView: View { // MARK: - Body var body: some View { - NavigationStack(path: $navigationRouter.path) { - // 최상위 ZStack: 여기서 시트를 띄워야 전체(상단바 포함)를 덮습니다. - ZStack(alignment: .bottom) { - VStack(spacing: 0) { - // 1. 상단바 - if shouldShowTopBar { - TopNavigationBar( - showSearchButton: showSearchButton, - showNotificationButton: showNotificationButton, - onSearchTap: viewModel.handleSearchTap, - onNotificationTap: viewModel.handleNotificationTap - ) - } - - // 2. 메인 콘텐츠 영역 - Group { - switch viewModel.selectedTab { - case .home: - HomeView(homeDIContainer: homeDIContainer, viewModel: homeViewModel) - .ignoresSafeArea(.all, edges: .bottom) - case .closet: - ClosetView(closetDIContainer: closetDIContainer) - case .add: - AddView(addDIContainer: addDIContainer) - .ignoresSafeArea(.all, edges: .bottom) - case .feed: - FeedView(viewModel: feedDIContainer.makeFeedViewModel()) - case .profile: - ProfileView() + ZStack { + NavigationStack(path: $navigationRouter.path) { + // 최상위 ZStack: 여기서 시트를 띄워야 전체(상단바 포함)를 덮습니다. + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + // 1. 상단바 + if shouldShowTopBar { + TopNavigationBar( + showSearchButton: showSearchButton, + showNotificationButton: showNotificationButton, + onSearchTap: viewModel.handleSearchTap, + onNotificationTap: viewModel.handleNotificationTap + ) } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - // 3. 하단 탭바 - TabBar(selectedTab: $viewModel.selectedTab) - .zIndex(shouldShowTabBar ? 1 : 0) - .allowsHitTesting(shouldShowTabBar) - } - .navigationDestination(for: AppDestination.self) { destination in - destinationView(for: destination) - } - .ignoresSafeArea(.keyboard, edges: .bottom) - - // 4. 바텀시트: VStack(상단바+컨텐츠+탭바) 위에 배치하여 전체를 딤 처리 - if homeViewModel.showLookBookSheet { - AddBottomSheet( - isPresented: $homeViewModel.showLookBookSheet, - entities: homeViewModel.lookBookList, - thumbnailProvider: { entity in - AsyncImage(url: URL(string: entity.imageUrl)) { image in - image.resizable().scaledToFill() - } placeholder: { - ProgressView() + // 2. 메인 콘텐츠 영역 + Group { + switch viewModel.selectedTab { + case .home: + HomeView(homeDIContainer: homeDIContainer, viewModel: homeViewModel) + .ignoresSafeArea(.all, edges: .bottom) + case .closet: + ClosetView(closetDIContainer: closetDIContainer) + case .add: + AddView(addDIContainer: addDIContainer) + .ignoresSafeArea(.all, edges: .bottom) + case .feed: + FeedView(viewModel: feedDIContainer.makeFeedViewModel()) + case .profile: + ProfileView() } - }, - onTapEntity: { entity in - homeViewModel.selectLookBook(entity) } - ) - .zIndex(100) // 가장 높은 숫자로 설정 - .transition(.move(edge: .bottom)) - } - - if homeViewModel.showCompletePopUp { - CompletePopUp( - isPresented: $homeViewModel.showCompletePopUp, - onRecordTapped: homeViewModel.handlePopupRecord, - onCloseTapped: homeViewModel.handlePopupClose, - selectedClothes: homeViewModel.selectedCodiClothes - ) - .zIndex(200) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // 3. 하단 탭바 + TabBar(selectedTab: $viewModel.selectedTab) + .zIndex(shouldShowTabBar ? 1 : 0) + .allowsHitTesting(shouldShowTabBar) + } + .navigationDestination(for: AppDestination.self) { destination in + destinationView(for: destination) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + + // 4. 바텀시트: VStack(상단바+컨텐츠+탭바) 위에 배치하여 전체를 딤 처리 + if homeViewModel.showLookBookSheet { + AddBottomSheet( + isPresented: $homeViewModel.showLookBookSheet, + entities: homeViewModel.lookBookList, + thumbnailProvider: { entity in + AsyncImage(url: URL(string: entity.imageUrl)) { image in + image.resizable().scaledToFill() + } placeholder: { + ProgressView() + } + }, + onTapEntity: { entity in + homeViewModel.selectLookBook(entity) + } + ) + .zIndex(100) // 가장 높은 숫자로 설정 + .transition(.move(edge: .bottom)) + } + + if homeViewModel.showCompletePopUp { + CompletePopUp( + isPresented: $homeViewModel.showCompletePopUp, + onRecordTapped: homeViewModel.handlePopupRecord, + onCloseTapped: homeViewModel.handlePopupClose, + selectedClothes: homeViewModel.selectedCodiClothes + ) + .zIndex(200) + } } - } - .environmentObject(navigationRouter) - .onReceive(navigationRouter.$pendingTabSwitch) { tab in - if let tab = tab { - viewModel.selectedTab = tab - navigationRouter.pendingTabSwitch = nil + .environmentObject(navigationRouter) + .onReceive(navigationRouter.$pendingTabSwitch) { tab in + if let tab = tab { + viewModel.selectedTab = tab + navigationRouter.pendingTabSwitch = nil + } } } From 43e70573585715a4196acf2117b83982c7df8139 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:37:56 +0900 Subject: [PATCH 32/37] =?UTF-8?q?[#39]=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/ViewModel/MyClosetSectionViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift index 9e2b46e6..bae38b55 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/MyClosetSectionViewModel.swift @@ -47,7 +47,7 @@ final class MyClosetSectionViewModel: ObservableObject { // 최대 8개만 표시 clothItems = Array(allItems.prefix(8)) - print("✅ [MyClosetSection] 옷 \(clothItems.count)개 로드 완료") + print("[MyClosetSection] 옷 \(clothItems.count)개 로드 완료") } catch { print("[MyClosetSection] 옷 로딩 실패: \(error)") } From 461dca06fc3004a5548387ee18b9179a70a57777 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:12:10 +0900 Subject: [PATCH 33/37] =?UTF-8?q?[#39]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=8B=9C,=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=EB=90=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Application/AppRootView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index e1b1183d..00b1f743 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -110,8 +110,9 @@ struct AppRootView: View { appRouter.navigateToMain() } } catch { - // 실패 시에도 일단 메인으로 - appRouter.navigateToMain() + appRouter.hideLoading() + // 실패 시 인증 화면으로 이동 + appRouter.finishSplash() } } } From 3ebf11284b3fb2dc28903ddf4ca15cbfb7f0a636 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:17:15 +0900 Subject: [PATCH 34/37] =?UTF-8?q?[#39]=20=EC=84=9C=EB=B2=84=20url=20config?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Auth/Data/SocialAuthService.swift | 5 ++++- Project.swift | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Codive/Features/Auth/Data/SocialAuthService.swift b/Codive/Features/Auth/Data/SocialAuthService.swift index 0ea1b943..9188c5de 100644 --- a/Codive/Features/Auth/Data/SocialAuthService.swift +++ b/Codive/Features/Auth/Data/SocialAuthService.swift @@ -27,7 +27,10 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol { // MARK: - Kakao Login (OIDC via ASWebAuthenticationSession) func kakaoLogin() async -> AuthResult { - let authURL = URL(string: "https://prod.clokey.store/oauth2/authorization/kakao")! + guard let urlString = Bundle.main.object(forInfoDictionaryKey: "KAKAO_AUTH_URL") as? String, + let authURL = URL(string: urlString) else { + return .failure(.unknown("Invalid auth URL configuration")) + } return await withCheckedContinuation { continuation in let session = ASWebAuthenticationSession( diff --git a/Project.swift b/Project.swift index 54c9ca23..167f9397 100644 --- a/Project.swift +++ b/Project.swift @@ -91,6 +91,7 @@ let project = Project( // 카카오 SDK 설정 "KAKAO_APP_KEY": "$(KAKAO_APP_KEY)", + "KAKAO_AUTH_URL": "$(KAKAO_AUTH_URL)", "CFBundleURLTypes": [ [ "CFBundleURLName": "KAKAO", From 3192f78fd60c11649c3e4fd9126765ff2e0ed7f5 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:28:09 +0900 Subject: [PATCH 35/37] =?UTF-8?q?[#39]=20OnboardingViewModel=EC=97=90?= =?UTF-8?q?=EC=84=9C=20SocialAuthService=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=8F=99=EC=9E=91=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/OnboardingViewModel.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift index 8e471f16..fb3e4bad 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/OnboardingViewModel.swift @@ -33,8 +33,26 @@ final class OnboardingViewModel: ObservableObject { // MARK: - Actions func kakaoLoginButtonTapped() async { - if let url = URL(string: "https://prod.clokey.store/oauth2/authorization/kakao") { - identifiableLoginURL = IdentifiableURL(url: url) + isLoading = true + errorMessage = nil + + let result = await authRepository.socialLogin(provider: .kakao) + + isLoading = false + + switch result { + case .success(let user): + print("카카오 로그인 성공: \(user.name ?? "Unknown") (\(user.id))") + appRouter.navigateToMain() + + case .failure(let error): + switch error { + case .cancelled: + print("카카오 로그인 취소됨") + return + default: + errorMessage = error.localizedDescription + } } } From 897ea911857143d77949c02f91b0330f17b9e9dc Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:39:00 +0900 Subject: [PATCH 36/37] =?UTF-8?q?[#39]=20=EC=9C=88=EB=8F=84=EC=9A=B0=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=8B=9C,=20=EA=B0=95=EC=A2=85=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Auth/Data/SocialAuthService.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Codive/Features/Auth/Data/SocialAuthService.swift b/Codive/Features/Auth/Data/SocialAuthService.swift index 9188c5de..1c4bccab 100644 --- a/Codive/Features/Auth/Data/SocialAuthService.swift +++ b/Codive/Features/Auth/Data/SocialAuthService.swift @@ -176,11 +176,11 @@ extension SocialAuthService: ASAuthorizationControllerDelegate { // MARK: - ASWebAuthenticationPresentationContextProviding extension SocialAuthService: ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - // 현재 활성 윈도우 반환 - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { - fatalError("No window found") - } + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } ?? UIWindow() + return window } } From 519b758b28c041edfafce7c22171abf7a180ef91 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:40:12 +0900 Subject: [PATCH 37/37] =?UTF-8?q?[#39]=20=EC=95=BD=EA=B4=80=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20API=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Application/AppRootView.swift | 12 +- .../View/TermsAgreementView.swift | 135 ++++++++++-------- 2 files changed, 75 insertions(+), 72 deletions(-) diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index 00b1f743..4693653b 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -75,16 +75,8 @@ struct AppRootView: View { return } - var accessToken: String? - var refreshToken: String? - - for item in queryItems { - if item.name == "accessToken" { - accessToken = item.value - } else if item.name == "refreshToken" { - refreshToken = item.value - } - } + let accessToken = queryItems.first(where: { $0.name == "accessToken" })?.value + let refreshToken = queryItems.first(where: { $0.name == "refreshToken" })?.value guard let unwrappedAccessToken = accessToken, let unwrappedRefreshToken = refreshToken else { diff --git a/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift b/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift index 832ecc8c..e0046bb1 100644 --- a/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift +++ b/Codive/Features/Auth/Presentation/View/TermsAgreementView.swift @@ -17,13 +17,10 @@ struct TermsAgreementView: View { private let termsAPIService: TermsAPIServiceProtocol // 약관 상태 관리 (termId 매핑) - @State private var agreements: [Int64: Bool] = [ - 1: false, // 서비스 이용약관 (필수) - 2: false, // 개인정보 처리방침 (필수) - 3: false, // 위치기반 서비스 이용약관 (필수) - 4: false, // 마케팅 정보 수신 동의 (선택) - 5: false // 푸시 알림 수신 동의 (선택) - ] + @State private var agreements: [Int64: Bool] = [:] + + // 서버에서 받아온 약관 목록 + @State private var termsList: [TermItem] = [] // 로딩 상태 @State private var isLoading = false @@ -31,12 +28,14 @@ struct TermsAgreementView: View { // 전체 동의 여부 private var isAllAgreed: Bool { - agreements.values.allSatisfy { $0 } + guard !termsList.isEmpty else { return false } + return agreements.values.allSatisfy { $0 } && agreements.count == termsList.count } - // 필수 항목 동의 여부 (termId 1, 2, 3) + // 필수 항목 동의 여부 private var canProceed: Bool { - (agreements[1] ?? false) && (agreements[2] ?? false) && (agreements[3] ?? false) + let requiredTerms = termsList.filter { !$0.isOptional } + return requiredTerms.allSatisfy { agreements[$0.termId] == true } } init( @@ -57,57 +56,44 @@ struct TermsAgreementView: View { .padding(.horizontal, 20) Spacer() - - // 약관 리스트 섹션 - VStack(spacing: 0) { - // 전체 동의 - AgreementRow( - title: "전체 동의", - isAgreed: Binding( - get: { isAllAgreed }, - set: { newValue in - for key in agreements.keys { - agreements[key] = newValue + + if isLoading && termsList.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + } else { + // 약관 리스트 섹션 + VStack(spacing: 0) { + // 전체 동의 + AgreementRow( + title: "전체 동의", + isAgreed: Binding( + get: { isAllAgreed }, + set: { newValue in + for term in termsList { + agreements[term.termId] = newValue + } } - } - ), - isBold: true, - showChevron: false - ) - - Divider() - .background(Color.Codive.grayscale2) - .padding(.vertical, 10) - - // 개별 항목들 - AgreementRow( - title: "서비스 이용약관", - isAgreed: binding(for: 1), - isRequired: true - ) - AgreementRow( - title: "개인정보 수집/이용 동의", - isAgreed: binding(for: 2), - isRequired: true - ) - AgreementRow( - title: "위치 기반 서비스 이용약관 동의", - isAgreed: binding(for: 3), - isRequired: true - ) - AgreementRow( - title: "마케팅 정보수신 동의", - isAgreed: binding(for: 4), - isRequired: false - ) - AgreementRow( - title: "푸시 알림 수신 동의", - isAgreed: binding(for: 5), - isRequired: false - ) + ), + isBold: true, + showChevron: false + ) + + Divider() + .background(Color.Codive.grayscale2) + .padding(.vertical, 10) + + // 개별 항목들 + ForEach(termsList, id: \.termId) { term in + AgreementRow( + title: term.title, + isAgreed: binding(for: term.termId), + isRequired: !term.isOptional + ) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 50) } - .padding(.horizontal, 20) - .padding(.bottom, 50) // 에러 메시지 if let errorMessage = errorMessage { @@ -120,9 +106,9 @@ struct TermsAgreementView: View { // 가입 완료 버튼 CustomButton( - text: isLoading ? "처리 중..." : "가입 완료", + text: isLoading && !termsList.isEmpty ? "처리 중..." : "가입 완료", widthType: .fixed, - isEnabled: canProceed && !isLoading + isEnabled: canProceed && (!isLoading || termsList.isEmpty) ) { submitAgreements() } @@ -131,9 +117,33 @@ struct TermsAgreementView: View { .padding(.bottom, 10) } .navigationBarHidden(true) + .task { + await loadTerms() + } } // MARK: - Helper Methods + + private func loadTerms() async { + isLoading = true + errorMessage = nil + + do { + let fetchedTerms = try await termsAPIService.fetchTerms() + termsList = fetchedTerms + + // agreements 초기화 + for term in fetchedTerms { + if agreements[term.termId] == nil { + agreements[term.termId] = false + } + } + } catch { + errorMessage = "약관 정보를 불러오는데 실패했습니다: \(error.localizedDescription)" + } + + isLoading = false + } private func binding(for termId: Int64) -> Binding { Binding( @@ -148,7 +158,8 @@ struct TermsAgreementView: View { Task { do { - // 동의 정보 생성 + // 동의한 항목만 필터링하거나 전체 전송 (API 명세에 따름) + // 여기서는 체크된 항목들의 리스트를 전송 let termAgreements = agreements.map { termId, agreed in TermAgreement(termId: termId, agreed: agreed) }