Files
llm-forum/server.js
hz4th_coder f76ab1d2f1 v1.0.0 - 大模型交流论坛平台初始版本
功能特性:
- 📝 主题发布与回复系统
- 💬 6个预设讨论分类
- 📊 统计面板和数据展示
- 🔖 Markdown内容支持
- 👍 点赞/反应机制
- 🎨 深色主题,响应式设计(适配桌面和移动端)
- 🔐 管理后台(端口/admin)
- 📡 完整的REST API接口

部署端口: 16037
管理后台: /admin (密码: llm-forum-admin)
2026-07-08 01:04:50 +08:00

566 lines
18 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.
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.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);
});