캠페인 성공 알림 · 운영 퀘스트 참여 인원 · 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}`),
});
},
@@ -7,7 +7,7 @@ import { fundCampaign } from '../src/fandom/fandom.js';
import { createCampaign, retryFeeRefunds, withdrawCampaign } from '../src/campaign/create.js';
import {
MAX_AUTO_REFUND_ATTEMPTS, cancelCampaign, fnCampaignAdminReview, fnCampaignReportSubmit, refundCampaign,
retryPendingRefunds, settleDueCampaigns, submitCampaignReport,
notifySuccess, retryPendingRefunds, settleDueCampaigns, submitCampaignReport,
} from '../src/campaign/settle.js';
/**
@@ -313,6 +313,17 @@ describe('정산 분기', () => {
expect(short.get('status')).toBe('fail');
expect(short.get('refundState')).toBe('pending');
expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000);
// B-3 8-10 성공 알림 — 개설자 · 후원자 한 건씩(kind campaign · essential), 실패 캠페인은 성공 알림 없음.
for (const uid of [CREATOR, A, B]) expect(await notes(uid)).toEqual(['campaignSuccess_cmp-ok']);
const note = await db().doc(`users/${A}/notifications/campaignSuccess_cmp-ok`).get();
expect(note.data()).toMatchObject({ eventType: 'campaign', type: 'essential', campaignId: 'cmp-ok' });
expect(note.get('title')).toBe('후원하신 「캠페인 cmp-ok」 캠페인이 목표를 달성했습니다');
expect((await db().doc(`users/${CREATOR}/notifications/campaignSuccess_cmp-ok`).get()).get('title'))
.toBe('개설하신 「캠페인 cmp-ok」 캠페인이 목표를 달성했습니다');
// 재호출은 같은 문서 — 중복 알림 0건.
await notifySuccess('cmp-ok');
expect(await notes(A)).toEqual(['campaignSuccess_cmp-ok']);
});
it('「응원 중」(donorCount)은 고유 후원자 수다 — 같은 사람의 두 번째 후원은 늘리지 않는다', async () => {
@@ -467,7 +478,7 @@ describe('결과보고 — 운영 전용 (B-35)', () => {
const doc = await db().doc('campaigns/cmp-rep/reports/main').get();
expect(doc.get('receiptPaths')).toEqual(['campaignReports/cmp-rep/r1.png']);
expect(doc.get('budgetTotal')).toBe(500);
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main', 'campaignSuccess_cmp-rep']);
// 게시 뒤 알림 전에 죽은 경우.
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
@@ -602,7 +602,7 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp
expect(reply.rewarded).toBe(4);
});
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
it('Music Shake 는 같은 날 두 번 완주해도 1회(C-128) · 방문은 1회', async () => {
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
const shake = () => call(fnQuestVerifyMusicShake, {
questId: 'music-count', trackId: 't1', totalSegments: 10,
@@ -610,9 +610,11 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp
});
await shake();
await shake();
expect(await count()).toBe(2);
// 보상은 한도 2회만큼 두 번 나가지만 검증 활동은 하루 1회.
expect((await db().collection(`users/${UID}/popTransactions`).where('reasonCode', '==', 'QUEST_MUSIC_SHAKE').get()).size).toBe(2);
expect(await count()).toBe(1);
await call(fnQuestVerifyPlace, { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 });
expect(await count()).toBe(3);
expect(await count()).toBe(2);
});
it('무효 처리는 원장 행으로 찾아 한 번만 빠진다 · 운영자만', async () => {
@@ -0,0 +1,83 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { countParticipant, participateStreaming } from '../src/quest/quest.js';
import { TEST_PAYOUT_MARK, popIssuance } from '../src/admin/admin.js';
/**
* 운영 Quest 「참여 인원」(10/6 관찰 「참여 인원 0」) · C-123 테스트 지급분 집계 제외.
* 트리거 본체([countParticipant])를 진행 문서 값으로 직접 부른다 — 에뮬레이터에 함수 트리거가 없다.
*/
const A = 'test-part-a';
const B = 'test-part-b';
const db = () => getFirestore();
const countOf = async (id: string) => (await db().doc(`quests/${id}`).get()).get('participantCount') as number | undefined;
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
for (const uid of [A, B]) await wipeUser(uid);
for (const id of ['op-stream', 'op-shake', 'ugc-q']) await db().recursiveDelete(db().doc(`quests/${id}`));
await db().doc(`users/${A}`).set({ uid: A });
await db().doc(`users/${B}`).set({ uid: B });
});
describe('운영 Quest 참여 인원 — 사람 단위 · 멱등', () => {
it('완료 진행 문서로 1명 · 같은 사람 재전달 · 다음 날 재참여는 그대로 · 다른 사람은 +1', async () => {
await db().doc('quests/op-stream').set({ type: 'streaming', title: '스밍', dailyLimit: 1 });
await participateStreaming(A, 'op-stream');
const progress = (await db().doc(`users/${A}/questProgress/op-stream`).get()).data()!;
expect(await countParticipant(A, 'op-stream', progress)).toBe(true);
expect(await countParticipant(A, 'op-stream', progress)).toBe(false);
expect(await countParticipant(A, 'op-stream', { count: 1 })).toBe(false);
expect(await countOf('op-stream')).toBe(1);
expect(await countParticipant(B, 'op-stream', { count: 1 })).toBe(true);
expect(await countOf('op-stream')).toBe(2);
});
it('진행 세션 시작(Music Shake 「참여」)도 1명 · 빈 진행 문서는 세지 않는다', async () => {
await db().doc('quests/op-shake').set({ type: 'musicShake', title: '셰이크' });
expect(await countParticipant(A, 'op-shake', {})).toBe(false);
expect(await countParticipant(A, 'op-shake', { session: { state: 'inProgress' } })).toBe(true);
expect(await countParticipant(A, 'op-shake', { count: 1 })).toBe(false);
expect(await countOf('op-shake')).toBe(1);
});
it('사용자 생성 Quest · 퀘스트 문서가 없는 진행 문서(ad)는 건너뛴다', async () => {
await db().doc('quests/ugc-q').set({ type: 'place', authorUid: 'someone', participantCount: 3 });
expect(await countParticipant(A, 'ugc-q', { count: 1 })).toBe(false);
expect(await countOf('ugc-q')).toBe(3);
expect(await countParticipant(A, 'ad', { count: 1 })).toBe(false);
expect((await db().doc('quests/ad').get()).exists).toBe(false);
});
});
describe('C-123 테스트 지급분 — 원장 불변 · 발행량 대조에서 제외', () => {
const at = (h: number) => Timestamp.fromDate(new Date(Date.UTC(2020, 0, 1, h)));
const row = (uid: string, id: string, data: Record<string, unknown>) =>
db().doc(`users/${uid}/popTransactions/${id}`).set({ type: 'grant', ...data });
it('표시된 행은 bySource 에서 빠지고 testPayout 에 따로 · 원장 행은 그대로', async () => {
await row(A, 'tx-test', { popType: 'activity', reasonCode: 'AD_REWARD', amount: 10, createdAt: at(1) });
await row(A, 'tx-ok', { popType: 'support', reasonCode: 'AD_REWARD', amount: 10, createdAt: at(2) });
await row(B, 'tx-attend', { popType: 'activity', reasonCode: 'QUEST_ATTEND', amount: 10, createdAt: at(3) });
await db().doc('popTransactionMarks/tx-test').set({
mark: TEST_PAYOUT_MARK, reason: 'C-123', path: `users/${A}/popTransactions/tx-test`, markedAt: at(5),
});
try {
const report = await popIssuance(at(0).toDate(), at(4).toDate());
expect(report.bySource).toEqual({
'grant:support:AD_REWARD': { count: 1, amount: 10 },
'grant:activity:QUEST_ATTEND': { count: 1, amount: 10 },
});
expect(report.testPayout).toEqual({ 'grant:activity:AD_REWARD': { count: 1, amount: 10 } });
// 원장 행은 수정 · 삭제 0건.
expect((await db().doc(`users/${A}/popTransactions/tx-test`).get()).data())
.toMatchObject({ amount: 10, popType: 'activity', reasonCode: 'AD_REWARD' });
} finally {
await db().doc('popTransactionMarks/tx-test').delete();
}
});
});
@@ -159,7 +159,8 @@ describe('회차 참여 — fn-quest-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);
// 운영 Quest 의 참여 인원은 진행 문서 트리거([countParticipant])가 사람 단위로 센다 — 회차 함수는 올리지 않는다.
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBeUndefined();
});
it('같은 원 행동의 중복 FPI 금지 — 회차 참여가 원 Quest 그날 참여를 겸한다', async () => {