Files
fanuzu/app/firebase/functions/src/account/translation.ts
T

307 lines
14 KiB
TypeScript

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<string, unknown> | 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 });
});