if(!pol.version)reasons.push('백업 상품정책이 아직 활성화되지 않았습니다');
elseif(pol.deferred.length)reasons.push(`상품정책의 미확정 항목(${pol.deferred.join(', ')})이 정해져야 구매할 수 있습니다`);
return{ok:reasons.length===0,reasons,policy:pol};
}
exportfunctionoptionOf(deps,siteId){returndeps.store.get('SELECT * FROM backup_options WHERE site_id=?',siteId)||null;}
exportfunctionoptionById(deps,id){returndeps.store.get('SELECT * FROM backup_options WHERE id=?',id)||null;}
/** 보관 중(삭제되지 않은) 백업의 총 크기 */
exportfunctionstoredBytes(deps,siteId){returndeps.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): 저장 한도·현재 사용량·일정·보존 개수·첫 백업 예정·검증 방식·추가 저장 옵션·포함/제외 */
notify(deps.store,{groupId:site.group_id,category:'backup',title:`${site.name}: 백업 옵션이 활성화되었습니다`,body:`첫 백업은 ${first} 까지 완료됩니다. 진행 상태는 사이트 › 백업 탭에서 확인하세요`,link:`/sites/${site.id}/backup`,subjectType:'site',subjectId:site.id});
returnoptionById(deps,id);
});
}
/** 일정(JSON {daily:'HH:MM'(KST)}) 기준 다음 실행 시각. 단순화: 매일 지정 시각(KST). */
/** 이번 실행의 보존 등급: 그 달 첫 실행 → monthly, 일요일(KST) → weekly, 그 밖 → daily */
exportfunctiontierFor(deps,siteId,at=nowIso()){
constmonth=kstDate(at).slice(0,7);
constmonthly=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);
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});
returnoptionById(deps,o.id);
}
/** 재개: resume_mode 가 auto 면 생명주기 영역이 자동으로, confirm 이면 사용자가 버튼으로 부른다 */
// 저장 공간 부족으로 멈춘 옵션은 용량이 늘거나 복구점이 정리되어 여유가 생겨야 재개된다(운영자 clearStorageFull 또는 실제 여유 확인)
if(o.paused_reason==='storage_full'&&!clearStorageFull){constused=storedBytes(deps,siteId);constlast=(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)thrownewHttpError(409,'backup_storage_full','저장 공간이 가득 차 재개할 수 없습니다. 추가 용량을 적용하거나 오래된 복구점을 정리하세요',{used,limit:o.storage_limit_bytes});}
if(o.resume_mode==='confirm'&&by==='system'&&!userConfirmed)returno;// 사용자 확인 필요
deps.store.run('UPDATE backup_options SET status=\'active\', paused_reason=NULL, next_run_at=?, updated_at=? WHERE id=?',nowIso(),nowIso(),o.id);
exportfunctionreasonLabel(r){return{site_paused:'사이트 일시 중지',site_not_deployed:'사이트 미배포(배포 뒤 다시 시작)',site_cancelled:'사이트 해지',storage_full:'저장 공간 부족',operator:'운영자 조치',option_ended:'옵션 종료'}[r]||r;}
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`});
returnoptionById(deps,o.id);
}
/** 저장 서버 고르기: 사이트 운영 서버와 다른 서버(backup 역할 우선, 없으면 web). 운영자 override 가 아니면 같은 서버는 절대 고르지 않는다. */
thrownewJobError('no_backup_store','운영 서버와 분리된 백업 저장 서버가 없습니다. 운영자가 백업 서버를 등록하거나 같은 서버 저장을 명시적으로 허용해야 합니다',{retryable:true});
}
/** 보존 정리(§19.1 Daily·Weekly·Monthly 개수). 최소 보존 세트(등급별 최신 1개·전체 최신 1개)는 어떤 경우에도 지우지 않는다. */
exportfunctionpruneList(deps,option){
constret=parseJson(option.retention_json,{})||{};constall=deps.store.all('SELECT * FROM backups WHERE option_id=? AND deleted_at IS NULL AND status=\'done\' ORDER BY created_at DESC',option.id);
if(!site||!['live','needs_domain','deploying'].includes(site.lifecycle)){stopForSite(deps,o.site_id,site&&site.lifecycle==='paused'?'site_paused':site&&site.lifecycle==='draft'?'site_not_deployed':'site_cancelled');continue;}// 초안(미배포) 사이트는 '해지' 가 아니라 '미배포' 로 표시한다(실제 환경 확인)
for(constoofdeps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND first_backup_due_at<? AND last_success_at IS NULL',now))notify(deps.store,{groupId:o.group_id,category:'backup',severity:'warning',incidentKey:`backup:${o.id}:first_overdue`,title:'첫 백업이 기한 안에 완료되지 않았습니다',body:'운영자가 확인 중입니다. 진행 상태는 백업 화면에서 볼 수 있습니다',link:`/sites/${o.site_id}/backup`,subjectType:'site',subjectId:o.site_id});
returnstarted;
}
/** HAL 작업 제출·대기 단계 공통: 제출 결과(halJobId)는 단계 결과로 저장되어 재시작 뒤 다시 제출하지 않는다. */
if(['failed','cancelled','reverted'].includes(j.status))thrownewJobError(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);
}
/** 작업 종류 등록 */
exportfunctionregisterJobs(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:'보존 정리'}],
asyncrun(job,ctx){
const{optionId,siteId}=job.subject;consto=optionById(deps,optionId);constsite=deps.store.get('SELECT * FROM sites WHERE id=?',siteId);
if(!o||o.status!=='active')thrownewJobError('backup_option_inactive','백업 옵션이 활성 상태가 아닙니다');
awaitctx.step('precheck',()=>{
constused=storedBytes(deps,siteId);constlimit=o.storage_limit_bytes||0;constest=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});thrownewJobError('backup_storage_full','저장 공간 부족 — 백업 중지');}
const{halJobId}=awaithalSubmit(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});
constverified=awaitctx.step('verify',async()=>{if(res.verified)returntrue;try{constr=awaithalOf(deps).createJob({kind:'backup.verify',target:storeServerId,payload:{location:res.location,checksum:res.checksum},idempotencyKey:`hal:${job.id}:verify`});constj=awaithalOf(deps).getJob(r.halJobId);if(!(j.result&&j.result.ok))thrownewJobError('backup_verify_failed','백업 사본을 읽을 수 없습니다');returntrue;}catch(e){throwasTransient(e);}});
constbackupId=awaitctx.step('record',()=>{constid=newId('bk_');constpol=productPolicy(deps,PRODUCT_ID);constret=parseJson(o.retention_json,{})||{};constdays={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);returnid;});
awaitctx.step('prune',async()=>{constdel=pruneList(deps,o);for(constbofdel){try{awaithalOf(deps).createJob({kind:'backup.delete',target:storeServerId,payload:{location:b.location},idempotencyKey:`hal:${job.id}:del:${b.id}`});}catch(e){throwasTransient(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`,'정상화');
asyncrun(job,ctx){consto=optionById(deps,job.subject.optionId);awaitctx.step('delete',async()=>{letn=0;for(constbofdeps.store.all('SELECT * FROM backups WHERE option_id=? AND deleted_at IS NULL',o.id)){if(b.location){try{awaithalOf(deps).createJob({kind:'backup.delete',target:o.store_server_id,payload:{location:b.location},idempotencyKey:`hal:${job.id}:del:${b.id}`});}catch(e){throwasTransient(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};});}});
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:'이전 상태 정리'}],
asyncrun(job,ctx){
constr=deps.store.get('SELECT * FROM restores WHERE id=?',job.subject.restoreId);constsite=deps.store.get('SELECT * FROM sites WHERE id=?',r.site_id);constb=deps.store.get('SELECT * FROM backups WHERE id=?',r.backup_id);constpol=parseJson(r.policy_json,{})||{};
constsetStatus=(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);
awaitctx.step('approval',()=>{if(r.approval_state!=='approved')thrownewJobError('restore_not_approved','운영자 승인이 없습니다');if(r.cost>0&&!r.paid_ok&&!job.subject.paid){consto=r.order_id?deps.store.get('SELECT status FROM orders WHERE id=?',r.order_id):null;if(o&&o.status!=='approved')thrownewJobError('restore_unpaid','복원 비용 결제가 확인되지 않았습니다');}return{cost:r.cost};});
constprot=awaitctx.step('protect',async()=>{consth=awaithalSubmit(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`});constres=awaithalWait(deps,ctx,'protect.wait','보호 시점 생성',h.halJobId);constuntil=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};});
awaitctx.step('isolated',async()=>{try{consth=awaithalOf(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`});constj=awaithalOf(deps).getJob(h.halJobId);if(!(j.result&&j.result.ok))thrownewJobError('restore_backup_unreadable','격리 환경에서 백업을 읽을 수 없습니다 — 복원을 시작하지 않았습니다(현재 운영 상태 유지)');returntrue;}catch(e){throwasTransient(e);}});
awaitctx.step('function',()=>{if(res.functionCheck&&res.functionCheck.ok===false)thrownewJobError('restore_function_failed','복원 뒤 사이트 기능 확인에 실패했습니다');returnres.functionCheck||{ok:true};});
awaitctx.step('external',async()=>{constext=res.externalCheck;if(ext&&ext.ok===false)thrownewJobError('restore_external_failed','복원 뒤 외부 접속 확인에 실패했습니다');returnext||{ok:true,note:'외부 확인 결과 없음'};});
awaitctx.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};});
awaitctx.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};});
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);
constbody=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)){constr=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}`});}}
impact:'복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다',estimatedDowntimeMin:p.restore_downtime_min??null,cost:p.restore_cost??null,currency:pol.currency,costDeferred:p.restore_cost===undefined,
constopen=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};
constinfo=restoreInfo(deps,site,backup);if(!info.canRequest)thrownewHttpError(409,'restore_not_possible',info.conflicts.length?'진행 중인 배포·복원·이전이 끝나야 복원을 요청할 수 있습니다':'이 백업은 복원할 수 없습니다(만료·삭제·미완료)',{conflicts:info.conflicts});
if(cost>0){constorders=awaitoptional('../commerce/orders.mjs');if(orders&&orders.createInternalOrder){try{consto=awaitorders.createInternalOrder(deps,{groupId:site.group_id,kind:'restore',amount:cost,targetType:'restore',targetId:id,label:`사이트 복원 (${site.name})`,by});orderId=o.id;}catch(_){/* 주문 영역 없으면 결제 대기 기록만 */}}}
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};
}
/** 결제 확인(주문 승인 이벤트 또는 운영자) → 승인 대기 */
exportfunctionmarkRestorePaid(deps,restoreId,by='system'){constr=deps.store.get('SELECT * FROM restores WHERE id=?',restoreId);if(!r||r.status!=='awaiting_payment')returnr;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}});returndeps.store.get('SELECT * FROM restores WHERE id=?',r.id);}
/** 운영자 승인(§30.3): 최종 확인 내용(고객·그룹·사이트·도메인·백업 시각)과 함께. 같은 승인을 반복해도 작업은 하나(멱등). */
if(!['awaiting_approval','awaiting_payment'].includes(r.status))thrownewHttpError(409,'restore_not_approvable',`지금 상태(${r.status})에서는 승인할 수 없습니다`);
if(r.status==='awaiting_payment')thrownewHttpError(409,'restore_unpaid','결제 확인 전에는 승인할 수 없습니다');
constsite=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);
return{restore:deps.store.get('SELECT * FROM restores WHERE id=?',r.id),job,created:true};
}
exportfunctionrejectRestore(deps,restoreId,{by,reason}){constr=deps.store.get('SELECT * FROM restores WHERE id=?',restoreId);if(!r)thrownewHttpError(404,'not_found','복원 요청이 없습니다');if(!['awaiting_approval','awaiting_payment'].includes(r.status))thrownewHttpError(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});returndeps.store.get('SELECT * FROM restores WHERE id=?',r.id);}
/** 사용자 확인 단계의 선택(§20.2 8단계): confirm → 완료 진행, revert → 되돌리기 */
constjob=deps.jobs.get(restore.job_id);if(!job||job.status!=='needs_user')thrownewHttpError(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);constj=awaitdeps.jobs.revert(job.id,by);return{reverted:j.status==='reverted',job:j};}
thrownewHttpError(400,'bad_action','알 수 없는 선택입니다');
constlist=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())}));
constrestores=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}));
constlastJob=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;