待看名单支持数据库同步

This commit is contained in:
2025-10-14 08:31:40 +08:00
parent 5be8ae9520
commit 54b9abfeeb
12 changed files with 686 additions and 106 deletions
+5
View File
@@ -55,6 +55,11 @@ class CacheConfigManager {
// 存储模式配置
storageMode: 'json', // 存储模式:'json' 或 'database'
},
// 待看名单配置
watchlist: {
// 存储模式:'json' 或 'database'
storageMode: 'json'
},
// 新增Windows特定配置
windows: {
skipInUseFiles: true, // 跳过被占用的文件
+188
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -63,6 +63,7 @@ const ModuleColors = {
'AbortControllerManager': '\x1b[94m', // 亮蓝色
'DatabaseManager': '\x1b[95m', // 亮紫色
'RegistrySchema': '\x1b[94m', // 亮蓝色
'WatchlistDatabase': '\x1b[94m', // 亮蓝色
'RegistryDatabase': '\x1b[94m', // 亮蓝色
'Default': '\x1b[39m' // 默认颜色
};