Files
AriWeb-Suite/portal/usage/api.mjs

98 lines
12 KiB
JavaScript

/**
* 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) };
}