836 lines
32 KiB
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);
|
|
}
|
|
}
|