상품·주문·수동 결제 승인·권리·청구·축소·해지·AI Wallet 표시·사용량 배정 화면·경고 엔진 (§6·§13·§27)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c90ac27596
commit
c397452acd
@@ -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) };
|
||||
}
|
||||
@@ -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 })); }
|
||||
@@ -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)
|
||||
);
|
||||
` }];
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user