import { desktopAssert, desktopId } from './contract.mjs' import { assertDetailProjectIdle, detailTransactionLock } from '@/lib/detail-generator/repository' type ProjectRef = { id: string; source: 'web' | 'desktop' } type Database = { $transaction: (action: (tx: any) => Promise) => Promise } type ReadDatabase = { $queryRawUnsafe: (query: string, ...values: any[]) => Promise } const tableFor = (source: string) => source === 'web' ? 'detail_generator_projects' : 'desktop_workspace_projects' /** Advisory UI state only: the DELETE transaction must still recheck every task. */ export async function projectDeletionSummaries(db: ReadDatabase, ownerId: string, requested: ProjectRef[]) { if (!requested.length) return [] desktopAssert(requested.length <= 50, '项目分页超出限制') const rows = await db.$queryRawUnsafe<{ id: string; source: string; deleteBlocked: boolean; deleteBlockedReason: 'pending_request' | 'task_running' | null }[]>(` WITH requested AS ( SELECT id,source FROM jsonb_to_recordset($2::jsonb) AS r(id text,source text) ), scope AS ( SELECT r.id,r.source,CASE WHEN r.source='web' THEN r.id ELSE p.source_web_id END AS web_id FROM requested r LEFT JOIN desktop_workspace_projects p ON r.source='desktop' AND p.id=r.id AND p.owner_id=$1 ), targets AS ( SELECT id AS requested_id,source AS requested_source,id,source FROM scope UNION SELECT id,source,web_id,'web' FROM scope WHERE web_id IS NOT NULL UNION SELECT s.id,s.source,p.id,'desktop' FROM scope s JOIN desktop_workspace_projects p ON p.source_web_id=s.web_id AND p.owner_id=$1 ), blocked AS ( SELECT t.requested_id,t.requested_source,CASE WHEN j.status='unknown' THEN 'pending_request' ELSE 'task_running' END AS reason FROM targets t JOIN detail_generator_jobs j ON t.source='web' AND j.project_id=t.id AND j.owner_id=$1 JOIN detail_generator_projects p ON p.id=t.id AND p.owner_id=$1 AND p.deleted_at IS NULL WHERE j.status IN ('queued','running','unknown') UNION ALL SELECT t.requested_id,t.requested_source,CASE WHEN j.status IN ('received','unknown') THEN 'pending_request' ELSE 'task_running' END FROM targets t JOIN desktop_creation_tasks j ON t.source='desktop' AND j.project_id=t.id AND j.owner_id=$1 JOIN desktop_workspace_projects p ON p.id=t.id AND p.owner_id=$1 AND p.deleted_at IS NULL WHERE j.status IN ('queued','running','received','unknown') OR j.lease_until>NOW() UNION ALL SELECT t.requested_id,t.requested_source,'pending_request' FROM targets t JOIN desktop_workspace_projects p ON t.source='desktop' AND p.id=t.id AND p.owner_id=$1 AND p.deleted_at IS NULL WHERE EXISTS (SELECT 1 FROM jsonb_array_elements(p.conversation) item WHERE (item->'pending' IS NOT NULL AND item->'pending' NOT IN ('null'::jsonb,'false'::jsonb,'0'::jsonb,'""'::jsonb)) OR (item->'unknown' IS NOT NULL AND item->'unknown' NOT IN ('null'::jsonb,'false'::jsonb,'0'::jsonb,'""'::jsonb))) ) SELECT r.id,r.source,COUNT(b.reason)>0 AS "deleteBlocked", CASE WHEN bool_or(b.reason='pending_request') THEN 'pending_request' WHEN COUNT(b.reason)>0 THEN 'task_running' ELSE NULL END AS "deleteBlockedReason" FROM requested r LEFT JOIN blocked b ON b.requested_id=r.id AND b.requested_source=r.source GROUP BY r.id,r.source `, ownerId, JSON.stringify(requested)) return rows } /** A web project and its desktop continuation are one deletion scope. Files stay private. */ export async function deleteLinkedProject(db: Database, ownerId: string, source: string, id: string, input: unknown) { desktopId(id) const revision = (input as { revision?: number })?.revision desktopAssert(['desktop', 'web'].includes(source) && Number.isSafeInteger(revision) && Number(revision) > 0, '项目来源或版本无效') return db.$transaction(async tx => { await detailTransactionLock(tx, 'desktop-tasks:' + ownerId) const targetTable = tableFor(source) const target = (await tx.$queryRawUnsafe(`SELECT id,revision,deleted_at AS deleted${source === 'desktop' ? ',source_web_id AS "sourceWebId"' : ''} FROM ${targetTable} WHERE id=$1 AND owner_id=$2`, id, ownerId))[0] desktopAssert(target, '项目不存在或无权访问', 404, 'not_found') const webId = source === 'web' ? id : target.sourceWebId const refs: ProjectRef[] = [{ id, source: source as ProjectRef['source'] }] if (webId) { refs.push({ id: webId, source: 'web' }) const siblings = await tx.$queryRawUnsafe('SELECT id FROM desktop_workspace_projects WHERE owner_id=$1 AND source_web_id=$2', ownerId, webId) for (const row of siblings) refs.push({ id: row.id, source: 'desktop' }) } const unique = Array.from(new Map(refs.map(ref => [ref.source + ':' + ref.id, ref])).values()).sort((a, b) => (a.source + a.id).localeCompare(b.source + b.id)) for (const ref of unique) { await detailTransactionLock(tx, ref.source === 'web' ? 'detail-project:' + ref.id : 'desktop:' + ref.id) const rows = await tx.$queryRawUnsafe(`SELECT revision,deleted_at AS deleted${ref.source === 'desktop' ? ',conversation' : ''} FROM ${tableFor(ref.source)} WHERE id=$1 AND owner_id=$2 FOR UPDATE`, ref.id, ownerId) const row = rows[0] desktopAssert(row, '关联项目不存在或无权访问', 404, 'not_found') if (ref.id === id && ref.source === source && !row.deleted) desktopAssert(row.revision === revision, '项目已有新版本,未删除;请刷新列表后重试', 409, 'revision_conflict') if (row.deleted) continue if (ref.source === 'web') { await assertDetailProjectIdle(ref.id, ownerId, tx) const unknown = await tx.$queryRawUnsafe("SELECT id FROM detail_generator_jobs WHERE project_id=$1 AND owner_id=$2 AND status='unknown' LIMIT 1", ref.id, ownerId) desktopAssert(!unknown.length, '此项目有运行中或结果待核查的任务,请先处理原任务再删除', 409, 'task_busy') } else { const tasks = await tx.$queryRawUnsafe("SELECT id FROM desktop_creation_tasks WHERE project_id=$1 AND owner_id=$2 AND (status IN ('queued','running','received','unknown') OR lease_until>NOW()) LIMIT 1", ref.id, ownerId) desktopAssert(!tasks.length && !row.conversation?.some((item: any) => item.pending || item.unknown), '此项目有运行中或结果待核查的任务,请先处理原任务再删除', 409, 'task_busy') } } for (const ref of unique) await tx.$executeRawUnsafe(`UPDATE ${tableFor(ref.source)} SET deleted_at=NOW(),updated_at=NOW(),revision=revision+1 WHERE id=$1 AND owner_id=$2 AND deleted_at IS NULL`, ref.id, ownerId) return { deleted: true, deletedProjects: unique } }) }