QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
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>{};
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user