评论系统(SQLite + Node.js)

评论系统(SQLite + Node.js)快速部署指南

仅包含后端安装与配置

一、安装依赖环境

1
2
3
4
5
6
7
8
9
10
11
12
13
# 更新系统
sudo apt update && sudo apt -y upgrade

# 安装 SQLite3
sudo apt install -y sqlite3

# 安装 Node.js
sudo apt install -y nodejs npm

# 验证版本
node -v
npm -v
sqlite3 --version

二、创建项目目录

1
2
3
sudo mkdir -p /opt/message

cd /opt/message

三、初始化 Node 项目

1
npm init -y

修改 package.json 为 ES Module 模式

1
sudo vim package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"name": "huishaonote-message",
"version": "1.0.0",
"description": "评论系统后端(Express + SQLite)",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "NODE_ENV=development nodemon server.js"
},
"dependencies": {
"@dicebear/core": "^9.2.4",
"@dicebear/identicon": "^9.2.4",
"better-sqlite3": "^9.6.0",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"helmet": "^7.2.0",
"morgan": "^1.10.1",
"sqlite3": "^5.1.7",
"xss": "^1.0.15"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}

安装依赖

1
2
npm install express helmet express-rate-limit morgan cors xss sqlite3 dotenv
npm install @dicebear/core @dicebear/identicon

四、创建数据库与表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sqlite3 /opt/message/huishaomessage.db

CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nickname TEXT NOT NULL,
email TEXT,
content TEXT NOT NULL,
avatar TEXT,
likes INTEGER DEFAULT 0,
parent_id INTEGER DEFAULT 0,
ip TEXT,
ua TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);

CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
SQL

数据库文件路径:/opt/message/huishaomessage.db

字段名 类型 说明
id 整数主键 每条评论唯一编号
nickname 文本 用户昵称
email 文本 邮箱(可为空)
content 文本 评论内容
avatar 文本 用户头像链接或Base64
likes 整数 点赞数
parent_id 整数 回复父评论ID(0为主评论)
ip 文本 用户IP
ua 文本 浏览器User-Agent
created_at 时间 创建时间(自动生成)
新增一条主评论
1
2
3
4
5
INSERT INTO messages 
(nickname, email, content, avatar, likes, parent_id, ip, ua)
VALUES
('辉少', 'huishao@example.com', '这是我在数据库中手动插入的一条主评论。',
'/api/avatar/huishao', 0, 0, '127.0.0.1', 'Manual Insert');
新增一条回复评论(假设回复的是 ID = 1 的评论)
1
2
3
4
5
INSERT INTO messages 
(nickname, email, content, avatar, likes, parent_id, ip, ua)
VALUES
('访客A', 'guest@example.com', '这是对 1 号评论的回复。',
'/api/avatar/guestA', 0, 1, '127.0.0.1', 'Manual Reply');
查询结果查看
1
2
3
4
SELECT id, nickname, content, parent_id, created_at 
FROM messages
ORDER BY id DESC
LIMIT 5;

返回类似结果:

id nickname content parent_id created_at
2 访客A 这是对 1 号评论的回复。 1 2025-10-04 15:01:12
1 辉少 这是我在数据库中手动插入的一条主评论。 0 2025-10-04 14:59:30

五、创建环境变量文件 .env

1
2
3
4
5
6
7
8
cat > /opt/message/.env <<'ENV'

PORT=3000
DB_FILE=/opt/message/huishaomessage.db
CORS_ORIGIN=*
TRUST_PROXY=1

ENV

六、创建主程序 server.js

保存路径:/opt/message/server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
/**
* ============================================================
* 评论系统后端 (Express + sqlite3)
* 支持本地头像生成(DiceBear identicon 离线版)
* 数据库文件: /opt/message/huishaomessage.db
* 作者: huishao
* ============================================================
*/

import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import morgan from 'morgan';
import cors from 'cors';
import xss from 'xss';
import sqlite3 from 'sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';

// ===== DiceBear 本地离线头像生成 =====
import { createAvatar } from '@dicebear/core';
import * as identicon from '@dicebear/identicon';

// =============================
// 环境初始化
// =============================
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
const PORT = process.env.PORT || 3000;
const DB_FILE = process.env.DB_FILE || '/opt/message/huishaomessage.db';
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
const TRUST_PROXY = process.env.TRUST_PROXY === '1';

if (TRUST_PROXY) app.set('trust proxy', 1);

// =============================
// 中间件
// =============================
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
})
);
app.use(express.json({ limit: '512kb' }));
app.use(morgan('tiny'));
app.use(
cors({
origin: CORS_ORIGIN === '*' ? true : CORS_ORIGIN,
credentials: false,
})
);

// 限流防刷
app.use(
rateLimit({
windowMs: 60 * 1000, // 每分钟
max: 120, // 最多 120 次
standardHeaders: true,
legacyHeaders: false,
})
);

// =============================
// 数据库连接
// =============================
const dbPath = path.resolve(DB_FILE);
const db = new sqlite3.Database(dbPath, (err) => {
if (err) console.error('❌ 数据库连接失败:', err.message);
else console.log(`✅ 已连接数据库: ${dbPath}`);
});

// =============================
// 工具函数
// =============================
const clamp = (n, min, max) => Math.min(max, Math.max(min, n));
const sanitizeInput = (str) =>
xss(str, {
whiteList: {},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script'],
});

// =============================
// 路由:获取评论(含嵌套)
// GET /api/messages
// =============================
app.get('/api/messages', (req, res) => {
const size = clamp(parseInt(req.query.size || '200', 10), 1, 500);
const page = clamp(parseInt(req.query.page || '1', 10), 1, 1000000);
const offset = (page - 1) * size;

db.get(`SELECT COUNT(*) AS total FROM messages`, [], (err, row) => {
if (err) return res.status(500).json({ error: '数据库统计失败' });

const total = row.total;
db.all(
`
SELECT
id, nickname, email, content, avatar, likes, parent_id, created_at
FROM messages
ORDER BY id ASC
LIMIT ? OFFSET ?
`,
[size, offset],
(err, rows) => {
if (err) return res.status(500).json({ error: '数据库查询失败' });
res.json({ total, page, size, items: rows });
}
);
});
});

// =============================
// 路由:发布评论 / 回复
// POST /api/messages
// =============================
app.post('/api/messages', (req, res) => {
let { nickname, email, content, avatar, parent_id } = req.body || {};
const ip =
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.socket.remoteAddress ||
'';
const ua = req.headers['user-agent'] || '';

// 参数校验
if (typeof nickname !== 'string' || typeof content !== 'string') {
return res.status(400).json({ error: '参数错误' });
}

nickname = nickname.trim();
email = (email || '').trim();
content = content.trim();
avatar = typeof avatar === 'string' ? avatar.trim() : null;
parent_id = parseInt(parent_id || 0);

if (!nickname || nickname.length > 20)
return res.status(400).json({ error: '昵称不能为空且不超过20字' });
if (!content || content.length > 1000)
return res.status(400).json({ error: '内容不能为空且不超过1000字' });

const safeNickname = sanitizeInput(nickname);
const safeContent = sanitizeInput(content);
const safeEmail = sanitizeInput(email);

const sql = `
INSERT INTO messages (nickname, email, content, avatar, ip, ua, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
db.run(
sql,
[safeNickname, safeEmail, safeContent, avatar, ip, ua, parent_id],
function (err) {
if (err) {
console.error('❌ 数据库写入失败:', err);
return res.status(500).json({ error: '数据库写入失败' });
}

db.get(
`
SELECT id, nickname, email, content, avatar, likes, parent_id, created_at
FROM messages WHERE id = ?
`,
[this.lastID],
(err, row) => {
if (err) {
console.error('❌ 查询新评论失败:', err);
return res.status(500).json({ error: '查询失败' });
}
res.json(row);
}
);
}
);
});

// =============================
// 路由:点赞评论
// POST /api/messages/like/:id
// =============================
app.post('/api/messages/like/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: '无效 ID' });

const updateSql = `UPDATE messages SET likes = likes + 1 WHERE id = ?`;
db.run(updateSql, [id], function (err) {
if (err) {
console.error('❌ 数据库更新失败:', err);
return res.status(500).json({ error: '数据库更新失败' });
}

db.get(`SELECT id, likes FROM messages WHERE id = ?`, [id], (err, row) => {
if (err) return res.status(500).json({ error: '查询失败' });
res.json(row);
});
});
});

// =============================
// 路由:本地头像生成(离线 DiceBear identicon)
// GET /api/avatar/:seed
// =============================
app.get('/api/avatar/:seed', async (req, res) => {
try {
const seed = req.params.seed || 'guest';
const avatar = createAvatar(identicon, {
seed,
backgroundColor: ['#ffffff', '#f0f0f0'],
radius: 50,
size: 80,
});
res.setHeader('Content-Type', 'image/svg+xml');
res.send(avatar.toString());
} catch (err) {
console.error('❌ 头像生成错误:', err);
res.status(500).send('头像模块未加载,请稍后再试');
}
});

// =============================
// 路由:健康检查
// GET /api/health
// =============================
app.get('/api/health', (req, res) => {
res.json({ ok: true, time: new Date().toLocaleString() });
});

// ============================================================
// 🛡️ 后台评论管理接口(管理员专用)
// ============================================================

// 管理密码(前端 index.html 访问时需要 ?key=xxx)
const ADMIN_KEY = '123456';

// 允许前端 file:// 访问跨域
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type");
next();
});

// =========================
// 获取所有评论
// =========================
app.get('/api/admin/messages', (req, res) => {
const { key } = req.query;
if (key !== ADMIN_KEY) return res.status(403).json({ error: '无权访问' });

db.all(`SELECT id, nickname, email, content, created_at FROM messages ORDER BY created_at DESC`, [], (err, rows) => {
if (err) return res.status(500).json({ error: '数据库查询失败: ' + err.message });
res.json(rows || []);
});
});

// =========================
// 批量删除选中评论
// =========================
app.post('/api/admin/delete', express.json(), (req, res) => {
const { key, ids } = req.body;
if (key !== ADMIN_KEY) return res.status(403).json({ error: '无权访问' });
if (!Array.isArray(ids) || !ids.length) return res.json({ deleted: 0 });

const placeholders = ids.map(() => '?').join(',');
db.run(`DELETE FROM messages WHERE id IN (${placeholders})`, ids, function (err) {
if (err) return res.status(500).json({ error: '删除失败: ' + err.message });
res.json({ deleted: this.changes });
});
});

// =========================
// 一键清空全部评论
// =========================
app.post('/api/admin/clear', express.json(), (req, res) => {
const { key } = req.body;
if (key !== ADMIN_KEY) return res.status(403).json({ error: '无权访问' });

db.run(`DELETE FROM messages`, function (err) {
if (err) return res.status(500).json({ error: '清空失败: ' + err.message });
res.json({ success: true, message: '所有评论已清空!' });
});
});

// =============================
// 启动服务
// =============================
app.listen(PORT, () => {
console.log(`🚀 评论系统已启动: http://0.0.0.0:${PORT}`);
});


七、启动测试

1
2
3
cd /opt/message

node server.js

访问:http://服务器IP:3000/api/health
返回:{“ok”:true,”time”:”2025/10/04 14:00:00”}

访问:http://服务器IP:3000/api/avatar/huishao
返回:生成一张 SVG 头像(浏览器直接显示)

访问:http://服务器IP:3000/api/messages
返回:所有评论的 JSON 数据列表

八、注意

1
2
3
const API_HOST   = '';
const API_BASE = '/api/messages';
const AVATAR_BASE= '/api/avatar';
接口地址配置说明:
变量名 示例值 含义 说明
API_HOST ‘’ 后端主机地址 若前端与后端同域(同服务器),可留空,自动使用当前域名。
API_BASE /api/messages 留言接口路径 提交留言、加载留言、点赞等均通过此路径访问。
AVATAR_BASE /api/avatar 头像接口路径 生成默认头像(DiceBear)或访问用户头像。
使用说明:
部署场景 写法示例 实际访问路径
前后端同域部署(推荐) const API_HOST = ‘’; 浏览器自动请求 `https://当前域名/api/…
本地调试 const API_HOST = ‘http://127.0.0.1:3000‘; http://127.0.0.1:3000/api/messages
前后端分离(不同服务器) const API_HOST = ‘https://api.huishao.fun‘; https://api.huishao.fun/api/messages
nginx.conf
1
2
3
4
5
6
7
8
9
10
# server块内加入
location /api/ {
proxy_pass http://127.0.0.1:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
}

以上就是基础部署,仅供参考


评论系统(SQLite + Node.js)
http://huishao.net/2025/10/01/评论系统+SQLite/
作者
huishao
发布于
2025年10月1日
许可协议