import 'package:cloud_firestore/cloud_firestore.dart'; import '../../core/data/backend.dart'; import '../../core/i18n/app_locale.dart'; import '../my/my_data.dart' show MyRepository; import '../my/my_repository_firestore.dart'; import '../planet/moderation.dart' show reportReasonCodes; import 'campaign_data.dart'; /// 캠페인의 실 서버 구현 — 화면 대조 원장 F5 (G-03). /// /// **읽기는 Firestore, 후원은 `fn-campaign-fund`.** 예전에는 서버가 붙어도 표본이 /// 쓰였고 표본의 `contribute` 가 금액을 그대로 돌려줘 화면이 성공처럼 보였다. /// /// 읽는 곳 — `firestore.rules` 의 `campaigns` 화이트리스트 필드만 읽는다. /// - 목록·상세 `campaigns/{id}` (공개 상태 `funding·success·fail·report` 만 규칙이 연다) /// - 기여 랭킹 `campaigns/{id}/donations` (`uid` · `amount` · `createdAt`) /// /// - 좋아요 `campaigns/{id}/likes/{uid}` (문서 ID = uid 로 중복 차단 · 개수는 집계 쿼리) /// - 응원 게시판 댓글 `campaigns/{id}/comments` (수는 `commentCount` — 서버 트리거가 센다) /// /// **서버에 자리가 없어 채우지 못한 것** — 조회수·공유 수(규칙에 필드·경로 /// 없음), 다른 후원자의 이름(후원 문서에 `uid` 만 있고 남의 `users` 문서는 못 읽는다). /// 0·빈 목록·중립 이름으로 두고 보고서에 남겼다. 없는 값을 여기서 만들지 않는다. class FirestoreCampaignRepository implements CampaignRepository { FirestoreCampaignRepository(this._backend); final Backend _backend; /// 규칙 `campaignIsPublic()` 과 같은 목록. 이 필터가 없으면 쿼리 전체가 거부된다. static const _public = ['funding', 'success', 'fail', 'report']; final List _queries = []; // ponytail: 공개 캠페인을 한 번에 최대 100건 읽어 앱에서 정렬·검색·페이징한다. // 캠페인이 늘면 (status, createdAt DESC) 인덱스 + 커서 페이징으로 옮긴다. Future> _all() async { final snap = await _backend.db .collection('campaigns') .where('status', whereIn: _public) .limit(100) .get(); final uid = _backend.uid; // 차단한 개설자의 캠페인은 목록 · 검색에서 뺀다. final blocked = uid == null ? const {} : await _blockedUids(); return snap.docs .where((d) => !blocked.contains(d.data()['creatorUid'])) .map((d) => _card(d.id, d.data(), uid)) .toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); } static CampaignCardData _card( String id, Map m, String? uid, ) => CampaignCardData( id: id, title: m.strOr('title'), summary: m.strOr('summary'), body: m.strOr('body'), authorName: publicName(m['creatorName']), fandomId: m.strOr('fandomId'), createdAt: m.dateOr('createdAt', DateTime.fromMillisecondsSinceEpoch(0)), status: switch (m.strOr('status')) { 'success' => CampaignStatus.success, 'fail' || 'failed' => CampaignStatus.failed, 'report' => CampaignStatus.reported, 'draft' || 'review' => CampaignStatus.review, 'rejected' => CampaignStatus.rejected, 'withdrawn' => CampaignStatus.withdrawn, 'cancelled' => CampaignStatus.cancelled, _ => CampaignStatus.funding, }, // 규칙의 스키마 이름은 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만 읽는다. goalPop: m.intOr('targetPop', m.intOr('goalPop')), raisedPop: m.intOr('raisedPop'), supporters: m.intOr('donorCount'), likes: m.intOr('likes'), comments: m.intOr('commentCount'), views: m.intOr('views'), shares: m.intOr('shares'), mine: uid != null && m['creatorUid'] == uid, creatorUid: m['creatorUid'] is String ? m['creatorUid'] as String : null, ); @override Future> load({ CampaignSort sort = CampaignSort.latest, int limit = 10, }) async { final all = await _all(); switch (sort) { case CampaignSort.latest: break; case CampaignSort.popular: all.sort((a, b) => a.comparePopularity(b)); case CampaignSort.contributing: // 내 원장에서 찾는다. 후원 문서의 collectionGroup 인덱스는 `donorUid` 로 // 걸려 있는데 서버는 `uid` 로 써서 그 경로가 맞지 않는다. final uid = _backend.requireUid; final mine = await _backend.db .collection('users/$uid/popTransactions') .where('reasonCode', isEqualTo: 'CAMPAIGN_FUND') .get(); final paths = {for (final d in mine.docs) d.data()['refPath']}; all.retainWhere((c) => paths.contains('campaigns/${c.id}')); } return all.take(limit).toList(); } @override Future> more({ required int offset, int limit = 10, }) async => (await _all()).skip(offset).take(limit).toList(); @override Future detail(String campaignId) async { final snap = await _backend.db.doc('campaigns/$campaignId').get(); final data = snap.data(); if (data == null) { throw BackendException('E-CMP-03', appL10n.campaignNotFound); } final uid = _backend.uid; final likes = _backend.db.collection('campaigns/$campaignId/likes'); final count = (await likes.count().get()).count ?? 0; final liked = uid != null && (await likes.doc(uid).get()).exists; final card = _card(snap.id, {...data, 'likes': count}, uid); return CampaignDetail( card: card, report: card.status == CampaignStatus.reported ? await _report(campaignId, card.createdAt) : null, liked: liked, mediaLabels: [appL10n.campaignMediaMain], endsAt: data.dateOr('endsAt', card.createdAt), topContributors: (await ranking(campaignId)).take(3).toList(), ); } /// 결과보고 — 파일은 Storage 경로로 저장돼 있어 표시용 URL 로 바꾼다 /// (`storage.rules` 의 `campaignReports/` 는 공개 읽기). Future _report(String campaignId, DateTime fallback) async { final m = (await _backend.db.doc('campaigns/$campaignId/reports/main').get()) .data(); if (m == null) return null; Future> urls(String key) => Future.wait([ for (final p in (m[key] as List? ?? const [])) if (p is String) _backend.storage.ref(p).getDownloadURL(), ]); return CampaignReport( body: m.strOr('body'), photoUrls: await urls('photoPaths'), receiptUrls: await urls('receiptPaths'), budget: [ for (final l in (m['budget'] as List? ?? const [])) if (l is Map) (label: l.strOr('label'), amount: l.intOr('amount')), ], createdAt: m.dateOr('createdAt', fallback), ); } @override Future> ranking( String campaignId, { RankWindow window = RankWindow.live, }) async { final snap = await _backend.db .collection('campaigns/$campaignId/donations') .get(); final since = switch (window) { RankWindow.live => null, RankWindow.daily => DateTime.now().subtract(const Duration(days: 1)), RankWindow.weekly => DateTime.now().subtract(const Duration(days: 7)), }; final totals = {}; for (final d in snap.docs) { final m = d.data(); if (since != null && m.dateOr('createdAt', since).isBefore(since)) continue; final uid = m.strOr('uid'); totals[uid] = (totals[uid] ?? 0) + m.intOr('amount'); } final me = _backend.uid; final rows = totals.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); return [ for (var i = 0; i < rows.length; i++) ContributorRank( rank: i + 1, // 남의 이름은 서버에 읽을 자리가 없다. UID 는 화면에 내보내지 않는다. name: rows[i].key == me ? appL10n.commonMe : appL10n.commonFan, pop: rows[i].value, isMe: rows[i].key == me, ), ]; } @override Future> recentQueries() async => [..._queries]; @override Future clearQueries() async => _queries.clear(); @override Future removeQuery(String query) async => _queries.remove(query); @override Future> search(String query) async { _queries ..remove(query) ..insert(0, query); return (await _all()) .where((c) => c.title.contains(query) || c.body.contains(query)) .toList(); } /// 아직 성공이 확인되지 않은 후원 시도의 요청 번호 — `캠페인 · 금액` 별. /// /// 응답이 유실된 뒤 다시 누르면 **같은 번호**로 보내야 서버 멱등 키가 두 번째 차감을 막는다. /// 탭마다 새 번호를 만들면 첫 요청이 실제로는 성공했을 때 이중 후원이 된다. final Map _pendingSeq = {}; /// 후원 — `fn-campaign-fund`. 기여 POP 만 쓴다(정책 원장 B-33). /// 실패는 [BackendException] 으로 그대로 올린다 — 화면이 성공처럼 그리지 않게. @override Future contribute(String campaignId, int amountPop) async { final key = '$campaignId:$amountPop'; final seq = _pendingSeq[key] ??= _backend.db.collection('_').doc().id; await _backend.call('fnCampaignFund', { 'campaignId': campaignId, 'clientSeq': seq, 'amountPop': amountPop, }); // 성공이 확인된 뒤에만 번호를 버린다 — 다음 후원은 새 시도다. _pendingSeq.remove(key); return amountPop; } @override Future balance() async { final uid = _backend.requireUid; final wallet = await _backend.db.doc('users/$uid/wallet/main').get(); return (wallet.data() ?? const {}).intOr('supportBalance'); } @override Future feeBalance() async { final uid = _backend.requireUid; final wallet = (await _backend.db.doc('users/$uid/wallet/main').get()).data() ?? const {}; return wallet.intOr('activityBalance') + wallet.intOr('supportBalance'); } /// 좋아요 — `campaigns/{id}/likes/{uid}` 를 만들거나 지운다(라운지 좋아요와 같은 구조). /// 실패는 그대로 올린다 — 화면은 저장이 끝난 뒤에만 숫자를 바꾼다. @override Future like(String campaignId, bool on) async { final ref = _backend.db.doc( 'campaigns/$campaignId/likes/${_backend.requireUid}', ); if (on) { await ref.set({'createdAt': FieldValue.serverTimestamp()}); } else { await ref.delete(); } } /// 규칙이 개설자 본인 문서는 상태와 무관하게 읽기를 허용한다(`creatorUid == auth.uid`). @override Future> mine() async { final uid = _backend.requireUid; final snap = await _backend.db .collection('campaigns') .where('creatorUid', isEqualTo: uid) .get(); return snap.docs.map((d) => _card(d.id, d.data(), uid)).toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); } @override Future withdraw(String campaignId) async { await _backend.call('fnCampaignWithdraw', {'campaignId': campaignId}); } /// 개설 — `fn-campaign-create`. 서버가 자격(B-56)을 보고 등록비(B-57)를 뺀 뒤 /// 심사 대기(`review`)로 올린다. 규칙은 클라이언트 직접 생성을 막는다. /// 응답이 유실된 재시도는 같은 요청 번호로 보내 등록비가 두 번 빠지지 않게 한다. @override Future create(CampaignDraft draft) async { final key = 'create:${draft.title}:${draft.goalPop}:${draft.days}'; final seq = _pendingSeq[key] ??= _backend.db.collection('_').doc().id; final result = await _backend.call('fnCampaignCreate', { 'clientSeq': seq, 'title': draft.title, 'body': draft.body, 'targetPop': draft.goalPop, 'days': draft.days, }); _pendingSeq.remove(key); return result.strOr('campaignId'); } QueryDocumentSnapshot>? _commentCursor; /// 내가 차단한 UID. 목록 · 댓글 장을 읽을 때마다 새로 읽는다 — MY 에서 차단을 /// 풀거나 다른 화면에서 차단해도 저장소 캐시가 낡지 않는다. Future> _blockedUids() async => { for (final d in (await _backend.db .collection('users/${_backend.requireUid}/blocks') .get()) .docs) d.id, }; /// 최신순 한 장. 차단한 작성자의 댓글이 한 장을 다 채우면 화면이 「끝」으로 /// 오인하므로 서버에 댓글이 남아 있는 동안 다음 장을 더 읽는다(라운지와 같은 방식). @override Future> comments( String campaignId, { bool more = false, }) async { if (!more) _commentCursor = null; final uid = _backend.requireUid; final blocked = await _blockedUids(); final out = []; while (out.length < campaignCommentPageSize) { var query = _backend.db .collection('campaigns/$campaignId/comments') .where('visibility', isEqualTo: 'visible') .orderBy('createdAt', descending: true); final cursor = _commentCursor; if (cursor != null) query = query.startAfterDocument(cursor); final snap = await query.limit(campaignCommentPageSize).get(); if (snap.docs.isNotEmpty) _commentCursor = snap.docs.last; out.addAll( await Future.wait([ for (final d in snap.docs) if (!blocked.contains(d.data()['authorUid'])) _comment(d, uid), ]), ); if (snap.docs.length < campaignCommentPageSize) break; } return out; } /// ponytail: 댓글마다 내 좋아요 문서를 한 번씩 읽는다(한 장 20건). 무거워지면 /// 좋아요한 댓글 ID 를 한 번의 collectionGroup 질의로 모은다. static Future _comment( DocumentSnapshot> d, String uid, ) async { final data = d.data() ?? const {}; final author = data['authorUid']; final liked = (await d.reference.collection('likes').doc(uid).get()).exists; return CampaignComment( likes: data.intOr('likeCount'), liked: liked, id: d.id, authorName: publicName(data['authorName']), authorUid: author is String ? author : null, body: data.strOr('body'), createdAt: data.dateOr('createdAt', DateTime.now()), isMine: author == uid, ); } /// 작성자 표시명 = 공개 닉네임(없으면 언어 무관 「팬」 — [storedPublicName]). /// 공급자 실명(`displayName`)은 쓰지 않는다(M-7). 규칙 `authorNameIsPublic()` 이 같은 계산으로 대조한다. @override Future addComment(String campaignId, String body) async { final uid = _backend.requireUid; final name = storedPublicName( (await _backend.db.doc('users/$uid').get()).data()?['nickname'], ); final ref = await _backend.db .collection('campaigns/$campaignId/comments') .add({ 'authorUid': uid, 'authorName': name, 'body': body, 'visibility': 'visible', 'createdAt': FieldValue.serverTimestamp(), 'updatedAt': FieldValue.serverTimestamp(), }); return _comment(await ref.get(), uid); } @override Future deleteComment(String campaignId, String commentId) => _backend.db.doc('campaigns/$campaignId/comments/$commentId').delete(); /// 댓글 좋아요 — `comments/{cid}/likes/{uid}`. 수는 서버 트리거가 센다. @override Future likeComment(String campaignId, String commentId, bool on) async { final ref = _backend.db.doc( 'campaigns/$campaignId/comments/$commentId/likes/${_backend.requireUid}', ); if (on) { await ref.set({'createdAt': FieldValue.serverTimestamp()}); } else { await ref.delete(); } } /// 신고 — 라운지 글과 같은 `reports` 스키마. 문서 ID 를 「대상(댓글은 캠페인 + 댓글) + 신고자」로 /// 고정해 규칙이 재접수(덮어쓰기)를 거부한다(B-45 「신고자 수」). @override Future reportComment( String campaignId, CampaignComment comment, String reason, ) => _fileReport( 'campaignComment', '$campaignId-${comment.id}', 'campaigns/$campaignId/comments/${comment.id}', reason, ); /// 캠페인 신고 — ID `campaign-{캠페인}-{신고자}`. @override Future reportCampaign(String campaignId, String reason) => _fileReport('campaign', campaignId, 'campaigns/$campaignId', reason); Future _fileReport( String type, String idPart, String targetPath, String reason, ) async { final uid = _backend.requireUid; final code = reportReasonCodes[reason]; if (code == null) { throw BackendException('E-PERM-01', appL10n.reportReasonUnknown); } try { await _backend.db.doc('reports/$type-$idPart-$uid').set({ 'reporterUid': uid, 'targetType': type, 'targetPath': targetPath, 'reasonCode': code, 'status': 'pending', 'createdAt': FieldValue.serverTimestamp(), }); } on FirebaseException catch (error) { if (error.code == 'permission-denied') { throw BackendException('E-PERM-01', appL10n.reportAlready); } rethrow; } } @override Future hasPublicNickname() async { final user = await _backend.db.doc('users/${_backend.requireUid}').get(); return publicName(user.data()?['nickname'], '').isNotEmpty; } @override late final MyRepository my = FirestoreMyRepository(_backend); /// 차단 — MY 의 차단 목록과 같은 경로 · 같은 메서드를 쓴다. @override Future blockAuthor(CampaignComment comment) => _block(comment.authorUid, comment.authorName); /// 개설자 차단 — 댓글 작성자 차단과 같은 목록이라 그 개설자의 댓글도 빠진다. @override Future blockCreator(CampaignCardData card) => _block(card.creatorUid, card.authorName); Future _block(String? target, String nickname) async { if (target == null) { throw BackendException('E-PERM-01', appL10n.blockUnavailable); } await my.block(target, nickname: nickname); } }