Files

66 lines
9.0 KiB
JavaScript

/**
* portal/usage/warnings.mjs — 사용량 경고·제한 평가 엔진(§13.4·§17.1·§35).
*
* evaluate(deps, groupId, at) 가 그룹의 모든 배정(lib/quota.listAllocations)에 대해
* level = warnLevel(사용량, 배정량, 상품정책의 warn_levels) (0 | 70 | 80 | 90 | 95 | 100)
* 를 계산하고, usage_limits 표의 이전 level 과 비교해 **올라간 구간마다 한 번만** 알림을 만든다(같은 구간은 incident_key 로 묶여 갱신).
* 70 Portal 사전 안내 80 Portal + 외부 이메일 경고
* 90 예상 소진 시각과 증설·정리 방법을 강조한 긴급 경고 95 곧 제한될 기능과 즉시 필요한 조치
* 100 자원별 제한(limits.LIMIT_SEMANTICS) — 유지되는 기능·해제 방법 표시, limited=1
* 월간 자원(web_out·mail_msgs)은 계측 period_start 가 현재 KST 월보다 앞이면 사용량 0 으로 본다(월 초기화). 구간이 내려가면 상위 사건을 종료한다.
* 초과 사용 허용이 유효하면 100% 에서도 limited=0(overage=1) 로 두고 "초과 사용 과금 중" 알림을 만든다.
* 계측이 없는 대상(used 가 null)은 평가하지 않는다 — 없는 정보로 경고하지 않는다.
*/
import * as quota from '../../lib/quota.mjs';
import { notify, resolveIncident } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
import { nowIso } from '../../lib/db.mjs';
import { monthStart, nextMonthStart, fmtKst } from '../../lib/kst.mjs';
import { LIMIT_SEMANTICS } from './limits.mjs';
import { appliedProduct, activeOverage } from '../commerce/orders.mjs';
const fmtBytes = (n) => (n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(0) + ' MB' : `${n} B`);
export const fmtAmount = (n, unit) => (unit.startsWith('bytes') ? fmtBytes(n) : `${Number(n).toLocaleString('ko-KR')}`);
/** 대상 이름(사이트·메일 서비스) */
function subjectName(store, type, id) { try { if (type === 'site') return store.get('SELECT name FROM sites WHERE id=?', id)?.name || id; if (type === 'mail_service') return store.get('SELECT name FROM mail_services WHERE id=?', id)?.name || id; } catch (_) {} return id; }
function subjectLink(type, id) { return type === 'site' ? `/sites/${id}/usage` : type === 'mail_service' ? `/mail/${id}` : '/usage'; }
/** 월간 자원의 예상 소진 시각(이번 달 평균 속도 기준). 산정 불가면 null */
export function projectExhaustion(used, limit, at = new Date()) { const start = Date.parse(monthStart(at)); const elapsed = Date.parse(at) - start; if (elapsed < 3600000 || used <= 0) return null; const rate = used / elapsed; const remain = Math.max(0, limit - used); const eta = Date.parse(at) + remain / rate; return eta < Date.parse(nextMonthStart(at)) ? new Date(eta).toISOString() : null; }
/**
* 평가 실행. 반환 { evaluated, notified:[{resource, subjectId, level}], limited:[...] }
*/
export function evaluate(deps, groupId, at = nowIso()) {
const { store } = deps; const pv = appliedProduct(store, groupId); const levels = pv?.warn_levels || quota.DEFAULT_WARN_LEVELS;
const out = { evaluated: 0, notified: [], limited: [] }; const ms = monthStart(at);
for (const a of quota.listAllocations(store, groupId)) {
if (a.used == null) continue; // 계측 없음
const monthly = quota.RESOURCES[a.resource]?.unit.endsWith('_month'); const u = quota.usageOf(store, groupId, a.resource, a.subject_type, a.subject_id);
const used = monthly && u?.period_start && u.period_start < ms ? 0 : a.used; // 지난 달 계측값은 0 으로(월 초기화)
const level = quota.warnLevel(used, a.amount, levels); const prev = store.get('SELECT * FROM usage_limits WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, a.resource, a.subject_type, a.subject_id);
const ov = level >= 100 ? activeOverage(store, groupId, a.resource, at) : null; const limited = level >= 100 && !ov; out.evaluated++;
const name = subjectName(store, a.subject_type, a.subject_id); const label = quota.RESOURCES[a.resource].label; const unit = quota.RESOURCES[a.resource].unit; const link = subjectLink(a.subject_type, a.subject_id);
const keyBase = `${groupId}:${a.resource}:${a.subject_type}:${a.subject_id}`;
if (level > (prev?.level || 0)) {
// 올라간 구간: 지난 구간들의 사건은 닫고 새 구간 알림
for (const l of [...levels, 100]) if (l < level) resolveIncident(store, `${keyBase}:${l}`, `상위 구간(${level}%)으로 올라감`);
const pct = quota.percent(used, a.amount); const common = { groupId, category: 'usage', subjectType: a.subject_type, subjectId: a.subject_id, incidentKey: `${keyBase}:${level}`, link };
if (level < 80) notify(store, { ...common, severity: 'info', title: `${name} ${label} ${pct}% 사용 — 사전 안내`, body: `${fmtAmount(used, unit)} / ${fmtAmount(a.amount, unit)}. ${monthly ? `다음 초기화 ${fmtKst(nextMonthStart(at))}` : '정리하거나 배정을 늘릴 수 있습니다'}` });
else if (level < 90) notify(store, { ...common, severity: 'warning', email: true, title: `${name} ${label} ${pct}% 사용 — 경고`, body: `${fmtAmount(used, unit)} / ${fmtAmount(a.amount, unit)}. 사용량 화면에서 배정을 늘리거나 추가 옵션을 구매하세요`, actionRequired: '배정 조정 또는 정리' });
else if (level < 100) { const eta = monthly ? projectExhaustion(used, a.amount, new Date(at)) : null; notify(store, { ...common, severity: 'critical', mandatory: true, title: level >= 95 ? `${name} ${label} ${pct}% — 곧 제한됩니다(최종 경고)` : `${name} ${label} ${pct}% — 긴급 경고`, body: `${fmtAmount(used, unit)} / ${fmtAmount(a.amount, unit)}. ${eta ? `현재 속도면 ${fmtKst(eta)} 소진 예상. ` : monthly ? '예상 소진 시각은 산정할 수 없습니다. ' : ''}${level >= 95 ? `제한 시: ${(LIMIT_SEMANTICS[a.resource]?.blocked || []).join(', ')}. 지금 배정을 늘리거나 정리하세요` : '증설(옵션 구매·배정 증가) 또는 정리(파일·메일 삭제)로 소진을 막으세요'}`, actionRequired: '즉시 증설 또는 정리' }); }
else notify(store, { ...common, severity: 'critical', mandatory: true, title: ov ? `${name} ${label} 100% — 초과 사용 과금 중` : `${name} ${label} 100% — 제한 적용`, body: ov ? `초과 사용 허용(상한 ${ov.cap_amount ? ov.cap_amount.toLocaleString('ko-KR') + '원' : ov.cap_units + ' 단위'}) 안에서 계속 제공합니다` : `제한: ${(LIMIT_SEMANTICS[a.resource]?.blocked || []).join(', ')}. 유지: ${(LIMIT_SEMANTICS[a.resource]?.kept || []).join(', ')}. 해제: ${LIMIT_SEMANTICS[a.resource]?.recovery || ''}`, actionRequired: ov ? null : '추가 용량 적용 또는 정리' });
out.notified.push({ resource: a.resource, subjectId: a.subject_id, level });
if (limited) audit(store, { actorType: 'system', automatic: true, groupId, subjectType: a.subject_type, subjectId: a.subject_id, action: 'usage.limit', detail: { resource: a.resource, used, limit: a.amount } });
} else if (level < (prev?.level || 0)) {
for (const l of [...levels, 100]) if (l > level) resolveIncident(store, `${keyBase}:${l}`, `사용량이 ${quota.percent(used, a.amount)}% 로 내려감`);
if (prev?.limited) { notify(store, { groupId, category: 'usage', severity: 'info', subjectType: a.subject_type, subjectId: a.subject_id, link, title: `${name} ${label} 제한 해제`, body: `현재 ${fmtAmount(used, unit)} / ${fmtAmount(a.amount, unit)}`, incidentKey: `${keyBase}:release` }); audit(store, { actorType: 'system', automatic: true, groupId, subjectType: a.subject_type, subjectId: a.subject_id, action: 'usage.limit.release', detail: { resource: a.resource } }); }
}
if (limited) out.limited.push({ resource: a.resource, subjectType: a.subject_type, subjectId: a.subject_id });
store.run('INSERT INTO usage_limits(group_id, resource, subject_type, subject_id, level, limited, used, "limit", since, reason, overage, evaluated_at, period_start) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(group_id, resource, subject_type, subject_id) DO UPDATE SET level=excluded.level, limited=excluded.limited, used=excluded.used, "limit"=excluded."limit", since=CASE WHEN excluded.level>0 AND (usage_limits.level<>excluded.level OR usage_limits.since IS NULL) THEN excluded.since WHEN excluded.level=0 THEN NULL ELSE usage_limits.since END, reason=excluded.reason, overage=excluded.overage, evaluated_at=excluded.evaluated_at, period_start=excluded.period_start',
groupId, a.resource, a.subject_type, a.subject_id, level, limited ? 1 : 0, used, a.amount, at, limited ? `${label} 100% 도달` : level ? `${label} ${level}% 구간` : null, ov ? 1 : 0, at, monthly ? ms : null);
}
return out;
}
/** 모든 그룹 평가(주기 작업) */
export function evaluateAll(deps, at = nowIso()) { const groups = deps.store.all('SELECT DISTINCT group_id FROM quota_allocations'); const r = []; for (const g of groups) { try { r.push({ groupId: g.group_id, ...evaluate(deps, g.group_id, at) }); } catch (e) { deps.log?.(`[usage] 평가 실패 ${g.group_id}: ${e.message}`); } } return r; }