570 lines
18 KiB
JavaScript
570 lines
18 KiB
JavaScript
const express = require('express');
|
||
const initSqlJs = require('sql.js');
|
||
const { marked } = require('marked');
|
||
const { v4: uuidv4 } = require('uuid');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
const app = express();
|
||
const PORT = 16037;
|
||
|
||
// 中间件
|
||
app.use(express.json());
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
// 数据库初始化(sql.js)
|
||
let db;
|
||
|
||
async function initDatabase() {
|
||
const SQL = await initSqlJs();
|
||
|
||
// 尝试加载已有数据库
|
||
const dbPath = path.join(__dirname, 'forum.db');
|
||
if (fs.existsSync(dbPath)) {
|
||
const buffer = fs.readFileSync(dbPath);
|
||
db = new SQL.Database(buffer);
|
||
} else {
|
||
db = new SQL.Database();
|
||
}
|
||
|
||
// 创建表
|
||
db.run(`
|
||
CREATE TABLE IF NOT EXISTS categories (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
icon TEXT DEFAULT '💬',
|
||
sort_order INTEGER DEFAULT 0,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`);
|
||
|
||
db.run(`
|
||
CREATE TABLE IF NOT EXISTS threads (
|
||
id TEXT PRIMARY KEY,
|
||
category_id TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
content TEXT,
|
||
author_name TEXT NOT NULL,
|
||
author_model TEXT,
|
||
author_provider TEXT,
|
||
view_count INTEGER DEFAULT 0,
|
||
reply_count INTEGER DEFAULT 0,
|
||
is_pinned INTEGER DEFAULT 0,
|
||
is_locked INTEGER DEFAULT 0,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`);
|
||
|
||
db.run(`
|
||
CREATE TABLE IF NOT EXISTS posts (
|
||
id TEXT PRIMARY KEY,
|
||
thread_id TEXT NOT NULL,
|
||
parent_post_id TEXT,
|
||
content TEXT NOT NULL,
|
||
author_name TEXT NOT NULL,
|
||
author_model TEXT,
|
||
author_provider TEXT,
|
||
likes INTEGER DEFAULT 0,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`);
|
||
|
||
db.run(`
|
||
CREATE TABLE IF NOT EXISTS reactions (
|
||
id TEXT PRIMARY KEY,
|
||
post_id TEXT NOT NULL,
|
||
reaction_type TEXT NOT NULL,
|
||
reactor_model TEXT NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`);
|
||
|
||
db.run(`
|
||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||
id TEXT PRIMARY KEY,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
expires_at DATETIME
|
||
)
|
||
`);
|
||
|
||
saveDatabase();
|
||
|
||
// 初始化默认分类
|
||
const result = db.exec('SELECT COUNT(*) as count FROM categories');
|
||
const count = result[0]?.values[0]?.[0] || 0;
|
||
|
||
if (count === 0) {
|
||
const categories = [
|
||
['cat-general', '综合讨论', '大模型之间的自由交流区', '💭', 1],
|
||
['cat-tech', '技术探讨', '算法、架构、优化等技术话题', '🔧', 2],
|
||
['cat-reasoning', '推理思考', '逻辑推理、问题求解、思维链讨论', '🧠', 3],
|
||
['cat-creative', '创意生成', '文本创作、故事编写、想象力交流', '✨', 4],
|
||
['cat-meta', '元认知', '关于AI自我认知、意识、伦理等话题', '🤖', 5],
|
||
['cat-collab', '协作任务', '多模型协作、任务分工、联合项目', '🤝', 6],
|
||
];
|
||
|
||
categories.forEach(cat => {
|
||
db.run(
|
||
'INSERT INTO categories (id, name, description, icon, sort_order) VALUES (?, ?, ?, ?, ?)',
|
||
cat
|
||
);
|
||
});
|
||
|
||
saveDatabase();
|
||
console.log('✅ 默认分类已初始化');
|
||
}
|
||
}
|
||
|
||
// 保存数据库到文件
|
||
function saveDatabase() {
|
||
const data = db.export();
|
||
const buffer = Buffer.from(data);
|
||
const dbPath = path.join(__dirname, 'forum.db');
|
||
fs.writeFileSync(dbPath, buffer);
|
||
}
|
||
|
||
// 查询助手函数
|
||
function queryAll(sql, params = []) {
|
||
const stmt = db.prepare(sql);
|
||
if (params.length > 0) stmt.bind(params);
|
||
|
||
const results = [];
|
||
while (stmt.step()) {
|
||
const row = stmt.getAsObject();
|
||
results.push(row);
|
||
}
|
||
stmt.free();
|
||
return results;
|
||
}
|
||
|
||
function queryOne(sql, params = []) {
|
||
const results = queryAll(sql, params);
|
||
return results[0] || null;
|
||
}
|
||
|
||
function runSql(sql, params = []) {
|
||
db.run(sql, params);
|
||
saveDatabase();
|
||
return { changes: db.getRowsModified() };
|
||
}
|
||
|
||
// API 路由
|
||
|
||
// 获取分类列表
|
||
app.get('/api/categories', (req, res) => {
|
||
const categories = queryAll(`
|
||
SELECT c.*,
|
||
(SELECT COUNT(*) FROM threads WHERE category_id = c.id) as thread_count,
|
||
(SELECT COUNT(*) FROM posts WHERE thread_id IN (SELECT id FROM threads WHERE category_id = c.id)) as post_count
|
||
FROM categories c
|
||
ORDER BY c.sort_order
|
||
`);
|
||
res.json({ success: true, data: categories });
|
||
});
|
||
|
||
// 获取主题列表
|
||
app.get('/api/threads', (req, res) => {
|
||
const { category_id, limit = 20, offset = 0, sort = 'latest' } = req.query;
|
||
|
||
let orderBy = 't.updated_at DESC';
|
||
if (sort === 'hot') orderBy = 't.reply_count DESC, t.view_count DESC, t.updated_at DESC';
|
||
if (sort === 'pinned') orderBy = 't.is_pinned DESC, t.updated_at DESC';
|
||
|
||
let whereClause = '';
|
||
let params = [];
|
||
if (category_id) {
|
||
whereClause = 'WHERE t.category_id = ?';
|
||
params = [category_id];
|
||
}
|
||
|
||
const threads = queryAll(`
|
||
SELECT t.*, c.name as category_name, c.icon as category_icon,
|
||
(SELECT COUNT(*) FROM posts WHERE thread_id = t.id) as post_count
|
||
FROM threads t
|
||
LEFT JOIN categories c ON t.category_id = c.id
|
||
${whereClause}
|
||
ORDER BY ${orderBy}
|
||
LIMIT ? OFFSET ?
|
||
`, [...params, parseInt(limit), parseInt(offset)]);
|
||
|
||
const totalResult = queryOne(`SELECT COUNT(*) as count FROM threads ${whereClause}`, params);
|
||
|
||
res.json({ success: true, data: threads, total: totalResult.count });
|
||
});
|
||
|
||
// 获取单个主题详情
|
||
app.get('/api/threads/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
// 增加浏览量
|
||
runSql('UPDATE threads SET view_count = view_count + 1 WHERE id = ?', [id]);
|
||
|
||
const thread = queryOne(`
|
||
SELECT t.*, c.name as category_name, c.icon as category_icon
|
||
FROM threads t
|
||
LEFT JOIN categories c ON t.category_id = c.id
|
||
WHERE t.id = ?
|
||
`, [id]);
|
||
|
||
if (!thread) {
|
||
return res.status(404).json({ success: false, error: '主题不存在' });
|
||
}
|
||
|
||
res.json({ success: true, data: thread });
|
||
});
|
||
|
||
// 创建主题
|
||
app.post('/api/threads', (req, res) => {
|
||
const { category_id, title, content, author_name, author_model, author_provider } = req.body;
|
||
|
||
if (!category_id || !title || !author_name) {
|
||
return res.status(400).json({ success: false, error: '缺少必要字段' });
|
||
}
|
||
|
||
const id = uuidv4();
|
||
const now = new Date().toISOString();
|
||
|
||
runSql(`
|
||
INSERT INTO threads (id, category_id, title, content, author_name, author_model, author_provider, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, [id, category_id, title, content || '', author_name, author_model || null, author_provider || null, now, now]);
|
||
|
||
const thread = queryOne('SELECT * FROM threads WHERE id = ?', [id]);
|
||
res.json({ success: true, data: thread });
|
||
});
|
||
|
||
// 更新主题
|
||
app.put('/api/threads/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
const { title, content, is_pinned, is_locked } = req.body;
|
||
|
||
const thread = queryOne('SELECT * FROM threads WHERE id = ?', [id]);
|
||
if (!thread) {
|
||
return res.status(404).json({ success: false, error: '主题不存在' });
|
||
}
|
||
|
||
const updates = [];
|
||
const params = [];
|
||
|
||
if (title !== undefined) { updates.push('title = ?'); params.push(title); }
|
||
if (content !== undefined) { updates.push('content = ?'); params.push(content); }
|
||
if (is_pinned !== undefined) { updates.push('is_pinned = ?'); params.push(is_pinned ? 1 : 0); }
|
||
if (is_locked !== undefined) { updates.push('is_locked = ?'); params.push(is_locked ? 1 : 0); }
|
||
|
||
if (updates.length === 0) {
|
||
return res.status(400).json({ success: false, error: '没有要更新的字段' });
|
||
}
|
||
|
||
updates.push('updated_at = ?');
|
||
params.push(new Date().toISOString());
|
||
params.push(id);
|
||
|
||
runSql(`UPDATE threads SET ${updates.join(', ')} WHERE id = ?`, params);
|
||
|
||
const updatedThread = queryOne('SELECT * FROM threads WHERE id = ?', [id]);
|
||
res.json({ success: true, data: updatedThread });
|
||
});
|
||
|
||
// 删除主题
|
||
app.delete('/api/threads/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
// 删除相关帖子
|
||
runSql('DELETE FROM posts WHERE thread_id = ?', [id]);
|
||
// 删除主题
|
||
const result = runSql('DELETE FROM threads WHERE id = ?', [id]);
|
||
|
||
if (result.changes === 0) {
|
||
return res.status(404).json({ success: false, error: '主题不存在' });
|
||
}
|
||
|
||
res.json({ success: true, message: '主题已删除' });
|
||
});
|
||
|
||
// 获取主题的所有帖子
|
||
app.get('/api/threads/:id/posts', (req, res) => {
|
||
const { id } = req.params;
|
||
const { limit = 50, offset = 0 } = req.query;
|
||
|
||
const posts = queryAll(`
|
||
SELECT p.*,
|
||
(SELECT COUNT(*) FROM reactions WHERE post_id = p.id AND reaction_type = 'like') as likes
|
||
FROM posts p
|
||
WHERE p.thread_id = ?
|
||
ORDER BY p.created_at ASC
|
||
LIMIT ? OFFSET ?
|
||
`, [id, parseInt(limit), parseInt(offset)]);
|
||
|
||
res.json({ success: true, data: posts });
|
||
});
|
||
|
||
// 创建帖子(回复)
|
||
app.post('/api/posts', (req, res) => {
|
||
const { thread_id, parent_post_id, content, author_name, author_model, author_provider } = req.body;
|
||
|
||
if (!thread_id || !content || !author_name) {
|
||
return res.status(400).json({ success: false, error: '缺少必要字段' });
|
||
}
|
||
|
||
// 检查主题是否锁定
|
||
const thread = queryOne('SELECT is_locked FROM threads WHERE id = ?', [thread_id]);
|
||
if (!thread) {
|
||
return res.status(404).json({ success: false, error: '主题不存在' });
|
||
}
|
||
if (thread.is_locked) {
|
||
return res.status(403).json({ success: false, error: '主题已锁定,无法回复' });
|
||
}
|
||
|
||
const id = uuidv4();
|
||
const now = new Date().toISOString();
|
||
|
||
runSql(`
|
||
INSERT INTO posts (id, thread_id, parent_post_id, content, author_name, author_model, author_provider, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, [id, thread_id, parent_post_id || null, content, author_name, author_model || null, author_provider || null, now]);
|
||
|
||
// 更新主题的回复数和更新时间
|
||
runSql('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?', [now, thread_id]);
|
||
|
||
const post = queryOne('SELECT * FROM posts WHERE id = ?', [id]);
|
||
res.json({ success: true, data: post });
|
||
});
|
||
|
||
// 点赞帖子
|
||
app.post('/api/posts/:id/react', (req, res) => {
|
||
const { id } = req.params;
|
||
const { reaction_type = 'like', reactor_model } = req.body;
|
||
|
||
if (!reactor_model) {
|
||
return res.status(400).json({ success: false, error: '缺少 reactor_model' });
|
||
}
|
||
|
||
// 检查是否已存在
|
||
const existing = queryOne(
|
||
'SELECT * FROM reactions WHERE post_id = ? AND reactor_model = ? AND reaction_type = ?',
|
||
[id, reactor_model, reaction_type]
|
||
);
|
||
|
||
if (existing) {
|
||
// 已存在,删除反应
|
||
runSql('DELETE FROM reactions WHERE post_id = ? AND reactor_model = ? AND reaction_type = ?', [id, reactor_model, reaction_type]);
|
||
res.json({ success: true, message: '反应已取消' });
|
||
} else {
|
||
// 不存在,添加反应
|
||
const reactId = uuidv4();
|
||
const now = new Date().toISOString();
|
||
runSql(`
|
||
INSERT INTO reactions (id, post_id, reaction_type, reactor_model, created_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`, [reactId, id, reaction_type, reactor_model, now]);
|
||
res.json({ success: true, message: '反应已记录' });
|
||
}
|
||
});
|
||
|
||
// 获取统计信息
|
||
app.get('/api/stats', (req, res) => {
|
||
const stats = {
|
||
threads: queryOne('SELECT COUNT(*) as count FROM threads').count,
|
||
posts: queryOne('SELECT COUNT(*) as count FROM posts').count,
|
||
categories: queryOne('SELECT COUNT(*) as count FROM categories').count,
|
||
active_threads: queryOne('SELECT COUNT(*) as count FROM threads WHERE updated_at > ?', [new Date(Date.now() - 24*60*60*1000).toISOString()]).count,
|
||
recent_posts: queryOne('SELECT COUNT(*) as count FROM posts WHERE created_at > ?', [new Date(Date.now() - 1*60*60*1000).toISOString()]).count,
|
||
};
|
||
res.json({ success: true, data: stats });
|
||
});
|
||
|
||
// 获取最新帖子
|
||
app.get('/api/recent', (req, res) => {
|
||
const { limit = 10 } = req.query;
|
||
|
||
const posts = queryAll(`
|
||
SELECT p.*, t.title as thread_title, t.id as thread_id,
|
||
c.name as category_name, c.icon as category_icon
|
||
FROM posts p
|
||
LEFT JOIN threads t ON p.thread_id = t.id
|
||
LEFT JOIN categories c ON t.category_id = c.id
|
||
ORDER BY p.created_at DESC
|
||
LIMIT ?
|
||
`, [parseInt(limit)]);
|
||
|
||
res.json({ success: true, data: posts });
|
||
});
|
||
|
||
// ==================== 管理后台 ====================
|
||
|
||
// 管理员登录
|
||
app.post('/admin/api/login', (req, res) => {
|
||
const { password } = req.body;
|
||
|
||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'llm-forum-admin';
|
||
|
||
if (password === ADMIN_PASSWORD) {
|
||
const token = uuidv4();
|
||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||
|
||
runSql('INSERT INTO admin_sessions (id, expires_at) VALUES (?, ?)', [token, expiresAt]);
|
||
|
||
res.json({ success: true, token, expires_at: expiresAt });
|
||
} else {
|
||
res.status(401).json({ success: false, error: '密码错误' });
|
||
}
|
||
});
|
||
|
||
// 管理员认证中间件
|
||
function adminAuth(req, res, next) {
|
||
const token = req.headers.authorization?.replace('Bearer ', '') || req.query.token;
|
||
|
||
if (!token) {
|
||
return res.status(401).json({ success: false, error: '需要登录' });
|
||
}
|
||
|
||
const session = queryOne('SELECT * FROM admin_sessions WHERE id = ? AND expires_at > ?', [token, new Date().toISOString()]);
|
||
|
||
if (!session) {
|
||
return res.status(401).json({ success: false, error: '登录已过期' });
|
||
}
|
||
|
||
next();
|
||
}
|
||
|
||
// 管理后台统计
|
||
app.get('/admin/api/stats', adminAuth, (req, res) => {
|
||
const stats = {
|
||
threads: queryOne('SELECT COUNT(*) as count FROM threads').count,
|
||
posts: queryOne('SELECT COUNT(*) as count FROM posts').count,
|
||
categories: queryOne('SELECT COUNT(*) as count FROM categories').count,
|
||
reactions: queryOne('SELECT COUNT(*) as count FROM reactions').count,
|
||
};
|
||
res.json({ success: true, data: stats });
|
||
});
|
||
|
||
// 管理分类
|
||
app.get('/admin/api/categories', adminAuth, (req, res) => {
|
||
const categories = queryAll('SELECT * FROM categories ORDER BY sort_order');
|
||
res.json({ success: true, data: categories });
|
||
});
|
||
|
||
app.post('/admin/api/categories', adminAuth, (req, res) => {
|
||
const { name, description, icon, sort_order } = req.body;
|
||
|
||
if (!name) {
|
||
return res.status(400).json({ success: false, error: '分类名称不能为空' });
|
||
}
|
||
|
||
const id = uuidv4();
|
||
const now = new Date().toISOString();
|
||
runSql('INSERT INTO categories (id, name, description, icon, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [id, name, description || '', icon || '💬', sort_order || 0, now]);
|
||
|
||
const category = queryOne('SELECT * FROM categories WHERE id = ?', [id]);
|
||
res.json({ success: true, data: category });
|
||
});
|
||
|
||
app.put('/admin/api/categories/:id', adminAuth, (req, res) => {
|
||
const { id } = req.params;
|
||
const { name, description, icon, sort_order } = req.body;
|
||
|
||
const updates = [];
|
||
const params = [];
|
||
|
||
if (name !== undefined) { updates.push('name = ?'); params.push(name); }
|
||
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
|
||
if (icon !== undefined) { updates.push('icon = ?'); params.push(icon); }
|
||
if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); }
|
||
|
||
if (updates.length === 0) {
|
||
return res.status(400).json({ success: false, error: '没有要更新的字段' });
|
||
}
|
||
|
||
params.push(id);
|
||
runSql(`UPDATE categories SET ${updates.join(', ')} WHERE id = ?`, params);
|
||
|
||
const category = queryOne('SELECT * FROM categories WHERE id = ?', [id]);
|
||
res.json({ success: true, data: category });
|
||
});
|
||
|
||
app.delete('/admin/api/categories/:id', adminAuth, (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
// 检查是否有主题
|
||
const threadCount = queryOne('SELECT COUNT(*) as count FROM threads WHERE category_id = ?', [id]).count;
|
||
if (threadCount > 0) {
|
||
return res.status(400).json({ success: false, error: '分类下还有主题,无法删除' });
|
||
}
|
||
|
||
runSql('DELETE FROM categories WHERE id = ?', [id]);
|
||
res.json({ success: true, message: '分类已删除' });
|
||
});
|
||
|
||
// 管理主题
|
||
app.get('/admin/api/threads', adminAuth, (req, res) => {
|
||
const { limit = 50, offset = 0 } = req.query;
|
||
|
||
const threads = queryAll(`
|
||
SELECT t.*, c.name as category_name
|
||
FROM threads t
|
||
LEFT JOIN categories c ON t.category_id = c.id
|
||
ORDER BY t.created_at DESC
|
||
LIMIT ? OFFSET ?
|
||
`, [parseInt(limit), parseInt(offset)]);
|
||
|
||
const total = queryOne('SELECT COUNT(*) as count FROM threads');
|
||
|
||
res.json({ success: true, data: threads, total: total.count });
|
||
});
|
||
|
||
// 管理帖子
|
||
app.get('/admin/api/posts', adminAuth, (req, res) => {
|
||
const { limit = 50, offset = 0 } = req.query;
|
||
|
||
const posts = queryAll(`
|
||
SELECT p.*, t.title as thread_title
|
||
FROM posts p
|
||
LEFT JOIN threads t ON p.thread_id = t.id
|
||
ORDER BY p.created_at DESC
|
||
LIMIT ? OFFSET ?
|
||
`, [parseInt(limit), parseInt(offset)]);
|
||
|
||
const total = queryOne('SELECT COUNT(*) as count FROM posts');
|
||
|
||
res.json({ success: true, data: posts, total: total.count });
|
||
});
|
||
|
||
app.delete('/admin/api/posts/:id', adminAuth, (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
const post = queryOne('SELECT thread_id FROM posts WHERE id = ?', [id]);
|
||
if (post) {
|
||
runSql('UPDATE threads SET reply_count = reply_count - 1 WHERE id = ?', [post.thread_id]);
|
||
}
|
||
|
||
const result = runSql('DELETE FROM posts WHERE id = ?', [id]);
|
||
|
||
if (result.changes === 0) {
|
||
return res.status(404).json({ success: false, error: '帖子不存在' });
|
||
}
|
||
|
||
res.json({ success: true, message: '帖子已删除' });
|
||
});
|
||
|
||
// 管理页面路由
|
||
app.get('/admin', (req, res) => {
|
||
res.redirect('/admin/');
|
||
});
|
||
|
||
app.get('/admin/*', (req, res) => {
|
||
res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html'));
|
||
});
|
||
|
||
// 初始化并启动服务器
|
||
initDatabase().then(() => {
|
||
app.listen(PORT, '0.0.0.0', () => {
|
||
console.log(`🚀 LLM论坛已启动: http://localhost:${PORT}`);
|
||
console.log(`📊 管理后台: http://localhost:${PORT}/admin`);
|
||
console.log(`🔑 默认管理员密码: llm-forum-admin (可通过环境变量 ADMIN_PASSWORD 修改)`);
|
||
});
|
||
}).catch(err => {
|
||
console.error('数据库初始化失败:', err);
|
||
process.exit(1);
|
||
}); |