import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_functions/cloud_functions.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; /// 서버 경계 — 앱에서 Firestore·Cloud Functions 로 나가는 유일한 통로. /// /// **읽기와 쓰기를 다르게 다룬다.** /// - 읽기는 Firestore 를 직접 구독한다. 목록·상세는 실시간이어야 하고, 함수를 /// 한 번 더 거치면 지연과 비용만 는다. /// - **재화가 오가는 쓰기는 전부 Cloud Functions 를 지난다.** 보안 규칙이 /// `popTransactions`·`wallet` 의 클라이언트 쓰기를 전면 차단해 두었으므로 /// (별첨 1 ② 「클라이언트 단독 지급 0건」) 앱에서 직접 쓰는 경로 자체가 없다. /// /// 리전을 서울로 고정한 것은 Functions 배포 리전과 맞추기 위함이다. 어긋나면 /// 호출이 `not-found` 로 떨어지는데, 원인이 함수 부재처럼 보여 찾기 어렵다. class Backend { Backend({ FirebaseFirestore? firestore, FirebaseFunctions? functions, FirebaseAuth? auth, }) : _firestore = firestore ?? FirebaseFirestore.instance, _functions = functions, _auth = auth ?? FirebaseAuth.instance; static const String region = 'asia-northeast3'; final FirebaseFirestore _firestore; final FirebaseAuth _auth; /// 실제로 함수를 부르기 전까지 만들지 않는다. 생성자에서 만들면 Firestore 만 /// 쓰는 화면·테스트도 Firebase 초기화를 강제당한다. FirebaseFunctions? _functions; FirebaseFunctions get functions => _functions ??= FirebaseFunctions.instanceFor(region: region); FirebaseFirestore get db => _firestore; String? get uid => _auth.currentUser?.uid; /// 로그인 필수 경로에서 UID 를 강제로 얻는다. String get requireUid { final value = uid; if (value == null) { throw const BackendException('E-AUTH-03', '로그인이 필요합니다.'); } return value; } /// Callable 호출 한 번. 서버가 실은 카탈로그 코드를 그대로 꺼내 던진다. /// /// `FirebaseFunctionsException.details` 에 `{code: 'E-POP-01'}` 가 실려 오는데, /// 화면은 gRPC 상태가 아니라 **이 코드**로 분기해야 한다. 상태는 여러 사유가 /// 겹쳐 들어오지만 카탈로그 코드는 사유와 1:1 이다. Future> call( String name, [ Map payload = const {}, ]) async { try { final result = await functions.httpsCallable(name).call(payload); final data = result.data; if (data is Map) return Map.from(data); return const {}; } on FirebaseFunctionsException catch (error) { throw BackendException( _catalogCode(error), error.message ?? '요청을 처리하지 못했습니다.', ); } } static String _catalogCode(FirebaseFunctionsException error) { final details = error.details; if (details is Map && details['code'] is String) { return details['code'] as String; } // 서버가 카탈로그 코드를 싣지 못한 경우. 화면이 최소한 분기는 할 수 있게 // 상태를 옮겨 준다. return switch (error.code) { 'unauthenticated' => 'E-AUTH-03', 'permission-denied' => 'E-PERM-01', _ => 'E-SYS-01', }; } } /// 화면이 잡는 실패. [code] 는 에러 카탈로그 ID 다. @immutable class BackendException implements Exception { const BackendException(this.code, this.message); final String code; final String message; @override String toString() => 'BackendException($code): $message'; } /// Firestore 문서에서 값을 꺼내는 도우미. /// /// 서버 스키마가 앞서 나가거나 필드가 아직 채워지지 않은 문서가 섞일 수 있어, /// 없는 필드를 기본값으로 받는다. 여기서 던지면 화면 전체가 빈 상태가 된다. extension SnapshotReader on Map { int intOr(String key, [int fallback = 0]) { final value = this[key]; if (value is int) return value; if (value is num) return value.round(); return fallback; } String strOr(String key, [String fallback = '']) { final value = this[key]; return value is String && value.isNotEmpty ? value : fallback; } bool boolOr(String key, [bool fallback = false]) { final value = this[key]; return value is bool ? value : fallback; } DateTime dateOr(String key, DateTime fallback) { final value = this[key]; if (value is Timestamp) return value.toDate(); if (value is DateTime) return value; return fallback; } List> mapsOr(String key) { final value = this[key]; if (value is! List) return const []; return value .whereType() .map(Map.from) .toList(growable: false); } }