2026-10-26 11:43:21 +00:00
/**
* portal/lifecycle/common.mjs — 백업·복원·이전·소유권 이전·생명주기 영역(fork F)이 함께 쓰는 도우미.
*
* - getSite / getMailService : 그룹 경계와 사이트별 권한(§4.3)을 검사해 행을 돌려준다(다른 그룹 것은 404 — 존재를 드러내지 않음 §37).
* - opsGuard : 운영자 콘솔(HAL)에서 오는 내부 호출을 lib/serviceauth 로 검증한다(보관소의 'service:<keyId>').
* - productPolicy : 활성 상품정책 버전(product_versions)에서 limits/policy 를 읽는다. 값이 없으면 deferred 로 표시해 화면이 "미확정" 을 솔직히 보이게 한다(§19.1·§27.2).
* - halOf : HAL 어댑터가 없거나 응답하지 않으면 '정보 확인 지연' 오류(503)로 바꿔 신규 증가를 막는다(§13.4·§26.2).
* - inProgress : 사이트·메일에 진행 중인 배포·복원·이전·소유권 이전이 있는지(충돌 확인 §11.1·§21·§22).
* - 설정 표(settings) 읽기/쓰기, KST 표시, 작업 상태 요약.
*/
import { HttpError } from '../../lib/http.mjs' ;
import { requireSitePerm , requireGroup } from '../auth/guard.mjs' ;
import { verifyCtx } from '../../lib/serviceauth.mjs' ;
import { parseJson , toJson , nowIso } from '../../lib/db.mjs' ;
import { TERMINAL } from '../../lib/status.mjs' ;
import { describe } from '../../lib/jobs.mjs' ;
import { fmtKst } from '../../lib/kst.mjs' ;
export { fmtKst , nowIso , parseJson , toJson , describe };
/** ms 단위 상수 */
export const DAY = 86400000 , HOUR = 3600000 ;
export const addMs = ( iso , ms ) => new Date ( Date . parse ( iso || nowIso ()) + ms ). toISOString ();
/** 사이트 행 + 권한 검사 */
export function getSite ( deps , ctx , id , perm = 'view' ) {
const s = deps . store . get ( 'SELECT * FROM sites WHERE id=?' , id );
requireSitePerm ( ctx , s , perm ); // 없거나 다른 그룹이면 404
return s ;
}
/** 메일 서비스 행 + 권한 검사(사이트 대표 메일은 사이트 권한 domain_mail, 독립 메일은 그룹 역할) */
export function getMailService ( deps , ctx , id , perm = 'view' ) {
requireGroup ( ctx );
const m = deps . store . get ( 'SELECT * FROM mail_services WHERE id=? AND group_id=?' , id , ctx . group . id );
if ( ! m ) throw new HttpError ( 404 , 'not_found' , '요청한 항목을 찾을 수 없습니다' );
if ( m . site_id ) { const s = deps . store . get ( 'SELECT * FROM sites WHERE id=?' , m . site_id ); if ( s ) requireSitePerm ( ctx , s , perm === 'view' ? 'view' : 'domain_mail' ); }
return m ;
}
/** 운영자 콘솔(HAL) 내부 호출 검증. 통과하면 keyId. */
export async function opsGuard ( ctx , deps ) {
const r = await verifyCtx ( ctx , ( keyId ) => { try { return deps . vault . get ( 'service:' + keyId ); } catch ( _ ) { return null ; } });
if ( ! r . ok ) throw new HttpError ( 401 , 'service_auth_failed' , `서비스 인증에 실패했습니다 ( ${ r . reason } )` );
return r . keyId ;
}
/** 운영자 식별(내부 호출 본문의 operator 필드) */
export const operatorOf = ( body ) => String (( body && body . operator ) || 'operator' ). slice ( 0 , 80 );
/**
* 활성 상품정책. productId 의 published 버전 중 effective_from ≤ now < effective_to 인 가장 높은 버전.
* @returns {{version:object|null, limits:object, policy:object, deferred:string[]}}
*/
export function productPolicy ( deps , productId , required = []) {
const now = nowIso ();
2026-10-26 12:06:32 +00:00
let 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 ;
let limits = parseJson ( v ? . limits_json , {}) || {}; let policy = parseJson ( v ? . policy_json , {}) || {}; let derivedPrice = null ;
// [통합 보완] 상거래 영역의 Catalog 는 백업·이전·보존 정책을 "별도 상품" 이 아니라 기본 상품(basic/team)의 옵션(backup)과 policy(relocation·retention_days·lifecycle) 안에 둔다.
// 전용 product_id 행이 없으면 활성 기본 상품에서 같은 뜻의 값을 유도한다(키 이름 대응은 아래). 전용 행이 있으면 그것이 우선한다(운영자가 나중에 분리 등록할 수 있음).
if ( ! v && [ 'backup' , 'relocation' , 'retention' ]. includes ( productId )) {
const base = deps . store . get ( 'SELECT * FROM product_versions WHERE kind=\'base\' AND published=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>?) ORDER BY version DESC, created_at DESC LIMIT 1' , now , now );
if ( base ) {
const bp = parseJson ( base . policy_json , {}) || {}; const opts = parseJson ( base . options_json , []) || [];
if ( productId === 'backup' ) { const o = opts . find (( x ) => x . id === 'backup' ); const q = ( o && o . policy ) || {}; limits = { storage_bytes : q . storage_bytes }; derivedPrice = o ? o . price : null ;
policy = { schedule : q . schedule , schedule_time_kst : q . schedule_time_kst , retention : q . retention , first_backup_within_hours : q . first_backup_within_hours , verify_method : q . verify_method ?? q . remote_copy_verification , restore_cost : q . restore_cost , restore_approval : q . restore_approval , restore_completion : q . restore_completion , protect_point_days : q . protect_point_days , revert_hours : q . revert_hours , expiry_retention_days : q . expiry_retention_days ?? q . expired_backup_retention_days , failure_notify_after : q . failure_notify_after ?? q . failure_alert_after , resume_after_pause : q . resume_after_pause , restore_downtime_minutes : q . restore_downtime_minutes }; }
else if ( productId === 'relocation' ) { const r = bp . relocation || {}; policy = { target_downtime_min : r . target_downtime_min ?? r . target_downtime_minutes , revert_hours : r . revert_hours ?? r . revert_window_hours , old_retention_days : r . old_retention_days ?? ( r . old_location_retention_hours != null ? Math . ceil ( r . old_location_retention_hours / 24 ) : undefined ), customer_cost : r . customer_cost ?? r . customer_requested_fee , platform_cost : r . platform_reason_fee ?? 0 , dns_wait_handling : r . dns_wait_handling , write_freeze : r . write_freeze }; }
else { const rd = bp . retention_days || {}; const lc = bp . lifecycle || {}; policy = { site_days : rd . site , mail_days : rd . mail , source_days : rd . source , backup_days : rd . backup_after_expiry , readonly_retention_days : lc . readonly_retention_days , delete_after_days : lc . delete_after_days , pause_keeps_site_count : lc . pause_keeps_site_count , cancel_effective : lc . cancel_effective }; }
for ( const k of Object . keys ( policy )) if ( policy [ k ] === undefined ) delete policy [ k ];
v = { ... base , name : ` ${ base . name } ( ${ productId } 정책)` , price : derivedPrice ?? 0 , derived_from : base . id };
}
}
2026-10-26 11:43:21 +00:00
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 }); }