Files
fanuzu/app/lib/features/my/my_repository_firestore.dart
T

487 lines
20 KiB
Dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import '../../core/data/backend.dart';
import '../home/home_repository_firestore.dart' show kstDayKey;
import 'my_data.dart';
import '../../core/i18n/app_locale.dart';
/// MY 의 실 서버 구현 — 화면 대조 원장 F6.
///
/// 서버가 실제로 들고 있는 것만 덮어쓴다. 결제 내역·환불 요청도 이제 서버 문서
/// (`users/{uid}/purchases`)를 읽고 쓴다.
/// 그래서 상속으로 붙이고 필요한 것만 덮는다 — 인터페이스를 빈 껍데기로 다시
/// 쓰면 어느 것이 진짜인지 화면에서 알 수 없다.
///
/// 읽는 곳 — **서버가 실제로 쓰는 필드**를 읽는다.
/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps · completedQuests)
/// - 누적 기여 POP `users/{uid}.totalContributedPop`(`fn-pop-spend` 가 갱신, 설계서 §3.6)
/// - 레벨 `users/{uid}.{userExp, userLevel}` · Passport `users/{uid}.passportTier`
/// (C-18 · B-65). 필드가 없는 기존 사용자는 Lv.1 · SILVER 로 둔다.
/// - 잔액 `users/{uid}/wallet/main.activityBalance`
/// - 행성별 기여 `fandoms/{fandomId}` + `fandoms/{fandomId}/members/{uid}.contributedPop`
/// - POP 원장 `users/{uid}/popTransactions` (최신순 · 커서 `createdAt`)
/// - 공지 `notices`
/// - 차단 `users/{uid}/blocks/{targetUid}`
/// - 문의 `inquiries` (본인 것만)
/// - 결제 `users/{uid}/purchases` (`fn-iap-verify` · 환불 요청 한 전이만 본인 쓰기)
/// - 푸시 설정 `users/{uid}.notificationPrefs` (보안 규칙이 본인 쓰기를 허용한 필드)
///
/// **예전에는 `users.contributedPop` 과 `planets/{uid}.contributedPop` 을 읽었다.**
/// 서버가 쓰는 이름이 아니어서 기여가 쌓여도 화면이 0 에 머물렀다. 등급도 앱이
/// 문턱표로 다시 계산해 서버 값과 갈릴 수 있었다.
///
/// 쓰는 곳은 재화가 오가지 않는 다섯 갈래뿐이다 — 닉네임·사진 URL·푸시 설정(`users/{uid}`),
/// 차단(`users/{uid}/blocks`), 문의 접수(`inquiries`), 환불 요청(`users/{uid}/purchases`). POP 과 기여는 여기서
/// 건드리지 않는다(별첨 1 ② 「클라이언트 단독 지급 0건」).
class FirestoreMyRepository extends SampleMyRepository {
FirestoreMyRepository(this._backend);
final Backend _backend;
@override
Future<MyProfile> profile() async {
final uid = _backend.requireUid;
final db = _backend.db;
final userF = db.doc('users/$uid').get();
final walletF = db.doc('users/$uid/wallet/main').get();
final user = (await userF).data() ?? const <String, dynamic>{};
final wallet = (await walletF).data() ?? const <String, dynamic>{};
final fandomId = user.strOr('fandomId');
var tiers = const <PlanetTier>[];
if (fandomId.isNotEmpty) {
final fandomF = db.doc('fandoms/$fandomId').get();
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
final fandom = (await fandomF).data() ?? const <String, dynamic>{};
final member = (await memberF).data() ?? const <String, dynamic>{};
tiers = [
PlanetTier(
fandomName: fandom.strOr('name', fandomId),
// 팬덤별 누적 기여 POP. `fn-pop-spend` 가 기여 차감 때 함께 올린다.
contributedPop: member.intOr('contributedPop'),
isPrimary: true,
),
];
}
// 닉네임이 아직 없으면(온보딩 전) 중립 이름으로 부른다. 「— 님」은 고장처럼 보인다.
// **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로
// 닉네임으로 저장해 홈 기여자명으로 퍼졌다. 이메일은 설정 「계정 정보」
// (명세 `554:22634` *닉네임 · 가입 이메일 값*)에만 둔다.
final email = _backend.email ?? '';
final photoUrl = user.strOr('photoUrl');
return MyProfile(
nickname: publicName(user['nickname']),
email: email,
contributedPop: user.intOr('totalContributedPop'),
userExp: user.intOr('userExp'),
userLevel: user.intOr('userLevel').clamp(1, 1 << 31),
passportTier: PassportTier.parse(user.strOr('passportTier')),
balancePop: wallet.intOr('activityBalance'),
steps: user.intOr('steps'),
completedQuests: user.intOr('completedQuests'),
planetTiers: tiers,
photoUrl: photoUrl.isEmpty ? null : photoUrl,
originNo: user.containsKey('originNo') ? user.intOr('originNo') : null,
);
}
/// 푸시 알림 설정 — 정책 원장 B-77 5 개 구분.
///
/// `notificationPrefs` 를 쓴 이유는 보안 규칙이다. `firestore.rules` 의
/// `users/{uid}` 업데이트 화이트리스트에 이 이름이 이미 들어 있어, 다른 이름을
/// 쓰면 클라이언트 쓰기가 규칙 층에서 거부된다.
///
/// 문서에 없는 구분은 정책 기본값으로 채운다 — 구분이 추가될 때 기존 계정의
/// 문서를 일괄 수정하지 않아도 되고, Marketing 은 그 기본값이 OFF 다.
@override
Future<Map<PushTopic, bool>> pushSettings() async {
final uid = _backend.requireUid;
final snap = await _backend.db.doc('users/$uid').get();
final prefs = snap.data()?['notificationPrefs'];
final saved = prefs is Map ? prefs : const <dynamic, dynamic>{};
return {
for (final topic in PushTopic.values)
topic: saved[topic.name] is bool
? saved[topic.name] as bool
: topic.defaultOn,
};
}
@override
Future<void> setPush(PushTopic topic, bool on) async {
// 서비스 필수는 끌 수 없으므로 저장하지 않는다. 값이 남으면 다음에 읽을 때
// false 가 되어 화면과 정책이 어긋난다.
if (topic.locked) return;
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid').set({
'notificationPrefs': {topic.name: on},
'updatedAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
}
@override
Future<void> updateNickname(String nickname) async {
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid').set(
{'nickname': nickname},
SetOptions(merge: true),
);
}
/// POP 원장 — S-M-WALLET · 명세 `554:23732`.
///
/// 원장 한 행을 그대로 옮긴다. 합산·재계산을 하지 않는 것이 별첨 1 ②
/// 「POP 원장 – MY 통계 3자 교차 대조」를 앱 쪽에서 지키는 방법이다.
/// **금액에 부호가 실려 있다** — `spendPop` 이 `amount: -input.amount` 로 쓰고
/// 설계서 §3.4 대조식이 `balance == Σ amount` 이므로, 화면이 부호를 다시
/// 뒤집으면 차감이 지급으로 보인다.
///
/// 이어읽기는 시각 비교가 아니라 **커서 문서**로 한다. 한 트랜잭션이 일반·기여
/// 두 종류를 각각 쓰면 두 행의 서버 타임스탬프가 같아지는데, `createdAt` 만
/// 비교해 끊으면 그 경계에서 한 행이 사라진다. Firestore 는 정렬에 문서 이름
/// (`__name__`)을 암묵으로 덧붙이므로 `startAfterDocument` 는 동률까지 가른다.
/// 커서 문서를 한 번 더 읽는 값을 내지만, 원장 행이 조용히 빠지는 것보다 싸다.
///
/// 딱 하나 예외가 Zap 이다. 명세가 *Zap 횟수에 따라 리스트 생성이 아닌 당일 기준
/// 총기여 값 및 시간 update* 를 요구하므로 같은 날 붙어 있는 `ECLIPSE_DEFENSE`
/// 행을 한 줄로 접는다([_foldZap]).
@override
Future<List<PopLedgerEntry>> popHistory({
int limit = 20,
PopCursor? after,
}) async {
final uid = _backend.requireUid;
final collection = _backend.db.collection('users/$uid/popTransactions');
var query = collection.orderBy('createdAt', descending: true);
if (after != null) {
final anchor = await collection.doc(after.id).get();
// 원장은 append-only 이고 규칙이 삭제를 막아 두었으므로 없을 수가 없다.
// 그래도 없다면 그 자리를 찾을 방법이 없으니 끝으로 본다. 처음부터 다시
// 읽으면 이미 그린 줄이 목록에 두 번 붙는다.
if (!anchor.exists) return const [];
query = query.startAfterDocument(anchor);
}
final snapshot = await query.limit(limit).get();
final rows = <_LedgerRow>[];
for (final doc in snapshot.docs) {
final data = doc.data();
final reasonCode = data.strOr('reasonCode');
final label = popReasonLabel(reasonCode);
final at = data.dateOr('createdAt', _epoch);
rows.add(_LedgerRow(
reasonCode: reasonCode,
// Zap 한 행이 몇 번분인가. `fn-eclipse-defend` 가 `count` 를 묶어 받으므로
// 행 하나가 여러 번일 수 있다. 없는 문서는 한 번으로 본다.
count: data.intOr('count', 1).clamp(1, 1 << 30),
entry: PopLedgerEntry(
at: at,
title: label.title,
type: label.type,
kind: PopKind.parse(data.strOr('popType')),
txType: PopTxType.parse(data.strOr('type', 'grant')),
delta: data.intOr('amount'),
balanceAfter: data.intOr('balanceAfter'),
cursor: PopCursor(at: at, id: doc.id),
),
));
}
return _foldZap(rows);
}
/// 서비스 결제 내역 — `users/{uid}/purchases`(`fn-iap-verify` 가 쓴다) 최신순.
///
/// 명세 `554:23841` *결제 일시 · 결제 아이템명 · 결제 아이템 가격 · 최신순 소팅*.
/// 가격은 결제 때 스토어가 앱에 준 표시 가격이다(지급 판단에는 쓰지 않는 값).
@override
Future<List<PaymentEntry>> payments() async {
final uid = _backend.requireUid;
final snapshot = await _backend.db
.collection('users/$uid/purchases')
.orderBy('purchasedAt', descending: true)
.limit(100)
.get();
return [for (final doc in snapshot.docs) _payment(doc.id, doc.data())];
}
@override
Future<PaymentEntry> payment(String id) async {
final uid = _backend.requireUid;
final doc = await _backend.db.doc('users/$uid/purchases/$id').get();
final data = doc.data();
if (data == null) {
throw BackendException('E-PAY-02', appL10n.payNotFound);
}
return _payment(doc.id, data);
}
/// 환불 요청 — 명세 `554:24008` *결제 완료 → 환불 요청으로 update*.
///
/// 보안 규칙이 **지급 완료 → 환불 요청** 한 전이와 이 세 필드만 허용한다. 승인 · 반려 ·
/// 회수는 Admin 과 `fn-iap-refund-webhook` 이 한다. POP 은 여기서 건드리지 않는다.
@override
Future<void> requestRefund(String paymentId, String reason) async {
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid/purchases/$paymentId').update({
'state': 'refund-requested',
'refundReason': reason,
'refundRequestedAt': FieldValue.serverTimestamp(),
});
}
/// Focus 구독 — `users/{uid}/subscription/current`(`fn-iap-verify` · 구독 웹훅이 쓴다).
/// 만료가 지났거나 해지·환불로 혜택이 꺼졌으면 null.
@override
Future<FocusStatus?> focus() async {
final uid = _backend.requireUid;
final data =
(await _backend.db.doc('users/$uid/subscription/current').get()).data();
if (data == null) return null;
final expiresAt = data.dateOr('expiresAt', _epoch);
final state = data.strOr('state');
if (!expiresAt.isAfter(DateTime.now()) ||
(state != 'active' && state != 'canceled')) {
return null;
}
return FocusStatus(
expiresAt: expiresAt,
// 서버와 같은 일 경계(KST). `fn-focus-daily-claim` 이 받은 날을 여기 적는다.
claimedToday: data.strOr('focusClaimedDay') == kstDayKey(),
);
}
@override
Future<int> claimFocusDaily() async =>
(await _backend.call('fnFocusDailyClaim')).intOr('granted');
PaymentEntry _payment(String id, Map<String, dynamic> data) => PaymentEntry(
id: id,
at: data.dateOr('purchasedAt', _epoch),
itemName: data.strOr('itemName', data.strOr('productId', appL10n.payItemFallback)),
price: data.intOr('priceAmount'),
method: data.strOr('method', appL10n.payMethodInApp),
state: paymentStateOf(data.strOr('state')),
);
/// 공지 — S-M-NOTICE · Figma `357:224659` 「공지사항」.
///
/// 필드와 정렬은 `firestore.indexes.json` 의 `notices` 복합 색인이 정해 둔
/// `active` + `publishedAt DESC` 를 그대로 따른다. 다른 조합으로 읽으면 색인이
/// 없어 실 배포에서 `failed-precondition` 으로 떨어진다 — 에뮬레이터는 색인을
/// 강제하지 않으므로(`firebase/test/README.md` 「복합 인덱스 검증 한계 실측」)
/// 여기서 어긋나도 테스트로는 잡히지 않는다.
@override
Future<List<NoticeItem>> notices() async {
final snapshot = await _backend.db
.collection('notices')
.where('active', isEqualTo: true)
.orderBy('publishedAt', descending: true)
.limit(50)
.get();
return [
for (final doc in snapshot.docs)
NoticeItem(
id: doc.id,
title: doc.data().strOr('title', appL10n.noticeBadge),
body: doc.data().strOr('body'),
at: doc.data().dateOr('publishedAt', _epoch),
),
];
}
@override
Future<List<BlockedUser>> blockedUsers() async {
final uid = _backend.requireUid;
final snapshot = await _backend.db.collection('users/$uid/blocks').get();
final list = [
for (final doc in snapshot.docs)
BlockedUser(
uid: doc.id,
// UID 를 이름 자리에 내보내지 않는다(G-07).
nickname: publicName(doc.data()['nickname'], appL10n.unknownUser),
at: doc.data().dateOr('createdAt', _epoch),
),
];
// 최근에 차단한 계정이 위로 온다. 규칙이 차단 문서 목록에 정렬 제약을 두지
// 않으므로 색인 없이 여기서 세운다(목록이 사용자당 수십 건 규모다).
list.sort((a, b) => (b.at ?? _epoch).compareTo(a.at ?? _epoch));
return list;
}
/// 차단 추가. 재화가 아니라 개인 설정이므로 규칙이 본인 직접 쓰기를 허용한다
/// (`firestore.rules` — `users/{uid}/blocks/{targetUid}` 는 `isSelf(uid)`).
@override
Future<void> block(String targetUid, {String? nickname}) async {
final uid = _backend.requireUid;
if (targetUid == uid) {
throw BackendException('E-PERM-01', appL10n.blockSelf);
}
await _backend.db.doc('users/$uid/blocks/$targetUid').set({
if (nickname != null && nickname.isNotEmpty) 'nickname': nickname,
'createdAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
}
@override
Future<void> unblock(String targetUid) async {
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid/blocks/$targetUid').delete();
}
/// 1:1 문의 접수 — Figma `373:635052` 「새 문의 작성」.
///
/// `status` 를 `pending` 으로 고정해 보내는 것은 규칙이 그렇게만 생성을 허용하기
/// 때문이다(`allow create: if authorIsSelf() && status == 'pending'`).
/// 상태를 올리는 것은 운영이다.
@override
Future<void> submitInquiry(String title, String body) async {
final uid = _backend.requireUid;
await _backend.db.collection('inquiries').add({
'authorUid': uid,
'title': title,
'body': body,
'status': 'pending',
'createdAt': FieldValue.serverTimestamp(),
});
}
/// 문의 수정. 규칙이 접수 상태의 제목·본문만 허용하므로 그 두 개와 갱신 시각만
/// 보낸다. `status` 를 함께 보내면 규칙이 요청 전체를 거부한다.
@override
Future<void> updateInquiry(String id, String title, String body) async {
await _backend.db.doc('inquiries/$id').update({
'title': title,
'body': body,
'updatedAt': FieldValue.serverTimestamp(),
});
}
@override
Future<List<InquiryItem>> myInquiries() async {
final uid = _backend.requireUid;
// `authorUid` 조건이 규칙의 목록 허용 조건과 같아야 질의 자체가 통과한다.
final snapshot = await _backend.db
.collection('inquiries')
.where('authorUid', isEqualTo: uid)
.orderBy('createdAt', descending: true)
.limit(50)
.get();
return [
for (final doc in snapshot.docs)
InquiryItem(
id: doc.id,
title: doc.data().strOr('title', appL10n.inquiryFallback),
body: doc.data().strOr('body'),
at: doc.data().dateOr('createdAt', _epoch),
status: InquiryStatus.parse(doc.data().strOr('status')),
answerBody: doc.data().strOr('answerBody').isEmpty
? null
: doc.data().strOr('answerBody'),
answeredAt: doc.data().containsKey('answeredAt')
? doc.data().dateOr('answeredAt', _epoch)
: null,
),
];
}
/// 프로필 사진 업로드 — Storage 에 올리고 URL 을 사용자 문서에 남긴다.
///
/// 경로를 사용자당 한 장으로 고정한 이유는 고아 파일이다. 시각을 붙이면 바꿀
/// 때마다 이전 파일이 남고 지우는 경로가 따로 필요해진다. 덮어쓰면 다운로드
/// URL 의 토큰이 바뀌므로 캐시도 함께 끊긴다.
///
/// `photoUrl` 은 규칙의 본인 수정 허용 목록에 이미 들어 있는 필드다.
@override
Future<String> uploadProfilePhoto(PickedPhoto photo) async {
checkProfilePhoto(photo);
final uid = _backend.requireUid;
final ref = _backend.storage.ref('users/$uid/profile/photo');
try {
await ref.putData(
photo.bytes,
SettableMetadata(contentType: photo.contentType),
);
} on FirebaseException catch (error) {
// Storage 규칙이 용량·형식을 서버에서 한 번 더 본다(B-44). 거부를 그대로
// 삼키면 화면이 성공으로 넘어가므로 사유가 있는 실패로 바꿔 올린다.
throw PhotoRejected(
error.code == 'unauthorized'
? appL10n.photoRejectedServer
: appL10n.photoUploadFailed,
);
}
final url = await ref.getDownloadURL();
await _backend.db.doc('users/$uid').set(
{'photoUrl': url},
SetOptions(merge: true),
);
return url;
}
}
/// 원장 행 하나와 그 사유 코드·횟수. 접기 판정에만 쓰고 화면으로 나가지 않는다.
class _LedgerRow {
const _LedgerRow({
required this.reasonCode,
required this.entry,
this.count = 1,
});
final String reasonCode;
final PopLedgerEntry entry;
/// 이 행이 몇 번분인가. 원장의 `count` 값이고, 없으면 1 이다.
final int count;
}
/// 최신순으로 붙어 있는 같은 날 Zap 행을 한 줄로 접는다.
///
/// 사이에 다른 거래가 끼면 그룹이 끊긴다. 그래야 접힌 줄의 「누적 POP」이 그 시점의
/// 실제 잔액과 같다. 되돌아가 다시 묶으면 누적 값이 거짓이 된다.
///
/// 접는 판정과 합치는 규칙은 [PopLedgerEntry.foldsWith] · [PopLedgerEntry.fold]
/// 한 벌을 쓴다. 한 장 안에서 못 붙는 경계는 화면이 같은 규칙으로 이어 붙인다
/// (당일 Zap 이 한 장 크기를 넘으면 장이 갈린다).
///
/// 횟수는 원장 행의 `count` 를 합산한다. `fn-eclipse-defend` 가 `count` 를 묶어
/// 받으므로 행 하나가 여러 번분일 수 있다. **필드가 없는 행은 한 번으로 센다** —
/// 지금 배포된 함수가 `count` 를 남기지 않아 예전 행에는 값이 없고, 그 경우
/// 실제보다 작게 나올 수 있다. 함수 쪽에서 `count` 를 남기기 시작하면 이 코드는
/// 고치지 않고 값이 정확해진다.
List<PopLedgerEntry> _foldZap(List<_LedgerRow> rows) {
final folded = <PopLedgerEntry>[];
for (final row in rows) {
final entry = row.reasonCode == 'ECLIPSE_DEFENSE'
? _withRepeat(row.entry, row.count)
: row.entry;
final last = folded.isEmpty ? null : folded.last;
if (last != null && last.foldsWith(entry)) {
folded[folded.length - 1] = last.fold(entry);
continue;
}
folded.add(entry);
}
return folded;
}
PopLedgerEntry _withRepeat(PopLedgerEntry entry, int count) => PopLedgerEntry(
at: entry.at,
title: entry.title,
type: entry.type,
kind: entry.kind,
txType: entry.txType,
delta: entry.delta,
balanceAfter: entry.balanceAfter,
repeatCount: count,
cursor: entry.cursor,
);
/// 서버가 시각을 아직 채우지 않은 문서의 기본값. `DateTime.now()` 를 쓰면
/// 목록을 새로 고칠 때마다 순서가 바뀐다.
final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0);