관리형 사이트 백업·복원, 서버 이전, 소유권 이전, 중지·해지·보존 생명주기와 운영 사이트 수 반환 (§14·§19~§23)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:43:21 +00:00
co-authored by Claude Fable 5.1
parent c397452acd
commit a014e3cd05
24 changed files with 1674 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
/**
* portal/lifecycle/api.mjs — 생명주기(§23)·사이트 수 반환(§14) 화면·API, 운영자 내부 API, 주기 작업 시작.
* GET /sites/:id/lifecycle · GET /mail/:id/lifecycle
* GET /api/lifecycle/sites/:id §23.3 한 화면 정보 + 중지/해지 영향
* POST /api/lifecycle/sites/:id/pause|resume|cancel|undo-cancel|export
* GET /api/lifecycle/sites/:id/site-count-return?mailChoice= §14 체크리스트 POST … { mailChoice, acknowledge, confirm }
* GET /api/lifecycle/mail/:id · POST /api/lifecycle/mail/:id/cancel { confirm, acknowledge }
* 운영자: GET /internal/ops/lifecycle?state= · GET /internal/ops/lifecycle/sites/:id · POST /internal/ops/lifecycle/sites/:id/hold {reason} · /release-hold · POST /internal/ops/lifecycle/run-due
* 주기 작업: 1분마다 backup·transfer·lifecycle 의 runDue (테스트 환경 NODE_ENV=test 에서는 켜지 않는다).
*/
import { HttpError, esc } from '../../lib/http.mjs';
import { requireGroup } from '../auth/guard.mjs';
import * as svc from './service.mjs';
import * as backup from '../backup/service.mjs';
import * as transfer from '../transfer/service.mjs';
import { getSite, getMailService, opsGuard, operatorOf, jobView, requireConfirm } from './common.mjs';
import { safeError } from '../../lib/redact.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 });
app.static('/ui/lifecycle', new URL('./public', import.meta.url).pathname);
app.get('/sites/:id/lifecycle', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return shell(ctx, { title: `${site.name} · 설정·생명주기`, nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: '생명주기' }], body: `<div id="lc-site" data-site="${esc(site.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/lifecycle/lifecycle.js'] }); });
app.get('/mail/:id/lifecycle', (ctx) => { const ms = getMailService(deps, ctx, ctx.params.id, 'view'); return shell(ctx, { title: `${ms.name} · 생명주기`, nav: 'mail', crumbs: [{ label: t('nav.mail'), href: '/mail' }, { label: ms.name, href: `/mail/${ms.id}` }, { label: '생명주기' }], body: `<div id="lc-mail" data-id="${esc(ms.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/lifecycle/lifecycle.js'] }); });
app.get('/api/lifecycle/sites/:id', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return { ok: true, view: svc.lifecycleView(deps, site), pauseImpact: svc.pauseImpact(deps, site), cancelImpact: svc.cancelImpact(deps, site), siteCount: await svc.siteCountChecklist(deps, site), canManage: ['owner', 'admin', 'billing'].includes(ctx.membership.role) }; });
app.post('/api/lifecycle/sites/:id/pause', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'admin'); requireConfirm(await ctx.body()); const r = svc.pauseSite(deps, site, { by: ctx.user.id }); return { ok: true, site: r.site, job: jobView(deps, r.job.id) }; });
app.post('/api/lifecycle/sites/:id/resume', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'admin'); const r = svc.resumeSite(deps, site, { by: ctx.user.id }); return { ok: true, site: r.site, job: jobView(deps, r.job.id), backupNeedsConfirm: r.backupNeedsConfirm }; });
app.post('/api/lifecycle/sites/:id/cancel', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const b = await ctx.body(); requireConfirm(b); const r = await svc.scheduleCancel(deps, site, { by: ctx.user.id, at: b.at || null, mailChoice: b.mailChoice || null, acknowledge: !!b.acknowledge }); return { ok: true, site: r.site, executed: r.executed, view: svc.lifecycleView(deps, r.site) }; });
app.post('/api/lifecycle/sites/:id/undo-cancel', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); return { ok: true, site: svc.undoCancel(deps, site, { by: ctx.user.id }) }; });
app.post('/api/lifecycle/sites/:id/export', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return { ok: true, ...svc.requestExport(deps, site, { by: ctx.user.id }) }; });
app.get('/api/lifecycle/sites/:id/site-count-return', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return { ok: true, checklist: await svc.siteCountChecklist(deps, site, { mailChoice: ctx.query.mailChoice || null, acknowledge: ctx.query.acknowledge === '1' }) }; });
app.post('/api/lifecycle/sites/:id/site-count-return', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const b = await ctx.body(); return { ok: true, ...(await svc.returnSiteCount(deps, site, { by: ctx.user.id, mailChoice: b.mailChoice || null, acknowledge: !!b.acknowledge, confirm: b.confirm === true })) }; });
app.get('/api/lifecycle/mail/:id', (ctx) => { const ms = getMailService(deps, ctx, ctx.params.id, 'view'); return { ok: true, view: svc.mailLifecycleView(deps, ms) }; });
app.post('/api/lifecycle/mail/:id/cancel', async (ctx) => { const ms = getMailService(deps, ctx, ctx.params.id, 'billing'); const b = await ctx.body(); requireConfirm(b); if (!b.acknowledge) throw new HttpError(400, 'acknowledge_required', '보존 종료일과 삭제 예정일을 확인했다는 표시가 필요합니다'); const m = await svc.endMail(deps, ms, ctx.user.id); return { ok: true, mailService: m, view: svc.mailLifecycleView(deps, deps.store.get('SELECT * FROM mail_services WHERE id=?', ms.id)) }; });
const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); };
app.get('/internal/ops/lifecycle', ops((ctx) => ({ ok: true, sites: deps.store.all(`SELECT s.id, s.name, s.group_id, g.name AS group_name, s.lifecycle, s.cancel_at, s.retention_until, s.delete_after, s.next_notice_at, s.delete_hold_reason, s.site_count_state FROM sites s JOIN groups g ON g.id=s.group_id ${ctx.query.state ? 'WHERE s.lifecycle=?' : 'WHERE s.lifecycle<>\'live\''} ORDER BY s.updated_at DESC LIMIT 500`, ...(ctx.query.state ? [ctx.query.state] : [])), mail: deps.store.all('SELECT id, name, group_id, domain, lifecycle, cancelled_at, retention_until, delete_after FROM mail_services WHERE lifecycle<>\'active\' ORDER BY updated_at DESC LIMIT 500'), retentionPolicy: svc.retentionPolicy(deps) })));
app.get('/internal/ops/lifecycle/sites/:id', ops(async (ctx) => { const site = deps.store.get('SELECT * FROM sites WHERE id=?', ctx.params.id); if (!site) throw new HttpError(404, 'not_found', '사이트가 없습니다'); return { ok: true, view: svc.lifecycleView(deps, site), siteCount: await svc.siteCountChecklist(deps, site), jobs: deps.jobs.list({ subjectType: 'site', subjectId: site.id, limit: 20 }).map((j) => jobView(deps, j.id)) }; }));
app.post('/internal/ops/lifecycle/sites/:id/hold', ops(async (ctx) => { const b = await ctx.body(); const site = deps.store.get('SELECT * FROM sites WHERE id=?', ctx.params.id); if (!site) throw new HttpError(404, 'not_found', '사이트가 없습니다'); return { ok: true, site: svc.setDeleteHold(deps, site, { by: operatorOf(b), reason: b.reason }) }; }));
app.post('/internal/ops/lifecycle/sites/:id/release-hold', ops(async (ctx) => { const b = await ctx.body(); const site = deps.store.get('SELECT * FROM sites WHERE id=?', ctx.params.id); if (!site) throw new HttpError(404, 'not_found', '사이트가 없습니다'); return { ok: true, site: svc.releaseDeleteHold(deps, site, { by: operatorOf(b) }) }; }));
app.post('/internal/ops/lifecycle/run-due', ops(async () => ({ ok: true, lifecycle: await svc.runDue(deps), backup: backup.runDue(deps), transfer: transfer.runDue(deps) })));
// 주기 작업(운영): 1분마다. 각 runDue 는 멱등이라 겹쳐 실행돼도 안전하다.
if (process.env.NODE_ENV !== 'test' && !deps.__lifecycleTimer) { const tick = async () => { try { await svc.runDue(deps); backup.runDue(deps); transfer.runDue(deps); } catch (e) { deps.log('[lifecycle] 주기 작업 오류', safeError(e)); } }; deps.__lifecycleTimer = setInterval(tick, 60000); deps.__lifecycleTimer.unref(); setTimeout(tick, 5000).unref(); }
}
+89
View File
@@ -0,0 +1,89 @@
/**
* portal/lifecycle/common.mjs — 백업·복원·이전·소유권 이전·생명주기 영역(fork F)이 함께 쓰는 도우미.
*
* - getSite / getMailService : 그룹 경계와 사이트별 권한(§4.3)을 검사해 행을 돌려준다(다른 그룹 것은 404 — 존재를 드러내지 않음 §37).
* - opsGuard : 운영자 콘솔(HAL)에서 오는 내부 호출을 lib/serviceauth 로 검증한다(보관소의 'service:<keyId>').
* - productPolicy : 활성 상품정책 버전(product_versions)에서 limits/policy 를 읽는다. 값이 없으면 deferred 로 표시해 화면이 "미확정" 을 솔직히 보이게 한다(§19.1·§27.2).
* - halOf : HAL 어댑터가 없거나 응답하지 않으면 '정보 확인 지연' 오류(503)로 바꿔 신규 증가를 막는다(§13.4·§26.2).
* - inProgress : 사이트·메일에 진행 중인 배포·복원·이전·소유권 이전이 있는지(충돌 확인 §11.1·§21·§22).
* - 설정 표(settings) 읽기/쓰기, KST 표시, 작업 상태 요약.
*/
import { HttpError } from '../../lib/http.mjs';
import { requireSitePerm, requireGroup } from '../auth/guard.mjs';
import { verifyCtx } from '../../lib/serviceauth.mjs';
import { parseJson, toJson, nowIso } from '../../lib/db.mjs';
import { TERMINAL } from '../../lib/status.mjs';
import { describe } from '../../lib/jobs.mjs';
import { fmtKst } from '../../lib/kst.mjs';
export { fmtKst, nowIso, parseJson, toJson, describe };
/** ms 단위 상수 */
export const DAY = 86400000, HOUR = 3600000;
export const addMs = (iso, ms) => new Date(Date.parse(iso || nowIso()) + ms).toISOString();
/** 사이트 행 + 권한 검사 */
export function getSite(deps, ctx, id, perm = 'view') {
const s = deps.store.get('SELECT * FROM sites WHERE id=?', id);
requireSitePerm(ctx, s, perm); // 없거나 다른 그룹이면 404
return s;
}
/** 메일 서비스 행 + 권한 검사(사이트 대표 메일은 사이트 권한 domain_mail, 독립 메일은 그룹 역할) */
export function getMailService(deps, ctx, id, perm = 'view') {
requireGroup(ctx);
const m = deps.store.get('SELECT * FROM mail_services WHERE id=? AND group_id=?', id, ctx.group.id);
if (!m) throw new HttpError(404, 'not_found', '요청한 항목을 찾을 수 없습니다');
if (m.site_id) { const s = deps.store.get('SELECT * FROM sites WHERE id=?', m.site_id); if (s) requireSitePerm(ctx, s, perm === 'view' ? 'view' : 'domain_mail'); }
return m;
}
/** 운영자 콘솔(HAL) 내부 호출 검증. 통과하면 keyId. */
export async function opsGuard(ctx, deps) {
const r = await verifyCtx(ctx, (keyId) => { try { return deps.vault.get('service:' + keyId); } catch (_) { return null; } });
if (!r.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증에 실패했습니다 (${r.reason})`);
return r.keyId;
}
/** 운영자 식별(내부 호출 본문의 operator 필드) */
export const operatorOf = (body) => String((body && body.operator) || 'operator').slice(0, 80);
/**
* 활성 상품정책. productId 의 published 버전 중 effective_from ≤ now < effective_to 인 가장 높은 버전.
* @returns {{version:object|null, limits:object, policy:object, deferred:string[]}}
*/
export function productPolicy(deps, productId, required = []) {
const now = nowIso();
const v = deps.store.get('SELECT * FROM product_versions WHERE product_id=? AND published=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>?) ORDER BY version DESC LIMIT 1', productId, now, now) || null;
const limits = parseJson(v?.limits_json, {}) || {}; const policy = parseJson(v?.policy_json, {}) || {};
const deferred = required.filter((k) => { const [bag, key] = k.split('.'); const src = bag === 'limits' ? limits : policy; return src[key] === undefined || src[key] === null || src[key] === ''; });
return { version: v, limits, policy, deferred, priceKrw: v ? v.price : null, currency: v ? v.currency : 'KRW', taxRate: v ? v.tax_rate : 0.1 };
}
/** HAL 어댑터. 없으면 503 '정보 확인 지연' */
export function halOf(deps) {
if (!deps.hal) throw new HttpError(503, 'hal_unavailable', '중앙 관리 기능(HAL)과 연결되지 않아 지금은 새 작업을 시작할 수 없습니다. 기존 서비스는 계속 제공되며 연결이 복구되면 다시 확인합니다', { state: 'stale' });
return deps.hal;
}
/** HAL 호출 오류를 작업 엔진의 일시 오류(재시도)로 바꾼다 */
export function asTransient(e) { if (e && e.code === 'service_unreachable') { e.retryable = true; } return e; }
/** 대상(사이트·메일)에 진행 중인 작업 목록 — 배포·복원·이전·소유권 이전 */
export function inProgress(deps, subjectType, subjectId) {
const out = [];
const jobs = deps.store.all('SELECT id, kind, status, label FROM jobs WHERE subject_type=? AND subject_id=?', subjectType, subjectId).filter((j) => !TERMINAL.has(j.status));
for (const j of jobs) out.push({ type: 'job', id: j.id, kind: j.kind, status: j.status, label: j.label });
if (subjectType === 'site') {
for (const r of deps.store.all('SELECT id, status FROM restores WHERE site_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\',\'rejected\',\'expired\')', subjectId)) out.push({ type: 'restore', id: r.id, status: r.status });
for (const d of deps.store.all('SELECT id, status FROM deployments WHERE site_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\')', subjectId)) out.push({ type: 'deployment', id: d.id, status: d.status });
}
for (const r of deps.store.all('SELECT id, status FROM relocations WHERE subject_type=? AND subject_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\',\'rejected\')', subjectType, subjectId)) out.push({ type: 'relocation', id: r.id, status: r.status });
const tr = deps.store.all('SELECT id, status, scope_json FROM ownership_transfers WHERE status IN (\'requested\',\'accepted\',\'on_hold\',\'reviewing\',\'approved\',\'disputed\')');
for (const t of tr) { const sc = parseJson(t.scope_json, {}); const ids = subjectType === 'site' ? sc.siteIds || [] : sc.mailServiceIds || []; if (ids.includes(subjectId)) out.push({ type: 'transfer', id: t.id, status: t.status }); }
return out;
}
/** 설정 표 */
export function settingGet(deps, key, fallback = null) { const r = deps.store.get('SELECT value_json FROM settings WHERE key=?', key); return r ? parseJson(r.value_json, fallback) : fallback; }
export function settingSet(deps, key, value, by = 'system') { deps.store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at, updated_by=excluded.updated_by', key, toJson(value), nowIso(), by); }
/** 작업 요약(화면) */
export function jobView(deps, jobId) { return jobId ? describe(deps.jobs.get(jobId)) : null; }
/** 사람이 읽는 바이트 */
export function humanBytes(n) { n = Number(n) || 0; if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'; if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'; return n + ' B'; }
/** 선택 의존(다른 영역 도우미)을 있을 때만 들여온다 */
export async function optional(rel) { try { const { existsSync } = await import('node:fs'); const u = new URL(rel, import.meta.url); if (!existsSync(u)) return null; return await import(u); } catch (_) { return null; } }
/** 요청 본문에서 확인 플래그(파괴적 작업 명시적 확인 §2.3) */
export function requireConfirm(body, field = 'confirm') { if (!body || body[field] !== true) throw new HttpError(400, 'confirmation_required', '대상과 영향을 확인한 뒤 명시적으로 확인해야 실행됩니다', { field }); }
+26
View File
@@ -0,0 +1,26 @@
/**
* portal/lifecycle/model.mjs — 생명주기(§23)·사이트 수 반환(§14) 영역 추가 표·열.
* lifecycle_events 상태 전이 기록(누가·언제·어디서→어디로·이유). 화면의 "되돌릴 수 없는 시점"·"다음 알림" 계산 근거.
* sites.mail_choice 해지 때 사이트 대표 메일 처리 선택(keep=독립 메일로 전환 | end=종료)
* sites.next_notice_at 다음 알림 예정 시각(§23.3)
* sites.delete_hold_reason 법적 보존·분쟁으로 삭제 보류(§23.2)
* sites.public_stopped_at 공개 서비스 중지 시각(§14 1단계 근거)
* sites.prev_lifecycle 해지 예약을 되돌릴 때 복귀할 상태
* domains.routed 공개 라우팅 여부(1=연결, 0=해제 §14 2단계)
* mail_services.lifecycle / next_notice_at / delete_hold_reason 메일 서비스 생명주기(active·paused·readonly_retention·pending_delete·deleted·delete_hold)
*/
export const MIGRATIONS = [{ id: 'lifecycle_0001', sql: `
CREATE TABLE IF NOT EXISTS lifecycle_events (
id TEXT PRIMARY KEY, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, group_id TEXT, from_state TEXT, to_state TEXT NOT NULL, at TEXT NOT NULL, by TEXT, reason TEXT, detail_json TEXT
);
CREATE INDEX IF NOT EXISTS lifecycle_events_subject ON lifecycle_events(subject_type, subject_id, at);
ALTER TABLE sites ADD COLUMN mail_choice TEXT;
ALTER TABLE sites ADD COLUMN next_notice_at TEXT;
ALTER TABLE sites ADD COLUMN delete_hold_reason TEXT;
ALTER TABLE sites ADD COLUMN public_stopped_at TEXT;
ALTER TABLE sites ADD COLUMN prev_lifecycle TEXT;
ALTER TABLE domains ADD COLUMN routed INTEGER NOT NULL DEFAULT 1;
ALTER TABLE mail_services ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'active';
ALTER TABLE mail_services ADD COLUMN next_notice_at TEXT;
ALTER TABLE mail_services ADD COLUMN delete_hold_reason TEXT;
` }];
+28
View File
@@ -0,0 +1,28 @@
/* portal/lifecycle/public/lifecycle.js — 생명주기 화면(§23.3)·사이트 수 반환 체크리스트(§14)·메일 생명주기 */
(() => {
const { api, esc, state, toast, confirmDanger, poll, fmt } = window.aw;
const LB = { draft: ['neutral', '초안'], building: ['neutral', '제작 중'], deploying: ['running', '배포 중'], live: ['ok', '운영 중'], needs_domain: ['warning', '도메인 설정 필요'], paused: ['warning', '일시 중지'], cancel_scheduled: ['warning', '해지 예약'], readonly_retention: ['warning', '읽기 전용 보존'], pending_delete: ['failed', '삭제 대기'], deleted: ['neutral', '삭제 완료'], delete_hold: ['failed', '삭제 보류'], active: ['ok', '운영 중'] };
const badge = (k) => { const [c, l] = LB[k] || ['neutral', k]; return `<span class="badge ${c}">${esc(l)}</span>`; };
const d8 = (x) => (x ? esc(fmt(x)) : '—');
const el = document.getElementById('lc-site');
if (el) { const id = el.dataset.site; let choice = null;
const render = async () => { let d; try { d = await api('GET', `/api/lifecycle/sites/${id}`); } catch (e) { state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: render }); return false; } const v = d.view; const sc = d.siteCount; const ci = d.cancelImpact; const pi = d.pauseImpact; choice = choice || v.mailChoice;
el.innerHTML = `${v.frozen ? `<div class="alert critical">${esc(v.frozenNote)}</div>` : ''}<div class="card"><h2>${badge(v.site.lifecycle)} ${esc(v.site.name)}</h2><dl class="kv"><dt>공개 서비스</dt><dd>${esc(v.publicAccess)}</dd><dt>관리자 접근</dt><dd>${esc(v.adminAccess)}</dd><dt>운영 사이트 수</dt><dd>${v.siteCountConsumed ? '소비 중(일시 중지도 포함)' : '소비하지 않음'}</dd><dt>용량 권리</dt><dd>${v.capacityConsumed.length ? v.capacityConsumed.map((a) => `${esc(a.resource)} ${a.amount}`).join(', ') : '없음'}</dd><dt>청구</dt><dd>${esc(v.billing)}</dd><dt>도메인</dt><dd>${v.domains.length ? v.domains.map((x) => `${esc(x.name)}${x.primary ? '(대표)' : ''}: ${x.routed ? '연결' : '해제'}`).join(', ') : '없음'}</dd><dt>메일</dt><dd>${v.mail.length ? v.mail.map((m) => `${esc(m.domain)}: ${esc((LB[m.lifecycle] || [0, m.lifecycle])[1])}`).join(', ') : '없음'}</dd><dt>백업</dt><dd>${v.backup ? `옵션 ${esc(v.backup.status)}${v.backup.deleteAfter ? ` · 백업 삭제 예정 ${d8(v.backup.deleteAfter)}` : ''}` : '없음'}</dd><dt>내보내기</dt><dd>${v.exportAvailable ? '가능' : '불가(삭제됨)'}</dd><dt>재개</dt><dd>${v.resumeAvailable ? '가능' : '불가'}</dd><dt>되돌릴 수 없는 시점</dt><dd>${esc(v.pointOfNoReturn)}</dd><dt>보존 종료</dt><dd>${d8(v.retentionUntil)}</dd><dt>삭제 예정</dt><dd>${d8(v.deleteAfter)}${v.deleteHold ? ` <span class="err">삭제 보류 — ${esc(v.deleteHold.reason)} · 문의 ${esc(v.deleteHold.contact)}</span>` : ''}</dd><dt>다음 알림</dt><dd>${d8(v.nextNoticeAt)}</dd><dt>보존 정책</dt><dd>사이트 ${v.retentionPolicy.site_days}일 · 메일 ${v.retentionPolicy.mail_days}일 · 소스 ${v.retentionPolicy.source_days}일 · 백업 ${v.retentionPolicy.backup_days}일 (${esc(v.retentionPolicy.source)})</dd></dl><p class="sub">${esc(v.mailBackupNote)}</p>
<div class="actions">${d.canManage && !v.frozen ? `${['live', 'needs_domain'].includes(v.site.lifecycle) ? '<button class="btn" id="lc-pause">일시 중지</button>' : ''}${v.site.lifecycle === 'paused' ? '<button class="btn primary" id="lc-resume">재개</button>' : ''}${['live', 'needs_domain', 'paused', 'draft', 'building'].includes(v.site.lifecycle) ? '<button class="btn danger" id="lc-cancel">해지</button>' : ''}${v.site.lifecycle === 'cancel_scheduled' && v.resumeAvailable ? '<button class="btn" id="lc-undo">해지 예약 취소</button>' : ''}` : ''}${v.exportAvailable ? '<button class="btn" id="lc-export">내보내기 요청</button>' : ''}</div></div>
<div class="card"><h2>운영 사이트 수 반환 (8단계)</h2><ol class="steps">${sc.steps.map((s) => `<li class="${s.ok ? 'done' : 'pending'}"><span class="s" aria-hidden="true">${s.ok ? '✔' : '○'}</span><span><b>${esc(s.title)}</b> — ${esc(s.note || '')}${s.link ? ` <a href="${esc(s.link)}">이동</a>` : ''}</span></li>`).join('')}</ol>${v.mail.length ? `<div class="f"><label>대표 메일 처리</label><select id="lc-mc"><option value="">선택</option><option value="keep" ${choice === 'keep' ? 'selected' : ''}>독립 메일 서비스로 전환해 유지</option><option value="end" ${choice === 'end' ? 'selected' : ''}>종료(30일 읽기·내보내기 후 삭제)</option></select></div>` : ''}<label class="f"><span><input type="checkbox" id="lc-ack"> 데이터와 보존 기한(보존 종료 ${d8(v.retentionUntil)}, 삭제 ${d8(v.deleteAfter)})을 확인했습니다</span></label><div class="actions"><button class="btn danger" id="lc-return" ${sc.consumes && d.canManage && !v.frozen ? '' : 'disabled'}>사이트 수 반환</button></div></div>
${v.events.length ? `<div class="card"><h2>상태 변경 기록</h2><table class="r"><thead><tr><th>시각</th><th>변경</th><th>주체</th><th>사유</th></tr></thead><tbody>${v.events.map((e) => `<tr><td data-l="시각">${d8(e.at)}</td><td data-l="변경">${esc((LB[e.from_state] || [0, e.from_state || '—'])[1])} → ${esc((LB[e.to_state] || [0, e.to_state])[1])}</td><td data-l="주체">${e.by === 'system' ? '자동' : '사용자'}</td><td data-l="사유">${esc(e.reason || '')}</td></tr>`).join('')}</tbody></table></div>` : ''}`;
const q = (i) => document.getElementById(i); const go = async (p, body, msg) => { try { await api('POST', `/api/lifecycle/sites/${id}/${p}`, body); toast(msg || '처리했습니다'); render(); } catch (e) { toast(e.message + (e.detail && e.detail.link ? ' — 상품 구매로 이동' : '')); if (e.detail && e.detail.link) setTimeout(() => (location.href = e.detail.link), 1500); } };
if (q('lc-pause')) q('lc-pause').onclick = async () => { if (await confirmDanger({ title: '일시 중지', target: v.site.name, impacts: [`공개 접속: ${pi.publicAccess}`, `관리자 접근: ${pi.adminAccess}`, `메일: ${pi.mail}`, `백업: ${pi.backups}`, `사용량·비용: ${pi.usageAndCost}`, `재개: ${pi.resume}`], confirmLabel: '중지' })) go('pause', { confirm: true }); };
if (q('lc-resume')) q('lc-resume').onclick = () => go('resume', {}, '재개했습니다');
if (q('lc-undo')) q('lc-undo').onclick = () => go('undo-cancel', {}, '해지 예약을 취소했습니다');
if (q('lc-export')) q('lc-export').onclick = () => go('export', {}, '내보내기를 요청했습니다');
if (q('lc-cancel')) q('lc-cancel').onclick = async () => { const mc = q('lc-mc') ? q('lc-mc').value : null; if (ci.mailOptions && !mc) return toast('대표 메일 처리를 먼저 선택하세요'); if (!q('lc-ack').checked) return toast('보존 기한 확인에 표시하세요'); if (await confirmDanger({ title: '해지', target: `${ci.target.name} (연결: 도메인 ${ci.linked.domains.join(', ') || '없음'} · 메일 ${ci.linked.siteMail.join(', ') || '없음'} · 백업 ${ci.linked.backupOption || '없음'})`, impacts: [...ci.immediateStops.map((s) => '즉시 중지: ' + s), `남는 데이터: ${ci.remainingData}`, `보존 종료 ${fmt(ci.retentionUntil)} · 삭제 ${fmt(ci.deleteAfter)}`, ci.export, ci.unpaid.length ? `미결제 ${ci.unpaid.map((o) => o.order_no).join(', ')}` : '미결제 없음', ci.refund, `되돌릴 수 없는 시점: ${ci.pointOfNoReturn}`, ci.siteCount], typeToConfirm: '해지', confirmLabel: '해지' })) go('cancel', { confirm: true, acknowledge: true, mailChoice: mc }, '해지를 예약했습니다'); };
if (q('lc-mc')) q('lc-mc').onchange = async () => { choice = q('lc-mc').value || null; const r = await api('GET', `/api/lifecycle/sites/${id}/site-count-return?mailChoice=${encodeURIComponent(choice || '')}&acknowledge=${q('lc-ack').checked ? 1 : 0}`); d.siteCount = r.checklist; render(); };
if (q('lc-return')) q('lc-return').onclick = async () => { const mc = q('lc-mc') ? q('lc-mc').value : null; if (!q('lc-ack').checked) return toast('데이터와 보존 기한 확인에 표시하세요'); if (await confirmDanger({ title: '운영 사이트 수 반환', target: v.site.name, impacts: ['공개 라우팅이 해제되고 대표 메일은 선택대로 처리됩니다', '반환 뒤 이 사이트는 공개 운영에 사이트 수를 다시 확보해야 합니다', mc === 'keep' ? '대표 메일은 독립 메일로 전환됩니다(권리 필요)' : mc === 'end' ? '대표 메일은 종료됩니다' : '대표 메일 없음'], typeToConfirm: '반환', confirmLabel: '반환' })) go('site-count-return', { mailChoice: mc, acknowledge: true, confirm: true }, '사이트 수를 반환했습니다'); };
return true; };
poll(render, 10000); }
const ml = document.getElementById('lc-mail');
if (ml) { const id = ml.dataset.id; const render = async () => { let d; try { d = await api('GET', `/api/lifecycle/mail/${id}`); } catch (e) { state(ml, e.status === 403 ? 'forbidden' : 'failed', e.message); return; } const v = d.view;
ml.innerHTML = `${v.frozen ? '<div class="alert critical">소유권 분쟁으로 설정 변경·삭제가 동결되었습니다</div>' : ''}<div class="card"><h2>${badge(v.mailService.lifecycle)} ${esc(v.mailService.name)} <span class="sub">${esc(v.mailService.domain || '')}</span></h2><dl class="kv"><dt>송수신</dt><dd>${esc(v.sendReceive)}</dd><dt>관리자 접근</dt><dd>${esc(v.adminAccess)}</dd><dt>권리 소비</dt><dd>${esc(v.consumes)}</dd><dt>청구</dt><dd>${esc(v.billing)}</dd><dt>내보내기</dt><dd>${v.exportAvailable ? '가능' : '불가'}</dd><dt>재개</dt><dd>${v.resumeAvailable ? '가능' : '불가'}</dd><dt>되돌릴 수 없는 시점</dt><dd>${esc(v.pointOfNoReturn)}</dd><dt>보존 종료</dt><dd>${d8(v.retentionUntil)}</dd><dt>삭제 예정</dt><dd>${d8(v.deleteAfter)}${v.deleteHold ? ` <span class="err">삭제 보류 — 문의 ${esc(v.deleteHold.contact)}</span>` : ''}</dd><dt>다음 알림</dt><dd>${d8(v.nextNoticeAt)}</dd></dl><p class="sub">${esc(v.backupNote)}</p><div class="actions">${v.mailService.lifecycle === 'active' && !v.frozen ? '<button class="btn danger" id="lcm-cancel">메일 서비스 해지</button>' : ''}</div></div>`;
const b = document.getElementById('lcm-cancel'); if (b) b.onclick = async () => { if (await confirmDanger({ title: '메일 서비스 해지', target: v.mailService.domain || v.mailService.name, impacts: ['송수신이 즉시 중지됩니다', `기존 메일 읽기·내보내기는 ${v.retentionPolicy.mail_days}일 동안 가능합니다`, '보존 종료 뒤 삭제되며 복구할 수 없습니다', '메일 원문은 고객용 백업 대상이 아닙니다'], typeToConfirm: '해지', confirmLabel: '해지' })) { try { await api('POST', `/api/lifecycle/mail/${id}/cancel`, { confirm: true, acknowledge: true }); toast('해지했습니다'); render(); } catch (e) { toast(e.message); } } }; }; render(); }
})();
+251
View File
@@ -0,0 +1,251 @@
/**
* portal/lifecycle/service.mjs — 중지·해지·보존·삭제 생명주기(§23)와 운영 사이트 수 반환(§14).
*
* 사이트 상태(sites.lifecycle, lib/status SERVICE_STATE)
* live/needs_domain ─pause→ paused ─resume→ live
* (live|paused|draft|needs_domain) ─scheduleCancel→ cancel_scheduled ─(cancel_at 도달, runDue)→ readonly_retention ─(retention_until)→ pending_delete ─(delete_after)→ deleted
* cancel_scheduled ─undoCancel(cancel_at 전)→ 이전 상태. delete_hold 는 법적 보존·분쟁으로 삭제만 멈춘 상태(문의 경로만 표시).
* 기본 보존(§23.2, 별도 승인 정책이 없을 때): 해지 사이트 30일, 해지 메일 30일(읽기·내보내기), 백업 7일(backup 영역).
* 일시 중지 사이트는 운영 사이트 수를 계속 소비한다. 실제 반환은 §14 의 8단계 조건을 모두 충족할 때만 returnSiteCount 가 수행한다.
* 공개 접속 중지·라우팅 해제·삭제는 HAL 작업(domain.vhost / site.remove)으로 수행하며, 서버가 배정되지 않은 초안 사이트는 건너뛴다.
*/
import { HttpError } from '../../lib/http.mjs';
import { newId } from '../../lib/crypto.mjs';
import { notify } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
import * as quota from '../../lib/quota.mjs';
import { JobError } from '../../lib/jobs.mjs';
import { SERVICE_LABEL } from '../../lib/status.mjs';
import { inProgress, productPolicy, halOf, asTransient, nowIso, parseJson, toJson, addMs, DAY, optional, settingGet } from './common.mjs';
import * as backup from '../backup/service.mjs';
import * as transfer from '../transfer/service.mjs';
export const DEFAULT_RETENTION = { site_days: 30, mail_days: 30, source_days: 30, backup_days: 7, delete_grace_days: 1, notice_before_days: 3 };
/** 보존 정책: 활성 상품정책(product 'retention') 값이 있으면 그것, 없으면 기본 §23.2 */
export function retentionPolicy(deps) { const pol = productPolicy(deps, 'retention'); return { ...DEFAULT_RETENTION, ...(pol.policy || {}), source: pol.version ? `상품정책 ${pol.version.name} v${pol.version.version}` : '기본 보존 기간(별도 승인 정책 없음)' }; }
const PUBLIC_STATES = new Set(['live', 'needs_domain', 'deploying']);
/** 메일 서비스가 끝났는지(메일 영역은 status, 이 영역은 lifecycle 열을 쓴다 — 둘 다 본다) */
export const mailEnded = (m) => ['cancelled', 'deleted'].includes(m.status) || ['readonly_retention', 'pending_delete', 'deleted'].includes(m.lifecycle);
/** 사이트의 살아 있는 대표 메일 서비스 */
export const activeMailsOf = (deps, siteId) => deps.store.all('SELECT * FROM mail_services WHERE site_id=?', siteId).filter((m) => !mailEnded(m));
/** 메일 종료: 메일 영역(fork E)의 cancelMailService 가 있으면 그것이 실제 송수신을 막고, 이 영역의 생명주기 열을 맞춘다. 없으면 로컬 처리. */
export async function endMail(deps, m, by = 'system') {
const mail = await optional('../mail/service.mjs'); const r = retentionPolicy(deps);
if (mail && mail.cancelMailService) { await mail.cancelMailService(deps, m.id, by && by !== 'system' ? { id: by } : null, { reason: 'site_lifecycle' }); const fresh = deps.store.get('SELECT * FROM mail_services WHERE id=?', m.id); if (!['readonly_retention', 'pending_delete', 'deleted'].includes(fresh.lifecycle)) { setMail(deps, m.id, { lifecycle: 'readonly_retention', next_notice_at: fresh.retention_until ? addMs(fresh.retention_until, -r.notice_before_days * DAY) : null }); event(deps, 'mail_service', m.id, m.group_id, m.lifecycle, 'readonly_retention', by, 'cancel'); } return deps.store.get('SELECT * FROM mail_services WHERE id=?', m.id); }
return cancelMail(deps, m, { by, local: true });
}
/** 독립 메일 전환 시작(메일 영역 작업). 이미 진행 중인 전환 작업이 있으면 그것을 돌려준다. */
export async function startConversion(deps, m, by = 'system') {
const mail = await optional('../mail/service.mjs'); if (!mail || !mail.convertToStandalone) throw new HttpError(409, 'needs_setup', '독립 메일 전환 기능이 아직 연결되지 않았습니다(메일 영역 필요)');
const running = deps.store.get('SELECT id FROM jobs WHERE kind=\'mail.convert\' AND subject_type=\'mail_service\' AND subject_id=? AND status IN (\'queued\',\'running\',\'awaiting_external\',\'needs_user\')', m.id); if (running) return deps.jobs.get(running.id);
return mail.convertToStandalone(deps, m.id, by && by !== 'system' ? { id: by } : null);
}
export const isPublic = (site) => PUBLIC_STATES.has(site.lifecycle) && !site.public_stopped_at;
function event(deps, subjectType, subjectId, groupId, from, to, by, reason, detail) { deps.store.run('INSERT INTO lifecycle_events(id, subject_type, subject_id, group_id, from_state, to_state, at, by, reason, detail_json) VALUES(?,?,?,?,?,?,?,?,?,?)', newId('le_'), subjectType, subjectId, groupId, from, to, nowIso(), by || null, reason || null, toJson(detail || null)); }
const setSite = (deps, id, patch) => { const keys = Object.keys(patch); deps.store.run(`UPDATE sites SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...Object.values(patch), nowIso(), id); return deps.store.get('SELECT * FROM sites WHERE id=?', id); };
const setMail = (deps, id, patch) => { const keys = Object.keys(patch); deps.store.run(`UPDATE mail_services SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...Object.values(patch), nowIso(), id); return deps.store.get('SELECT * FROM mail_services WHERE id=?', id); };
/** 상태 전이(다른 영역도 사용). 전이 기록·감사를 남긴다. */
export function setState(deps, siteId, to, { by = 'system', reason = null, detail = null, extra = {} } = {}) {
const s = deps.store.get('SELECT * FROM sites WHERE id=?', siteId); if (!s) throw new HttpError(404, 'not_found', '사이트가 없습니다');
if (!SERVICE_LABEL[to]) throw new HttpError(400, 'bad_state', `알 수 없는 상태: ${to}`);
const site = setSite(deps, siteId, { lifecycle: to, ...extra }); event(deps, 'site', siteId, s.group_id, s.lifecycle, to, by, reason, detail);
audit(deps.store, { actorType: by === 'system' ? 'system' : 'user', actorId: by, groupId: s.group_id, subjectType: 'site', subjectId: siteId, action: `lifecycle.${to}`, detail: { from: s.lifecycle, reason, ...(detail || {}) }, immutable: ['readonly_retention', 'deleted', 'cancel_scheduled'].includes(to) });
return site;
}
/** 공개 접속 중지/재개/라우팅 해제 작업 넣기(HAL domain.vhost). 서버가 없으면 즉시 완료로 기록. */
function publicJob(deps, site, mode, by) {
const { job } = deps.jobs.enqueue({ kind: 'lifecycle.public', subject: { siteId: site.id, mode, groupId: site.group_id }, idempotencyKey: `lifecycle:public:${site.id}:${mode}:${nowIso().slice(0, 16)}`, actorType: by === 'system' ? 'system' : 'user', createdBy: by, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: { stop: '공개 서비스 중지', start: '공개 서비스 재개', unroute: '웹 도메인 공개 라우팅 해제' }[mode] });
return job;
}
/** 일시 중지(§23.1): 영향 안내는 pauseImpact 가 만들고, 여기서는 실행 */
export function pauseImpact(deps, site) {
const mails = activeMailsOf(deps, site.id); const bo = backup.optionOf(deps, site.id); const f = quota.figures(deps.store, site.group_id, 'sites');
return { publicAccess: '중지 — 방문자에게 안내 페이지(503)를 보입니다', adminAccess: '유지 — 포털·Webmaker 제작 화면은 계속 사용', mail: mails.length ? `유지 — 사이트 대표 메일(${mails.map((m) => m.domain).join(', ')}) 송수신은 계속됩니다` : '해당 없음', backups: bo && bo.status === 'active' ? `신규 정기 백업 중지(기존 백업 유지) · 재개 시 ${bo.resume_mode === 'auto' ? '자동 재시작' : '사용자 확인 후 재시작'}` : '해당 없음', usageAndCost: '운영 사이트 수와 배정 용량은 계속 소비하며 청구도 계속됩니다', resume: '언제든 재개할 수 있습니다(재개 시 공개 접속·백업 복구)', siteCount: `사이트 수 ${f.allocated}/${f.purchased} 사용 중(일시 중지도 포함)` };
}
export function pauseSite(deps, site, { by }) {
transfer.assertNotFrozen(deps, 'site', site.id);
if (!['live', 'needs_domain'].includes(site.lifecycle)) throw new HttpError(409, 'lifecycle_transition', `지금 상태(${SERVICE_LABEL[site.lifecycle] || site.lifecycle})에서는 일시 중지할 수 없습니다`);
const conflicts = inProgress(deps, 'site', site.id); if (conflicts.length) throw new HttpError(409, 'conflict', '진행 중인 배포·복원·이전이 끝나야 중지할 수 있습니다', { conflicts });
const job = publicJob(deps, site, 'stop', by); const s = setState(deps, site.id, 'paused', { by, reason: 'user_pause', extra: { paused_at: nowIso(), prev_lifecycle: site.lifecycle, public_stopped_at: nowIso() } });
backup.stopForSite(deps, site.id, 'site_paused');
notify(deps.store, { groupId: site.group_id, category: 'site', title: `${site.name}: 일시 중지했습니다`, body: '공개 접속이 중지되었습니다. 운영 사이트 수와 용량은 계속 소비합니다', link: `/sites/${site.id}/lifecycle`, subjectType: 'site', subjectId: site.id });
return { site: s, job };
}
export function resumeSite(deps, site, { by }) {
if (site.lifecycle !== 'paused') throw new HttpError(409, 'lifecycle_transition', '일시 중지 상태가 아닙니다');
const job = publicJob(deps, site, 'start', by); const s = setState(deps, site.id, site.prev_lifecycle && site.prev_lifecycle !== 'paused' ? site.prev_lifecycle : 'live', { by, reason: 'user_resume', extra: { paused_at: null, public_stopped_at: null, prev_lifecycle: null } });
const bo = backup.resumeForSite(deps, site.id, { by: 'system' }); const needConfirm = !!(bo && bo.status === 'paused');
notify(deps.store, { groupId: site.group_id, category: 'site', title: `${site.name}: 재개했습니다`, body: needConfirm ? '정기 백업은 백업 화면에서 다시 시작을 확인해야 합니다' : '정기 백업이 자동으로 다시 시작됩니다', link: `/sites/${site.id}/lifecycle`, subjectType: 'site', subjectId: site.id });
return { site: s, job, backupNeedsConfirm: needConfirm };
}
/** 해지 전 안내(§23.1): 대상·연결 서비스·즉시 중지·남는 데이터·보존 종료·내보내기·미결제·되돌릴 수 없는 시점 */
export function cancelImpact(deps, site, { at = null } = {}) {
const r = retentionPolicy(deps); const when = at || nowIso(); const retentionUntil = addMs(when, r.site_days * DAY);
const mails = activeMailsOf(deps, site.id); const bo = backup.optionOf(deps, site.id); const domains = deps.store.all('SELECT name, is_primary FROM domains WHERE site_id=?', site.id);
const unpaid = deps.store.all('SELECT order_no, total, status FROM orders WHERE group_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\') AND (target_id=? OR target_id IS NULL)', site.group_id, site.id);
const standalone = quota.figures(deps.store, site.group_id, 'mail_services');
return { target: { id: site.id, name: site.name, lifecycle: site.lifecycle }, linked: { domains: domains.map((d) => d.name + (d.is_primary ? '(대표)' : '')), siteMail: mails.map((m) => m.domain), backupOption: bo ? bo.status : null, source: '연결된 Source 저장소는 30일 읽기 전용 뒤 삭제됩니다(Source 영역)' },
immediateStops: ['공개 접속(대표·추가 도메인 라우팅 해제)', '새 배포·설정 변경', '신규 정기 백업', '대표 메일 송수신(종료를 고른 경우)'], remainingData: '사이트 파일·데이터베이스·업로드·설정은 읽기 전용으로 보존됩니다', retentionUntil, deleteAfter: addMs(retentionUntil, r.delete_grace_days * DAY), retentionSource: r.source,
export: '보존 기간 동안 내보내기를 요청할 수 있습니다', unpaid, refund: '환불·크레딧은 활성 상품정책과 주문 화면 기준으로 처리됩니다', pointOfNoReturn: `${when} (해지 시각) — 그 뒤에는 재개할 수 없고 보존 기간 뒤 삭제됩니다`,
mailOptions: mails.length ? { keep: { label: '독립 메일 서비스로 전환해 유지', available: standalone.unallocated > 0, note: standalone.unallocated > 0 ? `독립 메일 권리 ${standalone.unallocated}개 사용 가능` : '독립 메일 권리가 부족합니다 — 상품 구매 뒤 선택할 수 있습니다' }, end: { label: '대표 메일 종료(30일 읽기·내보내기 후 삭제)', available: true } } : null,
siteCount: '해지 시각에 공개 중지·도메인 해제·메일 처리 조건이 충족되면 운영 사이트 수가 반환됩니다(§14)' };
}
/** 해지 예약/즉시 해지 */
export async function scheduleCancel(deps, site, { by, at = null, mailChoice = null, acknowledge = false }) {
transfer.assertNotFrozen(deps, 'site', site.id);
if (!['live', 'needs_domain', 'paused', 'draft', 'building'].includes(site.lifecycle)) throw new HttpError(409, 'lifecycle_transition', `지금 상태(${SERVICE_LABEL[site.lifecycle] || site.lifecycle})에서는 해지할 수 없습니다`);
if (!acknowledge) throw new HttpError(400, 'acknowledge_required', '남는 데이터·보존 종료일·되돌릴 수 없는 시점을 확인했다는 표시가 필요합니다');
const mails = activeMailsOf(deps, site.id);
if (mails.length) { if (!['keep', 'end'].includes(mailChoice)) throw new HttpError(400, 'mail_choice_required', '사이트 대표 메일을 유지(독립 전환)할지 종료할지 선택하세요'); if (mailChoice === 'keep') { const f = quota.figures(deps.store, site.group_id, 'mail_services'); if (f.unallocated < mails.length) throw new HttpError(409, 'quota_insufficient', '독립 메일 권리가 부족해 대표 메일을 유지할 수 없습니다. 필요한 옵션을 먼저 구매하세요', { resource: 'mail_services', needed: mails.length, available: f.unallocated, link: '/billing/products?product=mail_services' }); const mail = await optional('../mail/service.mjs'); if (!mail || !mail.convertToStandalone) throw new HttpError(409, 'needs_setup', '독립 메일 전환 기능이 아직 연결되지 않았습니다(메일 영역 필요)'); } }
const when = at && Date.parse(at) > Date.now() ? new Date(at).toISOString() : nowIso();
const s = setState(deps, site.id, 'cancel_scheduled', { by, reason: 'user_cancel', detail: { at: when, mailChoice }, extra: { cancel_at: when, mail_choice: mailChoice, prev_lifecycle: site.lifecycle, next_notice_at: when } });
notify(deps.store, { groupId: site.group_id, category: 'site', severity: 'warning', mandatory: true, title: `${site.name}: 해지가 예약되었습니다`, body: `해지 시각 ${when} 전까지 되돌릴 수 있습니다. 그 뒤 ${retentionPolicy(deps).site_days}일 읽기 전용 보존 뒤 삭제됩니다`, link: `/sites/${site.id}/lifecycle`, subjectType: 'site', subjectId: site.id, incidentKey: `lifecycle:${site.id}:cancel` });
if (Date.parse(when) <= Date.now()) return { site: await executeCancel(deps, s, 'system'), executed: true };
return { site: s, executed: false };
}
export function undoCancel(deps, site, { by }) {
if (site.lifecycle !== 'cancel_scheduled') throw new HttpError(409, 'lifecycle_transition', '해지 예약 상태가 아닙니다');
if (Date.parse(site.cancel_at) <= Date.now()) throw new HttpError(409, 'point_of_no_return', '해지 시각이 지나 되돌릴 수 없습니다');
const s = setState(deps, site.id, site.prev_lifecycle || 'live', { by, reason: 'undo_cancel', extra: { cancel_at: null, mail_choice: null, prev_lifecycle: null, next_notice_at: null } });
notify(deps.store, { groupId: site.group_id, category: 'site', title: `${site.name}: 해지 예약을 취소했습니다`, link: `/sites/${site.id}/lifecycle`, incidentKey: `lifecycle:${site.id}:cancel` });
return s;
}
/** 해지 시각 도달: 공개 중지·도메인 해제·백업 종료·메일 처리·읽기 전용 보존 → 사이트 수 반환 시도 */
export async function executeCancel(deps, site, by = 'system') {
const r = retentionPolicy(deps); const now = nowIso(); const retentionUntil = addMs(now, r.site_days * DAY); const deleteAfter = addMs(retentionUntil, r.delete_grace_days * DAY);
if (site.server_id) { publicJob(deps, site, 'unroute', by); }
deps.store.run('UPDATE domains SET routed=0, updated_at=? WHERE site_id=?', now, site.id);
const bo = backup.optionOf(deps, site.id); if (bo && bo.status !== 'ended') backup.expireOption(deps, bo.id, { by, reason: 'site_cancelled' });
const mails = activeMailsOf(deps, site.id); const mailResult = [];
for (const m of mails) {
if (site.mail_choice === 'keep') { try { const job = await startConversion(deps, m, by); mailResult.push({ id: m.id, conversionJobId: job ? job.id : null }); } catch (e) { mailResult.push({ id: m.id, kept: false, missing: [e.message] }); } }
else { await endMail(deps, m, by); mailResult.push({ id: m.id, ended: true }); }
}
const s = setState(deps, site.id, 'readonly_retention', { by, reason: 'cancel_executed', detail: { mailResult }, extra: { public_stopped_at: site.public_stopped_at || now, retention_until: retentionUntil, delete_after: deleteAfter, next_notice_at: addMs(retentionUntil, -r.notice_before_days * DAY), cancel_at: now } });
notify(deps.store, { groupId: site.group_id, category: 'site', severity: 'warning', mandatory: true, title: `${site.name}: 해지되어 읽기 전용 보존 중입니다`, body: `보존 종료 ${retentionUntil}, 삭제 예정 ${deleteAfter}. 보존 중 내보내기를 요청할 수 있으며 재개는 불가합니다`, link: `/sites/${site.id}/lifecycle`, subjectType: 'site', subjectId: site.id, incidentKey: `lifecycle:${site.id}:cancel` });
// §14: 조건이 모두 충족되면 사이트 수 반환(메일 keep 인데 누락이 있으면 반환하지 않는다)
try { const ck = await siteCountChecklist(deps, s, { acknowledge: true }); if (ck.ok) await returnSiteCount(deps, s, { by, mailChoice: s.mail_choice || 'none', acknowledge: true, confirm: true, auto: true }); } catch (e) { notify(deps.store, { groupId: site.group_id, category: 'site', severity: 'warning', title: `${site.name}: 운영 사이트 수를 아직 반환하지 못했습니다`, body: e.message, link: `/sites/${site.id}/lifecycle`, incidentKey: `lifecycle:${site.id}:return` }); }
return deps.store.get('SELECT * FROM sites WHERE id=?', site.id);
}
/** 삭제 보류(법적 보존·분쟁 §23.2) */
export function setDeleteHold(deps, site, { by, reason }) { const s = setState(deps, site.id, 'delete_hold', { by, reason: 'hold', detail: { reason }, extra: { delete_hold_reason: String(reason || '').slice(0, 300), prev_lifecycle: site.lifecycle } }); notify(deps.store, { groupId: site.group_id, category: 'site', mandatory: true, title: `${site.name}: 삭제가 보류되었습니다`, body: '법적 보존 또는 분쟁으로 삭제가 보류 중입니다. 문의: 운영자 지원 창구', link: `/sites/${site.id}/lifecycle`, incidentKey: `lifecycle:${site.id}:hold` }); return s; }
export function releaseDeleteHold(deps, site, { by }) { if (site.lifecycle !== 'delete_hold') throw new HttpError(409, 'lifecycle_transition', '삭제 보류 상태가 아닙니다'); return setState(deps, site.id, site.prev_lifecycle || 'pending_delete', { by, reason: 'hold_released', extra: { delete_hold_reason: null, prev_lifecycle: null } }); }
/** 메일 서비스 해지(송수신 즉시 중지, 30일 읽기·내보내기 §23.2). 메일 영역 helper 가 있으면 그쪽이 실제 송수신을 막는다. */
export function cancelMail(deps, ms, { by, local = false }) {
if (!local) transfer.assertNotFrozen(deps, 'mail_service', ms.id);
if (['readonly_retention', 'pending_delete', 'deleted'].includes(ms.lifecycle)) return ms;
const r = retentionPolicy(deps); const now = nowIso(); const until = addMs(now, r.mail_days * DAY); const del = addMs(until, r.delete_grace_days * DAY);
const m = setMail(deps, ms.id, { lifecycle: 'readonly_retention', status: 'cancelled', cancelled_at: now, retention_until: until, delete_after: del, next_notice_at: addMs(until, -r.notice_before_days * DAY) });
event(deps, 'mail_service', ms.id, ms.group_id, ms.lifecycle, 'readonly_retention', by, 'cancel'); audit(deps.store, { actorType: by === 'system' ? 'system' : 'user', actorId: by, groupId: ms.group_id, subjectType: 'mail_service', subjectId: ms.id, action: 'cancel.mail', detail: { retentionUntil: until, deleteAfter: del } });
notify(deps.store, { groupId: ms.group_id, category: 'mail', severity: 'warning', mandatory: true, title: `${ms.name}: 메일 서비스가 해지되었습니다`, body: `송수신은 즉시 중지되었고 기존 메일 읽기·내보내기는 ${until} 까지 가능합니다. 삭제 예정 ${del}. 메일 원문은 고객용 백업·복원 대상이 아닙니다`, link: `/mail/${ms.id}/lifecycle`, subjectType: 'mail_service', subjectId: ms.id, incidentKey: `lifecycle:mail:${ms.id}` });
// 메일함 수·저장 공간 배정은 삭제 때까지 소비(§16.1 정지 메일함과 같은 원칙) — 반환은 삭제 시점
return m;
}
/** §14 체크리스트 — 8단계 각각의 충족 여부와 해야 할 일 */
export async function siteCountChecklist(deps, site, { mailChoice = null, acknowledge = false } = {}) {
const choice = mailChoice || site.mail_choice || null; const allMails = deps.store.all('SELECT * FROM mail_services WHERE site_id=?', site.id); const mails = allMails.filter((m) => !mailEnded(m)); const mail = await optional('../mail/service.mjs');
const alloc = quota.allocationOf(deps.store, site.group_id, 'sites', 'site', site.id); const resv = deps.store.get('SELECT * FROM quota_reservations WHERE group_id=? AND resource=\'sites\' AND subject_type=\'site\' AND subject_id=? AND state IN (\'reserved\',\'in_use\')', site.group_id, site.id);
const consumes = !!alloc || site.site_count_state === 'in_use' || site.site_count_state === 'reserved';
const domains = deps.store.all('SELECT name, routed, is_primary FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\')', site.id);
const jobs = inProgress(deps, 'site', site.id).filter((c) => !(c.kind || '').startsWith('lifecycle.'));
let mailStep; let convertStep;
if (!mails.length) { mailStep = { ok: true, note: allMails.length ? '사이트 대표 메일 종료됨(또는 독립 전환됨)' : '사이트 대표 메일 없음' }; convertStep = { ok: true, note: '해당 없음' }; }
else if (choice === 'end') { mailStep = { ok: true, choice, note: '대표 메일 종료 선택' }; convertStep = { ok: false, note: '반환 실행 때 대표 메일이 종료됩니다(30일 읽기·내보내기 후 삭제)', action: 'end_mail', pendingOnExecute: true }; }
else if (choice === 'keep') { const f = quota.figures(deps.store, site.group_id, 'mail_services'); const need = mails.filter((m) => m.kind === 'site_bound').length; const entitled = f.unallocated >= need; mailStep = { ok: entitled, choice, note: entitled ? '독립 메일로 전환해 유지' : `독립 메일 권리 부족(필요 ${need}, 가능 ${f.unallocated}) — 필요한 옵션을 구매하세요`, action: entitled ? null : 'buy_mail_services', link: '/billing/products?product=mail_services' };
const converting = mails.some((m) => deps.store.get('SELECT 1 AS x FROM jobs WHERE kind=\'mail.convert\' AND subject_type=\'mail_service\' AND subject_id=? AND status IN (\'queued\',\'running\',\'awaiting_external\',\'needs_user\')', m.id));
let conv; if (mail && mail.canReturnSiteCount) { try { const r = await mail.canReturnSiteCount(deps, site.id); conv = { ok: !!r.ok, note: r.ok ? '전환 완료·송수신 확인됨' : converting ? '독립 전환 진행 중 — 전환 전후 송수신 확인이 끝나면 반환할 수 있습니다' : (r.reasons || []).map((x) => x.message || x).join(' / ') || '전환 전', missing: (r.reasons || []).map((x) => x.code || x), link: r.reasons && r.reasons[0] && r.reasons[0].link }; } catch (e) { conv = { ok: false, note: e.message, missing: ['확인 실패'] }; } } else conv = { ok: false, note: '독립 메일 전환 기능이 아직 연결되지 않았습니다', missing: ['메일 영역 미연결'] }; convertStep = conv; }
else { mailStep = { ok: false, note: '대표 메일 유지(독립 전환) 또는 종료를 선택하세요', action: 'choose_mail' }; convertStep = { ok: false, note: '선택 후 확인' }; }
const steps = [
{ id: 'public_stopped', title: '공개 서비스 중지', ok: !isPublic(site), note: isPublic(site) ? '사이트가 아직 공개 중입니다(일시 중지 또는 해지 필요)' : `중지됨 ${site.public_stopped_at || ''}` },
{ id: 'domains_unrouted', title: '대표 도메인을 포함한 모든 웹 도메인의 공개 라우팅 해제', ok: domains.every((d) => !d.routed), note: domains.length ? domains.map((d) => `${d.name}${d.is_primary ? '(대표)' : ''}: ${d.routed ? '연결 중' : '해제됨'}`).join(', ') : '연결된 웹 도메인 없음', action: domains.some((d) => d.routed) ? 'unroute' : null },
{ id: 'no_jobs', title: '진행 중인 배포·복원·이전 종료', ok: jobs.length === 0, note: jobs.length ? jobs.map((j) => `${j.type} ${j.status}`).join(', ') : '없음' },
{ id: 'mail_choice', title: '사이트 대표 메일의 유지 또는 종료 선택', ...mailStep },
{ id: 'mail_converted', title: '유지할 대표 메일을 독립 메일 서비스로 전환(주소·메일함·별칭·전달·비밀번호·DNS 유지 확인)', ...convertStep },
{ id: 'retention_ack', title: '데이터와 보존 기한 확인', ok: !!acknowledge, note: `보존 종료 ${site.retention_until || '(해지 시 정해짐)'} · 삭제 예정 ${site.delete_after || '(보존 종료 후)'}` },
{ id: 'final_confirm', title: '사용자 최종 확인', ok: false, note: '아래 "사이트 수 반환" 버튼에서 확인' },
{ id: 'return', title: '사이트 수 반환', ok: !consumes, note: consumes ? `반환 대상: 배정 ${alloc ? alloc.amount : 0}${resv ? ` · 예약 ${resv.state}` : ''}` : '이미 반환됨(소비하지 않음)' },
];
const blocking = steps.filter((s) => !s.ok && !s.pendingOnExecute && !['final_confirm', 'return', 'retention_ack'].includes(s.id));
return { ok: blocking.length === 0 && !!acknowledge, steps, blocking: blocking.map((s) => s.id), consumes, allocationId: alloc ? alloc.id : null, reservationId: resv ? resv.id : null };
}
/** §14 실행: 조건을 만족시키는 작업을 수행하고 모두 충족되면 반환한다 */
export async function returnSiteCount(deps, site, { by, mailChoice = null, acknowledge = false, confirm = false, auto = false }) {
if (!auto) transfer.assertNotFrozen(deps, 'site', site.id);
if (!confirm) throw new HttpError(400, 'confirmation_required', '사용자 최종 확인이 필요합니다');
const choice = mailChoice || site.mail_choice; if (choice && choice !== site.mail_choice) setSite(deps, site.id, { mail_choice: choice });
let fresh = deps.store.get('SELECT * FROM sites WHERE id=?', site.id);
// 1) 부수 효과 전에 먼저 점검: 메일 처리·라우팅 해제 말고 다른 조건(공개 중·진행 작업·메일 선택)이 막혀 있으면 아무것도 바꾸지 않는다
if (isPublic(fresh) && !auto) throw new HttpError(409, 'site_public', '먼저 사이트를 일시 중지하거나 해지하세요(공개 서비스 중지)');
if (choice === 'keep') { const live = activeMailsOf(deps, fresh.id).filter((x) => x.kind === 'site_bound').length; const f = quota.figures(deps.store, fresh.group_id, 'mail_services'); if (live && f.unallocated < live) throw new HttpError(409, 'quota_insufficient', '독립 메일 권리가 부족해 반환할 수 없습니다. 필요한 옵션을 구매하거나 메일 종료를 선택하세요', { resource: 'mail_services', needed: live, available: f.unallocated, link: '/billing/products?product=mail_services' }); }
const pre = await siteCountChecklist(deps, fresh, { mailChoice: choice, acknowledge }); const hard = pre.blocking.filter((b) => !['domains_unrouted', 'mail_converted'].includes(b));
if (hard.length) throw new HttpError(409, 'site_count_return_blocked', `아직 충족되지 않은 조건이 있어 반환하지 않습니다: ${hard.map((b) => ({ public_stopped: '공개 서비스 중지', no_jobs: '진행 중 작업 종료', mail_choice: '대표 메일 선택(권리 포함)' }[b] || b)).join(', ')}`, { checklist: pre });
// 2) 실행 가능한 조치: 라우팅 해제·메일 처리
if (deps.store.get('SELECT 1 AS x FROM domains WHERE site_id=? AND routed=1 AND purpose IN (\'web\',\'both\')', fresh.id)) { if (fresh.server_id) publicJob(deps, fresh, 'unroute', by); deps.store.run('UPDATE domains SET routed=0, updated_at=? WHERE site_id=?', nowIso(), fresh.id); }
const mails = activeMailsOf(deps, fresh.id);
if (mails.length && choice === 'keep') { const f = quota.figures(deps.store, fresh.group_id, 'mail_services'); const need = mails.filter((m) => m.kind === 'site_bound').length; if (f.unallocated < need) throw new HttpError(409, 'quota_insufficient', '독립 메일 권리가 부족해 반환할 수 없습니다. 필요한 옵션을 구매하거나 메일 종료를 선택하세요', { resource: 'mail_services', needed: need, available: f.unallocated, link: '/billing/products?product=mail_services' }); for (const m of mails.filter((x) => x.kind === 'site_bound')) await startConversion(deps, m, by); }
if (mails.length && choice === 'end') for (const m of mails) await endMail(deps, m, by);
fresh = deps.store.get('SELECT * FROM sites WHERE id=?', site.id);
const ck = await siteCountChecklist(deps, fresh, { mailChoice: choice, acknowledge }); if (!ck.ok) throw new HttpError(409, 'site_count_return_blocked', `아직 충족되지 않은 조건이 있어 반환하지 않습니다: ${ck.blocking.map((b) => ({ public_stopped: '공개 서비스 중지', domains_unrouted: '도메인 라우팅 해제', no_jobs: '진행 중 작업 종료', mail_choice: '대표 메일 선택', mail_converted: '독립 메일 전환 완료(송수신 확인 포함)' }[b] || b)).join(', ')}`, { checklist: ck });
return deps.store.tx(() => {
if (ck.allocationId) { quota.beginReturn(deps.store, ck.allocationId); quota.finishReturn(deps.store, ck.allocationId); }
if (ck.reservationId) quota.release(deps.store, ck.reservationId);
setSite(deps, fresh.id, { site_count_state: 'none', site_count_reservation_id: null });
audit(deps.store, { actorType: auto ? 'system' : 'user', actorId: by, groupId: fresh.group_id, subjectType: 'site', subjectId: fresh.id, action: 'site_count.return', detail: { allocationId: ck.allocationId, mailChoice: choice } });
notify(deps.store, { groupId: fresh.group_id, category: 'site', title: `${fresh.name}: 운영 사이트 수를 반환했습니다`, body: '반환된 사이트 수는 다른 사이트의 최초 공개 배포에 사용할 수 있습니다', link: '/usage', subjectType: 'site', subjectId: fresh.id, incidentKey: `lifecycle:${fresh.id}:return` });
return { returned: true, checklist: ck, figures: quota.figures(deps.store, fresh.group_id, 'sites') };
});
}
/** §23.3 한 화면 생명주기 보기 */
export function lifecycleView(deps, site) {
const r = retentionPolicy(deps); const st = site.lifecycle; const alloc = quota.allocationOf(deps.store, site.group_id, 'sites', 'site', site.id); const bo = backup.optionOf(deps, site.id);
const mails = deps.store.all('SELECT id, name, domain, lifecycle, status, retention_until FROM mail_services WHERE site_id=?', site.id).map((m) => ({ ...m, lifecycle: m.status === 'deleted' ? 'deleted' : m.status === 'cancelled' && m.lifecycle === 'active' ? 'readonly_retention' : m.lifecycle })); const domains = deps.store.all('SELECT name, is_primary, routed FROM domains WHERE site_id=?', site.id);
const events = deps.store.all('SELECT * FROM lifecycle_events WHERE subject_type=\'site\' AND subject_id=? ORDER BY at DESC LIMIT 20', site.id).map((e) => ({ ...e, detail: parseJson(e.detail_json, null) }));
const deleted = st === 'deleted'; const frozen = transfer.isFrozen(deps, 'site', site.id);
const billing = ['readonly_retention', 'pending_delete', 'deleted'].includes(st) ? `청구 종료: ${site.cancel_at || site.retention_until || ''}` : st === 'cancel_scheduled' ? `해지 시각(${site.cancel_at})까지 청구 계속, 그 뒤 종료` : '청구 계속(일시 중지 포함)';
return {
site: { id: site.id, name: site.name, lifecycle: st, label: SERVICE_LABEL[st] || st, deployMode: site.deploy_mode, primaryDomain: site.primary_domain }, frozen, frozenNote: frozen ? '소유권 분쟁으로 설정 변경·삭제·추가 이전이 동결되었습니다. 문의: 운영자 지원 창구' : null,
publicAccess: isPublic(site) ? '제공 중' : deleted ? '삭제됨' : '중지됨', adminAccess: deleted ? '불가' : ['readonly_retention', 'pending_delete', 'delete_hold'].includes(st) ? '읽기 전용(내보내기 가능)' : '가능',
siteCountConsumed: !!alloc || ['reserved', 'in_use'].includes(site.site_count_state), capacityConsumed: deps.store.all('SELECT resource, amount FROM quota_allocations WHERE group_id=? AND subject_type=\'site\' AND subject_id=? AND resource<>\'sites\'', site.group_id, site.id),
billing, domains: domains.map((d) => ({ name: d.name, primary: !!d.is_primary, routed: !!d.routed })), mail: mails.map((m) => ({ id: m.id, domain: m.domain, lifecycle: m.lifecycle, retentionUntil: m.retention_until })), backup: bo ? { status: bo.status, endsAt: bo.ends_at, deleteAfter: bo.delete_after } : null,
exportAvailable: !deleted, resumeAvailable: st === 'paused' || (st === 'cancel_scheduled' && Date.parse(site.cancel_at) > Date.now()), pointOfNoReturn: st === 'cancel_scheduled' ? site.cancel_at : ['readonly_retention', 'pending_delete'].includes(st) ? `${site.cancel_at || ''} (지남 — 재개 불가)` : deleted ? '삭제 완료 — 복구할 수 없습니다' : '해지 시각(해지 예약 시 표시)',
retentionUntil: site.retention_until, deleteAfter: site.delete_after, deleteHold: st === 'delete_hold' ? { reason: '법적 보존 또는 분쟁', contact: '운영자 지원 창구' } : null, nextNoticeAt: site.next_notice_at, retentionPolicy: r, cancelAt: site.cancel_at, pausedAt: site.paused_at, mailChoice: site.mail_choice, deletedAt: site.deleted_at, events,
mailBackupNote: '메일 원문은 고객용 백업·복원 대상이 아니며 메일 해지 후 30일 읽기·내보내기만 제공됩니다',
};
}
export function mailLifecycleView(deps, ms) {
const r = retentionPolicy(deps); const st = ms.status === 'deleted' ? 'deleted' : ms.status === 'cancelled' && (ms.lifecycle || 'active') === 'active' ? 'readonly_retention' : (ms.lifecycle || 'active'); const deleted = st === 'deleted';
return { mailService: { id: ms.id, name: ms.name, domain: ms.domain, kind: ms.kind, lifecycle: st, label: { active: '운영 중', paused: '일시 중지', readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류' }[st] || st, siteId: ms.site_id },
sendReceive: st === 'active' ? '송수신 가능' : deleted ? '삭제됨' : '송수신 중지(읽기·내보내기만)', adminAccess: deleted ? '불가' : '가능', consumes: !deleted ? '메일함 수·저장 공간 배정을 삭제 전까지 소비합니다' : '반환됨', billing: ['readonly_retention', 'pending_delete', 'deleted'].includes(st) ? `청구 종료: ${ms.cancelled_at || ''}` : '청구 계속',
exportAvailable: !deleted, resumeAvailable: false, pointOfNoReturn: ms.cancelled_at ? `${ms.cancelled_at} (해지 시각 — 재개 불가)` : '해지 시각', retentionUntil: ms.retention_until, deleteAfter: ms.delete_after, nextNoticeAt: ms.next_notice_at, deleteHold: st === 'delete_hold' ? { reason: ms.delete_hold_reason || '법적 보존 또는 분쟁', contact: '운영자 지원 창구' } : null, retentionPolicy: r, backupNote: '고객용 메일 백업·복원은 제공하지 않습니다(§17.4)', frozen: transfer.isFrozen(deps, 'mail_service', ms.id) };
}
/** 내보내기 요청(보존 중 §23.1): 운영자 작업으로 기록 */
export function requestExport(deps, site, { by }) { if (site.lifecycle === 'deleted') throw new HttpError(409, 'deleted', '삭제된 사이트는 내보낼 수 없습니다'); notify(deps.store, { groupId: null, category: 'ops', title: `[운영] 데이터 내보내기 요청 — ${site.name}`, body: `사이트 ${site.id}, 요청자 ${by}`, link: `/ops/lifecycle/sites/${site.id}`, incidentKey: `export:${site.id}` }); audit(deps.store, { actorType: 'user', actorId: by, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'export.request' }); notify(deps.store, { groupId: site.group_id, category: 'site', title: `${site.name}: 내보내기를 요청했습니다`, body: '준비되면 내려받을 수 있는 링크를 알림으로 보냅니다. 내보내기 파일에는 현재 사용자가 볼 수 있는 정보만 포함됩니다', link: `/sites/${site.id}/lifecycle` }); return { requested: true }; }
/** 주기 작업: 해지 실행·보존 종료·삭제·사전 알림(사이트·메일) */
export async function runDue(deps, now = nowIso()) {
const out = { cancelled: [], pendingDelete: [], deleted: [], noticed: [] }; const r = retentionPolicy(deps);
for (const s of deps.store.all('SELECT * FROM sites WHERE lifecycle=\'cancel_scheduled\' AND cancel_at<=?', now)) { await executeCancel(deps, s, 'system'); out.cancelled.push(s.id); }
for (const s of deps.store.all('SELECT * FROM sites WHERE lifecycle=\'readonly_retention\' AND next_notice_at IS NOT NULL AND next_notice_at<=? AND retention_until>?', now, now)) { notify(deps.store, { groupId: s.group_id, category: 'site', severity: 'warning', mandatory: true, title: `${s.name}: 보존 종료와 삭제가 ${r.notice_before_days}일 안에 예정되어 있습니다`, body: `보존 종료 ${s.retention_until}, 삭제 ${s.delete_after}. 필요한 데이터는 지금 내보내세요`, link: `/sites/${s.id}/lifecycle`, incidentKey: `lifecycle:${s.id}:notice` }); setSite(deps, s.id, { next_notice_at: s.retention_until }); out.noticed.push(s.id); }
for (const s of deps.store.all('SELECT * FROM sites WHERE lifecycle=\'readonly_retention\' AND retention_until<=?', now)) { setState(deps, s.id, 'pending_delete', { by: 'system', reason: 'retention_ended', extra: { next_notice_at: s.delete_after } }); notify(deps.store, { groupId: s.group_id, category: 'site', severity: 'critical', mandatory: true, title: `${s.name}: 삭제 대기 — ${s.delete_after} 에 삭제됩니다`, body: '삭제 뒤에는 복구할 수 없습니다', link: `/sites/${s.id}/lifecycle`, incidentKey: `lifecycle:${s.id}:delete` }); out.pendingDelete.push(s.id); }
for (const s of deps.store.all('SELECT * FROM sites WHERE lifecycle=\'pending_delete\' AND delete_after<=?', now)) { deps.jobs.enqueue({ kind: 'lifecycle.delete', subject: { siteId: s.id, groupId: s.group_id }, idempotencyKey: `lifecycle:delete:${s.id}`, actorType: 'system', groupId: s.group_id, subjectType: 'site', subjectId: s.id, label: `사이트 데이터 삭제 (${s.name})` }); out.deleted.push(s.id); }
for (const m of deps.store.all('SELECT * FROM mail_services WHERE lifecycle=\'readonly_retention\' AND next_notice_at IS NOT NULL AND next_notice_at<=? AND retention_until>?', now, now)) { notify(deps.store, { groupId: m.group_id, category: 'mail', severity: 'warning', mandatory: true, title: `${m.name}: 메일 보존 종료가 ${r.notice_before_days}일 안에 예정되어 있습니다`, body: `보존 종료 ${m.retention_until}, 삭제 ${m.delete_after}. 필요한 메일은 지금 내보내세요`, link: `/mail/${m.id}/lifecycle`, incidentKey: `lifecycle:mail:${m.id}:notice` }); setMail(deps, m.id, { next_notice_at: m.retention_until }); }
for (const m of deps.store.all('SELECT * FROM mail_services WHERE lifecycle=\'readonly_retention\' AND retention_until<=?', now)) { setMail(deps, m.id, { lifecycle: 'pending_delete' }); event(deps, 'mail_service', m.id, m.group_id, 'readonly_retention', 'pending_delete', 'system', 'retention_ended'); notify(deps.store, { groupId: m.group_id, category: 'mail', severity: 'critical', mandatory: true, title: `${m.name}: 메일 삭제 대기 — ${m.delete_after} 에 삭제됩니다`, link: `/mail/${m.id}/lifecycle`, incidentKey: `lifecycle:mail:${m.id}:delete` }); }
const mail = await optional('../mail/service.mjs');
if (mail && mail.purgeExpired) { try { await mail.purgeExpired(deps, now); } catch (e) { notify(deps.store, { groupId: null, category: 'ops', severity: 'warning', title: '[운영] 메일 보존 만료 삭제 실패', body: e.message, incidentKey: 'ops:mail-purge' }); } for (const m of deps.store.all('SELECT * FROM mail_services WHERE status=\'deleted\' AND lifecycle<>\'deleted\'')) { setMail(deps, m.id, { lifecycle: 'deleted' }); event(deps, 'mail_service', m.id, m.group_id, m.lifecycle, 'deleted', 'system', 'deleted'); } }
for (const m of deps.store.all('SELECT * FROM mail_services WHERE lifecycle=\'pending_delete\' AND delete_after<=? AND status<>\'deleted\'', now)) { if (mail && mail.purgeExpired) continue; const alloc = deps.store.all('SELECT id FROM quota_allocations WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', m.group_id, m.id); for (const a of alloc) quota.finishReturn(deps.store, a.id); setMail(deps, m.id, { lifecycle: 'deleted', deleted_at: now }); event(deps, 'mail_service', m.id, m.group_id, 'pending_delete', 'deleted', 'system', 'deleted'); audit(deps.store, { actorType: 'system', groupId: m.group_id, subjectType: 'mail_service', subjectId: m.id, action: 'terminate.mail.deleted', immutable: true }); notify(deps.store, { groupId: m.group_id, category: 'mail', mandatory: true, title: `${m.name}: 메일 데이터가 삭제되었습니다`, body: '삭제된 메일은 복구할 수 없습니다', link: `/mail/${m.id}/lifecycle`, incidentKey: `lifecycle:mail:${m.id}:delete` }); }
// §14 자동 반환 재시도: 해지된 사이트가 아직 사이트 수를 소비하면(독립 전환 완료 대기 등) 조건을 다시 확인한다
for (const s of deps.store.all('SELECT * FROM sites WHERE lifecycle IN (\'readonly_retention\',\'pending_delete\') AND site_count_state<>\'none\'')) { try { const ck = await siteCountChecklist(deps, s, { acknowledge: true }); if (ck.ok) { await returnSiteCount(deps, s, { by: 'system', acknowledge: true, confirm: true, auto: true }); out.returned = (out.returned || []).concat(s.id); } } catch (_) { /* 다음 주기에 다시 */ } }
return out;
}
export function registerJobs(deps) {
deps.jobs.register('lifecycle.public', { title: '공개 서비스 상태 변경', maxAttempts: 5, retryable: (e) => !!e.retryable, steps: [{ id: 'apply', title: '웹서버 설정 적용' }, { id: 'verify', title: '적용 확인' }],
async run(job, ctx) { const site = deps.store.get('SELECT * FROM sites WHERE id=?', job.subject.siteId); if (!site || !site.server_id) return { skipped: 'no_server' }; const mode = job.subject.mode; const domains = deps.store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\')', site.id).map((d) => d.name);
const { halJobId } = await ctx.step('apply', async () => { try { return { halJobId: (await halOf(deps).createJob({ kind: 'domain.vhost', target: site.server_id, payload: { siteId: site.id, domains: mode === 'unroute' ? [] : domains, primaryDomain: mode === 'unroute' ? null : site.primary_domain, maintenance: mode !== 'start', unroute: mode === 'unroute' }, idempotencyKey: `hal:${job.id}:vhost`, groupId: site.group_id, subjectType: 'site', subjectId: site.id })).halJobId }; } catch (e) { throw asTransient(e); } });
return ctx.step('verify', async () => { let j; try { j = await halOf(deps).getJob(halJobId); } catch (e) { throw asTransient(e); } if (j.status === 'done') { if (mode === 'unroute') deps.store.run('UPDATE domains SET routed=0 WHERE site_id=?', site.id); if (mode === 'start') deps.store.run('UPDATE domains SET routed=1 WHERE site_id=?', site.id); return j.result || {}; } if (['failed', 'cancelled'].includes(j.status)) throw new JobError('vhost_failed', j.error || '웹서버 설정 적용 실패'); ctx.retryLater(5000, '적용 대기'); }); } });
deps.jobs.register('lifecycle.delete', { title: '사이트 데이터 삭제', maxAttempts: 5, retryable: (e) => !!e.retryable, steps: [{ id: 'check', title: '삭제 보류 여부 확인' }, { id: 'remove', title: '서버 데이터 삭제' }, { id: 'release', title: '권리·배정 반환과 기록' }],
async run(job, ctx) { const site = deps.store.get('SELECT * FROM sites WHERE id=?', job.subject.siteId); if (!site || site.lifecycle === 'deleted') return { already: true };
await ctx.step('check', () => { if (site.lifecycle === 'delete_hold') throw new JobError('delete_hold', '삭제 보류 상태입니다'); if (transfer.isFrozen(deps, 'site', site.id)) throw new JobError('transfer_disputed', '소유권 분쟁으로 삭제가 동결되었습니다'); return { ok: true }; });
await ctx.step('remove', async () => { if (!site.server_id) return { skipped: 'no_server' }; try { const h = await halOf(deps).createJob({ kind: 'site.remove', target: site.server_id, payload: { siteId: site.id, keepData: false }, idempotencyKey: `hal:${job.id}:remove`, groupId: site.group_id, subjectType: 'site', subjectId: site.id }); const j = await halOf(deps).getJob(h.halJobId); if (['failed', 'cancelled'].includes(j.status)) throw new JobError('remove_failed', j.error || '삭제 실패'); if (j.status !== 'done') ctx.retryLater(5000, '삭제 진행 중'); return j.result; } catch (e) { throw asTransient(e); } });
await ctx.step('release', () => { for (const a of deps.store.all('SELECT id FROM quota_allocations WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', site.group_id, site.id)) quota.finishReturn(deps.store, a.id); const now = nowIso(); setState(deps, site.id, 'deleted', { by: 'system', reason: 'deleted', extra: { deleted_at: now, site_count_state: 'none', next_notice_at: null } }); deps.store.run('UPDATE domains SET routed=0, site_id=NULL, updated_at=? WHERE site_id=?', now, site.id); notify(deps.store, { groupId: site.group_id, category: 'site', mandatory: true, title: `${site.name}: 사이트 데이터가 삭제되었습니다`, body: '삭제된 데이터는 복구할 수 없습니다', link: `/sites/${site.id}/lifecycle`, incidentKey: `lifecycle:${site.id}:delete` }); return { deletedAt: now }; });
return { deleted: true }; } });
}
+53
View File
@@ -0,0 +1,53 @@
/**
* portal/lifecycle/testing/fakehal.mjs — 테스트용 가짜 HAL 어댑터(docs/HAL-JOBS.md 계약과 같은 모양).
* 작업은 기본적으로 즉시 완료되며 종류별 결과를 돌려준다. behave[kind] 로 결과·오류를 바꾸고, gate 가 켜진 site.relocate 는 전환 직전에 awaiting_external 로 멈춘다(resumeJob 으로 진행).
*/
import { newId } from '../../../lib/crypto.mjs';
export function createFakeHal({ servers = null } = {}) {
const jobs = new Map(); const calls = [];
const fake = {
servers: servers || [
{ id: 'srv-a', name: 'web-1', role: 'web', location: 'seoul', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: new Date().toISOString(), capacity: { diskFreeBytes: 50e9, diskTotalBytes: 100e9, sitesHosted: 3 } },
{ id: 'srv-b', name: 'backup-1', role: 'backup', location: 'seoul', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: new Date().toISOString(), capacity: { diskFreeBytes: 500e9, diskTotalBytes: 1e12 } },
{ id: 'srv-c', name: 'web-2', role: 'web', location: 'busan', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: new Date().toISOString(), capacity: { diskFreeBytes: 80e9, diskTotalBytes: 100e9, sitesHosted: 1 } },
],
behave: {}, calls, jobs, unreachable: false, gateRelocate: false,
async createJob(req) {
if (fake.unreachable) { const e = new Error('unreachable'); e.code = 'service_unreachable'; throw e; }
calls.push(req);
const ex = req.idempotencyKey && [...jobs.values()].find((j) => j.idempotencyKey === req.idempotencyKey); if (ex) return { halJobId: ex.id, status: ex.status };
const id = newId('hj_'); const job = { id, kind: req.kind, target: req.target, payload: req.payload, idempotencyKey: req.idempotencyKey, status: 'done', steps: [], progress: 100, result: null, error: null, updatedAt: new Date().toISOString() };
try {
const b = fake.behave[req.kind]; const r = b ? await b(req, job) : defaultResult(req);
if (r && r.__pending) { job.status = r.status || 'running'; job.result = null; } else job.result = r;
if (req.kind === 'site.relocate' && fake.gateRelocate && !(r && r.__pending)) { job.status = 'awaiting_external'; job.steps = ['precheck', 'prepare', 'precopy', 'verify_copy', 'final_sync', 'verify_new'].map((s) => ({ id: s, state: 'done' })).concat([{ id: 'switch', state: 'waiting', detail: '전환 대기' }]); job.pendingResult = r; job.result = null; }
} catch (e) { job.status = 'failed'; job.error = e.message; job.errorCode = e.code || 'failed'; }
jobs.set(id, job); return { halJobId: id, status: job.status };
},
async getJob(id) { if (fake.unreachable) { const e = new Error('unreachable'); e.code = 'service_unreachable'; throw e; } const j = jobs.get(id); if (!j) throw Object.assign(new Error('없음'), { code: 'not_found' }); return j; },
async resumeJob(id) { const j = jobs.get(id); if (j && j.status === 'awaiting_external') { j.status = 'done'; j.result = j.pendingResult; j.steps.forEach((s) => (s.state = 'done')); } return j; },
async cancelJob(id) { const j = jobs.get(id); if (j) { j.status = 'cancelled'; } return j; },
async retryJob(id) { return jobs.get(id); }, async revertJob(id) { const j = jobs.get(id); if (j) j.status = 'reverted'; return j; },
async listServers({ role } = {}) { if (fake.unreachable) { const e = new Error('unreachable'); e.code = 'service_unreachable'; throw e; } return fake.servers.filter((s) => !role || s.role === role); },
async pickServer({ role = 'web' } = {}) { const s = fake.servers.find((x) => x.role === role && x.acceptsNew); return s ? { serverId: s.id } : null; },
async health() { return { ok: !fake.unreachable, version: 'fake' }; },
/** 테스트: 진행 중이던 이전 작업을 전환 직전 멈춤 상태로 옮긴다 */
reachGate(id, result) { const j = jobs.get(id); j.status = 'awaiting_external'; j.pendingResult = result || defaultResult({ kind: 'site.relocate', payload: j.payload }); j.steps = ['precheck', 'prepare', 'precopy', 'verify_copy', 'final_sync', 'verify_new'].map((s) => ({ id: s, state: 'done' })).concat([{ id: 'switch', state: 'waiting', detail: '전환 대기' }]); },
complete(id, result) { const j = jobs.get(id); j.status = 'done'; j.result = result; },
};
return fake;
}
function defaultResult(req) {
const p = req.payload || {};
switch (req.kind) {
case 'backup.create': return { backupId: newId('hb_'), location: `${p.storeServerId || req.target}:/srv/ariweb/backups/${p.siteId}/${Date.now()}.tar.gz`, sizeBytes: 1000, checksum: 'sha256:fake', verified: true, createdAt: new Date().toISOString() };
case 'backup.verify': return { ok: true };
case 'backup.restore': return { protectPointLocation: 'pp', restoredVersion: 'v-restored', functionCheck: { ok: true }, externalCheck: { ok: true, url: 'https://example.test' } };
case 'backup.delete': return { deleted: true };
case 'site.check': return { checks: (p.urls || []).map((u) => ({ url: u, status: 200, ok: true, ms: 10 })) };
case 'site.relocate': return { newServerId: p.toServerId, switchedAt: new Date().toISOString(), oldRetainedUntil: null };
case 'site.remove': return { removed: true, leftovers: [] };
default: return { ok: true };
}
}