광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · 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
+49 -10
View File
@@ -232,12 +232,18 @@ class QuestCatalog {
: (pool * feeRate).ceil();
}
/// 위치 Quest 의 등록 장소 — `places/{placeId}`.
/// 위치 Quest 의 장소 — 등록 장소(`places/{placeId}`) 또는 지도에 꽂은 핀(`id` 빈 값).
class QuestPlace {
const QuestPlace({required this.id, required this.name});
const QuestPlace({required this.id, required this.name, this.lat, this.lng});
final String id;
/// 등록 장소 이름 또는 핀 주소.
final String name;
final double? lat;
final double? lng;
bool get isPin => id.isEmpty;
}
/// 링크 미리보기 — 서버 `fn-link-preview`(C-05). [status] 는 `ok` · `preview_failed` · `invalid`.
@@ -272,10 +278,20 @@ class QuestSubmission {
this.rejectReason,
this.resubmitAllowed = false,
this.summary = '',
this.txId,
this.paidAt,
this.adClaimed = false,
});
final String id;
final String status;
/// 승인 지급의 기본 보상 원장 txId · 지급 시각 — 광고 추가 보상을 붙이는 기준(Q-UQ-03 A).
final String? txId;
final DateTime? paidAt;
/// 이 지급에 광고 추가 보상을 이미 받았는가(`users/{uid}/adBonuses/{txId}`).
final bool adClaimed;
final DateTime? submittedAt;
final String? rejectReason;
final bool resubmitAllowed;
@@ -414,10 +430,19 @@ class QuestDetail {
this.repeatMax = 1,
this.submission,
this.reviews = const [],
this.publishState = '',
this.moderationReason,
});
final QuestCard card;
/// 사용자 생성 Quest 의 공개 상태(`quests.status`) — `review`(검수 대기) · `published` ·
/// `rejected`(운영 반려) · `cancelled` · `ended`. 운영 Quest 는 빈 값.
final String publishState;
/// 운영 반려 사유(`quests.moderation.reason`).
final String? moderationReason;
/// 대표 이미지. 자산이 아직 없어 라벨로 둔다.
final String imageLabel;
@@ -475,8 +500,16 @@ class QuestDraft {
this.url,
this.manualTitle,
this.placeId,
this.pin,
this.goal,
});
/// 지도에 꽂은 핀(등록 장소가 아닐 때) — 서버가 그 좌표로 장소를 만든다.
final QuestPlace? pin;
/// 만보기 목표 걸음 수(`inputSchema` 의 `goal`).
final int? goal;
/// 같은 화면의 재시도를 한 건으로 묶는 멱등 키.
final String clientSeq;
final String activityKey;
@@ -529,6 +562,10 @@ abstract interface class QuestRepository {
required String description,
});
/// 생성자 취소(B-30) — 검수 중이면 예치 · 수수료 전액 반환, 공개 후면 수수료 비환불 ·
/// 미배정 Pool 즉시 반환.
Future<void> cancel(String questId);
/// 상태 전이. 서버가 확정한 상태를 돌려준다.
Future<QuestStage> advance(String questId, QuestStage next);
@@ -565,12 +602,14 @@ abstract interface class QuestRepository {
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
///
/// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force ·
/// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」).
Future<RewardedAdHandle> loadRewardedAd();
/// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」). [base] 는 광고를 붙일 기본 보상이다 —
/// 서버측 시청 검증(AdMob SSV)에 그 txId 를 싣는다.
Future<RewardedAdHandle> loadRewardedAd(QuestReward base);
/// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은
/// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02).
/// 일일 한도를 넘으면 E-AD-02, 같은 노출의 재전송은 첫 지급 결과다.
/// 지급 근거는 Google 이 서버로 보낸 SSV 기록이다(없으면 E-AD-03 — 증분 미지급 · 횟수 미차감).
/// 일일 한도를 넘으면 E-AD-02, 같은 기본 보상의 재전송은 첫 지급 결과다.
Future<int> claimAdReward(String impressionId, QuestReward base);
}
@@ -730,10 +769,7 @@ class SampleQuestRepository implements QuestRepository {
Future<QuestCatalog> catalog() async => _catalog;
@override
Future<List<QuestPlace>> places() async => [
for (final name in PlacePickerScreen.defaultPlaces)
QuestPlace(id: name, name: name),
];
Future<List<QuestPlace>> places() async => PlacePickerScreen.defaultPlaces;
/// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다.
@override
@@ -766,6 +802,9 @@ class SampleQuestRepository implements QuestRepository {
/// 이것이 없으면 보상을 받은 뒤 화면을 다시 읽을 때 또 「보상받기」로 보인다.
static final _claimed = <String>{};
@override
Future<void> cancel(String questId) async {}
@override
Future<QuestStage> advance(String questId, QuestStage next) async => next;
@@ -802,7 +841,7 @@ class SampleQuestRepository implements QuestRepository {
/// 표본은 광고 SDK 를 부르지 않는다 — 잠깐 기다렸다가 끝까지 본 것으로 둔다.
@override
Future<RewardedAdHandle> loadRewardedAd() async {
Future<RewardedAdHandle> loadRewardedAd(QuestReward base) async {
await Future<void>.delayed(const Duration(seconds: 2));
return const _SampleAd();
}