Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
@@ -0,0 +1,198 @@
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { callerUid, num, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { txIdOf } from '../core/ids.js';
import { refundSplit, spendSplit } from '../ledger/ledger.js';
import { verifiedQuestCountOf } from '../ledger/fpi.js';
import { publicNameOf } from '../core/names.js';
/**
* `fn-campaign-create` — 사용자 캠페인 개설(원장 B-56 자격 · B-57 등록비).
*
* 예전에는 앱이 `campaigns` 에 `draft` 로 직접 썼는데 개설자는 status 를 바꿀 수 없어
* 심사(`review`)로 넘어갈 길이 없었다. 개설 · 등록비 차감 · `review` 진입을
* 한 트랜잭션에서 하고, 승인은 그대로 `fn-campaign-admin-review` 가 한다.
*
* 등록비는 겸용이다(B-57 · B-06) — 일반 POP 을 먼저 쓰고 부족분만 기여 POP 에서 뺀다
* (`spendSplit`, 원장 최대 2건). 그 거래 ID 들을 `feeTxIds` 에 남겨 반환(B-58)이 행마다
* 원래 타입으로 돌린다. 등록비는 성장 · FPI 원천이 아니다(C-30).
*
* 🟡 운영값(등록비 · 가입 경과일 · 동시 진행 수 · 검증 활동 횟수)은 `config/campaign` 이 덮는다.
* 「검증 활동」은 서버가 인정한 Quest 완료 누적 수다(해석 확인 요청 Campaign 개설자격 A안 · 5회).
*/
const DEFAULTS = { feePop: 100, minAccountDays: 7, maxActive: 1, minVerifiedQuests: 5 };
const MAX_DAYS = 365;
export const fnCampaignCreate = onCall(async (req) => {
const data = req.data as Record<string, unknown> | null;
return createCampaign({
uid: callerUid(req),
clientSeq: str(data, 'clientSeq'),
title: str(data, 'title'),
body: str(data, 'body'),
targetPop: positiveInt(num(data, 'targetPop'), 'targetPop'),
days: positiveInt(num(data, 'days'), 'days'),
});
});
export async function createCampaign(input: {
uid: string; clientSeq: string; title: string; body: string; targetPop: number; days: number;
}): Promise<{ campaignId: string; status: 'review' }> {
const { uid, clientSeq } = input;
const title = input.title.trim();
const body = input.body.trim();
if (!title || title.length > 100) throw new HttpsError('invalid-argument', '제목은 1~100자입니다.');
if (!body || body.length > 5000) throw new HttpsError('invalid-argument', '내용은 1~5000자입니다.');
if (input.days > MAX_DAYS) throw new HttpsError('invalid-argument', `진행 기간은 ${MAX_DAYS}일 이하입니다.`);
const db = getFirestore();
const key = `campaign:${uid}:${clientSeq}`;
const campaignRef = db.doc(`campaigns/${txIdOf(key)}`);
// 같은 요청의 재시도 — 등록비도 두 번 빼지 않는다(spendPop 도 같은 키로 멱등).
if ((await campaignRef.get()).exists) return { campaignId: campaignRef.id, status: 'review' };
const cfg = { ...DEFAULTS, ...((await db.doc('config/campaign').get()).data() ?? {}) };
const user = await db.doc(`users/${uid}`).get();
const fandomId = user.get('fandomId') as string | undefined;
if (!fandomId) fail(ERR.permissionDenied, '팬덤에 가입한 뒤 캠페인을 만들 수 있습니다.');
if ((user.get('status') ?? 'active') !== 'active') {
fail(ERR.permissionDenied, '이용이 제한된 계정은 캠페인을 만들 수 없습니다.');
}
const joined = user.get('createdAt') as Timestamp | undefined;
if (!joined || Date.now() - joined.toMillis() < cfg.minAccountDays * 86_400_000) {
fail(ERR.permissionDenied, `가입 ${cfg.minAccountDays}일이 지나야 캠페인을 만들 수 있습니다.`);
}
const need = Number(cfg.minVerifiedQuests);
const done = need > 0 ? await verifiedQuestCountOf(uid) : 0;
if (done < need) {
fail(ERR.campaignNotEligible, `퀘스트 완료 ${done}/${need} — 퀘스트를 ${need - done}번 더 완료하면 캠페인을 열 수 있습니다.`, { done, need });
}
// 단일 필드 조건만 써서 복합 색인 없이 센다. 개설자 한 명의 캠페인 수는 작다.
const mine = await db.collection('campaigns').where('creatorUid', '==', uid).get();
const active = mine.docs.filter((d) => ['review', 'funding'].includes(d.get('status'))).length;
if (active >= cfg.maxActive) {
fail(ERR.permissionDenied, `진행 중인 캠페인은 ${cfg.maxActive}건까지입니다.`);
}
await spendSplit({
uid,
amount: cfg.feePop,
reasonCode: 'CAMPAIGN_FEE',
idempotencyKey: `campaignFee:${uid}:${clientSeq}`,
refPath: campaignRef.path,
alsoWrite: (tx, parts) => tx.create(campaignRef, {
fandomId,
creatorUid: uid,
creatorName: publicNameOf(user),
title,
summary: '',
body,
targetPop: input.targetPop,
endsAt: Timestamp.fromMillis(Date.now() + input.days * 86_400_000),
status: 'review',
raisedPop: 0,
achievedRate: 0,
donorCount: 0,
refundState: 'none',
feePop: cfg.feePop,
feeTxIds: parts.map((p) => p.txId),
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}),
});
return { campaignId: campaignRef.id, status: 'review' };
}
/**
* 등록비 반환 — B-58 「회사귀책 · 반려는 등록비 반환」 · 공개 전 철회(B-57 「공개 확정 시 Burn」).
*
* 반환이 정해진 캠페인은 상태 전이와 같은 쓰기에서 `feeRefund: 'due'` 를 받는다. 여기서
* 행마다 원래 타입으로 돌려준 뒤 `done` 으로 닫는다. 캠페인 하나에 멱등키 하나라 중간에
* 실패해도 다시 부르면 두 번 돌려주지 않는다. `due` 가 아니면 이미 남긴 결과만 돌려준다.
*/
export async function settleFeeRefund(campaignId: string): Promise<string[]> {
const ref = getFirestore().doc(`campaigns/${campaignId}`);
const snap = await ref.get();
if (snap.get('feeRefund') !== 'due') {
return (snap.get('feeRefundTxIds') as string[] | undefined) ?? [];
}
// 분할 차감 전의 옛 캠페인은 `feeTxId` 한 건이다. 둘 다 없으면 면제(Official 등)다.
const legacy = snap.get('feeTxId') as string | undefined;
const txIds = (snap.get('feeTxIds') as string[] | undefined) ?? (legacy ? [legacy] : []);
const refunded = txIds.length === 0 ? [] : (await refundSplit({
uid: snap.get('creatorUid') as string,
originalTxIds: txIds,
idempotencyKey: `campaignFeeRefund:${campaignId}`,
reasonCode: 'CAMPAIGN_FEE_REFUND',
})).map((r) => r.txId);
await ref.update({
feeRefund: 'done',
feeRefundTxIds: refunded,
updatedAt: FieldValue.serverTimestamp(),
});
return refunded;
}
/**
* 반환이 정해졌는데 끝나지 않은 캠페인을 다시 돈다 — `fn-campaign-settle` 회차마다.
* 상태 전이 뒤 반환 전에 죽은 호출을 복구하는 경로다. 실패한 건은 다음 회차가 다시 집는다.
*/
export async function retryFeeRefunds(): Promise<number> {
const due = await getFirestore().collection('campaigns').where('feeRefund', '==', 'due').get();
let done = 0;
for (const doc of due.docs) {
try {
await settleFeeRefund(doc.id);
done += 1;
} catch (error) {
logger.error('[campaign] 등록비 반환 실패 — 다음 회차 재시도', { campaignId: doc.id, error: String(error) });
}
}
return done;
}
/**
* `fn-campaign-withdraw` — 개설자 철회(B-58).
*
* - 공개 전(`draft` · `review`) — 철회하고 등록비를 돌려준다. 등록비는 공개 확정 때
* Burn 되므로(B-57) 아직 확정되지 않았다.
* - 공개 후 첫 후원 전(`funding` · 후원 0건) — 철회는 되지만 **생성자 변심이라 등록비는
* 비환불**이다.
* - 첫 후원 뒤 — 개설자 단독으로는 못 한다. 운영 승인 경로다(E-PERM-01).
*
* 판정과 상태 전이를 한 트랜잭션에 둔다. 후원(`fn-campaign-fund`)도 같은 문서의 `status`
* 를 트랜잭션으로 읽으므로 「후원 0건이라 철회」와 「첫 후원」이 함께 서지 않는다.
*/
export const fnCampaignWithdraw = onCall(async (req) =>
withdrawCampaign(callerUid(req), str(req.data, 'campaignId')));
export async function withdrawCampaign(
uid: string, campaignId: string,
): Promise<{ status: 'withdrawn'; feeRefundTxIds: string[] }> {
const db = getFirestore();
const ref = db.doc(`campaigns/${campaignId}`);
await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
if (snap.get('creatorUid') !== uid) fail(ERR.permissionDenied, '개설자만 철회할 수 있습니다.');
const status = snap.get('status') as string | undefined;
if (status === 'withdrawn') return; // 재시도 — 남은 반환만 아래에서 마저 한다.
const beforePublish = status === 'draft' || status === 'review';
const noDonation = status === 'funding'
&& ((snap.get('donorCount') as number | undefined) ?? 0) === 0
&& ((snap.get('raisedPop') as number | undefined) ?? 0) === 0;
if (!beforePublish && !noDonation) {
fail(ERR.permissionDenied, '첫 후원 뒤에는 운영 승인 없이 철회할 수 없습니다.');
}
tx.update(ref, {
status: 'withdrawn',
withdrawnAt: FieldValue.serverTimestamp(),
...(beforePublish ? { feeRefund: 'due' } : {}),
updatedAt: FieldValue.serverTimestamp(),
});
});
return { status: 'withdrawn', feeRefundTxIds: await settleFeeRefund(campaignId) };
}
@@ -0,0 +1,564 @@
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import {
getFirestore, FieldPath, FieldValue, Timestamp, type DocumentSnapshot,
} from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { logger } from 'firebase-functions';
import { callerUid, requireRole, str } from '../core/callable.js';
import { CatalogError, ERR, fail } from '../core/errors.js';
import { refundPop } from '../ledger/ledger.js';
import { retryFeeRefunds, settleFeeRefund } from './create.js';
/**
* 캠페인 정산과 환불 — 별첨 1 ⑤.
*
* 판정 기준이 「성공/실패 분기 정확 · 자동환불 무결성(**부분 실패 방치·중복 환불
* 0건**) · 결과보고 게시 확인」이다. 부분 실패를 방치하지 않는 것이 핵심이라
* 실패한 건을 세어 남기고 Admin 큐로 넘긴다.
*
* 환불 기한은 정책 §P-09 가 **마감 후 72시간**으로 확정했다.
*/
const REFUND_DEADLINE_HOURS = 72;
/**
* 자동 환불 패스 상한. 실패가 남은 패스가 이만큼 쌓이면 Admin Exception Queue 로 넘기고
* 자동 재시도를 멈춘다(원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
*/
export const MAX_AUTO_REFUND_ATTEMPTS = 3;
/** 한 회차에 처리하는 후원 수. 넘으면 커서를 남기고 다음 회차가 이어 간다. */
export const REFUND_PAGE = 200;
/** 함수 제한 시간과 리스. 리스가 제한 시간보다 길어야 죽은 회차의 잠금이 풀린 뒤에만 겹친다. */
const TIMEOUT_SECONDS = 540;
const LEASE_MS = (TIMEOUT_SECONDS + 60) * 1000;
/**
* 후원을 돌려주는 캠페인 상태. `fail` 이 규칙·앱의 표기이고 `failed` 는 예전 정산이 쓴
* 값이다. `cancelled` 는 회사귀책 취소(B-58 · `fn-campaign-admin-cancel`)다.
*/
const FAILED = ['fail', 'failed', 'cancelled'];
/** 규칙·설계서의 스키마 이름은 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만 읽는다. */
const goalOf = (snap: DocumentSnapshot): number =>
(snap.get('targetPop') as number | undefined)
?? (snap.get('goalPop') as number | undefined) ?? 0;
const raisedOf = (snap: DocumentSnapshot): number =>
(snap.get('raisedPop') as number | undefined) ?? 0;
/**
* `fn-campaign-settle` — 마감을 지난 캠페인을 성공·실패로 가르고, 실패면 **같은 회차에서**
* 환불을 시작한다(B-36 「즉시 환불 시작」). 부분 실패·미완 캠페인도 여기서 이어 돈다.
*
* 스케줄을 10분으로 둔 이유는 마감 직후의 대기 시간을 줄이기 위해서다.
* 그보다 촘촘히 돌려도 마감 시각이 분 단위라 얻는 것이 없다.
*/
export const fnCampaignSettle = onSchedule(
{ schedule: 'every 10 minutes', timeoutSeconds: TIMEOUT_SECONDS },
async () => {
const settled = await settleDueCampaigns();
const retried = await retryPendingRefunds();
const fees = await retryFeeRefunds();
logger.info('[campaign] 정산 완료', { settled, retried, fees });
},
);
/**
* 마감이 지난 `funding` 캠페인 판정. 판정한 건수를 돌려준다.
*
* **캠페인마다 트랜잭션으로 다시 읽고 판정한다.** 쿼리 결과로 판정하면 그 사이 들어온
* 후원이 빠진 모금액으로 성공·실패를 가른다. 후원(`fn-campaign-fund`)도 같은 문서를
* 트랜잭션으로 읽고 마감 뒤 후원을 거부하므로 판정 뒤에 모금액이 움직이지 않는다.
*/
export async function settleDueCampaigns(now: Timestamp = Timestamp.now()): Promise<number> {
const db = getFirestore();
const due = await db
.collection('campaigns')
.where('status', '==', 'funding')
.where('endsAt', '<=', now)
.get();
let settled = 0;
for (const doc of due.docs) {
const success = await db.runTransaction(async (tx) => {
const snap = await tx.get(doc.ref);
const endsAt = snap.get('endsAt') as Timestamp | undefined;
if (snap.get('status') !== 'funding' || !endsAt || endsAt.toMillis() > now.toMillis()) {
return null;
}
const goal = goalOf(snap);
const ok = goal > 0 && raisedOf(snap) >= goal;
tx.update(doc.ref, {
// 규칙 `campaignIsPublic()` · 앱과 같은 표기.
status: ok ? 'success' : 'fail',
settledAt: FieldValue.serverTimestamp(),
// 실패면 환불 기한을 함께 박아 둔다. 나중에 계산하면 기준이 흔들린다.
...(ok
? {}
: {
refundDueAt: Timestamp.fromMillis(
now.toMillis() + REFUND_DEADLINE_HOURS * 60 * 60 * 1000,
),
refundState: 'pending',
refundAttempts: 0,
refundPassFailed: 0,
}),
updatedAt: FieldValue.serverTimestamp(),
});
return ok;
});
if (success === null) continue;
settled += 1;
logger.info('[campaign] 정산', { id: doc.id, success });
}
return settled;
}
/**
* 환불이 끝나지 않은 실패 캠페인을 돌린다 — 방금 실패로 판정된 건도 여기서 첫 환불을 받는다.
* 자동 시도 상한을 넘긴 건은 Admin 큐에 있으므로 건드리지 않는다. 실제로 돈 건수를 돌려준다.
*/
export async function retryPendingRefunds(): Promise<number> {
// 단일 필드 조건이라 복합 색인이 필요 없다. 상태는 아래에서 거른다.
const open = await getFirestore()
.collection('campaigns')
.where('refundState', 'in', ['pending', 'partial'])
.get();
let ran = 0;
for (const doc of open.docs) {
if (!FAILED.includes(doc.get('status') as string)) continue;
if (((doc.get('refundAttempts') as number | undefined) ?? 0) >= MAX_AUTO_REFUND_ATTEMPTS) continue;
const result = await refundCampaign(doc.id, { manual: false });
if (!result.skipped) ran += 1;
}
return ran;
}
type RefundFailure = { donationId: string; error: string };
export interface RefundResult {
/** 이 회차에 새로 돌려준 건수. */
refunded: number;
failed: RefundFailure[];
/** 한 바퀴를 다 돌았는가. `false` 면 커서가 남아 다음 회차가 이어 간다. */
done: boolean;
/** 돌지 않은 이유 — 다른 회차가 잡고 있거나, 옛 정산이 실패로 잘못 적은 캠페인. */
skipped?: 'locked' | 'legacy-goal-met';
}
/**
* 실패 캠페인 환불 한 회차. 자동(스케줄)과 수동(Admin) 이 같은 코어를 지난다.
*
* - **리스** — `lockedUntil` 을 트랜잭션으로 잡은 회차만 돈다. 겹친 회차는 건너뛴다.
* - **분할** — 한 회차에 [REFUND_PAGE] 건. 커서(`refundCursor`)와 이번 바퀴의 실패 수
* (`refundPassFailed`)를 매 회차 남긴다.
* - **중복 환불 0건** — 후원 한 건의 멱등 키가 `refund:{donationId}` 하나다. 다른 경로
* (`fn-pop-refund`)가 먼저 돌려준 원거래는 그 환불을 받아들여 끝난 건으로 본다.
* - **부분 실패를 성공으로 덮지 않는다.** 실패가 남은 바퀴는 `partial` 로 남기고 자동 시도를
* 센다(`refundAttempts`). 상한에 닿거나 Admin 이 돌려도 남으면 Exception Queue
* (`adminActionLogs`)에 적는다(E-CMP-02).
* - **옛 데이터** — `refundAttempts` 가 없는 문서는 goalPop 만 읽던 예전 정산이 판정했다.
* `targetPop` 으로 다시 판정해 목표를 채운 캠페인은 돌려주지 않는다.
*/
export async function refundCampaign(
campaignId: string,
opts: { manual: boolean },
): Promise<RefundResult> {
const db = getFirestore();
const campaignRef = db.doc(`campaigns/${campaignId}`);
const lease = await db.runTransaction(async (tx) => {
const snap = await tx.get(campaignRef);
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
if (!FAILED.includes(snap.get('status') as string)) {
fail(ERR.campaignNotApproved, '실패하거나 취소된 캠페인만 환불할 수 있습니다.');
}
const locked = snap.get('lockedUntil') as Timestamp | undefined;
if (locked && locked.toMillis() > Date.now()) return { skipped: 'locked' as const };
const legacy = typeof snap.get('refundAttempts') !== 'number';
if (legacy) {
const goal = goalOf(snap);
if (goal > 0 && raisedOf(snap) >= goal) return { skipped: 'legacy-goal-met' as const };
}
tx.update(campaignRef, {
lockedUntil: Timestamp.fromMillis(Date.now() + LEASE_MS),
// 다시 판정해도 실패인 옛 문서는 지금 표기로 맞춘다.
...(legacy ? { status: 'fail', refundAttempts: 0, refundPassFailed: 0 } : {}),
});
return {
title: (snap.get('title') as string | undefined) ?? '캠페인',
cancelled: snap.get('status') === 'cancelled',
cursor: (snap.get('refundCursor') as string | undefined) ?? null,
attempts: legacy ? 0 : (snap.get('refundAttempts') as number),
passFailed: legacy ? 0 : ((snap.get('refundPassFailed') as number | undefined) ?? 0),
};
});
if ('skipped' in lease) {
logger.warn('[campaign] 환불 건너뜀', { campaignId, reason: lease.skipped });
return { refunded: 0, failed: [], done: false, skipped: lease.skipped };
}
let query = campaignRef.collection('donations')
.orderBy(FieldPath.documentId())
.limit(REFUND_PAGE);
if (lease.cursor) query = query.startAfter(lease.cursor);
const page = await query.get();
const failed: RefundFailure[] = [];
let refunded = 0;
for (const donation of page.docs) {
const uid = donation.get('uid') as string | undefined;
let refundTxId = donation.get('refundTxId') as string | undefined;
if (!refundTxId) {
const txId = donation.get('txId') as string | undefined;
if (!uid || !txId) {
failed.push({ donationId: donation.id, error: 'missing-uid-or-tx' });
continue;
}
try {
refundTxId = (await refundPop({
uid,
originalTxId: txId,
// 후원 한 건당 한 번. 재실행해도 두 번 돌려주지 않는다.
idempotencyKey: `refund:${donation.id}`,
reasonCode: 'CAMPAIGN_REFUND',
})).txId;
} catch (error) {
if (error instanceof CatalogError && error.catalogCode === ERR.popDuplicate) {
// 다른 경로가 이미 돌려준 원거래 — 그 환불을 이 후원의 환불로 받아들인다.
refundTxId = (await db.doc(`users/${uid}/popTransactions/${txId}`).get())
.get('refundedBy') as string;
} else {
failed.push({ donationId: donation.id, error: String(error) });
await donation.ref.set({ refundError: String(error) }, { merge: true });
continue;
}
}
await donation.ref.set({
refundTxId,
refundedAt: FieldValue.serverTimestamp(),
refundError: FieldValue.delete(),
}, { merge: true });
refunded += 1;
}
// 환불과 알림 사이에 죽어도 다음 회차가 알림만 다시 보낸다(문서 ID = 환불 거래).
if (uid && !donation.get('refundNotifiedAt')) {
await notifyOnce(uid, `campaignRefund_${refundTxId}`, {
title: lease.cancelled
? `「${lease.title}」 캠페인이 취소되어 후원 POP 을 돌려드렸습니다`
: `「${lease.title}」 목표 미달로 후원 POP 을 돌려드렸습니다`,
campaignId,
});
await donation.ref.set({ refundNotifiedAt: FieldValue.serverTimestamp() }, { merge: true });
}
}
const passFailed = lease.passFailed + failed.length;
if (page.size === REFUND_PAGE) {
// 아직 남았다 — 진행 상황만 남기고 잠금을 푼다. 다음 회차가 커서부터 잇는다.
await campaignRef.update({
refundCursor: page.docs[page.docs.length - 1]!.id,
refundPassFailed: passFailed,
refundLastRunAt: FieldValue.serverTimestamp(),
lockedUntil: FieldValue.delete(),
updatedAt: FieldValue.serverTimestamp(),
});
return { refunded, failed, done: false };
}
const counted = passFailed > 0 && !opts.manual ? 1 : 0;
const toQueue = passFailed > 0
&& (opts.manual || lease.attempts + counted >= MAX_AUTO_REFUND_ATTEMPTS);
const refundedCount = (await campaignRef.collection('donations')
.where('refundTxId', '!=', null).count().get()).data().count;
await campaignRef.update({
refundState: passFailed === 0 ? 'done' : 'partial',
refundedCount,
refundFailedCount: passFailed,
refundAttempts: FieldValue.increment(counted),
refundCursor: FieldValue.delete(),
refundPassFailed: 0,
refundLastRunAt: FieldValue.serverTimestamp(),
lockedUntil: FieldValue.delete(),
...(passFailed === 0 ? { refundedAt: FieldValue.serverTimestamp() } : {}),
...(toQueue ? { refundQueuedAt: FieldValue.serverTimestamp() } : {}),
updatedAt: FieldValue.serverTimestamp(),
});
if (toQueue) {
await db.collection('adminActionLogs').add({
actorUid: 'system',
targetPath: campaignRef.path,
reason: '캠페인 환불 부분 실패 — 수동 복구 필요',
// 건별 사유는 후원 문서의 `refundError` 에 남아 있다.
patch: { failedCount: passFailed, attempts: lease.attempts + counted, lastRun: failed },
createdAt: FieldValue.serverTimestamp(),
});
logger.error('[campaign] 환불 부분 실패 — Admin 큐', { campaignId, passFailed });
} else if (passFailed > 0) {
logger.warn('[campaign] 환불 부분 실패 — 자동 재시도 예정', { campaignId, passFailed });
}
return { refunded, failed, done: true };
}
/**
* 알림함 한 건. 문서 ID 를 고정해 재실행이 같은 알림을 두 번 쌓지 않는다.
* `type` 은 B-77 수신 설정 축, `eventType` 은 알림함 이동 축이다(앱 `NotificationKind`).
*/
async function notifyOnce(
uid: string,
id: string,
data: { title: string; campaignId: string },
): Promise<void> {
try {
await getFirestore().doc(`users/${uid}/notifications/${id}`).create({
type: 'essential',
eventType: 'campaign',
...data,
read: false,
createdAt: FieldValue.serverTimestamp(),
});
} catch (error) {
// 이미 보낸 알림(ALREADY_EXISTS = 6)은 정상이다. 그 밖의 실패도 환불을 되돌리지 않는다.
if ((error as { code?: number }).code !== 6) {
logger.error('[campaign] 알림 기록 실패', { uid, id, message: (error as Error).message });
}
}
}
/**
* `fn-campaign-refund-batch` — Admin 수동 복구. 환불은 정산이 자동으로 시작하므로
* 이 호출은 Exception Queue 에 오른 건을 다시 돌리는 경로다. `done: false` 면 다시 부른다.
*/
export const fnCampaignRefundBatch = onCall({ timeoutSeconds: TIMEOUT_SECONDS }, async (req) => {
callerUid(req);
requireRole(req, ['master', 'ops']);
return refundCampaign(str(req.data, 'campaignId'), { manual: true });
});
/** 결과보고 사진·영수증 상한 — 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
export const REPORT_MAX_PHOTOS = 5;
const REPORT_MAX_CHARS = 5000;
const BUDGET_MAX_LINES = 50;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const CAMPAIGN_ID = /^[A-Za-z0-9_-]{1,128}$/;
export interface BudgetLine { label: string; amount: number }
/**
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(원장 B-35 · FR-ADM-05 · FR-CMP-06).
*
* **운영(master·ops)만 올린다.** B-35 는 funded 이후 집행·증빙·결과보고를 Admin 수동 운영으로
* 두었다. 집행 사진 · 영수증 · 예산 사용 내역을 함께 받아 보고와 상태 `report` 를 한
* 트랜잭션에 쓰고, 후원자 전원의 알림함에 안내를 남긴다.
*
* 파일은 URL 이 아니라 **Storage 경로**로 받는다. 경로가 정확히 `campaignReports/{id}/{파일}`
* 이고(상위 이동 · 하위 폴더 없음) 실제로 올라가 있어야 한다.
*/
export const fnCampaignReportSubmit = onCall({ timeoutSeconds: 120 }, async (req) => {
const uid = callerUid(req);
requireRole(req, ['master', 'ops']);
const data = req.data as Record<string, unknown> | null;
return submitCampaignReport({
uid,
campaignId: str(data, 'campaignId'),
body: str(data, 'body'),
photoPaths: data?.photoPaths,
receiptPaths: data?.receiptPaths,
budget: data?.budget,
});
});
const storageExists = async (path: string): Promise<boolean> =>
(await getStorage().bucket().file(path).exists())[0];
function reportPaths(raw: unknown, campaignId: string, what: string): string[] {
const prefix = `campaignReports/${campaignId}/`;
const ok = Array.isArray(raw) && raw.length >= 1 && raw.length <= REPORT_MAX_PHOTOS
&& new Set(raw).size === raw.length
&& raw.every((p) => typeof p === 'string' && p.startsWith(prefix)
&& FILE_NAME.test(p.slice(prefix.length)));
if (!ok) throw new HttpsError('invalid-argument', `${what}을 1~${REPORT_MAX_PHOTOS}장 올려 주세요.`);
return raw as string[];
}
function budgetLines(raw: unknown): BudgetLine[] {
const ok = Array.isArray(raw) && raw.length >= 1 && raw.length <= BUDGET_MAX_LINES
&& raw.every((l) => typeof l?.label === 'string'
&& l.label.trim().length >= 1 && l.label.trim().length <= 100
&& Number.isSafeInteger(l.amount) && l.amount > 0);
if (!ok) {
throw new HttpsError('invalid-argument', `예산 사용 내역을 1~${BUDGET_MAX_LINES}줄(항목 · 금액) 적어 주세요.`);
}
return (raw as BudgetLine[]).map((l) => ({ label: l.label.trim(), amount: l.amount }));
}
export async function submitCampaignReport(
input: {
uid: string; campaignId: string; body: string;
photoPaths: unknown; receiptPaths: unknown; budget: unknown;
},
exists: (path: string) => Promise<boolean> = storageExists,
): Promise<{ duplicate: boolean }> {
const { uid, campaignId } = input;
if (!CAMPAIGN_ID.test(campaignId)) throw new HttpsError('invalid-argument', 'campaignId 가 올바르지 않습니다.');
const body = input.body.trim();
if (body.length === 0 || body.length > REPORT_MAX_CHARS) {
throw new HttpsError('invalid-argument', `본문은 1~${REPORT_MAX_CHARS}자입니다.`);
}
const photoPaths = reportPaths(input.photoPaths, campaignId, '집행 사진');
const receiptPaths = reportPaths(input.receiptPaths, campaignId, '영수증');
const budget = budgetLines(input.budget);
for (const path of [...photoPaths, ...receiptPaths]) {
if (!(await exists(path))) throw new HttpsError('invalid-argument', '올라가지 않은 파일이 있습니다.');
}
const db = getFirestore();
const campaignRef = db.doc(`campaigns/${campaignId}`);
const reportRef = campaignRef.collection('reports').doc('main');
const duplicate = await db.runTransaction(async (tx) => {
const campaign = await tx.get(campaignRef);
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
if (campaign.get('status') === 'report') return true;
if (campaign.get('status') !== 'success') {
fail(ERR.campaignNotApproved, '성공한 캠페인만 결과보고를 올릴 수 있습니다.');
}
tx.create(reportRef, {
authorUid: uid,
body,
photoPaths,
receiptPaths,
budget,
budgetTotal: budget.reduce((sum, l) => sum + l.amount, 0),
createdAt: FieldValue.serverTimestamp(),
});
tx.update(campaignRef, {
status: 'report',
reportedAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
return false;
});
// 게시와 알림 사이에 죽었으면 재호출이 알림만 다시 보낸다(문서 ID = 보고 ID).
const report = await reportRef.get();
if (!report.get('notifiedAt')) {
const title = ((await campaignRef.get()).get('title') as string | undefined) ?? '캠페인';
const donations = await campaignRef.collection('donations').get();
const donors = new Set(donations.docs
.map((d) => d.get('uid') as string | undefined)
.filter((u): u is string => !!u));
for (const donor of donors) {
await notifyOnce(donor, `campaignReport_${campaignId}_${reportRef.id}`, {
title: `후원하신 「${title}」 결과보고가 올라왔습니다`,
campaignId,
});
}
await reportRef.update({ notifiedAt: FieldValue.serverTimestamp() });
}
return { duplicate };
}
/**
* `fn-campaign-admin-review` — 승인·반려.
*
* **승인 전에는 모금이 열리지 않는다**(`fn-campaign-fund` 가 E-CMP-03 로 막는다).
* 여기서 `funding` 으로 올려야 후원이 시작된다. 반려는 등록비를 원래 타입으로 돌려준다
* (B-58). 반환이 중간에 실패하면 같은 요청의 재시도가 마저 한다.
*/
export const fnCampaignAdminReview = onCall(async (req) => {
const actorUid = callerUid(req);
requireRole(req, ['master', 'ops']);
const campaignId = str(req.data, 'campaignId');
const requestId = str(req.data, 'requestId');
const approve = (req.data as Record<string, unknown>)?.approve === true;
const memo = str(req.data, 'memo');
const db = getFirestore();
const seen = db.doc(`campaigns/${campaignId}/reviews/${requestId}`);
const campaignRef = db.doc(`campaigns/${campaignId}`);
// 앞선 반려의 반환이 중간에 끊겼으면 여기서 마저 한다 — 상태 판정보다 먼저.
await settleFeeRefund(campaignId);
// 판정과 전이를 한 트랜잭션에 둔다. 개설자 철회(`fn-campaign-withdraw`)도 같은 문서를
// 트랜잭션으로 읽으므로 승인과 철회가 함께 서지 않는다.
const result = await db.runTransaction(async (tx) => {
const [review, campaign] = await Promise.all([tx.get(seen), tx.get(campaignRef)]);
// 같은 요청이 두 번 들어와도 판정을 뒤집지 않는다.
if (review.exists) return { duplicate: true };
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
if (campaign.get('status') !== 'review') {
fail(ERR.campaignNotApproved, '검토 대기 상태가 아닙니다.');
}
tx.update(campaignRef, {
status: approve ? 'funding' : 'rejected',
// 반려는 등록비 반환(B-58). 전이와 같은 쓰기에 표시해 두면 반환이 끊겨도 스케줄이 잇는다.
...(approve ? {} : { feeRefund: 'due' }),
reviewedBy: actorUid,
reviewMemo: memo,
updatedAt: FieldValue.serverTimestamp(),
});
tx.create(seen, { actorUid, approve, memo, createdAt: FieldValue.serverTimestamp() });
return { duplicate: false, status: approve ? 'funding' : 'rejected' };
});
if (!approve) await settleFeeRefund(campaignId);
return result;
});
/**
* `fn-campaign-admin-cancel` — 회사귀책 취소(B-58).
*
* 심사 중 · 모금 중 캠페인을 `cancelled` 로 닫고 **등록비와 후원 전액**을 돌려준다.
* 후원 환불은 실패 캠페인과 같은 코어(`refundCampaign` — 건당 멱등 · 분할 · 부분 실패 큐)를
* 지나며, 한 회차에 끝나지 않으면 `fn-campaign-settle` 이 이어 돈다. 개설자에게 알린다.
* 이미 취소된 캠페인에 다시 부르면 남은 반환만 이어 간다.
*/
export const fnCampaignAdminCancel = onCall({ timeoutSeconds: TIMEOUT_SECONDS }, async (req) => {
const actorUid = callerUid(req);
requireRole(req, ['master', 'ops']);
return cancelCampaign(str(req.data, 'campaignId'), actorUid, str(req.data, 'reason'));
});
export async function cancelCampaign(
campaignId: string, actorUid: string, reason: string,
): Promise<{ status: 'cancelled'; refund: RefundResult }> {
const db = getFirestore();
const campaignRef = db.doc(`campaigns/${campaignId}`);
const creator = await db.runTransaction(async (tx) => {
const snap = await tx.get(campaignRef);
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
const status = snap.get('status') as string | undefined;
if (status !== 'cancelled') {
if (status !== 'review' && status !== 'funding') {
fail(ERR.campaignNotApproved, '심사 중이거나 모금 중인 캠페인만 취소할 수 있습니다.');
}
tx.update(campaignRef, {
status: 'cancelled',
cancelReason: reason,
cancelledBy: actorUid,
cancelledAt: FieldValue.serverTimestamp(),
feeRefund: 'due',
refundState: 'pending',
refundAttempts: 0,
refundPassFailed: 0,
updatedAt: FieldValue.serverTimestamp(),
});
}
return {
uid: snap.get('creatorUid') as string | undefined,
title: (snap.get('title') as string | undefined) ?? '캠페인',
};
});
await settleFeeRefund(campaignId);
if (creator.uid) {
await notifyOnce(creator.uid, `campaignCancel_${campaignId}`, {
title: `「${creator.title}」 캠페인이 운영 사유로 취소되어 등록비를 돌려드렸습니다`,
campaignId,
});
}
return { status: 'cancelled', refund: await refundCampaign(campaignId, { manual: false }) };
}