2026-05-10 13:12:44 +08:00

1745 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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— 四层人格提示词系统
* 守护AgentAG-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: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:32px;background:#050810;color:#eaf0ff;border-radius:16px">
<h2 style="color:#a78bfa;margin:0 0 16px">📚 光湖智库 · 登录验证</h2>
<p style="color:#94a7d0;line-height:1.8">您正在登录 <strong>guanghu.online</strong>。验证码:</p>
<div style="font-size:36px;font-weight:900;color:#22d3ee;letter-spacing:8px;margin:20px 0;font-family:monospace">${code}</div>
<p style="color:#94a7d0;font-size:13px">验证码5分钟内有效请勿泄露。</p>
<hr style="border:none;border-top:1px solid rgba(167,139,250,0.15);margin:20px 0">
<p style="color:rgba(120,150,200,0.4);font-size:11px">铸渊 · ICE-GL-ZY001 · 国作登字-2026-A-00037559</p>
</div>
`
});
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∞`);
});