diff --git a/app/firebase/firestore.indexes.json b/app/firebase/firestore.indexes.json index 303f5a9..7bd912f 100644 --- a/app/firebase/firestore.indexes.json +++ b/app/firebase/firestore.indexes.json @@ -1181,6 +1181,34 @@ "order": "ASCENDING" } ] + }, + { + "collectionGroup": "quests", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "timeAttack.preNotified", + "order": "ASCENDING" + }, + { + "fieldPath": "timeAttack.notifyAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "quests", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "timeAttack.fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "timeAttack.activeTo", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [ diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules index 004268c..7b9d689 100644 --- a/app/firebase/firestore.rules +++ b/app/firebase/firestore.rules @@ -275,6 +275,12 @@ service cloud.firestore { allow write: if false; // 별첨1 ② 원장 정합성 · 멱등키는 Functions 관리 } + // 기본 보상 한 건당 광고 추가 보상 표식(fn-ad-reward). 앱은 이미 받았는지만 읽는다. + match /users/{uid}/adBonuses/{baseTxId} { + allow read: if isSelf(uid) || isViewer(); + allow write: if false; + } + // 활동 로그(걸음·스밍·위치·출석). 어뷰징 판정 입력이라 클라이언트 쓰기 금지. // 화면: S-H-WALK · S-H-STREAM · S-H-PLACE · S-H-ATTEND · S-AD-ABUSE match /users/{uid}/activityLogs/{logId} { @@ -869,6 +875,12 @@ service cloud.firestore { allow write: if false; } + // AdMob 서버측 시청 검증(SSV) 기록 — fn-ad-ssv 만 쓴다. 지급 근거라 클라이언트 쓰기 없음. + match /adSsv/{transactionId} { + allow read: if isViewer(); + allow write: if false; + } + // 공지·배너. 화면: S-X-NOTICE · S-AD-NOTICE match /notices/{noticeId} { allow read: if true; diff --git a/app/firebase/functions/src/core/config.ts b/app/firebase/functions/src/core/config.ts index aae4d95..8cf8084 100644 --- a/app/firebase/functions/src/core/config.ts +++ b/app/firebase/functions/src/core/config.ts @@ -93,8 +93,15 @@ export interface PolicyConfig { ugcMinVerifiedQuests: number; /** 사람 검토 목표 시간(C-08 · 주말 포함 24h). 지연 안내 기준이며 자동 승인은 없다. */ ugcReviewHours: number; - /** 긴급 돌발 타임어택 1회 보상 상한(활동 POP). 🟡 기준자료에 수치가 없어 보수적 출발점 1,000. */ + /** + * 사람 검토형 Quest(Vote · SNS) 승인 지급의 FPI 축. C-35 매핑에 없어 **🟡 임시값 — 회신 대기** + * (해석 확인 요청 영역 B). Streaming 무보상 참여와 같은 COMMUNITY 로 둔다. null 이면 FPI 없음. + */ + ugcReviewedFpiAxis: FpiAxis | null; + /** TIME ATTACK 회차별 보상 상한(일반 POP) — 10/3 통합회신 Q-TA-04 A 「초기 상한 100」. 운영값. */ timeAttackMaxPop: number; + /** 예약 회차의 사전 Push 시점(시작 N분 전) — 10/3 통합회신 Q-TA-03 C 「예약 3분 전 Push」. 운영값. */ + timeAttackPreNotifyMinutes: number; /** * 타임어택 참여 1건의 FPI 축 — 해석 확인 요청 **Q-TA-05**. 🟡 회신 전 권고안(A안) `community` * (출석과 같은 축 · C-35). null 이면 B안 — FPI 에 넣지 않고 User EXP 만 준다. @@ -209,8 +216,10 @@ export const DEFAULT_POLICY: PolicyConfig = { ugcMinAccountHours: 24, ugcMinVerifiedQuests: 1, ugcReviewHours: 24, - timeAttackMaxPop: 1000, + timeAttackMaxPop: 100, + timeAttackPreNotifyMinutes: 3, timeAttackFpiAxis: 'community', + ugcReviewedFpiAxis: 'community', liveWindowMinutes: 5, referralReferrerPop: 100, referralReferredPop: 20, diff --git a/app/firebase/functions/src/core/errors.ts b/app/firebase/functions/src/core/errors.ts index 4c247eb..7fc7d1d 100644 --- a/app/firebase/functions/src/core/errors.ts +++ b/app/firebase/functions/src/core/errors.ts @@ -17,6 +17,8 @@ export const ERR = { ugcFreeDailyCap: 'E-QST-04', adLoadFailed: 'E-AD-01', adLimitExceeded: 'E-AD-02', + // 보상형 광고 서버측 검증(AdMob SSV) 기록이 아직 없음 — 증분 미지급 · 횟수 미차감(B-03). + adNotVerified: 'E-AD-03', eclipseClosed: 'E-ECL-01', campaignOverGoal: 'E-CMP-01', campaignPartialFail: 'E-CMP-02', @@ -62,6 +64,7 @@ const STATUS: Record = { 'E-QST-04': 'resource-exhausted', 'E-AD-01': 'internal', 'E-AD-02': 'resource-exhausted', + 'E-AD-03': 'failed-precondition', 'E-ECL-01': 'failed-precondition', 'E-CMP-01': 'failed-precondition', 'E-CMP-02': 'internal', diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 2f54c75..3fdc4f4 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -26,11 +26,14 @@ export { export { fnQuestVerifyMusicShake, fnQuestVerifyPlace, } from './quest/verify.js'; -export { fnQuestTimeAttackStart, fnQuestTimeAttack, fnServerTime } from './quest/timeattack.js'; export { - fnQuestCreate, fnQuestSubmit, fnQuestReview, fnQuestEdit, fnQuestPoolSettle, + fnQuestTimeAttackStart, fnQuestTimeAttack, fnQuestTimeAttackNotify, fnServerTime, +} from './quest/timeattack.js'; +export { + fnQuestCreate, fnQuestSubmit, fnQuestReview, fnQuestEdit, fnQuestPoolSettle, fnQuestModerate, fnQuestCancel, } from './quest/ugc.js'; export { fnLinkPreview } from './quest/link_preview.js'; +export { fnAdSsv } from './quest/ad_ssv.js'; export { fnCampaignSettle, fnCampaignRefundBatch, fnCampaignAdminReview, fnCampaignAdminCancel, fnCampaignReportSubmit, diff --git a/app/firebase/functions/src/ledger/ledger.ts b/app/firebase/functions/src/ledger/ledger.ts index cb15c1a..b6b3b94 100644 --- a/app/firebase/functions/src/ledger/ledger.ts +++ b/app/firebase/functions/src/ledger/ledger.ts @@ -37,7 +37,9 @@ export type ReasonCode = | 'CAMPAIGN_FEE_REFUND' // 사용자 생성 Quest(C-02 · C-09 · B-30) — 참여 보상 · 수수료(Burn) · Pool 예치 · 미사용분 반환 · // 공개 전 실패 시 예치 원타입 환불. - | 'QUEST_UGC' | 'QUEST_FEE' | 'QUEST_POOL_DEPOSIT' | 'QUEST_POOL_RETURN' | 'QUEST_POOL_REFUND'; + | 'QUEST_UGC' | 'QUEST_FEE' | 'QUEST_POOL_DEPOSIT' | 'QUEST_POOL_RETURN' | 'QUEST_POOL_REFUND' + // 공개 전 검수 반려 · 검수 중 생성자 철회 시 수수료 반환(B-30 「운영반려 · 회사귀책은 반환」). + | 'QUEST_FEE_REFUND'; /** * 기여로 집계하는 차감 사유 — `user.totalContributedPop` 의 모집단(§3.6). @@ -552,7 +554,7 @@ function zapFandomOf(user: DocumentSnapshot | null, target: string | undefined): } type RefundReason = Extract; + 'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND' | 'QUEST_FEE_REFUND'>; /** * 환불 — `fn-pop-refund` 의 코어. diff --git a/app/firebase/functions/src/quest/ad_ssv.ts b/app/firebase/functions/src/quest/ad_ssv.ts new file mode 100644 index 0000000..e32d553 --- /dev/null +++ b/app/firebase/functions/src/quest/ad_ssv.ts @@ -0,0 +1,111 @@ +import { createVerify } from 'node:crypto'; + +import { onRequest } from 'firebase-functions/v2/https'; +import { logger } from 'firebase-functions'; +import { getFirestore, FieldValue } from 'firebase-admin/firestore'; + +/** + * 보상형 광고 **서버측 시청 검증(SSV)** — AdMob 공식 방식. + * + * 광고를 끝까지 본 사실은 앱이 아니라 **Google 이 서버로 직접 알린다.** AdMob 은 시청이 끝나면 이 + * 함수의 URL 로 GET 콜백을 보내고, 쿼리 끝의 `signature`(ECDSA · SHA-256 · web-safe base64 DER)를 + * `key_id` 의 공개 키로 검증한다. 검증을 통과한 콜백만 `adSsv/{transaction_id}` 에 남고, + * `fnAdReward` 는 이 기록이 있을 때만 증분을 지급한다 — 앱이 보낸 노출 ID 는 더 이상 근거가 아니다. + * + * - 앱은 광고를 띄우기 전에 `user_id = uid` · `custom_data = 기본 보상 txId` 를 싣는다 + * (`ServerSideVerificationOptions`). 그래서 이 기록은 「누가 · 어느 기본 보상에 붙여」 본 광고인지 담는다. + * - 같은 `transaction_id` 는 한 번만 기록한다(AdMob 재전송). + * - 공식 문서: https://developers.google.com/admob/android/ssv + */ + +export const VERIFIER_KEYS_URL = 'https://www.gstatic.com/admob/reward/verifier-keys.json'; + +/** `key_id` → PEM. */ +export type VerifierKeys = Map; + +let cachedKeys: { keys: VerifierKeys; at: number } | null = null; + +/** 공개 키 목록. Google 이 키를 돌리므로 하루 단위로 다시 받고, 모르는 `key_id` 면 즉시 다시 받는다. */ +async function verifierKeys(keyId: string): Promise { + const fresh = cachedKeys && Date.now() - cachedKeys.at < 24 * 3_600_000; + if (fresh && cachedKeys!.keys.has(keyId)) return cachedKeys!.keys; + const res = await fetch(VERIFIER_KEYS_URL); + if (!res.ok) throw new Error(`verifier keys ${res.status}`); + const body = await res.json() as { keys?: Array<{ keyId: number | string; pem: string }> }; + const keys: VerifierKeys = new Map((body.keys ?? []).map((k) => [String(k.keyId), k.pem])); + cachedKeys = { keys, at: Date.now() }; + return keys; +} + +/** + * 콜백 쿼리 문자열(`?` 뒤 원문)의 서명을 검증한다. 서명 대상은 `&signature=` 앞까지의 원문이다 + * (AdMob 규약 — `signature` · `key_id` 는 항상 마지막 두 파라미터). + * 통과하면 파라미터를, 아니면 null 을 돌려준다. + */ +export function verifySsv(rawQuery: string, keys: VerifierKeys): URLSearchParams | null { + const cut = rawQuery.indexOf('&signature='); + if (cut <= 0) return null; + const message = rawQuery.slice(0, cut); + const params = new URLSearchParams(rawQuery); + const signature = params.get('signature'); + const pem = keys.get(params.get('key_id') ?? ''); + if (!signature || !pem) return null; + const der = Buffer.from(signature.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + try { + return createVerify('sha256').update(message).verify(pem, der) ? params : null; + } catch { + return null; + } +} + +/** 검증된 콜백 한 건을 기록한다. 같은 `transaction_id` 의 재전송은 그대로 둔다. */ +export async function recordSsv(params: URLSearchParams): Promise<'recorded' | 'duplicate' | 'ignored'> { + const transactionId = params.get('transaction_id') ?? ''; + const uid = params.get('user_id') ?? ''; + const baseTxId = params.get('custom_data') ?? ''; + // AdMob 콘솔 「URL 확인」 테스트 콜백은 user_id · custom_data 가 비어 온다 — 200 으로 받고 기록하지 않는다. + if (!transactionId || !uid || !baseTxId || [transactionId, uid, baseTxId].some((v) => v.includes('/'))) { + return 'ignored'; + } + try { + await getFirestore().doc(`adSsv/${transactionId}`).create({ + uid, baseTxId, + adNetwork: params.get('ad_network') ?? '', + adUnit: params.get('ad_unit') ?? '', + rewardAmount: Number(params.get('reward_amount') ?? 0), + rewardItem: params.get('reward_item') ?? '', + timestamp: Number(params.get('timestamp') ?? 0), + keyId: params.get('key_id') ?? '', + verifiedAt: FieldValue.serverTimestamp(), + }); + return 'recorded'; + } catch (e) { + const code = (e as { code?: number | string }).code; + if (code === 6 || code === 'already-exists') return 'duplicate'; + throw e; + } +} + +/** + * `fn-ad-ssv` — AdMob SSV 콜백 수신(HTTP GET). AdMob 콘솔의 광고 단위 「서버 측 확인」 URL 로 등록한다. + * 서명이 틀리면 403 — 기록도 지급도 없다. + */ +export const fnAdSsv = onRequest(async (req, res) => { + const url = req.originalUrl ?? req.url ?? ''; + const rawQuery = url.includes('?') ? url.slice(url.indexOf('?') + 1) : ''; + try { + const keyId = new URLSearchParams(rawQuery).get('key_id') ?? ''; + const params = verifySsv(rawQuery, await verifierKeys(keyId)); + if (!params) { + logger.warn('[ad-ssv] 서명 검증 실패', { keyId }); + res.status(403).send('invalid signature'); + return; + } + const outcome = await recordSsv(params); + res.status(200).send(outcome); + } catch (error) { + // 5xx 면 AdMob 이 다시 보낸다. + logger.error('[ad-ssv] 처리 실패', { error: String(error) }); + res.status(500).send('error'); + } +}); diff --git a/app/firebase/functions/src/quest/quest.ts b/app/firebase/functions/src/quest/quest.ts index 568e409..bb785ce 100644 --- a/app/firebase/functions/src/quest/quest.ts +++ b/app/firebase/functions/src/quest/quest.ts @@ -8,6 +8,7 @@ import { dayKey, isPlausibleOccurredAt } from '../core/time.js'; import { loadPolicy, type PolicyConfig } from '../core/config.js'; import { grantPop } from '../ledger/ledger.js'; import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js'; +import { verifyUgcWalk } from './ugc.js'; /** * 퀘스트 검증 — 별첨 1 ③ 「서버검증 필터」의 구현부. @@ -221,7 +222,7 @@ export const fnQuestVerifySteps = onCall(async (req) => { // 퀘스트 문서가 있어야 하고 걸음 유형이어야 한다. 없는 ID 로 바꿔 부르면 진행 문서 · 멱등키가 // 새로 열려 구간 보상이 다시 나간다(리뷰 재검토 blocker). 대표 팬덤 자격(C-16)도 본다. const questId = str(req.data, 'questId'); - await questOf(uid, questId, ['walk', 'steps', 'health']); + const quest = await questOf(uid, questId, ['walk', 'steps', 'health']); const steps = positiveInt(num(req.data, 'steps'), 'steps'); const policy = await loadPolicy(); const db = getFirestore(); @@ -258,6 +259,8 @@ export const fnQuestVerifySteps = onCall(async (req) => { }); if (abnormal) fail(ERR.questAbnormal, '비정상적인 활동으로 판정했습니다.'); + // 사용자 생성 만보기 — 구간표가 아니라 생성자가 정한 목표 걸음 · 생성자 Pool 지급(Figma `259:45798`). + if (typeof quest.get('authorUid') === 'string') return verifyUgcWalk(uid, quest, steps, at); if (cleared === 0) { fail(ERR.questIncomplete, `아직 ${tiers[0]!.steps.toLocaleString()}보에 도달하지 않았습니다.`); } @@ -343,43 +346,25 @@ function questIdOfKey(key: unknown): string | null { /** * `fn-ad-reward` — 보상형 광고 증분. * - * 앱은 방금 받은 **기본 보상의 원장 txId**(`baseTxId`)와 노출 ID 를 보낸다. 서버는 그 - * 원장 행이 본인 · 오늘 · 퀘스트 보상인지 확인하고 증분을 기여 POP 으로 지급한다. + * 앱은 방금 받은 **기본 보상의 원장 txId**(`baseTxId`)만 보낸다. 서버는 그 원장 행이 본인 · 오늘 · + * 퀘스트 보상인지 확인하고, **AdMob 서버측 검증(SSV) 기록**(`adSsv`, [fnAdSsv])이 그 기본 보상에 + * 붙어 있을 때만 증분을 기여 POP 으로 지급한다. 앱이 보낸 노출 ID 는 근거로 쓰지 않는다 — 함수만 + * 불러서는 기여 POP 이 나가지 않는다(원장 B-03 「광고 검증 실패 시 증분 미지급 · 횟수 미차감」). * 기본 보상 한 건에 광고 보상은 한 번이다(`users/{uid}/adBonuses/{baseTxId}`). * - * 일일 한도를 넘으면 E-AD-02. 같은 노출 ID 의 재전송은 첫 지급 결과를 그대로 돌려준다(`duplicate`). + * - SSV 콜백이 아직 오지 않았으면 E-AD-03(횟수 미차감). 앱은 잠시 뒤 다시 부른다. + * - 일일 한도를 넘으면 E-AD-02. + * - 이미 지급된 기본 보상의 재호출(응답 유실)은 첫 지급 결과를 그대로 돌려준다(`duplicate`). */ export const fnAdReward = onCall(async (req) => { const uid = callerUid(req); - const impressionId = str(req.data, 'adImpressionId'); - const network = optionalStr(req.data, 'network') ?? 'unknown'; const now = new Date(); const key = dayKey(now); const policy = await loadPolicy(); const db = getFirestore(); - const logRef = getFirestore().doc(`adRewardLogs/${impressionId}`); - // 성공한 호출의 재전송(응답 유실)은 오류가 아니라 **첫 지급 결과**로 돌려준다. - const prior = await logRef.get(); - if (prior.exists) { - if (prior.get('uid') !== uid || typeof prior.get('txId') !== 'string') { - fail(ERR.popDuplicate, '이미 보상받은 광고입니다.'); - } - const tx = await getFirestore().doc(`users/${uid}/popTransactions/${prior.get('txId')}`).get(); - return { - txId: prior.get('txId') as string, - balanceAfter: (tx.get('balanceAfter') as number | undefined) ?? 0, - rewarded: (tx.get('amount') as number | undefined) ?? 0, - remaining: Math.max(0, policy.adDailyLimit - (await progress(uid, 'ad', key)).count), - duplicate: true, - }; - } - - // 멱등 단위는 **그날 n 번째 광고 보상**이다. 예전에는 노출 ID(클라이언트 값)를 키로 써서 - // 서로 다른 노출 ID 로 동시에 부르면 모두 한도 판정을 통과했다(리뷰 재검토 · ID 전수 점검). - // 같은 n 은 같은 키로 모여 한 번만 지급되고, 노출 로그는 지급과 같은 트랜잭션에서 `create` - // 해 같은 노출을 두 번 쓰지 못한다. const baseTxId = str(req.data, 'baseTxId'); + if (baseTxId.includes('/')) fail(ERR.questIncomplete, '광고 추가 보상은 오늘 받은 퀘스트 보상에만 붙습니다.'); const base = await db.doc(`users/${uid}/popTransactions/${baseTxId}`).get(); const questId = questIdOfKey(base.get('idempotencyKey')); const baseAt = base.get('createdAt'); @@ -389,9 +374,28 @@ export const fnAdReward = onCall(async (req) => { fail(ERR.questIncomplete, '광고 추가 보상은 오늘 받은 퀘스트 보상에만 붙습니다.'); } const bonusRef = db.doc(`users/${uid}/adBonuses/${baseTxId}`); - if ((await bonusRef.get()).exists) { - fail(ERR.popDuplicate, '이 퀘스트 보상의 광고 추가 보상은 이미 받았습니다.'); + // 성공한 호출의 재전송(응답 유실)은 오류가 아니라 **첫 지급 결과**로 돌려준다. + const bonus = await bonusRef.get(); + if (bonus.exists) { + const log = await db.doc(`adRewardLogs/${bonus.get('impressionId')}`).get(); + const tx = await db.doc(`users/${uid}/popTransactions/${log.get('txId')}`).get(); + return { + txId: log.get('txId') as string, + balanceAfter: (tx.get('balanceAfter') as number | undefined) ?? 0, + rewarded: (tx.get('amount') as number | undefined) ?? 0, + remaining: Math.max(0, policy.adDailyLimit - (await progress(uid, 'ad', key)).count), + duplicate: true, + }; } + + // 시청 증거 — Google 이 서버로 보낸 SSV 콜백(서명 검증 통과분)만 인정한다. + const ssv = await db.collection('adSsv') + .where('uid', '==', uid).where('baseTxId', '==', baseTxId).limit(1).get(); + if (ssv.empty) fail(ERR.adNotVerified, '광고 시청이 아직 확인되지 않았습니다.'); + const impressionId = ssv.docs[0]!.id; + const network = (ssv.docs[0]!.get('adNetwork') as string | undefined) || 'admob'; + const logRef = db.doc(`adRewardLogs/${impressionId}`); + const quest = (await db.doc(`quests/${questId}`).get()).data() ?? {}; const amount = adIncrement(quest, (base.get('amount') as number | undefined) ?? 0, policy); if (!Number.isSafeInteger(amount) || amount <= 0) { diff --git a/app/firebase/functions/src/quest/timeattack.ts b/app/firebase/functions/src/quest/timeattack.ts index 6bb9444..df716ec 100644 --- a/app/firebase/functions/src/quest/timeattack.ts +++ b/app/firebase/functions/src/quest/timeattack.ts @@ -1,7 +1,9 @@ import { onCall } from 'firebase-functions/v2/https'; +import { onSchedule } from 'firebase-functions/v2/scheduler'; +import { logger } from 'firebase-functions'; import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore'; -import { callerUid, num, requireRole, str } from '../core/callable.js'; +import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js'; import { ERR, fail, positiveInt } from '../core/errors.js'; import { txIdOf } from '../core/ids.js'; import { dayKey } from '../core/time.js'; @@ -9,23 +11,28 @@ import { FPI_AXES, loadPolicy } from '../core/config.js'; import { fandomOfUser, markCompletion, writeFpiEvent, writeUserExp } from '../ledger/fpi.js'; import { grantPop } from '../ledger/ledger.js'; import { sendFandomPush } from '../notify/push.js'; -import { questOf } from './quest.js'; +import { dailyLimitOf, questOf } from './quest.js'; /** - * 긴급 돌발 타임어택 — 별첨 1 ③-4 「참여 → 검증 → 보상 지급」 · FR-QST-06 - * 「운영이 지정한 시간대에 돌발 퀘스트를 노출하고 제한 시간 내 참여 → 검증 → 보상 지급」. + * TIME ATTACK — 10/3 통합회신 3절(Q-TA-01 보완 · 02 A · 03 C · 04 A · 05 A) · 별첨 1 ③-4. * - * 기준자료에 있는 정의는 이 두 줄뿐이다(화면 대조 원장 `S-H-TIMEATTACK` 「Figma 화면 없음」). - * 그래서 **가장 보수적인 해석**으로 만든다 — 해석 확인 요청 대상은 보고서에 남겼다. - * - 발동은 운영(master)만. 팬덤 한 곳 · 지금부터 N분. - * - 참여 = 창 안에서 서버에 참여를 올리는 것. 판정은 **서버 시각**만 본다. - * - 보상은 퀘스트 문서의 `rewardPop` · 1인 1회 · 원장 멱등. - * - 보상 상한은 `config/policy.timeAttackMaxPop`(기본 1,000). 지속 시간 상한은 - * `config/policy.timeAttackMaxMinutes`(기본 1,440 = 24시간). - * - 인정 활동이라 User EXP(C-18)를 주고, FPI 는 `config/policy.timeAttackFpiAxis` 축 1 단위 - * (해석 확인 요청 Q-TA-05 권고 A안 · 기본 COMMUNITY). null 이면 FPI 에 넣지 않는다(B안). + * - **별도 Quest 유형이 아니라 기존 Quest 에 붙이는 제한시간형 긴급 참여 모드다.** 회차는 원 Quest + * 문서의 `timeAttack` 필드에 둔다(`roundId` · `activeFrom` · `activeTo` · `rewardPop` · `fandomId`). + * - 참여 자격은 원 Quest 규칙(현재 Primary Fandom 기준, [questOf]). 판정은 서버 시각 · 회차당 1인 1회. + * 인원 제한 없음(Q-TA-02 A). + * - 외부 플랫폼 실제 투표 · 스트리밍 완료는 검증할 수 없으므로 「외부 행동 완료」로 판정하지 않고 + * FANUZU 안의 참여만 기록한다(Q-TA-01). 그래서 지금은 외부 행동 Quest(Vote · SNS · Streaming · + * YouTube)에만 붙인다. **검증 가능한 원 Quest(걸음 · 위치 · Shake 등)에 붙일 때 원 Quest 보상과 + * 회차 보상을 어떻게 나눌지는 자료에 없어 회신 대기**(해석 확인 요청 영역 B) — 그 유형은 발동을 거부한다. + * - 시작은 즉시 또는 예약, 예약은 시작 `timeAttackPreNotifyMinutes`(3)분 전 팬덤 Push(Q-TA-03 C). + * - 회차별 보상은 운영자 입력, 상한 `timeAttackMaxPop`(초기 100 일반 POP, Q-TA-04 A). + * - FPI COMMUNITY 1회(Q-TA-05 A). 같은 원 행동의 중복 FPI 를 막기 위해 회차 참여는 **원 Quest 의 + * 그날 참여 1회를 겸한다** — 그날 이미 원 Quest 에 참여했으면 FPI · 참여 횟수를 더하지 않는다. */ +/** 회차를 붙일 수 있는 원 Quest 유형 — 외부 행동형(위 설명). */ +export const TIME_ATTACK_TYPES = ['vote', 'sns', 'streaming', 'stream', 'youtube'] as const; + /** 제목 길이 상한 — 푸시 제목 · 홈 카드 한 줄에 들어가는 길이. */ const MAX_TITLE = 60; @@ -36,104 +43,161 @@ export function inWindow( return !!from && !!to && now.getTime() >= from.toMillis() && now.getTime() < to.toMillis(); } +interface Round { + roundId: string; title: string; fandomId: string; rewardPop: number; + activeFrom: Timestamp; activeTo: Timestamp; scheduled: boolean; + notifyAt: Timestamp | null; preNotified: boolean; +} + /** - * `fn-quest-timeattack-start` — 운영 발동. + * `fn-quest-timeattack-start` — 운영 발동(master). 원 Quest 에 회차를 붙인다. * - * `quests` 는 master 전용 문서라(`fn-admin-write` MASTER_ONLY) 같은 선을 긋는다. - * 재전송은 `requestId` 로 모은다 — 같은 발동이 두 번 눌려 퀘스트 · 푸시가 둘이 되지 않게. + * 입력: `questId`(원 Quest) · `title` · `rewardPop` · `durationMinutes` · `requestId` · `fandomId`(Push 대상, + * 원 Quest 가 팬덤 Quest 면 그 팬덤만) · `startAt`(ISO, 없으면 즉시). + * 재전송은 `requestId` 로 모은다. 진행 중이거나 예약된 회차가 있으면 새 회차를 붙이지 않는다. */ export const fnQuestTimeAttackStart = onCall(async (req) => { const actorUid = callerUid(req); requireRole(req, ['master']); + const questId = str(req.data, 'questId'); const fandomId = str(req.data, 'fandomId'); const title = str(req.data, 'title'); const requestId = str(req.data, 'requestId'); const rewardPop = positiveInt(num(req.data, 'rewardPop'), 'rewardPop'); const minutes = positiveInt(num(req.data, 'durationMinutes'), 'durationMinutes'); - const { timeAttackMaxPop, timeAttackMaxMinutes } = await loadPolicy(); - if (minutes > timeAttackMaxMinutes) { - fail(ERR.questIncomplete, `타임어택은 ${timeAttackMaxMinutes}분을 넘길 수 없습니다.`); + const startRaw = optionalStr(req.data, 'startAt'); + const policy = await loadPolicy(); + if (minutes > policy.timeAttackMaxMinutes) { + fail(ERR.questIncomplete, `타임어택은 ${policy.timeAttackMaxMinutes}분을 넘길 수 없습니다.`); } // `/` 가 들어가면 문서 경로가 갈라져 다른 문서를 가리킨다. - if (fandomId.includes('/')) fail(ERR.questIncomplete, '팬덤 ID 가 올바르지 않습니다.'); + if (fandomId.includes('/') || questId.includes('/')) fail(ERR.questIncomplete, 'ID 가 올바르지 않습니다.'); if (title.length > MAX_TITLE) fail(ERR.questIncomplete, `제목은 ${MAX_TITLE}자 이내입니다.`); - if (rewardPop > timeAttackMaxPop) { - fail(ERR.questIncomplete, `타임어택 보상은 ${timeAttackMaxPop} POP 을 넘길 수 없습니다.`); + if (rewardPop > policy.timeAttackMaxPop) { + fail(ERR.questIncomplete, `타임어택 보상은 ${policy.timeAttackMaxPop} POP 을 넘길 수 없습니다.`); } + const now = new Date(); + const start = startRaw ? new Date(startRaw) : now; + if (Number.isNaN(start.getTime())) fail(ERR.questIncomplete, '시작 시각이 올바르지 않습니다.'); + const scheduled = !!startRaw && start.getTime() > now.getTime(); + if (startRaw && !scheduled) fail(ERR.questIncomplete, '예약 시작 시각은 지금 이후여야 합니다.'); const db = getFirestore(); - const fandom = await db.doc(`fandoms/${fandomId}`).get(); - if (!fandom.exists) fail(ERR.questIncomplete, '없는 팬덤입니다.'); - - const questId = `timeattack_${txIdOf(`timeattack:${actorUid}:${requestId}`)}`; + if (!(await db.doc(`fandoms/${fandomId}`).get()).exists) fail(ERR.questIncomplete, '없는 팬덤입니다.'); const ref = db.doc(`quests/${questId}`); - const now = new Date(); - const activeTo = Timestamp.fromMillis(now.getTime() + minutes * 60_000); - try { - await ref.create({ - type: 'timeattack', - title, - fandomId, - rewardPop, - dailyLimit: 1, - active: true, - activeFrom: Timestamp.fromDate(now), - activeTo, - createdBy: actorUid, - createdAt: FieldValue.serverTimestamp(), - updatedAt: FieldValue.serverTimestamp(), + const roundId = `ta_${txIdOf(`timeattack:${actorUid}:${requestId}`)}`; + const round: Round = { + roundId, title, fandomId, rewardPop, scheduled, + activeFrom: Timestamp.fromDate(start), + activeTo: Timestamp.fromMillis(start.getTime() + minutes * 60_000), + notifyAt: scheduled + ? Timestamp.fromMillis(start.getTime() - policy.timeAttackPreNotifyMinutes * 60_000) : null, + preNotified: !scheduled, + }; + const outcome = await db.runTransaction(async (tx) => { + const quest = await tx.get(ref); + const type = String(quest.get('type') ?? '').toLowerCase(); + if (!quest.exists || !(TIME_ATTACK_TYPES as readonly string[]).includes(type)) { + fail(ERR.questIncomplete, '타임어택은 외부 행동 Quest(투표 · SNS · 스트리밍 · 영상)에만 붙일 수 있습니다.'); + } + if (typeof quest.get('authorUid') === 'string') { + fail(ERR.questIncomplete, '타임어택은 운영 Quest 에만 붙입니다.'); + } + const qFandom = quest.get('fandomId'); + if (typeof qFandom === 'string' && qFandom && qFandom !== fandomId) { + fail(ERR.questIncomplete, '팬덤 Quest 의 타임어택은 그 팬덤에만 발동합니다.'); + } + const prior = quest.get('timeAttack') as Round | undefined; + if (prior?.roundId === roundId) return { duplicate: true, round: prior }; + if (prior && prior.activeTo.toMillis() > now.getTime()) { + fail(ERR.questIncomplete, '이 Quest 에는 진행 중이거나 예약된 타임어택이 있습니다.'); + } + tx.update(ref, { timeAttack: round, updatedAt: FieldValue.serverTimestamp() }); + tx.create(db.collection('adminActionLogs').doc(), { + actorUid, role: 'master', targetPath: ref.path, reason: 'timeattack-start', + patch: { questId, fandomId, title, rewardPop, durationMinutes: minutes, startAt: start.toISOString() }, + existedBefore: true, createdAt: FieldValue.serverTimestamp(), }); - } catch (e) { - const code = (e as { code?: number | string }).code; - if (code !== 6 && code !== 'already-exists') throw e; - const prior = await ref.get(); - return { - questId, - activeTo: (prior.get('activeTo') as Timestamp).toDate().toISOString(), - pushed: false, - duplicate: true, - }; - } - - await db.collection('adminActionLogs').add({ - actorUid, role: 'master', targetPath: ref.path, reason: 'timeattack-start', - patch: { fandomId, title, rewardPop, durationMinutes: minutes }, - existedBefore: false, createdAt: FieldValue.serverTimestamp(), + return { duplicate: false, round }; }); + const reply = { + questId, roundId, activeFrom: outcome.round.activeFrom.toDate().toISOString(), + activeTo: outcome.round.activeTo.toDate().toISOString(), scheduled: outcome.round.scheduled, + }; + if (outcome.duplicate) return { ...reply, pushed: false, duplicate: true }; + // 즉시 발동은 지금 Push. 예약은 [fnQuestTimeAttackNotify] 가 시작 3분 전에 보낸다. + const pushed = scheduled ? false : await pushRound(questId, round, minutes); + return { ...reply, pushed }; +}); - // 팬덤 이벤트 구분(B-77). 앱은 이 알림을 홈으로 연다 — 타임어택은 홈 TODAY'S QUEST 맨 앞이다. - const pushed = await sendFandomPush({ - fandomId, +async function pushRound(questId: string, round: Round, minutes: number, before?: number): Promise { + return sendFandomPush({ + fandomId: round.fandomId, category: 'fandomEvent', title: '긴급 돌발 타임어택', - body: `${title} — ${minutes}분 안에 참여하면 ${rewardPop} POP 을 받습니다.`, - data: { kind: 'timeattack', questId }, + body: before + ? `${round.title} — ${before}분 뒤 시작합니다. ${minutes}분 안에 참여하면 ${round.rewardPop} POP 을 받습니다.` + : `${round.title} — ${minutes}분 안에 참여하면 ${round.rewardPop} POP 을 받습니다.`, + data: { kind: 'timeattack', questId, roundId: round.roundId }, }); +} - return { questId, activeTo: activeTo.toDate().toISOString(), pushed }; +/** 예약 회차의 사전 Push — 알림 시각이 지났고 아직 보내지 않은 회차. 한 회차에 한 번. */ +export async function notifyDueRounds(now = new Date()): Promise { + const db = getFirestore(); + const due = await db.collection('quests') + .where('timeAttack.preNotified', '==', false) + .where('timeAttack.notifyAt', '<=', Timestamp.fromDate(now)).limit(50).get(); + let sent = 0; + for (const doc of due.docs) { + const round = doc.get('timeAttack') as Round; + // 먼저 표시해 두 번 보내지 않는다(스케줄 겹침). 발송 실패는 로그로 남긴다. + const claimed = await db.runTransaction(async (tx) => { + const fresh = (await tx.get(doc.ref)).get('timeAttack') as Round | undefined; + if (!fresh || fresh.roundId !== round.roundId || fresh.preNotified) return false; + tx.update(doc.ref, { 'timeAttack.preNotified': true }); + return true; + }); + if (!claimed) continue; + const minutes = Math.round((round.activeTo.toMillis() - round.activeFrom.toMillis()) / 60_000); + const before = Math.max(1, Math.ceil((round.activeFrom.toMillis() - now.getTime()) / 60_000)); + if (await pushRound(doc.id, round, minutes, before)) sent += 1; + } + return sent; +} + +/** `fn-quest-timeattack-notify` — 1분마다 예약 회차의 「3분 전」 Push(Q-TA-03 C). */ +export const fnQuestTimeAttackNotify = onSchedule('every 1 minutes', async () => { + try { + await notifyDueRounds(); + } catch (error) { + logger.error('[timeattack] 사전 Push 실패 — 다음 회차 재시도', { error: String(error) }); + } }); /** - * `fn-quest-timeattack` — 참여 · 검증 · 지급. + * `fn-quest-timeattack` — 회차 참여 · 지급. * - * 판정은 세 가지다. 대표 팬덤 자격(C-16, [questOf]) · **서버 시각** 창 · 1인 1회. - * 멱등키에 날짜를 넣지 않는다 — 창이 자정을 넘어도 두 번 받지 못한다. 접두 `quest:` 는 - * `fnAdReward` 가 기본 보상 행을 알아보는 규칙이다. - * - * 이미 받은 사용자의 재호출(응답 유실 재시도)은 오류가 아니라 **첫 지급 결과**를 - * `duplicate: true` 로 돌려준다 — 마감 뒤 재시도여도 같다. 새 지급은 없다. + * 판정: 원 Quest 자격(C-16, [questOf]) · **서버 시각** 창 · 회차당 1인 1회. 멱등키는 회차 단위라 창이 + * 자정을 넘어도 두 번 받지 못한다. 접두 `quest:` 는 `fnAdReward` 가 기본 보상 행을 알아보는 규칙이다. + * 이미 받은 사용자의 재호출은 첫 지급 결과를 `duplicate: true` 로 돌려준다(마감 뒤 재시도도 같다). */ export const fnQuestTimeAttack = onCall(async (req) => { const uid = callerUid(req); - const questId = str(req.data, 'questId'); - const quest = await questOf(uid, questId, ['timeattack']); + return joinTimeAttack(uid, str(req.data, 'questId')); +}); + +export async function joinTimeAttack(uid: string, questId: string, now = new Date()) { + const quest = await questOf(uid, questId, TIME_ATTACK_TYPES); + const round = quest.get('timeAttack') as Round | undefined; + if (!round) fail(ERR.questIncomplete, '타임어택 참여 시간이 아닙니다.'); const { timeAttackFpiAxis: axis } = await loadPolicy(); - const now = new Date(); - const idempotencyKey = `quest:${uid}:${questId}:1`; - const progressRef = getFirestore().doc(`users/${uid}/questProgress/${questId}`); - if (((await progressRef.get()).get('count') as number | undefined ?? 0) >= 1) { - const prior = await getFirestore() - .doc(`users/${uid}/popTransactions/${txIdOf(idempotencyKey)}`).get(); + const db = getFirestore(); + const idempotencyKey = `quest:${uid}:${questId}:${round.roundId}`; + const progressRef = db.doc(`users/${uid}/questProgress/${questId}`); + const progress = await progressRef.get(); + if (progress.get('timeAttackRoundId') === round.roundId) { + const prior = await db.doc(`users/${uid}/popTransactions/${txIdOf(idempotencyKey)}`).get(); if (!prior.exists) fail(ERR.popDuplicate, '이미 참여한 타임어택입니다.'); return { txId: prior.id, @@ -143,52 +207,47 @@ export const fnQuestTimeAttack = onCall(async (req) => { duplicate: true, }; } - if (quest.get('active') === false || - !inWindow(now, quest.get('activeFrom') as Timestamp | undefined, - quest.get('activeTo') as Timestamp | undefined)) { + if (!inWindow(now, round.activeFrom, round.activeTo)) { fail(ERR.questIncomplete, '타임어택 참여 시간이 아닙니다.'); } - const amount = quest.get('rewardPop'); - if (!Number.isSafeInteger(amount) || (amount as number) <= 0) { + if (!Number.isSafeInteger(round.rewardPop) || round.rewardPop <= 0) { fail(ERR.questIncomplete, '보상이 설정되지 않은 타임어택입니다.'); } - + const key = dayKey(now); + const limit = await dailyLimitOf(questId); const result = await grantPop({ uid, popType: 'activity', - amount: amount as number, + amount: round.rewardPop, reasonCode: 'QUEST_TIMEATTACK', growthSource: 'quest_reward', idempotencyKey, refPath: quest.ref.path, alsoWrite: (tx, user) => { - // 인정 활동 1건 = User EXP(C-18) + FPI 축 1 단위(Q-TA-05). 축이 꺼졌거나 모르는 값이면 EXP 만. - if (axis && FPI_AXES.includes(axis)) { + // 원 Quest 의 그날 참여를 겸한다 — 이미 했으면 FPI · 횟수를 더하지 않는다(중복 FPI 금지). + const done = progress.get('dayKey') === key ? ((progress.get('count') as number | undefined) ?? 0) : 0; + const counts = done < limit; + if (counts && axis && FPI_AXES.includes(axis)) { writeFpiEvent(tx, { uid, fandomId: fandomOfUser(user), axis, units: 1, activity: 'timeattack', - sourceId: `timeattack:${questId}`, occurredAt: now, refPath: quest.ref.path, + sourceId: `timeattack:${questId}:${round.roundId}`, occurredAt: now, refPath: quest.ref.path, }, user); } else { writeUserExp(tx, uid, user); } - markCompletion(tx, uid, `${questId}_${dayKey(now)}`, { questId, txId: txIdOf(idempotencyKey) }); + markCompletion(tx, uid, `${questId}_${key}`, { questId, txId: txIdOf(idempotencyKey) }); tx.set(progressRef, { - questId, dayKey: dayKey(now), count: 1, rewardedPop: amount, + questId, timeAttackRoundId: round.roundId, + ...(counts ? { dayKey: key, count: done + 1 } : {}), completedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); - // 참여 인원 — 카드의 「참여 인원」이 읽는 필드. 지급과 같은 트랜잭션이라 중복 집계가 없다. tx.update(quest.ref, { participantCount: FieldValue.increment(1) }); }, }); - return { - txId: result.txId, - balanceAfter: result.balanceAfter, - rewarded: amount, - questId, - }; -}); + return { txId: result.txId, balanceAfter: result.balanceAfter, rewarded: round.rewardPop, questId }; +} /** * `fn-server-time` — 서버 시각(ms). 앱이 단말 시계와의 차이를 재어 타임어택 카운트다운 · diff --git a/app/firebase/functions/src/quest/ugc.ts b/app/firebase/functions/src/quest/ugc.ts index 199f65d..c8b52d5 100644 --- a/app/firebase/functions/src/quest/ugc.ts +++ b/app/firebase/functions/src/quest/ugc.ts @@ -1,14 +1,14 @@ import { HttpsError, onCall } from 'firebase-functions/v2/https'; import { onSchedule } from 'firebase-functions/v2/scheduler'; import { logger } from 'firebase-functions'; -import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore'; +import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore'; import type { DocumentReference, DocumentSnapshot, Transaction } from 'firebase-admin/firestore'; -import { callerUid, optionalStr, str } from '../core/callable.js'; +import { callerUid, optionalStr, requireRole, str } from '../core/callable.js'; import { ERR, fail, positiveInt } from '../core/errors.js'; import { txIdOf } from '../core/ids.js'; import { dayKey } from '../core/time.js'; -import { loadPolicy, type PolicyConfig } from '../core/config.js'; +import { FPI_AXES, loadPolicy, type PolicyConfig } from '../core/config.js'; import { grantPop, refundSplit, spendSplit, type GrantInput, type LedgerResult, type PopType } from '../ledger/ledger.js'; import { fandomOfUser, markCompletion, verifiedQuestCountOf, writeFpiEvent } from '../ledger/fpi.js'; import { linkPreview } from './link_preview.js'; @@ -42,6 +42,11 @@ const field = (f: string) => [{ field: f, required: true }]; /** * `config/questCatalog` 가 없을 때의 출발점 — C-01 매핑 · C-02 `ugcAllowed` 초기값(원장 §19 S03 A안). * 위치 무관 QR / NFC 는 검증 명세 확정 전 `ugcAllowed = false`(C-03). 운영에서는 문서가 덮는다. + * + * 만보기는 사용자 생성을 연다 — Figma 퀘스트 생성 「건강 → 만보기 · 목표 10,000」(`259:45798`) · + * 8/24 답변서 Q8 「Figma 의 Quest 생성 흐름 기준 · HEALTH 우선 지원」. S03 초기값(운영 등록 전용)은 + * 저희가 정한 값이라 Figma 를 따른다(확인 통보). 폰 흔들기(Shake)는 BPM 출처가 정해지지 않아 + * Music Shake 해석 확인 요청 Q-MS-07 회신 대기로 닫아 둔다. */ export const DEFAULT_QUEST_CATALOG: QuestCatalog = { categories: [ @@ -51,7 +56,7 @@ export const DEFAULT_QUEST_CATALOG: QuestCatalog = { { key: 'location', label: '특정 위치', order: 4, visible: true }, ], activities: [ - { key: 'walk', categoryKey: 'health', order: 1, visible: true, ugcAllowed: false, inputSchema: field('goal') }, + { key: 'walk', categoryKey: 'health', order: 1, visible: true, ugcAllowed: true, inputSchema: field('goal') }, { key: 'shake', categoryKey: 'health', order: 2, visible: true, ugcAllowed: false, inputSchema: field('goal') }, { key: 'streaming', categoryKey: 'planet', order: 1, visible: true, ugcAllowed: false, inputSchema: field('link') }, { key: 'youtube', categoryKey: 'planet', order: 2, visible: true, ugcAllowed: false, inputSchema: field('link') }, @@ -71,10 +76,16 @@ export async function loadCatalog(): Promise { } /** - * 서버에 사용자 생성 참여 경로가 있는 활동. Walk · Shake 는 Pool 지급 검증 경로가 없고, 위치 무관 + * 서버에 사용자 생성 참여 경로가 있는 활동. Shake 는 BPM 출처 회신 대기(Q-MS-07), 위치 무관 * QR · NFC 는 검증 명세 전이다(C-03) — 운영자가 `ugcAllowed` 를 켜도 여기서 막는다. */ -const UGC_SUPPORTED = new Set(['location', 'vote', 'sns', 'community', 'youtube', 'streaming']); +const UGC_SUPPORTED = new Set(['walk', 'location', 'vote', 'sns', 'community', 'youtube', 'streaming']); + +/** + * 만보기 목표 걸음 상한 — 걸음 판정(`fnQuestVerifySteps`)이 하루 10만 보 초과를 비정상으로 끊는다. + * 그보다 큰 목표는 달성할 수 없으므로 받지 않는다. 판정 문턱 자체는 영역 B-02 와 함께 검토 대상. + */ +export const UGC_WALK_MAX_GOAL = 100_000; /** 무보상 기본 참여 Quest(C-04). Pool 예치가 없다. */ const NO_REWARD = new Set(['youtube', 'streaming']); /** 사람 검토 대상(C-08). 나머지(위치 · Community)는 자동 검증 즉시 지급(B-60). */ @@ -160,27 +171,34 @@ function isEnded(quest: DocumentSnapshot, now = Date.now()): boolean { return endsAt instanceof Timestamp && endsAt.toMillis() <= now; } +/** 운영 Quest(생성자 없음)인가 — Reward Pool 이 없고 보상은 FANUZU 재원에서 나간다. */ +const isOperatorQuest = (quest: DocumentSnapshot) => typeof quest.get('authorUid') !== 'string'; + /** * Pool 에서 참여자에게 지급한다 — 지급 원장과 장부 증감이 한 트랜잭션이다. * `fromReserved` 면 예약분(검토 승인)을, 아니면 미배정분(자동 검증)을 쓴다. + * + * 운영 Quest(Vote · SNS · Community — Figma 홈 「오늘 꼭 해야하는」 운영 좋아요 · 댓글 퀘스트)는 Pool 이 + * 없으므로 장부를 보지 않고 FANUZU 재원으로 지급한다. 예전에는 Pool 0 이라 보상이 있으면 항상 실패했다. */ export async function payFromPool( questRef: DocumentReference, amount: number, fromReserved: boolean, grant: GrantInput, ): Promise { return getFirestore().runTransaction(async (tx) => { const quest = await tx.get(questRef); + const operator = isOperatorQuest(quest); if (!fromReserved) { assertOpen(quest); - if (unallocated(poolOf(quest)) < amount) fail(ERR.questIncomplete, '보상 재원이 모두 배정된 퀘스트입니다.'); + if (!operator && unallocated(poolOf(quest)) < amount) fail(ERR.questIncomplete, '보상 재원이 모두 배정된 퀘스트입니다.'); } return grantPop({ ...grant, alsoWrite: (t, user) => { grant.alsoWrite?.(t, user); t.update(questRef, { - 'pool.paid': FieldValue.increment(amount), + ...(operator ? {} : { 'pool.paid': FieldValue.increment(amount) }), ...(fromReserved - ? { 'pool.reserved': FieldValue.increment(-amount) } + ? (operator ? {} : { 'pool.reserved': FieldValue.increment(-amount) }) : { participantCount: FieldValue.increment(1) }), updatedAt: FieldValue.serverTimestamp(), }); @@ -258,7 +276,8 @@ async function finalizeRejection( reviewLog: FieldValue.arrayUnion({ actorUid, action: 'finalize', reason, at: Timestamp.now() }), }); tx.update(questRef, { - 'pool.reserved': FieldValue.increment(-((sub.get('rewardPop') as number | undefined) ?? 0)), + ...(sub.get('questAuthorUid') + ? { 'pool.reserved': FieldValue.increment(-((sub.get('rewardPop') as number | undefined) ?? 0)) } : {}), participantCount: FieldValue.increment(-1), updatedAt: FieldValue.serverTimestamp(), }); return true; @@ -281,16 +300,9 @@ export async function abandonDraft(questId: string, status: 'abandoned' | 'cance if (quest.get('status') !== 'draft') return; tx.update(ref, { status, active: false, poolRefund: 'due', updatedAt: FieldValue.serverTimestamp() }); }); - const quest = await ref.get(); - if (quest.get('poolRefund') !== 'due') return; - const txIds = (quest.get('depositTxIds') as string[] | undefined) ?? []; - if (txIds.length > 0) { - await refundSplit({ - uid: quest.get('authorUid') as string, originalTxIds: txIds, - idempotencyKey: `questPoolRefund:${questId}`, reasonCode: 'QUEST_POOL_REFUND', - }); - } - await ref.update({ poolRefund: 'done', updatedAt: FieldValue.serverTimestamp() }); + if ((await ref.get()).get('poolRefund') !== 'due') return; + // 검수 반려 · 검수 중 철회가 환불 도중 멈춘 건도 여기서 마저 돌린다(수수료 포함). + await refundBeforePublish(ref); } /** 15분 넘게 공개되지 못한 Draft 와 환불이 끝나지 않은 Draft 를 닫는다. */ @@ -336,6 +348,10 @@ export interface CreateInput { uid: string; clientSeq: string; activityKey: string; title: string; description: string; rewardPop: number; capacity: number; endDate?: string | undefined; utcOffsetMinutes: number; url?: string | undefined; manualTitle?: string | undefined; placeId?: string | undefined; + /** 만보기 목표 걸음 수(`inputSchema` 의 `goal`). */ + goal?: number | undefined; + /** 지도에 꽂은 핀(등록 장소가 아닐 때) — Figma `371:356705` 위치 선택. */ + pin?: { lat: number; lng: number; address: string } | undefined; } export const fnQuestCreate = onCall(async (req) => { @@ -354,6 +370,8 @@ export const fnQuestCreate = onCall(async (req) => { url: optionalStr(data, 'url'), manualTitle: optionalStr(data, 'manualTitle'), placeId: optionalStr(data, 'placeId'), + goal: int('goal'), + pin: data.pin && typeof data.pin === 'object' ? data.pin as CreateInput['pin'] : undefined, }); }); @@ -428,8 +446,28 @@ export async function createQuest(input: CreateInput, now = new Date()): Promise fetchedAt: Timestamp.fromDate(now), }; } + let goal = 1; + if (fields.has('goal')) { + goal = positiveInt(input.goal ?? 0, 'goal'); + if (goal > UGC_WALK_MAX_GOAL) { + throw new HttpsError('invalid-argument', `목표는 ${UGC_WALK_MAX_GOAL.toLocaleString()}보 이하입니다.`); + } + } let placeId: string | null = null; - if (fields.has('place')) { + if (fields.has('place') && !input.placeId && input.pin) { + // 지도 핀 → 이 Quest 의 장소를 만든다(해석 확인 요청 영역 B 장소 등록 주체 — 권고안 임시 적용). + // 반경은 문서에 두지 않아 판정이 B-27 기본 100m 를 쓴다. 재시도는 같은 문서를 덮는다. + const { lat, lng, address } = input.pin; + if (typeof lat !== 'number' || typeof lng !== 'number' || Math.abs(lat) > 90 || Math.abs(lng) > 180 + || typeof address !== 'string' || !address.trim() || address.length > 200) { + throw new HttpsError('invalid-argument', '선택한 위치가 올바르지 않습니다.'); + } + placeId = `ugc_${questRef.id}`; + await db.doc(`places/${placeId}`).set({ + name: address.trim(), address: address.trim(), geo: new GeoPoint(lat, lng), + source: 'user', createdBy: uid, createdAt: FieldValue.serverTimestamp(), + }); + } else if (fields.has('place')) { placeId = input.placeId ?? ''; if (!placeId || placeId.includes('/') || !(await db.doc(`places/${placeId}`).get()).exists) { throw new HttpsError('invalid-argument', '등록된 장소를 골라 주세요.'); @@ -440,10 +478,12 @@ export async function createQuest(input: CreateInput, now = new Date()): Promise const draft = { type: activity.key, activityKey: activity.key, categoryKey: category.key, title, userDescription: description, authorUid: uid, authorName: publicNameOf(user), - fandomId, rewardMode, rewardPop, capacity, goal: 1, + fandomId, rewardMode, rewardPop, capacity, goal, link, placeId, verifyMethods: placeId ? ['gps'] : [], repeat: { allowed: false, intervalHours: 24, maxCount: 1 }, endsAt: Timestamp.fromDate(endsAt), creatorTz: offsetLabel(input.utcOffsetMinutes), + // 마감은 검수 승인(실제 공개) 시각으로 다시 계산한다 — 입력값을 남겨 둔다. + endDate: input.endDate ?? null, utcOffsetMinutes: input.utcOffsetMinutes, pool: { deposited: pool, depositSupport: 0, paid: 0, reserved: 0, returned: 0, returnedSupport: 0 }, feePop: fee, participantCount: 0, status: 'draft', active: false, depositTxIds: [] as string[], createdAt: Timestamp.fromDate(now), updatedAt: FieldValue.serverTimestamp(), @@ -485,8 +525,13 @@ export async function createQuest(input: CreateInput, now = new Date()): Promise } /** - * 2) 수수료(일반 우선 · Burn) + 공개 — Draft 에 저장된 `feePop` · `endsAt` 만 쓴다. 수수료를 못 내면 + * 2) 수수료(일반 우선) + **공개 요청** — Draft 에 저장된 `feePop` 만 쓴다. 수수료를 못 내면 * Draft 취소(예치 원타입 환불). Draft 가 이미 닫혔으면(스케줄러가 버림) 수수료도 빼지 않는다. + * + * 공개 요청은 곧 공개가 아니다 — 상태는 `review`(검수 대기)이고 노출되지 않는다. 운영 승인 뒤에만 + * `published` 가 된다(상세정의 v1.6.1 8장 「검수 절차」 · 1주차 회신 4항 「승인 후 공개」 · 8/24 답변서 + * Q8 「Admin 수동 승인 또는 Role 기반 검토 후 공개」). 수수료는 공개 확정(승인) 때 Burn 으로 굳고, + * 운영 반려면 예치 · 수수료를 모두 돌려준다(B-30). [fnQuestModerate] */ async function publishDraft( questRef: DocumentReference, uid: string, clientSeq: string, now: Date, @@ -497,8 +542,8 @@ async function publishDraft( if ((await tx.get(questRef)).get('status') !== 'draft') fail(ERR.permissionDenied, '공개할 수 없는 퀘스트입니다.'); }; const publish = (tx: Transaction, feeTxIds: string[]) => tx.update(questRef, { - status: 'published', active: true, feeTxIds, - publishedAt: Timestamp.fromDate(now), updatedAt: FieldValue.serverTimestamp(), + status: 'review', active: false, feeTxIds, + reviewRequestedAt: Timestamp.fromDate(now), updatedAt: FieldValue.serverTimestamp(), }); try { if (fee > 0) { @@ -519,7 +564,115 @@ async function publishDraft( await abandonDraft(questRef.id, 'cancelled'); throw error; } - return { questId: questRef.id, status: 'published' }; + return { questId: questRef.id, status: 'review' }; +} + +// ─── 공개 전 검수 · 취소(상세 8장 · 1주차 4항 · Q8 · B-30) ───────────────────── + +/** 검수 대기 Quest 의 예치 · 수수료를 원타입으로 돌려준다(반려 · 검수 중 철회). 멱등. */ +async function refundBeforePublish(ref: DocumentReference): Promise { + const quest = await ref.get(); + const uid = quest.get('authorUid') as string; + const deposit = (quest.get('depositTxIds') as string[] | undefined) ?? []; + const fee = (quest.get('feeTxIds') as string[] | undefined) ?? []; + if (deposit.length > 0) { + await refundSplit({ uid, originalTxIds: deposit, idempotencyKey: `questPoolRefund:${ref.id}`, reasonCode: 'QUEST_POOL_REFUND' }); + } + if (fee.length > 0) { + await refundSplit({ uid, originalTxIds: fee, idempotencyKey: `questFeeRefund:${ref.id}`, reasonCode: 'QUEST_FEE_REFUND' }); + } + await ref.update({ poolRefund: 'done', updatedAt: FieldValue.serverTimestamp() }); +} + +/** + * `fn-quest-moderate` — 운영자(ops · master)의 공개 전 검수. + * + * - 승인: `published` · 노출. 마감은 **승인 시각** 기준으로 다시 계산한다(종료일 없으면 +24h). 고른 + * 종료일이 이미 최소 기간을 못 채우면 승인할 수 없다 — 반려(전액 반환)로 처리한다. + * - 반려: 사유 필수. 예치 · 수수료를 원타입으로 반환(B-30 「운영반려 … 반환」). + * 화면(Admin 「퀘스트 일람(사용자)」)은 4차 Admin 웹에서 붙는다. + */ +export const fnQuestModerate = onCall(async (req) => { + const actorUid = callerUid(req); + requireRole(req, ['ops', 'master']); + const data = (req.data ?? {}) as Record; + const action = str(data, 'action'); + if (action !== 'approve' && action !== 'reject') throw new HttpsError('invalid-argument', 'action 이 올바르지 않습니다.'); + return moderateQuest(actorUid, str(data, 'questId'), action, optionalStr(data, 'reason')); +}); + +export async function moderateQuest( + actorUid: string, questId: string, action: 'approve' | 'reject', reason?: string, now = new Date(), +): Promise<{ questId: string; status: string }> { + if (questId.includes('/')) fail(ERR.permissionDenied, '퀘스트가 아닙니다.'); + const db = getFirestore(); + const ref = db.doc(`quests/${questId}`); + const policy = await loadPolicy(); + const at = Timestamp.fromDate(now); + if (action === 'approve') { + await db.runTransaction(async (tx) => { + const quest = await tx.get(ref); + if (quest.get('status') !== 'review') fail(ERR.popDuplicate, '검수 대기 중인 퀘스트가 아닙니다.'); + // 입력값이 남아 있으면(생성 경로) 승인 시각 기준으로 다시 계산, 없으면 굳힌 마감 그대로. + const endsAt = quest.get('endDate') === undefined + ? quest.get('endsAt') as Timestamp + : Timestamp.fromDate(endsAtOf(now, (quest.get('endDate') as string | null) ?? undefined, + (quest.get('utcOffsetMinutes') as number | undefined) ?? 0, policy)); + tx.update(ref, { + status: 'published', active: true, publishedAt: at, endsAt, + moderation: { action: 'approve', actorUid, reason: null, at }, + updatedAt: FieldValue.serverTimestamp(), + }); + }); + return { questId, status: 'published' }; + } + const text = reason?.trim() ?? ''; + if (!text || text.length > 500) throw new HttpsError('invalid-argument', '반려 사유를 입력해 주세요.'); + await db.runTransaction(async (tx) => { + const quest = await tx.get(ref); + if (quest.get('status') !== 'review') fail(ERR.popDuplicate, '검수 대기 중인 퀘스트가 아닙니다.'); + tx.update(ref, { + status: 'rejected', active: false, poolRefund: 'due', + moderation: { action: 'reject', actorUid, reason: text, at }, + updatedAt: FieldValue.serverTimestamp(), + }); + }); + await refundBeforePublish(ref); + return { questId, status: 'rejected' }; +} + +/** + * `fn-quest-cancel` — 생성자 취소(B-30). + * + * - 검수 대기 중: 공개 확정 전이라 예치 · 수수료를 모두 돌려준다(Draft 취소 미부과와 같은 단계). + * - 공개 후: **수수료 비환불.** 마감을 지금으로 당겨 참여를 닫고, 미배정 Pool 은 즉시 반환한다(C-09). + * 이미 제출된 검토 대기 건은 예약분 그대로 검토 · 지급한다. + */ +export const fnQuestCancel = onCall(async (req) => cancelQuest(callerUid(req), str(req.data, 'questId'))); + +export async function cancelQuest(uid: string, questId: string, now = new Date()): Promise<{ questId: string; status: string }> { + if (questId.includes('/')) fail(ERR.permissionDenied, '퀘스트가 아닙니다.'); + const db = getFirestore(); + const ref = db.doc(`quests/${questId}`); + const before = await db.runTransaction(async (tx) => { + const quest = await tx.get(ref); + if (!quest.exists || quest.get('authorUid') !== uid) fail(ERR.permissionDenied, '생성자만 취소할 수 있습니다.'); + const status = quest.get('status') as string; + if (status === 'review') { + tx.update(ref, { status: 'cancelled', active: false, poolRefund: 'due', cancelledAt: Timestamp.fromDate(now), updatedAt: FieldValue.serverTimestamp() }); + } else if (status === 'published' && !isEnded(quest, now.getTime())) { + tx.update(ref, { endsAt: Timestamp.fromDate(now), cancelledAt: Timestamp.fromDate(now), updatedAt: FieldValue.serverTimestamp() }); + } else { + fail(ERR.popDuplicate, '취소할 수 있는 퀘스트가 아닙니다.'); + } + return status; + }); + if (before === 'review') { + await refundBeforePublish(ref); + return { questId, status: 'cancelled' }; + } + await settleQuestPool(questId, now); + return { questId, status: 'ended' }; } // ─── 참여 · 제출(C-08 · C-11 · C-16 · C-17 · G-01) ────────────────────────── @@ -577,7 +730,7 @@ export async function submitQuest( const user = await db.doc(`users/${uid}`).get(); const at = Timestamp.now(); const base = { - questId, uid, questAuthorUid: quest.get('authorUid'), rewardPop, + questId, uid, questAuthorUid: quest.get('authorUid') ?? null, rewardPop, fandomIdAtSubmit: fandomOfUser(user), submittedAt: at, }; @@ -633,19 +786,65 @@ export async function submitQuest( }); return; } - if (unallocated(poolOf(q)) < rewardPop) fail(ERR.questIncomplete, '보상 재원이 모두 배정된 퀘스트입니다.'); + const operator = isOperatorQuest(q); + if (!operator && unallocated(poolOf(q)) < rewardPop) fail(ERR.questIncomplete, '보상 재원이 모두 배정된 퀘스트입니다.'); tx.create(subRef, { ...base, status: 'submitted', payoutStatus: 'pending', evidence: [proof], reviewLog: [{ actorUid: uid, action: 'submit', reason: null, at }], }); tx.update(quest.ref, { - 'pool.reserved': FieldValue.increment(rewardPop), participantCount: FieldValue.increment(1), + ...(operator ? {} : { 'pool.reserved': FieldValue.increment(rewardPop) }), + participantCount: FieldValue.increment(1), updatedAt: FieldValue.serverTimestamp(), }); }); return { status: 'submitted', submissionId: subRef.id }; } +/** + * 사용자 생성 만보기 — 오늘 걸음이 Quest 목표(`goal`) 이상이면 생성자 Pool 에서 1인 1회 즉시 지급 + * (자동 검증형 — B-60). 걸음 값 자체의 비정상 판정은 호출자(`fnQuestVerifySteps`)가 먼저 한다. + * 참여 기록은 Community 와 같은 제출 건(`questSubmissions/{questId}_{uid}`, `paid`)이다. + */ +export async function verifyUgcWalk( + uid: string, quest: DocumentSnapshot, steps: number, at: Date, +): Promise> { + const db = getFirestore(); + if (quest.get('authorUid') === uid) fail(ERR.permissionDenied, '내가 만든 퀘스트에는 참여할 수 없습니다.'); + const goal = (quest.get('goal') as number | undefined) ?? 0; + if (!(goal > 0) || steps < goal) { + fail(ERR.questIncomplete, `아직 ${goal.toLocaleString()}보에 도달하지 않았습니다.`); + } + const subRef = db.doc(`questSubmissions/${submissionIdOf(quest.id, uid)}`); + if ((await subRef.get()).exists) fail(ERR.popDuplicate, '이미 참여한 퀘스트입니다.'); + const rewardPop = (quest.get('rewardPop') as number | undefined) ?? 0; + const user = await db.doc(`users/${uid}`).get(); + const stamp = Timestamp.fromDate(at); + const idempotencyKey = `quest:${uid}:${quest.id}:${subRef.id}`; + const result = await payFromPool(quest.ref, rewardPop, false, { + uid, popType: 'activity', amount: rewardPop, reasonCode: 'QUEST_UGC', growthSource: 'quest_reward', + idempotencyKey, refPath: subRef.path, + alsoWrite: (tx, u) => { + tx.create(subRef, { + questId: quest.id, uid, questAuthorUid: quest.get('authorUid') ?? null, rewardPop, + fandomIdAtSubmit: fandomOfUser(user), submittedAt: stamp, + status: 'paid', payoutStatus: 'succeeded', + evidence: [{ kind: 'steps', steps, at: stamp }], + reviewLog: [{ actorUid: 'system', action: 'auto_verify', reason: null, at: stamp }], + }); + // 걸음은 FPI HEALTH 축 1 단위(C-35) — 운영 만보기의 구간 1개와 같은 단위. + writeFpiEvent(tx, { + uid, fandomId: fandomOfUser(u), axis: 'health', units: 1, activity: 'steps', + sourceId: `ugc:${quest.id}`, occurredAt: at, refPath: subRef.path, + }, u); + markCompletion(tx, uid, `${quest.id}_${dayKey(at)}`, { + questId: quest.id, submissionId: subRef.id, txId: txIdOf(idempotencyKey), + }); + }, + }); + return { status: 'paid', txId: result.txId, balanceAfter: result.balanceAfter, rewarded: rewardPop, questId: quest.id }; +} + // ─── 검토(C-08 · C-09 · C-17 · G-03) ───────────────────────────────────────── /** @@ -705,7 +904,8 @@ export async function reviewSubmission(input: { }); if (final) { tx.update(questRef, { - 'pool.reserved': FieldValue.increment(-((sub.get('rewardPop') as number | undefined) ?? 0)), + ...(sub.get('questAuthorUid') + ? { 'pool.reserved': FieldValue.increment(-((sub.get('rewardPop') as number | undefined) ?? 0)) } : {}), participantCount: FieldValue.increment(-1), updatedAt: FieldValue.serverTimestamp(), }); } @@ -724,12 +924,25 @@ export async function reviewSubmission(input: { }); const uid = snap.get('uid') as string; const amount = (snap.get('rewardPop') as number | undefined) ?? 0; + const { ugcReviewedFpiAxis: axis } = await loadPolicy(); try { const result = await payFromPool(questRef, amount, true, { uid, popType: 'activity', amount, reasonCode: 'QUEST_UGC', growthSource: 'quest_reward', idempotencyKey: `quest:${uid}:${snap.get('questId')}:${subRef.id}`, refPath: subRef.path, - alsoWrite: (tx) => { - tx.update(subRef, { status: 'paid', payoutStatus: 'succeeded', paidAt: at }); + alsoWrite: (tx, u) => { + // 기본 보상 txId — 앱이 승인 · 지급 시점에 광고 추가 보상을 붙인다(10/3 통합회신 Q-UQ-03 A). + tx.update(subRef, { + status: 'paid', payoutStatus: 'succeeded', paidAt: at, + txId: txIdOf(`quest:${uid}:${snap.get('questId')}:${subRef.id}`), + }); + // 승인 지급도 인정 활동이다 — FPI 원천 1건(C-34). Vote · SNS 의 축은 C-35 매핑에 없어 + // 운영값 `ugcReviewedFpiAxis`(🟡 임시 COMMUNITY — 회신 대기, 해석 확인 요청 영역 B). + if (axis && FPI_AXES.includes(axis)) { + writeFpiEvent(tx, { + uid, fandomId: fandomOfUser(u), axis, units: 1, activity: 'reviewed_quest', + sourceId: `ugc:${snap.get('questId')}`, occurredAt: at.toDate(), refPath: subRef.path, + }, u); + } // 운영 검토를 통과한 완료 = 검증 활동 1회(Campaign 개설자격 A안). markCompletion(tx, uid, `${snap.get('questId')}_${dayKey(at.toDate())}`, { questId: snap.get('questId') as string, submissionId: subRef.id, diff --git a/app/firebase/functions/test/ad_ssv.test.ts b/app/firebase/functions/test/ad_ssv.test.ts new file mode 100644 index 0000000..a52bb74 --- /dev/null +++ b/app/firebase/functions/test/ad_ssv.test.ts @@ -0,0 +1,27 @@ +import { createSign, generateKeyPairSync } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; + +import { verifySsv } from '../src/quest/ad_ssv.js'; + +/** AdMob SSV 서명 검증(fn-ad-ssv) — 에뮬레이터 없이 도는 순수 검증. */ +describe('AdMob SSV 서명 검증', () => { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const keys = new Map([['42', publicKey.export({ type: 'spki', format: 'pem' }) as string]]); + const sign = (message: string) => createSign('sha256').update(message).sign(privateKey) + .toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const message = 'ad_network=5450213213286189855&ad_unit=1234&custom_data=tx1&reward_amount=1' + + '&reward_item=Reward×tamp=1507770365237823&transaction_id=abc&user_id=u1'; + + it('서명이 맞으면 파라미터를 돌려준다', () => { + const params = verifySsv(`${message}&signature=${sign(message)}&key_id=42`, keys); + expect(params?.get('user_id')).toBe('u1'); + expect(params?.get('custom_data')).toBe('tx1'); + }); + + it('값을 바꾸거나 모르는 키 · 서명 없음이면 거부한다', () => { + const sig = sign(message); + expect(verifySsv(`${message.replace('u1', 'u2')}&signature=${sig}&key_id=42`, keys)).toBeNull(); + expect(verifySsv(`${message}&signature=${sig}&key_id=7`, keys)).toBeNull(); + expect(verifySsv(message, keys)).toBeNull(); + }); +}); diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts index a3de09c..234383d 100644 --- a/app/firebase/functions/test/quest_e2e.test.ts +++ b/app/firebase/functions/test/quest_e2e.test.ts @@ -81,8 +81,10 @@ beforeEach(async () => { const snap = await db().collection(`users/${UID}/${sub}`).get(); await Promise.all(snap.docs.map((d) => d.ref.delete())); } - const proofs = await db().collection('placeProofs').get(); - await Promise.all(proofs.docs.map((d) => d.ref.delete())); + for (const c of ['placeProofs', 'adSsv', 'adRewardLogs']) { + const docs = await db().collection(c).get(); + await Promise.all(docs.docs.map((d) => d.ref.delete())); + } const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get(); await Promise.all(events.docs.map((d) => d.ref.delete())); await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM, totalContributedPop: 0 }); @@ -345,29 +347,52 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리 const adRows = async () => (await db().collection(`users/${UID}/popTransactions`) .where('reasonCode', '==', 'AD_REWARD').get()).docs; - it('광고: 노출 ID 를 바꿔 동시에 불러도 일일 한도를 넘지 않고, 같은 노출 재전송은 첫 결과를 돌려준다', async () => { + /** Google 이 보낸 SSV 콜백(서명 검증 통과)을 흉내 낸다 — fn-ad-ssv 가 남기는 기록과 같은 모양. */ + async function ssv(baseTxId: string, transactionId = `ssv-${baseTxId}-${Math.random()}`) { + await db().doc(`adSsv/${transactionId}`).set({ uid: UID, baseTxId, adNetwork: 'admob' }); + return transactionId; + } + const ad = (baseTxId: string) => call(fnAdReward, { baseTxId }); + + it('광고: SSV 기록 없이 함수만 부르면 증분을 주지 않고 횟수도 차감하지 않는다 (B-03 · D-26)', async () => { const baseTxId = await baseReward(); - await db().doc('config/policy').set({ adDailyLimit: 1 }, { merge: true }); - await Promise.allSettled([ - call(fnAdReward, { adImpressionId: `imp-a-${Date.now()}`, baseTxId }), - call(fnAdReward, { adImpressionId: `imp-b-${Date.now()}`, baseTxId }), - ]); + expect(await codeOf(() => ad(baseTxId))).toBe('E-AD-03'); + // 다른 사람의 SSV 기록은 증거가 아니다. + await db().doc('adSsv/ssv-other').set({ uid: 'someone-else', baseTxId, adNetwork: 'admob' }); + expect(await codeOf(() => ad(baseTxId))).toBe('E-AD-03'); + expect((await adRows()).length).toBe(0); + expect((await db().doc(`users/${UID}/questProgress/ad`).get()).exists).toBe(false); + await ssv(baseTxId); + expect((await ad(baseTxId)).rewarded).toBe(10); + }); + + it('광고: 동시에 불러도 한 번만 지급하고, 재전송은 첫 결과를 돌려준다', async () => { + const baseTxId = await baseReward(); + await ssv(baseTxId, 'ssv-a'); + await ssv(baseTxId, 'ssv-b'); + await Promise.allSettled([ad(baseTxId), ad(baseTxId)]); const ledger = await adRows(); expect(ledger.length).toBe(1); - await db().doc('config/policy').set({ adDailyLimit: 5 }, { merge: true }); // 성공한 호출의 재전송(응답 유실) — 오류가 아니라 같은 txId 로 성공, 지급은 늘지 않는다. - const used = ledger[0]!.get('refPath') as string; - const again = await call(fnAdReward, { adImpressionId: used.split('/')[1], baseTxId }); + const again = await ad(baseTxId); expect(again.duplicate).toBe(true); expect(again.txId).toBe(ledger[0]!.id); - // 앱은 이 값을 보상 화면에 띄운다 — 재전송에서도 원장 행의 실제 지급액이다. expect(again.rewarded).toBe(ledger[0]!.get('amount')); expect((await adRows()).length).toBe(1); }); + it('광고: 일일 한도를 넘으면 E-AD-02', async () => { + await db().doc('config/policy').set({ adDailyLimit: 0 }, { merge: true }); + const baseTxId = await baseReward(); + await ssv(baseTxId); + expect(await codeOf(() => ad(baseTxId))).toBe('E-AD-02'); + await db().doc('config/policy').set({ adDailyLimit: 5 }, { merge: true }); + }); + it('광고: 운영 Quest 는 기본 x2 증분만 기여 POP 으로 지급한다 (B-03)', async () => { const baseTxId = await baseReward(30); - const reply = await call(fnAdReward, { adImpressionId: 'imp-x2', baseTxId }); + await ssv(baseTxId); + const reply = await ad(baseTxId); expect(reply.rewarded).toBe(30); const row = only(await adRows()); expect(row.get('popType')).toBe('support'); @@ -381,27 +406,27 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리 it('광고: 지정 Quest 의 x3 은 기본 보상의 2배를 증분으로 준다 (B-03)', async () => { await db().doc(`quests/${ATTEND_QUEST}`).update({ adMultiplier: 3 }); const baseTxId = await baseReward(30); - expect((await call(fnAdReward, { adImpressionId: 'imp-x3', baseTxId })).rewarded).toBe(60); + await ssv(baseTxId); + expect((await ad(baseTxId)).rewarded).toBe(60); }); it('광고: 사용자 생성 Quest 는 기본 보상과 무관한 고정 10 기여 POP 이다 (C-02 · B-28)', async () => { await db().doc(`quests/${ATTEND_QUEST}`).update({ authorUid: 'creator-1' }); const baseTxId = await baseReward(30); - expect((await call(fnAdReward, { adImpressionId: 'imp-ugc', baseTxId })).rewarded).toBe(10); + await ssv(baseTxId); + expect((await ad(baseTxId)).rewarded).toBe(10); const row = only(await adRows()); expect(row.get('popType')).toBe('support'); }); - it('광고: 기본 보상 한 건에 광고 보상은 한 번 · 퀘스트 보상이 아닌 행에는 붙지 않는다', async () => { + it('광고: 퀘스트 보상이 아닌 행에는 붙지 않는다', async () => { + expect(await codeOf(() => ad('no-such-tx'))).toBe('E-QST-01'); const baseTxId = await baseReward(); - await call(fnAdReward, { adImpressionId: 'imp-once-1', baseTxId }); - expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-once-2', baseTxId }))) - .toBe('E-POP-03'); - expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-none', baseTxId: 'no-such-tx' }))) - .toBe('E-QST-01'); + await ssv(baseTxId); + await ad(baseTxId); const adTx = only(await adRows()).id; - expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-self', baseTxId: adTx }))) - .toBe('E-QST-01'); + await ssv(adTx); + expect(await codeOf(() => ad(adTx))).toBe('E-QST-01'); }); }); diff --git a/app/firebase/functions/test/quest_ugc.test.ts b/app/firebase/functions/test/quest_ugc.test.ts index b225f7d..6adb708 100644 --- a/app/firebase/functions/test/quest_ugc.test.ts +++ b/app/firebase/functions/test/quest_ugc.test.ts @@ -5,14 +5,14 @@ import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { DEFAULT_POLICY } from '../src/core/config.js'; import { grantPop } from '../src/ledger/ledger.js'; import { verifiedQuestCountOf } from '../src/ledger/fpi.js'; -import { fnAdReward } from '../src/quest/quest.js'; +import { fnAdReward, fnQuestVerifySteps } from '../src/quest/quest.js'; import { fnQuestVerifyPlace } from '../src/quest/verify.js'; import { isPrivateAddress, linkPreview, parseHtmlMeta, parsePublicUrl, } from '../src/quest/link_preview.js'; import { DEFAULT_QUEST_CATALOG, DRAFT_TTL_MS, createQuest, editQuest, endsAtOf, feeOf, fnQuestEdit, - reviewSubmission, settleQuestPool, submitQuest, sweepDrafts, + reviewSubmission, settleQuestPool, submitQuest, sweepDrafts, moderateQuest, cancelQuest, fnQuestModerate, } from '../src/quest/ugc.js'; import { LINK_PREVIEW_PER_MINUTE, takeLinkPreviewSlot } from '../src/quest/link_preview.js'; import { spendSplit } from '../src/ledger/ledger.js'; @@ -58,11 +58,15 @@ async function wallet(uid: string) { } let seq = 0; -const create = (over: Record = {}) => createQuest({ +/** 공개 요청까지 — 상태 `review`(검수 대기). */ +const request = (over: Record = {}) => createQuest({ uid: CREATOR, clientSeq: `s${++seq}`, activityKey: 'vote', title: '투표 인증', description: '인증샷', rewardPop: 5, capacity: 10, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤직어워드 투표', ...over, }); +/** 공개 요청 + 운영 승인 — 참여를 보는 테스트의 출발점. */ +const create = async (over: Record = {}) => + moderateQuest('ops-1', (await request(over)).questId, 'approve'); const quest = async (id: string) => (await db().doc(`quests/${id}`).get()).data()!; @@ -70,7 +74,7 @@ beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { for (const uid of [CREATOR, PLAYER]) await wipeUser(uid); - for (const c of ['quests', 'questSubmissions', 'posts', 'adRewardLogs', 'questPostUses']) { + for (const c of ['quests', 'questSubmissions', 'posts', 'adRewardLogs', 'questPostUses', 'adSsv']) { await db().recursiveDelete(db().collection(c)); } await db().recursiveDelete(db().collection(`users/${CREATOR}/rateLimits`)); @@ -111,7 +115,7 @@ describe('순수 규칙', () => { community: ['community'], location: ['location', 'qr', 'nfc'], }); const open = DEFAULT_QUEST_CATALOG.activities.filter((a) => a.ugcAllowed).map((a) => a.key); - expect(open).toEqual(['vote', 'sns', 'community', 'location']); + expect(open).toEqual(['walk', 'vote', 'sns', 'community', 'location']); }); it('C-05 SSRF — http(s) 공개 주소만', async () => { @@ -190,7 +194,7 @@ describe('생성', () => { expect(await codeOf(make)).toBe('E-PERM-01'); await db().doc(`users/${PLAYER}/verifiedCompletions/seed_2026-09-01`).set({ questId: 'seed' }); const { questId, status } = await make(); - expect(status).toBe('published'); + expect(status).toBe('review'); expect(await quest(questId)).toMatchObject({ rewardMode: 'none', feePop: 0, feeTxIds: [] }); expect((await db().collection(`users/${PLAYER}/popTransactions`).get()).size).toBe(0); }); @@ -204,7 +208,7 @@ describe('생성', () => { uid: CREATOR, clientSeq: `cap${++seq}`, activityKey: 'youtube', title: '뮤비', description: '', rewardPop: 0, capacity: 0, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤비', }); - for (let i = 0; i < 3; i++) expect((await make()).status).toBe('published'); + for (let i = 0; i < 3; i++) expect((await make()).status).toBe('review'); const err = await make().then(() => null, (e: unknown) => e); expect((err as { details?: Record }).details).toEqual({ code: 'E-QST-04', cap: 3 }); // 보상형은 상한과 무관하다. @@ -212,7 +216,7 @@ describe('생성', () => { }); it('3-16 · 3-18 카탈로그 — ugcAllowed false · 위치 무관 QR 거부 · Admin 변경 즉시 반영', async () => { - expect(await codeOf(() => create({ activityKey: 'walk' }))).toBe('E-PERM-01'); + expect(await codeOf(() => create({ activityKey: 'shake' }))).toBe('E-PERM-01'); expect(await codeOf(() => create({ activityKey: 'qr' }))).toBe('E-PERM-01'); await db().doc('config/questCatalog').set({ ...DEFAULT_QUEST_CATALOG, @@ -236,9 +240,9 @@ describe('생성', () => { uid: CREATOR, clientSeq: seqKey, activityKey: 'vote', title: '딴 제목', description: '', rewardPop: 1, capacity: 1, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: 'x', endDate: '2099-01-01', }); - expect(res.status).toBe('published'); + expect(res.status).toBe('review'); const q = await quest(ref.id); - expect(q).toMatchObject({ status: 'published', rewardPop: 5, capacity: 10, feePop: 10 }); + expect(q).toMatchObject({ status: 'review', rewardPop: 5, capacity: 10, feePop: 10 }); expect((q.endsAt as Timestamp).toMillis()).toBeLessThan(Date.now() + 6 * HOUR); expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 }); }); @@ -316,7 +320,8 @@ describe('참여 · 검토 · 종료', () => { // 운영 검토 통과 = 검증 활동 1회(Campaign 개설자격 A안). expect(await verifiedQuestCountOf(PLAYER)).toBe(1); - const ad = await call(fnAdReward, PLAYER, { adImpressionId: 'imp-ugc-1', baseTxId: paid.txId }); + await db().doc(`adSsv/ssv-ugc-${paid.txId}`).set({ uid: PLAYER, baseTxId: paid.txId, adNetwork: 'admob' }); + const ad = await call(fnAdReward, PLAYER, { baseTxId: paid.txId }); expect(ad.rewarded).toBe(DEFAULT_POLICY.adRewardPop); const q = await quest(questId); expect(q.pool).toMatchObject({ paid: 5, reserved: 0 }); // 광고 보상은 Pool 무관 @@ -434,3 +439,142 @@ describe('참여 · 검토 · 종료', () => { expect(await codeOf(() => submitQuest(PLAYER, second.questId, {}))).toBe('E-QST-01'); }); }); + +describe('공개 전 검수 · 취소 (상세 8장 · 1주차 4항 · Q8 · B-30)', () => { + it('공개 요청은 검수 대기 — 노출 · 참여 불가, 수수료 · 예치는 차감', async () => { + const { questId, status } = await request(); + expect(status).toBe('review'); + expect(await quest(questId)).toMatchObject({ status: 'review', active: false }); + expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 }); + expect(await codeOf(() => submitQuest(PLAYER, questId, { url: 'https://x.com/1' }))).toBe('E-QST-01'); + }); + + it('운영자만 검수한다', async () => { + const { questId } = await request(); + const run = (role?: string) => fnQuestModerate.run({ + data: { questId, action: 'approve' }, auth: { uid: 'u', token: role ? { role } : {} }, + } as never); + expect(await codeOf(() => run())).toBe('E-PERM-01'); + expect(await codeOf(() => run('viewer'))).toBe('E-PERM-01'); + await run('ops'); + expect((await quest(questId)).status).toBe('published'); + }); + + it('승인 — 공개 · 마감은 승인 시각 기준 +24h', async () => { + const { questId } = await request(); + const later = new Date(Date.now() + 3 * HOUR); + await moderateQuest('ops-1', questId, 'approve', undefined, later); + const q = await quest(questId); + expect(q).toMatchObject({ status: 'published', active: true }); + expect((q.endsAt as Timestamp).toMillis()).toBe(later.getTime() + 24 * HOUR); + expect(await codeOf(() => moderateQuest('ops-1', questId, 'reject', '중복'))).toBe('E-POP-03'); + }); + + it('반려 — 사유 필수 · 예치와 수수료를 원타입으로 모두 반환', async () => { + const { questId } = await request(); + expect(await codeOf(() => moderateQuest('ops-1', questId, 'reject', ' '))).toBe('invalid-argument'); + await moderateQuest('ops-1', questId, 'reject', '부적절한 링크'); + expect(await quest(questId)).toMatchObject({ status: 'rejected', poolRefund: 'done' }); + expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 }); + const fee = await db().collection(`users/${CREATOR}/popTransactions`).where('reasonCode', '==', 'QUEST_FEE_REFUND').get(); + expect(fee.size).toBe(1); + expect(await sweepDrafts()).toBe(0); // 두 번 돌려주지 않는다 + expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 }); + }); + + it('검수 중 생성자 취소 — 공개 확정 전이라 전액 반환', async () => { + const { questId } = await request(); + expect(await codeOf(() => cancelQuest(PLAYER, questId))).toBe('E-PERM-01'); + expect((await cancelQuest(CREATOR, questId)).status).toBe('cancelled'); + expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 }); + }); + + it('공개 후 생성자 취소 — 수수료 비환불 · 미배정 Pool 즉시 반환 · 참여 마감', async () => { + const { questId } = await create(); + expect((await cancelQuest(CREATOR, questId)).status).toBe('ended'); + expect((await quest(questId)).status).toBe('ended'); + // 예치 50(일반 40 · 기여 10)은 원타입으로 돌아오고, 수수료 10(기여)은 Burn 그대로. + expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 90 }); + expect(await codeOf(() => submitQuest(PLAYER, questId, { url: 'https://x.com/1' }))).toBe('E-QST-01'); + expect(await codeOf(() => cancelQuest(CREATOR, questId))).toBe('E-POP-03'); + }); +}); + +describe('사용자 생성 만보기 (Figma 259:45798 · Q8)', () => { + const walk = (over: Record = {}) => + create({ activityKey: 'walk', url: undefined, manualTitle: undefined, goal: 10_000, ...over }); + const steps = (questId: string, n: number) => call(fnQuestVerifySteps, PLAYER, { questId, steps: n }); + + it('목표 걸음을 받아 저장한다 · 목표 없음 · 10만 보 초과는 거부', async () => { + expect(await codeOf(() => walk({ goal: 0 }))).toBe('invalid-argument'); + expect(await codeOf(() => walk({ goal: 100_001 }))).toBe('invalid-argument'); + const { questId } = await walk(); + expect(await quest(questId)).toMatchObject({ type: 'walk', goal: 10_000, status: 'published' }); + }); + + it('목표 미달은 거부 · 달성하면 Pool 에서 1인 1회 즉시 지급 · HEALTH FPI', async () => { + const { questId } = await walk(); + expect(await codeOf(() => steps(questId, 9_999))).toBe('E-QST-01'); + const paid = await steps(questId, 10_200) as Record; + expect(paid).toMatchObject({ status: 'paid', rewarded: 5 }); + expect((await quest(questId)).pool).toMatchObject({ paid: 5 }); + expect(await codeOf(() => steps(questId, 12_000))).toBe('E-POP-03'); + const sub = await db().doc(`questSubmissions/${questId}_${PLAYER}`).get(); + expect(sub.get('status')).toBe('paid'); + const fpi = await db().collection(`fandoms/${FAN}/fpiEvents`).where('sourceId', '==', `ugc:${questId}`).get(); + expect(fpi.docs.map((d) => [d.get('uid'), d.get('axis')])).toEqual([[PLAYER, 'health']]); + // 생성자는 자기 Quest 에 참여하지 못한다. + expect(await codeOf(() => call(fnQuestVerifySteps, CREATOR, { questId, steps: 10_200 }))).toBe('E-PERM-01'); + }); +}); + +describe('위치 선택 지도 핀 (Figma 371:356705)', () => { + it('핀 좌표 · 주소로 이 Quest 의 장소를 만들고, 그 자리 반경 안에서 인증된다', async () => { + const { questId } = await create({ + activityKey: 'location', url: undefined, manualTitle: undefined, + pin: { lat: 37.4979, lng: 127.0276, address: '서울특별시 강남구 역삼동' }, + }); + const q = await quest(questId); + const place = (await db().doc(`places/${q.placeId}`).get()).data()!; + expect(place).toMatchObject({ name: '서울특별시 강남구 역삼동', source: 'user', createdBy: CREATOR }); + const ok = await call(fnQuestVerifyPlace, PLAYER, { questId, placeId: q.placeId, lat: 37.4980, lng: 127.0277 }); + expect((ok as Record).rewarded).toBe(5); + }); + + it('좌표가 틀리면 거부한다', async () => { + expect(await codeOf(() => create({ + activityKey: 'location', url: undefined, manualTitle: undefined, + pin: { lat: 91, lng: 0, address: 'x' }, + }))).toBe('invalid-argument'); + }); +}); + +describe('운영 Vote · SNS · Community Quest 지급 (B-11 · B-13)', () => { + it('운영 Vote — 제출 → 운영 승인 시 FANUZU 재원으로 지급 · 승인 지급도 FPI 원천 1건', async () => { + await db().doc('quests/op-vote').set({ type: 'vote', title: '운영 투표', fandomId: FAN, rewardPop: 7, status: 'published' }); + const sub = await submitQuest(PLAYER, 'op-vote', { url: 'https://x.com/vote' }); + expect(sub.status).toBe('submitted'); + expect(await codeOf(() => reviewSubmission({ + actorUid: CREATOR, isOps: false, submissionId: sub.submissionId as string, action: 'approve', + }))).toBe('E-PERM-01'); + const paid = await reviewSubmission({ actorUid: 'ops-1', isOps: true, submissionId: sub.submissionId as string, action: 'approve' }); + expect(paid.status).toBe('paid'); + // 앱이 승인 시점 광고 추가 보상을 붙이는 기준(Q-UQ-03 A). + expect((await db().doc(`questSubmissions/${sub.submissionId as string}`).get()).get('txId')).toBe(paid.txId); + expect(await wallet(PLAYER)).toMatchObject({ general: 7 }); + const fpi = await db().collection(`fandoms/${FAN}/fpiEvents`).where('sourceId', '==', 'ugc:op-vote').get(); + expect(fpi.docs.map((d) => [d.get('axis'), d.get('activity')])).toEqual([['community', 'reviewed_quest']]); + expect((await quest('op-vote')).pool).toBeUndefined(); + }); + + it('운영 Community — 반응 달린 응원글이면 즉시 지급(Pool 없음)', async () => { + await db().doc('quests/op-com').set({ + type: 'community', title: '좋아요', fandomId: FAN, rewardPop: 4, status: 'published', + publishedAt: Timestamp.fromMillis(Date.now() - 60_000), + }); + await db().doc('posts/op-post').set({ fandomId: FAN, authorUid: PLAYER, visibility: 'visible', createdAt: Timestamp.now(), likeCount: 1 }); + const reply = await submitQuest(PLAYER, 'op-com', {}); + expect(reply).toMatchObject({ status: 'paid', rewarded: 4 }); + expect((await quest('op-com')).participantCount).toBe(1); + }); +}); diff --git a/app/firebase/functions/test/timeattack.test.ts b/app/firebase/functions/test/timeattack.test.ts index 5364406..b70fe0d 100644 --- a/app/firebase/functions/test/timeattack.test.ts +++ b/app/firebase/functions/test/timeattack.test.ts @@ -2,16 +2,20 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; -import { fnQuestTimeAttack, fnQuestTimeAttackStart, fnServerTime } from '../src/quest/timeattack.js'; +import { + fnQuestTimeAttack, fnQuestTimeAttackStart, fnServerTime, joinTimeAttack, notifyDueRounds, +} from '../src/quest/timeattack.js'; +import { participateStreaming } from '../src/quest/quest.js'; import { verifiedQuestCountOf } from '../src/ledger/fpi.js'; /** - * 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06 검증 기준 + * TIME ATTACK — 10/3 통합회신 3절(기존 Quest 에 붙는 제한시간 모드 · Q-TA-01~05) · 별첨 1 ③-4 * 「발동·마감 경계 시각의 참여 수락/거부 동작 확인」. */ const UID = 'test-timeattack'; const FANDOM = 'fandom-timeattack'; +const BASE = 'ta-base-stream'; const db = () => getFirestore(); type Reply = Record; @@ -32,12 +36,16 @@ async function codeOf(run: () => Promise): Promise { throw new Error('실패해야 하는 호출이 성공했습니다.'); } -/** 창을 직접 박은 퀘스트. [fromMs] · [toMs] 는 지금 기준 상대 ms. */ -async function quest(id: string, fromMs: number, toMs: number, extra: Reply = {}) { +/** 회차를 직접 박은 원 Quest. [fromMs] · [toMs] 는 지금 기준 상대 ms. */ +async function round(id: string, fromMs: number, toMs: number, extra: Reply = {}, type = 'streaming') { await db().doc(`quests/${id}`).set({ - type: 'timeattack', title: '돌발', fandomId: FANDOM, rewardPop: 30, dailyLimit: 1, active: true, - activeFrom: Timestamp.fromMillis(Date.now() + fromMs), - activeTo: Timestamp.fromMillis(Date.now() + toMs), + type, title: '총공', fandomId: FANDOM, dailyLimit: 1, active: true, + timeAttack: { + roundId: `r-${id}`, title: '돌발', fandomId: FANDOM, rewardPop: 30, scheduled: false, + activeFrom: Timestamp.fromMillis(Date.now() + fromMs), + activeTo: Timestamp.fromMillis(Date.now() + toMs), + notifyAt: null, preNotified: true, + }, ...extra, }); } @@ -46,9 +54,12 @@ beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { await wipeUser(UID); + await db().recursiveDelete(db().collection('quests')); await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM }); await db().doc(`fandoms/${FANDOM}`).set({ name: '타임어택 행성' }, { merge: true }); await db().recursiveDelete(db().collection(`fandoms/${FANDOM}/fpiEvents`)); + await db().doc(`quests/${BASE}`).set({ type: 'streaming', title: '스트리밍 총공', fandomId: FANDOM, dailyLimit: 1 }); + await db().doc('config/policy').delete(); }); describe('서버 시각 — fn-server-time', () => { @@ -59,8 +70,8 @@ describe('서버 시각 — fn-server-time', () => { }); }); -describe('타임어택 발동 — fn-quest-timeattack-start', () => { - const start = { fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 }; +describe('회차 발동 — fn-quest-timeattack-start', () => { + const start = { questId: BASE, fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 }; it('master 가 아니면 거부한다', async () => { expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }))).toBe('E-PERM-01'); @@ -68,125 +79,124 @@ describe('타임어택 발동 — fn-quest-timeattack-start', () => { .toBe('E-PERM-01'); }); - it('창을 서버 시각으로 열고 같은 요청의 재전송은 한 건으로 모은다', async () => { + it('별도 유형을 만들지 않고 원 Quest 에 즉시 회차를 붙인다 · 재전송은 한 건', async () => { const before = Date.now(); const first = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' }); const again = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' }); - expect(again.questId).toBe(first.questId); + expect(again.roundId).toBe(first.roundId); expect(again.duplicate).toBe(true); - - const doc = await db().doc(`quests/${first.questId as string}`).get(); - expect(doc.get('type')).toBe('timeattack'); - expect(doc.get('fandomId')).toBe(FANDOM); - const span = (doc.get('activeTo') as Timestamp).toMillis() - (doc.get('activeFrom') as Timestamp).toMillis(); - expect(span).toBe(10 * 60_000); - expect((doc.get('activeFrom') as Timestamp).toMillis()).toBeGreaterThanOrEqual(before - 1000); + expect((await db().collection('quests').get()).size).toBe(1); + const doc = await db().doc(`quests/${BASE}`).get(); + expect(doc.get('type')).toBe('streaming'); + const r = doc.get('timeAttack'); + expect(r.activeTo.toMillis() - r.activeFrom.toMillis()).toBe(10 * 60_000); + expect(r.activeFrom.toMillis()).toBeGreaterThanOrEqual(before - 1000); + expect(r.scheduled).toBe(false); + // 진행 중 회차가 있으면 새 회차를 붙이지 않는다. + expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r1b' }, { role: 'master' }))) + .toBe('E-QST-01'); }); - it('보상 상한(config/policy.timeAttackMaxPop) · 경로 문자 · 긴 제목을 거부한다', async () => { - const saved = (await db().doc('config/policy').get()).data(); - await db().doc('config/policy').set({ timeAttackMaxPop: 100 }); - try { - expect(await codeOf(() => call(fnQuestTimeAttackStart, - { ...start, rewardPop: 101, requestId: 'r4' }, { role: 'master' }))).toBe('E-QST-01'); - const ok = await call(fnQuestTimeAttackStart, - { ...start, rewardPop: 100, requestId: 'r5' }, { role: 'master' }); - expect(ok.questId).toBeTruthy(); - } finally { - await db().doc('config/policy').delete(); - } - try { - // 문서가 없으면 기본 상한 1,000. - expect(await codeOf(() => call(fnQuestTimeAttackStart, - { ...start, rewardPop: 1001, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01'); - } finally { - if (saved) await db().doc('config/policy').set(saved); - } + it('보상 상한은 초기 100(Q-TA-04 A) · 운영값으로 바꿀 수 있다', async () => { expect(await codeOf(() => call(fnQuestTimeAttackStart, - { ...start, fandomId: 'a/b', requestId: 'r7' }, { role: 'master' }))).toBe('E-QST-01'); + { ...start, rewardPop: 101, requestId: 'r4' }, { role: 'master' }))).toBe('E-QST-01'); + const ok = await call(fnQuestTimeAttackStart, { ...start, rewardPop: 100, requestId: 'r5' }, { role: 'master' }); + expect(ok.roundId).toBeTruthy(); + await db().doc('config/policy').set({ timeAttackMaxPop: 50 }); + await db().doc(`quests/${BASE}`).update({ timeAttack: FieldValue.delete() }); expect(await codeOf(() => call(fnQuestTimeAttackStart, - { ...start, title: 'x'.repeat(61), requestId: 'r8' }, { role: 'master' }))).toBe('E-QST-01'); + { ...start, rewardPop: 51, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01'); }); - it('하루를 넘는 창 · 없는 팬덤은 거부한다', async () => { - expect(await codeOf(() => call(fnQuestTimeAttackStart, - { ...start, durationMinutes: 24 * 60 + 1, requestId: 'r2' }, { role: 'master' }))).toBe('E-QST-01'); - expect(await codeOf(() => call(fnQuestTimeAttackStart, - { ...start, fandomId: 'no-such-fandom', requestId: 'r3' }, { role: 'master' }))).toBe('E-QST-01'); + it('검증형 · 사용자 생성 · 다른 팬덤 Quest, 경로 문자 · 긴 제목 · 하루 초과는 거부한다', async () => { + await db().doc('quests/ta-walk').set({ type: 'walk', fandomId: FANDOM }); + await db().doc('quests/ta-ugc').set({ type: 'vote', fandomId: FANDOM, authorUid: 'someone' }); + await db().doc('quests/ta-else').set({ type: 'vote', fandomId: 'fandom-else' }); + const master = { role: 'master' }; + for (const [over, id] of [ + [{ questId: 'ta-walk' }, 'a'], [{ questId: 'ta-ugc' }, 'b'], [{ questId: 'ta-else' }, 'c'], + [{ fandomId: 'a/b' }, 'd'], [{ title: 'x'.repeat(61) }, 'e'], [{ durationMinutes: 24 * 60 + 1 }, 'f'], + [{ fandomId: 'no-such-fandom' }, 'g'], [{ startAt: new Date(Date.now() - 60_000).toISOString() }, 'h'], + ] as Array<[Reply, string]>) { + expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, ...over, requestId: id }, master))) + .toBe('E-QST-01'); + } }); - it('지속 시간 상한은 config/policy.timeAttackMaxMinutes 를 따른다', async () => { - const saved = (await db().doc('config/policy').get()).data(); - await db().doc('config/policy').set({ timeAttackMaxMinutes: 30 }); - try { - expect(await codeOf(() => call(fnQuestTimeAttackStart, - { ...start, durationMinutes: 31, requestId: 'r9' }, { role: 'master' }))).toBe('E-QST-01'); - const ok = await call(fnQuestTimeAttackStart, - { ...start, durationMinutes: 30, requestId: 'r10' }, { role: 'master' }); - expect(ok.questId).toBeTruthy(); - } finally { - await db().doc('config/policy').delete(); - if (saved) await db().doc('config/policy').set(saved); - } + it('예약 발동 — 즉시 Push 없이 시작 3분 전에 한 번 보낸다(Q-TA-03 C)', async () => { + const startAt = new Date(Date.now() + 10 * 60_000); + const reply = await call(fnQuestTimeAttackStart, + { ...start, startAt: startAt.toISOString(), requestId: 'r7' }, { role: 'master' }); + expect(reply).toMatchObject({ scheduled: true, pushed: false }); + const r = (await db().doc(`quests/${BASE}`).get()).get('timeAttack'); + expect(r.notifyAt.toMillis()).toBe(startAt.getTime() - 3 * 60_000); + // 알림 시각 전에는 보내지 않는다. + await notifyDueRounds(new Date(startAt.getTime() - 4 * 60_000)); + expect((await db().doc(`quests/${BASE}`).get()).get('timeAttack.preNotified')).toBe(false); + // 알림 시각이 지나면 표시하고(에뮬레이터에는 FCM 이 없어 발송 수는 세지 않는다), 다시 보내지 않는다. + await notifyDueRounds(new Date(startAt.getTime() - 3 * 60_000)); + expect((await db().doc(`quests/${BASE}`).get()).get('timeAttack.preNotified')).toBe(true); + expect(await notifyDueRounds(new Date(startAt.getTime() - 60_000))).toBe(0); + // 시작 전에는 참여할 수 없다. + expect(await codeOf(() => call(fnQuestTimeAttack, { questId: BASE }))).toBe('E-QST-01'); }); }); -describe('타임어택 참여 — fn-quest-timeattack', () => { - it('마감 뒤 재시도여도 이미 받은 사용자는 첫 결과를 받는다', async () => { - await quest('ta-retry', -60_000, 60_000); - const first = await call(fnQuestTimeAttack, { questId: 'ta-retry' }); - await db().doc('quests/ta-retry').update({ activeTo: Timestamp.fromMillis(Date.now() - 1) }); - const again = await call(fnQuestTimeAttack, { questId: 'ta-retry' }); - expect(again.txId).toBe(first.txId); - expect(again.duplicate).toBe(true); - expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1); - }); - - it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위로 남고 재호출은 첫 결과를 돌려준다', async () => { - await quest('ta-open', -60_000, 60_000); +describe('회차 참여 — fn-quest-timeattack', () => { + it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위, 재호출은 첫 결과(마감 뒤도)', async () => { + await round('ta-open', -60_000, 60_000); const reply = await call(fnQuestTimeAttack, { questId: 'ta-open' }); expect(reply.rewarded).toBe(30); expect(reply.balanceAfter).toBe(30); + await db().doc('quests/ta-open').update({ 'timeAttack.activeTo': Timestamp.fromMillis(Date.now() - 1) }); const again = await call(fnQuestTimeAttack, { questId: 'ta-open' }); expect(again).toMatchObject({ txId: reply.txId, balanceAfter: 30, rewarded: 30, duplicate: true }); - // 인정 활동 1건 = EXP 100(C-18) · 검증 활동 1회 · FPI COMMUNITY 1 단위(Q-TA-05 권고 A안). expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100); expect(await verifiedQuestCountOf(UID)).toBe(1); const fpi = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get(); expect(fpi.docs.map((d) => [d.get('axis'), d.get('units'), d.get('activity')])) .toEqual([['community', 1, 'timeattack']]); - const ledger = await db().collection(`users/${UID}/popTransactions`).get(); expect(ledger.size).toBe(1); expect(ledger.docs[0]!.get('reasonCode')).toBe('QUEST_TIMEATTACK'); expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBe(1); - expect((await db().doc(`users/${UID}/questProgress/ta-open`).get()).get('count')).toBe(1); + }); + + it('같은 원 행동의 중복 FPI 금지 — 회차 참여가 원 Quest 그날 참여를 겸한다', async () => { + await round('ta-dup', -60_000, 60_000); + await joinTimeAttack(UID, 'ta-dup'); + // 같은 날 일반 참여는 이미 한도. + expect(await codeOf(() => participateStreaming(UID, 'ta-dup'))).toBe('E-POP-03'); + expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1); + }); + + it('그날 원 Quest 에 이미 참여했으면 회차 보상만 받고 FPI 는 더하지 않는다', async () => { + await round('ta-after', -60_000, 60_000); + await participateStreaming(UID, 'ta-after'); + const reply = await joinTimeAttack(UID, 'ta-after'); + expect(reply.rewarded).toBe(30); + expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1); }); it('timeAttackFpiAxis 가 null 이면 FPI 없이 EXP 만(Q-TA-05 B안)', async () => { - await db().doc('config/policy').set({ timeAttackFpiAxis: null }, { merge: true }); - try { - await quest('ta-no-fpi', -60_000, 60_000); - await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' }); - } finally { - await db().doc('config/policy').update({ timeAttackFpiAxis: FieldValue.delete() }); - } + await db().doc('config/policy').set({ timeAttackFpiAxis: null }); + await round('ta-no-fpi', -60_000, 60_000); + await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' }); expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100); expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(0); }); - it('시작 전 · 마감 후(끝 시각 포함)는 거부한다', async () => { - await quest('ta-early', 60_000, 120_000); - await quest('ta-late', -120_000, -1); + it('시작 전 · 마감 후(끝 시각 포함) · 회차 없음은 거부한다', async () => { + await round('ta-early', 60_000, 120_000); + await round('ta-late', -120_000, -1); expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-early' }))).toBe('E-QST-01'); expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-late' }))).toBe('E-QST-01'); + expect(await codeOf(() => call(fnQuestTimeAttack, { questId: BASE }))).toBe('E-QST-01'); expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0); }); - it('다른 팬덤 · 다른 유형 퀘스트로는 받지 못한다', async () => { - await quest('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' }); - await db().doc('quests/ta-attend').set({ type: 'attend', active: true }); + it('다른 팬덤 Quest 로는 받지 못한다', async () => { + await round('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' }); expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-other' }))).toBe('E-PERM-01'); - expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-attend' }))).toBe('E-QST-01'); }); }); diff --git a/app/lib/core/monetization/rewarded_ad.dart b/app/lib/core/monetization/rewarded_ad.dart index 69768d9..b1ffad7 100644 --- a/app/lib/core/monetization/rewarded_ad.dart +++ b/app/lib/core/monetization/rewarded_ad.dart @@ -35,7 +35,13 @@ abstract interface class RewardedAdHandle { /// /// 불러오지 못하면(채울 광고 없음 · 네트워크 · 시간 초과) `E-AD-01` 로 끊는다 — /// 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」. +/// +/// [userId] · [customData] 는 **서버측 시청 검증(AdMob SSV)** 에 실린다 — Google 이 시청 완료를 +/// 서버(`fn-ad-ssv`)로 직접 알릴 때 이 두 값이 「누가 · 어느 기본 보상(txId)에 붙여」 본 광고인지 +/// 가리킨다. 서버는 그 기록이 있어야만 증분을 지급한다(앱의 보상 콜백은 근거가 아니다). Future loadGoogleRewardedAd({ + required String userId, + required String customData, Duration timeout = const Duration(seconds: 15), }) async { await (_initialized ??= MobileAds.instance.initialize()); @@ -49,7 +55,11 @@ Future loadGoogleRewardedAd({ ), ); try { - return _GoogleRewardedAd(await loaded.future.timeout(timeout)); + final ad = await loaded.future.timeout(timeout); + await ad.setServerSideOptions( + ServerSideVerificationOptions(userId: userId, customData: customData), + ); + return _GoogleRewardedAd(ad); } catch (_) { // 시간 초과 뒤에 늦게 도착한 광고는 띄우지 않고 버린다. unawaited(loaded.future.then((ad) => ad.dispose(), onError: (_) {})); @@ -75,7 +85,7 @@ class _GoogleRewardedAd implements RewardedAdHandle { try { await _ad.show( onUserEarnedReward: (_, _) { - // 서버가 이 값으로 같은 노출의 이중 지급을 막는다(`adRewardLogs/{id}`). + // 화면 분기용(끝까지 봤는가)일 뿐 지급 근거가 아니다 — 근거는 SSV 콜백이다. impression = _ad.responseInfo?.responseId ?? 'gma-${DateTime.now().microsecondsSinceEpoch}'; }, diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index 570dc8d..f6b63a5 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -8,7 +8,7 @@ import 'package:flutter/painting.dart' show Color; import 'home_data.dart'; import '../inside/inside_repository_firestore.dart' show insideQuestionFrom; import '../quest/quest_data.dart' show QuestType; -import '../quest/quest_repository_firestore.dart' show questToday; +import '../quest/quest_repository_firestore.dart' show openTimeAttack, questToday; import '../../core/i18n/app_locale.dart'; /// 홈의 실 서버 구현 — 화면 대조 원장 F1. @@ -124,10 +124,9 @@ class FirestoreHomeRepository implements HomeRepository { ? null : db .collection('quests') - .where('type', isEqualTo: 'timeattack') - .where('fandomId', isEqualTo: fandomId) + .where('timeAttack.fandomId', isEqualTo: fandomId) .where( - 'activeTo', + 'timeAttack.activeTo', isGreaterThan: Timestamp.fromDate( DateTime.now().subtract(const Duration(hours: 1)), ), @@ -194,16 +193,17 @@ class FirestoreHomeRepository implements HomeRepository { if (doc.data()['active'] != false) doc, ]; if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend); - final serverNow = ServerClock.now(); + final open = [ + for (final d in timeAttacks) + if (openTimeAttack(d.data()) != null) d, + ]; + final openIds = {for (final d in open) d.id}; final quests = [ - ...timeAttacks.where((d) { - final to = d.data()['activeTo']; - return to is Timestamp && to.toDate().isAfter(serverNow); - }), - // 타임어택은 위에서 창이 열린 것만 받았다. 여기서 다시 받으면 마감된 것이 남는다. + ...open, + // 회차가 열린 Quest 는 위에서 맨 앞에 넣었다. 두 번 넣지 않는다. ...adminQuestsLatest([ for (final d in (await questsF).docs) - if (d.data()['type'] != 'timeattack') d, + if (!openIds.contains(d.id)) d, ], (d) => d.data()), ].take(3).toList(); final progress = (await progressF).docs; @@ -374,7 +374,8 @@ class FirestoreHomeRepository implements HomeRepository { title: quest.strOr('title'), type: QuestType.parse(quest.strOr('type')), category: QuestType.parse(quest.strOr('type')).label, - rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy), + rewardPop: openTimeAttack(quest)?.rewardPop ?? + QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy), state: done ? QuestState.done : started @@ -384,9 +385,7 @@ class FirestoreHomeRepository implements HomeRepository { ? null : quest.strOr('progressLabel'), id: id, - endsAt: quest['type'] == 'timeattack' && quest['activeTo'] is Timestamp - ? (quest['activeTo'] as Timestamp).toDate() - : null, + endsAt: openTimeAttack(quest)?.endsAt, ); } diff --git a/app/lib/features/my/my_data.dart b/app/lib/features/my/my_data.dart index 6b18082..f505e3e 100644 --- a/app/lib/features/my/my_data.dart +++ b/app/lib/features/my/my_data.dart @@ -101,6 +101,10 @@ enum PopTxType { title: appL10n.popReasonQuestPoolReturn, type: appL10n.popTxRefund, ), + 'QUEST_FEE_REFUND' => ( + title: appL10n.popReasonQuestFeeRefund, + type: appL10n.popTxRefund, + ), 'QUEST_TIMEATTACK' => ( title: appL10n.popReasonQuestTimeAttack, type: appL10n.popTypeQuest, diff --git a/app/lib/features/quest/place_picker_screen.dart b/app/lib/features/quest/place_picker_screen.dart index 5b9bae6..cf169ae 100644 --- a/app/lib/features/quest/place_picker_screen.dart +++ b/app/lib/features/quest/place_picker_screen.dart @@ -1,139 +1,232 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:latlong2/latlong.dart'; import '../../core/theme/tokens.dart'; import '../../core/i18n/app_locale.dart'; +import 'quest_data.dart'; -/// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`. +/// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`(지도 + 핀 주소 + 「선택 완료」). /// -/// 명세 `567:19531` — *분류 특정 위치를 선택했을 경우 목표 수치(특정 위치) 필드 활성화*. +/// 지도를 누르면 그 자리에 핀을 꽂고 주소를 보여 준다. 운영이 등록한 장소는 지도 위 표시와 +/// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안: +/// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다. /// -/// **지도를 붙이지 않았다.** 지오펜스 반경(D-08)과 대상 장소 등록이 모두 Admin -/// 운영값이고 아직 확정되지 않았다. 지금 지도 SDK 를 넣으면 최소 지원 OS 가 -/// 다시 움직인다(플러그인 선검증 §3.5). **장소 선택 동선은 여기서 완결**되고, -/// 지도 표시는 대상 장소가 확정된 뒤 이 화면 안에서 바뀐다. +/// 돌려주는 값: 등록 장소면 그 [QuestPlace], 핀이면 `id` 가 빈 [QuestPlace](좌표 · 주소). class PlacePickerScreen extends StatefulWidget { const PlacePickerScreen({super.key, this.places = defaultPlaces}); - final List places; + final List places; - /// 어드민에 등록될 장소의 표본. **정책값이 아니다.** + /// 어드민에 등록될 장소의 표본(좌표 없음). **정책값이 아니다.** static const defaultPlaces = [ - '홍대 팝업스토어', - 'HYBE 사옥', - 'SM 코엑스 아티움', - '올림픽공원 체조경기장', - '고척 스카이돔', + QuestPlace(id: 'hongdae', name: '홍대 팝업스토어'), + QuestPlace(id: 'hybe', name: 'HYBE 사옥'), + QuestPlace(id: 'coex', name: 'SM 코엑스 아티움'), + QuestPlace(id: 'kspo', name: '올림픽공원 체조경기장'), + QuestPlace(id: 'gocheok', name: '고척 스카이돔'), ]; + /// 등록 장소 좌표가 하나도 없을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다. + static const fallbackCenter = LatLng(37.5665, 126.9780); + @override State createState() => _PlacePickerScreenState(); } class _PlacePickerScreenState extends State { + final _map = MapController(); String _query = ''; - String? _selected; + QuestPlace? _selected; - List get _visible => widget.places - .where((p) => _query.isEmpty || p.contains(_query)) + List get _visible => widget.places + .where((p) => _query.isEmpty || p.name.contains(_query)) .toList(); + LatLng get _center { + for (final p in widget.places) { + if (p.lat != null && p.lng != null) return LatLng(p.lat!, p.lng!); + } + return PlacePickerScreen.fallbackCenter; + } + + void _choose(QuestPlace place) { + setState(() => _selected = place); + if (place.lat != null && place.lng != null) { + _map.move(LatLng(place.lat!, place.lng!), _map.camera.zoom); + } + } + + /// 지도를 누른 자리에 핀 — 주소는 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다. + Future _drop(LatLng point) async { + final coords = + '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}'; + setState(() => _selected = QuestPlace( + id: '', name: coords, lat: point.latitude, lng: point.longitude)); + var address = coords; + try { + final marks = await Geocoding().placemarkFromCoordinates(point.latitude, point.longitude); + if (marks.isNotEmpty) { + final m = marks.first; + final parts = [m.administrativeArea, m.locality, m.subLocality, m.thoroughfare, m.subThoroughfare] + .whereType() + .where((s) => s.isNotEmpty) + .toList(); + if (parts.isNotEmpty) address = parts.join(' '); + } + } catch (_) { + // 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다. + } + if (!mounted || _selected?.lat != point.latitude) return; + setState(() => _selected = QuestPlace( + id: '', name: address, lat: point.latitude, lng: point.longitude)); + } + @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(leading: const CloseButton()), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 시안 `371:356705` 머리 — 안내 문구 + 고른 장소. 지도 자리는 아래 검색 목록이다. - Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.placePickPrompt, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - height: 1.4, - letterSpacing: -0.45, - color: AppColors.onCosmic, + Widget build(BuildContext context) { + final pinned = _selected; + return Scaffold( + appBar: AppBar(leading: const CloseButton()), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 시안 `371:356705` 머리 — 안내 문구 + 고른 장소 주소. + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.placePickPrompt, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.4, + letterSpacing: -0.45, + color: AppColors.onCosmic, + ), ), - ), - const SizedBox(height: 10), - Text( - _selected ?? '', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.4, - color: Color(0xFFCDCDCD), + const SizedBox(height: 10), + Text( + pinned?.name ?? '', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.4, + color: Color(0xFFCDCDCD), + ), ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), - child: TextField( - decoration: InputDecoration( - labelText: context.l10n.placeSearch, - prefixIcon: Icon(Icons.search), + ], ), - onChanged: (v) => setState(() => _query = v.trim()), ), - ), - Expanded( - child: _visible.isEmpty - ? Center( + SizedBox( + height: 300, + child: FlutterMap( + mapController: _map, + options: MapOptions( + initialCenter: _center, + initialZoom: 15, + onTap: (_, point) => _drop(point), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'kr.co.fanuzu.app', + ), + MarkerLayer( + markers: [ + for (final p in widget.places) + if (p.lat != null && p.lng != null) + Marker( + point: LatLng(p.lat!, p.lng!), + child: GestureDetector( + onTap: () => _choose(p), + child: const Icon(Icons.place, color: AppColors.cosmicAccent), + ), + ), + if (pinned != null && pinned.lat != null && pinned.lng != null) + Marker( + point: LatLng(pinned.lat!, pinned.lng!), + alignment: Alignment.topCenter, + child: const Icon(Icons.location_pin, size: 40, color: Colors.redAccent), + ), + ], + ), + // OSM 타일 이용 조건 — 출처 표기. + const Align( + alignment: Alignment.bottomRight, child: Text( - context.l10n.commonNoResults, - style: Theme.of(context).textTheme.bodyMedium, - ), - ) - // 디폴트 미선택 · 단수 선택. - : RadioGroup( - groupValue: _selected, - onChanged: (v) => setState(() => _selected = v), - child: ListView.builder( - itemCount: _visible.length, - itemBuilder: (context, i) { - final place = _visible[i]; - return RadioListTile( - value: place, - title: Text(place), - secondary: const Icon(Icons.place_outlined), - ); - }, + '© OpenStreetMap', + style: TextStyle(fontSize: 10, color: Colors.black54), ), ), - ), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: FilledButton( - onPressed: _selected == null - ? null - : () => Navigator.of(context).pop(_selected), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - backgroundColor: AppColors.cosmicAccent, - foregroundColor: Colors.black, - textStyle: const TextStyle( - fontFamily: AppFonts.body, - fontSize: 16, - fontWeight: FontWeight.w600, - height: 24 / 16, - ), - ), - child: Text(context.l10n.commonSelectDone), + ], ), ), - ), - ], - ), - ); + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + child: TextField( + decoration: InputDecoration( + labelText: context.l10n.placeSearch, + prefixIcon: Icon(Icons.search), + ), + onChanged: (v) => setState(() => _query = v.trim()), + ), + ), + Expanded( + child: _visible.isEmpty + ? Center( + child: Text( + context.l10n.commonNoResults, + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + // 디폴트 미선택 · 단수 선택. + : RadioGroup( + groupValue: pinned?.id, + onChanged: (v) => _choose(widget.places.firstWhere((p) => p.id == v)), + child: ListView.builder( + itemCount: _visible.length, + itemBuilder: (context, i) { + final place = _visible[i]; + return RadioListTile( + value: place.id, + title: Text(place.name), + secondary: const Icon(Icons.place_outlined), + ); + }, + ), + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: FilledButton( + onPressed: pinned == null + ? null + : () => Navigator.of(context).pop(pinned), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + backgroundColor: AppColors.cosmicAccent, + foregroundColor: Colors.black, + textStyle: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 24 / 16, + ), + ), + child: Text(context.l10n.commonSelectDone), + ), + ), + ), + ], + ), + ); + } } diff --git a/app/lib/features/quest/quest_create_screen.dart b/app/lib/features/quest/quest_create_screen.dart index 8c59888..cc27eeb 100644 --- a/app/lib/features/quest/quest_create_screen.dart +++ b/app/lib/features/quest/quest_create_screen.dart @@ -35,6 +35,8 @@ class _QuestCreateScreenState extends State { final _manualTitle = TextEditingController(); final _reward = TextEditingController(); final _participants = TextEditingController(); + // 만보기 목표 걸음(Figma `259:45798` 「목표 0 / 10,000」). + final _goal = TextEditingController(); /// 이 화면 한 번의 생성 요청 키 — 응답 유실 뒤 다시 눌러도 서버가 한 건으로 묶는다. final _clientSeq = DateTime.now().microsecondsSinceEpoch.toString(); @@ -52,7 +54,7 @@ class _QuestCreateScreenState extends State { @override void initState() { super.initState(); - for (final c in [_title, _description, _manualTitle, _reward, _participants]) { + for (final c in [_title, _description, _manualTitle, _reward, _participants, _goal]) { // 입력이 바뀔 때마다 미리보기와 완료 버튼 활성화가 함께 움직여야 한다. c.addListener(() => setState(() {})); } @@ -65,7 +67,7 @@ class _QuestCreateScreenState extends State { @override void dispose() { - for (final c in [_title, _description, _url, _manualTitle, _reward, _participants]) { + for (final c in [_title, _description, _url, _manualTitle, _reward, _participants, _goal]) { c.dispose(); } super.dispose(); @@ -88,6 +90,7 @@ class _QuestCreateScreenState extends State { bool get _ready { if (_activity == null || _title.text.trim().isEmpty) return false; if (_needs('place') && _place == null) return false; + if (_needs('goal') && _intOf(_goal) <= 0) return false; if (_needs('link')) { final link = _link; if (link == null || !(link.ok || (link.failed && _manualTitle.text.trim().isNotEmpty))) { @@ -105,7 +108,7 @@ class _QuestCreateScreenState extends State { categoryName: _activity?.name ?? _category?.name ?? context.l10n.questNoCategory, author: QuestAuthor.user, stage: QuestStage.mine, - goal: 1, + goal: _needs('goal') ? _intOf(_goal) : 1, progress: 0, rewardPop: _intOf(_reward), // 시안 — *참여 인원 : 0 / 0*. 분모가 모집 인원이다. @@ -284,6 +287,15 @@ class _QuestCreateScreenState extends State { const SizedBox(height: AppSpace.s3), ], + if (_needs('goal')) ...[ + TextField( + controller: _goal, + keyboardType: TextInputType.number, + decoration: _field(context.l10n.questGoalSteps), + ), + const SizedBox(height: AppSpace.s3), + ], + if (_needs('place')) ...[ ListTile( contentPadding: EdgeInsets.zero, @@ -377,13 +389,11 @@ class _QuestCreateScreenState extends State { if (!mounted) return; _places = places; } - final name = await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (_) => PlacePickerScreen(places: [for (final p in _places) p.name]), - ), + final picked = await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute(builder: (_) => PlacePickerScreen(places: _places)), ); - if (name == null || !mounted) return; - setState(() => _place = _places.firstWhere((p) => p.name == name)); + if (picked == null || !mounted) return; + setState(() => _place = picked); } /// 공개일을 1일째로 최대 [QuestCatalog.maxDays] 일째까지. 남은 1시간 미만 판정은 서버가 한다. @@ -433,7 +443,9 @@ class _QuestCreateScreenState extends State { endDate: _endDate, url: _needs('link') ? _link?.url : null, manualTitle: _needs('link') && (_link?.failed ?? false) ? _manualTitle.text.trim() : null, - placeId: _needs('place') ? _place?.id : null, + placeId: _needs('place') && !(_place?.isPin ?? true) ? _place?.id : null, + pin: _needs('place') && (_place?.isPin ?? false) ? _place : null, + goal: _needs('goal') ? _intOf(_goal) : null, ), ); } on BackendException catch (error) { diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index 2c08183..62d4dd1 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -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 cancel(String questId); + /// 상태 전이. 서버가 확정한 상태를 돌려준다. Future advance(String questId, QuestStage next); @@ -565,12 +602,14 @@ abstract interface class QuestRepository { /// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다. /// /// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force · - /// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」). - Future loadRewardedAd(); + /// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」). [base] 는 광고를 붙일 기본 보상이다 — + /// 서버측 시청 검증(AdMob SSV)에 그 txId 를 싣는다. + Future loadRewardedAd(QuestReward base); /// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은 /// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02). - /// 일일 한도를 넘으면 E-AD-02, 같은 노출의 재전송은 첫 지급 결과다. + /// 지급 근거는 Google 이 서버로 보낸 SSV 기록이다(없으면 E-AD-03 — 증분 미지급 · 횟수 미차감). + /// 일일 한도를 넘으면 E-AD-02, 같은 기본 보상의 재전송은 첫 지급 결과다. Future claimAdReward(String impressionId, QuestReward base); } @@ -730,10 +769,7 @@ class SampleQuestRepository implements QuestRepository { Future catalog() async => _catalog; @override - Future> places() async => [ - for (final name in PlacePickerScreen.defaultPlaces) - QuestPlace(id: name, name: name), - ]; + Future> places() async => PlacePickerScreen.defaultPlaces; /// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다. @override @@ -766,6 +802,9 @@ class SampleQuestRepository implements QuestRepository { /// 이것이 없으면 보상을 받은 뒤 화면을 다시 읽을 때 또 「보상받기」로 보인다. static final _claimed = {}; + @override + Future cancel(String questId) async {} + @override Future advance(String questId, QuestStage next) async => next; @@ -802,7 +841,7 @@ class SampleQuestRepository implements QuestRepository { /// 표본은 광고 SDK 를 부르지 않는다 — 잠깐 기다렸다가 끝까지 본 것으로 둔다. @override - Future loadRewardedAd() async { + Future loadRewardedAd(QuestReward base) async { await Future.delayed(const Duration(seconds: 2)); return const _SampleAd(); } diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index a8e7208..29dc30e 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -120,8 +120,11 @@ class _QuestDetailScreenState extends State { size: 24, color: AppColors.onCosmic, ), - onSelected: (value) => - value == 'edit' ? _edit(detail) : _report(card.title), + onSelected: (value) => switch (value) { + 'edit' => _edit(detail), + 'cancel' => _cancel(detail), + _ => _report(card.title), + }, itemBuilder: (_) => [ // 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07). if (stage == QuestStage.mine && ugc) @@ -129,6 +132,15 @@ class _QuestDetailScreenState extends State { value: 'edit', child: Text(context.l10n.questEdit), ), + // 생성자 취소(B-30) — 검수 중 · 공개 중에만. + if (stage == QuestStage.mine && + ugc && + (detail.publishState == 'review' || + detail.publishState == 'published')) + PopupMenuItem( + value: 'cancel', + child: Text(context.l10n.questCancel), + ), PopupMenuItem( value: 'report', child: Text(context.l10n.reportTitle), @@ -309,7 +321,38 @@ class _QuestDetailScreenState extends State { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Text(blocked, style: _strong), ), + // 공개 전 검수(1주차 4항 · Q8) — 생성자에게 검수 대기 · 반려 사유를 보인다. + if (stage == QuestStage.mine && detail.publishState == 'review') + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text(context.l10n.questUnderReview, style: _strong), + ), + if (stage == QuestStage.mine && detail.publishState == 'rejected') + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + context.l10n.questModerationRejected( + detail.moderationReason ?? '', + ), + style: _strong, + ), + ), if (submission != null) ..._submissionStatus(submission), + // 검토형 Quest 의 광고 추가 보상은 승인 · 지급된 날에 붙인다(10/3 통합회신 · Q-UQ-03 A). + if (submission != null && + submission.status == 'paid' && + submission.txId != null && + !submission.adClaimed && + submission.paidAt != null && + _sameKstDay(submission.paidAt!, ServerClock.now()) && + card.adBonusPop != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: OutlinedButton( + onPressed: _busy ? null : () => _approvedAdBonus(submission), + child: Text(context.l10n.questAdBonus('${card.adBonusPop}')), + ), + ), if (detail.reviews.isNotEmpty) ..._reviewQueue(detail), ], ), @@ -561,6 +604,43 @@ class _QuestDetailScreenState extends State { } /// 제목 · 설명만 고친다(C-07). 잠금 필드는 입력 자체가 없다. + static bool _sameKstDay(DateTime a, DateTime b) { + String day(DateTime t) => t + .toUtc() + .add(const Duration(hours: 9)) + .toIso8601String() + .substring(0, 10); + return day(a) == day(b); + } + + /// 승인 지급분에 광고 추가 보상 — 기본 보상 txId 를 SSV 에 실어 광고를 보고 서버에 청구한다. + Future _approvedAdBonus(QuestSubmission submission) async { + final base = QuestReward( + rewardedPop: 0, + balanceAfter: 0, + txId: submission.txId, + ); + final impression = await _watchAd(base); + if (impression == null || !mounted) return; + await _run(() async { + final pop = await widget.repository.claimAdReward(impression, base); + if (mounted) _toast(context.l10n.questAdBonus(formatPop(pop))); + }); + } + + Future _cancel(QuestDetail detail) async { + final ok = await showConfirmDialog( + context, + context.l10n.questCancel, + body: detail.publishState == 'review' + ? context.l10n.questCancelInReview + : context.l10n.questCancelPublished, + confirmLabel: context.l10n.questCancel, + ); + if (ok != true || !mounted) return; + await _run(() => widget.repository.cancel(detail.card.id)); + } + Future _edit(QuestDetail detail) async { final title = TextEditingController(text: detail.card.title); final description = TextEditingController(text: detail.description); @@ -774,7 +854,7 @@ class _QuestDetailScreenState extends State { // 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다. if (error is BackendException && error.code == 'E-QST-01' && - detail.card.type == QuestType.timeAttack) { + detail.card.endsAt != null) { await _load(); } return; @@ -786,7 +866,7 @@ class _QuestDetailScreenState extends State { _stage = QuestStage.rewarded; }); // 시청 · 스트리밍은 무보상 참여다(C-04) — 보상 · 광고 화면 없이 참여만 알리고 링크를 연다. - if (detail.card.type == QuestType.streaming) { + if (detail.card.type == QuestType.streaming && detail.card.endsAt == null) { _toast(context.l10n.questStreamJoined); final url = Uri.tryParse(detail.link?.url ?? ''); if (url != null && url.hasScheme) { @@ -811,7 +891,7 @@ class _QuestDetailScreenState extends State { int? adPop; if (bonus) { // 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다. - final impression = await _watchAd(); + final impression = await _watchAd(reward); if (!mounted) return; if (impression != null) { final claim = widget.repository.claimAdReward(impression, reward); @@ -848,10 +928,10 @@ class _QuestDetailScreenState extends State { /// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면 /// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 · /// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다. - Future _watchAd() async { + Future _watchAd(QuestReward reward) async { RewardedAdHandle? ad; BackendException? failure; - final loading = widget.repository.loadRewardedAd().then( + final loading = widget.repository.loadRewardedAd(reward).then( (handle) => ad = handle, onError: (Object error) => failure = error is BackendException ? error diff --git a/app/lib/features/quest/quest_list_screen.dart b/app/lib/features/quest/quest_list_screen.dart index 11acb43..3df65f2 100644 --- a/app/lib/features/quest/quest_list_screen.dart +++ b/app/lib/features/quest/quest_list_screen.dart @@ -355,7 +355,18 @@ class _QuestListScreenState extends State { builder: (_) => QuestCreateScreen(repository: widget.repository), ), ); - if (id != null && mounted) await _load(); + if (id != null && mounted) { + // 공개 요청은 곧 공개가 아니다 — 운영 검수 뒤 공개된다(1주차 4항 · Q8). + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(context.l10n.questSubmittedForReview), + behavior: SnackBarBehavior.floating, + ), + ); + await _load(); + } } } diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index b79f813..d83c997 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -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.from(evidence.last as Map) : const {}; 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 _submission(String questId, Map 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> 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 cancel(String questId) => + _backend.call('fnQuestCancel', {'questId': questId}); + @override Future 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 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 loadRewardedAd() => loadGoogleRewardedAd(); + Future 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 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.delayed(const Duration(seconds: 2)); + } + } } Future> _verifySteps(QuestCard card) async { @@ -655,6 +718,12 @@ List _stepTiers(Map policy) { DateTime? now, ]) { final p = progress ?? const {}; + // 열린 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 _stepTiers(Map 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 quest, +) { + final raw = quest['timeAttack']; + if (raw is! Map) return null; + final round = Map.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'), + ); +} diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 3ef912c..b24386f 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -622,6 +622,7 @@ "questStopBody": "Your progress will be reset to the beginning.", "questStop": "Stop", "adNotFinished": "You didn't finish the ad, so there's no bonus. You still get the basic reward.", + "adNotVerified": "We couldn't confirm the ad view, so there's no bonus. You still get the basic reward.", "openSettings": "Open settings", "permStepsDenied": "Step permission is off. Allow \"Physical activity\" in Settings.", "permStepsNeeded": "Physical activity permission is needed to count steps.", @@ -817,5 +818,13 @@ "shakeJudged": "Judged {n} / {total}", "shakePlayHint": "Play the video and shake your phone to the beat", "shakeVideoError": "Can't play this video", - "shakeShareResult": "Share result" + "shakeShareResult": "Share result", + "questCancel": "Cancel quest", + "questCancelInReview": "Cancelling while under review returns the deposited reward POP and the fee in full.", + "questCancelPublished": "Cancelling a published quest closes it now and returns the unused reward POP. The fee is not refunded.", + "questUnderReview": "Under review. It will be published once approved.", + "questModerationRejected": "Rejected in review: {reason}. Your deposit and fee were returned.", + "questSubmittedForReview": "Submitted. It will be published after review.", + "popReasonQuestFeeRefund": "Quest fee refund", + "questGoalSteps": "Step goal" } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 8b3b8fc..808d616 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -622,6 +622,7 @@ "questStopBody": "Tu progreso se reiniciará desde el principio.", "questStop": "Detener", "adNotFinished": "No terminaste el anuncio, así que no hay extra. Recibes la recompensa básica.", + "adNotVerified": "No pudimos confirmar el anuncio, así que no hay extra. Recibes la recompensa básica.", "openSettings": "Abrir ajustes", "permStepsDenied": "El permiso de pasos está desactivado. Permite \"Actividad física\" en Ajustes.", "permStepsNeeded": "Se necesita el permiso de actividad física para contar pasos.", @@ -817,5 +818,13 @@ "shakeJudged": "Evaluados {n} / {total}", "shakePlayHint": "Reproduce el video y agita el teléfono al ritmo", "shakeVideoError": "No se puede reproducir este video", - "shakeShareResult": "Compartir resultado" + "shakeShareResult": "Compartir resultado", + "questCancel": "Cancelar misión", + "questCancelInReview": "Si cancelas durante la revisión, te devolvemos todo el POP depositado y la comisión.", + "questCancelPublished": "Si cancelas una misión publicada, se cierra al momento y te devolvemos el POP no usado. La comisión no se reembolsa.", + "questUnderReview": "En revisión. Se publicará cuando se apruebe.", + "questModerationRejected": "Rechazada en revisión: {reason}. Te devolvimos el depósito y la comisión.", + "questSubmittedForReview": "Enviada. Se publicará tras la revisión.", + "popReasonQuestFeeRefund": "Reembolso de comisión de misión", + "questGoalSteps": "Meta de pasos" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 75124b5..a6549b0 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -622,6 +622,7 @@ "questStopBody": "これまでの進行状況がリセットされ、最初の状態に戻ります。", "questStop": "中断", "adNotFinished": "広告を最後まで見なかったため追加報酬はありません。基本報酬はそのまま受け取れます。", + "adNotVerified": "広告の視聴を確認できなかったため追加報酬はありません。基本報酬はそのまま受け取れます。", "openSettings": "設定を開く", "permStepsDenied": "歩数の権限がオフです。設定で「身体活動」を許可してください。", "permStepsNeeded": "歩数を数えるには身体活動の権限が必要です。", @@ -817,5 +818,13 @@ "shakeJudged": "判定 {n} / {total}", "shakePlayHint": "動画を再生してビートに合わせてスマホを振ってください", "shakeVideoError": "この動画を再生できません", - "shakeShareResult": "結果をシェア" + "shakeShareResult": "結果をシェア", + "questCancel": "クエストを取り消す", + "questCancelInReview": "審査中に取り消すと、預けた報酬POPと手数料はすべて返還されます。", + "questCancelPublished": "公開中のクエストを取り消すと参加はすぐ締め切られ、残りの報酬POPは返還されます。手数料は返還されません。", + "questUnderReview": "運営の審査中です。承認されると公開されます。", + "questModerationRejected": "審査で差し戻されました:{reason}。預けたPOPと手数料は返還済みです。", + "questSubmittedForReview": "公開を申請しました。審査後に公開されます。", + "popReasonQuestFeeRefund": "クエスト手数料の返還", + "questGoalSteps": "目標歩数" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index b44d6a5..28b8a4c 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1230,6 +1230,7 @@ "questStopBody": "지금까지의 진행이 초기화되고 처음 상태로 돌아갑니다.", "questStop": "중단", "adNotFinished": "광고를 끝까지 보지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.", + "adNotVerified": "광고 시청이 확인되지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.", "openSettings": "설정 열기", "permStepsDenied": "걸음 수 권한이 꺼져 있습니다. 설정에서 「신체 활동」을 허용해 주세요.", "permStepsNeeded": "걸음 수를 세려면 신체 활동 권한이 필요합니다.", @@ -1750,5 +1751,14 @@ }, "shakePlayHint": "영상을 재생하고 박자에 맞춰 폰을 흔들어 주세요", "shakeVideoError": "영상을 재생할 수 없어요", - "shakeShareResult": "결과 공유" + "shakeShareResult": "결과 공유", + "questCancel": "퀘스트 취소", + "questCancelInReview": "검수 중인 퀘스트를 취소하면 예치한 보상 POP 과 수수료를 모두 돌려드려요.", + "questCancelPublished": "공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.", + "questUnderReview": "운영 검수 중이에요. 승인되면 공개됩니다.", + "questModerationRejected": "운영 검수에서 반려되었어요: {reason}. 예치한 POP 과 수수료는 돌려드렸어요.", + "@questModerationRejected": {"placeholders": {"reason": {"type": "String"}}}, + "questSubmittedForReview": "공개 요청했어요. 운영 검수 후 공개됩니다.", + "popReasonQuestFeeRefund": "퀘스트 수수료 반환", + "questGoalSteps": "목표 걸음 수" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 84a5e69..a03ce1b 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -3836,6 +3836,12 @@ abstract class AppLocalizations { /// **'광고를 끝까지 보지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.'** String get adNotFinished; + /// No description provided for @adNotVerified. + /// + /// In ko, this message translates to: + /// **'광고 시청이 확인되지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.'** + String get adNotVerified; + /// No description provided for @openSettings. /// /// In ko, this message translates to: @@ -5023,6 +5029,54 @@ abstract class AppLocalizations { /// In ko, this message translates to: /// **'결과 공유'** String get shakeShareResult; + + /// No description provided for @questCancel. + /// + /// In ko, this message translates to: + /// **'퀘스트 취소'** + String get questCancel; + + /// No description provided for @questCancelInReview. + /// + /// In ko, this message translates to: + /// **'검수 중인 퀘스트를 취소하면 예치한 보상 POP 과 수수료를 모두 돌려드려요.'** + String get questCancelInReview; + + /// No description provided for @questCancelPublished. + /// + /// In ko, this message translates to: + /// **'공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.'** + String get questCancelPublished; + + /// No description provided for @questUnderReview. + /// + /// In ko, this message translates to: + /// **'운영 검수 중이에요. 승인되면 공개됩니다.'** + String get questUnderReview; + + /// No description provided for @questModerationRejected. + /// + /// In ko, this message translates to: + /// **'운영 검수에서 반려되었어요: {reason}. 예치한 POP 과 수수료는 돌려드렸어요.'** + String questModerationRejected(String reason); + + /// No description provided for @questSubmittedForReview. + /// + /// In ko, this message translates to: + /// **'공개 요청했어요. 운영 검수 후 공개됩니다.'** + String get questSubmittedForReview; + + /// No description provided for @popReasonQuestFeeRefund. + /// + /// In ko, this message translates to: + /// **'퀘스트 수수료 반환'** + String get popReasonQuestFeeRefund; + + /// No description provided for @questGoalSteps. + /// + /// In ko, this message translates to: + /// **'목표 걸음 수'** + String get questGoalSteps; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 6eed588..202580e 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -2155,6 +2155,10 @@ class AppLocalizationsEn extends AppLocalizations { String get adNotFinished => 'You didn\'t finish the ad, so there\'s no bonus. You still get the basic reward.'; + @override + String get adNotVerified => + 'We couldn\'t confirm the ad view, so there\'s no bonus. You still get the basic reward.'; + @override String get openSettings => 'Open settings'; @@ -2861,4 +2865,34 @@ class AppLocalizationsEn extends AppLocalizations { @override String get shakeShareResult => 'Share result'; + + @override + String get questCancel => 'Cancel quest'; + + @override + String get questCancelInReview => + 'Cancelling while under review returns the deposited reward POP and the fee in full.'; + + @override + String get questCancelPublished => + 'Cancelling a published quest closes it now and returns the unused reward POP. The fee is not refunded.'; + + @override + String get questUnderReview => + 'Under review. It will be published once approved.'; + + @override + String questModerationRejected(String reason) { + return 'Rejected in review: $reason. Your deposit and fee were returned.'; + } + + @override + String get questSubmittedForReview => + 'Submitted. It will be published after review.'; + + @override + String get popReasonQuestFeeRefund => 'Quest fee refund'; + + @override + String get questGoalSteps => 'Step goal'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 1037f85..db2f46f 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -2169,6 +2169,10 @@ class AppLocalizationsEs extends AppLocalizations { String get adNotFinished => 'No terminaste el anuncio, así que no hay extra. Recibes la recompensa básica.'; + @override + String get adNotVerified => + 'No pudimos confirmar el anuncio, así que no hay extra. Recibes la recompensa básica.'; + @override String get openSettings => 'Abrir ajustes'; @@ -2877,4 +2881,33 @@ class AppLocalizationsEs extends AppLocalizations { @override String get shakeShareResult => 'Compartir resultado'; + + @override + String get questCancel => 'Cancelar misión'; + + @override + String get questCancelInReview => + 'Si cancelas durante la revisión, te devolvemos todo el POP depositado y la comisión.'; + + @override + String get questCancelPublished => + 'Si cancelas una misión publicada, se cierra al momento y te devolvemos el POP no usado. La comisión no se reembolsa.'; + + @override + String get questUnderReview => 'En revisión. Se publicará cuando se apruebe.'; + + @override + String questModerationRejected(String reason) { + return 'Rechazada en revisión: $reason. Te devolvimos el depósito y la comisión.'; + } + + @override + String get questSubmittedForReview => + 'Enviada. Se publicará tras la revisión.'; + + @override + String get popReasonQuestFeeRefund => 'Reembolso de comisión de misión'; + + @override + String get questGoalSteps => 'Meta de pasos'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 78b614a..2b979bb 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -2090,6 +2090,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get adNotFinished => '広告を最後まで見なかったため追加報酬はありません。基本報酬はそのまま受け取れます。'; + @override + String get adNotVerified => '広告の視聴を確認できなかったため追加報酬はありません。基本報酬はそのまま受け取れます。'; + @override String get openSettings => '設定を開く'; @@ -2769,4 +2772,31 @@ class AppLocalizationsJa extends AppLocalizations { @override String get shakeShareResult => '結果をシェア'; + + @override + String get questCancel => 'クエストを取り消す'; + + @override + String get questCancelInReview => '審査中に取り消すと、預けた報酬POPと手数料はすべて返還されます。'; + + @override + String get questCancelPublished => + '公開中のクエストを取り消すと参加はすぐ締め切られ、残りの報酬POPは返還されます。手数料は返還されません。'; + + @override + String get questUnderReview => '運営の審査中です。承認されると公開されます。'; + + @override + String questModerationRejected(String reason) { + return '審査で差し戻されました:$reason。預けたPOPと手数料は返還済みです。'; + } + + @override + String get questSubmittedForReview => '公開を申請しました。審査後に公開されます。'; + + @override + String get popReasonQuestFeeRefund => 'クエスト手数料の返還'; + + @override + String get questGoalSteps => '目標歩数'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 1b8d37e..890d8d3 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -2091,6 +2091,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get adNotFinished => '광고를 끝까지 보지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.'; + @override + String get adNotVerified => '광고 시청이 확인되지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.'; + @override String get openSettings => '설정 열기'; @@ -2771,4 +2774,32 @@ class AppLocalizationsKo extends AppLocalizations { @override String get shakeShareResult => '결과 공유'; + + @override + String get questCancel => '퀘스트 취소'; + + @override + String get questCancelInReview => + '검수 중인 퀘스트를 취소하면 예치한 보상 POP 과 수수료를 모두 돌려드려요.'; + + @override + String get questCancelPublished => + '공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.'; + + @override + String get questUnderReview => '운영 검수 중이에요. 승인되면 공개됩니다.'; + + @override + String questModerationRejected(String reason) { + return '운영 검수에서 반려되었어요: $reason. 예치한 POP 과 수수료는 돌려드렸어요.'; + } + + @override + String get questSubmittedForReview => '공개 요청했어요. 운영 검수 후 공개됩니다.'; + + @override + String get popReasonQuestFeeRefund => '퀘스트 수수료 반환'; + + @override + String get questGoalSteps => '목표 걸음 수'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 1359ebe..b87b4e9 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -2088,6 +2088,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get adNotFinished => '未看完广告,无额外奖励,基础奖励照常发放。'; + @override + String get adNotVerified => '未能确认广告观看,无额外奖励,基础奖励照常发放。'; + @override String get openSettings => '打开设置'; @@ -2766,4 +2769,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get shakeShareResult => '分享结果'; + + @override + String get questCancel => '取消任务'; + + @override + String get questCancelInReview => '审核中取消将全额退还已存入的奖励POP和手续费。'; + + @override + String get questCancelPublished => '取消已公开的任务将立即截止,剩余奖励POP退还,手续费不退。'; + + @override + String get questUnderReview => '运营审核中,批准后公开。'; + + @override + String questModerationRejected(String reason) { + return '审核未通过:$reason。已退还存入的POP和手续费。'; + } + + @override + String get questSubmittedForReview => '已提交公开申请,审核后公开。'; + + @override + String get popReasonQuestFeeRefund => '任务手续费退还'; + + @override + String get questGoalSteps => '目标步数'; } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 83c34a6..fdbead2 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -622,6 +622,7 @@ "questStopBody": "当前进度将被重置并恢复初始状态。", "questStop": "中止", "adNotFinished": "未看完广告,无额外奖励,基础奖励照常发放。", + "adNotVerified": "未能确认广告观看,无额外奖励,基础奖励照常发放。", "openSettings": "打开设置", "permStepsDenied": "步数权限已关闭,请在设置中允许“身体活动”。", "permStepsNeeded": "计步需要身体活动权限。", @@ -817,5 +818,13 @@ "shakeJudged": "判定 {n} / {total}", "shakePlayHint": "播放视频并跟着节拍摇动手机", "shakeVideoError": "无法播放该视频", - "shakeShareResult": "分享结果" + "shakeShareResult": "分享结果", + "questCancel": "取消任务", + "questCancelInReview": "审核中取消将全额退还已存入的奖励POP和手续费。", + "questCancelPublished": "取消已公开的任务将立即截止,剩余奖励POP退还,手续费不退。", + "questUnderReview": "运营审核中,批准后公开。", + "questModerationRejected": "审核未通过:{reason}。已退还存入的POP和手续费。", + "questSubmittedForReview": "已提交公开申请,审核后公开。", + "popReasonQuestFeeRefund": "任务手续费退还", + "questGoalSteps": "目标步数" } diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 9d340a9..b8af19c 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -31,6 +31,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get adNotFinished => _remote['adNotFinished'] ?? _base.adNotFinished; + @override + String get adNotVerified => _remote['adNotVerified'] ?? _base.adNotVerified; + @override String get adPartyPack => _remote['adPartyPack'] ?? _base.adPartyPack; @@ -1579,6 +1582,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get popReasonQuestFee => _remote['popReasonQuestFee'] ?? _base.popReasonQuestFee; + @override + String get popReasonQuestFeeRefund => _remote['popReasonQuestFeeRefund'] ?? _base.popReasonQuestFeeRefund; + @override String get popReasonQuestMusicShake => _remote['popReasonQuestMusicShake'] ?? _base.popReasonQuestMusicShake; @@ -1741,6 +1747,15 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get questAuthorNote => _remote['questAuthorNote'] ?? _base.questAuthorNote; + @override + String get questCancel => _remote['questCancel'] ?? _base.questCancel; + + @override + String get questCancelInReview => _remote['questCancelInReview'] ?? _base.questCancelInReview; + + @override + String get questCancelPublished => _remote['questCancelPublished'] ?? _base.questCancelPublished; + @override String get questCatCommunity => _remote['questCatCommunity'] ?? _base.questCatCommunity; @@ -1816,6 +1831,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get questGoalBpm => _remote['questGoalBpm'] ?? _base.questGoalBpm; + @override + String get questGoalSteps => _remote['questGoalSteps'] ?? _base.questGoalSteps; + @override String get questGoalValue => _remote['questGoalValue'] ?? _base.questGoalValue; @@ -1843,6 +1861,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String questMine(String count) => _fill('questMine', {'count': count}) ?? _base.questMine(count); + @override + String questModerationRejected(String reason) => _fill('questModerationRejected', {'reason': reason}) ?? _base.questModerationRejected(reason); + @override String get questName => _remote['questName'] ?? _base.questName; @@ -1972,6 +1993,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get questSubmitted => _remote['questSubmitted'] ?? _base.questSubmitted; + @override + String get questSubmittedForReview => _remote['questSubmittedForReview'] ?? _base.questSubmittedForReview; + @override String get questTabEmpty => _remote['questTabEmpty'] ?? _base.questTabEmpty; @@ -2014,6 +2038,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get questTypeVote => _remote['questTypeVote'] ?? _base.questTypeVote; + @override + String get questUnderReview => _remote['questUnderReview'] ?? _base.questUnderReview; + @override String get rankEmpty => _remote['rankEmpty'] ?? _base.rankEmpty; diff --git a/app/pubspec.lock b/app/pubspec.lock index a3c85a6..b420932 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" dart_jsonwebtoken: dependency: "direct overridden" description: @@ -201,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.17.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dio: dependency: transitive description: @@ -456,6 +472,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "9bbe5472f66ae648d2ca2efbce97fcaa04e71261fabd3e062f26728222d7f767" + url: "https://pub.dev" + source: hosted + version: "8.3.2" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -487,6 +511,38 @@ packages: description: flutter source: sdk version: "0.0.0" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: "40f845e0a5505d4b47da991057f2ab25cd1c68bc4570255c7d4cfbb03a952063" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: "17e88a69bb0e1ae44597d70df2d2fbdb2f503672cf665421a16ab56ab97e4ead" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + geocoding_darwin: + dependency: transitive + description: + name: geocoding_darwin + sha256: "97552aa24f1453defc17e4d8e40b13ddf097e669c2edfa17ed86349cacdbc1ae" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "5164b8871705af7573698b3aa0746337faf818d72b488988741bcc7aeedd2f6e" + url: "https://pub.dev" + source: hosted + version: "5.0.0" geolocator: dependency: "direct main" description: @@ -804,6 +860,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc" + url: "https://pub.dev" + source: hosted + version: "0.10.1" leak_tracker: dependency: transitive description: @@ -876,6 +940,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" mime: dependency: transitive description: @@ -1084,6 +1156,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.6" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" pub_semver: dependency: transitive description: @@ -1228,6 +1308,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" sky_engine: dependency: transitive description: flutter @@ -1297,6 +1385,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" url_launcher: dependency: "direct main" description: @@ -1465,6 +1561,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 03cd74c..480a095 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -116,6 +116,13 @@ dependencies: # Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다. # 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0). kakao_flutter_sdk_user: ^2.0.1 + # 위치 Quest 생성 「위치 선택」 지도 · 핀(Figma `371:356705`). flutter_map 은 순수 Dart 라 네이티브 SDK · + # API 키가 없다. 타일은 OpenStreetMap 공개 타일 — 운영 트래픽 전 타일 공급원 확정이 필요하다(보고서). + # 핀 주소 표시는 단말 지오코더(geocoding — iOS CLGeocoder · Android Geocoder, 키 없음). + # 최소 OS 하한 영향 없음: geocoding_android minSdk 24(앱 29) · geocoding_darwin iOS 12.0(앱 15.0). + flutter_map: ^8.3.2 + latlong2: ^0.10.1 + geocoding: ^5.0.0 dev_dependencies: flutter_test: diff --git a/app/test/f4_screens_test.dart b/app/test/f4_screens_test.dart index 07fcc3b..550183f 100644 --- a/app/test/f4_screens_test.dart +++ b/app/test/f4_screens_test.dart @@ -539,7 +539,7 @@ class _AdRepo extends SampleQuestRepository { } @override - Future loadRewardedAd() async { + Future loadRewardedAd(QuestReward base) async { if (loadError case final error?) throw error; return _Ad(impression); } diff --git a/app/test/timeattack_briefing_test.dart b/app/test/timeattack_briefing_test.dart index f58ed66..46308c7 100644 --- a/app/test/timeattack_briefing_test.dart +++ b/app/test/timeattack_briefing_test.dart @@ -69,15 +69,21 @@ class _InviteBackend extends Backend { Future _seed({Duration left = const Duration(minutes: 5)}) async { final db = FakeFirebaseFirestore(); await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'}); + // 10/3 통합회신 — TIME ATTACK 은 원 Quest(여기서는 스트리밍)에 붙는 회차다. await db.doc('quests/ta').set({ - 'type': 'timeattack', 'title': '돌발', 'fandomId': 'bts', 'rewardPop': 30, + 'type': 'streaming', 'title': '돌발', 'fandomId': 'bts', 'dailyLimit': 1, 'active': true, - 'activeFrom': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))), - 'activeTo': Timestamp.fromDate(DateTime.now().add(left)), + 'timeAttack': _round('r1', left), }); return db; } +Map _round(String id, Duration left) => { + 'roundId': id, 'fandomId': 'bts', 'rewardPop': 30, + 'activeFrom': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))), + 'activeTo': Timestamp.fromDate(DateTime.now().add(left)), +}; + BriefingItem _brief(int rank, String headline) => BriefingItem( rank: rank, headline: headline, @@ -104,7 +110,7 @@ void main() { final backend = _Backend(db); final repo = FirestoreQuestRepository(backend); final card = (await repo.load()).single; - expect(card.type, QuestType.timeAttack); + expect(card.type, QuestType.streaming); expect(card.endsAt, isNotNull); expect(card.rewardPop, 30); final reward = await repo.verify(card); @@ -123,33 +129,38 @@ void main() { // 서버가 10분 앞서 있다 → 단말 기준 5분 남은 창은 이미 닫혔다. final db = await _seed(); final backend = _Backend(db, serverAhead: const Duration(minutes: 10)); - expect(await FirestoreQuestRepository(backend).load(), isEmpty); + // 회차가 닫히면 원 Quest 는 일반 Quest 로 남고 회차 표시(마감)만 빠진다. + expect((await FirestoreQuestRepository(backend).load()).single.endsAt, isNull); expect(ServerClock.offset.inMinutes, inInclusiveRange(9, 10)); final home = await FirestoreHomeRepository(backend).load(); - expect(home.quests.where((q) => q.id == 'ta'), isEmpty); + expect(home.quests.singleWhere((q) => q.id == 'ta').endsAt, isNull); }); - test('어제 받은 타임어택도 보상 완료로 남는다(1인 1회)', () async { + test('받은 회차는 날짜와 무관하게 보상 완료로 남는다(회차당 1인 1회)', () async { final db = await _seed(); - await db.doc('users/$_uid/questProgress/ta').set({'dayKey': '2000-01-01', 'count': 1}); + await db.doc('users/$_uid/questProgress/ta').set({ + 'dayKey': '2000-01-01', 'count': 1, 'timeAttackRoundId': 'r1', + }); final card = (await FirestoreQuestRepository(_Backend(db)).load()).single; expect(card.stage, QuestStage.rewarded); }); - test('홈은 열린 타임어택을 맨 앞에 두고 마감된 것은 뺀다', () async { + test('홈은 회차가 열린 Quest 를 맨 앞에 둔다', () async { final db = await _seed(); await db.doc('quests/ta-old').set({ - 'type': 'timeattack', 'title': '지난 돌발', 'fandomId': 'bts', 'active': true, - 'activeTo': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))), + 'type': 'vote', 'title': '지난 돌발', 'fandomId': 'bts', 'active': true, + 'timeAttack': _round('r-old', const Duration(minutes: -1)), }); await db.doc('quests/ta-off').set({ - 'type': 'timeattack', 'title': '내려진 돌발', 'fandomId': 'bts', 'active': false, - 'activeTo': Timestamp.fromDate(DateTime.now().add(const Duration(minutes: 5))), + 'type': 'vote', 'title': '내려진 돌발', 'fandomId': 'bts', 'active': false, + 'timeAttack': _round('r-off', const Duration(minutes: 5)), }); await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'active': true}); final home = await FirestoreHomeRepository(_Backend(db)).load(); - expect(home.quests.map((q) => q.id), ['ta', 'attend']); + // 열린 회차가 맨 앞, 닫힌 회차의 원 Quest 는 일반 Quest 로, 내려진 Quest 는 빠진다. + expect(home.quests.map((q) => q.id), ['ta', 'ta-old', 'attend']); expect(home.quests.first.endsAt, isNotNull); + expect(home.quests[1].endsAt, isNull); }); test('성운 목표는 config/policy 운영값 · 탄생 권한은 창립자만(Q-PN-01)', () async {