85 lines
6.6 KiB
TypeScript
85 lines
6.6 KiB
TypeScript
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<any>) => Promise<any> }
|
|
type ReadDatabase = { $queryRawUnsafe: <T = unknown>(query: string, ...values: any[]) => Promise<T> }
|
|
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 }
|
|
})
|
|
}
|