광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원
This commit is contained in:
@@ -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'),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user