import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_storage/firebase_storage.dart'; import '../../core/data/backend.dart'; import '../campaign/campaign_data.dart' show ContributorRank; import '../my/my_data.dart' show PickedPhoto, PhotoRejected; import '../my/my_repository_firestore.dart'; import '../home/home_data.dart' show nebulaTargetsFrom; import 'moderation.dart' show reportReasonCodes; import 'planet_data.dart'; import '../../core/i18n/app_locale.dart'; /// 행성·성운 상세의 실 서버 구현 — 화면 대조 원장 F3. /// /// **지표 카드·행성 랭킹·사용자 랭킹이 한 원천을 본다.** 발주사 검토회신서 4절이 /// 지적한 「지표 카드와 Ranking 사이 수치·순위 불일치」의 원인이 화면마다 다른 /// 데이터를 들고 있던 것이라, 값이 갈릴 자리를 없앤다. /// /// | 화면 | 읽는 곳 | /// |---|---| /// | TOTAL FPI | `fandoms/{id}.fpiScore` — 서버 가중합 하나 | /// | 지표 카드 값·전체 순위 | `fandoms/{id}.fpiAxes[축]` · `.fpiRanks[축]` | /// | 행성 지표 랭킹 | 같은 `fpiAxes[축]` 내림차순 · 순위는 **서버의 `fpiRanks[축]`** | /// | 사용자 지표 랭킹 | `fandoms/{id}/members/*.fpiAxes[축]` 내림차순 | /// | 국가 지표 랭킹 | `rankings/country-{축}` 사전 집계 1건 | /// /// **라운지 목록은 offset 이 아니라 커서로 넘긴다.** Firestore 에는 offset 이 없고, /// 흉내 내려면 앞 페이지를 매번 다시 읽어야 한다. 인터페이스는 명세대로 /// `offset` 을 받되 내부에서는 마지막 문서를 커서로 들고 간다. class FirestorePlanetRepository implements PlanetRepository { FirestorePlanetRepository(this._backend, {required this.fandomId}); final Backend _backend; final String fandomId; @override Future history({ String? cursor, bool personal = false, }) async { _backend.requireUid; final result = await _backend.call('fnPlanetHistory', { 'fandomId': fandomId, if (cursor != null) 'cursor': cursor, if (personal) 'scope': 'personal', }); final rows = result['items']; final next = result['nextCursor']; if (rows is! List || (next != null && (next is! String || next.isEmpty))) { throw StateError('역사 기록을 확인할 수 없습니다.'); } final items = []; for (final row in rows) { if (row is! Map) throw StateError('역사 기록을 확인할 수 없습니다.'); final kind = switch (row['kind']) { 'evolution' => PlanetHistoryKind.evolution, 'hibernation' => PlanetHistoryKind.hibernation, 'wake_up' => PlanetHistoryKind.wakeUp, 'zap' => PlanetHistoryKind.zap, 'quest_reward' => PlanetHistoryKind.questReward, 'company_ad_bonus' => PlanetHistoryKind.companyAdBonus, 'funding' => PlanetHistoryKind.funding, 'eclipse_success' => PlanetHistoryKind.eclipseSuccess, 'emblem_evolution' => PlanetHistoryKind.emblemEvolution, 'badge_founder100' => PlanetHistoryKind.founderBadge, _ => null, }; final date = row['occurredAt'] is String ? DateTime.tryParse(row['occurredAt']) : null; final id = row['id']; final from = row['fromStage']; final to = row['toStage']; final amount = row['amount']; final activity = [ PlanetHistoryKind.zap, PlanetHistoryKind.questReward, PlanetHistoryKind.companyAdBonus, PlanetHistoryKind.funding, ].contains(kind); final stamp = [ PlanetHistoryKind.eclipseSuccess, PlanetHistoryKind.emblemEvolution, PlanetHistoryKind.founderBadge, ].contains(kind); if ((activity || stamp) != personal || (activity && (amount is! int || amount <= 0)) || kind == null || date == null || id is! String || id.isEmpty || (kind == PlanetHistoryKind.evolution && (from is! int || to is! int || from < 0 || to > 6 || to <= from))) { throw StateError('역사 기록을 확인할 수 없습니다.'); } items.add( PlanetHistoryEntry( id: id, kind: kind, occurredAt: date, amount: activity ? amount as int : null, fromStage: kind == PlanetHistoryKind.evolution ? from as int : null, toStage: kind == PlanetHistoryKind.evolution ? to as int : kind == PlanetHistoryKind.emblemEvolution && to is int ? to : null, memberNo: row['memberNo'] is int ? row['memberNo'] as int : null, participationConfirmed: kind == PlanetHistoryKind.evolution && row['participationConfirmed'] == true, ), ); } return PlanetHistoryPage(items: items, nextCursor: next as String?); } @override Future claimHeroDrop(String dropId) async { _backend.requireUid; final result = await _backend.call('fnHeroDropClaim', { 'fandomId': fandomId, 'dropId': dropId, }); final amount = result['claimedPop']; final txId = result['txId']; if (amount is! int || amount <= 0 || txId is! String || txId.isEmpty) { throw StateError('지급 결과를 확인할 수 없습니다.'); } return HeroDropReceipt(txId: txId, amount: amount); } /// 명세 — *최초 5개, 스크롤 시 5개씩 호출*. static const int pageSize = 5; DocumentSnapshot>? _cursor; /// 집계 축 키. 지표 이름을 그대로 쓴다 — 설계서 §4.1 의 `fpiAxes` 키와 같다. /// 두 벌을 두면 집계 축과 화면 표기가 갈린다. static String _axisKey(FpiMetric metric) => metric.name; @override Future join() => _backend.call('fnFandomJoin', {'fandomId': fandomId}); @override Future leave() => _backend.call('fnFandomLeave', {'fandomId': fandomId}); @override Future load({required bool nebula}) async { final db = _backend.db; final uid = _backend.requireUid; final fandomF = db.doc('fandoms/$fandomId').get(); // 최근 동향(최근 변동)은 **일 1 회 Daily FPI Snapshot 대비 현재값**이다. // 별첨 B — 원천 이벤트 즉시 적재 · FPI/랭킹 1 분 주기 집계 · Daily FPI // Snapshot 은 일 1 회. 스냅샷 두 건을 비교하면 「어제 대 그저께」가 되어 // 1 분마다 움직이는 지표 카드와 어긋난다. final snapshotF = db .collection('fandoms/$fandomId/fpiSnapshots') .orderBy('createdAt', descending: true) .limit(1) .get(); final memberF = db.doc('fandoms/$fandomId/members/$uid').get(); // 성운 전환 목표는 서버 판정과 같은 운영값을 읽는다(앱 상수 아님 · B-10). final policyF = db.doc('config/policy').get(); _cursor = null; final postsF = _page(pageSize); final fandom = (await fandomF).data() ?? const {}; final snapshots = (await snapshotF).docs; final memberSnap = await memberF; final member = memberSnap.data() ?? const {}; final posts = await postsF; final targets = nebulaTargetsFrom((await policyF).data()); // 생성자 = `founderUid`(성운 생성자 · 탄생 전 탈퇴 시 승계자, C-100). 프로필 · 닉네임은 그 멤버 문서에서 // 읽는다(Figma `544:19765` 「생성자 프로필 · 닉네임」). 떠났거나 시드 행성이면 줄을 그리지 않는다. final founderUid = fandom.strOr('founderUid'); final founder = founderUid.isEmpty ? null : (await db.doc('fandoms/$fandomId/members/$founderUid').get()).data(); final axes = _map(fandom, 'fpiAxes'); final ranks = _map(fandom, 'fpiRanks'); final myTop = _map(member, 'topPercentByAxis'); // `topPercentByAxis` 는 서버 5 분 집계(`fnRankingCountryAggregate`)가 적는다. // **값이 없으면 null** — 그 축 기여가 0 이거나 모집단(B-17) 밖이다. 100%(최하위)로 // 채우면 없는 값이 사실처럼 보인다. int? myTopOf(FpiMetric metric) => myTop[_axisKey(metric)] is num ? myTop.intOr(_axisKey(metric)) : null; // 스냅샷의 축 맵은 `fpiAxes` 다. 초기 시드가 `axes` 로 쓴 문서가 남아 있어 // 읽는 쪽에서 두 이름을 모두 받는다 — 시드를 다시 넣으면 앞 이름만 남는다. final snapshot = snapshots.isEmpty ? const {} : snapshots.first.data(); final dailyAxes = _map(snapshot, 'fpiAxes'); final daily = dailyAxes.isNotEmpty ? dailyAxes : _map(snapshot, 'axes'); final stage = switch (fandom['stage']) { int n when n >= 0 && n <= 6 => n, _ => null, }; return PlanetDetail( isOwner: founderUid.isNotEmpty && founderUid == uid, ownerName: founder == null ? null : publicName(founder['nickname']), joined: memberSnap.exists, stage: stage, fandomId: fandomId, name: fandom.strOr('name', fandomId), // TOTAL FPI = 서버가 적어 준 `fpiScore` 하나. 클라이언트가 5축을 다시 // 더하지 않는다 — `/config/policy` 의 가중치가 1.0 을 벗어나는 순간 // 갤럭시 HUD(서버 `fpiScore`)와 이 화면(클라이언트 합)이 갈린다. // 가중치 초기값은 1.0 이라 지금은 두 값이 같지만, 같아 보이는 것과 // 한 원천을 읽는 것은 다르다(별첨 B · 설계서 §4.1). totalFpi: fandom.intOr('fpiScore'), fpiUpdatedAt: switch (fandom['fpiUpdatedAt']) { Timestamp t => t.toDate(), _ => null, }, isNebula: stage == null ? nebula : stage == 0, members: fandom.intOr('memberCount'), growthPop: fandom.intOr('growthPop'), nebulaTargetPop: targets.pop, nebulaTargetMembers: targets.members, scores: [ for (final metric in FpiMetric.values) FpiScore( metric: metric, value: axes.intOr(_axisKey(metric)), rank: ranks.intOr(_axisKey(metric), 0), // 스냅샷이 아직 없으면 0 — 「오늘 시작 이후 변동 없음」이다. trend: daily.isEmpty ? 0 : axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)), // 없으면 null — 화면이 숫자 대신 「—」를 띄운다. 행성 값이 0 이면 // 기여를 나눌 모수가 없으니 남아 있는 값(시드 등)도 쓰지 않는다. myTopPercent: axes.intOr(_axisKey(metric)) > 0 ? myTopOf(metric) : null, ), ], posts: posts, // 한 축이라도 서버 값이 없으면 배지로 알린다. 화면이 이 문구를 그대로 // 띄운다 — 「집계 준비 중」 카드만으로는 이유가 보이지 않는다(대조표 3-5). mockNote: null, ); } /// 행성 랭킹 — 서버 1분 집계(`fnFpiAggregate`)가 적은 `fpiUpdatedAt` 중 가장 늦은 값이 마지막 갱신이다. @override Future planetRanking({ required FpiMetric metric, int limit = 30, }) async { final ranking = await _planetRanking(metric: metric, limit: limit); final me = (await _backend.db.doc('fandoms/$fandomId').get()).data(); return ranking.withUpdate(_time(me?['fpiUpdatedAt']), 1); } static DateTime? _time(Object? v) => v is Timestamp ? v.toDate() : null; Future _planetRanking({ required FpiMetric metric, required int limit, }) async { final axis = _axisKey(metric); // 모집단 = 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」. // 최근 7 일 유효 활동 판정은 서버(1 분 집계)가 `rankingEligible` 로 적는다. // 클라이언트가 `lastActiveAt` 부등호로 거르면 정렬 키와 부등호 필드가 달라 // Firestore 가 질의를 거부한다. final snap = await _backend.db .collection('fandoms') .where('rankingEligible', isEqualTo: true) .orderBy('fpiAxes.$axis', descending: true) .limit(limit) .get(); // **순위는 서버가 적어 준 `fpiRanks[축]` 이다.** 쿼리 결과의 인덱스로 다시 // 매기면 동점에서 두 계산이 갈린다 — 서버는 축 값 내림차순으로, Firestore 는 // 같은 값끼리 문서 ID 순으로 세우기 때문이다. 그 자리가 지표 카드의 // 「전체 n위」와 이 목록의 순위가 어긋나던 곳이다(검토회신서 4절). // 서버 값이 아직 없는 문서만 쿼리 인덱스로 메운다. // 서버 값이 없을 때의 메움도 1·1·3 공동 순위다(C-19). 실값(반올림 전)으로 비교하고 // 동점 내부는 fandomId 오름차순 — Firestore 의 내림차순 정렬은 동점을 ID // 내림차순으로 세우므로 여기서 다시 세운다. final rows = [ for (final (doc, rank) in rankWithTies( snap.docs, (d) => _raw(d.data(), axis), (d) => d.id, )) _planetRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank), ]; final mine = rows.where((r) => r.isMine).firstOrNull; if (mine != null) return FpiRanking(rows: rows, mine: mine); // 상위 [limit] 밖이면 내 행성 문서를 한 번 더 읽어 「소속 행성 지표 랭킹 // 영역」(Figma `546:14628`)을 채운다. 사용자 랭킹의 count() 폴백과 같은 // 취지다 — 내 행성이 몇 등이든 그 영역은 떠야 한다. final me = (await _backend.db.doc('fandoms/$fandomId').get()).data(); if (me == null) return FpiRanking(rows: rows); return FpiRanking( rows: rows, // 서버 순위가 없으면 목록 밖이라는 것만 아는 셈이라 0 으로 둔다. mine: _planetRow(me, id: fandomId, axis: axis, fallbackRank: 0), ); } /// 축의 실제 산정값(반올림 전). 순위 비교에만 쓴다 — 표시는 [SnapshotReader.intOr]. static num _raw(Map data, String axis) { final v = _map(data, 'fpiAxes')[axis]; return v is num ? v : 0; } /// 행성 랭킹 한 줄. 목록 안이든 밖이든 **같은 필드에서** 만든다. FpiRankRow _planetRow( Map data, { required String id, required String axis, required int fallbackRank, }) { final rank = _map(data, 'fpiRanks').intOr(axis, fallbackRank); return FpiRankRow( id: id, rank: rank, name: data.strOr('name', id), value: _map(data, 'fpiAxes').intOr(axis), // 순위 동향 = 직전 순위 − 현재 순위. 둘 다 서버가 적은 값이다. change: _map(data, 'fpiRanksPrev').intOr(axis, rank) - rank, isMine: id == fandomId, planetType: data['planetType'] is String ? data['planetType'] as String : null, colorKey: data['colorKey'] is String ? data['colorKey'] as String : null, ); } /// 사용자 랭킹 — 서버 5분 집계(`fnRankingCountryAggregate`)가 팬덤 문서에 적는 `memberRankedAt`. @override Future userRanking({ required FpiMetric metric, required String fandomId, int limit = 30, }) async { final ranking = await _userRanking( metric: metric, fandomId: fandomId, limit: limit, ); final fandom = (await _backend.db.doc('fandoms/$fandomId').get()).data(); return ranking.withUpdate(_time(fandom?['memberRankedAt']), 5); } Future _userRanking({ required FpiMetric metric, required String fandomId, required int limit, }) async { final axis = _axisKey(metric); final uid = _backend.requireUid; final members = _backend.db.collection('fandoms/$fandomId/members'); final snap = await members .orderBy('fpiAxes.$axis', descending: true) .limit(limit) .get(); // 순위는 1·1·3 공동 순위(C-19) — 실값 동률만 같은 순위, 동점 내부는 uid 오름차순. final rows = [ for (final (doc, rank) in rankWithTies( snap.docs, (d) => _raw(d.data(), axis), (d) => d.id, )) _userRow( doc.data(), id: doc.id, axis: axis, fallbackRank: rank, uid: uid, ), ]; // 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로 // 알린다 — 「▲▼ 가 없다」와 「아직 집계 전이다」는 다른 이야기다. final note = snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num) ? null : '사용자 순위 동향은 아직 집계 전입니다'; final mine = rows.where((r) => r.isMine).firstOrNull; if (mine != null) { return FpiRanking(rows: rows, mine: mine, mockNote: note); } // 상위 목록 밖이면 내 값보다 큰 멤버 수를 세어 정확한 순위를 낸다. // Figma `546:14668` 「나의 랭킹 영역」은 내가 몇 등이든 떠야 한다. final me = (await members.doc(uid).get()).data(); if (me == null) return FpiRanking(rows: rows, mockNote: note); // 서버 순위가 없을 때만 실값보다 큰 멤버 수 + 1 = 공동 순위(C-19)로 센다. final serverRank = _map(me, 'rankByAxis')[axis]; final fallback = serverRank is num ? 0 : ((await members .where('fpiAxes.$axis', isGreaterThan: _raw(me, axis)) .count() .get()) .count ?? rows.length) + 1; return FpiRanking( rows: rows, mockNote: note, mine: _userRow( me, id: uid, axis: axis, fallbackRank: fallback, uid: uid, name: appL10n.commonMe, ), ); } /// 사용자 랭킹 한 줄. 순위 · 직전 순위는 서버 5 분 집계(`rankByAxis` · `rankPrevByAxis`)를 /// 읽는다 — 상위 % 와 같은 원천이라 두 값이 갈리지 않는다. 서버 값이 없을 때만 [fallbackRank]. static FpiRankRow _userRow( Map data, { required String id, required String axis, required int fallbackRank, required String uid, String? name, }) { final rank = _map(data, 'rankByAxis').intOr(axis, fallbackRank); return FpiRankRow( id: id, rank: rank, // UID·이메일을 이름 자리에 내보내지 않는다(G-07). name: publicName(data['nickname'], name), value: _map(data, 'fpiAxes').intOr(axis), // 순위 동향 = 직전 순위 − 현재 순위. 직전 값이 없으면 0(▲▼ 없음). change: _map(data, 'rankPrevByAxis').intOr(axis, rank) - rank, isMine: id == uid, ); } @override Future> contributors({int limit = 3}) async { final uid = _backend.requireUid; final snap = await _backend.db .collection('fandoms/$fandomId/members') .orderBy('contributedPop', descending: true) .limit(limit) .get(); return [ for (final (doc, rank) in rankWithTies( snap.docs.where((d) => d.data().intOr('contributedPop') > 0).toList(), (d) => d.data().intOr('contributedPop'), (d) => d.id, )) ContributorRank( rank: rank, // UID·이메일을 이름 자리에 내보내지 않는다(G-07). name: publicName(doc.data()['nickname'], null), pop: doc.data().intOr('contributedPop'), isMe: doc.id == uid, ), ]; } @override Future countryRanking({ required FpiMetric metric, int limit = 30, }) async { // 설계서 §10 「랭킹 사전 집계 — 랭킹 화면은 이 문서 1건만 읽는다」. // 국가는 사용자 프로필 `country`, 없으면 `ZZ`(미지정) 버킷이다. final doc = await _backend.db .doc('rankings/country-${_axisKey(metric)}') .get(); final rows = (doc.data() ?? const {}).mapsOr('rows'); if (rows.isEmpty) { return const FpiRanking(rows: [], mockNote: '국가별 집계가 아직 없습니다'); } // 내 국가는 멤버 문서에 비정규화된 `countryCode` 다(집계가 세는 필드와 같다). // 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을 // 찾아 올린다. final me = (await _backend.db .doc('fandoms/$fandomId/members/${_backend.requireUid}') .get()) .data(); final myCode = (me ?? const {}).strOr('countryCode', 'ZZ'); final list = []; for (final (i, row) in rows.take(limit).indexed) { final code = row.strOr('code', 'ZZ'); list.add( FpiRankRow( id: code, rank: row.intOr('rank', i + 1), // 표시명은 코드에서 만든다 — 서버는 코드만 안다([countryName]). name: countryName(code), value: row.intOr('value'), change: row.intOr('change'), isMine: code == myCode, ), ); } return FpiRanking( rows: list, mine: list.where((r) => r.isMine).firstOrNull, updatedAt: _time(doc.data()?['updatedAt']), refreshMinutes: 5, ); } @override Future> morePosts({ required int offset, int limit = pageSize, }) { // offset 0 은 「처음부터 다시」라는 뜻이다. 커서를 버리고 첫 장을 읽는다. if (offset == 0) _cursor = null; return _page(limit); } /// 한 장을 읽되 **차단한 작성자의 글은 뺀다.** 한 장이 통째로 빠지면 화면이 /// 「끝」으로 오인하므로 서버에 글이 남아 있는 동안 다음 장을 더 읽는다. Future> _page(int limit) async { final blocked = await _blockedUids(); final out = >>[]; while (out.length < limit) { final snap = await _pageQuery(limit: limit).get(); if (snap.docs.isNotEmpty) _cursor = snap.docs.last; out.addAll( snap.docs.where((d) => !blocked.contains(d.data()['authorUid'])), ); if (snap.docs.length < limit) break; } return Future.wait(out.map(_post)); } Query> _pageQuery({int limit = pageSize}) { var query = _backend.db .collection('posts') .where('fandomId', isEqualTo: fandomId) // 설계서 §2.6 — `visibility` 는 visible | hidden | deleted. 규칙이 새 글을 // `visible` 로만 만들게 하므로 목록도 같은 값을 본다. .where('visibility', isEqualTo: 'visible') // 명세 — 최신순 소팅. 인덱스는 firestore.indexes.json 의 // (fandomId, visibility, createdAt desc) 가 받는다. .orderBy('createdAt', descending: true); final cursor = _cursor; // 커서를 먼저 걸고 마지막에 개수를 자른다. 순서를 뒤집으면 앞 5개를 자른 // 결과에 커서를 다시 대는 셈이 되어 다음 장이 비어 나온다. if (cursor != null) query = query.startAfterDocument(cursor); return query.limit(limit); } static Map _map(Map data, String key) => Map.from( (data[key] as Map?) ?? const {}, ); /// 내가 차단한 UID. 저장소 하나가 행성 상세 하나와 수명이 같아 한 번만 읽는다. Set? _blocked; Future> _blockedUids() async => _blocked ??= { for (final d in (await _backend.db .collection('users/${_backend.requireUid}/blocks') .get()) .docs) d.id, }; Future _post(DocumentSnapshot> doc) async { final data = doc.data() ?? const {}; final uid = _backend.requireUid; final liked = (await doc.reference.collection('likes').doc(uid).get()).exists; final author = data['authorUid']; final images = [ for (final src in [...?(data['imageUrls'] as List?)?.whereType()]) ?await _imageUrl(src), ]; return LoungePost( id: doc.id, authorUid: author is String ? author : null, isMine: author == uid, liked: liked, authorName: publicName(data['authorName']), createdAt: data.dateOr('createdAt', DateTime.now()), title: data.strOr('title'), body: data.strOr('body'), likes: data.intOr('likeCount'), comments: data.intOr('commentCount'), images: images, ); } /// 글 사진 칸의 값 → 화면이 그릴 URL. 새 글은 Storage **경로**를 담는다(규칙이 /// 칸마다 정확한 경로만 받는다). 예전 글 · 시드의 `http` 주소는 그대로 쓴다. /// 지워졌거나 읽지 못한 사진은 칸을 뺀다 — 깨진 칸을 그리지 않는다. /// /// ponytail: 사진마다 URL 조회가 한 번씩 붙는다. 목록이 무거워지면 URL 을 /// 트리거가 글 문서에 비정규화해 적는다. Future _imageUrl(String src) async { if (src.startsWith('http')) return src; try { return await _backend.storage.ref(src).getDownloadURL(); } catch (_) { return null; } } /// 작성자 표시명 = 공개 닉네임(`publicName`, 없으면 「팬」). 공급자 실명 /// (`displayName`)은 쓰지 않는다(M-7). 규칙이 같은 계산으로 대조한다. Future _myName() async => storedPublicName( (await _backend.db.doc('users/${_backend.requireUid}').get()) .data()?['nickname'], ); /// 글 쓰기 — 사진 업로드 → 글 문서 한 건. /// /// 사진은 `users/{uid}/lounge/` 아래에 올린다. 경로 첫 칸이 UID 여야 Storage /// 규칙이 본인 쓰기로 받는다. 글 저장이 거부되면(다른 팬덤 · 사칭 등) 올린 /// 사진을 지워 고아 파일을 남기지 않는다. @override Future createPost( String body, { List photos = const [], }) async { checkLoungePhotos(photos); final uid = _backend.requireUid; final ref = _backend.db.collection('posts').doc(); final uploaded = []; final paths = []; try { for (final (i, photo) in photos.indexed) { // 파일 이름 = 글 ID-칸 번호. Storage · Firestore 규칙이 이 모양만 받는다. final path = 'users/$uid/lounge/${ref.id}-$i'; final file = _backend.storage.ref(path); uploaded.add(file); await file.putData( photo.bytes, SettableMetadata(contentType: photo.contentType), ); paths.add(path); } } catch (error) { // 어떤 실패든 올린 것부터 치운다. 절반만 올라간 사진을 남기지 않는다. await _discard(uploaded); // Storage 규칙이 용량 · 형식을 서버에서 한 번 더 본다(B-44). throw PhotoRejected( error is FirebaseException && error.code == 'unauthorized' ? appL10n.photoRejectedServer : appL10n.photoUploadFailed, ); } try { await ref.set({ 'fandomId': fandomId, 'authorUid': uid, 'authorName': await _myName(), 'body': body, 'imageUrls': paths, 'likeCount': 0, 'commentCount': 0, 'visibility': 'visible', 'createdAt': FieldValue.serverTimestamp(), 'updatedAt': FieldValue.serverTimestamp(), }); } catch (_) { await _discard(uploaded); rethrow; } return _post(await ref.get()); } static Future _discard(List files) async { for (final f in files) { try { await f.delete(); } on FirebaseException { // 지우기 실패는 원래 오류를 가리지 않게 삼킨다 — 원인은 앞의 실패다. } } } @override Future updatePost(LoungePost post, String body) async { await _backend.db.doc('posts/${post.id}').update({ 'body': body, 'updatedAt': FieldValue.serverTimestamp(), }); return post.copyWith(body: body); } /// 글 삭제 — 규칙이 작성자 본인만 허용한다. 좋아요 · 댓글 · 사진은 /// 서버 트리거(`fnPostCleanup`)가 지운다. @override Future deletePost(LoungePost post) => _backend.db.doc('posts/${post.id}').delete(); /// 좋아요 — 문서 ID = uid 로 한 사람 한 번. `likeCount` 는 서버 트리거 /// (`fnPostLikeCount`)가 하위 문서 수를 다시 세어 적는다 — 앱은 쓰지 못한다. @override Future toggleLike(LoungePost post) async { final like = _backend.db.doc( 'posts/${post.id}/likes/${_backend.requireUid}', ); if (post.liked) { await like.delete(); } else { await like.set({'createdAt': FieldValue.serverTimestamp()}); } return post.copyWith( liked: !post.liked, likes: post.likes + (post.liked ? -1 : 1), ); } @override Future> comments(String postId) async { final uid = _backend.requireUid; final blocked = await _blockedUids(); final snap = await _backend.db .collection('posts/$postId/comments') .where('visibility', isEqualTo: 'visible') .orderBy('createdAt') .get(); return [ for (final d in snap.docs) if (!blocked.contains(d.data()['authorUid'])) _comment(d, uid), ]; } static LoungeComment _comment( DocumentSnapshot> d, String uid, ) { final data = d.data() ?? const {}; return LoungeComment( id: d.id, authorName: publicName(data['authorName']), body: data.strOr('body'), createdAt: data.dateOr('createdAt', DateTime.now()), isMine: data['authorUid'] == uid, ); } @override Future addComment(String postId, String body) async { final uid = _backend.requireUid; final ref = await _backend.db.collection('posts/$postId/comments').add({ 'authorUid': uid, 'authorName': await _myName(), 'body': body, 'visibility': 'visible', 'createdAt': FieldValue.serverTimestamp(), 'updatedAt': FieldValue.serverTimestamp(), }); return _comment(await ref.get(), uid); } @override Future deleteComment(String postId, String commentId) => _backend.db.doc('posts/$postId/comments/$commentId').delete(); /// 신고 — 설계서 §2.6 `reports`. 문서 ID 를 「대상 + 신고자」로 고정해 같은 /// 사람이 같은 글을 두 번 세지 못하게 한다(B-45 「신고자 수」 집계의 전제). /// 두 번째 접수는 규칙이 덮어쓰기를 막아 거부된다. @override Future reportPost(LoungePost post, 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/post-${post.id}-$uid').set({ 'reporterUid': uid, 'targetType': 'post', 'targetPath': 'posts/${post.id}', 'reasonCode': code, 'status': 'pending', 'createdAt': FieldValue.serverTimestamp(), }); } on FirebaseException catch (error) { if (error.code == 'permission-denied') { throw BackendException('E-PERM-01', appL10n.reportAlready); } rethrow; } } /// 차단 — MY 의 차단 목록과 같은 경로 · 같은 메서드를 쓴다. @override Future blockAuthor(LoungePost post) async { final target = post.authorUid; if (target == null) { throw BackendException('E-PERM-01', appL10n.blockUnavailable); } await FirestoreMyRepository( _backend, ).block(target, nickname: post.authorName); (await _blockedUids()).add(target); } }