From 16d1a87ecf324eaea97249df0b4f3d8085ea5f20 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Thu, 30 Oct 2025 21:28:10 +0900 Subject: [PATCH 1/2] anonymous edit --- today-s-sound/App/TodaySSoundApp.swift | 17 +++-- .../Features/OnBoarding/OnBoardingView.swift | 57 ++++++++++++++++ .../Services/AppState/SessionStore.swift | 66 +++++++++++++++++++ .../Services/Config/Enviroment.swift | 13 ++++ .../Services/Network/APIClient.swift | 61 +++++++++++++++++ .../Network/Endpoints/RegisterAnonymous.swift | 48 ++++++++++++++ .../Services/Security/Keychain.swift | 64 ++++++++++++++++++ .../{Core => Services}/SpeechService.swift | 0 8 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift create mode 100644 today-s-sound/Services/AppState/SessionStore.swift create mode 100644 today-s-sound/Services/Config/Enviroment.swift create mode 100644 today-s-sound/Services/Network/APIClient.swift create mode 100644 today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift create mode 100644 today-s-sound/Services/Security/Keychain.swift rename today-s-sound/{Core => Services}/SpeechService.swift (100%) diff --git a/today-s-sound/App/TodaySSoundApp.swift b/today-s-sound/App/TodaySSoundApp.swift index 127aaf5..bb5020b 100644 --- a/today-s-sound/App/TodaySSoundApp.swift +++ b/today-s-sound/App/TodaySSoundApp.swift @@ -9,9 +9,18 @@ import SwiftUI @main struct TodaySSoundApp: App { - var body: some Scene { - WindowGroup { - MainView() + @StateObject private var session = SessionStore() + + var body: some Scene { + WindowGroup { + Group { + if session.isRegistered { + MainView() + } else { + OnBoardingView() + } + } + .environmentObject(session) + } } - } } diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift new file mode 100644 index 0000000..6bcd6b6 --- /dev/null +++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift @@ -0,0 +1,57 @@ +// +// OnBoardingView.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import SwiftUI + +struct OnBoardingView: View { + @EnvironmentObject var session: SessionStore + @State private var isLoading = false + + var body: some View { + VStack(spacing: 20) { + Text("환영합니다 👋") + .font(.largeTitle).bold() + Text("이 기기를 익명 사용자로 등록하고 서비스를 시작합니다.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + + if isLoading { + ProgressView("등록 중…") + .padding(.top, 8) + } else { + Button { + Task { + isLoading = true + defer { isLoading = false } + await session.registerIfNeeded() + } + } label: { + Text("시작하기") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.top, 12) + } + + if let err = session.lastError { + Text(err) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.top, 8) + } + + // 디버그: 생성된 deviceSecret 미리보기(실서비스에서는 숨기기) + // if let s = Keychain.getString(for: KeychainKey.deviceSecret) { + // Text("secret: \(s)").font(.footnote).foregroundStyle(.secondary) + // } + } + .padding(24) + } +} diff --git a/today-s-sound/Services/AppState/SessionStore.swift b/today-s-sound/Services/AppState/SessionStore.swift new file mode 100644 index 0000000..ff312d6 --- /dev/null +++ b/today-s-sound/Services/AppState/SessionStore.swift @@ -0,0 +1,66 @@ +// +// SessionStore.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation +import SwiftUI + +@MainActor +final class SessionStore: ObservableObject { + @Published private(set) var userId: String? + @Published private(set) var isRegistered: Bool = false + @Published var lastError: String? + + private let api = APIClient() + + init() { + // 앱 시작 시, 키체인에 저장되어 있으면 로드 + if let savedId = Keychain.getString(for: KeychainKey.userId) { + self.userId = savedId + self.isRegistered = true + } else { + self.isRegistered = false + } + } + + /// 처음 실행 시 한 번 호출: deviceSecret 생성/보관 → 서버 등록 + func registerIfNeeded() async { + guard !isRegistered else { return } + + // 1) deviceSecret을 키체인에서 찾고, 없으면 생성하여 저장 + let secret: String = { + if let s = Keychain.getString(for: KeychainKey.deviceSecret) { return s } + let gen = DeviceSecret.generate() + Keychain.setString(gen, for: KeychainKey.deviceSecret) + return gen + }() + + do { + // 2) 서버 호출 + let body = RegisterAnonymousBody(deviceSecret: secret) + typealias Resp = SuccessEnvelope + let envelope: Resp = try await api.postJSON(path: "/api/users/anonymous", body: body) + + // 3) user_id 보관 + Keychain.setString(envelope.result.user_id, for: KeychainKey.userId) + self.userId = envelope.result.user_id + self.isRegistered = true + + // (옵션) 서버가 api_key 같은 걸 준다면 저장 + // if let key = envelope.result.api_key { Keychain.setString(key, for: KeychainKey.apiKey) } + + } catch let APIError.http(_, data) { + // 스웨거 에러 포맷 시도 디코딩 + if let data, let err = try? JSONDecoder().decode(ErrorEnvelope.self, from: data) { + lastError = "[\(err.code)] \(err.message)" + } else { + lastError = "알 수 없는 서버 오류" + } + } catch { + lastError = error.localizedDescription + } + } +} diff --git a/today-s-sound/Services/Config/Enviroment.swift b/today-s-sound/Services/Config/Enviroment.swift new file mode 100644 index 0000000..95aa923 --- /dev/null +++ b/today-s-sound/Services/Config/Enviroment.swift @@ -0,0 +1,13 @@ +// +// Enviroment.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation + +enum AppConfig { + // 배포/개발 분기 필요하면 Scheme/xcconfig로 주입해도 됨 + static let baseURL = URL(string: "http://localhost:8080/")! // 예: https://api.example.com +} diff --git a/today-s-sound/Services/Network/APIClient.swift b/today-s-sound/Services/Network/APIClient.swift new file mode 100644 index 0000000..92e0afe --- /dev/null +++ b/today-s-sound/Services/Network/APIClient.swift @@ -0,0 +1,61 @@ +// +// APIClient.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation + +enum APIError: Error, LocalizedError { + case invalidURL + case http(status: Int, data: Data?) + case decoding(Error) + case underlying(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: return "잘못된 URL" + case .http(let s, _): return "서버 오류(\(s))" + case .decoding(let e): return "디코딩 오류: \(e.localizedDescription)" + case .underlying(let e): return e.localizedDescription + } + } +} + +final class APIClient { + private let baseURL: URL + private let session: URLSession + + init(baseURL: URL = AppConfig.baseURL) { + self.baseURL = baseURL + let cfg = URLSessionConfiguration.default + cfg.httpCookieStorage = HTTPCookieStorage.shared + cfg.httpShouldSetCookies = true + cfg.httpAdditionalHeaders = ["Accept": "application/json", + "Content-Type": "application/json"] + self.session = URLSession(configuration: cfg) + } + + func postJSON( + path: String, + body: Request + ) async throws -> Response { + guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.httpBody = try JSONEncoder().encode(body) + + let (data, resp) = try await session.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw APIError.underlying(URLError(.badServerResponse)) } + guard (200..<300).contains(http.statusCode) else { + throw APIError.http(status: http.statusCode, data: data) + } + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + // 성공 스펙이 래핑되어 있지 않거나 빈 바디일 수 있으니 필요시 분기 + throw APIError.decoding(error) + } + } +} diff --git a/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift new file mode 100644 index 0000000..e97130d --- /dev/null +++ b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift @@ -0,0 +1,48 @@ +// +// RegisterAnonymous.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation +import CryptoKit + +// 1) Request +struct RegisterAnonymousBody: Encodable { + let deviceSecret: String +} + +// 2) 성공 Response 래퍼 +struct SuccessEnvelope: Decodable { + let errorCode: String? + let message: String + let result: Result +} + +struct AnonymousResult: Decodable { + let user_id: String + // 서버가 추가로 키 같은 걸 준다면 여기에 옵셔널로: + // let api_key: String? +} + +// 3) 에러 Response +struct ErrorEnvelope: Decodable, Error { + let status: Int + let code: String + let message: String +} + +// 4) deviceSecret 생성 유틸 (32바이트 랜덤 → base64URL) +enum DeviceSecret { + static func generate() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + // URL-safe base64 + let data = Data(bytes) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/today-s-sound/Services/Security/Keychain.swift b/today-s-sound/Services/Security/Keychain.swift new file mode 100644 index 0000000..9be506a --- /dev/null +++ b/today-s-sound/Services/Security/Keychain.swift @@ -0,0 +1,64 @@ +// +// Keychain.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation +import Security + +enum KeychainKey { + static let deviceSecret = "device_secret" + static let userId = "user_id" + static let apiKey = "api_key" // 서버가 추가 키를 준다면 여기에 저장 (옵셔널) +} + +enum Keychain { + @discardableResult + static func set(_ value: Data, for key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: value, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock // 앱 재부팅 후에도 접근 + ] + SecItemDelete(query as CFDictionary) // 기존 값 제거 + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + static func get(for key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { return nil } + return (item as? Data) + } + + @discardableResult + static func delete(for key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + // 문자열 편의 + @discardableResult + static func setString(_ value: String, for key: String) -> Bool { + set(Data(value.utf8), for: key) + } + + static func getString(for key: String) -> String? { + guard let data = get(for: key) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/today-s-sound/Core/SpeechService.swift b/today-s-sound/Services/SpeechService.swift similarity index 100% rename from today-s-sound/Core/SpeechService.swift rename to today-s-sound/Services/SpeechService.swift From b3c14255e110411372345d8b749f8e6e453e4589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=84?= <102128060+wlgusqkr@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:13:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=9D=B5=EB=AA=85=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- today-s-sound/Services/AppState/SessionStore.swift | 4 ++-- today-s-sound/Services/Config/Enviroment.swift | 2 +- today-s-sound/Services/Network/APIClient.swift | 3 ++- .../Services/Network/Endpoints/RegisterAnonymous.swift | 6 +++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/today-s-sound/Services/AppState/SessionStore.swift b/today-s-sound/Services/AppState/SessionStore.swift index ff312d6..7ffe585 100644 --- a/today-s-sound/Services/AppState/SessionStore.swift +++ b/today-s-sound/Services/AppState/SessionStore.swift @@ -45,8 +45,8 @@ final class SessionStore: ObservableObject { let envelope: Resp = try await api.postJSON(path: "/api/users/anonymous", body: body) // 3) user_id 보관 - Keychain.setString(envelope.result.user_id, for: KeychainKey.userId) - self.userId = envelope.result.user_id + Keychain.setString(envelope.result.userId, for: KeychainKey.userId) + self.userId = envelope.result.userId self.isRegistered = true // (옵션) 서버가 api_key 같은 걸 준다면 저장 diff --git a/today-s-sound/Services/Config/Enviroment.swift b/today-s-sound/Services/Config/Enviroment.swift index 95aa923..2bd4160 100644 --- a/today-s-sound/Services/Config/Enviroment.swift +++ b/today-s-sound/Services/Config/Enviroment.swift @@ -9,5 +9,5 @@ import Foundation enum AppConfig { // 배포/개발 분기 필요하면 Scheme/xcconfig로 주입해도 됨 - static let baseURL = URL(string: "http://localhost:8080/")! // 예: https://api.example.com + static let baseURL = URL(string: "http://localhost:8080")! // 예: https://api.example.com } diff --git a/today-s-sound/Services/Network/APIClient.swift b/today-s-sound/Services/Network/APIClient.swift index 92e0afe..ae8e41f 100644 --- a/today-s-sound/Services/Network/APIClient.swift +++ b/today-s-sound/Services/Network/APIClient.swift @@ -46,7 +46,8 @@ final class APIClient { req.httpMethod = "POST" req.httpBody = try JSONEncoder().encode(body) - let (data, resp) = try await session.data(for: req) + let (data, resp) = try await session.data(for: req) // 요청 보내는 코드 + print("서버 raw 응답:", String(data: data, encoding: .utf8) ?? "") guard let http = resp as? HTTPURLResponse else { throw APIError.underlying(URLError(.badServerResponse)) } guard (200..<300).contains(http.statusCode) else { throw APIError.http(status: http.statusCode, data: data) diff --git a/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift index e97130d..bae95da 100644 --- a/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift +++ b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift @@ -14,14 +14,14 @@ struct RegisterAnonymousBody: Encodable { } // 2) 성공 Response 래퍼 -struct SuccessEnvelope: Decodable { +struct SuccessEnvelope: Decodable { let errorCode: String? let message: String - let result: Result + let result: AnonymousResult } struct AnonymousResult: Decodable { - let user_id: String + let userId: String // 서버가 추가로 키 같은 걸 준다면 여기에 옵셔널로: // let api_key: String? }