앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent cbfb72efe0
commit c4e9a9430e
165 changed files with 29991 additions and 54 deletions
+63 -6
View File
@@ -1,7 +1,9 @@
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 로 나가는 유일한 통로.
///
@@ -19,9 +21,11 @@ class Backend {
FirebaseFirestore? firestore,
FirebaseFunctions? functions,
FirebaseAuth? auth,
FirebaseStorage? storage,
}) : _firestore = firestore ?? FirebaseFirestore.instance,
_functions = functions,
_auth = auth ?? FirebaseAuth.instance;
_auth = auth ?? FirebaseAuth.instance,
_storage = storage;
static const String region = 'asia-northeast3';
@@ -35,15 +39,24 @@ class Backend {
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 const BackendException('E-AUTH-03', '로그인이 필요합니다.');
throw BackendException('E-AUTH-03', appL10n.authLoginRequired);
}
return value;
}
@@ -63,13 +76,34 @@ class Backend {
if (data is Map) return Map<String, dynamic>.from(data);
return const {};
} on FirebaseFunctionsException catch (error) {
throw BackendException(
_catalogCode(error),
error.message ?? '요청을 처리하지 못했습니다.',
);
final code = _catalogCode(error);
throw BackendException(code, _message(code, error));
}
}
/// 앱이 문구를 가진 카탈로그 코드는 표시 언어로 바꾼다. 나머지는 서버 사유 그대로.
static String _message(String code, FirebaseFunctionsException error) {
final details = error.details;
return switch (code) {
// 성운 창립자 탄생 전 탈퇴(Q-PN-03).
'E-FDM-05' => appL10n.fandomFounderLeaveBlocked,
// 검증 활동(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) {
@@ -97,6 +131,29 @@ class BackendException implements Exception {
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 문서에서 값을 꺼내는 도우미.
///
/// 서버 스키마가 앞서 나가거나 필드가 아직 채워지지 않은 문서가 섞일 수 있어,