From 243479bfa1d74f30b7fd709171eea86cfaa83f0a Mon Sep 17 00:00:00 2001 From: AppGoni Date: Thu, 24 Sep 2026 12:20:56 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=9D=B8=EC=A6=9D=20=E2=80=94=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=C2=B7=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=C2=B7=ED=83=88=ED=87=B4=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?,=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=ED=8F=AC=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain: IssueGuestSessionUseCase + DeviceIdentifierProviding, AuthRepository를 게스트 발급으로 교체 (SignIn/Logout/Withdraw UseCase·SocialCredential·SignUpForm·SessionEnding·AlarmSessionTeardown 삭제) - AtchaData: POST /auth/guest {deviceId, fcmToken} 제안 계약(서버 합의 대기), withdraw·check·login·sign-up 제거 - CoreAuth: signOut/invalidateSession/clearSession·LogoutEndpoint 제거, SessionState.loginRequired → noSession Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Auth/Sources/AuthSessionManager.swift | 34 +-- .../Core/Auth/Sources/LogoutEndpoint.swift | 12 - .../Auth/Tests/AuthSessionManagerTests.swift | 78 +----- .../Data/Sources/DTO/AuthResponseDTO.swift | 8 +- .../Sources/DTO/GuestSessionRequestDTO.swift | 5 + .../Data/Sources/DTO/SignUpRequestDTO.swift | 12 - .../Data/Sources/DTO/WithdrawRequestDTO.swift | 4 - .../Data/Sources/Network/AuthEndpoint.swift | 53 +---- .../Data/Sources/Network/UserEndpoint.swift | 8 +- .../Repositories/AuthRepositoryImpl.swift | 37 +-- .../Repositories/UserRepositoryImpl.swift | 6 - Projects/Data/Tests/AuthEndpointTests.swift | 84 +------ .../Data/Tests/AuthResponseDTOTests.swift | 10 - Projects/Data/Tests/UserEndpointTests.swift | 12 - .../Sources/Entities/LoginSession.swift | 4 +- .../Domain/Sources/Entities/SignUpForm.swift | 32 --- .../Sources/Entities/SocialCredential.swift | 26 -- .../Interfaces/AlarmSessionTeardown.swift | 8 - .../Sources/Interfaces/AuthRepository.swift | 14 +- .../DeviceIdentifierProviding.swift | 5 + .../Interfaces/PushTokenProviding.swift | 4 +- .../Sources/Interfaces/SessionEnding.swift | 8 - .../Sources/Interfaces/SessionStoring.swift | 2 +- .../Interfaces/SocialLoginService.swift | 5 - .../Sources/Interfaces/UserRepository.swift | 2 - .../UseCases/IssueGuestSessionUseCase.swift | 32 +++ .../Sources/UseCases/LogoutUseCase.swift | 21 -- .../Sources/UseCases/SignInUseCase.swift | 86 ------- .../Sources/UseCases/WithdrawUseCase.swift | 20 -- ...DefaultIssueGuestSessionUseCaseTests.swift | 70 ++++++ .../Tests/DefaultLogoutUseCaseTests.swift | 40 ---- .../Tests/DefaultSignInUseCaseTests.swift | 223 ------------------ ...DefaultUpdateHomeAddressUseCaseTests.swift | 1 - .../Tests/DefaultWithdrawUseCaseTests.swift | 74 ------ 34 files changed, 155 insertions(+), 885 deletions(-) delete mode 100644 Projects/Core/Auth/Sources/LogoutEndpoint.swift create mode 100644 Projects/Data/Sources/DTO/GuestSessionRequestDTO.swift delete mode 100644 Projects/Data/Sources/DTO/SignUpRequestDTO.swift delete mode 100644 Projects/Data/Sources/DTO/WithdrawRequestDTO.swift delete mode 100644 Projects/Domain/Sources/Entities/SignUpForm.swift delete mode 100644 Projects/Domain/Sources/Entities/SocialCredential.swift delete mode 100644 Projects/Domain/Sources/Interfaces/AlarmSessionTeardown.swift create mode 100644 Projects/Domain/Sources/Interfaces/DeviceIdentifierProviding.swift delete mode 100644 Projects/Domain/Sources/Interfaces/SessionEnding.swift delete mode 100644 Projects/Domain/Sources/Interfaces/SocialLoginService.swift create mode 100644 Projects/Domain/Sources/UseCases/IssueGuestSessionUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/LogoutUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/SignInUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/WithdrawUseCase.swift create mode 100644 Projects/Domain/Tests/DefaultIssueGuestSessionUseCaseTests.swift delete mode 100644 Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift delete mode 100644 Projects/Domain/Tests/DefaultSignInUseCaseTests.swift delete mode 100644 Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift diff --git a/Projects/Core/Auth/Sources/AuthSessionManager.swift b/Projects/Core/Auth/Sources/AuthSessionManager.swift index d5e5d42c..2ee5e0aa 100644 --- a/Projects/Core/Auth/Sources/AuthSessionManager.swift +++ b/Projects/Core/Auth/Sources/AuthSessionManager.swift @@ -1,14 +1,14 @@ import CoreNetwork /// Whether a stored session exists at launch. The splash decides between the -/// home flow (`active`) and the login flow (`loginRequired`) on this alone — +/// home flow (`active`) and guest issuance (`noSession`) on this alone — /// token validity is proven lazily by the first authenticated request. public enum SessionState: Sendable, Equatable { case active - case loginRequired + case noSession } -/// Owns the session lifecycle: adopting server tokens after social login, +/// Owns the session lifecycle: adopting server tokens after guest issuance, /// token refresh via GET /auth/reissue, and the 401-recovery chain. /// Single-flight is guaranteed by keeping the in-progress recovery task on /// the actor — the legacy interceptor's waiting-queue semantics without the @@ -20,7 +20,7 @@ public actor AuthSessionManager { /// Yields once each time the session dies for good (no refresh token, or /// the server rejected the refresh). Single-consumer stream — the app - /// coordinator subscribes and routes back to login; yields are buffered + /// coordinator subscribes and re-issues a guest session; yields are buffered /// until consumed. public nonisolated let sessionExpired: AsyncStream @@ -39,37 +39,15 @@ public actor AuthSessionManager { /// Splash-time check: no network, no throw — a keychain read failure just /// means there is no usable session. public nonisolated func bootstrapState() -> SessionState { - ((try? tokenStore.accessToken()) != nil) ? .active : .loginRequired + ((try? tokenStore.accessToken()) != nil) ? .active : .noSession } - /// Adopts the server token pair obtained by social login. Actor-isolated + /// Adopts the server token pair obtained by guest issuance. Actor-isolated /// so adoption serializes with any in-flight recovery. public func adopt(_ tokens: TokenPair) throws { try tokenStore.save(tokens) } - /// Logout/withdrawal path (screens are a later phase — the clear path - /// ships now so callers never reach into TokenStore directly). - public func clearSession() throws { - try tokenStore.clear() - } - - /// Logout: best-effort server call (legacy special case — the *refresh* - /// token rides as Bearer), then local expiry either way. Offline or a - /// server failure must never trap the user in a session they asked to end. - public func signOut() async { - if let refreshToken = try? tokenStore.refreshToken() { - _ = try? await networkClient.data(for: LogoutEndpoint(refreshToken: refreshToken)) - } - _ = expireSession() - } - - /// Post-withdrawal cleanup: the server already revoked the tokens, so no - /// network call — just local expiry and the login-routing yield. - public func invalidateSession() { - _ = expireSession() - } - /// Single entry point for 401 recovery: refresh, or declare the session /// dead (`AuthError.loginRequired` + a `sessionExpired` yield). Concurrent /// callers join the in-flight recovery instead of starting another. diff --git a/Projects/Core/Auth/Sources/LogoutEndpoint.swift b/Projects/Core/Auth/Sources/LogoutEndpoint.swift deleted file mode 100644 index 5f221e55..00000000 --- a/Projects/Core/Auth/Sources/LogoutEndpoint.swift +++ /dev/null @@ -1,12 +0,0 @@ -import CoreNetwork - -/// Legacy-measured contract (TokenInterceptor special case): POST /auth/logout -/// with the *refresh* token as the Bearer credential — the reason this endpoint -/// lives in CoreAuth instead of AtchaData, which has no token access. -struct LogoutEndpoint: Endpoint { - let refreshToken: String - - var path: String { "/auth/logout" } - var method: HTTPMethod { .post } - var headers: [String: String] { ["Authorization": "Bearer \(refreshToken)"] } -} diff --git a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift index d58397c7..c5ea02c7 100644 --- a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift +++ b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift @@ -18,10 +18,10 @@ struct AuthSessionManagerTests { // MARK: - bootstrapState @Test - func bootstrapState_emptyStore_returnsLoginRequired() { + func bootstrapState_emptyStore_returnsNoSession() { let (manager, _) = makeManager() - #expect(manager.bootstrapState() == .loginRequired) + #expect(manager.bootstrapState() == .noSession) } @Test @@ -32,7 +32,7 @@ struct AuthSessionManagerTests { #expect(manager.bootstrapState() == .active) } - // MARK: - adopt / clearSession + // MARK: - adopt @Test func adopt_savesBothTokens() async throws { @@ -45,78 +45,6 @@ struct AuthSessionManagerTests { #expect(manager.bootstrapState() == .active) } - @Test - func clearSession_removesTokens() async throws { - try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, _) = makeManager() - - try await manager.clearSession() - - #expect(try tokenStore.accessToken() == nil) - #expect(manager.bootstrapState() == .loginRequired) - } - - // MARK: - signOut / invalidateSession - - /// Pins the legacy-measured logout contract: POST /auth/logout with the - /// *refresh* token as the Bearer credential. - @Test - func signOut_sendsPostLogoutWithBearerRefreshHeader_thenClearsAndYields() async throws { - try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, network) = makeManager() - - await manager.signOut() - - let logouts = network.recordedCalls(to: "/auth/logout") - #expect(logouts.count == 1) - #expect(logouts.first?.method == .post) - #expect(logouts.first?.headers["Authorization"] == "Bearer R") - #expect(try tokenStore.accessToken() == nil) - #expect(try tokenStore.refreshToken() == nil) - - var iterator = manager.sessionExpired.makeAsyncIterator() - #expect(await iterator.next() != nil) - } - - /// Best-effort: a server failure must not trap the user in the session. - @Test - func signOut_serverFailure_stillClearsTokensAndYields() async throws { - try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, _) = makeManager(handler: { _, _ in - throw NetworkError.offline(underlying: URLError(.notConnectedToInternet)) - }) - - await manager.signOut() - - #expect(try tokenStore.accessToken() == nil) - var iterator = manager.sessionExpired.makeAsyncIterator() - #expect(await iterator.next() != nil) - } - - /// No refresh token → nothing to revoke server-side; local expiry only. - @Test - func signOut_withoutRefreshToken_skipsServerCall() async throws { - let (manager, network) = makeManager() - - await manager.signOut() - - #expect(network.recorded.isEmpty) - } - - /// Post-withdrawal path: no network call, local expiry + yield only. - @Test - func invalidateSession_clearsTokensAndYieldsWithoutNetwork() async throws { - try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, network) = makeManager() - - await manager.invalidateSession() - - #expect(network.recorded.isEmpty) - #expect(try tokenStore.accessToken() == nil) - var iterator = manager.sessionExpired.makeAsyncIterator() - #expect(await iterator.next() != nil) - } - // MARK: - recoverSession /// Pins the legacy-measured reissue contract: GET /auth/reissue with the diff --git a/Projects/Data/Sources/DTO/AuthResponseDTO.swift b/Projects/Data/Sources/DTO/AuthResponseDTO.swift index 0eaeed0b..7ff6dcdf 100644 --- a/Projects/Data/Sources/DTO/AuthResponseDTO.swift +++ b/Projects/Data/Sources/DTO/AuthResponseDTO.swift @@ -1,12 +1,8 @@ import CoreNetwork import Domain -struct AuthCheckResponseDTO: Decodable, Sendable { - let exists: Bool -} - -/// `/auth/login` 응답(레거시 LoginDTO 실측) — 전 필드 옵셔널, 좌표 키는 "lat"/"lon". -/// lat/lon(집 좌표)은 현 스코프 미사용이라 디코딩만 하고 엔티티로 올리지 않는다. +/// 토큰 발급 응답 — 레거시 LoginDTO 실측 형태(전 필드 옵셔널, 좌표 키 "lat"/"lon")를 +/// 게스트 발급도 그대로 쓴다고 제안했다. lat/lon은 미사용이라 디코딩만 한다. struct LoginResponseDTO: Decodable, Sendable { let id: Int? let accessToken: String? diff --git a/Projects/Data/Sources/DTO/GuestSessionRequestDTO.swift b/Projects/Data/Sources/DTO/GuestSessionRequestDTO.swift new file mode 100644 index 00000000..9808139b --- /dev/null +++ b/Projects/Data/Sources/DTO/GuestSessionRequestDTO.swift @@ -0,0 +1,5 @@ +/// POST /auth/guest 요청(제안 계약) — 서버 합의 전까지 필드명은 가안이다. +struct GuestSessionRequestDTO: Encodable, Sendable { + let deviceId: String + let fcmToken: String? +} diff --git a/Projects/Data/Sources/DTO/SignUpRequestDTO.swift b/Projects/Data/Sources/DTO/SignUpRequestDTO.swift deleted file mode 100644 index 8ba37845..00000000 --- a/Projects/Data/Sources/DTO/SignUpRequestDTO.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// `POST /auth/sign-up` body(레거시 OnboardingDTO.SignUpRequest 실측). -/// 응답은 `/auth/login`과 계약이 같아 `LoginResponseDTO`를 재사용한다. -struct SignUpRequestDTO: Encodable, Sendable { - let provider: Int - let userName: String? - let address: String - let lat: Double - let lon: Double - let alertFrequencies: [Int] - /// 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. - let fcmToken: String -} diff --git a/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift b/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift deleted file mode 100644 index 5e3a2577..00000000 --- a/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift +++ /dev/null @@ -1,4 +0,0 @@ -/// `DELETE /members/me` body(레거시 WithdrawRequest 실측). -struct WithdrawRequestDTO: Encodable, Sendable { - let reason: String? -} diff --git a/Projects/Data/Sources/Network/AuthEndpoint.swift b/Projects/Data/Sources/Network/AuthEndpoint.swift index b13d7e6c..218af78b 100644 --- a/Projects/Data/Sources/Network/AuthEndpoint.swift +++ b/Projects/Data/Sources/Network/AuthEndpoint.swift @@ -1,70 +1,33 @@ import CoreNetwork -import Domain import Foundation -/// `/auth/check`·`/auth/login` — 소셜 자격 증명을 Bearer로 보내는 특수 계약(레거시 실측). -/// AuthenticatedNetworkClient를 타면 Authorization이 서버 토큰으로 덮이므로, -/// 이 엔드포인트는 반드시 plain client로 호출한다(AuthRepositoryImpl 주입 규약). +/// 게스트 토큰 발급 — 토큰 없이 호출한다. AuthenticatedNetworkClient를 타면 401 복구가 +/// 발급 경로로 재귀할 수 있으므로 반드시 plain client로 호출한다(AuthRepositoryImpl 주입 규약). enum AuthEndpoint: Endpoint { - case check(SocialCredential) - case login(SocialCredential, fcmToken: String?) - case signUp(SocialCredential, SignUpRequestDTO) + // TODO: [서버 합의] 경로·필드는 클라 제안안(POST /auth/guest). 확정되면 이 enum만 고친다. + case guest(GuestSessionRequestDTO) var path: String { switch self { - case .check: "/auth/check" - case .login: "/auth/login" - case .signUp: "/auth/sign-up" + case .guest: "/auth/guest" } } var method: HTTPMethod { switch self { - case .check, .login: .get - case .signUp: .post + case .guest: .post } } var headers: [String: String] { switch self { - case let .check(credential), let .login(credential, _): - ["Authorization": "Bearer \(credential.accessToken)"] - case let .signUp(credential, _): - [ - "Authorization": "Bearer \(credential.accessToken)", - "Content-Type": "application/json", - ] - } - } - - var queryItems: [URLQueryItem] { - switch self { - case let .check(credential): - [URLQueryItem(name: "provider", value: String(credential.provider.serverCode))] - case let .login(credential, fcmToken): - [ - URLQueryItem(name: "provider", value: String(credential.provider.serverCode)), - // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. - URLQueryItem(name: "fcmToken", value: fcmToken ?? ""), - ] - case .signUp: [] + case .guest: ["Content-Type": "application/json"] } } var body: Data? { switch self { - case .check, .login: nil - case let .signUp(_, request): try? JSONEncoder().encode(request) - } - } -} - -extension SocialLoginProvider { - /// 서버 provider 코드 — 레거시 `LoginType` raw value 실측(kakao=0, apple=1). - var serverCode: Int { - switch self { - case .kakao: 0 - case .apple: 1 + case let .guest(request): try? JSONEncoder().encode(request) } } } diff --git a/Projects/Data/Sources/Network/UserEndpoint.swift b/Projects/Data/Sources/Network/UserEndpoint.swift index 39c1c7ac..b02b5596 100644 --- a/Projects/Data/Sources/Network/UserEndpoint.swift +++ b/Projects/Data/Sources/Network/UserEndpoint.swift @@ -5,12 +5,10 @@ enum UserEndpoint: Endpoint { case me case updateHomeAddress(HomePatchRequestDTO) case updateAlertFrequencies(AlertFrequencyPatchRequestDTO) - // 탈퇴는 DELETE지만 body로 사유를 받는다 (레거시 실측). - case withdraw(WithdrawRequestDTO) var path: String { switch self { - case .me, .withdraw: "/members/me" + case .me: "/members/me" case .updateHomeAddress: "/members/me/home-address" case .updateAlertFrequencies: "/members/me/alert-frequency" } @@ -20,7 +18,6 @@ enum UserEndpoint: Endpoint { switch self { case .me: .get case .updateHomeAddress, .updateAlertFrequencies: .patch - case .withdraw: .delete } } @@ -28,7 +25,7 @@ enum UserEndpoint: Endpoint { switch self { // 레거시 실측: 조회에만 플랫폼 식별 헤더가 붙는다. case .me: ["X-Platform": "iOS"] - case .updateHomeAddress, .updateAlertFrequencies, .withdraw: + case .updateHomeAddress, .updateAlertFrequencies: ["Content-Type": "application/json"] } } @@ -38,7 +35,6 @@ enum UserEndpoint: Endpoint { case .me: nil case let .updateHomeAddress(request): try? JSONEncoder().encode(request) case let .updateAlertFrequencies(request): try? JSONEncoder().encode(request) - case let .withdraw(request): try? JSONEncoder().encode(request) } } } diff --git a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift index 486c7c7d..1c81e522 100644 --- a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift @@ -4,44 +4,15 @@ import Domain public struct AuthRepositoryImpl: AuthRepository { private let networkClient: any NetworkClient - /// plain client(AuthenticatedNetworkClient 미적용)를 주입할 것 — Authorization에 - /// 소셜 자격 증명을 실어야 하고, 401이 세션 복구를 촉발해서도 안 된다. + /// plain client(AuthenticatedNetworkClient 미적용)를 주입할 것 — 발급 요청은 토큰이 없고, + /// 401이 세션 복구를 촉발해서도 안 된다. public init(networkClient: any NetworkClient) { self.networkClient = networkClient } - public func checkRegistration(credential: SocialCredential) async throws -> Bool { - let dto: AuthCheckResponseDTO = try await networkClient.requestEnveloped( - AuthEndpoint.check(credential) - ) - return dto.exists - } - - public func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { - let dto: LoginResponseDTO = try await networkClient.requestEnveloped( - AuthEndpoint.login(credential, fcmToken: fcmToken) - ) - return try dto.toEntity() - } - - public func signUp( - credential: SocialCredential, - form: SignUpForm, - fcmToken: String? - ) async throws -> LoginSession { - let request = SignUpRequestDTO( - provider: credential.provider.serverCode, - userName: form.userName, - address: form.address, - lat: form.coordinate.latitude, - lon: form.coordinate.longitude, - alertFrequencies: form.alertFrequencies, - // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. - fcmToken: fcmToken ?? "" - ) - // 응답 계약은 /auth/login과 동일 — LoginResponseDTO 재사용. + public func issueGuestSession(deviceID: String, fcmToken: String?) async throws -> LoginSession { let dto: LoginResponseDTO = try await networkClient.requestEnveloped( - AuthEndpoint.signUp(credential, request) + AuthEndpoint.guest(GuestSessionRequestDTO(deviceId: deviceID, fcmToken: fcmToken)) ) return try dto.toEntity() } diff --git a/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift b/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift index 15e0b400..8af959fa 100644 --- a/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift @@ -32,10 +32,4 @@ public struct UserRepositoryImpl: UserRepository { UserEndpoint.updateAlertFrequencies(AlertFrequencyPatchRequestDTO(alertFrequencies: frequencies)) ) } - - public func withdraw(reason: String?) async throws { - let _: APIEmptyResult = try await networkClient.requestEnveloped( - UserEndpoint.withdraw(WithdrawRequestDTO(reason: reason)) - ) - } } diff --git a/Projects/Data/Tests/AuthEndpointTests.swift b/Projects/Data/Tests/AuthEndpointTests.swift index 1d29c359..acdb930f 100644 --- a/Projects/Data/Tests/AuthEndpointTests.swift +++ b/Projects/Data/Tests/AuthEndpointTests.swift @@ -1,88 +1,20 @@ @testable import AtchaData import CoreNetwork -import Domain import Foundation import Testing struct AuthEndpointTests { - private let kakaoCredential = SocialCredential(provider: .kakao, accessToken: "KAKAO_AT") - private let appleCredential = SocialCredential(provider: .apple, accessToken: "APPLE_IDT") - - /// 레거시 실측 계약 핀: 소셜 토큰이 Bearer로, provider는 kakao=0. - @Test - func check_kakao_sendsSocialBearerAndProviderZero() { - let endpoint = AuthEndpoint.check(kakaoCredential) - - #expect(endpoint.path == "/auth/check") - #expect(endpoint.method == .get) - #expect(endpoint.headers == ["Authorization": "Bearer KAKAO_AT"]) - #expect(endpoint.queryItems == [URLQueryItem(name: "provider", value: "0")]) - } - - @Test - func check_apple_sendsProviderOne() { - let endpoint = AuthEndpoint.check(appleCredential) - - #expect(endpoint.headers == ["Authorization": "Bearer APPLE_IDT"]) - #expect(endpoint.queryItems == [URLQueryItem(name: "provider", value: "1")]) - } - + /// 제안 계약 핀 — 서버 합의로 바뀌면 이 테스트가 먼저 깨져야 한다. @Test - func login_sendsProviderAndFcmToken() { - let endpoint = AuthEndpoint.login(kakaoCredential, fcmToken: "FCM") + func guest_postsDeviceIdAndFcmTokenWithoutAuthorization() throws { + let endpoint = AuthEndpoint.guest(GuestSessionRequestDTO(deviceId: "DEVICE", fcmToken: "FCM")) - #expect(endpoint.path == "/auth/login") - #expect(endpoint.method == .get) - #expect(endpoint.headers == ["Authorization": "Bearer KAKAO_AT"]) - #expect(endpoint.queryItems == [ - URLQueryItem(name: "provider", value: "0"), - URLQueryItem(name: "fcmToken", value: "FCM"), - ]) - } - - /// 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. - @Test - func login_withoutFcmToken_sendsEmptyStringLikeLegacy() { - let endpoint = AuthEndpoint.login(appleCredential, fcmToken: nil) - - #expect(endpoint.queryItems == [ - URLQueryItem(name: "provider", value: "1"), - URLQueryItem(name: "fcmToken", value: ""), - ]) - } - - /// 레거시 실측 계약 핀: sign-up도 소셜 토큰이 Bearer로, 필드는 JSON body로. - @Test - func signUp_sendsSocialBearerAndJSONBody() throws { - let request = SignUpRequestDTO( - provider: 0, - userName: "", - address: "서울 어딘가 1-2", - lat: 37.5, - lon: 127.0, - alertFrequencies: [1, 10], - fcmToken: "" - ) - let endpoint = AuthEndpoint.signUp(kakaoCredential, request) - - #expect(endpoint.path == "/auth/sign-up") + #expect(endpoint.path == "/auth/guest") #expect(endpoint.method == .post) - #expect(endpoint.headers == [ - "Authorization": "Bearer KAKAO_AT", - "Content-Type": "application/json", - ]) - #expect(endpoint.queryItems.isEmpty) - + #expect(endpoint.headers == ["Content-Type": "application/json"]) let body = try #require(endpoint.body) - let json = try #require( - try JSONSerialization.jsonObject(with: body) as? [String: Any] - ) - #expect(json["provider"] as? Int == 0) - #expect(json["userName"] as? String == "") - #expect(json["address"] as? String == "서울 어딘가 1-2") - #expect(json["lat"] as? Double == 37.5) - #expect(json["lon"] as? Double == 127.0) - #expect(json["alertFrequencies"] as? [Int] == [1, 10]) - #expect(json["fcmToken"] as? String == "") + let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(json["deviceId"] as? String == "DEVICE") + #expect(json["fcmToken"] as? String == "FCM") } } diff --git a/Projects/Data/Tests/AuthResponseDTOTests.swift b/Projects/Data/Tests/AuthResponseDTOTests.swift index 99aea7f7..130d7294 100644 --- a/Projects/Data/Tests/AuthResponseDTOTests.swift +++ b/Projects/Data/Tests/AuthResponseDTOTests.swift @@ -34,14 +34,4 @@ struct AuthResponseDTOTests { #expect(try dto.toEntity().userID == nil) } - - @Test - func checkResponse_decodesExists() throws { - let dto = try JSONDecoder().decode( - AuthCheckResponseDTO.self, - from: Data(#"{"exists": true}"#.utf8) - ) - - #expect(dto.exists) - } } diff --git a/Projects/Data/Tests/UserEndpointTests.swift b/Projects/Data/Tests/UserEndpointTests.swift index 0d99ca9a..1e6bec27 100644 --- a/Projects/Data/Tests/UserEndpointTests.swift +++ b/Projects/Data/Tests/UserEndpointTests.swift @@ -44,18 +44,6 @@ struct UserEndpointTests { #expect(json["alertFrequencies"] as? [Int] == [1, 10]) } - /// 레거시 실측: 탈퇴는 DELETE지만 body로 사유를 싣는다. - @Test - func withdraw_deletesWithReasonBody() throws { - let endpoint = UserEndpoint.withdraw(WithdrawRequestDTO(reason: "서비스 미사용")) - - #expect(endpoint.path == "/members/me") - #expect(endpoint.method == .delete) - - let json = try decodeBody(endpoint) - #expect(json["reason"] as? String == "서비스 미사용") - } - private func decodeBody(_ endpoint: any Endpoint) throws -> [String: Any] { let body = try #require(endpoint.body) return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) diff --git a/Projects/Domain/Sources/Entities/LoginSession.swift b/Projects/Domain/Sources/Entities/LoginSession.swift index 795a1433..87a3390c 100644 --- a/Projects/Domain/Sources/Entities/LoginSession.swift +++ b/Projects/Domain/Sources/Entities/LoginSession.swift @@ -1,5 +1,5 @@ -/// `/auth/login` 성공 결과 — 서버가 교환해준 토큰 쌍. -/// 응답의 집 좌표(lat/lon)는 현 스코프에서 쓰지 않으므로 엔티티에서 생략한다(DTO만 디코딩). +/// 게스트 발급(`/auth/guest`) 결과 — 서버 토큰 쌍. +/// 응답의 집 좌표(lat/lon)는 쓰지 않으므로 엔티티에서 생략한다(DTO만 디코딩). public struct LoginSession: Sendable, Equatable { public let userID: Int? public let accessToken: String diff --git a/Projects/Domain/Sources/Entities/SignUpForm.swift b/Projects/Domain/Sources/Entities/SignUpForm.swift deleted file mode 100644 index 4d613c0a..00000000 --- a/Projects/Domain/Sources/Entities/SignUpForm.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// `POST /auth/sign-up` 입력 — V2는 온보딩 화면 없이 최소 가입으로 보낸다. -/// 집 주소·알림 빈도는 가입 후 `/members/me` PATCH로 언제든 바꿀 수 있다. -public struct SignUpForm: Sendable, Equatable { - public let userName: String? - public let address: String - public let coordinate: Coordinate - public let alertFrequencies: [Int] - - public init( - userName: String?, - address: String, - coordinate: Coordinate, - alertFrequencies: [Int] - ) { - self.userName = userName - self.address = address - self.coordinate = coordinate - self.alertFrequencies = alertFrequencies - } - - /// 레거시 실측 기본 알림 빈도(HomeFindViewModel): 1분·10분 전. - public static let defaultAlertFrequencies = [1, 10] - - /// 위치를 확보하지 못했을 때의 폼 — 레거시가 실제로 보내던 값 - /// (userName ""·address ""·좌표 (0,0))이라 서버 수용이 실측돼 있다. - public static let minimalFallback = SignUpForm( - userName: "", - address: "", - coordinate: Coordinate(latitude: 0, longitude: 0), - alertFrequencies: defaultAlertFrequencies - ) -} diff --git a/Projects/Domain/Sources/Entities/SocialCredential.swift b/Projects/Domain/Sources/Entities/SocialCredential.swift deleted file mode 100644 index cf7ccd9c..00000000 --- a/Projects/Domain/Sources/Entities/SocialCredential.swift +++ /dev/null @@ -1,26 +0,0 @@ -/// 소셜 로그인 제공자 — 서버 provider 파라미터 값(kakao=0, apple=1)은 Data 레이어가 매핑한다. -public enum SocialLoginProvider: Sendable, Equatable, CaseIterable { - case kakao - case apple -} - -/// 소셜 SDK가 발급한 자격 증명 — 카카오는 OAuth access token, 애플은 identityToken. -/// 서버 `/auth/*` 호출에서 Bearer로 실려 서버 토큰과 교환된다. -public struct SocialCredential: Sendable, Equatable { - public let provider: SocialLoginProvider - public let accessToken: String - - public init(provider: SocialLoginProvider, accessToken: String) { - self.provider = provider - self.accessToken = accessToken - } -} - -public enum SocialLoginError: Error, Equatable, Sendable { - /// 사용자가 소셜 로그인 UI를 스스로 닫음 — 에러 표출 없이 idle 복귀. - case cancelled - /// 제공자를 쓸 수 없는 상태(카카오 SDK 미초기화 등). - case providerUnavailable - /// 그 외 제공자 실패. - case failed -} diff --git a/Projects/Domain/Sources/Interfaces/AlarmSessionTeardown.swift b/Projects/Domain/Sources/Interfaces/AlarmSessionTeardown.swift deleted file mode 100644 index 19b9ea8c..00000000 --- a/Projects/Domain/Sources/Interfaces/AlarmSessionTeardown.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// 계정 세션 종료(로그아웃·탈퇴·강제 만료) 시 알람 세션 정리 포트 — 구현은 App 어댑터. -/// 레거시는 로그아웃 후에도 알람·노티가 살아 있어 로그인 화면에서 알람이 울렸다(이식 금지 버그). -/// 멱등이어야 한다 — 로그아웃 경로와 만료 라우팅 경로가 연달아 부를 수 있다. -public protocol AlarmSessionTeardown: Sendable { - /// 로컬 알람·LA·스냅샷·동기화 상태를 비운다. cancelOnServer=true면 서버 알람 취소를 - /// 베스트 에포트로 먼저 시도한다 — 토큰이 아직 살아 있는 로그아웃 경로에서만 의미가 있다. - func tearDown(cancelOnServer: Bool) async -} diff --git a/Projects/Domain/Sources/Interfaces/AuthRepository.swift b/Projects/Domain/Sources/Interfaces/AuthRepository.swift index 8edf532d..596f7d38 100644 --- a/Projects/Domain/Sources/Interfaces/AuthRepository.swift +++ b/Projects/Domain/Sources/Interfaces/AuthRepository.swift @@ -1,11 +1,7 @@ -/// 로그인 도메인 API(`/auth/check`·`/auth/login`·`/auth/sign-up`) — 구현은 AtchaData. -/// 소셜 자격 증명을 Bearer로 보내는 특수 계약이라 서버 토큰 데코레이터를 타지 않는다 -/// (조합 루트가 plain client를 주입). +/// 게스트 인증 API — 구현은 AtchaData. 토큰 없이 호출하는 발급 계약이라 서버 토큰 데코레이터를 +/// 타지 않는다(조합 루트가 plain client를 주입). public protocol AuthRepository: Sendable { - /// GET /auth/check — 이 소셜 계정이 이미 가입돼 있는가. - func checkRegistration(credential: SocialCredential) async throws -> Bool - /// GET /auth/login — 소셜 자격 증명을 서버 토큰 쌍으로 교환. - func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession - /// POST /auth/sign-up — 최소 가입 후 서버 토큰 쌍 수령(레거시 실측 계약). - func signUp(credential: SocialCredential, form: SignUpForm, fcmToken: String?) async throws -> LoginSession + /// POST /auth/guest(서버 합의 대기 중인 제안 계약) — 기기 ID로 게스트 토큰 쌍을 발급받는다. + /// 같은 deviceID면 같은 게스트 회원을 돌려줘야 한다(재설치·refresh 만료 후에도 알람·집 주소 유지). + func issueGuestSession(deviceID: String, fcmToken: String?) async throws -> LoginSession } diff --git a/Projects/Domain/Sources/Interfaces/DeviceIdentifierProviding.swift b/Projects/Domain/Sources/Interfaces/DeviceIdentifierProviding.swift new file mode 100644 index 00000000..e4d50a62 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/DeviceIdentifierProviding.swift @@ -0,0 +1,5 @@ +/// 게스트 계정의 키 — 앱 재설치 후에도 같은 값이어야 같은 게스트 회원으로 이어진다. +/// 구현은 App 어댑터(키체인 보관 UUID). +public protocol DeviceIdentifierProviding: Sendable { + func deviceID() throws -> String +} diff --git a/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift b/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift index 30c4bf77..1bc5a064 100644 --- a/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift +++ b/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift @@ -1,5 +1,5 @@ -/// 푸시 토큰 포트 — `/auth/login`의 fcmToken 파라미터 소싱용. -/// V2는 FCM 토큰 서버 전달이 미정(미확정 #5)이라 nil이 일반 상태다. +/// 푸시 토큰 포트 — 게스트 발급 요청의 fcmToken 소싱용. +/// V2용 GoogleService-Info.plist가 없으면 nil이 일반 상태다. public protocol PushTokenProviding: Sendable { func currentPushToken() async -> String? } diff --git a/Projects/Domain/Sources/Interfaces/SessionEnding.swift b/Projects/Domain/Sources/Interfaces/SessionEnding.swift deleted file mode 100644 index 642d2ca0..00000000 --- a/Projects/Domain/Sources/Interfaces/SessionEnding.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// 세션 종료 포트 — 어댑터는 App에서 `AuthSessionManager`로 배선. -public protocol SessionEnding: Sendable { - /// 로그아웃: 서버 `/auth/logout`은 베스트 에포트(실패 무시)로 호출하고, - /// 로컬 토큰 정리와 세션 만료 라우팅(로그인 복귀)을 항상 진행한다. - func endSession() async - /// 탈퇴 후 정리: 서버 토큰은 이미 무효이므로 로컬 정리 + 만료 라우팅만. - func invalidateLocalSession() async -} diff --git a/Projects/Domain/Sources/Interfaces/SessionStoring.swift b/Projects/Domain/Sources/Interfaces/SessionStoring.swift index 0fec2ca2..71bf71c4 100644 --- a/Projects/Domain/Sources/Interfaces/SessionStoring.swift +++ b/Projects/Domain/Sources/Interfaces/SessionStoring.swift @@ -1,4 +1,4 @@ -/// 서버 토큰 채택 포트 — Domain이 CoreAuth를 모른 채 로그인 결과를 세션에 반영한다. +/// 서버 토큰 채택 포트 — Domain이 CoreAuth를 모른 채 발급 결과를 세션에 반영한다. /// 어댑터는 App에서 `AuthSessionManager.adopt(_:)`로 배선. public protocol SessionStoring: Sendable { func store(accessToken: String, refreshToken: String) async throws diff --git a/Projects/Domain/Sources/Interfaces/SocialLoginService.swift b/Projects/Domain/Sources/Interfaces/SocialLoginService.swift deleted file mode 100644 index 1d9bed18..00000000 --- a/Projects/Domain/Sources/Interfaces/SocialLoginService.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// 소셜 SDK 포트 — 어댑터 구현은 App에 둔다(외부 SDK는 앱 타겟에서만 링크). -/// 제공자 UI(카카오톡 전환·Apple 시트)를 띄우므로 메인 액터 격리. -public protocol SocialLoginService: Sendable { - @MainActor func authorize(provider: SocialLoginProvider) async throws -> SocialCredential -} diff --git a/Projects/Domain/Sources/Interfaces/UserRepository.swift b/Projects/Domain/Sources/Interfaces/UserRepository.swift index 7c455812..0b0aca78 100644 --- a/Projects/Domain/Sources/Interfaces/UserRepository.swift +++ b/Projects/Domain/Sources/Interfaces/UserRepository.swift @@ -6,6 +6,4 @@ public protocol UserRepository: Sendable { func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws /// PATCH /members/me/alert-frequency — 알림 빈도 변경. func updateAlertFrequencies(_ frequencies: [Int]) async throws - /// DELETE /members/me — 회원탈퇴. 성공하면 서버 세션은 이미 무효다. - func withdraw(reason: String?) async throws } diff --git a/Projects/Domain/Sources/UseCases/IssueGuestSessionUseCase.swift b/Projects/Domain/Sources/UseCases/IssueGuestSessionUseCase.swift new file mode 100644 index 00000000..f2414c25 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/IssueGuestSessionUseCase.swift @@ -0,0 +1,32 @@ +/// 게스트 세션 발급 → 세션 채택. 앱 시작(토큰 없음)과 refresh 확정 만료 두 경로가 호출한다. +public protocol IssueGuestSessionUseCase: Sendable { + func execute() async throws +} + +public struct DefaultIssueGuestSessionUseCase: IssueGuestSessionUseCase { + private let authRepository: any AuthRepository + private let sessionStore: any SessionStoring + private let deviceIdentifier: any DeviceIdentifierProviding + private let pushTokenProvider: any PushTokenProviding + + public init( + authRepository: any AuthRepository, + sessionStore: any SessionStoring, + deviceIdentifier: any DeviceIdentifierProviding, + pushTokenProvider: any PushTokenProviding + ) { + self.authRepository = authRepository + self.sessionStore = sessionStore + self.deviceIdentifier = deviceIdentifier + self.pushTokenProvider = pushTokenProvider + } + + public func execute() async throws { + let session = try await authRepository.issueGuestSession( + deviceID: try deviceIdentifier.deviceID(), + // 발급 시점에 FCM 토큰을 함께 실어 두면 갱신 API가 없어도 첫 푸시가 성립한다. + fcmToken: await pushTokenProvider.currentPushToken() + ) + try await sessionStore.store(accessToken: session.accessToken, refreshToken: session.refreshToken) + } +} diff --git a/Projects/Domain/Sources/UseCases/LogoutUseCase.swift b/Projects/Domain/Sources/UseCases/LogoutUseCase.swift deleted file mode 100644 index f3427c17..00000000 --- a/Projects/Domain/Sources/UseCases/LogoutUseCase.swift +++ /dev/null @@ -1,21 +0,0 @@ -public protocol LogoutUseCase: Sendable { - /// 서버 로그아웃은 베스트 에포트 — 실패해도 로컬 세션은 끝난다(throws 없음). - func execute() async -} - -public struct DefaultLogoutUseCase: LogoutUseCase { - private let sessionEnding: any SessionEnding - /// nil이면 알람 정리 없이 동작한다(Example·테스트 호환). - private let alarmTeardown: (any AlarmSessionTeardown)? - - public init(sessionEnding: any SessionEnding, alarmTeardown: (any AlarmSessionTeardown)? = nil) { - self.sessionEnding = sessionEnding - self.alarmTeardown = alarmTeardown - } - - public func execute() async { - // 서버 알람 취소는 토큰이 살아 있을 때만 가능하다 — 세션 종료보다 반드시 먼저. - await alarmTeardown?.tearDown(cancelOnServer: true) - await sessionEnding.endSession() - } -} diff --git a/Projects/Domain/Sources/UseCases/SignInUseCase.swift b/Projects/Domain/Sources/UseCases/SignInUseCase.swift deleted file mode 100644 index d56af840..00000000 --- a/Projects/Domain/Sources/UseCases/SignInUseCase.swift +++ /dev/null @@ -1,86 +0,0 @@ -/// 소셜 로그인 전 과정: 소셜 인가 → 가입 여부 확인 → 토큰 교환(기존 회원) 또는 -/// 최소 가입(신규 — 온보딩 화면 없이 현재 위치 기반 폼) → 세션 채택. -/// 소셜 UI를 띄우는 authorize가 메인 액터 격리라 execute도 @MainActor. -public protocol SignInUseCase: Sendable { - @MainActor func execute(provider: SocialLoginProvider) async throws -} - -public final class DefaultSignInUseCase: SignInUseCase { - private let socialLoginService: any SocialLoginService - private let authRepository: any AuthRepository - private let sessionStore: any SessionStoring - private let pushTokenProvider: any PushTokenProviding - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase - private let locationTimeout: Duration - - public init( - socialLoginService: any SocialLoginService, - authRepository: any AuthRepository, - sessionStore: any SessionStoring, - pushTokenProvider: any PushTokenProviding, - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, - locationTimeout: Duration = .seconds(8) - ) { - self.socialLoginService = socialLoginService - self.authRepository = authRepository - self.sessionStore = sessionStore - self.pushTokenProvider = pushTokenProvider - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase - self.locationTimeout = locationTimeout - } - - @MainActor - public func execute(provider: SocialLoginProvider) async throws { - let credential = try await socialLoginService.authorize(provider: provider) - let fcmToken = await pushTokenProvider.currentPushToken() - let session: LoginSession - if try await authRepository.checkRegistration(credential: credential) { - session = try await authRepository.login(credential: credential, fcmToken: fcmToken) - } else { - session = try await authRepository.signUp( - credential: credential, - form: await makeMinimalForm(), - fcmToken: fcmToken - ) - } - try await sessionStore.store( - accessToken: session.accessToken, - refreshToken: session.refreshToken - ) - } - - /// 최소 가입 폼 — 위치 실패(권한 거부·타임아웃)면 레거시 실측 폴백, - /// 역지오코딩만 실패하면 주소만 빈 문자열로 강등한다. - private func makeMinimalForm() async -> SignUpForm { - guard let coordinate = await currentCoordinate() else { - return .minimalFallback - } - let address = (try? await reverseGeocodeUseCase.execute(coordinate: coordinate))?.address ?? "" - return SignUpForm( - userName: "", - address: address, - coordinate: coordinate, - alertFrequencies: SignUpForm.defaultAlertFrequencies - ) - } - - /// 위치 조회는 권한 프롬프트 대기 등으로 길어질 수 있어 타임아웃과 레이스한다 — - /// 가입이 위치 때문에 무한정 막히면 안 된다. - private func currentCoordinate() async -> Coordinate? { - let locationUseCase = getCurrentLocationUseCase - let timeout = locationTimeout - return await withTaskGroup(of: Coordinate?.self) { group in - group.addTask { try? await locationUseCase.execute() } - group.addTask { - try? await Task.sleep(for: timeout) - return nil - } - let first = await group.next() ?? nil - group.cancelAll() - return first - } - } -} diff --git a/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift b/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift deleted file mode 100644 index 62efa5d9..00000000 --- a/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift +++ /dev/null @@ -1,20 +0,0 @@ -public protocol WithdrawUseCase: Sendable { - func execute(reason: String?) async throws -} - -public struct DefaultWithdrawUseCase: WithdrawUseCase { - private let userRepository: any UserRepository - private let sessionEnding: any SessionEnding - - public init(userRepository: any UserRepository, sessionEnding: any SessionEnding) { - self.userRepository = userRepository - self.sessionEnding = sessionEnding - } - - public func execute(reason: String?) async throws { - // 탈퇴가 서버에서 성공한 뒤에만 로컬 세션을 무효화한다 — - // 실패 시 세션을 보존해야 사용자가 재시도할 수 있다. - try await userRepository.withdraw(reason: reason) - await sessionEnding.invalidateLocalSession() - } -} diff --git a/Projects/Domain/Tests/DefaultIssueGuestSessionUseCaseTests.swift b/Projects/Domain/Tests/DefaultIssueGuestSessionUseCaseTests.swift new file mode 100644 index 00000000..1944110e --- /dev/null +++ b/Projects/Domain/Tests/DefaultIssueGuestSessionUseCaseTests.swift @@ -0,0 +1,70 @@ +@testable import Domain +import Synchronization +import Testing + +private final class GuestRecorder: Sendable { + private let events = Mutex<[String]>([]) + func record(_ event: String) { events.withLock { $0.append(event) } } + var log: [String] { events.withLock { $0 } } +} + +private struct StubAuthRepository: AuthRepository { + let recorder: GuestRecorder + var fails = false + + func issueGuestSession(deviceID: String, fcmToken: String?) async throws -> LoginSession { + recorder.record("issue(\(deviceID),\(fcmToken ?? "nil"))") + if fails { throw GuestFailure() } + return LoginSession(userID: 7, accessToken: "access", refreshToken: "refresh") + } +} + +private struct StubSessionStore: SessionStoring { + let recorder: GuestRecorder + func store(accessToken: String, refreshToken: String) async throws { + recorder.record("store(\(accessToken),\(refreshToken))") + } +} + +private struct StubDeviceIdentifier: DeviceIdentifierProviding { + func deviceID() throws -> String { "device-1" } +} + +private struct StubPushTokenProvider: PushTokenProviding { + let token: String? + func currentPushToken() async -> String? { token } +} + +private struct GuestFailure: Error {} + +struct DefaultIssueGuestSessionUseCaseTests { + private let recorder = GuestRecorder() + + @Test + func execute_issuesWithDeviceIDAndPushToken_thenStoresTokens() async throws { + let sut = DefaultIssueGuestSessionUseCase( + authRepository: StubAuthRepository(recorder: recorder), + sessionStore: StubSessionStore(recorder: recorder), + deviceIdentifier: StubDeviceIdentifier(), + pushTokenProvider: StubPushTokenProvider(token: "fcm") + ) + + try await sut.execute() + + #expect(recorder.log == ["issue(device-1,fcm)", "store(access,refresh)"]) + } + + /// 발급 실패 시 아무것도 저장하지 않는다 — 스플래시가 재시도 UI를 띄운다. + @Test + func execute_failure_propagatesWithoutStoring() async { + let sut = DefaultIssueGuestSessionUseCase( + authRepository: StubAuthRepository(recorder: recorder, fails: true), + sessionStore: StubSessionStore(recorder: recorder), + deviceIdentifier: StubDeviceIdentifier(), + pushTokenProvider: StubPushTokenProvider(token: nil) + ) + + await #expect(throws: GuestFailure.self) { try await sut.execute() } + #expect(recorder.log == ["issue(device-1,nil)"]) + } +} diff --git a/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift b/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift deleted file mode 100644 index 092fc869..00000000 --- a/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -@testable import Domain -import Synchronization -import Testing - -private final class SessionEndingSpy: SessionEnding, Sendable { - private let events = Mutex<[String]>([]) - var log: [String] { events.withLock { $0 } } - - func endSession() async { events.withLock { $0.append("endSession") } } - func invalidateLocalSession() async { events.withLock { $0.append("invalidate") } } - func append(_ event: String) { events.withLock { $0.append(event) } } -} - -private struct TeardownSpy: AlarmSessionTeardown { - let spy: SessionEndingSpy - func tearDown(cancelOnServer: Bool) async { spy.append("tearDown(\(cancelOnServer))") } -} - -struct DefaultLogoutUseCaseTests { - @Test - func execute_endsSession() async { - let spy = SessionEndingSpy() - let sut = DefaultLogoutUseCase(sessionEnding: spy) - - await sut.execute() - - #expect(spy.log == ["endSession"]) - } - - /// 서버 알람 취소는 토큰이 살아 있을 때만 가능 — 정리가 세션 종료보다 먼저여야 한다. - @Test - func execute_tearsDownAlarmsBeforeEndingSession() async { - let spy = SessionEndingSpy() - let sut = DefaultLogoutUseCase(sessionEnding: spy, alarmTeardown: TeardownSpy(spy: spy)) - - await sut.execute() - - #expect(spy.log == ["tearDown(true)", "endSession"]) - } -} diff --git a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift deleted file mode 100644 index b6fb998a..00000000 --- a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift +++ /dev/null @@ -1,223 +0,0 @@ -@testable import Domain -import Foundation -import Synchronization -import Testing - -/// 스텁 공유 기록기 — 호출 순서·횟수·전달 인자를 한 곳에서 검증한다. -private final class SignInRecorder: Sendable { - private let events = Mutex<[String]>([]) - - func record(_ event: String) { events.withLock { $0.append(event) } } - var log: [String] { events.withLock { $0 } } - func count(of event: String) -> Int { log.count(where: { $0 == event }) } -} - -private struct StubSocialLoginService: SocialLoginService { - let recorder: SignInRecorder - var result: Result = - .success(SocialCredential(provider: .kakao, accessToken: "SOCIAL")) - - func authorize(provider: SocialLoginProvider) async throws -> SocialCredential { - recorder.record("authorize") - return try result.get() - } -} - -private struct StubAuthRepository: AuthRepository { - let recorder: SignInRecorder - var exists = true - var session = LoginSession(userID: 1, accessToken: "SA", refreshToken: "SR") - var signUpError: (any Error)? - - func checkRegistration(credential: SocialCredential) async throws -> Bool { - recorder.record("check(\(credential.accessToken))") - return exists - } - - func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { - recorder.record("login(fcm:\(fcmToken ?? "nil"))") - return session - } - - func signUp( - credential: SocialCredential, - form: SignUpForm, - fcmToken: String? - ) async throws -> LoginSession { - recorder.record( - "signUp(addr:\(form.address)/lat:\(form.coordinate.latitude)" - + "/freq:\(form.alertFrequencies)/name:\(form.userName ?? "nil")" - + "/fcm:\(fcmToken ?? "nil"))" - ) - if let signUpError { throw signUpError } - return session - } -} - -private struct StubSessionStore: SessionStoring { - let recorder: SignInRecorder - var error: (any Error)? - - func store(accessToken: String, refreshToken: String) async throws { - recorder.record("store(\(accessToken)/\(refreshToken))") - if let error { throw error } - } -} - -private struct StubPushTokenProvider: PushTokenProviding { - var token: String? - - func currentPushToken() async -> String? { token } -} - -private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { - var result: Result = - .success(Coordinate(latitude: 37.5, longitude: 127.0)) - - func execute() async throws -> Coordinate { - try result.get() - } -} - -private struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { - var result: Result = .success( - Place(name: "집앞", address: "서울 어딘가 1-2", coordinate: Coordinate(latitude: 37.5, longitude: 127.0)) - ) - - func execute(coordinate: Coordinate) async throws -> Place { - try result.get() - } -} - -private struct StoreError: Error {} -private struct SignUpError: Error {} -private struct GeocodeError: Error {} - -struct DefaultSignInUseCaseTests { - private let recorder = SignInRecorder() - - private func makeSUT( - social: StubSocialLoginService? = nil, - repository: StubAuthRepository? = nil, - store: StubSessionStore? = nil, - pushToken: String? = nil, - location: StubGetCurrentLocationUseCase = StubGetCurrentLocationUseCase(), - geocode: StubReverseGeocodeUseCase = StubReverseGeocodeUseCase() - ) -> DefaultSignInUseCase { - DefaultSignInUseCase( - socialLoginService: social ?? StubSocialLoginService(recorder: recorder), - authRepository: repository ?? StubAuthRepository(recorder: recorder), - sessionStore: store ?? StubSessionStore(recorder: recorder), - pushTokenProvider: StubPushTokenProvider(token: pushToken), - getCurrentLocationUseCase: location, - reverseGeocodeUseCase: geocode - ) - } - - @Test - func execute_registered_runsAuthorizeCheckLoginStoreInOrder() async throws { - let sut = makeSUT(pushToken: "FCM") - - try await sut.execute(provider: .kakao) - - #expect(recorder.log == [ - "authorize", - "check(SOCIAL)", - "login(fcm:FCM)", - "store(SA/SR)", - ]) - } - - @Test - func execute_noPushToken_passesNilToLogin() async throws { - let sut = makeSUT() - - try await sut.execute(provider: .apple) - - #expect(recorder.count(of: "login(fcm:nil)") == 1) - } - - /// 미가입이면 자동으로 최소 가입한다 — 현재 좌표 + 역지오코딩 주소 + 기본 알림 빈도. - @Test - func execute_notRegistered_signsUpWithLocationFormThenStores() async throws { - let sut = makeSUT( - repository: StubAuthRepository(recorder: recorder, exists: false), - pushToken: "FCM" - ) - - try await sut.execute(provider: .kakao) - - #expect(recorder.log == [ - "authorize", - "check(SOCIAL)", - "signUp(addr:서울 어딘가 1-2/lat:37.5/freq:[1, 10]/name:/fcm:FCM)", - "store(SA/SR)", - ]) - } - - /// 위치 실패(권한 거부 등) → 레거시 실측 폴백(address ""·좌표 (0,0))으로 가입한다. - @Test - func execute_notRegistered_locationFailure_fallsBackToMinimalForm() async throws { - let sut = makeSUT( - repository: StubAuthRepository(recorder: recorder, exists: false), - location: StubGetCurrentLocationUseCase(result: .failure(.permissionDenied)) - ) - - try await sut.execute(provider: .kakao) - - #expect(recorder.count(of: "signUp(addr:/lat:0.0/freq:[1, 10]/name:/fcm:nil)") == 1) - } - - /// 역지오코딩만 실패하면 좌표는 유지하고 주소만 빈 문자열로 강등한다. - @Test - func execute_notRegistered_geocodeFailure_keepsCoordinateWithEmptyAddress() async throws { - let sut = makeSUT( - repository: StubAuthRepository(recorder: recorder, exists: false), - geocode: StubReverseGeocodeUseCase(result: .failure(GeocodeError())) - ) - - try await sut.execute(provider: .kakao) - - #expect(recorder.count(of: "signUp(addr:/lat:37.5/freq:[1, 10]/name:/fcm:nil)") == 1) - } - - /// 가입 실패는 그대로 전파되고 세션은 채택되지 않는다. - @Test - func execute_signUpFailure_propagatesWithoutStore() async { - let sut = makeSUT( - repository: StubAuthRepository( - recorder: recorder, exists: false, signUpError: SignUpError() - ) - ) - - await #expect(throws: SignUpError.self) { - try await sut.execute(provider: .kakao) - } - - #expect(recorder.count(of: "store(SA/SR)") == 0) - } - - @Test - func execute_authorizeCancelled_propagatesWithoutServerCalls() async { - let sut = makeSUT( - social: StubSocialLoginService(recorder: recorder, result: .failure(.cancelled)) - ) - - await #expect(throws: SocialLoginError.cancelled) { - try await sut.execute(provider: .kakao) - } - - #expect(recorder.log == ["authorize"]) - } - - @Test - func execute_storeFailure_propagates() async { - let sut = makeSUT(store: StubSessionStore(recorder: recorder, error: StoreError())) - - await #expect(throws: StoreError.self) { - try await sut.execute(provider: .kakao) - } - - #expect(recorder.count(of: "store(SA/SR)") == 1) - } -} diff --git a/Projects/Domain/Tests/DefaultUpdateHomeAddressUseCaseTests.swift b/Projects/Domain/Tests/DefaultUpdateHomeAddressUseCaseTests.swift index cadbdb83..08a3f9b0 100644 --- a/Projects/Domain/Tests/DefaultUpdateHomeAddressUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultUpdateHomeAddressUseCaseTests.swift @@ -30,7 +30,6 @@ private struct StubUserRepository: UserRepository { recorder.record("patch(\(address ?? "nil"))") } func updateAlertFrequencies(_ frequencies: [Int]) async throws {} - func withdraw(reason: String?) async throws {} } private struct StubFailure: Error {} diff --git a/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift b/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift deleted file mode 100644 index 230b9ab0..00000000 --- a/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -@testable import Domain -import Synchronization -import Testing - -private final class WithdrawRecorder: Sendable { - private let events = Mutex<[String]>([]) - - func record(_ event: String) { events.withLock { $0.append(event) } } - var log: [String] { events.withLock { $0 } } -} - -private struct StubUserRepository: UserRepository { - let recorder: WithdrawRecorder - var withdrawError: (any Error)? - - func fetchMe() async throws -> UserProfile { - UserProfile( - userID: nil, providerID: nil, nickname: nil, - address: nil, coordinate: nil, appVersion: nil - ) - } - - func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws {} - - func updateAlertFrequencies(_ frequencies: [Int]) async throws {} - - func withdraw(reason: String?) async throws { - recorder.record("withdraw(\(reason ?? "nil"))") - if let withdrawError { throw withdrawError } - } -} - -private struct StubSessionEnding: SessionEnding { - let recorder: WithdrawRecorder - - func endSession() async { recorder.record("endSession") } - func invalidateLocalSession() async { recorder.record("invalidate") } -} - -private struct WithdrawServerError: Error {} - -struct DefaultWithdrawUseCaseTests { - private let recorder = WithdrawRecorder() - - /// 탈퇴가 서버에서 성공한 뒤에만 로컬 세션을 무효화한다. - @Test - func execute_success_withdrawsThenInvalidatesLocalSession() async throws { - let sut = DefaultWithdrawUseCase( - userRepository: StubUserRepository(recorder: recorder), - sessionEnding: StubSessionEnding(recorder: recorder) - ) - - try await sut.execute(reason: "서비스 미사용") - - #expect(recorder.log == ["withdraw(서비스 미사용)", "invalidate"]) - } - - /// 서버 탈퇴 실패 시 세션을 보존한다 — 재시도할 수 있어야 한다. - @Test - func execute_serverFailure_propagatesAndKeepsSession() async { - let sut = DefaultWithdrawUseCase( - userRepository: StubUserRepository( - recorder: recorder, withdrawError: WithdrawServerError() - ), - sessionEnding: StubSessionEnding(recorder: recorder) - ) - - await #expect(throws: WithdrawServerError.self) { - try await sut.execute(reason: nil) - } - - #expect(recorder.log == ["withdraw(nil)"]) - } -} From 975a5761ea916908f0e4df80b2fef72ba60af7d6 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Thu, 24 Sep 2026 12:20:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20App=C2=B7=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=84=ED=99=98=20=E2=80=94=20?= =?UTF-8?q?=EC=8A=A4=ED=94=8C=EB=9E=98=EC=8B=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=C2=B7=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=A1=B0=EC=9A=A9=ED=95=9C=20=EC=9E=AC=EB=B0=9C=EA=B8=89,=20Au?= =?UTF-8?q?thFeature=C2=B7=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토큰 없음 → 키체인 기기 UUID로 게스트 발급 후 홈, 실패 시 스플래시 재시도 UI - refresh 확정 만료 → 화면 유지 + 같은 기기 ID로 재발급(같은 게스트 회원 전제라 알람 정리 없음) - AuthFeature 모듈·SocialLoginAdapter·KakaoConfig·카카오 SDK 링크·Apple 로그인 entitlement 제거 (레거시 타겟이 쓰는 카카오 패키지 선언은 유지) - 설정에서 로그아웃·계정 탈퇴 제거, DEV 로그아웃 메뉴 제거 - CLAUDE.md·로드맵 서버 질문 #1 갱신 Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- Projects/App/Project.swift | 26 +--- .../AlarmSessionTeardownAdapter.swift | 46 ------ .../Adapters/AuthSessionEndingAdapter.swift | 17 --- .../Adapters/AuthSessionStoreAdapter.swift | 2 +- .../Adapters/KeychainDeviceIdentifier.swift | 23 +++ .../Sources/Adapters/SocialLoginAdapter.swift | 127 ---------------- Projects/App/Sources/AlarmSyncService.swift | 18 --- Projects/App/Sources/AppCoordinator.swift | 90 +++++------- Projects/App/Sources/AppDIContainer.swift | 62 ++------ Projects/App/Sources/AppDelegate.swift | 7 +- .../App/Sources/BootstrapFailureMessage.swift | 4 +- Projects/App/Sources/KakaoConfig.swift | 12 -- Projects/App/Sources/SceneDelegate.swift | 15 -- .../Feature/Auth/Example/ExampleApp.swift | 106 -------------- .../Interface/AuthCoordinatorBuildable.swift | 15 -- Projects/Feature/Auth/Project.swift | 23 --- .../AuthFeature.xcassets/Contents.json | 6 - .../apple.imageset/Button Icon.png | Bin 348 -> 0 bytes .../apple.imageset/Button Icon@2x.png | Bin 624 -> 0 bytes .../apple.imageset/Button Icon@3x.png | Bin 933 -> 0 bytes .../apple.imageset/Contents.json | 26 ---- .../kakao.imageset/Button Icon.png | Bin 454 -> 0 bytes .../kakao.imageset/Button Icon@2x.png | Bin 772 -> 0 bytes .../kakao.imageset/Button Icon@3x.png | Bin 1106 -> 0 bytes .../kakao.imageset/Contents.json | 26 ---- .../Auth/Sources/AuthDIContainer.swift | 31 ---- .../Feature/Auth/Sources/AuthPalette.swift | 10 -- .../Auth/Sources/LoginCoordinator.swift | 46 ------ .../Auth/Sources/LoginViewController.swift | 138 ------------------ .../Feature/Auth/Sources/LoginViewModel.swift | 70 --------- .../Auth/Sources/SocialLoginButton.swift | 53 ------- .../Auth/Tests/LoginViewModelTests.swift | 133 ----------------- .../Feature/Settings/Example/ExampleApp.swift | 15 -- .../SettingsCoordinatorBuildable.swift | 1 - .../Sources/SettingsCoordinator.swift | 4 +- .../Sources/SettingsDIContainer.swift | 11 -- .../Settings/Sources/SettingsScreen.swift | 7 - .../Sources/SettingsViewController.swift | 15 +- .../Settings/Sources/SettingsViewModel.swift | 23 --- .../Sources/WithdrawViewController.swift | 125 ---------------- .../Settings/Sources/WithdrawViewModel.swift | 96 ------------ .../Settings/Tests/SettingsFeatureStubs.swift | 21 --- .../Tests/SettingsViewModelTests.swift | 17 +-- .../Tests/WithdrawViewModelTests.swift | 58 -------- Workspace.swift | 1 - docs/planning/atcha-v2-post12-roadmap.md | 4 +- 47 files changed, 85 insertions(+), 1447 deletions(-) delete mode 100644 Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift delete mode 100644 Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift create mode 100644 Projects/App/Sources/Adapters/KeychainDeviceIdentifier.swift delete mode 100644 Projects/App/Sources/Adapters/SocialLoginAdapter.swift delete mode 100644 Projects/App/Sources/KakaoConfig.swift delete mode 100644 Projects/Feature/Auth/Example/ExampleApp.swift delete mode 100644 Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift delete mode 100644 Projects/Feature/Auth/Project.swift delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@3x.png delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Contents.json delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon.png delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@2x.png delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@3x.png delete mode 100644 Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Contents.json delete mode 100644 Projects/Feature/Auth/Sources/AuthDIContainer.swift delete mode 100644 Projects/Feature/Auth/Sources/AuthPalette.swift delete mode 100644 Projects/Feature/Auth/Sources/LoginCoordinator.swift delete mode 100644 Projects/Feature/Auth/Sources/LoginViewController.swift delete mode 100644 Projects/Feature/Auth/Sources/LoginViewModel.swift delete mode 100644 Projects/Feature/Auth/Sources/SocialLoginButton.swift delete mode 100644 Projects/Feature/Auth/Tests/LoginViewModelTests.swift delete mode 100644 Projects/Feature/Settings/Sources/WithdrawViewController.swift delete mode 100644 Projects/Feature/Settings/Sources/WithdrawViewModel.swift delete mode 100644 Projects/Feature/Settings/Tests/WithdrawViewModelTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 8b36e088..94e0cfb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho ### 미완 상태 (작업 시 참고) -- **인증은 소셜 로그인(카카오·애플), 가입까지 완결** — 강제 로그인(게스트 없음)으로 `AuthFeature`가 최초 토큰을 발급받는다(`GET /auth/check`·`/auth/login`·`POST /auth/sign-up`, 소셜 토큰을 Bearer로 — plain client 전용). 미가입 계정은 `SignInUseCase`가 온보딩 화면 없이 **최소 가입**(현재 위치+역지오코딩 주소, 실패 시 레거시 실측 폴백 `""`·(0,0), 알림 빈도 [1,10])까지 자동 수행한다. 로그아웃(`POST /auth/logout` — refresh 토큰을 Bearer로 싣는 특례, CoreAuth `AuthSessionManager.signOut()`)·회원탈퇴(`DELETE /members/me`)·유저 정보(`GET /members/me`, 홈주소/알림빈도 PATCH — `UserRepository`)은 `SettingsFeature`(홈 상단 톱니바퀴 진입: 우리집 설정·약관·피드백·버전·로그아웃·계정 탈퇴)로 연결됐다. 알림 빈도는 레거시처럼 미노출. 로그아웃·탈퇴·강제 만료는 전부 `AppCoordinator.handleSessionExpiry`에 합류하고 거기서 `AlarmSessionTeardown`이 로컬 알람·LA·스냅샷·동기화 상태를 비운다(로그아웃은 토큰이 살아 있을 때 서버 알람 취소를 먼저 시도). 홈주소 변경은 `GET /locations/is-service-region` 통과 후 PATCH. 앱 버전은 `GET /app/version`(무토큰, 1.5초 예산·실패 시 통과)으로 권장 업데이트 팝업만. 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. `DevDemoFallbacks`(실패 은폐 데모 폴백)와 "DEV 건너뛰기"는 제거됨 — DEV에 남은 것은 막차 변경 주입용 `DevChangeSimulator`(refresh 가로채기 전용, 에러 은폐 없음)뿐이라 서버 실패가 DEV에서도 그대로 표면화된다. +- **인증은 게스트 전용(MVP, 2026-09)** — 소셜 로그인(AuthFeature·카카오 SDK·Apple 로그인)은 제거됐다(복구가 필요하면 PR #361 히스토리). 토큰이 없으면 스플래시에서 `IssueGuestSessionUseCase`가 키체인 보관 기기 UUID(`KeychainDeviceIdentifier`, 재설치해도 유지)로 **`POST /auth/guest`(클라 제안 계약, 서버 미구현·합의 대기)** 를 호출해 토큰을 채택하고, 실패하면 스플래시 재시도 UI를 띄운다. refresh 확정 만료 시에는 화면을 그대로 두고 같은 기기 ID로 조용히 재발급한다 — "같은 deviceId = 같은 게스트 회원"이 서버 계약 전제라 알람·집 주소는 정리하지 않는다. 로그아웃·회원탈퇴 개념은 없다. `SettingsFeature`(홈 상단 톱니바퀴: 우리집 설정·약관·피드백·버전)가 `GET /members/me`·홈주소 PATCH를 쓰고, 홈주소 변경은 `GET /locations/is-service-region` 통과 후 PATCH, 알림 빈도는 미노출. 앱 버전은 `GET /app/version`(무토큰, 1.5초 예산·실패 시 통과)으로 권장 업데이트 팝업만. `DevDemoFallbacks`와 "DEV 건너뛰기"는 제거됨 — DEV에 남은 것은 막차 변경 주입용 `DevChangeSimulator`뿐이라 서버 실패가 DEV에서도 그대로 표면화된다. - `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정. - `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰은 로그인/가입 파라미터로만 서버에 가고, 갱신 전달은 `SyncPushTokenUseCase`까지 배선됐지만 서버 API 미확정이라 `UnconfirmedPushTokenRepository`(no-op)가 주입돼 있다. 그래서 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐. - AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 382a0bf6..8c2393d9 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -1,12 +1,7 @@ import ProjectDescription import ProjectDescriptionHelpers -// 카카오 네이티브 앱 키 — xcconfig 금지 규약(AtchaV2) 하에서 env(TUIST_KAKAO_APP_KEY)로 -// 주입한다(mise [env] 또는 CI secret). 미주입이면 빈 문자열: generate/빌드는 통과하고 -// 카카오 로그인만 런타임 비활성(providerUnavailable → 실패 토스트)이다. -let kakaoAppKey = Environment.kakaoAppKey.getString(default: "") - -var appInfoPlist: [String: Plist.Value] = [ +let appInfoPlist: [String: Plist.Value] = [ "CFBundleDisplayName": "앗차", "UILaunchStoryboardName": "LaunchScreen", "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", @@ -27,17 +22,7 @@ var appInfoPlist: [String: Plist.Value] = [ ], "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], "ITSAppUsesNonExemptEncryption": false, - // 카카오톡 로그인 앱 전환 조회용(레거시 Info.plist 실측). - "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaolink"], - // 런타임(KakaoConfig)이 읽는 키 — 빈 값이면 KakaoSDK.initSDK를 건너뛴다. - "KAKAO_APP_KEY": .string(kakaoAppKey), ] -// 키 미주입 시 무의미한 `kakao` 빈 스킴이 등록되지 않도록 조건 분기. -if !kakaoAppKey.isEmpty { - appInfoPlist["CFBundleURLTypes"] = [ - ["CFBundleURLSchemes": ["kakao\(kakaoAppKey)"]], - ] -} let appTarget = Target.target( name: "AtchaV2", @@ -54,13 +39,9 @@ let appTarget = Target.target( "aps-environment": "development", // 폴백 노티의 .timeSensitive interruptionLevel용(Phase 15) — 집중 모드 관통. "com.apple.developer.usernotifications.time-sensitive": true, - // 소셜 로그인(Apple) — App ID capability 활성 + 프로비저닝 갱신 필요(콘솔 작업). - "com.apple.developer.applesignin": ["Default"], ]), dependencies: [ .target(name: "AtchaWidget"), - .project(target: "AuthFeature", path: "../Feature/Auth"), - .project(target: "AuthFeatureInterface", path: "../Feature/Auth"), .project(target: "HomeFeature", path: "../Feature/Home"), .project(target: "HomeFeatureInterface", path: "../Feature/Home"), .project(target: "SearchFeature", path: "../Feature/Search"), @@ -79,11 +60,6 @@ let appTarget = Target.target( .external(name: "FirebaseCore"), .external(name: "FirebaseCrashlytics"), .external(name: "FirebaseMessaging"), - // 소셜 SDK는 앱 타겟에서만 링크(내부 모듈 static framework 중복 심볼 방지 규약) - // — import 지점은 SocialLoginAdapter·AppDelegate·SceneDelegate뿐. - .external(name: "KakaoSDKCommon"), - .external(name: "KakaoSDKAuth"), - .external(name: "KakaoSDKUser"), ], settings: .atchaV2(base: [ // Firebase static libraries under XcodeProj-based integration need diff --git a/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift b/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift deleted file mode 100644 index 85eb2e58..00000000 --- a/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Domain -import Foundation -import os - -/// 계정 세션 종료 시 알람 세션 정리 — 조합 루트가 공유 인스턴스(스케줄러·LA·스냅샷·동기화)를 -/// 그대로 넘긴다. 등록/취소 UseCase와 다른 인스턴스를 보면 정리가 헛돈다. -struct AlarmSessionTeardownAdapter: AlarmSessionTeardown { - let alarmRepository: any AlarmRepository - let scheduler: any AlarmScheduler - let activityPort: any LastTrainActivityPort - let snapshotStore: any AlarmSessionSnapshotStore - let syncService: AlarmSyncService - private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmTeardown") - - func tearDown(cancelOnServer: Bool) async { - if cancelOnServer, let routeId = await activeRouteId() { - // 베스트 에포트 — 오프라인이어도 로그아웃을 막지 않는다. 서버에 남은 세션은 - // 다음 로그인의 refresh가 정본으로 되돌려 준다(같은 계정일 때만 의미가 있다). - do { - try await alarmRepository.cancel(lastRouteId: routeId) - } catch { - Self.logger.error("로그아웃 서버 알람 취소 실패(무시): \(error.localizedDescription, privacy: .public)") - } - } - // 더는 울리면 안 되는 것이 핵심 — 표출 정리(LA)보다 알람 취소를 먼저 한다. - await scheduler.cancelAlarm() - await snapshotStore.clear() - let now = Date() - await activityPort.end(final: LastTrainActivityState( - departureTime: now, - alarmTime: now, - urgency: .relaxed, - changeBadgeExpiry: nil, - phase: .active - )) - await syncService.resetForSignOut() - } - - /// 만료 톰스톤은 이미 죽은 세션이라 서버 취소 대상이 아니다. - private func activeRouteId() async -> String? { - if let snapshot = await snapshotStore.load(), !snapshot.expired { - return snapshot.info.lastRouteId - } - return await syncService.currentSession?.lastRouteId - } -} diff --git a/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift b/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift deleted file mode 100644 index 10a2d547..00000000 --- a/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift +++ /dev/null @@ -1,17 +0,0 @@ -import CoreAuth -import Domain - -/// Domain의 세션 종료 포트를 CoreAuth의 AuthSessionManager로 배선한다. -/// 두 경로 모두 sessionExpired yield를 거치므로 로그인 복귀 라우팅은 -/// AppCoordinator의 기존 만료 관찰이 그대로 처리한다. -struct AuthSessionEndingAdapter: SessionEnding { - let sessionManager: AuthSessionManager - - func endSession() async { - await sessionManager.signOut() - } - - func invalidateLocalSession() async { - await sessionManager.invalidateSession() - } -} diff --git a/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift b/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift index 2a37ed3e..7170d85f 100644 --- a/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift +++ b/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift @@ -2,7 +2,7 @@ import CoreAuth import Domain /// `SessionStoring` → `AuthSessionManager.adopt` 배선 — Domain이 CoreAuth를 모른 채 -/// 로그인 결과 토큰을 세션에 채택시키는 다리. +/// 게스트 발급 토큰을 세션에 채택시키는 다리. struct AuthSessionStoreAdapter: SessionStoring { let sessionManager: AuthSessionManager diff --git a/Projects/App/Sources/Adapters/KeychainDeviceIdentifier.swift b/Projects/App/Sources/Adapters/KeychainDeviceIdentifier.swift new file mode 100644 index 00000000..beca2d14 --- /dev/null +++ b/Projects/App/Sources/Adapters/KeychainDeviceIdentifier.swift @@ -0,0 +1,23 @@ +import CoreStorage +import Domain +import Foundation + +/// 게스트 계정 키 — 키체인은 앱 삭제 후에도 남으므로 재설치해도 같은 게스트 회원으로 이어진다 +/// (identifierForVendor는 같은 벤더 앱이 전부 지워지면 바뀐다). +struct KeychainDeviceIdentifier: DeviceIdentifierProviding { + private static let key = "guest.deviceID" + private let store: any KeyValueStore + + init(store: any KeyValueStore = KeychainStore()) { + self.store = store + } + + func deviceID() throws -> String { + if let data = try store.data(forKey: Self.key), let existing = String(data: data, encoding: .utf8) { + return existing + } + let created = UUID().uuidString + try store.set(Data(created.utf8), forKey: Self.key) + return created + } +} diff --git a/Projects/App/Sources/Adapters/SocialLoginAdapter.swift b/Projects/App/Sources/Adapters/SocialLoginAdapter.swift deleted file mode 100644 index b1f833b4..00000000 --- a/Projects/App/Sources/Adapters/SocialLoginAdapter.swift +++ /dev/null @@ -1,127 +0,0 @@ -import AuthenticationServices -import Domain -import KakaoSDKAuth -import KakaoSDKCommon -import KakaoSDKUser -import UIKit - -/// 소셜 SDK를 아는 곳은 이 어댑터뿐(외부 라이브러리 앱 타겟 전용 링크 규약). -/// 레거시 `LoginUseCaseImpl`의 delegate 이중 보관·`self.delegate!`·`view.window!` -/// force unwrap을 제거한 async 래핑 — 진행 중 continuation은 take()(nil 교체 후 사용)로 -/// 이중 resume을 차단한다. -@MainActor -final class SocialLoginAdapter: NSObject, SocialLoginService { - // 진행 중 Apple 플로우 — 어댑터가 delegate로 살아 있는 동안 controller도 함께 보관. - private var appleContinuation: CheckedContinuation? - private var appleController: ASAuthorizationController? - - func authorize(provider: SocialLoginProvider) async throws -> SocialCredential { - switch provider { - case .kakao: try await authorizeKakao() - case .apple: try await authorizeApple() - } - } - - // MARK: - Kakao - - private func authorizeKakao() async throws -> SocialCredential { - // initSDK가 안 된 상태(키 미주입)에서 UserApi를 부르면 SDK가 fatal — 선차단. - guard KakaoConfig.appKey != nil else { throw SocialLoginError.providerUnavailable } - let accessToken: String = try await withCheckedThrowingContinuation { continuation in - let completion: (OAuthToken?, (any Error)?) -> Void = { oauthToken, error in - if let error { - continuation.resume(throwing: Self.mapKakaoError(error)) - } else if let accessToken = oauthToken?.accessToken { - continuation.resume(returning: accessToken) - } else { - continuation.resume(throwing: SocialLoginError.failed) - } - } - // 카카오톡 앱 설치 시 앱 전환, 아니면 계정 웹뷰 — 레거시 분기 그대로. - if UserApi.isKakaoTalkLoginAvailable() { - UserApi.shared.loginWithKakaoTalk(completion: completion) - } else { - UserApi.shared.loginWithKakaoAccount(completion: completion) - } - } - return SocialCredential(provider: .kakao, accessToken: accessToken) - } - - private static func mapKakaoError(_ error: any Error) -> any Error { - // 사용자 취소는 조용한 idle 복귀 대상 — 그 외는 실패 토스트로 표면화. - if let sdkError = error as? SdkError, - case let .ClientFailed(reason, _) = sdkError, - reason == .Cancelled { - return SocialLoginError.cancelled - } - return error - } - - // MARK: - Apple - - private func authorizeApple() async throws -> SocialCredential { - // ViewModel의 로딩 가드가 1차 방어지만, 플로우 단일성은 여기서도 보장한다. - guard appleContinuation == nil else { throw SocialLoginError.failed } - let request = ASAuthorizationAppleIDProvider().createRequest() - request.requestedScopes = [.fullName, .email] - let controller = ASAuthorizationController(authorizationRequests: [request]) - controller.delegate = self - controller.presentationContextProvider = self - appleController = controller - return try await withCheckedThrowingContinuation { continuation in - appleContinuation = continuation - controller.performRequests() - } - } - - private func takeAppleContinuation() -> CheckedContinuation? { - defer { - appleContinuation = nil - appleController = nil - } - return appleContinuation - } -} - -extension SocialLoginAdapter: ASAuthorizationControllerDelegate { - func authorizationController( - controller: ASAuthorizationController, - didCompleteWithAuthorization authorization: ASAuthorization - ) { - guard let continuation = takeAppleContinuation() else { return } - guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, - let tokenData = credential.identityToken, - let identityToken = String(data: tokenData, encoding: .utf8) - else { - continuation.resume(throwing: SocialLoginError.failed) - return - } - continuation.resume( - returning: SocialCredential(provider: .apple, accessToken: identityToken) - ) - } - - func authorizationController( - controller: ASAuthorizationController, - didCompleteWithError error: any Error - ) { - guard let continuation = takeAppleContinuation() else { return } - if let authorizationError = error as? ASAuthorizationError, - authorizationError.code == .canceled { - continuation.resume(throwing: SocialLoginError.cancelled) - } else { - continuation.resume(throwing: error) - } - } -} - -extension SocialLoginAdapter: ASAuthorizationControllerPresentationContextProviding { - func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { - // 레거시의 `view.window!` 대체 — 호출 시점의 활성 씬 key window를 해석하고, - // 끝내 없으면 빈 앵커로 강제 크래시만 피한다(시트가 안 뜰 뿐). - let scene = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first { $0.activationState == .foregroundActive } - return scene?.keyWindow ?? scene?.windows.first ?? ASPresentationAnchor() - } -} diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index bb29f05e..4a422e14 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -136,24 +136,6 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque await sync() != nil ? .newData : .failed } - // MARK: - 계정 세션 종료 (로그아웃·탈퇴·만료) - - /// 현재 보유 세션 — 로그아웃 시 서버 취소 대상 routeId의 출처(스냅샷 부재 대비). - var currentSession: AlarmInfo? { lastInfo } - - /// 계정이 바뀌면 이전 계정의 세션 기억이 새 계정의 diff·만료 판정을 오염시킨다 — - /// 메모리 상태를 전부 비우고, 다음 로그인의 첫 sync가 (이미 비워진) 스냅샷부터 다시 시딩한다. - func resetForSignOut() { - inFlight?.cancel() - inFlight = nil - lastInfo = nil - lastCheckedAt = nil - locallyExpiredSession = nil - sessionWalkSeconds = nil - changeBadgeExpiry = nil - isSeededFromSnapshot = false - } - // MARK: - AlarmSyncRequesting (Phase 16 — 홈 pull-to-refresh) /// 수동 갱신 트리거(4번째) — 기존 sync()에 그대로 합류한다. 실패는 던지지 않고 diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index 9769b497..e5e9bf61 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -11,11 +11,8 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { private weak var splashViewController: SplashViewController? private var sessionExpiryTask: Task? - /// 로그인 플로우 표시 중 가드 — 만료 스트림의 중복 yield에 로그인을 겹치지 않는다. - private var isShowingLogin = false - /// 세션 만료 경유 재로그인이면 성공 직후 수동 동기화 1회 — activate()의 시작 - /// 동기화는 최초 1회 가드라 재로그인 경로에선 돌지 않기 때문. - private var needsSyncAfterLogin = false + /// 만료 스트림의 중복 yield(동시 401 등)에 게스트 재발급을 겹치지 않는다. + private var isReissuingSession = false init(navigationController: UINavigationController, container: AppDIContainer) { self.navigationController = navigationController @@ -55,7 +52,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { alert.addAction(UIAlertAction(title: "업데이트", style: .default) { _ in UIApplication.shared.open(AppEnvironment.current.appStoreURL) }) - // 로그인 시트가 떠 있으면 그 위에 — 가장 위의 presented에 얹는다. + // 다른 화면이 present돼 있으면 그 위에 — 가장 위의 presented에 얹는다. var top: UIViewController = navigationController while let presented = top.presentedViewController { top = presented } top.present(alert, animated: true) @@ -66,41 +63,29 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { // 세션 판정은 동기(키체인 존재 여부) — 토큰 유효성은 첫 인증 요청이 증명한다. switch container.authSessionManager.bootstrapState() { case .active: - startHome() - // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. - container.alarmSyncService.activate() - case .loginRequired: - showLogin() + enterHome() + case .noSession: + // 게스트 전용 — 로그인 화면 없이 기기 ID로 게스트 세션을 발급받는다. + let issueGuestSession = container.issueGuestSessionUseCase + Task { [weak self] in + do { + try await issueGuestSession.execute() + self?.enterHome() + } catch { + self?.splashViewController?.showRetry(message: BootstrapFailureMessage.text(for: error)) + } + } } } - /// 강제 로그인 — 스플래시를 root로 유지한 채 로그인 시트를 present한다 - /// (스플래시 배경 위 바텀시트 = 레거시와 같은 시각 결과). - private func showLogin() { - guard !isShowingLogin else { return } - isShowingLogin = true - let loginCoordinator = container.makeAuthDIContainer().makeLoginCoordinator( - navigationController: navigationController, - onAuthenticated: { [weak self] in - guard let self else { return } - self.isShowingLogin = false - self.startHome() - self.container.alarmSyncService.activate() - self.syncPushTokenAfterLogin() - if self.needsSyncAfterLogin { - self.needsSyncAfterLogin = false - let syncService = self.container.alarmSyncService - Task { await syncService.syncNow() } - } - } - ) - loginCoordinator.finishDelegate = self - addChild(loginCoordinator) - loginCoordinator.start() + private func enterHome() { + startHome() + // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. + container.alarmSyncService.activate() + syncPushToken() } - /// refresh 확정 사망(AuthSessionManager.sessionExpired) 관찰 — 홈을 접고 스플래시 - /// 위 로그인으로 되돌린다. 스트림은 단일 소비자(이 코디네이터) 전제. + /// refresh 확정 사망(AuthSessionManager.sessionExpired) 관찰. 스트림은 단일 소비자(이 코디네이터) 전제. private func observeSessionExpiry() { sessionExpiryTask = Task { [weak self] in guard let stream = self?.container.authSessionManager.sessionExpired else { return } @@ -111,27 +96,24 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { } } + /// 게스트는 돌아갈 로그인 화면이 없다 — 화면은 그대로 두고 같은 기기 ID로 조용히 재발급한다. + /// 서버가 같은 게스트 회원을 돌려주는 계약이라 알람·집 주소는 정리하지 않는다. + /// 재발급이 실패하면 다음 401이 다시 만료를 yield해 자연 재시도가 된다. private func handleSessionExpiry() { - guard !isShowingLogin else { return } - needsSyncAfterLogin = true - // 로그아웃·탈퇴·강제 만료 공통 합류점 — 이전 계정의 알람이 로그인 화면에서 울리지 않게 - // 로컬 정리를 여기서 한 번 더 보장한다(로그아웃 경로의 선행 정리와 겹쳐도 멱등). - let teardown = container.alarmSessionTeardown - Task { await teardown.tearDown(cancelOnServer: false) } - // 다음 계정에는 같은 FCM 토큰이라도 다시 전달해야 한다. - container.syncPushTokenUseCase.reset() - navigationController.presentedViewController?.dismiss(animated: false) - // setViewControllers가 didShow를 태워 SearchCoordinator류의 정리 경로도 돈다. - let splash = SplashViewController() - splash.onRetryTapped = { [weak self] in self?.bootstrap() } - splashViewController = splash - navigationController.setViewControllers([splash], animated: false) - childCoordinators.removeAll() - showLogin() + guard !isReissuingSession else { return } + isReissuingSession = true + let issueGuestSession = container.issueGuestSessionUseCase + let syncService = container.alarmSyncService + Task { [weak self] in + let succeeded = (try? await issueGuestSession.execute()) != nil + self?.isReissuingSession = false + // 만료 사이에 실패한 동기화를 메운다(activate의 시작 동기화는 최초 1회 가드). + if succeeded { await syncService.syncNow() } + } } - /// 로그인 중에 토큰이 갱신됐을 수 있다 — 세션이 생긴 직후 현재 토큰을 한 번 맞춘다. - private func syncPushTokenAfterLogin() { + /// 발급 요청 이후 토큰이 갱신됐을 수 있다 — 세션이 생긴 직후 현재 토큰을 한 번 맞춘다. + private func syncPushToken() { let syncPushToken = container.syncPushTokenUseCase Task { guard let token = await FCMPushTokenAdapter().currentPushToken() else { return } diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 6c5a2b41..c8e9392d 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -1,6 +1,4 @@ import AtchaData -import AuthFeature -import AuthFeatureInterface import CoreAlarm import CoreAuth import CoreNetwork @@ -18,8 +16,8 @@ import SettingsFeatureInterface /// Presentation modules depend on Domain protocols only. final class AppDIContainer { private let networkClient: any NetworkClient - /// 데코레이터 미적용 클라이언트 — reissue(AuthSessionManager)와 소셜 Bearer를 실어야 - /// 하는 로그인 API(AuthRepositoryImpl)만 쓴다. + /// 데코레이터 미적용 클라이언트 — reissue(AuthSessionManager)와 토큰 없이 부르는 + /// 게스트 발급(AuthRepositoryImpl)만 쓴다. private let plainNetworkClient: any NetworkClient let authSessionManager: AuthSessionManager @@ -75,11 +73,10 @@ final class AppDIContainer { let networkClient = AuthenticatedNetworkClient( base: baseClient, sessionManager: sessionManager, - // /auth/* 는 전부 plain client 또는 CoreAuth 내부에서 특수 Bearer(소셜·refresh)로 - // 호출되지만, 실수로 이 데코레이터를 타도 그 Authorization이 서버 access 토큰으로 - // 조용히 덮이지 않도록 이중 방어로 전부 public 처리한다(레거시 allowlist 대응). + // /auth/* 는 plain client(CoreAuth reissue·게스트 발급)로만 호출되지만, 실수로 이 + // 데코레이터를 타도 401 복구가 발급 경로로 재귀하지 않도록 이중 방어로 public 처리한다. publicPathSuffixes: [ - "/auth/reissue", "/auth/check", "/auth/login", "/auth/sign-up", "/auth/logout", + "/auth/reissue", "/auth/guest", // 로그인 전 스플래시에서 호출된다 — 토큰이 없어 401 복구가 로그인 라우팅을 촉발하면 안 된다. "/app/version", ] @@ -144,59 +141,28 @@ final class AppDIContainer { await liveActivityAdapter.reattachOrphans(snapshot: snapshot, now: Date()) } - func makeAuthDIContainer() -> any AuthCoordinatorBuildable { - AuthDIContainer( - signInUseCase: DefaultSignInUseCase( - socialLoginService: SocialLoginAdapter(), - // plain client — 소셜 Bearer 보존 + 401이 세션 복구를 촉발하지 않게. - authRepository: AuthRepositoryImpl(networkClient: plainNetworkClient), - sessionStore: AuthSessionStoreAdapter(sessionManager: authSessionManager), - pushTokenProvider: FCMPushTokenAdapter(), - // 최소 가입 폼 재료 — 신규 계정일 때만 쓰인다(현재 위치 + 역지오코딩 주소). - getCurrentLocationUseCase: DefaultGetCurrentLocationUseCase( - locationService: CoreLocationServiceAdapter() - ), - reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository) - ) + /// 앱 시작(토큰 없음)·refresh 확정 만료 두 경로가 같은 발급 UseCase를 쓴다. + var issueGuestSessionUseCase: any IssueGuestSessionUseCase { + DefaultIssueGuestSessionUseCase( + // plain client — 토큰 없는 발급 요청 + 401이 세션 복구를 촉발하지 않게. + authRepository: AuthRepositoryImpl(networkClient: plainNetworkClient), + sessionStore: AuthSessionStoreAdapter(sessionManager: authSessionManager), + deviceIdentifier: KeychainDeviceIdentifier(), + pushTokenProvider: FCMPushTokenAdapter() ) } - // MARK: - 계정 스택 (설정 화면 + DEV 디버그 메뉴의 로그아웃) + // MARK: - 설정 private var userRepository: any UserRepository { UserRepositoryImpl(networkClient: networkClient) } - private var sessionEnding: any SessionEnding { - AuthSessionEndingAdapter(sessionManager: authSessionManager) - } - - /// 세션 종료 시 알람 정리 — 등록/취소 UseCase와 같은 공유 인스턴스를 봐야 한다. - var alarmSessionTeardown: any AlarmSessionTeardown { - AlarmSessionTeardownAdapter( - alarmRepository: alarmRepository, - scheduler: alarmScheduler, - activityPort: liveActivityPort, - snapshotStore: alarmSessionSnapshotStore, - syncService: alarmSyncService - ) - } - - func makeLogoutUseCase() -> any LogoutUseCase { - DefaultLogoutUseCase(sessionEnding: sessionEnding, alarmTeardown: alarmSessionTeardown) - } - - func makeWithdrawUseCase() -> any WithdrawUseCase { - DefaultWithdrawUseCase(userRepository: userRepository, sessionEnding: sessionEnding) - } - func makeSettingsDIContainer( getCurrentLocationUseCase: any GetCurrentLocationUseCase ) -> any SettingsCoordinatorBuildable { SettingsDIContainer( getUserProfileUseCase: DefaultGetUserProfileUseCase(userRepository: userRepository), - logoutUseCase: makeLogoutUseCase(), - withdrawUseCase: makeWithdrawUseCase(), updateHomeAddressUseCase: DefaultUpdateHomeAddressUseCase( userRepository: userRepository, placeRepository: placeRepository diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index f98eceaa..75690a89 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -1,6 +1,5 @@ import FirebaseCore import FirebaseMessaging -import KakaoSDKCommon import UIKit import os @@ -28,10 +27,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { // 노티 탭 라우팅(Phase 15) — launch 완료 전에 등록해야 탭이 앱을 cold start시키는 // 경우의 didReceive까지 잡는다. 홈 랜딩 훅 배선은 SceneDelegate 몫. container.notificationTapDelegate.attachToNotificationCenter() - // 카카오 로그인 — 키 미주입(TUIST_KAKAO_APP_KEY 부재) 빌드에선 건너뛴다. - if let kakaoAppKey = KakaoConfig.appKey { - KakaoSDK.initSDK(appKey: kakaoAppKey) - } configureFirebaseIfAvailable() if isFirebaseEnabled { Messaging.messaging().delegate = self @@ -94,7 +89,7 @@ extension AppDelegate: MessagingDelegate { nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { guard let fcmToken else { return } Task { @MainActor in - // 로그인 전 토큰은 로그인/가입 파라미터가 싣는다 — 여기선 세션이 있을 때의 갱신만. + // 세션 전 토큰은 게스트 발급 요청이 싣는다 — 여기선 세션이 있을 때의 갱신만. guard container.authSessionManager.bootstrapState() == .active else { return } await container.syncPushTokenUseCase.execute(token: fcmToken) } diff --git a/Projects/App/Sources/BootstrapFailureMessage.swift b/Projects/App/Sources/BootstrapFailureMessage.swift index f1bcf071..25dbd7d9 100644 --- a/Projects/App/Sources/BootstrapFailureMessage.swift +++ b/Projects/App/Sources/BootstrapFailureMessage.swift @@ -2,8 +2,8 @@ import CoreNetwork import Foundation /// 스플래시 부트스트랩 실패 문구 매핑(Phase 16) — 원인 불문 고정 문구를 오프라인/기타로 -/// 나눈다. 순수 함수라 AtchaV2Tests가 직접 검증한다. 현재 부트스트랩은 동기 판정 -/// (bootstrapState)이라 실패 경로가 없다 — 서버 검증형 부트스트랩 도입 대비로 유지. +/// 나눈다. 순수 함수라 AtchaV2Tests가 직접 검증한다. 실패 경로는 토큰이 없을 때의 +/// 게스트 세션 발급(스플래시 재시도 UI)이다. enum BootstrapFailureMessage { static let offline = "네트워크 연결을 확인해주세요" static let transient = "일시적인 문제가 생겼어요. 잠시 후 다시 시도해주세요" diff --git a/Projects/App/Sources/KakaoConfig.swift b/Projects/App/Sources/KakaoConfig.swift deleted file mode 100644 index 782040eb..00000000 --- a/Projects/App/Sources/KakaoConfig.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -/// 카카오 네이티브 앱 키 — Tuist env(TUIST_KAKAO_APP_KEY)가 Info.plist로 주입한 값을 -/// 읽는다(xcconfig 금지 규약). 미주입(빈 값)이면 nil — 카카오 로그인만 비활성. -enum KakaoConfig { - static var appKey: String? { - guard let key = Bundle.main.object(forInfoDictionaryKey: "KAKAO_APP_KEY") as? String, - !key.isEmpty - else { return nil } - return key - } -} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index b9b8a6b1..de37ac44 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -1,4 +1,3 @@ -import KakaoSDKAuth import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -36,12 +35,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { #endif } - /// 카카오톡 앱 로그인 리디렉션(kakao{KEY}:// 스킴) 복귀 처리 — 레거시 동일. - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - guard let url = URLContexts.first?.url, AuthApi.isKakaoTalkLoginUrl(url) else { return } - _ = AuthController.handleOpenUrl(url: url) - } - #if DEV // MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 플로팅 버튼) @@ -102,14 +95,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { add("10분 늦춤 (3초 후)", injection: .delay(10 * 60), delay: 3) add("운행 종료 (3초 후)", injection: .end, delay: 3, style: .destructive) add("즉시 refresh", injection: nil, delay: 0) - // 로그아웃 엔드투엔드 검수(설정 화면 前 유일한 진입점) — 서버 /auth/logout 베스트 - // 에포트 후 sessionExpired 경유로 로그인 시트에 복귀해야 정상이다. - sheet.addAction(UIAlertAction(title: "로그아웃 (DEV)", style: .destructive) { _ in - Task { @MainActor in - await container.makeLogoutUseCase().execute() - print("⚠️ [DEV] 로그아웃 실행 — sessionExpired 라우팅으로 로그인 시트 복귀 기대") - } - }) // Phase 12 dismiss 폴백 검수용 — 잠금화면 스와이프 자동화가 불안정할 때 기록을 강제한다. // 켠 뒤 앞당김/운행 종료를 주입하면 LA alert 대신 로컬 노티 경로를 탄다. // 표시용 현재값은 UserDefaults 동기 읽기 — 어댑터가 캐시와 함께 갱신하므로 어긋나지 않는다. diff --git a/Projects/Feature/Auth/Example/ExampleApp.swift b/Projects/Feature/Auth/Example/ExampleApp.swift deleted file mode 100644 index 1f177240..00000000 --- a/Projects/Feature/Auth/Example/ExampleApp.swift +++ /dev/null @@ -1,106 +0,0 @@ -import AuthFeature -import AuthFeatureInterface -import CoreCoordinator -import Domain -import UIKit - -@main -final class AppDelegate: UIResponder, UIApplicationDelegate { - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - true - } - - func application( - _ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions - ) -> UISceneConfiguration { - UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) - } -} - -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - private let navigationController = UINavigationController() - private var loginCoordinator: (any Coordinator)? - - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions - ) { - guard let windowScene = scene as? UIWindowScene else { return } - - let launcher = LauncherViewController() - launcher.onStart = { [weak self] in self?.startLoginFlow() } - navigationController.viewControllers = [launcher] - - let window = UIWindow(windowScene: windowScene) - window.rootViewController = navigationController - window.makeKeyAndVisible() - self.window = window - } - - private func startLoginFlow() { - let container = AuthDIContainer(signInUseCase: PreviewSignInUseCase()) - let coordinator = container.makeLoginCoordinator( - navigationController: navigationController, - onAuthenticated: { [weak self] in self?.showAuthenticated() } - ) - coordinator.finishDelegate = self - loginCoordinator = coordinator - coordinator.start() - } - - private func showAuthenticated() { - let alert = UIAlertController(title: "로그인 완료", message: "세션 채택 성공 경로", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - navigationController.present(alert, animated: true) - } -} - -extension SceneDelegate: CoordinatorFinishDelegate { - // finish 후 런처로 복귀 — 플로우를 반복 시연할 수 있다. - func coordinatorDidFinish(_ coordinator: any Coordinator) { - loginCoordinator = nil - } -} - -private final class LauncherViewController: UIViewController { - var onStart: (() -> Void)? - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - title = "AuthFeature Example" - - let startButton = UIButton(type: .system) - startButton.setTitle("로그인 시트 열기 (실패→성공 순환)", for: .normal) - startButton.addAction(UIAction { [weak self] _ in self?.onStart?() }, for: .touchUpInside) - startButton.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(startButton) - NSLayoutConstraint.activate([ - startButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - startButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } -} - -/// 탭할 때마다 실패 토스트 → 성공을 순환 — 두 UX를 모두 시연한다 -/// (미가입은 UseCase 내부에서 자동 가입으로 흡수돼 화면 분기가 없다). -/// (스텁이 Tests와 중복되는 것은 의도된 트레이드오프 — 규약 참고.) -@MainActor -private final class PreviewSignInUseCase: SignInUseCase { - private var attempt = 0 - - private struct PreviewError: Error {} - - func execute(provider: SocialLoginProvider) async throws { - try? await Task.sleep(for: .seconds(1)) - defer { attempt += 1 } - if attempt % 2 == 0 { throw PreviewError() } - } -} diff --git a/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift b/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift deleted file mode 100644 index 8f7482b5..00000000 --- a/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift +++ /dev/null @@ -1,15 +0,0 @@ -import CoreCoordinator -import UIKit - -/// Entry point other modules use to start the login flow. -/// The concrete builder is the feature's DIContainer; wiring happens at the -/// App composition root. -@MainActor -public protocol AuthCoordinatorBuildable { - /// 강제 로그인 플로우 — 성공(세션 채택 완료) 시에만 onAuthenticated가 불린다. - /// 취소 경로는 없다(시트 dismiss 제스처 미제공). - func makeLoginCoordinator( - navigationController: UINavigationController, - onAuthenticated: @escaping () -> Void - ) -> any Coordinator -} diff --git a/Projects/Feature/Auth/Project.swift b/Projects/Feature/Auth/Project.swift deleted file mode 100644 index 453a3675..00000000 --- a/Projects/Feature/Auth/Project.swift +++ /dev/null @@ -1,23 +0,0 @@ -import ProjectDescription -import ProjectDescriptionHelpers - -let project = Project.feature( - name: "Auth", - dependencies: [ - .project(target: "Domain", path: "../../Domain"), - .project(target: "DesignSystem", path: "../../DesignSystem"), - .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), - .external(name: "SnapKit"), - ], - interfaceDependencies: [ - .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), - ], - testDependencies: [ - .project(target: "Domain", path: "../../Domain"), - ], - exampleDependencies: [ - .project(target: "Domain", path: "../../Domain"), - .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), - ], - resources: ["Resources/**"] -) diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png deleted file mode 100644 index 1619631eafbdbe7ae72ea2adf8cfadff42dadeef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 348 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O)#kDe}$Ar*{Qr#=*HGT>dyNwyWHXG!deAG)k#(hF+Zt>!t>>&47p8s+m#qiGzEoz@5UfgSB+E_B_ z_tV`cK3YEG-*PcSDCUBjimb_1{tV@eRqHPtJO6jXZ>j1HA6ZhH9!>Eo08)E+jSrs< zT&-kYa&f`iCE9t5y;L`B)7qSM{i@RCgI5oh%!u5vdeMi^%ahjGO;t&mQEVtzG*Kn+ q%8BV4E9T~X+H>@Z;`i%&7&aU`bBW~?`*Wb@89ZJ6T-G@yGywoq>5Rz$ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png deleted file mode 100644 index 230f9e87d0a0fd7c7b98c224c2eabbf4c90cae73..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 624 zcmV-$0+0QPP)a6^qyK^oMFc1zA-Ecf}ym5@t7o6|~{U$~qV4)F5Mt|x44Ah9v&<9xPicYga*O4Vc z)kNqzHFtlGKEOhEC&Z`d18(ykvqWDg8IW06QUfJ(gfnoPP(d_xIX}mT9aST_F`9ykl9-VW8Moq?Q?=PD7PvG{{EqDS(CE6- z45DJKuTiXZE98dFeS=h+n2Iw?1Y0&76JJrdh6gG(WCUBJB0XpZYL?Ixw&WZW?ciDi zbg0*syo5Xtd@~TkmYl_>AqhSM_poJyKzs%)%ck&&K)PnYdyYD$YX*W*J&6qozF|HC zIc&K@AU*?Mu;r1TJwdbJGq8m%B7PQ<&@}_Xlmgl<8Uw+l?M!4A)>;#_2Ha7$jai7G zG@&V@?`VgJBlEq~XbhyVUq)qWtCH7%TSgnSM{Ofa`y_rvJEXpM19fAI*P^`C$FSwr zXCUM(^aNYdi+Ad%Xi`~_QB&Uzq~S!(lb|W6=r}jop=-YV&KHTP8Dw^CUZVIyiARX+ z7ikYh?pKb=X-&@BOnk{iEII)Rl5;tf#XNH?jYSR_PC zqNsO=^Ih&jAHP8o1VCV*zYtNzc7}l`Sda70YVR0x3e~_;z7}6rt*UN7y1oJiI5(&Dqg=W=IuTIu+tg93L&Az&lVRv)RRzteUepGbnijf4UJ{xUsAw zj^O1%WI}!Mo)}UN1=}YUs6Y*TJ!pbB#1ZCo#Swf3`iwZ(X*cCVdRS5XI0XXX<3SlBA!O1_69*;5AW#Lp6o>-W&U^%FwYuO*hCuXHpa}xeSAl{> z{Jxh}xu|A{gLff{j=r08w~KQB6Qq{!E6@YtVHzU(@3RL9wcBMh3A5N*ZzZdE2_;@0 zWH-IoyhzGZYERNc(!o**4I&*O8_k}i5onD#$=J4JI!~OvN!1?|Sv*vRbBv_Dto}s2 z-1!8MS`UixG~08*0y*cbRUTx2fnbU81}S{R5$fcFejq;P@T67~Coi=X0;U#DjCjvE zJgJ*eR7a>S;O#v^ErD!lH-@EFG}iudh&|Dyy)8p``JgQY)lS;m6RpcjZC-$L{U1&6(8Okq3aHOvr*lf3pJ z^Wav3NY}K&n(a4CGOnpCm=VVBO7MIz)}e!ih!8spd^l! zDBwn7Xn#u_f4slnhaa%wzhO-qTo*o**rTB*fxdQdDjBYXKx8E_8sZ2NXS&fEIPD9> zGcJE6;CblJ-!P#=KK=qj)Y-%V=a`kJ4Mqdp(5-i1fePD37rVO1n!dpu;FLaxu*d!U z!24@luq6{2$)Z z;9XbI7C8O&5g{Fo)Mqce4Ky3rD@nMH-sl9W&j4owa(%N5pGkNd%Z1Zy?6}TGz>BZbMpSA@q;U86njM^Me0CaZ7&dyL;(rKkW0i@FkcmhLbCq4nOvomktSpWeF;|yp> zCb#}}=a>PN+r7I@a(C=!hT$eT$nAH2zun)z02YhIVzF2(7K_DV88-|Qq*{gVx~T90 z{Ln$=;(JbrF1^;J%b?RifC(`+fqWm8UJrG~u?UWuc!}e(C~?q(w(0&h{q1MCXpKv> zkqRV5`Gr^XDYZxxV@Sa`wni=1NCYC+5rV9l7A1xz9Bh+NEf~hAKobz=5{zMoGrTcr zQAPw3u5@;aHF~($$N?nO=JX62K_EBzZ2t=xkyaDpJze_97lC^dD|>siv1hoYKjclGFlK=V`A2-Xj0hvWS#Qph%o`6o=FzB^IcF;+&I8 zq(qzagL%|L;3JBRgC|MiC2fI3-bAO>F7kI;C4v^*ou0*lZV2E&mr%NvkAfrezq8*m zEYU*Cf*#(*(vaaR%7mt?X<5+F!3N3(mmZ-+X+hE6^9?10BJE{Wq^-0dHq1wUH426U z)K&MWU5w!|N(v>l&y@GXz3Eq|;5CIGC^6nF?!+DWu@=_?VNP2oey)agSxJazQ(f5c%eX(dzVHX9)Fg<816@@gm+jm}Z>mU*h-1rn)MFp*M; zBS<~|B197IMk#;VRFjVXvtMR7#nZ)FD!=27F)EE z1+tn|(?jJqU>LF^bDFN#(iTx%PiG-p^;%IJz$)oUxx3ZL?Nnx zqZH*LMlC2xMbt3qiozXQ@)W#!Fe@N2y5kb#e;5^r4N>O`n-u#6xyAbVqN6<{Ku$3# z5XBvfS2|c^H3ywy72k}VVqy~HsxPq!tMO*A+$oqi0%I1`pZZrEc?1Ki`-ke Void - ) -> any Coordinator { - LoginCoordinator( - navigationController: navigationController, - container: self, - onAuthenticated: onAuthenticated - ) - } - - func makeLoginViewController(onAuthenticated: @escaping () -> Void) -> LoginViewController { - let viewModel = LoginViewModel(signInUseCase: signInUseCase) - viewModel.onAuthenticated = onAuthenticated - return LoginViewController(viewModel: viewModel) - } -} diff --git a/Projects/Feature/Auth/Sources/AuthPalette.swift b/Projects/Feature/Auth/Sources/AuthPalette.swift deleted file mode 100644 index 77388f37..00000000 --- a/Projects/Feature/Auth/Sources/AuthPalette.swift +++ /dev/null @@ -1,10 +0,0 @@ -import UIKit - -/// 소셜 브랜드 고정색 — 테마를 타는 시맨틱 토큰이 아니라 브랜드 상수라 DSPalette에 -/// 올리지 않는다(소비처가 로그인 화면뿐). 값은 레거시 colorset 실측. -enum AuthPalette { - /// 카카오 컨테이너 배경 (#FEE500). - static let kakaoYellow = UIColor(red: 0xFE / 255, green: 0xE5 / 255, blue: 0x00 / 255, alpha: 1) - /// 카카오 심볼 틴트 (#181600). - static let kakaoSymbol = UIColor(red: 0x18 / 255, green: 0x16 / 255, blue: 0x00 / 255, alpha: 1) -} diff --git a/Projects/Feature/Auth/Sources/LoginCoordinator.swift b/Projects/Feature/Auth/Sources/LoginCoordinator.swift deleted file mode 100644 index a291a10d..00000000 --- a/Projects/Feature/Auth/Sources/LoginCoordinator.swift +++ /dev/null @@ -1,46 +0,0 @@ -import CoreCoordinator -import UIKit - -final class LoginCoordinator: Coordinator { - var childCoordinators: [any Coordinator] = [] - weak var finishDelegate: (any CoordinatorFinishDelegate)? - - // The App (window root) owns the navigation controller. - private weak var navigationController: UINavigationController? - private let container: AuthDIContainer - private let onAuthenticated: () -> Void - private weak var loginViewController: LoginViewController? - - init( - navigationController: UINavigationController, - container: AuthDIContainer, - onAuthenticated: @escaping () -> Void - ) { - self.navigationController = navigationController - self.container = container - self.onAuthenticated = onAuthenticated - } - - func start() { - guard let navigationController else { return } - let viewController = container.makeLoginViewController(onAuthenticated: { [weak self] in - self?.completeFlow() - }) - loginViewController = viewController - navigationController.present(viewController, animated: false) - } - - /// 성공 유일 종료 경로 — 시트 하강 연출 후 onAuthenticated → finish. - private func completeFlow() { - guard let loginViewController else { - onAuthenticated() - finish() - return - } - loginViewController.animateDismiss { [weak self] in - guard let self else { return } - self.onAuthenticated() - self.finish() - } - } -} diff --git a/Projects/Feature/Auth/Sources/LoginViewController.swift b/Projects/Feature/Auth/Sources/LoginViewController.swift deleted file mode 100644 index 3ffd61f2..00000000 --- a/Projects/Feature/Auth/Sources/LoginViewController.swift +++ /dev/null @@ -1,138 +0,0 @@ -import DesignSystem -import Domain -import SnapKit -import UIKit - -/// 레거시 로그인 바텀시트 비주얼 이식(dim 0.9 + 컨테이너 198 + 핸들 + 슬라이드업). -/// 강제 로그인이라 dim 탭·팬 다운 dismiss 제스처는 계승하지 않는다 — 핸들은 장식. -final class LoginViewController: UIViewController { - private let viewModel: LoginViewModel - - private let dimView = UIView() - private let containerView = UIView() - private let sheetHandle = UIView() - private let kakaoButton = SocialLoginButton( - title: "카카오톡으로 3초만에 시작", - icon: AuthFeatureAsset.kakao.image, - textColor: .black, - background: AuthPalette.kakaoYellow, - iconTint: AuthPalette.kakaoSymbol - ) - private let appleButton = SocialLoginButton( - title: "Apple로 시작", - icon: AuthFeatureAsset.apple.image, - textColor: .white, - background: .black, - iconTint: .white - ) - private let sheetHeight: CGFloat = 198 - - init(viewModel: LoginViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - modalPresentationStyle = .overFullScreen - modalTransitionStyle = .crossDissolve - } - - override func viewDidLoad() { - super.viewDidLoad() - setupLayout() - bindViewModel() - containerView.transform = CGAffineTransform(translationX: 0, y: sheetHeight) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { - self.dimView.alpha = 1 - self.containerView.transform = .identity - } - } - - /// 성공 종료 연출(레거시 dismissSheet) — 시트 하강 + dim 페이드 후 실제 dismiss. - /// 코디네이터가 부른다. - func animateDismiss(completion: @escaping () -> Void) { - UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) { - self.dimView.alpha = 0 - self.containerView.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight) - } completion: { _ in - self.dismiss(animated: false, completion: completion) - } - } - - private func setupLayout() { - view.backgroundColor = .clear - - dimView.backgroundColor = UIColor.black.withAlphaComponent(0.9) - dimView.alpha = 0 - view.addSubview(dimView) - dimView.snp.makeConstraints { $0.edges.equalToSuperview() } - - containerView.backgroundColor = DSColor.Background.elevated - containerView.layer.cornerRadius = 20 - containerView.clipsToBounds = true - view.addSubview(containerView) - containerView.snp.makeConstraints { make in - make.leading.trailing.bottom.equalToSuperview() - make.height.equalTo(sheetHeight) - } - - sheetHandle.backgroundColor = DSColor.Fill.highlight - sheetHandle.layer.cornerRadius = 2 - containerView.addSubview(sheetHandle) - sheetHandle.snp.makeConstraints { make in - make.top.equalToSuperview().offset(DSSpacing.sm12) - make.centerX.equalToSuperview() - make.width.equalTo(40) - make.height.equalTo(4) - } - - let buttonStack = UIStackView(arrangedSubviews: [kakaoButton, appleButton]) - buttonStack.axis = .vertical - buttonStack.spacing = DSSpacing.sm12 - containerView.addSubview(buttonStack) - buttonStack.snp.makeConstraints { make in - make.horizontalEdges.equalToSuperview().inset(DSSpacing.lg) - make.bottom.equalToSuperview().inset(40) - } - - kakaoButton.addTarget(self, action: #selector(didTapKakao), for: .touchUpInside) - appleButton.addTarget(self, action: #selector(didTapApple), for: .touchUpInside) - } - - private func bindViewModel() { - viewModel.onStateChange = { [weak self] state in - self?.render(state) - } - viewModel.onEvent = { [weak self] event in - self?.handle(event) - } - render(viewModel.state) - } - - private func render(_ state: LoginViewModel.State) { - let isLoading = state != .idle - kakaoButton.isEnabled = !isLoading - appleButton.isEnabled = !isLoading - } - - private func handle(_ event: LoginViewModel.Event) { - switch event { - case let .showFailureToast(message): - DSToast.show(message, in: view) - } - } - - @objc private func didTapKakao() { - viewModel.kakaoTapped() - } - - @objc private func didTapApple() { - viewModel.appleTapped() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } -} diff --git a/Projects/Feature/Auth/Sources/LoginViewModel.swift b/Projects/Feature/Auth/Sources/LoginViewModel.swift deleted file mode 100644 index 2a3c637c..00000000 --- a/Projects/Feature/Auth/Sources/LoginViewModel.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Domain -import Foundation - -@MainActor -final class LoginViewModel { - enum State: Equatable { - case idle - /// 어느 제공자로 진행 중인지 — 화면은 양 버튼을 잠근다(중복 탭 방지). - case loading(SocialLoginProvider) - } - - /// 원샷 안내 — 상태와 분리한다(Home의 ToastEvent 패턴). - enum Event: Equatable { - case showFailureToast(message: String) - } - - static let failureMessage = "로그인에 실패했어요. 잠시 후 다시 시도해주세요." - - var onStateChange: ((State) -> Void)? - var onEvent: ((Event) -> Void)? - /// 세션 채택까지 완료된 뒤에만 불린다 — 코디네이터가 플로우를 닫는 신호. - var onAuthenticated: (() -> Void)? - - private(set) var state: State = .idle { - didSet { onStateChange?(state) } - } - - private let signInUseCase: any SignInUseCase - private var signInTask: Task? - - init(signInUseCase: any SignInUseCase) { - self.signInUseCase = signInUseCase - } - - deinit { - signInTask?.cancel() - } - - func kakaoTapped() { - signIn(provider: .kakao) - } - - func appleTapped() { - signIn(provider: .apple) - } - - private func signIn(provider: SocialLoginProvider) { - guard state == .idle else { return } - state = .loading(provider) - signInTask = Task { [weak self] in - guard let self else { return } - do { - // 미가입 계정은 UseCase가 최소 가입까지 수행한다 — 성공은 곧 세션 채택 완료. - try await self.signInUseCase.execute(provider: provider) - guard !Task.isCancelled else { return } - // 성공 시 idle 복귀 없이 바로 닫는다 — 버튼이 다시 살아나며 - // 깜빡이는 프레임을 만들지 않는다. - self.onAuthenticated?() - } catch SocialLoginError.cancelled { - // 사용자가 소셜 UI를 스스로 닫음 — 에러 표출 없이 재시도 대기. - guard !Task.isCancelled else { return } - self.state = .idle - } catch { - guard !Task.isCancelled else { return } - self.state = .idle - self.onEvent?(.showFailureToast(message: Self.failureMessage)) - } - } - } -} diff --git a/Projects/Feature/Auth/Sources/SocialLoginButton.swift b/Projects/Feature/Auth/Sources/SocialLoginButton.swift deleted file mode 100644 index bc335fb5..00000000 --- a/Projects/Feature/Auth/Sources/SocialLoginButton.swift +++ /dev/null @@ -1,53 +0,0 @@ -import DesignSystem -import SnapKit -import UIKit - -/// 레거시 로그인 버튼 스펙 이식: 높이 52 · radius 8 · 아이콘 leading 16(24pt) · 라벨 중앙. -/// DSButton은 아이콘을 타이틀에 인접 배치하는 Configuration 기반이라 이 스펙(아이콘 -/// 고정 + 라벨 독립 중앙)에는 전용 컴포넌트가 맞다. -final class SocialLoginButton: UIButton { - static let height: CGFloat = 52 - - private let iconView = UIImageView() - private let label = UILabel() - - init(title: String, icon: UIImage?, textColor: UIColor, background: UIColor, iconTint: UIColor) { - super.init(frame: .zero) - - backgroundColor = background - layer.cornerRadius = DSRadius.sm - clipsToBounds = true - - iconView.image = icon - iconView.contentMode = .scaleAspectFit - iconView.tintColor = iconTint - iconView.isUserInteractionEnabled = false - - label.attributedText = DSTypography.label1.attributed(title, color: textColor, alignment: .center) - label.isUserInteractionEnabled = false - - addSubview(iconView) - addSubview(label) - - iconView.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(DSSpacing.md) - make.centerY.equalToSuperview() - make.width.height.equalTo(DSIconSize.lg) - } - label.snp.makeConstraints { make in - make.center.equalToSuperview() - } - snp.makeConstraints { make in - make.height.equalTo(Self.height) - } - } - - override var isEnabled: Bool { - didSet { alpha = isEnabled ? 1 : 0.5 } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } -} diff --git a/Projects/Feature/Auth/Tests/LoginViewModelTests.swift b/Projects/Feature/Auth/Tests/LoginViewModelTests.swift deleted file mode 100644 index aba3d00c..00000000 --- a/Projects/Feature/Auth/Tests/LoginViewModelTests.swift +++ /dev/null @@ -1,133 +0,0 @@ -@testable import AuthFeature -import Domain -import Foundation -import Testing - -@MainActor -private final class StubSignInUseCase: SignInUseCase { - private(set) var executeCallCount = 0 - private(set) var providers: [SocialLoginProvider] = [] - private var result: Result - private let suspends: Bool - private var continuation: CheckedContinuation? - - init(result: Result = .success(()), suspends: Bool = false) { - self.result = result - self.suspends = suspends - } - - func execute(provider: SocialLoginProvider) async throws { - executeCallCount += 1 - providers.append(provider) - if suspends { - return try await withCheckedThrowingContinuation { continuation = $0 } - } - return try result.get() - } - - func resumeSuccessfully() { - continuation?.resume(returning: ()) - continuation = nil - } -} - -@MainActor -private final class Recorder { - private(set) var states: [LoginViewModel.State] = [] - private(set) var events: [LoginViewModel.Event] = [] - private(set) var authenticatedCount = 0 - - func attach(to viewModel: LoginViewModel) { - viewModel.onStateChange = { [weak self] state in self?.states.append(state) } - viewModel.onEvent = { [weak self] event in self?.events.append(event) } - viewModel.onAuthenticated = { [weak self] in self?.authenticatedCount += 1 } - } - - // Home 템플릿의 드레인 패턴: 스텁이 즉시 resolve하므로 yield로 충분하다. - func waitUntil(_ predicate: () -> Bool) async { - while !predicate() { - await Task.yield() - } - } -} - -private struct StubError: Error {} - -@MainActor -struct LoginViewModelTests { - @Test - func kakaoTapped_success_callsOnAuthenticatedWithoutIdleFlicker() async { - let useCase = StubSignInUseCase() - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.kakaoTapped() - await recorder.waitUntil { recorder.authenticatedCount == 1 } - - #expect(useCase.providers == [.kakao]) - // 성공 시 idle 복귀 없이 바로 닫힌다 — 버튼 재활성 프레임 없음. - #expect(recorder.states == [.loading(.kakao)]) - #expect(recorder.events.isEmpty) - } - - @Test - func appleTapped_passesAppleProvider() async { - let useCase = StubSignInUseCase() - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.appleTapped() - await recorder.waitUntil { recorder.authenticatedCount == 1 } - - #expect(useCase.providers == [.apple]) - } - - @Test - func tapped_socialCancelled_returnsIdleSilently() async { - let useCase = StubSignInUseCase(result: .failure(SocialLoginError.cancelled)) - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.kakaoTapped() - await recorder.waitUntil { recorder.states.last == .idle } - - #expect(recorder.events.isEmpty) - #expect(recorder.authenticatedCount == 0) - } - - @Test - func tapped_failure_returnsIdleAndEmitsFailureToast() async { - let useCase = StubSignInUseCase(result: .failure(StubError())) - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.appleTapped() - await recorder.waitUntil { recorder.states.last == .idle } - - #expect(recorder.events == [.showFailureToast(message: LoginViewModel.failureMessage)]) - #expect(recorder.authenticatedCount == 0) - } - - /// 로딩 중 중복 탭은 무시된다 — 소셜 UI 이중 기동 방지. - @Test - func tapped_whileLoading_ignoresSecondTap() async { - let useCase = StubSignInUseCase(suspends: true) - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.kakaoTapped() - await recorder.waitUntil { useCase.executeCallCount == 1 } - sut.appleTapped() - - #expect(useCase.executeCallCount == 1) - #expect(recorder.states == [.loading(.kakao)]) - - useCase.resumeSuccessfully() - await recorder.waitUntil { recorder.authenticatedCount == 1 } - } -} diff --git a/Projects/Feature/Settings/Example/ExampleApp.swift b/Projects/Feature/Settings/Example/ExampleApp.swift index d9ca767a..0e8d8584 100644 --- a/Projects/Feature/Settings/Example/ExampleApp.swift +++ b/Projects/Feature/Settings/Example/ExampleApp.swift @@ -47,8 +47,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func startSettingsFlow() { let container = SettingsDIContainer( getUserProfileUseCase: PreviewGetUserProfileUseCase(), - logoutUseCase: PreviewLogoutUseCase(), - withdrawUseCase: PreviewWithdrawUseCase(), updateHomeAddressUseCase: PreviewUpdateHomeAddressUseCase(), searchPlacesUseCase: PreviewSearchPlacesUseCase(), getCurrentLocationUseCase: PreviewGetCurrentLocationUseCase(), @@ -93,8 +91,6 @@ private final class LauncherViewController: UIViewController { // Example 스텁 — Data 무의존으로 피처 단독 실행(Tests 스텁과의 중복은 의도된 트레이드오프). // "부산" 검색 결과를 고르면 서비스 지역 밖 토스트, 그 외는 저장 성공 경로를 시연한다. -private struct PreviewError: Error {} - private struct PreviewGetUserProfileUseCase: GetUserProfileUseCase { func execute() async throws -> UserProfile { try? await Task.sleep(for: .milliseconds(500)) @@ -102,17 +98,6 @@ private struct PreviewGetUserProfileUseCase: GetUserProfileUseCase { } } -private struct PreviewLogoutUseCase: LogoutUseCase { - func execute() async {} -} - -private struct PreviewWithdrawUseCase: WithdrawUseCase { - func execute(reason: String?) async throws { - try? await Task.sleep(for: .seconds(1)) - throw PreviewError() - } -} - private struct PreviewUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { func execute(address: String?, coordinate: Coordinate) async throws { try? await Task.sleep(for: .milliseconds(500)) diff --git a/Projects/Feature/Settings/Interface/SettingsCoordinatorBuildable.swift b/Projects/Feature/Settings/Interface/SettingsCoordinatorBuildable.swift index f3fd8fcf..81322f8c 100644 --- a/Projects/Feature/Settings/Interface/SettingsCoordinatorBuildable.swift +++ b/Projects/Feature/Settings/Interface/SettingsCoordinatorBuildable.swift @@ -2,7 +2,6 @@ import CoreCoordinator import UIKit /// Entry point other modules use to start the Settings flow. -/// 로그아웃·탈퇴 후의 로그인 복귀는 앱의 세션 만료 관찰이 맡는다 — 여기엔 완료 콜백이 없다. @MainActor public protocol SettingsCoordinatorBuildable { func makeSettingsCoordinator(navigationController: UINavigationController) -> any Coordinator diff --git a/Projects/Feature/Settings/Sources/SettingsCoordinator.swift b/Projects/Feature/Settings/Sources/SettingsCoordinator.swift index 14db49ec..53d9ac17 100644 --- a/Projects/Feature/Settings/Sources/SettingsCoordinator.swift +++ b/Projects/Feature/Settings/Sources/SettingsCoordinator.swift @@ -3,7 +3,7 @@ import SafariServices import UIKit // NSObject 상속은 UINavigationControllerDelegate(NSObjectProtocol) 요구 — 이탈 경로 -// (백 버튼·스와이프 백·popToRoot·세션 만료의 setViewControllers)를 didShow 한 지점에서 +// (백 버튼·스와이프 백·popToRoot)를 didShow 한 지점에서 // 정리한다(SearchCoordinator와 같은 규약). final class SettingsCoordinator: NSObject, Coordinator { var childCoordinators: [any Coordinator] = [] @@ -44,8 +44,6 @@ final class SettingsCoordinator: NSObject, Coordinator { self.settingsViewModel?.homeAddressDidChange() } navigationController.pushViewController(viewController, animated: true) - case .withdraw: - navigationController.pushViewController(container.makeWithdrawViewController(), animated: true) case let .externalLink(url): if url.scheme?.hasPrefix("http") == true { navigationController.present(SFSafariViewController(url: url), animated: true) diff --git a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift index e44e71fc..6a0475a9 100644 --- a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift +++ b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift @@ -8,8 +8,6 @@ import UIKit /// coordinator initializers. public final class SettingsDIContainer: SettingsCoordinatorBuildable { private let getUserProfileUseCase: any GetUserProfileUseCase - private let logoutUseCase: any LogoutUseCase - private let withdrawUseCase: any WithdrawUseCase private let updateHomeAddressUseCase: any UpdateHomeAddressUseCase private let searchPlacesUseCase: any SearchPlacesUseCase private let getCurrentLocationUseCase: any GetCurrentLocationUseCase @@ -20,8 +18,6 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { public init( getUserProfileUseCase: any GetUserProfileUseCase, - logoutUseCase: any LogoutUseCase, - withdrawUseCase: any WithdrawUseCase, updateHomeAddressUseCase: any UpdateHomeAddressUseCase, searchPlacesUseCase: any SearchPlacesUseCase, getCurrentLocationUseCase: any GetCurrentLocationUseCase, @@ -31,8 +27,6 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { appStoreURL: URL? = nil ) { self.getUserProfileUseCase = getUserProfileUseCase - self.logoutUseCase = logoutUseCase - self.withdrawUseCase = withdrawUseCase self.updateHomeAddressUseCase = updateHomeAddressUseCase self.searchPlacesUseCase = searchPlacesUseCase self.getCurrentLocationUseCase = getCurrentLocationUseCase @@ -49,7 +43,6 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { func makeSettingsScreen() -> (UIViewController, SettingsViewModel) { let viewModel = SettingsViewModel( getUserProfileUseCase: getUserProfileUseCase, - logoutUseCase: logoutUseCase, checkAppUpdateUseCase: checkAppUpdateUseCase, currentVersion: currentVersion, appStoreURL: appStoreURL @@ -57,10 +50,6 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { return (SettingsViewController(viewModel: viewModel), viewModel) } - func makeWithdrawViewController() -> UIViewController { - WithdrawViewController(viewModel: WithdrawViewModel(withdrawUseCase: withdrawUseCase)) - } - func makeHomeAddressViewController(onSaved: @escaping () -> Void) -> UIViewController { let viewModel = HomeAddressViewModel( searchPlacesUseCase: searchPlacesUseCase, diff --git a/Projects/Feature/Settings/Sources/SettingsScreen.swift b/Projects/Feature/Settings/Sources/SettingsScreen.swift index 52b9fec4..8627af06 100644 --- a/Projects/Feature/Settings/Sources/SettingsScreen.swift +++ b/Projects/Feature/Settings/Sources/SettingsScreen.swift @@ -49,13 +49,6 @@ class SettingsScreen: UIViewController, UIGestureRecognizerDelegate { DSToast.show(message, in: view) } - func confirm(title: String, confirmTitle: String, onConfirm: @escaping () -> Void) { - let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "취소", style: .cancel)) - alert.addAction(UIAlertAction(title: confirmTitle, style: .destructive) { _ in onConfirm() }) - present(alert, animated: true) - } - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { (navigationController?.viewControllers.count ?? 0) > 1 } diff --git a/Projects/Feature/Settings/Sources/SettingsViewController.swift b/Projects/Feature/Settings/Sources/SettingsViewController.swift index a7dc6877..16fe7a9e 100644 --- a/Projects/Feature/Settings/Sources/SettingsViewController.swift +++ b/Projects/Feature/Settings/Sources/SettingsViewController.swift @@ -57,10 +57,6 @@ final class SettingsViewController: SettingsScreen { DSListCell.Content(title: "피드백 보내기", accessory: .chevron) case let .version(text, hasUpdate): DSListCell.Content(title: "현재 버전 \(text)", accessory: hasUpdate ? .value("업데이트") : .none) - case .logout: - DSListCell.Content(title: "로그아웃") - case .withdraw: - DSListCell.Content(title: "계정 탈퇴") } } } @@ -85,7 +81,7 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - sections[section].title == nil ? DSSpacing.lg : UITableView.automaticDimension + UITableView.automaticDimension } func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { @@ -98,13 +94,6 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let row = sections[indexPath.section].rows[indexPath.row] - if row == .logout { - confirm(title: "로그아웃하시겠어요?", confirmTitle: "로그아웃") { [weak self] in - self?.viewModel.logoutConfirmed() - } - return - } - viewModel.didSelect(row) + viewModel.didSelect(sections[indexPath.section].rows[indexPath.row]) } } diff --git a/Projects/Feature/Settings/Sources/SettingsViewModel.swift b/Projects/Feature/Settings/Sources/SettingsViewModel.swift index 2e9553fd..508097d8 100644 --- a/Projects/Feature/Settings/Sources/SettingsViewModel.swift +++ b/Projects/Feature/Settings/Sources/SettingsViewModel.swift @@ -6,7 +6,6 @@ import Foundation final class SettingsViewModel { enum Route: Equatable { case homeAddress - case withdraw case externalLink(URL) } @@ -15,8 +14,6 @@ final class SettingsViewModel { case privacyPolicy case feedback case version(text: String, hasUpdate: Bool) - case logout - case withdraw } struct Section: Equatable { @@ -36,27 +33,22 @@ final class SettingsViewModel { private var addressText = "불러오는 중…" private var hasUpdate = false - private var isLoggingOut = false private let getUserProfileUseCase: any GetUserProfileUseCase - private let logoutUseCase: any LogoutUseCase private let checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? private let currentVersion: String private let appStoreURL: URL? private var profileTask: Task? private var updateTask: Task? - private var logoutTask: Task? init( getUserProfileUseCase: any GetUserProfileUseCase, - logoutUseCase: any LogoutUseCase, checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? = nil, currentVersion: String, appStoreURL: URL? = nil ) { self.getUserProfileUseCase = getUserProfileUseCase - self.logoutUseCase = logoutUseCase self.checkAppUpdateUseCase = checkAppUpdateUseCase self.currentVersion = currentVersion self.appStoreURL = appStoreURL @@ -66,7 +58,6 @@ final class SettingsViewModel { deinit { profileTask?.cancel() updateTask?.cancel() - logoutTask?.cancel() } func viewDidLoad() { @@ -88,19 +79,6 @@ final class SettingsViewModel { case .version(_, hasUpdate: true): if let appStoreURL { onRoute?(.externalLink(appStoreURL)) } case .version: break - case .withdraw: onRoute?(.withdraw) - // 확인 팝업은 VC 몫 — 확정되면 logoutConfirmed()로 들어온다. - case .logout: break - } - } - - /// 로그인 화면 복귀는 세션 만료 관찰(App)이 한다 — 여기선 중복 탭만 막는다. - func logoutConfirmed() { - guard !isLoggingOut else { return } - isLoggingOut = true - logoutTask = Task { [weak self] in - guard let useCase = self?.logoutUseCase else { return } - await useCase.execute() } } @@ -142,7 +120,6 @@ final class SettingsViewModel { .feedback, .version(text: currentVersion, hasUpdate: hasUpdate), ]), - Section(title: nil, rows: [.logout, .withdraw]), ] } } diff --git a/Projects/Feature/Settings/Sources/WithdrawViewController.swift b/Projects/Feature/Settings/Sources/WithdrawViewController.swift deleted file mode 100644 index e1ecf9e9..00000000 --- a/Projects/Feature/Settings/Sources/WithdrawViewController.swift +++ /dev/null @@ -1,125 +0,0 @@ -import DesignSystem -import SnapKit -import UIKit - -final class WithdrawViewController: SettingsScreen { - // VC strongly owns the VM; the VM's closures capture the VC weakly. - private let viewModel: WithdrawViewModel - private let scrollView = UIScrollView() - private let stack: UIStackView = { - let stack = UIStackView() - stack.axis = .vertical - stack.spacing = DSSpacing.sm - return stack - }() - private let titleLabel: UILabel = { - let label = UILabel() - label.numberOfLines = 0 - label.attributedText = DSTypography.title2.attributed( - "떠나시는 이유를 알려주세요", color: DSColor.Text.primary - ) - return label - }() - private var reasonButtons: [WithdrawViewModel.Reason: UIButton] = [:] - private let otherField = DSTextField(placeholder: "이유를 입력해 주세요") - private let submitButton = DSButton(title: "탈퇴하기") - - init(viewModel: WithdrawViewModel) { - self.viewModel = viewModel - super.init(title: "계정 탈퇴") - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } - - override func viewDidLoad() { - super.viewDidLoad() - configureUI() - bind() - } - - private func configureUI() { - scrollView.keyboardDismissMode = .onDrag - [scrollView, submitButton].forEach(contentView.addSubview) - scrollView.addSubview(stack) - submitButton.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(DSSpacing.md) - make.bottom.equalTo(view.keyboardLayoutGuide.snp.top).offset(-DSSpacing.sm) - } - scrollView.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - make.bottom.equalTo(submitButton.snp.top).offset(-DSSpacing.sm) - } - stack.snp.makeConstraints { make in - make.edges.equalTo(scrollView.contentLayoutGuide).inset(DSSpacing.md) - make.width.equalTo(scrollView.frameLayoutGuide).offset(-DSSpacing.md * 2) - } - - stack.addArrangedSubview(titleLabel) - stack.setCustomSpacing(DSSpacing.lg, after: titleLabel) - for reason in WithdrawViewModel.Reason.allCases { - let button = makeReasonButton(reason) - reasonButtons[reason] = button - stack.addArrangedSubview(button) - } - stack.addArrangedSubview(otherField) - otherField.isHidden = true - otherField.onTextChange = { [weak self] text in - self?.viewModel.otherTextDidChange(text) - } - - submitButton.addAction( - UIAction { [weak self] _ in - self?.confirm(title: "탈퇴하시겠어요?", confirmTitle: "탈퇴하기") { - self?.viewModel.withdrawConfirmed() - } - }, - for: .touchUpInside - ) - } - - private func makeReasonButton(_ reason: WithdrawViewModel.Reason) -> UIButton { - var configuration = UIButton.Configuration.plain() - configuration.title = reason.title - configuration.imagePadding = DSSpacing.sm12 - configuration.contentInsets = NSDirectionalEdgeInsets( - top: DSSpacing.sm12, leading: 0, bottom: DSSpacing.sm12, trailing: 0 - ) - let button = UIButton(configuration: configuration) - button.contentHorizontalAlignment = .leading - button.tintColor = DSColor.Text.primary - button.accessibilityTraits.insert(.button) - button.addAction( - UIAction { [weak self] _ in self?.viewModel.select(reason) }, - for: .touchUpInside - ) - return button - } - - private func bind() { - viewModel.onStateChange = { [weak self] state in - self?.render(state) - } - viewModel.onToast = { [weak self] message in - self?.showToast(message) - } - render(viewModel.state) - } - - private func render(_ state: WithdrawViewModel.State) { - for (reason, button) in reasonButtons { - let isSelected = reason == state.selected - let tint = isSelected ? DSColor.Accent.default : DSColor.Icon.muted - button.configuration?.image = UIImage(systemName: isSelected ? "largecircle.fill.circle" : "circle")? - .withTintColor(tint, renderingMode: .alwaysOriginal) - button.configuration?.baseForegroundColor = DSColor.Text.primary - button.accessibilityTraits = isSelected ? [.button, .selected] : .button - } - if otherField.isHidden == state.isOtherInputVisible { - otherField.isHidden = !state.isOtherInputVisible - } - submitButton.isEnabled = state.canSubmit - } -} diff --git a/Projects/Feature/Settings/Sources/WithdrawViewModel.swift b/Projects/Feature/Settings/Sources/WithdrawViewModel.swift deleted file mode 100644 index 20545933..00000000 --- a/Projects/Feature/Settings/Sources/WithdrawViewModel.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Domain -import Foundation - -// Convention: every ViewModel in the codebase is @MainActor. -@MainActor -final class WithdrawViewModel { - /// 레거시 WithdrawOption 실측 순서·문구 그대로 — 서버에는 문구 자체가 사유로 저장된다. - enum Reason: CaseIterable, Equatable { - case timeMismatch - case rarelyUse - case frequentErrors - case hardToFind - case hardToUse - case mapAppEnough - case other - - var title: String { - switch self { - case .timeMismatch: "막차 시간이 안맞아요" - case .rarelyUse: "막차를 자주 안 타요" - case .frequentErrors: "잦은 에러를 겪었어요" - case .hardToFind: "막차를 찾기가 번거로워요" - case .hardToUse: "앱 사용법을 모르겠어요" - case .mapAppEnough: "기존에 쓰던 지도 앱으로 충분해요" - case .other: "기타" - } - } - } - - struct State: Equatable { - var selected: Reason? - var isOtherInputVisible: Bool { selected == .other } - var canSubmit = false - var isSubmitting = false - } - - /// Set by the ViewController; always invoked on the main actor. - var onStateChange: ((State) -> Void)? - var onToast: ((String) -> Void)? - - private(set) var state = State() { - didSet { if state != oldValue { onStateChange?(state) } } - } - - private var otherText = "" - private let withdrawUseCase: any WithdrawUseCase - private var submitTask: Task? - - init(withdrawUseCase: any WithdrawUseCase) { - self.withdrawUseCase = withdrawUseCase - } - - deinit { - submitTask?.cancel() - } - - func select(_ reason: Reason) { - state.selected = reason - refreshCanSubmit() - } - - func otherTextDidChange(_ text: String) { - otherText = text - refreshCanSubmit() - } - - /// 확인 팝업을 통과한 뒤에만 호출된다. 성공하면 앱의 세션 만료 관찰이 로그인으로 보낸다. - func withdrawConfirmed() { - guard state.canSubmit, !state.isSubmitting, let reason = reasonText() else { return } - state.isSubmitting = true - refreshCanSubmit() - submitTask = Task { [weak self] in - guard let useCase = self?.withdrawUseCase else { return } - do { - try await useCase.execute(reason: reason) - } catch { - // 실패 시 세션이 보존된다(UseCase 계약) — 같은 화면에서 재시도할 수 있다. - guard !Task.isCancelled else { return } - self?.state.isSubmitting = false - self?.refreshCanSubmit() - self?.onToast?("탈퇴에 실패했어요. 다시 시도해 주세요") - } - } - } - - private func reasonText() -> String? { - guard let selected = state.selected else { return nil } - guard selected == .other else { return selected.title } - let trimmed = otherText.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private func refreshCanSubmit() { - state.canSubmit = !state.isSubmitting && reasonText() != nil - } -} diff --git a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift index 7afb7026..f3f9853c 100644 --- a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift +++ b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift @@ -27,27 +27,6 @@ final class StubGetUserProfileUseCase: GetUserProfileUseCase { } } -final class SpyLogoutUseCase: LogoutUseCase { - private let count = Mutex(0) - var callCount: Int { count.withLock { $0 } } - func execute() async { count.withLock { $0 += 1 } } -} - -final class SpyWithdrawUseCase: WithdrawUseCase { - private let log = Mutex<[String?]>([]) - private let failing = Mutex(false) - var reasons: [String?] { log.withLock { $0 } } - var fails: Bool { - get { failing.withLock { $0 } } - set { failing.withLock { $0 = newValue } } - } - - func execute(reason: String?) async throws { - log.withLock { $0.append(reason) } - if fails { throw StubFailure() } - } -} - struct StubCheckAppUpdateUseCase: CheckAppUpdateUseCase { let status: AppUpdateStatus func execute(currentVersion: String) async throws -> AppUpdateStatus { status } diff --git a/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift b/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift index 929b9583..beafc546 100644 --- a/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift +++ b/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift @@ -8,12 +8,10 @@ struct SettingsViewModelTests { private func makeSUT( address: String? = "서울 중구 세종대로 110", profileFails: Bool = false, - update: AppUpdateStatus = .upToDate, - logout: SpyLogoutUseCase = SpyLogoutUseCase() + update: AppUpdateStatus = .upToDate ) -> SettingsViewModel { SettingsViewModel( getUserProfileUseCase: StubGetUserProfileUseCase(address: address, fails: profileFails), - logoutUseCase: logout, checkAppUpdateUseCase: StubCheckAppUpdateUseCase(status: update), currentVersion: "2.0.0", appStoreURL: URL(string: "itms-apps://example") @@ -55,19 +53,6 @@ struct SettingsViewModelTests { #expect(routes == [.externalLink(URL(string: "itms-apps://example")!)]) } - /// 중복 탭이 로그아웃을 두 번 보내지 않는다. - @Test - func logoutConfirmed_twice_executesOnce() async { - let logout = SpyLogoutUseCase() - let sut = makeSUT(logout: logout) - - sut.logoutConfirmed() - sut.logoutConfirmed() - await waitUntil { logout.callCount == 1 } - - #expect(logout.callCount == 1) - } - @Test func homeAddressDidChange_toastsAndReloads() { let sut = makeSUT() diff --git a/Projects/Feature/Settings/Tests/WithdrawViewModelTests.swift b/Projects/Feature/Settings/Tests/WithdrawViewModelTests.swift deleted file mode 100644 index ca9a6a10..00000000 --- a/Projects/Feature/Settings/Tests/WithdrawViewModelTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -@testable import SettingsFeature -import Testing - -@MainActor -struct WithdrawViewModelTests { - @Test - func noSelection_cannotSubmit() { - let sut = WithdrawViewModel(withdrawUseCase: SpyWithdrawUseCase()) - #expect(sut.state.canSubmit == false) - } - - @Test - func presetReason_sendsTitleAsReason() async { - let useCase = SpyWithdrawUseCase() - let sut = WithdrawViewModel(withdrawUseCase: useCase) - - sut.select(.rarelyUse) - sut.withdrawConfirmed() - await waitUntil { useCase.reasons.count == 1 } - - #expect(useCase.reasons == ["막차를 자주 안 타요"]) - } - - /// 기타는 공백만 입력하면 제출할 수 없다(레거시 규칙). - @Test - func otherReason_requiresNonBlankText() async { - let useCase = SpyWithdrawUseCase() - let sut = WithdrawViewModel(withdrawUseCase: useCase) - - sut.select(.other) - sut.otherTextDidChange(" ") - #expect(sut.state.canSubmit == false) - - sut.otherTextDidChange(" 알림이 늦어요 ") - #expect(sut.state.canSubmit) - sut.withdrawConfirmed() - await waitUntil { useCase.reasons.count == 1 } - - #expect(useCase.reasons == ["알림이 늦어요"]) - } - - @Test - func failure_toastsAndAllowsRetry() async { - let useCase = SpyWithdrawUseCase() - useCase.fails = true - let sut = WithdrawViewModel(withdrawUseCase: useCase) - var toasts: [String] = [] - sut.onToast = { toasts.append($0) } - - sut.select(.timeMismatch) - sut.withdrawConfirmed() - await waitUntil { !toasts.isEmpty } - - #expect(toasts == ["탈퇴에 실패했어요. 다시 시도해 주세요"]) - #expect(sut.state.canSubmit) - #expect(sut.state.isSubmitting == false) - } -} diff --git a/Workspace.swift b/Workspace.swift index a8cfd843..15b97644 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -4,7 +4,6 @@ let workspace = Workspace( name: "Atcha", projects: [ "Projects/App", - "Projects/Feature/Auth", "Projects/Feature/Home", "Projects/Feature/Search", "Projects/Feature/Settings", diff --git a/docs/planning/atcha-v2-post12-roadmap.md b/docs/planning/atcha-v2-post12-roadmap.md index 9a42ad06..b7e4edbe 100644 --- a/docs/planning/atcha-v2-post12-roadmap.md +++ b/docs/planning/atcha-v2-post12-roadmap.md @@ -39,7 +39,7 @@ Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완 ### 서버에 전달할 질문 리스트 -1. 익명 세션 **발급** 엔드포인트 스펙 (경로·요청·응답 — 레거시엔 소셜 로그인뿐, `/auth/reissue`만 실측됨) +1. **(2026-09 재개 — 게스트 전용 MVP)** 게스트 토큰 발급 엔드포인트. 클라 제안: `POST /auth/guest`, body `{deviceId, fcmToken?}`, 응답은 로그인과 같은 envelope `{id, accessToken, refreshToken}`, 무토큰 호출. **같은 deviceId면 같은 게스트 회원 반환(멱등)** 필수 — 재설치·refresh 만료 후 알람·집 주소 유지의 전제. 게스트 회원이 `/routes/user-routes`·`/members/me`·home-address PATCH를 그대로 쓸 수 있는지도 확인 2. FCM 토큰을 익명 체계에서 어떻게 등록하나 (엔드포인트·토큰 로테이션 처리) 3. `GET /routes/last-routes`가 "오늘 막차 종료"와 "경로 없음"을 각각 어떤 responseCode로 주나 (실측값) 4. 등록된 알람이 없을 때 `GET /routes/user-routes/refresh`가 무엇을 반환하나 (에러 코드? 빈 성공?) @@ -62,7 +62,7 @@ Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완 | # | 항목 | 상태 (2026-08-23) | |---|---|---| | 1 | 실서버 base URL | **부분 해결** — dev/live 실주소 반영(`AppEnvironment.swift`, 레거시 trust-evaluator에서 복원·사용자 승인). Stage는 dev 호스트 공유 — 전용 호스트만 미정 | -| 2 | 익명 인증 발급 엔드포인트 | **대체 해결 (2026-09)** — 소셜 로그인(카카오·애플)이 최초 토큰 소스. 익명 발급 스펙 질문은 철회 | +| 2 | 익명 인증 발급 엔드포인트 | **재개 (2026-09-24)** — 소셜 로그인 제거, 게스트 전용 MVP로 전환. 클라는 제안 계약 `POST /auth/guest`로 구현 완료, 서버 구현·합의 대기(질문 #1) | | 3 | responseCode 실측 | **미해결 (S3)** — `.noRoute` 도달 불가 상태 | | 4 | 단일 알람 규약 | 미해결 — 클라 refresh→cancel→register 우회 동작 중 | | 5 | FCM 토큰 전달 | **부분 (2026-09)** — 로그인/가입 파라미터 전달 + 갱신 전달 UseCase·호출 지점 배선 완료, 서버 API 미확정이라 no-op 저장소(`UnconfirmedPushTokenRepository`) 주입 중. 질문 #2 |