151 lines
15 KiB
JavaScript
151 lines
15 KiB
JavaScript
/**
|
|||
|
|
* lib/quota.mjs — 상품 권리(entitlement)·서비스별 배정(allocation)·진행 중 예약(reservation) (§13·§14·§6.4).
|
||
|
|
*
|
||
|
|
* 세 층
|
||
|
|
* entitlement 그룹이 구매해 활성화된 총량(상품정책 버전 참조). 결제·승인 전에는 만들지 않는다(§6.2).
|
||
|
|
* allocation 사이트·메일 서비스에 배정된 양(사용 중). 실제 사용량(usage)보다 작게 줄일 수 없다.
|
||
|
|
* reservation 진행 중 작업(최초 공개 배포·메일 생성 등)이 잡아 둔 양. 성공하면 commit → allocation, 실패·취소·되돌리기면 release → 반환.
|
||
|
|
* 불변식: Σ(allocation) + Σ(reserved) ≤ Σ(active entitlement). reserve/commit/setAllocation 은 BEGIN IMMEDIATE 트랜잭션 안에서 검사해
|
||
|
|
* 마지막 남은 단위를 두 작업이 동시에 요청하면 하나만 성공한다(§13.3, 검증 27).
|
||
|
|
* 자원 종류(RESOURCES): sites(개) web_disk(바이트) web_out(바이트/월) mail_storage(바이트) mail_msgs(통/월) mailboxes(개) mail_services(개) source(개) backup(개)
|
||
|
|
*/
|
||
|
|
import { newId } from './crypto.mjs';
|
||
|
|
import { nowIso } from './db.mjs';
|
||
|
|
|
||
|
|
export const RESOURCES = Object.freeze({
|
||
|
|
sites: { unit: 'count', label: '운영 사이트 수' }, web_disk: { unit: 'bytes', label: '웹 디스크' }, web_out: { unit: 'bytes_month', label: '월간 웹 아웃바운드' },
|
||
|
|
mail_storage: { unit: 'bytes', label: '메일 저장 공간' }, mail_msgs: { unit: 'count_month', label: '월간 메일 통수(송신+수신)' }, mailboxes: { unit: 'count', label: '메일함 수' },
|
||
|
|
mail_services: { unit: 'count', label: '독립 메일 서비스' }, source: { unit: 'count', label: 'Source 옵션' }, backup: { unit: 'count', label: '사이트 백업 옵션' },
|
||
|
|
});
|
||
|
|
export const DEFAULT_WARN_LEVELS = [70, 80, 90, 95];
|
||
|
|
|
||
|
|
export const MIGRATIONS = [{ id: 'lib_quota_0001', sql: `
|
||
|
|
CREATE TABLE IF NOT EXISTS quota_entitlements (
|
||
|
|
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, source_type TEXT, source_id TEXT, product_version_id TEXT,
|
||
|
|
starts_at TEXT NOT NULL, ends_at TEXT, note TEXT, created_at TEXT NOT NULL
|
||
|
|
);
|
||
|
|
CREATE INDEX IF NOT EXISTS qe_group ON quota_entitlements(group_id, resource);
|
||
|
|
CREATE TABLE IF NOT EXISTS quota_allocations (
|
||
|
|
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, amount INTEGER NOT NULL,
|
||
|
|
state TEXT NOT NULL DEFAULT 'in_use', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, return_due_at TEXT,
|
||
|
|
UNIQUE(group_id, resource, subject_type, subject_id)
|
||
|
|
);
|
||
|
|
CREATE TABLE IF NOT EXISTS quota_reservations (
|
||
|
|
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'reserved',
|
||
|
|
subject_type TEXT, subject_id TEXT, job_id TEXT, reason TEXT, created_at TEXT NOT NULL, expires_at TEXT, committed_at TEXT, released_at TEXT, return_due_at TEXT
|
||
|
|
);
|
||
|
|
CREATE INDEX IF NOT EXISTS qr_group ON quota_reservations(group_id, resource, state);
|
||
|
|
CREATE TABLE IF NOT EXISTS quota_usage (
|
||
|
|
group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, used INTEGER NOT NULL, period_start TEXT, measured_at TEXT NOT NULL, source TEXT,
|
||
|
|
PRIMARY KEY(group_id, resource, subject_type, subject_id)
|
||
|
|
);` }];
|
||
|
|
|
||
|
|
export class QuotaError extends Error { constructor(code, message, detail) { super(message); this.code = code; this.status = 409; this.detail = detail; } }
|
||
|
|
|
||
|
|
/** 권리 부여(주문 승인 후). ends_at 이 없으면 무기한(갱신 때 갱신). */
|
||
|
|
export function grantEntitlement(store, { groupId, resource, amount, sourceType = 'order', sourceId = null, productVersionId = null, startsAt = null, endsAt = null, note = null }) {
|
||
|
|
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
|
||
|
|
const id = newId('qe_'); store.run('INSERT INTO quota_entitlements(id, group_id, resource, amount, source_type, source_id, product_version_id, starts_at, ends_at, note, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, groupId, resource, Math.round(amount), sourceType, sourceId, productVersionId, startsAt || nowIso(), endsAt, note, nowIso()); return id;
|
||
|
|
}
|
||
|
|
/** 권리 종료(해지·축소 적용) */
|
||
|
|
export function endEntitlement(store, id, at = nowIso()) { return store.run('UPDATE quota_entitlements SET ends_at=? WHERE id=? AND (ends_at IS NULL OR ends_at>?)', at, id, at).changes; }
|
||
|
|
|
||
|
|
const sumSql = { purchased: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_entitlements WHERE group_id=? AND resource=? AND starts_at<=? AND (ends_at IS NULL OR ends_at>?)', allocated: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_allocations WHERE group_id=? AND resource=? AND state IN (\'in_use\',\'returning\')', reserved: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_reservations WHERE group_id=? AND resource=? AND state=\'reserved\' AND (expires_at IS NULL OR expires_at>?)' };
|
||
|
|
/** 자원 하나의 수치 */
|
||
|
|
export function figures(store, groupId, resource, at = nowIso()) {
|
||
|
|
const purchased = store.get(sumSql.purchased, groupId, resource, at, at).n; const allocated = store.get(sumSql.allocated, groupId, resource).n; const reserved = store.get(sumSql.reserved, groupId, resource, at).n;
|
||
|
|
const used = store.get('SELECT COALESCE(SUM(used),0) AS n FROM quota_usage WHERE group_id=? AND resource=?', groupId, resource).n;
|
||
|
|
return { resource, unit: RESOURCES[resource].unit, label: RESOURCES[resource].label, purchased, allocated, reserved, unallocated: Math.max(0, purchased - allocated - reserved), used };
|
||
|
|
}
|
||
|
|
/** 그룹 전체 요약(§13.2) */
|
||
|
|
export function summary(store, groupId, at = nowIso()) { return Object.keys(RESOURCES).map((r) => figures(store, groupId, r, at)); }
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 예약(§13.3). 트랜잭션 안에서 가용량을 검사한다. 부족하면 QuotaError('quota_insufficient', shortage).
|
||
|
|
* 같은 subject·resource 의 활성 예약이 있으면 그것을 돌려준다(중복 예약 금지).
|
||
|
|
*/
|
||
|
|
export function reserve(store, { groupId, resource, amount, subjectType = null, subjectId = null, jobId = null, reason = null, ttlMs = 24 * 3600 * 1000 }) {
|
||
|
|
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
|
||
|
|
amount = Math.round(amount); if (amount <= 0) throw new QuotaError('bad_amount', '예약량은 0보다 커야 합니다');
|
||
|
|
return store.tx(() => {
|
||
|
|
if (subjectId) { const ex = store.get('SELECT * FROM quota_reservations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=? AND state=\'reserved\'', groupId, resource, subjectType, subjectId); if (ex) return { ...ex, existing: true }; }
|
||
|
|
const f = figures(store, groupId, resource);
|
||
|
|
if (amount > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label}이(가) 부족합니다 (필요 ${amount}, 사용 가능 ${f.unallocated})`, { resource, requested: amount, available: f.unallocated, shortage: amount - f.unallocated, purchased: f.purchased, allocated: f.allocated, reserved: f.reserved });
|
||
|
|
const id = newId('qr_'); const now = nowIso();
|
||
|
|
store.run('INSERT INTO quota_reservations(id, group_id, resource, amount, state, subject_type, subject_id, job_id, reason, created_at, expires_at) VALUES(?,?,?,?,\'reserved\',?,?,?,?,?,?)', id, groupId, resource, amount, subjectType, subjectId, jobId, reason, now, ttlMs ? new Date(Date.now() + ttlMs).toISOString() : null);
|
||
|
|
return store.get('SELECT * FROM quota_reservations WHERE id=?', id);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/** 예약 확정 → 배정(in_use). 배정이 이미 있으면 양을 더한다. */
|
||
|
|
export function commit(store, reservationId) {
|
||
|
|
return store.tx(() => {
|
||
|
|
const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) throw new QuotaError('reservation_not_found', '예약이 없습니다');
|
||
|
|
if (r.state === 'in_use') return r;
|
||
|
|
if (r.state !== 'reserved') throw new QuotaError('reservation_not_active', `예약 상태(${r.state})에서는 확정할 수 없습니다`);
|
||
|
|
const now = nowIso();
|
||
|
|
store.run('UPDATE quota_reservations SET state=\'in_use\', committed_at=? WHERE id=?', now, r.id);
|
||
|
|
const a = store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id);
|
||
|
|
if (a) store.run('UPDATE quota_allocations SET amount=amount+?, state=\'in_use\', updated_at=? WHERE id=?', r.amount, now, a.id);
|
||
|
|
else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id, r.amount, now, now);
|
||
|
|
return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/** 예약 반환(실패·취소·되돌리기). returning=true 면 정리 전 '반환 중' 으로 두고 returnDueAt 을 표시한다(§11.2 "반환 예정 시각"). */
|
||
|
|
export function release(store, reservationId, { returning = false, returnDueAt = null } = {}) {
|
||
|
|
const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) return null;
|
||
|
|
if (r.state === 'returned') return r;
|
||
|
|
if (returning) store.run('UPDATE quota_reservations SET state=\'returning\', return_due_at=? WHERE id=?', returnDueAt, r.id);
|
||
|
|
else store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', nowIso(), r.id);
|
||
|
|
return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id);
|
||
|
|
}
|
||
|
|
/** 만료된 예약 자동 반환(주기 작업). 반환된 예약 목록을 돌려줘 호출자가 알림을 만든다. */
|
||
|
|
export function expireReservations(store, at = nowIso()) {
|
||
|
|
const rows = store.all('SELECT * FROM quota_reservations WHERE state=\'reserved\' AND expires_at IS NOT NULL AND expires_at<=?', at);
|
||
|
|
for (const r of rows) store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', at, r.id);
|
||
|
|
return rows;
|
||
|
|
}
|
||
|
|
/** 대상의 배정 읽기 */
|
||
|
|
export function allocationOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; }
|
||
|
|
/**
|
||
|
|
* 배정량 변경(§13.3). 늘릴 때는 미배정량 안에서, 줄일 때는 실제 사용량(usedNow)과 이번 달 누적(monthUsed, 월간 자원)보다 작게 할 수 없다.
|
||
|
|
* @returns {{allocation:object, before:number, after:number}}
|
||
|
|
*/
|
||
|
|
export function setAllocation(store, { groupId, resource, subjectType, subjectId, amount, usedNow = 0, monthUsed = 0 }) {
|
||
|
|
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
|
||
|
|
amount = Math.round(amount); if (amount < 0) throw new QuotaError('bad_amount', '배정량은 0 이상이어야 합니다');
|
||
|
|
return store.tx(() => {
|
||
|
|
const a = allocationOf(store, groupId, resource, subjectType, subjectId); const before = a ? a.amount : 0; const f = figures(store, groupId, resource);
|
||
|
|
const floor = RESOURCES[resource].unit.endsWith('_month') ? Math.max(usedNow, monthUsed) : usedNow;
|
||
|
|
if (amount < floor) throw new QuotaError('allocation_below_usage', `현재 사용량(${floor})보다 작게 줄일 수 없습니다. 먼저 ${floor - amount} 만큼 정리하세요`, { resource, requested: amount, floor, cleanupNeeded: floor - amount });
|
||
|
|
const delta = amount - before;
|
||
|
|
if (delta > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label} 미배정량(${f.unallocated})을 넘습니다`, { resource, requested: delta, available: f.unallocated, shortage: delta - f.unallocated });
|
||
|
|
const now = nowIso();
|
||
|
|
if (a) store.run('UPDATE quota_allocations SET amount=?, state=\'in_use\', updated_at=? WHERE id=?', amount, now, a.id);
|
||
|
|
else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), groupId, resource, subjectType, subjectId, amount, now, now);
|
||
|
|
return { allocation: allocationOf(store, groupId, resource, subjectType, subjectId), before, after: amount };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/** 배정 반환 시작(반환 중) / 완료(삭제). §14 사이트 수 반환은 모든 조건이 충족된 뒤 finish 를 부른다. */
|
||
|
|
export function beginReturn(store, allocationId, returnDueAt = null) { return store.run('UPDATE quota_allocations SET state=\'returning\', return_due_at=?, updated_at=? WHERE id=? AND state=\'in_use\'', returnDueAt, nowIso(), allocationId).changes; }
|
||
|
|
export function finishReturn(store, allocationId) { return store.run('DELETE FROM quota_allocations WHERE id=?', allocationId).changes; }
|
||
|
|
export function cancelReturn(store, allocationId) { return store.run('UPDATE quota_allocations SET state=\'in_use\', return_due_at=NULL, updated_at=? WHERE id=? AND state=\'returning\'', nowIso(), allocationId).changes; }
|
||
|
|
/** 실제 사용량 기록(계측 결과). 월간 자원은 period_start(KST 월 시작) 를 함께 둔다. */
|
||
|
|
export function recordUsage(store, { groupId, resource, subjectType, subjectId, used, periodStart = null, source = null, measuredAt = nowIso() }) {
|
||
|
|
store.run('INSERT INTO quota_usage(group_id, resource, subject_type, subject_id, used, period_start, measured_at, source) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(group_id, resource, subject_type, subject_id) DO UPDATE SET used=excluded.used, period_start=excluded.period_start, measured_at=excluded.measured_at, source=excluded.source', groupId, resource, subjectType, subjectId, Math.round(used), periodStart, measuredAt, source);
|
||
|
|
}
|
||
|
|
export function usageOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_usage WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; }
|
||
|
|
/** 서비스별 배정·사용 목록(화면: 사이트별 배정량·사용량) */
|
||
|
|
export function listAllocations(store, groupId, resource = null) {
|
||
|
|
return store.all(`SELECT a.*, u.used, u.measured_at FROM quota_allocations a LEFT JOIN quota_usage u ON u.group_id=a.group_id AND u.resource=a.resource AND u.subject_type=a.subject_type AND u.subject_id=a.subject_id WHERE a.group_id=? ${resource ? 'AND a.resource=?' : ''} ORDER BY a.resource, a.created_at`, ...(resource ? [groupId, resource] : [groupId]));
|
||
|
|
}
|
||
|
|
export function listReservations(store, groupId, { active = true } = {}) { return store.all(`SELECT * FROM quota_reservations WHERE group_id=? ${active ? 'AND state IN (\'reserved\',\'returning\')' : ''} ORDER BY created_at DESC`, groupId); }
|
||
|
|
/**
|
||
|
|
* 경고 구간(§13.4). 넘은 가장 높은 구간을 돌려준다(0 = 정상, 100 = 제한).
|
||
|
|
* @param {number} used @param {number} limit 0 이면 제한 없음 → 0
|
||
|
|
*/
|
||
|
|
export function warnLevel(used, limit, levels = DEFAULT_WARN_LEVELS) {
|
||
|
|
if (!limit || limit <= 0) return 0; const pct = (used / limit) * 100; if (pct >= 100) return 100;
|
||
|
|
let lvl = 0; for (const l of levels) if (pct >= l) lvl = l; return lvl;
|
||
|
|
}
|
||
|
|
export const percent = (used, limit) => (limit > 0 ? Math.min(999, Math.round((used / limit) * 1000) / 10) : 0);
|