코드 주석을 이 저장소 기준으로 정리

This commit is contained in:
Sn-Kinos
2026-10-16 18:04:14 +09:00
parent 80b90aad4c
commit 4236d75fd1
89 changed files with 212 additions and 212 deletions
+13 -13
View File
@@ -86,12 +86,12 @@ export async function planetColorKeys(): Promise<string[]> {
/**
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
* - 성운 전환: 정책 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
* `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와
* 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false).
* - 대표 팬덤: 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
* - 대표 팬덤: 정책 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지.
* 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치.
* 정책 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치.
* 자동 지정은 C-15 대기에 세지 않는다.
*/
const FANDOM_DEFAULTS = {
@@ -122,7 +122,7 @@ function primaryOf(user: Data): string | null {
}
/**
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 원장 C-15.
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 정책 원장 C-15.
*
* 대표가 없던 계정의 첫 지정은 「변경」이 아니므로 대기를 걸지 않고 시각도 찍지 않는다.
* 이미 대표가 있으면 `primaryChangedAt + 대기일` 이 지나야 바뀐다. `patch` 가 null 이면
@@ -145,7 +145,7 @@ function primaryPatch(user: Data, fandomId: string, cooldownDays: number, now: n
/**
* 멤버십 한 건을 트랜잭션 안에서 쓴다. 호출 측이 「아직 멤버가 아님」을 확인한 뒤에만 부른다.
*
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 원장 B-68 #001~ 기록).
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 정책 원장 B-68 #001~ 기록).
* 순번은 탈퇴로 줄지 않는 `memberSeq` 에서 뽑아 재사용하지 않는다.
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
* - `memberCount` 는 같은 트랜잭션에서 올린다 — 전환 조건(B-10)이 이 값을 읽는다.
@@ -183,7 +183,7 @@ function writeJoin(tx: Transaction, uid: string, fandomId: string, fandom: Data)
}
/**
* `fn-nebula-create` — 성운(Stage0) 생성. 원장 B-08 · B-09 · B-10 · C-15.
* `fn-nebula-create` — 성운(Stage0) 생성. 정책 원장 B-08 · B-09 · B-10 · C-15.
*
* 팬덤 문서 자체가 공용 성운/행성이다(팬덤 1 : 행성 1). 만든 사람은 창립 멤버 #001 이다.
* 확인 다이얼로그 「선택한 행성을 기본 행성으로 변경할까요?」대로 대표 팬덤을 바꾸되, C-15
@@ -237,7 +237,7 @@ export const fnNebulaCreate = onCall(async (req) => {
});
/**
* `fn-fandom-join` — 행성/성운 가입. 원장 C-15 「복수 팬덤 가입」.
* `fn-fandom-join` — 행성/성운 가입. 정책 원장 C-15 「복수 팬덤 가입」.
*
* 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다).
* 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다.
@@ -276,10 +276,10 @@ export const fnFandomJoin = onCall(async (req) => {
* 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15).
* 순번은 `memberSeq` 를 되돌리지 않으므로 다른 사용자에게 다시 주지 않는다(C-100 · B-68).
*
* - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버
* - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(정책 원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버
* 가운데 `joinedAt` 이 가장 이른 사용자에게 넘기고 알림을 남긴다. 남은 멤버가 없으면 비워 두고
* 다음 가입자가 이어받는다(원장 S20 확인 전 유지 기준). `ownerUid`(행성 문서) 등 최초 생성자 기록은 그대로다.
* - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(원장 C-101). 자동 지정이라
* - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(정책 원장 C-101). 자동 지정이라
* `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다.
*/
export const fnFandomLeave = onCall(async (req) => {
@@ -343,7 +343,7 @@ export const fnFandomLeave = onCall(async (req) => {
});
/**
* `fn-primary-fandom-set` — 대표 팬덤 변경. 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
* `fn-primary-fandom-set` — 대표 팬덤 변경. 정책 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
*
* 가입한 팬덤만 대표가 될 수 있다. 변경은 무료이고, 재변경은 `primaryChangedAt + 30일`
* (운영값 `primaryCooldownDays`) 이 지나야 한다. 과거 기여 · Passport 는 건드리지 않는다.
@@ -369,11 +369,11 @@ export const fnPrimaryFandomSet = onCall(async (req) => {
});
/**
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 원장 B-09 · B-10 · C-50.
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 정책 원장 B-09 · B-10 · C-50.
*
* 형태(`planetType`)와 컬러(`colorKey`) 두 가지만 이때 한 번 고른다. 행성 문서 ID 는
* 팬덤 안에서 고정(`planet`)이라 팬덤당 1건이 문서 ID 충돌로 원자적으로 지켜진다
* (Firestore 설계서 §2.4 정정). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
* (Firestore 설계서 §2.4). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
* 싣는다. 생성 후 두 값을 바꾸는 경로는 없다(보안 규칙이 클라이언트 쓰기를 전부 닫음).
*
* 전환 조건(인원 · POP)은 **트랜잭션 안에서 읽은** 팬덤 문서로 판정한다 — 가입 · 탈퇴와
@@ -505,7 +505,7 @@ export async function hasLocationConsent(uid: string): Promise<boolean> {
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const;
/**
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 원장 C-134).
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 정책 원장 C-134).
*
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
@@ -67,7 +67,7 @@ export async function inviteCodeOf(uid: string): Promise<string> {
/** `fn-invite-code-get` — 내 초대 코드(없으면 만들어서). 공유 링크 `?ref=` 에 붙는다. */
export const fnInviteCodeGet = onCall(async (req) => ({ code: await inviteCodeOf(callerUid(req)) }));
/** 추천 보상 보류 시간 — 원장 **B-71** 「기록 → 보상 pending/24h」. 지난 뒤 정산 작업이 확정한다. */
/** 추천 보상 보류 시간 — 정책 원장 **B-71** 「기록 → 보상 pending/24h」. 지난 뒤 정산 작업이 확정한다. */
export const REFERRAL_HOLD_MS = 24 * 60 * 60 * 1000;
/**
@@ -109,7 +109,7 @@ export const fnReferralAttribute = onCall(async (req) => {
// 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다.
fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.');
}
// 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(원장 C-120).
// 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(정책 원장 C-120).
// 자기 · 상호 · 중복 · 이상 패턴은 아래 판정과 Risk Engine 이 맡는다.
const { referralAttachDays } = await loadPolicy();
@@ -244,7 +244,7 @@ export async function settleReferral(uid: string): Promise<'paid' | 'released' |
uid: referrerUid, fandomId: fandomOfUser(referrer), axis: 'referral', units: 1,
activity: 'referral', sourceId: referralFpiSource(fresh), occurredAt: new Date(),
refPath: `referrals/${fresh.get('attributionId')}`,
// 추천인은 이 활동을 직접 하지 않았다 — EXP · DAU 에서 뺀다(리뷰 M5). FPI REFERRAL 축만.
// 추천인은 이 활동을 직접 하지 않았다 — EXP · DAU 에서 뺀다. FPI REFERRAL 축만.
grantsExp: false,
}, referrer);
});
@@ -66,7 +66,7 @@ export function parseTranslationInput(data: unknown): TranslationInput {
}
/**
* 등록 시점 판정 — 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」.
* 등록 시점 판정 — 정책 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」.
* 상한의 [TRANSLATION_HARD_LIMIT_FACTOR] 배를 넘으면 등록 자체를 거부(E-TRN-02),
* 상한까지는 보상, 그 사이는 등록만 받고 보상 0.
*/
+1 -1
View File
@@ -15,7 +15,7 @@ import { voidCompletions } from '../ledger/fpi.js';
*/
/**
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**(리뷰 H3).
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**.
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
*/
const FORBIDDEN = [
@@ -10,7 +10,7 @@ import { verifiedQuestCountOf } from '../ledger/fpi.js';
import { publicNameOf } from '../core/names.js';
/**
* `fn-campaign-create` — 사용자 캠페인 개설(원장 B-56 자격 · B-57 등록비).
* `fn-campaign-create` — 사용자 캠페인 개설(정책 원장 B-56 자격 · B-57 등록비).
*
* 예전에는 앱이 `campaigns` 에 `draft` 로 직접 썼는데 개설자는 status 를 바꿀 수 없어
* 심사(`review`)로 넘어갈 길이 없었다. 개설 · 등록비 차감 · `review` 진입을
@@ -25,7 +25,7 @@ const REFUND_DEADLINE_HOURS = 72;
/**
* 자동 환불 패스 상한. 실패가 남은 패스가 이만큼 쌓이면 Admin Exception Queue 로 넘기고
* 자동 재시도를 멈춘다(원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
* 자동 재시도를 멈춘다(정책 원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
*/
export const MAX_AUTO_REFUND_ATTEMPTS = 3;
@@ -369,7 +369,7 @@ export const fnCampaignRefundBatch = onCall({ timeoutSeconds: TIMEOUT_SECONDS },
return refundCampaign(str(req.data, 'campaignId'), { manual: true });
});
/** 결과보고 사진·영수증 상한 — 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
/** 결과보고 사진·영수증 상한 — 정책 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
export const REPORT_MAX_PHOTOS = 5;
const REPORT_MAX_CHARS = 5000;
const BUDGET_MAX_LINES = 50;
@@ -379,7 +379,7 @@ 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).
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(정책 원장 B-35 · FR-ADM-05 · FR-CMP-06).
*
* **운영(master·ops)만 올린다.** B-35 는 funded 이후 집행·증빙·결과보고를 Admin 수동 운영으로
* 두었다. 집행 사진 · 영수증 · 예산 사용 내역을 함께 받아 보고와 상태 `report` 를 한
+7 -7
View File
@@ -10,22 +10,22 @@ export interface PolicyConfig {
/** 출석 1회 보상 활동 POP. */
attendPop: number;
/**
* 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정형 **기여 POP** / 회.
* 사용자 생성 Quest 의 광고 추가 보상 — 정책 원장 **C-02** 고정형 **기여 POP** / 회.
* 기본 보상액에 비례하지 않고 회사 재원이다(B-28).
*/
adRewardPop: number;
/**
* 운영(관리자) Quest 의 광고 배수 — 원장 **B-03** 기본 x2. 지정 Quest 는
* 운영(관리자) Quest 의 광고 배수 — 정책 원장 **B-03** 기본 x2. 지정 Quest 는
* `quests/{id}.adMultiplier`(예: 3)가 덮는다. 지급은 **증분만**(기본 보상 × (배수 − 1))
* 기여 POP 이다.
*/
adMultiplier: number;
/**
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 원장 **B-02** 「POP 자체 직접 판매 없음」
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 정책 원장 **B-02** 「POP 자체 직접 판매 없음」
* 이라 기본 false. 켜지 않으면 `fn-iap-verify` 가 E-PAY-01 로 거부한다.
*/
popPackSalesEnabled: boolean;
/** Focus 구독 일일 Claim 기여 POP — 원장 **B-04** 확정 40. */
/** Focus 구독 일일 Claim 기여 POP — 정책 원장 **B-04** 확정 40. */
focusDailyPop: number;
/** 광고 보상 일일 상한 횟수 — E-AD-02 판정 기준. */
adDailyLimit: number;
@@ -111,14 +111,14 @@ export interface PolicyConfig {
* HUD 「LIVE」 = 최근 이 분(分) 안에 앱을 쓴 팬덤 멤버 수 — 해석 확인 요청 **Q-GM-03** A안. 🟡 운영값 5.
*/
liveWindowMinutes: number;
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
/** 추천 보상 — 정책 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
referralReferrerPop: number;
referralReferredPop: number;
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
referralAttachDays: number;
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
timeAttackMaxMinutes: number;
/** Music Shake 판정 · 성공 · 보상 운영값(원장 B-24). 아래 [MusicShakePolicy] 참고. */
/** Music Shake 판정 · 성공 · 보상 운영값(정책 원장 B-24). 아래 [MusicShakePolicy] 참고. */
musicShake: MusicShakePolicy;
}
@@ -128,7 +128,7 @@ export type ShakeGrade = 'S' | 'A' | 'B' | 'C' | 'F';
/**
* Music Shake 운영값 — `config/policy.musicShake`.
*
* **🟡 임시값 — 회신 대기. 근거 없음.** 원장 B-24 가 확정한 것은 「BPM 기반 Shake 판정이 성공 /
* **🟡 임시값 — 회신 대기. 근거 없음.** 정책 원장 B-24 가 확정한 것은 「BPM 기반 Shake 판정이 성공 /
* 보상 기준 · 완청 보상 아님 · 보상액은 운영값」뿐이다. 판정 창 · 성공 기준 · 등급(S/A/B/C) ·
* 등급 배수는 PRD-04 · 상세정의 v1.6.1 §20 · 별첨 B 어디에도 수치가 없어 **저희가 임시로 둔
* 값**이며, 해석 확인 요청 「2026-10-07 Music Shake」(Q-MS-04 · Q-MS-05) 회신으로 바꾼다.
@@ -3,7 +3,7 @@ import { dayKey } from '../core/time.js';
/**
* 2026-09-16 EC02·EC05: 누락된 집계를 0으로 채우지 않는다.
* 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
* 정책 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
* C-21 집계 기간은 운영값(기본 7일).
*/
export async function eclipseTarget(
@@ -102,7 +102,7 @@ const FINAL_STAGE = 6;
export interface Promotion { fandomId: string; fromStage: number; toStage: number }
/**
* 정식 행성 Level · Stage 판정 — 수치표 v1.4(`planet_level.ts`) · 원장 **B-11 · B-53 · B-54**.
* 정식 행성 Level · Stage 판정 — 수치표 v1.4(`planet_level.ts`) · 정책 원장 **B-11 · B-53 · B-54**.
*
* - 레벨업 POP 진행도(`levelPop`) = 생성 기준점 이후 Growth POP 증가분 × ZAP 참여도 가중치(`zapWeight`,
* 일일 마감이 정함). 가중치는 진행도에만 쓰고 `growthPop`(원장 누계)은 그대로 둔다.
@@ -156,7 +156,7 @@ export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]>
if (!result) continue;
const { name, ...promotion } = result;
promoted.push(promotion);
// 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
// 정책 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
await sendFandomPush({
fandomId: ref.id,
category: 'fandomEvent',
+13 -13
View File
@@ -156,7 +156,7 @@ export async function defendEclipse(input: {
const room = Math.max(0, settings.perUserMaxDefense - myTotal);
const accepted = Math.min(shakes, settings.perCallMaxShakes, room);
const clamped = shakes - accepted;
// 원장 C-25: 서버가 인정한 인정 횟수(기본 60회, 운영값)에 처음 도달할 때 회차당 한 명만 센다.
// 정책 원장 C-25: 서버가 인정한 인정 횟수(기본 60회, 운영값)에 처음 도달할 때 회차당 한 명만 센다.
// 회차 문서에 고정한 값을 쓴다 — 진행 중 운영값 변경이 같은 회차의 기준을 바꾸지 않는다.
const need = (eclipse.get('qualifyShakes') as number | undefined) ?? settings.qualifyShakes;
const qualified = myTotal < need && myTotal + accepted >= need;
@@ -204,7 +204,7 @@ export async function defendEclipse(input: {
});
if (result.accepted === 0) return result;
// **성공 판정을 1 분 집계에만 맡기지 않는다**(검수 D2). 목표에 닿은 순간 세워야
// **성공 판정을 1 분 집계에만 맡기지 않는다**. 목표에 닿은 순간 세워야
// 그 뒤의 참여가 헛돌지 않고, 화면이 성공을 곧 본다.
await markDefendedIfReached(eclipseRef);
return result;
@@ -300,22 +300,22 @@ export interface EclipseSettings {
/** 이 팬덤에 이클립스를 발동하는가. Admin 「활성화」. */
enabled: boolean;
/**
* 공통 발생 일정 — 원장 **C-24** · 설계서 v1.2 `config/eclipse.schedule[]`.
* 공통 발생 일정 — 정책 원장 **C-24** · 설계서 v1.2 `config/eclipse.schedule[]`.
* 배열 길이가 곧 하루 회차 수다(**하루 2회는 고정 제한이 아니다**). 팬덤 문서의
* `eclipseSchedule[]` 이 있으면 그것이 우선한다.
*/
schedule: EclipseSlot[];
/** 1회 발동당 FPI 침식률(%). Admin 「FPI 감소 수치」 · 수치표 v1.4 「침식 0.25% FPI」. */
erosionPercent: number;
/** 사전 알림 시간(초). 원장 C-22 「발생 3분 전 푸시」 = 180. `warnAt` 에 발송한다. */
/** 사전 알림 시간(초). 정책 원장 C-22 「발생 3분 전 푸시」 = 180. `warnAt` 에 발송한다. */
warnSeconds: number;
/** 평균 DAU 대비 고유 유효 참여자 목표율. 원장 C-25 기본 5%. */
/** 평균 DAU 대비 고유 유효 참여자 목표율. 정책 원장 C-25 기본 5%. */
targetRate: number;
/** 목표 분모의 DAU 평균 기간(일). 원장 C-21 기본 7일 · 운영값. */
/** 목표 분모의 DAU 평균 기간(일). 정책 원장 C-21 기본 7일 · 운영값. */
dauWindowDays: number;
/** 공통 방어 창(초). 원장 C-22 기본 60초 · 운영값. */
/** 공통 방어 창(초). 정책 원장 C-22 기본 60초 · 운영값. */
windowSec: number;
/** 고유 참여자 1명으로 인정하는 개인 유효 Shake 누적. 원장 C-25 기본 60회 · 운영값. */
/** 고유 참여자 1명으로 인정하는 개인 유효 Shake 누적. 정책 원장 C-25 기본 60회 · 운영값. */
qualifyShakes: number;
/** 한 호출이 실을 수 있는 최대 흔들기 수. 서버 상한이며 단말 값을 믿지 않는다. */
perCallMaxShakes: number;
@@ -391,7 +391,7 @@ function kstMoment(day: string, slot: EclipseSlot | undefined): Date | null {
/**
* `fn-eclipse-trigger` — 이클립스 발동 · 사전 알림 · 종료.
*
* 원장 **C-24** — *기본 KST 07:50 · 17:50, 팬덤별 발생 횟수 · 각 시각 별도 설정, 미설정 시
* 정책 원장 **C-24** — *기본 KST 07:50 · 17:50, 팬덤별 발생 횟수 · 각 시각 별도 설정, 미설정 시
* 공통값. 하루 2회는 고정 제한이 아님. 일정 변경 시 푸시 · 종료 시각 함께 계산*.
*
* **중복 발동을 조건문으로 막지 않는다.** 문서 ID 를 `{KST 날짜}-{회차}` 로 고정하고
@@ -400,7 +400,7 @@ function kstMoment(day: string, slot: EclipseSlot | undefined): Date | null {
*
* **사전 알림 시각부터 문서를 만들고 `status: 'scheduled'` 로 둔다.** 발동 시각에야
* 문서를 만들면 `warnAt` 이 이미 지난 시각이라 사전 알림을 보낼 수 없다. 푸시
* 예고(원장 C-22 「발생 3분 전 푸시」)는 이 함수가 `warnAt` 에 바로 보내고, 앱의 경보
* 예고(정책 원장 C-22 「발생 3분 전 푸시」)는 이 함수가 `warnAt` 에 바로 보내고, 앱의 경보
* 배너는 `active` 만 구독하므로 시작 전에 배너가 뜨지는 않는다.
*
* 시작 시 FPI 스냅샷만 남기고, 마감 후 실패 확정 때에만 그 기준으로 차감한다.
@@ -602,7 +602,7 @@ async function closeExpiredEclipses(
occurredAt, createdAt: FieldValue.serverTimestamp(),
});
tx.update(fandomRef, {
// FPI 는 0 아래로 내려가지 않는다(리뷰 M2). 감소량 기록(penalty)은 그대로 남긴다.
// FPI 는 0 아래로 내려가지 않는다. 감소량 기록(penalty)은 그대로 남긴다.
fpiScore: Math.max(0, (fandom.get('fpiScore') as number) - penalty),
eclipsePenaltyRevision: FieldValue.increment(1),
});
@@ -938,7 +938,7 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true });
});
}
// 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
// 정책 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
const promoted = await promoteStages();
return { day, fandoms: fandoms.size, promoted: promoted.length };
}
@@ -1134,7 +1134,7 @@ export const fnCampaignFund = onCall(async (req) => fundCampaign({
/**
* 후원 한 건의 코어. 테스트가 HTTP 왕복 없이 부르도록 떼어 둔다.
*
* - **기여 POP 만 쓴다**(원장 B-33). 클라이언트가 보낸 `popType` 을 따르지 않는다.
* - **기여 POP 만 쓴다**(정책 원장 B-33). 클라이언트가 보낸 `popType` 을 따르지 않는다.
* - 목표는 규칙·설계서의 스키마 이름 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만
* 읽는다 — 예전에는 `goalPop` 만 읽어 목표 초과 차단(E-CMP-01)이 한 번도 걸리지 않았다.
* - 성장 귀속 팬덤은 **캠페인 문서의 `fandomId`** 다. 클라이언트가 보낸 값을 믿지 않는다.
@@ -25,7 +25,7 @@ interface DropConfig {
* 총량 하나에 운영값(1인 상한 % · 시간)을 붙여 Drop 규격을 만든다.
*
* [totalPop] 을 받으면 운영 기본 총량 대신 그 값을 쓴다 — Hero Gift 구매는 상품별
* Admin 확정값이 총량이다(원장 B-84 · B-85).
* Admin 확정값이 총량이다(정책 원장 B-84 · B-85).
*/
export async function dropConfig(totalPop?: number): Promise<DropConfig> {
const snap = await getFirestore().doc('config/policy').get();
+1 -1
View File
@@ -522,7 +522,7 @@ async function spendParts(
if (growthSpend) {
// FPI 는 기여 **행위 단위**다(C-34). 금액이 아니라 Zap 횟수(`fn-pop-spend` 가 서버에서
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18).
writeFpiEvent(tx, {
uid: input.uid, fandomId: fandomId ?? null, axis: 'contribution',
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
+3 -3
View File
@@ -47,7 +47,7 @@ export const fnPopGrant = onCall(async (req) => {
* 사용자가 직접 부를 수 있는 차감 사유 — **허용 목록**.
*
* 다른 사유는 각자의 검증 함수를 지나야 한다. 예전에는 사유를 그대로 받아 `CAMPAIGN_FUND`
* 를 캠페인 검증 없이 쓰거나(B-33 · B-34 우회) 원장 사유를 위조할 수 있었다(리뷰 H1 · R03).
* 를 캠페인 검증 없이 쓰거나(B-33 · B-34 우회) 원장 사유를 위조할 수 있었다.
*/
const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
@@ -55,9 +55,9 @@ const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
* `fn-pop-spend` — 본인 계정에서만 차감한다. 지금 열린 사유는 Zap 하나다.
*
* **Zap 횟수는 서버가 센다** — `금액 ÷ zapPop`(운영값). 클라이언트 `count` 를 믿으면
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤은 Zap 한 행성
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다. 귀속 팬덤은 Zap 한 행성
* `fandomId` 다(상세정의 5장 · 10/3 #10). 원장이 같은 트랜잭션에서 가입 여부를 확인하므로
* 가입하지 않은 행성은 거부된다(리뷰 M1 — 남의 행성 성장을 올리는 길을 막는다).
* 가입하지 않은 행성은 거부된다(남의 행성 성장을 올리는 길을 막는다).
*/
export const fnPopSpend = onCall(async (req) => {
const uid = callerUid(req);
+1 -1
View File
@@ -126,7 +126,7 @@ export async function sendUserPush(
}
/**
* 이클립스 사전 알림 — 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
* 이클립스 사전 알림 — 정책 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
*
* `fn-eclipse-trigger` 가 회차의 `warnAt`(시작 − `warnSeconds`) 이 지난 첫 점검에서
* 부른다. 활성화 뒤에 보내면 예고가 아니라 사후 통지가 된다.
+3 -3
View File
@@ -48,7 +48,7 @@ function platformOf(data: unknown): Platform {
const iapKey = (claim: ReceiptClaim) =>
`iap:${claim.platform}:${claim.transactionId}`;
/** POP 묶음 상품 ID 접두 — 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */
/** POP 묶음 상품 ID 접두 — 정책 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */
const POP_PACK_PREFIX = 'pop.support.';
const PAY_METHOD: Readonly<Record<Platform, string>> = {
@@ -59,7 +59,7 @@ const PAY_METHOD: Readonly<Record<Platform, string>> = {
interface HeroGiftProduct { name: string; buyerPop: number; dropPop: number }
/**
* Hero Gift 상품 Catalog — 원장 B-85. 문서 ID 가 스토어 상품 ID 다.
* Hero Gift 상품 Catalog — 정책 원장 B-85. 문서 ID 가 스토어 상품 ID 다.
*
* Admin `관리 마스터_아이템 관리` 가 `fnAdminWrite` 로 쓴다. 상품을 늘려도 앱을
* 다시 배포하지 않는다(B-40 검증 기준). Catalog 에 없으면 null — 일반 상품 표로 넘어간다.
@@ -408,7 +408,7 @@ export const fnSubscriptionWebhook = onCall(async (req) => {
});
/**
* `fn-focus-daily-claim` — Focus 구독 혜택, 원장 **B-04**.
* `fn-focus-daily-claim` — Focus 구독 혜택, 정책 원장 **B-04**.
*
* *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음.* 하루는 서버 일 단위(KST
* `dayKey`, 퀘스트 한도와 같은 경계)다. 멱등키가 `focus:{uid}:{day}` 라 같은 날 두 번 지급되지
@@ -9,7 +9,7 @@ import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { callerUid, str } from '../core/callable.js';
/**
* `fn-link-preview` — 링크 기반 Quest 의 원본 제목 · 썸네일 · 서비스명(원장 **C-05** · R10).
* `fn-link-preview` — 링크 기반 Quest 의 원본 제목 · 썸네일 · 서비스명(정책 원장 **C-05** · R10).
*
* 서버가 사용자가 준 URL 을 대신 여는 경로라 **SSRF 방어가 이 파일의 본체**다.
* - http / https 만, 계정 정보(`user:pass@`) · 기본 외 포트 거부
+6 -6
View File
@@ -56,7 +56,7 @@ export async function dailyLimitOf(questId: string): Promise<number> {
}
/**
* 참여할 퀘스트 문서를 확인한다 — 리뷰 H2 · B2.
* 참여할 퀘스트 문서를 확인한다.
*
* **문서가 없거나 유형이 다르면 거부한다.** 예전에는 `questId` 가 진행 문서 ID 로만 쓰여
* 있지도 않은 ID 를 바꿔 가며 부르면 일일 한도 · 1회 제한이 매번 새로 열렸다.
@@ -140,7 +140,7 @@ function count(data: Record<string, unknown>, key: string, max: number): number
}
/**
* `fn-quest-progress` — Quest 진행 상태(진행중 · 일시 정지 · 진행완료)를 진행 문서에 남긴다(원장 C-83).
* `fn-quest-progress` — Quest 진행 상태(진행중 · 일시 정지 · 진행완료)를 진행 문서에 남긴다(정책 원장 C-83).
*
* 화면을 다시 열거나 앱을 다시 켜도 「진행중」 탭 · 「이어하기」 · 목록 「보상받기」가 유지되게 하는
* **표시용 상태**다. 지급 판정은 여기서 하지 않는다 — 보상은 그대로 검증 함수가 증거로 판정한다.
@@ -271,7 +271,7 @@ export const fnQuestAttend = onCall(async (req) => {
});
/**
* `fn-quest-stream-participate` — Streaming · YouTube 시청 Quest **무보상 기본 참여**(원장 C-04 · B-25).
* `fn-quest-stream-participate` — Streaming · YouTube 시청 Quest **무보상 기본 참여**(정책 원장 C-04 · B-25).
*
* POP 원장을 쓰지 않는다(검증 기준 「POP 지급 원장 0건」). 참여 기록(`questProgress`)과 FPI 원천
* 이벤트만 같은 트랜잭션에 남긴다 — 보상이 없는 유효 활동도 FPI 에 들어간다(C-34).
@@ -320,7 +320,7 @@ export const fnQuestVerifySteps = onCall(async (req) => {
const at = occurredAt(req.data, now);
const key = dayKey(at);
// 퀘스트 문서가 있어야 하고 걸음 유형이어야 한다. 없는 ID 로 바꿔 부르면 진행 문서 · 멱등키가
// 새로 열려 구간 보상이 다시 나간다(리뷰 재검토 blocker). 대표 팬덤 자격(C-16)도 본다.
// 새로 열려 구간 보상이 다시 나간다. 대표 팬덤 자격(C-16)도 본다.
const questId = str(req.data, 'questId');
const quest = await questOf(uid, questId, ['walk', 'steps', 'health']);
const steps = positiveInt(num(req.data, 'steps'), 'steps');
@@ -414,7 +414,7 @@ export const fnQuestVerifySteps = onCall(async (req) => {
});
/**
* 광고 증분 — 원장 B-03 · C-02(B-28).
* 광고 증분 — 정책 원장 B-03 · C-02(B-28).
*
* - 사용자 생성 Quest(`authorUid` 있음): 고정 [PolicyConfig.adRewardPop] 기여 POP.
* - 운영 Quest: 기본 보상 × (배수 − 1) 기여 POP. 배수는 Quest 지정값(`adMultiplier`,
@@ -449,7 +449,7 @@ function questIdOfKey(key: unknown): string | null {
* 앱은 방금 받은 **기본 보상의 원장 txId**(`baseTxId`)만 보낸다. 서버는 그 원장 행이 본인 · 오늘 ·
* 퀘스트 보상인지 확인하고, **AdMob 서버측 검증(SSV) 기록**(`adSsv`, [fnAdSsv])이 그 기본 보상에
* 붙어 있을 때만 증분을 기여 POP 으로 지급한다. 앱이 보낸 노출 ID 는 근거로 쓰지 않는다 — 함수만
* 불러서는 기여 POP 이 나가지 않는다(원장 B-03 「광고 검증 실패 시 증분 미지급 · 횟수 미차감」).
* 불러서는 기여 POP 이 나가지 않는다(정책 원장 B-03 「광고 검증 실패 시 증분 미지급 · 횟수 미차감」).
* 기본 보상 한 건에 광고 보상은 한 번이다(`users/{uid}/adBonuses/{baseTxId}`).
*
* - SSV 콜백이 아직 오지 않았으면 E-AD-03(횟수 미차감). 앱은 잠시 뒤 다시 부른다.
+4 -4
View File
@@ -52,7 +52,7 @@ export interface ShakeJudgement {
export type { ShakeGrade };
/**
* 판정표 → 등급. F 면 실패(보상 0 — 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며
* 판정표 → 등급. F 면 실패(보상 0 — 정책 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며
* 🟡 임시값 — 회신 대기다(근거 없음 · 해석 확인 요청 2026-10-07 Q-MS-05).
*/
export function gradeShake(
@@ -93,7 +93,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
const now = new Date();
const data = (req.data ?? {}) as Record<string, unknown>;
// 곡과 하루 한도는 **퀘스트 문서**가 정한다. 예전 멱등키는 `trackId · 날짜` 라 곡 ID 만
// 바꿔 부르면 하루에 무제한 지급됐다(리뷰 재검토 blocker). 곡은 퀘스트의 카탈로그와 대조하고,
// 바꿔 부르면 하루에 무제한 지급됐다. 곡은 퀘스트의 카탈로그와 대조하고,
// 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게.
const questId = str(req.data, 'questId');
const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']);
@@ -160,7 +160,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
fail(ERR.questIncomplete, '박자 판정이 성공 기준에 미치지 못했습니다.');
}
// 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
// 정책 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
// `config/policy.musicShake.pop` 으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
// 등급 배수는 🟡 임시값 — 회신 대기(Q-MS-05).
const base = quest.get('rewardPop');
@@ -224,7 +224,7 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
const lng = num(req.data, 'lng');
// 참여 횟수는 Quest 단위다(C-12 — 같은 장소의 다른 Quest 는 별개). 그래서 퀘스트 문서가
// 반드시 있어야 하고 **그 퀘스트의 장소**여야 한다 — 없는 ID 로 바꿔 부르면 1회 제한이
// 매번 새로 열린다(리뷰 B2). 대표 팬덤 자격(C-16)도 여기서 본다.
// 매번 새로 열린다. 대표 팬덤 자격(C-16)도 여기서 본다.
const questId = str(req.data, 'questId');
const questDoc = await questOf(uid, questId, ['place', 'location']);
if (questDoc.get('authorUid') === uid) fail(ERR.permissionDenied, '내가 만든 퀘스트에는 참여할 수 없습니다.');