import { HttpsError, onCall } from 'firebase-functions/v2/https'; import { onSchedule } from 'firebase-functions/v2/scheduler'; import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions'; import { callerUid, str } from '../core/callable.js'; import { loadPolicy } from '../core/config.js'; import { ERR, fail } from '../core/errors.js'; import { deviceHashOf, txIdOf } from '../core/ids.js'; import { dayKey } from '../core/time.js'; import { publicNameOf } from '../core/names.js'; import { grantPop, settlePendingGrant } from '../ledger/ledger.js'; import { fandomOfUser } from '../ledger/fpi.js'; /** * 참여형 번역 — 별첨 1 ⑧ · 요구사항 FR-I18N-02 · 정책 원장 B-63. * * 등록(`fn-translation-submit`) → 다른 멤버 마킹(`fn-translation-mark`) → 24시간 뒤 * 정산(`fn-translation-settle`). 보상은 등록 때 **보류 지급**으로 잡아 두고(예약 잔액) * 정산이 확정하거나 해제한다 — 24시간 전 잔액 미포함(B-63 검증 기준). */ /** 1차 공식 지원 언어 — 정책 원장 B-76. 앱 `appLocales` 와 같은 태그다. */ export const TRANSLATION_LOCALES = ['ko', 'en', 'ja', 'zh-CN', 'es'] as const; export const TRANSLATION_MAX_LENGTH = 2000; /** 하루 등록 자체를 막는 선 = 보상 일일상한 × 이 배수(도배 차단). */ export const TRANSLATION_HARD_LIMIT_FACTOR = 3; /** * 번역을 붙일 수 있는 콘텐츠 — 인사이드 질문·답변 · 라운지 글·댓글 · 캠페인(응원 게시판) · 공지. * 그 밖의 경로(사용자 문서 등)는 존재 여부를 드러내지 않고 E-TRN-03 으로 끊는다. */ const TARGET_PATTERNS = [ /^questions\/[^/]+$/, /^questions\/[^/]+\/answers\/[^/]+$/, /^posts\/[^/]+$/, /^posts\/[^/]+\/comments\/[^/]+$/, /^campaigns\/[^/]+$/, /^campaigns\/[^/]+\/comments\/[^/]+$/, /^notices\/[^/]+$/, ]; export interface TranslationInput { targetPath: string; locale: string; body: string; } const invalidTarget = (): never => fail(ERR.trnInvalidTarget, '번역 대상을 찾을 수 없습니다.'); export function parseTranslationInput(data: unknown): TranslationInput { const targetPath = str(data, 'targetPath'); const locale = str(data, 'locale'); const body = str(data, 'body').trim(); if (!TARGET_PATTERNS.some((p) => p.test(targetPath)) || targetPath.split('/').some((s) => s === '.' || s === '..')) { invalidTarget(); } if (!(TRANSLATION_LOCALES as readonly string[]).includes(locale)) { throw new HttpsError('invalid-argument', '지원하지 않는 언어입니다.'); } if (body.length === 0 || body.length > TRANSLATION_MAX_LENGTH) { throw new HttpsError('invalid-argument', `번역은 1~${TRANSLATION_MAX_LENGTH}자입니다.`); } return { targetPath, locale, body }; } /** * 등록 시점 판정 — 정책 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」. * 상한의 [TRANSLATION_HARD_LIMIT_FACTOR] 배를 넘으면 등록 자체를 거부(E-TRN-02), * 상한까지는 보상, 그 사이는 등록만 받고 보상 0. */ export function translationRewardOf(input: { pop: number; dailyLimit: number; todayCount: number; duplicate: boolean; }): { rewardPop: number; excludedReason: 'duplicate' | 'daily_limit' | null } { if (input.todayCount >= input.dailyLimit * TRANSLATION_HARD_LIMIT_FACTOR) { fail(ERR.trnDailyCap, '오늘 등록할 수 있는 번역 수를 넘었습니다.'); } if (input.duplicate) return { rewardPop: 0, excludedReason: 'duplicate' }; if (input.todayCount >= input.dailyLimit) return { rewardPop: 0, excludedReason: 'daily_limit' }; return { rewardPop: input.pop, excludedReason: null }; } /** 정산 판정 — 확정 · 해제 · 대기. 작성자 마킹은 이미 빠진 수를 받는다. */ export function translationSettleOf(input: { valid: number; invalid: number; ageHours: number; minValid: number; maxPendingHours: number; }): 'confirm' | 'release' | 'wait' { if (input.valid >= input.minValid && input.valid > input.invalid) return 'confirm'; return input.ageHours >= input.maxPendingHours ? 'release' : 'wait'; } /** 번역을 받는 캠페인 상태 — 공개된 캠페인만(검수 · 철회 · 취소 · 반려는 제외). */ const VISIBLE_CAMPAIGN_STATUSES = ['funding', 'success', 'fail', 'report']; /** * 다른 사용자에게 보이는 콘텐츠인가. 캠페인은 [VISIBLE_CAMPAIGN_STATUSES], 나머지(글 · 댓글 · * 질문 · 답변 · 공지)는 `visibility` 가 `visible` 이어야 한다 — 필드가 없는 질문 · 공지는 공개다. * 없는 콘텐츠와 숨긴 콘텐츠는 호출자가 같은 E-TRN-03 으로 끊는다(존재 여부를 드러내지 않게). */ export function isVisibleTarget(targetPath: string, data: FirebaseFirestore.DocumentData | undefined): boolean { if (!data) return false; // 캠페인 문서는 상태로, 응원 게시판 댓글(`campaigns/{id}/comments/{cid}`)은 아래 visibility 로 본다. if (/^campaigns\/[^/]+$/.test(targetPath)) return VISIBLE_CAMPAIGN_STATUSES.includes(String(data.status)); return (data.visibility ?? 'visible') === 'visible'; } /** 콘텐츠의 팬덤. 답변·댓글은 부모 글에서 읽는다. 공지처럼 팬덤이 없으면 null. */ async function fandomOfTarget(targetPath: string, target: FirebaseFirestore.DocumentSnapshot) { const own = target.get('fandomId'); if (typeof own === 'string' && own.length > 0) return own; const segments = targetPath.split('/'); if (segments.length <= 2) return null; const root = await getFirestore().doc(segments.slice(0, 2).join('/')).get(); const parent = root.get('fandomId'); return typeof parent === 'string' && parent.length > 0 ? parent : null; } /** `fn-translation-submit` — 번역 등록. 클라이언트 직접 쓰기는 규칙이 막는다. */ export const fnTranslationSubmit = onCall(async (req) => { const uid = callerUid(req); const input = parseTranslationInput(req.data); const db = getFirestore(); const target = await db.doc(input.targetPath).get(); const rootPath = input.targetPath.split('/').slice(0, 2).join('/'); // 댓글 · 답변은 달린 글도 보여야 한다. const root = rootPath === input.targetPath ? target : await db.doc(rootPath).get(); if (!isVisibleTarget(input.targetPath, target.data()) || !isVisibleTarget(rootPath, root.data())) { invalidTarget(); } const fandomId = await fandomOfTarget(input.targetPath, target); const author = await db.doc(`users/${uid}`).get(); const policy = await loadPolicy(); const today = dayKey(new Date()); const entryRef = db.doc(`translationEntries/${txIdOf(`trn:${uid}:${input.targetPath}:${input.locale}`)}`); const counterRef = db.doc(`translationDaily/${uid}_${today}`); // 본문은 색인 한도(1,500바이트)를 넘을 수 있어 같은 문장 판정은 해시로 한다. const bodyHash = deviceHashOf(input.body); const result = await db.runTransaction(async (tx) => { const [existing, counter, same] = await Promise.all([ tx.get(entryRef), tx.get(counterRef), tx.get(db.collection('translationEntries').where('targetPath', '==', input.targetPath) .where('locale', '==', input.locale).where('bodyHash', '==', bodyHash).limit(1)), ]); if (existing.exists) fail(ERR.trnDuplicate, '이미 이 콘텐츠에 같은 언어로 번역을 등록했습니다.'); const reward = translationRewardOf({ pop: policy.translationPop, dailyLimit: policy.translationDailyLimit, todayCount: (counter.get('count') as number | undefined) ?? 0, duplicate: !same.empty, }); // 보상분을 예약 잔액으로 잡는다(읽기가 끝난 뒤 · 쓰기 전에 — 트랜잭션 순서 규칙). if (reward.rewardPop > 0) { await grantPop({ uid, popType: 'activity', amount: reward.rewardPop, reasonCode: 'TRANSLATION', idempotencyKey: `trn:${entryRef.id}`, refPath: entryRef.path, pending: true, }, tx); } tx.create(entryRef, { authorUid: uid, targetPath: input.targetPath, // 글 · 질문 · 캠페인 경로. 상세 화면이 본문과 댓글의 번역 댓글을 한 번에 읽는다. rootPath, authorName: publicNameOf(author), fandomId, locale: input.locale, body: input.body, bodyHash, status: 'pending', // 마킹 수 — fn-translation-mark 가 같은 트랜잭션에서 올린다. 앱은 이 값만 읽는다. validCount: 0, invalidCount: 0, ...reward, dayKey: today, createdAt: FieldValue.serverTimestamp(), }); tx.set(counterRef, { uid, dayKey: today, count: FieldValue.increment(1), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); return reward; }); return { entryId: entryRef.id, ...result }; }); /** * 마킹 자격 — 가입 24시간 이상 · 번역 대상 팬덤의 멤버(팬덤 없는 공지는 로그인만). * INSIDE 질문·답변은 운영값 `translationInsideAnyMember`(🟡 Q-TR-04 권고 B안 · 기본 true) * 가 켜져 있으면 팬덤 조건 없이 가입 24시간 경과 회원 전체가 마킹한다. */ const ACCOUNT_MIN_AGE_MS = 24 * 60 * 60 * 1000; /** * `fn-translation-mark` — 유효/무효 마킹. 본인 번역은 E-TRN-01, 자격 미달은 E-TRN-04, * 두 번째 마킹은 E-TRN-05. 번복은 없다. */ export const fnTranslationMark = onCall(async (req) => { const uid = callerUid(req); const entryId = str(req.data, 'entryId'); const valid = (req.data as Record | null)?.valid; if (typeof valid !== 'boolean') throw new HttpsError('invalid-argument', 'valid 이(가) 필요합니다.'); const db = getFirestore(); const entryRef = db.doc(`translationEntries/${entryId}`); const entry = await entryRef.get(); if (!entry.exists || entry.get('status') !== 'pending') invalidTarget(); if (entry.get('authorUid') === uid) fail(ERR.trnSelfMark, '본인 번역은 마킹할 수 없습니다.'); const user = await db.doc(`users/${uid}`).get(); const created = user.get('createdAt') as Timestamp | undefined; if (!created || Date.now() - created.toMillis() < ACCOUNT_MIN_AGE_MS) { fail(ERR.trnNotEligible, '가입 후 24시간이 지나야 마킹할 수 있습니다.'); } const targetPath = String(entry.get('targetPath')); // 등록 뒤 가려진 콘텐츠(숨김 · 캠페인 철회 등)의 번역은 더 마킹하지 않는다 — 등록과 같은 E-TRN-03. const rootPath = targetPath.split('/').slice(0, 2).join('/'); const [target, root] = await Promise.all([db.doc(targetPath).get(), db.doc(rootPath).get()]); if (!isVisibleTarget(targetPath, target.data()) || !isVisibleTarget(rootPath, root.data())) invalidTarget(); const fandomId = entry.get('fandomId') as string | null | undefined; const insideOpen = targetPath.startsWith('questions/') && (await loadPolicy()).translationInsideAnyMember; // 캠페인은 팬덤 멤버만 마킹한다 — 팬덤이 없는 캠페인은 마킹할 수 있는 사람이 없다. if (!fandomId && targetPath.startsWith('campaigns/')) { fail(ERR.trnNotEligible, '이 팬덤의 멤버만 마킹할 수 있습니다.'); } if (fandomId && !insideOpen) { const member = await db.doc(`fandoms/${fandomId}/members/${uid}`).get(); if (!member.exists && fandomOfUser(user) !== fandomId) { fail(ERR.trnNotEligible, '이 팬덤의 멤버만 마킹할 수 있습니다.'); } } // 마킹과 수 집계는 함께 커밋한다 — 앱은 번역 문서의 수만 읽는다. const markRef = entryRef.collection('markings').doc(uid); await db.runTransaction(async (tx) => { const [fresh, mine] = await Promise.all([tx.get(entryRef), tx.get(markRef)]); if (fresh.get('status') !== 'pending') invalidTarget(); if (mine.exists) fail(ERR.trnDuplicate, '이미 마킹한 번역입니다.'); tx.create(markRef, { valid, createdAt: FieldValue.serverTimestamp() }); tx.update(entryRef, { [valid ? 'validCount' : 'invalidCount']: FieldValue.increment(1) }); }); return { entryId, valid }; }); /** * `fn-translation-settle` — 24시간 지난 번역 정산. * * 유효 마킹이 운영값 이상이고 무효보다 많으면 확정(예약 → 사용 가능). 못 채우면 기다리고, * 최대 대기 시간을 넘기면 예약을 해제하고 닫는다. 자기 마킹은 세지 않는다(별첨 1 ⑧). */ export const fnTranslationSettle = onSchedule('every 1 hours', async () => { const db = getFirestore(); const policy = await loadPolicy(); const now = Date.now(); const due = await db .collection('translationEntries') .where('status', '==', 'pending') .where('createdAt', '<=', Timestamp.fromMillis(now - 24 * 60 * 60 * 1000)) .get(); const count = { confirmed: 0, excluded: 0, waiting: 0 }; for (const entry of due.docs) { const authorUid = entry.get('authorUid') as string | undefined; if (!authorUid) continue; const reward = (entry.get('rewardPop') as number | undefined) ?? 0; try { if (reward <= 0) { // 등록 때 보상에서 제외된 건(중복·일일상한). 예약이 없으니 닫기만 한다. await entry.ref.set({ status: 'excluded', settledAt: FieldValue.serverTimestamp() }, { merge: true }); count.excluded += 1; continue; } const markings = await entry.ref.collection('markings').get(); const others = markings.docs.filter((m) => m.id !== authorUid); const decision = translationSettleOf({ valid: others.filter((m) => m.get('valid') === true).length, invalid: others.filter((m) => m.get('valid') === false).length, ageHours: (now - (entry.get('createdAt') as Timestamp).toMillis()) / 3_600_000, minValid: policy.translationMinValidMarks, maxPendingHours: policy.translationMaxPendingHours, }); if (decision === 'wait') { count.waiting += 1; continue; } await settlePendingGrant({ uid: authorUid, idempotencyKey: `trn:${entry.id}`, outcome: decision, }); await entry.ref.set({ status: decision === 'confirm' ? 'confirmed' : 'excluded', ...(decision === 'release' ? { excludedReason: 'unverified' } : {}), settledAt: FieldValue.serverTimestamp(), }, { merge: true }); count[decision === 'confirm' ? 'confirmed' : 'excluded'] += 1; } catch (error) { logger.error('[translation] 정산 실패', { entryId: entry.id, error: String(error) }); } } logger.info('[translation] 정산', { scanned: due.size, ...count }); });