
前言
很多小站都需要一个留言 / 反馈区,但又不想上重型论坛。今天分享一套原创教学用途的轻量 PHP 留言本:SQLite 存储、图形验证码、IP 防刷、后台审核,解压即可运行。
声明:本文源码为原创教学示例,请勿用于违法违规用途;请遵守当地法律法规与目标服务条款。
功能特性
- 前台留言:昵称 / 邮箱 / 内容 / 图形验证码
- 防刷:同 IP 最短间隔限制、内容长度限制
- 审核流:默认待审,后台通过 / 标垃圾 / 删除
- 管理后台:列表筛选、改密、CSRF 防护
- SQLite 免数据库,深色 UI,手机可用
环境要求
- PHP 8.0+,需
pdo_sqlite与gd(验证码) - 任意可跑 PHP 的主机 / 宝塔 / Nginx + php-fpm
目录结构
guestbook/
├── index.php # 入口
├── lib.php # 核心逻辑
├── config.php # 配置
├── templates/ # 页面模板
└── data/ # SQLite(自动创建)
完整源码
config.php
<?php
/** 轻量留言本 · 配置 */
return [
'site_name' => '轻留言本',
'db_path' => __DIR__ . '/data/guestbook.db',
'per_page' => 10,
'admin_user' => 'admin',
'admin_pass' => 'admin12345', // 首次初始化写入哈希,之后请到 admin 改密
'max_len' => 500,
'flood_seconds' => 30, // 同 IP 最短留言间隔
'require_code' => true, // 图形验证码
];
lib.php
<?php
/**
* 轻量留言本核心:SQLite + 验证码 + 防刷 + 后台审核
*/
function cfg(string $key, $default = null)
{
static $c = null;
if ($c === null) {
$c = require __DIR__ . '/config.php';
}
return $c[$key] ?? $default;
}
function e(?string $s): string
{
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$path = cfg('db_path');
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$isNew = !file_exists($path);
$pdo = new PDO('sqlite:' . $path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA journal_mode = WAL');
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nickname TEXT NOT NULL,
email TEXT DEFAULT '',
content TEXT NOT NULL,
ip TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
SQL);
if ($isNew) {
$hash = password_hash((string)cfg('admin_pass', 'admin12345'), PASSWORD_DEFAULT);
$pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?,?)')
->execute([(string)cfg('admin_user', 'admin'), $hash]);
}
return $pdo;
}
function client_ip(): string
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
return substr($ip, 0, 45);
}
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['csrf'];
}
function csrf_check(): void
{
if (!hash_equals(csrf_token(), (string)($_POST['csrf'] ?? ''))) {
http_response_code(419);
exit('CSRF 校验失败,请刷新页面');
}
}
function flash(string $key, ?string $msg = null)
{
if ($msg !== null) {
$_SESSION['flash'][$key] = $msg;
return null;
}
$v = $_SESSION['flash'][$key] ?? null;
unset($_SESSION['flash'][$key]);
return $v;
}
/* 图形验证码 */
function captcha_generate(): void
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
$_SESSION['captcha'] = $code;
$img = imagecreatetruecolor(120, 42);
$bg = imagecolorallocate($img, 18, 21, 28);
$fg = imagecolorallocate($img, 240, 68, 148);
$noise = imagecolorallocate($img, 70, 76, 88);
imagefilledrectangle($img, 0, 0, 120, 42, $bg);
for ($i = 0; $i < 6; $i++) {
imageline($img, random_int(0, 120), random_int(0, 42), random_int(0, 120), random_int(0, 42), $noise);
}
for ($i = 0; $i < 4; $i++) {
imagechar($img, 5, 12 + $i * 26, random_int(8, 16), $code[$i], $fg);
}
header('Content-Type: image/png');
imagepng($img);
imagedestroy($img);
exit;
}
function captcha_ok(string $input): bool
{
return isset($_SESSION['captcha'])
&& strtoupper(trim($input)) === strtoupper((string)$_SESSION['captcha']);
}
function flood_guard(): bool
{
$sec = (int)cfg('flood_seconds', 30);
if ($sec <= 0) {
return true;
}
$st = db()->prepare('SELECT created_at FROM messages WHERE ip = ? ORDER BY id DESC LIMIT 1');
$st->execute([client_ip()]);
$last = $st->fetchColumn();
if ($last && (time() - strtotime((string)$last)) < $sec) {
return false;
}
return true;
}
function add_message(string $nickname, string $email, string $content): array
{
$nickname = trim($nickname);
$email = trim($email);
$content = trim($content);
$max = (int)cfg('max_len', 500);
if ($nickname === '' || $content === '') {
return ['ok' => false, 'msg' => '昵称和内容不能为空'];
}
if (mb_strlen($nickname) > 24) {
return ['ok' => false, 'msg' => '昵称过长'];
}
if (mb_strlen($content) > $max) {
return ['ok' => false, 'msg' => '内容不能超过 ' . $max . ' 字'];
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'msg' => '邮箱格式不正确'];
}
if (!flood_guard()) {
return ['ok' => false, 'msg' => '留言太快,请稍后再试'];
}
db()->prepare('INSERT INTO messages (nickname, email, content, ip, status) VALUES (?,?,?,?,?)')
->execute([$nickname, $email, $content, client_ip(), 'pending']);
return ['ok' => true, 'msg' => '留言成功,审核通过后显示'];
}
function is_admin(): bool
{
return !empty($_SESSION['gb_admin']);
}
function admin_login(string $user, string $pass): bool
{
$st = db()->prepare('SELECT * FROM users WHERE username = ?');
$st->execute([trim($user)]);
$u = $st->fetch();
if ($u && password_verify($pass, $u['password_hash'])) {
session_regenerate_id(true);
$_SESSION['gb_admin'] = (int)$u['id'];
return true;
}
return false;
}
function view(string $tpl, array $data = []): void
{
extract($data, EXTR_SKIP);
require __DIR__ . '/templates/' . $tpl . '.php';
}
index.php
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/lib.php';
$r = $_GET['r'] ?? 'home';
$action = $_GET['action'] ?? '';
header('Content-Type: text/html; charset=utf-8');
if ($r === 'captcha') {
captcha_generate();
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
if ($r === 'home') {
if (cfg('require_code') && !captcha_ok((string)($_POST['captcha'] ?? ''))) {
flash('msg', '验证码错误');
} else {
$res = add_message($_POST['nickname'] ?? '', $_POST['email'] ?? '', $_POST['content'] ?? '');
flash('msg', $res['msg']);
}
header('Location: index.php');
exit;
}
if ($r === 'admin') {
if ($action === 'login') {
if (admin_login((string)($_POST['username'] ?? ''), (string)($_POST['password'] ?? ''))) {
header('Location: index.php?r=admin');
} else {
flash('msg', '账号或密码错误');
header('Location: index.php?r=admin&page=login');
}
exit;
}
if ($action === 'logout') {
unset($_SESSION['gb_admin']);
header('Location: index.php');
exit;
}
if ($action === 'approve' && is_admin()) {
db()->prepare("UPDATE messages SET status='approved' WHERE id=?")
->execute([(int)($_POST['id'] ?? 0)]);
flash('msg', '已通过');
header('Location: index.php?r=admin&page=messages');
exit;
}
if ($action === 'reject' && is_admin()) {
db()->prepare("UPDATE messages SET status='spam' WHERE id=?")
->execute([(int)($_POST['id'] ?? 0)]);
flash('msg', '已标记垃圾');
header('Location: index.php?r=admin&page=messages');
exit;
}
if ($action === 'delete' && is_admin()) {
db()->prepare('DELETE FROM messages WHERE id=?')
->execute([(int)($_POST['id'] ?? 0)]);
flash('msg', '已删除');
header('Location: index.php?r=admin&page=messages');
exit;
}
if ($action === 'password' && is_admin()) {
$pass = (string)($_POST['password'] ?? '');
if (strlen($pass) >= 8) {
db()->prepare('UPDATE users SET password_hash=? WHERE id=?')
->execute([password_hash($pass, PASSWORD_DEFAULT), (int)$_SESSION['gb_admin']]);
flash('msg', '密码已更新');
} else {
flash('msg', '密码至少 8 位');
}
header('Location: index.php?r=admin&page=settings');
exit;
}
}
}
if ($r === 'admin') {
$page = $_GET['page'] ?? 'messages';
if ($page === 'login' || !is_admin()) {
if (!is_admin() && $page !== 'login') {
header('Location: index.php?r=admin&page=login');
exit;
}
view('admin_login', ['msg' => flash('msg')]);
exit;
}
if ($page === 'settings') {
view('admin_settings', ['msg' => flash('msg')]);
exit;
}
$status = $_GET['status'] ?? 'all';
$params = [];
$sql = 'SELECT * FROM messages';
if (in_array($status, ['pending', 'approved', 'spam'], true)) {
$sql .= ' WHERE status = ?';
$params[] = $status;
}
$sql .= ' ORDER BY id DESC LIMIT 100';
$st = db()->prepare($sql);
$st->execute($params);
view('admin_messages', [
'messages' => $st->fetchAll(),
'status' => $status,
'msg' => flash('msg'),
]);
exit;
}
// 前台
$perPage = (int)cfg('per_page', 10);
$page = max(1, (int)($_GET['page'] ?? 1));
$total = (int)db()->query("SELECT COUNT(*) FROM messages WHERE status='approved'")->fetchColumn();
$st = db()->prepare("SELECT * FROM messages WHERE status='approved' ORDER BY id DESC LIMIT $perPage OFFSET " . (($page - 1) * $perPage));
$st->execute();
view('home', [
'site_name' => cfg('site_name'),
'messages' => $st->fetchAll(),
'total' => $total,
'page' => $page,
'perPage' => $perPage,
'msg' => flash('msg'),
]);
templates/_top.php
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($site_name ?? '轻留言本') ?></title>
<style>
:root { --bg:#0f1115; --panel:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#8b93a7; --accent:#f04494; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, "PingFang SC", sans-serif; background:var(--bg); color:var(--text); }
.wrap { max-width: 720px; margin: 0 auto; padding: 28px 16px 60px; }
h1 { margin: 0 0 6px; font-size: 26px; }
.sub { color: var(--muted); margin: 0 0 22px; }
.card { background: var(--panel); border:1px solid var(--border); border-radius:16px; padding:20px; margin-bottom:16px; }
label { display:block; font-size:13px; color:var(--muted); margin:10px 0 6px; }
input, textarea { width:100%; padding:11px 12px; border-radius:10px; border:1px solid var(--border); background:#12151c; color:var(--text); font: inherit; }
textarea { min-height:110px; resize:vertical; }
input:focus, textarea:focus { outline:0; border-color:var(--accent); }
.row { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.codebox { display:grid; grid-template-columns:1fr auto; gap:10px; align-items:center; }
.codebox img { height:42px; border-radius:8px; cursor:pointer; border:1px solid var(--border); }
button, .btn { display:inline-flex; align-items:center; justify-content:center; border:0; cursor:pointer;
background:var(--accent); color:#fff; font-weight:600; border-radius:999px; padding:11px 18px; font:inherit; text-decoration:none; }
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); }
.msg { padding:11px 13px; border-radius:10px; background:rgba(240,68,148,.12); color:#ff8fbc; margin-bottom:12px; font-size:14px; }
.ok { background:rgba(61,214,140,.12); color:#7dffa3; }
.item .head { display:flex; gap:8px; align-items:center; margin-bottom:6px; }
.avatar { width:34px; height:34px; border-radius:50%; background:linear-gradient(135deg,#f04494,#7c5cff); display:grid; place-items:center; color:#fff; font-weight:700; }
.name { font-weight:600; }
.time { color:var(--muted); font-size:12px; }
.body { color:#c7cdd8; line-height:1.7; white-space:pre-wrap; word-break:break-word; }
.pager { display:flex; gap:8px; justify-content:center; margin-top:18px; }
.pager a, .pager span { padding:8px 12px; border-radius:10px; border:1px solid var(--border); color:var(--text); text-decoration:none; background:var(--panel); }
.pager .cur { background:var(--accent); border-color:transparent; color:#fff; }
footer { text-align:center; color:var(--muted); font-size:13px; margin-top:28px; }
footer a { color:var(--accent); text-decoration:none; }
</style>
</head>
<body>
<div class="wrap">
templates/_bottom.php
<footer>
轻量 PHP 留言本 · <a href="index.php?r=admin">管理后台</a>
</footer>
</div>
</body>
</html>
templates/home.php
<?php require __DIR__ . '/_top.php'; ?>
<h1><?= e($site_name ?? '轻留言本') ?></h1>
<p class="sub">SQLite 存储 · 图形验证码 · 后台审核 · 共 <?= (int)$total ?> 条留言</p>
<?php if ($msg): ?>
<div class="msg ok"><?= e($msg) ?></div>
<?php endif; ?>
<div class="card">
<form method="post" action="index.php">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<div class="row">
<div>
<label>昵称 *</label>
<input name="nickname" required maxlength="24" placeholder="怎么称呼你">
</div>
<div>
<label>邮箱(选填)</label>
<input name="email" type="email" maxlength="80" placeholder="不会公开">
</div>
</div>
<label>留言内容 *</label>
<textarea name="content" required maxlength="<?= (int)cfg('max_len', 500) ?>" placeholder="说点什么…"></textarea>
<label>验证码</label>
<div class="codebox">
<input name="captcha" required maxlength="4" placeholder="点击图片更换" autocomplete="off">
<img src="index.php?r=captcha" alt="验证码" onclick="this.src='index.php?r=captcha&'+Date.now()" title="点击刷新">
</div>
<div style="margin-top:16px">
<button type="submit">提交留言</button>
</div>
</form>
</div>
<?php if (!$messages): ?>
<div class="card"><p class="sub" style="margin:0">还没有留言,来抢沙发~</p></div>
<?php else: ?>
<?php foreach ($messages as $m): ?>
<div class="card item">
<div class="head">
<div class="avatar"><?= e(mb_substr($m['nickname'], 0, 1)) ?></div>
<div>
<div class="name"><?= e($m['nickname']) ?></div>
<div class="time"><?= e($m['created_at']) ?></div>
</div>
</div>
<div class="body"><?= nl2br(e($m['content'])) ?></div>
</div>
<?php endforeach; ?>
<?php
$pages = max(1, (int)ceil($total / max(1, (int)$perPage)));
if ($pages > 1):
?>
<div class="pager">
<?php for ($i = 1; $i <= $pages; $i++): ?>
<?php if ($i === (int)$page): ?>
<span class="cur"><?= $i ?></span>
<?php else: ?>
<a href="index.php?page=<?= $i ?>"><?= $i ?></a>
<?php endif; ?>
<?php endfor; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php require __DIR__ . '/_bottom.php'; ?>
templates/admin_login.php
<?php require __DIR__ . '/_top.php'; ?>
<h1>管理登录</h1>
<?php if (!empty($msg)): ?><div class="msg"><?= e($msg) ?></div><?php endif; ?>
<div class="card" style="max-width:420px">
<form method="post" action="index.php?r=admin&action=login">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<label>用户名</label>
<input name="username" required autofocus>
<label>密码</label>
<input type="password" name="password" required>
<div style="margin-top:16px"><button type="submit">登录</button></div>
</form>
<p class="sub" style="margin:14px 0 0">默认 admin / admin12345,请尽快修改</p>
</div>
<?php require __DIR__ . '/_bottom.php'; ?>
templates/admin_messages.php
<?php require __DIR__ . '/_top.php'; ?>
<h1>留言管理</h1>
<p class="sub">
<a class="btn btn-ghost" href="index.php?r=admin&page=messages&status=all" style="margin-right:6px">全部</a>
<a class="btn btn-ghost" href="index.php?r=admin&page=messages&status=pending" style="margin-right:6px">待审</a>
<a class="btn btn-ghost" href="index.php?r=admin&page=messages&status=approved" style="margin-right:6px">已通过</a>
<a class="btn btn-ghost" href="index.php?r=admin&page=messages&status=spam" style="margin-right:6px">垃圾</a>
<a class="btn btn-ghost" href="index.php?r=admin&page=settings">设置</a>
<a class="btn btn-ghost" href="index.php">返回前台</a>
<a class="btn btn-ghost" href="index.php?r=admin&action=logout">退出</a>
</p>
<?php if (!empty($msg)): ?><div class="msg ok"><?= e($msg) ?></div><?php endif; ?>
<?php if (!$messages): ?>
<div class="card">暂无留言</div>
<?php else: ?>
<?php foreach ($messages as $m): ?>
<div class="card">
<div class="head" style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
<strong><?= e($m['nickname']) ?></strong>
<span class="time"><?= e($m['created_at']) ?> · <?= e($m['ip']) ?></span>
<span class="time">
<?= $m['status'] === 'approved' ? '已通过' : ($m['status'] === 'spam' ? '垃圾' : '待审') ?>
</span>
<span style="flex:1"></span>
<?php if ($m['status'] !== 'approved'): ?>
<form method="post" action="index.php?r=admin&action=approve" style="display:inline">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<button class="btn" type="submit">通过</button>
</form>
<?php endif; ?>
<?php if ($m['status'] !== 'spam'): ?>
<form method="post" action="index.php?r=admin&action=reject" style="display:inline">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<button class="btn btn-ghost" type="submit">垃圾</button>
</form>
<?php endif; ?>
<form method="post" action="index.php?r=admin&action=delete" style="display:inline"
onsubmit="return confirm('删除这条留言?')">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<button class="btn btn-ghost" type="submit">删除</button>
</form>
</div>
<div class="body"><?= nl2br(e($m['content'])) ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php require __DIR__ . '/_bottom.php'; ?>
templates/admin_settings.php
<?php require __DIR__ . '/_top.php'; ?>
<h1>后台设置</h1>
<p class="sub">
<a class="btn btn-ghost" href="index.php?r=admin&page=messages">留言管理</a>
<a class="btn btn-ghost" href="index.php">返回前台</a>
<a class="btn btn-ghost" href="index.php?r=admin&action=logout">退出</a>
</p>
<?php if (!empty($msg)): ?><div class="msg ok"><?= e($msg) ?></div><?php endif; ?>
<div class="card" style="max-width:480px">
<h3 style="margin-top:0">修改密码</h3>
<form method="post" action="index.php?r=admin&action=password">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<label>新密码(至少 8 位)</label>
<input type="password" name="password" required minlength="8">
<div style="margin-top:16px"><button type="submit">更新密码</button></div>
</form>
</div>
<?php require __DIR__ . '/_bottom.php'; ?>
部署步骤
- 把整个
guestbook/目录上传到网站(或本地php -S)。 - 确保
data/可写;访问首页自动建库。 - 后台地址
index.php?r=admin,默认admin / admin12345,请立刻改密。 - 可按需修改
config.php的站点名、防刷间隔、是否开启验证码。
安全与合规说明
- 密码使用
password_hash存储,输出统一转义。 - 请修改默认密码;生产环境建议开启 HTTPS,并限制
data/目录访问。 - 留言内容由访客产生,请及时审核,避免违规信息传播。
- 本源码仅供学习交流,使用后果自负。
小结
几十个文件就能落地一套可用的留言本。下一步可扩展:邮件通知、@回复、表情、黑名单。需要增强版可在评论区留言。
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END






暂无评论内容