관리형 사이트 백업·복원, 서버 이전, 소유권 이전, 중지·해지·보존 생명주기와 운영 사이트 수 반환 (§14·§19~§23)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c397452acd
commit
a014e3cd05
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* portal/backup/api.mjs — 백업·복원 화면과 API(§19·§20), 운영자 내부 API(§30.3).
|
||||
*
|
||||
* 고객 화면
|
||||
* GET /backup 그룹의 백업 현황(옵션 활성 사이트·최근 결과·복원 요청)
|
||||
* GET /sites/:id/backup 사이트 백업 탭(옵션·보관 목록·복원 요청·실행 상태)
|
||||
* GET /sites/:id/restore/:backupId 복원 요청 화면(§20.1)
|
||||
* 고객 API (/api/backup/…)
|
||||
* GET sites/:id siteView POST sites/:id/option 옵션 구매(권리 필요)
|
||||
* POST sites/:id/option/resume 재개(confirm 방식·저장 공간 정리 후) POST sites/:id/option/end 옵션 해지(확인 필요)
|
||||
* POST sites/:id/run 지금 백업(수동, 옵션 활성 때) GET backups/:id 백업 하나
|
||||
* POST sites/:id/restore { backupId, completionMode } 복원 요청 GET restores/:id 복원 상태(작업 포함)
|
||||
* POST restores/:id/decision { action: confirm|revert } POST restores/:id/cancel
|
||||
* 운영자 내부 API (/internal/ops/backup/…, lib/serviceauth 키 hal)
|
||||
* GET options · GET backups?site= · GET restores · GET restores/:id · POST restores/:id/approve {operator, window, confirm:{groupId,siteId,domain,backupAt}}
|
||||
* POST restores/:id/reject · POST options/:id/override-same-server · POST options/:id/retry · POST options/:id/end · POST run-due
|
||||
*/
|
||||
import { HttpError, esc } from '../../lib/http.mjs';
|
||||
import { requireGroup } from '../auth/guard.mjs';
|
||||
import * as svc from './service.mjs';
|
||||
import { getSite, opsGuard, operatorOf, jobView, humanBytes, fmtKst, nowIso, parseJson, requireConfirm, inProgress } from '../lifecycle/common.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
import { notify } from '../../lib/notify.mjs';
|
||||
import { QuotaError } from '../../lib/quota.mjs';
|
||||
|
||||
export async function register(app, deps) {
|
||||
svc.registerJobs(deps);
|
||||
const { page, t } = deps;
|
||||
const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || (ctx.group ? [ctx.group] : []), csrf: ctx.session?.csrf, unread: ctx.unread || 0, ...o });
|
||||
const quotaErr = (e) => { if (e instanceof QuotaError) throw new HttpError(409, e.code, e.message, { ...(e.detail || {}), action: 'buy_product', link: '/billing/products?product=backup' }); throw e; };
|
||||
|
||||
// ---------- 고객 화면 ----------
|
||||
app.get('/backup', (ctx) => { requireGroup(ctx); return shell(ctx, { title: t('backup.title'), nav: 'backup', crumbs: [{ label: t('nav.backup') }], body: `<div id="bk-list">${deps.statePanel('loading')}</div>`, scripts: ['/ui/backup/backup.js'] }); });
|
||||
app.get('/sites/:id/backup', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return shell(ctx, { title: `${site.name} · ${t('backup.title')}`, nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('backup.title') }], body: `${siteTabs(site.id, 'backup')}<div id="bk-site" data-site="${esc(site.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/backup/backup.js'] }); });
|
||||
app.get('/sites/:id/restore/:backupId', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const b = backupOf(ctx.params.backupId, site); return shell(ctx, { title: t('backup.restore.title'), nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('backup.title'), href: `/sites/${site.id}/backup` }, { label: t('backup.restore.title') }], body: `<div id="bk-restore" data-site="${esc(site.id)}" data-backup="${esc(b.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/backup/backup.js'] }); });
|
||||
app.static('/ui/backup', new URL('./public', import.meta.url).pathname);
|
||||
|
||||
function backupOf(id, site) { const b = deps.store.get('SELECT * FROM backups WHERE id=? AND site_id=?', id, site.id); if (!b) throw new HttpError(404, 'not_found', t('error.notFound')); return b; }
|
||||
function siteTabs(id, on) { const tabs = [['overview', '개요', ''], ['deploy', '배포', '/deploy'], ['domains', '도메인', '/domains'], ['mail', '메일', '/mail'], ['backup', '백업', '/backup'], ['usage', '사용량 및 통계', '/usage'], ['lifecycle', '설정·생명주기', '/lifecycle']]; return `<nav class="tabs" aria-label="사이트 탭">${tabs.map(([k, l, p]) => `<a href="/sites/${id}${p}" class="${k === on ? 'on' : ''}">${l}</a>`).join('')}</nav>`; }
|
||||
|
||||
// ---------- 고객 API ----------
|
||||
app.get('/api/backup/overview', (ctx) => {
|
||||
requireGroup(ctx); const gid = ctx.group.id;
|
||||
const sites = deps.store.all('SELECT * FROM sites WHERE group_id=? AND deleted_at IS NULL ORDER BY name', gid).map((s) => { const o = svc.optionOf(deps, s.id); const last = deps.store.get('SELECT created_at, status, verify_state FROM backups WHERE site_id=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', s.id); return { id: s.id, name: s.name, deployMode: s.deploy_mode, lifecycle: s.lifecycle, eligible: svc.eligibility(deps, s).ok, option: o ? { status: o.status, pausedReason: o.paused_reason, nextRunAt: o.next_run_at, lastSuccessAt: o.last_success_at, lastFailureAt: o.last_failure_at, consecutiveFailures: o.consecutive_failures, endsAt: o.ends_at, deleteAfter: o.delete_after } : null, lastBackup: last || null }; });
|
||||
const restores = deps.store.all('SELECT r.*, s.name AS site_name FROM restores r JOIN sites s ON s.id=r.site_id WHERE r.group_id=? ORDER BY r.created_at DESC LIMIT 20', gid).map((r) => ({ id: r.id, siteId: r.site_id, siteName: r.site_name, status: r.status, createdAt: r.created_at, cost: r.cost, job: jobView(deps, r.job_id) }));
|
||||
return { ok: true, sites, restores, mailBackupNotice: '메일 원문은 고객용 백업·복원 대상이 아닙니다', measuredAt: nowIso() };
|
||||
});
|
||||
app.get('/api/backup/sites/:id', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); const v = svc.siteView(deps, site); return { ok: true, ...v, lastRun: v.lastRun ? jobView(deps, v.lastRun.id) : null, restores: v.restores.map((r) => ({ ...r, job: r.job ? jobView(deps, r.job.id) : null })) }; });
|
||||
app.post('/api/backup/sites/:id/option', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const body = await ctx.body(); requireConfirm(body); try { const o = svc.purchaseOption(deps, site, { by: ctx.user.id, orderId: body.orderId || null }); return { ok: true, option: o }; } catch (e) { quotaErr(e); } });
|
||||
app.post('/api/backup/sites/:id/option/resume', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const o = svc.resumeForSite(deps, site.id, { by: ctx.user.id, userConfirmed: true }); audit(deps.store, { actorType: 'user', actorId: ctx.user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'backup.option.resume' }); return { ok: true, option: o }; });
|
||||
app.post('/api/backup/sites/:id/option/end', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const body = await ctx.body(); requireConfirm(body); const o = svc.optionOf(deps, site.id); if (!o || o.status === 'ended') throw new HttpError(404, 'not_found', '활성 백업 옵션이 없습니다'); return { ok: true, option: svc.expireOption(deps, o.id, { by: ctx.user.id, reason: 'option_ended' }) }; });
|
||||
app.post('/api/backup/sites/:id/run', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const o = svc.optionOf(deps, site.id); if (!o || o.status !== 'active') throw new HttpError(409, 'backup_option_inactive', '활성 백업 옵션이 있어야 수동 백업을 할 수 있습니다'); const conflicts = inProgress(deps, 'site', site.id).filter((c) => c.kind !== 'backup.scheduled'); if (conflicts.length) throw new HttpError(409, 'conflict', '진행 중인 배포·복원·이전이 있어 지금은 백업할 수 없습니다', { conflicts }); const slot = 'manual:' + nowIso().slice(0, 16); const { job, created } = deps.jobs.enqueue({ kind: 'backup.scheduled', subject: { optionId: o.id, siteId: site.id, groupId: site.group_id, slot, manual: true }, idempotencyKey: `backup:${o.id}:${slot}`, actorType: 'user', createdBy: ctx.user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: `수동 백업 (${site.name})` }); return { ok: true, job: jobView(deps, job.id), created }; });
|
||||
app.get('/api/backup/backups/:id', (ctx) => { const b = deps.store.get('SELECT * FROM backups WHERE id=?', ctx.params.id); if (!b) throw new HttpError(404, 'not_found', t('error.notFound')); const site = getSite(deps, ctx, b.site_id, 'view'); return { ok: true, backup: { ...b, scope: parseJson(b.scope_json, {}) }, restoreInfo: svc.restoreInfo(deps, site, b) }; });
|
||||
app.post('/api/backup/sites/:id/restore', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const body = await ctx.body(); const b = backupOf(String(body.backupId || ''), site); requireConfirm(body); const r = await svc.requestRestore(deps, site, b, { by: ctx.user.id, completionMode: body.completionMode || null }); return { ok: true, restore: r.restore, created: r.created, info: svc.restoreInfo(deps, site, b), finalConfirmation: { group: ctx.group.name, site: site.name, domain: site.primary_domain } }; });
|
||||
app.get('/api/backup/restores/:id', (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); const site = getSite(deps, ctx, r.site_id, 'view'); return { ok: true, restore: { ...r, policy: parseJson(r.policy_json, {}) }, job: jobView(deps, r.job_id), site: { id: site.id, name: site.name, domain: site.primary_domain }, group: ctx.group.name }; });
|
||||
app.post('/api/backup/restores/:id/decision', async (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); getSite(deps, ctx, r.site_id, 'deploy'); const body = await ctx.body(); if (body.action === 'revert') requireConfirm(body); const out = await svc.userDecision(deps, r, body.action, ctx.user.id); return { ok: true, ...out, job: jobView(deps, r.job_id) }; });
|
||||
app.post('/api/backup/restores/:id/cancel', (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); getSite(deps, ctx, r.site_id, 'deploy'); if (!['awaiting_payment', 'awaiting_approval'].includes(r.status)) throw new HttpError(409, 'restore_not_cancellable', '승인 뒤에는 취소할 수 없습니다. 복원이 끝난 뒤 되돌리기를 사용하세요'); deps.store.run('UPDATE restores SET status=\'cancelled\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), r.id); audit(deps.store, { actorType: 'user', actorId: ctx.user.id, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.cancel', detail: { restoreId: r.id } }); return { ok: true }; });
|
||||
|
||||
// ---------- 운영자 내부 API ----------
|
||||
const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); };
|
||||
app.get('/internal/ops/backup/options', ops((ctx) => ({ ok: true, options: deps.store.all('SELECT o.*, s.name AS site_name, s.server_id FROM backup_options o JOIN sites s ON s.id=o.site_id ORDER BY o.updated_at DESC LIMIT 500').map((o) => ({ ...o, storedBytes: svc.storedBytes(deps, o.site_id) })) })));
|
||||
app.get('/internal/ops/backup/backups', ops((ctx) => ({ ok: true, backups: deps.store.all(`SELECT * FROM backups ${ctx.query.site ? 'WHERE site_id=?' : ''} ORDER BY created_at DESC LIMIT 500`, ...(ctx.query.site ? [ctx.query.site] : [])) })));
|
||||
app.get('/internal/ops/backup/restores', ops((ctx) => ({ ok: true, restores: deps.store.all(`SELECT r.*, s.name AS site_name, s.primary_domain, g.name AS group_name FROM restores r JOIN sites s ON s.id=r.site_id JOIN groups g ON g.id=r.group_id ${ctx.query.status ? 'WHERE r.status=?' : ''} ORDER BY r.created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => ({ ...r, job: jobView(deps, r.job_id) })) })));
|
||||
app.get('/internal/ops/backup/restores/:id', ops((ctx) => { const r = deps.store.get('SELECT r.*, s.name AS site_name, s.primary_domain, g.name AS group_name FROM restores r JOIN sites s ON s.id=r.site_id JOIN groups g ON g.id=r.group_id WHERE r.id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); const b = deps.store.get('SELECT * FROM backups WHERE id=?', r.backup_id); return { ok: true, restore: r, backup: b, job: jobView(deps, r.job_id), finalConfirmation: { customerGroup: r.group_name, groupId: r.group_id, site: r.site_name, siteId: r.site_id, domain: r.primary_domain, backupAt: b ? b.created_at : null } }; }));
|
||||
app.post('/internal/ops/backup/restores/:id/approve', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT r.*, s.primary_domain FROM restores r JOIN sites s ON s.id=r.site_id WHERE r.id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); const b = deps.store.get('SELECT created_at FROM backups WHERE id=?', r.backup_id); const c = body.confirm || {}; if (c.groupId !== r.group_id || c.siteId !== r.site_id || (c.backupAt || null) !== (b ? b.created_at : null)) throw new HttpError(400, 'final_confirmation_mismatch', '최종 확인 내용(고객 그룹·사이트·백업 시각)이 요청과 다릅니다', { expected: { groupId: r.group_id, siteId: r.site_id, domain: r.primary_domain, backupAt: b ? b.created_at : null } }); if (body.paymentConfirmed) svc.markRestorePaid(deps, r.id, operatorOf(body)); const out = svc.approveRestore(deps, r.id, { by: operatorOf(body), window: body.window || null }); return { ok: true, restore: out.restore, job: jobView(deps, out.job.id), created: out.created }; }));
|
||||
app.post('/internal/ops/backup/restores/:id/reject', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, restore: svc.rejectRestore(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason }) }; }));
|
||||
app.post('/internal/ops/backup/restores/:id/retry', ops(async (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '복원 작업이 없습니다'); const body = await ctx.body(); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE restores SET status=\'queued\', error=NULL, updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; }));
|
||||
app.post('/internal/ops/backup/options/:id/override-same-server', ops(async (ctx) => { const body = await ctx.body(); const o = svc.optionById(deps, ctx.params.id); if (!o) throw new HttpError(404, 'not_found', '옵션이 없습니다'); deps.store.run('UPDATE backup_options SET same_server_override=?, updated_at=? WHERE id=?', body.allow ? 1 : 0, nowIso(), o.id); audit(deps.store, { actorType: 'operator', actorId: operatorOf(body), groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, action: 'backup.option.same_server_override', detail: { allow: !!body.allow, reason: body.reason } }); return { ok: true }; }));
|
||||
app.post('/internal/ops/backup/options/:id/retry', ops(async (ctx) => { const body = await ctx.body(); const o = svc.optionById(deps, ctx.params.id); if (!o) throw new HttpError(404, 'not_found', '옵션이 없습니다'); const last = deps.store.get('SELECT id, status FROM jobs WHERE kind=\'backup.scheduled\' AND subject_id=? ORDER BY created_at DESC LIMIT 1', o.site_id); if (last && ['failed', 'needs_user'].includes(last.status)) { const j = deps.jobs.retry(last.id, operatorOf(body)); return { ok: true, job: jobView(deps, j.id) }; } svc.resumeForSite(deps, o.site_id, { by: operatorOf(body), userConfirmed: true, clearStorageFull: !!body.clearStorageFull }); deps.store.run('UPDATE backup_options SET next_run_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id); return { ok: true, jobs: svc.runDue(deps) }; }));
|
||||
app.post('/internal/ops/backup/options/:id/end', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, option: svc.expireOption(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason || 'operator' }) }; }));
|
||||
app.post('/internal/ops/backup/run-due', ops(() => ({ ok: true, started: svc.runDue(deps) })));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* portal/backup/model.mjs — 백업 영역 추가 열·표.
|
||||
* 중심 표(backup_options·backups·restores)는 portal/core/model.mjs 에 있고 여기서는 운영에 필요한 열만 더한다.
|
||||
* backup_options.store_server_id 백업 사본을 두는 서버(운영 서버와 다른 서버 §19.2)
|
||||
* backup_options.same_server_override 운영자가 같은 서버 저장을 명시적으로 허용했는지(기본 0)
|
||||
* backup_options.resume_mode 사이트 재개 뒤 정기 백업 재개 방식: auto | confirm (§19.2)
|
||||
* backup_options.allocation_id lib/quota 배정(backup 자원) id — 옵션 해지 때 반환
|
||||
* restores.policy_json 복원 시작 때 적용한 정책 스냅샷(비용·보호 시점 유지·완료 방식)
|
||||
*/
|
||||
export const MIGRATIONS = [{ id: 'backup_0001', sql: `
|
||||
ALTER TABLE backup_options ADD COLUMN store_server_id TEXT;
|
||||
ALTER TABLE backup_options ADD COLUMN same_server_override INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE backup_options ADD COLUMN resume_mode TEXT NOT NULL DEFAULT 'auto';
|
||||
ALTER TABLE backup_options ADD COLUMN allocation_id TEXT;
|
||||
ALTER TABLE restores ADD COLUMN policy_json TEXT;
|
||||
ALTER TABLE restores ADD COLUMN error TEXT;
|
||||
` }];
|
||||
@@ -0,0 +1,58 @@
|
||||
/* portal/backup/public/backup.js — 백업·복원 화면 스크립트(§19·§20). 서버 JSON(/api/backup/*)을 그려 준다. */
|
||||
(() => {
|
||||
const { api, esc, state, toast, confirmDanger, poll, job, fmt } = window.aw;
|
||||
const bytes = (n) => { n = Number(n) || 0; return n >= 1073741824 ? (n / 1073741824).toFixed(1) + ' GB' : n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(0) + ' KB' : n + ' B'; };
|
||||
const OPT = { active: ['ok', '활성'], paused: ['warning', '중지됨'], ended: ['failed', '종료됨'] };
|
||||
const RS = { awaiting_payment: ['waiting', '결제 대기'], awaiting_approval: ['waiting', '승인 대기'], queued: ['waiting', '처리 대기'], running: ['running', '진행 중'], needs_user: ['warning', '사용자 확인 필요'], done: ['done', '완료'], failed: ['failed', '실패'], reverted: ['ok', '되돌림 완료'], rejected: ['failed', '거절됨'], cancelled: ['neutral', '취소됨'] };
|
||||
const badge = (m, k) => { const [c, l] = m[k] || ['neutral', k]; return `<span class="badge ${c}">${esc(l)}</span>`; };
|
||||
const v = (x, unit = '') => (x === null || x === undefined || x === '' ? '<span class="muted">미확정(후속 상품정책)</span>' : esc(x) + unit);
|
||||
|
||||
const list = document.getElementById('bk-list');
|
||||
if (list) poll(async () => {
|
||||
try {
|
||||
const d = await api('GET', '/api/backup/overview');
|
||||
if (!d.sites.length) { state(list, 'empty', '사이트가 없습니다. 사이트를 만든 뒤 관리형으로 배포하면 백업 옵션을 살 수 있습니다', { href: '/sites', hrefLabel: '사이트로' }); return; }
|
||||
list.innerHTML = `<div class="card"><p class="sub">${esc(d.mailBackupNotice)} · 외부 배포 사이트에는 백업 옵션을 제공하지 않습니다.</p></div>
|
||||
<table class="r"><thead><tr><th>사이트</th><th>배포</th><th>옵션</th><th>최근 백업</th><th>다음 예정</th><th>작업</th></tr></thead><tbody>${d.sites.map((s) => `<tr><td data-l="사이트"><a href="/sites/${s.id}/backup">${esc(s.name)}</a></td><td data-l="배포">${s.deployMode === 'managed' ? '관리형' : s.deployMode === 'external' ? '외부(백업 미제공)' : '미정'}</td><td data-l="옵션">${s.option ? badge(OPT, s.option.status) + (s.option.pausedReason ? ` <span class="sub">${esc(s.option.pausedReason)}</span>` : '') + (s.option.deleteAfter ? ` <span class="sub">백업 삭제 예정 ${esc(fmt(s.option.deleteAfter))}</span>` : '') : s.eligible ? '<span class="muted">미구매</span>' : '<span class="muted">제공 불가</span>'}</td><td data-l="최근 백업">${s.lastBackup ? `${esc(fmt(s.lastBackup.created_at))} ${s.lastBackup.verify_state === 'verified' ? '✔ 검증됨' : '검증 안 됨'}` : '—'}${s.option && s.option.consecutiveFailures ? ` <span class="err">실패 ${s.option.consecutiveFailures}회</span>` : ''}</td><td data-l="다음 예정">${s.option && s.option.status === 'active' ? esc(fmt(s.option.nextRunAt)) : '—'}</td><td data-l="작업"><a class="btn" href="/sites/${s.id}/backup">${s.option ? '관리' : s.eligible ? '옵션 구매' : '보기'}</a></td></tr>`).join('')}</tbody></table>
|
||||
${d.restores.length ? `<h2>복원 요청</h2><table class="r"><thead><tr><th>사이트</th><th>상태</th><th>요청 시각</th><th>비용</th></tr></thead><tbody>${d.restores.map((r) => `<tr><td data-l="사이트"><a href="/sites/${r.siteId}/backup">${esc(r.siteName)}</a></td><td data-l="상태">${badge(RS, r.status)}</td><td data-l="요청">${esc(fmt(r.createdAt))}</td><td data-l="비용">${r.cost}</td></tr>`).join('')}</tbody></table>` : ''}`;
|
||||
} catch (e) { state(list, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' ? 'stale' : 'failed', e.message, { retry: () => location.reload() }); }
|
||||
}, 15000);
|
||||
|
||||
const el = document.getElementById('bk-site');
|
||||
if (el) {
|
||||
const siteId = el.dataset.site;
|
||||
const render = async () => {
|
||||
let d; try { d = await api('GET', `/api/backup/sites/${siteId}`); } catch (e) { state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: render }); return false; }
|
||||
const p = d.purchase; const o = d.option;
|
||||
const scope = `<p><b>포함:</b> ${Object.values(d.scope).map(esc).join(', ')}<br><b>제외:</b> ${d.excluded.map(esc).join(', ')}</p><p class="sub">${esc(d.mailBackupNotice)}</p>`;
|
||||
const policy = `<dl class="kv"><dt>저장 한도</dt><dd>${p.storageLimitBytes != null ? bytes(p.storageLimitBytes) : v(null)}</dd><dt>현재 사용량</dt><dd>${bytes(p.currentUsageBytes)}</dd><dt>정기 실행</dt><dd>${p.schedule ? '매일 ' + esc(p.schedule.daily || '') + ' (KST)' : v(null)}</dd><dt>보존 개수</dt><dd>${p.retention ? `Daily ${p.retention.daily} · Weekly ${p.retention.weekly} · Monthly ${p.retention.monthly}` : v(null)}</dd><dt>첫 백업 예정</dt><dd>${o ? esc(fmt(o.first_backup_due_at)) + (o.firstBackupPending ? ' (대기 중)' : ' (완료)') : p.firstBackupDueAt ? '구매 후 ' + p.firstBackupWithinHours + '시간 이내' : v(null)}</dd><dt>원격 사본 검증</dt><dd>${v(p.verifyMethod)}</dd><dt>추가 저장 옵션</dt><dd>${(p.extraStorageOptions || []).length ? p.extraStorageOptions.map((x) => esc(x.label || JSON.stringify(x))).join(', ') : '없음'}</dd><dt>복원 비용</dt><dd>${p.restoreCost != null ? esc(p.restoreCost) + ' ' + esc(p.productVersion ? p.productVersion.currency : '') : v(null)}</dd><dt>옵션 만료 후 보관</dt><dd>${p.expiryRetentionDays}일 뒤 삭제</dd></dl>${p.deferred.length ? `<p class="err">미확정 정책 항목: ${p.deferred.map(esc).join(', ')}</p>` : ''}`;
|
||||
let head;
|
||||
if (!o || o.status === 'ended') head = `<div class="card"><h2>백업 옵션</h2>${o ? `<p>${badge(OPT, 'ended')} 종료 ${esc(fmt(o.ends_at))} · 백업 삭제 예정 ${esc(fmt(o.delete_after))} (보존 중에는 복원 요청 가능)</p>` : ''}${p.eligible ? `<p>${p.productVersion ? `${esc(p.productVersion.name)} · ${p.productVersion.price} ${esc(p.productVersion.currency)} / ${esc(p.productVersion.billingCycle)}` : ''}</p>${policy}${scope}<button class="btn primary" id="bk-buy">백업 옵션 활성화</button>` : `<div class="aw-state"><b>이 사이트에는 제공하지 않습니다</b><p>${p.reasons.map(esc).join('<br>')}</p></div>`}</div>`;
|
||||
else head = `<div class="card"><h2>백업 옵션 ${badge(OPT, o.status)}${o.pausedReasonLabel ? ` <span class="sub">사유: ${esc(o.pausedReasonLabel)}</span>` : ''}</h2>${policy}<dl class="kv"><dt>마지막 성공</dt><dd>${o.last_success_at ? esc(fmt(o.last_success_at)) : '아직 없음'}</dd><dt>마지막 실패</dt><dd>${o.last_failure_at ? esc(fmt(o.last_failure_at)) + ` (연속 ${o.consecutive_failures}회, ${o.failureNotifyAfter}회부터 알림)` : '없음'}</dd><dt>다음 예정</dt><dd>${o.status === 'active' ? esc(fmt(o.next_run_at)) : '중지됨'}</dd><dt>저장 서버</dt><dd>${o.store_server_id ? '운영 서버와 분리된 저장소' : '첫 백업 때 결정'}</dd><dt>재개 방식</dt><dd>${o.resume_mode === 'auto' ? '사이트 재개 후 자동' : '사이트 재개 후 사용자 확인'}</dd></dl>${scope}<div class="actions">${o.status === 'active' ? '<button class="btn" id="bk-run">지금 백업</button>' : o.status === 'paused' ? '<button class="btn primary" id="bk-resume">정기 백업 다시 시작</button>' : ''}<button class="btn danger" id="bk-end">옵션 해지</button></div>${d.lastRun ? '<h3>최근 실행</h3><div id="bk-job"></div>' : ''}</div>`;
|
||||
const rows = d.backups.map((b) => `<tr><td data-l="생성">${esc(fmt(b.createdAt))}</td><td data-l="등급">${esc(b.tier || b.kind)}</td><td data-l="범위">${Object.keys(b.scope).filter((k) => b.scope[k] === true).join(', ')}</td><td data-l="크기">${bytes(b.sizeBytes)}</td><td data-l="검증">${b.verifyState === 'verified' ? '<span class="badge ok">✔ 검증됨</span>' : '<span class="badge warning">! 미검증</span>'}</td><td data-l="만료">${esc(fmt(b.expiresAt))}</td><td data-l="작업">${b.canRestore ? `<a class="btn" href="/sites/${siteId}/restore/${b.id}">복원 요청</a>` : '—'}</td></tr>`).join('');
|
||||
el.innerHTML = head + `<div class="card"><h2>보관 목록</h2>${rows ? `<table class="r"><thead><tr><th>생성</th><th>등급</th><th>범위</th><th>크기</th><th>검증</th><th>만료</th><th>작업</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="aw-state empty"><b>비어 있음</b><p>아직 완료된 백업이 없습니다</p></div>'}</div>
|
||||
${d.restores.length ? `<div class="card"><h2>복원 기록</h2>${d.restores.map((r) => `<div class="kv-row"><p>${badge(RS, r.status)} 요청 ${esc(fmt(r.created_at))} · 백업 ${esc(r.backup_id)} · 비용 ${r.cost}${r.protect_until ? ` · 보호 시점 유지 ${esc(fmt(r.protect_until))}` : ''}${r.revert_until ? ` · 되돌리기 가능 ${esc(fmt(r.revert_until))} 까지` : ''}</p><div id="rs-${r.id}"></div></div>`).join('')}</div>` : ''}`;
|
||||
if (d.lastRun) job('#bk-job', d.lastRun);
|
||||
for (const r of d.restores) if (r.job) job(`#rs-${r.id}`, r.job, { onAction: async (a) => { try { if (a === 'confirm' || a === 'revert') { if (a === 'revert' && !(await confirmDanger({ title: '복원 전으로 되돌리기', target: '이 사이트', impacts: ['복원 뒤 생성된 데이터는 사라집니다', '보호 시점으로 되돌립니다'], confirmLabel: '되돌리기' }))) return; await api('POST', `/api/backup/restores/${r.id}/decision`, { action: a, confirm: true }); } else if (a === 'cancel') await api('POST', `/api/backup/restores/${r.id}/cancel`); toast('처리했습니다'); render(); } catch (e) { toast(e.message); } } });
|
||||
const q = (id) => document.getElementById(id);
|
||||
if (q('bk-buy')) q('bk-buy').onclick = async () => { if (!(await confirmDanger({ title: '백업 옵션 활성화', target: '이 사이트', impacts: ['백업 권리 1개를 사용합니다', `첫 백업은 ${p.firstBackupWithinHours}시간 이내에 완료됩니다`], confirmLabel: '활성화' }))) return; try { await api('POST', `/api/backup/sites/${siteId}/option`, { confirm: true }); toast('활성화했습니다'); render(); } catch (e) { toast(e.message + (e.detail && e.detail.link ? ' — 상품 구매로 이동' : '')); if (e.detail && e.detail.link) setTimeout(() => (location.href = e.detail.link), 1200); } };
|
||||
if (q('bk-run')) q('bk-run').onclick = async () => { try { await api('POST', `/api/backup/sites/${siteId}/run`); toast('백업을 시작했습니다'); render(); } catch (e) { toast(e.message); } };
|
||||
if (q('bk-resume')) q('bk-resume').onclick = async () => { try { await api('POST', `/api/backup/sites/${siteId}/option/resume`); toast('다시 시작합니다'); render(); } catch (e) { toast(e.message); } };
|
||||
if (q('bk-end')) q('bk-end').onclick = async () => { if (!(await confirmDanger({ title: '백업 옵션 해지', target: '이 사이트의 백업 옵션', impacts: ['새 백업이 중지됩니다', `기존 백업은 ${p.expiryRetentionDays}일 보관 뒤 삭제됩니다`, '삭제 뒤에는 복구할 수 없습니다'], typeToConfirm: '해지', confirmLabel: '해지' }))) return; try { await api('POST', `/api/backup/sites/${siteId}/option/end`, { confirm: true }); toast('해지했습니다'); render(); } catch (e) { toast(e.message); } };
|
||||
return true;
|
||||
};
|
||||
poll(render, 8000);
|
||||
}
|
||||
|
||||
const rs = document.getElementById('bk-restore');
|
||||
if (rs) (async () => {
|
||||
const siteId = rs.dataset.site; const bid = rs.dataset.backup;
|
||||
try {
|
||||
const d = await api('GET', `/api/backup/backups/${bid}`); const i = d.restoreInfo; const b = i.backup;
|
||||
rs.innerHTML = `<div class="card"><h2>복원할 백업</h2><dl class="kv"><dt>생성 시각</dt><dd>${esc(fmt(b.createdAt))}</dd><dt>포함 데이터</dt><dd>${i.included.map(esc).join(', ')}</dd><dt>제외</dt><dd>${i.excluded.map(esc).join(', ')}</dd><dt>검증</dt><dd>${esc(b.verifyState)}</dd><dt>서비스 영향</dt><dd>${esc(i.impact)}</dd><dt>예상 중단 시간</dt><dd>${i.estimatedDowntimeMin != null ? i.estimatedDowntimeMin + '분' : v(null)}</dd><dt>복원 비용</dt><dd>${i.cost != null ? i.cost + ' ' + esc(i.currency) : v(null)}</dd><dt>되돌리기 범위</dt><dd>복원 직전 보호 시점(${i.protectPointDays != null ? i.protectPointDays + '일 유지' : v(null)})으로 ${i.revertHours != null ? i.revertHours + '시간' : v(null)} 안에 되돌릴 수 있습니다</dd><dt>완료 방식</dt><dd>${i.completionMode === 'user_confirm' ? '고객 확인 후 완료' : '자동 완료'}</dd><dt>승인</dt><dd>운영자 승인 뒤 시작${i.cost ? ' (비용 확인·결제 선행)' : ''}</dd></dl>
|
||||
${i.conflicts.length ? `<p class="err">진행 중인 작업이 끝나야 요청할 수 있습니다: ${i.conflicts.map((c) => esc(c.type + ' ' + c.status)).join(', ')}</p>` : ''}
|
||||
<div class="actions"><a class="btn" href="/sites/${siteId}/backup">뒤로</a><button class="btn primary" id="rs-go" ${i.canRequest ? '' : 'disabled'}>복원 요청</button></div></div>`;
|
||||
document.getElementById('rs-go').onclick = async () => { if (!(await confirmDanger({ title: '복원 요청', target: `백업 ${fmt(b.createdAt)}`, impacts: [i.impact, '운영자 승인과 (해당 시) 결제 확인 뒤 시작됩니다', '시작 전 최종 확인 화면에 그룹·사이트·도메인이 표시됩니다'], confirmLabel: '요청' }))) return; try { const r = await api('POST', `/api/backup/sites/${siteId}/restore`, { backupId: bid, confirm: true }); toast(r.created ? `복원 요청 접수 (${r.finalConfirmation.group} / ${r.finalConfirmation.site} / ${r.finalConfirmation.domain || '도메인 없음'})` : '이미 열린 요청이 있습니다'); location.href = `/sites/${siteId}/backup`; } catch (e) { toast(e.message); } };
|
||||
} catch (e) { state(rs, e.status === 403 ? 'forbidden' : 'failed', e.message); }
|
||||
})();
|
||||
})();
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* portal/backup/service.mjs — 관리형 사이트 백업 옵션(§19)과 복원(§20)의 업무 규칙.
|
||||
*
|
||||
* 흐름
|
||||
* 구매(purchaseOption) → 옵션 활성 → 정기 실행(runDue → 작업 backup.scheduled) → HAL backup.create(운영 서버와 다른 저장 서버) → 검증 → 보관 목록 → 보존 정리(prune)
|
||||
* 복원 요청(requestRestore) → 비용·결제 → 운영자 승인(approveRestore) → 작업 backup.restore(§20.2 의 9단계) → 사용자 확인 또는 자동 완료 → 이전 상태 정리
|
||||
* 옵션 만료(expireOption) → 백업 7일 보관 뒤 삭제(runDue 가 삭제 작업)
|
||||
* 정책 값은 활성 상품정책(product_versions, product_id 'backup')에서 읽고 코드에 고정하지 않는다. 값이 없으면 deferred 로 화면에 "미확정" 을 보인다.
|
||||
* 모든 작업은 lib/jobs 로 내구성 있게 실행되며 HAL 호출은 단계로 나누어 재시작 뒤 이어서 진행한다(제출 단계 결과에 HAL 작업 id 저장 → 대기 단계는 retryLater 로 다시 확인).
|
||||
*/
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { newId } from '../../lib/crypto.mjs';
|
||||
import { notify, resolveIncident } from '../../lib/notify.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
import * as quota from '../../lib/quota.mjs';
|
||||
import { JobError } from '../../lib/jobs.mjs';
|
||||
import { kstDate, nextMonthStart } from '../../lib/kst.mjs';
|
||||
import { productPolicy, halOf, asTransient, inProgress, nowIso, parseJson, toJson, addMs, DAY, HOUR, optional } from '../lifecycle/common.mjs';
|
||||
|
||||
export const PRODUCT_ID = 'backup';
|
||||
/** 구매에 꼭 필요한 정책 항목(없으면 구매 버튼 대신 미확정 안내) */
|
||||
export const REQUIRED_POLICY = ['limits.storage_bytes', 'policy.schedule', 'policy.retention', 'policy.first_backup_within_hours', 'policy.verify_method', 'policy.restore_cost', 'policy.restore_completion', 'policy.protect_point_days', 'policy.revert_hours'];
|
||||
export const EXPIRY_RETENTION_DAYS = 7; // §20.3 옵션 만료 후 백업 보관 일수(기본 정책)
|
||||
export const SCOPE = { files: '현재 배포본(파일)', db: '데이터베이스', uploads: '업로드 데이터', config: '복원에 필요한 사이트 설정' };
|
||||
export const EXCLUDED = ['메일 원문', '소스 저장소', 'Webmaker 프로젝트 이력', '외부 배포 데이터', '플랫폼 내부 복구 사본'];
|
||||
|
||||
/** 사이트가 백업 옵션을 살 수 있는지(관리형·운영/제작 상태·외부 배포 아님 §19.1) */
|
||||
export function eligibility(deps, site) {
|
||||
const reasons = [];
|
||||
if (!site) return { ok: false, reasons: ['사이트가 없습니다'] };
|
||||
if (site.deploy_mode !== 'managed') reasons.push(site.deploy_mode === 'external' ? '외부 배포 사이트에는 백업 옵션을 제공하지 않습니다' : '관리형 배포로 설정된 사이트만 백업 옵션을 살 수 있습니다');
|
||||
if (['deleted', 'pending_delete', 'readonly_retention', 'cancel_scheduled'].includes(site.lifecycle)) reasons.push('해지·보존 중인 사이트에는 새 옵션을 추가할 수 없습니다');
|
||||
const existing = optionOf(deps, site.id);
|
||||
if (existing && existing.status !== 'ended') reasons.push('이 사이트에는 이미 백업 옵션이 있습니다(사이트당 하나)');
|
||||
const pol = productPolicy(deps, PRODUCT_ID, REQUIRED_POLICY);
|
||||
if (!pol.version) reasons.push('백업 상품정책이 아직 활성화되지 않았습니다');
|
||||
else if (pol.deferred.length) reasons.push(`상품정책의 미확정 항목(${pol.deferred.join(', ')})이 정해져야 구매할 수 있습니다`);
|
||||
return { ok: reasons.length === 0, reasons, policy: pol };
|
||||
}
|
||||
export function optionOf(deps, siteId) { return deps.store.get('SELECT * FROM backup_options WHERE site_id=?', siteId) || null; }
|
||||
export function optionById(deps, id) { return deps.store.get('SELECT * FROM backup_options WHERE id=?', id) || null; }
|
||||
/** 보관 중(삭제되지 않은) 백업의 총 크기 */
|
||||
export function storedBytes(deps, siteId) { return deps.store.get('SELECT COALESCE(SUM(size_bytes),0) AS n FROM backups WHERE site_id=? AND deleted_at IS NULL AND status=\'done\'', siteId).n; }
|
||||
/** 화면용 구매 정보(§19.1): 저장 한도·현재 사용량·일정·보존 개수·첫 백업 예정·검증 방식·추가 저장 옵션·포함/제외 */
|
||||
export function purchaseInfo(deps, site) {
|
||||
const el = eligibility(deps, site); const { limits, policy, deferred, version } = el.policy;
|
||||
return {
|
||||
eligible: el.ok, reasons: el.reasons, deferred, productVersion: version ? { id: version.id, name: version.name, version: version.version, price: version.price, currency: version.currency, taxRate: version.tax_rate, billingCycle: version.billing_cycle } : null,
|
||||
storageLimitBytes: limits.storage_bytes ?? null, currentUsageBytes: storedBytes(deps, site.id), schedule: policy.schedule ?? null, retention: policy.retention ?? null,
|
||||
firstBackupWithinHours: policy.first_backup_within_hours ?? null, firstBackupDueAt: policy.first_backup_within_hours ? addMs(nowIso(), policy.first_backup_within_hours * HOUR) : null,
|
||||
verifyMethod: policy.verify_method ?? null, extraStorageOptions: limits.extra_storage_options ?? [], restoreCost: policy.restore_cost ?? null, restoreCompletion: policy.restore_completion ?? null,
|
||||
scope: SCOPE, excluded: EXCLUDED, expiryRetentionDays: policy.expiry_retention_days ?? EXPIRY_RETENTION_DAYS,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 옵션 구매(결제·승인은 주문 영역이 맡는다). 여기서는 권리(backup 자원) 1개를 예약→확정해 옵션을 활성화한다.
|
||||
* 권리가 없으면 QuotaError(quota_insufficient) → 화면은 상품 구매를 안내한다.
|
||||
*/
|
||||
export function purchaseOption(deps, site, { by, orderId = null } = {}) {
|
||||
const el = eligibility(deps, site); if (!el.ok) throw new HttpError(409, 'backup_not_eligible', el.reasons.join(' / '), { reasons: el.reasons });
|
||||
const { limits, policy, version } = el.policy; const now = nowIso();
|
||||
return deps.store.tx(() => {
|
||||
const r = quota.reserve(deps.store, { groupId: site.group_id, resource: 'backup', amount: 1, subjectType: 'site', subjectId: site.id, reason: 'backup_option' }); quota.commit(deps.store, r.id);
|
||||
const alloc = quota.allocationOf(deps.store, site.group_id, 'backup', 'site', site.id);
|
||||
const id = newId('bo_'); const first = addMs(now, (policy.first_backup_within_hours || 24) * HOUR);
|
||||
deps.store.run('INSERT INTO backup_options(id, site_id, group_id, product_version_id, order_id, status, schedule_json, retention_json, storage_limit_bytes, first_backup_due_at, next_run_at, resume_mode, allocation_id, created_at, updated_at) VALUES(?,?,?,?,?,\'active\',?,?,?,?,?,?,?,?,?)',
|
||||
id, site.id, site.group_id, version.id, orderId, toJson(policy.schedule), toJson(policy.retention), limits.storage_bytes, first, now, policy.resume_after_pause || 'auto', alloc ? alloc.id : null, now, now);
|
||||
audit(deps.store, { actorType: 'user', actorId: by, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'backup.option.activate', detail: { optionId: id, productVersionId: version.id } });
|
||||
notify(deps.store, { groupId: site.group_id, category: 'backup', title: `${site.name}: 백업 옵션이 활성화되었습니다`, body: `첫 백업은 ${first} 까지 완료됩니다. 진행 상태는 사이트 › 백업 탭에서 확인하세요`, link: `/sites/${site.id}/backup`, subjectType: 'site', subjectId: site.id });
|
||||
return optionById(deps, id);
|
||||
});
|
||||
}
|
||||
/** 일정(JSON {daily:'HH:MM'(KST)}) 기준 다음 실행 시각. 단순화: 매일 지정 시각(KST). */
|
||||
export function nextRunFrom(schedule, from = nowIso()) {
|
||||
const hhmm = String((schedule && schedule.daily) || '03:00'); const [h, m] = hhmm.split(':').map(Number);
|
||||
const d = new Date(from); const kstMs = d.getTime() + 9 * HOUR; const day = new Date(kstMs); day.setUTCHours(h, m || 0, 0, 0);
|
||||
let next = day.getTime() - 9 * HOUR; if (next <= d.getTime()) next += DAY; return new Date(next).toISOString();
|
||||
}
|
||||
/** 이번 실행의 보존 등급: 그 달 첫 실행 → monthly, 일요일(KST) → weekly, 그 밖 → daily */
|
||||
export function tierFor(deps, siteId, at = nowIso()) {
|
||||
const month = kstDate(at).slice(0, 7);
|
||||
const monthly = deps.store.get('SELECT 1 AS x FROM backups WHERE site_id=? AND tier=\'monthly\' AND substr(created_at,1,7)=? AND deleted_at IS NULL', siteId, month);
|
||||
if (!monthly) return 'monthly';
|
||||
return new Date(Date.parse(at) + 9 * HOUR).getUTCDay() === 0 ? 'weekly' : 'daily';
|
||||
}
|
||||
/** 정기 백업 중지(사이트 일시 중지·해지 §19.2·§35). 다른 영역(생명주기)이 부른다. */
|
||||
export function stopForSite(deps, siteId, reason = 'site_paused') {
|
||||
const o = optionOf(deps, siteId); if (!o || o.status !== 'active') return null;
|
||||
deps.store.run('UPDATE backup_options SET status=\'paused\', paused_reason=?, updated_at=? WHERE id=?', reason, nowIso(), o.id);
|
||||
notify(deps.store, { groupId: o.group_id, category: 'backup', incidentKey: `backup:${o.id}:paused`, title: '정기 백업이 중지되었습니다', body: `사유: ${reasonLabel(reason)}. 기존 백업은 정책에 따라 유지됩니다. 사이트 재개 후 ${o.resume_mode === 'auto' ? '자동으로 다시 시작됩니다' : '백업 화면에서 다시 시작을 확인해야 합니다'}`, link: `/sites/${siteId}/backup`, subjectType: 'site', subjectId: siteId });
|
||||
return optionById(deps, o.id);
|
||||
}
|
||||
/** 재개: resume_mode 가 auto 면 생명주기 영역이 자동으로, confirm 이면 사용자가 버튼으로 부른다 */
|
||||
export function resumeForSite(deps, siteId, { by = 'system', userConfirmed = false, clearStorageFull = false } = {}) {
|
||||
const o = optionOf(deps, siteId); if (!o || o.status !== 'paused') return o;
|
||||
// 저장 공간 부족으로 멈춘 옵션은 용량이 늘거나 복구점이 정리되어 여유가 생겨야 재개된다(운영자 clearStorageFull 또는 실제 여유 확인)
|
||||
if (o.paused_reason === 'storage_full' && !clearStorageFull) { const used = storedBytes(deps, siteId); const last = (deps.store.get('SELECT size_bytes FROM backups WHERE site_id=? AND status=\'done\' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId) || {}).size_bytes || 0; if (o.storage_limit_bytes && used + last > o.storage_limit_bytes) throw new HttpError(409, 'backup_storage_full', '저장 공간이 가득 차 재개할 수 없습니다. 추가 용량을 적용하거나 오래된 복구점을 정리하세요', { used, limit: o.storage_limit_bytes }); }
|
||||
if (o.resume_mode === 'confirm' && by === 'system' && !userConfirmed) return o; // 사용자 확인 필요
|
||||
deps.store.run('UPDATE backup_options SET status=\'active\', paused_reason=NULL, next_run_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id);
|
||||
return optionById(deps, o.id);
|
||||
}
|
||||
export function reasonLabel(r) { return { site_paused: '사이트 일시 중지', site_cancelled: '사이트 해지', storage_full: '저장 공간 부족', operator: '운영자 조치', option_ended: '옵션 종료' }[r] || r; }
|
||||
/** 옵션 종료(만료·해지): 새 백업 중지, 백업은 7일 보관 뒤 삭제(§20.3·§23.2). 권리 반환. */
|
||||
export function expireOption(deps, optionId, { by = 'system', reason = 'option_ended' } = {}) {
|
||||
const o = optionById(deps, optionId); if (!o || o.status === 'ended') return o;
|
||||
const pol = productPolicy(deps, PRODUCT_ID); const days = pol.policy.expiry_retention_days ?? EXPIRY_RETENTION_DAYS; const now = nowIso(); const del = addMs(now, days * DAY);
|
||||
deps.store.run('UPDATE backup_options SET status=\'ended\', paused_reason=?, ends_at=?, delete_after=?, next_run_at=NULL, updated_at=? WHERE id=?', reason, now, del, now, o.id);
|
||||
deps.store.run('UPDATE backups SET expires_at=? WHERE option_id=? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at>?)', del, o.id, del);
|
||||
if (o.allocation_id) quota.finishReturn(deps.store, o.allocation_id);
|
||||
audit(deps.store, { actorType: by === 'system' ? 'system' : 'user', actorId: by, groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, action: 'backup.option.end', detail: { optionId: o.id, deleteAfter: del, reason } });
|
||||
notify(deps.store, { groupId: o.group_id, category: 'backup', severity: 'warning', mandatory: true, title: '백업 옵션이 종료되었습니다 — 백업 삭제 예정', body: `기존 백업은 ${del} 까지 보관된 뒤 삭제됩니다. 보존 중에는 복원을 요청할 수 있습니다(복원 비용은 정책에 따릅니다)`, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id, incidentKey: `backup:${o.id}:ended` });
|
||||
return optionById(deps, o.id);
|
||||
}
|
||||
/** 저장 서버 고르기: 사이트 운영 서버와 다른 서버(backup 역할 우선, 없으면 web). 운영자 override 가 아니면 같은 서버는 절대 고르지 않는다. */
|
||||
export async function pickStoreServer(deps, site, option) {
|
||||
const hal = halOf(deps); let servers = [];
|
||||
try { servers = await hal.listServers({}); } catch (e) { throw asTransient(e); }
|
||||
const ok = servers.filter((s) => s.acceptsNew && s.status === 'ready' && ['backup', 'web'].includes(s.role));
|
||||
const pref = ok.filter((s) => s.id !== site.server_id).sort((a, b) => (a.role === 'backup' ? -1 : 1) - (b.role === 'backup' ? -1 : 1) || ((b.capacity?.diskFreeBytes || 0) - (a.capacity?.diskFreeBytes || 0)));
|
||||
if (pref.length) return pref[0].id;
|
||||
if (option.same_server_override && ok.some((s) => s.id === site.server_id)) return site.server_id;
|
||||
throw new JobError('no_backup_store', '운영 서버와 분리된 백업 저장 서버가 없습니다. 운영자가 백업 서버를 등록하거나 같은 서버 저장을 명시적으로 허용해야 합니다', { retryable: true });
|
||||
}
|
||||
/** 보존 정리(§19.1 Daily·Weekly·Monthly 개수). 최소 보존 세트(등급별 최신 1개·전체 최신 1개)는 어떤 경우에도 지우지 않는다. */
|
||||
export function pruneList(deps, option) {
|
||||
const ret = parseJson(option.retention_json, {}) || {}; const all = deps.store.all('SELECT * FROM backups WHERE option_id=? AND deleted_at IS NULL AND status=\'done\' ORDER BY created_at DESC', option.id);
|
||||
const keep = new Set(); if (all[0]) keep.add(all[0].id);
|
||||
const toDelete = [];
|
||||
for (const tier of ['daily', 'weekly', 'monthly']) { const list = all.filter((b) => b.tier === tier); const n = Math.max(1, Number(ret[tier] ?? 1)); list.forEach((b, i) => { if (i < n) keep.add(b.id); }); if (list[0]) keep.add(list[0].id); }
|
||||
for (const b of all) if (!keep.has(b.id)) toDelete.push(b);
|
||||
return toDelete;
|
||||
}
|
||||
/** 실행 예정 옵션 → 작업 넣기(주기 작업). 만료 옵션의 삭제 예정도 처리. */
|
||||
export function runDue(deps, now = nowIso()) {
|
||||
const started = [];
|
||||
for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND next_run_at IS NOT NULL AND next_run_at<=?', now)) {
|
||||
const site = deps.store.get('SELECT * FROM sites WHERE id=?', o.site_id);
|
||||
if (!site || !['live', 'needs_domain', 'deploying'].includes(site.lifecycle)) { stopForSite(deps, o.site_id, site && site.lifecycle === 'paused' ? 'site_paused' : 'site_cancelled'); continue; }
|
||||
const slot = kstDate(now) + ':' + String(o.next_run_at);
|
||||
const { job } = deps.jobs.enqueue({ kind: 'backup.scheduled', subject: { optionId: o.id, siteId: o.site_id, groupId: o.group_id, slot }, idempotencyKey: `backup:${o.id}:${slot}`, actorType: 'system', groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, label: `정기 백업 (${site.name})` });
|
||||
deps.store.run('UPDATE backup_options SET next_run_at=?, updated_at=? WHERE id=?', nextRunFrom(parseJson(o.schedule_json, {}), now), now, o.id);
|
||||
started.push(job.id);
|
||||
}
|
||||
// 만료된 옵션의 백업 삭제(delete_after 경과)
|
||||
for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'ended\' AND delete_after IS NOT NULL AND delete_after<=?', now)) {
|
||||
const left = deps.store.all('SELECT id FROM backups WHERE option_id=? AND deleted_at IS NULL', o.id);
|
||||
if (!left.length) { deps.store.run('UPDATE backup_options SET delete_after=NULL, updated_at=? WHERE id=?', now, o.id); continue; }
|
||||
const { job } = deps.jobs.enqueue({ kind: 'backup.purge', subject: { optionId: o.id, siteId: o.site_id, groupId: o.group_id }, idempotencyKey: `backup-purge:${o.id}:${o.delete_after}`, actorType: 'system', groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, label: '만료 백업 삭제' }); started.push(job.id);
|
||||
}
|
||||
// 첫 백업 기한 초과 경고
|
||||
for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND first_backup_due_at<? AND last_success_at IS NULL', now)) notify(deps.store, { groupId: o.group_id, category: 'backup', severity: 'warning', incidentKey: `backup:${o.id}:first_overdue`, title: '첫 백업이 기한 안에 완료되지 않았습니다', body: '운영자가 확인 중입니다. 진행 상태는 백업 화면에서 볼 수 있습니다', link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id });
|
||||
return started;
|
||||
}
|
||||
/** HAL 작업 제출·대기 단계 공통: 제출 결과(halJobId)는 단계 결과로 저장되어 재시작 뒤 다시 제출하지 않는다. */
|
||||
async function halSubmit(deps, ctx, stepId, title, req) { return ctx.step(stepId, async () => { try { const r = await halOf(deps).createJob(req); return { halJobId: r.halJobId }; } catch (e) { throw asTransient(e); } }, title); }
|
||||
async function halWait(deps, ctx, stepId, title, halJobId, { pollMs = 5000 } = {}) {
|
||||
return ctx.step(stepId, async () => {
|
||||
let j; try { j = await halOf(deps).getJob(halJobId); } catch (e) { throw asTransient(e); }
|
||||
if (['done'].includes(j.status)) return j.result || {};
|
||||
if (['failed', 'cancelled', 'reverted'].includes(j.status)) throw new JobError(j.errorCode || 'hal_job_failed', j.error || `HAL 작업이 ${j.status} 로 끝났습니다`);
|
||||
if (j.status === 'needs_user') ctx.needsUser(j.error || 'HAL 작업이 운영자 확인을 기다립니다');
|
||||
ctx.retryLater(pollMs, `HAL 작업 ${halJobId} 진행 중 (${j.status})`);
|
||||
}, title);
|
||||
}
|
||||
/** 작업 종류 등록 */
|
||||
export function registerJobs(deps) {
|
||||
deps.jobs.register('backup.scheduled', { title: '정기 백업', maxAttempts: 3, retryable: (e) => !!e.retryable, steps: [{ id: 'precheck', title: '사전 확인(옵션·저장 공간)' }, { id: 'pick', title: '저장 서버 선택' }, { id: 'submit', title: '백업 생성 요청' }, { id: 'wait', title: '백업 생성·전송' }, { id: 'verify', title: '사본 읽기 검증' }, { id: 'record', title: '보관 목록 기록' }, { id: 'prune', title: '보존 정리' }],
|
||||
async run(job, ctx) {
|
||||
const { optionId, siteId } = job.subject; const o = optionById(deps, optionId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', siteId);
|
||||
if (!o || o.status !== 'active') throw new JobError('backup_option_inactive', '백업 옵션이 활성 상태가 아닙니다');
|
||||
await ctx.step('precheck', () => {
|
||||
const used = storedBytes(deps, siteId); const limit = o.storage_limit_bytes || 0; const est = Math.max(1, (deps.store.get('SELECT size_bytes FROM backups WHERE site_id=? AND status=\'done\' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId) || {}).size_bytes || 0);
|
||||
if (limit && used + est > limit && pruneList(deps, o).length === 0) { deps.store.run('UPDATE backup_options SET status=\'paused\', paused_reason=\'storage_full\', updated_at=? WHERE id=?', nowIso(), o.id); notify(deps.store, { groupId: o.group_id, category: 'backup', severity: 'critical', mandatory: true, incidentKey: `backup:${o.id}:storage_full`, title: '백업 저장 공간이 부족해 다음 백업을 중지했습니다', body: `최소 보존 세트는 삭제하지 않습니다. 추가 저장 옵션을 적용하거나 오래된 복구점을 정리한 뒤 백업 화면에서 다시 시작하세요 (사용 ${used} / 한도 ${limit} 바이트)`, link: `/sites/${siteId}/backup`, subjectType: 'site', subjectId: siteId }); throw new JobError('backup_storage_full', '저장 공간 부족 — 백업 중지'); }
|
||||
return { used, limit };
|
||||
});
|
||||
const { storeServerId } = await ctx.step('pick', async () => ({ storeServerId: await pickStoreServer(deps, site, o) }));
|
||||
const tier = tierFor(deps, siteId, job.created_at);
|
||||
const { halJobId } = await halSubmit(deps, ctx, 'submit', '백업 생성 요청', { kind: 'backup.create', target: site.server_id, payload: { siteId, scope: { files: true, db: true, uploads: true, config: true }, storeServerId, retentionTag: tier }, idempotencyKey: `hal:${job.id}:create`, groupId: o.group_id, subjectType: 'site', subjectId: siteId });
|
||||
const res = await halWait(deps, ctx, 'wait', '백업 생성·전송', halJobId);
|
||||
const verified = await ctx.step('verify', async () => { if (res.verified) return true; try { const r = await halOf(deps).createJob({ kind: 'backup.verify', target: storeServerId, payload: { location: res.location, checksum: res.checksum }, idempotencyKey: `hal:${job.id}:verify` }); const j = await halOf(deps).getJob(r.halJobId); if (!(j.result && j.result.ok)) throw new JobError('backup_verify_failed', '백업 사본을 읽을 수 없습니다'); return true; } catch (e) { throw asTransient(e); } });
|
||||
const backupId = await ctx.step('record', () => { const id = newId('bk_'); const pol = productPolicy(deps, PRODUCT_ID); const ret = parseJson(o.retention_json, {}) || {}; const days = { daily: Number(ret.daily || 7), weekly: Number(ret.weekly || 4) * 7, monthly: Number(ret.monthly || 3) * 31 }[tier]; deps.store.run('INSERT INTO backups(id, site_id, group_id, option_id, kind, tier, created_at, completed_at, scope_json, size_bytes, location, checksum, verify_state, verified_at, expires_at, status, job_id) VALUES(?,?,?,?,\'scheduled\',?,?,?,?,?,?,?,?,?,?,\'done\',?)', id, siteId, o.group_id, o.id, tier, job.created_at, nowIso(), toJson({ files: true, db: true, uploads: true, config: true, storeServerId, verifyMethod: pol.policy.verify_method || null }), res.sizeBytes || 0, res.location, res.checksum, verified ? 'verified' : 'unverified', verified ? nowIso() : null, addMs(nowIso(), days * DAY), job.id); deps.store.run('UPDATE backup_options SET last_success_at=?, consecutive_failures=0, store_server_id=?, updated_at=? WHERE id=?', nowIso(), storeServerId, nowIso(), o.id); return id; });
|
||||
await ctx.step('prune', async () => { const del = pruneList(deps, o); for (const b of del) { try { await halOf(deps).createJob({ kind: 'backup.delete', target: storeServerId, payload: { location: b.location }, idempotencyKey: `hal:${job.id}:del:${b.id}` }); } catch (e) { throw asTransient(e); } deps.store.run('UPDATE backups SET deleted_at=?, status=\'deleted\' WHERE id=?', nowIso(), b.id); } return { deleted: del.length }; });
|
||||
resolveIncident(deps.store, `backup:${o.id}:failed`, '백업이 다시 정상적으로 완료되었습니다'); resolveIncident(deps.store, `ops:backup:${o.id}:failed`, '정상화');
|
||||
return { backupId, tier, storeServerId };
|
||||
} });
|
||||
deps.jobs.register('backup.purge', { title: '만료 백업 삭제', maxAttempts: 5, retryable: (e) => !!e.retryable, steps: [{ id: 'delete', title: '백업 사본 삭제' }],
|
||||
async run(job, ctx) { const o = optionById(deps, job.subject.optionId); await ctx.step('delete', async () => { let n = 0; for (const b of deps.store.all('SELECT * FROM backups WHERE option_id=? AND deleted_at IS NULL', o.id)) { if (b.location) { try { await halOf(deps).createJob({ kind: 'backup.delete', target: o.store_server_id, payload: { location: b.location }, idempotencyKey: `hal:${job.id}:del:${b.id}` }); } catch (e) { throw asTransient(e); } } deps.store.run('UPDATE backups SET deleted_at=?, status=\'deleted\' WHERE id=?', nowIso(), b.id); n++; } deps.store.run('UPDATE backup_options SET delete_after=NULL, updated_at=? WHERE id=?', nowIso(), o.id); notify(deps.store, { groupId: o.group_id, category: 'backup', mandatory: true, title: '만료된 백업이 삭제되었습니다', body: `삭제된 백업 ${n}개. 삭제된 백업은 복구할 수 없습니다`, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id }); return { deleted: n }; }); } });
|
||||
deps.jobs.register('backup.restore', { title: '사이트 복원', maxAttempts: 2, retryable: (e) => !!e.retryable,
|
||||
steps: [{ id: 'received', title: '요청 접수' }, { id: 'approval', title: '비용과 승인 확인' }, { id: 'protect', title: '현재 상태 보호' }, { id: 'isolated', title: '격리된 환경에서 백업 확인' }, { id: 'restore', title: '데이터 복원' }, { id: 'function', title: '사이트 기능 확인' }, { id: 'external', title: '외부 접속 확인' }, { id: 'confirm', title: '사용자 확인 또는 자동 완료' }, { id: 'cleanup', title: '이전 상태 정리' }],
|
||||
async run(job, ctx) {
|
||||
const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id); const b = deps.store.get('SELECT * FROM backups WHERE id=?', r.backup_id); const pol = parseJson(r.policy_json, {}) || {};
|
||||
const setStatus = (s, extra = {}) => deps.store.run(`UPDATE restores SET status=?, updated_at=? ${Object.keys(extra).map((k) => `, ${k}=?`).join('')} WHERE id=?`, s, nowIso(), ...Object.values(extra), r.id);
|
||||
await ctx.step('received', () => { setStatus('running'); return { backupId: b.id, backupAt: b.created_at }; });
|
||||
await ctx.step('approval', () => { if (r.approval_state !== 'approved') throw new JobError('restore_not_approved', '운영자 승인이 없습니다'); if (r.cost > 0 && !r.paid_ok && !job.subject.paid) { const o = r.order_id ? deps.store.get('SELECT status FROM orders WHERE id=?', r.order_id) : null; if (o && o.status !== 'approved') throw new JobError('restore_unpaid', '복원 비용 결제가 확인되지 않았습니다'); } return { cost: r.cost }; });
|
||||
const prot = await ctx.step('protect', async () => { const h = await halSubmit(deps, ctx, 'protect.submit', '보호 시점 생성 요청', { kind: 'backup.create', target: site.server_id, payload: { siteId: site.id, scope: { files: true, db: true, uploads: true, config: true }, storeServerId: parseJson(b.scope_json, {}).storeServerId || null, retentionTag: 'protect' }, idempotencyKey: `hal:${job.id}:protect` }); const res = await halWait(deps, ctx, 'protect.wait', '보호 시점 생성', h.halJobId); const until = addMs(nowIso(), (pol.protect_point_days || 7) * DAY); setStatus('running', { protect_point_id: res.location || res.backupId || null, protect_until: until, revert_until: addMs(nowIso(), (pol.revert_hours || 72) * HOUR) }); return { location: res.location, checksum: res.checksum, until }; });
|
||||
await ctx.step('isolated', async () => { try { const h = await halOf(deps).createJob({ kind: 'backup.verify', target: parseJson(b.scope_json, {}).storeServerId || site.server_id, payload: { location: b.location, checksum: b.checksum, isolated: true }, idempotencyKey: `hal:${job.id}:isolated` }); const j = await halOf(deps).getJob(h.halJobId); if (!(j.result && j.result.ok)) throw new JobError('restore_backup_unreadable', '격리 환경에서 백업을 읽을 수 없습니다 — 복원을 시작하지 않았습니다(현재 운영 상태 유지)'); return true; } catch (e) { throw asTransient(e); } });
|
||||
const h = await halSubmit(deps, ctx, 'restore', '데이터 복원', { kind: 'backup.restore', target: site.server_id, payload: { siteId: site.id, location: b.location, checksum: b.checksum, protectPoint: true, isolatedVerify: true }, idempotencyKey: `hal:${job.id}:restore` });
|
||||
const res = await halWait(deps, ctx, 'restore.wait', '복원 진행', h.halJobId);
|
||||
await ctx.step('function', () => { if (res.functionCheck && res.functionCheck.ok === false) throw new JobError('restore_function_failed', '복원 뒤 사이트 기능 확인에 실패했습니다'); return res.functionCheck || { ok: true }; });
|
||||
await ctx.step('external', async () => { const ext = res.externalCheck; if (ext && ext.ok === false) throw new JobError('restore_external_failed', '복원 뒤 외부 접속 확인에 실패했습니다'); return ext || { ok: true, note: '외부 확인 결과 없음' }; });
|
||||
await ctx.step('confirm', () => { if (r.completion_mode === 'user_confirm' && !job.subject.userConfirmed) { setStatus('needs_user'); ctx.needsUser(`복원된 사이트를 확인하고 완료 또는 되돌리기를 선택하세요. 되돌릴 수 있는 마지막 시각: ${r.revert_until || addMs(nowIso(), (pol.revert_hours || 72) * HOUR)}`, [{ id: 'confirm', label: '복원 완료 확정' }, { id: 'revert', label: '복원 전으로 되돌리기' }]); } return { mode: r.completion_mode }; });
|
||||
await ctx.step('cleanup', () => { setStatus('done', { ended_at: nowIso() }); deps.store.run('UPDATE sites SET current_version=?, updated_at=? WHERE id=?', res.restoredVersion || site.current_version, nowIso(), site.id); audit(deps.store, { actorType: 'system', groupId: r.group_id, subjectType: 'site', subjectId: site.id, action: 'restore.complete', detail: { restoreId: r.id, backupId: b.id } }); notify(deps.store, { groupId: r.group_id, category: 'backup', title: `${site.name}: 복원이 완료되었습니다`, body: `보호 시점은 ${r.protect_until || prot.until} 까지 유지됩니다`, link: `/sites/${site.id}/backup`, subjectType: 'site', subjectId: site.id }); return { done: true }; });
|
||||
return { restoreId: r.id, restoredVersion: res.restoredVersion || null };
|
||||
},
|
||||
async revert(job, ctx) {
|
||||
const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id);
|
||||
if (!r.protect_point_id) throw new JobError('no_protect_point', '보호 시점이 없어 되돌릴 수 없습니다');
|
||||
if (r.revert_until && Date.parse(r.revert_until) < Date.now()) throw new JobError('revert_window_passed', '되돌릴 수 있는 기간이 지났습니다');
|
||||
const h = await halSubmit(deps, ctx, 'revert.submit', '보호 시점으로 되돌리기 요청', { kind: 'backup.restore', target: site.server_id, payload: { siteId: site.id, location: r.protect_point_id, protectPoint: false, isolatedVerify: true }, idempotencyKey: `hal:${job.id}:revert` });
|
||||
const res = await halWait(deps, ctx, 'revert.wait', '되돌리기 진행', h.halJobId);
|
||||
if (res.externalCheck && res.externalCheck.ok === false) throw new JobError('revert_external_failed', '되돌린 뒤 외부 접속 확인에 실패했습니다 — 운영자 확인 필요');
|
||||
deps.store.run('UPDATE restores SET status=\'reverted\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), r.id);
|
||||
audit(deps.store, { actorType: 'user', actorId: job.subject.by || null, groupId: r.group_id, subjectType: 'site', subjectId: site.id, action: 'restore.revert', detail: { restoreId: r.id } });
|
||||
} });
|
||||
deps.jobs.onChange((job) => { // 실패 시 옵션 실패 횟수·알림(고객+운영자)과 다음 재시도 시각
|
||||
if (job.kind === 'backup.scheduled' && ['failed', 'queued'].includes(job.status) && job.error && job.attempt > 0) {
|
||||
const o = optionById(deps, job.subject.optionId); if (!o) return;
|
||||
if (job.status === 'failed') deps.store.run('UPDATE backup_options SET consecutive_failures=consecutive_failures+1, last_failure_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id);
|
||||
const n = (o.consecutive_failures || 0) + (job.status === 'failed' ? 1 : 0); const pol = productPolicy(deps, PRODUCT_ID); const threshold = Number(pol.policy.failure_notify_after || 1);
|
||||
const body = job.status === 'queued' ? `일시 오류로 ${job.next_run_at} 에 다시 시도합니다 (${job.attempt}/${job.max_attempts}회). 오류: ${job.error}` : `백업이 실패했습니다 (연속 ${n}회). 운영자에게 알렸습니다. 오류: ${job.error}`;
|
||||
if (job.status === 'failed' || n >= threshold) { notify(deps.store, { groupId: o.group_id, category: 'backup', severity: job.status === 'failed' ? 'critical' : 'warning', incidentKey: `backup:${o.id}:failed`, title: '백업 실패', body, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id, actionRequired: job.status === 'failed' ? '백업 화면에서 다시 시도하거나 문의하세요' : null }); notify(deps.store, { groupId: null, category: 'backup', severity: 'warning', incidentKey: `ops:backup:${o.id}:failed`, title: `[운영] 백업 실패 — 사이트 ${o.site_id}`, body, link: `/ops/backup?site=${o.site_id}` }); }
|
||||
}
|
||||
if (job.kind === 'backup.restore' && ['failed'].includes(job.status)) { const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); if (r) { deps.store.run('UPDATE restores SET status=\'failed\', error=?, updated_at=? WHERE id=?', job.error, nowIso(), r.id); notify(deps.store, { groupId: r.group_id, category: 'backup', severity: 'critical', mandatory: true, incidentKey: `restore:${r.id}:failed`, title: '복원이 실패했습니다', body: `기존 운영 상태는 ${r.protect_point_id ? '보호 시점으로 되돌릴 수 있습니다' : '유지됩니다'}. 자동 복구가 불가능해 운영자가 확인 중입니다. 오류: ${job.error} (작업 번호 ${job.id})`, link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id }); notify(deps.store, { groupId: null, category: 'backup', severity: 'critical', incidentKey: `ops:restore:${r.id}:failed`, title: `[운영] 복원 실패 — ${r.id}`, body: job.error, link: `/ops/backup/restores/${r.id}` }); } }
|
||||
});
|
||||
}
|
||||
|
||||
/** 복원 요청 화면 정보(§20.1) */
|
||||
export function restoreInfo(deps, site, backup) {
|
||||
const pol = productPolicy(deps, PRODUCT_ID); const p = pol.policy; const conflicts = inProgress(deps, 'site', site.id);
|
||||
return { backup: { id: backup.id, createdAt: backup.created_at, scope: parseJson(backup.scope_json, {}), sizeBytes: backup.size_bytes, verifyState: backup.verify_state, expiresAt: backup.expires_at, tier: backup.tier }, included: Object.values(SCOPE), excluded: EXCLUDED,
|
||||
impact: '복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다', estimatedDowntimeMin: p.restore_downtime_min ?? null, cost: p.restore_cost ?? null, currency: pol.currency, costDeferred: p.restore_cost === undefined,
|
||||
completionMode: p.restore_completion || 'user_confirm', protectPointDays: p.protect_point_days ?? null, revertHours: p.revert_hours ?? null, approvalRequired: true, conflicts, canRequest: conflicts.length === 0 && backup.status === 'done' && !backup.deleted_at && (!backup.expires_at || Date.parse(backup.expires_at) > Date.now()), deferred: pol.deferred };
|
||||
}
|
||||
/** 복원 요청(§20.1). 같은 백업에 열린 요청이 있으면 그것을 돌려준다(중복 방지 §36). */
|
||||
export async function requestRestore(deps, site, backup, { by, completionMode = null } = {}) {
|
||||
const open = deps.store.get('SELECT * FROM restores WHERE site_id=? AND backup_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\',\'queued\',\'running\',\'needs_user\')', site.id, backup.id); if (open) return { restore: open, created: false };
|
||||
const info = restoreInfo(deps, site, backup); if (!info.canRequest) throw new HttpError(409, 'restore_not_possible', info.conflicts.length ? '진행 중인 배포·복원·이전이 끝나야 복원을 요청할 수 있습니다' : '이 백업은 복원할 수 없습니다(만료·삭제·미완료)', { conflicts: info.conflicts });
|
||||
const id = newId('rs_'); const now = nowIso(); const cost = Number(info.cost || 0); const pol = productPolicy(deps, PRODUCT_ID).policy;
|
||||
let orderId = null; let status = cost > 0 ? 'awaiting_payment' : 'awaiting_approval';
|
||||
if (cost > 0) { const orders = await optional('../commerce/orders.mjs'); if (orders && orders.createInternalOrder) { try { const o = await orders.createInternalOrder(deps, { groupId: site.group_id, kind: 'restore', amount: cost, targetType: 'restore', targetId: id, label: `사이트 복원 (${site.name})`, by }); orderId = o.id; } catch (_) { /* 주문 영역 없으면 결제 대기 기록만 */ } } }
|
||||
deps.store.run('INSERT INTO restores(id, site_id, group_id, backup_id, status, cost, order_id, approval_state, completion_mode, requested_by, created_at, updated_at, policy_json) VALUES(?,?,?,?,?,?,?,\'pending\',?,?,?,?,?)', id, site.id, site.group_id, backup.id, status, cost, orderId, completionMode || info.completionMode, by, now, now, toJson(pol));
|
||||
audit(deps.store, { actorType: 'user', actorId: by, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'restore.request', detail: { restoreId: id, backupId: backup.id, cost } });
|
||||
notify(deps.store, { groupId: null, category: 'backup', incidentKey: `ops:restore:${id}:approval`, title: `[운영] 복원 승인 대기 — ${site.name}`, body: `백업 ${backup.created_at}, 비용 ${cost}`, link: `/ops/backup/restores/${id}` });
|
||||
return { restore: deps.store.get('SELECT * FROM restores WHERE id=?', id), created: true };
|
||||
}
|
||||
/** 결제 확인(주문 승인 이벤트 또는 운영자) → 승인 대기 */
|
||||
export function markRestorePaid(deps, restoreId, by = 'system') { const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r || r.status !== 'awaiting_payment') return r; deps.store.run('UPDATE restores SET status=\'awaiting_approval\', updated_at=? WHERE id=?', nowIso(), r.id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'payment.restore.confirmed', detail: { restoreId } }); return deps.store.get('SELECT * FROM restores WHERE id=?', r.id); }
|
||||
/** 운영자 승인(§30.3): 최종 확인 내용(고객·그룹·사이트·도메인·백업 시각)과 함께. 같은 승인을 반복해도 작업은 하나(멱등). */
|
||||
export function approveRestore(deps, restoreId, { by, window = null }) {
|
||||
const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다');
|
||||
if (r.approval_state === 'approved' && r.job_id) return { restore: r, job: deps.jobs.get(r.job_id), created: false };
|
||||
if (!['awaiting_approval', 'awaiting_payment'].includes(r.status)) throw new HttpError(409, 'restore_not_approvable', `지금 상태(${r.status})에서는 승인할 수 없습니다`);
|
||||
if (r.status === 'awaiting_payment') throw new HttpError(409, 'restore_unpaid', '결제 확인 전에는 승인할 수 없습니다');
|
||||
const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id);
|
||||
const { job } = deps.jobs.enqueue({ kind: 'backup.restore', subject: { restoreId: r.id, siteId: r.site_id, groupId: r.group_id }, idempotencyKey: `restore:${r.id}`, actorType: 'operator', createdBy: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, label: `사이트 복원 (${site ? site.name : r.site_id})`, runAt: window || null });
|
||||
deps.store.run('UPDATE restores SET approval_state=\'approved\', status=\'queued\', job_id=?, updated_at=? WHERE id=?', job.id, nowIso(), r.id);
|
||||
audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.approve', detail: { restoreId: r.id, backupId: r.backup_id, window } });
|
||||
notify(deps.store, { groupId: r.group_id, category: 'backup', title: '복원이 승인되어 진행됩니다', body: window ? `작업 시간: ${window}` : '곧 시작합니다', link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id });
|
||||
return { restore: deps.store.get('SELECT * FROM restores WHERE id=?', r.id), job, created: true };
|
||||
}
|
||||
export function rejectRestore(deps, restoreId, { by, reason }) { const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); if (!['awaiting_approval', 'awaiting_payment'].includes(r.status)) throw new HttpError(409, 'restore_not_rejectable', '지금 상태에서는 거절할 수 없습니다'); deps.store.run('UPDATE restores SET status=\'rejected\', approval_state=\'rejected\', error=?, updated_at=?, ended_at=? WHERE id=?', reason || null, nowIso(), nowIso(), r.id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.reject', detail: { restoreId, reason } }); notify(deps.store, { groupId: r.group_id, category: 'backup', severity: 'warning', title: '복원 요청이 거절되었습니다', body: reason || '사유 없음', link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id }); return deps.store.get('SELECT * FROM restores WHERE id=?', r.id); }
|
||||
/** 사용자 확인 단계의 선택(§20.2 8단계): confirm → 완료 진행, revert → 되돌리기 */
|
||||
export async function userDecision(deps, restore, action, by) {
|
||||
const job = deps.jobs.get(restore.job_id); if (!job || job.status !== 'needs_user') throw new HttpError(409, 'restore_not_waiting', '사용자 확인을 기다리는 상태가 아닙니다');
|
||||
if (action === 'confirm') { deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...job.subject, userConfirmed: true, by }), job.id); deps.jobs.resume(job.id, by); deps.store.run('UPDATE restores SET status=\'running\', updated_at=? WHERE id=?', nowIso(), restore.id); audit(deps.store, { actorType: 'user', actorId: by, groupId: restore.group_id, subjectType: 'site', subjectId: restore.site_id, action: 'restore.user_confirm', detail: { restoreId: restore.id } }); return { resumed: true }; }
|
||||
if (action === 'revert') { deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...job.subject, by }), job.id); const j = await deps.jobs.revert(job.id, by); return { reverted: j.status === 'reverted', job: j }; }
|
||||
throw new HttpError(400, 'bad_action', '알 수 없는 선택입니다');
|
||||
}
|
||||
/** 사이트 백업 화면 데이터(§19.2) */
|
||||
export function siteView(deps, site) {
|
||||
const o = optionOf(deps, site.id); const pol = productPolicy(deps, PRODUCT_ID);
|
||||
const list = deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 100', site.id).map((b) => ({ id: b.id, createdAt: b.created_at, tier: b.tier, kind: b.kind, scope: parseJson(b.scope_json, {}), sizeBytes: b.size_bytes, verifyState: b.verify_state, expiresAt: b.expires_at, status: b.status, canRestore: b.status === 'done' && (!b.expires_at || Date.parse(b.expires_at) > Date.now()) }));
|
||||
const restores = deps.store.all('SELECT * FROM restores WHERE site_id=? ORDER BY created_at DESC LIMIT 20', site.id).map((r) => ({ ...r, job: r.job_id ? deps.jobs.get(r.job_id) : null }));
|
||||
const lastJob = o ? deps.store.get('SELECT id FROM jobs WHERE kind=\'backup.scheduled\' AND subject_id=? ORDER BY created_at DESC LIMIT 1', site.id) : null;
|
||||
return { eligibility: eligibility(deps, site), purchase: purchaseInfo(deps, site), option: o ? { ...o, schedule: parseJson(o.schedule_json, {}), retention: parseJson(o.retention_json, {}), pausedReasonLabel: o.paused_reason ? reasonLabel(o.paused_reason) : null, storedBytes: storedBytes(deps, site.id), firstBackupPending: !o.last_success_at, failureNotifyAfter: Number(pol.policy.failure_notify_after || 1) } : null,
|
||||
lastRun: lastJob ? deps.jobs.get(lastJob.id) : null, backups: list, restores, scope: SCOPE, excluded: EXCLUDED, siteState: site.lifecycle, mailBackupNotice: '메일 원문은 고객용 백업·복원에 포함되지 않습니다(§17.4)' };
|
||||
}
|
||||
Reference in New Issue
Block a user