캠페인 성공 알림 · 운영 퀘스트 참여 인원 · Music Shake 하루 1회 · 함수 배포 전 빌드

This commit is contained in:
Sn-Kinos
2026-10-08 16:01:12 +09:00
parent 93150a0d5b
commit f7075f0e6f
14 changed files with 243 additions and 18 deletions
+54 -3
View File
@@ -1,5 +1,5 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
@@ -19,7 +19,7 @@ import { voidCompletions } from '../ledger/fpi.js';
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
*/
const FORBIDDEN = [
/popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/, /growthEvents/,
/popTransactions/, /popTransactionMarks/, /popLots/, /\/wallet\//, /fpiSnapshots/, /growthEvents/,
/\/fpiEvents\//, /\/eclipseRounds\//, /\/fandomDailyStats\//, /^\/?adminActionLogs\//,
];
@@ -248,3 +248,54 @@ export const fnAdminCompletionVoid = onCall(async (req) => {
export async function voidCompletion(uid: string, key: { txId?: string; submissionId?: string }): Promise<number> {
return getFirestore().runTransaction(async (tx) => (await voidCompletions(tx, uid, key))(tx));
}
/**
* C-123 「테스트 지급분」 표시 — `popTransactionMarks/{txId}` 의 `mark` 값(Firestore 설계서 v1.4 §3.4).
* 원장 행은 고치지 않고 표시 행을 덧붙인다. 표시는 운영 스크립트(`tools/demo/mark_test_payout.mjs`)가 쓴다.
*/
export const TEST_PAYOUT_MARK = 'test_payout';
interface Tally { count: number; amount: number }
export interface IssuanceReport {
/** `{type}:{popType}:{reasonCode}` → 건수 · 합계. 발행(grant) · 소모(spend) · 환불 · 소멸이 같은 표에 나뉜다. */
bySource: Record<string, Tally>;
/** 판정에서 뺀 테스트 지급분(C-123). 같은 키로 따로 보인다. */
testPayout: Record<string, Tally>;
}
/**
* 원천별 발행량 · 소모량 대조(C-71) — 기간 [from, to) 의 원장 행을 원천별로 더한다.
* **테스트 지급분 표시 행은 `bySource` 에서 빼고 `testPayout` 에 따로** 모은다(C-123 「검수 · 발행량 판정에서 제외」).
* 잔액 ↔ 원장 대조(`reconcileUser`)는 실제 잔액이라 제외하지 않는다.
* ponytail: 기간의 원장 행을 한 번에 읽는다. 행이 수십만이 되면 일 단위 집계 문서로.
*/
export async function popIssuance(from: Date, to: Date): Promise<IssuanceReport> {
const db = getFirestore();
const [rows, marks] = await Promise.all([
db.collectionGroup('popTransactions')
.where('createdAt', '>=', Timestamp.fromDate(from)).where('createdAt', '<', Timestamp.fromDate(to)).get(),
db.collection('popTransactionMarks').where('mark', '==', TEST_PAYOUT_MARK).get(),
]);
const marked = new Set(marks.docs.map((m) => m.get('path') as string));
const report: IssuanceReport = { bySource: {}, testPayout: {} };
for (const r of rows.docs) {
const key = `${r.get('type')}:${r.get('popType')}:${r.get('reasonCode')}`;
const bucket = marked.has(r.ref.path) ? report.testPayout : report.bySource;
const t = (bucket[key] ??= { count: 0, amount: 0 });
t.count += 1;
t.amount += (r.get('amount') as number | undefined) ?? 0;
}
return report;
}
/** `fn-admin-pop-issuance` — 조회 등급 이상. 입력 `from` · `to`(ISO). 최대 31일. */
export const fnAdminPopIssuance = onCall(async (req) => {
requireRole(req, ['master', 'ops', 'viewer']);
const from = new Date(str(req.data, 'from'));
const to = new Date(str(req.data, 'to'));
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from
|| to.getTime() - from.getTime() > 31 * 86_400_000) {
throw new HttpsError('invalid-argument', '조회 기간은 31일 이내의 올바른 ISO 시각이어야 합니다.');
}
return popIssuance(from, to);
});
@@ -113,10 +113,39 @@ export async function settleDueCampaigns(now: Timestamp = Timestamp.now()): Prom
if (success === null) continue;
settled += 1;
logger.info('[campaign] 정산', { id: doc.id, success });
if (success) await notifySuccess(doc.id);
}
return settled;
}
/**
* 성공 알림 — B-3 8-10 「캠페인 성공 Push」. 개설자 1건 · 후원자(고유 uid) 1건씩. 알림함에 쌓이면
* `fnPushInbox` 가 같은 문구로 Push 를 보낸다(`kind = campaign` → 앱 C-78 「결과」 단계).
* 문서 ID 가 캠페인마다 고정이라 다시 불러도 한 사람에게 한 건이다.
* ponytail: 판정 트랜잭션과 알림 사이에 함수가 죽으면 그 회차 알림은 빠진다(상태가 이미 success).
* 빠짐이 문제가 되면 `successNotifiedAt` 표식 + 재시도 패스로.
*/
export async function notifySuccess(campaignId: string): Promise<number> {
const ref = getFirestore().doc(`campaigns/${campaignId}`);
const [campaign, donations] = await Promise.all([ref.get(), ref.collection('donations').get()]);
const title = (campaign.get('title') as string | undefined) ?? '캠페인';
const creator = campaign.get('creatorUid') as string | undefined;
const donors = new Set(donations.docs
.map((d) => d.get('uid') as string | undefined)
.filter((u): u is string => !!u && u !== creator));
if (creator) {
await notifyOnce(creator, `campaignSuccess_${campaignId}`, {
title: `개설하신 「${title}」 캠페인이 목표를 달성했습니다`, campaignId,
});
}
for (const donor of donors) {
await notifyOnce(donor, `campaignSuccess_${campaignId}`, {
title: `후원하신 「${title}」 캠페인이 목표를 달성했습니다`, campaignId,
});
}
return donors.size + (creator ? 1 : 0);
}
/**
* 환불이 끝나지 않은 실패 캠페인을 돌린다 — 방금 실패로 판정된 건도 여기서 첫 환불을 받는다.
* 자동 시도 상한을 넘긴 건은 Admin 큐에 있으므로 건드리지 않는다. 실제로 돈 건수를 돌려준다.
+4 -2
View File
@@ -7,7 +7,7 @@ export {
fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile,
} from './ledger/pop.js';
export {
fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, fnQuestProgress,
fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, fnQuestProgress, fnQuestParticipants,
} from './quest/quest.js';
export {
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
@@ -19,7 +19,9 @@ export {
fnFpiAggregate, fnLiveAggregate, fnFpiDailySnapshot,
fnRankingCountryAggregate, fnCampaignFund,
} from './fandom/fandom.js';
export { fnAdminWrite, fnAdminPopGrantBatch, fnAdminGrowthReverse, fnAdminCompletionVoid } from './admin/admin.js';
export {
fnAdminWrite, fnAdminPopGrantBatch, fnAdminGrowthReverse, fnAdminCompletionVoid, fnAdminPopIssuance,
} from './admin/admin.js';
export {
fnIapVerify, fnIapRefundWebhook, fnSubscriptionWebhook, fnFocusDailyClaim,
} from './payment/iap.js';
+2 -2
View File
@@ -121,8 +121,8 @@ export function writeUserExp(tx: Transaction, uid: string, user: DocumentSnapsho
* 「검증 활동」 1회 — 해석 확인 요청 Campaign 개설자격 **A안** · 집계 규칙(2026-09-28 결정).
*
* 서버가 인정한 Quest 완료를 **완료 단위 표식** `users/{uid}/verifiedCompletions/{id}` 하나로 남긴다.
* `id` 가 곧 「하루 1회」 경계다 — 출석 · 걸음(구간 무관) · 방문 · UGC · 타임어택은
* `{questId}_{dayKey}`, Music Shake 는 완주마다 `{questId}_{dayKey}_{n}`. 같은 날 같은 표식을
* `id` 가 곧 「하루 1회」 경계다 — 모든 Quest(출석 · 걸음(구간 무관) · Music Shake · 방문 · UGC · 타임어택)가
* `{questId}_{dayKey}` 다(C-128 「동일 반복 Quest 는 같은 날 1회」). 같은 날 같은 표식을
* 다시 써도(구간 · 재시도) `merge` 라 하나로 모인다. 보상 유무 · 팬덤 유무와 무관하다.
*
* 무효 정정은 원장 행(`txIds`) 또는 제출 건(`submissionId`)으로 찾는다([voidCompletions]).
+35
View File
@@ -1,4 +1,5 @@
import { onCall } from 'firebase-functions/v2/https';
import { onDocumentWritten } from 'firebase-functions/v2/firestore';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { callerUid, num, optionalStr, str } from '../core/callable.js';
@@ -188,6 +189,40 @@ export const fnQuestProgress = onCall(async (req) => {
return { questId, state };
});
/**
* 운영 Quest 「참여 인원」(`quests/{id}.participantCount`) — 10/6 실기기 관찰 「참여 인원 0」.
*
* 운영 Quest(작성자 없음)의 참여 · 완료는 모두 진행 문서(`users/{uid}/questProgress/{questId}`)를 거친다 —
* 진행 세션 시작(`session`) · 출석 · 걸음 · Music Shake · 방문 · 스트리밍 · TIME ATTACK. 그래서 여기 한 곳에서
* **사람 단위로 한 번** 센다. 표식 `quests/{questId}/participants/{uid}` 가 있으면 다시 세지 않는다(재전달 · 재참여 멱등).
* 사용자 생성 Quest 는 지급 · 제출 경로(`ugc.ts`)가 정원과 함께 따로 센다 — 여기서는 건너뛴다.
* 진행 문서가 퀘스트가 아닌 것(`ad` 한도 카운터)도 퀘스트 문서가 없으니 건너뛴다.
*/
export async function countParticipant(
uid: string, questId: string, after: { session?: unknown; count?: unknown },
): Promise<boolean> {
if (!after.session && !(typeof after.count === 'number' && after.count > 0)) return false;
const db = getFirestore();
const questRef = db.doc(`quests/${questId}`);
const markRef = questRef.collection('participants').doc(uid);
return db.runTransaction(async (tx) => {
const [quest, mark] = await Promise.all([tx.get(questRef), tx.get(markRef)]);
if (!quest.exists || typeof quest.get('authorUid') === 'string' || mark.exists) return false;
tx.create(markRef, { uid, createdAt: FieldValue.serverTimestamp() });
tx.update(questRef, { participantCount: FieldValue.increment(1) });
return true;
});
}
export const fnQuestParticipants = onDocumentWritten(
{ document: 'users/{uid}/questProgress/{questId}', retry: true },
async (event) => {
const after = event.data?.after;
if (!after?.exists) return;
await countParticipant(event.params.uid, event.params.questId, after.data() ?? {});
},
);
/** `fn-quest-attend` — 출석. 한도는 `quests/{questId}.dailyLimit` 이 정한다. */
export const fnQuestAttend = onCall(async (req) => {
const uid = callerUid(req);
@@ -242,7 +242,8 @@ export async function joinTimeAttack(uid: string, questId: string, now = new Dat
...(counts ? { dayKey: key, count: done + 1 } : {}),
completedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
tx.update(quest.ref, { participantCount: FieldValue.increment(1) });
// 운영 Quest 의 참여 인원은 진행 문서 트리거가 사람 단위로 센다([countParticipant]) — 여기서는 사용자 생성 Quest 만.
if (typeof quest.get('authorUid') === 'string') tx.update(quest.ref, { participantCount: FieldValue.increment(1) });
},
});
+2 -2
View File
@@ -183,8 +183,8 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
uid, fandomId: fandomOfUser(user), axis: 'bpm', units: 1, activity: 'music_shake',
sourceId: `music:${questId}:${key}:${state.count + 1}`, occurredAt: at, refPath: logRef.path,
}, user);
// 검증 활동 — Music Shake 는 완주마다 1회.
markCompletion(tx, uid, `${questId}_${key}_${state.count + 1}`, {
// 검증 활동 — 같은 반복 Quest 는 같은 날 1회(C-128). 같은 날 두 번째 완주는 같은 표식에 모인다.
markCompletion(tx, uid, `${questId}_${key}`, {
questId, txId: txIdOf(`quest:${uid}:${questId}:${key}:${state.count + 1}`),
});
},