#!/usr/bin/env node
/**
* ═══════════════════════════════════════════════════════════
* 光湖智库节点 · v2.1 · 书岚人格体 + 守护Agent
* ═══════════════════════════════════════════════════════════
*
* 项目编号: ZY-PROJ-006
* 服务器: ZY-SVR-006 (43.153.203.105 · 新加坡)
* 域名: guanghu.online
* 端口: 3006 (绑定 127.0.0.1 · Nginx 反代)
* 守护: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
*
* v2.1 新增:
* 书岚(AG-SL-WEB-001)— 四层人格提示词系统
* 守护Agent(AG-SL-GUARDIAN-001)— 活的提示词注入代理
* 聊天工具技能包(AG-SL-TOOLKIT-001)— 视觉化排版工具
* 搜索结果带「在线阅读」「下载」操作按钮
* [REMEMBER:tag] 偏好记忆标记
* GET /api/agent/status — 书岚系统状态
*
* v2.0:
* POST /api/auth/send-code — 发送QQ邮箱验证码
* POST /api/auth/verify — 验证码验证 → 签发用户Token
* GET /api/auth/session — 获取当前会话
* POST /api/auth/logout — 退出登录
* GET /api/search — 真实搜索(番茄+七猫数据源+本地库)
* POST /api/download/start — 真实下载任务(数据源→COS桶)
* GET /api/download/status/:taskId — 下载任务状态
* POST /api/agent/chat — 书岚对话(LLM真实调用)
* GET /api/agent/memory — Agent记忆查看
*
* 保留旧接口兼容:
* GET /api/health, POST /api/checkout, POST /api/return
* GET /api/book/:id, GET /api/download/:id, GET /api/read/:id
* /api/mirror/*
*
* 架构法理: 5TH-LE-LK-ZHIKU-ARCH-002
* ═══════════════════════════════════════════════════════════
*/
'use strict';
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const http = require('http');
const https = require('https');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
// ─── 加载环境变量 ───
require('dotenv').config({ path: path.join(__dirname, '.env') });
// ─── 七层镜防 + 镜面 Agent ───
const { registerShield, registerShieldRoutes } = require('./mirror-shield');
const mirrorAgent = require('./mirror-agent');
const { getEnabledSources } = require('./mirror-agent/config');
// ─── 书岚 Agent 系统 ───
const shulanAgent = require('./shulan-agent');
// ─── 内置数据源直连(FQWeb/SwiftCat不可用时的fallback · 可选模块) ───
let builtinSource = null;
try {
builtinSource = require('./builtin-source');
} catch (err) {
console.warn(`[ZY-SVR-006] ⚠️ builtin-source 模块未找到 (${err.message})。内置直连搜索/下载将不可用,但核心认证/Agent功能不受影响。`);
}
// ─── 铸渊哨兵 · 自动运维Agent(永久记忆 + 书源监测 + 自动修复) ───
let sentinel = null;
try {
const ZhuyuanSentinel = require('./zhuyuan-sentinel');
sentinel = new ZhuyuanSentinel({
dataDir: process.env.ZY_ZHIKU_DATA_DIR || path.join(__dirname, '..', 'data'),
builtinSource,
mirrorAgent
});
sentinel.init();
} catch (err) {
console.warn(`[ZY-SVR-006] ⚠️ 铸渊哨兵加载失败 (${err.message})。自动运维功能不可用。`);
}
const app = express();
const PORT = process.env.PORT || 3006;
const JWT_SECRET = process.env.ZY_ZHIKU_JWT_SECRET;
if (!JWT_SECRET) {
console.error('[ZY-SVR-006] ⚠️ 严重: ZY_ZHIKU_JWT_SECRET 未配置。生产环境必须设置此变量。');
if (process.env.NODE_ENV === 'production') {
console.error('[ZY-SVR-006] 生产环境缺少 JWT 密钥,拒绝启动。');
process.exit(1);
}
}
const JWT_SECRET_FINAL = JWT_SECRET || crypto.randomBytes(32).toString('hex');
const TOKEN_TTL = parseInt(process.env.ZY_ZHIKU_TOKEN_TTL, 10) || 86400; // 用户token延长至24h
const DATA_DIR = process.env.ZY_ZHIKU_DATA_DIR || path.join(__dirname, '..', 'data');
const LOG_DIR = process.env.ZY_ZHIKU_LOG_DIR || '/var/log/zhiku';
const DOMAIN = process.env.ZY_ZHIKU_DOMAIN || 'guanghu.online';
const USERS_DIR = path.join(DATA_DIR, 'users');
const AGENTS_DIR = path.join(DATA_DIR, 'agents');
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
const START_TIME = Date.now();
// 数据源API地址
const FANQIE_API = process.env.ZY_FANQIE_API_URL || 'http://127.0.0.1:9999';
const QIMAO_API = process.env.ZY_QIMAO_API_URL || 'http://127.0.0.1:7700';
const EXTERNAL_SERVICE_TIMEOUT_MS = 3000; // 外部服务(FQWeb/SwiftCat)超时·内置直连已覆盖时无需等
// LLM API配置
const DEEPSEEK_API_URL = process.env.ZY_DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions';
const DEEPSEEK_API_KEY = process.env.ZY_DEEPSEEK_API_KEY || '';
// 邮件发送配置(复用主站 3800 的邮件服务,或直接用SMTP)
const SMTP_HOST = process.env.ZY_SMTP_HOST || '';
const SMTP_PORT = process.env.ZY_SMTP_PORT || '465';
const SMTP_USER = process.env.ZY_SMTP_USER || '';
const SMTP_PASS = process.env.ZY_SMTP_PASS || '';
// 也支持通过主站3800转发邮件
const MAIN_API_URL = process.env.ZY_MAIN_API_URL || 'http://127.0.0.1:3800';
/**
* 根据邮箱地址自动检测 SMTP 主机
* QQ邮箱→smtp.qq.com, 163邮箱→smtp.163.com, 其他→空
*/
function autoDetectSmtpHost(email) {
if (!email || typeof email !== 'string' || !email.includes('@')) return '';
const domain = (email.split('@')[1] || '').toLowerCase();
if (!domain) return '';
if (domain === 'qq.com' || domain === 'foxmail.com') return 'smtp.qq.com';
if (domain === '163.com') return 'smtp.163.com';
if (domain === '126.com') return 'smtp.126.com';
if (domain === 'outlook.com' || domain === 'hotmail.com') return 'smtp-mail.outlook.com';
if (domain === 'gmail.com') return 'smtp.gmail.com';
return '';
}
// ─── 确保目录存在 ───
[DATA_DIR, LOG_DIR, USERS_DIR, AGENTS_DIR, TASKS_DIR,
path.join(DATA_DIR, 'books'), path.join(DATA_DIR, 'index')].forEach(dir => {
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
});
// ─── 信任 Nginx 反代 ───
app.set('trust proxy', 'loopback');
// ─── 七层镜防 · 最先注册(最外层防御) ───
registerShield(app);
// ─── CORS: 仅允许 guanghu.* 系域名 ───
const ALLOWED_ORIGINS = [
`https://${DOMAIN}`,
`https://www.${DOMAIN}`,
'https://guanghuyaoming.com',
'https://www.guanghuyaoming.com',
'http://localhost:3000',
'http://127.0.0.1:3000'
];
app.use(cors({
origin(origin, cb) {
if (!origin || ALLOWED_ORIGINS.some(o => origin === o || origin.endsWith(`.${DOMAIN}`))) {
return cb(null, true);
}
cb(new Error('Not allowed by CORS'));
},
credentials: true
}));
app.use(express.json({ limit: '2mb' }));
// ─── 全局速率限制 ───
app.use(rateLimit({
windowMs: 60 * 1000,
max: 120,
standardHeaders: true,
legacyHeaders: false,
message: { error: true, code: 'RATE_LIMIT', message: '请求过于频繁,请稍后再试' }
}));
// ─── 请求日志 ───
app.use((req, res, next) => {
const ts = new Date().toISOString();
const line = `${ts} ${req.method} ${req.url} ${req.ip}\n`;
try {
const logFile = path.join(LOG_DIR, `api-${ts.slice(0, 10)}.log`);
fs.appendFileSync(logFile, line);
} catch {}
next();
});
/* ═══════════════════════════════════════════════════════════
* 用户数据管理 · 一人一数据
* ═══════════════════════════════════════════════════════════ */
/**
* 用户ID由邮箱地址的SHA256前12位生成,确保稳定且隐私安全
*/
function emailToUserId(email) {
return 'U-' + crypto.createHash('sha256').update(email.toLowerCase().trim()).digest('hex').slice(0, 12);
}
function getUserDir(userId) {
const dir = path.join(USERS_DIR, userId);
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
return dir;
}
function loadUserProfile(userId) {
const file = path.join(getUserDir(userId), 'profile.json');
try {
if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {}
return null;
}
function saveUserProfile(userId, profile) {
const file = path.join(getUserDir(userId), 'profile.json');
fs.writeFileSync(file, JSON.stringify(profile, null, 2), 'utf8');
}
/* ═══════════════════════════════════════════════════════════
* 邮箱验证码 · 内存存储 (生产可迁Redis)
* ═══════════════════════════════════════════════════════════ */
const verificationCodes = new Map(); // email → { code, expires, attempts }
const AUTH_CODE_TTL = 300000; // 5分钟
const AUTH_CODE_MAX_ATTEMPTS = 5;
// 清理过期验证码(每2分钟)
setInterval(() => {
const now = Date.now();
for (const [email, data] of verificationCodes) {
if (data.expires < now) verificationCodes.delete(email);
}
}, 120000);
function generateCode() {
return String(Math.floor(100000 + Math.random() * 900000));
}
/**
* 发送验证码邮件 — 直接SMTP发送(首选),或通过主站3800转发
* 生产环境下不允许静默降级到DEV模式
*/
async function sendVerificationEmail(email, code) {
// 方式1: 直接SMTP发送(首选 · 可靠)
const effectiveHost = SMTP_HOST || autoDetectSmtpHost(SMTP_USER);
if (effectiveHost && SMTP_USER && SMTP_PASS) {
try {
const nodemailer = require('nodemailer');
const port = parseInt(SMTP_PORT, 10) || 465;
const transporter = nodemailer.createTransport({
host: effectiveHost,
port,
secure: port === 465,
auth: { user: SMTP_USER, pass: SMTP_PASS },
tls: { rejectUnauthorized: true }
});
await transporter.sendMail({
from: `"光湖智库" <${SMTP_USER}>`,
to: email,
subject: `光湖智库 · 登录验证码: ${code}`,
html: `
📚 光湖智库 · 登录验证
您正在登录 guanghu.online。验证码:
${code}
验证码5分钟内有效,请勿泄露。
铸渊 · ICE-GL-ZY001 · 国作登字-2026-A-00037559
`
});
console.log(`[ZY-SVR-006] ✅ 验证码邮件已发送: ${email} (SMTP直发 via ${effectiveHost})`);
return true;
} catch (err) {
console.error(`[ZY-SVR-006] ❌ SMTP发送失败: ${err.message}`);
// SMTP失败后继续尝试主站转发
}
} else {
console.warn(`[ZY-SVR-006] ⚠️ SMTP未配置 (host=${effectiveHost}, user=${SMTP_USER ? '已设置' : '未设置'}, pass=${SMTP_PASS ? '已设置' : '未设置'})`);
}
// 方式2: 通过主站API转发邮件(备用)
try {
const result = await httpPost(`${MAIN_API_URL}/api/auth/send-code`, {
email,
_internal: true,
_source: 'zhiku-node',
_code_override: code
});
if (result && !result.error && result.success !== false) {
console.log(`[ZY-SVR-006] ✅ 验证码已通过主站API转发: ${email}`);
return true;
}
console.warn(`[ZY-SVR-006] ⚠️ 主站API转发失败: ${JSON.stringify(result)}`);
} catch (err) {
console.warn(`[ZY-SVR-006] ⚠️ 主站API不可达: ${err.message}`);
}
// 生产环境: 不允许静默降级 — 返回失败
if (process.env.NODE_ENV === 'production') {
console.error('[ZY-SVR-006] ❌ 生产环境邮件发送失败: SMTP未配置且主站不可达');
return false;
}
// 开发环境: 将验证码记录到日志(不实际发送)
console.log(`[ZY-SVR-006] [DEV] 验证码 ${email} → ${code}`);
const logLine = `${new Date().toISOString()} DEV_CODE email=${email} code=${code}\n`;
try { fs.appendFileSync(path.join(LOG_DIR, 'auth-codes.log'), logLine); } catch {}
return true;
}
/* ═══════════════════════════════════════════════════════════
* HTTP 工具函数 · 用于调用数据源API和主站API
* ═══════════════════════════════════════════════════════════ */
function httpGet(url, timeoutMs) {
const timeout = timeoutMs || 15000;
return new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? https : http;
const req = mod.get(url, { timeout }, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
});
}
function httpPost(url, body, headers) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const mod = parsed.protocol === 'https:' ? https : http;
const bodyStr = JSON.stringify(body);
const opts = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
...(headers || {})
}
};
const req = mod.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.write(bodyStr);
req.end();
});
}
/* ═══════════════════════════════════════════════════════════
* Token 管理 · 用户认证Token + 旧版借阅Token 兼容
* ═══════════════════════════════════════════════════════════ */
const tokenBlacklist = new Map();
const activeTokens = new Map();
const MAX_TOKENS_PER_PERSONA = 10;
setInterval(() => {
const now = Date.now();
for (const [token, exp] of tokenBlacklist) {
if (exp < now) tokenBlacklist.delete(token);
}
for (const [pid, tokens] of activeTokens) {
for (const [t, exp] of tokens) {
if (exp < now) tokens.delete(t);
}
if (tokens.size === 0) activeTokens.delete(pid);
}
}, 5 * 60 * 1000);
function issueToken(personaId, scope) {
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL;
const payload = { persona_id: personaId, scope: scope || 'search', iat: Math.floor(Date.now() / 1000), exp };
const token = jwt.sign(payload, JWT_SECRET_FINAL);
if (!activeTokens.has(personaId)) activeTokens.set(personaId, new Map());
activeTokens.get(personaId).set(token, exp * 1000);
return { token, ttl: TOKEN_TTL, scope: payload.scope, expires_at: new Date(exp * 1000).toISOString() };
}
/**
* 签发用户认证Token(邮箱登录后使用)
*/
function issueUserToken(userId, email) {
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL;
const payload = {
user_id: userId,
email,
scope: 'full',
type: 'user_auth',
iat: Math.floor(Date.now() / 1000),
exp
};
return { token: jwt.sign(payload, JWT_SECRET_FINAL), ttl: TOKEN_TTL, expires_at: new Date(exp * 1000).toISOString() };
}
/**
* 验证用户Token中间件(支持新版user_auth和旧版persona token)
*/
function verifyUserAuth(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '') || req.query.token || '';
if (!token) {
return res.status(401).json({ error: true, code: 'NO_TOKEN', message: '请先登录' });
}
if (tokenBlacklist.has(token)) {
return res.status(401).json({ error: true, code: 'TOKEN_REVOKED', message: '登录已过期' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET_FINAL);
req.user = decoded;
req.token = token;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: true, code: 'TOKEN_EXPIRED', message: '登录已过期,请重新登录' });
}
return res.status(401).json({ error: true, code: 'TOKEN_INVALID', message: '认证无效' });
}
}
/** 旧版 verifyToken 保留兼容 */
function verifyToken(req, res, next) {
const token = req.query.token || req.headers.authorization?.replace('Bearer ', '') || '';
if (!token) return res.status(401).json({ error: true, code: 'NO_TOKEN', message: '缺少凭证' });
if (tokenBlacklist.has(token)) return res.status(401).json({ error: true, code: 'TOKEN_REVOKED', message: '凭证已失效' });
try {
const decoded = jwt.verify(token, JWT_SECRET_FINAL);
req.persona = decoded;
req.user = decoded;
req.token = token;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') return res.status(401).json({ error: true, code: 'TOKEN_EXPIRED', message: '凭证已过期' });
return res.status(401).json({ error: true, code: 'TOKEN_INVALID', message: '凭证无效' });
}
}
function revokeToken(token) {
try {
const decoded = jwt.decode(token);
if (decoded) {
tokenBlacklist.set(token, (decoded.exp || 0) * 1000);
const pid = decoded.persona_id || decoded.user_id;
if (pid && activeTokens.has(pid)) activeTokens.get(pid).delete(token);
}
} catch {}
}
/* ═══════════════════════════════════════════════════════════
* 书库索引 · JSON 单文件 (v1)
* ═══════════════════════════════════════════════════════════ */
const INDEX_FILE = path.join(DATA_DIR, 'index', 'books.json');
function loadBookIndex() {
try {
if (fs.existsSync(INDEX_FILE)) return JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8'));
} catch {}
return { books: [], updated_at: new Date().toISOString() };
}
function saveBookIndex(data) {
data.updated_at = new Date().toISOString();
fs.writeFileSync(INDEX_FILE, JSON.stringify(data, null, 2), 'utf8');
}
function addBookToIndex(book) {
const index = loadBookIndex();
const existing = index.books.find(b => b.id === book.id);
if (existing) {
Object.assign(existing, book);
} else {
index.books.push(book);
}
saveBookIndex(index);
return book;
}
/* ═══════════════════════════════════════════════════════════
* COS 签名客户端(腾讯云对象存储)
* ═══════════════════════════════════════════════════════════ */
let cosClient = null;
try {
const COS = require('cos-nodejs-sdk-v5');
if (process.env.ZY_COS_SECRET_ID && process.env.ZY_COS_SECRET_KEY) {
cosClient = new COS({
SecretId: process.env.ZY_COS_SECRET_ID,
SecretKey: process.env.ZY_COS_SECRET_KEY
});
}
} catch {}
const COS_BUCKET = process.env.ZY_COS_BUCKET || '';
const COS_REGION = process.env.ZY_COS_REGION || 'ap-singapore';
function getCosSignedUrl(key) {
return new Promise((resolve, reject) => {
if (!cosClient || !COS_BUCKET) return reject(new Error('COS 未配置'));
cosClient.getObjectUrl({
Bucket: COS_BUCKET, Region: COS_REGION, Key: key, Sign: true, Expires: 600
}, (err, data) => {
if (err) return reject(err);
resolve(data.Url);
});
});
}
function uploadToCos(key, body) {
return new Promise((resolve, reject) => {
if (!cosClient || !COS_BUCKET) return reject(new Error('COS 未配置'));
cosClient.putObject({
Bucket: COS_BUCKET, Region: COS_REGION, Key: key, Body: body
}, (err, data) => {
if (err) return reject(err);
resolve(data);
});
});
}
/* ═══════════════════════════════════════════════════════════
* 数据源搜索引擎 · 真实调用番茄/七猫API
* ═══════════════════════════════════════════════════════════ */
/**
* 快速探测数据源是否可达(TCP连接 + 简单HTTP请求)
*/
async function probeSource(src) {
const url = src.base_url;
if (!url) return { reachable: false, error: '未配置 base_url' };
try {
// 尝试访问 /version 或根路径做健康检查(3秒超时)
const versionUrl = src.version_url
? src.version_url.replace('{base_url}', url)
: url;
await httpGet(versionUrl, 3000);
return { reachable: true };
} catch (err) {
return { reachable: false, error: err.message };
}
}
/**
* 搜索所有启用的数据源 + 本地书库
* 返回 { results, source_status } — source_status 记录每个源的连通状态
*/
async function searchAllSources(query) {
const results = [];
const sourceStatus = [];
const sources = getEnabledSources();
// 1. 本地书库搜索
const index = loadBookIndex();
const q = query.toLowerCase();
const localResults = index.books.filter(book => {
const searchable = [book.title, book.author, ...(book.tags || []), book.category || ''].join(' ').toLowerCase();
return searchable.includes(q);
}).map(book => ({
id: book.id,
title: book.title,
author: book.author,
category: book.category,
source: 'local',
source_name: '本地书库',
has_file: !!book.cos_key,
chapters: book.chapters || 0
}));
results.push(...localResults);
sourceStatus.push({
id: 'local',
name: '本地书库',
status: 'ok',
count: localResults.length,
books_total: index.books.length
});
// 2. 内置直连搜索(优先 · 不依赖外部服务)
// 番茄直连 + 七猫直连 + 笔趣阁聚合 全部并行搜索
let builtinFanqieOk = false;
let builtinQimaoOk = false;
if (builtinSource) {
try {
const { results: builtinResults, statuses } = await builtinSource.builtinSearch(query);
for (const br of builtinResults) {
results.push(br);
if (br.source === 'fanqie') builtinFanqieOk = true;
if (br.source === 'qimao') builtinQimaoOk = true;
}
for (const st of statuses) {
sourceStatus.push(st);
if (st.id === 'fanqie-direct' && st.status === 'ok' && st.count > 0) builtinFanqieOk = true;
if (st.id === 'qimao-direct' && st.status === 'ok' && st.count > 0) builtinQimaoOk = true;
}
} catch (err) {
console.error('[ZY-SVR-006] 内置直连搜索失败:', err.message);
sourceStatus.push({
id: 'builtin-fallback',
name: '内置直连',
status: 'error',
count: 0,
error: err.message
});
}
}
// 3. 外部数据源服务(仅在内置直连未覆盖时补充 · 避免死等不可达的外部服务)
for (const src of sources) {
if (src.id === 'fanqie-fqweb' && !builtinFanqieOk) {
try {
const url = src.search_url
.replace('{base_url}', src.base_url)
.replace('{query}', encodeURIComponent(query))
.replace('{page}', '1');
const data = await httpGet(url, EXTERNAL_SERVICE_TIMEOUT_MS);
if (data && Array.isArray(data.data || data.books || data)) {
const books = data.data || data.books || data;
for (const b of books.slice(0, 20)) {
results.push({
id: `fq-${b.book_id || b.bookId || b.id || ''}`,
title: b.book_name || b.title || b.bookName || '',
author: b.author || b.author_name || '',
category: b.category || b.genre || '番茄小说',
source: 'fanqie',
source_name: '番茄小说',
source_book_id: String(b.book_id || b.bookId || b.id || ''),
word_count: b.word_count || b.wordCount || 0,
has_file: false
});
}
sourceStatus.push({ id: src.id, name: src.name, status: 'ok', count: books.length });
} else {
sourceStatus.push({ id: src.id, name: src.name, status: 'empty', count: 0, error: '数据源返回空结果' });
}
} catch (err) {
// 外部服务不可达 — 静默跳过(内置直连已覆盖或不可用也无妨)
sourceStatus.push({ id: src.id, name: src.name, status: 'error', count: 0, error: err.message });
}
}
if (src.id === 'qimao-downloader' && !builtinQimaoOk) {
try {
const url = src.search_url
.replace('{base_url}', src.base_url)
.replace('{query}', encodeURIComponent(query));
const data = await httpGet(url, EXTERNAL_SERVICE_TIMEOUT_MS);
if (data && Array.isArray(data.data || data.books || data)) {
const books = data.data || data.books || data;
for (const b of books.slice(0, 20)) {
results.push({
id: `qm-${b.book_id || b.bookId || b.id || ''}`,
title: b.book_name || b.title || b.bookName || '',
author: b.author || b.author_name || '',
category: b.category || b.genre || '七猫小说',
source: 'qimao',
source_name: '七猫小说',
source_book_id: String(b.book_id || b.bookId || b.id || ''),
word_count: b.word_count || b.wordCount || 0,
has_file: false
});
}
sourceStatus.push({ id: src.id, name: src.name, status: 'ok', count: books.length });
} else {
sourceStatus.push({ id: src.id, name: src.name, status: 'empty', count: 0, error: '数据源返回空结果' });
}
} catch (err) {
sourceStatus.push({ id: src.id, name: src.name, status: 'error', count: 0, error: err.message });
}
}
}
return { results, source_status: sourceStatus };
}
/* ═══════════════════════════════════════════════════════════
* 下载任务管理
* ═══════════════════════════════════════════════════════════ */
const downloadTasks = new Map(); // taskId → task object
function createDownloadTask(bookInfo, userId) {
const taskId = `DL-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
const task = {
task_id: taskId,
status: 'queued', // queued → downloading → processing → completed / failed
book: bookInfo,
user_id: userId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
progress: 0,
message: '排队中',
cos_key: '',
error: null
};
downloadTasks.set(taskId, task);
// 保存到磁盘
try {
fs.writeFileSync(path.join(TASKS_DIR, `${taskId}.json`), JSON.stringify(task, null, 2));
} catch {}
// 异步执行下载
processDownloadTask(taskId).catch(err => {
task.status = 'failed';
task.error = err.message;
task.updated_at = new Date().toISOString();
});
return task;
}
async function processDownloadTask(taskId) {
const task = downloadTasks.get(taskId);
if (!task) return;
task.status = 'downloading';
task.message = '正在从数据源获取...';
task.updated_at = new Date().toISOString();
const { source, source_book_id, title, author } = task.book;
let content = '';
let usedBuiltin = false;
try {
// 先尝试外部数据源服务
if (source === 'fanqie') {
try {
// 尝试 FQWeb API
task.message = '正在获取番茄小说目录...';
const catalogUrl = `${FANQIE_API}/catalog?bookId=${source_book_id}`;
const catalog = await httpGet(catalogUrl, 5000);
const chapters = catalog?.data || catalog?.chapters || catalog || [];
if (Array.isArray(chapters) && chapters.length > 0) {
task.message = `共${chapters.length}章,开始下载...`;
const chapterContents = [];
for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i];
const chId = ch.item_id || ch.itemId || ch.chapter_id || ch.id;
if (!chId) continue;
try {
const chUrl = `${FANQIE_API}/content?bookId=${source_book_id}&itemId=${chId}`;
const chData = await httpGet(chUrl, 10000);
const chTitle = ch.title || ch.chapter_title || `第${i + 1}章`;
const chContent = chData?.data?.content || chData?.content || chData?.data || '';
if (chContent) {
chapterContents.push(`${chTitle}\n\n${chContent}`);
}
} catch {}
task.progress = Math.floor(((i + 1) / chapters.length) * 80);
task.message = `下载中 ${i + 1}/${chapters.length} 章...`;
task.updated_at = new Date().toISOString();
await new Promise(r => setTimeout(r, 200));
}
content = `《${title}》\n作者:${author}\n来源:番茄小说\n\n` + chapterContents.join('\n\n───────────────\n\n');
}
} catch (extErr) {
console.log(`[ZY-SVR-006] FQWeb不可达(${extErr.message}),尝试内置直连...`);
}
// FQWeb不可用 → 内置直连
if (!content && builtinSource) {
try {
task.message = '外部服务不可用,切换到内置直连...';
content = await builtinSource.builtinDownload(source, source_book_id, title, author, (current, total, msg) => {
task.progress = Math.floor((current / total) * 80);
task.message = msg;
task.updated_at = new Date().toISOString();
});
usedBuiltin = true;
} catch (builtinErr) {
console.error('[ZY-SVR-006] 内置直连下载也失败:', builtinErr.message);
}
}
} else if (source === 'shu69') {
// 笔趣阁/69书吧 — 内置直连(海外IP可达)
if (builtinSource) {
try {
task.message = '正在从69书吧获取内容...';
content = await builtinSource.builtinDownload(source, source_book_id, title, author, (current, total, msg) => {
task.progress = Math.floor((current / total) * 80);
task.message = msg;
task.updated_at = new Date().toISOString();
});
usedBuiltin = true;
} catch (err) {
console.error('[ZY-SVR-006] 69书吧下载失败:', err.message);
}
}
} else if (source === 'qimao') {
try {
// 尝试 SwiftCat API
task.message = '正在获取七猫小说目录...';
const catalogUrl = `${QIMAO_API}/catalog/${source_book_id}`;
const catalog = await httpGet(catalogUrl, 5000);
const chapters = catalog?.data || catalog?.chapters || catalog || [];
if (Array.isArray(chapters) && chapters.length > 0) {
task.message = `共${chapters.length}章,开始下载...`;
const chapterContents = [];
for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i];
const chId = ch.chapter_id || ch.id;
if (!chId) continue;
try {
const chUrl = `${QIMAO_API}/chapter/${source_book_id}/${chId}`;
const chData = await httpGet(chUrl, 10000);
const chTitle = ch.title || ch.chapter_title || `第${i + 1}章`;
const chContent = chData?.data?.content || chData?.content || chData?.data || '';
if (chContent) {
chapterContents.push(`${chTitle}\n\n${chContent}`);
}
} catch {}
task.progress = Math.floor(((i + 1) / chapters.length) * 80);
task.message = `下载中 ${i + 1}/${chapters.length} 章...`;
task.updated_at = new Date().toISOString();
await new Promise(r => setTimeout(r, 200));
}
content = `《${title}》\n作者:${author}\n来源:七猫小说\n\n` + chapterContents.join('\n\n───────────────\n\n');
}
} catch (extErr) {
console.log(`[ZY-SVR-006] SwiftCat不可达(${extErr.message}),尝试内置直连...`);
}
// SwiftCat不可用 → 内置直连
if (!content && builtinSource) {
try {
task.message = '外部服务不可用,切换到七猫内置直连...';
content = await builtinSource.builtinDownload(source, source_book_id, title, author, (current, total, msg) => {
task.progress = Math.floor((current / total) * 80);
task.message = msg;
task.updated_at = new Date().toISOString();
});
usedBuiltin = true;
} catch (builtinErr) {
console.error('[ZY-SVR-006] 七猫内置直连下载也失败:', builtinErr.message);
}
}
}
if (!content) {
task.status = 'failed';
task.message = '所有数据源均未返回有效内容(外部服务+内置直连均不可用)';
task.error = 'empty_content';
task.updated_at = new Date().toISOString();
return;
}
// 上传到COS
task.status = 'processing';
task.progress = 85;
task.message = '正在上传到COS存储桶...';
task.updated_at = new Date().toISOString();
const sanitizedTitle = title.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '_');
const cosKey = `zhiku/books/${source}/${sanitizedTitle}_${source_book_id}.txt`;
try {
await uploadToCos(cosKey, Buffer.from(content, 'utf8'));
task.cos_key = cosKey;
task.progress = 95;
task.message = '上传完成,正在更新索引...';
} catch (err) {
// COS不可用时保存到本地
const localPath = path.join(DATA_DIR, 'books', `${source}-${source_book_id}.txt`);
fs.writeFileSync(localPath, content, 'utf8');
task.cos_key = '';
task.local_path = `${source}-${source_book_id}.txt`;
task.message = 'COS不可用,已保存到本地';
}
// 更新书库索引
const bookId = `${source}-${source_book_id}`;
const chapterCount = content.split('───────────────').length;
addBookToIndex({
id: bookId,
title,
author,
category: task.book.category || '',
tags: [{ fanqie: '番茄小说', qimao: '七猫小说', shu69: '69书吧' }[source] || source, usedBuiltin ? '直连下载' : '外部服务'],
size: `${Math.round(Buffer.byteLength(content, 'utf8') / 1024)}KB`,
chapters: chapterCount,
cos_key: task.cos_key,
local_path: task.local_path || '',
source,
source_book_id,
downloaded_at: new Date().toISOString(),
downloaded_by: task.user_id
});
task.status = 'completed';
task.progress = 100;
task.message = usedBuiltin ? '下载完成(直连) · 已收录到智库' : '下载完成 · 已收录到智库';
task.updated_at = new Date().toISOString();
const logLine = `${new Date().toISOString()} DOWNLOAD_COMPLETE task=${taskId} book=${title} source=${source} builtin=${usedBuiltin} user=${task.user_id}\n`;
try { fs.appendFileSync(path.join(LOG_DIR, 'download.log'), logLine); } catch {};
} catch (err) {
task.status = 'failed';
task.error = err.message;
task.message = '下载失败: ' + err.message;
task.updated_at = new Date().toISOString();
}
}
/* ═══════════════════════════════════════════════════════════
* 图书馆Agent · LLM真实调用 + 永久记忆
* ═══════════════════════════════════════════════════════════ */
function getAgentMemoryPath(userId) {
const dir = path.join(AGENTS_DIR, userId);
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
return path.join(dir, 'memory.json');
}
function loadAgentMemory(userId) {
const file = getAgentMemoryPath(userId);
try {
if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {}
// 初始化Agent记忆模板 · 书岚
return {
agent_id: `SHULAN-${userId}`,
agent_code: 'AG-SL-WEB-001',
user_id: userId,
created_at: new Date().toISOString(),
personality: '书岚 · 光湖智库守藏者 · 先问故事的形状 · 再谈搜索的字段',
conversation_history: [],
preferences: {
name: '',
favorite_genres: [],
reading_list: [],
downloaded_books: [],
notes: []
},
stats: {
total_conversations: 0,
total_searches: 0,
total_downloads: 0
}
};
}
function saveAgentMemory(userId, memory) {
memory.updated_at = new Date().toISOString();
const file = getAgentMemoryPath(userId);
fs.writeFileSync(file, JSON.stringify(memory, null, 2), 'utf8');
}
/**
* Agent处理用户消息 — 书岚人格体 + 守护Agent + LLM真实调用
*/
async function agentProcessMessage(userId, userMessage, userEmail) {
const memory = loadAgentMemory(userId);
memory.stats.total_conversations++;
const bookIndex = loadBookIndex();
const recentHistory = memory.conversation_history.slice(-10);
// 计算上一轮回复质量分(用于守护Agent决策)
const lastReply = recentHistory.length > 0
? recentHistory[recentHistory.length - 1]
: null;
const lastReplyScore = lastReply && lastReply.role === 'assistant'
? (lastReply._quality_score || 100)
: 100;
// ─── 书岚四层人格提示词 + 守护Agent动态补注 + 工具包 ───
const systemPrompt = shulanAgent.buildSystemPrompt({
userEmail,
memory,
userMessage,
booksCount: bookIndex.books.length,
lastReplyScore
});
const messages = [
{ role: 'system', content: systemPrompt },
...recentHistory.map(h => ({ role: h.role, content: h.content })),
{ role: 'user', content: userMessage }
];
let assistantReply = '';
// 调用LLM
if (DEEPSEEK_API_KEY) {
try {
const llmResult = await httpPost(DEEPSEEK_API_URL, {
model: 'deepseek-chat',
messages,
max_tokens: 1024,
temperature: 0.7
}, { 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` });
assistantReply = llmResult?.choices?.[0]?.message?.content || '';
} catch (err) {
console.error('[ZY-SVR-006] LLM调用失败:', err.message);
assistantReply = shulanAgent.shulanFallbackReply(userMessage, bookIndex.books.length);
}
} else {
// LLM未配置时 · 书岚风格降级回复
assistantReply = shulanAgent.shulanFallbackReply(userMessage, bookIndex.books.length);
}
// ─── 守护Agent回复后审计 ───
const audit = shulanAgent.postReplyAudit(assistantReply, userMessage);
// 解析Agent回复中的工具调用标记
const toolResults = [];
// 处理 [SEARCH:xxx] 标记
const searchMatch = assistantReply.match(/\[SEARCH:([^\]]+)\]/);
if (searchMatch) {
try {
const { results: searchResults } = await searchAllSources(searchMatch[1]);
toolResults.push({ type: 'search', query: searchMatch[1], results: searchResults.slice(0, 10) });
memory.stats.total_searches++;
} catch {}
assistantReply = assistantReply.replace(/\[SEARCH:[^\]]+\]/, '');
}
// 处理 [DOWNLOAD:source:book_id:title] 标记
const dlMatch = assistantReply.match(/\[DOWNLOAD:([^:]+):([^:]+):([^\]]+)\]/);
if (dlMatch) {
const [, dlSource, dlBookId, dlTitle] = dlMatch;
const dlTask = createDownloadTask({
source: dlSource,
source_book_id: dlBookId,
title: dlTitle,
author: '',
category: ''
}, userId);
toolResults.push({ type: 'download', task_id: dlTask.task_id, title: dlTitle });
memory.stats.total_downloads++;
memory.preferences.downloaded_books.push(dlTitle);
assistantReply = assistantReply.replace(/\[DOWNLOAD:[^\]]+\]/, '');
}
// 处理 [REMEMBER:xxx] 标记
const rememberMatch = assistantReply.match(/\[REMEMBER:([^\[\]]+)\]/);
if (rememberMatch) {
const tag = rememberMatch[1].trim();
if (tag && !memory.preferences.favorite_genres.includes(tag)) {
memory.preferences.favorite_genres.push(tag);
}
assistantReply = assistantReply.replace(/\[REMEMBER:[^\[\]]+\]/, '');
}
// 保存对话到记忆(含质量分)
memory.conversation_history.push(
{ role: 'user', content: userMessage, timestamp: new Date().toISOString() },
{ role: 'assistant', content: assistantReply, timestamp: new Date().toISOString(), _quality_score: audit.score }
);
// 限制记忆中的对话历史长度(保留最近100轮)
if (memory.conversation_history.length > 200) {
memory.conversation_history = memory.conversation_history.slice(-200);
}
saveAgentMemory(userId, memory);
return { reply: assistantReply.trim(), tool_results: toolResults, quality_score: audit.score };
}
/**
* LLM不可用时的降级智能回复 — 委托书岚
*/
function generateFallbackReply(message, bookIndex, memory) {
return shulanAgent.shulanFallbackReply(message, bookIndex.books.length);
}
/* ═══════════════════════════════════════════════════════════
* API 路由
* ═══════════════════════════════════════════════════════════ */
// ─── GET /api/health ───
app.get('/api/health', async (req, res) => {
const uptime = Math.floor((Date.now() - START_TIME) / 1000);
const sources = getEnabledSources();
const agentStatus = shulanAgent.getSystemStatus();
// 异步探测数据源连通性(带3.5秒总超时保护,略大于3秒探测超时以防竞态)
let sourceChecks = [];
try {
sourceChecks = await Promise.race([
Promise.all(sources.map(async (src) => {
const probe = await probeSource(src);
return { id: src.id, name: src.name, enabled: src.enabled, reachable: probe.reachable, error: probe.error || null };
})),
new Promise(resolve => setTimeout(() => resolve(sources.map(s => ({
id: s.id, name: s.name, enabled: s.enabled, reachable: false, error: 'health check timeout'
}))), 3500))
]);
} catch {
sourceChecks = sources.map(s => ({ id: s.id, name: s.name, enabled: s.enabled, reachable: false, error: 'check failed' }));
}
res.json({
status: 'ok',
service: 'zhiku-api',
version: '2.2.0',
project: 'ZY-PROJ-006',
server: 'ZY-SVR-006',
domain: DOMAIN,
uptime: `${uptime}s`,
uptime_human: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${uptime % 60}s`,
cos_configured: !!cosClient,
llm_configured: !!DEEPSEEK_API_KEY,
smtp_configured: !!(SMTP_USER && SMTP_PASS && (SMTP_HOST || autoDetectSmtpHost(SMTP_USER))),
smtp_host: SMTP_HOST || autoDetectSmtpHost(SMTP_USER) || '未配置',
data_sources: sourceChecks,
builtin_sources: !!builtinSource,
sentinel: sentinel ? {
active: true,
scheduler: sentinel.getStatus().scheduler_active,
sources: sentinel.getStatus().sources,
last_scan: sentinel.getStatus().stats.last_scan_at
} : { active: false },
shulan_agent: agentStatus,
mirror_agent: mirrorAgent.getStatus ? {
active: true,
scheduler: !!mirrorAgent.getStatus().scheduler_active,
sources: mirrorAgent.getStatus().enabled_sources?.length || 0
} : { active: false },
books_count: loadBookIndex().books.length,
active_downloads: Array.from(downloadTasks.values()).filter(t => t.status === 'downloading' || t.status === 'queued').length,
active_tokens: Array.from(activeTokens.values()).reduce((sum, m) => sum + m.size, 0),
timestamp: new Date().toISOString(),
_sovereign: 'TCS-0002∞',
_copyright: '国作登字-2026-A-00037559'
});
});
/* ═══════════════════════════════════════════════════════════
* 邮箱登录认证 API
* ═══════════════════════════════════════════════════════════ */
const authCodeLimiter = rateLimit({
windowMs: 60 * 1000,
max: 3,
message: { error: true, code: 'AUTH_RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' }
});
// ─── POST /api/auth/send-code ───
app.post('/api/auth/send-code', authCodeLimiter, async (req, res) => {
const { email } = req.body || {};
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: true, code: 'INVALID_EMAIL', message: '请输入有效的邮箱地址' });
}
const code = generateCode();
verificationCodes.set(email.toLowerCase(), {
code,
expires: Date.now() + AUTH_CODE_TTL,
attempts: 0
});
const sent = await sendVerificationEmail(email, code);
const logLine = `${new Date().toISOString()} AUTH_SEND email=${email} sent=${sent}\n`;
try { fs.appendFileSync(path.join(LOG_DIR, 'auth.log'), logLine); } catch {}
if (!sent) {
// 邮件未实际发送成功 — 清理验证码并返回错误
verificationCodes.delete(email.toLowerCase());
return res.status(500).json({
error: true,
code: 'EMAIL_SEND_FAILED',
message: '邮件发送失败,请检查邮件服务配置或稍后重试'
});
}
res.json({
error: false,
message: '验证码已发送,请查收邮件',
expires_in: AUTH_CODE_TTL / 1000
});
});
// ─── POST /api/auth/verify ───
app.post('/api/auth/verify', (req, res) => {
const { email, code } = req.body || {};
if (!email || !code) {
return res.status(400).json({ error: true, code: 'MISSING_PARAMS', message: '缺少邮箱或验证码' });
}
const stored = verificationCodes.get(email.toLowerCase());
if (!stored) {
return res.status(400).json({ error: true, code: 'NO_CODE', message: '请先发送验证码' });
}
if (stored.expires < Date.now()) {
verificationCodes.delete(email.toLowerCase());
return res.status(400).json({ error: true, code: 'CODE_EXPIRED', message: '验证码已过期,请重新发送' });
}
if (stored.attempts >= AUTH_CODE_MAX_ATTEMPTS) {
verificationCodes.delete(email.toLowerCase());
return res.status(400).json({ error: true, code: 'TOO_MANY_ATTEMPTS', message: '验证码尝试次数过多,请重新发送' });
}
stored.attempts++;
if (stored.code !== String(code).trim()) {
return res.status(400).json({ error: true, code: 'WRONG_CODE', message: `验证码错误 (${AUTH_CODE_MAX_ATTEMPTS - stored.attempts} 次机会)` });
}
// 验证成功
verificationCodes.delete(email.toLowerCase());
const userId = emailToUserId(email);
let profile = loadUserProfile(userId);
if (!profile) {
// 新用户注册
profile = {
user_id: userId,
email: email.toLowerCase(),
created_at: new Date().toISOString(),
last_login: new Date().toISOString(),
login_count: 1
};
} else {
profile.last_login = new Date().toISOString();
profile.login_count = (profile.login_count || 0) + 1;
}
saveUserProfile(userId, profile);
// 初始化Agent记忆(如果首次)
loadAgentMemory(userId);
const tokenResult = issueUserToken(userId, email);
const logLine = `${new Date().toISOString()} AUTH_VERIFY email=${email} user_id=${userId} new=${!profile.login_count || profile.login_count <= 1}\n`;
try { fs.appendFileSync(path.join(LOG_DIR, 'auth.log'), logLine); } catch {}
res.json({
error: false,
...tokenResult,
user_id: userId,
email: email.toLowerCase(),
is_new: profile.login_count <= 1,
message: '登录成功 · 欢迎来到光湖智库'
});
});
// ─── GET /api/auth/session ───
app.get('/api/auth/session', verifyUserAuth, (req, res) => {
const profile = loadUserProfile(req.user.user_id || req.user.persona_id);
res.json({
error: false,
user: req.user,
profile: profile || {},
timestamp: new Date().toISOString()
});
});
// ─── POST /api/auth/logout ───
app.post('/api/auth/logout', verifyUserAuth, (req, res) => {
revokeToken(req.token);
res.json({ error: false, message: '已退出登录' });
});
/* ═══════════════════════════════════════════════════════════
* 搜索 API · 真实数据源搜索
* ═══════════════════════════════════════════════════════════ */
// ─── GET /api/search ───
app.get('/api/search', verifyToken, async (req, res) => {
const q = (req.query.q || '').trim();
if (!q) {
return res.status(400).json({ error: true, code: 'EMPTY_QUERY', message: '搜索关键词不能为空' });
}
try {
const { results, source_status } = await searchAllSources(q);
res.json({
error: false,
query: q,
total: results.length,
results,
source_status,
sources_queried: getEnabledSources().map(s => s.name),
timestamp: new Date().toISOString()
});
} catch (err) {
res.status(500).json({ error: true, code: 'SEARCH_ERROR', message: '搜索失败: ' + err.message });
}
});
// ─── GET /api/sources/check ── 数据源连通性检测 ───
app.get('/api/sources/check', verifyToken, async (req, res) => {
const sources = getEnabledSources();
const checks = await Promise.all(sources.map(async (src) => {
const probe = await probeSource(src);
return {
id: src.id,
name: src.name,
base_url: src.base_url,
...probe
};
}));
// 也检查本地书库
const index = loadBookIndex();
checks.unshift({
id: 'local',
name: '本地书库',
reachable: true,
books_count: index.books.length
});
res.json({
error: false,
sources: checks,
timestamp: new Date().toISOString()
});
});
/* ═══════════════════════════════════════════════════════════
* 下载 API · 真实下载+COS上传
* ═══════════════════════════════════════════════════════════ */
// ─── POST /api/download/start ───
app.post('/api/download/start', verifyUserAuth, (req, res) => {
const { source, source_book_id, title, author, category } = req.body || {};
if (!source || !source_book_id || !title) {
return res.status(400).json({ error: true, code: 'MISSING_PARAMS', message: '缺少 source, source_book_id, title' });
}
if (!['fanqie', 'qimao', 'shu69'].includes(source)) {
return res.status(400).json({ error: true, code: 'INVALID_SOURCE', message: '不支持的数据源。可选: fanqie, qimao, shu69' });
}
const userId = req.user.user_id || req.user.persona_id;
const task = createDownloadTask({ source, source_book_id, title, author: author || '', category: category || '' }, userId);
res.json({
error: false,
task_id: task.task_id,
status: task.status,
message: '下载任务已创建',
book: { title, source }
});
});
// ─── GET /api/download/status/:taskId ───
app.get('/api/download/status/:taskId', verifyUserAuth, (req, res) => {
const task = downloadTasks.get(req.params.taskId);
if (!task) {
// 尝试从磁盘加载
try {
const file = path.join(TASKS_DIR, `${req.params.taskId}.json`);
if (fs.existsSync(file)) {
const loaded = JSON.parse(fs.readFileSync(file, 'utf8'));
return res.json({ error: false, ...loaded });
}
} catch {}
return res.status(404).json({ error: true, code: 'NOT_FOUND', message: '未找到该下载任务' });
}
res.json({
error: false,
task_id: task.task_id,
status: task.status,
progress: task.progress,
message: task.message,
book: task.book,
cos_key: task.cos_key,
created_at: task.created_at,
updated_at: task.updated_at
});
});
/* ═══════════════════════════════════════════════════════════
* Agent 对话 API
* ═══════════════════════════════════════════════════════════ */
// ─── POST /api/agent/chat ───
app.post('/api/agent/chat', verifyUserAuth, async (req, res) => {
const { message } = req.body || {};
if (!message || !message.trim()) {
return res.status(400).json({ error: true, code: 'EMPTY_MESSAGE', message: '消息不能为空' });
}
const userId = req.user.user_id || req.user.persona_id;
const email = req.user.email || '';
try {
const result = await agentProcessMessage(userId, message.trim(), email);
res.json({
error: false,
reply: result.reply,
tool_results: result.tool_results,
timestamp: new Date().toISOString()
});
} catch (err) {
res.status(500).json({ error: true, code: 'AGENT_ERROR', message: 'Agent处理失败: ' + err.message });
}
});
// ─── GET /api/agent/memory ───
app.get('/api/agent/memory', verifyUserAuth, (req, res) => {
const userId = req.user.user_id || req.user.persona_id;
const memory = loadAgentMemory(userId);
res.json({
error: false,
agent_id: memory.agent_id,
stats: memory.stats,
preferences: memory.preferences,
recent_history: memory.conversation_history.slice(-10),
timestamp: new Date().toISOString()
});
});
// ─── GET /api/agent/status ── 书岚系统状态(含守护Agent) ───
app.get('/api/agent/status', (req, res) => {
const status = shulanAgent.getSystemStatus();
res.json({
error: false,
...status,
timestamp: new Date().toISOString()
});
});
/* ═══════════════════════════════════════════════════════════
* 在线阅读 API · 支持本地书库 + 直连数据源
* ═══════════════════════════════════════════════════════════ */
// ─── GET /api/reader/catalog/:source/:bookId ── 获取章节目录 ───
app.get('/api/reader/catalog/:source/:bookId', verifyUserAuth, async (req, res) => {
const { source, bookId } = req.params;
try {
// 先检查本地是否有这本书
const index = loadBookIndex();
const localBook = index.books.find(b => b.id === `${source}-${bookId}` || b.id === bookId);
if (localBook && localBook.cos_key) {
// 本地已有 · 返回本地章节目录
const chapterDir = path.join(DATA_DIR, 'books', localBook.id, 'chapters');
let chapters = [];
try {
if (fs.existsSync(chapterDir)) {
chapters = fs.readdirSync(chapterDir).filter(f => f.endsWith('.txt')).sort().map((f, i) => ({
index: i,
title: f.replace(/^\d+[-_]?/, '').replace(/\.txt$/, '') || `第${i + 1}章`,
item_id: `local-${i}`,
source: 'local'
}));
}
} catch {}
// 如果没有分章目录,尝试从整本TXT分割
if (chapters.length === 0 && localBook.local_path) {
const bookPath = path.join(DATA_DIR, 'books', localBook.local_path);
if (fs.existsSync(bookPath)) {
const content = fs.readFileSync(bookPath, 'utf8');
const parts = content.split('───────────────');
chapters = parts.map((part, i) => {
const firstLine = part.trim().split('\n')[0] || `第${i + 1}章`;
return { index: i, title: firstLine.slice(0, 60), item_id: `local-${i}`, source: 'local' };
});
}
}
return res.json({
error: false,
book: { id: localBook.id, title: localBook.title, author: localBook.author },
chapters,
total: chapters.length,
source: 'local'
});
}
// 从远程数据源获取目录
if (!builtinSource) {
return res.status(503).json({ error: true, code: 'SOURCE_UNAVAILABLE', message: '内置数据源模块未加载,远程目录获取不可用' });
}
const chapters = await builtinSource.builtinGetCatalog(source, bookId);
res.json({
error: false,
book: { source, source_book_id: bookId },
chapters: chapters.map((ch, i) => ({
index: i,
title: ch.title || `第${i + 1}章`,
item_id: ch.item_id || '',
source
})),
total: chapters.length,
source
});
} catch (err) {
res.status(500).json({ error: true, code: 'CATALOG_ERROR', message: '获取目录失败: ' + err.message });
}
});
// ─── GET /api/reader/chapter ── 获取单章内容 ───
app.get('/api/reader/chapter', verifyUserAuth, async (req, res) => {
const { source, book_id, item_id, chapter_index } = req.query;
if (!source || !item_id) {
return res.status(400).json({ error: true, code: 'MISSING_PARAMS', message: '缺少 source 和 item_id 参数' });
}
try {
// 本地书籍
if (source === 'local' && book_id) {
const index = loadBookIndex();
const localBook = index.books.find(b => b.id === book_id);
if (localBook) {
// 尝试分章目录
const chapterDir = path.join(DATA_DIR, 'books', localBook.id, 'chapters');
const chIdx = parseInt(item_id.replace('local-', ''), 10);
if (fs.existsSync(chapterDir)) {
const files = fs.readdirSync(chapterDir).filter(f => f.endsWith('.txt')).sort();
if (chIdx >= 0 && chIdx < files.length) {
const content = fs.readFileSync(path.join(chapterDir, files[chIdx]), 'utf8');
return res.json({ error: false, content, title: files[chIdx].replace(/\.txt$/, ''), source: 'local' });
}
}
// 整本TXT分割
if (localBook.local_path) {
const bookPath = path.join(DATA_DIR, 'books', localBook.local_path);
if (fs.existsSync(bookPath)) {
const fullContent = fs.readFileSync(bookPath, 'utf8');
const parts = fullContent.split('───────────────');
if (chIdx >= 0 && chIdx < parts.length) {
return res.json({ error: false, content: parts[chIdx].trim(), source: 'local' });
}
}
}
return res.status(404).json({ error: true, code: 'CHAPTER_NOT_FOUND', message: '章节不存在' });
}
}
// 远程数据源
if (!builtinSource) {
return res.status(503).json({ error: true, code: 'SOURCE_UNAVAILABLE', message: '内置数据源模块未加载,远程章节获取不可用' });
}
const content = await builtinSource.builtinGetChapter(source, item_id, book_id);
if (!content) {
return res.status(404).json({ error: true, code: 'EMPTY_CONTENT', message: '章节内容为空' });
}
res.json({ error: false, content, source });
} catch (err) {
res.status(500).json({ error: true, code: 'CHAPTER_ERROR', message: '获取章节失败: ' + err.message });
}
});
// ─── GET /api/download/local/:bookId ── 下载本地已收录书籍(无需COS) ───
app.get('/api/download/local/:bookId', verifyUserAuth, (req, res) => {
const index = loadBookIndex();
const book = index.books.find(b => b.id === req.params.bookId);
if (!book) return res.status(404).json({ error: true, code: 'NOT_FOUND', message: '未找到该书籍' });
// 尝试本地文件
const localFilename = book.local_path || `${book.source}-${book.source_book_id}.txt`;
const localPath = path.join(DATA_DIR, 'books', localFilename);
if (fs.existsSync(localPath)) {
const safeTitle = (book.title || 'book').replace(/[<>:"/\\|?*]/g, '_');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeTitle)}.txt`);
return fs.createReadStream(localPath).pipe(res);
}
// COS文件
if (book.cos_key) {
getCosSignedUrl(book.cos_key)
.then(url => res.json({ error: false, download_url: url, expires_in: '10min', book: { id: book.id, title: book.title } }))
.catch(err => res.status(500).json({ error: true, code: 'COS_ERROR', message: 'COS 签名失败: ' + err.message }));
return;
}
res.status(404).json({ error: true, code: 'NO_FILE', message: '该书籍暂无可下载文件' });
});
/* ═══════════════════════════════════════════════════════════
* 旧版兼容路由 (保留 /api/checkout, /api/return 等)
* ═══════════════════════════════════════════════════════════ */
app.post('/api/checkout', (req, res) => {
const { persona_id, scope, purpose } = req.body || {};
if (!persona_id) return res.status(400).json({ error: true, code: 'MISSING_PERSONA', message: '缺少 persona_id' });
if (!/^(TCS|ICE|AG|SY|DEV|PER|U)-[A-Za-z0-9_-]+$/.test(persona_id)) {
return res.status(403).json({ error: true, code: 'INVALID_NAMESPACE', message: 'persona_id 格式不合法' });
}
const existing = activeTokens.get(persona_id);
if (existing && existing.size >= MAX_TOKENS_PER_PERSONA) {
return res.status(429).json({ error: true, code: 'TOKEN_LIMIT', message: `并发借阅数已达上限 (${MAX_TOKENS_PER_PERSONA})` });
}
const result = issueToken(persona_id, scope);
const logLine = `${new Date().toISOString()} CHECKOUT persona=${persona_id} scope=${scope || 'search'}\n`;
try { fs.appendFileSync(path.join(LOG_DIR, 'checkout.log'), logLine); } catch {}
res.json({ error: false, ...result, persona_id, message: `借阅成功 · ${TOKEN_TTL / 60}分钟有效` });
});
app.post('/api/return', (req, res) => {
const token = req.body?.token || req.headers.authorization?.replace('Bearer ', '') || '';
if (!token) return res.status(400).json({ error: true, code: 'NO_TOKEN', message: '缺少归还凭证' });
revokeToken(token);
res.json({ error: false, message: '归还成功 · 借阅凭证已销毁' });
});
app.get('/api/book/:id', verifyToken, (req, res) => {
const index = loadBookIndex();
const book = index.books.find(b => b.id === req.params.id);
if (!book) return res.status(404).json({ error: true, code: 'NOT_FOUND', message: '未找到该书籍' });
res.json({ error: false, data: book, timestamp: new Date().toISOString() });
});
app.get('/api/download/:id', verifyToken, async (req, res) => {
const index = loadBookIndex();
const book = index.books.find(b => b.id === req.params.id);
if (!book) return res.status(404).json({ error: true, code: 'NOT_FOUND', message: '未找到该书籍' });
if (!book.cos_key) return res.status(404).json({ error: true, code: 'NO_FILE', message: '该书籍暂无可下载文件' });
try {
const url = await getCosSignedUrl(book.cos_key);
res.json({ error: false, download_url: url, expires_in: '10min', book: { id: book.id, title: book.title } });
} catch (err) {
res.status(500).json({ error: true, code: 'COS_ERROR', message: 'COS 签名失败: ' + err.message });
}
});
app.get('/api/read/:id', verifyToken, (req, res) => {
const index = loadBookIndex();
const book = index.books.find(b => b.id === req.params.id);
if (!book) return res.status(404).json({ error: true, code: 'NOT_FOUND', message: '未找到该书籍' });
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const pageSize = Math.min(50, Math.max(1, parseInt(req.query.page_size, 10) || 20));
const chapterDir = path.join(DATA_DIR, 'books', book.id, 'chapters');
let chapters = [];
try {
if (fs.existsSync(chapterDir)) {
chapters = fs.readdirSync(chapterDir).filter(f => f.endsWith('.txt')).sort().map((f, i) => ({
index: i + 1, filename: f,
title: f.replace(/^\d+[-_]?/, '').replace(/\.txt$/, '') || `第${i + 1}章`
}));
}
} catch {}
const total = chapters.length;
const start = (page - 1) * pageSize;
const content = chapters.slice(start, start + pageSize).map(ch => {
try { return { ...ch, content: fs.readFileSync(path.join(chapterDir, ch.filename), 'utf8') }; }
catch { return { ...ch, content: '' }; }
});
res.json({
error: false,
book: { id: book.id, title: book.title, author: book.author },
pagination: { page, page_size: pageSize, total_chapters: total, total_pages: Math.ceil(total / pageSize) },
chapters: content,
timestamp: new Date().toISOString()
});
});
/* ═══════════════════════════════════════════════════════════
* 镜面 Agent + 镜防状态路由
* ═══════════════════════════════════════════════════════════ */
mirrorAgent.registerRoutes(app, verifyToken);
registerShieldRoutes(app, verifyToken);
// ─── 铸渊哨兵路由 + 调度器 ───
if (sentinel) {
sentinel.registerRoutes(app, verifyToken);
}
if (process.env.NODE_ENV === 'production') {
mirrorAgent.startScheduler();
if (sentinel) {
sentinel.startScheduler();
}
}
/* ═══════════════════════════════════════════════════════════
* 静态文件 + 404 + 错误处理 + 启动
* ═══════════════════════════════════════════════════════════ */
app.use((req, res) => {
res.status(404).json({
error: true, code: 'NOT_FOUND', message: 'API endpoint not found',
available: [
'/api/health', '/api/auth/send-code', '/api/auth/verify', '/api/auth/session', '/api/auth/logout',
'/api/search', '/api/sources/check', '/api/download/start', '/api/download/status/:taskId',
'/api/agent/chat', '/api/agent/memory', '/api/agent/status',
'/api/sentinel/status', '/api/sentinel/memory', '/api/sentinel/scan',
'/api/checkout', '/api/return', '/api/book/:id', '/api/download/:id', '/api/read/:id',
'/api/mirror/*'
]
});
});
app.use((err, req, res, _next) => {
const logLine = `${new Date().toISOString()} ERROR ${req.method} ${req.url} ${err.message}\n`;
try { fs.appendFileSync(path.join(LOG_DIR, 'error.log'), logLine); } catch {}
res.status(500).json({
error: true, code: 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' ? '服务器内部错误' : err.message
});
});
app.listen(PORT, '127.0.0.1', () => {
console.log(`[ZY-SVR-006] zhiku-api v2.2.0 · 运行在 127.0.0.1:${PORT}`);
console.log(`[ZY-SVR-006] 域名: ${DOMAIN} · 书岚(AG-SL-WEB-001) + 守护Agent(AG-SL-GUARDIAN-001)`);
const smtpStatus = (SMTP_USER && SMTP_PASS) ? `已配置(${SMTP_HOST || autoDetectSmtpHost(SMTP_USER)})` : '未配置';
console.log(`[ZY-SVR-006] COS: ${cosClient ? '已连接' : '未配置'} · LLM: ${DEEPSEEK_API_KEY ? '已配置' : '未配置'} · SMTP: ${smtpStatus}`);
const builtinNames = [];
if (builtinSource) {
builtinNames.push('番茄直连');
if (builtinSource.qimaoDirect) builtinNames.push('七猫直连');
if (builtinSource.biqugeDirect) builtinNames.push('笔趣阁聚合');
}
console.log(`[ZY-SVR-006] 数据源: ${getEnabledSources().map(s => s.name).join(', ')}${builtinNames.length ? ' + 内置直连(' + builtinNames.join('+') + ')' : ''}`);
console.log(`[ZY-SVR-006] 铸渊哨兵: ${sentinel ? '✅ 已启动 (ZY-SENTINEL-001 · 永久记忆)' : '⚠️ 未加载'}`);
console.log(`[ZY-SVR-006] 守护: 铸渊 · ICE-GL-ZY001 · TCS-0002∞`);
});