PHP 实战:留言板

综合运用 PHP 表单处理、PDO 数据库、Session 等知识,构建一个完整的留言板应用。这个项目虽然功能简单,但涵盖了 Web 开发的核心要素:数据库设计、表单处理、安全防护、用户反馈等,是学习 PHP 全栈开发的绝佳练习项目。

项目结构

一个清晰的项目结构是良好代码组织的基础。留言板项目按功能拆分为多个文件,每个文件职责单一,便于维护和扩展。

实例
guestbook/
├── index.php      # 留言列表 + 发表表单
├── submit.php     # 处理提交
├── delete.php     # 删除留言(管理员)
├── db.php         # 数据库连接
└── style.css      # 样式
``

db.php 集中管理数据库连接,其他文件通过 require 引入,避免重复创建连接。这种结构虽然简单,但体现了关注点分离的思想:展示逻辑(index.php)、业务逻辑(submit.php)、数据访问(db.php)各司其职。

数据库设计

良好的数据库设计是项目的基础。留言板的核心表结构如下,记录了留言的昵称、内容、IP 地址和创建时间。

`sql CREATE TABLE messages ( id INT AUTO_INCREMENT PRIMARY KEY, nickname VARCHAR(50) NOT NULL, content TEXT NOT NULL, ip VARCHAR(45) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `

ip 字段使用 VARCHAR(45) 是为了同时支持 IPv4(最长 15 字符)和 IPv6(最长 39 字符)地址。created_at 使用 DEFAULT CURRENT_TIMESTAMP 让数据库自动记录时间,无需在 PHP 代码中手动设置。如果后续需要添加审核功能,可以增加 status 字段(0=待审核,1=已通过,2=已拒绝)。

数据库连接(db.php)

使用静态变量实现单例模式,确保整个请求生命周期内只创建一个数据库连接,避免重复连接的开销。

`php function getDB(): PDO { static $pdo = null; if ($pdo === null) { $pdo = new PDO( 'mysql:host=localhost;dbname=guestbook;charset=utf8mb4', 'root', 'password', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] ); } return $pdo; } `

PDO::ERRMODE_EXCEPTION 确保数据库操作失败时抛出异常,而不是静默失败。生产环境中,数据库密码应从环境变量读取:$_ENV['DB_PASS']getenv('DB_PASS'),不要硬编码在代码中。

留言列表与表单(index.php)

首页同时承担两个职责:展示留言列表和提供发表表单。通过 Session 的闪存消息机制,在表单提交后显示成功或错误提示。

`php require 'db.php';

$pdo = getDB(); $messages = $pdo->query("SELECT * FROM messages ORDER BY id DESC LIMIT 50") ->fetchAll();

$error = $_SESSION['error'] ?? ''; $success = $_SESSION['success'] ?? ''; unset($_SESSION['error'], $_SESSION['success']); `

查询时加上 LIMIT 50 限制返回数量,防止留言过多时页面加载缓慢。实际项目中应实现分页功能,每页显示固定数量的留言。读取完 Session 消息后立即 unset,确保消息只显示一次(闪存消息模式)。

`html <!DOCTYPE html> <html lang="zh-CN"> <head><meta charset="UTF-8"><title>留言板</title></head> <body> <h1>留言板</h1>

<?php if ($success): ?> <p style="color:green"><?= htmlspecialchars($success) ?></p> <?php endif; ?> <?php if ($error): ?> <p style="color:red"><?= htmlspecialchars($error) ?></p> <?php endif; ?>

<form action="submit.php" method="POST"> <input type="text" name="nickname" placeholder="昵称" maxlength="50" required> <textarea name="content" placeholder="留言内容..." maxlength="500" required></textarea> <button type="submit">发表留言</button> </form>

<hr> <?php foreach ($messages as $msg): ?> <div class="message"> <strong><?= htmlspecialchars($msg['nickname']) ?></strong> <time><?= $msg['created_at'] ?></time> <p><?= nl2br(htmlspecialchars($msg['content'])) ?></p> </div> <?php endforeach; ?> </body> </html> `

所有用户输入的内容在输出时都必须经过 htmlspecialchars() 转义,防止 XSS 攻击。nl2br() 将换行符转换为 <br> 标签,保留用户输入的换行格式。注意 nl2br() 应在 htmlspecialchars() 之后调用,否则 <br> 标签本身也会被转义。

处理提交(submit.php)

表单处理是安全的重点区域,需要验证请求方法、验证输入数据、防止刷屏,最后才执行数据库插入。

`php session_start(); require 'db.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: index.php'); exit; }

$nickname = trim($_POST['nickname'] ?? ''); $content = trim($_POST['content'] ?? ''); $ip = $_SERVER['REMOTE_ADDR'];

// 验证 if (empty($nickname) || mb_strlen($nickname) > 50) { $_SESSION['error'] = '昵称不合法'; header('Location: index.php'); exit; } if (empty($content) || mb_strlen($content) > 500) { $_SESSION['error'] = '内容不合法'; header('Location: index.php'); exit; }

// 简单防刷:同一 IP 60 秒内只能发一条 $stmt = getDB()->prepare( "SELECT COUNT(*) FROM messages WHERE ip = ? AND created_at > DATE_SUB(NOW(), INTERVAL 60 SECOND)" ); $stmt->execute([$ip]); if ($stmt->fetchColumn() > 0) { $_SESSION['error'] = '发言太频繁,请稍后再试'; header('Location: index.php'); exit; }

// 插入 $stmt = getDB()->prepare("INSERT INTO messages (nickname, content, ip) VALUES (?, ?, ?)"); $stmt->execute([$nickname, $content, $ip]);

$_SESSION['success'] = '留言成功!'; header('Location: index.php'); exit; `

使用 mb_strlen() 而不是 strlen() 来计算字符串长度,因为 strlen() 返回字节数,中文字符在 UTF-8 编码下占 3 个字节,会导致长度判断不准确。防刷逻辑基于 IP 地址,但要注意同一局域网内的用户可能共享同一个公网 IP,可以根据实际情况调整时间间隔或改用其他限流策略(如 Redis 计数器)。

删除留言(delete.php)

删除功能需要严格的权限验证,防止普通用户删除他人留言。

`php session_start(); require 'db.php';

// 简单管理员验证 if (($_SESSION['is_admin'] ?? false) !== true) { http_response_code(403); die('无权限'); }

$id = (int)($_GET['id'] ?? 0); if ($id > 0) { $stmt = getDB()->prepare("DELETE FROM messages WHERE id = ?"); $stmt->execute([$id]); }

header('Location: index.php'); exit; `

(int) 强制类型转换确保 $id 是整数,防止 SQL 注入(虽然已经使用了预处理语句,双重保护更安全)。生产环境中,管理员验证应该更加严格,例如检查用户角色、使用 CSRF Token 防止跨站请求伪造等。

安全加固

留言板涉及用户输入,安全是重中之重。以下是几个重要的安全措施:

`php <?php // 1. CSRF 防护:在表单中添加 Token session_start();

// 生成 CSRF Token if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }

// 验证 CSRF Token(在 submit.php 中) if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) { die('CSRF 验证失败'); } ?>

<!-- 在表单中添加隐藏字段 --> <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">

CSRF(跨站请求伪造)攻击是 Web 应用的常见威胁,攻击者诱导用户访问恶意页面,该页面向目标网站发送伪造请求。CSRF Token 通过在表单中嵌入随机令牌,并在服务器端验证,可以有效防御此类攻击。hash_equals() 使用恒定时间比较,防止时序攻击。

功能扩展思路

基础留言板完成后,可以从以下方向扩展功能,进一步提升项目的完整性:

  • 添加验证码(图形验证码或滑块验证码)防止机器人刷留言
  • 支持 Markdown 格式留言内容,使用 league/commonmark 库解析
  • 管理员后台:留言审核、批量删除、关键词过滤
  • 邮件通知:新留言时发送邮件给管理员(使用 PHPMailer)
  • 留言点赞功能:记录每条留言的点赞数
  • 分页功能:每页显示固定数量留言,支持翻页
  • 图片上传:允许用户上传图片附件(需要严格的安全验证)
  • 回复功能:支持对留言进行回复,形成树状结构

常见问题

Q1:为什么输出用户内容时必须使用 htmlspecialchars(),不用会有什么后果?

A:不转义用户输入直接输出到 HTML 会导致 XSS(跨站脚本攻击)漏洞。攻击者可以在留言中插入 ,其他用户访问页面时脚本会执行,可以窃取 Cookie(包括 Session ID)、重定向到钓鱼网站、修改页面内容等。htmlspecialchars()<>&"' 等特殊字符转换为 HTML 实体,使其无法被浏览器解析为 HTML 标签。

Q2:这个留言板项目如何防止 SQL 注入?

A:项目全程使用 PDO 预处理语句(prepare() + execute()),参数通过占位符(?)传递,与 SQL 语句分离,数据库引擎不会将参数值解析为 SQL 代码。例如即使用户输入 '; DROP TABLE messages; --,也只会被当作普通字符串存储,不会执行恶意 SQL。永远不要用字符串拼接构造 SQL,如 "SELECT * FROM messages WHERE id = " . $_GET['id'],这是 SQL 注入的根源。

Q3:如何将这个项目升级为支持用户注册登录的完整留言板?

A:需要添加:① users 表存储用户信息(用户名、邮箱、密码哈希);② 注册页面(register.php):表单验证、密码哈希(password_hash())、插入数据库;③ 登录页面(login.php):验证密码(password_verify())、设置 Session;④ 修改 messages 表,添加 user_id 外键关联用户;⑤ 登录后才能发表留言,未登录显示登录提示;⑥ 用户只能删除自己的留言,管理员可以删除所有留言。

Q4:如何为留言板添加分页功能?

A:分页需要两个查询:一是查询总记录数(SELECT COUNT() FROM messages),二是查询当前页数据(SELECT FROM messages ORDER BY id DESC LIMIT ? OFFSET ?)。从 $_GET['page'] 获取当前页码(默认为 1),计算 $offset = ($page - 1) * $pageSize,然后生成分页链接。注意对 page 参数做整数转换和范围验证(max(1, (int)$_GET['page'])`),防止非法输入。