From a014e3cd05b75e97f35ce769ec95af2ef5646645 Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 11:43:21 +0000 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=ED=98=95=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B0=B1=EC=97=85=C2=B7=EB=B3=B5=EC=9B=90?= =?UTF-8?q?,=20=EC=84=9C=EB=B2=84=20=EC=9D=B4=EC=A0=84,=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EA=B6=8C=20=EC=9D=B4=EC=A0=84,=20=EC=A4=91=EC=A7=80?= =?UTF-8?q?=C2=B7=ED=95=B4=EC=A7=80=C2=B7=EB=B3=B4=EC=A1=B4=20=EC=83=9D?= =?UTF-8?q?=EB=AA=85=EC=A3=BC=EA=B8=B0=EC=99=80=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EC=88=98=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?(=C2=A714=C2=B7=C2=A719~=C2=A723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- lang/ko/backup.json | 1 + lang/ko/lifecycle.json | 1 + portal/backup/api.mjs | 72 +++++++ portal/backup/model.mjs | 17 ++ portal/backup/public/backup.js | 58 ++++++ portal/backup/service.mjs | 277 +++++++++++++++++++++++++ portal/lifecycle/api.mjs | 47 +++++ portal/lifecycle/common.mjs | 89 ++++++++ portal/lifecycle/model.mjs | 26 +++ portal/lifecycle/public/lifecycle.js | 28 +++ portal/lifecycle/service.mjs | 251 ++++++++++++++++++++++ portal/lifecycle/testing/fakehal.mjs | 53 +++++ portal/relocation/api.mjs | 36 ++++ portal/relocation/model.mjs | 14 ++ portal/relocation/public/relocation.js | 22 ++ portal/relocation/service.mjs | 145 +++++++++++++ portal/transfer/api.mjs | 39 ++++ portal/transfer/model.mjs | 13 ++ portal/transfer/public/transfer.js | 25 +++ portal/transfer/service.mjs | 146 +++++++++++++ test/backup.test.mjs | 99 +++++++++ test/lifecycle.test.mjs | 80 +++++++ test/relocation.test.mjs | 66 ++++++ test/transfer.test.mjs | 69 ++++++ 24 files changed, 1674 insertions(+) create mode 100644 lang/ko/backup.json create mode 100644 lang/ko/lifecycle.json create mode 100644 portal/backup/api.mjs create mode 100644 portal/backup/model.mjs create mode 100644 portal/backup/public/backup.js create mode 100644 portal/backup/service.mjs create mode 100644 portal/lifecycle/api.mjs create mode 100644 portal/lifecycle/common.mjs create mode 100644 portal/lifecycle/model.mjs create mode 100644 portal/lifecycle/public/lifecycle.js create mode 100644 portal/lifecycle/service.mjs create mode 100644 portal/lifecycle/testing/fakehal.mjs create mode 100644 portal/relocation/api.mjs create mode 100644 portal/relocation/model.mjs create mode 100644 portal/relocation/public/relocation.js create mode 100644 portal/relocation/service.mjs create mode 100644 portal/transfer/api.mjs create mode 100644 portal/transfer/model.mjs create mode 100644 portal/transfer/public/transfer.js create mode 100644 portal/transfer/service.mjs create mode 100644 test/backup.test.mjs create mode 100644 test/lifecycle.test.mjs create mode 100644 test/relocation.test.mjs create mode 100644 test/transfer.test.mjs diff --git a/lang/ko/backup.json b/lang/ko/backup.json new file mode 100644 index 0000000..63f99ae --- /dev/null +++ b/lang/ko/backup.json @@ -0,0 +1 @@ +{ "backup": { "title": "백업", "restore": { "title": "복원 요청" }, "option": { "active": "활성", "paused": "중지됨", "ended": "종료됨" }, "notProvidedExternal": "외부 배포 사이트에는 백업 옵션을 제공하지 않습니다", "mailExcluded": "메일 원문은 고객용 백업·복원에 포함되지 않습니다" } } diff --git a/lang/ko/lifecycle.json b/lang/ko/lifecycle.json new file mode 100644 index 0000000..1643549 --- /dev/null +++ b/lang/ko/lifecycle.json @@ -0,0 +1 @@ +{ "lifecycle": { "title": "설정·생명주기", "relocation": "서버 이전", "transfer": "소유권 이전", "state": { "draft": "초안", "live": "운영 중", "paused": "일시 중지", "cancel_scheduled": "해지 예약", "readonly_retention": "읽기 전용 보존", "pending_delete": "삭제 대기", "deleted": "삭제 완료", "delete_hold": "삭제 보류" }, "siteCountReturn": "운영 사이트 수 반환" } } diff --git a/portal/backup/api.mjs b/portal/backup/api.mjs new file mode 100644 index 0000000..754431d --- /dev/null +++ b/portal/backup/api.mjs @@ -0,0 +1,72 @@ +/** + * portal/backup/api.mjs — 백업·복원 화면과 API(§19·§20), 운영자 내부 API(§30.3). + * + * 고객 화면 + * GET /backup 그룹의 백업 현황(옵션 활성 사이트·최근 결과·복원 요청) + * GET /sites/:id/backup 사이트 백업 탭(옵션·보관 목록·복원 요청·실행 상태) + * GET /sites/:id/restore/:backupId 복원 요청 화면(§20.1) + * 고객 API (/api/backup/…) + * GET sites/:id siteView POST sites/:id/option 옵션 구매(권리 필요) + * POST sites/:id/option/resume 재개(confirm 방식·저장 공간 정리 후) POST sites/:id/option/end 옵션 해지(확인 필요) + * POST sites/:id/run 지금 백업(수동, 옵션 활성 때) GET backups/:id 백업 하나 + * POST sites/:id/restore { backupId, completionMode } 복원 요청 GET restores/:id 복원 상태(작업 포함) + * POST restores/:id/decision { action: confirm|revert } POST restores/:id/cancel + * 운영자 내부 API (/internal/ops/backup/…, lib/serviceauth 키 hal) + * GET options · GET backups?site= · GET restores · GET restores/:id · POST restores/:id/approve {operator, window, confirm:{groupId,siteId,domain,backupAt}} + * POST restores/:id/reject · POST options/:id/override-same-server · POST options/:id/retry · POST options/:id/end · POST run-due + */ +import { HttpError, esc } from '../../lib/http.mjs'; +import { requireGroup } from '../auth/guard.mjs'; +import * as svc from './service.mjs'; +import { getSite, opsGuard, operatorOf, jobView, humanBytes, fmtKst, nowIso, parseJson, requireConfirm, inProgress } from '../lifecycle/common.mjs'; +import { audit } from '../../lib/audit.mjs'; +import { notify } from '../../lib/notify.mjs'; +import { QuotaError } from '../../lib/quota.mjs'; + +export async function register(app, deps) { + svc.registerJobs(deps); + const { page, t } = deps; + const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || (ctx.group ? [ctx.group] : []), csrf: ctx.session?.csrf, unread: ctx.unread || 0, ...o }); + const quotaErr = (e) => { if (e instanceof QuotaError) throw new HttpError(409, e.code, e.message, { ...(e.detail || {}), action: 'buy_product', link: '/billing/products?product=backup' }); throw e; }; + + // ---------- 고객 화면 ---------- + app.get('/backup', (ctx) => { requireGroup(ctx); return shell(ctx, { title: t('backup.title'), nav: 'backup', crumbs: [{ label: t('nav.backup') }], body: `
${deps.statePanel('loading')}
`, scripts: ['/ui/backup/backup.js'] }); }); + app.get('/sites/:id/backup', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return shell(ctx, { title: `${site.name} · ${t('backup.title')}`, nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('backup.title') }], body: `${siteTabs(site.id, 'backup')}
${deps.statePanel('loading')}
`, scripts: ['/ui/backup/backup.js'] }); }); + app.get('/sites/:id/restore/:backupId', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const b = backupOf(ctx.params.backupId, site); return shell(ctx, { title: t('backup.restore.title'), nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('backup.title'), href: `/sites/${site.id}/backup` }, { label: t('backup.restore.title') }], body: `
${deps.statePanel('loading')}
`, scripts: ['/ui/backup/backup.js'] }); }); + app.static('/ui/backup', new URL('./public', import.meta.url).pathname); + + function backupOf(id, site) { const b = deps.store.get('SELECT * FROM backups WHERE id=? AND site_id=?', id, site.id); if (!b) throw new HttpError(404, 'not_found', t('error.notFound')); return b; } + function siteTabs(id, on) { const tabs = [['overview', '개요', ''], ['deploy', '배포', '/deploy'], ['domains', '도메인', '/domains'], ['mail', '메일', '/mail'], ['backup', '백업', '/backup'], ['usage', '사용량 및 통계', '/usage'], ['lifecycle', '설정·생명주기', '/lifecycle']]; return ``; } + + // ---------- 고객 API ---------- + app.get('/api/backup/overview', (ctx) => { + requireGroup(ctx); const gid = ctx.group.id; + const sites = deps.store.all('SELECT * FROM sites WHERE group_id=? AND deleted_at IS NULL ORDER BY name', gid).map((s) => { const o = svc.optionOf(deps, s.id); const last = deps.store.get('SELECT created_at, status, verify_state FROM backups WHERE site_id=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', s.id); return { id: s.id, name: s.name, deployMode: s.deploy_mode, lifecycle: s.lifecycle, eligible: svc.eligibility(deps, s).ok, option: o ? { status: o.status, pausedReason: o.paused_reason, nextRunAt: o.next_run_at, lastSuccessAt: o.last_success_at, lastFailureAt: o.last_failure_at, consecutiveFailures: o.consecutive_failures, endsAt: o.ends_at, deleteAfter: o.delete_after } : null, lastBackup: last || null }; }); + const restores = deps.store.all('SELECT r.*, s.name AS site_name FROM restores r JOIN sites s ON s.id=r.site_id WHERE r.group_id=? ORDER BY r.created_at DESC LIMIT 20', gid).map((r) => ({ id: r.id, siteId: r.site_id, siteName: r.site_name, status: r.status, createdAt: r.created_at, cost: r.cost, job: jobView(deps, r.job_id) })); + return { ok: true, sites, restores, mailBackupNotice: '메일 원문은 고객용 백업·복원 대상이 아닙니다', measuredAt: nowIso() }; + }); + app.get('/api/backup/sites/:id', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); const v = svc.siteView(deps, site); return { ok: true, ...v, lastRun: v.lastRun ? jobView(deps, v.lastRun.id) : null, restores: v.restores.map((r) => ({ ...r, job: r.job ? jobView(deps, r.job.id) : null })) }; }); + app.post('/api/backup/sites/:id/option', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const body = await ctx.body(); requireConfirm(body); try { const o = svc.purchaseOption(deps, site, { by: ctx.user.id, orderId: body.orderId || null }); return { ok: true, option: o }; } catch (e) { quotaErr(e); } }); + app.post('/api/backup/sites/:id/option/resume', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const o = svc.resumeForSite(deps, site.id, { by: ctx.user.id, userConfirmed: true }); audit(deps.store, { actorType: 'user', actorId: ctx.user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'backup.option.resume' }); return { ok: true, option: o }; }); + app.post('/api/backup/sites/:id/option/end', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const body = await ctx.body(); requireConfirm(body); const o = svc.optionOf(deps, site.id); if (!o || o.status === 'ended') throw new HttpError(404, 'not_found', '활성 백업 옵션이 없습니다'); return { ok: true, option: svc.expireOption(deps, o.id, { by: ctx.user.id, reason: 'option_ended' }) }; }); + app.post('/api/backup/sites/:id/run', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const o = svc.optionOf(deps, site.id); if (!o || o.status !== 'active') throw new HttpError(409, 'backup_option_inactive', '활성 백업 옵션이 있어야 수동 백업을 할 수 있습니다'); const conflicts = inProgress(deps, 'site', site.id).filter((c) => c.kind !== 'backup.scheduled'); if (conflicts.length) throw new HttpError(409, 'conflict', '진행 중인 배포·복원·이전이 있어 지금은 백업할 수 없습니다', { conflicts }); const slot = 'manual:' + nowIso().slice(0, 16); const { job, created } = deps.jobs.enqueue({ kind: 'backup.scheduled', subject: { optionId: o.id, siteId: site.id, groupId: site.group_id, slot, manual: true }, idempotencyKey: `backup:${o.id}:${slot}`, actorType: 'user', createdBy: ctx.user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: `수동 백업 (${site.name})` }); return { ok: true, job: jobView(deps, job.id), created }; }); + app.get('/api/backup/backups/:id', (ctx) => { const b = deps.store.get('SELECT * FROM backups WHERE id=?', ctx.params.id); if (!b) throw new HttpError(404, 'not_found', t('error.notFound')); const site = getSite(deps, ctx, b.site_id, 'view'); return { ok: true, backup: { ...b, scope: parseJson(b.scope_json, {}) }, restoreInfo: svc.restoreInfo(deps, site, b) }; }); + app.post('/api/backup/sites/:id/restore', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const body = await ctx.body(); const b = backupOf(String(body.backupId || ''), site); requireConfirm(body); const r = await svc.requestRestore(deps, site, b, { by: ctx.user.id, completionMode: body.completionMode || null }); return { ok: true, restore: r.restore, created: r.created, info: svc.restoreInfo(deps, site, b), finalConfirmation: { group: ctx.group.name, site: site.name, domain: site.primary_domain } }; }); + app.get('/api/backup/restores/:id', (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); const site = getSite(deps, ctx, r.site_id, 'view'); return { ok: true, restore: { ...r, policy: parseJson(r.policy_json, {}) }, job: jobView(deps, r.job_id), site: { id: site.id, name: site.name, domain: site.primary_domain }, group: ctx.group.name }; }); + app.post('/api/backup/restores/:id/decision', async (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); getSite(deps, ctx, r.site_id, 'deploy'); const body = await ctx.body(); if (body.action === 'revert') requireConfirm(body); const out = await svc.userDecision(deps, r, body.action, ctx.user.id); return { ok: true, ...out, job: jobView(deps, r.job_id) }; }); + app.post('/api/backup/restores/:id/cancel', (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); getSite(deps, ctx, r.site_id, 'deploy'); if (!['awaiting_payment', 'awaiting_approval'].includes(r.status)) throw new HttpError(409, 'restore_not_cancellable', '승인 뒤에는 취소할 수 없습니다. 복원이 끝난 뒤 되돌리기를 사용하세요'); deps.store.run('UPDATE restores SET status=\'cancelled\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), r.id); audit(deps.store, { actorType: 'user', actorId: ctx.user.id, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.cancel', detail: { restoreId: r.id } }); return { ok: true }; }); + + // ---------- 운영자 내부 API ---------- + const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); }; + app.get('/internal/ops/backup/options', ops((ctx) => ({ ok: true, options: deps.store.all('SELECT o.*, s.name AS site_name, s.server_id FROM backup_options o JOIN sites s ON s.id=o.site_id ORDER BY o.updated_at DESC LIMIT 500').map((o) => ({ ...o, storedBytes: svc.storedBytes(deps, o.site_id) })) }))); + app.get('/internal/ops/backup/backups', ops((ctx) => ({ ok: true, backups: deps.store.all(`SELECT * FROM backups ${ctx.query.site ? 'WHERE site_id=?' : ''} ORDER BY created_at DESC LIMIT 500`, ...(ctx.query.site ? [ctx.query.site] : [])) }))); + app.get('/internal/ops/backup/restores', ops((ctx) => ({ ok: true, restores: deps.store.all(`SELECT r.*, s.name AS site_name, s.primary_domain, g.name AS group_name FROM restores r JOIN sites s ON s.id=r.site_id JOIN groups g ON g.id=r.group_id ${ctx.query.status ? 'WHERE r.status=?' : ''} ORDER BY r.created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => ({ ...r, job: jobView(deps, r.job_id) })) }))); + app.get('/internal/ops/backup/restores/:id', ops((ctx) => { const r = deps.store.get('SELECT r.*, s.name AS site_name, s.primary_domain, g.name AS group_name FROM restores r JOIN sites s ON s.id=r.site_id JOIN groups g ON g.id=r.group_id WHERE r.id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); const b = deps.store.get('SELECT * FROM backups WHERE id=?', r.backup_id); return { ok: true, restore: r, backup: b, job: jobView(deps, r.job_id), finalConfirmation: { customerGroup: r.group_name, groupId: r.group_id, site: r.site_name, siteId: r.site_id, domain: r.primary_domain, backupAt: b ? b.created_at : null } }; })); + app.post('/internal/ops/backup/restores/:id/approve', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT r.*, s.primary_domain FROM restores r JOIN sites s ON s.id=r.site_id WHERE r.id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); const b = deps.store.get('SELECT created_at FROM backups WHERE id=?', r.backup_id); const c = body.confirm || {}; if (c.groupId !== r.group_id || c.siteId !== r.site_id || (c.backupAt || null) !== (b ? b.created_at : null)) throw new HttpError(400, 'final_confirmation_mismatch', '최종 확인 내용(고객 그룹·사이트·백업 시각)이 요청과 다릅니다', { expected: { groupId: r.group_id, siteId: r.site_id, domain: r.primary_domain, backupAt: b ? b.created_at : null } }); if (body.paymentConfirmed) svc.markRestorePaid(deps, r.id, operatorOf(body)); const out = svc.approveRestore(deps, r.id, { by: operatorOf(body), window: body.window || null }); return { ok: true, restore: out.restore, job: jobView(deps, out.job.id), created: out.created }; })); + app.post('/internal/ops/backup/restores/:id/reject', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, restore: svc.rejectRestore(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason }) }; })); + app.post('/internal/ops/backup/restores/:id/retry', ops(async (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '복원 작업이 없습니다'); const body = await ctx.body(); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE restores SET status=\'queued\', error=NULL, updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; })); + app.post('/internal/ops/backup/options/:id/override-same-server', ops(async (ctx) => { const body = await ctx.body(); const o = svc.optionById(deps, ctx.params.id); if (!o) throw new HttpError(404, 'not_found', '옵션이 없습니다'); deps.store.run('UPDATE backup_options SET same_server_override=?, updated_at=? WHERE id=?', body.allow ? 1 : 0, nowIso(), o.id); audit(deps.store, { actorType: 'operator', actorId: operatorOf(body), groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, action: 'backup.option.same_server_override', detail: { allow: !!body.allow, reason: body.reason } }); return { ok: true }; })); + app.post('/internal/ops/backup/options/:id/retry', ops(async (ctx) => { const body = await ctx.body(); const o = svc.optionById(deps, ctx.params.id); if (!o) throw new HttpError(404, 'not_found', '옵션이 없습니다'); const last = deps.store.get('SELECT id, status FROM jobs WHERE kind=\'backup.scheduled\' AND subject_id=? ORDER BY created_at DESC LIMIT 1', o.site_id); if (last && ['failed', 'needs_user'].includes(last.status)) { const j = deps.jobs.retry(last.id, operatorOf(body)); return { ok: true, job: jobView(deps, j.id) }; } svc.resumeForSite(deps, o.site_id, { by: operatorOf(body), userConfirmed: true, clearStorageFull: !!body.clearStorageFull }); deps.store.run('UPDATE backup_options SET next_run_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id); return { ok: true, jobs: svc.runDue(deps) }; })); + app.post('/internal/ops/backup/options/:id/end', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, option: svc.expireOption(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason || 'operator' }) }; })); + app.post('/internal/ops/backup/run-due', ops(() => ({ ok: true, started: svc.runDue(deps) }))); +} diff --git a/portal/backup/model.mjs b/portal/backup/model.mjs new file mode 100644 index 0000000..a97ced2 --- /dev/null +++ b/portal/backup/model.mjs @@ -0,0 +1,17 @@ +/** + * portal/backup/model.mjs — 백업 영역 추가 열·표. + * 중심 표(backup_options·backups·restores)는 portal/core/model.mjs 에 있고 여기서는 운영에 필요한 열만 더한다. + * backup_options.store_server_id 백업 사본을 두는 서버(운영 서버와 다른 서버 §19.2) + * backup_options.same_server_override 운영자가 같은 서버 저장을 명시적으로 허용했는지(기본 0) + * backup_options.resume_mode 사이트 재개 뒤 정기 백업 재개 방식: auto | confirm (§19.2) + * backup_options.allocation_id lib/quota 배정(backup 자원) id — 옵션 해지 때 반환 + * restores.policy_json 복원 시작 때 적용한 정책 스냅샷(비용·보호 시점 유지·완료 방식) + */ +export const MIGRATIONS = [{ id: 'backup_0001', sql: ` +ALTER TABLE backup_options ADD COLUMN store_server_id TEXT; +ALTER TABLE backup_options ADD COLUMN same_server_override INTEGER NOT NULL DEFAULT 0; +ALTER TABLE backup_options ADD COLUMN resume_mode TEXT NOT NULL DEFAULT 'auto'; +ALTER TABLE backup_options ADD COLUMN allocation_id TEXT; +ALTER TABLE restores ADD COLUMN policy_json TEXT; +ALTER TABLE restores ADD COLUMN error TEXT; +` }]; diff --git a/portal/backup/public/backup.js b/portal/backup/public/backup.js new file mode 100644 index 0000000..b99869e --- /dev/null +++ b/portal/backup/public/backup.js @@ -0,0 +1,58 @@ +/* portal/backup/public/backup.js — 백업·복원 화면 스크립트(§19·§20). 서버 JSON(/api/backup/*)을 그려 준다. */ +(() => { + const { api, esc, state, toast, confirmDanger, poll, job, fmt } = window.aw; + const bytes = (n) => { n = Number(n) || 0; return n >= 1073741824 ? (n / 1073741824).toFixed(1) + ' GB' : n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(0) + ' KB' : n + ' B'; }; + const OPT = { active: ['ok', '활성'], paused: ['warning', '중지됨'], ended: ['failed', '종료됨'] }; + const RS = { awaiting_payment: ['waiting', '결제 대기'], awaiting_approval: ['waiting', '승인 대기'], queued: ['waiting', '처리 대기'], running: ['running', '진행 중'], needs_user: ['warning', '사용자 확인 필요'], done: ['done', '완료'], failed: ['failed', '실패'], reverted: ['ok', '되돌림 완료'], rejected: ['failed', '거절됨'], cancelled: ['neutral', '취소됨'] }; + const badge = (m, k) => { const [c, l] = m[k] || ['neutral', k]; return `${esc(l)}`; }; + const v = (x, unit = '') => (x === null || x === undefined || x === '' ? '미확정(후속 상품정책)' : esc(x) + unit); + + const list = document.getElementById('bk-list'); + if (list) poll(async () => { + try { + const d = await api('GET', '/api/backup/overview'); + if (!d.sites.length) { state(list, 'empty', '사이트가 없습니다. 사이트를 만든 뒤 관리형으로 배포하면 백업 옵션을 살 수 있습니다', { href: '/sites', hrefLabel: '사이트로' }); return; } + list.innerHTML = `

${esc(d.mailBackupNotice)} · 외부 배포 사이트에는 백업 옵션을 제공하지 않습니다.

+ ${d.sites.map((s) => ``).join('')}
사이트배포옵션최근 백업다음 예정작업
${esc(s.name)}${s.deployMode === 'managed' ? '관리형' : s.deployMode === 'external' ? '외부(백업 미제공)' : '미정'}${s.option ? badge(OPT, s.option.status) + (s.option.pausedReason ? ` ${esc(s.option.pausedReason)}` : '') + (s.option.deleteAfter ? ` 백업 삭제 예정 ${esc(fmt(s.option.deleteAfter))}` : '') : s.eligible ? '미구매' : '제공 불가'}${s.lastBackup ? `${esc(fmt(s.lastBackup.created_at))} ${s.lastBackup.verify_state === 'verified' ? '✔ 검증됨' : '검증 안 됨'}` : '—'}${s.option && s.option.consecutiveFailures ? ` 실패 ${s.option.consecutiveFailures}회` : ''}${s.option && s.option.status === 'active' ? esc(fmt(s.option.nextRunAt)) : '—'}${s.option ? '관리' : s.eligible ? '옵션 구매' : '보기'}
+ ${d.restores.length ? `

복원 요청

${d.restores.map((r) => ``).join('')}
사이트상태요청 시각비용
${esc(r.siteName)}${badge(RS, r.status)}${esc(fmt(r.createdAt))}${r.cost}
` : ''}`; + } catch (e) { state(list, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' ? 'stale' : 'failed', e.message, { retry: () => location.reload() }); } + }, 15000); + + const el = document.getElementById('bk-site'); + if (el) { + const siteId = el.dataset.site; + const render = async () => { + let d; try { d = await api('GET', `/api/backup/sites/${siteId}`); } catch (e) { state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: render }); return false; } + const p = d.purchase; const o = d.option; + const scope = `

포함: ${Object.values(d.scope).map(esc).join(', ')}
제외: ${d.excluded.map(esc).join(', ')}

${esc(d.mailBackupNotice)}

`; + const policy = `
저장 한도
${p.storageLimitBytes != null ? bytes(p.storageLimitBytes) : v(null)}
현재 사용량
${bytes(p.currentUsageBytes)}
정기 실행
${p.schedule ? '매일 ' + esc(p.schedule.daily || '') + ' (KST)' : v(null)}
보존 개수
${p.retention ? `Daily ${p.retention.daily} · Weekly ${p.retention.weekly} · Monthly ${p.retention.monthly}` : v(null)}
첫 백업 예정
${o ? esc(fmt(o.first_backup_due_at)) + (o.firstBackupPending ? ' (대기 중)' : ' (완료)') : p.firstBackupDueAt ? '구매 후 ' + p.firstBackupWithinHours + '시간 이내' : v(null)}
원격 사본 검증
${v(p.verifyMethod)}
추가 저장 옵션
${(p.extraStorageOptions || []).length ? p.extraStorageOptions.map((x) => esc(x.label || JSON.stringify(x))).join(', ') : '없음'}
복원 비용
${p.restoreCost != null ? esc(p.restoreCost) + ' ' + esc(p.productVersion ? p.productVersion.currency : '') : v(null)}
옵션 만료 후 보관
${p.expiryRetentionDays}일 뒤 삭제
${p.deferred.length ? `

미확정 정책 항목: ${p.deferred.map(esc).join(', ')}

` : ''}`; + let head; + if (!o || o.status === 'ended') head = `

백업 옵션

${o ? `

${badge(OPT, 'ended')} 종료 ${esc(fmt(o.ends_at))} · 백업 삭제 예정 ${esc(fmt(o.delete_after))} (보존 중에는 복원 요청 가능)

` : ''}${p.eligible ? `

${p.productVersion ? `${esc(p.productVersion.name)} · ${p.productVersion.price} ${esc(p.productVersion.currency)} / ${esc(p.productVersion.billingCycle)}` : ''}

${policy}${scope}` : `
이 사이트에는 제공하지 않습니다

${p.reasons.map(esc).join('
')}

`}
`; + else head = `

백업 옵션 ${badge(OPT, o.status)}${o.pausedReasonLabel ? ` 사유: ${esc(o.pausedReasonLabel)}` : ''}

${policy}
마지막 성공
${o.last_success_at ? esc(fmt(o.last_success_at)) : '아직 없음'}
마지막 실패
${o.last_failure_at ? esc(fmt(o.last_failure_at)) + ` (연속 ${o.consecutive_failures}회, ${o.failureNotifyAfter}회부터 알림)` : '없음'}
다음 예정
${o.status === 'active' ? esc(fmt(o.next_run_at)) : '중지됨'}
저장 서버
${o.store_server_id ? '운영 서버와 분리된 저장소' : '첫 백업 때 결정'}
재개 방식
${o.resume_mode === 'auto' ? '사이트 재개 후 자동' : '사이트 재개 후 사용자 확인'}
${scope}
${o.status === 'active' ? '' : o.status === 'paused' ? '' : ''}
${d.lastRun ? '

최근 실행

' : ''}
`; + const rows = d.backups.map((b) => `${esc(fmt(b.createdAt))}${esc(b.tier || b.kind)}${Object.keys(b.scope).filter((k) => b.scope[k] === true).join(', ')}${bytes(b.sizeBytes)}${b.verifyState === 'verified' ? '✔ 검증됨' : '! 미검증'}${esc(fmt(b.expiresAt))}${b.canRestore ? `복원 요청` : '—'}`).join(''); + el.innerHTML = head + `

보관 목록

${rows ? `${rows}
생성등급범위크기검증만료작업
` : '
비어 있음

아직 완료된 백업이 없습니다

'}
+ ${d.restores.length ? `

복원 기록

${d.restores.map((r) => `

${badge(RS, r.status)} 요청 ${esc(fmt(r.created_at))} · 백업 ${esc(r.backup_id)} · 비용 ${r.cost}${r.protect_until ? ` · 보호 시점 유지 ${esc(fmt(r.protect_until))}` : ''}${r.revert_until ? ` · 되돌리기 가능 ${esc(fmt(r.revert_until))} 까지` : ''}

`).join('')}
` : ''}`; + if (d.lastRun) job('#bk-job', d.lastRun); + for (const r of d.restores) if (r.job) job(`#rs-${r.id}`, r.job, { onAction: async (a) => { try { if (a === 'confirm' || a === 'revert') { if (a === 'revert' && !(await confirmDanger({ title: '복원 전으로 되돌리기', target: '이 사이트', impacts: ['복원 뒤 생성된 데이터는 사라집니다', '보호 시점으로 되돌립니다'], confirmLabel: '되돌리기' }))) return; await api('POST', `/api/backup/restores/${r.id}/decision`, { action: a, confirm: true }); } else if (a === 'cancel') await api('POST', `/api/backup/restores/${r.id}/cancel`); toast('처리했습니다'); render(); } catch (e) { toast(e.message); } } }); + const q = (id) => document.getElementById(id); + if (q('bk-buy')) q('bk-buy').onclick = async () => { if (!(await confirmDanger({ title: '백업 옵션 활성화', target: '이 사이트', impacts: ['백업 권리 1개를 사용합니다', `첫 백업은 ${p.firstBackupWithinHours}시간 이내에 완료됩니다`], confirmLabel: '활성화' }))) return; try { await api('POST', `/api/backup/sites/${siteId}/option`, { confirm: true }); toast('활성화했습니다'); render(); } catch (e) { toast(e.message + (e.detail && e.detail.link ? ' — 상품 구매로 이동' : '')); if (e.detail && e.detail.link) setTimeout(() => (location.href = e.detail.link), 1200); } }; + if (q('bk-run')) q('bk-run').onclick = async () => { try { await api('POST', `/api/backup/sites/${siteId}/run`); toast('백업을 시작했습니다'); render(); } catch (e) { toast(e.message); } }; + if (q('bk-resume')) q('bk-resume').onclick = async () => { try { await api('POST', `/api/backup/sites/${siteId}/option/resume`); toast('다시 시작합니다'); render(); } catch (e) { toast(e.message); } }; + if (q('bk-end')) q('bk-end').onclick = async () => { if (!(await confirmDanger({ title: '백업 옵션 해지', target: '이 사이트의 백업 옵션', impacts: ['새 백업이 중지됩니다', `기존 백업은 ${p.expiryRetentionDays}일 보관 뒤 삭제됩니다`, '삭제 뒤에는 복구할 수 없습니다'], typeToConfirm: '해지', confirmLabel: '해지' }))) return; try { await api('POST', `/api/backup/sites/${siteId}/option/end`, { confirm: true }); toast('해지했습니다'); render(); } catch (e) { toast(e.message); } }; + return true; + }; + poll(render, 8000); + } + + const rs = document.getElementById('bk-restore'); + if (rs) (async () => { + const siteId = rs.dataset.site; const bid = rs.dataset.backup; + try { + const d = await api('GET', `/api/backup/backups/${bid}`); const i = d.restoreInfo; const b = i.backup; + rs.innerHTML = `

복원할 백업

생성 시각
${esc(fmt(b.createdAt))}
포함 데이터
${i.included.map(esc).join(', ')}
제외
${i.excluded.map(esc).join(', ')}
검증
${esc(b.verifyState)}
서비스 영향
${esc(i.impact)}
예상 중단 시간
${i.estimatedDowntimeMin != null ? i.estimatedDowntimeMin + '분' : v(null)}
복원 비용
${i.cost != null ? i.cost + ' ' + esc(i.currency) : v(null)}
되돌리기 범위
복원 직전 보호 시점(${i.protectPointDays != null ? i.protectPointDays + '일 유지' : v(null)})으로 ${i.revertHours != null ? i.revertHours + '시간' : v(null)} 안에 되돌릴 수 있습니다
완료 방식
${i.completionMode === 'user_confirm' ? '고객 확인 후 완료' : '자동 완료'}
승인
운영자 승인 뒤 시작${i.cost ? ' (비용 확인·결제 선행)' : ''}
+ ${i.conflicts.length ? `

진행 중인 작업이 끝나야 요청할 수 있습니다: ${i.conflicts.map((c) => esc(c.type + ' ' + c.status)).join(', ')}

` : ''} +
뒤로
`; + document.getElementById('rs-go').onclick = async () => { if (!(await confirmDanger({ title: '복원 요청', target: `백업 ${fmt(b.createdAt)}`, impacts: [i.impact, '운영자 승인과 (해당 시) 결제 확인 뒤 시작됩니다', '시작 전 최종 확인 화면에 그룹·사이트·도메인이 표시됩니다'], confirmLabel: '요청' }))) return; try { const r = await api('POST', `/api/backup/sites/${siteId}/restore`, { backupId: bid, confirm: true }); toast(r.created ? `복원 요청 접수 (${r.finalConfirmation.group} / ${r.finalConfirmation.site} / ${r.finalConfirmation.domain || '도메인 없음'})` : '이미 열린 요청이 있습니다'); location.href = `/sites/${siteId}/backup`; } catch (e) { toast(e.message); } }; + } catch (e) { state(rs, e.status === 403 ? 'forbidden' : 'failed', e.message); } + })(); +})(); diff --git a/portal/backup/service.mjs b/portal/backup/service.mjs new file mode 100644 index 0000000..4ad6dfa --- /dev/null +++ b/portal/backup/service.mjs @@ -0,0 +1,277 @@ +/** + * portal/backup/service.mjs — 관리형 사이트 백업 옵션(§19)과 복원(§20)의 업무 규칙. + * + * 흐름 + * 구매(purchaseOption) → 옵션 활성 → 정기 실행(runDue → 작업 backup.scheduled) → HAL backup.create(운영 서버와 다른 저장 서버) → 검증 → 보관 목록 → 보존 정리(prune) + * 복원 요청(requestRestore) → 비용·결제 → 운영자 승인(approveRestore) → 작업 backup.restore(§20.2 의 9단계) → 사용자 확인 또는 자동 완료 → 이전 상태 정리 + * 옵션 만료(expireOption) → 백업 7일 보관 뒤 삭제(runDue 가 삭제 작업) + * 정책 값은 활성 상품정책(product_versions, product_id 'backup')에서 읽고 코드에 고정하지 않는다. 값이 없으면 deferred 로 화면에 "미확정" 을 보인다. + * 모든 작업은 lib/jobs 로 내구성 있게 실행되며 HAL 호출은 단계로 나누어 재시작 뒤 이어서 진행한다(제출 단계 결과에 HAL 작업 id 저장 → 대기 단계는 retryLater 로 다시 확인). + */ +import { HttpError } from '../../lib/http.mjs'; +import { newId } from '../../lib/crypto.mjs'; +import { notify, resolveIncident } from '../../lib/notify.mjs'; +import { audit } from '../../lib/audit.mjs'; +import * as quota from '../../lib/quota.mjs'; +import { JobError } from '../../lib/jobs.mjs'; +import { kstDate, nextMonthStart } from '../../lib/kst.mjs'; +import { productPolicy, halOf, asTransient, inProgress, nowIso, parseJson, toJson, addMs, DAY, HOUR, optional } from '../lifecycle/common.mjs'; + +export const PRODUCT_ID = 'backup'; +/** 구매에 꼭 필요한 정책 항목(없으면 구매 버튼 대신 미확정 안내) */ +export const REQUIRED_POLICY = ['limits.storage_bytes', 'policy.schedule', 'policy.retention', 'policy.first_backup_within_hours', 'policy.verify_method', 'policy.restore_cost', 'policy.restore_completion', 'policy.protect_point_days', 'policy.revert_hours']; +export const EXPIRY_RETENTION_DAYS = 7; // §20.3 옵션 만료 후 백업 보관 일수(기본 정책) +export const SCOPE = { files: '현재 배포본(파일)', db: '데이터베이스', uploads: '업로드 데이터', config: '복원에 필요한 사이트 설정' }; +export const EXCLUDED = ['메일 원문', '소스 저장소', 'Webmaker 프로젝트 이력', '외부 배포 데이터', '플랫폼 내부 복구 사본']; + +/** 사이트가 백업 옵션을 살 수 있는지(관리형·운영/제작 상태·외부 배포 아님 §19.1) */ +export function eligibility(deps, site) { + const reasons = []; + if (!site) return { ok: false, reasons: ['사이트가 없습니다'] }; + if (site.deploy_mode !== 'managed') reasons.push(site.deploy_mode === 'external' ? '외부 배포 사이트에는 백업 옵션을 제공하지 않습니다' : '관리형 배포로 설정된 사이트만 백업 옵션을 살 수 있습니다'); + if (['deleted', 'pending_delete', 'readonly_retention', 'cancel_scheduled'].includes(site.lifecycle)) reasons.push('해지·보존 중인 사이트에는 새 옵션을 추가할 수 없습니다'); + const existing = optionOf(deps, site.id); + if (existing && existing.status !== 'ended') reasons.push('이 사이트에는 이미 백업 옵션이 있습니다(사이트당 하나)'); + const pol = productPolicy(deps, PRODUCT_ID, REQUIRED_POLICY); + if (!pol.version) reasons.push('백업 상품정책이 아직 활성화되지 않았습니다'); + else if (pol.deferred.length) reasons.push(`상품정책의 미확정 항목(${pol.deferred.join(', ')})이 정해져야 구매할 수 있습니다`); + return { ok: reasons.length === 0, reasons, policy: pol }; +} +export function optionOf(deps, siteId) { return deps.store.get('SELECT * FROM backup_options WHERE site_id=?', siteId) || null; } +export function optionById(deps, id) { return deps.store.get('SELECT * FROM backup_options WHERE id=?', id) || null; } +/** 보관 중(삭제되지 않은) 백업의 총 크기 */ +export function storedBytes(deps, siteId) { return deps.store.get('SELECT COALESCE(SUM(size_bytes),0) AS n FROM backups WHERE site_id=? AND deleted_at IS NULL AND status=\'done\'', siteId).n; } +/** 화면용 구매 정보(§19.1): 저장 한도·현재 사용량·일정·보존 개수·첫 백업 예정·검증 방식·추가 저장 옵션·포함/제외 */ +export function purchaseInfo(deps, site) { + const el = eligibility(deps, site); const { limits, policy, deferred, version } = el.policy; + return { + eligible: el.ok, reasons: el.reasons, deferred, productVersion: version ? { id: version.id, name: version.name, version: version.version, price: version.price, currency: version.currency, taxRate: version.tax_rate, billingCycle: version.billing_cycle } : null, + storageLimitBytes: limits.storage_bytes ?? null, currentUsageBytes: storedBytes(deps, site.id), schedule: policy.schedule ?? null, retention: policy.retention ?? null, + firstBackupWithinHours: policy.first_backup_within_hours ?? null, firstBackupDueAt: policy.first_backup_within_hours ? addMs(nowIso(), policy.first_backup_within_hours * HOUR) : null, + verifyMethod: policy.verify_method ?? null, extraStorageOptions: limits.extra_storage_options ?? [], restoreCost: policy.restore_cost ?? null, restoreCompletion: policy.restore_completion ?? null, + scope: SCOPE, excluded: EXCLUDED, expiryRetentionDays: policy.expiry_retention_days ?? EXPIRY_RETENTION_DAYS, + }; +} +/** + * 옵션 구매(결제·승인은 주문 영역이 맡는다). 여기서는 권리(backup 자원) 1개를 예약→확정해 옵션을 활성화한다. + * 권리가 없으면 QuotaError(quota_insufficient) → 화면은 상품 구매를 안내한다. + */ +export function purchaseOption(deps, site, { by, orderId = null } = {}) { + const el = eligibility(deps, site); if (!el.ok) throw new HttpError(409, 'backup_not_eligible', el.reasons.join(' / '), { reasons: el.reasons }); + const { limits, policy, version } = el.policy; const now = nowIso(); + return deps.store.tx(() => { + const r = quota.reserve(deps.store, { groupId: site.group_id, resource: 'backup', amount: 1, subjectType: 'site', subjectId: site.id, reason: 'backup_option' }); quota.commit(deps.store, r.id); + const alloc = quota.allocationOf(deps.store, site.group_id, 'backup', 'site', site.id); + const id = newId('bo_'); const first = addMs(now, (policy.first_backup_within_hours || 24) * HOUR); + deps.store.run('INSERT INTO backup_options(id, site_id, group_id, product_version_id, order_id, status, schedule_json, retention_json, storage_limit_bytes, first_backup_due_at, next_run_at, resume_mode, allocation_id, created_at, updated_at) VALUES(?,?,?,?,?,\'active\',?,?,?,?,?,?,?,?,?)', + id, site.id, site.group_id, version.id, orderId, toJson(policy.schedule), toJson(policy.retention), limits.storage_bytes, first, now, policy.resume_after_pause || 'auto', alloc ? alloc.id : null, now, now); + audit(deps.store, { actorType: 'user', actorId: by, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'backup.option.activate', detail: { optionId: id, productVersionId: version.id } }); + notify(deps.store, { groupId: site.group_id, category: 'backup', title: `${site.name}: 백업 옵션이 활성화되었습니다`, body: `첫 백업은 ${first} 까지 완료됩니다. 진행 상태는 사이트 › 백업 탭에서 확인하세요`, link: `/sites/${site.id}/backup`, subjectType: 'site', subjectId: site.id }); + return optionById(deps, id); + }); +} +/** 일정(JSON {daily:'HH:MM'(KST)}) 기준 다음 실행 시각. 단순화: 매일 지정 시각(KST). */ +export function nextRunFrom(schedule, from = nowIso()) { + const hhmm = String((schedule && schedule.daily) || '03:00'); const [h, m] = hhmm.split(':').map(Number); + const d = new Date(from); const kstMs = d.getTime() + 9 * HOUR; const day = new Date(kstMs); day.setUTCHours(h, m || 0, 0, 0); + let next = day.getTime() - 9 * HOUR; if (next <= d.getTime()) next += DAY; return new Date(next).toISOString(); +} +/** 이번 실행의 보존 등급: 그 달 첫 실행 → monthly, 일요일(KST) → weekly, 그 밖 → daily */ +export function tierFor(deps, siteId, at = nowIso()) { + const month = kstDate(at).slice(0, 7); + const monthly = deps.store.get('SELECT 1 AS x FROM backups WHERE site_id=? AND tier=\'monthly\' AND substr(created_at,1,7)=? AND deleted_at IS NULL', siteId, month); + if (!monthly) return 'monthly'; + return new Date(Date.parse(at) + 9 * HOUR).getUTCDay() === 0 ? 'weekly' : 'daily'; +} +/** 정기 백업 중지(사이트 일시 중지·해지 §19.2·§35). 다른 영역(생명주기)이 부른다. */ +export function stopForSite(deps, siteId, reason = 'site_paused') { + const o = optionOf(deps, siteId); if (!o || o.status !== 'active') return null; + deps.store.run('UPDATE backup_options SET status=\'paused\', paused_reason=?, updated_at=? WHERE id=?', reason, nowIso(), o.id); + notify(deps.store, { groupId: o.group_id, category: 'backup', incidentKey: `backup:${o.id}:paused`, title: '정기 백업이 중지되었습니다', body: `사유: ${reasonLabel(reason)}. 기존 백업은 정책에 따라 유지됩니다. 사이트 재개 후 ${o.resume_mode === 'auto' ? '자동으로 다시 시작됩니다' : '백업 화면에서 다시 시작을 확인해야 합니다'}`, link: `/sites/${siteId}/backup`, subjectType: 'site', subjectId: siteId }); + return optionById(deps, o.id); +} +/** 재개: resume_mode 가 auto 면 생명주기 영역이 자동으로, confirm 이면 사용자가 버튼으로 부른다 */ +export function resumeForSite(deps, siteId, { by = 'system', userConfirmed = false, clearStorageFull = false } = {}) { + const o = optionOf(deps, siteId); if (!o || o.status !== 'paused') return o; + // 저장 공간 부족으로 멈춘 옵션은 용량이 늘거나 복구점이 정리되어 여유가 생겨야 재개된다(운영자 clearStorageFull 또는 실제 여유 확인) + if (o.paused_reason === 'storage_full' && !clearStorageFull) { const used = storedBytes(deps, siteId); const last = (deps.store.get('SELECT size_bytes FROM backups WHERE site_id=? AND status=\'done\' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId) || {}).size_bytes || 0; if (o.storage_limit_bytes && used + last > o.storage_limit_bytes) throw new HttpError(409, 'backup_storage_full', '저장 공간이 가득 차 재개할 수 없습니다. 추가 용량을 적용하거나 오래된 복구점을 정리하세요', { used, limit: o.storage_limit_bytes }); } + if (o.resume_mode === 'confirm' && by === 'system' && !userConfirmed) return o; // 사용자 확인 필요 + deps.store.run('UPDATE backup_options SET status=\'active\', paused_reason=NULL, next_run_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id); + return optionById(deps, o.id); +} +export function reasonLabel(r) { return { site_paused: '사이트 일시 중지', site_cancelled: '사이트 해지', storage_full: '저장 공간 부족', operator: '운영자 조치', option_ended: '옵션 종료' }[r] || r; } +/** 옵션 종료(만료·해지): 새 백업 중지, 백업은 7일 보관 뒤 삭제(§20.3·§23.2). 권리 반환. */ +export function expireOption(deps, optionId, { by = 'system', reason = 'option_ended' } = {}) { + const o = optionById(deps, optionId); if (!o || o.status === 'ended') return o; + const pol = productPolicy(deps, PRODUCT_ID); const days = pol.policy.expiry_retention_days ?? EXPIRY_RETENTION_DAYS; const now = nowIso(); const del = addMs(now, days * DAY); + deps.store.run('UPDATE backup_options SET status=\'ended\', paused_reason=?, ends_at=?, delete_after=?, next_run_at=NULL, updated_at=? WHERE id=?', reason, now, del, now, o.id); + deps.store.run('UPDATE backups SET expires_at=? WHERE option_id=? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at>?)', del, o.id, del); + if (o.allocation_id) quota.finishReturn(deps.store, o.allocation_id); + audit(deps.store, { actorType: by === 'system' ? 'system' : 'user', actorId: by, groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, action: 'backup.option.end', detail: { optionId: o.id, deleteAfter: del, reason } }); + notify(deps.store, { groupId: o.group_id, category: 'backup', severity: 'warning', mandatory: true, title: '백업 옵션이 종료되었습니다 — 백업 삭제 예정', body: `기존 백업은 ${del} 까지 보관된 뒤 삭제됩니다. 보존 중에는 복원을 요청할 수 있습니다(복원 비용은 정책에 따릅니다)`, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id, incidentKey: `backup:${o.id}:ended` }); + return optionById(deps, o.id); +} +/** 저장 서버 고르기: 사이트 운영 서버와 다른 서버(backup 역할 우선, 없으면 web). 운영자 override 가 아니면 같은 서버는 절대 고르지 않는다. */ +export async function pickStoreServer(deps, site, option) { + const hal = halOf(deps); let servers = []; + try { servers = await hal.listServers({}); } catch (e) { throw asTransient(e); } + const ok = servers.filter((s) => s.acceptsNew && s.status === 'ready' && ['backup', 'web'].includes(s.role)); + const pref = ok.filter((s) => s.id !== site.server_id).sort((a, b) => (a.role === 'backup' ? -1 : 1) - (b.role === 'backup' ? -1 : 1) || ((b.capacity?.diskFreeBytes || 0) - (a.capacity?.diskFreeBytes || 0))); + if (pref.length) return pref[0].id; + if (option.same_server_override && ok.some((s) => s.id === site.server_id)) return site.server_id; + throw new JobError('no_backup_store', '운영 서버와 분리된 백업 저장 서버가 없습니다. 운영자가 백업 서버를 등록하거나 같은 서버 저장을 명시적으로 허용해야 합니다', { retryable: true }); +} +/** 보존 정리(§19.1 Daily·Weekly·Monthly 개수). 최소 보존 세트(등급별 최신 1개·전체 최신 1개)는 어떤 경우에도 지우지 않는다. */ +export function pruneList(deps, option) { + const ret = parseJson(option.retention_json, {}) || {}; const all = deps.store.all('SELECT * FROM backups WHERE option_id=? AND deleted_at IS NULL AND status=\'done\' ORDER BY created_at DESC', option.id); + const keep = new Set(); if (all[0]) keep.add(all[0].id); + const toDelete = []; + for (const tier of ['daily', 'weekly', 'monthly']) { const list = all.filter((b) => b.tier === tier); const n = Math.max(1, Number(ret[tier] ?? 1)); list.forEach((b, i) => { if (i < n) keep.add(b.id); }); if (list[0]) keep.add(list[0].id); } + for (const b of all) if (!keep.has(b.id)) toDelete.push(b); + return toDelete; +} +/** 실행 예정 옵션 → 작업 넣기(주기 작업). 만료 옵션의 삭제 예정도 처리. */ +export function runDue(deps, now = nowIso()) { + const started = []; + for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND next_run_at IS NOT NULL AND next_run_at<=?', now)) { + const site = deps.store.get('SELECT * FROM sites WHERE id=?', o.site_id); + if (!site || !['live', 'needs_domain', 'deploying'].includes(site.lifecycle)) { stopForSite(deps, o.site_id, site && site.lifecycle === 'paused' ? 'site_paused' : 'site_cancelled'); continue; } + const slot = kstDate(now) + ':' + String(o.next_run_at); + const { job } = deps.jobs.enqueue({ kind: 'backup.scheduled', subject: { optionId: o.id, siteId: o.site_id, groupId: o.group_id, slot }, idempotencyKey: `backup:${o.id}:${slot}`, actorType: 'system', groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, label: `정기 백업 (${site.name})` }); + deps.store.run('UPDATE backup_options SET next_run_at=?, updated_at=? WHERE id=?', nextRunFrom(parseJson(o.schedule_json, {}), now), now, o.id); + started.push(job.id); + } + // 만료된 옵션의 백업 삭제(delete_after 경과) + for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'ended\' AND delete_after IS NOT NULL AND delete_after<=?', now)) { + const left = deps.store.all('SELECT id FROM backups WHERE option_id=? AND deleted_at IS NULL', o.id); + if (!left.length) { deps.store.run('UPDATE backup_options SET delete_after=NULL, updated_at=? WHERE id=?', now, o.id); continue; } + const { job } = deps.jobs.enqueue({ kind: 'backup.purge', subject: { optionId: o.id, siteId: o.site_id, groupId: o.group_id }, idempotencyKey: `backup-purge:${o.id}:${o.delete_after}`, actorType: 'system', groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, label: '만료 백업 삭제' }); started.push(job.id); + } + // 첫 백업 기한 초과 경고 + for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND first_backup_due_at { try { const r = await halOf(deps).createJob(req); return { halJobId: r.halJobId }; } catch (e) { throw asTransient(e); } }, title); } +async function halWait(deps, ctx, stepId, title, halJobId, { pollMs = 5000 } = {}) { + return ctx.step(stepId, async () => { + let j; try { j = await halOf(deps).getJob(halJobId); } catch (e) { throw asTransient(e); } + if (['done'].includes(j.status)) return j.result || {}; + if (['failed', 'cancelled', 'reverted'].includes(j.status)) throw new JobError(j.errorCode || 'hal_job_failed', j.error || `HAL 작업이 ${j.status} 로 끝났습니다`); + if (j.status === 'needs_user') ctx.needsUser(j.error || 'HAL 작업이 운영자 확인을 기다립니다'); + ctx.retryLater(pollMs, `HAL 작업 ${halJobId} 진행 중 (${j.status})`); + }, title); +} +/** 작업 종류 등록 */ +export function registerJobs(deps) { + deps.jobs.register('backup.scheduled', { title: '정기 백업', maxAttempts: 3, retryable: (e) => !!e.retryable, steps: [{ id: 'precheck', title: '사전 확인(옵션·저장 공간)' }, { id: 'pick', title: '저장 서버 선택' }, { id: 'submit', title: '백업 생성 요청' }, { id: 'wait', title: '백업 생성·전송' }, { id: 'verify', title: '사본 읽기 검증' }, { id: 'record', title: '보관 목록 기록' }, { id: 'prune', title: '보존 정리' }], + async run(job, ctx) { + const { optionId, siteId } = job.subject; const o = optionById(deps, optionId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', siteId); + if (!o || o.status !== 'active') throw new JobError('backup_option_inactive', '백업 옵션이 활성 상태가 아닙니다'); + await ctx.step('precheck', () => { + const used = storedBytes(deps, siteId); const limit = o.storage_limit_bytes || 0; const est = Math.max(1, (deps.store.get('SELECT size_bytes FROM backups WHERE site_id=? AND status=\'done\' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId) || {}).size_bytes || 0); + if (limit && used + est > limit && pruneList(deps, o).length === 0) { deps.store.run('UPDATE backup_options SET status=\'paused\', paused_reason=\'storage_full\', updated_at=? WHERE id=?', nowIso(), o.id); notify(deps.store, { groupId: o.group_id, category: 'backup', severity: 'critical', mandatory: true, incidentKey: `backup:${o.id}:storage_full`, title: '백업 저장 공간이 부족해 다음 백업을 중지했습니다', body: `최소 보존 세트는 삭제하지 않습니다. 추가 저장 옵션을 적용하거나 오래된 복구점을 정리한 뒤 백업 화면에서 다시 시작하세요 (사용 ${used} / 한도 ${limit} 바이트)`, link: `/sites/${siteId}/backup`, subjectType: 'site', subjectId: siteId }); throw new JobError('backup_storage_full', '저장 공간 부족 — 백업 중지'); } + return { used, limit }; + }); + const { storeServerId } = await ctx.step('pick', async () => ({ storeServerId: await pickStoreServer(deps, site, o) })); + const tier = tierFor(deps, siteId, job.created_at); + const { halJobId } = await halSubmit(deps, ctx, 'submit', '백업 생성 요청', { kind: 'backup.create', target: site.server_id, payload: { siteId, scope: { files: true, db: true, uploads: true, config: true }, storeServerId, retentionTag: tier }, idempotencyKey: `hal:${job.id}:create`, groupId: o.group_id, subjectType: 'site', subjectId: siteId }); + const res = await halWait(deps, ctx, 'wait', '백업 생성·전송', halJobId); + const verified = await ctx.step('verify', async () => { if (res.verified) return true; try { const r = await halOf(deps).createJob({ kind: 'backup.verify', target: storeServerId, payload: { location: res.location, checksum: res.checksum }, idempotencyKey: `hal:${job.id}:verify` }); const j = await halOf(deps).getJob(r.halJobId); if (!(j.result && j.result.ok)) throw new JobError('backup_verify_failed', '백업 사본을 읽을 수 없습니다'); return true; } catch (e) { throw asTransient(e); } }); + const backupId = await ctx.step('record', () => { const id = newId('bk_'); const pol = productPolicy(deps, PRODUCT_ID); const ret = parseJson(o.retention_json, {}) || {}; const days = { daily: Number(ret.daily || 7), weekly: Number(ret.weekly || 4) * 7, monthly: Number(ret.monthly || 3) * 31 }[tier]; deps.store.run('INSERT INTO backups(id, site_id, group_id, option_id, kind, tier, created_at, completed_at, scope_json, size_bytes, location, checksum, verify_state, verified_at, expires_at, status, job_id) VALUES(?,?,?,?,\'scheduled\',?,?,?,?,?,?,?,?,?,?,\'done\',?)', id, siteId, o.group_id, o.id, tier, job.created_at, nowIso(), toJson({ files: true, db: true, uploads: true, config: true, storeServerId, verifyMethod: pol.policy.verify_method || null }), res.sizeBytes || 0, res.location, res.checksum, verified ? 'verified' : 'unverified', verified ? nowIso() : null, addMs(nowIso(), days * DAY), job.id); deps.store.run('UPDATE backup_options SET last_success_at=?, consecutive_failures=0, store_server_id=?, updated_at=? WHERE id=?', nowIso(), storeServerId, nowIso(), o.id); return id; }); + await ctx.step('prune', async () => { const del = pruneList(deps, o); for (const b of del) { try { await halOf(deps).createJob({ kind: 'backup.delete', target: storeServerId, payload: { location: b.location }, idempotencyKey: `hal:${job.id}:del:${b.id}` }); } catch (e) { throw asTransient(e); } deps.store.run('UPDATE backups SET deleted_at=?, status=\'deleted\' WHERE id=?', nowIso(), b.id); } return { deleted: del.length }; }); + resolveIncident(deps.store, `backup:${o.id}:failed`, '백업이 다시 정상적으로 완료되었습니다'); resolveIncident(deps.store, `ops:backup:${o.id}:failed`, '정상화'); + return { backupId, tier, storeServerId }; + } }); + deps.jobs.register('backup.purge', { title: '만료 백업 삭제', maxAttempts: 5, retryable: (e) => !!e.retryable, steps: [{ id: 'delete', title: '백업 사본 삭제' }], + async run(job, ctx) { const o = optionById(deps, job.subject.optionId); await ctx.step('delete', async () => { let n = 0; for (const b of deps.store.all('SELECT * FROM backups WHERE option_id=? AND deleted_at IS NULL', o.id)) { if (b.location) { try { await halOf(deps).createJob({ kind: 'backup.delete', target: o.store_server_id, payload: { location: b.location }, idempotencyKey: `hal:${job.id}:del:${b.id}` }); } catch (e) { throw asTransient(e); } } deps.store.run('UPDATE backups SET deleted_at=?, status=\'deleted\' WHERE id=?', nowIso(), b.id); n++; } deps.store.run('UPDATE backup_options SET delete_after=NULL, updated_at=? WHERE id=?', nowIso(), o.id); notify(deps.store, { groupId: o.group_id, category: 'backup', mandatory: true, title: '만료된 백업이 삭제되었습니다', body: `삭제된 백업 ${n}개. 삭제된 백업은 복구할 수 없습니다`, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id }); return { deleted: n }; }); } }); + deps.jobs.register('backup.restore', { title: '사이트 복원', maxAttempts: 2, retryable: (e) => !!e.retryable, + steps: [{ id: 'received', title: '요청 접수' }, { id: 'approval', title: '비용과 승인 확인' }, { id: 'protect', title: '현재 상태 보호' }, { id: 'isolated', title: '격리된 환경에서 백업 확인' }, { id: 'restore', title: '데이터 복원' }, { id: 'function', title: '사이트 기능 확인' }, { id: 'external', title: '외부 접속 확인' }, { id: 'confirm', title: '사용자 확인 또는 자동 완료' }, { id: 'cleanup', title: '이전 상태 정리' }], + async run(job, ctx) { + const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id); const b = deps.store.get('SELECT * FROM backups WHERE id=?', r.backup_id); const pol = parseJson(r.policy_json, {}) || {}; + const setStatus = (s, extra = {}) => deps.store.run(`UPDATE restores SET status=?, updated_at=? ${Object.keys(extra).map((k) => `, ${k}=?`).join('')} WHERE id=?`, s, nowIso(), ...Object.values(extra), r.id); + await ctx.step('received', () => { setStatus('running'); return { backupId: b.id, backupAt: b.created_at }; }); + await ctx.step('approval', () => { if (r.approval_state !== 'approved') throw new JobError('restore_not_approved', '운영자 승인이 없습니다'); if (r.cost > 0 && !r.paid_ok && !job.subject.paid) { const o = r.order_id ? deps.store.get('SELECT status FROM orders WHERE id=?', r.order_id) : null; if (o && o.status !== 'approved') throw new JobError('restore_unpaid', '복원 비용 결제가 확인되지 않았습니다'); } return { cost: r.cost }; }); + const prot = await ctx.step('protect', async () => { const h = await halSubmit(deps, ctx, 'protect.submit', '보호 시점 생성 요청', { kind: 'backup.create', target: site.server_id, payload: { siteId: site.id, scope: { files: true, db: true, uploads: true, config: true }, storeServerId: parseJson(b.scope_json, {}).storeServerId || null, retentionTag: 'protect' }, idempotencyKey: `hal:${job.id}:protect` }); const res = await halWait(deps, ctx, 'protect.wait', '보호 시점 생성', h.halJobId); const until = addMs(nowIso(), (pol.protect_point_days || 7) * DAY); setStatus('running', { protect_point_id: res.location || res.backupId || null, protect_until: until, revert_until: addMs(nowIso(), (pol.revert_hours || 72) * HOUR) }); return { location: res.location, checksum: res.checksum, until }; }); + await ctx.step('isolated', async () => { try { const h = await halOf(deps).createJob({ kind: 'backup.verify', target: parseJson(b.scope_json, {}).storeServerId || site.server_id, payload: { location: b.location, checksum: b.checksum, isolated: true }, idempotencyKey: `hal:${job.id}:isolated` }); const j = await halOf(deps).getJob(h.halJobId); if (!(j.result && j.result.ok)) throw new JobError('restore_backup_unreadable', '격리 환경에서 백업을 읽을 수 없습니다 — 복원을 시작하지 않았습니다(현재 운영 상태 유지)'); return true; } catch (e) { throw asTransient(e); } }); + const h = await halSubmit(deps, ctx, 'restore', '데이터 복원', { kind: 'backup.restore', target: site.server_id, payload: { siteId: site.id, location: b.location, checksum: b.checksum, protectPoint: true, isolatedVerify: true }, idempotencyKey: `hal:${job.id}:restore` }); + const res = await halWait(deps, ctx, 'restore.wait', '복원 진행', h.halJobId); + await ctx.step('function', () => { if (res.functionCheck && res.functionCheck.ok === false) throw new JobError('restore_function_failed', '복원 뒤 사이트 기능 확인에 실패했습니다'); return res.functionCheck || { ok: true }; }); + await ctx.step('external', async () => { const ext = res.externalCheck; if (ext && ext.ok === false) throw new JobError('restore_external_failed', '복원 뒤 외부 접속 확인에 실패했습니다'); return ext || { ok: true, note: '외부 확인 결과 없음' }; }); + await ctx.step('confirm', () => { if (r.completion_mode === 'user_confirm' && !job.subject.userConfirmed) { setStatus('needs_user'); ctx.needsUser(`복원된 사이트를 확인하고 완료 또는 되돌리기를 선택하세요. 되돌릴 수 있는 마지막 시각: ${r.revert_until || addMs(nowIso(), (pol.revert_hours || 72) * HOUR)}`, [{ id: 'confirm', label: '복원 완료 확정' }, { id: 'revert', label: '복원 전으로 되돌리기' }]); } return { mode: r.completion_mode }; }); + await ctx.step('cleanup', () => { setStatus('done', { ended_at: nowIso() }); deps.store.run('UPDATE sites SET current_version=?, updated_at=? WHERE id=?', res.restoredVersion || site.current_version, nowIso(), site.id); audit(deps.store, { actorType: 'system', groupId: r.group_id, subjectType: 'site', subjectId: site.id, action: 'restore.complete', detail: { restoreId: r.id, backupId: b.id } }); notify(deps.store, { groupId: r.group_id, category: 'backup', title: `${site.name}: 복원이 완료되었습니다`, body: `보호 시점은 ${r.protect_until || prot.until} 까지 유지됩니다`, link: `/sites/${site.id}/backup`, subjectType: 'site', subjectId: site.id }); return { done: true }; }); + return { restoreId: r.id, restoredVersion: res.restoredVersion || null }; + }, + async revert(job, ctx) { + const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id); + if (!r.protect_point_id) throw new JobError('no_protect_point', '보호 시점이 없어 되돌릴 수 없습니다'); + if (r.revert_until && Date.parse(r.revert_until) < Date.now()) throw new JobError('revert_window_passed', '되돌릴 수 있는 기간이 지났습니다'); + const h = await halSubmit(deps, ctx, 'revert.submit', '보호 시점으로 되돌리기 요청', { kind: 'backup.restore', target: site.server_id, payload: { siteId: site.id, location: r.protect_point_id, protectPoint: false, isolatedVerify: true }, idempotencyKey: `hal:${job.id}:revert` }); + const res = await halWait(deps, ctx, 'revert.wait', '되돌리기 진행', h.halJobId); + if (res.externalCheck && res.externalCheck.ok === false) throw new JobError('revert_external_failed', '되돌린 뒤 외부 접속 확인에 실패했습니다 — 운영자 확인 필요'); + deps.store.run('UPDATE restores SET status=\'reverted\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), r.id); + audit(deps.store, { actorType: 'user', actorId: job.subject.by || null, groupId: r.group_id, subjectType: 'site', subjectId: site.id, action: 'restore.revert', detail: { restoreId: r.id } }); + } }); + deps.jobs.onChange((job) => { // 실패 시 옵션 실패 횟수·알림(고객+운영자)과 다음 재시도 시각 + if (job.kind === 'backup.scheduled' && ['failed', 'queued'].includes(job.status) && job.error && job.attempt > 0) { + const o = optionById(deps, job.subject.optionId); if (!o) return; + if (job.status === 'failed') deps.store.run('UPDATE backup_options SET consecutive_failures=consecutive_failures+1, last_failure_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id); + const n = (o.consecutive_failures || 0) + (job.status === 'failed' ? 1 : 0); const pol = productPolicy(deps, PRODUCT_ID); const threshold = Number(pol.policy.failure_notify_after || 1); + const body = job.status === 'queued' ? `일시 오류로 ${job.next_run_at} 에 다시 시도합니다 (${job.attempt}/${job.max_attempts}회). 오류: ${job.error}` : `백업이 실패했습니다 (연속 ${n}회). 운영자에게 알렸습니다. 오류: ${job.error}`; + if (job.status === 'failed' || n >= threshold) { notify(deps.store, { groupId: o.group_id, category: 'backup', severity: job.status === 'failed' ? 'critical' : 'warning', incidentKey: `backup:${o.id}:failed`, title: '백업 실패', body, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id, actionRequired: job.status === 'failed' ? '백업 화면에서 다시 시도하거나 문의하세요' : null }); notify(deps.store, { groupId: null, category: 'backup', severity: 'warning', incidentKey: `ops:backup:${o.id}:failed`, title: `[운영] 백업 실패 — 사이트 ${o.site_id}`, body, link: `/ops/backup?site=${o.site_id}` }); } + } + if (job.kind === 'backup.restore' && ['failed'].includes(job.status)) { const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); if (r) { deps.store.run('UPDATE restores SET status=\'failed\', error=?, updated_at=? WHERE id=?', job.error, nowIso(), r.id); notify(deps.store, { groupId: r.group_id, category: 'backup', severity: 'critical', mandatory: true, incidentKey: `restore:${r.id}:failed`, title: '복원이 실패했습니다', body: `기존 운영 상태는 ${r.protect_point_id ? '보호 시점으로 되돌릴 수 있습니다' : '유지됩니다'}. 자동 복구가 불가능해 운영자가 확인 중입니다. 오류: ${job.error} (작업 번호 ${job.id})`, link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id }); notify(deps.store, { groupId: null, category: 'backup', severity: 'critical', incidentKey: `ops:restore:${r.id}:failed`, title: `[운영] 복원 실패 — ${r.id}`, body: job.error, link: `/ops/backup/restores/${r.id}` }); } } + }); +} + +/** 복원 요청 화면 정보(§20.1) */ +export function restoreInfo(deps, site, backup) { + const pol = productPolicy(deps, PRODUCT_ID); const p = pol.policy; const conflicts = inProgress(deps, 'site', site.id); + return { backup: { id: backup.id, createdAt: backup.created_at, scope: parseJson(backup.scope_json, {}), sizeBytes: backup.size_bytes, verifyState: backup.verify_state, expiresAt: backup.expires_at, tier: backup.tier }, included: Object.values(SCOPE), excluded: EXCLUDED, + impact: '복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다', estimatedDowntimeMin: p.restore_downtime_min ?? null, cost: p.restore_cost ?? null, currency: pol.currency, costDeferred: p.restore_cost === undefined, + completionMode: p.restore_completion || 'user_confirm', protectPointDays: p.protect_point_days ?? null, revertHours: p.revert_hours ?? null, approvalRequired: true, conflicts, canRequest: conflicts.length === 0 && backup.status === 'done' && !backup.deleted_at && (!backup.expires_at || Date.parse(backup.expires_at) > Date.now()), deferred: pol.deferred }; +} +/** 복원 요청(§20.1). 같은 백업에 열린 요청이 있으면 그것을 돌려준다(중복 방지 §36). */ +export async function requestRestore(deps, site, backup, { by, completionMode = null } = {}) { + const open = deps.store.get('SELECT * FROM restores WHERE site_id=? AND backup_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\',\'queued\',\'running\',\'needs_user\')', site.id, backup.id); if (open) return { restore: open, created: false }; + const info = restoreInfo(deps, site, backup); if (!info.canRequest) throw new HttpError(409, 'restore_not_possible', info.conflicts.length ? '진행 중인 배포·복원·이전이 끝나야 복원을 요청할 수 있습니다' : '이 백업은 복원할 수 없습니다(만료·삭제·미완료)', { conflicts: info.conflicts }); + const id = newId('rs_'); const now = nowIso(); const cost = Number(info.cost || 0); const pol = productPolicy(deps, PRODUCT_ID).policy; + let orderId = null; let status = cost > 0 ? 'awaiting_payment' : 'awaiting_approval'; + if (cost > 0) { const orders = await optional('../commerce/orders.mjs'); if (orders && orders.createInternalOrder) { try { const o = await orders.createInternalOrder(deps, { groupId: site.group_id, kind: 'restore', amount: cost, targetType: 'restore', targetId: id, label: `사이트 복원 (${site.name})`, by }); orderId = o.id; } catch (_) { /* 주문 영역 없으면 결제 대기 기록만 */ } } } + deps.store.run('INSERT INTO restores(id, site_id, group_id, backup_id, status, cost, order_id, approval_state, completion_mode, requested_by, created_at, updated_at, policy_json) VALUES(?,?,?,?,?,?,?,\'pending\',?,?,?,?,?)', id, site.id, site.group_id, backup.id, status, cost, orderId, completionMode || info.completionMode, by, now, now, toJson(pol)); + audit(deps.store, { actorType: 'user', actorId: by, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'restore.request', detail: { restoreId: id, backupId: backup.id, cost } }); + notify(deps.store, { groupId: null, category: 'backup', incidentKey: `ops:restore:${id}:approval`, title: `[운영] 복원 승인 대기 — ${site.name}`, body: `백업 ${backup.created_at}, 비용 ${cost}`, link: `/ops/backup/restores/${id}` }); + return { restore: deps.store.get('SELECT * FROM restores WHERE id=?', id), created: true }; +} +/** 결제 확인(주문 승인 이벤트 또는 운영자) → 승인 대기 */ +export function markRestorePaid(deps, restoreId, by = 'system') { const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r || r.status !== 'awaiting_payment') return r; deps.store.run('UPDATE restores SET status=\'awaiting_approval\', updated_at=? WHERE id=?', nowIso(), r.id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'payment.restore.confirmed', detail: { restoreId } }); return deps.store.get('SELECT * FROM restores WHERE id=?', r.id); } +/** 운영자 승인(§30.3): 최종 확인 내용(고객·그룹·사이트·도메인·백업 시각)과 함께. 같은 승인을 반복해도 작업은 하나(멱등). */ +export function approveRestore(deps, restoreId, { by, window = null }) { + const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); + if (r.approval_state === 'approved' && r.job_id) return { restore: r, job: deps.jobs.get(r.job_id), created: false }; + if (!['awaiting_approval', 'awaiting_payment'].includes(r.status)) throw new HttpError(409, 'restore_not_approvable', `지금 상태(${r.status})에서는 승인할 수 없습니다`); + if (r.status === 'awaiting_payment') throw new HttpError(409, 'restore_unpaid', '결제 확인 전에는 승인할 수 없습니다'); + const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id); + const { job } = deps.jobs.enqueue({ kind: 'backup.restore', subject: { restoreId: r.id, siteId: r.site_id, groupId: r.group_id }, idempotencyKey: `restore:${r.id}`, actorType: 'operator', createdBy: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, label: `사이트 복원 (${site ? site.name : r.site_id})`, runAt: window || null }); + deps.store.run('UPDATE restores SET approval_state=\'approved\', status=\'queued\', job_id=?, updated_at=? WHERE id=?', job.id, nowIso(), r.id); + audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.approve', detail: { restoreId: r.id, backupId: r.backup_id, window } }); + notify(deps.store, { groupId: r.group_id, category: 'backup', title: '복원이 승인되어 진행됩니다', body: window ? `작업 시간: ${window}` : '곧 시작합니다', link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id }); + return { restore: deps.store.get('SELECT * FROM restores WHERE id=?', r.id), job, created: true }; +} +export function rejectRestore(deps, restoreId, { by, reason }) { const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); if (!['awaiting_approval', 'awaiting_payment'].includes(r.status)) throw new HttpError(409, 'restore_not_rejectable', '지금 상태에서는 거절할 수 없습니다'); deps.store.run('UPDATE restores SET status=\'rejected\', approval_state=\'rejected\', error=?, updated_at=?, ended_at=? WHERE id=?', reason || null, nowIso(), nowIso(), r.id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.reject', detail: { restoreId, reason } }); notify(deps.store, { groupId: r.group_id, category: 'backup', severity: 'warning', title: '복원 요청이 거절되었습니다', body: reason || '사유 없음', link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id }); return deps.store.get('SELECT * FROM restores WHERE id=?', r.id); } +/** 사용자 확인 단계의 선택(§20.2 8단계): confirm → 완료 진행, revert → 되돌리기 */ +export async function userDecision(deps, restore, action, by) { + const job = deps.jobs.get(restore.job_id); if (!job || job.status !== 'needs_user') throw new HttpError(409, 'restore_not_waiting', '사용자 확인을 기다리는 상태가 아닙니다'); + if (action === 'confirm') { deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...job.subject, userConfirmed: true, by }), job.id); deps.jobs.resume(job.id, by); deps.store.run('UPDATE restores SET status=\'running\', updated_at=? WHERE id=?', nowIso(), restore.id); audit(deps.store, { actorType: 'user', actorId: by, groupId: restore.group_id, subjectType: 'site', subjectId: restore.site_id, action: 'restore.user_confirm', detail: { restoreId: restore.id } }); return { resumed: true }; } + if (action === 'revert') { deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...job.subject, by }), job.id); const j = await deps.jobs.revert(job.id, by); return { reverted: j.status === 'reverted', job: j }; } + throw new HttpError(400, 'bad_action', '알 수 없는 선택입니다'); +} +/** 사이트 백업 화면 데이터(§19.2) */ +export function siteView(deps, site) { + const o = optionOf(deps, site.id); const pol = productPolicy(deps, PRODUCT_ID); + const list = deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 100', site.id).map((b) => ({ id: b.id, createdAt: b.created_at, tier: b.tier, kind: b.kind, scope: parseJson(b.scope_json, {}), sizeBytes: b.size_bytes, verifyState: b.verify_state, expiresAt: b.expires_at, status: b.status, canRestore: b.status === 'done' && (!b.expires_at || Date.parse(b.expires_at) > Date.now()) })); + const restores = deps.store.all('SELECT * FROM restores WHERE site_id=? ORDER BY created_at DESC LIMIT 20', site.id).map((r) => ({ ...r, job: r.job_id ? deps.jobs.get(r.job_id) : null })); + const lastJob = o ? deps.store.get('SELECT id FROM jobs WHERE kind=\'backup.scheduled\' AND subject_id=? ORDER BY created_at DESC LIMIT 1', site.id) : null; + return { eligibility: eligibility(deps, site), purchase: purchaseInfo(deps, site), option: o ? { ...o, schedule: parseJson(o.schedule_json, {}), retention: parseJson(o.retention_json, {}), pausedReasonLabel: o.paused_reason ? reasonLabel(o.paused_reason) : null, storedBytes: storedBytes(deps, site.id), firstBackupPending: !o.last_success_at, failureNotifyAfter: Number(pol.policy.failure_notify_after || 1) } : null, + lastRun: lastJob ? deps.jobs.get(lastJob.id) : null, backups: list, restores, scope: SCOPE, excluded: EXCLUDED, siteState: site.lifecycle, mailBackupNotice: '메일 원문은 고객용 백업·복원에 포함되지 않습니다(§17.4)' }; +} diff --git a/portal/lifecycle/api.mjs b/portal/lifecycle/api.mjs new file mode 100644 index 0000000..086e144 --- /dev/null +++ b/portal/lifecycle/api.mjs @@ -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: `
${deps.statePanel('loading')}
`, 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: `
${deps.statePanel('loading')}
`, 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(); } +} diff --git a/portal/lifecycle/common.mjs b/portal/lifecycle/common.mjs new file mode 100644 index 0000000..9f24ffa --- /dev/null +++ b/portal/lifecycle/common.mjs @@ -0,0 +1,89 @@ +/** + * portal/lifecycle/common.mjs — 백업·복원·이전·소유권 이전·생명주기 영역(fork F)이 함께 쓰는 도우미. + * + * - getSite / getMailService : 그룹 경계와 사이트별 권한(§4.3)을 검사해 행을 돌려준다(다른 그룹 것은 404 — 존재를 드러내지 않음 §37). + * - opsGuard : 운영자 콘솔(HAL)에서 오는 내부 호출을 lib/serviceauth 로 검증한다(보관소의 'service:'). + * - 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 }); } diff --git a/portal/lifecycle/model.mjs b/portal/lifecycle/model.mjs new file mode 100644 index 0000000..b568798 --- /dev/null +++ b/portal/lifecycle/model.mjs @@ -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; +` }]; diff --git a/portal/lifecycle/public/lifecycle.js b/portal/lifecycle/public/lifecycle.js new file mode 100644 index 0000000..326b3a7 --- /dev/null +++ b/portal/lifecycle/public/lifecycle.js @@ -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 `${esc(l)}`; }; + 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 ? `
${esc(v.frozenNote)}
` : ''}

${badge(v.site.lifecycle)} ${esc(v.site.name)}

공개 서비스
${esc(v.publicAccess)}
관리자 접근
${esc(v.adminAccess)}
운영 사이트 수
${v.siteCountConsumed ? '소비 중(일시 중지도 포함)' : '소비하지 않음'}
용량 권리
${v.capacityConsumed.length ? v.capacityConsumed.map((a) => `${esc(a.resource)} ${a.amount}`).join(', ') : '없음'}
청구
${esc(v.billing)}
도메인
${v.domains.length ? v.domains.map((x) => `${esc(x.name)}${x.primary ? '(대표)' : ''}: ${x.routed ? '연결' : '해제'}`).join(', ') : '없음'}
메일
${v.mail.length ? v.mail.map((m) => `${esc(m.domain)}: ${esc((LB[m.lifecycle] || [0, m.lifecycle])[1])}`).join(', ') : '없음'}
백업
${v.backup ? `옵션 ${esc(v.backup.status)}${v.backup.deleteAfter ? ` · 백업 삭제 예정 ${d8(v.backup.deleteAfter)}` : ''}` : '없음'}
내보내기
${v.exportAvailable ? '가능' : '불가(삭제됨)'}
재개
${v.resumeAvailable ? '가능' : '불가'}
되돌릴 수 없는 시점
${esc(v.pointOfNoReturn)}
보존 종료
${d8(v.retentionUntil)}
삭제 예정
${d8(v.deleteAfter)}${v.deleteHold ? ` 삭제 보류 — ${esc(v.deleteHold.reason)} · 문의 ${esc(v.deleteHold.contact)}` : ''}
다음 알림
${d8(v.nextNoticeAt)}
보존 정책
사이트 ${v.retentionPolicy.site_days}일 · 메일 ${v.retentionPolicy.mail_days}일 · 소스 ${v.retentionPolicy.source_days}일 · 백업 ${v.retentionPolicy.backup_days}일 (${esc(v.retentionPolicy.source)})

${esc(v.mailBackupNote)}

+
${d.canManage && !v.frozen ? `${['live', 'needs_domain'].includes(v.site.lifecycle) ? '' : ''}${v.site.lifecycle === 'paused' ? '' : ''}${['live', 'needs_domain', 'paused', 'draft', 'building'].includes(v.site.lifecycle) ? '' : ''}${v.site.lifecycle === 'cancel_scheduled' && v.resumeAvailable ? '' : ''}` : ''}${v.exportAvailable ? '' : ''}
+

운영 사이트 수 반환 (8단계)

    ${sc.steps.map((s) => `
  1. ${esc(s.title)} — ${esc(s.note || '')}${s.link ? ` 이동` : ''}
  2. `).join('')}
${v.mail.length ? `
` : ''}
+ ${v.events.length ? `

상태 변경 기록

${v.events.map((e) => ``).join('')}
시각변경주체사유
${d8(e.at)}${esc((LB[e.from_state] || [0, e.from_state || '—'])[1])} → ${esc((LB[e.to_state] || [0, e.to_state])[1])}${e.by === 'system' ? '자동' : '사용자'}${esc(e.reason || '')}
` : ''}`; + 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 ? '
소유권 분쟁으로 설정 변경·삭제가 동결되었습니다
' : ''}

${badge(v.mailService.lifecycle)} ${esc(v.mailService.name)} ${esc(v.mailService.domain || '')}

송수신
${esc(v.sendReceive)}
관리자 접근
${esc(v.adminAccess)}
권리 소비
${esc(v.consumes)}
청구
${esc(v.billing)}
내보내기
${v.exportAvailable ? '가능' : '불가'}
재개
${v.resumeAvailable ? '가능' : '불가'}
되돌릴 수 없는 시점
${esc(v.pointOfNoReturn)}
보존 종료
${d8(v.retentionUntil)}
삭제 예정
${d8(v.deleteAfter)}${v.deleteHold ? ` 삭제 보류 — 문의 ${esc(v.deleteHold.contact)}` : ''}
다음 알림
${d8(v.nextNoticeAt)}

${esc(v.backupNote)}

${v.mailService.lifecycle === 'active' && !v.frozen ? '' : ''}
`; + 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(); } +})(); diff --git a/portal/lifecycle/service.mjs b/portal/lifecycle/service.mjs new file mode 100644 index 0000000..3654cfe --- /dev/null +++ b/portal/lifecycle/service.mjs @@ -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 }; } }); +} diff --git a/portal/lifecycle/testing/fakehal.mjs b/portal/lifecycle/testing/fakehal.mjs new file mode 100644 index 0000000..f622be9 --- /dev/null +++ b/portal/lifecycle/testing/fakehal.mjs @@ -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 }; + } +} diff --git a/portal/relocation/api.mjs b/portal/relocation/api.mjs new file mode 100644 index 0000000..f78e6bb --- /dev/null +++ b/portal/relocation/api.mjs @@ -0,0 +1,36 @@ +/** + * portal/relocation/api.mjs — 서버 이전 화면·API(§21)와 운영자 내부 API(§30.4). + * GET /sites/:id/relocation 이전 화면(종류·경계·진행·되돌리기) + * GET /api/relocation/sites/:id 현황·자동 이전 조건·경계값 + * POST /api/relocation/sites/:id { toServerId?, reason, confirm:true } 고객 요청(유료) + * GET /api/relocation/:id 상세(작업 포함) POST /api/relocation/:id/cancel POST /api/relocation/:id/revert {confirm:true} + * 운영자: GET /internal/ops/relocation · GET /internal/ops/relocation/:id · POST /internal/ops/relocation {subjectType, subjectId, toServerId, kind, reason, operator} + * POST /internal/ops/relocation/:id/approve {operator, scheduledAt, confirm:{subjectId,toServerId,paymentConfirmed}} · /reject · /retry · /revert + */ +import { HttpError, esc } from '../../lib/http.mjs'; +import { requireGroup } from '../auth/guard.mjs'; +import * as svc from './service.mjs'; +import { getSite, opsGuard, operatorOf, jobView, requireConfirm, nowIso, toJson } from '../lifecycle/common.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/relocation', new URL('./public', import.meta.url).pathname); + app.get('/sites/:id/relocation', (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: `
${deps.statePanel('loading')}
`, scripts: ['/ui/relocation/relocation.js'] }); }); + const mine = (ctx, id) => { const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'view'); else { requireGroup(ctx); if (r.group_id !== ctx.group.id) throw new HttpError(404, 'not_found', t('error.notFound')); } return r; }; + app.get('/api/relocation/sites/:id', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); const subject = { type: 'site', id: site.id, site, serverId: site.server_id }; const list = deps.store.all('SELECT * FROM relocations WHERE subject_type=\'site\' AND subject_id=? ORDER BY created_at DESC LIMIT 20', site.id).map((r) => ({ ...svc.view(deps, r), job: jobView(deps, r.job_id) })); return { ok: true, managed: site.deploy_mode === 'managed', runtime: svc.runtimeOf(site), auto: svc.autoEligible(deps, subject), boundaries: { customer: svc.boundaries(deps, subject, 'customer_paid'), platform: svc.boundaries(deps, subject, svc.runtimeOf(site) === 'static' ? 'auto_static' : 'approved') }, relocations: list, stepTitles: svc.STEP_TITLES, currentLocation: site.server_id ? '관리형 서버(내부 위치는 표시하지 않습니다)' : '미배치' }; }); + app.post('/api/relocation/sites/:id', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'admin'); const body = await ctx.body(); requireConfirm(body); const r = await svc.request(deps, { subjectType: 'site', subjectId: site.id, toServerId: body.toServerId || null, kind: 'customer_paid', reason: String(body.reason || '고객 요청'), origin: 'customer', by: ctx.user.id }); return { ok: true, relocation: svc.view(deps, r.relocation), created: r.created }; }); + app.get('/api/relocation/:id', (ctx) => { const r = mine(ctx, ctx.params.id); return { ok: true, relocation: svc.view(deps, r), job: jobView(deps, r.job_id) }; }); + app.post('/api/relocation/:id/cancel', async (ctx) => { const r = mine(ctx, ctx.params.id); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'admin'); return { ok: true, relocation: await svc.cancel(deps, r.id, ctx.user.id) }; }); + app.post('/api/relocation/:id/revert', async (ctx) => { const r = mine(ctx, ctx.params.id); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'admin'); const body = await ctx.body(); requireConfirm(body); if (!r.job_id) throw new HttpError(409, 'no_job', '되돌릴 작업이 없습니다'); deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...deps.jobs.get(r.job_id).subject, by: ctx.user.id }), r.job_id); const j = await deps.jobs.revert(r.job_id, ctx.user.id); return { ok: true, job: jobView(deps, j.id), relocation: deps.store.get('SELECT * FROM relocations WHERE id=?', r.id) }; }); + const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); }; + app.get('/internal/ops/relocation', ops((ctx) => ({ ok: true, relocations: deps.store.all(`SELECT r.*, g.name AS group_name FROM relocations r JOIN groups g ON g.id=r.group_id ${ctx.query.status ? 'WHERE r.status=?' : ''} ORDER BY r.created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => ({ ...svc.view(deps, r), job: jobView(deps, r.job_id) })) }))); + app.get('/internal/ops/relocation/:id', ops((ctx) => { const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); const subject = r.subject_type === 'site' ? deps.store.get('SELECT id, name, primary_domain, server_id, lifecycle FROM sites WHERE id=?', r.subject_id) : deps.store.get('SELECT id, name, domain, arimail_server_id FROM mail_services WHERE id=?', r.subject_id); return { ok: true, relocation: svc.view(deps, r), job: jobView(deps, r.job_id), subject, children: r.job_id ? deps.jobs.childrenSummary(r.job_id) : null, finalConfirmation: { subjectId: r.subject_id, fromServerId: r.from_server_id, toServerId: r.to_server_id, cost: r.cost, charged: !!r.charged } }; })); + app.post('/internal/ops/relocation', ops(async (ctx) => { const body = await ctx.body(); const r = await svc.request(deps, { subjectType: body.subjectType || 'site', subjectId: body.subjectId, toServerId: body.toServerId || null, kind: body.kind || null, reason: body.reason || '플랫폼 운영', origin: 'platform', by: operatorOf(body), scheduledAt: body.scheduledAt || null }); return { ok: true, relocation: svc.view(deps, r.relocation), created: r.created }; })); + app.post('/internal/ops/relocation/:id/approve', ops(async (ctx) => { const body = await ctx.body(); const out = svc.approve(deps, ctx.params.id, { by: operatorOf(body), scheduledAt: body.scheduledAt || null, confirm: body.confirm || {} }); return { ok: true, relocation: svc.view(deps, out.relocation), job: out.job ? jobView(deps, out.job.id) : null, created: out.created }; })); + app.post('/internal/ops/relocation/:id/reject', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, relocation: svc.reject(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason }) }; })); + app.post('/internal/ops/relocation/:id/retry', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE relocations SET status=\'queued\', updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; })); + app.post('/internal/ops/relocation/:id/resume', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE relocations SET status=\'queued\', updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; })); + app.post('/internal/ops/relocation/:id/revert', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = await deps.jobs.revert(r.job_id, operatorOf(body)); return { ok: true, job: jobView(deps, j.id) }; })); +} diff --git a/portal/relocation/model.mjs b/portal/relocation/model.mjs new file mode 100644 index 0000000..68919e3 --- /dev/null +++ b/portal/relocation/model.mjs @@ -0,0 +1,14 @@ +/** + * portal/relocation/model.mjs — 서버 이전 영역 추가 열. + * relocations.precheck_json 시작 때 찍은 조건 스냅샷(전환 직전에 다시 비교 §21.2) + * relocations.hal_job_id HAL site.relocate 작업 id + * relocations.old_retained_until 이전 위치 보존 종료 시각 + * relocations.charged 고객 과금 여부(플랫폼 사유 이전은 0 §21.2) + */ +export const MIGRATIONS = [{ id: 'relocation_0001', sql: ` +ALTER TABLE relocations ADD COLUMN precheck_json TEXT; +ALTER TABLE relocations ADD COLUMN hal_job_id TEXT; +ALTER TABLE relocations ADD COLUMN old_retained_until TEXT; +ALTER TABLE relocations ADD COLUMN charged INTEGER NOT NULL DEFAULT 0; +ALTER TABLE relocations ADD COLUMN notices_json TEXT; +` }]; diff --git a/portal/relocation/public/relocation.js b/portal/relocation/public/relocation.js new file mode 100644 index 0000000..c8b9169 --- /dev/null +++ b/portal/relocation/public/relocation.js @@ -0,0 +1,22 @@ +/* portal/relocation/public/relocation.js — 서버 이전 화면(§21). 경계값·조건·진행·취소·되돌리기 */ +(() => { + const { api, esc, state, toast, confirmDanger, poll, job, fmt } = window.aw; + const el = document.getElementById('rl-site'); if (!el) return; const siteId = el.dataset.site; + const ST = { awaiting_approval: ['waiting', '승인 대기'], queued: ['waiting', '처리 대기'], running: ['running', '진행 중'], paused: ['warning', '일시 중지'], cancelling: ['warning', '취소 중'], cancelled: ['neutral', '취소됨'], done: ['done', '완료'], failed: ['failed', '실패'], reverting: ['running', '되돌리는 중'], reverted: ['ok', '되돌림 완료'], rejected: ['failed', '거절됨'], needs_user: ['warning', '사용자 확인 필요'] }; + const badge = (k) => { const [c, l] = ST[k] || ['neutral', k]; return `${esc(l)}`; }; + const v = (x, u = '') => (x == null ? '미확정(후속 상품정책)' : esc(x) + u); + const bounds = (b) => `
취소 가능 마지막 단계
${esc(b.lastCancelLabel)}
쓰기 제한
${b.writeFreeze ? `${esc(b.writeFreeze.startsAt)} ~ ${esc(b.writeFreeze.endsAt)} — ${esc(b.writeFreeze.note)}` : '없음(정적 사이트)'}
목표 중단 시간
${v(b.targetDowntimeMin, '분')}
새 위치가 유일한 운영 위치가 되는 시점
${esc(b.soleLocationAt)}
전환 후 되돌리기 기간
${v(b.revertHours, '시간')}
이전 위치 보존·삭제
${b.oldRetentionDays != null ? `전환 후 ${b.oldRetentionDays}일 보존 뒤 삭제` : v(null)}
DNS·인증서 반영 대기 중
${esc(b.dnsWaitHandling)}
비용
${b.chargeable ? v(b.cost) + ' ' + esc(b.currency) : '없음(플랫폼 사유)'}
`; + const render = async () => { + let d; try { d = await api('GET', `/api/relocation/sites/${siteId}`); } catch (e) { state(el, e.status === 403 ? 'forbidden' : e.code === 'hal_unavailable' ? 'stale' : 'failed', e.message, { retry: render }); return false; } + if (!d.managed) { state(el, 'empty', '외부 배포 사이트는 Ariweb 가 서버를 관리하지 않아 서버 이전 대상이 아닙니다'); return false; } + const open = d.relocations.find((r) => !['done', 'failed', 'cancelled', 'reverted', 'rejected'].includes(r.status)); + el.innerHTML = `

이전 안내

현재 위치
${esc(d.currentLocation)}
실행 형태
${d.runtime === 'static' ? '정적 사이트(조건이 맞으면 플랫폼이 자동 이전할 수 있습니다)' : '동적 사이트(운영자 승인과 작업 시간 확인 뒤 이전)'}
자동 이전 조건
${d.auto.ok ? '충족' : d.auto.reasons.map(esc).join('
')}

고객 요청 이전(유료)

${bounds(d.boundaries.customer)}

플랫폼 사유 이전(장애 회피·용량 조정, 무료)

${bounds(d.boundaries.platform)}
${open ? '' : ''}
+ ${d.relocations.length ? d.relocations.map((r) => `

${badge(r.status)} ${r.kind === 'customer_paid' ? '고객 요청' : r.kind === 'auto_static' ? '자동(정적)' : '승인형'} · 요청 ${esc(fmt(r.created_at))}

이유: ${esc(r.reason || '')}${r.scheduled_at ? ` · 예정 ${esc(fmt(r.scheduled_at))}` : ''}${r.old_retained_until ? ` · 이전 위치 보존 종료 ${esc(fmt(r.old_retained_until))}` : ''}${r.charged ? ` · 비용 ${r.cost}` : ' · 과금 없음'}

${['awaiting_approval', 'queued', 'running'].includes(r.status) ? `` : ''}${r.status === 'done' ? `` : ''}
`).join('') : ''}`; + for (const r of d.relocations) if (r.job) job(`#rl-job-${r.id}`, r.job, { onAction: async (a) => { try { if (a === 'cancel') await api('POST', `/api/relocation/${r.id}/cancel`); toast('처리했습니다'); render(); } catch (e) { toast(e.message); } } }); + el.querySelectorAll('[data-cancel]').forEach((b) => (b.onclick = async () => { try { await api('POST', `/api/relocation/${b.dataset.cancel}/cancel`); toast('취소했습니다'); render(); } catch (e) { toast(e.message); } })); + el.querySelectorAll('[data-revert]').forEach((b) => (b.onclick = async () => { if (!(await confirmDanger({ title: '이전 위치로 되돌리기', target: '이 사이트', impacts: ['전환 뒤 새 위치에서 생긴 변경은 사라질 수 있습니다', '되돌리기 기간 안에서만 가능합니다'], confirmLabel: '되돌리기' }))) return; try { await api('POST', `/api/relocation/${b.dataset.revert}/revert`, { confirm: true }); toast('되돌리기를 시작했습니다'); render(); } catch (e) { toast(e.message); } })); + const req = document.getElementById('rl-req'); if (req) req.onclick = async () => { if (!(await confirmDanger({ title: '서버 이전 요청(유료)', target: '이 사이트', impacts: ['운영자 승인 뒤 안내된 시간에 진행됩니다', d.boundaries.customer.writeFreeze ? '최종 반영~전환 사이 쓰기가 제한됩니다' : '정적 사이트라 쓰기 제한은 없습니다', `비용: ${d.boundaries.customer.cost ?? '미확정'} ${d.boundaries.customer.currency}`], confirmLabel: '요청' }))) return; try { await api('POST', `/api/relocation/sites/${siteId}`, { reason: '고객 요청', confirm: true }); toast('이전을 요청했습니다'); render(); } catch (e) { toast(e.message); } }; + return true; + }; + poll(render, 6000); +})(); diff --git a/portal/relocation/service.mjs b/portal/relocation/service.mjs new file mode 100644 index 0000000..6e9260f --- /dev/null +++ b/portal/relocation/service.mjs @@ -0,0 +1,145 @@ +/** + * portal/relocation/service.mjs — 서버 이전(§21·§30.4) 업무 규칙. + * + * 종류(kind) + * auto_static 정적 사이트 자동 이전(플랫폼 사유: 장애 회피·용량 조정). 조건이 맞으면 승인 없이, 사전·완료 통지, 과금 없음. + * approved 동적 사이트·메일: 운영자 승인과 작업 시간 확인 뒤 진행. 플랫폼 사유면 과금 없음. + * customer_paid 고객 요청 유료 이전(정책 가격). + * 진행(§21.2 10단계)은 HAL 의 site.relocate 작업이 수행하고(docs/HAL-JOBS.md), 포털 작업은 + * precheck(로컬 조건 스냅샷) → submit(HAL, 전환 전 멈춤) → wait_copy → precheck_switch(조건 재확인; 달라지면 자동 중지) → switch → wait_done → finalize + * 로 감싼다. 같은 사이트가 두 서버에서 동시에 운영되지 않도록 전환 전까지 sites.server_id 를 바꾸지 않고, 전환 뒤에만 바꾼다. + * 경계값(boundaries: 취소 가능 마지막 단계·쓰기 제한 시각·목표 중단 시간·유일 운영 시점·되돌리기 기간·이전 위치 보존 종료·DNS 대기 처리)은 활성 상품정책(product 'relocation')에서 읽는다. + */ +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 { JobError } from '../../lib/jobs.mjs'; +import { productPolicy, halOf, asTransient, inProgress, nowIso, parseJson, toJson, addMs, DAY, HOUR, optional } from '../lifecycle/common.mjs'; + +export const PRODUCT_ID = 'relocation'; +export const KINDS = ['auto_static', 'approved', 'customer_paid']; +export const STEP_TITLES = [['precheck', '조건 확인'], ['prepare', '새 위치 준비'], ['precopy', '데이터 사전 복사'], ['verify_copy', '복사 결과 확인'], ['final_sync', '최종 변경분 반영'], ['verify_new', '새 위치 확인'], ['switch', '연결 전환'], ['external_check', '외부 접속 또는 송수신 확인'], ['cleanup', '이전 위치 정리'], ['done', '완료']]; + +/** 사이트 실행 형태(정적/동적). 사이트 settings_json.runtime 또는 기본 static */ +export function runtimeOf(site) { return (parseJson(site.settings_json, {}) || {}).runtime || 'static'; } +/** 이전 화면 경계값(§21.2) */ +export function boundaries(deps, subject, kind) { + const pol = productPolicy(deps, PRODUCT_ID); const p = pol.policy; const dyn = subject.type === 'mail' || runtimeOf(subject.site || {}) !== 'static'; + return { + lastCancelStep: 'verify_new', lastCancelLabel: '새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다', + writeFreeze: dyn ? { startsAt: 'final_sync 단계 시작', endsAt: 'switch 완료 또는 되돌림 완료', note: '쓰기 제한 중 업로드·데이터 변경이 거부됩니다' } : null, + targetDowntimeMin: p.target_downtime_min ?? null, soleLocationAt: 'switch 단계 완료 시점(그 전에는 이전 위치가 유일한 운영 위치)', + revertHours: p.revert_hours ?? null, oldRetentionDays: p.old_retention_days ?? null, dnsWaitHandling: p.dns_wait_handling || '전환 뒤 DNS·인증서 반영 대기 중에는 두 위치 모두 같은 내용을 제공하되 쓰기는 새 위치만 받습니다', + cost: kind === 'customer_paid' ? (p.customer_cost ?? null) : 0, currency: pol.currency, deferred: pol.deferred, chargeable: kind === 'customer_paid', + }; +} +/** 조건 스냅샷(시작·전환 직전 비교) */ +export async function snapshot(deps, subject, toServerId) { + const hal = halOf(deps); let servers; try { servers = await hal.listServers({}); } catch (e) { throw asTransient(e); } + const from = servers.find((s) => s.id === subject.serverId) || null; const to = servers.find((s) => s.id === toServerId) || null; + const conflicts = inProgress(deps, subject.type === 'mail' ? 'mail_service' : 'site', subject.id).filter((c) => !(c.type === 'relocation' || c.kind === 'site.relocate')); // 자기 자신(이전 작업)은 충돌이 아니다 + const domains = subject.type === 'site' ? deps.store.all('SELECT name, cert_state, dns_state FROM domains WHERE site_id=?', subject.id) : []; + const need = Number((parseJson(subject.site?.settings_json, {}) || {}).diskBytes || 0); + return { at: nowIso(), currentServer: subject.serverId, fromStatus: from ? from.status : 'unknown', toStatus: to ? to.status : 'missing', toAcceptsNew: !!(to && to.acceptsNew), toFree: to ? (to.capacity?.diskFreeBytes || 0) : 0, tempOk: !!(to && (to.capacity?.diskFreeBytes || 0) >= need * 2), conflicts: conflicts.length, domains: domains.map((d) => `${d.name}:${d.cert_state}:${d.dns_state}`), version: subject.site?.current_version || null }; +} +/** 두 스냅샷 비교 — 달라진 항목 */ +export function diffSnapshots(a, b) { const keys = ['currentServer', 'fromStatus', 'toStatus', 'toAcceptsNew', 'tempOk', 'conflicts', 'version']; const out = keys.filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k])); if (JSON.stringify(a.domains) !== JSON.stringify(b.domains)) out.push('domains'); return out; } +function subjectOf(deps, subjectType, subjectId) { + if (subjectType === 'mail_service') { const m = deps.store.get('SELECT * FROM mail_services WHERE id=?', subjectId); if (!m) return null; return { type: 'mail', id: m.id, groupId: m.group_id, name: m.name, serverId: m.arimail_server_id, row: m }; } + const s = deps.store.get('SELECT * FROM sites WHERE id=?', subjectId); if (!s) return null; return { type: 'site', id: s.id, groupId: s.group_id, name: s.name, serverId: s.server_id, site: s, row: s }; +} +/** 자동 이전 조건(§21.1): 정적·관리형·진행 작업 없음·같은 대상의 메일 없음 */ +export function autoEligible(deps, subject) { + const reasons = []; + if (subject.type !== 'site') reasons.push('메일 이전은 승인형입니다'); + else { if (subject.site.deploy_mode !== 'managed') reasons.push('관리형 사이트만 이전할 수 있습니다'); if (runtimeOf(subject.site) !== 'static') reasons.push('동적 사이트는 운영자 승인과 작업 시간 확인이 필요합니다'); if (deps.store.get('SELECT 1 AS x FROM mail_services WHERE site_id=? AND status NOT IN (\'deleted\',\'cancelled\')', subject.id)) reasons.push('사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다'); } + const c = inProgress(deps, subject.type === 'mail' ? 'mail_service' : 'site', subject.id); if (c.length) reasons.push('진행 중인 배포·복원·이전이 있습니다'); + return { ok: reasons.length === 0, reasons }; +} +/** 이전 요청 만들기. origin: 'platform'(장애 회피·용량) | 'customer'. */ +export async function request(deps, { subjectType = 'site', subjectId, toServerId = null, kind = null, reason = '', origin = 'customer', by = null, scheduledAt = null }) { + const subject = subjectOf(deps, subjectType, subjectId); if (!subject) throw new HttpError(404, 'not_found', '대상이 없습니다'); + if (subject.type === 'site' && subject.site.deploy_mode !== 'managed') throw new HttpError(409, 'relocation_not_managed', '외부 배포 사이트는 Ariweb 가 서버를 관리하지 않아 이전 대상이 아닙니다'); + const open = deps.store.get('SELECT * FROM relocations WHERE subject_type=? AND subject_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\',\'rejected\')', subjectType, subjectId); if (open) return { relocation: open, created: false }; + const conflicts = inProgress(deps, subjectType === 'mail_service' ? 'mail_service' : 'site', subjectId); if (conflicts.length) throw new HttpError(409, 'conflict', '진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다', { conflicts }); + const auto = autoEligible(deps, subject); + if (!kind) kind = origin === 'customer' ? 'customer_paid' : auto.ok ? 'auto_static' : 'approved'; + if (kind === 'auto_static' && !auto.ok) throw new HttpError(409, 'relocation_not_auto', auto.reasons.join(' / '), { reasons: auto.reasons }); + if (!toServerId) { // 도착 서버 자동 선택: 같은 역할·신규 배치 허용·현재 서버 제외, 여유 디스크가 큰 순 + const hal = halOf(deps); let servers; try { servers = await hal.listServers({}); } catch (e) { throw new HttpError(503, 'hal_unavailable', '정보 확인 지연 — 도착 서버를 고를 수 없습니다'); } + const role = subject.type === 'mail' ? 'mail' : 'web'; const cand = servers.filter((x) => x.role === role && x.acceptsNew && x.status === 'ready' && x.id !== subject.serverId).sort((a, b2) => (b2.capacity?.diskFreeBytes || 0) - (a.capacity?.diskFreeBytes || 0)); + if (!cand.length) throw new HttpError(409, 'no_target_server', '신규 배치를 받을 수 있는 다른 서버가 없습니다'); toServerId = cand[0].id; + } + if (toServerId === subject.serverId) throw new HttpError(400, 'same_server', '현재 운영 서버와 같은 서버로는 이전할 수 없습니다'); + if (subject.type === 'mail') { const mail = await optional('../mail/service.mjs'); if (!mail || !mail.setMigrating) throw new HttpError(409, 'needs_setup', '메일 서비스 이전 기능이 아직 연결되지 않았습니다(메일 영역 필요)'); } + const b = boundaries(deps, subject, kind); const now = nowIso(); const id = newId('rl_'); + const charged = kind === 'customer_paid' ? 1 : 0; const status = kind === 'auto_static' ? 'queued' : 'awaiting_approval'; + deps.store.run('INSERT INTO relocations(id, group_id, subject_type, subject_id, kind, reason, from_server_id, to_server_id, status, boundaries_json, approval_state, scheduled_at, cost, requested_by, created_at, updated_at, charged) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', id, subject.groupId, subjectType, subjectId, kind, reason, subject.serverId, toServerId, status, toJson(b), kind === 'auto_static' ? 'not_required' : 'pending', scheduledAt, charged ? Number(b.cost || 0) : 0, by, now, now, charged); + audit(deps.store, { actorType: origin === 'customer' ? 'user' : 'operator', actorId: by, groupId: subject.groupId, subjectType, subjectId, action: 'relocate.request', detail: { relocationId: id, kind, toServerId, reason } }); + // 사전 통지(§21.1): 대상·이유·출발/도착의 사용자용 설명·예상 시간·영향·변경 제한 시간·비용·사용자 행동 + notify(deps.store, { groupId: subject.groupId, category: 'relocation', severity: 'info', mandatory: kind !== 'auto_static', title: `${subject.name}: 서버 이전이 ${kind === 'auto_static' ? '예정되었습니다(자동)' : '요청되었습니다'}`, body: `이유: ${reason || (origin === 'platform' ? '플랫폼 운영(장애 회피·용량 조정)' : '고객 요청')}. 현재 위치 → 새 위치로 옮기며 ${b.targetDowntimeMin != null ? `목표 중단 시간 ${b.targetDowntimeMin}분` : '중단 시간은 정책 미확정'}. ${b.writeFreeze ? '최종 반영 단계부터 전환까지 쓰기가 제한됩니다. ' : ''}비용: ${charged ? `${b.cost ?? '미확정'} ${b.currency}` : '없음(플랫폼 사유)'}. ${kind === 'auto_static' ? '별도 조치는 필요 없습니다' : '운영자 승인 뒤 안내된 시간에 진행됩니다'}`, link: `/sites/${subjectId}/relocation`, subjectType, subjectId, incidentKey: `relocation:${id}` }); + if (kind === 'auto_static') start(deps, id, 'system'); + return { relocation: deps.store.get('SELECT * FROM relocations WHERE id=?', id), created: true }; +} +/** 운영자 승인(§30.4) → 작업 시작(scheduled_at 이 있으면 그 시각) */ +export function approve(deps, id, { by, scheduledAt = null, confirm = {} }) { + const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); + if (r.approval_state === 'approved' && r.job_id) return { relocation: r, created: false }; + if (r.status !== 'awaiting_approval') throw new HttpError(409, 'relocation_not_approvable', `지금 상태(${r.status})에서는 승인할 수 없습니다`); + if (confirm.subjectId !== r.subject_id || confirm.toServerId !== r.to_server_id) throw new HttpError(400, 'final_confirmation_mismatch', '최종 확인(대상·도착 서버)이 요청과 다릅니다', { expected: { subjectId: r.subject_id, fromServerId: r.from_server_id, toServerId: r.to_server_id } }); + if (r.charged && r.cost > 0 && !confirm.paymentConfirmed) throw new HttpError(409, 'relocation_unpaid', '고객 요청 유료 이전은 결제 확인 뒤 승인합니다'); + deps.store.run('UPDATE relocations SET approval_state=\'approved\', scheduled_at=COALESCE(?, scheduled_at), updated_at=? WHERE id=?', scheduledAt, nowIso(), id); + audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.approve', detail: { relocationId: id, scheduledAt } }); + return { ...start(deps, id, by, scheduledAt || r.scheduled_at), created: true }; +} +export function reject(deps, id, { by, reason }) { const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r || r.status !== 'awaiting_approval') throw new HttpError(409, 'relocation_not_rejectable', '승인 대기 상태가 아닙니다'); deps.store.run('UPDATE relocations SET status=\'rejected\', approval_state=\'rejected\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), id); notify(deps.store, { groupId: r.group_id, category: 'relocation', severity: 'warning', title: '서버 이전 요청이 거절되었습니다', body: reason || '', link: `/sites/${r.subject_id}/relocation`, incidentKey: `relocation:${id}` }); audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.reject', detail: { relocationId: id, reason } }); return deps.store.get('SELECT * FROM relocations WHERE id=?', id); } +function start(deps, id, by, runAt = null) { + const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); const subject = subjectOf(deps, r.subject_type, r.subject_id); + const { job } = deps.jobs.enqueue({ kind: 'site.relocate', subject: { relocationId: id, subjectType: r.subject_type, subjectId: r.subject_id, groupId: r.group_id }, idempotencyKey: `relocation:${id}`, actorType: by === 'system' ? 'system' : 'operator', createdBy: by, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, label: `서버 이전 (${subject ? subject.name : r.subject_id})`, runAt }); + deps.store.run('UPDATE relocations SET status=\'queued\', job_id=?, updated_at=? WHERE id=?', job.id, nowIso(), id); + return { relocation: deps.store.get('SELECT * FROM relocations WHERE id=?', id), job }; +} +/** 사용자 취소(전환 전까지 §21.2) */ +export async function cancel(deps, id, by) { + const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); + if (['awaiting_approval', 'queued'].includes(r.status)) { if (r.job_id) deps.jobs.cancel(r.job_id, by); deps.store.run('UPDATE relocations SET status=\'cancelled\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), id); } + else if (r.status === 'running') { const job = deps.jobs.get(r.job_id); const switched = job.steps.find((s) => s.id === 'switch' && s.state === 'done'); if (switched) throw new HttpError(409, 'relocation_switched', '연결 전환이 끝나 취소할 수 없습니다. 되돌리기 기간 안에 되돌리기를 사용하세요'); deps.store.run('UPDATE relocations SET status=\'cancelling\', updated_at=? WHERE id=?', nowIso(), id); deps.jobs.cancel(r.job_id, by); if (r.hal_job_id) { try { await halOf(deps).cancelJob(r.hal_job_id); } catch (_) {} } if (['awaiting_external', 'queued', 'paused'].includes(job.status)) deps.store.run('UPDATE relocations SET status=\'cancelled\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), id); } + else throw new HttpError(409, 'relocation_not_cancellable', `지금 상태(${r.status})에서는 취소할 수 없습니다`); + audit(deps.store, { actorType: 'user', actorId: by, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.cancel', detail: { relocationId: id } }); + return deps.store.get('SELECT * FROM relocations WHERE id=?', id); +} +/** 화면용 상세 */ +export function view(deps, r) { const job = r.job_id ? deps.jobs.get(r.job_id) : null; return { ...r, boundaries: parseJson(r.boundaries_json, {}), precheck: parseJson(r.precheck_json, null), notices: parseJson(r.notices_json, []), stepTitles: STEP_TITLES, job }; } +export function registerJobs(deps) { + deps.jobs.register('site.relocate', { title: '서버 이전', maxAttempts: 3, retryable: (e) => !!e.retryable, + steps: [{ id: 'precheck', title: '조건 확인(시작)' }, { id: 'submit', title: '새 위치 준비·사전 복사 요청' }, { id: 'wait_copy', title: '데이터 복사·확인(전환 전)' }, { id: 'precheck_switch', title: '전환 직전 조건 재확인' }, { id: 'switch', title: '연결 전환' }, { id: 'wait_done', title: '외부 확인·이전 위치 정리' }, { id: 'finalize', title: '완료 처리' }], + async run(job, ctx) { + const r = deps.store.get('SELECT * FROM relocations WHERE id=?', job.subject.relocationId); const subject = subjectOf(deps, r.subject_type, r.subject_id); const b = parseJson(r.boundaries_json, {}); + const set = (patch) => { const keys = Object.keys(patch); deps.store.run(`UPDATE relocations SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...Object.values(patch), nowIso(), r.id); }; + if (r.status === 'cancelled' || r.status === 'cancelling') { set({ status: 'cancelled', ended_at: nowIso() }); return { cancelled: true }; } + set({ status: 'running' }); + const snap = await ctx.step('precheck', async () => { const s = await snapshot(deps, subject, r.to_server_id); const bad = []; if (s.toStatus !== 'ready' || !s.toAcceptsNew) bad.push('도착 서버가 신규 배치를 받을 수 없습니다'); if (s.fromStatus === 'offline') bad.push('출발 서버가 응답하지 않습니다'); if (!s.tempOk) bad.push('도착 서버의 임시 공간이 부족합니다'); if (s.conflicts) bad.push('다른 백업·복원·이전이 진행 중입니다'); if (bad.length) throw new JobError('relocation_precheck_failed', bad.join(' / ')); set({ precheck_json: toJson(s) }); return s; }); + if (subject.type === 'mail') { const mail = await optional('../mail/service.mjs'); if (mail && mail.setMigrating) await ctx.step('mail_freeze', () => mail.setMigrating(deps, subject.id, true, { relocationId: r.id }), '메일 이전 상태 표시(두 서버 동시 처리 금지)'); } + const { halJobId } = await ctx.step('submit', async () => { try { const h = await halOf(deps).createJob({ kind: 'site.relocate', target: r.from_server_id, payload: { siteId: subject.id, subjectType: subject.type, fromServerId: r.from_server_id, toServerId: r.to_server_id, kind: runtimeOf(subject.site || {}) === 'static' && subject.type === 'site' ? 'static' : 'dynamic', writeFreeze: !!b.writeFreeze, pauseBeforeSwitch: true, domains: deps.store.all('SELECT name FROM domains WHERE site_id=?', subject.id).map((d) => d.name), primaryDomain: subject.site?.primary_domain || null }, idempotencyKey: `hal:${job.id}:relocate`, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id }); set({ hal_job_id: h.halJobId }); return { halJobId: h.halJobId }; } catch (e) { throw asTransient(e); } }); + const copy = await ctx.step('wait_copy', async () => { let h; try { h = await halOf(deps).getJob(halJobId); } catch (e) { throw asTransient(e); } if (h.status === 'done') return { alreadyDone: true, result: h.result }; if (['failed', 'cancelled'].includes(h.status)) throw new JobError(h.errorCode || 'relocation_copy_failed', h.error || '복사 단계가 실패했습니다 — 이전 위치가 그대로 운영됩니다'); const sw = (h.steps || []).find((s) => s.id === 'switch'); if (h.status === 'awaiting_external' || (sw && sw.state === 'waiting')) return { gate: true }; ctx.checkCancel(); ctx.retryLater(5000, `복사 진행 중 (${h.status})`); }); + if (!copy.alreadyDone) { + await ctx.step('precheck_switch', async () => { const again = await snapshot(deps, subject, r.to_server_id); const diff = diffSnapshots(snap, again); if (diff.length) { try { await halOf(deps).cancelJob(halJobId); } catch (_) {} set({ status: 'paused' }); notify(deps.store, { groupId: r.group_id, category: 'relocation', severity: 'warning', mandatory: true, title: '서버 이전을 전환 직전에 자동 중지했습니다', body: `조건이 달라졌습니다: ${diff.join(', ')}. 이전 위치가 계속 운영되며 운영자가 확인합니다`, link: `/sites/${r.subject_id}/relocation`, incidentKey: `relocation:${r.id}:stopped` }); notify(deps.store, { groupId: null, category: 'relocation', severity: 'warning', title: `[운영] 이전 자동 중지 — ${r.id}`, body: diff.join(', '), link: `/ops/relocation/${r.id}` }); throw new JobError('relocation_conditions_changed', `전환 직전 조건 변경: ${diff.join(', ')}`); } return { ok: true }; }); + await ctx.step('switch', async () => { ctx.checkCancel(); try { await halOf(deps).resumeJob(halJobId); } catch (e) { throw asTransient(e); } return { resumedAt: nowIso() }; }); + } + const res = await ctx.step('wait_done', async () => { let h; try { h = await halOf(deps).getJob(halJobId); } catch (e) { throw asTransient(e); } if (h.status === 'done') { if (h.result && h.result.externalCheck && h.result.externalCheck.ok === false) throw new JobError('relocation_final_check_failed', '최종 외부 확인 실패 — 새 위치를 운영 위치로 확정하지 않았습니다'); return h.result || {}; } if (['failed', 'cancelled'].includes(h.status)) throw new JobError(h.errorCode || 'relocation_failed', h.error || '이전이 실패했습니다'); ctx.retryLater(5000, `전환·정리 진행 중 (${h.status})`); }); + await ctx.step('finalize', async () => { const now = nowIso(); const keep = b.oldRetentionDays != null ? addMs(now, b.oldRetentionDays * DAY) : null; if (subject.type === 'site') deps.store.run('UPDATE sites SET server_id=?, updated_at=? WHERE id=?', r.to_server_id, now, subject.id); else { deps.store.run('UPDATE mail_services SET arimail_server_id=?, migrating=0, updated_at=? WHERE id=?', r.to_server_id, now, subject.id); const mail = await optional('../mail/service.mjs'); if (mail && mail.setMigrating) await mail.setMigrating(deps, subject.id, false, { relocationId: r.id }); } set({ status: 'done', ended_at: now, old_retained_until: keep }); audit(deps.store, { actorType: 'system', groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.complete', detail: { relocationId: r.id, from: r.from_server_id, to: r.to_server_id } }); notify(deps.store, { groupId: r.group_id, category: 'relocation', title: `${subject.name}: 서버 이전이 완료되었습니다`, body: `새 위치가 유일한 운영 위치입니다. ${b.revertHours != null ? `되돌리기 가능 기간 ${b.revertHours}시간` : ''}${keep ? ` · 이전 위치 보존 종료 ${keep}` : ''}`, link: `/sites/${r.subject_id}/relocation`, incidentKey: `relocation:${r.id}` }); return { switchedAt: res.switchedAt || now }; }); + return { newServerId: r.to_server_id }; + }, + async revert(job, ctx) { + const r = deps.store.get('SELECT * FROM relocations WHERE id=?', job.subject.relocationId); const b = parseJson(r.boundaries_json, {}); const subject = subjectOf(deps, r.subject_type, r.subject_id); + if (r.status === 'done' && b.revertHours != null && Date.parse(r.ended_at) + b.revertHours * HOUR < Date.now()) throw new JobError('revert_window_passed', '되돌릴 수 있는 기간이 지났습니다'); + deps.store.run('UPDATE relocations SET status=\'reverting\', updated_at=? WHERE id=?', nowIso(), r.id); + const h = await ctx.step('revert.submit', async () => { try { return await halOf(deps).createJob({ kind: 'site.relocate', target: r.to_server_id, payload: { siteId: subject.id, subjectType: subject.type, fromServerId: r.to_server_id, toServerId: r.from_server_id, kind: 'static', revertOf: r.id }, idempotencyKey: `hal:${job.id}:revert` }); } catch (e) { throw asTransient(e); } }, '이전 위치로 되돌리기 요청'); + await ctx.step('revert.wait', async () => { let j; try { j = await halOf(deps).getJob(h.halJobId); } catch (e) { throw asTransient(e); } if (j.status === 'done') return j.result; if (['failed', 'cancelled'].includes(j.status)) throw new JobError('revert_failed', j.error || '되돌리기 실패'); ctx.retryLater(5000, '되돌리는 중'); }, '되돌리기 진행'); + if (subject.type === 'site') deps.store.run('UPDATE sites SET server_id=?, updated_at=? WHERE id=?', r.from_server_id, nowIso(), subject.id); else deps.store.run('UPDATE mail_services SET arimail_server_id=?, updated_at=? WHERE id=?', r.from_server_id, nowIso(), subject.id); + deps.store.run('UPDATE relocations SET status=\'reverted\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), r.id); + audit(deps.store, { actorType: 'user', actorId: job.subject.by || null, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.revert', detail: { relocationId: r.id } }); + } }); + deps.jobs.onChange((job) => { if (job.kind !== 'site.relocate') return; const r = deps.store.get('SELECT * FROM relocations WHERE id=?', job.subject.relocationId); if (!r) return; const map = { failed: 'failed', cancelled: 'cancelled', paused: 'paused', needs_user: 'needs_user', reverting: 'reverting', reverted: 'reverted' }; if (map[job.status] && !['done', 'reverted'].includes(r.status) && r.status !== map[job.status]) { deps.store.run('UPDATE relocations SET status=?, updated_at=?, ended_at=CASE WHEN ? IN (\'failed\',\'cancelled\',\'reverted\') THEN ? ELSE ended_at END WHERE id=?', map[job.status], nowIso(), map[job.status], nowIso(), r.id); if (job.status === 'failed') notify(deps.store, { groupId: r.group_id, category: 'relocation', severity: 'critical', mandatory: true, title: '서버 이전이 실패했습니다', body: `${job.error || ''} — 이전 위치가 계속 운영됩니다 (작업 번호 ${job.id})`, link: `/sites/${r.subject_id}/relocation`, incidentKey: `relocation:${r.id}:failed` }); } }); +} diff --git a/portal/transfer/api.mjs b/portal/transfer/api.mjs new file mode 100644 index 0000000..34c92a2 --- /dev/null +++ b/portal/transfer/api.mjs @@ -0,0 +1,39 @@ +/** + * portal/transfer/api.mjs — 소유권 이전 화면·API(§22)와 운영자 내부 API. + * GET /transfers · /transfers/new · /transfers/:id + * GET /api/transfer 목록(보낸·받은) POST /api/transfer { toEmail|toGroupId, siteIds[], mailServiceIds[], confirm:true } + * POST /api/transfer/preview 범위 미리보기 GET /api/transfer/:id + * POST /api/transfer/:id/accept {token?} · /decline {reason} · /cancel · /dispute {note} · /recheck + * 운영자: GET /internal/ops/transfer · GET /internal/ops/transfer/:id · POST /internal/ops/transfer/:id/approve {operator, review:{ownershipProof, domainChecks}, confirm:{fromGroupId,toGroupId}} · /reject · /resolve-dispute {outcome} + */ +import { HttpError, esc } from '../../lib/http.mjs'; +import { requireGroup, requireRole, requireUser } from '../auth/guard.mjs'; +import * as svc from './service.mjs'; +import { opsGuard, operatorOf, requireConfirm } from '../lifecycle/common.mjs'; + +export async function register(app, 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/transfer', new URL('./public', import.meta.url).pathname); + app.get('/transfers', (ctx) => { requireGroup(ctx); return shell(ctx, { title: '소유권 이전', nav: 'group', crumbs: [{ label: t('nav.group'), href: '/group' }, { label: '소유권 이전' }], actions: [{ label: '새 이전 요청', href: '/transfers/new', kind: 'primary' }], body: `
${deps.statePanel('loading')}
`, scripts: ['/ui/transfer/transfer.js'] }); }); + app.get('/transfers/new', (ctx) => { requireRole(ctx, 'owner'); return shell(ctx, { title: '새 소유권 이전 요청', nav: 'group', crumbs: [{ label: '소유권 이전', href: '/transfers' }, { label: '새 요청' }], body: `
${deps.statePanel('loading')}
`, scripts: ['/ui/transfer/transfer.js'] }); }); + app.get('/transfers/:id', (ctx) => { requireUser(ctx); return shell(ctx, { title: '소유권 이전 상세', nav: 'group', crumbs: [{ label: '소유권 이전', href: '/transfers' }, { label: ctx.params.id }], body: `
${deps.statePanel('loading')}
`, scripts: ['/ui/transfer/transfer.js'] }); }); + const visible = (ctx, id) => { requireUser(ctx); const tr = svc.get(deps, id); if (!tr) throw new HttpError(404, 'not_found', t('error.notFound')); const gid = ctx.group ? ctx.group.id : null; const byEmail = tr.to_email && ctx.user.email && ctx.user.email.toLowerCase() === tr.to_email; if (tr.from_group_id !== gid && tr.to_group_id !== gid && !byEmail && !ctx.query.token) throw new HttpError(404, 'not_found', t('error.notFound')); return tr; }; + app.get('/api/transfer', (ctx) => { requireGroup(ctx); const gid = ctx.group.id; const rows = deps.store.all('SELECT * FROM ownership_transfers WHERE from_group_id=? OR to_group_id=? OR to_email=? ORDER BY created_at DESC LIMIT 100', gid, gid, (ctx.user.email || '').toLowerCase()); return { ok: true, transfers: rows.map((r) => svc.view(deps, svc.get(deps, r.id), gid)), excluded: svc.EXCLUDED }; }); + app.get('/api/transfer/candidates', (ctx) => { requireRole(ctx, 'owner'); const gid = ctx.group.id; return { ok: true, sites: deps.store.all('SELECT id, name, lifecycle, primary_domain FROM sites WHERE group_id=? AND deleted_at IS NULL ORDER BY name', gid), mailServices: deps.store.all('SELECT id, name, domain, status FROM mail_services WHERE group_id=? AND kind=\'standalone\' AND deleted_at IS NULL ORDER BY name', gid), excluded: svc.EXCLUDED }; }); + app.post('/api/transfer/preview', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); const scope = svc.scopeOf(deps, ctx.group.id, { siteIds: b.siteIds || [], mailServiceIds: b.mailServiceIds || [] }); return { ok: true, scope }; }); + app.post('/api/transfer', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); const r = svc.request(deps, { fromGroup: ctx.group, by: ctx.user.id, toEmail: b.toEmail || null, toGroupId: b.toGroupId || null, siteIds: b.siteIds || [], mailServiceIds: b.mailServiceIds || [] }); return { ok: true, transfer: svc.view(deps, r.transfer, ctx.group.id) }; }); + app.get('/api/transfer/:id', (ctx) => { const tr = visible(ctx, ctx.params.id); return { ok: true, transfer: svc.view(deps, tr, ctx.group ? ctx.group.id : null) }; }); + app.post('/api/transfer/:id/accept', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); const tr = svc.accept(deps, ctx.params.id, { user: ctx.user, group: ctx.group, token: b.token || null }); return { ok: true, transfer: svc.view(deps, tr, ctx.group.id) }; }); + app.post('/api/transfer/:id/recheck', (ctx) => { requireRole(ctx, 'owner'); const tr = svc.get(deps, ctx.params.id); if (!tr || tr.to_group_id !== ctx.group.id) throw new HttpError(404, 'not_found', t('error.notFound')); return { ok: true, transfer: svc.view(deps, svc.recheck(deps, tr.id), ctx.group.id) }; }); + app.post('/api/transfer/:id/decline', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); const tr = visible(ctx, ctx.params.id); if (tr.from_group_id === ctx.group.id) throw new HttpError(403, 'forbidden', '보내는 쪽은 취소를 사용하세요'); return { ok: true, transfer: svc.view(deps, svc.decline(deps, tr.id, { by: ctx.user.id, groupId: ctx.group.id, reason: b.reason }), ctx.group.id) }; }); + app.post('/api/transfer/:id/cancel', (ctx) => { requireRole(ctx, 'owner'); return { ok: true, transfer: svc.view(deps, svc.cancel(deps, ctx.params.id, { by: ctx.user.id, groupId: ctx.group.id }), ctx.group.id) }; }); + app.post('/api/transfer/:id/dispute', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); return { ok: true, transfer: svc.view(deps, svc.dispute(deps, ctx.params.id, { by: ctx.user.id, groupId: ctx.group.id, note: b.note, email: ctx.user.email }), ctx.group.id) }; }); + const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); }; + app.get('/internal/ops/transfer', ops((ctx) => ({ ok: true, transfers: deps.store.all(`SELECT id FROM ownership_transfers ${ctx.query.status ? 'WHERE status=?' : ''} ORDER BY created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => svc.view(deps, svc.get(deps, r.id), null)) }))); + app.get('/internal/ops/transfer/:id', ops((ctx) => { const tr = svc.get(deps, ctx.params.id); if (!tr) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); return { ok: true, transfer: svc.view(deps, tr, null), finalConfirmation: { fromGroupId: tr.from_group_id, toGroupId: tr.to_group_id, siteIds: tr.scope.siteIds, mailServiceIds: tr.scope.mailServiceIds }, domains: (tr.scope.siteIds || []).flatMap((s) => deps.store.all('SELECT name, ownership_state, dns_state, cert_state FROM domains WHERE site_id=?', s)) }; })); + app.post('/internal/ops/transfer/:id/approve', ops(async (ctx) => { const b = await ctx.body(); const out = svc.approve(deps, ctx.params.id, { by: operatorOf(b), review: b.review || {}, confirm: b.confirm || {} }); return { ok: true, transfer: out.transfer, created: out.created }; })); + app.post('/internal/ops/transfer/:id/reject', ops(async (ctx) => { const b = await ctx.body(); return { ok: true, transfer: svc.reject(deps, ctx.params.id, { by: operatorOf(b), reason: b.reason }) }; })); + app.post('/internal/ops/transfer/:id/resolve-dispute', ops(async (ctx) => { const b = await ctx.body(); return { ok: true, transfer: svc.resolveDispute(deps, ctx.params.id, { by: operatorOf(b), outcome: b.outcome === 'resume' ? 'resume' : 'cancel', note: b.note }) }; })); + app.post('/internal/ops/transfer/run-due', ops(() => ({ ok: true, expired: svc.runDue(deps) }))); +} diff --git a/portal/transfer/model.mjs b/portal/transfer/model.mjs new file mode 100644 index 0000000..9c14b9e --- /dev/null +++ b/portal/transfer/model.mjs @@ -0,0 +1,13 @@ +/** + * portal/transfer/model.mjs — 소유권 이전 영역 추가 열. + * ownership_transfers.hold_json 받는 쪽 권리 부족 내역(필요 상품 안내 §22) + * ownership_transfers.completion_json 완료 시 옮긴 대상 요약(감사·화면) + * ownership_transfers.cancelled_by / declined_reason + */ +export const MIGRATIONS = [{ id: 'transfer_0001', sql: ` +ALTER TABLE ownership_transfers ADD COLUMN hold_json TEXT; +ALTER TABLE ownership_transfers ADD COLUMN completion_json TEXT; +ALTER TABLE ownership_transfers ADD COLUMN cancelled_by TEXT; +ALTER TABLE ownership_transfers ADD COLUMN declined_reason TEXT; +ALTER TABLE ownership_transfers ADD COLUMN disputed_at TEXT; +` }]; diff --git a/portal/transfer/public/transfer.js b/portal/transfer/public/transfer.js new file mode 100644 index 0000000..72341a6 --- /dev/null +++ b/portal/transfer/public/transfer.js @@ -0,0 +1,25 @@ +/* portal/transfer/public/transfer.js — 소유권 이전 화면(§22): 목록·새 요청(범위 미리보기)·상세(수락·거절·취소·분쟁) */ +(() => { + const { api, esc, state, toast, confirmDanger, fmt } = window.aw; + const ST = { requested: ['waiting', '수락 대기'], accepted: ['waiting', '권리 확인'], on_hold: ['warning', '보류(권리 부족)'], reviewing: ['waiting', '운영자 심사'], approved: ['running', '승인됨'], completed: ['done', '완료'], rejected: ['failed', '거절(운영자)'], declined: ['failed', '거절(받는 쪽)'], cancelled: ['neutral', '취소'], expired: ['neutral', '만료'], disputed: ['failed', '분쟁 보류'] }; + const badge = (k) => { const [c, l] = ST[k] || ['neutral', k]; return `${esc(l)}`; }; + const scopeHtml = (sc, ex) => !sc ? '' : `

이전 대상

${sc.sites.map((s) => `
${esc(s.name)} ${esc(s.lifecycle)}${s.siteCount === 'in_use' ? ' · 운영 사이트 수 사용 중' : ''}
도메인
${s.domains.map((d) => esc(d.name) + (d.is_primary ? '(대표)' : '')).join(', ') || '없음'}
사이트 대표 메일
${s.siteMail.map((m) => esc(m.domain)).join(', ') || '없음'}
백업
${s.backup ? `옵션 ${esc(s.backup.status)} · 백업 ${s.backup.backups}개` : '없음'}
배정 용량
${s.allocations.map((a) => `${esc(a.resource)} ${a.amount}`).join(', ') || '없음'}
진행 작업
${s.inProgress.length ? s.inProgress.map((c) => esc(c.type + ' ' + c.status)).join(', ') : '없음'}
미납
${s.unpaid.length ? s.unpaid.map((o) => `${esc(o.order_no)} ${o.total}`).join(', ') : '없음'}
`).join('')}${sc.mailServices.map((m) => `
${esc(m.name)} (독립 메일 ${esc(m.domain)}) 메일함 ${m.mailboxes}개 · 배정 ${m.allocations.map((a) => `${esc(a.resource)} ${a.amount}`).join(', ') || '없음'}
`).join('')}

이전하지 않는 항목: ${ex.map(esc).join(', ')}

`; + const list = document.getElementById('tr-list'); + if (list) (async () => { try { const d = await api('GET', '/api/transfer'); if (!d.transfers.length) return state(list, 'empty', '소유권 이전 요청이 없습니다'); list.innerHTML = `${d.transfers.map((x) => ``).join('')}
상태방향상대대상요청만료
${badge(x.status)}${x.role === 'sender' ? '보냄' : '받음'}${esc(x.role === 'sender' ? x.toGroupName : x.fromGroupName)}사이트 ${(x.scope.siteIds || []).length} · 메일 ${(x.scope.mailServiceIds || []).length}${esc(fmt(x.created_at))}${esc(fmt(x.expires_at))}
`; } catch (e) { state(list, e.status === 403 ? 'forbidden' : 'failed', e.message); } })(); + const nw = document.getElementById('tr-new'); + if (nw) (async () => { try { const d = await api('GET', '/api/transfer/candidates'); nw.innerHTML = `

대상 선택

${d.sites.length ? d.sites.map((s) => ``).join('') : '

사이트가 없습니다

'}

독립 메일 서비스(명시적으로 고른 것만 포함)

${d.mailServices.length ? d.mailServices.map((m) => ``).join('') : '

없음

'}
받는 사람이 가입 사용자이면 알림으로, 아니면 이메일로 수락 링크가 전달됩니다. 요청은 7일 뒤 만료되며 운영자 승인 전까지 취소할 수 있습니다

이전하지 않는 항목: ${d.excluded.map(esc).join(', ')}

`; + const sel = () => ({ siteIds: [...nw.querySelectorAll('input[name=site]:checked')].map((i) => i.value), mailServiceIds: [...nw.querySelectorAll('input[name=mail]:checked')].map((i) => i.value) }); + document.getElementById('tr-prev').onclick = async () => { try { const r = await api('POST', '/api/transfer/preview', sel()); document.getElementById('tr-preview').innerHTML = scopeHtml(r.scope, d.excluded) + (r.scope.blocking.length ? `

진행 중 작업이 끝나야 요청할 수 있습니다: ${r.scope.blocking.map((b) => esc(b.subject + ' ' + b.type)).join(', ')}

` : ''); } catch (e) { toast(e.message); } }; + document.getElementById('tr-form').onsubmit = async (ev) => { ev.preventDefault(); const s = sel(); const to = document.getElementById('to').value.trim(); if (!(await confirmDanger({ title: '소유권 이전 요청', target: `${to} 에게 사이트 ${s.siteIds.length}개 · 메일 ${s.mailServiceIds.length}개`, impacts: ['받는 쪽 수락과 운영자 심사 뒤 한 번에 이전됩니다', '완료 시각부터 청구 책임이 넘어갑니다', '이전 뒤 우리 그룹 구성원은 접근할 수 없습니다'], typeToConfirm: '이전', confirmLabel: '요청' }))) return; try { const r = await api('POST', '/api/transfer', { ...s, toEmail: to, confirm: true }); window.aw.markClean(); location.href = `/transfers/${r.transfer.id}`; } catch (e) { toast(e.message); } }; + } catch (e) { state(nw, e.status === 403 ? 'forbidden' : 'failed', e.message); } })(); + const det = document.getElementById('tr-detail'); + if (det) { const id = det.dataset.id; const token = new URLSearchParams(location.search).get('token'); const render = async () => { try { const d = await api('GET', `/api/transfer/${id}${token ? '?token=' + encodeURIComponent(token) : ''}`); const x = d.transfer; + det.innerHTML = `

${badge(x.status)} ${esc(x.fromGroupName)} → ${esc(x.toGroupName)}

요청
${esc(fmt(x.created_at))}
만료
${esc(fmt(x.expires_at))}
취소 가능
${esc(x.cancelDeadline)} (보내는 쪽) · ${esc(x.declineDeadline)} (받는 쪽)
청구
${x.billing.senderEndsAt ? `종료/시작 ${esc(fmt(x.billing.senderEndsAt))} · ` : ''}${esc(x.billing.note)}
${x.hold ? `
보류 사유
${x.hold.map((h) => `${esc(h.label)} ${h.shortage} 부족 — ${esc(h.product)}`).join('
')}
` : ''}${x.status === 'disputed' ? `
분쟁
기존 서비스 유지 · 설정 변경·삭제·추가 이전 동결 · 문의: 운영자 지원 창구 ${esc(x.dispute_note || '')}
` : ''}${x.completion ? `
완료
사이트 ${x.completion.moved.sites} · 도메인 ${x.completion.moved.domains} · 메일 ${x.completion.moved.mailServices} · 배정 ${x.completion.moved.allocations}
` : ''}
+ ${scopeHtml(x.scopeDetail, x.excluded)} +
${x.canAccept ? '' : ''}${x.canRecheck ? '' : ''}${x.canDecline ? '' : ''}${x.canCancel ? '' : ''}${x.canDispute ? '' : ''}
`; + const q = (i) => document.getElementById(i); const act = async (p, body) => { try { await api('POST', `/api/transfer/${id}/${p}`, body); toast('처리했습니다'); render(); } catch (e) { toast(e.message); } }; + if (q('t-acc')) q('t-acc').onclick = async () => { if (await confirmDanger({ title: '소유권 이전 수락', target: '현재 사용자 그룹으로 받습니다', impacts: ['우리 그룹의 상품 권리로 대상을 받습니다(부족하면 보류)', '운영자 심사 뒤 완료 시각부터 청구 책임이 시작됩니다'], confirmLabel: '수락' })) act('accept', { confirm: true, token }); }; + if (q('t-re')) q('t-re').onclick = () => act('recheck'); if (q('t-dec')) q('t-dec').onclick = () => act('decline', { reason: '' }); if (q('t-can')) q('t-can').onclick = async () => { if (await confirmDanger({ title: '요청 취소', target: '이 이전 요청', impacts: ['받는 쪽에 취소가 알려집니다'], confirmLabel: '취소' })) act('cancel'); }; + if (q('t-dis')) q('t-dis').onclick = async () => { if (await confirmDanger({ title: '분쟁 신고', target: '이 이전 요청', impacts: ['기존 서비스는 유지됩니다', '설정 변경·삭제·추가 이전이 동결됩니다', '운영자가 확인할 때까지 보류됩니다'], typeToConfirm: '분쟁', confirmLabel: '신고' })) act('dispute', { confirm: true, note: '' }); }; + } catch (e) { state(det, e.status === 403 ? 'forbidden' : e.status === 404 ? 'empty' : 'failed', e.message); } }; render(); } +})(); diff --git a/portal/transfer/service.mjs b/portal/transfer/service.mjs new file mode 100644 index 0000000..e4d47f0 --- /dev/null +++ b/portal/transfer/service.mjs @@ -0,0 +1,146 @@ +/** + * portal/transfer/service.mjs — 사이트·서비스 소유권 이전(§22) 업무 규칙. + * + * 흐름: 보내는 그룹 요청(request) → 받는 쪽 수락(accept; 권리 부족이면 on_hold) → 운영자 심사(approve/reject: 소유 증빙·도메인 확인) → 완료(complete: 한 트랜잭션) + * 상태: requested → accepted → on_hold | reviewing → approved → completed / rejected / declined / cancelled / expired / disputed + * 이전 대상: 사이트(+웹 도메인·사이트 대표 메일·백업 옵션과 백업·배정 용량·진행 작업·미납 항목 표시), 명시적으로 고른 독립 메일 서비스. + * 제외: AI Wallet, 그룹 공용 미배정 용량, 기존 청구·결제 이력(§22). + * 분쟁(disputed): 대상의 설정 변경·삭제·추가 이전을 동결한다 — 다른 영역은 isFrozen() 으로 확인한다. + * 완료는 전부 옮겨지거나 전혀 옮겨지지 않는다(store.tx). 완료 뒤 이전 그룹 구성원은 사이트별 권한이 지워져 접근할 수 없다. + */ +import { HttpError } from '../../lib/http.mjs'; +import { newId, randomToken, sha256 } from '../../lib/crypto.mjs'; +import { notify, resolveIncident } from '../../lib/notify.mjs'; +import { audit } from '../../lib/audit.mjs'; +import * as quota from '../../lib/quota.mjs'; +import { inProgress, nowIso, parseJson, toJson, addMs, DAY } from '../lifecycle/common.mjs'; + +export const EXPIRY_DAYS = 7; +export const EXCLUDED = ['AI Wallet(잔액·충전 이력)', '사용자 그룹 공용 미배정 용량', '기존 청구·결제 이력']; +const OPEN = ['requested', 'accepted', 'on_hold', 'reviewing', 'approved', 'disputed']; + +/** 이전 범위 계산(화면 표시용·검증용) */ +export function scopeOf(deps, fromGroupId, { siteIds = [], mailServiceIds = [] } = {}) { + const sites = siteIds.map((id) => deps.store.get('SELECT * FROM sites WHERE id=? AND group_id=? AND deleted_at IS NULL', id, fromGroupId)).filter(Boolean); + if (sites.length !== siteIds.length) throw new HttpError(404, 'not_found', '이전할 사이트를 찾을 수 없습니다'); + const standalone = mailServiceIds.map((id) => deps.store.get('SELECT * FROM mail_services WHERE id=? AND group_id=? AND kind=\'standalone\' AND deleted_at IS NULL', id, fromGroupId)).filter(Boolean); + if (standalone.length !== mailServiceIds.length) throw new HttpError(404, 'not_found', '이전할 독립 메일 서비스를 찾을 수 없습니다(독립 메일은 명시적으로 선택한 것만 포함)'); + const items = sites.map((s) => ({ type: 'site', id: s.id, name: s.name, lifecycle: s.lifecycle, siteCount: s.site_count_state, + domains: deps.store.all('SELECT id, name, purpose, is_primary FROM domains WHERE site_id=?', s.id), + siteMail: deps.store.all('SELECT id, name, domain, status FROM mail_services WHERE site_id=? AND deleted_at IS NULL', s.id), + backup: (() => { const o = deps.store.get('SELECT id, status, ends_at FROM backup_options WHERE site_id=?', s.id); return o ? { ...o, backups: deps.store.get('SELECT COUNT(*) AS n FROM backups WHERE site_id=? AND deleted_at IS NULL', s.id).n } : null; })(), + allocations: deps.store.all('SELECT resource, amount, state FROM quota_allocations WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', fromGroupId, s.id), + inProgress: inProgress(deps, 'site', s.id).filter((c) => c.type !== 'transfer'), + unpaid: deps.store.all('SELECT id, order_no, total, status, due_at FROM orders WHERE group_id=? AND target_type=\'site\' AND target_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\')', fromGroupId, s.id) })); + const mails = standalone.map((m) => ({ type: 'mail_service', id: m.id, name: m.name, domain: m.domain, status: m.status, allocations: deps.store.all('SELECT resource, amount, state FROM quota_allocations WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', fromGroupId, m.id), inProgress: inProgress(deps, 'mail_service', m.id).filter((c) => c.type !== 'transfer'), mailboxes: deps.store.get('SELECT COUNT(*) AS n FROM mailboxes WHERE mail_service_id=? AND deleted_at IS NULL', m.id).n })); + // 사이트 대표 메일의 배정도 함께 옮긴다 + const siteMailAlloc = items.flatMap((s) => s.siteMail.flatMap((m) => deps.store.all('SELECT resource, amount, state FROM quota_allocations WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', fromGroupId, m.id))); + const needed = {}; for (const a of [...items.flatMap((s) => s.allocations), ...mails.flatMap((m) => m.allocations), ...siteMailAlloc]) needed[a.resource] = (needed[a.resource] || 0) + a.amount; + return { sites: items, mailServices: mails, excluded: EXCLUDED, needed, siteIds, mailServiceIds, blocking: [...items.flatMap((s) => s.inProgress.map((c) => ({ subject: s.name, ...c }))), ...mails.flatMap((m) => m.inProgress.map((c) => ({ subject: m.name, ...c })))] }; +} +/** 받는 그룹의 권리 부족 계산 */ +export function shortages(deps, toGroupId, needed) { const out = []; for (const [res, amt] of Object.entries(needed)) { const f = quota.figures(deps.store, toGroupId, res); if (amt > f.unallocated) out.push({ resource: res, label: f.label, needed: amt, available: f.unallocated, shortage: amt - f.unallocated, product: `${res} 권리를 ${amt - f.unallocated} 만큼 추가 구매` }); } return out; } +export function get(deps, id) { const t = deps.store.get('SELECT * FROM ownership_transfers WHERE id=?', id); return t ? { ...t, scope: parseJson(t.scope_json, {}), review: parseJson(t.review_json, null), hold: parseJson(t.hold_json, null), completion: parseJson(t.completion_json, null) } : null; } +/** 대상이 분쟁 동결 상태인지(다른 영역이 설정 변경·삭제·추가 이전 전에 부른다) */ +export function isFrozen(deps, subjectType, subjectId) { return deps.store.all('SELECT scope_json FROM ownership_transfers WHERE status=\'disputed\'').some((t) => { const sc = parseJson(t.scope_json, {}); return (subjectType === 'site' ? sc.siteIds : sc.mailServiceIds || []).includes(subjectId); }); } +export function assertNotFrozen(deps, subjectType, subjectId) { if (isFrozen(deps, subjectType, subjectId)) throw new HttpError(423, 'transfer_disputed', '소유권 분쟁으로 동결되어 설정 변경·삭제·추가 이전을 할 수 없습니다. 문의 경로: 알림 센터의 분쟁 안내를 확인하세요'); } +/** 요청(보내는 그룹 §22) */ +export function request(deps, { fromGroup, by, toEmail = null, toGroupId = null, siteIds = [], mailServiceIds = [] }) { + if (!siteIds.length && !mailServiceIds.length) throw new HttpError(400, 'empty_scope', '이전할 사이트 또는 독립 메일 서비스를 하나 이상 고르세요'); + if (toGroupId === fromGroup.id) throw new HttpError(400, 'same_group', '같은 그룹으로는 이전할 수 없습니다'); + if (!toEmail && !toGroupId) throw new HttpError(400, 'receiver_required', '받는 쪽 이메일 또는 그룹을 지정하세요'); + const scope = scopeOf(deps, fromGroup.id, { siteIds, mailServiceIds }); + if (scope.blocking.length) throw new HttpError(409, 'conflict', '진행 중인 배포·복원·이전이 완료 또는 취소되어야 소유권 이전을 시작할 수 있습니다', { blocking: scope.blocking }); + for (const id of [...siteIds, ...mailServiceIds]) { const open = deps.store.all('SELECT id, scope_json, status FROM ownership_transfers WHERE status IN (' + OPEN.map(() => '?').join(',') + ')', ...OPEN).find((t) => { const sc = parseJson(t.scope_json, {}); return [...(sc.siteIds || []), ...(sc.mailServiceIds || [])].includes(id); }); if (open) throw new HttpError(409, open.status === 'disputed' ? 'transfer_disputed' : 'transfer_open', open.status === 'disputed' ? '분쟁 중인 대상은 추가로 이전할 수 없습니다' : '이미 진행 중인 소유권 이전이 있습니다', { transferId: open.id }); } + const id = newId('tr_'); const token = randomToken(24); const now = nowIso(); const expires = addMs(now, EXPIRY_DAYS * DAY); + deps.store.run('INSERT INTO ownership_transfers(id, from_group_id, to_group_id, to_email, scope_json, status, token_hash, requested_by, expires_at, created_at, updated_at) VALUES(?,?,?,?,?,\'requested\',?,?,?,?,?)', id, fromGroup.id, toGroupId, toEmail ? String(toEmail).toLowerCase() : null, toJson({ siteIds, mailServiceIds, needed: scope.needed, summary: { sites: scope.sites.map((s) => ({ id: s.id, name: s.name, domains: s.domains.map((d) => d.name), siteMail: s.siteMail.map((m) => m.domain), backup: !!s.backup })), mailServices: scope.mailServices.map((m) => ({ id: m.id, name: m.name, domain: m.domain })) } }), sha256(token), by, expires, now, now); + audit(deps.store, { actorType: 'user', actorId: by, groupId: fromGroup.id, subjectType: 'transfer', subjectId: id, action: 'ownership.request', detail: { siteIds, mailServiceIds, toGroupId, toEmail } }); + // 받는 쪽 알림(가입 사용자면 그 사용자의 알림으로, 그룹이면 그룹 알림으로). 토큰은 저장하지 않고 이메일 발송기에만 넘긴다. + const toUser = toEmail ? deps.store.get('SELECT id FROM users WHERE email=?', String(toEmail).toLowerCase()) : null; + notify(deps.store, { groupId: toGroupId || null, userId: toUser ? toUser.id : null, category: 'membership', severity: 'info', mandatory: true, title: `${fromGroup.name} 이(가) 소유권 이전을 요청했습니다`, body: `대상: 사이트 ${siteIds.length}개, 독립 메일 ${mailServiceIds.length}개. ${expires} 까지 수락하지 않으면 만료됩니다`, link: `/transfers/${id}`, subjectType: 'transfer', subjectId: id, email: true, detail: { acceptToken: token } }); + notify(deps.store, { groupId: fromGroup.id, category: 'membership', title: '소유권 이전을 요청했습니다', body: `받는 쪽이 수락하고 운영자 심사가 끝나면 완료됩니다. 승인 전까지 취소할 수 있습니다 (만료 ${expires})`, link: `/transfers/${id}`, subjectType: 'transfer', subjectId: id }); + return { transfer: get(deps, id), token }; +} +/** 받는 쪽 수락: 권리 확인 → on_hold 또는 reviewing */ +export function accept(deps, id, { user, group, token = null }) { + const t = get(deps, id); if (!t) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); + if (t.status !== 'requested') throw new HttpError(409, 'transfer_not_acceptable', `지금 상태(${t.status})에서는 수락할 수 없습니다`); + if (Date.parse(t.expires_at) < Date.now()) { expire(deps, t.id); throw new HttpError(409, 'transfer_expired', '요청이 만료되었습니다'); } + const byEmail = t.to_email && user && user.email && user.email.toLowerCase() === t.to_email; const byGroup = t.to_group_id && group && group.id === t.to_group_id; const byToken = token && sha256(token) === t.token_hash; + if (!(byEmail || byGroup || byToken)) throw new HttpError(403, 'forbidden', '이 요청의 받는 쪽이 아닙니다'); + if (!group || group.id === t.from_group_id) throw new HttpError(400, 'receiver_group_required', '받을 사용자 그룹을 선택하세요(보내는 그룹과 달라야 합니다)'); + deps.store.run('UPDATE ownership_transfers SET to_group_id=?, accepted_by=?, status=\'accepted\', updated_at=? WHERE id=?', group.id, user.id, nowIso(), id); + audit(deps.store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'transfer', subjectId: id, action: 'ownership.accept' }); + return recheck(deps, id); +} +/** 받는 쪽 권리 재확인(수락 직후·상품 구매 뒤) */ +export function recheck(deps, id) { + const t = get(deps, id); if (!t || !['accepted', 'on_hold'].includes(t.status)) return t; + const sh = shortages(deps, t.to_group_id, t.scope.needed || {}); + if (sh.length) { deps.store.run('UPDATE ownership_transfers SET status=\'on_hold\', hold_json=?, updated_at=? WHERE id=?', toJson(sh), nowIso(), id); notify(deps.store, { groupId: t.to_group_id, category: 'membership', severity: 'warning', incidentKey: `transfer:${id}:hold`, title: '소유권 이전 보류 — 받을 권리가 부족합니다', body: sh.map((s) => `${s.label}: ${s.shortage} 부족 (${s.product})`).join(' / '), link: `/transfers/${id}`, actionRequired: '필요한 상품을 구매한 뒤 다시 확인하세요' }); notify(deps.store, { groupId: t.from_group_id, category: 'membership', incidentKey: `transfer:${id}:hold:from`, title: '소유권 이전이 보류 중입니다', body: '받는 쪽의 상품 권리가 부족합니다. 받는 쪽이 준비되면 운영자 심사로 넘어갑니다', link: `/transfers/${id}` }); } + else { deps.store.run('UPDATE ownership_transfers SET status=\'reviewing\', hold_json=NULL, updated_at=? WHERE id=?', nowIso(), id); notify(deps.store, { groupId: null, category: 'ops', severity: 'info', incidentKey: `ops:transfer:${id}`, title: `[운영] 소유권 이전 심사 대기 — ${id}`, body: '소유 증빙과 도메인 확인 결과를 심사하세요', link: `/ops/transfer/${id}` }); } + return get(deps, id); +} +/** 운영자 심사 승인 → 즉시 완료(원자적) */ +export function approve(deps, id, { by, review = {}, confirm = {} }) { + const t = get(deps, id); if (!t) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); + if (t.status === 'completed') return { transfer: t, created: false }; + if (t.status !== 'reviewing') throw new HttpError(409, 'transfer_not_approvable', `지금 상태(${t.status})에서는 승인할 수 없습니다`); + if (confirm.fromGroupId !== t.from_group_id || confirm.toGroupId !== t.to_group_id) throw new HttpError(400, 'final_confirmation_mismatch', '최종 확인(보내는 그룹·받는 그룹)이 요청과 다릅니다', { expected: { fromGroupId: t.from_group_id, toGroupId: t.to_group_id } }); + if (!review.ownershipProof || !review.domainChecks) throw new HttpError(400, 'review_incomplete', '소유 증빙과 도메인 확인 결과를 기록해야 승인할 수 있습니다'); + deps.store.run('UPDATE ownership_transfers SET status=\'approved\', reviewed_by=?, review_json=?, updated_at=? WHERE id=?', by, toJson(review), nowIso(), id); + audit(deps.store, { actorType: 'operator', actorId: by, groupId: t.from_group_id, subjectType: 'transfer', subjectId: id, action: 'ownership.approve', detail: { review } }); + return { transfer: complete(deps, id, by), created: true }; +} +/** 완료: 한 트랜잭션에서 모든 대상을 옮긴다. 중간에 실패하면 아무것도 옮겨지지 않는다. */ +export function complete(deps, id, by = 'system') { + const t = get(deps, id); if (!t || t.status !== 'approved') throw new HttpError(409, 'transfer_not_approved', '승인된 요청만 완료할 수 있습니다'); + const { siteIds = [], mailServiceIds = [] } = t.scope; const from = t.from_group_id; const to = t.to_group_id; const now = nowIso(); + return deps.store.tx(() => { + const scope = scopeOf(deps, from, { siteIds, mailServiceIds }); if (scope.blocking.length) throw new HttpError(409, 'conflict', '진행 중인 작업이 있어 완료할 수 없습니다', { blocking: scope.blocking }); + const sh = shortages(deps, to, scope.needed); if (sh.length) throw new HttpError(409, 'quota_insufficient', '받는 그룹의 권리가 부족합니다', { shortages: sh }); + const moved = { sites: 0, domains: 0, mailServices: 0, mailboxes: 0, backups: 0, allocations: 0, permissionsCleared: 0 }; + const siteMailIds = siteIds.length ? deps.store.all(`SELECT id FROM mail_services WHERE site_id IN (${siteIds.map(() => '?').join(',')})`, ...siteIds).map((r) => r.id) : []; + const allMail = [...new Set([...mailServiceIds, ...siteMailIds])]; + for (const sid of siteIds) { + moved.sites += deps.store.run('UPDATE sites SET group_id=?, updated_at=? WHERE id=? AND group_id=?', to, now, sid, from).changes; + moved.domains += deps.store.run('UPDATE domains SET group_id=?, updated_at=? WHERE site_id=? AND group_id=?', to, now, sid, from).changes; + deps.store.run('UPDATE deployments SET group_id=? WHERE site_id=?', to, sid); deps.store.run('UPDATE backup_options SET group_id=?, updated_at=? WHERE site_id=?', to, now, sid); moved.backups += deps.store.run('UPDATE backups SET group_id=? WHERE site_id=?', to, sid).changes; deps.store.run('UPDATE restores SET group_id=? WHERE site_id=?', to, sid); deps.store.run('UPDATE relocations SET group_id=? WHERE subject_type=\'site\' AND subject_id=?', to, sid); + moved.permissionsCleared += deps.store.run('DELETE FROM site_permissions WHERE site_id=?', sid).changes; // 이전 그룹 구성원의 사이트 권한 소멸 + moved.allocations += deps.store.run('UPDATE quota_allocations SET group_id=?, updated_at=? WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', to, now, from, sid).changes; deps.store.run('UPDATE quota_reservations SET group_id=? WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', to, from, sid); deps.store.run('UPDATE quota_usage SET group_id=? WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', to, from, sid); + deps.store.run('UPDATE jobs SET group_id=? WHERE subject_type=\'site\' AND subject_id=? AND group_id=?', to, sid, from); + if (deps._transferTxHook) deps._transferTxHook(sid); // 테스트: 중간 실패 시뮬레이션 + } + for (const mid of allMail) { + moved.mailServices += deps.store.run('UPDATE mail_services SET group_id=?, updated_at=? WHERE id=? AND group_id=?', to, now, mid, from).changes; moved.mailboxes += deps.store.run('UPDATE mailboxes SET group_id=?, updated_at=? WHERE mail_service_id=? AND group_id=?', to, now, mid, from).changes; + deps.store.run('UPDATE domains SET group_id=?, updated_at=? WHERE mail_service_id=? AND group_id=?', to, now, mid, from); + moved.allocations += deps.store.run('UPDATE quota_allocations SET group_id=?, updated_at=? WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', to, now, from, mid).changes; deps.store.run('UPDATE quota_reservations SET group_id=? WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', to, from, mid); deps.store.run('UPDATE quota_usage SET group_id=? WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', to, from, mid); deps.store.run('UPDATE relocations SET group_id=? WHERE subject_type=\'mail_service\' AND subject_id=?', to, mid); + } + deps.store.run('UPDATE ownership_transfers SET status=\'completed\', completed_at=?, billing_cutover_at=?, completion_json=?, updated_at=? WHERE id=?', now, now, toJson({ moved, siteIds, mailServiceIds: allMail }), now, id); + for (const g of [from, to]) audit(deps.store, { actorType: by === 'system' ? 'system' : 'operator', actorId: by, groupId: g, subjectType: 'transfer', subjectId: id, action: 'ownership.complete', detail: { from, to, moved, siteIds, mailServiceIds: allMail, billingCutoverAt: now } }); + notify(deps.store, { groupId: from, category: 'membership', mandatory: true, title: '소유권 이전이 완료되었습니다', body: `사이트 ${moved.sites}개·메일 서비스 ${moved.mailServices}개가 새 소유자에게 넘어갔습니다. 이 시각(${now})부터 청구 책임이 종료되며 다음 청구에 반영됩니다`, link: '/transfers', subjectType: 'transfer', subjectId: id }); + notify(deps.store, { groupId: to, category: 'membership', mandatory: true, title: '소유권을 넘겨받았습니다', body: `사이트 ${moved.sites}개·메일 서비스 ${moved.mailServices}개. 이 시각(${now})부터 청구 책임이 시작되며 다음 청구에 반영됩니다. 구성원 권한은 다시 지정하세요`, link: '/sites', subjectType: 'transfer', subjectId: id }); + return get(deps, id); + }); +} +export function reject(deps, id, { by, reason }) { const t = get(deps, id); if (!t || !['reviewing', 'on_hold', 'accepted'].includes(t.status)) throw new HttpError(409, 'transfer_not_rejectable', '심사 중인 요청만 거절할 수 있습니다'); deps.store.run('UPDATE ownership_transfers SET status=\'rejected\', reviewed_by=?, declined_reason=?, updated_at=? WHERE id=?', by, reason || null, nowIso(), id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: t.from_group_id, subjectType: 'transfer', subjectId: id, action: 'ownership.reject', detail: { reason } }); for (const g of [t.from_group_id, t.to_group_id].filter(Boolean)) notify(deps.store, { groupId: g, category: 'membership', severity: 'warning', title: '소유권 이전이 거절되었습니다', body: reason || '', link: `/transfers/${id}` }); return get(deps, id); } +/** 보내는 쪽 취소(승인 전까지) */ +export function cancel(deps, id, { by, groupId }) { const t = get(deps, id); if (!t || t.from_group_id !== groupId) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); if (!['requested', 'accepted', 'on_hold', 'reviewing'].includes(t.status)) throw new HttpError(409, 'transfer_not_cancellable', `지금 상태(${t.status})에서는 취소할 수 없습니다(운영자 승인 뒤에는 취소 불가)`); deps.store.run('UPDATE ownership_transfers SET status=\'cancelled\', cancelled_by=?, updated_at=? WHERE id=?', by, nowIso(), id); audit(deps.store, { actorType: 'user', actorId: by, groupId, subjectType: 'transfer', subjectId: id, action: 'ownership.cancel' }); if (t.to_group_id) notify(deps.store, { groupId: t.to_group_id, category: 'membership', title: '소유권 이전 요청이 취소되었습니다', link: `/transfers/${id}` }); return get(deps, id); } +/** 받는 쪽 거절(심사 전까지) */ +export function decline(deps, id, { by, groupId, reason }) { const t = get(deps, id); if (!t) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); if (!['requested', 'accepted', 'on_hold'].includes(t.status)) throw new HttpError(409, 'transfer_not_declinable', `지금 상태(${t.status})에서는 거절할 수 없습니다`); deps.store.run('UPDATE ownership_transfers SET status=\'declined\', declined_reason=?, updated_at=? WHERE id=?', reason || null, nowIso(), id); audit(deps.store, { actorType: 'user', actorId: by, groupId, subjectType: 'transfer', subjectId: id, action: 'ownership.decline', detail: { reason } }); notify(deps.store, { groupId: t.from_group_id, category: 'membership', severity: 'warning', title: '받는 쪽이 소유권 이전을 거절했습니다', body: reason || '', link: `/transfers/${id}` }); return get(deps, id); } +/** 분쟁 신고(양쪽 누구나): 기존 서비스 유지, 설정 변경·삭제·추가 이전 동결 */ +export function dispute(deps, id, { by, groupId, note, email = null }) { const t = get(deps, id); if (!t || !([t.from_group_id, t.to_group_id].includes(groupId) || (email && t.to_email === String(email).toLowerCase()))) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); if (['cancelled', 'expired', 'declined', 'rejected'].includes(t.status)) throw new HttpError(409, 'transfer_closed', '종료된 요청에는 분쟁을 걸 수 없습니다'); deps.store.run('UPDATE ownership_transfers SET status=\'disputed\', dispute_note=?, disputed_at=?, updated_at=? WHERE id=?', String(note || '').slice(0, 1000), nowIso(), nowIso(), id); audit(deps.store, { actorType: 'user', actorId: by, groupId, subjectType: 'transfer', subjectId: id, action: 'ownership.dispute', detail: { note } }); const toUser = !t.to_group_id && t.to_email ? deps.store.get('SELECT id FROM users WHERE email=?', t.to_email) : null; for (const g of [t.from_group_id, t.to_group_id || (toUser ? { userId: toUser.id } : null)].filter(Boolean)) notify(deps.store, { ...(typeof g === 'string' ? { groupId: g } : { groupId: null, userId: g.userId }), category: 'membership', severity: 'critical', mandatory: true, incidentKey: `transfer:${id}:dispute:${typeof g === 'string' ? g : g.userId}`, title: '소유권 이전이 분쟁 보류 상태입니다', body: '기존 서비스는 유지되며 설정 변경·삭제·추가 이전은 동결됩니다. 문의: 운영자에게 알림 센터 또는 지원 창구로 연락하세요', link: `/transfers/${id}` }); notify(deps.store, { groupId: null, category: 'ops', severity: 'critical', incidentKey: `ops:transfer:${id}:dispute`, title: `[운영] 소유권 분쟁 — ${id}`, body: note || '', link: `/ops/transfer/${id}` }); return get(deps, id); } +/** 운영자 분쟁 해소: resume(심사 계속) | cancel */ +export function resolveDispute(deps, id, { by, outcome, note }) { const t = get(deps, id); if (!t || t.status !== 'disputed') throw new HttpError(409, 'not_disputed', '분쟁 상태가 아닙니다'); const next = outcome === 'resume' ? (t.completed_at ? 'completed' : t.to_group_id ? 'reviewing' : 'requested') : 'cancelled'; deps.store.run('UPDATE ownership_transfers SET status=?, dispute_note=?, updated_at=? WHERE id=?', next, note || t.dispute_note, nowIso(), id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: t.from_group_id, subjectType: 'transfer', subjectId: id, action: 'ownership.dispute_resolve', detail: { outcome, note } }); for (const g of [t.from_group_id, t.to_group_id].filter(Boolean)) { resolveIncident(deps.store, `transfer:${id}:dispute:${g}`, '해소'); notify(deps.store, { groupId: g, category: 'membership', title: '소유권 분쟁이 해소되었습니다', body: `결과: ${outcome === 'resume' ? '절차 계속' : '이전 취소'} ${note || ''}`, link: `/transfers/${id}` }); } return get(deps, id); } +export function expire(deps, id) { deps.store.run('UPDATE ownership_transfers SET status=\'expired\', updated_at=? WHERE id=? AND status IN (\'requested\',\'accepted\',\'on_hold\')', nowIso(), id); const t = get(deps, id); if (t && t.status === 'expired') notify(deps.store, { groupId: t.from_group_id, category: 'membership', title: '소유권 이전 요청이 만료되었습니다', link: `/transfers/${id}` }); return t; } +/** 주기 작업: 만료 처리 */ +export function runDue(deps, now = nowIso()) { const rows = deps.store.all('SELECT id FROM ownership_transfers WHERE status IN (\'requested\',\'accepted\',\'on_hold\') AND expires_at<=?', now); for (const r of rows) expire(deps, r.id); return rows.map((r) => r.id); } +/** 화면용 상세(보는 쪽에 따라 가능한 행동) */ +export function view(deps, t, groupId) { + const sender = t.from_group_id === groupId; const receiver = t.to_group_id === groupId; + const fromGroup = deps.store.get('SELECT name FROM groups WHERE id=?', t.from_group_id); const toGroup = t.to_group_id ? deps.store.get('SELECT name FROM groups WHERE id=?', t.to_group_id) : null; + let scope = null; try { scope = t.status === 'completed' ? null : scopeOf(deps, t.from_group_id, { siteIds: t.scope.siteIds || [], mailServiceIds: t.scope.mailServiceIds || [] }); } catch (_) { scope = null; } + return { ...t, role: sender ? 'sender' : receiver ? 'receiver' : 'other', fromGroupName: fromGroup ? fromGroup.name : '', toGroupName: toGroup ? toGroup.name : (t.to_email || ''), scopeDetail: scope, excluded: EXCLUDED, + canCancel: sender && ['requested', 'accepted', 'on_hold', 'reviewing'].includes(t.status), cancelDeadline: '운영자 승인 전까지', canAccept: !sender && t.status === 'requested', canDecline: !sender && ['requested', 'accepted', 'on_hold'].includes(t.status), declineDeadline: '운영자 심사 시작 전까지', canDispute: (sender || receiver) && !['cancelled', 'expired', 'declined', 'rejected', 'disputed'].includes(t.status), canRecheck: receiver && t.status === 'on_hold', + billing: t.completed_at ? { senderEndsAt: t.billing_cutover_at, receiverStartsAt: t.billing_cutover_at, note: '다음 청구부터 새 소유관계로 반영됩니다' } : { note: '완료 시각부터 보내는 쪽 청구 책임이 끝나고 받는 쪽이 시작됩니다' } }; +} diff --git a/test/backup.test.mjs b/test/backup.test.mjs new file mode 100644 index 0000000..678bbc5 --- /dev/null +++ b/test/backup.test.mjs @@ -0,0 +1,99 @@ +/** test/backup.test.mjs — 백업 옵션·정기 백업·보존·복원(§19·§20) 회귀 테스트 */ +import { test, eq, ok, rejects, done } from '../lib/test.mjs'; +import { makeDeps, seedGroup, appFor, call } from './helpers.mjs'; +import { createFakeHal } from '../portal/lifecycle/testing/fakehal.mjs'; +import { signHeaders } from '../lib/serviceauth.mjs'; +import * as quota from '../lib/quota.mjs'; +import * as svc from '../portal/backup/service.mjs'; +import { newId } from '../lib/crypto.mjs'; + +const now = () => new Date().toISOString(); +function seedPolicy(deps, over = {}) { deps.store.run('INSERT INTO product_versions(id, product_id, version, name, kind, billing_cycle, price, currency, tax_rate, limits_json, policy_json, effective_from, published, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', 'pv_backup_1', 'backup', 1, '사이트 백업 옵션', 'option', 'monthly', 5000, 'KRW', 0.1, JSON.stringify({ storage_bytes: 10000, extra_storage_options: [{ label: '+10GB' }] }), JSON.stringify({ schedule: { daily: '03:00' }, retention: { daily: 2, weekly: 1, monthly: 1 }, first_backup_within_hours: 24, verify_method: '원격 사본 읽기 + 체크섬', restore_cost: 0, restore_completion: 'user_confirm', protect_point_days: 7, revert_hours: 72, failure_notify_after: 2, expiry_retention_days: 7, ...over }), '2026-01-01T00:00:00Z', 1, now()); } +function seedSite(deps, group, { mode = 'managed', lifecycle = 'live', server = 'srv-a', name = '사이트' } = {}) { const id = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, server_id, primary_domain, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', id, group.id, name, lifecycle, mode, server, 'ex.example.test', now(), now()); return deps.store.get('SELECT * FROM sites WHERE id=?', id); } +async function setup() { const hal = createFakeHal(); const deps = await makeDeps({ hal }); const w = seedGroup(deps); seedPolicy(deps); quota.grantEntitlement(deps.store, { groupId: w.group.id, resource: 'backup', amount: 1 }); deps.vault.set('service:hal', 'ops-secret-example'); const app = await appFor(deps, ['backup'], w); return { deps, hal, w, app }; } +const opsCall = (app, deps, method, path, body) => call(app, method, path, body, Object.fromEntries(Object.entries(signHeaders('hal', 'ops-secret-example', method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]))); + +await test('옵션 구매: 관리형 사이트만, 외부 배포 거부, 사이트당 하나, 권리 없으면 상품 안내', async () => { + const { deps, w, app } = await setup(); const managed = seedSite(deps, w.group); const ext = seedSite(deps, w.group, { mode: 'external' }); + let r = await call(app, 'POST', `/api/backup/sites/${ext.id}/option`, { confirm: true }); eq(r.status, 409); ok(r.json.message.includes('외부 배포')); + r = await call(app, 'POST', `/api/backup/sites/${managed.id}/option`, {}); eq(r.json.error, 'confirmation_required'); + r = await call(app, 'POST', `/api/backup/sites/${managed.id}/option`, { confirm: true }); eq(r.status, 200); eq(r.json.option.status, 'active'); ok(r.json.option.first_backup_due_at); + r = await call(app, 'POST', `/api/backup/sites/${managed.id}/option`, { confirm: true }); eq(r.status, 409, '사이트당 하나'); + const second = seedSite(deps, w.group); r = await call(app, 'POST', `/api/backup/sites/${second.id}/option`, { confirm: true }); eq(r.json.error, 'quota_insufficient'); eq(r.json.detail.link, '/billing/products?product=backup'); + const v = (await call(app, 'GET', `/api/backup/sites/${ext.id}`)).json; eq(v.eligibility.ok, false); eq(v.purchase.eligible, false); +}); +await test('정책 미확정이면 구매 불가하고 미확정 항목을 보인다', async () => { + const hal = createFakeHal(); const deps = await makeDeps({ hal }); const w = seedGroup(deps); seedPolicy(deps, { verify_method: null }); quota.grantEntitlement(deps.store, { groupId: w.group.id, resource: 'backup', amount: 1 }); const app = await appFor(deps, ['backup'], w); const s = seedSite(deps, w.group); + const v = (await call(app, 'GET', `/api/backup/sites/${s.id}`)).json; eq(v.purchase.deferred, ['policy.verify_method']); eq(v.purchase.eligible, false); +}); +await test('정기 백업: 저장 서버는 운영 서버와 다름, 검증·보관 기록, 등급 보존 정리와 최소 세트 보호, 실패 알림·재시도', async () => { + const { deps, hal, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true }); + eq(svc.runDue(deps).length, 1); await deps.jobs.drain(); let j = deps.jobs.list({ kind: 'backup.scheduled' })[0]; eq(j.status, 'done', j.error); + const create = hal.calls.find((c) => c.kind === 'backup.create'); eq(create.payload.storeServerId, 'srv-b'); ok(create.payload.storeServerId !== site.server_id); + let b = deps.store.all('SELECT * FROM backups WHERE site_id=?', site.id); eq(b.length, 1); eq(b[0].verify_state, 'verified'); eq(b[0].tier, 'monthly'); + const o = svc.optionOf(deps, site.id); ok(o.last_success_at); ok(Date.parse(o.next_run_at) > Date.now()); + // 같은 슬롯 재실행은 멱등 + deps.store.run('UPDATE backup_options SET next_run_at=? WHERE id=?', '2000-01-01T00:00:00Z', o.id); svc.runDue(deps); svc.runDue(deps); await deps.jobs.drain(); eq(deps.store.all('SELECT * FROM backups WHERE site_id=?', site.id).length, 2); + // 보존 정리: daily 2 — daily 4개 중 최신 2개만, 각 등급 최신 1개와 전체 최신 보호 + for (let i = 0; i < 4; i++) deps.store.run('INSERT INTO backups(id, site_id, group_id, option_id, kind, tier, created_at, scope_json, size_bytes, location, status) VALUES(?,?,?,?,\'scheduled\',\'daily\',?,\'{}\',10,?,\'done\')', newId('bk_'), site.id, w.group.id, o.id, `2026-05-0${i + 1}T00:00:00Z`, 'loc' + i); + const del = svc.pruneList(deps, o); eq(del.length, 3, 'daily 5개 중 최신 2개만 유지'); ok(del.every((x) => x.tier === 'daily' && ['loc0', 'loc1', 'loc2'].includes(x.location))); + // 실패 → 재시도 큐 + 알림(2회째부터 고객 경고), 최종 실패 → critical + 운영자 + hal.unreachable = true; + deps.store.run('UPDATE backup_options SET next_run_at=? WHERE id=?', '2001-01-01T00:00:00Z', o.id); svc.runDue(deps); await deps.jobs.drain(); j = deps.jobs.list({ kind: 'backup.scheduled' })[0]; eq(j.status, 'queued'); eq(j.attempt, 1); + for (let i = 0; i < 2; i++) { deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', '2000-01-01T00:00:00Z', j.id); await deps.jobs.drain(); } + j = deps.jobs.get(j.id); eq(j.status, 'failed'); eq(svc.optionOf(deps, site.id).consecutive_failures, 1); + const n = deps.store.all('SELECT * FROM notifications WHERE incident_key=?', `backup:${o.id}:failed`); eq(n.length, 1); eq(n[0].severity, 'critical'); ok(n[0].body.includes('운영자')); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=?', `ops:backup:${o.id}:failed`)); + // 운영자 재시도 → 성공 → 사건 해결 + hal.unreachable = false; const r = await opsCall(app, deps, 'POST', `/internal/ops/backup/options/${o.id}/retry`, { operator: 'ops-1' }); eq(r.status, 200); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done'); eq(deps.store.get('SELECT state FROM notifications WHERE incident_key=?', `backup:${o.id}:failed`).state, 'resolved'); + // 서비스 키 없으면 401 + eq((await call(app, 'POST', `/internal/ops/backup/options/${o.id}/retry`, {})).status, 401); +}); +await test('저장 공간 부족: 최소 세트는 지우지 않고 다음 백업을 중지하며 안내', async () => { + const { deps, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true }); const o = svc.optionOf(deps, site.id); + deps.store.run('INSERT INTO backups(id, site_id, group_id, option_id, kind, tier, created_at, scope_json, size_bytes, location, status) VALUES(?,?,?,?,\'scheduled\',\'daily\',?,\'{}\',9999,\'l\',\'done\')', newId('bk_'), site.id, w.group.id, o.id, now()); + svc.runDue(deps); await deps.jobs.drain(); eq(deps.jobs.list({ kind: 'backup.scheduled' })[0].error_code, 'backup_storage_full'); const o2 = svc.optionOf(deps, site.id); eq(o2.status, 'paused'); eq(o2.paused_reason, 'storage_full'); + eq(deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL', site.id).length, 1, '최소 세트 유지'); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `backup:${o.id}:storage_full`)); + eq((await call(app, 'POST', `/api/backup/sites/${site.id}/option/resume`)).json.error, 'backup_storage_full'); +}); +await test('사이트 중지 → 정기 백업 중지, 재개 방식 표시, 옵션 종료 → 7일 보관 후 삭제·권리 반환', async () => { + const { deps, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true }); + deps.store.run('UPDATE sites SET lifecycle=\'paused\' WHERE id=?', site.id); eq(svc.runDue(deps).length, 0); eq(svc.optionOf(deps, site.id).paused_reason, 'site_paused'); + deps.store.run('UPDATE sites SET lifecycle=\'live\' WHERE id=?', site.id); eq(svc.resumeForSite(deps, site.id).status, 'active', 'auto 재개'); + svc.runDue(deps); await deps.jobs.drain(); eq(deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL', site.id).length, 1); + const r = await call(app, 'POST', `/api/backup/sites/${site.id}/option/end`, { confirm: true }); eq(r.json.option.status, 'ended'); ok(r.json.option.delete_after); eq(quota.figures(deps.store, w.group.id, 'backup').unallocated, 1, '권리 반환'); + ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `backup:${r.json.option.id}:ended`)); + deps.store.run('UPDATE backup_options SET delete_after=? WHERE id=?', '2000-01-01T00:00:00Z', r.json.option.id); svc.runDue(deps); await deps.jobs.drain(); eq(deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL', site.id).length, 0); +}); +await test('복원: 요청 정보·중복 방지·운영자 최종 확인·승인 멱등·9단계·보호 시점·사용자 확인·되돌리기·실패 처리', async () => { + const { deps, hal, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true }); svc.runDue(deps); await deps.jobs.drain(); + const b = deps.store.get('SELECT * FROM backups WHERE site_id=?', site.id); + const info = (await call(app, 'GET', `/api/backup/backups/${b.id}`)).json.restoreInfo; eq(info.canRequest, true); eq(info.completionMode, 'user_confirm'); eq(info.protectPointDays, 7); eq(info.revertHours, 72); + let r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); eq(r.status, 200); eq(r.json.restore.status, 'awaiting_approval'); eq(r.json.finalConfirmation.site, site.name); const rid = r.json.restore.id; + r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); eq(r.json.created, false, '중복 요청 없음'); + // 잘못된 최종 확인 → 거부 + r = await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid}/approve`, { operator: 'ops', confirm: { groupId: 'wrong', siteId: site.id, backupAt: b.created_at } }); eq(r.json.error, 'final_confirmation_mismatch'); + const ok1 = await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid}/approve`, { operator: 'ops', confirm: { groupId: w.group.id, siteId: site.id, backupAt: b.created_at } }); eq(ok1.status, 200); eq(ok1.json.created, true); + const ok2 = await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid}/approve`, { operator: 'ops', confirm: { groupId: w.group.id, siteId: site.id, backupAt: b.created_at } }); eq(ok2.json.created, false, '승인 멱등'); eq(ok2.json.job.id, ok1.json.job.id); + await deps.jobs.drain(); let j = deps.jobs.get(ok1.json.job.id); eq(j.status, 'needs_user'); eq(j.steps.map((s) => s.id).slice(0, 9), ['received', 'approval', 'protect', 'isolated', 'restore', 'function', 'external', 'confirm', 'cleanup']); eq(j.actions.map((a) => a.id), ['confirm', 'revert']); + let rs = deps.store.get('SELECT * FROM restores WHERE id=?', rid); eq(rs.status, 'needs_user'); ok(rs.protect_point_id); ok(rs.revert_until); + // 사용자 확인 → 완료 + r = await call(app, 'POST', `/api/backup/restores/${rid}/decision`, { action: 'confirm' }); eq(r.status, 200); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done'); eq(deps.store.get('SELECT status FROM restores WHERE id=?', rid).status, 'done'); eq(deps.store.get('SELECT current_version FROM sites WHERE id=?', site.id).current_version, 'v-restored'); + ok(deps.store.get('SELECT immutable FROM audit_log WHERE action=\'restore.complete\'').immutable === 1); + // 두 번째 복원: 되돌리기 + r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const rid2 = r.json.restore.id; const a2 = await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid2}/approve`, { operator: 'ops', confirm: { groupId: w.group.id, siteId: site.id, backupAt: b.created_at } }); await deps.jobs.drain(); + r = await call(app, 'POST', `/api/backup/restores/${rid2}/decision`, { action: 'revert' }); eq(r.json.error, 'confirmation_required'); r = await call(app, 'POST', `/api/backup/restores/${rid2}/decision`, { action: 'revert', confirm: true }); eq(r.json.reverted, true); eq(deps.store.get('SELECT status FROM restores WHERE id=?', rid2).status, 'reverted'); + ok(hal.calls.some((c) => c.kind === 'backup.restore' && c.payload.location === deps.store.get('SELECT protect_point_id FROM restores WHERE id=?', rid2).protect_point_id), '보호 시점으로 되돌림'); + // 격리 확인 실패 → 복원 시작 안 함, 운영 상태 유지, 고객·운영자 알림 + hal.behave['backup.verify'] = () => ({ ok: false }); r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const rid3 = r.json.restore.id; await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid3}/approve`, { operator: 'ops', confirm: { groupId: w.group.id, siteId: site.id, backupAt: b.created_at } }); await deps.jobs.drain(); for (let i = 0; i < 2; i++) { const jj = deps.store.get('SELECT id FROM restores WHERE id=?', rid3); const job = deps.jobs.get(deps.store.get('SELECT job_id FROM restores WHERE id=?', rid3).job_id); if (job.status === 'queued') { deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', '2000-01-01T00:00:00Z', job.id); await deps.jobs.drain(); } } + rs = deps.store.get('SELECT * FROM restores WHERE id=?', rid3); eq(rs.status, 'failed'); ok(!hal.calls.some((c) => c.kind === 'backup.restore' && c.idempotencyKey.includes(rs.job_id) && c.payload.location === b.location), '복원 호출 안 함'); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `restore:${rid3}:failed`)); + // 승인 전 취소 가능, 승인 뒤 불가 + delete hal.behave['backup.verify']; r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const rid4 = r.json.restore.id; eq((await call(app, 'POST', `/api/backup/restores/${rid4}/cancel`)).status, 200); eq((await call(app, 'POST', `/api/backup/restores/${rid2}/cancel`)).status, 409); +}); +await test('HAL 연결 불가: 작업은 일시 오류로 재시도 큐에 남고 실패로 숨기지 않는다', async () => { + const { deps, hal, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true }); hal.unreachable = true; svc.runDue(deps); await deps.jobs.drain(); const j = deps.jobs.list({ kind: 'backup.scheduled' })[0]; eq(j.status, 'queued'); ok(j.next_run_at > new Date().toISOString()); +}); +await test('화면: 백업 목록·사이트 탭·복원 화면이 열린다(권한 없음은 404/403)', async () => { + const { deps, w, app } = await setup(); const site = seedSite(deps, w.group); let r = await call(app, 'GET', '/backup'); eq(r.status, 200); ok(r.text.includes('bk-list')); r = await call(app, 'GET', `/sites/${site.id}/backup`); eq(r.status, 200); ok(r.text.includes('bk-site')); + const other = seedGroup(deps, { email: 'o2@example.test' }); const app2 = await appFor(deps, ['backup'], other); eq((await call(app2, 'GET', `/sites/${site.id}/backup`)).status, 404, '다른 그룹은 존재를 알 수 없음'); +}); +done(); diff --git a/test/lifecycle.test.mjs b/test/lifecycle.test.mjs new file mode 100644 index 0000000..3d22d22 --- /dev/null +++ b/test/lifecycle.test.mjs @@ -0,0 +1,80 @@ +/** test/lifecycle.test.mjs — 중지·해지·보존·삭제 생명주기(§23)와 운영 사이트 수 반환(§14) 회귀 테스트 */ +import { test, eq, ok, done } from '../lib/test.mjs'; +import { makeDeps, seedGroup, appFor, call } from './helpers.mjs'; +import { createFakeHal } from '../portal/lifecycle/testing/fakehal.mjs'; +import { signHeaders } from '../lib/serviceauth.mjs'; +import * as quota from '../lib/quota.mjs'; +import * as svc from '../portal/lifecycle/service.mjs'; +import * as backup from '../portal/backup/service.mjs'; +import { newId } from '../lib/crypto.mjs'; + +const now = () => new Date().toISOString(); const DAY = 86400000; +function seedSite(deps, group, { lifecycle = 'live', mail = true, name = '사이트' } = {}) { const id = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, server_id, primary_domain, site_count_state, created_at, updated_at) VALUES(?,?,?,?,\'managed\',\'srv-a\',?,\'in_use\',?,?)', id, group.id, name, lifecycle, name + '.example.test', now(), now()); deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, is_primary, created_at, updated_at) VALUES(?,?,?,\'web\',?,1,?,?)', newId('d_'), group.id, name + '.example.test', id, now(), now()); quota.setAllocation(deps.store, { groupId: group.id, resource: 'sites', subjectType: 'site', subjectId: id, amount: 1 }); if (mail) { deps.store.run('INSERT INTO mail_services(id, group_id, kind, site_id, name, domain, status, created_at, updated_at) VALUES(?,?,\'site_bound\',?,?,?,\'active\',?,?)', 'ms_' + id, group.id, id, name + ' 메일', name + '.example.test', now(), now()); } return deps.store.get('SELECT * FROM sites WHERE id=?', id); } +async function setup() { const hal = createFakeHal(); const deps = await makeDeps({ hal }); const w = seedGroup(deps); deps.vault.set('service:hal', 'ops-secret-example'); quota.grantEntitlement(deps.store, { groupId: w.group.id, resource: 'sites', amount: 2 }); const app = await appFor(deps, ['backup', 'transfer', 'relocation', 'lifecycle'], w); return { deps, hal, w, app }; } +const opsCall = (app, method, path, body) => call(app, method, path, body, Object.fromEntries(Object.entries(signHeaders('hal', 'ops-secret-example', method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]))); +const site = (deps, id) => deps.store.get('SELECT * FROM sites WHERE id=?', id); + +await test('§23.3 한 화면 보기와 영향 안내(중지·해지), 기본 보존 기간', async () => { + const { deps, w, app } = await setup(); const s = seedSite(deps, w.group); const d = (await call(app, 'GET', `/api/lifecycle/sites/${s.id}`)).json; + eq(d.view.site.label, '운영 중'); eq(d.view.siteCountConsumed, true); eq(d.view.publicAccess, '제공 중'); eq(d.view.retentionPolicy.site_days, 30); eq(d.view.retentionPolicy.backup_days, 7); ok(d.pauseImpact.usageAndCost.includes('계속')); ok(d.cancelImpact.pointOfNoReturn); eq(d.cancelImpact.mailOptions.keep.available, false, '독립 메일 권리 없음'); eq(d.siteCount.steps.length, 8); eq(d.siteCount.steps[0].ok, false, '공개 중'); + eq((await call(app, 'GET', `/sites/${s.id}/lifecycle`)).status, 200); +}); +await test('일시 중지: 공개 중지 작업·백업 중지·사이트 수 유지 → 재개', async () => { + const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group); deps.store.run('INSERT INTO backup_options(id, site_id, group_id, status, schedule_json, retention_json, created_at, updated_at) VALUES(\'bo1\',?,?,\'active\',\'{}\',\'{}\',?,?)', s.id, w.group.id, now(), now()); + let r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, {}); eq(r.json.error, 'confirmation_required'); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, { confirm: true }); eq(r.status, 200); eq(r.json.site.lifecycle, 'paused'); await deps.jobs.drain(); ok(hal.calls.some((c) => c.kind === 'domain.vhost' && c.payload.maintenance === true)); eq(deps.jobs.get(r.json.job.id).status, 'done'); + eq(backup.optionOf(deps, s.id).status, 'paused'); eq(quota.figures(deps.store, w.group.id, 'sites').allocated, 1, '사이트 수 계속 소비'); const v = svc.lifecycleView(deps, site(deps, s.id)); eq(v.resumeAvailable, true); eq(v.publicAccess, '중지됨'); + eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, { confirm: true })).status, 409); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/resume`); eq(r.json.site.lifecycle, 'live'); eq(backup.optionOf(deps, s.id).status, 'active'); eq(deps.store.all('SELECT * FROM lifecycle_events WHERE subject_id=?', s.id).length, 2); +}); +await test('해지: 선택·확인 필수, 메일 유지는 권리 필요, 예약 → 되돌리기 → 즉시 해지 → 읽기 전용 보존 → 사이트 수 자동 반환 → 삭제 대기 → 삭제', async () => { + const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group); + let r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true }); eq(r.json.error, 'acknowledge_required'); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true }); eq(r.json.error, 'mail_choice_required'); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true, mailChoice: 'keep' }); eq(r.json.error, 'quota_insufficient'); eq(r.json.detail.resource, 'mail_services'); + const later = new Date(Date.now() + DAY).toISOString(); r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true, mailChoice: 'end', at: later }); eq(r.status, 200); eq(r.json.site.lifecycle, 'cancel_scheduled'); eq(r.json.executed, false); eq(r.json.view.resumeAvailable, true); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `lifecycle:${s.id}:cancel`)); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/undo-cancel`); eq(r.json.site.lifecycle, 'live'); eq(r.json.site.cancel_at, null); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true, mailChoice: 'end' }); eq(r.json.executed, true); let st = site(deps, s.id); eq(st.lifecycle, 'readonly_retention'); ok(st.retention_until); ok(st.delete_after); eq(deps.store.get('SELECT routed FROM domains WHERE site_id=?', s.id).routed, 0); + eq(deps.store.get('SELECT lifecycle, status FROM mail_services WHERE site_id=?', s.id).lifecycle, 'readonly_retention', '대표 메일 종료(로컬 fallback)'); + eq(st.site_count_state, 'none', '조건 충족 → 자동 반환'); eq(quota.figures(deps.store, w.group.id, 'sites').allocated, 0); ok(deps.store.get('SELECT 1 AS x FROM audit_log WHERE action=\'site_count.return\' AND immutable=1')); + eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/undo-cancel`)).status, 409, '되돌릴 수 없는 시점 뒤'); eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/resume`)).status, 409); + const v = svc.lifecycleView(deps, site(deps, s.id)); eq(v.adminAccess, '읽기 전용(내보내기 가능)'); eq(v.exportAvailable, true); eq(v.resumeAvailable, false); eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/export`)).json.requested, true); + // 시간 진행: 사전 알림 → 삭제 대기 → 삭제 + deps.store.run('UPDATE sites SET next_notice_at=? WHERE id=?', '2000-01-01T00:00:00Z', s.id); await svc.runDue(deps); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `lifecycle:${s.id}:notice`)); + deps.store.run('UPDATE sites SET retention_until=? WHERE id=?', '2000-01-01T00:00:00Z', s.id); await svc.runDue(deps); eq(site(deps, s.id).lifecycle, 'pending_delete'); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND severity=\'critical\'', `lifecycle:${s.id}:delete`)); + // 삭제 보류 → 삭제 작업이 멈춤 → 해제 → 삭제 + await opsCall(app, 'POST', `/internal/ops/lifecycle/sites/${s.id}/hold`, { operator: 'ops', reason: '법적 보존' }); eq(site(deps, s.id).lifecycle, 'delete_hold'); eq(svc.lifecycleView(deps, site(deps, s.id)).deleteHold.contact, '운영자 지원 창구'); + deps.store.run('UPDATE sites SET delete_after=? WHERE id=?', '2000-01-01T00:00:00Z', s.id); await svc.runDue(deps); await deps.jobs.drain(); eq(site(deps, s.id).lifecycle, 'delete_hold', '보류 중에는 삭제 안 됨'); + await opsCall(app, 'POST', `/internal/ops/lifecycle/sites/${s.id}/release-hold`, { operator: 'ops' }); eq(site(deps, s.id).lifecycle, 'pending_delete'); await svc.runDue(deps); await deps.jobs.drain(); st = site(deps, s.id); eq(st.lifecycle, 'deleted'); ok(st.deleted_at); ok(hal.calls.some((c) => c.kind === 'site.remove' && c.payload.keepData === false)); + const vd = svc.lifecycleView(deps, st); eq(vd.exportAvailable, false); ok(vd.pointOfNoReturn.includes('복구할 수 없습니다')); eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/export`)).status, 409); +}); +await test('§14 반환 체크리스트: 공개 중·도메인 연결·진행 작업·메일 미선택이면 거부, 메일 유지 권리 부족 거부, 종료 선택 후 반환 성공', async () => { + const { deps, w, app } = await setup(); const s = seedSite(deps, w.group); + let r = await call(app, 'GET', `/api/lifecycle/sites/${s.id}/site-count-return`); const ck = r.json.checklist; eq(ck.ok, false); ok(ck.blocking.includes('public_stopped')); ok(ck.blocking.includes('domains_unrouted')); ok(ck.blocking.includes('mail_choice')); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'end', acknowledge: true }); eq(r.json.error, 'confirmation_required'); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'end', acknowledge: true, confirm: true }); eq(r.json.error, 'site_public'); + await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, { confirm: true }); await deps.jobs.drain(); + deps.store.run('INSERT INTO restores(id, site_id, group_id, backup_id, status, requested_by, created_at, updated_at) VALUES(\'rs1\',?,?,\'b\',\'running\',\'u\',?,?)', s.id, w.group.id, now(), now()); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'end', acknowledge: true, confirm: true }); eq(r.json.error, 'site_count_return_blocked'); ok(r.json.detail.checklist.blocking.includes('no_jobs')); + deps.store.run('UPDATE restores SET status=\'done\' WHERE id=\'rs1\''); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'keep', acknowledge: true, confirm: true }); eq(r.json.error, 'quota_insufficient', '메일 유지 권리 부족이면 반환하지 않음'); eq(quota.figures(deps.store, w.group.id, 'sites').allocated, 1); + r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'end', acknowledge: true, confirm: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.returned, true); eq(r.json.figures.unallocated, 2); eq(site(deps, s.id).site_count_state, 'none'); eq(deps.store.get('SELECT routed FROM domains WHERE site_id=?', s.id).routed, 0); eq(deps.store.get('SELECT lifecycle FROM mail_services WHERE site_id=?', s.id).lifecycle, 'readonly_retention'); + // 마지막 사이트 수를 두 사이트가 동시에 요구 → 하나만(lib/quota) + const s2 = seedSite(deps, w.group, { mail: false, name: 'b' }); const s3 = { id: newId('s_') }; deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,\'c\',\'draft\',?,?)', s3.id, w.group.id, now(), now()); + quota.reserve(deps.store, { groupId: w.group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s3.id }); let threw = null; try { quota.reserve(deps.store, { groupId: w.group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: 'another' }); } catch (e) { threw = e; } eq(threw && threw.code, 'quota_insufficient'); +}); +await test('메일 서비스 해지: 송수신 중지·30일 보존·삭제 예정, 사전 알림 → 삭제 대기 → 삭제(배정 반환)', async () => { + const { deps, w, app } = await setup(); deps.store.run('INSERT INTO mail_services(id, group_id, kind, name, domain, status, created_at, updated_at) VALUES(\'ms_std\',?,\'standalone\',\'독립\',\'std.example.test\',\'active\',?,?)', w.group.id, now(), now()); quota.grantEntitlement(deps.store, { groupId: w.group.id, resource: 'mailboxes', amount: 5 }); quota.setAllocation(deps.store, { groupId: w.group.id, resource: 'mailboxes', subjectType: 'mail_service', subjectId: 'ms_std', amount: 3 }); + eq((await call(app, 'GET', '/mail/ms_std/lifecycle')).status, 200); let r = await call(app, 'GET', '/api/lifecycle/mail/ms_std'); eq(r.json.view.sendReceive, '송수신 가능'); + r = await call(app, 'POST', '/api/lifecycle/mail/ms_std/cancel', { confirm: true }); eq(r.json.error, 'acknowledge_required'); + r = await call(app, 'POST', '/api/lifecycle/mail/ms_std/cancel', { confirm: true, acknowledge: true }); eq(r.status, 200); eq(r.json.view.sendReceive, '송수신 중지(읽기·내보내기만)'); ok(r.json.view.retentionUntil); ok(r.json.view.backupNote.includes('제공하지 않습니다')); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key IN (\'lifecycle:mail:ms_std\',\'mail:ms_std:cancel\') AND mandatory=1')); + deps.store.run('UPDATE mail_services SET next_notice_at=? WHERE id=\'ms_std\'', '2000-01-01T00:00:00Z'); await svc.runDue(deps); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=\'lifecycle:mail:ms_std:notice\'')); + deps.store.run('UPDATE mail_services SET retention_until=? WHERE id=\'ms_std\'', '2000-01-01T00:00:00Z'); await svc.runDue(deps); eq(deps.store.get('SELECT lifecycle FROM mail_services WHERE id=\'ms_std\'').lifecycle, 'pending_delete'); eq(quota.figures(deps.store, w.group.id, 'mailboxes').allocated, 3, '삭제 전까지 소비'); + deps.store.run('UPDATE mail_services SET delete_after=? WHERE id=\'ms_std\'', '2000-01-01T00:00:00Z'); await svc.runDue(deps); eq(deps.store.get('SELECT lifecycle FROM mail_services WHERE id=\'ms_std\'').lifecycle, 'deleted'); eq(quota.figures(deps.store, w.group.id, 'mailboxes').allocated, 0); + const r2 = await opsCall(app, 'GET', '/internal/ops/lifecycle'); eq(r2.status, 200); ok(r2.json.mail.some((m) => m.id === 'ms_std')); +}); +await test('분쟁 동결 중에는 중지·해지·반환이 거부된다', async () => { + const { deps, w, app } = await setup(); const s = seedSite(deps, w.group, { mail: false }); deps.store.run('INSERT INTO ownership_transfers(id, from_group_id, to_email, scope_json, status, expires_at, created_at, updated_at) VALUES(\'tr1\',?,\'x@example.test\',?,\'disputed\',?,?,?)', w.group.id, JSON.stringify({ siteIds: [s.id] }), now(), now(), now()); + eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, { confirm: true })).json.error, 'transfer_disputed'); eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true })).json.error, 'transfer_disputed'); eq((await call(app, 'GET', `/api/lifecycle/sites/${s.id}`)).json.view.frozen, true); +}); +done(); diff --git a/test/relocation.test.mjs b/test/relocation.test.mjs new file mode 100644 index 0000000..b58e5a6 --- /dev/null +++ b/test/relocation.test.mjs @@ -0,0 +1,66 @@ +/** test/relocation.test.mjs — 서버 이전(§21) 회귀 테스트 */ +import { test, eq, ok, done } from '../lib/test.mjs'; +import { makeDeps, seedGroup, appFor, call } from './helpers.mjs'; +import { createFakeHal } from '../portal/lifecycle/testing/fakehal.mjs'; +import { signHeaders } from '../lib/serviceauth.mjs'; +import * as svc from '../portal/relocation/service.mjs'; +import { newId } from '../lib/crypto.mjs'; + +const now = () => new Date().toISOString(); +function seedPolicy(deps) { deps.store.run('INSERT INTO product_versions(id, product_id, version, name, kind, billing_cycle, price, currency, tax_rate, limits_json, policy_json, effective_from, published, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', 'pv_reloc', 'relocation', 1, '서버 이전', 'service', 'once', 30000, 'KRW', 0.1, '{}', JSON.stringify({ target_downtime_min: 10, revert_hours: 48, old_retention_days: 7, customer_cost: 30000 }), '2026-01-01T00:00:00Z', 1, now()); } +function seedSite(deps, group, { runtime = 'static', mode = 'managed', server = 'srv-a' } = {}) { const id = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, server_id, primary_domain, settings_json, created_at, updated_at) VALUES(?,?,?,\'live\',?,?,\'ex.example.test\',?,?,?)', id, group.id, '사이트', mode, server, JSON.stringify({ runtime, diskBytes: 1000 }), now(), now()); deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, is_primary, cert_state, dns_state, created_at, updated_at) VALUES(?,?,?,\'web\',?,1,\'valid\',\'ok\',?,?)', newId('d_'), group.id, 'ex' + id.slice(-6) + '.example.test', id, now(), now()); return deps.store.get('SELECT * FROM sites WHERE id=?', id); } +async function setup() { const hal = createFakeHal(); hal.behave['site.relocate'] = () => ({ __pending: true, status: 'running' }); const deps = await makeDeps({ hal }); const w = seedGroup(deps); seedPolicy(deps); deps.vault.set('service:hal', 'ops-secret-example'); const app = await appFor(deps, ['relocation'], w); return { deps, hal, w, app }; } +const opsCall = (app, method, path, body) => call(app, method, path, body, Object.fromEntries(Object.entries(signHeaders('hal', 'ops-secret-example', method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]))); +async function runUntilSettled(deps, jobId, max = 10) { for (let i = 0; i < max; i++) { const j = deps.jobs.get(jobId); if (!['queued', 'running'].includes(j.status)) return j; if (j.status === 'queued') deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', '2000-01-01T00:00:00Z', jobId); await deps.jobs.drain(); } return deps.jobs.get(jobId); } + +await test('경계값·자동 이전 조건·외부 배포 거부', async () => { + const { deps, w, app } = await setup(); const s = seedSite(deps, w.group); const d = (await call(app, 'GET', `/api/relocation/sites/${s.id}`)).json; + eq(d.auto.ok, true); eq(d.boundaries.customer.cost, 30000); eq(d.boundaries.platform.cost, 0); eq(d.boundaries.customer.revertHours, 48); eq(d.boundaries.customer.writeFreeze, null); ok(d.boundaries.customer.lastCancelLabel); + const dyn = seedSite(deps, w.group, { runtime: 'node' }); const d2 = (await call(app, 'GET', `/api/relocation/sites/${dyn.id}`)).json; eq(d2.auto.ok, false); ok(d2.boundaries.customer.writeFreeze); + const ext = seedSite(deps, w.group, { mode: 'external' }); eq((await call(app, 'POST', `/api/relocation/sites/${ext.id}`, { confirm: true })).json.error, 'relocation_not_managed'); +}); +await test('자동(정적) 이전: 승인 없이 시작, 전환 전 멈춤 → 재확인 → 전환 → 완료, 서버는 전환 뒤에만 바뀜, 과금 없음', async () => { + const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group); + const r = await opsCall(app, 'POST', '/internal/ops/relocation', { subjectId: s.id, toServerId: 'srv-c', reason: '장애 회피', operator: 'ops' }); eq(r.status, 200); eq(r.json.relocation.kind, 'auto_static'); eq(r.json.relocation.charged, 0); ok(r.json.relocation.job_id); + const jid = r.json.relocation.job_id; await deps.jobs.drain(); let j = deps.jobs.get(jid); eq(j.status, 'queued', '복사 대기(retryLater)'); eq(j.steps.find((x) => x.id === 'wait_copy').state, 'pending'); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a', '전환 전 서버 유지'); + hal.reachGate(deps.store.get('SELECT hal_job_id FROM relocations WHERE id=?', r.json.relocation.id).hal_job_id); j = await runUntilSettled(deps, jid); eq(j.status, 'done', j.error); eq(j.steps.map((x) => x.state).every((x) => x === 'done'), true); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-c'); + const rel = deps.store.get('SELECT * FROM relocations WHERE id=?', r.json.relocation.id); eq(rel.status, 'done'); ok(rel.old_retained_until); ok(hal.calls.some((c) => c.kind === 'site.relocate' && c.payload.pauseBeforeSwitch === true)); + ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND title LIKE \'%완료%\'', `relocation:${rel.id}`)); ok(deps.store.get('SELECT immutable FROM audit_log WHERE action=\'relocate.complete\'').immutable === 1); +}); +await test('전환 직전 조건이 달라지면 자동 중지하고 이전 위치 유지(두 서버 동시 운영 없음)', async () => { + const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group); + const r = await opsCall(app, 'POST', '/internal/ops/relocation', { subjectId: s.id, toServerId: 'srv-c', reason: '용량 조정', operator: 'ops' }); const jid = r.json.relocation.job_id; await deps.jobs.drain(); + hal.servers.find((x) => x.id === 'srv-c').acceptsNew = false; // 조건 변경 + hal.reachGate(deps.store.get('SELECT hal_job_id FROM relocations WHERE id=?', r.json.relocation.id).hal_job_id); + const j = await runUntilSettled(deps, jid); eq(j.status, 'failed'); eq(j.error_code, 'relocation_conditions_changed'); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a'); eq(hal.jobs.get(deps.store.get('SELECT hal_job_id FROM relocations WHERE id=?', r.json.relocation.id).hal_job_id).status, 'cancelled'); + ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `relocation:${r.json.relocation.id}:stopped`)); eq(deps.store.get('SELECT status FROM relocations WHERE id=?', r.json.relocation.id).status, 'failed'); +}); +await test('승인형(동적): 고객 요청은 유료·승인 대기, 최종 확인 불일치 거부, 결제 확인 전 승인 거부, 승인 멱등, 취소·되돌리기', async () => { + const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group, { runtime: 'node' }); + let r = await call(app, 'POST', `/api/relocation/sites/${s.id}`, { confirm: true, reason: '성능' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.relocation.status, 'awaiting_approval'); eq(r.json.relocation.charged, 1); eq(r.json.relocation.cost, 30000); const id = r.json.relocation.id; + eq((await call(app, 'POST', `/api/relocation/sites/${s.id}`, { confirm: true })).json.created, false, '중복 요청 없음'); + r = await opsCall(app, 'POST', `/internal/ops/relocation/${id}/approve`, { operator: 'ops', confirm: { subjectId: s.id, toServerId: 'nope' } }); eq(r.json.error, 'final_confirmation_mismatch'); + r = await opsCall(app, 'POST', `/internal/ops/relocation/${id}/approve`, { operator: 'ops', confirm: { subjectId: s.id, toServerId: 'srv-c' } }); eq(r.json.error, 'relocation_unpaid'); + r = await opsCall(app, 'POST', `/internal/ops/relocation/${id}/approve`, { operator: 'ops', scheduledAt: '2000-01-01T00:00:00Z', confirm: { subjectId: s.id, toServerId: 'srv-c', paymentConfirmed: true } }); eq(r.status, 200); eq(r.json.created, true); + eq((await opsCall(app, 'POST', `/internal/ops/relocation/${id}/approve`, { operator: 'ops', confirm: { subjectId: s.id, toServerId: 'srv-c', paymentConfirmed: true } })).json.created, false, '승인 멱등'); + const jid = r.json.job.id; await deps.jobs.drain(); // 복사 대기 + // 전환 전 취소 가능 + r = await call(app, 'POST', `/api/relocation/${id}/cancel`); eq(r.status, 200); eq(r.json.relocation.status, 'cancelled'); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a'); + // 새 요청 → 완료 → 되돌리기 + delete hal.behave['site.relocate']; hal.gateRelocate = true; + r = await call(app, 'POST', `/api/relocation/sites/${s.id}`, { confirm: true }); const id2 = r.json.relocation.id; r = await opsCall(app, 'POST', `/internal/ops/relocation/${id2}/approve`, { operator: 'ops', confirm: { subjectId: s.id, toServerId: 'srv-c', paymentConfirmed: true } }); const j2 = await runUntilSettled(deps, r.json.job.id); eq(j2.status, 'done', j2.error); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-c'); + eq((await call(app, 'POST', `/api/relocation/${id2}/cancel`)).status, 409, '전환 뒤 취소 불가'); + hal.gateRelocate = false; r = await call(app, 'POST', `/api/relocation/${id2}/revert`, { confirm: true }); eq(r.status, 200); const rj = await runUntilSettled(deps, r.json.job.id); eq(rj.status, 'reverted', rj.error); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a'); eq(deps.store.get('SELECT status FROM relocations WHERE id=?', id2).status, 'reverted'); +}); +await test('최종 외부 확인 실패면 새 위치를 확정하지 않고 실패·알림', async () => { + const { deps, hal, w, app } = await setup(); hal.behave['site.relocate'] = (req) => ({ newServerId: req.payload.toServerId, externalCheck: { ok: false } }); const s = seedSite(deps, w.group); + const r = await opsCall(app, 'POST', '/internal/ops/relocation', { subjectId: s.id, toServerId: 'srv-c', reason: 'x', operator: 'ops' }); const j = await runUntilSettled(deps, r.json.relocation.job_id); eq(j.status, 'failed'); eq(j.error_code, 'relocation_final_check_failed'); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a'); +}); +await test('진행 중인 작업이 있으면 요청 거부, 메일 이전은 메일 영역 없이는 needs_setup', async () => { + const { deps, w, app } = await setup(); const s = seedSite(deps, w.group); deps.store.run('INSERT INTO restores(id, site_id, group_id, backup_id, status, requested_by, created_at, updated_at) VALUES(?,?,?,?,\'running\',?,?,?)', newId('rs_'), s.id, w.group.id, 'b', w.user.id, now(), now()); + eq((await call(app, 'POST', `/api/relocation/sites/${s.id}`, { confirm: true })).json.error, 'conflict'); + deps.store.run('INSERT INTO mail_services(id, group_id, kind, name, domain, arimail_server_id, status, created_at, updated_at) VALUES(?,?,\'standalone\',\'m\',\'m.example.test\',\'srv-m1\',\'active\',?,?)', 'ms_1', w.group.id, now(), now()); + const r = await opsCall(app, 'POST', '/internal/ops/relocation', { subjectType: 'mail_service', subjectId: 'ms_1', toServerId: 'srv-m2', operator: 'ops' }); eq(r.json.error, 'needs_setup'); + eq((await call(app, 'GET', `/sites/${s.id}/relocation`)).status, 200); +}); +done(); diff --git a/test/transfer.test.mjs b/test/transfer.test.mjs new file mode 100644 index 0000000..e80ef55 --- /dev/null +++ b/test/transfer.test.mjs @@ -0,0 +1,69 @@ +/** test/transfer.test.mjs — 소유권 이전(§22) 회귀 테스트 */ +import { test, eq, ok, done } from '../lib/test.mjs'; +import { makeDeps, seedGroup, appFor, call } from './helpers.mjs'; +import { createFakeHal } from '../portal/lifecycle/testing/fakehal.mjs'; +import { signHeaders } from '../lib/serviceauth.mjs'; +import * as quota from '../lib/quota.mjs'; +import * as svc from '../portal/transfer/service.mjs'; +import { newId } from '../lib/crypto.mjs'; + +const now = () => new Date().toISOString(); +function seedSite(deps, group, name = '사이트') { const id = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, server_id, primary_domain, site_count_state, created_at, updated_at) VALUES(?,?,?,\'live\',\'managed\',\'srv-a\',?,\'in_use\',?,?)', id, group.id, name, name + '.example.test', now(), now()); deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, is_primary, created_at, updated_at) VALUES(?,?,?,\'web\',?,1,?,?)', newId('d_'), group.id, name + '.example.test', id, now(), now()); deps.store.run('INSERT INTO mail_services(id, group_id, kind, site_id, name, domain, status, created_at, updated_at) VALUES(?,?,\'site_bound\',?,?,?,\'active\',?,?)', 'ms_' + id, group.id, id, name + ' 메일', name + '.example.test', now(), now()); deps.store.run('INSERT INTO mailboxes(id, mail_service_id, group_id, address, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('mb_'), 'ms_' + id, group.id, 'admin@' + name + '.example.test', now(), now()); deps.store.run('INSERT INTO site_permissions(site_id, user_id, perms_json, updated_at) VALUES(?,?,?,?)', id, 'u_other', '["view"]', now()); return deps.store.get('SELECT * FROM sites WHERE id=?', id); } +async function setup() { const hal = createFakeHal(); const deps = await makeDeps({ hal }); const a = seedGroup(deps, { email: 'a@example.test', name: 'A그룹' }); const b = seedGroup(deps, { email: 'b@example.test', name: 'B그룹' }); deps.vault.set('service:hal', 'ops-secret-example'); for (const g of [a, b]) { quota.grantEntitlement(deps.store, { groupId: g.group.id, resource: 'sites', amount: 1 }); quota.grantEntitlement(deps.store, { groupId: g.group.id, resource: 'web_disk', amount: 1000 }); } const appA = await appFor(deps, ['transfer'], a); const appB = await appFor(deps, ['transfer'], b); return { deps, a, b, appA, appB }; } +const opsCall = (app, method, path, body) => call(app, method, path, body, Object.fromEntries(Object.entries(signHeaders('hal', 'ops-secret-example', method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]))); +function alloc(deps, g, site) { quota.setAllocation(deps.store, { groupId: g.id, resource: 'sites', subjectType: 'site', subjectId: site.id, amount: 1 }); quota.setAllocation(deps.store, { groupId: g.id, resource: 'web_disk', subjectType: 'site', subjectId: site.id, amount: 500 }); } + +await test('범위: 사이트·도메인·대표 메일·백업·배정·진행 작업·미납 표시, 제외 항목, 독립 메일은 명시 선택만', async () => { + const { deps, a, appA } = await setup(); const s = seedSite(deps, a.group, 'alpha'); alloc(deps, a.group, s); + const r = await call(appA, 'POST', '/api/transfer/preview', { siteIds: [s.id] }); eq(r.status, 200); const sc = r.json.scope; eq(sc.sites[0].domains.length, 1); eq(sc.sites[0].siteMail.length, 1); eq(sc.needed, { sites: 1, web_disk: 500 }); eq(sc.excluded.length, 3); + deps.store.run('INSERT INTO mail_services(id, group_id, kind, name, domain, status, created_at, updated_at) VALUES(\'ms_std\',?,\'standalone\',\'독립\',\'std.example.test\',\'active\',?,?)', a.group.id, now(), now()); + eq((await call(appA, 'POST', '/api/transfer/preview', { siteIds: [s.id], mailServiceIds: ['ms_std'] })).json.scope.mailServices.length, 1); + eq((await call(appA, 'POST', '/api/transfer/preview', { siteIds: [s.id], mailServiceIds: ['ms_' + s.id] })).status, 404, '사이트 대표 메일은 독립 메일로 고를 수 없음'); +}); +await test('흐름: 요청 → 수락 → 심사 → 승인 → 원자적 완료, 권한 소멸·청구 시각·감사, 진행 중 작업 차단, 중복 요청 차단', async () => { + const { deps, a, b, appA, appB } = await setup(); const s = seedSite(deps, a.group, 'beta'); alloc(deps, a.group, s); + deps.store.run('INSERT INTO restores(id, site_id, group_id, backup_id, status, requested_by, created_at, updated_at) VALUES(\'rs1\',?,?,\'b\',\'running\',\'u\',?,?)', s.id, a.group.id, now(), now()); + let r = await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true }); eq(r.json.error, 'conflict'); + deps.store.run('UPDATE restores SET status=\'done\' WHERE id=\'rs1\''); + r = await call(appA, 'POST', '/api/transfer', { toEmail: 'B@example.test', siteIds: [s.id], confirm: true }); eq(r.status, 200, JSON.stringify(r.json)); const id = r.json.transfer.id; eq(r.json.transfer.status, 'requested'); eq(r.json.transfer.canCancel, true); + eq((await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.error, 'transfer_open'); + ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE user_id=? AND subject_id=?', b.user.id, id), '받는 사용자 알림'); ok(!JSON.stringify(deps.store.all('SELECT detail_json FROM notifications')).includes('"acceptToken":"'), '토큰 원문은 저장하지 않음'); + // 다른 그룹(무관)은 볼 수 없음 + const c = seedGroup(deps, { email: 'c@example.test' }); const appC = await appFor(deps, ['transfer'], c); eq((await call(appC, 'GET', `/api/transfer/${id}`)).status, 404); + // 받는 쪽: 권리 부족 → 보류 → 구매 뒤 재확인 → 심사 + quota.setAllocation(deps.store, { groupId: b.group.id, resource: 'sites', subjectType: 'site', subjectId: 'other', amount: 1 }); + r = await call(appB, 'POST', `/api/transfer/${id}/accept`, { confirm: true }); eq(r.status, 200); eq(r.json.transfer.status, 'on_hold'); eq(r.json.transfer.hold[0].resource, 'sites'); eq(r.json.transfer.hold[0].shortage, 1); + quota.grantEntitlement(deps.store, { groupId: b.group.id, resource: 'sites', amount: 1 }); r = await call(appB, 'POST', `/api/transfer/${id}/recheck`); eq(r.json.transfer.status, 'reviewing'); + // 운영자: 심사 내용 없으면 거부, 최종 확인 불일치 거부 + r = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: {}, confirm: { fromGroupId: a.group.id, toGroupId: b.group.id } }); eq(r.json.error, 'review_incomplete'); + r = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: a.group.id, toGroupId: 'x' } }); eq(r.json.error, 'final_confirmation_mismatch'); + r = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: a.group.id, toGroupId: b.group.id } }); eq(r.status, 200); eq(r.json.transfer.status, 'completed'); ok(r.json.transfer.billing_cutover_at); + eq(deps.store.get('SELECT group_id FROM sites WHERE id=?', s.id).group_id, b.group.id); eq(deps.store.get('SELECT group_id FROM domains WHERE site_id=?', s.id).group_id, b.group.id); eq(deps.store.get('SELECT group_id FROM mail_services WHERE site_id=?', s.id).group_id, b.group.id); eq(deps.store.get('SELECT group_id FROM mailboxes WHERE mail_service_id=?', 'ms_' + s.id).group_id, b.group.id); + eq(deps.store.all('SELECT * FROM site_permissions WHERE site_id=?', s.id).length, 0, '이전 구성원 권한 소멸'); eq(quota.figures(deps.store, a.group.id, 'sites').allocated, 0); eq(quota.figures(deps.store, b.group.id, 'sites').allocated, 2); eq(quota.figures(deps.store, b.group.id, 'web_disk').allocated, 500); + eq(deps.store.all('SELECT * FROM audit_log WHERE action=\'ownership.complete\' AND immutable=1').length, 2); eq(deps.store.all('SELECT * FROM notifications WHERE subject_id=? AND mandatory=1 AND title LIKE \'%완료%\' OR title LIKE \'%넘겨받%\'', id).length >= 2, true); + eq((await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: a.group.id, toGroupId: b.group.id } })).json.created, false, '승인 멱등'); + // 완료 뒤 보내는 쪽은 사이트를 볼 수 없음(그룹 경계) + const appB2 = await appFor(deps, ['transfer', 'backup'], b); eq((await call(appB2, 'GET', `/api/backup/sites/${s.id}`)).status, 200); const appA2 = await appFor(deps, ['backup'], a); eq((await call(appA2, 'GET', `/api/backup/sites/${s.id}`)).status, 404); +}); +await test('원자성: 완료 도중 실패하면 아무것도 옮겨지지 않는다', async () => { + const { deps, a, b, appA, appB } = await setup(); const s1 = seedSite(deps, a.group, 'g1'); const s2 = seedSite(deps, a.group, 'g2'); quota.grantEntitlement(deps.store, { groupId: a.group.id, resource: 'sites', amount: 1 }); alloc(deps, a.group, s1); quota.setAllocation(deps.store, { groupId: a.group.id, resource: 'sites', subjectType: 'site', subjectId: s2.id, amount: 1 }); quota.grantEntitlement(deps.store, { groupId: b.group.id, resource: 'sites', amount: 2 }); + const id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s1.id, s2.id], confirm: true })).json.transfer.id; await call(appB, 'POST', `/api/transfer/${id}/accept`, { confirm: true }); + let n = 0; deps._transferTxHook = () => { if (++n === 2) throw new Error('simulated failure'); }; + const r = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: a.group.id, toGroupId: b.group.id } }); eq(r.status, 500); + eq(deps.store.get('SELECT group_id FROM sites WHERE id=?', s1.id).group_id, a.group.id, '첫 사이트도 되돌려짐'); eq(deps.store.all('SELECT * FROM site_permissions WHERE site_id=?', s1.id).length, 1); eq(deps.store.get('SELECT status FROM ownership_transfers WHERE id=?', id).status, 'approved', '승인 기록은 남고 완료는 아님'); + delete deps._transferTxHook; const t2 = svc.complete(deps, id, 'ops'); eq(t2.status, 'completed'); eq(deps.store.get('SELECT group_id FROM sites WHERE id=?', s2.id).group_id, b.group.id); +}); +await test('취소·거절·만료·분쟁 동결', async () => { + const { deps, a, b, appA, appB } = await setup(); const s = seedSite(deps, a.group, 'd'); alloc(deps, a.group, s); + let id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.transfer.id; + eq((await call(appB, 'POST', `/api/transfer/${id}/decline`, { reason: '아니오' })).json.transfer.status, 'declined'); + id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.transfer.id; eq((await call(appA, 'POST', `/api/transfer/${id}/cancel`)).json.transfer.status, 'cancelled'); + id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.transfer.id; deps.store.run('UPDATE ownership_transfers SET expires_at=? WHERE id=?', '2000-01-01T00:00:00Z', id); eq(svc.runDue(deps), [id]); eq(svc.get(deps, id).status, 'expired'); eq((await call(appB, 'POST', `/api/transfer/${id}/accept`, { confirm: true })).json.error, 'transfer_not_acceptable'); + id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.transfer.id; const r = await call(appB, 'POST', `/api/transfer/${id}/dispute`, { confirm: true, note: '합의 안 됨' }); eq(r.json.transfer.status, 'disputed'); + eq(svc.isFrozen(deps, 'site', s.id), true); let threw = null; try { svc.assertNotFrozen(deps, 'site', s.id); } catch (e) { threw = e; } eq(threw.code, 'transfer_disputed'); + eq((await call(appA, 'POST', '/api/transfer', { toEmail: 'c@example.test', siteIds: [s.id], confirm: true })).json.error, 'transfer_disputed', '추가 이전 동결'); + ok(deps.store.all('SELECT * FROM notifications WHERE incident_key LIKE ? AND mandatory=1', `transfer:${id}:dispute:%`).length === 2, '양쪽 알림'); + { const rr = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/resolve-dispute`, { operator: 'ops', outcome: 'cancel', note: '취소' }); eq(rr.json.transfer?.status, 'cancelled', JSON.stringify(rr.json)); } eq(svc.isFrozen(deps, 'site', s.id), false); + for (const p of ['/transfers', '/transfers/new', `/transfers/${id}`]) eq((await call(appA, 'GET', p)).status, 200, p); +}); +done();