Files

252 lines
46 KiB
JavaScript

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