307 lines
14 KiB
TypeScript
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 });
|
|
});
|