Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfcdddabad | |||
| 4b9bcd7a96 | |||
| a12fec4d82 | |||
| 1c0c024b9a | |||
| 359e15a82d | |||
| b7702d0285 | |||
| 37aa05b1e1 | |||
| 329256bd33 | |||
| 58caaae37a |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudsearch-backend",
|
||||
"version": "2.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudsearch-backend",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -70,14 +71,14 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`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(),
|
||||
ipAddress || null, ipLocation, localTimestamp(), null,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
@@ -155,16 +156,16 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
|
||||
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: config.cookie!, nickname: config.nickname });
|
||||
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: config.cookie!, nickname: config.nickname });
|
||||
const driver = new BaiduDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
break;
|
||||
}
|
||||
@@ -174,6 +175,21 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
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) {
|
||||
@@ -189,16 +205,16 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
}
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`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,
|
||||
null, driverResult.fileCount || 0, driverResult.folderCount || 0,
|
||||
driverResult.fileSize == null ? null : String(driverResult.fileSize), driverResult.fileCount || 0, driverResult.folderCount || 0,
|
||||
durationMs, driverResult.success ? 'success' : 'failed',
|
||||
driverResult.success ? null : driverResult.message,
|
||||
driverResult.success ? null : (actualError ? `${driverResult.message} | ${actualError}` : driverResult.message),
|
||||
driverResult.folderName || null, driverResult.originalFolderName || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
ipAddress || null, ipLocation, localTimestamp(), config.id
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -221,9 +237,9 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
).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)
|
||||
`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());
|
||||
).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp(), null);
|
||||
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
@@ -270,15 +286,19 @@ export function getSaveRecords(page: number = 1, pageSize: number = 20, startDat
|
||||
summaryConditions.push('source_type = ?'); summaryParams.push(sourceType);
|
||||
}
|
||||
if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); }
|
||||
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${where}`).get(...params) as any).count;
|
||||
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 * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, offset) as SaveRecord[];
|
||||
`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 ' + summaryConditions.join(' AND ') : '';
|
||||
const summaryWhere = summaryConditions.length > 0 ? 'WHERE sr.' + summaryConditions.join(' AND sr.') : '';
|
||||
const summaryRows = db.prepare(
|
||||
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere} GROUP BY status`
|
||||
`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) {
|
||||
@@ -308,7 +328,7 @@ export async function refreshAllStorageInfo(): Promise<void> {
|
||||
for (const cfg of configs) {
|
||||
try {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: cfg.cookie, nickname: cfg.nickname });
|
||||
const driver = new QuarkDriver({ cookie: decrypt(cfg.cookie!), nickname: cfg.nickname });
|
||||
const storage = await driver.getStorageInfo();
|
||||
if (storage.totalBytes > 0 || storage.usedBytes > 0) {
|
||||
const db = getDb();
|
||||
@@ -320,4 +340,4 @@ export async function refreshAllStorageInfo(): Promise<void> {
|
||||
console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface CloudConfig {
|
||||
is_active: number;
|
||||
promotion_account?: string;
|
||||
is_transfer_enabled: number;
|
||||
is_primary: number;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
|
||||
@@ -70,7 +71,7 @@ function extractQuarkUid(cookie: string): string | null {
|
||||
export function getCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
@@ -81,7 +82,7 @@ export function getCloudConfigs(): CloudConfig[] {
|
||||
export function getAvailableClouds(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
@@ -93,7 +94,7 @@ export function getAvailableClouds(): CloudConfig[] {
|
||||
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
@@ -105,7 +106,7 @@ export function getCloudConfigByType(cloudType: string): CloudConfig | undefined
|
||||
export function getCloudConfigById(id: number): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
@@ -117,7 +118,7 @@ export function getCloudConfigById(id: number): CloudConfig | undefined {
|
||||
export function getActiveCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
@@ -126,6 +127,31 @@ export function getActiveCloudConfigs(): CloudConfig[] {
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the is_primary flag for a cloud config.
|
||||
* Enforces max 2 primary accounts per cloud type.
|
||||
*/
|
||||
export function togglePrimary(id: number, setPrimary: boolean): CloudConfig {
|
||||
const db = getDb();
|
||||
const config = getCloudConfigById(id);
|
||||
if (!config) throw new Error(`Cloud config ${id} not found`);
|
||||
|
||||
if (setPrimary) {
|
||||
// Check how many primary accounts already exist for this cloud type
|
||||
const primaryCount = db.prepare(
|
||||
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND id != ?`
|
||||
).get(config.cloud_type, id) as { c: number };
|
||||
if (primaryCount.c >= 2) {
|
||||
throw new Error(`同类型网盘最多只能设置 2 个默认账号(已存在 ${primaryCount.c} 个)`);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE cloud_configs SET is_primary = ?, updated_at = datetime('now', 'localtime') WHERE id = ?`)
|
||||
.run(setPrimary ? 1 : 0, id);
|
||||
|
||||
return getCloudConfigById(id)!;
|
||||
}
|
||||
|
||||
export function saveCloudConfig(data: {
|
||||
id?: number;
|
||||
cloud_type: string;
|
||||
@@ -163,7 +189,7 @@ export function saveCloudConfig(data: {
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id);
|
||||
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id);
|
||||
} else {
|
||||
// Try to find existing config by cloud_type + cloud_type_uid
|
||||
let existing: any = null;
|
||||
@@ -194,12 +220,12 @@ export function saveCloudConfig(data: {
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id);
|
||||
).run(encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id);
|
||||
|
||||
// Re-read savedId for return
|
||||
const savedId = existing.id;
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
@@ -210,12 +236,12 @@ export function saveCloudConfig(data: {
|
||||
// No existing config found — insert new
|
||||
db.prepare(
|
||||
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, cloud_type_uid, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)'
|
||||
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null);
|
||||
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null);
|
||||
}
|
||||
|
||||
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
@@ -402,13 +428,15 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
|
||||
|
||||
let config: CloudConfig | undefined;
|
||||
|
||||
|
||||
|
||||
if (!ipAddress) {
|
||||
// No IP info — fallback to simple LUR
|
||||
config = db.prepare(
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
} else {
|
||||
@@ -428,17 +456,24 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
|
||||
|
||||
const ipTodayCount = ipCountRow?.total || 0;
|
||||
|
||||
if (ipTodayCount < 3) {
|
||||
// First 2 saves — use the primary account (least recently used, healthy)
|
||||
// How many primary accounts does this cloud type have?
|
||||
const primaryCountRow = db.prepare(
|
||||
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND is_active = 1`
|
||||
).get(cloudType) as { c: number };
|
||||
const primaryCount = primaryCountRow?.c || 0;
|
||||
const primaryThreshold = primaryCount * 2; // Each primary account gets 2 uses per IP
|
||||
|
||||
if (ipTodayCount < primaryThreshold) {
|
||||
// First N saves (primaryCount × 2) — use primary accounts (is_primary=1), fallback to any healthy
|
||||
config = db.prepare(
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
} else {
|
||||
// 3rd+ save — exclude accounts this IP has already used today,
|
||||
// After primary threshold — exclude accounts this IP has already used today,
|
||||
// fall back to other available accounts round-robin
|
||||
const usedConfigIds = db.prepare(
|
||||
`SELECT DISTINCT config_id FROM ip_daily_save_counts
|
||||
@@ -464,7 +499,7 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
}
|
||||
|
||||
@@ -166,12 +166,13 @@ async function batchDeleteFiles(
|
||||
export async function createWarningDirectories(
|
||||
cookie: string,
|
||||
dirNames: string[],
|
||||
parentDirFid: string = "0",
|
||||
): Promise<void> {
|
||||
if (!dirNames.length) return;
|
||||
|
||||
// 先获取根目录下所有文件夹,避免重复创建
|
||||
await humanDelay();
|
||||
const rootFiles = await listDirAllPages(cookie, "0");
|
||||
const rootFiles = await listDirAllPages(cookie, parentDirFid);
|
||||
const existingDirs = new Set(
|
||||
rootFiles.filter((f) => f.dir).map((f) => f.file_name),
|
||||
);
|
||||
@@ -192,7 +193,7 @@ export async function createWarningDirectories(
|
||||
continue;
|
||||
}
|
||||
|
||||
await createSingleDir(cookie, formattedName);
|
||||
await createSingleDir(cookie, formattedName, parentDirFid);
|
||||
// 加入已存在集合,防止同名重试
|
||||
existingDirs.add(formattedName);
|
||||
}
|
||||
@@ -204,6 +205,7 @@ export async function createWarningDirectories(
|
||||
async function createSingleDir(
|
||||
cookie: string,
|
||||
dirName: string,
|
||||
pdirFid: string = "0",
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
@@ -215,7 +217,7 @@ async function createSingleDir(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pdir_fid: "0",
|
||||
pdir_fid: pdirFid,
|
||||
file_name: dirName,
|
||||
dir: true,
|
||||
dir_path: "",
|
||||
@@ -276,7 +278,7 @@ export async function runAdCleanup(
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length} 个`,
|
||||
);
|
||||
await createWarningDirectories(cookie, warningNames);
|
||||
await createWarningDirectories(cookie, warningNames, savedDirFid);
|
||||
warningDirs = warningNames.length;
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`,
|
||||
|
||||
@@ -89,14 +89,8 @@ export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string
|
||||
*/
|
||||
export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
|
||||
try {
|
||||
const mparam = getMparam(cookie);
|
||||
let totalBytes = 0;
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
kps: mparam.kps || '',
|
||||
sign: mparam.sign || '',
|
||||
vcode: mparam.vcode || '',
|
||||
});
|
||||
const params = new URLSearchParams(getCommonParams());
|
||||
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
||||
headers: getHeaders(cookie),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function saveFromShare(
|
||||
renamed?: string[];
|
||||
fileCount?: number;
|
||||
folderCount?: number;
|
||||
fileSize?: number;
|
||||
originalFolderName?: string;
|
||||
}> {
|
||||
try {
|
||||
@@ -203,6 +204,10 @@ export async function saveFromShare(
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total file size
|
||||
const allFiles = topDir && childFiles ? childFiles : topFiles;
|
||||
const fileSize = allFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
|
||||
|
||||
const renameMsg = renamed.length > 0
|
||||
? `,已重命名 ${renamed.length} 个文件`
|
||||
: '';
|
||||
@@ -218,6 +223,7 @@ export async function saveFromShare(
|
||||
renamed: renamed.map(r => `${r.original} → ${r.renamed}`),
|
||||
fileCount,
|
||||
folderCount,
|
||||
fileSize,
|
||||
originalFolderName,
|
||||
};
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -138,27 +138,6 @@ function runMigrations(db: Database.Database): void {
|
||||
}
|
||||
|
||||
/** 迁移: 给已有 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 列 */
|
||||
function migrateContentCache(db: Database.Database): void {
|
||||
@@ -254,6 +233,21 @@ function migrateCloudConfigs(db: Database.Database): void {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_transfer_enabled INTEGER DEFAULT 1");
|
||||
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
|
||||
}
|
||||
|
||||
// Migration 6: Add is_primary column (default account, max 2 per cloud type)
|
||||
const row6 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_primary%'").get();
|
||||
if (!row6) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_primary INTEGER DEFAULT 0");
|
||||
console.log('[DB] cloud_configs migration: is_primary column added');
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移: 给 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 {
|
||||
@@ -267,6 +261,12 @@ function seedAdmin(db: Database.Database): void {
|
||||
'INSERT INTO admins (username, password_hash) VALUES (?, ?)'
|
||||
).run(config.adminUsername, hash);
|
||||
|
||||
// Migration 6: Add is_primary column (default account, max 2 per cloud type)
|
||||
const row6 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE %'is_primary%'").get();
|
||||
if (!row6) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_primary INTEGER DEFAULT 0");
|
||||
console.log("[DB] cloud_configs migration: is_primary column added");
|
||||
}
|
||||
console.log(`[DB] Admin user "${config.adminUsername}" created`);
|
||||
}
|
||||
|
||||
@@ -321,6 +321,8 @@ function seedSystemConfigs(db: Database.Database): void {
|
||||
{ key: 'quark_ad_keywords', value: '广告,推广,福利,加V,加微,联系,客服,赚钱,兼职', description: '夸克转存广告关键词(一行一个,匹配文件名/文件夹名即删除)' },
|
||||
{ key: 'quark_warning_folder_names', value: '⚠️ 网盘内除您所需资源外', description: '夸克转存后自动创建的警示文件夹名(一行一个,自动加上 ⚠️ 前缀)' },
|
||||
{ key: 'quark_sus_extensions', value: 'bat\nexe\nvbs\nscr\ncmd\ncom\npif\njs\njar\nmsi\nreg\ninf\nps1', description: '夸克转存可疑文件后缀(一行一个,不写点号,匹配即删除)' },
|
||||
{ key: 'link_valid_keywords', value: '链接有效', description: 'PanSou 链接有效关键词(一行一条)' },
|
||||
{ key: 'link_invalid_keywords', value: '链接失效', description: 'PanSou 链接失效关键词和本地验证失效关键词(一行一条)' },
|
||||
];
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "fs";
|
||||
import { execSync } from 'child_process';
|
||||
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
|
||||
import { getSaveRecords } from '../cloud/cloud.service';
|
||||
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
|
||||
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie, togglePrimary } from '../cloud/credential.service';
|
||||
// Note: check-in routes were removed (sign-in feature removed)
|
||||
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
||||
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
|
||||
@@ -199,6 +199,20 @@ router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/cloud-configs/:id/primary — toggle primary status (max 2 per type)
|
||||
*/
|
||||
router.put('/admin/cloud-configs/:id/primary', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const { primary } = req.body;
|
||||
const config = togglePrimary(id, !!primary);
|
||||
res.json(config);
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message || 'Failed to toggle primary status' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */
|
||||
router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudsearch-frontend",
|
||||
"version": "1.1.8",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -267,6 +267,14 @@ export async function deleteCloudConfig(
|
||||
await api.delete(`/admin/cloud-configs/${id}`)
|
||||
}
|
||||
|
||||
export async function setPrimary(
|
||||
id: number,
|
||||
primary: boolean
|
||||
): Promise<any> {
|
||||
const { data } = await api.put(`/admin/cloud-configs/${id}/primary`, { primary })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getStats(days?: number): Promise<StatsData> {
|
||||
const params: Record<string, number> = {}
|
||||
if (days) params.days = days
|
||||
|
||||
@@ -99,6 +99,16 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="默认账号" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.is_primary === 1"
|
||||
:disabled="!row.is_transfer_enabled"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleTogglePrimary(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="390" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
|
||||
@@ -171,7 +181,7 @@ import { Loading } from '@element-plus/icons-vue'
|
||||
import { CLOUD_LABELS } from '../../types'
|
||||
import type { CloudType, CloudConfig } from '../../types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType } from '../../api'
|
||||
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType, setPrimary } from '../../api'
|
||||
import CloudBadge from '../../components/CloudBadge.vue'
|
||||
import type { ElForm } from 'element-plus'
|
||||
|
||||
@@ -341,7 +351,7 @@ async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
|
||||
nickname: row.nickname || '',
|
||||
promotion_account: row.promotion_account || '',
|
||||
is_transfer_enabled: newVal,
|
||||
is_active: row.is_active !== 0,
|
||||
is_active: row.is_active,
|
||||
cookie: undefined, // don't send cookie on toggle-only
|
||||
})
|
||||
row.is_transfer_enabled = newVal
|
||||
@@ -351,6 +361,16 @@ async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePrimary(row: CloudConfig, enabled: boolean) {
|
||||
try {
|
||||
await setPrimary(row.id!, enabled)
|
||||
row.is_primary = enabled ? 1 : 0
|
||||
ElMessage.success(enabled ? `已将「${row.nickname || row.cloud_type}」设为默认账号` : '已取消默认账号')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function autoVerifyAll() {
|
||||
for (const cfg of configs.value) {
|
||||
if (cfg.cookie_preview || cfg.nickname) {
|
||||
@@ -483,9 +503,9 @@ async function handleSave() {
|
||||
cloud_type: form.cloud_type as CloudType,
|
||||
nickname: form.nickname,
|
||||
promotion_account: form.promotion_account,
|
||||
is_transfer_enabled: form.is_transfer_enabled,
|
||||
is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
|
||||
cookie: form.cookie || undefined,
|
||||
is_active: true,
|
||||
is_active: 1,
|
||||
storage_used: form._storageUsed || undefined,
|
||||
storage_total: form._storageTotal || undefined,
|
||||
})
|
||||
@@ -495,9 +515,9 @@ async function handleSave() {
|
||||
cloud_type: form.cloud_type as CloudType,
|
||||
nickname: form.nickname,
|
||||
promotion_account: form.promotion_account,
|
||||
is_transfer_enabled: form.is_transfer_enabled,
|
||||
is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
|
||||
cookie: form.cookie,
|
||||
is_active: true,
|
||||
is_active: 1,
|
||||
storage_used: form._storageUsed || undefined,
|
||||
storage_total: form._storageTotal || undefined,
|
||||
})
|
||||
|
||||
@@ -85,15 +85,15 @@
|
||||
<el-table-column type="expand" width="36">
|
||||
<template #default="{ row }">
|
||||
<div class="expand-detail">
|
||||
<!-- Row 1: 原始链接 + 文件夹数量 + 文件数量 -->
|
||||
<!-- Row 1: 原始链接 + 文件大小 + 文件夹+文件数 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-cell">
|
||||
<div class="detail-cell" style="flex:2">
|
||||
<span class="detail-label">原始链接</span>
|
||||
<a :href="row.source_url" target="_blank" class="detail-link">{{ row.source_url }}</a>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.original_folder_name">
|
||||
<span class="detail-label">原始文件夹名</span>
|
||||
<code class="detail-code">{{ row.original_folder_name }}</code>
|
||||
<div class="detail-cell" v-if="row.file_size">
|
||||
<span class="detail-label">文件大小</span>
|
||||
<code class="detail-code">{{ row.file_size ? (function(n){if(n==null||n==='')return'-';var v=typeof n==='string'?parseInt(n,10):n;if(!v||v<=0)return'-';var u=['B','KB','MB','GB','TB'];var i=0,s=v;while(s>=1024&&i<4){s/=1024;i++}return s.toFixed(i===0?0:2)+' '+u[i]})(row.file_size) : '-' }}</code>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
|
||||
<span class="detail-label">文件夹</span>
|
||||
@@ -108,9 +108,24 @@
|
||||
<span class="reuse-msg">♻️ 直接使用已有分享链接,无需实际转存</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: 分享链接 + 分享密码 + 转存文件夹 -->
|
||||
<!-- Row 2: 使用账号 + 原始文件夹名 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-cell" v-if="row.share_url">
|
||||
<div class="detail-cell" v-if="row.config_nickname">
|
||||
<span class="detail-label">使用账号</span>
|
||||
<el-tag size="small" type="success" effect="plain">{{ row.config_nickname }}</el-tag>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.original_folder_name">
|
||||
<span class="detail-label">原始文件夹名</span>
|
||||
<code class="detail-code">{{ row.original_folder_name }}</code>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.folder_name">
|
||||
<span class="detail-label">转存文件夹</span>
|
||||
<code class="detail-code">{{ row.folder_name }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: 分享链接 + 分享密码 + 耗时 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-cell" v-if="row.share_url" style="flex:2">
|
||||
<span class="detail-label">分享链接</span>
|
||||
<a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a>
|
||||
</div>
|
||||
@@ -118,12 +133,12 @@
|
||||
<span class="detail-label">分享密码</span>
|
||||
<el-tag size="small" type="warning">{{ row.share_pwd }}</el-tag>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.folder_name">
|
||||
<span class="detail-label">转存文件夹</span>
|
||||
<code class="detail-code">{{ row.folder_name }}</code>
|
||||
<div class="detail-cell">
|
||||
<span class="detail-label">耗时</span>
|
||||
<span :class="['detail-duration', durationClass(row.duration_ms)]">{{ formatDuration(row.duration_ms) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: IP地址 + 归属地 -->
|
||||
<!-- Row 4: IP地址 + 归属地 + 创建时间 -->
|
||||
<div class="detail-row" v-if="row.ip_address">
|
||||
<div class="detail-cell">
|
||||
<span class="detail-label">IP 地址</span>
|
||||
@@ -133,12 +148,22 @@
|
||||
<span class="detail-label">归属地</span>
|
||||
<code class="detail-code">{{ formatLocation(row.ip_location) }}</code>
|
||||
</div>
|
||||
<div class="detail-cell">
|
||||
<span class="detail-label">时间</span>
|
||||
<code class="detail-code">{{ formatTime(row.created_at) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 4: 错误信息(整行) -->
|
||||
<!-- Row 5: 错误信息(整行) -->
|
||||
<div class="detail-row" v-if="row.status === 'failed' && row.error_message">
|
||||
<div class="detail-cell detail-full">
|
||||
<span class="detail-label">错误信息</span>
|
||||
<pre class="detail-error">{{ row.error_message }}</pre>
|
||||
<pre class="detail-error">{{ row.error_message.includes(' | ') ? row.error_message.split(' | ')[1] : row.error_message.split(' | ')[0] }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row" v-if="row.status === 'failed' && row.error_message && row.error_message.includes(' | ')">
|
||||
<div class="detail-cell detail-full">
|
||||
<span class="detail-label">友好提示</span>
|
||||
<pre class="detail-error">{{ row.error_message.split(' | ')[0] }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,7 +339,7 @@ function formatTime(t: string): string {
|
||||
const d = new Date(ts)
|
||||
if (isNaN(d.getTime())) return t
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
|
||||
Reference in New Issue
Block a user