diff --git a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift index 75f79d46..8c1de325 100644 --- a/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift +++ b/Atcha-iOS/Core/Manager/Discord/DiscordWebhookManager.swift @@ -11,8 +11,10 @@ final class DiscordWebhookManager { static let shared = DiscordWebhookManager() private init() {} - private let webhookURLString = "https://discord.com/api/webhooks/1483870710018474066/qyzNBI1Bwr7J5tQDrPx2-mOcej_9yLSOk5Bmlmza2D-4nSWqvWgcMd4CZDziG4vkpKrm" + private let errorWebhookURLString = "https://discord.com/api/webhooks/1483870710018474066/qyzNBI1Bwr7J5tQDrPx2-mOcej_9yLSOk5Bmlmza2D-4nSWqvWgcMd4CZDziG4vkpKrm" + private let authWebhookURLString = "https://discord.com/api/webhooks/1488745616485126185/AXfHS732U9-Oo3iMgicAitZh-oNnjE8EAUVapWxg38tmyCpjuHd8R3BaxbcSEr82Y_qu" + // MARK: - 오류 로그 func sendErrorLog( baseURL: String, statusCode: Int, @@ -24,14 +26,10 @@ final class DiscordWebhookManager { requestBody: [String: Any]? = nil, requestParameters: [String: Any]? = nil ) { - guard let url = URL(string: webhookURLString) else { return } + guard let url = URL(string: errorWebhookURLString) else { return } - // Authorization 토큰 앞 30자만 노출 - let headersText = requestHeaders.map { key, value in - return "\(key): \(value)" - }.joined(separator: "\n") + let headersText = requestHeaders.map { "\($0.key): \($0.value)" }.joined(separator: "\n") - // body JSON 변환 let bodyText: String if let body = requestBody, let data = try? JSONSerialization.data(withJSONObject: body, options: .prettyPrinted), @@ -54,22 +52,58 @@ final class DiscordWebhookManager { "content": "🚨 [Atcha-iOS] API 에러 발생!", "embeds": [[ "title": "서버 에러 상세 보고", - "color": 16711680, + "color": 16711680, // 빨강 "fields": [ - ["name": "Base URL", "value": "`\(baseURL)`", "inline": false], - ["name": "Method & Path", "value": "`\(method) \(path)`", "inline": false], - ["name": "HTTP Status", "value": "\(statusCode)", "inline": true], - ["name": "responseCode", "value": responseCode, "inline": true], - ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true], - ["name": "Error Message", "value": message, "inline": false], - ["name": "Request Headers", "value": "```\n\(headersText)\n```", "inline": false], - ["name": "Request Parameters", "value": paramsText, "inline": false], - ["name": "Request Body", "value": bodyText, "inline": false] + ["name": "Base URL", "value": "`\(baseURL)`", "inline": false], + ["name": "Method & Path", "value": "`\(method) \(path)`", "inline": false], + ["name": "HTTP Status", "value": "\(statusCode)", "inline": true], + ["name": "responseCode", "value": responseCode, "inline": true], + ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true], + ["name": "Error Message", "value": message, "inline": false], + ["name": "Request Headers", "value": "```\n\(headersText)\n```", "inline": false], + ["name": "Request Parameters", "value": paramsText, "inline": false], + ["name": "Request Body", "value": bodyText, "inline": false] ], "footer": ["text": "발생 시각: \(Date().kstString)"] ]] ] + sendToWebhook(url: url, payload: payload) + } + + // MARK: - 로그인/탈퇴 로그 + func sendAuthLog(event: AuthEvent, userID: String, provider: String? = nil, reason: String? = nil) { + guard let url = URL(string: authWebhookURLString) else { return } + + var fields: [[String: Any]] = [ + ["name": "이벤트", "value": event.title, "inline": true], + ["name": "유저 ID", "value": "`\(userID)`", "inline": true], + ["name": "App Version", "value": AppInfoProvider.currentVersion, "inline": true] + ] + + if let provider { + fields.append(["name": "로그인 방식", "value": provider, "inline": true]) + } + + if let reason { + fields.append(["name": "탈퇴 사유", "value": reason, "inline": false]) + } + + let payload: [String: Any] = [ + "content": event.headerMessage, + "embeds": [[ + "title": event.embedTitle, + "color": event.color, + "fields": fields, + "footer": ["text": "발생 시각: \(Date().kstString)"] + ]] + ] + + sendToWebhook(url: url, payload: payload) + } + + // MARK: - 공통 전송 + private func sendToWebhook(url: URL, payload: [String: Any]) { var request = URLRequest(url: url) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") @@ -79,6 +113,51 @@ final class DiscordWebhookManager { } } +// MARK: - Auth Event 타입 +enum AuthEvent { + case login + case signup + case logout + case withdraw + + var title: String { + switch self { + case .login: return "로그인" + case .signup: return "회원가입" + case .logout: return "로그아웃" + case .withdraw: return "회원탈퇴" + } + } + + var embedTitle: String { + switch self { + case .login: return "로그인 이벤트" + case .signup: return "회원가입 이벤트" + case .logout: return "로그아웃 이벤트" + case .withdraw: return "회원탈퇴 이벤트" + } + } + + var headerMessage: String { + switch self { + case .login: return "✅ [Atcha-iOS] 로그인" + case .signup: return "🎉 [Atcha-iOS] 회원가입" + case .logout: return "👋 [Atcha-iOS] 로그아웃" + case .withdraw: return "❌ [Atcha-iOS] 회원탈퇴" + } + } + + var color: Int { + switch self { + case .login: return 3066993 // 초록 + case .signup: return 5814783 // 파랑 + case .logout: return 16776960 // 노랑 + case .withdraw: return 10038562 // 보라 + } + } +} + +// MARK: - Date Extension private extension Date { var kstString: String { let formatter = DateFormatter() diff --git a/Atcha-iOS/Presentation/Location/MainViewController.swift b/Atcha-iOS/Presentation/Location/MainViewController.swift index c5a7d096..d59b7c80 100644 --- a/Atcha-iOS/Presentation/Location/MainViewController.swift +++ b/Atcha-iOS/Presentation/Location/MainViewController.swift @@ -969,6 +969,12 @@ extension MainViewController { safeStartJump() if viewModel.isGuest { + if latestIsServiceRegion == false { + presentLoginAlert() + return + } + + // 서비스 지역 내라면 기존의 2-tap 로직(문구 노출 후 로그인) 유지 handleGuestBallonTap() return } @@ -1041,7 +1047,7 @@ extension MainViewController { ballonView.isHidden = false ballonView.alpha = 1 - ballonView.setupTitle(topMessage: nil, bottomMessage: "궁금하면 로그인 해봐요!") + ballonView.setupTitle(topMessage: nil, bottomMessage: "택시비가 궁금하면 로그인해봐요!") ballonView.animateStaggered(secondaryDelay: 0, fade: 0.25) } else { @@ -1276,8 +1282,8 @@ extension MainViewController { guard !isShowingToast else { return } if viewModel.isGuest && guestTapCount == 1 { - return - } + return + } // [수정] 우리가 정의한 로그인 기반 가이드 로직 적용 let showGuideLine = shouldShowMapGuide diff --git a/Atcha-iOS/Presentation/Login/LoginViewModel.swift b/Atcha-iOS/Presentation/Login/LoginViewModel.swift index d26b1a94..39ab529a 100644 --- a/Atcha-iOS/Presentation/Login/LoginViewModel.swift +++ b/Atcha-iOS/Presentation/Login/LoginViewModel.swift @@ -74,6 +74,12 @@ extension LoginViewModel { AmplitudeManager.shared.bindUser(id: String(id)) AmplitudeManager.shared.flush() + + DiscordWebhookManager.shared.sendAuthLog( + event: .login, + userID: String(id), + provider: type == .kakao ? "카카오" : "애플" + ) } UserDefaultsWrapper.shared.set(false, forKey: UserDefaultsWrapper.Key.isGuest.rawValue) diff --git a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift index b3be003b..5ef7f4c7 100644 --- a/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift +++ b/Atcha-iOS/Presentation/User/Home/HomeFindViewModel.swift @@ -274,6 +274,12 @@ extension HomeFindViewModel { AmplitudeManager.shared.track( .signup ) + + DiscordWebhookManager.shared.sendAuthLog( + event: .signup, + userID: String(id), + provider: provider == 0 ? "카카오" : "애플" + ) } else { print("회원가입 응답에 lat/lon 없음") } diff --git a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift index 212cd1af..53a0956c 100644 --- a/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift +++ b/Atcha-iOS/Presentation/User/MyAccount/MyAccountViewModel.swift @@ -27,10 +27,17 @@ final class MyAccountViewModel: BaseViewModel { Task { do { let _ = try await logoutUseCase.excute() + + let userId = UserDefaultsWrapper.shared.integer(forKey: UserDefaultsWrapper.Key.userId.rawValue) ?? 0 + DiscordWebhookManager.shared.sendAuthLog( + event: .logout, + userID: String(userId) + ) + AmplitudeManager.shared.track(.logout) AmplitudeManager.shared.reset() - tokenStorage.clearAllTokens() + tokenStorage.clearAllTokens() UserDefaultsWrapper.shared.removeAll() locationStateHolder.clear() diff --git a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift index d91516f5..ca2f3816 100644 --- a/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift +++ b/Atcha-iOS/Presentation/User/Withdraw/WithdrawViewModel.swift @@ -28,6 +28,14 @@ final class WithdrawViewModel: BaseViewModel { let reason = request.reason? .trimmingCharacters(in: .whitespacesAndNewlines) + let userId = UserDefaultsWrapper.shared.integer(forKey: UserDefaultsWrapper.Key.userId.rawValue) ?? 0 + + DiscordWebhookManager.shared.sendAuthLog( + event: .withdraw, + userID: String(userId), + reason: reason + ) + AmplitudeManager.shared.track( .withdraw, [AmplitudePropertyKey.withdrawReason.rawValue: reason ?? "unknown"]