diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs index 1fdf055..e562aa9 100644 --- a/mindspace-workspace-sync.mjs +++ b/mindspace-workspace-sync.mjs @@ -154,6 +154,29 @@ export function createWorkspaceAssetSync({ return new Map(rows.map((row) => [row.original_filename, row])); }; + /** Deleted workspace assets still own workspace:// storage_key rows — restore instead of re-import. */ + const loadDeletedWorkspaceAssetsByFilename = async (userId, categoryId) => { + const [rows] = await pool.query( + `SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status + FROM h5_assets a + WHERE a.user_id = ? AND a.category_id = ? AND a.status = 'deleted' AND a.source_type = 'workspace'`, + [userId, categoryId], + ); + return new Map(rows.map((row) => [row.original_filename, row])); + }; + + const findDeletedWorkspaceAssetByStorageKey = async (userId, storageKey) => { + const [rows] = await pool.query( + `SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status + FROM h5_asset_versions v + JOIN h5_assets a ON a.id = v.asset_id AND a.user_id = ? + WHERE v.storage_key = ? AND a.status = 'deleted' AND a.source_type = 'workspace' + LIMIT 1`, + [userId, storageKey], + ); + return rows[0] ?? null; + }; + /** Skip re-importing workspace files the user already deleted (checksum unchanged). */ const loadDeletedWorkspaceChecksums = async (userId, categoryId) => { const [rows] = await pool.query( @@ -307,7 +330,7 @@ export function createWorkspaceAssetSync({ throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' }); } const [assetRows] = await conn.query( - `SELECT id, current_version_id, size_bytes, checksum + `SELECT id, current_version_id, size_bytes, checksum, status FROM h5_assets WHERE id = ? AND user_id = ? LIMIT 1 @@ -318,6 +341,7 @@ export function createWorkspaceAssetSync({ if (!currentAsset) { throw Object.assign(new Error('工作区资产不存在'), { code: 'asset_not_found' }); } + const wasDeleted = currentAsset.status === 'deleted'; const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename); if (!detectedMimeType) { @@ -375,7 +399,8 @@ export function createWorkspaceAssetSync({ await conn.query( `UPDATE h5_assets SET size_bytes = ?, checksum = ?, mime_type = ?, - asset_type = ?, risk_level = ?, status = ?, workspace_relative_path = ?, updated_at = ? + asset_type = ?, risk_level = ?, status = ?, workspace_relative_path = ?, + deleted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?`, [ buffer.length, @@ -406,16 +431,17 @@ export function createWorkspaceAssetSync({ currentAsset.id, ], ); - if (sizeDelta !== 0) { + const quotaDelta = wasDeleted ? buffer.length : sizeDelta; + if (quotaDelta !== 0) { await conn.query( `UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ? WHERE id = ? AND user_id = ?`, - [sizeDelta, now, category.space_id, userId], + [quotaDelta, now, category.space_id, userId], ); } await conn.commit(); return { - action: 'updated', + action: wasDeleted ? 'restored' : 'updated', assetId: currentAsset.id, filename: file.filename, checksum, @@ -490,6 +516,7 @@ export function createWorkspaceAssetSync({ if (!category) return { imported: 0, updated: 0, skipped: 0 }; const existingByName = await loadExistingAssets(userId, category.id); + const deletedByName = await loadDeletedWorkspaceAssetsByFilename(userId, category.id); const deletedWorkspaceChecksums = await loadDeletedWorkspaceChecksums(userId, category.id); let imported = 0; let updated = 0; @@ -524,6 +551,31 @@ export function createWorkspaceAssetSync({ existing.size_bytes = buffer.length; updated += 1; } else { + const storageKey = buildWorkspaceStorageKey( + userId, + category.category_code, + file.filename, + ); + const deletedExisting = + deletedByName.get(file.filename) + ?? (await findDeletedWorkspaceAssetByStorageKey(userId, storageKey)); + if (deletedExisting) { + const result = await updateWorkspaceFile(userId, category, deletedExisting, file, buffer); + if (result.action === 'skipped') { + skipped += 1; + continue; + } + await registerWorkspaceArtifactForConversation(userId, source, result); + deletedByName.delete(deletedExisting.original_filename); + existingByName.set(deletedExisting.original_filename, { + id: deletedExisting.id, + checksum, + size_bytes: buffer.length, + current_version_id: deletedExisting.current_version_id, + }); + updated += 1; + continue; + } const result = await importWorkspaceFile(userId, category, file, buffer); await registerWorkspaceArtifactForConversation(userId, source, result); existingByName.set(file.filename, { checksum }); diff --git a/mindspace-workspace-sync.test.mjs b/mindspace-workspace-sync.test.mjs index 08e5a20..0396415 100644 --- a/mindspace-workspace-sync.test.mjs +++ b/mindspace-workspace-sync.test.mjs @@ -476,3 +476,160 @@ test('syncUserWorkspace imports nested workspace files into asset library', asyn assert.equal(result.imported, 1); assert.equal(state.assets[0].original_filename, '暑假实习报告/report.csv'); }); + +test('syncUserWorkspace restores deleted workspace asset instead of duplicate storage_key insert', async () => { + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-deleted-restore-')); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); + await fs.mkdir(path.join(workspace, 'public'), { recursive: true }); + await fs.writeFile( + path.join(workspace, 'public', 'simple-page.html'), + 'restored', + ); + + const storageKey = 'workspace://user-1/public/simple-page.html'; + const state = { + categories: [ + { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, + ], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 5 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + assets: [ + { + id: 'asset-deleted-1', + user_id: 'user-1', + category_id: 'cat-public', + original_filename: 'simple-page.html', + checksum: 'old-checksum', + size_bytes: 10, + current_version_id: 'version-deleted-1', + status: 'deleted', + source_type: 'workspace', + deleted_at: Date.now(), + }, + ], + versions: [ + { + id: 'version-deleted-1', + asset_id: 'asset-deleted-1', + storage_key: storageKey, + scan_status: 'passed', + }, + ], + }; + + const pool = { + async query(sql, params = []) { + if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) { + const category = state.categories.find( + (item) => item.user_id === params[0] && item.category_code === params[1], + ); + return [category ? [category] : []]; + } + if (sql.includes('FROM h5_asset_versions v') && sql.includes('storage_key = ?')) { + const row = state.versions.find((item) => item.storage_key === params[1]); + if (!row) return [[]]; + const asset = state.assets.find( + (item) => item.id === row.asset_id && item.user_id === params[0] && item.status === 'deleted', + ); + return asset + ? [[{ + id: asset.id, + original_filename: asset.original_filename, + checksum: asset.checksum, + size_bytes: asset.size_bytes, + current_version_id: asset.current_version_id, + status: asset.status, + }]] + : [[]]; + } + if (sql.includes('FROM h5_assets')) { + const assets = state.assets.filter((item) => { + if (item.user_id !== params[0] || item.category_id !== params[1]) return false; + if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false; + if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false; + if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') { + return false; + } + return true; + }); + return [assets]; + } + return [[]]; + }, + async getConnection() { + return { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async query(sql, params = []) { + if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { + return [[state.spaces[0]]]; + } + if (sql.includes('FROM h5_assets') && sql.includes('FOR UPDATE')) { + const asset = state.assets.find( + (item) => item.id === params[0] && item.user_id === params[1], + ); + return [asset ? [asset] : []]; + } + if (sql.includes('UPDATE h5_assets') && sql.includes('deleted_at = NULL')) { + const asset = state.assets.find( + (item) => item.id === params[8] && item.user_id === params[9], + ); + asset.size_bytes = params[0]; + asset.checksum = params[1]; + asset.status = params[5]; + asset.deleted_at = null; + return [[]]; + } + if (sql.includes('UPDATE h5_asset_versions')) { + const version = state.versions.find( + (item) => item.id === params[6] && item.asset_id === params[7], + ); + version.size_bytes = params[0]; + version.checksum = params[1]; + version.scan_status = params[4]; + return [[]]; + } + if (sql.includes('used_bytes = GREATEST')) { + state.spaces[0].used_bytes += params[0]; + return [[]]; + } + if (sql.includes('INSERT INTO h5_assets')) { + throw new Error('should not insert a new asset when restoring deleted workspace file'); + } + if (sql.includes('INSERT INTO h5_asset_versions')) { + throw new Error('should not insert a new asset version when restoring deleted workspace file'); + } + return pool.query(sql, params); + }, + }; + }, + }; + + const sync = createWorkspaceAssetSync({ + pool, + storageRoot, + h5Root, + maxFileBytes: 1024 * 1024, + idFactory: () => 'unused-id', + }); + + const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'public' }); + assert.equal(result.updated, 1); + assert.equal(state.assets.length, 1); + assert.equal(state.assets[0].status, 'ready'); + assert.equal(state.assets[0].deleted_at, null); + assert.equal(state.versions.length, 1); + assert.equal(state.versions[0].storage_key, storageKey); + assert.ok(state.spaces[0].used_bytes > 0); +});