Files

86 lines
7.0 KiB
JavaScript

'use strict';
// 사용자별 캐시(크레딧, 원 단위) 보관소. data/credits.json
// - users[userId] = { balance, updatedAt }
// - ledger[] = { id, at, userId, type: set|add|adjust|usage|refund|migrate, amount, balanceAfter, memo, by, ref }
// 관리자 콘솔(수동 설정/추가)과 외부 서비스(서비스 키 API)가 같은 함수로 값을 조정한다.
// 잔액이 바뀌면 onChange 리스너에 알려 캐시 대기 중인 작업을 이어갈 수 있게 한다.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'credits.json');
const TYPES = ['set', 'add', 'adjust', 'usage', 'refund', 'migrate'];
const listeners = new Set();
// v1.6.31 (요구 10·11): 파일이 깨졌으면 빈 원장으로 "정상처럼" 계속하지 않는다 — strict 로 예외(관리자 콘솔 운영 복구 화면에 손상 파일로 보인다)
const js = require('./jsonstore');
function load() { const d = js.readJson(FILE, null, { strict: true }); return d && typeof d === 'object' ? { users: d.users || {}, ledger: d.ledger || [] } : { users: {}, ledger: [] }; }
function save(d) { js.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true }); }
const MAX_ABS = 1e9; // 한 번에 조정할 수 있는 최대 금액(원) — 잘못된 입력으로 잔액이 비정상이 되지 않게
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
function balance(userId) { const d = load(); const u = d.users[userId]; return u ? Math.round(u.balance) : 0; }
function info(userId) {
const d = load(); const u = d.users[userId] || { balance: 0, updatedAt: null };
const mine = d.ledger.filter((e) => e.userId === userId);
const used = mine.filter((e) => e.type === 'usage').reduce((a, e) => a - e.amount, 0);
const granted = mine.filter((e) => ['set', 'add', 'adjust', 'refund', 'migrate'].includes(e.type)).reduce((a, e) => a + e.amount, 0);
return { userId, balance: Math.round(u.balance), updatedAt: u.updatedAt, used: Math.round(used), granted: Math.round(granted), entries: mine.length };
}
function ledger(userId, limit = 100) { const d = load(); return d.ledger.filter((e) => !userId || e.userId === userId).slice(-limit).reverse(); }
/**
* v1.3.17 (B20): 기간별(일·주·월) 사용 합계용 — 화면에 보이는 최근 원장 30건이 아니라 since 이후 사용 기록 전부를 {at, amount} 로 준다.
* (예전에는 최근 30건만 더해 기간 합계가 실제보다 작게 보일 수 있었다)
*/
function usageSince(userId, since) { const d = load(); const t = since ? Date.parse(since) : 0; return d.ledger.filter((e) => e.userId === userId && e.type === 'usage' && Date.parse(e.at) >= t).map((e) => ({ at: e.at, amount: e.amount })); }
function all() { const d = load(); return Object.entries(d.users).map(([userId, u]) => ({ userId, balance: Math.round(u.balance), updatedAt: u.updatedAt })); }
/** 원장에 기록하고 잔액을 갱신한다. type=set 은 amount 를 "새 잔액"으로 해석(원장에는 차액 기록). */
function apply({ userId, type, amount, memo, by, ref }) {
if (!userId || typeof userId !== 'string') throw httpError('사용자를 지정하세요');
if (!TYPES.includes(type)) throw httpError('알 수 없는 조정 종류: ' + type);
const n = Math.round(Number(amount));
if (!Number.isFinite(n)) throw httpError('금액이 올바르지 않습니다');
if (Math.abs(n) > MAX_ABS) throw httpError(`금액이 너무 큽니다 (최대 ${MAX_ABS.toLocaleString()}원)`);
if ((type === 'add' || type === 'refund') && n < 0) throw httpError('충전·환불 금액은 0보다 커야 합니다 (줄이려면 조정(adjust)을 쓰세요)');
if (type === 'set' && n < 0) throw httpError('잔액은 음수로 설정할 수 없습니다');
const d = load(); const u = d.users[userId] || { balance: 0, updatedAt: null };
// v1.6.31 (요구 11·12): 같은 작업(ref)의 사용 차감이 이미 원장에 있으면 다시 차감하지 않는다 — 재시도·서버 재시작 뒤 중복 차감 방지
if (type === 'usage' && ref && typeof ref === 'object') { const key = JSON.stringify(ref); const dup = d.ledger.find((e) => e.userId === userId && e.type === 'usage' && e.ref && JSON.stringify(e.ref) === key); if (dup) return { entry: dup, balance: Math.round(u.balance), duplicate: true }; }
const before = Math.round(u.balance);
let delta = n;
if (type === 'set') delta = n - before;
else if (type === 'usage') delta = -Math.abs(n);
if (delta === 0 && type !== 'set') throw httpError('0원은 기록할 수 없습니다');
const after = before + delta;
u.balance = after; u.updatedAt = new Date().toISOString(); d.users[userId] = u;
const e = { id: crypto.randomBytes(6).toString('hex'), at: u.updatedAt, userId, type, amount: delta, balanceAfter: after, memo: String(memo || '').slice(0, 200), by: by || null, ref: ref || null };
d.ledger.push(e); if (d.ledger.length > 50000) d.ledger.splice(0, d.ledger.length - 50000);
save(d);
if (delta > 0 || type === 'set') for (const fn of listeners) { try { fn({ userId, before, after, entry: e }); } catch (_) {} }
return { entry: e, balance: after };
}
const set = (userId, amount, by, memo) => apply({ userId, type: 'set', amount, by, memo: memo || '관리자 설정' });
const add = (userId, amount, by, memo) => apply({ userId, type: amount < 0 ? 'adjust' : 'add', amount, by, memo: memo || '관리자 추가' });
/** 작업 비용 차감 (잔액이 음수가 되어도 기록) */
function charge(userId, amountKrw, ref, memo) { const n = Math.round(Number(amountKrw) || 0); if (n <= 0) return { balance: balance(userId), entry: null }; return apply({ userId, type: 'usage', amount: n, ref, memo }); }
function onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
/** 이전 선결제 충전(billing.json) 을 초기 잔액으로 1회 이관 */
function migrateFromBilling(charges) {
if (fs.existsSync(FILE) || !charges || !charges.length) return 0;
const byUser = {}; for (const c of charges) byUser[c.userId] = (byUser[c.userId] || 0) + (c.amountKrw || 0);
let n = 0; for (const [userId, amt] of Object.entries(byUser)) if (amt) { apply({ userId, type: 'migrate', amount: amt, by: 'system', memo: '선결제 충전 내역 이관' }); n += 1; }
return n;
}
/**
* v1.4.1 캐시 금액 표시 (관리자 콘솔 › 설정 › 캐시 표시 단위). 내부 값은 원 기준 숫자 그대로이고 글자만 바뀐다.
* 한글 단위(원·환·동…)는 숫자에 붙이고, 그 밖(D, ING, Gold…)은 한 칸 띄운다: 1,234원 / 1,234 D
* @param {number} n 금액(원 기준)
*/
function unit() { try { return require('./settings').load().cashUnit || '원'; } catch (_) { return '원'; } }
function fmt(n) { const u = unit(); return `${Math.round(Number(n) || 0).toLocaleString('ko-KR')}${/^[가-힣]+$/.test(u) ? '' : ' '}${u}`; }
module.exports = { unit, fmt, usageSince, balance, info, ledger, all, apply, set, add, charge, onChange, migrateFromBilling, TYPES };