import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import '../../core/data/backend.dart'; import '../../core/data/server_clock.dart'; import '../../core/monetization/rewarded_ad.dart'; import 'quest_data.dart'; import 'quest_evidence.dart'; import '../../core/i18n/app_locale.dart'; /// 퀘스트의 실 서버 구현 — 화면 대조 원장 F4. /// /// **읽기는 Firestore, 재화가 오가는 쓰기는 Cloud Functions.** 별첨 1 ② 가 /// 「클라이언트 단독 지급 0건」을 판정 항목으로 두었으므로 보상은 전부 /// `fn-quest-attend` · `fn-quest-verify-steps` · `fn-quest-verify-place` 를 지난다. /// 이 파일에 잔액·원장을 쓰는 코드는 없다. /// /// 읽는 곳 /// - 정의 `quests/{questId}` (설계서 §2.8 — `type` · `title` · `rewardPop` · `dailyLimit`) /// - 진행 `users/{uid}/questProgress/{questId}` (설계서 §2.1 — `dayKey` · `count`) /// - 운영값 `config/policy` (걸음 목표. 앱에 하드코딩하지 않는다 — 별첨 B 「운영값 관리 원칙」) /// /// **설계서에 없어서 채우지 못한 것**은 0 이나 빈 값으로 두고 보고서에 남겼다 — /// 참여 인원·목록 정렬 키·분류 마스터·위치 퀘스트의 장소 연결 필드가 그렇다. /// 없는 필드를 여기서 만들면 Admin 이 쓰지 않는 값을 앱만 읽는 상태가 된다. class FirestoreQuestRepository implements QuestRepository { FirestoreQuestRepository( this._backend, { StepCountProbe stepCount = deviceStepCount, PlaceProbe placeFix = devicePlaceFix, WalkBaseline? baseline, }) : _stepCount = stepCount, _placeFix = placeFix, _baseline = baseline ?? WalkBaseline(); final Backend _backend; final StepCountProbe _stepCount; final PlaceProbe _placeFix; /// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다. final WalkBaseline _baseline; /// 마지막 페이지의 끝 문서. `offset` 대신 커서로 넘긴다 — Firestore 에 offset 이 없다. DocumentSnapshot>? _cursor; /// 오늘 걸은 수. 마지막으로 센서에서 받은 값이며 서버에 그대로 보낸다. int _walkSteps = 0; @override Future> load({int limit = 10}) async { _cursor = null; return _page(limit); } @override Future> more({required int offset, int limit = 10}) => _page(limit); Future> _page(int limit) async { final uid = _backend.requireUid; final db = _backend.db; // 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는 // 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다). // `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다. var query = db.collection('quests').limit(limit); final cursor = _cursor; if (cursor != null) query = query.startAfterDocument(cursor); final snap = await query.get(); if (snap.docs.isNotEmpty) _cursor = snap.docs.last; // 타임어택 창은 서버 시각이다. 타임어택이 있을 때만 서버 시계를 잰다(세션당 1회). if (snap.docs.any((d) => d.data()['type'] == 'timeattack')) { await ServerClock.ensure(_backend); } final now = ServerClock.now(); final policy = await _policy(); // 명세 `518:88395` 의 *퀘스트 생성 최신순* 은 페이지 안에서만 맞춘다. 서버 정렬은 // 걸지 않았다 — `quests` 에 정렬 키가 정해져 있지 않고, `orderBy` 를 걸면 그 필드가 // 없는 문서가 목록에서 통째로 빠진다. final docs = [...snap.docs]..sort((a, b) { final left = _writtenAt(a.data()); final right = _writtenAt(b.data()); if (left == null && right == null) return 0; if (left == null) return 1; if (right == null) return -1; return right.compareTo(left); }); final cards = []; for (final doc in docs) { final quest = doc.data(); if (!_open(quest, now, uid)) continue; // 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다. // UGC 퀘스트 하나의 제출 건 조회가 거부되어 **모든 사용자의** 목록이 무한 로딩이었다(2026-10-04). Map progress; try { progress = await db .doc('users/$uid/questProgress/${doc.id}') .get() .then((s) => s.data() ?? const {}); } on Exception { progress = const {}; } cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest))); } return cards; } /// 문서가 쓰인 시각. Admin 이 쓴 문서에는 `createdAt` 이 없고 `updatedAt` 만 있다 /// (`fn-admin-write`). 둘 다 없으면 정렬 근거가 없다는 뜻이라 null 이다. DateTime? _writtenAt(Map quest) { for (final key in ['createdAt', 'updatedAt']) { final value = quest[key]; if (value is Timestamp) return value.toDate(); } return null; } /// 지금 참여할 수 있는 기간인가 — 설계서 §2.8 `activeFrom`/`activeTo`. /// /// 두 필드가 없는 문서도 있어(시연 데이터는 `active` 불리언을 쓴다) **있는 /// 조건만 본다.** 없는 필드를 근거로 감추면 목록이 이유 없이 비어 보인다. bool _open(Map quest, DateTime now, String uid) { // 사용자 생성 Quest — 공개 전(Draft) · 취소는 감추고, 마감분은 생성자의 「내 퀘스트」에만 남긴다. final status = quest.strOr('status'); if (status == 'draft' || status == 'cancelled') return false; final mine = quest.strOr('authorUid') == uid; final endsAt = quest['endsAt']; if (!mine && (status == 'ended' || (endsAt is Timestamp && !now.isBefore(endsAt.toDate())))) { return false; } if (mine && status.isNotEmpty) return true; if (quest['active'] == false) return false; final from = quest['activeFrom']; if (from is Timestamp && now.isBefore(from.toDate())) return false; final to = quest['activeTo']; if (to is Timestamp && now.isAfter(to.toDate())) return false; return true; } QuestCard _card( String id, Map quest, Map progress, String uid, Map policy, [ QuestSubmission? submission, ]) { final type = QuestType.parse(quest.strOr('type')); final authorUid = quest.strOr('authorUid'); final dailyLimit = quest.intOr('dailyLimit', 1); final (:count, :done) = questToday(type, quest, progress, policy); final tiers = _stepTiers(policy); final steps = type == QuestType.steps; final goal = steps ? tiers[count.clamp(0, tiers.length - 1)] : dailyLimit; return QuestCard( id: id, title: _title(quest), categoryName: type.label, type: type, author: authorUid.isEmpty ? QuestAuthor.admin : QuestAuthor.user, stage: authorUid == uid ? QuestStage.mine : done || submission?.status == 'paid' ? QuestStage.rewarded // 검토 대기는 「진행중」 탭에 둔다 — 지급 전이라 완료가 아니다(G-03). : submission?.pending == true ? QuestStage.inProgress : QuestStage.notStarted, goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1), // 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다. progress: steps ? (done ? goal : 0) : count, rewardPop: type.rewardFrom(quest, policy), // 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다. participants: quest.intOr('participantCount'), // 사용자 생성 Quest 의 모집 인원(`capacity`). 운영 Quest 에는 없다. capacity: quest['capacity'] is num ? (quest['capacity'] as num).round() : null, // 광고 안내값 — 서버 `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, ); } /// 다국어 제목 — 설계서 §2.8 은 `title`(다국어)이라고만 적었다. /// 문자열과 지도(map) 둘 다 들어올 수 있어 둘 다 받는다. String _title(Map quest) { final raw = quest['title']; if (raw is String) return raw; if (raw is Map) { final map = Map.from(raw); return map.strOr('ko', map.strOr('en')); } return ''; } Future> _policy() async { final snap = await _backend.db.doc('config/policy').get(); return snap.data() ?? const {}; } @override Future detail(String questId) async { final uid = _backend.requireUid; final db = _backend.db; final questSnap = await db.doc('quests/$questId').get(); final quest = questSnap.data() ?? const {}; final progress = await db .doc('users/$uid/questProgress/$questId') .get() .then((s) => s.data() ?? const {}); final submission = await _submission(questId, quest); final card = _card(questId, quest, progress, uid, await _policy(), submission); final link = quest['link']; final repeat = quest['repeat'] is Map ? Map.from(quest['repeat'] as Map) : null; final endsAt = quest['endsAt']; return QuestDetail( card: card, imageLabel: card.categoryName, description: quest.strOr('userDescription', quest.strOr('description')), link: link is Map ? _link(Map.from(link)) : null, endsAt: endsAt is Timestamp ? endsAt.toDate() : null, repeatHours: repeat?['allowed'] == true ? repeat!.intOr('intervalHours', 24) : null, repeatMax: repeat?['allowed'] == true ? repeat!.intOr('maxCount', 1) : 1, submission: submission, reviews: quest.strOr('authorUid') == uid ? await _reviews(questId, uid) : const [], ); } QuestLink _link(Map m) => QuestLink( url: m.strOr('url'), status: m.strOr('status'), title: m.strOr('title'), thumbnail: m.strOr('thumbnail').isEmpty ? null : m.strOr('thumbnail'), provider: m.strOr('provider').isEmpty ? null : m.strOr('provider'), ); QuestSubmission _submissionOf(String id, Map m) { final evidence = m['evidence'] is List ? m['evidence'] as List : const []; final last = evidence.isNotEmpty && evidence.last is Map ? Map.from(evidence.last as Map) : const {}; final at = m['resubmittedAt'] ?? m['submittedAt']; return QuestSubmission( id: id, status: m.strOr('status'), submittedAt: at is Timestamp ? at.toDate() : null, rejectReason: m.strOr('rejectReason').isEmpty ? null : m.strOr('rejectReason'), resubmitAllowed: m['resubmitAllowed'] == true, summary: [last.strOr('url'), last.strOr('text')].where((v) => v.isNotEmpty).join('\n'), ); } /// 내 제출 건. 사용자 생성 Quest 만 있다(문서 ID `{questId}_{uid}`). Future _submission(String questId, Map quest) async { if (quest.strOr('authorUid').isEmpty) 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); } on Exception { // 규칙 배포 전(없는 문서 get 이 permission-denied) 등 — 제출 없음으로 본다. return null; } } /// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `questAuthorUid` 를 함께 건다. Future> _reviews(String questId, String uid) async { final snap = await _backend.db .collection('questSubmissions') .where('questId', isEqualTo: questId) .where('questAuthorUid', isEqualTo: uid) .get(); return [ for (final d in snap.docs) if (d.data().strOr('status') == 'submitted') _submissionOf(d.id, d.data()), ]; } /// 분류 마스터(C-01) — 앱 재배포 없이 Admin 이 바꾼 순서 · 노출 · `ugcAllowed` 를 그대로 따른다. /// 사용자가 만들 수 있는 활동이 하나도 없는 분류는 보이지 않는다. @override Future catalog() async { final raw = await _backend.db .doc('config/questCatalog') .get() .then((s) => s.data() ?? const {}); final policy = await _policy(); List> rows(String key) => [ for (final r in (raw[key] is List ? raw[key] as List : const [])) if (r is Map && r['visible'] != false) Map.from(r), ]..sort((a, b) => a.intOr('order').compareTo(b.intOr('order'))); final activities = [for (final a in rows('activities')) if (a['ugcAllowed'] == true) a]; final categories = []; for (final c in rows('categories')) { final mine = [ for (final a in activities) if (a.strOr('categoryKey') == c.strOr('key')) QuestActivity( key: a.strOr('key'), name: _activityName(a.strOr('key'), a.strOr('label', a.strOr('key'))), fields: { for (final f in (a['inputSchema'] is List ? a['inputSchema'] as List : const [])) if (f is Map) '${f['field']}', }, ), ]; if (mine.isEmpty) continue; categories.add(QuestCategory( id: c.strOr('key'), name: _categoryName(c.strOr('key'), c.strOr('label')), activities: mine, )); } final rate = policy['ugcFeeRate']; return QuestCatalog( categories: categories, feeRate: rate is num ? rate.toDouble() : 0.1, feeMin: policy.intOr('ugcFeeMin', 10), feeOnNoReward: policy['ugcFeeOnNoReward'] == true, maxDays: policy.intOr('ugcMaxDays', 30), ); } static String _categoryName(String key, String fallback) => switch (key) { 'health' => appL10n.questCatHealth, 'planet' => appL10n.questCatPlanet, 'community' => appL10n.questCatCommunity, 'location' => appL10n.questTypePlace, _ => fallback, }; static String _activityName(String key, String fallback) => switch (key) { 'walk' => appL10n.questTypeSteps, 'shake' => appL10n.questActShake, 'streaming' => appL10n.questTypeStreaming, 'youtube' => 'YouTube', 'vote' => appL10n.questTypeVote, 'sns' => appL10n.questTypeSns, 'community' => appL10n.questCatCommunity, 'location' => appL10n.questTypePlace, _ => fallback, }; @override Future> 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)), ]; } @override Future previewLink(String url) async => _link(await _backend.call('fnLinkPreview', {'url': url})); @override Future submit(String questId, {String? url, String? text}) => _backend.call('fnQuestSubmit', { 'questId': questId, if (url != null && url.isNotEmpty) 'url': url, if (text != null && text.isNotEmpty) 'text': text, }); @override Future review( String submissionId, { required bool approve, String? reason, bool resubmitAllowed = false, }) => _backend.call('fnQuestReview', { 'submissionId': submissionId, 'action': approve ? 'approve' : 'reject', if (reason != null) 'reason': reason, 'resubmitAllowed': resubmitAllowed, }); @override Future edit(String questId, {required String title, required String description}) => _backend.call('fnQuestEdit', { 'questId': questId, 'title': title, 'description': description, }); /// 시작·중단은 세션 상태다. /// /// 설계서의 `questProgress` 는 **당일 수행 횟수와 지급액**만 담는다(§2.1). 참여 /// 시작 시각을 남기는 필드가 없어 서버에 「진행중」이 존재하지 않는다. 보상 상태는 /// [verify] 가 지급에 성공한 뒤 서버 카운터로 확정된다. @override Future advance(String questId, QuestStage next) async => next; /// 생성 · 예치 · 수수료 · 공개는 `fn-quest-create` 가 원장과 함께 한다(B-28 · B-30). 앱은 /// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜 /// 23:59:59** 를 서버가 UTC 로 굳히도록 날짜와 그 시각의 UTC 오프셋을 보낸다(C-06). @override Future create(QuestDraft draft) async { final end = draft.endDate; final offset = end == null ? DateTime.now().timeZoneOffset : DateTime(end.year, end.month, end.day, 23, 59, 59).timeZoneOffset; final result = await _backend.call('fnQuestCreate', { 'clientSeq': draft.clientSeq, 'activityKey': draft.activityKey, 'title': draft.title, 'description': draft.description, 'rewardPop': draft.rewardPop, 'capacity': draft.participants, 'utcOffsetMinutes': offset.inMinutes, if (end != null) 'endDate': '${end.year.toString().padLeft(4, '0')}-' '${end.month.toString().padLeft(2, '0')}-${end.day.toString().padLeft(2, '0')}', if (draft.url != null) 'url': draft.url, if (draft.manualTitle != null) 'manualTitle': draft.manualTitle, if (draft.placeId != null) 'placeId': draft.placeId, }); return result.strOr('questId'); } @override Future popBalance() async { final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get(); return wallet.data()?.intOr('activityBalance'); } @override Stream progressStream(QuestCard card) { if (card.type != QuestType.steps) return const Stream.empty(); // 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의 // 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다. return _stepCount().asyncMap((cumulative) async { _walkSteps = await _baseline.todaySteps(cumulative); return _walkSteps; }); } @override Future verify(QuestCard card) async { final reason = card.type.blockedReason; if (reason != null) throw BackendException('E-QST-01', reason); final result = switch (card.type) { // **퀘스트 문서 ID 를 함께 보낸다.** 서버가 진행 문서를 이 ID 로 쓰고 일일 한도를 // `quests/{questId}.dailyLimit` 에서 읽는다. 보내지 않으면 서버는 유형 이름에 // 쓰고 앱은 문서 ID 로 읽어, 지급은 되는데 화면은 「시작하기」에 머문다. QuestType.attend => await _backend.call('fnQuestAttend', {'questId': card.id}), QuestType.steps => await _verifySteps(card), 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}), _ => throw BackendException('E-QST-01', card.type.blockedReason ?? ''), }; return QuestReward( rewardedPop: result.intOr('rewarded'), balanceAfter: result.intOr('balanceAfter'), txId: result.strOr('txId').isEmpty ? null : result.strOr('txId'), duplicate: result['duplicate'] == true, ); } @override Future loadRewardedAd() => loadGoogleRewardedAd(); @override Future claimAdReward(String impressionId, QuestReward base) async { final baseTxId = base.txId; 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'); } Future> _verifySteps(QuestCard card) async { if (_walkSteps <= 0) { // 센서 값이 아직 오지 않았다. 0 을 보내면 서버가 미완주로 판정하지만, // 원인이 「걷지 않았다」가 아니라 「세지 못했다」라 사유가 달라진다. throw BackendException('E-QST-01', appL10n.questNoSteps); } return _backend.call('fnQuestVerifySteps', { 'questId': card.id, 'steps': _walkSteps, 'occurredAt': DateTime.now().toUtc().toIso8601String(), ...await _fandomField(), }); } Future> _verifyPlace(QuestCard card) async { final quest = await _backend.db .doc('quests/${card.id}') .get() .then((s) => s.data() ?? const {}); final placeId = _placeIdOf(quest); if (placeId.isEmpty) { // 설계서 §2.8 의 `quests` 에 장소를 가리키는 필드가 없다. 좌표만 보내면 // 서버가 어느 반경과 견줄지 알 수 없으므로 임의로 고르지 않고 끊는다. throw BackendException('E-QST-03', appL10n.questNoPlace); } final fix = await _placeFix(); return _backend.call('fnQuestVerifyPlace', { // 참여 횟수는 Quest 단위로 센다(정책 원장 C-12 — 같은 장소의 다른 Quest 는 별개). 'questId': card.id, 'placeId': placeId, 'lat': fix.lat, 'lng': fix.lng, 'accuracyM': fix.accuracyM, // 판정하지 않고 그대로 올린다 — 설계서 §8 「mock location 차단」은 서버 판정이다. 'mocked': fix.mocked, ...await _fandomField(), }); } String _placeIdOf(Map quest) { final direct = quest.strOr('placeId'); if (direct.isNotEmpty) return direct; final conditions = quest['conditions']; if (conditions is Map) { return Map.from(conditions).strOr('placeId'); } return ''; } /// 활동 로그의 집계 축 — `activityLogs.fandomId`(설계서 §2.1). 소속이 없으면 싣지 않는다. Future> _fandomField() async { final uid = _backend.uid; if (uid == null) return const {}; final user = await _backend.db .doc('users/$uid') .get() .then((s) => s.data() ?? const {}); final fandomId = user.strOr('fandomId'); return fandomId.isEmpty ? const {} : {'fandomId': fandomId}; } } /// 일일 한도의 리셋 단위 — 서버 `dayKey()` 와 같은 기준(KST)으로 끊는다. /// 단말 시간대로 끊으면 해외 사용자의 화면과 서버 판정이 하루 어긋난다. String _dayKey(DateTime at) => at.toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10); /// 걸음 구간 문턱들 — `config/policy.stepTiers`(운영값). /// /// **화면 표시용이다. 판정은 서버가 다시 한다.** 문서가 아직 없을 때만 예전 단일 /// 목표(`stepsGoal`)로 떨어뜨린다 — 앱에 구간값을 박으면 운영값이 두 벌이 된다. List _stepTiers(Map policy) { final raw = policy['stepTiers']; if (raw is List && raw.isNotEmpty) { final steps = []; for (final row in raw) { if (row is Map && row['steps'] is num) { steps.add((row['steps'] as num).round()); } } if (steps.isNotEmpty) return steps..sort(); } return [policy.intOr('stepsGoal', 10000)]; } /// 오늘(KST) 수행 횟수와 완료 여부 — **HOME 과 퀘스트 목록 · 상세가 같이 쓰는 단일 판정.** /// /// `users/{uid}/questProgress/{questId}.count` 는 `dayKey` 가 오늘일 때만 오늘 값이다. 날짜를 /// 보지 않으면 9/21 출석의 `count: 1` 이 매일 「보상 완료」로 남는다(2026-10-04 실기기 4-1). /// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다. /// /// 걸음은 **구간표**가 목표다 — 정책 원장 **B-26**(1 천·5 천·1 만·1.5 만보). 오늘 받은 구간 /// 수가 `count` 라 구간을 다 받아야 완료다. 나머지 유형은 일일 한도가 곧 목표 횟수다 — /// 설계서 §2.8 이 `quests` 에 목표 수치 필드를 두지 않았다. ({int count, bool done}) questToday( QuestType type, Map quest, Map? progress, Map policy, [ DateTime? now, ]) { final p = progress ?? const {}; final sameDay = type == QuestType.timeAttack || p.strOr('dayKey') == _dayKey(now ?? DateTime.now()); final count = sameDay ? p.intOr('count') : 0; final done = type == QuestType.steps ? count >= _stepTiers(policy).length : count >= quest.intOr('dailyLimit', 1); return (count: count, done: done); }