import 'dart:math' as math; 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) /// - 완료 퀘스트 `users/{uid}/verifiedCompletions` 표식 수(무효 제외) /// - 누적 기여 POP `users/{uid}.totalContributedPop`(`fn-pop-spend` 가 갱신, 설계서 §3.6) /// - 레벨 `users/{uid}.{userExp, userLevel}`(C-18). 필드가 없는 기존 사용자는 Lv.1. /// - Passport `users/{uid}/passports/{fandomId}.tier` — **팬덤별**(10/3 #10 · 9/21 5절). 없으면 SILVER. /// 탈퇴한 팬덤의 Passport 도 지우지 않고 보인다(ARCHIVED). /// - Badge `users/{uid}/badges`(상세정의 4장 · 별첨 1 ②-5) /// - 잔액 `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 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(); // 완료 퀘스트 = 검증 활동 표식 수 − 무효 표식 수. 서버 `verifiedQuestCountOf`(fpi.ts)와 같은 식. // `users.completedQuests` 는 쓰는 서버 코드가 없어 늘 0 이었다. final done = db.collection('users/$uid/verifiedCompletions'); // 규칙 배포 전 서버에서는 이 읽기가 거부된다 — MY 전체를 떨어뜨리지 않고 0 으로 둔다. final completedF = Future.wait([ done.count().get(), done.where('voided', isEqualTo: true).count().get(), ]).then( (c) => math.max(0, (c[0].count ?? 0) - (c[1].count ?? 0)), onError: (Object _) => 0, ); final user = (await userF).data() ?? const {}; final wallet = (await walletF).data() ?? const {}; final primaryId = user.strOr('primaryFandomId', user.strOr('fandomId')); final joinedIds = (user['fandomIds'] as List?)?.whereType().toSet() ?? {}; if (primaryId.isNotEmpty) joinedIds.add(primaryId); // 규칙 배포 전 서버에서는 하위 컬렉션 읽기가 거부된다 — MY 전체를 떨어뜨리지 않는다. Future>>> docsOf(String path) => db .collection(path) .get() .then((q) => q.docs, onError: (Object _) => >>[]); final passportsF = docsOf('users/$uid/passports'); final badgesF = docsOf('users/$uid/badges'); final passports = {for (final d in await passportsF) d.id: d.data()}; final ids = {...joinedIds, ...passports.keys}; final tiers = await Future.wait(ids.map((id) async { final joined = joinedIds.contains(id); final fandom = (await db.doc('fandoms/$id').get()).data() ?? const {}; final member = joined ? (await db.doc('fandoms/$id/members/$uid').get()).data() ?? const {} : const {}; final passport = passports[id] ?? const {}; return PlanetTier( fandomId: id, fandomName: fandom.strOr('name', id), // 팬덤별 누적 기여 POP. 가입 중이면 멤버 문서(`fn-pop-spend` 가 올림), 탈퇴했으면 Passport 보존값. contributedPop: joined ? member.intOr('contributedPop') : passport.intOr('contributedPop'), isPrimary: id == primaryId, passportTier: PassportTier.parse(passport.strOr('tier')), joined: joined, ); })); final primaryTier = tiers.where((t) => t.isPrimary).firstOrNull?.passportTier ?? PassportTier.silver; final badges = [ for (final d in await badgesF) MyBadge( id: d.id, kind: d.data().strOr('kind'), stage: d.data().intOr('stage'), axis: d.data().strOr('axis'), memberNo: d.data()['memberNo'] is num ? (d.data()['memberNo'] as num).toInt() : null, toStage: d.data()['toStage'] is num ? (d.data()['toStage'] as num).toInt() : null, ), ]; // 닉네임이 아직 없으면(온보딩 전) 중립 이름으로 부른다. 「— 님」은 고장처럼 보인다. // **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로 // 닉네임으로 저장해 홈 기여자명으로 퍼졌다. 이메일은 설정 「계정 정보」 // (명세 `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: primaryTier, balancePop: wallet.intOr('activityBalance'), steps: user.intOr('steps'), completedQuests: await completedF, planetTiers: tiers, badges: badges, 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> 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 {}; return { for (final topic in PushTopic.values) topic: saved[topic.name] is bool ? saved[topic.name] as bool : topic.defaultOn, }; } @override Future 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 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> 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> 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 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 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 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 claimFocusDaily() async => (await _backend.call('fnFocusDailyClaim')).intOr('granted'); PaymentEntry _payment(String id, Map 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> 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> 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 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 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 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 updateInquiry(String id, String title, String body) async { await _backend.db.doc('inquiries/$id').update({ 'title': title, 'body': body, 'updatedAt': FieldValue.serverTimestamp(), }); } @override Future> 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 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 _foldZap(List<_LedgerRow> rows) { final folded = []; 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);