Files
fanuzu/app/lib/features/planet/planet_repository_firestore.dart

836 lines
32 KiB
Dart

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<PlanetHistoryPage> 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 = <PlanetHistoryEntry>[];
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<HeroDropReceipt> 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<Map<String, dynamic>>? _cursor;
/// 집계 축 키. 지표 이름을 그대로 쓴다 — 설계서 §4.1 의 `fpiAxes` 키와 같다.
/// 두 벌을 두면 집계 축과 화면 표기가 갈린다.
static String _axisKey(FpiMetric metric) => metric.name;
@override
Future<void> join() => _backend.call('fnFandomJoin', {'fandomId': fandomId});
@override
Future<void> leave() =>
_backend.call('fnFandomLeave', {'fandomId': fandomId});
@override
Future<PlanetDetail> 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 <String, dynamic>{};
final snapshots = (await snapshotF).docs;
final memberSnap = await memberF;
final member = memberSnap.data() ?? const <String, dynamic>{};
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 <String, dynamic>{}
: 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<FpiRanking> 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<FpiRanking> _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 myId = await _myPlanetId();
final rows = <FpiRankRow>[
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,
myId: myId,
),
];
final mine = rows.where((r) => r.isMine).firstOrNull;
if (mine != null || myId == null) return FpiRanking(rows: rows, mine: mine);
// 상위 [limit] 밖이면 내 행성 문서를 한 번 더 읽어 「소속 행성 지표 랭킹
// 영역」(Figma `546:14628`)을 채운다. 사용자 랭킹의 count() 폴백과 같은
// 취지다 — 내 행성이 몇 등이든 그 영역은 떠야 한다.
final me = (await _backend.db.doc('fandoms/$myId').get()).data();
if (me == null) return FpiRanking(rows: rows);
return FpiRanking(
rows: rows,
// 서버 순위가 없으면 목록 밖이라는 것만 아는 셈이라 0 으로 둔다.
mine: _planetRow(me, id: myId, axis: axis, fallbackRank: 0, myId: myId),
);
}
/// 「내 행성」(소속 행성) — **실제 가입 여부로** 정한다. 보고 있는 행성에
/// 가입했으면 그 행성, 아니면 대표 행성(`users.fandomId`), 둘 다 없으면 null.
/// 보고 있는 행성을 그대로 내 행성으로 두면 미가입 행성 랭킹이 그 행성을
/// 「(나)」로 표시한다(10/6 실기기 C-7).
Future<String?> _myPlanetId() async {
final uid = _backend.requireUid;
final joined =
(await _backend.db.doc('users/$uid/memberships/$fandomId').get())
.exists;
if (joined) return fandomId;
final primary = (await _backend.db.doc('users/$uid').get()).data()?[
'fandomId'];
return primary is String && primary.isNotEmpty ? primary : null;
}
/// 축의 실제 산정값(반올림 전). 순위 비교에만 쓴다 — 표시는 [SnapshotReader.intOr].
static num _raw(Map<String, dynamic> data, String axis) {
final v = _map(data, 'fpiAxes')[axis];
return v is num ? v : 0;
}
/// 행성 랭킹 한 줄. 목록 안이든 밖이든 **같은 필드에서** 만든다.
FpiRankRow _planetRow(
Map<String, dynamic> data, {
required String id,
required String axis,
required int fallbackRank,
required String? myId,
}) {
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 == myId,
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<FpiRanking> 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<FpiRanking> _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 = <FpiRankRow>[
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<String, dynamic> 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<List<ContributorRank>> 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<FpiRanking> 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 <String, dynamic>{}).mapsOr('rows');
if (rows.isEmpty) {
return const FpiRanking(rows: [], mockNote: '국가별 집계가 아직 없습니다');
}
// 내 국가는 사용자 프로필 `users.countryCode` 다. 보고 있는 행성의 멤버
// 문서에서 읽으면 미가입 행성에서는 늘 「미지정」이 된다(10/6 실기기 C-7).
// 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을
// 찾아 올린다.
final me =
(await _backend.db.doc('users/${_backend.requireUid}').get()).data();
final myCode = (me ?? const <String, dynamic>{}).strOr('countryCode', 'ZZ');
final list = <FpiRankRow>[];
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<List<LoungePost>> morePosts({
required int offset,
int limit = pageSize,
}) {
// offset 0 은 「처음부터 다시」라는 뜻이다. 커서를 버리고 첫 장을 읽는다.
if (offset == 0) _cursor = null;
return _page(limit);
}
/// 한 장을 읽되 **차단한 작성자의 글은 뺀다.** 한 장이 통째로 빠지면 화면이
/// 「끝」으로 오인하므로 서버에 글이 남아 있는 동안 다음 장을 더 읽는다.
Future<List<LoungePost>> _page(int limit) async {
final blocked = await _blockedUids();
final out = <QueryDocumentSnapshot<Map<String, dynamic>>>[];
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<Map<String, dynamic>> _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<String, dynamic> _map(Map<String, dynamic> data, String key) =>
Map<String, dynamic>.from(
(data[key] as Map?) ?? const <String, dynamic>{},
);
/// 내가 차단한 UID. 저장소 하나가 행성 상세 하나와 수명이 같아 한 번만 읽는다.
Set<String>? _blocked;
Future<Set<String>> _blockedUids() async => _blocked ??= {
for (final d
in (await _backend.db
.collection('users/${_backend.requireUid}/blocks')
.get())
.docs)
d.id,
};
Future<LoungePost> _post(DocumentSnapshot<Map<String, dynamic>> doc) async {
final data = doc.data() ?? const <String, dynamic>{};
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<String>()])
?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<String?> _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<String> _myName() async => storedPublicName(
(await _backend.db.doc('users/${_backend.requireUid}').get())
.data()?['nickname'],
);
/// 글 쓰기 — 사진 업로드 → 글 문서 한 건.
///
/// 사진은 `users/{uid}/lounge/` 아래에 올린다. 경로 첫 칸이 UID 여야 Storage
/// 규칙이 본인 쓰기로 받는다. 글 저장이 거부되면(다른 팬덤 · 사칭 등) 올린
/// 사진을 지워 고아 파일을 남기지 않는다.
@override
Future<LoungePost> createPost(
String body, {
List<PickedPhoto> photos = const [],
}) async {
checkLoungePhotos(photos);
final uid = _backend.requireUid;
final ref = _backend.db.collection('posts').doc();
final uploaded = <Reference>[];
final paths = <String>[];
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<void> _discard(List<Reference> files) async {
for (final f in files) {
try {
await f.delete();
} on FirebaseException {
// 지우기 실패는 원래 오류를 가리지 않게 삼킨다 — 원인은 앞의 실패다.
}
}
}
@override
Future<LoungePost> 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<void> deletePost(LoungePost post) =>
_backend.db.doc('posts/${post.id}').delete();
/// 좋아요 — 문서 ID = uid 로 한 사람 한 번. `likeCount` 는 서버 트리거
/// (`fnPostLikeCount`)가 하위 문서 수를 다시 세어 적는다 — 앱은 쓰지 못한다.
@override
Future<LoungePost> 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<List<LoungeComment>> 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<Map<String, dynamic>> d,
String uid,
) {
final data = d.data() ?? const <String, dynamic>{};
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<LoungeComment> 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<void> deleteComment(String postId, String commentId) =>
_backend.db.doc('posts/$postId/comments/$commentId').delete();
/// 신고 — 설계서 §2.6 `reports`. 문서 ID 를 「대상 + 신고자」로 고정해 같은
/// 사람이 같은 글을 두 번 세지 못하게 한다(B-45 「신고자 수」 집계의 전제).
/// 두 번째 접수는 규칙이 덮어쓰기를 막아 거부된다.
@override
Future<void> 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<void> 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);
}
}