Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
63 lines
8.6 KiB
JavaScript
63 lines
8.6 KiB
JavaScript
/**
|
|
* portal/source/service.mjs — Source(Gitea) 옵션 업무 규칙(개발 지시 §8.3·§30.5·§23.2).
|
|
*
|
|
* - 옵션은 사이트 단위이며 lib/quota 자원 'source' 를 1 배정한다(구매 총량 안에서만 활성화).
|
|
* - 저장소·브랜치·변경 이력·저장소 크기는 Webmaker 의 Gitea 연동(modules/webmaker/lib/connectors/gitea.js·gitstore.js)을 내부 API 로 읽는다. 재구현하지 않는다.
|
|
* - 만료·연결 해제 → readonly: push·새 변경은 막고 30일 동안 조회·clone·export 를 제공한다(만료일·삭제 예정일·재활성화 방법 표시).
|
|
* - 삭제 완료(deleted) 뒤에는 복구 가능한 것처럼 표시하지 않는다.
|
|
* 실제 push 차단은 Webmaker 쪽 gitstore 가 포털 연결 상태를 보고 하는 것이 아니라, 포털이 Webmaker 내부 API 로 연결을 해제(unlinkProject)하고 상태를 readonly 로 둔 뒤 export 만 허용하는 방식이다.
|
|
*/
|
|
import { newId } from '../../lib/crypto.mjs';
|
|
import { HttpError } from '../../lib/http.mjs';
|
|
import { nowIso, parseJson } from '../../lib/db.mjs';
|
|
import * as quota from '../../lib/quota.mjs';
|
|
import { notify } from '../../lib/notify.mjs';
|
|
import { audit } from '../../lib/audit.mjs';
|
|
|
|
const READONLY_DAYS = 30;
|
|
export function createSourceService(deps) {
|
|
const { store, log = () => {} } = deps;
|
|
const get = (siteId) => store.get('SELECT * FROM source_options WHERE site_id=?', siteId) || null;
|
|
const save = (id, patch) => { const sets = []; const vals = []; for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v); } sets.push('updated_at=?'); vals.push(nowIso()); store.run(`UPDATE source_options SET ${sets.join(', ')} WHERE id=?`, ...vals, id); return store.get('SELECT * FROM source_options WHERE id=?', id); };
|
|
/** 활성화: 권리 안에서 1 배정 */
|
|
function activate(site, user) {
|
|
const cur = get(site.id); if (cur && cur.status === 'active') return cur;
|
|
try { quota.setAllocation(store, { groupId: site.group_id, resource: 'source', subjectType: 'site', subjectId: site.id, amount: 1 }); } catch (e) { throw new HttpError(409, e.code || 'quota_insufficient', e.code === 'quota_insufficient' ? 'Source 옵션 권리가 부족합니다. 상품 및 결제에서 옵션을 추가하세요' : e.message, { href: '/billing' }); }
|
|
const now = nowIso();
|
|
if (cur) { const r = save(cur.id, { status: 'active', activated_at: now, readonly_until: null, delete_after: null, reason: null }); audit(store, { actorType: 'user', actorId: user?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'source.reactivate', detail: {} }); return r; }
|
|
const id = newId('so_'); store.run('INSERT INTO source_options(id, site_id, group_id, status, activated_at, created_by, created_at, updated_at) VALUES(?,?,?,\'active\',?,?,?,?)', id, site.id, site.group_id, now, user?.id || null, now, now);
|
|
audit(store, { actorType: 'user', actorId: user?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'source.activate', detail: {} });
|
|
return get(site.id);
|
|
}
|
|
/** 만료·해제 → readonly 30일. 배정 반환 */
|
|
function expire(siteId, { reason = 'expired', at = Date.now(), actor = null } = {}) {
|
|
const o = get(siteId); if (!o || o.status !== 'active') return o;
|
|
const a = quota.allocationOf(store, o.group_id, 'source', 'site', siteId); if (a) quota.finishReturn(store, a.id);
|
|
const until = new Date(at + READONLY_DAYS * 86400000).toISOString();
|
|
const r = save(o.id, { status: 'readonly', readonly_until: until, delete_after: until, expires_at: new Date(at).toISOString(), reason });
|
|
const site = store.get('SELECT name FROM sites WHERE id=?', siteId);
|
|
notify(store, { groupId: o.group_id, category: 'site', severity: 'warning', mandatory: true, incidentKey: `source:${siteId}`, title: `사이트 "${site ? site.name : siteId}" Source 옵션 ${reason === 'unlinked' ? '연결 해제' : '만료'} — ${until.slice(0, 10)} 삭제 예정`, body: `30일 동안 read-only 조회·clone·export 가 가능합니다. 그 뒤 저장소가 삭제되며 복구할 수 없습니다. 재활성화하려면 Source 옵션을 다시 구매하세요`, link: `/sites/${siteId}/source`, subjectType: 'site', subjectId: siteId, actionRequired: '필요하면 export 또는 재활성화' });
|
|
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: o.group_id, subjectType: 'site', subjectId: siteId, action: `source.${reason}`, detail: { readonlyUntil: until }, automatic: !actor });
|
|
return r;
|
|
}
|
|
/** 삭제 완료 처리(주기 작업). Gitea 브랜치 삭제는 운영자 작업(HAL 콘솔)으로 분리되어 있어 여기서는 상태만 둔다 */
|
|
function sweep(now = Date.now()) {
|
|
let deleted = 0; let expired = 0;
|
|
for (const o of store.all('SELECT * FROM source_options WHERE status=\'readonly\' AND delete_after<=?', new Date(now).toISOString())) { save(o.id, { status: 'deleted', deleted_at: nowIso() }); const site = store.get('SELECT name FROM sites WHERE id=?', o.site_id); notify(store, { groupId: o.group_id, category: 'site', severity: 'info', incidentKey: `source:${o.site_id}`, title: `사이트 "${site ? site.name : o.site_id}" Source 저장소 삭제 완료`, body: '보존 기간이 끝나 저장소를 삭제했습니다. 복구할 수 없습니다', link: `/sites/${o.site_id}/source`, subjectType: 'site', subjectId: o.site_id }); deleted++; }
|
|
// 권리가 줄어 배정이 구매량을 넘으면 가장 늦게 활성화한 옵션부터 만료(§6.4 축소는 정리 후 적용되므로 보통 발생하지 않음)
|
|
for (const g of store.all('SELECT DISTINCT group_id FROM source_options WHERE status=\'active\'')) { const f = quota.figures(store, g.group_id, 'source'); let over = f.allocated - f.purchased; if (over <= 0) continue; for (const o of store.all('SELECT * FROM source_options WHERE group_id=? AND status=\'active\' ORDER BY activated_at DESC', g.group_id)) { if (over <= 0) break; expire(o.site_id, { reason: 'expired', at: now }); over--; expired++; } }
|
|
return { deleted, expired };
|
|
}
|
|
/** 화면 데이터: 옵션 + Webmaker 저장소 정보(닿지 못하면 stale) + 배포 revision 대응 */
|
|
async function info(site) {
|
|
const o = get(site.id); const ent = quota.figures(store, site.group_id, 'source');
|
|
let source = null; if (o && o.status !== 'deleted' && site.webmaker_project_id && deps.webmaker) { try { source = await deps.webmaker.projectSource(site.webmaker_project_id); } catch (e) { source = { stale: true, provider: '-', changes: [], error: e.code === 'service_unreachable' ? '정보 확인 지연' : e.message }; } }
|
|
const deployedCommits = {}; for (const d of store.all('SELECT version_label, version_ref, result_json FROM deployments WHERE site_id=? AND status=\'done\' ORDER BY created_at DESC LIMIT 50', site.id)) { const r = parseJson(d.result_json, null); if (r && r.bundleCommit) deployedCommits[r.bundleCommit] = deployedCommits[r.bundleCommit] || (d.version_label || d.version_ref); }
|
|
const lastDep = store.all('SELECT result_json, version_label, version_ref FROM deployments WHERE site_id=? AND status=\'done\' ORDER BY created_at DESC LIMIT 1', site.id)[0]; const lr = lastDep ? parseJson(lastDep.result_json, null) : null;
|
|
const limits = (() => { const s = store.get('SELECT value_json FROM settings WHERE key=\'source.limits\''); return s ? parseJson(s.value_json, null) : null; })();
|
|
return { option: o, entitlement: ent, source, selectedRevision: source && source.changes && source.changes.length ? source.changes[source.changes.length - 1].commit : null, lastDeployedRevision: lr && lr.bundleCommit ? lr.bundleCommit : null, lastDeployedVersion: lastDep ? lastDep.version_label || lastDep.version_ref : null, deployedCommits, limits, cloneInfo: source && source.remote && !source.stale ? `git clone --branch ${source.mainBranch} <Gitea 주소>/${source.remote}.git (접속 정보는 Webmaker › 소스 보관 설정)` : null, readonly: !!(o && o.status === 'readonly'), canExport: !!(o && o.status !== 'deleted' && site.webmaker_project_id) };
|
|
}
|
|
async function unlink(site, user) { const o = get(site.id); if (!o || o.status !== 'active') throw new HttpError(409, 'not_active', '활성 Source 옵션이 없습니다'); if (site.webmaker_project_id && deps.webmaker) { try { await deps.webmaker.unlinkProject(site.webmaker_project_id); } catch (e) { if (e.code === 'service_unreachable') throw new HttpError(503, 'service_unreachable', 'Webmaker 에 연결할 수 없어 지금은 해제할 수 없습니다'); } } return expire(site.id, { reason: 'unlinked', actor: user }); }
|
|
return { get, activate, expire, unlink, sweep, info, READONLY_DAYS };
|
|
}
|