// ============================================================ // 闲言APP — 云端暂存API路由 // 创建时间: 2026-05-12 // 更新时间: 2026-05-12 // 作用: 云端暂存文件上传/下载/列表/删除/定时清理 // 上次更新: v11.3.0 初始版本 — Express+multer+node-cron // ============================================================ const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const router = express.Router(); // ============================================================ // 配置 // ============================================================ const DATA_DIR = path.join(__dirname, 'data', 'cloud_cache'); const META_FILE = path.join(DATA_DIR, 'meta.json'); const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB const DEFAULT_EXPIRE_HOURS = 24; const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1小时 // ============================================================ // 初始化 // ============================================================ function ensureDataDir() { if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } } function loadMeta() { ensureDataDir(); try { if (fs.existsSync(META_FILE)) { return JSON.parse(fs.readFileSync(META_FILE, 'utf8')); } } catch (e) { console.error('CloudCache: Failed to load meta:', e.message); } return {}; } function saveMeta(meta) { ensureDataDir(); try { fs.writeFileSync(META_FILE, JSON.stringify(meta, null, 2)); } catch (e) { console.error('CloudCache: Failed to save meta:', e.message); } } // ============================================================ // Multer配置 // ============================================================ const storage = multer.diskStorage({ destination: function (req, file, cb) { ensureDataDir(); cb(null, DATA_DIR); }, filename: function (req, file, cb) { const cacheId = req.body.cacheId || crypto.randomUUID(); const ext = path.extname(file.originalname) || '.enc'; cb(null, cacheId + ext); } }); const upload = multer({ storage: storage, limits: { fileSize: MAX_FILE_SIZE } }); // ============================================================ // 中间件: 请求日志 // ============================================================ router.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = Date.now() - start; console.log(`CloudCache: ${req.method} ${req.path} ${res.statusCode} ${duration}ms`); }); next(); }); // ============================================================ // POST /upload — 上传暂存文件 // ============================================================ router.post('/upload', upload.single('file'), (req, res) => { try { if (!req.file) { return res.json({ code: 0, msg: 'No file provided', data: null }); } const fromId = req.body.fromId || ''; const toId = req.body.toId || ''; const encryptKeyHash = req.body.encryptKeyHash || ''; const expireHours = parseInt(req.body.expireHours) || DEFAULT_EXPIRE_HOURS; const fileName = req.body.fileName || req.file.originalname; const fileSize = parseInt(req.body.fileSize) || req.file.size; const mimeType = req.body.mimeType || 'application/octet-stream'; if (!fromId || !toId) { fs.unlinkSync(req.file.path); return res.json({ code: 0, msg: 'fromId and toId required', data: null }); } const cacheId = path.basename(req.file.filename, path.extname(req.file.filename)); const now = Date.now(); const expiresAt = now + expireHours * 3600 * 1000; const meta = loadMeta(); meta[cacheId] = { id: cacheId, fileName: fileName, fileSize: fileSize, mimeType: mimeType, fromId: fromId, toId: toId, encryptKeyHash: encryptKeyHash, storedFile: req.file.filename, uploadedAt: now, expiresAt: expiresAt, downloadedBy: [], status: 'active' }; saveMeta(meta); console.log(`CloudCache: Uploaded cacheId=${cacheId} fileName=${fileName} fromId=${fromId} toId=${toId} expiresAt=${expiresAt}`); res.json({ code: 1, msg: 'ok', data: { cacheId: cacheId, expiresAt: expiresAt, uploadedAt: now } }); } catch (e) { console.error('CloudCache: Upload error:', e.message); res.json({ code: 0, msg: 'Upload failed: ' + e.message, data: null }); } }); // ============================================================ // GET /download — 下载暂存文件 // ============================================================ router.get('/download', (req, res) => { try { const cacheId = req.query.cacheId; const deviceId = req.query.deviceId || req.headers['x-device-id'] || ''; if (!cacheId) { return res.json({ code: 0, msg: 'cacheId required', data: null }); } const meta = loadMeta(); const record = meta[cacheId]; if (!record || record.status !== 'active') { return res.json({ code: 0, msg: 'Cache not found or expired', data: null }); } if (Date.now() > record.expiresAt) { _deleteCache(cacheId, meta); return res.json({ code: 0, msg: 'Cache expired', data: null }); } const filePath = path.join(DATA_DIR, record.storedFile); if (!fs.existsSync(filePath)) { delete meta[cacheId]; saveMeta(meta); return res.json({ code: 0, msg: 'File not found on disk', data: null }); } if (deviceId && !record.downloadedBy.includes(deviceId)) { record.downloadedBy.push(deviceId); saveMeta(meta); } const downloadName = record.fileName || cacheId + '.enc'; res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(downloadName)}"`); res.setHeader('X-Cache-Id', cacheId); res.setHeader('X-From-Id', record.fromId); const fileStream = fs.createReadStream(filePath); fileStream.pipe(res); console.log(`CloudCache: Downloaded cacheId=${cacheId} deviceId=${deviceId}`); } catch (e) { console.error('CloudCache: Download error:', e.message); res.json({ code: 0, msg: 'Download failed: ' + e.message, data: null }); } }); // ============================================================ // GET /list — 查询暂存列表 // ============================================================ router.get('/list', (req, res) => { try { const userId = req.query.userId || req.headers['x-device-id'] || ''; if (!userId) { return res.json({ code: 0, msg: 'userId required', data: null }); } const meta = loadMeta(); const now = Date.now(); const items = []; for (const [cacheId, record] of Object.entries(meta)) { if (record.status !== 'active') continue; if (now > record.expiresAt) continue; if (record.toId === userId || record.fromId === userId) { items.push({ cacheId: record.id, fileName: record.fileName, fileSize: record.fileSize, mimeType: record.mimeType, fromId: record.fromId, toId: record.toId, encryptKeyHash: record.encryptKeyHash, uploadedAt: record.uploadedAt, expiresAt: record.expiresAt, isDownloaded: record.downloadedBy.includes(userId), direction: record.toId === userId ? 'incoming' : 'outgoing' }); } } items.sort((a, b) => b.uploadedAt - a.uploadedAt); res.json({ code: 1, msg: 'ok', data: items }); } catch (e) { console.error('CloudCache: List error:', e.message); res.json({ code: 0, msg: 'List failed: ' + e.message, data: null }); } }); // ============================================================ // DELETE /delete — 删除暂存 // ============================================================ router.delete('/delete', (req, res) => { try { const { cacheId, deviceId } = req.body; if (!cacheId) { return res.json({ code: 0, msg: 'cacheId required', data: null }); } const meta = loadMeta(); const record = meta[cacheId]; if (!record) { return res.json({ code: 0, msg: 'Cache not found', data: null }); } if (deviceId && record.fromId !== deviceId && record.toId !== deviceId) { return res.json({ code: 0, msg: 'Not authorized', data: null }); } _deleteCache(cacheId, meta); res.json({ code: 1, msg: 'ok', data: { cacheId: cacheId } }); } catch (e) { console.error('CloudCache: Delete error:', e.message); res.json({ code: 0, msg: 'Delete failed: ' + e.message, data: null }); } }); // ============================================================ // POST /notify — 通知接收方有暂存文件 // ============================================================ router.post('/notify', (req, res) => { try { const { cacheId, toId, fromId, fileName } = req.body; if (!cacheId || !toId) { return res.json({ code: 0, msg: 'cacheId and toId required', data: null }); } if (typeof notifyCallback === 'function') { notifyCallback({ type: 'cloud-cache-notify', from: fromId || 'server', to: toId, payload: { cacheId: cacheId, fileName: fileName || '', fromId: fromId || '' } }); } res.json({ code: 1, msg: 'ok', data: { notified: true, toId: toId } }); } catch (e) { console.error('CloudCache: Notify error:', e.message); res.json({ code: 0, msg: 'Notify failed: ' + e.message, data: null }); } }); // ============================================================ // GET /info — 查询单个暂存信息 // ============================================================ router.get('/info', (req, res) => { try { const cacheId = req.query.cacheId; if (!cacheId) { return res.json({ code: 0, msg: 'cacheId required', data: null }); } const meta = loadMeta(); const record = meta[cacheId]; if (!record || record.status !== 'active') { return res.json({ code: 0, msg: 'Cache not found', data: null }); } res.json({ code: 1, msg: 'ok', data: { cacheId: record.id, fileName: record.fileName, fileSize: record.fileSize, mimeType: record.mimeType, fromId: record.fromId, toId: record.toId, uploadedAt: record.uploadedAt, expiresAt: record.expiresAt, downloadedBy: record.downloadedBy, isExpired: Date.now() > record.expiresAt } }); } catch (e) { console.error('CloudCache: Info error:', e.message); res.json({ code: 0, msg: 'Info failed: ' + e.message, data: null }); } }); // ============================================================ // 内部方法 // ============================================================ function _deleteCache(cacheId, meta) { const record = meta[cacheId]; if (record && record.storedFile) { const filePath = path.join(DATA_DIR, record.storedFile); try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } catch (e) { console.error('CloudCache: Failed to delete file:', e.message); } } delete meta[cacheId]; saveMeta(meta); console.log(`CloudCache: Deleted cacheId=${cacheId}`); } function _cleanupExpired() { const meta = loadMeta(); const now = Date.now(); let cleaned = 0; for (const [cacheId, record] of Object.entries(meta)) { if (now > record.expiresAt || record.status === 'deleted') { _deleteCache(cacheId, meta); cleaned++; } } if (cleaned > 0) { console.log(`CloudCache: Cleaned ${cleaned} expired caches`); } return cleaned; } // ============================================================ // 定时清理 // ============================================================ let _cleanupTimer = null; function startCleanupSchedule() { if (_cleanupTimer) return; _cleanupTimer = setInterval(_cleanupExpired, CLEANUP_INTERVAL_MS); console.log('CloudCache: Cleanup schedule started (every 1 hour)'); } function stopCleanupSchedule() { if (_cleanupTimer) { clearInterval(_cleanupTimer); _cleanupTimer = null; console.log('CloudCache: Cleanup schedule stopped'); } } // ============================================================ // 通知回调(由外部设置) // ============================================================ let notifyCallback = null; function setNotifyCallback(callback) { notifyCallback = callback; } // ============================================================ // 统计 // ============================================================ function getStats() { const meta = loadMeta(); const now = Date.now(); let total = 0, active = 0, expired = 0, totalSize = 0; for (const [cacheId, record] of Object.entries(meta)) { total++; if (now > record.expiresAt) { expired++; } else if (record.status === 'active') { active++; totalSize += record.fileSize || 0; } } return { total, active, expired, totalSize }; } // ============================================================ // 导出 // ============================================================ module.exports = { router, startCleanupSchedule, stopCleanupSchedule, setNotifyCallback, getStats, _cleanupExpired };