轻量级 PHP 留言本源码|SQLite + 图形验证码 + 后台审核(解压即用)

轻量级 PHP 留言本源码|SQLite + 图形验证码 + 后台审核(解压即用)

前言

很多小站都需要一个留言 / 反馈区,但又不想上重型论坛。今天分享一套原创教学用途的轻量 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'; ?>

? 源码下载

下载 php-guestbook.zip(完整可用源码)

下载解压后按文中「部署步骤」即可运行。若正文代码与压缩包不一致,以压缩包为准。

部署步骤

  1. 把整个 guestbook/ 目录上传到网站(或本地 php -S)。
  2. 确保 data/ 可写;访问首页自动建库。
  3. 后台地址 index.php?r=admin,默认 admin / admin12345,请立刻改密。
  4. 可按需修改 config.php 的站点名、防刷间隔、是否开启验证码。

安全与合规说明

  • 密码使用 password_hash 存储,输出统一转义。
  • 请修改默认密码;生产环境建议开启 HTTPS,并限制 data/ 目录访问。
  • 留言内容由访客产生,请及时审核,避免违规信息传播。
  • 本源码仅供学习交流,使用后果自负。

小结

几十个文件就能落地一套可用的留言本。下一步可扩展:邮件通知、@回复、表情、黑名单。需要增强版可在评论区留言。

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容