상품·주문·수동 결제 승인·권리·청구·축소·해지·AI Wallet 표시·사용량 배정 화면·경고 엔진 (§6·§13·§27)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:26:35 +00:00
co-authored by Claude Fable 5.1
parent c90ac27596
commit c397452acd
21 changed files with 1147 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
/**
* portal/usage/api.mjs — 사용량·배정 화면과 API(§13), 운영자 내부 API.
*
* 화면 /usage 그룹 전체 사용량(구매 총량·배정·실제 사용·미배정·예약)과 서비스별 표, 경고·제한, 적용 상품정책 버전, 다음 KST 월 초기화, 마지막 계측
* /usage/allocate/:subject 서비스(site:<id> | mail_service:<id>)별 보장량 배정 변경(§13.3)
* API GET /api/usage 요약 JSON
* GET /api/usage/allocate/:subject 자원별 수치(구매 총량·사용 중·다른 서비스 배정·예약·미배정·변경 후 값 계산용)
* POST /api/usage/allocate/:subject { resource, amount } → lib/quota.setAllocation (동시 요청 안전, 사용량 미만 거부)
* GET /api/usage/limits 경고·제한 목록
* POST /api/usage/evaluate 경고 재평가(새로 고침)
* 운영자 /internal/ops/usage/overview · /internal/ops/usage/group/:groupId · /internal/ops/usage/limits · POST /internal/ops/usage/evaluate
* 지연 처리: 대상의 마지막 계측이 settings 'usage.stale_minutes'(기본 30분) 보다 오래되면 '정보 확인 지연' 을 표시하고 배정 증가를 막는다(오래된 정보로 신규 증가를 허용하지 않음).
*/
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as quota from '../../lib/quota.mjs';
import { HttpError } from '../../lib/http.mjs';
import { nowIso } from '../../lib/db.mjs';
import { nextMonthStart, monthStart, fmtKst } from '../../lib/kst.mjs';
import { requireGroup, requireRole } from '../auth/guard.mjs';
import { requireOps } from '../commerce/opsauth.mjs';
import { getSetting } from '../commerce/catalog.mjs';
import { appliedProduct, activeSubscription } from '../commerce/orders.mjs';
import { listLimits, limitsFor, LIMIT_SEMANTICS } from './limits.mjs';
import { evaluate, evaluateAll, fmtAmount } from './warnings.mjs';
import { ALLOC_LABEL } from '../../lib/status.mjs';
import { audit } from '../../lib/audit.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
export const SUBJECT_TYPES = { site: { table: 'sites', label: '사이트', resources: ['web_disk', 'web_out', 'mailboxes', 'mail_storage', 'mail_msgs'] }, mail_service: { table: 'mail_services', label: '메일 서비스', resources: ['mail_storage', 'mail_msgs', 'mailboxes'] } };
/** 'site:abc' → { type, id, row } (현재 그룹 소속 확인) */
function parseSubject(store, groupId, subject) {
const i = String(subject || '').indexOf(':'); const type = subject.slice(0, i); const id = subject.slice(i + 1); const def = SUBJECT_TYPES[type];
if (!def || !id) throw new HttpError(400, 'bad_subject', '대상은 site:<id> 또는 mail_service:<id> 형식입니다');
const row = store.get(`SELECT * FROM ${def.table} WHERE id=? AND group_id=?`, id, groupId); if (!row) throw new HttpError(404, 'not_found', '요청한 항목을 찾을 수 없습니다');
return { type, id, row, def };
}
export function staleMinutes(store) { return Number(getSetting(store, 'usage.stale_minutes', 30)) || 30; }
const isStale = (measuredAt, mins, now = Date.now()) => !!measuredAt && now - Date.parse(measuredAt) > mins * 60000;
/** 그룹 요약 JSON */
export function usageSummary(deps, groupId, at = nowIso()) {
const { store } = deps; const mins = staleMinutes(store); const pv = appliedProduct(store, groupId); const sub = activeSubscription(store, groupId);
const resources = quota.summary(store, groupId, at).map((f) => ({ ...f, percent: quota.percent(f.allocated + f.reserved, f.purchased), usedPercent: quota.percent(f.used, f.purchased), semantics: LIMIT_SEMANTICS[f.resource] || null, monthly: f.unit.endsWith('_month') }));
const allocs = quota.listAllocations(store, groupId).map((a) => { const def = SUBJECT_TYPES[a.subject_type]; const row = def ? store.get(`SELECT name FROM ${def.table} WHERE id=?`, a.subject_id) : null; return { ...a, subjectName: row?.name || a.subject_id, subjectLabel: def?.label || a.subject_type, stateLabel: ALLOC_LABEL[a.state] || a.state, percent: a.used == null ? null : quota.percent(a.used, a.amount), stale: isStale(a.measured_at, mins), unit: quota.RESOURCES[a.resource].unit, label: quota.RESOURCES[a.resource].label, usedText: a.used == null ? '계측 전' : fmtAmount(a.used, quota.RESOURCES[a.resource].unit), amountText: fmtAmount(a.amount, quota.RESOURCES[a.resource].unit) }; });
const reservations = quota.listReservations(store, groupId).map((r) => ({ ...r, stateLabel: ALLOC_LABEL[r.state] || r.state, label: quota.RESOURCES[r.resource]?.label }));
const measured = allocs.map((a) => a.measured_at).filter(Boolean).sort(); const lastMeasuredAt = measured[measured.length - 1] || null;
return { resources, allocations: allocs, reservations, limits: listLimits(store, groupId), product: pv ? { id: pv.id, name: pv.name, version: pv.version, warnLevels: pv.warn_levels } : null, subscription: sub ? { id: sub.id, status: sub.status } : null,
monthStart: monthStart(at), nextResetAt: nextMonthStart(at), nextResetText: fmtKst(nextMonthStart(at)), lastMeasuredAt, lastMeasuredText: lastMeasuredAt ? fmtKst(lastMeasuredAt) : '계측 전', staleMinutes: mins, stale: !!lastMeasuredAt && isStale(lastMeasuredAt, mins), generatedAt: at };
}
/** 배정 화면 수치(§13.3) */
export function allocationView(deps, groupId, subjectStr, at = nowIso()) {
const { store } = deps; const s = parseSubject(store, groupId, subjectStr); const mins = staleMinutes(store); const ms = monthStart(at);
const rows = s.def.resources.map((r) => {
const f = quota.figures(store, groupId, r, at); const mine = quota.allocationOf(store, groupId, r, s.type, s.id); const u = quota.usageOf(store, groupId, r, s.type, s.id);
const monthly = quota.RESOURCES[r].unit.endsWith('_month'); const used = u ? (monthly && u.period_start && u.period_start < ms ? 0 : u.used) : null;
const stale = isStale(u?.measured_at, mins); const myAmount = mine ? mine.amount : 0;
return { resource: r, label: f.label, unit: f.unit, monthly, purchased: f.purchased, mine: myAmount, mineState: mine ? (ALLOC_LABEL[mine.state] || mine.state) : ALLOC_LABEL.available, others: f.allocated - myAmount, reserved: f.reserved, unallocated: f.unallocated, max: f.unallocated + myAmount, used, monthUsed: monthly ? used : null, floor: used || 0, measuredAt: u?.measured_at || null, stale, canIncrease: !stale, percent: used == null || !myAmount ? null : quota.percent(used, myAmount), limit: limitsFor(store, s.type, s.id).find((l) => l.resource === r) || null, texts: { purchased: fmtAmount(f.purchased, f.unit), mine: fmtAmount(myAmount, f.unit), others: fmtAmount(f.allocated - myAmount, f.unit), reserved: fmtAmount(f.reserved, f.unit), unallocated: fmtAmount(f.unallocated, f.unit), used: used == null ? '계측 전' : fmtAmount(used, f.unit) } };
});
return { subject: { type: s.type, id: s.id, name: s.row.name, label: s.def.label }, resources: rows, nextResetText: fmtKst(nextMonthStart(at)), staleMinutes: mins };
}
/** 배정 변경 */
export function changeAllocation(deps, { groupId, userId, subjectStr, resource, amount }) {
const { store } = deps; const s = parseSubject(store, groupId, subjectStr); if (!s.def.resources.includes(resource)) throw new HttpError(400, 'bad_resource', `${s.def.label}에는 ${resource} 를 배정할 수 없습니다`);
const sub = activeSubscription(store, groupId); if (sub && sub.status === 'restricted') throw new HttpError(409, 'subscription_restricted', '미납 제한 중에는 배정을 늘릴 수 없습니다. 입금 확인 후 다시 시도하세요');
const view = allocationView(deps, groupId, subjectStr).resources.find((r) => r.resource === resource);
amount = Math.round(Number(amount)); if (!(amount >= 0)) throw new HttpError(400, 'bad_amount', '배정량을 입력하세요');
if (amount > view.mine && view.stale) throw new HttpError(409, 'usage_stale', `마지막 계측(${fmtKst(view.measuredAt)})이 오래되어 배정을 늘릴 수 없습니다. 계측이 갱신된 뒤 다시 시도하세요(정보 확인 지연)`, { measuredAt: view.measuredAt });
const r = quota.setAllocation(store, { groupId, resource, subjectType: s.type, subjectId: s.id, amount, usedNow: view.used || 0, monthUsed: view.monthUsed || 0 });
audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: s.type, subjectId: s.id, action: 'allocation.change', detail: { resource, before: r.before, after: r.after } });
try { evaluate(deps, groupId); } catch (_) {}
return { ...r, view: allocationView(deps, groupId, subjectStr).resources.find((x) => x.resource === resource) };
}
export async function register(app, deps) {
const { store, page } = deps;
app.static('/ui/usage', join(HERE, 'public'));
const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || [ctx.group].filter(Boolean), csrf: ctx.session?.csrf, unread: ctx.unread, nav: 'usage', ...o });
app.get('/usage', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '사용량', crumbs: [{ label: '사용량' }], actions: [{ label: '경고 다시 평가', action: 'evaluate' }], body: `<div id="usage" data-group="${ctx.group.id}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/usage/usage.js'] })); });
app.get('/usage/allocate/:subject', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '보장량 배정 변경', crumbs: [{ label: '사용량', href: '/usage' }, { label: '배정 변경' }], body: `<div id="alloc" data-subject="${ctx.params.subject.replace(/[^a-zA-Z0-9_:.-]/g, '')}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/usage/allocate.js'] })); });
app.get('/api/usage', (ctx) => { requireGroup(ctx); return usageSummary(deps, ctx.group.id); });
app.get('/api/usage/limits', (ctx) => { requireGroup(ctx); return { limits: listLimits(store, ctx.group.id), semantics: LIMIT_SEMANTICS }; });
app.post('/api/usage/evaluate', (ctx) => { requireRole(ctx, ['owner', 'admin', 'billing', 'member']); return { ok: true, ...evaluate(deps, ctx.group.id) }; });
app.get('/api/usage/allocate/:subject', (ctx) => { requireGroup(ctx); return allocationView(deps, ctx.group.id, ctx.params.subject); });
app.post('/api/usage/allocate/:subject', async (ctx) => { requireRole(ctx, 'admin'); const b = await ctx.body(); try { return { ok: true, ...changeAllocation(deps, { groupId: ctx.group.id, userId: ctx.user.id, subjectStr: ctx.params.subject, resource: b.resource, amount: b.amount }) }; } catch (e) { if (e instanceof quota.QuotaError) throw new HttpError(409, e.code, e.message, e.detail); throw e; } });
// 운영자 콘솔용
app.get('/internal/ops/usage/overview', async (ctx) => { await requireOps(ctx, deps); const groups = store.all('SELECT DISTINCT group_id FROM quota_entitlements'); return { groups: groups.map((g) => { const gr = store.get('SELECT id, name, kind FROM groups WHERE id=?', g.group_id); return { group: gr || { id: g.group_id }, resources: quota.summary(store, g.group_id).map((f) => ({ resource: f.resource, label: f.label, unit: f.unit, purchased: f.purchased, allocated: f.allocated, reserved: f.reserved, used: f.used, percent: quota.percent(f.used, f.purchased) })), limits: listLimits(store, g.group_id).length }; }), staleMinutes: staleMinutes(store) }; });
app.get('/internal/ops/usage/group/:groupId', async (ctx) => { await requireOps(ctx, deps); return usageSummary(deps, ctx.params.groupId); });
app.get('/internal/ops/usage/limits', async (ctx) => { await requireOps(ctx, deps); return { limits: store.all('SELECT * FROM usage_limits WHERE level>0 ORDER BY level DESC, evaluated_at DESC').map((r) => ({ ...r, limited: !!r.limited, semantics: LIMIT_SEMANTICS[r.resource] || null })) }; });
app.post('/internal/ops/usage/evaluate', async (ctx) => { await requireOps(ctx, deps); return { ok: true, results: evaluateAll(deps) }; });
// 주기 작업 등록(스케줄러가 있으면)
if (deps.scheduler?.every) deps.scheduler.every('usage.evaluate', 5 * 60 * 1000, () => evaluateAll(deps));
deps.usage = { evaluate: (groupId) => evaluate(deps, groupId), evaluateAll: () => evaluateAll(deps), summary: (groupId) => usageSummary(deps, groupId) };
}
+32
View File
@@ -0,0 +1,32 @@
/**
* portal/usage/limits.mjs — 100% 제한 조회(다른 영역이 쓰는 읽기 전용 API)와 자원별 제한 의미(§13.4).
*
* 자원별 100% 동작
* web_out 사이트 대신 제한 안내 화면(유지: 관리자 화면·조회·삭제·내보내기 / 제한: 공개 접속). 다음 KST 월 시작 또는 추가 용량 적용 후 복구.
* web_disk 유지: 기존 사이트 조회·삭제·내보내기 / 제한: 새 업로드·데이터베이스 증가·새 배포.
* mail_storage 유지: 기존 메일 조회·삭제 / 제한: 새 수신(발신 서버가 재시도하도록 임시 실패 4xx)·저장.
* mail_msgs 유지: 조회·삭제 / 제한: 새 발신 차단 + 새 수신 임시 실패. 다음 KST 월 시작 또는 추가 용량 적용 후 복구.
* mailboxes 유지: 기존 메일함 / 제한: 새 메일함 생성.
* sites·mail_services·source·backup 은 개수 권리라 100% = "추가 생성 불가"(lib/quota.reserve 가 거부).
* 초과 사용 허용(overage opt-in)이 유효하고 상한 안이면 limited=false 로 두되 overage=1 로 표시한다.
* 고객 데이터는 어떤 경우에도 자동 삭제하지 않는다.
*/
export const LIMIT_SEMANTICS = Object.freeze({
web_out: { kept: ['관리 화면 접근', '파일 조회·내보내기', '삭제'], blocked: ['공개 사이트 접속(제한 안내 화면 표시)'], recovery: '다음 KST 월 시작 또는 추가 용량(옵션 구매·배정 증가) 적용 후 자동 복구', monthly: true },
web_disk: { kept: ['기존 사이트 조회', '삭제·내보내기'], blocked: ['새 업로드', '데이터베이스 증가', '새 배포'], recovery: '파일·데이터 정리 또는 추가 용량 적용 후 복구', monthly: false },
mail_storage: { kept: ['기존 메일 조회', '삭제'], blocked: ['새 수신(임시 실패 응답 — 발신 서버가 재시도)', '새 저장'], recovery: '메일 정리 또는 추가 용량 적용 후 복구', monthly: false },
mail_msgs: { kept: ['기존 메일 조회', '삭제'], blocked: ['새 발신', '새 수신(임시 실패 응답)'], recovery: '다음 KST 월 시작 또는 추가 통수 적용 후 복구', monthly: true },
mailboxes: { kept: ['기존 메일함'], blocked: ['새 메일함 생성'], recovery: '메일함 정리 또는 추가 메일함 옵션 적용 후 복구', monthly: false },
sites: { kept: ['운영 중 사이트'], blocked: ['새 공개 배포(사이트 수 확보)'], recovery: '사이트 수 반환(§14) 또는 상품 구매', monthly: false },
mail_services: { kept: ['기존 메일 서비스'], blocked: ['새 독립 메일 서비스'], recovery: '옵션 구매', monthly: false },
source: { kept: ['기존 저장소 조회'], blocked: ['새 Source 옵션 적용'], recovery: '옵션 구매', monthly: false },
backup: { kept: ['기존 백업 보관'], blocked: ['새 백업 옵션 활성화'], recovery: '옵션 구매', monthly: false },
});
/** 대상(사이트·메일 서비스)의 자원이 100% 제한 중인지 */
export function isLimited(store, resource, subjectType, subjectId) { const r = store.get('SELECT limited FROM usage_limits WHERE resource=? AND subject_type=? AND subject_id=?', resource, subjectType, subjectId); return !!(r && r.limited); }
/** 제한 상세(화면용): { level, limited, used, limit, since, reason, semantics, overage } 또는 null */
export function limitInfo(store, resource, subjectType, subjectId) { const r = store.get('SELECT * FROM usage_limits WHERE resource=? AND subject_type=? AND subject_id=?', resource, subjectType, subjectId); if (!r) return null; return { resource, level: r.level, limited: !!r.limited, used: r.used, limit: r.limit, since: r.since, reason: r.reason, overage: !!r.overage, evaluatedAt: r.evaluated_at, semantics: LIMIT_SEMANTICS[resource] || null }; }
/** 그룹 전체의 제한·경고 목록 */
export function listLimits(store, groupId) { return store.all('SELECT * FROM usage_limits WHERE group_id=? AND level>0 ORDER BY level DESC, resource', groupId).map((r) => ({ ...r, limited: !!r.limited, overage: !!r.overage, semantics: LIMIT_SEMANTICS[r.resource] || null })); }
/** 대상에 걸린 모든 제한(배포 전 확인 등에서 한 번에) */
export function limitsFor(store, subjectType, subjectId) { return store.all('SELECT * FROM usage_limits WHERE subject_type=? AND subject_id=? AND level>0', subjectType, subjectId).map((r) => ({ ...r, limited: !!r.limited, semantics: LIMIT_SEMANTICS[r.resource] || null })); }
+11
View File
@@ -0,0 +1,11 @@
/**
* portal/usage/model.mjs — 사용량 영역 표(§13.4).
* usage_limits 그룹·자원·대상별 현재 경고 구간(level)과 제한 여부(limited). 다른 영역(사이트·메일·배포)은 portal/usage/limits.mjs 로 읽는다.
*/
export const MIGRATIONS = [{ id: 'usage_0001', sql: `
CREATE TABLE IF NOT EXISTS usage_limits (
group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, level INTEGER NOT NULL DEFAULT 0, limited INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0, "limit" INTEGER NOT NULL DEFAULT 0, since TEXT, reason TEXT, overage INTEGER NOT NULL DEFAULT 0, evaluated_at TEXT NOT NULL, period_start TEXT,
PRIMARY KEY(group_id, resource, subject_type, subject_id)
);
` }];
+19
View File
@@ -0,0 +1,19 @@
/* portal/usage/public/allocate.js — 서비스별 보장량 배정 변경(§13.3). 구매 총량·사용 중·다른 서비스 배정·예약·미배정·변경 후 값을 함께 보인다. */
(() => {
const el = document.getElementById('alloc'); const subject = el.dataset.subject; const esc = aw.esc;
const fmt = (n, unit) => (unit.startsWith('bytes') ? (n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(2) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(0) + ' MB' : n + ' B') : Number(n).toLocaleString('ko-KR'));
const GB = 1024 ** 3;
async function load() {
try {
const d = await aw.api('GET', `/api/usage/allocate/${encodeURIComponent(subject)}`);
el.innerHTML = `<div class="card"><h2>${esc(d.subject.label)} · ${esc(d.subject.name)}</h2><p class="sub">월간 항목은 ${esc(d.nextResetText)} 에 초기화됩니다. 실제 사용량보다 작게 줄일 수 없고, 월간 항목은 이번 달 누적보다 작게 줄일 수 없습니다.</p></div>` +
d.resources.map((r) => { const bytes = r.unit.startsWith('bytes'); const step = bytes ? GB : 1; return `<div class="card" data-res="${r.resource}"><h2>${esc(r.label)}${r.monthly ? ' (월간)' : ''}</h2>
<dl class="kv"><dt>구매 총량</dt><dd>${esc(r.texts.purchased)}</dd><dt>이 서비스 사용 중(배정)</dt><dd>${esc(r.texts.mine)} <span class="badge neutral">○ ${esc(r.mineState)}</span></dd><dt>다른 서비스 배정</dt><dd>${esc(r.texts.others)}</dd><dt>진행 중 작업 예약</dt><dd>${esc(r.texts.reserved)}</dd><dt>미배정</dt><dd>${esc(r.texts.unallocated)}</dd><dt>실제 사용${r.monthly ? '(이번 달 누적)' : ''}</dt><dd>${esc(r.texts.used)}${r.measuredAt ? ` <span class="sub">계측 ${aw.fmt(r.measuredAt)}</span>` : ''}${r.stale ? ' <span class="badge warning">! 정보 확인 지연 — 늘릴 수 없음</span>' : ''}</dd>${r.limit ? `<dt>제한</dt><dd class="err">${r.limit.limited ? '100% 제한 적용 중' : r.limit.level + '% 경고'}</dd>` : ''}</dl>
<form class="f" data-dirty-guard><label for="in-${r.resource}">변경 후 값 (${bytes ? 'GB' : '개'})</label><input id="in-${r.resource}" type="number" min="${Math.ceil(r.floor / step)}" max="${Math.floor(r.max / step)}" step="${bytes ? '0.5' : '1'}" value="${bytes ? (r.mine / GB).toFixed(1) : r.mine}"><p class="hint">허용 범위 ${fmt(r.floor, r.unit)} ~ ${fmt(r.max, r.unit)} (미배정 ${esc(r.texts.unallocated)} 안에서)</p><p class="err" id="err-${r.resource}"></p><div class="actions"><button class="btn primary" type="submit">적용</button></div></form></div>`; }).join('');
el.querySelectorAll('form').forEach((f) => { f.onsubmit = async (ev) => { ev.preventDefault(); const res = f.closest('[data-res]').dataset.res; const r = d.resources.find((x) => x.resource === res); const v = Number(f.querySelector('input').value); const amount = r.unit.startsWith('bytes') ? Math.round(v * GB) : Math.round(v); const errEl = f.querySelector('.err'); errEl.textContent = '';
try { const out = await aw.api('POST', `/api/usage/allocate/${encodeURIComponent(subject)}`, { resource: res, amount }); aw.markClean(); aw.toast(`${r.label} 배정을 ${fmt(out.before, r.unit)} → ${fmt(out.after, r.unit)} 로 바꿨습니다`); load(); }
catch (e) { errEl.textContent = e.message + (e.detail?.cleanupNeeded ? ` (정리 필요: ${fmt(e.detail.cleanupNeeded, r.unit)})` : e.detail?.shortage ? ` (부족: ${fmt(e.detail.shortage, r.unit)})` : ''); } }; });
} catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : e.status === 404 ? 'empty' : 'failed', e.message, { retry: load }); }
}
load();
})();
+21
View File
@@ -0,0 +1,21 @@
/* portal/usage/public/usage.js — 사용량 화면(§13.2·§13.4). /api/usage 를 그려 준다. */
(() => {
const el = document.getElementById('usage'); const esc = aw.esc;
const bar = (pct, lvl) => `<div class="bar w${lvl || 0}" aria-label="${pct}%"><i style="width:${Math.min(100, pct)}%"></i></div>`;
const lvlOf = (p) => (p >= 100 ? 100 : p >= 95 ? 95 : p >= 90 ? 90 : p >= 80 ? 80 : p >= 70 ? 70 : 0);
async function load() {
try {
const d = await aw.api('GET', '/api/usage');
if (!d.product) { aw.state(el, 'empty', '활성 상품이 없습니다. 상품을 주문하면 사용량이 표시됩니다', { href: '/products', hrefLabel: '상품 보기' }); return; }
let h = `<div class="card"><dl class="kv"><dt>적용 상품정책</dt><dd>${esc(d.product.name)} v${d.product.version} (경고 구간 ${d.product.warnLevels.join('·')}%)</dd><dt>다음 KST 월 초기화</dt><dd>${esc(d.nextResetText)}</dd><dt>마지막 계측</dt><dd>${esc(d.lastMeasuredText)}${d.stale ? ' <span class="badge warning">! 정보 확인 지연</span>' : ''}</dd></dl></div>`;
if (d.limits.length) h += `<div class="card"><h2>경고·제한</h2><ul>${d.limits.map((l) => `<li><span class="badge ${l.limited ? 'failed' : 'warning'}">${l.limited ? '✘ 제한' : '! ' + l.level + '%'}</span> ${esc(l.resource)} · ${esc(l.subject_type)} ${esc(l.subject_id)}${l.limited && l.semantics ? ` — 제한: ${esc(l.semantics.blocked.join(', '))} / 유지: ${esc(l.semantics.kept.join(', '))} / 해제: ${esc(l.semantics.recovery)}` : ''}${l.overage ? ' (초과 사용 허용 중)' : ''}</li>`).join('')}</ul></div>`;
h += `<div class="card"><h2>그룹 전체(구매 총량 기준)</h2><table class="r"><thead><tr><th>자원</th><th>구매 총량</th><th>배정</th><th>예약(진행 중 작업)</th><th>미배정</th><th>실제 사용</th><th>사용률</th></tr></thead><tbody>${d.resources.map((r) => `<tr><td data-l="자원">${esc(r.label)}${r.monthly ? ' <span class="sub">(월간)</span>' : ''}</td><td data-l="구매">${fmt(r.purchased, r.unit)}</td><td data-l="배정">${fmt(r.allocated, r.unit)}</td><td data-l="예약">${fmt(r.reserved, r.unit)}</td><td data-l="미배정">${fmt(r.unallocated, r.unit)}</td><td data-l="사용">${fmt(r.used, r.unit)}</td><td data-l="사용률">${bar(r.usedPercent, lvlOf(r.usedPercent))} ${r.usedPercent}%</td></tr>`).join('')}</tbody></table></div>`;
h += `<div class="card"><h2>서비스별 배정과 사용</h2>${d.allocations.length ? `<table class="r"><thead><tr><th>서비스</th><th>자원</th><th>배정</th><th>상태</th><th>사용</th><th>계측</th><th></th></tr></thead><tbody>${d.allocations.map((a) => `<tr><td data-l="서비스">${esc(a.subjectLabel)} · ${esc(a.subjectName)}</td><td data-l="자원">${esc(a.label)}</td><td data-l="배정">${esc(a.amountText)}</td><td data-l="상태"><span class="badge neutral">○ ${esc(a.stateLabel)}</span></td><td data-l="사용">${esc(a.usedText)} ${a.percent != null ? bar(a.percent, lvlOf(a.percent)) + a.percent + '%' : ''}</td><td data-l="계측">${a.measured_at ? aw.fmt(a.measured_at) : '계측 전'}${a.stale ? ' <span class="badge warning">! 지연</span>' : ''}</td><td data-l=""><a class="btn" href="/usage/allocate/${esc(a.subject_type)}:${esc(a.subject_id)}">배정 변경</a></td></tr>`).join('')}</tbody></table>` : '<p class="muted">아직 배정된 서비스가 없습니다. 사이트나 메일 서비스를 만들면 여기에 표시됩니다.</p>'}</div>`;
if (d.reservations.length) h += `<div class="card"><h2>진행 중 작업의 예약</h2><table class="r"><thead><tr><th>자원</th><th>양</th><th>상태</th><th>사유</th><th>만료·반환 예정</th></tr></thead><tbody>${d.reservations.map((r) => `<tr><td data-l="자원">${esc(r.label)}</td><td data-l="양">${r.amount}</td><td data-l="상태">${esc(r.stateLabel)}</td><td data-l="사유">${esc(r.reason || '')}</td><td data-l="예정">${r.return_due_at ? aw.fmt(r.return_due_at) : r.expires_at ? aw.fmt(r.expires_at) : ''}</td></tr>`).join('')}</tbody></table></div>`;
el.innerHTML = h;
} catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: load }); }
}
function fmt(n, unit) { if (unit.startsWith('bytes')) return n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(0) + ' MB' : n + ' B'; return Number(n).toLocaleString('ko-KR'); }
document.addEventListener('click', async (e) => { if (e.target.closest('[data-action=evaluate]')) { try { const r = await aw.api('POST', '/api/usage/evaluate'); aw.toast(`평가 ${r.evaluated}건 · 새 알림 ${r.notified.length}건`); load(); } catch (err) { aw.toast(err.message); } } });
load();
})();
+65
View File
@@ -0,0 +1,65 @@
/**
* 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; }