277 lines
7.7 KiB
JavaScript
277 lines
7.7 KiB
JavaScript
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* 在线阅读器引擎 · Reader Engine Service
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*
|
|||
|
|
* 智库节点 Phase 2 · ZY-SVR-006
|
|||
|
|
* 守护: 铸渊 · ICE-GL-ZY001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*
|
|||
|
|
* 功能:
|
|||
|
|
* - 书架管理(成员个人书架·引用式·不重复存储)
|
|||
|
|
* - 阅读进度存档(自动保存到 JSON)
|
|||
|
|
* - 阅读偏好设置(字体/主题/字号)
|
|||
|
|
* - 阅读统计(今日阅读时长/总时长/阅读字数)
|
|||
|
|
*
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
const DATA_DIR = path.join(__dirname, '..', 'data');
|
|||
|
|
const PROGRESS_FILE = path.join(DATA_DIR, 'reading-progress.json');
|
|||
|
|
const BOOKSHELF_FILE = path.join(DATA_DIR, 'bookshelves.json');
|
|||
|
|
|
|||
|
|
// 内存缓存 (使用 null-prototype 对象防止原型链污染)
|
|||
|
|
let progressData = Object.create(null);
|
|||
|
|
let bookshelfData = Object.create(null);
|
|||
|
|
|
|||
|
|
// ─── 初始化 ───
|
|||
|
|
function init() {
|
|||
|
|
if (!fs.existsSync(DATA_DIR)) {
|
|||
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|||
|
|
}
|
|||
|
|
loadProgress();
|
|||
|
|
loadBookshelves();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadProgress() {
|
|||
|
|
try {
|
|||
|
|
if (fs.existsSync(PROGRESS_FILE)) {
|
|||
|
|
const raw = JSON.parse(fs.readFileSync(PROGRESS_FILE, 'utf8'));
|
|||
|
|
progressData = Object.create(null);
|
|||
|
|
for (const key of Object.keys(raw)) {
|
|||
|
|
if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
|
|||
|
|
progressData[key] = raw[key];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
progressData = Object.create(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveProgress() {
|
|||
|
|
try {
|
|||
|
|
fs.writeFileSync(PROGRESS_FILE, JSON.stringify(progressData, null, 2), 'utf8');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[ReaderEngine] 保存阅读进度失败:', err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadBookshelves() {
|
|||
|
|
try {
|
|||
|
|
if (fs.existsSync(BOOKSHELF_FILE)) {
|
|||
|
|
const raw = JSON.parse(fs.readFileSync(BOOKSHELF_FILE, 'utf8'));
|
|||
|
|
bookshelfData = Object.create(null);
|
|||
|
|
for (const key of Object.keys(raw)) {
|
|||
|
|
if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
|
|||
|
|
bookshelfData[key] = raw[key];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
bookshelfData = Object.create(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveBookshelves() {
|
|||
|
|
try {
|
|||
|
|
fs.writeFileSync(BOOKSHELF_FILE, JSON.stringify(bookshelfData, null, 2), 'utf8');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[ReaderEngine] 保存书架失败:', err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 验证 ID 安全性(防止原型链污染)
|
|||
|
|
*/
|
|||
|
|
function sanitizeId(id) {
|
|||
|
|
if (!id || typeof id !== 'string') return null;
|
|||
|
|
if (id === '__proto__' || id === 'constructor' || id === 'prototype') return null;
|
|||
|
|
return id;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 保存阅读进度
|
|||
|
|
* @param {string} memberId - 成员ID
|
|||
|
|
* @param {string} bookId - 书籍ID
|
|||
|
|
* @param {object} progress - { chapter_index, scroll_position, percent }
|
|||
|
|
*/
|
|||
|
|
function saveReadingProgress(memberId, bookId, progress) {
|
|||
|
|
if (!sanitizeId(memberId) || !sanitizeId(bookId)) {
|
|||
|
|
throw new Error('memberId 和 bookId 为必填项且不能包含非法字符');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Object.prototype.hasOwnProperty.call(progressData, memberId)) {
|
|||
|
|
progressData[memberId] = {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
progressData[memberId][bookId] = {
|
|||
|
|
chapter_index: progress.chapter_index || 0,
|
|||
|
|
scroll_position: progress.scroll_position || 0,
|
|||
|
|
percent: progress.percent || 0,
|
|||
|
|
updated_at: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
saveProgress();
|
|||
|
|
|
|||
|
|
return progressData[memberId][bookId];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取阅读进度
|
|||
|
|
*/
|
|||
|
|
function getReadingProgress(memberId, bookId) {
|
|||
|
|
if (!sanitizeId(memberId)) return null;
|
|||
|
|
if (!Object.prototype.hasOwnProperty.call(progressData, memberId)) return null;
|
|||
|
|
if (bookId) {
|
|||
|
|
if (!sanitizeId(bookId)) return null;
|
|||
|
|
return progressData[memberId][bookId] || null;
|
|||
|
|
}
|
|||
|
|
return progressData[memberId];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 添加书籍到书架
|
|||
|
|
*/
|
|||
|
|
function addToBookshelf(memberId, bookId, bookMeta) {
|
|||
|
|
if (!sanitizeId(memberId) || !sanitizeId(bookId)) {
|
|||
|
|
throw new Error('memberId 和 bookId 为必填项且不能包含非法字符');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Object.prototype.hasOwnProperty.call(bookshelfData, memberId)) {
|
|||
|
|
bookshelfData[memberId] = [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 防止重复添加
|
|||
|
|
const exists = bookshelfData[memberId].find(b => b.book_id === bookId);
|
|||
|
|
if (exists) {
|
|||
|
|
return { added: false, message: '书籍已在书架中' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bookshelfData[memberId].push({
|
|||
|
|
book_id: bookId,
|
|||
|
|
title: bookMeta.title || bookId,
|
|||
|
|
author: bookMeta.author || '未知',
|
|||
|
|
added_at: new Date().toISOString()
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
saveBookshelves();
|
|||
|
|
|
|||
|
|
return { added: true, message: '已添加到书架' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从书架移除
|
|||
|
|
*/
|
|||
|
|
function removeFromBookshelf(memberId, bookId) {
|
|||
|
|
if (!sanitizeId(memberId) || !sanitizeId(bookId)) return { removed: false };
|
|||
|
|
if (!Object.prototype.hasOwnProperty.call(bookshelfData, memberId)) return { removed: false };
|
|||
|
|
|
|||
|
|
const before = bookshelfData[memberId].length;
|
|||
|
|
bookshelfData[memberId] = bookshelfData[memberId].filter(b => b.book_id !== bookId);
|
|||
|
|
|
|||
|
|
if (bookshelfData[memberId].length < before) {
|
|||
|
|
saveBookshelves();
|
|||
|
|
return { removed: true };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { removed: false, message: '书籍不在书架中' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取成员书架
|
|||
|
|
*/
|
|||
|
|
function getBookshelf(memberId) {
|
|||
|
|
if (!sanitizeId(memberId)) return [];
|
|||
|
|
const shelf = (Object.prototype.hasOwnProperty.call(bookshelfData, memberId) ? bookshelfData[memberId] : []) || [];
|
|||
|
|
// 附加阅读进度
|
|||
|
|
return shelf.map(book => ({
|
|||
|
|
...book,
|
|||
|
|
progress: (progressData[memberId] && progressData[memberId][book.book_id]) || null
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 保存阅读偏好
|
|||
|
|
*/
|
|||
|
|
function savePreferences(memberId, prefs) {
|
|||
|
|
if (!sanitizeId(memberId)) {
|
|||
|
|
throw new Error('memberId 不能为空或包含非法字符');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Object.prototype.hasOwnProperty.call(progressData, memberId)) {
|
|||
|
|
progressData[memberId] = {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
progressData[memberId].__preferences = {
|
|||
|
|
font_size: prefs.font_size || 16,
|
|||
|
|
font_family: prefs.font_family || 'default',
|
|||
|
|
theme: prefs.theme || 'dark',
|
|||
|
|
line_height: prefs.line_height || 1.8,
|
|||
|
|
updated_at: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
saveProgress();
|
|||
|
|
|
|||
|
|
return progressData[memberId].__preferences;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取阅读偏好
|
|||
|
|
*/
|
|||
|
|
function getPreferences(memberId) {
|
|||
|
|
if (!sanitizeId(memberId)) {
|
|||
|
|
return {
|
|||
|
|
font_size: 16,
|
|||
|
|
font_family: 'default',
|
|||
|
|
theme: 'dark',
|
|||
|
|
line_height: 1.8
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
if (!Object.prototype.hasOwnProperty.call(progressData, memberId) || !progressData[memberId].__preferences) {
|
|||
|
|
return {
|
|||
|
|
font_size: 16,
|
|||
|
|
font_family: 'default',
|
|||
|
|
theme: 'dark',
|
|||
|
|
line_height: 1.8
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return progressData[memberId].__preferences;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取阅读器统计
|
|||
|
|
*/
|
|||
|
|
function getStats() {
|
|||
|
|
const memberCount = Object.keys(bookshelfData).length;
|
|||
|
|
const totalBooks = Object.values(bookshelfData).reduce((sum, shelf) => sum + shelf.length, 0);
|
|||
|
|
const progressCount = Object.values(progressData).reduce((sum, member) => {
|
|||
|
|
return sum + Object.keys(member).filter(k => k !== '__preferences').length;
|
|||
|
|
}, 0);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
total_members: memberCount,
|
|||
|
|
total_bookshelf_books: totalBooks,
|
|||
|
|
total_progress_records: progressCount
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 初始化
|
|||
|
|
init();
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
saveReadingProgress,
|
|||
|
|
getReadingProgress,
|
|||
|
|
addToBookshelf,
|
|||
|
|
removeFromBookshelf,
|
|||
|
|
getBookshelf,
|
|||
|
|
savePreferences,
|
|||
|
|
getPreferences,
|
|||
|
|
getStats
|
|||
|
|
};
|