feat: add share security, resumable upload, global search and reservation ops panel
This commit is contained in:
@@ -223,6 +223,11 @@ function initDatabase() {
|
||||
share_path TEXT NOT NULL,
|
||||
share_type TEXT DEFAULT 'file',
|
||||
share_password TEXT,
|
||||
max_downloads INTEGER DEFAULT NULL,
|
||||
ip_whitelist TEXT,
|
||||
device_limit TEXT DEFAULT 'all',
|
||||
access_time_start TEXT,
|
||||
access_time_end TEXT,
|
||||
|
||||
-- 分享统计
|
||||
view_count INTEGER DEFAULT 0,
|
||||
@@ -333,6 +338,39 @@ function initDatabase() {
|
||||
console.error('数据库迁移(share_type)失败:', error);
|
||||
}
|
||||
|
||||
// 数据库迁移:分享高级安全策略字段
|
||||
try {
|
||||
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
|
||||
const hasMaxDownloads = shareColumns.some(col => col.name === 'max_downloads');
|
||||
const hasIpWhitelist = shareColumns.some(col => col.name === 'ip_whitelist');
|
||||
const hasDeviceLimit = shareColumns.some(col => col.name === 'device_limit');
|
||||
const hasAccessTimeStart = shareColumns.some(col => col.name === 'access_time_start');
|
||||
const hasAccessTimeEnd = shareColumns.some(col => col.name === 'access_time_end');
|
||||
|
||||
if (!hasMaxDownloads) {
|
||||
db.exec('ALTER TABLE shares ADD COLUMN max_downloads INTEGER DEFAULT NULL');
|
||||
console.log('数据库迁移:添加 shares.max_downloads 字段完成');
|
||||
}
|
||||
if (!hasIpWhitelist) {
|
||||
db.exec('ALTER TABLE shares ADD COLUMN ip_whitelist TEXT');
|
||||
console.log('数据库迁移:添加 shares.ip_whitelist 字段完成');
|
||||
}
|
||||
if (!hasDeviceLimit) {
|
||||
db.exec('ALTER TABLE shares ADD COLUMN device_limit TEXT DEFAULT "all"');
|
||||
console.log('数据库迁移:添加 shares.device_limit 字段完成');
|
||||
}
|
||||
if (!hasAccessTimeStart) {
|
||||
db.exec('ALTER TABLE shares ADD COLUMN access_time_start TEXT');
|
||||
console.log('数据库迁移:添加 shares.access_time_start 字段完成');
|
||||
}
|
||||
if (!hasAccessTimeEnd) {
|
||||
db.exec('ALTER TABLE shares ADD COLUMN access_time_end TEXT');
|
||||
console.log('数据库迁移:添加 shares.access_time_end 字段完成');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('数据库迁移(分享安全策略)失败:', error);
|
||||
}
|
||||
|
||||
// 数据库迁移:邮箱验证字段
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
@@ -445,6 +483,47 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// 本地分片上传会话(断点续传)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS upload_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
storage_type TEXT NOT NULL DEFAULT 'local',
|
||||
target_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_size INTEGER NOT NULL DEFAULT 0,
|
||||
total_chunks INTEGER NOT NULL DEFAULT 0,
|
||||
uploaded_chunks TEXT NOT NULL DEFAULT '',
|
||||
uploaded_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
temp_file_path TEXT NOT NULL,
|
||||
file_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active/completed/expired/cancelled
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// 文件哈希索引(用于秒传)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_file_hash_index (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
storage_type TEXT NOT NULL DEFAULT 'local',
|
||||
file_hash TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
file_path TEXT NOT NULL,
|
||||
object_key TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, storage_type, file_hash, file_size, file_path),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// 日志表索引
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON system_logs(created_at);
|
||||
@@ -468,6 +547,18 @@ function initDatabase() {
|
||||
-- 已处理日志索引(按处理时间回溯)
|
||||
CREATE INDEX IF NOT EXISTS idx_ingested_logs_processed_at
|
||||
ON download_traffic_ingested_logs(processed_at);
|
||||
|
||||
-- 断点上传会话索引
|
||||
CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status_expires
|
||||
ON upload_sessions(user_id, status, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
|
||||
ON upload_sessions(user_id, file_hash, file_size);
|
||||
|
||||
-- 秒传索引
|
||||
CREATE INDEX IF NOT EXISTS idx_file_hash_index_lookup
|
||||
ON user_file_hash_index(user_id, storage_type, file_hash, file_size);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_hash_index_path
|
||||
ON user_file_hash_index(user_id, storage_type, file_path);
|
||||
`);
|
||||
|
||||
console.log('[数据库性能优化] ✓ 日志表复合索引已创建');
|
||||
@@ -1142,7 +1233,12 @@ const ShareDB = {
|
||||
file_path = '',
|
||||
file_name = '',
|
||||
password = null,
|
||||
expiry_days = null
|
||||
expiry_days = null,
|
||||
max_downloads = null,
|
||||
ip_whitelist = null,
|
||||
device_limit = 'all',
|
||||
access_time_start = null,
|
||||
access_time_end = null
|
||||
} = options;
|
||||
|
||||
let shareCode;
|
||||
@@ -1174,8 +1270,11 @@ const ShareDB = {
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO shares (
|
||||
user_id, share_code, share_path, share_type, share_password, expires_at,
|
||||
max_downloads, ip_whitelist, device_limit, access_time_start, access_time_end
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const hashedPassword = password ? bcrypt.hashSync(password, 10) : null;
|
||||
@@ -1199,7 +1298,12 @@ const ShareDB = {
|
||||
sharePath,
|
||||
share_type,
|
||||
hashedPassword,
|
||||
expiresAt
|
||||
expiresAt,
|
||||
max_downloads,
|
||||
ip_whitelist,
|
||||
device_limit || 'all',
|
||||
access_time_start,
|
||||
access_time_end
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -1207,6 +1311,11 @@ const ShareDB = {
|
||||
share_code: shareCode,
|
||||
share_type: share_type,
|
||||
expires_at: expiresAt,
|
||||
max_downloads: max_downloads ?? null,
|
||||
ip_whitelist: ip_whitelist ?? null,
|
||||
device_limit: device_limit || 'all',
|
||||
access_time_start: access_time_start ?? null,
|
||||
access_time_end: access_time_end ?? null
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1221,6 +1330,7 @@ const ShareDB = {
|
||||
s.id, s.user_id, s.share_code, s.share_path, s.share_type,
|
||||
s.storage_type,
|
||||
s.share_password,
|
||||
s.max_downloads, s.ip_whitelist, s.device_limit, s.access_time_start, s.access_time_end,
|
||||
s.view_count, s.download_count, s.created_at, s.expires_at,
|
||||
u.username,
|
||||
-- OSS 配置(访问分享文件所需)
|
||||
@@ -2029,6 +2139,481 @@ const DownloadTrafficReservationDB = {
|
||||
}
|
||||
|
||||
return { consumed, finalizedCount };
|
||||
},
|
||||
|
||||
getAdminList(options = {}) {
|
||||
const where = ['1=1'];
|
||||
const params = [];
|
||||
|
||||
const status = typeof options.status === 'string' ? options.status.trim() : '';
|
||||
const allowedStatus = new Set(['pending', 'confirmed', 'expired', 'cancelled']);
|
||||
if (allowedStatus.has(status)) {
|
||||
where.push('r.status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
const uid = Number(options.userId);
|
||||
if (Number.isFinite(uid) && uid > 0) {
|
||||
where.push('r.user_id = ?');
|
||||
params.push(Math.floor(uid));
|
||||
}
|
||||
|
||||
const keyword = typeof options.keyword === 'string' ? options.keyword.trim() : '';
|
||||
if (keyword) {
|
||||
const likeValue = `%${keyword}%`;
|
||||
where.push('(u.username LIKE ? OR COALESCE(r.object_key, \'\') LIKE ? OR r.source LIKE ?)');
|
||||
params.push(likeValue, likeValue, likeValue);
|
||||
}
|
||||
|
||||
const whereSql = where.join(' AND ');
|
||||
const page = Math.max(1, Math.floor(Number(options.page) || 1));
|
||||
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(options.pageSize) || 20)));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const totalRow = db.prepare(`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM user_download_traffic_reservations r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE ${whereSql}
|
||||
`).get(...params);
|
||||
const total = Math.max(0, Number(totalRow?.total || 0));
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const safeOffset = (safePage - 1) * pageSize;
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
r.*,
|
||||
u.username
|
||||
FROM user_download_traffic_reservations r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY r.created_at DESC, r.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, pageSize, safeOffset);
|
||||
|
||||
return {
|
||||
rows,
|
||||
pagination: {
|
||||
page: safePage,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
getAdminSummary() {
|
||||
const statusRows = db.prepare(`
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(remaining_bytes), 0) AS remaining_bytes,
|
||||
COALESCE(SUM(reserved_bytes), 0) AS reserved_bytes
|
||||
FROM user_download_traffic_reservations
|
||||
GROUP BY status
|
||||
`).all();
|
||||
|
||||
const summary = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
confirmed: 0,
|
||||
expired: 0,
|
||||
cancelled: 0,
|
||||
pending_remaining_bytes: 0,
|
||||
pending_reserved_bytes: 0,
|
||||
pending_expiring_soon: 0
|
||||
};
|
||||
|
||||
for (const row of statusRows) {
|
||||
const status = String(row.status || '').trim();
|
||||
const count = Number(row.count || 0);
|
||||
const remaining = Number(row.remaining_bytes || 0);
|
||||
const reserved = Number(row.reserved_bytes || 0);
|
||||
|
||||
summary.total += count;
|
||||
if (Object.prototype.hasOwnProperty.call(summary, status)) {
|
||||
summary[status] = count;
|
||||
}
|
||||
if (status === 'pending') {
|
||||
summary.pending_remaining_bytes = Math.max(0, remaining);
|
||||
summary.pending_reserved_bytes = Math.max(0, reserved);
|
||||
}
|
||||
}
|
||||
|
||||
const expiringSoonRow = db.prepare(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM user_download_traffic_reservations
|
||||
WHERE status = 'pending'
|
||||
AND expires_at <= datetime('now', 'localtime', '+5 minutes')
|
||||
`).get();
|
||||
summary.pending_expiring_soon = Number(expiringSoonRow?.count || 0);
|
||||
|
||||
return summary;
|
||||
},
|
||||
|
||||
cancelPendingById(id) {
|
||||
const rid = Number(id);
|
||||
if (!Number.isFinite(rid) || rid <= 0) {
|
||||
return { changes: 0, row: null };
|
||||
}
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT *
|
||||
FROM user_download_traffic_reservations
|
||||
WHERE id = ?
|
||||
AND status = 'pending'
|
||||
`).get(Math.floor(rid));
|
||||
if (!row) {
|
||||
return { changes: 0, row: null };
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE user_download_traffic_reservations
|
||||
SET status = 'cancelled',
|
||||
remaining_bytes = 0,
|
||||
finalized_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
AND status = 'pending'
|
||||
`).run(Math.floor(rid));
|
||||
|
||||
return {
|
||||
changes: Number(result?.changes || 0),
|
||||
row: db.prepare('SELECT * FROM user_download_traffic_reservations WHERE id = ?').get(Math.floor(rid))
|
||||
};
|
||||
},
|
||||
|
||||
cleanupFinalizedHistory(keepDays = 7) {
|
||||
const days = Math.min(365, Math.max(1, Math.floor(Number(keepDays) || 7)));
|
||||
return db.prepare(`
|
||||
DELETE FROM user_download_traffic_reservations
|
||||
WHERE status IN ('confirmed', 'expired', 'cancelled')
|
||||
AND updated_at < datetime('now', 'localtime', '-' || ? || ' days')
|
||||
`).run(days);
|
||||
}
|
||||
};
|
||||
|
||||
const UploadSessionDB = {
|
||||
_normalizeSessionId(sessionId) {
|
||||
return typeof sessionId === 'string' ? sessionId.trim() : '';
|
||||
},
|
||||
|
||||
parseUploadedChunks(chunksText) {
|
||||
const raw = typeof chunksText === 'string' ? chunksText.trim() : '';
|
||||
if (!raw) return [];
|
||||
const set = new Set();
|
||||
for (const part of raw.split(',')) {
|
||||
const idx = Number(part);
|
||||
if (Number.isFinite(idx) && idx >= 0) {
|
||||
set.add(Math.floor(idx));
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a - b);
|
||||
},
|
||||
|
||||
formatUploadedChunks(chunks = []) {
|
||||
if (!Array.isArray(chunks) || chunks.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const set = new Set();
|
||||
for (const chunk of chunks) {
|
||||
const idx = Number(chunk);
|
||||
if (Number.isFinite(idx) && idx >= 0) {
|
||||
set.add(Math.floor(idx));
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a - b).join(',');
|
||||
},
|
||||
|
||||
create(data = {}) {
|
||||
const sessionId = this._normalizeSessionId(data.sessionId);
|
||||
const userId = Number(data.userId);
|
||||
const storageType = typeof data.storageType === 'string' ? data.storageType : 'local';
|
||||
const targetPath = typeof data.targetPath === 'string' ? data.targetPath : '';
|
||||
const fileName = typeof data.fileName === 'string' ? data.fileName : '';
|
||||
const fileSize = Math.max(0, Math.floor(Number(data.fileSize) || 0));
|
||||
const chunkSize = Math.max(1, Math.floor(Number(data.chunkSize) || 1));
|
||||
const totalChunks = Math.max(1, Math.floor(Number(data.totalChunks) || 1));
|
||||
const uploadedChunks = this.formatUploadedChunks(data.uploadedChunks || []);
|
||||
const uploadedBytes = Math.max(0, Math.floor(Number(data.uploadedBytes) || 0));
|
||||
const tempFilePath = typeof data.tempFilePath === 'string' ? data.tempFilePath : '';
|
||||
const fileHash = typeof data.fileHash === 'string' ? data.fileHash : null;
|
||||
const expiresAt = typeof data.expiresAt === 'string' ? data.expiresAt : null;
|
||||
|
||||
if (!sessionId || !Number.isFinite(userId) || userId <= 0 || !targetPath || !fileName || !tempFilePath || !expiresAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO upload_sessions (
|
||||
session_id, user_id, storage_type, target_path, file_name,
|
||||
file_size, chunk_size, total_chunks, uploaded_chunks, uploaded_bytes,
|
||||
temp_file_path, file_hash, status, expires_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, 'active', ?, datetime('now', 'localtime'), datetime('now', 'localtime')
|
||||
)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
storage_type = excluded.storage_type,
|
||||
target_path = excluded.target_path,
|
||||
file_name = excluded.file_name,
|
||||
file_size = excluded.file_size,
|
||||
chunk_size = excluded.chunk_size,
|
||||
total_chunks = excluded.total_chunks,
|
||||
uploaded_chunks = excluded.uploaded_chunks,
|
||||
uploaded_bytes = excluded.uploaded_bytes,
|
||||
temp_file_path = excluded.temp_file_path,
|
||||
file_hash = excluded.file_hash,
|
||||
status = 'active',
|
||||
expires_at = excluded.expires_at,
|
||||
completed_at = NULL,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
`).run(
|
||||
sessionId,
|
||||
Math.floor(userId),
|
||||
storageType,
|
||||
targetPath,
|
||||
fileName,
|
||||
fileSize,
|
||||
chunkSize,
|
||||
totalChunks,
|
||||
uploadedChunks,
|
||||
uploadedBytes,
|
||||
tempFilePath,
|
||||
fileHash,
|
||||
expiresAt
|
||||
);
|
||||
|
||||
return this.findBySessionId(sessionId);
|
||||
},
|
||||
|
||||
findBySessionId(sessionId) {
|
||||
const sid = this._normalizeSessionId(sessionId);
|
||||
if (!sid) return null;
|
||||
return db.prepare('SELECT * FROM upload_sessions WHERE session_id = ?').get(sid);
|
||||
},
|
||||
|
||||
findActiveForResume(userId, targetPath, fileSize, fileHash = null) {
|
||||
const uid = Number(userId);
|
||||
const safePath = typeof targetPath === 'string' ? targetPath : '';
|
||||
const size = Math.floor(Number(fileSize) || 0);
|
||||
const hash = typeof fileHash === 'string' ? fileHash.trim() : '';
|
||||
if (!Number.isFinite(uid) || uid <= 0 || !safePath || size <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hash) {
|
||||
return db.prepare(`
|
||||
SELECT *
|
||||
FROM upload_sessions
|
||||
WHERE user_id = ?
|
||||
AND target_path = ?
|
||||
AND file_size = ?
|
||||
AND status = 'active'
|
||||
AND expires_at > datetime('now', 'localtime')
|
||||
AND COALESCE(file_hash, '') = ?
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`).get(Math.floor(uid), safePath, size, hash);
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
SELECT *
|
||||
FROM upload_sessions
|
||||
WHERE user_id = ?
|
||||
AND target_path = ?
|
||||
AND file_size = ?
|
||||
AND status = 'active'
|
||||
AND expires_at > datetime('now', 'localtime')
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`).get(Math.floor(uid), safePath, size);
|
||||
},
|
||||
|
||||
updateProgress(sessionId, uploadedChunks = [], uploadedBytes = 0, expiresAt = null) {
|
||||
const sid = this._normalizeSessionId(sessionId);
|
||||
if (!sid) return { changes: 0 };
|
||||
const chunksText = this.formatUploadedChunks(uploadedChunks);
|
||||
const bytes = Math.max(0, Math.floor(Number(uploadedBytes) || 0));
|
||||
|
||||
if (typeof expiresAt === 'string' && expiresAt) {
|
||||
return db.prepare(`
|
||||
UPDATE upload_sessions
|
||||
SET uploaded_chunks = ?,
|
||||
uploaded_bytes = ?,
|
||||
expires_at = ?,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE session_id = ?
|
||||
`).run(chunksText, bytes, expiresAt, sid);
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
UPDATE upload_sessions
|
||||
SET uploaded_chunks = ?,
|
||||
uploaded_bytes = ?,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE session_id = ?
|
||||
`).run(chunksText, bytes, sid);
|
||||
},
|
||||
|
||||
setStatus(sessionId, status, { completed = false, expiresAt = null } = {}) {
|
||||
const sid = this._normalizeSessionId(sessionId);
|
||||
const safeStatus = typeof status === 'string' ? status : '';
|
||||
if (!sid || !safeStatus) return { changes: 0 };
|
||||
|
||||
if (expiresAt && completed) {
|
||||
return db.prepare(`
|
||||
UPDATE upload_sessions
|
||||
SET status = ?,
|
||||
expires_at = ?,
|
||||
completed_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE session_id = ?
|
||||
`).run(safeStatus, expiresAt, sid);
|
||||
}
|
||||
|
||||
if (expiresAt) {
|
||||
return db.prepare(`
|
||||
UPDATE upload_sessions
|
||||
SET status = ?,
|
||||
expires_at = ?,
|
||||
completed_at = CASE WHEN ? = 1 THEN datetime('now', 'localtime') ELSE completed_at END,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE session_id = ?
|
||||
`).run(safeStatus, expiresAt, completed ? 1 : 0, sid);
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
UPDATE upload_sessions
|
||||
SET status = ?,
|
||||
completed_at = CASE WHEN ? = 1 THEN datetime('now', 'localtime') ELSE completed_at END,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE session_id = ?
|
||||
`).run(safeStatus, completed ? 1 : 0, sid);
|
||||
},
|
||||
|
||||
delete(sessionId) {
|
||||
const sid = this._normalizeSessionId(sessionId);
|
||||
if (!sid) return { changes: 0 };
|
||||
return db.prepare('DELETE FROM upload_sessions WHERE session_id = ?').run(sid);
|
||||
},
|
||||
|
||||
listExpiredActive(limit = 100) {
|
||||
const safeLimit = Math.min(500, Math.max(1, Math.floor(Number(limit) || 100)));
|
||||
return db.prepare(`
|
||||
SELECT *
|
||||
FROM upload_sessions
|
||||
WHERE status = 'active'
|
||||
AND expires_at <= datetime('now', 'localtime')
|
||||
ORDER BY expires_at ASC
|
||||
LIMIT ?
|
||||
`).all(safeLimit);
|
||||
},
|
||||
|
||||
expireSession(sessionId) {
|
||||
const sid = this._normalizeSessionId(sessionId);
|
||||
if (!sid) return { changes: 0 };
|
||||
return db.prepare(`
|
||||
UPDATE upload_sessions
|
||||
SET status = 'expired',
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE session_id = ?
|
||||
AND status = 'active'
|
||||
`).run(sid);
|
||||
}
|
||||
};
|
||||
|
||||
const FileHashIndexDB = {
|
||||
normalizeStorageType(storageType) {
|
||||
return storageType === 'oss' ? 'oss' : 'local';
|
||||
},
|
||||
|
||||
upsert(entry = {}) {
|
||||
const userId = Number(entry.userId);
|
||||
const storageType = this.normalizeStorageType(entry.storageType);
|
||||
const fileHash = typeof entry.fileHash === 'string' ? entry.fileHash.trim() : '';
|
||||
const fileSize = Math.max(0, Math.floor(Number(entry.fileSize) || 0));
|
||||
const filePath = typeof entry.filePath === 'string' ? entry.filePath : '';
|
||||
const objectKey = typeof entry.objectKey === 'string' && entry.objectKey ? entry.objectKey : null;
|
||||
|
||||
if (!Number.isFinite(userId) || userId <= 0 || !fileHash || fileSize <= 0 || !filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
INSERT INTO user_file_hash_index (
|
||||
user_id, storage_type, file_hash, file_size, file_path, object_key, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
||||
ON CONFLICT(user_id, storage_type, file_hash, file_size, file_path)
|
||||
DO UPDATE SET
|
||||
object_key = excluded.object_key,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
`).run(
|
||||
Math.floor(userId),
|
||||
storageType,
|
||||
fileHash,
|
||||
fileSize,
|
||||
filePath,
|
||||
objectKey
|
||||
);
|
||||
},
|
||||
|
||||
findLatestByHash(userId, storageType, fileHash, fileSize) {
|
||||
const uid = Number(userId);
|
||||
const normalizedStorage = this.normalizeStorageType(storageType);
|
||||
const hash = typeof fileHash === 'string' ? fileHash.trim() : '';
|
||||
const size = Math.max(0, Math.floor(Number(fileSize) || 0));
|
||||
if (!Number.isFinite(uid) || uid <= 0 || !hash || size <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
SELECT *
|
||||
FROM user_file_hash_index
|
||||
WHERE user_id = ?
|
||||
AND storage_type = ?
|
||||
AND file_hash = ?
|
||||
AND file_size = ?
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`).get(Math.floor(uid), normalizedStorage, hash, size);
|
||||
},
|
||||
|
||||
deleteByPath(userId, storageType, filePath) {
|
||||
const uid = Number(userId);
|
||||
const normalizedStorage = this.normalizeStorageType(storageType);
|
||||
const safePath = typeof filePath === 'string' ? filePath : '';
|
||||
if (!Number.isFinite(uid) || uid <= 0 || !safePath) {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
DELETE FROM user_file_hash_index
|
||||
WHERE user_id = ?
|
||||
AND storage_type = ?
|
||||
AND file_path = ?
|
||||
`).run(Math.floor(uid), normalizedStorage, safePath);
|
||||
},
|
||||
|
||||
getByPath(userId, storageType, filePath) {
|
||||
const uid = Number(userId);
|
||||
const normalizedStorage = this.normalizeStorageType(storageType);
|
||||
const safePath = typeof filePath === 'string' ? filePath : '';
|
||||
if (!Number.isFinite(uid) || uid <= 0 || !safePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
SELECT *
|
||||
FROM user_file_hash_index
|
||||
WHERE user_id = ?
|
||||
AND storage_type = ?
|
||||
AND file_path = ?
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`).get(Math.floor(uid), normalizedStorage, safePath);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2284,6 +2869,8 @@ module.exports = {
|
||||
PasswordResetTokenDB,
|
||||
DownloadTrafficReportDB,
|
||||
DownloadTrafficReservationDB,
|
||||
UploadSessionDB,
|
||||
FileHashIndexDB,
|
||||
DownloadTrafficIngestDB,
|
||||
SystemLogDB,
|
||||
TransactionDB,
|
||||
|
||||
Reference in New Issue
Block a user