489 lines
18 KiB
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);
|
|
}
|
|
}
|