前七篇的知识点在这篇全部串联起来——从数据库设计到 OOP 架构,从表单安全到 AJAX 交互,最终产出一个接近生产环境的完整博客系统。用户注册登录、图形验证码、文章发布管理、评论点赞收藏、站内通知,全部从零实现。
一、为什么要做这个项目
从第一行 echo 到面向对象,零散的知识点只有落地到真实项目里才能真正消化。这篇会把前七篇的所有东西揉到一起,搭建一个功能接近生产环境的个人博客系统。你会看到 OOP 如何让代码模块化、多表关联如何支撑复杂业务、安全防护如何贯穿每个功能环节、AJAX 交互如何从零逐步落地。
功能总览
整个系统分五大模块,每个模块独立完整但彼此关联:
• 用户体系:注册/登录/退出、图形验证码、资料编辑、头像更换、密码修改、忘记密码重置、账号注销• 内容体系:文章发布/编辑/删除、封面图上传、文章列表分页、详情页展示• 交互体系:文章评论、评论点赞、一键点赞/取消、收藏/取消收藏、我的收藏列表• 通知体系:点赞收藏自动推送站内通知、未读数量提示、全部标记已读、一键清空• 安全体系:SQL注入防护、XSS转义、CSRF令牌、权限校验、文件上传安全
二、项目架构与数据库设计
2.1 目录结构
项目按 Core(核心类)→ Models(数据模型)→ 页面文件三层组织。上传文件单独放 uploads 目录,按头像和文章封面分子目录。
blog/├── Core/│ └── Database.php # 数据库单例类├── Models/│ ├── User.php # 用户模型│ ├── Post.php # 文章模型│ ├── Comment.php # 评论模型│ ├── CommentLike.php # 评论点赞模型│ ├── Like.php # 点赞模型│ ├── Favorite.php # 收藏模型│ └── Notification.php # 通知模型├── uploads/│ ├── avatar/ # 用户头像存储│ └── post/ # 文章封面存储├── captcha.php # 图形验证码生成├── register.php # 用户注册├── login.php # 用户登录├── welcome.php # 个人中心首页├── profile.php # 修改个人资料├── change_password.php # 修改登录密码├── forgot_password.php # 忘记密码入口├── reset_password.php # 重置密码页面├── home.php # 文章列表(分页)├── create_post.php # 发布文章├── view_post.php # 文章详情├── edit_post.php # 编辑文章├── delete_post.php # 删除文章├── add_comment.php # 提交评论├── delete_comment.php # 删除评论├── toggle_like.php # 点赞切换├── toggle_favorite.php # 收藏切换├── toggle_comment_like.php # 评论点赞切换├── my_favorites.php # 我的收藏列表├── notifications.php # 我的通知列表├── delete_user.php # 注销账号└── logout.php # 退出登录
2.2 完整数据库设计
下面这段 SQL 一次性建完所有表,统一用 utf8mb4 编码(支持 emoji),外键级联删除保证数据一致性——删文章时关联的评论、点赞、收藏自动清掉。
CREATE DATABASE IF NOT EXISTS blog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;USE blog;-- 用户表CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, avatar VARCHAR(255) NULL COMMENT '头像路径', create_time DATETIME DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 文章表CREATE TABLE posts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, cover VARCHAR(255) NULL COMMENT '文章封面图路径', content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 评论表CREATE TABLE comments ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, post_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 点赞表(用户+文章唯一)CREATE TABLE post_likes ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, post_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_post_user (post_id, user_id), FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 评论点赞表(用户+评论唯一,防止重复点赞)CREATE TABLE comment_likes ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, comment_id INT UNSIGNED NOT NULL COMMENT '关联评论ID', user_id INT UNSIGNED NOT NULL COMMENT '点赞用户ID', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_comment_user (comment_id, user_id), FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 收藏表(用户+文章唯一)CREATE TABLE post_favorites ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, post_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_post_user (post_id, user_id), FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 密码重置表CREATE TABLE password_resets ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(100) NOT NULL, token VARCHAR(100) NOT NULL, expire_time INT UNSIGNED NOT NULL COMMENT '过期时间戳', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_email (email), KEY idx_token (token)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 站内通知表CREATE TABLE notifications ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL COMMENT '接收通知的用户ID', type VARCHAR(20) NOT NULL COMMENT '通知类型:like/comment', related_post_id INT UNSIGNED NULL COMMENT '关联文章ID', trigger_user_id INT UNSIGNED NOT NULL COMMENT '触发通知的用户ID', content VARCHAR(255) NOT NULL COMMENT '通知内容', is_read TINYINT UNSIGNED DEFAULT 0 COMMENT '0未读 1已读', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (trigger_user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (related_post_id) REFERENCES posts(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
实战提醒:点赞表和收藏表都用了 UNIQUE KEY 联合唯一索引,这样数据库层面就杜绝了重复点赞/收藏,比在 PHP 里先查再插靠谱得多。
三、核心基础层
3.1 数据库单例类 Core/Database.php
全局唯一数据库连接,避免重复创建连接浪费资源。所有模型通过 Database::getInstance()->getPdo() 获取同一个 PDO 实例。
<?phpnamespace Blog\Core;class Database{ private static ?self $instance = null; private \PDO $pdo; private function __construct() { $host = '127.0.0.1'; $dbname = 'blog'; $dbUser = 'root'; $dbPass = ''; $charset = 'utf8mb4'; $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset"; $options = [ \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, \PDO::ATTR_EMULATE_PREPARES => false, ]; $this->pdo = new \PDO($dsn, $dbUser, $dbPass, $options); } public static function getInstance(): self { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } public function getPdo(): \PDO { return $this->pdo; } private function __clone() {}}
3.2 自动加载规范
每个页面头部统一用这套 PSR-4 自动加载器,不需要 Composer 就能跑。Blog\Models\User 会自动映射到 Models/User.php 文件。
<?phpspl_autoload_register(function ($className) { $prefix = 'Blog\\'; $baseDir = __DIR__ . DIRECTORY_SEPARATOR; if (str_starts_with($className, $prefix)) { $relativeClass = substr($className, strlen($prefix)); $file = $baseDir . str_replace('\\', DIRECTORY_SEPARATOR, $relativeClass) . '.php'; if (file_exists($file)) require $file; }});session_start();
四、核心模型层
7 个模型类分别对应 7 张数据表,每个类封装自己的 CRUD 逻辑。由于篇幅原因,下面展示最核心的 User 模型(其他模型结构类似,完整代码在资料包里)。
4.1 用户模型 Models/User.php
User 模型是最复杂的,涵盖注册、查询、密码校验、资料更新、密码修改、密码重置全流程。注意 findByUsername 查询时不带 password 字段,需要校验密码时单独查——避免密码哈希不小心泄露到 Session 或日志里。
<?phpnamespace Blog\Models;use Blog\Core\Database;class User{ public ?int $id = null; public string $username = ''; public string $email = ''; public ?string $avatar = null; // 根据用户名查询 public static function findByUsername(string $username): ?self { $pdo = Database::getInstance()->getPdo(); $stmt = $pdo->prepare("SELECT id, username, email, avatar FROM users WHERE username = :u"); $stmt->execute([':u' => $username]); $data = $stmt->fetch(); if (!$data) return null; $user = new self(); $user->id = $data['id']; $user->username = $data['username']; $user->email = $data['email']; $user->avatar = $data['avatar']; return $user; } // 根据ID查询(含密码) public static function findById(int $id): ?self { $pdo = Database::getInstance()->getPdo(); $stmt = $pdo->prepare("SELECT id, username, email, avatar, password FROM users WHERE id = :id"); $stmt->execute([':id' => $id]); $data = $stmt->fetch(); if (!$data) return null; $user = new self(); $user->id = $data['id']; $user->username = $data['username']; $user->email = $data['email']; $user->avatar = $data['avatar']; return $user; } // 注册新用户 public static function create(string $username, string $password, string $email, ?string $avatarPath = null): int { $pdo = Database::getInstance()->getPdo(); $hashPwd = password_hash($password, PASSWORD_DEFAULT); $stmt = $pdo->prepare( "INSERT INTO users (username, password, email, avatar) VALUES (:u, :p, :e, :avatar)" ); $stmt->execute([ ':u' => $username, ':p' => $hashPwd, ':e' => $email, ':avatar' => $avatarPath ]); return (int)$pdo->lastInsertId(); } // 校验密码 public function verifyPassword(string $inputPwd): bool { if (!$this->id) return false; $pdo = Database::getInstance()->getPdo(); $stmt = $pdo->prepare("SELECT password FROM users WHERE id = :id"); $stmt->execute([':id' => $this->id]); $row = $stmt->fetch(); return $row && password_verify($inputPwd, $row['password']); } // 校验用户名/邮箱是否存在 public static function isExists(string $username, string $email): bool { $pdo = Database::getInstance()->getPdo(); $stmt = $pdo->prepare("SELECT id FROM users WHERE username = :u OR email = :e"); $stmt->execute([':u' => $username, ':e' => $email]); return (bool)$stmt->fetch(); } // 修改密码(需原密码) public function updatePassword(string $oldPwd, string $newPwd): bool { if (!$this->id) return false; if (!$this->verifyPassword($oldPwd)) return false; $pdo = Database::getInstance()->getPdo(); $hash = password_hash($newPwd, PASSWORD_DEFAULT); $stmt = $pdo->prepare("UPDATE users SET password = :p WHERE id = :id"); $stmt->execute([':p' => $hash, ':id' => $this->id]); return $stmt->rowCount() > 0; } // 重置密码(无需原密码,忘记密码用) public static function resetPasswordByEmail(string $email, string $newPwd): bool { $pdo = Database::getInstance()->getPdo(); $hash = password_hash($newPwd, PASSWORD_DEFAULT); $stmt = $pdo->prepare("UPDATE users SET password = :p WHERE email = :e"); $stmt->execute([':p' => $hash, ':e' => $email]); return $stmt->rowCount() > 0; } // 创建密码重置令牌(30分钟有效) public static function createResetToken(string $email): string { $pdo = Database::getInstance()->getPdo(); $token = bin2hex(random_bytes(32)); $expire = time() + 1800; $stmt = $pdo->prepare("DELETE FROM password_resets WHERE email = :e"); $stmt->execute([':e' => $email]); $stmt = $pdo->prepare("INSERT INTO password_resets (email, token, expire_time) VALUES (:e, :t, :exp)"); $stmt->execute([':e' => $email, ':t' => $token, ':exp' => $expire]); return $token; } // 校验重置令牌 public static function validateResetToken(string $token): ?string { $pdo = Database::getInstance()->getPdo(); $stmt = $pdo->prepare("SELECT email, expire_time FROM password_resets WHERE token = :t LIMIT 1"); $stmt->execute([':t' => $token]); $row = $stmt->fetch(); if (!$row || $row['expire_time'] < time()) return null; return $row['email']; } // 删除账号 public function delete(): void { if (!$this->id) return; $pdo = Database::getInstance()->getPdo(); $stmt = $pdo->prepare("DELETE FROM users WHERE id = :uid"); $stmt->execute([':uid' => $this->id]); }}
实战提醒:密码重置令牌用 bin2hex(random_bytes(32)) 生成 64 位随机字符串,比 md5(uniqid()) 安全得多——后者可预测,前者密码学安全。创建新令牌前先删旧令牌,保证同一邮箱只有一个有效令牌。
4.2 其他模型概览
• Post.php:分页查询(LIMIT + OFFSET + bindValue 绑定 INT)、总数统计、save() 方法自动判断新增/更新• Comment.php:按文章 ID 查评论列表(JOIN users 带出作者名)、新增评论、按 ID 查单条评论• Like.php / Favorite.php:toggle() 切换设计——已赞则删除、未赞则插入,利用 UNIQUE KEY 防重复• CommentLike.php:和 Like.php 结构一致,操作 comment_likes 表• Notification.php:send() 发通知(自己操作自己不发)、getUnreadCount() 未读数、markAllRead() 全部已读、clearAll() 清空
完整代码在配套资料包里,每个模型都是独立文件,可以直接放到项目里运行。
五、用户体系完整实现
5.1 图形验证码 captcha.php
用 PHP GD 库生成 4 位验证码图片,去除了容易混淆的 0/O、1/I 字符。验证码存 Session,登录时比对,比对完立即销毁。

<?phpob_clean();session_start();header("Content-Type: image/png");$width = 120;$height = 40;$image = imagecreatetruecolor($width, $height);$bgColor = imagecolorallocate($image, 245, 247, 250);$textColor = imagecolorallocate($image, 37, 99, 235);$lineColor = imagecolorallocate($image, 200, 200, 200);imagefill($image, 0, 0, $bgColor);// 去除易混淆字符 0/O、1/I$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';$code = '';for ($i = 0; $i < 4; $i++) { $code .= $chars[mt_rand(0, strlen($chars) - 1)];}$_SESSION['captcha_code'] = strtolower($code);imagestring($image, 5, 32, 12, $code, $textColor);// 3条干扰线for ($i = 0; $i < 3; $i++) { imageline($image, 0, mt_rand(0, $height), $width, mt_rand(0, $height), $lineColor);}imagepng($image);imagedestroy($image);exit;
踩坑提醒:ob_clean() 必须放在最前面,清空之前所有输出。否则 header("Content-Type: image/png") 会报 "headers already sent" 错误,图片显示成乱码。文件末尾的 exit 也不能省,防止后面有空白字符输出。
5.2 用户注册 register.php
注册页支持头像上传、用户名/邮箱/密码校验、CSRF 防护。注册成功后自动写入 Session 跳转个人中心,不需要再手动登录一次。
<?phpspl_autoload_register(function ($className) { $prefix = 'Blog\\'; $baseDir = __DIR__ . DIRECTORY_SEPARATOR; if (str_starts_with($className, $prefix)) { $relativeClass = substr($className, strlen($prefix)); $file = $baseDir . str_replace('\\', DIRECTORY_SEPARATOR, $relativeClass) . '.php'; if (file_exists($file)) require $file; }});session_start();header("X-XSS-Protection: 1; mode=block");header("X-Frame-Options: DENY");use Blog\Models\User;if (empty($_SESSION['csrf_reg'])) { $_SESSION['csrf_reg'] = bin2hex(random_bytes(32));}$errors = [];$fill = ['username' => '', 'email' => ''];$uploadDir = __DIR__ . '/uploads/avatar/';if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);$avatarPath = null;if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!hash_equals($_SESSION['csrf_reg'], $_POST['csrf_token'] ?? '')) { $errors['global'] = "非法提交请求"; } $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $password2 = $_POST['password2'] ?? ''; $email = trim($_POST['email'] ?? ''); $fill['username'] = $username; $fill['email'] = $email; if (empty($username)) $errors['username'] = "用户名不能为空"; elseif (mb_strlen($username) < 3) $errors['username'] = "用户名至少3字符"; if (empty($password)) $errors['password'] = "密码不能为空"; elseif (strlen($password) < 6) $errors['password'] = "密码最少6位"; if ($password !== $password2) $errors['password2'] = "两次密码不一致"; if (empty($email)) $errors['email'] = "邮箱不能为空"; elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors['email'] = "邮箱格式错误"; // 头像上传 if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] !== UPLOAD_ERR_NO_FILE) { $file = $_FILES['avatar']; if ($file['error'] !== UPLOAD_ERR_OK) { $errors['avatar'] = "文件上传失败"; } else { $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']); if (!in_array($mime, ['image/jpeg','image/png'])) { $errors['avatar'] = "仅支持jpg/png"; } elseif ($file['size'] > 1048576) { $errors['avatar'] = "头像不超过1MB"; } else { $ext = $mime === 'image/png' ? 'png' : 'jpg'; $name = md5(uniqid(true).$username).".".$ext; move_uploaded_file($file['tmp_name'], $uploadDir.$name); $avatarPath = "uploads/avatar/".$name; } } } if (empty($errors)) { if (User::isExists($username, $email)) { $errors['global'] = "用户名或邮箱已被注册"; } else { $uid = User::create($username, $password, $email, $avatarPath); $_SESSION['logged_in'] = true; $_SESSION['user_id'] = $uid; $_SESSION['username'] = $username; $_SESSION['avatar'] = $avatarPath; header("Location: welcome.php", true, 302); exit; } }}?><!-- 下方为HTML表单,完整代码见资料包 -->
注册页的 HTML 部分包含用户名、密码、确认密码、邮箱、头像上传五个字段,每个字段下方都有错误提示位。完整 HTML 代码在资料包的 register.php 里。
5.3 用户登录 login.php
登录页整合了图形验证码,点击验证码图片可以刷新。验证码校验通过后立即 unset 销毁,防止重复使用。登录错误统一提示"用户名或密码错误",不告诉你是用户名错了还是密码错了——防止用户名枚举攻击。
<?php// 自动加载 + session_start(同上,省略)use Blog\Models\User;$error = '';$fillUser = '';if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $captcha = strtolower(trim($_POST['captcha'] ?? '')); $fillUser = $username; if (empty($username) || empty($password)) { $error = "用户名和密码不能为空"; } elseif (empty($captcha) || $captcha !== ($_SESSION['captcha_code'] ?? '')) { $error = "验证码错误"; } else { unset($_SESSION['captcha_code']); // 用完即销毁 $user = User::findByUsername($username); if (!$user || !$user->verifyPassword($password)) { $error = "用户名或密码错误"; } else { $_SESSION['logged_in'] = true; $_SESSION['user_id'] = $user->id; $_SESSION['username'] = $user->username; $_SESSION['avatar'] = $user->avatar; header("Location: welcome.php", true, 302); exit; } }}
5.4 个人中心 welcome.php
个人中心整合了头像展示、功能入口导航、未读通知红点提示。所有功能入口用网格布局排列,通知按钮右上角有红色数字角标显示未读数。
<?php// 自动加载 + session_start + 登录检查(省略)use Blog\Models\User;use Blog\Models\Notification;$userName = htmlspecialchars($_SESSION['username'], ENT_QUOTES);$avatar = $_SESSION['avatar'] ?? '';$unreadCount = Notification::getUnreadCount($_SESSION['user_id']);?><div class="box"> <h2>欢迎你,<?= $userName ?>!</h2> <div class="avatar-wrap"> <?php if(!empty($avatar) && file_exists($avatar)): ?> <img class="avatar-img" src="<?= htmlspecialchars($avatar, ENT_QUOTES) ?>" alt="头像"> <?php else: ?> <div class="avatar-empty">暂无头像</div> <?php endif; ?> </div> <div class="btn-wrap"> <a href="home.php" class="btn btn-blue">浏览文章</a> <a href="create_post.php" class="btn btn-blue">发布文章</a> <a href="my_favorites.php" class="btn btn-blue">我的收藏</a> <a href="profile.php" class="btn btn-blue">修改资料</a> <a href="change_password.php" class="btn btn-blue">修改密码</a> <a href="notifications.php" class="btn btn-blue"> 我的通知 <?php if($unreadCount > 0): ?> <span class="badge"><?= $unreadCount ?></span> <?php endif; ?> </a> <a href="logout.php" class="btn btn-gray">退出登录</a> <a href="delete_user.php" class="btn btn-red">永久注销账号</a> </div></div>
5.5 修改资料 / 修改密码 / 忘记密码 / 重置密码
这四个页面逻辑各不相同但互相关联:
• profile.php:修改邮箱和头像,用户名不可改(disabled 属性)。上传新头像会覆盖旧头像路径• change_password.php:需输入原密码验证身份,修改成功后立即销毁 Session 跳转登录页(强制重新登录)• forgot_password.php:输入注册邮箱,生成重置令牌存入 password_resets 表(30分钟有效)。生产环境应发邮件,这里直接返回链接方便测试• reset_password.php:从 URL 获取 token,验证有效后允许设置新密码。成功后删除令牌防止重复使用
实战提醒:change_password.php 修改成功后用 header("refresh:2;url=login.php") 延迟跳转,给用户 2 秒看到"修改成功"的提示。注意此时 Session 已经销毁,下方的表单 HTML 不能再输出——用 exit 终止脚本。
这四个文件的完整代码在资料包里,每个都是独立可运行的 PHP 文件。
六、文章体系完整实现
6.1 文章列表(分页)home.php
首页用 LIMIT + OFFSET 分页,每页 5 篇。封面有就显示缩略图,没有就显示灰色占位块。底部有上一页/下一页导航,当前页码显示"第 X / Y 页"。
<?php// 自动加载 + session_start(省略)use Blog\Models\Post;$page = max(1, (int)($_GET['page'] ?? 1));$perPage = 5;$postList = Post::getPaginated($page, $perPage);$totalPosts = Post::getTotalCount();$totalPages = max(1, ceil($totalPosts / $perPage));?><!-- 文章列表 + 分页导航 --><?php foreach($postList as $p): ?><div class="item"> <?php if(!empty($p->cover) && file_exists($p->cover)): ?> <img class="item-cover" src="<?= htmlspecialchars($p->cover) ?>" alt="封面"> <?php else: ?> <div class="item-cover"></div> <?php endif; ?> <div class="item-body"> <div class="title"> <a href="view_post.php?id=<?= $p->id ?>"><?= htmlspecialchars($p->title) ?></a> </div> <div class="meta">作者:<?= htmlspecialchars($p->authorName) ?> · 发布于 <?= $p->createdAt ?></div> </div></div><?php endforeach; ?><!-- 分页 --><div class="pagination"> <?php if($page > 1): ?> <a href="?page=<?= $page-1 ?>">上一页</a> <?php else: ?> <span class="disabled">上一页</span> <?php endif; ?> <span class="current">第 <?= $page ?> / <?= $totalPages ?> 页</span> <?php if($page < $totalPages): ?> <a href="?page=<?= $page+1 ?>">下一页</a> <?php else: ?> <span class="disabled">下一页</span> <?php endif; ?></div>
6.2 发布文章 create_post.php
支持标题、封面上传(2MB 限制)、正文内容,带完整校验。封面上传和头像上传逻辑一样:finfo 检测真实 MIME、随机文件名、存入 uploads/post/ 目录。
<?php// 自动加载 + session_start + 登录检查(省略)use Blog\Models\Post;if (empty($_SESSION['csrf_post'])) { $_SESSION['csrf_post'] = bin2hex(random_bytes(32));}$errors = [];$uploadDir = __DIR__ . '/uploads/post/';if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!hash_equals($_SESSION['csrf_post'], $_POST['csrf_token'] ?? '')) { $errors[] = "非法请求"; } $title = trim($_POST['title'] ?? ''); $content = trim($_POST['content'] ?? ''); if (empty($title)) $errors[] = "标题不能为空"; elseif (mb_strlen($title) > 200) $errors[] = "标题不能超过200字"; if (empty($content)) $errors[] = "正文不能为空"; // 封面上传 $coverPath = null; if (isset($_FILES['cover']) && $_FILES['cover']['error'] === UPLOAD_ERR_OK) { $file = $_FILES['cover']; $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']); if (in_array($mime, ['image/jpeg','image/png']) && $file['size'] <= 2097152) { $ext = $mime === 'image/png' ? 'png' : 'jpg'; $name = md5(uniqid(true)).".".$ext; move_uploaded_file($file['tmp_name'], $uploadDir.$name); $coverPath = "uploads/post/".$name; } else { $errors[] = "封面仅支持jpg/png,不超过2MB"; } } if (empty($errors)) { $post = new Post(); $post->userId = $_SESSION['user_id']; $post->title = $title; $post->cover = $coverPath; $post->content = $content; $post->save(); header("Location: view_post.php?id=".$post->id, true, 302); exit; }}
6.3 文章详情页 view_post.php
详情页是整个项目最复杂的页面——整合了封面展示、正文渲染、点赞收藏按钮、评论区(带评论点赞/删除)、作者操作按钮(编辑/删除)。所有交互用 fetch API 实现 AJAX 无刷新操作。
<?php// 自动加载 + session_start(省略)use Blog\Models\Post;use Blog\Models\Comment;use Blog\Models\Like;use Blog\Models\Favorite;use Blog\Models\CommentLike;$id = (int)($_GET['id'] ?? 0);$post = Post::find($id);if(!$post){ header("Location: home.php", true, 302); exit;}$isAuthor = !empty($_SESSION['logged_in']) && $_SESSION['user_id'] == $post->userId;$comments = Comment::getByPostId($id);$likeCount = Like::getCount($id);$favoriteCount = Favorite::getCount($id);$isLiked = false;$isFavorited = false;if (!empty($_SESSION['logged_in'])) { $isLiked = Like::checkUserLike($id, $_SESSION['user_id']); $isFavorited = Favorite::checkUserFavorite($id, $_SESSION['user_id']);}$csrfToken = $_SESSION['csrf_interact'] ?? bin2hex(random_bytes(32));$_SESSION['csrf_interact'] = $csrfToken;?><!-- 文章卡片 --><article class="post-card"> <?php if(!empty($post->cover) && file_exists($post->cover)): ?> <img class="post-cover" src="<?= htmlspecialchars($post->cover) ?>" alt="封面"> <?php endif; ?> <h1 class="post-title"><?= htmlspecialchars($post->title) ?></h1> <div class="post-meta"> <span>作者:<?= htmlspecialchars($post->authorName) ?></span> <span>发布于 <?= $post->createdAt ?></span> </div> <div class="post-content"><?= nl2br(htmlspecialchars($post->content)) ?></div> <!-- 点赞收藏按钮 --> <div class="interact-bar"> <button class="interact-btn js-post-like <?= $isLiked ? 'active' : '' ?>" data-id="<?= $post->id ?>"> 👍 点赞 <span class="like-count"><?= $likeCount ?></span> </button> <button class="interact-btn js-post-favorite <?= $isFavorited ? 'active' : '' ?>" data-id="<?= $post->id ?>"> ⭐ 收藏 <span class="favorite-count"><?= $favoriteCount ?></span> </button> </div> <?php if($isAuthor): ?> <a href="edit_post.php?id=<?= $post->id ?>" class="btn btn-edit">编辑</a> <a href="delete_post.php?id=<?= $post->id ?>" class="btn btn-del">删除</a> <?php endif; ?></article><!-- 评论区 --><div class="comment-card"> <h2 class="comment-title">评论区(<span class="comment-total"><?= count($comments) ?></span>)</h2> <div class="comment-list js-comment-list"> <?php foreach($comments as $c): ?> <div class="comment-item" data-id="<?= $c->id ?>"> <div class="comment-header"> <span class="comment-name"><?= htmlspecialchars($c->authorName) ?></span> <span class="comment-time"><?= $c->createdAt ?></span> </div> <div class="comment-content"><?= nl2br(htmlspecialchars($c->content)) ?></div> <div class="comment-footer"> <button class="comment-like-btn js-comment-like" data-id="<?= $c->id ?>"> 👍 <span class="comment-like-count"><?= CommentLike::getCount($c->id) ?></span> </button> <?php if($isAuthor || $_SESSION['user_id'] ?? 0 == $c->userId): ?> <button class="comment-del-btn js-delete-comment" data-id="<?= $c->id ?>">删除</button> <?php endif; ?> </div> </div> <?php endforeach; ?> </div> <?php if(!empty($_SESSION['logged_in'])): ?> <form class="comment-form"> <textarea placeholder="写下你的评论..." required></textarea> <button type="submit">发表评论</button> </form> <?php else: ?> <p class="login-tip"><a href="login.php">登录</a> 后即可发表评论</p> <?php endif; ?></div><script>const csrfToken = '<?= htmlspecialchars($csrfToken) ?>';const isLoggedIn = <?= !empty($_SESSION['logged_in']) ? 'true' : 'false' ?>;// AJAX 通用请求function ajaxPost(url, data) { return fetch(url, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest'}, body: new URLSearchParams(data).toString() }).then(res => res.json());}// 文章点赞document.querySelector('.js-post-like').addEventListener('click', function() { if (!isLoggedIn) { alert('请先登录'); return; } ajaxPost('toggle_like.php', {post_id: this.dataset.id, csrf_token: csrfToken}) .then(res => { if (res.code === 1) { this.classList.toggle('active', res.is_liked); this.querySelector('.like-count').textContent = res.count; } });});// 文章收藏、评论点赞、删除评论、发表评论逻辑类似// 完整 JS 代码见资料包</script>
实战提醒:评论的点赞和删除按钮用了事件委托(addEventListener 绑在父容器上),因为评论是 AJAX 动态追加的,直接绑事件会丢失。用 e.target.closest('.js-comment-like') 判断点击的是不是目标按钮。
6.4 编辑文章 / 删除文章
编辑文章 edit_post.php 和发布文章逻辑基本一样,区别是预填已有数据,save() 时走 UPDATE 分支。删除文章 delete_post.php 有确认页面,防止误点。两个页面都校验 $post->userId != $_SESSION['user_id'] 防越权操作。
七、交互与通知体系
7.1 评论提交 add_comment.php
评论提交支持 AJAX 和普通表单两种模式。AJAX 模式返回 JSON 数据(前端无刷新追加评论),普通模式跳转回文章页。通过 HTTP_X_REQUESTED_WITH 请求头判断是哪种模式。
<?php// 自动加载 + session_start(省略)use Blog\Models\Comment;if (empty($_SESSION['logged_in'])) { echo json_encode(['code' => 0, 'msg' => '请先登录']); exit;}$isAjax = isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest';if ($_SERVER['REQUEST_METHOD'] === 'POST') { $postId = (int)($_POST['post_id'] ?? 0); $content = trim($_POST['content'] ?? ''); $token = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_interact'] ?? '', $token)) { $msg = '非法请求'; if ($isAjax) { echo json_encode(['code' => 0, 'msg' => $msg]); exit; } else die($msg); } if ($postId <= 0 || empty($content)) { $msg = '评论内容不能为空'; if ($isAjax) { echo json_encode(['code' => 0, 'msg' => $msg]); exit; } else { header("Location: view_post.php?id=$postId"); exit; } } $commentId = Comment::create($postId, $_SESSION['user_id'], $content); if ($isAjax) { echo json_encode([ 'code' => 1, 'msg' => '评论成功', 'data' => [ 'id' => $commentId, 'author_name' => $_SESSION['username'], 'content' => $content, 'created_at' => date('Y-m-d H:i:s'), 'like_count' => 0, 'can_delete' => true ] ]); exit; } else { header("Location: view_post.php?id=$postId", true, 302); exit; }}
7.2 点赞切换 toggle_like.php
点赞成功后自动给文章作者发站内通知。关键细节:if ($userId === $triggerUserId) return;——自己赞自己的文章不发通知,避免骚扰。文章标题超 20 字会截断加省略号。
<?php// 自动加载 + session_start(省略)use Blog\Models\Like;use Blog\Models\Post;use Blog\Models\Notification;if (empty($_SESSION['logged_in'])) { echo json_encode(['code' => 0, 'msg' => '请先登录']); exit;}if ($_SERVER['REQUEST_METHOD'] === 'POST') { $postId = (int)($_POST['post_id'] ?? 0); $token = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_interact'] ?? '', $token)) { echo json_encode(['code' => 0, 'msg' => '非法请求']); exit; } $isLikedAfter = Like::toggle($postId, $_SESSION['user_id']); $likeCount = Like::getCount($postId); // 点赞成功时发通知给文章作者 if ($isLikedAfter) { $post = Post::find($postId); if ($post) { $shortTitle = mb_substr($post->title, 0, 20); if (mb_strlen($post->title) > 20) $shortTitle .= '…'; $content = "赞了你的文章《".$shortTitle."》"; Notification::send($post->userId, 'like', $_SESSION['user_id'], $postId, $content); } } echo json_encode([ 'code' => 1, 'is_liked' => $isLikedAfter, 'count' => $likeCount ]); exit;}
收藏切换 toggle_favorite.php、评论点赞 toggle_comment_like.php、删除评论 delete_comment.php 的结构和 toggle_like.php 基本一致,区别是操作的表和通知内容不同。完整代码在资料包里。
7.3 我的通知 notifications.php
通知列表页支持全部标记已读和一键清空。未读通知左侧有蓝色竖条标记,已读的没有。通知内容格式:"XXX 赞了你的文章《标题》"、"XXX 收藏了你的文章《标题》"、"XXX 赞了你的评论:「评论摘要」"。
<?php// 自动加载 + session_start + 登录检查(省略)use Blog\Models\Notification;// 全部标记已读if (isset($_GET['action']) && $_GET['action'] === 'readall') { Notification::markAllRead($_SESSION['user_id']); header("Location: notifications.php", true, 302); exit;}// 一键清空if (isset($_GET['action']) && $_GET['action'] === 'clearall') { Notification::clearAll($_SESSION['user_id']); header("Location: notifications.php", true, 302); exit;}$list = Notification::getUserAll($_SESSION['user_id']);?><?php foreach($list as $item): ?><div class="item <?= $item->isRead ? '' : 'unread' ?>"> <div class="item-content"> <strong><?= htmlspecialchars($item->triggerUserName) ?></strong> <?= htmlspecialchars($item->content) ?> </div> <div class="item-time"><?= $item->createdAt ?></div></div><?php endforeach; ?>
八、辅助功能
退出登录 logout.php 三行代码搞定:清空 Session、销毁会话、跳转首页。注销账号 delete_user.php 有确认页面,确认后调用 $currentUser->delete() 删除用户——外键级联删除会自动清掉该用户的所有文章、评论、点赞、收藏数据。
// logout.php<?phpsession_start();$_SESSION = [];session_destroy();header("Location: home.php", true, 302);exit;
九、上传 GitHub 与部署上线
9.1 项目配置与 .gitignore
上线前需要把数据库配置抽到独立文件,用 .gitignore 排除敏感文件和上传目录。Git 不跟踪空目录,uploads 子目录下放 .gitkeep 占位。
# .gitignoreconfig.phpuploads/avatar/*uploads/post/*!uploads/**/.gitkeep.vscode/.idea/*.swp.DS_StoreThumbs.db*.logtmp/cache/phpinfo.phptest_*.php
9.2 Git 推送命令
git initgit add .git commit -m "完整PHP博客系统:用户/文章/评论/点赞/通知"git remote add origin https://github.com/你的用户名/php-blog-system.gitgit branch -M maingit push -u origin main


9.3 服务器部署(Ubuntu + Nginx + PHP-FPM)
生产环境部署完整流程:安装 Nginx + PHP-FPM + MariaDB → 克隆代码 → 创建数据库和专用用户 → 导入 SQL → 配置 Nginx 站点 → 设置权限 → 配置 php.ini 安全项。
# 安装环境sudo apt install nginx php8.1-fpm php8.1-mysql php8.1-gd php8.1-mbstring -ysudo apt install mariadb-server git -y# 克隆代码sudo git clone https://github.com/你的用户名/php-blog-system.git /var/www/blog# 创建数据库和用户sudo mysql -u root -pCREATE DATABASE blog DEFAULT CHARACTER SET utf8mb4;CREATE USER 'blog_user'@'localhost' IDENTIFIED BY '强密码';GRANT ALL PRIVILEGES ON blog.* TO 'blog_user'@'localhost';FLUSH PRIVILEGES;# 导入SQLmysql -u blog_user -p blog < /var/www/blog/install.sql# 权限设置sudo chown -R www-data:www-data /var/www/blog/sudo chmod -R 755 /var/www/blog/uploads/sudo chmod 600 /var/www/blog/config.php








9.4 Nginx 站点配置
server { listen 80; server_name 你的域名或IP; root /var/www/blog; index home.php index.html; location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.1-fpm.sock; } # 禁止访问敏感文件 location ~* /config\.php$ { deny all; } location ~* /\.git { deny all; } location ~ /\.ht { deny all; }}
9.5 php.ini 安全配置
; 关闭错误显示(防止泄露路径)display_errors = Offlog_errors = Onerror_log = /var/log/php/error.log; Session 安全session.cookie_httponly = Onsession.cookie_samesite = "Lax"; 禁用危险函数disable_functions = exec,passthru,shell_exec,system,proc_open,popen; 上传限制upload_max_filesize = 2Mpost_max_size = 8M
十、功能测试验证
部署完成后,按以下模块逐项验证。每个模块都有对应的测试用例和预期结果。

10.1 用户注册测试
• 正常注册 → 成功跳转个人中心,数据库新增哈希密码• 重复用户名 → 提示"用户名或邮箱已被注册"• 邮箱格式错误 → 提示"邮箱格式错误"• 密码过短 → 提示"密码最少6位"• 头像上传 → 注册成功,uploads/avatar/ 有新文件






10.2 验证码与登录测试






10.3 密码管理测试








10.4 文章发布与管理测试















10.5 交互功能测试














10.6 通知系统测试











10.7 安全防护验证
• XSS 防护:评论输入 <script>alert(1)</script> → 页面正常显示文本,不弹窗• SQL 注入:登录框输入 ' or 1=1 -- → 登录失败,PDO 预处理拦截• 文件上传:php 脚本改名为 test.jpg.php → 上传失败,finfo 检测真实 MIME• CSRF:删除表单 csrf_token 隐藏域再提交 → 提示"非法请求"• Session:PHPSESSID 带 HttpOnly,JS 无法读取

十一、全文总结
完成这个项目后,你已经具备了独立开发中小型 Web 系统的完整能力:
• 语法基础:PHP 核心语法、数组字符串、表单处理、文件上传、会话管理• 数据库:表结构设计、多表关联、外键约束、PDO 预处理、分页查询• 编程思想:面向对象、命名空间、自动加载、单例模式、MVC 分层思想• 安全意识:SQL 注入、XSS、CSRF、文件上传漏洞的原理与防护• 项目能力:从需求分析、架构设计到功能落地的完整开发流程
从第一篇的 echo "Hello World" 到这篇的完整博客系统,PHP 入门之旅到此圆满收官。后续可以继续探索 API 开发、Laravel 框架、微服务架构等进阶方向。
文字整理过程中借助了 AI 工具辅助排版和代码校对,核心代码和知识点均经过实际验证。