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'])`),防止非法输入。