import { getDb } from '../database/database'; import { localTimestamp, formatLocalDateTime } from '../utils/time'; import { getSystemConfig } from '../admin/system-config.service'; import { decrypt } from '../utils/crypto'; import { QuarkDriver } from './drivers/quark.driver'; import { BaiduDriver } from './drivers/baidu.driver'; import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service'; import { lookupIpLocation } from './ip-lookup'; import { notify, notifyError, notifyInfo, notifyWarn, notifyEvent, notifyConfigEvent } from './notification.service'; /** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */ const inFlightSaves = new Map>(); export interface SaveResult { success: boolean; shareUrl?: string; share_url?: string; sharePwd?: string; folderName?: string; message: string; file_count?: number; folder_count?: number; duration_ms?: number; } export interface SaveRecord { id: number; source_type: string; source_title: string | null; source_url: string; target_cloud: string; share_url: string | null; share_pwd: string | null; file_size: string | null; file_count: number; folder_count: number; duration_ms: number; status: string; error_message: string | null; folder_name: string | null; original_folder_name: string | null; ip_address: string | null; ip_location: string | null; created_at: string; } /** Core save logic extracted so inFlight dedup can wrap it */ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise { const db = getDb(); const ipLocation = await lookupIpLocation(ipAddress || ''); // โ”€โ”€ Short-term dedup: prevent duplicate saves of the same URL within 60 seconds โ”€โ”€ const DEDUP_WINDOW_SEC = 60; let dedupCutoff = ''; try { const recentCutoff = db.prepare( `SELECT datetime('now','localtime', '-${DEDUP_WINDOW_SEC} seconds') as cutoff` ).get() as { cutoff: string }; dedupCutoff = recentCutoff.cutoff; const recentRecord = db.prepare( `SELECT share_url, share_pwd, status, error_message, folder_name, original_folder_name FROM save_records WHERE source_url = ? AND created_at >= ? ORDER BY created_at DESC LIMIT 1` ).get(shareUrl, dedupCutoff) as { share_url: string | null; share_pwd: string | null; status: string; error_message: string | null; folder_name: string | null; original_folder_name: string | null; } | undefined; if (recentRecord) { const alreadySaved = recentRecord.status === 'success' || recentRecord.status === 'reused'; if (alreadySaved && recentRecord.share_url) { console.log(`[Share] ๐Ÿ›ก๏ธ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`); db.prepare( `INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at, config_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( cloudType, sourceTitle || null, shareUrl, cloudType, recentRecord.share_url, recentRecord.share_pwd || null, null, 0, 0, 0, 'reused', null, recentRecord.folder_name || null, recentRecord.original_folder_name || null, ipAddress || null, ipLocation, localTimestamp(), null, ); return { success: true, message: `๐Ÿ›ก๏ธ ๆญค่ต„ๆบๅˆšๅœจ ${DEDUP_WINDOW_SEC} ็ง’ๅ†…่ฝฌๅญ˜่ฟ‡๏ผŒ็›ดๆŽฅ่ฟ”ๅ›žๅทฒๆœ‰ๅˆ†ไบซ้“พๆŽฅ`, share_url: recentRecord.share_url, shareUrl: recentRecord.share_url, sharePwd: recentRecord.share_pwd || '', folderName: '', file_count: 0, folder_count: 0, duration_ms: 0, }; } } } catch (err: any) { console.log(`[Share] Dedup check failed: ${err.message}, proceeding with normal save`); } // โ”€โ”€ Share link reuse: if same source URL was already saved successfully, validate and reuse โ”€โ”€ const reuseEnabled = getSystemConfig('save_reuse_enabled'); if (reuseEnabled !== 'false') { try { const existing = db.prepare( `SELECT share_url, share_pwd, folder_name, original_folder_name FROM save_records WHERE source_url = ? AND status IN ('success', 'reused') AND share_url IS NOT NULL AND share_url != '' ORDER BY created_at DESC LIMIT 1` ).get(shareUrl) as { share_url: string; share_pwd: string; folder_name: string | null; original_folder_name: string | null } | undefined; if (existing?.share_url) { const { LinkValidator } = await import('../validation/link-validator.service'); const validator = new LinkValidator(); const validation = await validator.validate(existing.share_url, 'quark'); if (validation.status === 'valid') { const isFirstReuse = dedupCutoff ? !db.prepare( `SELECT 1 FROM save_records WHERE source_url = ? AND created_at >= ? AND status = 'reused' LIMIT 1` ).get(shareUrl, dedupCutoff) : true; const reuseStatus = isFirstReuse ? 'success' : 'reused'; const reuseMsg = isFirstReuse ? `โ™ป๏ธ ๆฃ€ๆต‹ๅˆฐๆญค่ต„ๆบไน‹ๅ‰ๅทฒ่ฝฌๅญ˜่ฟ‡๏ผŒ็›ดๆŽฅๅค็”จๅทฒๅญ˜ๅœจ็š„ๅˆ†ไบซ้“พๆŽฅ` : `โ™ป๏ธ ็Ÿญๆ—ถ้—ดๅ†…้‡ๅค่ฏทๆฑ‚๏ผŒๅค็”จๅทฒๆœ‰ๅˆ†ไบซ้“พๆŽฅ`; console.log(`[Share] โ™ป๏ธ Reusing existing share link for ${shareUrl}: ${existing.share_url} (firstReuse=${isFirstReuse})`); db.prepare( `INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( cloudType, sourceTitle || null, shareUrl, cloudType, existing.share_url, existing.share_pwd || null, null, 0, 0, 0, reuseStatus, null, existing.folder_name || null, existing.original_folder_name || null, ipAddress || null, ipLocation, localTimestamp(), ); return { success: true, message: reuseMsg, share_url: existing.share_url, shareUrl: existing.share_url, sharePwd: existing.share_pwd || '', folderName: '', file_count: 0, folder_count: 0, duration_ms: 0, }; } console.log(`[Share] Existing share link for ${shareUrl} is invalid/expired, will re-save`); } } catch (err: any) { console.log(`[Share] Link reuse check failed: ${err.message}, proceeding with normal save`); } } // โ”€โ”€ Unified credential validation โ”€โ”€ const credential = await getAndValidateCredential(cloudType, ipAddress); if (!credential.valid || !credential.config) { return { success: false, message: credential.message }; } const config = credential.config; // โ”€โ”€ Check transfer enabled โ”€โ”€ if (config.is_transfer_enabled === 0) { return { success: false, message: `${config.nickname || cloudType} ็š„่ฝฌๅญ˜ๅŠŸ่ƒฝๅทฒๅ…ณ้—ญ๏ผŒ่ฏทๅ…ˆๅœจๅŽๅฐๅผ€ๅฏ` }; } const startTime = Date.now(); try { let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; fileSize?: number; originalFolderName?: string }; switch (cloudType) { case 'quark': { const driver = new QuarkDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname }); driverResult = await driver.saveFromShare(shareUrl, sourceTitle); break; } case 'baidu': { const driver = new BaiduDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname }); driverResult = await driver.saveFromShare(shareUrl, sourceTitle); break; } case 'aliyun': return { success: false, message: '้˜ฟ้‡Œไบ‘็›˜ไฟๅญ˜ๅŠŸ่ƒฝๆš‚ๆœชๅฎž็Žฐ' }; default: return { success: false, message: `ๆš‚ไธๆ”ฏๆŒ ${cloudType} ็š„ไฟๅญ˜ๅŠŸ่ƒฝ` }; } // โ”€โ”€ If save failed, get actual error reason from PanSou validation โ”€โ”€ let actualError: string | null = null; if (!driverResult.success) { try { const { LinkValidator } = await import('../validation/link-validator.service'); const validator = new LinkValidator(); const validation = await validator.validate(shareUrl, cloudType); if (validation.message) { actualError = validation.message; } } catch { // PanSou unreachable } } const durationMs = Date.now() - startTime; if (driverResult.success) { const nickname = config.nickname || cloudType; notifyConfigEvent(config.id, 'save_success', `โœ… ่ฝฌๅญ˜ๆˆๅŠŸ`, `**${cloudType}** ยท ${nickname}\nๆ–‡ไปถ: ${driverResult.folderName || sourceTitle || shareUrl}\n่€—ๆ—ถ: ${((Date.now() - startTime) / 1000).toFixed(1)}s`, 'info', { file_name: driverResult.folderName || sourceTitle || shareUrl, file_size: '', cloud_type: cloudType, nickname: nickname || '', duration: ((Date.now() - startTime) / 1000).toFixed(1), share_url: shareUrl, }); db.prepare( `UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?` ).run(config.id); } else if ((driverResult as any).cookieExpired) { // Cookie expired โ€” don't count as failure, user needs to re-login notifyConfigEvent(config.id, 'cookie_expire', `โš ๏ธ Cookie่ฟ‡ๆœŸ`, `**${cloudType}** ยท ${config.nickname || 'ๆœช็Ÿฅ'}\n้“พๆŽฅ: ${shareUrl}\n่ฏท้‡ๆ–ฐ็™ปๅฝ•`, 'error', { cloud_type: cloudType, nickname: config.nickname || '', share_url: shareUrl, }); } else { db.prepare( `UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?` ).run(config.id); const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0; if (failCount >= 3) { notifyConfigEvent(config.id, 'save_fail', `โŒ ่ฝฌๅญ˜่ฟž็ปญๅคฑ่ดฅ ${failCount} ๆฌก`, `**${cloudType}** ยท ${config.nickname || 'ๆœช็Ÿฅ'}\n้“พๆŽฅ: ${shareUrl}\n้”™่ฏฏ: ${driverResult.message}`, 'warn', { file_name: sourceTitle || shareUrl, fail_count: String(failCount), cloud_type: cloudType, nickname: config.nickname || '', error: driverResult.message || '', share_url: shareUrl, }); } } db.prepare( `INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at, config_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType, driverResult.shareUrl || null, driverResult.sharePwd || null, driverResult.fileSize == null ? null : String(driverResult.fileSize), driverResult.fileCount || 0, driverResult.folderCount || 0, durationMs, driverResult.success ? 'success' : 'failed', driverResult.success ? null : (actualError ? `${driverResult.message} | ${actualError}` : driverResult.message), driverResult.folderName || null, driverResult.originalFolderName || null, ipAddress || null, ipLocation, localTimestamp(), config.id ); return { success: driverResult.success, message: driverResult.message, share_url: driverResult.shareUrl || '', shareUrl: driverResult.shareUrl, sharePwd: (driverResult as any).sharePwd || '', folderName: driverResult.folderName || '', file_count: driverResult.fileCount || 0, folder_count: driverResult.folderCount || 0, duration_ms: durationMs, }; } catch (err: any) { const durationMs = Date.now() - startTime; const errorMessage = err.message || 'Failed to save to cloud'; db.prepare( `UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?` ).run(config.id); db.prepare( `INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at, config_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp(), null); return { success: false, message: errorMessage }; } } export async function saveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise { const key = `${cloudType}:${shareUrl}`; const inflight = inFlightSaves.get(key); if (inflight) { console.log(`[Share] โณ In-flight: ${shareUrl} โ€” another save is already running, awaiting result`); return inflight; } const promise = doSaveFromShare(shareUrl, cloudType, sourceTitle, ipAddress); inFlightSaves.set(key, promise); try { return await promise; } finally { inFlightSaves.delete(key); } } // โ”€โ”€ Save Records โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export function getSaveRecords(page: number = 1, pageSize: number = 20, startDate?: string, endDate?: string, status?: string, sourceType?: string, keyword?: string): { total: number; records: SaveRecord[]; summary?: { total: number; success: number; failed: number; reused: number } } { const db = getDb(); const offset = (page - 1) * pageSize; const conditions: string[] = []; const params: any[] = []; const summaryConditions: string[] = []; const summaryParams: any[] = []; if (startDate) { conditions.push('created_at >= ?'); params.push(startDate); summaryConditions.push('created_at >= ?'); summaryParams.push(startDate); } if (endDate) { conditions.push('created_at < ?'); params.push(endDate); summaryConditions.push('created_at < ?'); summaryParams.push(endDate); } if (status) { conditions.push('status = ?'); params.push(status); } if (sourceType) { conditions.push('source_type = ?'); params.push(sourceType); summaryConditions.push('source_type = ?'); summaryParams.push(sourceType); } if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); } const srWhere = conditions.length > 0 ? 'WHERE sr.' + conditions.join(' AND sr.') : ''; const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${srWhere.replace(/sr\./g, '')}`).get(...params) as any).count; const records = db.prepare( `SELECT sr.*, cc.nickname as config_nickname FROM save_records sr LEFT JOIN cloud_configs cc ON sr.config_id = cc.id ${srWhere} ORDER BY sr.created_at DESC LIMIT ? OFFSET ?` ).all(...params, pageSize, offset) as any[]; const summaryWhere = summaryConditions.length > 0 ? 'WHERE sr.' + summaryConditions.join(' AND sr.') : ''; const summaryRows = db.prepare( `SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere.replace(/sr\./g, '')} GROUP BY status` ).all(...summaryParams) as { status: string; cnt: number }[]; let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0; for (const r of summaryRows) { sumTotal += r.cnt; if (r.status === 'success') sumSuccess = r.cnt; else if (r.status === 'failed') sumFailed = r.cnt; else if (r.status === 'reused') sumReused = r.cnt; } const summary = { total: sumTotal, success: sumSuccess, failed: sumFailed, reused: sumReused }; return { total, records, summary }; } export function cleanupOldSaveRecords(): void { const db = getDb(); const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)); const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff); console.log(`[Cleanup] Deleted ${deleted.changes} save records older than 60 days (before ${cutoff})`); } // โ”€โ”€ Storage Refresh โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export async function refreshAllStorageInfo(): Promise { const configs = getActiveCloudConfigs().filter(c => c.cookie); if (configs.length === 0) return; const verifyCookies = getSystemConfig('cleanup_verify_enabled') === 'true'; for (const cfg of configs) { try { const db = getDb(); const decryptedCookie = decrypt(cfg.cookie!); switch (cfg.cloud_type) { case 'quark': { const driver = new QuarkDriver({ cookie: decryptedCookie, nickname: cfg.nickname }); // Get storage info (includes background calibration callback) const storage = await driver.getStorageInfo( (fullUsed: string, total: string) => { const dbInner = getDb(); dbInner.prepare( `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` ).run(fullUsed, total, cfg.id); console.log(`[Storage] Background calibration done for quark#${cfg.id}: ${fullUsed} / ${total}`); } ); if (storage.totalBytes > 0 || storage.usedBytes > 0) { db.prepare( `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` ).run(storage.used, storage.total, cfg.id); console.log(`[Storage] Updated quark#${cfg.id}: ${storage.used} / ${storage.total}`); } // Cookie verification if (verifyCookies) { const valid = await driver.validate(); db.prepare( `UPDATE cloud_configs SET verification_status = ?, updated_at = ? WHERE id = ?` ).run(valid ? 'valid' : 'invalid', localTimestamp(), cfg.id); console.log(`[Storage] Verification for quark#${cfg.id}: ${valid ? 'valid' : 'invalid'}`); } break; } case 'baidu': { const driver = new BaiduDriver({ cookie: decryptedCookie, nickname: cfg.nickname }); // Get storage info const storage = await driver.getStorageInfo(); if (storage.used !== '0 B' && storage.total !== '0 B') { db.prepare( `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` ).run(storage.used, storage.total, cfg.id); console.log(`[Storage] Updated baidu#${cfg.id}: ${storage.used} / ${storage.total}`); } // Cookie verification if (verifyCookies) { const valid = await driver.validate(); db.prepare( `UPDATE cloud_configs SET verification_status = ?, updated_at = ? WHERE id = ?` ).run(valid ? 'valid' : 'invalid', localTimestamp(), cfg.id); console.log(`[Storage] Verification for baidu#${cfg.id}: ${valid ? 'valid' : 'invalid'}`); } break; } default: console.log(`[Storage] Skipping ${cfg.cloud_type}#${cfg.id} โ€” unsupported cloud type for storage refresh`); break; } } catch (err: any) { console.error(`[Storage] Failed to refresh ${cfg.cloud_type}#${cfg.id}:`, err.message); // On error, mark as invalid if verification is enabled if (verifyCookies) { try { const db = getDb(); db.prepare( `UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?` ).run(localTimestamp(), cfg.id); } catch {} } } } }