광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:09 +09:00
parent 4bb92c2ee7
commit fbf9a2c3d7
39 changed files with 1871 additions and 477 deletions
@@ -77,8 +77,8 @@ class FirestoreQuestRepository implements QuestRepository {
final snap = await query.get();
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
// 타임어택 창은 서버 시각이다. 타임어택이 있을 때만 서버 시계를 잰다(세션당 1회).
if (snap.docs.any((d) => d.data()['type'] == 'timeattack')) {
// 타임어택 창은 서버 시각이다. 회차가 붙은 Quest 가 있을 때만 서버 시계를 잰다(세션당 1회).
if (snap.docs.any((d) => d.data()['timeAttack'] is Map)) {
await ServerClock.ensure(_backend);
}
final now = ServerClock.now();
@@ -133,6 +133,9 @@ class FirestoreQuestRepository implements QuestRepository {
// 사용자 생성 Quest — 공개 전(Draft) · 취소는 감추고, 마감분은 생성자의 「내 퀘스트」에만 남긴다.
final status = quest.strOr('status');
if (status == 'draft' || status == 'cancelled') return false;
final mineFirst = quest.strOr('authorUid') == uid;
// 공개 전 검수 대기 · 운영 반려는 생성자에게만 보인다(승인 후 공개 — 1주차 4항 · Q8).
if (!mineFirst && (status == 'review' || status == 'rejected')) return false;
final mine = quest.strOr('authorUid') == uid;
final endsAt = quest['endsAt'];
if (!mine && (status == 'ended' || (endsAt is Timestamp && !now.isBefore(endsAt.toDate())))) {
@@ -186,7 +189,7 @@ class FirestoreQuestRepository implements QuestRepository {
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1),
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
progress: type.gathers ? (done ? goal : 0) : count,
rewardPop: type.rewardFrom(quest, policy),
rewardPop: openTimeAttack(quest)?.rewardPop ?? type.rewardFrom(quest, policy),
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
participants: quest.intOr('participantCount'),
// 사용자 생성 Quest 의 모집 인원(`capacity`). 운영 Quest 에는 없다.
@@ -194,9 +197,8 @@ class FirestoreQuestRepository implements QuestRepository {
// 광고 안내값 — 서버 `fnAdReward` 와 같은 원천(B-03 · C-02 운영값).
adMultiplier: quest.intOr('adMultiplier', policy.intOr('adMultiplier', 2)),
ugcAdPop: policy.intOr('adRewardPop', 10),
endsAt: type == QuestType.timeAttack && quest['activeTo'] is Timestamp
? (quest['activeTo'] as Timestamp).toDate()
: null,
// 열린 TIME ATTACK 회차의 마감. 있으면 카드 · 상세가 남은 시간을 세고 참여는 회차로 간다.
endsAt: openTimeAttack(quest)?.endsAt,
);
}
@@ -243,6 +245,10 @@ class FirestoreQuestRepository implements QuestRepository {
repeatMax: repeat?['allowed'] == true ? repeat!.intOr('maxCount', 1) : 1,
submission: submission,
reviews: quest.strOr('authorUid') == uid ? await _reviews(questId, uid) : const [],
publishState: quest.strOr('status'),
moderationReason: quest['moderation'] is Map
? (quest['moderation'] as Map)['reason'] as String?
: null,
);
}
@@ -260,7 +266,10 @@ class FirestoreQuestRepository implements QuestRepository {
? Map<String, dynamic>.from(evidence.last as Map)
: const <String, dynamic>{};
final at = m['resubmittedAt'] ?? m['submittedAt'];
final paidAt = m['paidAt'];
return QuestSubmission(
txId: m.strOr('txId').isEmpty ? null : m.strOr('txId'),
paidAt: paidAt is Timestamp ? paidAt.toDate() : null,
id: id,
status: m.strOr('status'),
submittedAt: at is Timestamp ? at.toDate() : null,
@@ -270,13 +279,34 @@ class FirestoreQuestRepository implements QuestRepository {
);
}
/// 내 제출 건. 사용자 생성 Quest 만 있다(문서 ID `{questId}_{uid}`).
/// 내 제출 건(문서 ID `{questId}_{uid}`). 사용자 생성 Quest 와 운영 Vote · SNS · Community Quest 에 있다.
Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async {
if (quest.strOr('authorUid').isEmpty) return null;
final type = QuestType.parse(quest.strOr('type'));
if (quest.strOr('authorUid').isEmpty && !type.reviewed && type != QuestType.community) {
return null;
}
final id = '${questId}_${_backend.requireUid}';
try {
final data = (await _backend.db.doc('questSubmissions/$id').get()).data();
return data == null ? null : _submissionOf(id, data);
if (data == null) return null;
final sub = _submissionOf(id, data);
final txId = sub.txId;
if (txId == null) return sub;
final claimed = (await _backend.db
.doc('users/${_backend.requireUid}/adBonuses/$txId')
.get())
.exists;
return QuestSubmission(
id: sub.id,
status: sub.status,
submittedAt: sub.submittedAt,
rejectReason: sub.rejectReason,
resubmitAllowed: sub.resubmitAllowed,
summary: sub.summary,
txId: txId,
paidAt: sub.paidAt,
adClaimed: claimed,
);
} on Exception {
// 규칙 배포 전(없는 문서 get 이 permission-denied) 등 — 제출 없음으로 본다.
return null;
@@ -365,7 +395,13 @@ class FirestoreQuestRepository implements QuestRepository {
Future<List<QuestPlace>> places() async {
final snap = await _backend.db.collection('places').get();
return [
for (final d in snap.docs) QuestPlace(id: d.id, name: d.data().strOr('name', d.id)),
for (final d in snap.docs)
QuestPlace(
id: d.id,
name: d.data().strOr('name', d.id),
lat: (d.data()['geo'] as GeoPoint?)?.latitude,
lng: (d.data()['geo'] as GeoPoint?)?.longitude,
),
];
}
@@ -394,6 +430,10 @@ class FirestoreQuestRepository implements QuestRepository {
'resubmitAllowed': resubmitAllowed,
});
@override
Future<void> cancel(String questId) =>
_backend.call('fnQuestCancel', {'questId': questId});
@override
Future<void> edit(String questId, {required String title, required String description}) =>
_backend.call('fnQuestEdit', {
@@ -433,6 +473,9 @@ class FirestoreQuestRepository implements QuestRepository {
if (draft.url != null) 'url': draft.url,
if (draft.manualTitle != null) 'manualTitle': draft.manualTitle,
if (draft.placeId != null) 'placeId': draft.placeId,
if (draft.pin case final pin?)
'pin': {'lat': pin.lat, 'lng': pin.lng, 'address': pin.name},
if (draft.goal != null) 'goal': draft.goal,
});
return result.strOr('questId');
}
@@ -456,10 +499,13 @@ class FirestoreQuestRepository implements QuestRepository {
@override
Future<QuestReward> verify(QuestCard card) async {
final reason = card.type.blockedReason;
// 열린 TIME ATTACK 회차 — 원 Quest 유형과 무관하게 회차 참여로 보낸다(10/3 통합회신 Q-TA-01).
final reason = card.endsAt != null ? null : card.type.blockedReason;
if (reason != null) throw BackendException('E-QST-01', reason);
final result = switch (card.type) {
final result = card.endsAt != null
? await _backend.call('fnQuestTimeAttack', {'questId': card.id})
: switch (card.type) {
// **퀘스트 문서 ID 를 함께 보낸다.** 서버가 진행 문서를 이 ID 로 쓰고 일일 한도를
// `quests/{questId}.dailyLimit` 에서 읽는다. 보내지 않으면 서버는 유형 이름에
// 쓰고 앱은 문서 ID 로 읽어, 지급은 되는데 화면은 「시작하기」에 머문다.
@@ -468,8 +514,6 @@ class FirestoreQuestRepository implements QuestRepository {
QuestType.place => await _verifyPlace(card),
// Community 는 서버가 응원글 · 반응을 확인해 즉시 지급한다(C-11). 증거를 싣지 않는다.
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
QuestType.timeAttack =>
await _backend.call('fnQuestTimeAttack', {'questId': card.id}),
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
QuestType.streaming =>
await _backend.call('fnQuestStreamParticipate', {'questId': card.id}),
@@ -486,7 +530,16 @@ class FirestoreQuestRepository implements QuestRepository {
}
@override
Future<RewardedAdHandle> loadRewardedAd() => loadGoogleRewardedAd();
Future<RewardedAdHandle> loadRewardedAd(QuestReward base) {
final baseTxId = base.txId;
if (baseTxId == null) {
throw BackendException('E-QST-01', appL10n.questNoBaseReward);
}
return loadGoogleRewardedAd(
userId: _backend.requireUid,
customData: baseTxId,
);
}
@override
Future<int> claimAdReward(String impressionId, QuestReward base) async {
@@ -494,12 +547,22 @@ class FirestoreQuestRepository implements QuestRepository {
if (baseTxId == null) {
throw BackendException('E-QST-01', appL10n.questNoBaseReward);
}
final result = await _backend.call('fnAdReward', {
'adImpressionId': impressionId,
'baseTxId': baseTxId,
'network': 'admob',
});
return result.intOr('rewarded');
// 시청 확인은 Google 이 서버로 보내는 SSV 콜백이 한다. 콜백은 광고가 닫힌 뒤 몇 초 안에
// 도착하므로, 아직이면(E-AD-03) 잠시 기다렸다 다시 묻는다. 끝내 없으면 증분 없이 끝난다(B-03).
for (var attempt = 0; ; attempt++) {
try {
final result = await _backend.call('fnAdReward', {'baseTxId': baseTxId});
return result.intOr('rewarded');
} on BackendException catch (error) {
if (error.code != 'E-AD-03' || attempt >= 5) {
if (error.code == 'E-AD-03') {
throw BackendException('E-AD-03', appL10n.adNotVerified);
}
rethrow;
}
await Future<void>.delayed(const Duration(seconds: 2));
}
}
}
Future<Map<String, dynamic>> _verifySteps(QuestCard card) async {
@@ -655,6 +718,12 @@ List<int> _stepTiers(Map<String, dynamic> policy) {
DateTime? now,
]) {
final p = progress ?? const <String, dynamic>{};
// 열린 TIME ATTACK 회차는 회차 단위 1인 1회다 — 그날 원 Quest 를 했어도 회차는 따로 받는다.
final round = openTimeAttack(quest);
if (round != null) {
final joined = p.strOr('timeAttackRoundId') == round.roundId;
return (count: joined ? 1 : 0, done: joined);
}
final sameDay = type == QuestType.timeAttack ||
p.strOr('dayKey') == _dayKey(now ?? DateTime.now());
final count = sameDay ? p.intOr('count') : 0;
@@ -663,3 +732,24 @@ List<int> _stepTiers(Map<String, dynamic> policy) {
: count >= quest.intOr('dailyLimit', 1);
return (count: count, done: done);
}
/// 열린 TIME ATTACK 회차 — 원 Quest 문서의 `timeAttack`(10/3 통합회신: 별도 유형이 아니라
/// 기존 Quest 에 붙는 제한시간 모드). 창(`activeFrom` ~ `activeTo`, 서버 시각) 밖이면 null.
/// 화면 표시용이다 — 참여 수락은 서버(`fn-quest-timeattack`)가 서버 시각으로 다시 판정한다.
({String roundId, DateTime endsAt, int rewardPop})? openTimeAttack(
Map<String, dynamic> quest,
) {
final raw = quest['timeAttack'];
if (raw is! Map) return null;
final round = Map<String, dynamic>.from(raw);
final from = round['activeFrom'];
final to = round['activeTo'];
if (from is! Timestamp || to is! Timestamp) return null;
final now = ServerClock.now();
if (now.isBefore(from.toDate()) || !now.isBefore(to.toDate())) return null;
return (
roundId: round.strOr('roundId'),
endsAt: to.toDate(),
rewardPop: round.intOr('rewardPop'),
);
}