193 lines
7.5 KiB
Dart
193 lines
7.5 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:cloud_functions/cloud_functions.dart';
|
|
import 'package:firebase_auth/firebase_auth.dart';
|
|
import 'package:firebase_storage/firebase_storage.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../i18n/app_locale.dart';
|
|
|
|
/// 서버 경계 — 앱에서 Firestore·Cloud Functions 로 나가는 유일한 통로.
|
|
///
|
|
/// **읽기와 쓰기를 다르게 다룬다.**
|
|
/// - 읽기는 Firestore 를 직접 구독한다. 목록·상세는 실시간이어야 하고, 함수를
|
|
/// 한 번 더 거치면 지연과 비용만 는다.
|
|
/// - **재화가 오가는 쓰기는 전부 Cloud Functions 를 지난다.** 보안 규칙이
|
|
/// `popTransactions`·`wallet` 의 클라이언트 쓰기를 전면 차단해 두었으므로
|
|
/// (별첨 1 ② 「클라이언트 단독 지급 0건」) 앱에서 직접 쓰는 경로 자체가 없다.
|
|
///
|
|
/// 리전을 서울로 고정한 것은 Functions 배포 리전과 맞추기 위함이다. 어긋나면
|
|
/// 호출이 `not-found` 로 떨어지는데, 원인이 함수 부재처럼 보여 찾기 어렵다.
|
|
class Backend {
|
|
Backend({
|
|
FirebaseFirestore? firestore,
|
|
FirebaseFunctions? functions,
|
|
FirebaseAuth? auth,
|
|
FirebaseStorage? storage,
|
|
}) : _firestore = firestore ?? FirebaseFirestore.instance,
|
|
_functions = functions,
|
|
_auth = auth ?? FirebaseAuth.instance,
|
|
_storage = storage;
|
|
|
|
static const String region = 'asia-northeast3';
|
|
|
|
final FirebaseFirestore _firestore;
|
|
final FirebaseAuth _auth;
|
|
|
|
/// 실제로 함수를 부르기 전까지 만들지 않는다. 생성자에서 만들면 Firestore 만
|
|
/// 쓰는 화면·테스트도 Firebase 초기화를 강제당한다.
|
|
FirebaseFunctions? _functions;
|
|
|
|
FirebaseFunctions get functions =>
|
|
_functions ??= FirebaseFunctions.instanceFor(region: region);
|
|
|
|
/// 사용자가 올리는 파일(프로필 사진 등)이 나가는 곳. Functions 와 같은 이유로
|
|
/// 실제로 쓰기 전까지 만들지 않는다.
|
|
FirebaseStorage? _storage;
|
|
|
|
FirebaseStorage get storage => _storage ??= FirebaseStorage.instance;
|
|
|
|
FirebaseFirestore get db => _firestore;
|
|
|
|
String? get uid => _auth.currentUser?.uid;
|
|
|
|
/// 로그인 계정 이메일. 닉네임이 없을 때 화면이 부르는 이름의 재료다.
|
|
String? get email => _auth.currentUser?.email;
|
|
|
|
/// 로그인 필수 경로에서 UID 를 강제로 얻는다.
|
|
String get requireUid {
|
|
final value = uid;
|
|
if (value == null) {
|
|
throw BackendException('E-AUTH-03', appL10n.authLoginRequired);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/// Callable 호출 한 번. 서버가 실은 카탈로그 코드를 그대로 꺼내 던진다.
|
|
///
|
|
/// `FirebaseFunctionsException.details` 에 `{code: 'E-POP-01'}` 가 실려 오는데,
|
|
/// 화면은 gRPC 상태가 아니라 **이 코드**로 분기해야 한다. 상태는 여러 사유가
|
|
/// 겹쳐 들어오지만 카탈로그 코드는 사유와 1:1 이다.
|
|
Future<Map<String, dynamic>> call(
|
|
String name, [
|
|
Map<String, dynamic> payload = const {},
|
|
]) async {
|
|
try {
|
|
final result = await functions.httpsCallable(name).call<Object?>(payload);
|
|
final data = result.data;
|
|
if (data is Map) return Map<String, dynamic>.from(data);
|
|
return const {};
|
|
} on FirebaseFunctionsException catch (error) {
|
|
final code = _catalogCode(error);
|
|
throw BackendException(code, _message(code, error));
|
|
}
|
|
}
|
|
|
|
/// 앱이 문구를 가진 카탈로그 코드는 표시 언어로 바꾼다. 나머지는 서버 사유 그대로.
|
|
static String _message(String code, FirebaseFunctionsException error) {
|
|
final details = error.details;
|
|
return switch (code) {
|
|
// 검증 활동(Quest 완료) 미달 — 서버가 `done` · `need` 를 싣는다(Campaign 개설자격 A안).
|
|
'E-CMP-04'
|
|
when details is Map &&
|
|
details['done'] is int &&
|
|
details['need'] is int =>
|
|
appL10n.campaignNeedsQuests(
|
|
'${details['done']}',
|
|
'${details['need']}',
|
|
'${(details['need'] as int) - (details['done'] as int)}',
|
|
),
|
|
// 보상 없는 사용자 생성 Quest 하루 상한 — 서버가 `cap` 을 싣는다(Q-UQ-02 보완).
|
|
'E-QST-04' when details is Map && details['cap'] is int =>
|
|
appL10n.questFreeDailyCap('${details['cap']}'),
|
|
_ => error.message ?? appL10n.commonRequestFailed,
|
|
};
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
/// 다른 사용자에게도 보이는 이름 — 정책 원장 B-52 · G-07
|
|
///
|
|
/// 닉네임이 비었거나 **이메일 형태(`@` 포함)**면 [fallback] 을 쓴다. 예전에는 닉네임이
|
|
/// 없을 때 이메일 앞부분을 이름으로 썼고, 그 값이 닉네임으로 저장돼 홈 기여자명에
|
|
/// 노출됐다. UID 도 이름 자리에 넣지 않는다.
|
|
///
|
|
/// 서버 · 규칙은 이름 없는 작성자를 「팬」([storedFanName])으로 저장한다 — 읽을 때는 그 값도
|
|
/// 지금 언어의 「팬」으로 바꿔 보여 준다.
|
|
String publicName(Object? raw, [String? fallback]) {
|
|
final v = raw is String ? raw.trim() : '';
|
|
return v.isEmpty || v.contains('@') || v == storedFanName
|
|
? (fallback ?? appL10n.commonFan)
|
|
: v;
|
|
}
|
|
|
|
/// 문서에 싣는 이름 없는 작성자 표기. 규칙 `publicNameOf` · 서버 `publicNameOf` 와 같은 값이라
|
|
/// 언어와 무관하게 고정이다(번역된 「Fan」을 실으면 규칙이 사칭으로 거절한다).
|
|
const String storedFanName = '팬';
|
|
|
|
/// 내 이름을 문서에 실을 때 — [publicName] 과 같되 대체값은 언어 무관 [storedFanName].
|
|
String storedPublicName(Object? nickname) =>
|
|
publicName(nickname, storedFanName);
|
|
|
|
/// Firestore 문서에서 값을 꺼내는 도우미.
|
|
///
|
|
/// 서버 스키마가 앞서 나가거나 필드가 아직 채워지지 않은 문서가 섞일 수 있어,
|
|
/// 없는 필드를 기본값으로 받는다. 여기서 던지면 화면 전체가 빈 상태가 된다.
|
|
extension SnapshotReader on Map<String, dynamic> {
|
|
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<Map<String, dynamic>> mapsOr(String key) {
|
|
final value = this[key];
|
|
if (value is! List) return const [];
|
|
return value
|
|
.whereType<Map>()
|
|
.map(Map<String, dynamic>.from)
|
|
.toList(growable: false);
|
|
}
|
|
}
|