待看名单支持数据库同步
This commit is contained in:
@@ -55,6 +55,11 @@ class CacheConfigManager {
|
||||
// 存储模式配置
|
||||
storageMode: 'json', // 存储模式:'json' 或 'database'
|
||||
},
|
||||
// 待看名单配置
|
||||
watchlist: {
|
||||
// 存储模式:'json' 或 'database'
|
||||
storageMode: 'json'
|
||||
},
|
||||
// 新增Windows特定配置
|
||||
windows: {
|
||||
skipInUseFiles: true, // 跳过被占用的文件
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
const { defaultLogger } = require('../utils/logger');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const logger = defaultLogger.child('WatchlistDatabase');
|
||||
|
||||
/**
|
||||
* 数据库版本的待看名单
|
||||
* 提供与JSON版本相同的接口,但使用MySQL数据库存储
|
||||
*/
|
||||
class WatchlistDatabase {
|
||||
constructor(databaseManager) {
|
||||
this.db = databaseManager;
|
||||
this.loaded = false;
|
||||
this.tableName = 'watchlist_items';
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库表
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// 如果表不存在则创建
|
||||
if (!(await this.db.tableExists(this.tableName))) {
|
||||
const createSQL = `
|
||||
CREATE TABLE ${this.tableName} (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
url_hash CHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_url_hash (url_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='待看名单表';
|
||||
`;
|
||||
await this.db.createTable(this.tableName, createSQL);
|
||||
}
|
||||
this.loaded = true;
|
||||
logger.info('待看名单数据库表初始化完成');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('初始化待看名单数据库失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有待看项目
|
||||
*/
|
||||
async getAllItems() {
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT id, title, url, created_at, updated_at
|
||||
FROM ${this.tableName}
|
||||
ORDER BY created_at ASC
|
||||
`);
|
||||
return result.data.map(row => ({
|
||||
id: row.id.toString(),
|
||||
title: row.title,
|
||||
url: row.url,
|
||||
createdAt: (row.created_at instanceof Date) ? row.created_at.toISOString() : row.created_at,
|
||||
updatedAt: (row.updated_at instanceof Date) ? row.updated_at.toISOString() : row.updated_at
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('获取待看项目失败(数据库):', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加或更新待看项目(按URL唯一)
|
||||
*/
|
||||
async addItem(item) {
|
||||
try {
|
||||
const { url, title } = item;
|
||||
if (!url) throw new Error('URL是必填项');
|
||||
const urlHash = crypto.createHash('sha256').update(url).digest('hex');
|
||||
|
||||
// 先查是否存在
|
||||
const exists = await this.db.select(this.tableName, { url_hash: urlHash });
|
||||
if (exists.data && exists.data.length > 0) {
|
||||
// 更新标题
|
||||
await this.db.update(this.tableName, { title }, { url_hash: urlHash });
|
||||
} else {
|
||||
const id = (item.id?.toString()) || Date.now().toString();
|
||||
await this.db.insert(this.tableName, {
|
||||
id,
|
||||
title: title || '待看页面',
|
||||
url,
|
||||
url_hash: urlHash
|
||||
});
|
||||
}
|
||||
return await this.getAllItems();
|
||||
} catch (error) {
|
||||
logger.error('添加待看项目失败(数据库):', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除待看项目
|
||||
*/
|
||||
async removeItem(id) {
|
||||
try {
|
||||
const result = await this.db.delete(this.tableName, { id });
|
||||
if (result.affectedRows === 0) {
|
||||
throw new Error('待看项目不存在');
|
||||
}
|
||||
return await this.getAllItems();
|
||||
} catch (error) {
|
||||
logger.error('删除待看项目失败(数据库):', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新待看项目
|
||||
*/
|
||||
async updateItem(id, updates) {
|
||||
try {
|
||||
const allowed = {};
|
||||
if (typeof updates.title === 'string') allowed.title = updates.title;
|
||||
if (typeof updates.url === 'string') {
|
||||
allowed.url = updates.url;
|
||||
allowed.url_hash = crypto.createHash('sha256').update(updates.url).digest('hex');
|
||||
}
|
||||
if (Object.keys(allowed).length === 0) {
|
||||
// 没有可更新字段则返回原项
|
||||
const rows = await this.db.select(this.tableName, { id });
|
||||
if (!rows.data || rows.data.length === 0) throw new Error('待看项目不存在');
|
||||
const row = rows.data[0];
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
title: row.title,
|
||||
url: row.url,
|
||||
createdAt: (row.created_at instanceof Date) ? row.created_at.toISOString() : row.created_at,
|
||||
updatedAt: (row.updated_at instanceof Date) ? row.updated_at.toISOString() : row.updated_at
|
||||
};
|
||||
}
|
||||
await this.db.update(this.tableName, allowed, { id });
|
||||
const rows = await this.db.select(this.tableName, { id });
|
||||
const row = rows.data[0];
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
title: row.title,
|
||||
url: row.url,
|
||||
createdAt: (row.created_at instanceof Date) ? row.created_at.toISOString() : row.created_at,
|
||||
updatedAt: (row.updated_at instanceof Date) ? row.updated_at.toISOString() : row.updated_at
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('更新待看项目失败(数据库):', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空表
|
||||
*/
|
||||
async clearAll() {
|
||||
await this.db.query(`DELETE FROM ${this.tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量插入
|
||||
*/
|
||||
async batchInsert(items) {
|
||||
const rows = items.map(i => {
|
||||
const url = i.url;
|
||||
const url_hash = url ? crypto.createHash('sha256').update(url).digest('hex') : null;
|
||||
return {
|
||||
id: i.id?.toString() || Date.now().toString(),
|
||||
title: i.title || '待看页面',
|
||||
url,
|
||||
url_hash
|
||||
};
|
||||
});
|
||||
return await this.db.batchInsert(this.tableName, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已有URL集合
|
||||
*/
|
||||
async getExistingUrlSet() {
|
||||
const result = await this.db.query(`SELECT url FROM ${this.tableName}`);
|
||||
return new Set(result.data.map(r => r.url));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WatchlistDatabase;
|
||||
+248
-6
@@ -2,10 +2,37 @@ const express = require('express');
|
||||
const WatchlistManager = require('../config/watchlist-manager');
|
||||
const ResponseUtil = require('../utils/response');
|
||||
const { defaultLogger } = require('../utils/logger');
|
||||
const CacheConfigManager = require('../config/cache-config');
|
||||
const WatchlistDatabase = require('../database/watchlist-database');
|
||||
|
||||
const router = express.Router();
|
||||
const logger = defaultLogger.child('WatchlistRouter');
|
||||
const watchlistManager = new WatchlistManager();
|
||||
const cacheConfigManager = new CacheConfigManager();
|
||||
let watchlistDb = null; // 延迟初始化
|
||||
|
||||
// 获取当前存储模式
|
||||
async function getStorageMode() {
|
||||
try {
|
||||
await cacheConfigManager.initialize();
|
||||
const config = await cacheConfigManager.loadConfig();
|
||||
return config.watchlist?.storageMode === 'database' ? 'database' : 'json';
|
||||
} catch (e) {
|
||||
logger.warn('读取待看名单存储模式失败,回退到json:', e.message);
|
||||
return 'json';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据库适配器(复用Registry连接)
|
||||
async function getDbAdapter(req) {
|
||||
const dbm = req.backend?.databaseManager;
|
||||
if (!dbm) return null;
|
||||
if (!watchlistDb) {
|
||||
watchlistDb = new WatchlistDatabase(dbm);
|
||||
await watchlistDb.init();
|
||||
}
|
||||
return watchlistDb;
|
||||
}
|
||||
|
||||
// 初始化待看名单
|
||||
watchlistManager.initialize().catch(error => {
|
||||
@@ -18,6 +45,12 @@ watchlistManager.initialize().catch(error => {
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const mode = await getStorageMode();
|
||||
if (mode === 'database') {
|
||||
const db = await getDbAdapter(req);
|
||||
const items = await db.getAllItems();
|
||||
return res.json(ResponseUtil.success(items));
|
||||
}
|
||||
const items = await watchlistManager.getAllItems();
|
||||
res.json(ResponseUtil.success(items));
|
||||
} catch (error) {
|
||||
@@ -120,11 +153,15 @@ router.post('/', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const item = {
|
||||
url,
|
||||
title: defaultTitle
|
||||
};
|
||||
|
||||
const item = { url, title: defaultTitle };
|
||||
|
||||
const mode = await getStorageMode();
|
||||
if (mode === 'database') {
|
||||
const db = await getDbAdapter(req);
|
||||
const items = await db.addItem(item);
|
||||
return res.json(ResponseUtil.success(items));
|
||||
}
|
||||
|
||||
const items = await watchlistManager.addItem(item);
|
||||
res.json(ResponseUtil.success(items));
|
||||
} catch (error) {
|
||||
@@ -133,6 +170,40 @@ router.post('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新待看名单存储配置(仅切换,不做迁移)
|
||||
* PUT /api/watchlist/config
|
||||
* body: { storageMode: 'json' | 'database' }
|
||||
*
|
||||
* 注意:必须放在 PUT '/:id' 之前,避免被动态路由拦截!
|
||||
*/
|
||||
router.put('/config', async (req, res) => {
|
||||
try {
|
||||
const { storageMode } = req.body;
|
||||
if (!['json', 'database'].includes(storageMode)) {
|
||||
return res.status(400).json(ResponseUtil.error('存储模式必须是 json 或 database'));
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
const current = await cacheConfigManager.loadConfig();
|
||||
const updated = await cacheConfigManager.updateConfig({
|
||||
...current,
|
||||
watchlist: { ...(current.watchlist || {}), storageMode }
|
||||
});
|
||||
|
||||
// 如果切换到数据库模式,确保表已初始化(复用 Registry 的连接)
|
||||
if (storageMode === 'database' && req.backend?.databaseManager) {
|
||||
const db = await getDbAdapter(req);
|
||||
await db.init();
|
||||
}
|
||||
|
||||
res.json(ResponseUtil.success({ storageMode: updated.watchlist?.storageMode || 'json' }));
|
||||
} catch (error) {
|
||||
logger.error('更新待看名单配置失败:', error);
|
||||
res.status(500).json(ResponseUtil.error(error.message));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新待看项目
|
||||
* PUT /api/watchlist/:id
|
||||
@@ -146,6 +217,13 @@ router.put('/:id', async (req, res) => {
|
||||
delete updates.id;
|
||||
delete updates.createdAt;
|
||||
|
||||
const mode = await getStorageMode();
|
||||
if (mode === 'database') {
|
||||
const db = await getDbAdapter(req);
|
||||
const updatedItem = await db.updateItem(id, updates);
|
||||
return res.json(ResponseUtil.success(updatedItem));
|
||||
}
|
||||
|
||||
const updatedItem = await watchlistManager.updateItem(id, updates);
|
||||
res.json(ResponseUtil.success(updatedItem));
|
||||
} catch (error) {
|
||||
@@ -165,6 +243,13 @@ router.put('/:id', async (req, res) => {
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const mode = await getStorageMode();
|
||||
if (mode === 'database') {
|
||||
const db = await getDbAdapter(req);
|
||||
const items = await db.removeItem(id);
|
||||
return res.json(ResponseUtil.success(items));
|
||||
}
|
||||
|
||||
const items = await watchlistManager.removeItem(id);
|
||||
res.json(ResponseUtil.success(items));
|
||||
} catch (error) {
|
||||
@@ -177,4 +262,161 @@ router.delete('/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
/**
|
||||
* 导出待看名单
|
||||
* GET /api/watchlist/export
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const mode = await getStorageMode();
|
||||
let exportItems = [];
|
||||
if (mode === 'database') {
|
||||
const db = await getDbAdapter(req);
|
||||
exportItems = await db.getAllItems();
|
||||
} else {
|
||||
const data = await watchlistManager.readData();
|
||||
exportItems = Array.isArray(data.items) ? data.items : [];
|
||||
}
|
||||
// 组装导出结构,向后兼容
|
||||
const exportData = {
|
||||
version: '1.0',
|
||||
exportTime: new Date().toISOString(),
|
||||
items: exportItems
|
||||
};
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="watchlist.json"');
|
||||
res.json(ResponseUtil.success(exportData));
|
||||
} catch (error) {
|
||||
logger.error('导出待看名单失败:', error);
|
||||
res.status(500).json(ResponseUtil.error(error.message));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 导入待看名单
|
||||
* POST /api/watchlist/import
|
||||
* body: { watchlistData: { items: [...] }, importMode: 'merge' | 'overwrite' }
|
||||
*/
|
||||
router.post('/import', async (req, res) => {
|
||||
try {
|
||||
const { watchlistData, importMode = 'merge' } = req.body;
|
||||
|
||||
if (!watchlistData || !Array.isArray(watchlistData.items)) {
|
||||
return res.status(400).json(ResponseUtil.error('缺少有效的待看名单数据'));
|
||||
}
|
||||
|
||||
const normalizeItem = (item) => {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: item.id?.toString() || Date.now().toString(),
|
||||
title: item.title || '待看页面',
|
||||
url: item.url,
|
||||
createdAt: item.createdAt || now,
|
||||
updatedAt: item.updatedAt || now
|
||||
};
|
||||
};
|
||||
|
||||
const importItems = watchlistData.items
|
||||
.filter(i => i && i.url)
|
||||
.map(normalizeItem);
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
let errorCount = 0;
|
||||
let deletedCount = 0;
|
||||
|
||||
const mode = await getStorageMode();
|
||||
let updatedItems = [];
|
||||
|
||||
if (mode === 'database') {
|
||||
const db = await getDbAdapter(req);
|
||||
if (importMode === 'overwrite') {
|
||||
// 覆盖:清空后批量插入
|
||||
const before = await db.getAllItems();
|
||||
deletedCount = before.length;
|
||||
await db.clearAll();
|
||||
await db.batchInsert(importItems);
|
||||
successCount = importItems.length;
|
||||
} else {
|
||||
// 合并:按URL去重,仅新增不存在的
|
||||
const existingUrlSet = await db.getExistingUrlSet();
|
||||
const toInsert = [];
|
||||
for (const item of importItems) {
|
||||
try {
|
||||
if (existingUrlSet.has(item.url)) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
toInsert.push(item);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
await db.batchInsert(toInsert);
|
||||
}
|
||||
updatedItems = await db.getAllItems();
|
||||
} else {
|
||||
const currentData = await watchlistManager.readData();
|
||||
const existingItems = Array.isArray(currentData.items) ? currentData.items : [];
|
||||
if (importMode === 'overwrite') {
|
||||
// 覆盖:直接保存导入数据为新清单
|
||||
deletedCount = existingItems.length;
|
||||
const newData = { items: importItems, lastUpdated: new Date().toISOString() };
|
||||
await watchlistManager.saveData(newData);
|
||||
successCount = importItems.length;
|
||||
} else {
|
||||
// 合并:按URL去重,仅新增不存在的
|
||||
const existingUrlSet = new Set(existingItems.map(i => i.url));
|
||||
const merged = [...existingItems];
|
||||
for (const item of importItems) {
|
||||
try {
|
||||
if (existingUrlSet.has(item.url)) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
merged.push(item);
|
||||
existingUrlSet.add(item.url);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
await watchlistManager.saveData({ items: merged, lastUpdated: new Date().toISOString() });
|
||||
}
|
||||
updatedItems = await watchlistManager.getAllItems();
|
||||
}
|
||||
|
||||
const message = importMode === 'overwrite'
|
||||
? `覆盖导入完成:删除 ${deletedCount} 项,成功添加 ${successCount} 项,失败 ${errorCount} 项`
|
||||
: `重合导入完成:成功 ${successCount} 项,跳过 ${skipCount} 项,失败 ${errorCount} 项`;
|
||||
|
||||
res.json(ResponseUtil.success({
|
||||
message,
|
||||
stats: { successCount, skipCount, errorCount, deletedCount },
|
||||
items: updatedItems
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('导入待看名单失败:', error);
|
||||
res.status(500).json(ResponseUtil.error(error.message));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取待看名单存储配置
|
||||
* GET /api/watchlist/config
|
||||
*/
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const mode = await getStorageMode();
|
||||
res.json(ResponseUtil.success({ storageMode: mode }));
|
||||
} catch (error) {
|
||||
logger.error('获取待看名单配置失败:', error);
|
||||
res.status(500).json(ResponseUtil.error(error.message));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
||||
@@ -63,6 +63,7 @@ const ModuleColors = {
|
||||
'AbortControllerManager': '\x1b[94m', // 亮蓝色
|
||||
'DatabaseManager': '\x1b[95m', // 亮紫色
|
||||
'RegistrySchema': '\x1b[94m', // 亮蓝色
|
||||
'WatchlistDatabase': '\x1b[94m', // 亮蓝色
|
||||
'RegistryDatabase': '\x1b[94m', // 亮蓝色
|
||||
'Default': '\x1b[39m' // 默认颜色
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user