Files
fanuzu/app/lib/features/campaign/campaign_repository_firestore.dart

489 lines
18 KiB
Dart

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<String> _queries = [];
// ponytail: 공개 캠페인을 한 번에 최대 100건 읽어 앱에서 정렬·검색·페이징한다.
// 캠페인이 늘면 (status, createdAt DESC) 인덱스 + 커서 페이징으로 옮긴다.
Future<List<CampaignCardData>> _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 <String>{} : 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<String, dynamic> 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<List<CampaignCardData>> 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<List<CampaignCardData>> more({
required int offset,
int limit = 10,
}) async => (await _all()).skip(offset).take(limit).toList();
@override
Future<CampaignDetail> 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<CampaignReport?> _report(String campaignId, DateTime fallback) async {
final m =
(await _backend.db.doc('campaigns/$campaignId/reports/main').get())
.data();
if (m == null) return null;
Future<List<String>> 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<String, dynamic>)
(label: l.strOr('label'), amount: l.intOr('amount')),
],
createdAt: m.dateOr('createdAt', fallback),
);
}
@override
Future<List<ContributorRank>> 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 = <String, int>{};
final names = <String, String>{};
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');
// 서버가 후원 때 실은 공개 닉네임(`donorName`, M-7). 예전 후원에는 없어 「팬」이다.
if (m.strOr('donorName').isNotEmpty) names[uid] = m.strOr('donorName');
}
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 · 이메일은 화면에 내보내지 않는다(G-07) — 공개 닉네임만.
name: rows[i].key == me
? appL10n.commonMe
: publicName(names[rows[i].key]),
pop: rows[i].value,
isMe: rows[i].key == me,
),
];
}
@override
Future<List<String>> recentQueries() async => [..._queries];
@override
Future<void> clearQueries() async => _queries.clear();
@override
Future<void> removeQuery(String query) async => _queries.remove(query);
@override
Future<List<CampaignCardData>> 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<String, String> _pendingSeq = {};
/// 후원 — `fn-campaign-fund`. 기여 POP 만 쓴다(정책 원장 B-33).
/// 실패는 [BackendException] 으로 그대로 올린다 — 화면이 성공처럼 그리지 않게.
@override
Future<int> 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<int> 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<int> 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<void> 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<List<CampaignCardData>> 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<void> withdraw(String campaignId) async {
await _backend.call('fnCampaignWithdraw', {'campaignId': campaignId});
}
/// 개설 — `fn-campaign-create`. 서버가 자격(B-56)을 보고 등록비(B-57)를 뺀 뒤
/// 심사 대기(`review`)로 올린다. 규칙은 클라이언트 직접 생성을 막는다.
/// 응답이 유실된 재시도는 같은 요청 번호로 보내 등록비가 두 번 빠지지 않게 한다.
@override
Future<String> 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<Map<String, dynamic>>? _commentCursor;
/// 내가 차단한 UID. 목록 · 댓글 장을 읽을 때마다 새로 읽는다 — MY 에서 차단을
/// 풀거나 다른 화면에서 차단해도 저장소 캐시가 낡지 않는다.
Future<Set<String>> _blockedUids() async => {
for (final d
in (await _backend.db
.collection('users/${_backend.requireUid}/blocks')
.get())
.docs)
d.id,
};
/// 최신순 한 장. 차단한 작성자의 댓글이 한 장을 다 채우면 화면이 「끝」으로
/// 오인하므로 서버에 댓글이 남아 있는 동안 다음 장을 더 읽는다(라운지와 같은 방식).
@override
Future<List<CampaignComment>> comments(
String campaignId, {
bool more = false,
}) async {
if (!more) _commentCursor = null;
final uid = _backend.requireUid;
final blocked = await _blockedUids();
final out = <CampaignComment>[];
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<CampaignComment> _comment(
DocumentSnapshot<Map<String, dynamic>> d,
String uid,
) async {
final data = d.data() ?? const <String, dynamic>{};
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<CampaignComment> 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<void> deleteComment(String campaignId, String commentId) =>
_backend.db.doc('campaigns/$campaignId/comments/$commentId').delete();
/// 댓글 좋아요 — `comments/{cid}/likes/{uid}`. 수는 서버 트리거가 센다.
@override
Future<void> 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<void> reportComment(
String campaignId,
CampaignComment comment,
String reason,
) => _fileReport(
'campaignComment',
'$campaignId-${comment.id}',
'campaigns/$campaignId/comments/${comment.id}',
reason,
);
/// 캠페인 신고 — ID `campaign-{캠페인}-{신고자}`.
@override
Future<void> reportCampaign(String campaignId, String reason) =>
_fileReport('campaign', campaignId, 'campaigns/$campaignId', reason);
Future<void> _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<bool> 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<void> blockAuthor(CampaignComment comment) =>
_block(comment.authorUid, comment.authorName);
/// 개설자 차단 — 댓글 작성자 차단과 같은 목록이라 그 개설자의 댓글도 빠진다.
@override
Future<void> blockCreator(CampaignCardData card) =>
_block(card.creatorUid, card.authorName);
Future<void> _block(String? target, String nickname) async {
if (target == null) {
throw BackendException('E-PERM-01', appL10n.blockUnavailable);
}
await my.block(target, nickname: nickname);
}
}