fix: 转存时先查资源历史, 复用原账号; save_records加config_id

- 资源维度优先级 > IP维度: 先查share_url是否被转存过
- save_records 表新增 config_id 字段 + 写入时记录
- cloud.service.ts 所有 INSERT 写入 config.id
- credential.service.ts: getAndValidateCredential 加 shareUrl 参数
- 数据库 migration: config_id 到 save_records
This commit is contained in:
2026-05-15 06:45:48 +08:00
parent 58caaae37a
commit 329256bd33
3 changed files with 45 additions and 31 deletions

View File

@@ -70,14 +70,14 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
if (alreadySaved && recentRecord.share_url) { if (alreadySaved && recentRecord.share_url) {
console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`); console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`);
db.prepare( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run( ).run(
cloudType, sourceTitle || null, shareUrl, cloudType, cloudType, sourceTitle || null, shareUrl, cloudType,
recentRecord.share_url, recentRecord.share_pwd || null, recentRecord.share_url, recentRecord.share_pwd || null,
null, 0, 0, 0, 'reused', null, null, 0, 0, 0, 'reused', null,
recentRecord.folder_name || null, recentRecord.original_folder_name || null, recentRecord.folder_name || null, recentRecord.original_folder_name || null,
ipAddress || null, ipLocation, localTimestamp(), ipAddress || null, ipLocation, localTimestamp(), null,
); );
return { return {
success: true, success: true,
@@ -141,7 +141,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
} }
// ── Unified credential validation ── // ── Unified credential validation ──
const credential = await getAndValidateCredential(cloudType, ipAddress); const credential = await getAndValidateCredential(cloudType, ipAddress, shareUrl);
if (!credential.valid || !credential.config) { if (!credential.valid || !credential.config) {
return { success: false, message: credential.message }; return { success: false, message: credential.message };
} }
@@ -189,8 +189,8 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
} }
db.prepare( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run( ).run(
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType, cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType,
driverResult.shareUrl || null, driverResult.sharePwd || null, driverResult.shareUrl || null, driverResult.sharePwd || null,
@@ -198,7 +198,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
durationMs, driverResult.success ? 'success' : 'failed', durationMs, driverResult.success ? 'success' : 'failed',
driverResult.success ? null : driverResult.message, driverResult.success ? null : driverResult.message,
driverResult.folderName || null, driverResult.originalFolderName || null, driverResult.folderName || null, driverResult.originalFolderName || null,
ipAddress || null, ipLocation, localTimestamp(), ipAddress || null, ipLocation, localTimestamp(), config.id
); );
return { return {
@@ -221,9 +221,9 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
).run(config.id); ).run(config.id);
db.prepare( db.prepare(
`INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at) `INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at, config_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp()); ).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp(), null);
return { success: false, message: errorMessage }; return { success: false, message: errorMessage };
} }

View File

@@ -423,11 +423,38 @@ export interface CredentialValidationResult {
* *
* Reference: search-ucmao get_and_validate_credential() pattern. * Reference: search-ucmao get_and_validate_credential() pattern.
*/ */
export async function getAndValidateCredential(cloudType: string, ipAddress?: string): Promise<CredentialValidationResult> { export async function getAndValidateCredential(cloudType: string, ipAddress?: string, shareUrl?: string): Promise<CredentialValidationResult> {
const db = getDb(); const db = getDb();
let config: CloudConfig | undefined; let config: CloudConfig | undefined;
// ── Resource history lookup: if this share URL was saved before, reuse that account ──
if (shareUrl) {
const historyRecord = db.prepare(
`SELECT config_id, target_cloud, folder_name FROM save_records
WHERE share_url = ? AND target_cloud = ? AND status IN ('success', 'reused')
ORDER BY id DESC LIMIT 1`
).get(shareUrl, cloudType) as { config_id: number; target_cloud: string; folder_name: string } | undefined;
if (historyRecord) {
// Resource was previously saved — reuse the exact same config if still healthy
if (historyRecord.config_id) {
config = db.prepare(
`SELECT * FROM cloud_configs WHERE id = ? AND is_active = 1 AND consecutive_failures < 5`
).get(historyRecord.config_id) as CloudConfig | undefined;
}
if (!config) {
// Fallback: pick any healthy account from this cloud type
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1 AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
}
}
}
if (!ipAddress) { if (!ipAddress) {
// No IP info — fallback to simple LUR // No IP info — fallback to simple LUR
config = db.prepare( config = db.prepare(

View File

@@ -138,27 +138,6 @@ function runMigrations(db: Database.Database): void {
} }
/** 迁移: 给已有 save_records 表补充新列 */ /** 迁移: 给已有 save_records 表补充新列 */
function migrateSaveRecords(db: Database.Database): void {
const newCols: { col: string; def: string }[] = [
{ col: 'share_pwd', def: 'TEXT' },
{ col: 'file_count', def: 'INTEGER DEFAULT 0' },
{ col: 'folder_count', def: 'INTEGER DEFAULT 0' },
{ col: 'duration_ms', def: 'INTEGER DEFAULT 0' },
{ col: 'status', def: "TEXT NOT NULL DEFAULT ''" },
{ col: 'error_message', def: 'TEXT' },
{ col: 'folder_name', def: 'TEXT' },
{ col: 'request_url', def: 'TEXT' },
{ col: 'ip_location', def: 'TEXT' },
{ col: 'original_folder_name', def: 'TEXT' },
];
for (const { col, def } of newCols) {
try {
db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`);
} catch {
// Column already exists — ignore
}
}
}
/** 迁移: 给 content_cache 表加 douban_url 列 */ /** 迁移: 给 content_cache 表加 douban_url 列 */
function migrateContentCache(db: Database.Database): void { function migrateContentCache(db: Database.Database): void {
@@ -263,6 +242,14 @@ function migrateCloudConfigs(db: Database.Database): void {
} }
} }
/** 迁移: 给 save_records 表加 config_id 字段 */
function migrateSaveRecords(db: any): void {
if (!db.prepare("SELECT sql FROM sqlite_master WHERE name='save_records' AND sql LIKE '%config_id%'").get()) {
db.exec("ALTER TABLE save_records ADD COLUMN config_id INTEGER DEFAULT NULL");
console.log('[DB] save_records migration: config_id column added');
}
}
function seedAdmin(db: Database.Database): void { function seedAdmin(db: Database.Database): void {
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername); const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
if (existing) return; if (existing) return;