광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ErrorCode, Status> = {
|
||||
'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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReasonCode,
|
||||
'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND'>;
|
||||
'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND' | 'QUEST_FEE_REFUND'>;
|
||||
|
||||
/**
|
||||
* 환불 — `fn-pop-refund` 의 코어.
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
let cachedKeys: { keys: VerifierKeys; at: number } | null = null;
|
||||
|
||||
/** 공개 키 목록. Google 이 키를 돌리므로 하루 단위로 다시 받고, 모르는 `key_id` 면 즉시 다시 받는다. */
|
||||
async function verifierKeys(keyId: string): Promise<VerifierKeys> {
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<number> {
|
||||
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). 앱이 단말 시계와의 차이를 재어 타임어택 카운트다운 ·
|
||||
|
||||
@@ -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<QuestCatalog> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버에 사용자 생성 참여 경로가 있는 활동. 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<LedgerResult> {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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<Record<string, unknown>> {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) => createQuest({
|
||||
/** 공개 요청까지 — 상태 `review`(검수 대기). */
|
||||
const request = (over: Record<string, unknown> = {}) => 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<string, unknown> = {}) =>
|
||||
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<string, unknown> }).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<string, unknown> = {}) =>
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -32,12 +36,16 @@ async function codeOf(run: () => Promise<unknown>): Promise<string> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user