摘要:历经前几篇的学习,你已从零掌握了 PHP 的环境搭建、语法基础、流程控制、数组字符串、表单处理、文件上传、数据库操作和面向对象编程。现在是时候将这些知识熔于一炉,亲手打造一个功能完备的博客系统。本篇将带你走完一个真实项目的完整开发流程:从需求分析、数据库设计、目录结构规划,到用户注册登录、文章发布与管理、评论功能、分页展示,再到安全加固和线上部署。涵盖用户注册登录、图形验证码、个人资料与密码修改、忘记密码重置、文章发布编辑、封面上传、列表分页、评论互动、点赞收藏、站内通知等全功能。全程采用 PDO 预处理 + CSRF + XSS 防护的安全规范。
一、引言:用一个项目串联所有知识点
从第一行 echo "Hello World" 到面向对象编程,零散的知识点只有落地到项目中才能真正内化。本篇我们将搭建一个接近生产环境的个人博客,你会亲眼看到:
-
面向对象如何让代码模块化、易扩展
-
数据库多表关联如何支撑复杂业务
-
安全防护如何贯穿每一个功能环节
-
交互功能如何从0到1逐步落地
功能总览
| 模块 | 核心功能 |
|---|---|
| 用户体系 | 注册/登录/退出、图形验证码、资料编辑、头像更换、密码修改、忘记密码重置、账号注销 |
| 内容体系 | 文章发布/编辑/删除、封面图上传、文章列表分页、详情页展示 |
| 交互体系 | 文章评论、评论点赞收藏、一键点赞/取消、收藏/取消收藏、我的收藏列表 |
| 通知体系 | 点赞收藏自动推送站内通知、未读数量提示、全部标记已读、一键清空 |
| 安全体系 | SQL注入防护、XSS转义、CSRF令牌、权限校验、文件上传安全 |
二、项目架构与数据库设计
2.1 目录结构
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 编码,外键级联删除保证数据一致性。
sql
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;
三、核心基础层
3.1 数据库单例类 Core/Database.php
全局唯一数据库连接,避免重复创建连接,所有模型复用。
php
<?php
namespace 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 即可运行。
php
<?php
spl_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();
四、核心模型层
4.1 用户模型 Models/User.php
涵盖注册、查询、密码校验、资料更新、密码修改、密码重置全流程。
php
<?php
namespace 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 findByEmail(string $email): ?self
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("SELECT id, username, email, avatar FROM users WHERE email = :e");
$stmt->execute([':e' => $email]);
$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 updateProfile(string $email, ?string $avatar = null): bool
{
if (!$this->id) return false;
$pdo = Database::getInstance()->getPdo();
if ($avatar !== null) {
$stmt = $pdo->prepare("UPDATE users SET email = :e, avatar = :avatar WHERE id = :id");
$stmt->execute([':e' => $email, ':avatar' => $avatar, ':id' => $this->id]);
} else {
$stmt = $pdo->prepare("UPDATE users SET email = :e WHERE id = :id");
$stmt->execute([':e' => $email, ':id' => $this->id]);
}
return true;
}
// 修改密码(需原密码)
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;
}
// 创建密码重置令牌
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 static function deleteResetToken(string $token): void
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("DELETE FROM password_resets WHERE token = :t");
$stmt->execute([':t' => $token]);
}
// 删除账号
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]);
}
}
4.2 文章模型 Models/Post.php
支持分页查询、封面字段、增删改查。
php
<?php
namespace Blog\Models;
use Blog\Core\Database;
class Post
{
public ?int $id = null;
public int $userId = 0;
public string $title = '';
public ?string $cover = null;
public string $content = '';
public string $createdAt = '';
public string $authorName = '';
// 分页获取文章列表
public static function getPaginated(int $page = 1, int $perPage = 5): array
{
$pdo = Database::getInstance()->getPdo();
$offset = ($page - 1) * $perPage;
$stmt = $pdo->prepare(
"SELECT p.*, u.username as authorName
FROM posts p
JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT :limit OFFSET :offset"
);
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
$stmt->execute();
$list = [];
while ($row = $stmt->fetch()) {
$post = new self();
$post->id = (int)$row['id'];
$post->userId = (int)$row['user_id'];
$post->title = $row['title'];
$post->cover = $row['cover'];
$post->content = $row['content'];
$post->createdAt = $row['created_at'];
$post->authorName = $row['authorName'] ?? '匿名用户';
$list[] = $post;
}
return $list;
}
// 获取文章总数
public static function getTotalCount(): int
{
$pdo = Database::getInstance()->getPdo();
return (int)$pdo->query("SELECT COUNT(*) FROM posts")->fetchColumn();
}
// 根据ID查询单篇文章
public static function find(int $id): ?self
{
$pdo = Database::getInstance()->getPdo();
$sql = "
SELECT p.*, u.username as authorName
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.id = :pid
";
$stmt = $pdo->prepare($sql);
$stmt->execute([':pid' => $id]);
$row = $stmt->fetch();
if (!$row) return null;
$post = new self();
$post->id = $row['id'];
$post->userId = $row['user_id'];
$post->title = $row['title'];
$post->cover = $row['cover'];
$post->content = $row['content'];
$post->createdAt = $row['created_at'];
$post->authorName = $row['authorName'] ?? '匿名用户';
return $post;
}
// 保存:新增/更新
public function save(): self
{
$pdo = Database::getInstance()->getPdo();
if ($this->id) {
$stmt = $pdo->prepare("UPDATE posts SET title=:t, cover=:cover, content=:c WHERE id=:id");
$stmt->execute([
':t' => $this->title,
':cover' => $this->cover,
':c' => $this->content,
':id' => $this->id
]);
} else {
$stmt = $pdo->prepare("INSERT INTO posts (user_id, title, cover, content) VALUES (:uid, :t, :cover, :c)");
$stmt->execute([
':uid' => $this->userId,
':t' => $this->title,
':cover' => $this->cover,
':c' => $this->content
]);
$this->id = (int)$pdo->lastInsertId();
}
return $this;
}
// 删除文章
public function delete(): void
{
if (!$this->id) return;
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("DELETE FROM posts WHERE id = :id");
$stmt->execute([':id' => $this->id]);
}
}
4.3 评论模型 Models/Comment.php
php
<?php
namespace Blog\Models;
use Blog\Core\Database;
class Comment
{
public ?int $id = null;
public int $postId = 0;
public int $userId = 0;
public string $content = '';
public string $createdAt = '';
public string $authorName = '';
// 获取文章所有评论
public static function getByPostId(int $postId): array
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"SELECT c.*, u.username as authorName
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.post_id = :pid
ORDER BY c.created_at ASC"
);
$stmt->execute([':pid' => $postId]);
$list = [];
while ($row = $stmt->fetch()) {
$comment = new self();
$comment->id = (int)$row['id'];
$comment->postId = (int)$row['post_id'];
$comment->userId = (int)$row['user_id'];
$comment->content = $row['content'];
$comment->createdAt = $row['created_at'];
$comment->authorName = $row['authorName'];
$list[] = $comment;
}
return $list;
}
// 新增评论
public static function create(int $postId, int $userId, string $content): int
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"INSERT INTO comments (post_id, user_id, content) VALUES (:pid, :uid, :c)"
);
$stmt->execute([
':pid' => $postId,
':uid' => $userId,
':c' => $content
]);
return (int)$pdo->lastInsertId();
}
/**
* 根据ID查询单条评论
*/
public static function findById(int $id): ?self
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("SELECT * FROM comments WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
if (!$row) return null;
$comment = new self();
$comment->id = (int)$row['id'];
$comment->postId = (int)$row['post_id'];
$comment->userId = (int)$row['user_id'];
$comment->content = $row['content'];
$comment->createdAt = $row['created_at'];
return $comment;
}
/**
* 删除评论
*/
public function delete(): void
{
if (!$this->id) return;
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("DELETE FROM comments WHERE id = :id");
$stmt->execute([':id' => $this->id]);
}
}
4.4 评论点赞模型Models/CommentLike.php
切换式设计,支持点赞数统计、状态判断。
php
<?php
namespace Blog\Models;
use Blog\Core\Database;
class CommentLike
{
/**
* 获取单条评论的点赞总数
*/
public static function getCount(int $commentId): int
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM comment_likes WHERE comment_id = :cid");
$stmt->execute([':cid' => $commentId]);
return (int)$stmt->fetchColumn();
}
/**
* 判断当前用户是否已点赞该评论
*/
public static function checkUserLike(int $commentId, int $userId): bool
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"SELECT id FROM comment_likes WHERE comment_id = :cid AND user_id = :uid LIMIT 1"
);
$stmt->execute([':cid' => $commentId, ':uid' => $userId]);
return $stmt->fetch() !== false;
}
/**
* 切换点赞状态:点赞→取消,取消→点赞
* 返回 true 为当前已点赞,false 为已取消
*/
public static function toggle(int $commentId, int $userId): bool
{
$pdo = Database::getInstance()->getPdo();
$isLiked = self::checkUserLike($commentId, $userId);
if ($isLiked) {
$stmt = $pdo->prepare("DELETE FROM comment_likes WHERE comment_id = :cid AND user_id = :uid");
$stmt->execute([':cid' => $commentId, ':uid' => $userId]);
return false;
} else {
try {
$stmt = $pdo->prepare("INSERT INTO comment_likes (comment_id, user_id) VALUES (:cid, :uid)");
$stmt->execute([':cid' => $commentId, ':uid' => $userId]);
return true;
} catch (\PDOException $e) {
return true;
}
}
}
}
4.5 点赞模型 Models/Like.php
切换式设计:点击即点赞,再点取消。
php
<?php
namespace Blog\Models;
use Blog\Core\Database;
class Like
{
// 获取点赞数
public static function getCount(int $postId): int
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM post_likes WHERE post_id = :pid");
$stmt->execute([':pid' => $postId]);
return (int)$stmt->fetchColumn();
}
// 判断用户是否已点赞
public static function checkUserLike(int $postId, int $userId): bool
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"SELECT id FROM post_likes WHERE post_id = :pid AND user_id = :uid LIMIT 1"
);
$stmt->execute([':pid' => $postId, ':uid' => $userId]);
return $stmt->fetch() !== false;
}
// 切换点赞状态,返回当前是否点赞
public static function toggle(int $postId, int $userId): bool
{
$pdo = Database::getInstance()->getPdo();
$isLiked = self::checkUserLike($postId, $userId);
if ($isLiked) {
$stmt = $pdo->prepare("DELETE FROM post_likes WHERE post_id = :pid AND user_id = :uid");
$stmt->execute([':pid' => $postId, ':uid' => $userId]);
return false;
} else {
try {
$stmt = $pdo->prepare("INSERT INTO post_likes (post_id, user_id) VALUES (:pid, :uid)");
$stmt->execute([':pid' => $postId, ':uid' => $userId]);
return true;
} catch (\PDOException $e) {
return true;
}
}
}
}
4.6 收藏模型 Models/Favorite.php
php
<?php
namespace Blog\Models;
use Blog\Core\Database;
class Favorite
{
// 获取收藏数
public static function getCount(int $postId): int
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM post_favorites WHERE post_id = :pid");
$stmt->execute([':pid' => $postId]);
return (int)$stmt->fetchColumn();
}
// 判断是否已收藏
public static function checkUserFavorite(int $postId, int $userId): bool
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"SELECT id FROM post_favorites WHERE post_id = :pid AND user_id = :uid LIMIT 1"
);
$stmt->execute([':pid' => $postId, ':uid' => $userId]);
return $stmt->fetch() !== false;
}
// 切换收藏状态
public static function toggle(int $postId, int $userId): bool
{
$pdo = Database::getInstance()->getPdo();
$isFavorited = self::checkUserFavorite($postId, $userId);
if ($isFavorited) {
$stmt = $pdo->prepare("DELETE FROM post_favorites WHERE post_id = :pid AND user_id = :uid");
$stmt->execute([':pid' => $postId, ':uid' => $userId]);
return false;
} else {
try {
$stmt = $pdo->prepare("INSERT INTO post_favorites (post_id, user_id) VALUES (:pid, :uid)");
$stmt->execute([':pid' => $postId, ':uid' => $userId]);
return true;
} catch (\PDOException $e) {
return true;
}
}
}
// 获取用户收藏的所有文章
public static function getUserFavorites(int $userId): array
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"SELECT p.*, u.username as authorName, f.created_at as favorite_time
FROM post_favorites f
JOIN posts p ON f.post_id = p.id
JOIN users u ON p.user_id = u.id
WHERE f.user_id = :uid
ORDER BY f.created_at DESC"
);
$stmt->execute([':uid' => $userId]);
$list = [];
while ($row = $stmt->fetch()) {
$post = new Post();
$post->id = (int)$row['id'];
$post->userId = (int)$row['user_id'];
$post->title = $row['title'];
$post->cover = $row['cover'];
$post->content = $row['content'];
$post->createdAt = $row['created_at'];
$post->authorName = $row['authorName'] ?? '匿名用户';
$list[] = $post;
}
return $list;
}
}
4.7 通知模型 Models/Notification.php
php
<?php
namespace Blog\Models;
use Blog\Core\Database;
class Notification
{
public ?int $id = null;
public int $userId = 0;
public string $type = '';
public ?int $relatedPostId = null;
public int $triggerUserId = 0;
public string $triggerUserName = '';
public string $content = '';
public int $isRead = 0;
public string $createdAt = '';
// 发送通知
public static function send(int $userId, string $type, int $triggerUserId, int $postId, string $content): void
{
if ($userId === $triggerUserId) return;
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"INSERT INTO notifications (user_id, type, related_post_id, trigger_user_id, content)
VALUES (:uid, :type, :pid, :tuid, :content)"
);
$stmt->execute([
':uid' => $userId,
':type' => $type,
':pid' => $postId,
':tuid' => $triggerUserId,
':content' => $content
]);
}
// 获取未读数量
public static function getUnreadCount(int $userId): int
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM notifications WHERE user_id = :uid AND is_read = 0");
$stmt->execute([':uid' => $userId]);
return (int)$stmt->fetchColumn();
}
// 获取全部通知
public static function getUserAll(int $userId): array
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"SELECT n.*, u.username as triggerUserName
FROM notifications n
JOIN users u ON n.trigger_user_id = u.id
WHERE n.user_id = :uid
ORDER BY n.created_at DESC"
);
$stmt->execute([':uid' => $userId]);
$list = [];
while ($row = $stmt->fetch()) {
$item = new self();
$item->id = (int)$row['id'];
$item->userId = (int)$row['user_id'];
$item->type = $row['type'];
$item->relatedPostId = (int)$row['related_post_id'];
$item->triggerUserId = (int)$row['trigger_user_id'];
$item->triggerUserName = $row['triggerUserName'];
$item->content = $row['content'];
$item->isRead = (int)$row['is_read'];
$item->createdAt = $row['created_at'];
$list[] = $item;
}
return $list;
}
// 全部标记已读
public static function markAllRead(int $userId): void
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("UPDATE notifications SET is_read = 1 WHERE user_id = :uid AND is_read = 0");
$stmt->execute([':uid' => $userId]);
}
/**
* 清空当前用户所有通知
*/
public static function clearAll(int $userId): void
{
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare("DELETE FROM notifications WHERE user_id = :uid");
$stmt->execute([':uid' => $userId]);
}
/**
* 发送评论点赞通知
*/
public static function sendCommentLike(int $commentAuthorId, int $triggerUserId, int $postId, string $content): void
{
if ($commentAuthorId === $triggerUserId) return;
$pdo = Database::getInstance()->getPdo();
$stmt = $pdo->prepare(
"INSERT INTO notifications (user_id, type, related_post_id, trigger_user_id, content)
VALUES (:uid, 'comment_like', :pid, :tuid, :content)"
);
$stmt->execute([
':uid' => $commentAuthorId,
':pid' => $postId,
':tuid' => $triggerUserId,
':content' => $content
]);
}
}
五、用户体系完整实现
5.1 图形验证码 captcha.php
需开启 GD 库,点击图片可刷新,验证后立即销毁。

php
<?php
// 强制清空所有之前的输出,解决 header 发送失败的核心问题
ob_clean();
session_start();
// 声明输出为 PNG 图片,必须在任何实际输出之前执行
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);
// 生成4位验证码(去除易混淆字符 0/O、1/I)
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= $chars[mt_rand(0, strlen($chars) - 1)];
}
// 存入 session,统一转小写
$_SESSION['captcha_code'] = strtolower($code);
// 写入验证码文字(PHP内置字体,无需额外字体文件)
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;
5.2 用户注册 register.php
支持头像上传、表单校验、CSRF 防护。
php
<?php
spl_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;
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户注册</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f4f6f9;padding:60px 20px;}
.card{width:420px;margin:0 auto;background:#fff;padding:32px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.07);}
h2{text-align:center;margin-bottom:24px;color:#2d3748;}
.global-err{background:#fee;color:#dc2626;padding:10px;border-radius:6px;margin-bottom:16px;text-align:center;}
.item{margin-bottom:16px;}
label{display:block;margin-bottom:6px;color:#4a5568;}
input{width:100%;padding:10px 12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;}
.err-text{color:#dc2626;font-size:13px;margin-top:4px;display:block;}
button{width:100%;padding:11px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
button:hover{background:#1d4ed8;}
.link{text-align:center;margin-top:16px;font-size:14px;}
.link a{color:#2563eb;text-decoration:none;}
</style>
</head>
<body>
<div class="card">
<h2>新用户注册</h2>
<?php if(!empty($errors['global'])): ?>
<div class="global-err"><?=htmlspecialchars($errors['global'],ENT_QUOTES)?></div>
<?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?=htmlspecialchars($_SESSION['csrf_reg'],ENT_QUOTES)?>">
<div class="item">
<label>用户名</label>
<input type="text" name="username" value="<?=htmlspecialchars($fill['username'],ENT_QUOTES)?>" placeholder="至少3字符">
<?php if(!empty($errors['username'])):?><span class="err-text"><?=$errors['username']?></span><?php endif; ?>
</div>
<div class="item">
<label>密码</label>
<input type="password" name="password" placeholder="最少6位">
<?php if(!empty($errors['password'])):?><span class="err-text"><?=$errors['password']?></span><?php endif; ?>
</div>
<div class="item">
<label>确认密码</label>
<input type="password" name="password2">
<?php if(!empty($errors['password2'])):?><span class="err-text"><?=$errors['password2']?></span><?php endif; ?>
</div>
<div class="item">
<label>邮箱</label>
<input type="email" name="email" value="<?=htmlspecialchars($fill['email'],ENT_QUOTES)?>">
<?php if(!empty($errors['email'])):?><span class="err-text"><?=$errors['email']?></span><?php endif; ?>
</div>
<div class="item">
<label>头像(选填,1MB内jpg/png)</label>
<input type="file" name="avatar" accept="image/jpeg,image/png">
<?php if(!empty($errors['avatar'])):?><span class="err-text"><?=$errors['avatar']?></span><?php endif; ?>
</div>
<button type="submit">注册</button>
</form>
<div class="link">已有账号?<a href="login.php">去登录</a></div>
</div>
</body>
</html>
5.3 用户登录 login.php
含图形验证码、错误提示、CSRF 防护。
php
<?php
spl_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;
$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;
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>账号登录</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f4f6f9;padding:60px 20px;}
.card{width:380px;margin:0 auto;background:#fff;padding:32px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.07);}
h2{text-align:center;margin-bottom:24px;color:#2d3748;}
.err-box{background:#fee;color:#dc2626;padding:10px;border-radius:6px;margin-bottom:16px;text-align:center;}
.row{margin-bottom:16px;}
label{display:block;margin-bottom:6px;color:#4a5568;}
input{width:100%;padding:10px 12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;}
.captcha-row{display:flex;gap:10px;align-items:center;}
.captcha-row input{flex:1;}
.captcha-img{height:40px;border-radius:6px;cursor:pointer;border:1px solid #cbd5e0;}
button{width:100%;padding:11px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
button:hover{background:#1d4ed8;}
.reg-link{text-align:center;margin-top:16px;font-size:14px;}
.reg-link a{color:#2563eb;text-decoration:none;}
</style>
</head>
<body>
<div class="card">
<h2>账号登录</h2>
<?php if($error): ?>
<div class="err-box"><?=htmlspecialchars($error,ENT_QUOTES)?></div>
<?php endif; ?>
<form method="post">
<div class="row">
<label>用户名</label>
<input type="text" name="username" value="<?=htmlspecialchars($fillUser,ENT_QUOTES)?>">
</div>
<div class="row">
<label>密码</label>
<input type="password" name="password">
</div>
<div class="row">
<label>验证码</label>
<div class="captcha-row">
<input type="text" name="captcha" placeholder="点击图片刷新" maxlength="4">
<img class="captcha-img" src="captcha.php" onclick="this.src='captcha.php?'+Math.random()" alt="验证码">
</div>
</div>
<button type="submit">登录</button>
</form>
<div class="reg-link">
<a href="forgot_password.php">忘记密码?</a> |
<a href="register.php">没有账号?注册</a>
</div>
</div>
</body>
</html>
5.4 个人中心 welcome.php
整合头像展示、功能入口、未读通知提示。
php
<?php
spl_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_once $file;
}
});
session_start();
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
use Blog\Models\User;
use Blog\Models\Notification;
$userName = htmlspecialchars($_SESSION['username'], ENT_QUOTES);
$avatar = $_SESSION['avatar'] ?? '';
$unreadCount = Notification::getUnreadCount($_SESSION['user_id']);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>个人中心</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f4f6f9;padding-top:80px;text-align:center;}
.box{width:520px;margin:0 auto;background:#fff;padding:40px 30px;border-radius:12px;box-shadow:0 2px 14px rgba(0,0,0,0.07);}
h2{color:#2d3748;margin-bottom:20px;font-size:22px;}
.avatar-wrap{margin:20px 0 30px;}
.avatar-img{width:120px;height:120px;border-radius:50%;object-fit:cover;border:3px solid #2563eb;}
.avatar-empty{width:120px;height:120px;border-radius:50%;background:#e2e8f0;display:inline-flex;align-items:center;justify-content:center;color:#666;font-size:14px;}
.btn-wrap{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;}
.btn{display:block;width:100%;height:44px;line-height:44px;font-size:15px;text-decoration:none;border-radius:8px;color:#fff;transition:opacity 0.2s ease;position:relative;}
.btn:hover{opacity:0.88;}
.btn-blue{background:#2563eb;}
.btn-gray{background:#6b7280;grid-column: span 3;}
.btn-red{background:#dc2626;grid-column: span 3;}
.badge{position:absolute;top:-6px;right:10px;background:#dc2626;color:#fff;padding:1px 6px;border-radius:10px;font-size:12px;line-height:1.4;}
</style>
</head>
<body>
<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>
</body>
</html>
5.5 修改个人资料 profile.php
支持修改邮箱、更换头像。
php
<?php
spl_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_once $file;
}
});
session_start();
header("X-XSS-Protection: 1; mode=block");
header("X-Frame-Options: DENY");
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
use Blog\Models\User;
$currentUser = User::findById($_SESSION['user_id']);
if (empty($_SESSION['csrf_profile'])) {
$_SESSION['csrf_profile'] = bin2hex(random_bytes(32));
}
$success = '';
$error = '';
$uploadDir = __DIR__ . '/uploads/avatar/';
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf_token'] ?? '';
$email = trim($_POST['email'] ?? '');
$newAvatar = $currentUser->avatar;
if (!hash_equals($_SESSION['csrf_profile'], $token)) {
$error = "非法请求";
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "邮箱格式错误";
} else {
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] !== UPLOAD_ERR_NO_FILE) {
$file = $_FILES['avatar'];
if ($file['error'] === UPLOAD_ERR_OK) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (in_array($mime, ['image/jpeg','image/png']) && $file['size'] <= 1048576) {
$ext = $mime === 'image/png' ? 'png' : 'jpg';
$name = md5(uniqid(true).$currentUser->username).".".$ext;
move_uploaded_file($file['tmp_name'], $uploadDir.$name);
$newAvatar = "uploads/avatar/".$name;
} else {
$error = "头像仅支持jpg/png,不超过1MB";
}
}
}
if (empty($error)) {
$currentUser->updateProfile($email, $newAvatar);
$_SESSION['avatar'] = $newAvatar;
$success = "资料修改成功";
$currentUser = User::findById($_SESSION['user_id']);
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>修改个人资料</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f4f6f9;padding:60px 20px;}
.card{width:460px;margin:0 auto;background:#fff;padding:36px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.07);}
h2{text-align:center;margin-bottom:24px;color:#2d3748;}
.msg{padding:10px;border-radius:6px;margin-bottom:16px;text-align:center;}
.msg.err{background:#fee;color:#dc2626;}
.msg.ok{background:#f0fdf4;color:#16a34a;}
.item{margin-bottom:18px;}
label{display:block;margin-bottom:6px;color:#4a5568;}
input{width:100%;padding:10px 12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;}
.disabled{background:#f1f5f9;color:#666;}
.avatar-preview{width:100px;height:100px;border-radius:50%;object-fit:cover;margin:10px auto;display:block;border:2px solid #2563eb;}
button{width:100%;padding:11px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
button:hover{background:#1d4ed8;}
.back{display:block;text-align:center;margin-top:16px;color:#666;text-decoration:none;}
</style>
</head>
<body>
<div class="card">
<h2>修改个人资料</h2>
<?php if($error): ?><div class="msg err"><?=htmlspecialchars($error)?></div><?php endif; ?>
<?php if($success): ?><div class="msg ok"><?=htmlspecialchars($success)?></div><?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?=htmlspecialchars($_SESSION['csrf_profile'])?>">
<div class="item">
<label>用户名(不可修改)</label>
<input type="text" value="<?=htmlspecialchars($currentUser->username)?>" class="disabled" disabled>
</div>
<div class="item">
<label>邮箱</label>
<input type="email" name="email" value="<?=htmlspecialchars($currentUser->email)?>">
</div>
<div class="item">
<label>头像</label>
<?php if(!empty($currentUser->avatar) && file_exists($currentUser->avatar)): ?>
<img class="avatar-preview" src="<?=htmlspecialchars($currentUser->avatar)?>" alt="当前头像">
<?php endif; ?>
<input type="file" name="avatar" accept="image/jpeg,image/png">
<p style="font-size:12px;color:#999;margin-top:4px;">不上传则保留原头像</p>
</div>
<button type="submit">保存修改</button>
</form>
<a class="back" href="welcome.php">返回个人中心</a>
</div>
</body>
</html>
5.6 修改登录密码 change_password.php
php
<?php
spl_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_once $file;
}
});
session_start();
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
use Blog\Models\User;
$currentUser = User::findById($_SESSION['user_id']);
if (empty($_SESSION['csrf_pwd'])) {
$_SESSION['csrf_pwd'] = bin2hex(random_bytes(32));
}
$error = '';
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$oldPwd = $_POST['old_password'] ?? '';
$newPwd = $_POST['new_password'] ?? '';
$confirmPwd = $_POST['confirm_password'] ?? '';
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_pwd'], $token)) {
$error = "非法请求";
} elseif (empty($oldPwd) || empty($newPwd)) {
$error = "密码不能为空";
} elseif (strlen($newPwd) < 6) {
$error = "新密码至少6位";
} elseif ($newPwd !== $confirmPwd) {
$error = "两次新密码不一致";
} elseif (!$currentUser->updatePassword($oldPwd, $newPwd)) {
$error = "原密码错误";
} else {
$success = "密码修改成功,请重新登录";
$_SESSION = [];
session_destroy();
// 发送跳转响应头
header("refresh:2;url=login.php");
// 单独渲染成功提示页面,不再加载下方表单
echo <<<PAGE
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>修改登录密码</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f4f6f9;display:flex;justify-content:center;align-items:center;height:100vh;}
.tip{background:#f0fdf4;color:#16a34a;padding:20px 40px;border-radius:8px;font-size:18px;box-shadow:0 2px 12px rgba(0,0,0,0.07);}
</style>
</head>
<body>
<div class="tip">{$success},2秒后跳转到登录页</div>
</body>
</html>
PAGE;
// 终止脚本,下方表单页面不再执行输出,彻底解决header报错
exit;
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>修改登录密码</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f4f6f9;padding:60px 20px;}
.card{width:420px;margin:0 auto;background:#fff;padding:36px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.07);}
h2{text-align:center;margin-bottom:24px;color:#2d3748;}
.msg{padding:10px;border-radius:6px;margin-bottom:16px;text-align:center;}
.msg.err{background:#fee;color:#dc2626;}
.msg.ok{background:#f0fdf4;color:#16a34a;}
.item{margin-bottom:16px;}
label{display:block;margin-bottom:6px;color:#4a5568;}
input{width:100%;padding:10px 12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;}
button{width:100%;padding:11px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
button:hover{background:#1d4ed8;}
.back{display:block;text-align:center;margin-top:16px;color:#666;text-decoration:none;}
</style>
</head>
<body>
<div class="card">
<h2>修改登录密码</h2>
<?php if($error): ?><div class="msg err"><?=htmlspecialchars($error)?></div><?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?=htmlspecialchars($_SESSION['csrf_pwd'])?>">
<div class="item">
<label>原密码</label>
<input type="password" name="old_password" required>
</div>
<div class="item">
<label>新密码</label>
<input type="password" name="new_password" required>
</div>
<div class="item">
<label>确认新密码</label>
<input type="password" name="confirm_password" required>
</div>
<button type="submit">确认修改</button>
</form>
<a class="back" href="welcome.php">返回个人中心</a>
</div>
</body>
</html>
5.7 忘记密码 forgot_password.php
php
<?php
spl_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_once $file;
}
});
session_start();
use Blog\Models\User;
$success = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "请输入正确的邮箱";
} elseif (!User::findByEmail($email)) {
$success = "若该邮箱已注册,重置链接已发送至您的邮箱,30分钟内有效";
} else {
$token = User::createResetToken($email);
$resetUrl = "reset_password.php?token=$token";
$success = "重置链接已生成:<a href='$resetUrl'>点击立即重置</a>(生产环境会发送到邮箱)";
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>忘记密码</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f4f6f9;padding:80px 20px;}
.card{width:400px;margin:0 auto;background:#fff;padding:32px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.07);}
h2{text-align:center;margin-bottom:20px;color:#2d3748;}
.msg{padding:10px;border-radius:6px;margin-bottom:16px;text-align:center;font-size:14px;}
.msg.err{background:#fee;color:#dc2626;}
.msg.ok{background:#f0fdf4;color:#16a34a;}
.item{margin-bottom:16px;}
label{display:block;margin-bottom:6px;color:#4a5568;}
input{width:100%;padding:10px 12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;}
button{width:100%;padding:11px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
.link{text-align:center;margin-top:16px;font-size:14px;}
.link a{color:#2563eb;text-decoration:none;}
</style>
</head>
<body>
<div class="card">
<h2>找回密码</h2>
<?php if($error): ?><div class="msg err"><?=htmlspecialchars($error)?></div><?php endif; ?>
<?php if($success): ?><div class="msg ok"><?=$success?></div><?php endif; ?>
<form method="post">
<div class="item">
<label>注册邮箱</label>
<input type="email" name="email" placeholder="输入注册时的邮箱" required>
</div>
<button type="submit">发送重置链接</button>
</form>
<div class="link"><a href="login.php">返回登录</a></div>
</div>
</body>
</html>
5.8 重置密码 reset_password.php
php
<?php
spl_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_once $file;
}
});
session_start();
use Blog\Models\User;
$token = $_GET['token'] ?? '';
$email = User::validateResetToken($token);
if (!$email) {
die("<div style='text-align:center;margin-top:100px;font-size:16px;'>重置链接无效或已过期,请<a href='forgot_password.php'>重新申请</a></div>");
}
$error = '';
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$newPwd = $_POST['new_password'] ?? '';
$confirmPwd = $_POST['confirm_password'] ?? '';
if (strlen($newPwd) < 6) {
$error = "密码至少6位";
} elseif ($newPwd !== $confirmPwd) {
$error = "两次密码不一致";
} else {
User::resetPasswordByEmail($email, $newPwd);
User::deleteResetToken($token);
$success = "密码重置成功,2秒后跳转到登录页";
header("refresh:2;url=login.php");
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>重置密码</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f4f6f9;padding:80px 20px;}
.card{width:400px;margin:0 auto;background:#fff;padding:32px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.07);}
h2{text-align:center;margin-bottom:20px;color:#2d3748;}
.msg{padding:10px;border-radius:6px;margin-bottom:16px;text-align:center;}
.msg.err{background:#fee;color:#dc2626;}
.msg.ok{background:#f0fdf4;color:#16a34a;}
.item{margin-bottom:16px;}
label{display:block;margin-bottom:6px;color:#4a5568;}
input{width:100%;padding:10px 12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;}
button{width:100%;padding:11px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
button:hover{background:#1d4ed8;}
</style>
</head>
<body>
<div class="card">
<h2>设置新密码</h2>
<?php if($error): ?><div class="msg err"><?=htmlspecialchars($error)?></div><?php endif; ?>
<?php if($success): ?><div class="msg ok"><?=htmlspecialchars($success)?></div><?php endif; ?>
<form method="post">
<div class="item">
<label>新密码</label>
<input type="password" name="new_password" placeholder="至少6位" required>
</div>
<div class="item">
<label>确认新密码</label>
<input type="password" name="confirm_password" placeholder="再次输入" required>
</div>
<button type="submit">确认重置</button>
</form>
</div>
</body>
</html>
六、文章体系完整实现
6.1 文章列表(分页)home.php
左侧封面缩略图 + 右侧标题信息,底部分页导航。
php
<?php
spl_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();
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));
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>全部文章</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f5f7fa;padding:40px 20px;}
.wrap{max-width:720px;margin:0 auto;}
.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;}
h1{color:#2d3748;font-size:24px;}
.btn{padding:8px 16px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;font-size:14px;}
.btn.gray{background:#666;}
.item{background:#fff;padding:16px;border-radius:10px;box-shadow:0 1px 8px rgba(0,0,0,0.06);margin-bottom:16px;display:flex;gap:16px;align-items:center;}
.item-cover{width:140px;height:90px;object-fit:cover;border-radius:6px;flex-shrink:0;background:#f1f5f9;}
.item-body{flex:1;min-width:0;}
.title{font-size:18px;margin-bottom:8px;}
.title a{color:#2563eb;text-decoration:none;}
.title a:hover{text-decoration:underline;}
.meta{color:#666;font-size:14px;}
.empty{text-align:center;padding:40px;color:#888;background:#fff;border-radius:10px;}
.pagination{display:flex;justify-content:center;align-items:center;gap:12px;margin-top:30px;}
.pagination a, .pagination span{padding:8px 14px;border-radius:6px;text-decoration:none;font-size:14px;}
.pagination a{background:#fff;color:#2563eb;border:1px solid #e2e8f0;}
.pagination a:hover{background:#2563eb;color:#fff;}
.pagination .current{background:#2563eb;color:#fff;}
.pagination .disabled{color:#aaa;background:#f1f5f9;border:1px solid #e2e8f0;cursor:not-allowed;}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<h1>文章列表</h1>
<div>
<?php if(!empty($_SESSION['logged_in'])): ?>
<a href="create_post.php" class="btn">发布文章</a>
<a href="welcome.php" class="btn gray" style="margin-left:8px;">个人中心</a>
<?php else: ?>
<a href="login.php" class="btn">登录</a>
<?php endif; ?>
</div>
</div>
<?php if(empty($postList)): ?>
<div class="empty">暂无文章</div>
<?php else: ?>
<?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>
<?php endif; ?>
</div>
</body>
</html>
6.2 发布文章 create_post.php
支持标题、封面上传、正文内容,带完整校验。
php
<?php
spl_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");
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
use Blog\Models\Post;
if (empty($_SESSION['csrf_post'])) {
$_SESSION['csrf_post'] = bin2hex(random_bytes(32));
}
$errors = [];
$tFill = '';
$cFill = '';
$uploadDir = __DIR__ . '/uploads/post/';
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
$coverPath = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($_SESSION['csrf_post'], $_POST['csrf_token'] ?? '')) {
$errors[] = "非法请求";
}
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$tFill = $title;
$cFill = $content;
if (empty($title)) $errors[] = "标题不能为空";
elseif (mb_strlen($title) > 200) $errors[] = "标题不能超过200字";
if (empty($content)) $errors[] = "正文不能为空";
// 处理封面上传
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;
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>发布新文章</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f5f7fa;padding:60px 20px;}
.card{width:720px;margin:0 auto;background:#fff;padding:32px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.08);}
h2{text-align:center;margin-bottom:24px;color:#2d3748;}
.err-list{background:#fee;color:#dc2626;padding:10px 16px;border-radius:6px;margin-bottom:16px;}
.err-list li{list-style-position:inside;line-height:1.6;}
.item{margin-bottom:18px;}
label{display:block;margin-bottom:6px;color:#4a5568;font-weight:500;}
input[type="text"]{width:100%;padding:10px 12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;}
textarea{width:100%;padding:12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;resize:vertical;min-height:300px;line-height:1.6;}
.tip{font-size:12px;color:#999;margin-top:4px;}
button{width:100%;padding:12px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
button:hover{background:#1d4ed8;}
.back{display:block;text-align:center;margin-top:16px;color:#666;text-decoration:none;}
</style>
</head>
<body>
<div class="card">
<h2>发布新文章</h2>
<?php if(!empty($errors)): ?>
<div class="err-list">
<?php foreach($errors as $e): ?>
<li><?=htmlspecialchars($e)?></li>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?=htmlspecialchars($_SESSION['csrf_post'])?>">
<div class="item">
<label>文章标题</label>
<input type="text" name="title" value="<?=htmlspecialchars($tFill)?>" placeholder="请输入文章标题" maxlength="200">
</div>
<div class="item">
<label>文章封面(选填)</label>
<input type="file" name="cover" accept="image/jpeg,image/png">
<p class="tip">建议尺寸 800x400,支持 jpg/png,不超过 2MB</p>
</div>
<div class="item">
<label>文章正文</label>
<textarea name="content" placeholder="请输入文章内容"><?=htmlspecialchars($cFill)?></textarea>
</div>
<button type="submit">发布文章</button>
</form>
<a class="back" href="home.php">返回文章列表</a>
</div>
</body>
</html>
6.3 文章详情页 view_post.php
整合封面、正文、点赞收藏、评论区、作者操作按钮。
php
<?php
spl_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();
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']);
}
if (empty($_SESSION['csrf_interact'])) {
$_SESSION['csrf_interact'] = bin2hex(random_bytes(32));
}
$csrfToken = $_SESSION['csrf_interact'];
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?=htmlspecialchars($post->title)?> - 我的博客</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:"Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;}
body{
background:#f0f2f5;
padding:40px 20px;
line-height:1.6;
color:#1f2937;
}
.container{max-width:800px;margin:0 auto;}
.post-card{
background:#fff;
border-radius:16px;
box-shadow:0 4px 20px rgba(0,0,0,0.06);
padding:48px;
margin-bottom:32px;
}
.post-cover{
width:100%;
max-height:380px;
object-fit:cover;
border-radius:12px;
margin-bottom:32px;
}
.post-title{
font-size:28px;
font-weight:600;
color:#111827;
line-height:1.4;
text-align:center;
margin-bottom:16px;
}
.post-meta{
text-align:center;
color:#6b7280;
font-size:14px;
padding-bottom:24px;
margin-bottom:32px;
border-bottom:1px solid #f3f4f6;
}
.post-meta span{margin:0 8px;}
.post-content{
font-size:16px;
color:#374151;
line-height:1.8;
word-break:break-word;
white-space:pre-wrap;
}
.post-content p{margin-bottom:16px;}
.interact-bar{
display:flex;
justify-content:center;
gap:16px;
margin:40px 0;
padding:24px 0;
border-top:1px solid #f3f4f6;
border-bottom:1px solid #f3f4f6;
}
.interact-btn{
display:inline-flex;
align-items:center;
gap:8px;
padding:10px 24px;
border-radius:50px;
border:1px solid #e5e7eb;
background:#fff;
cursor:pointer;
color:#4b5563;
font-size:15px;
transition:all 0.2s ease;
}
.interact-btn:hover{
background:#f9fafb;
transform:translateY(-1px);
}
.interact-btn.active{
background:#2563eb;
color:#fff;
border-color:#2563eb;
}
.interact-btn.active:hover{background:#1d4ed8;}
.interact-btn:disabled{opacity:0.6;cursor:not-allowed;}
.btn-group{
text-align:center;
margin-bottom:20px;
}
.btn{
display:inline-block;
padding:9px 22px;
margin:0 6px;
text-decoration:none;
border-radius:8px;
font-size:14px;
transition:opacity 0.2s;
color:#fff;
border:none;
cursor:pointer;
}
.btn:hover{opacity:0.88;}
.btn-edit{background:#059669;}
.btn-del{background:#dc2626;}
.btn-back{background:#6b7280;}
.comment-card{
background:#fff;
border-radius:16px;
box-shadow:0 4px 20px rgba(0,0,0,0.06);
padding:36px 48px;
}
.comment-title{
font-size:20px;
font-weight:600;
color:#111827;
margin-bottom:24px;
padding-bottom:16px;
border-bottom:1px solid #f3f4f6;
}
.comment-list{margin-bottom:32px;}
.comment-item{
padding:20px 0;
border-bottom:1px solid #f9fafb;
transition:opacity 0.3s;
}
.comment-item:last-child{border-bottom:none;}
.comment-header{
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:10px;
}
.comment-user{
display:flex;
align-items:center;
gap:10px;
}
.comment-avatar{
width:36px;
height:36px;
border-radius:50%;
background:#e0e7ff;
color:#4338ca;
display:flex;
align-items:center;
justify-content:center;
font-size:14px;
font-weight:500;
}
.comment-name{
font-weight:500;
color:#111827;
font-size:15px;
}
.comment-time{
color:#9ca3af;
font-size:13px;
}
.comment-content{
color:#374151;
font-size:15px;
line-height:1.7;
margin-left:46px;
margin-bottom:12px;
}
.comment-footer{
display:flex;
justify-content:flex-end;
align-items:center;
gap:16px;
margin-left:46px;
}
.comment-like-btn{
display:inline-flex;
align-items:center;
gap:4px;
padding:4px 10px;
border-radius:4px;
font-size:13px;
color:#6b7280;
border:none;
background:none;
cursor:pointer;
transition:color 0.2s;
}
.comment-like-btn:hover{color:#2563eb;}
.comment-like-btn.active{color:#2563eb;}
.comment-del-btn{
font-size:13px;
color:#9ca3af;
text-decoration:none;
background:none;
border:none;
cursor:pointer;
}
.comment-del-btn:hover{color:#dc2626;}
.comment-empty{
text-align:center;
padding:40px 0;
color:#9ca3af;
font-size:14px;
}
.comment-form{
border-top:1px solid #f3f4f6;
padding-top:24px;
}
.comment-form textarea{
width:100%;
min-height:100px;
padding:12px 16px;
border:1px solid #e5e7eb;
border-radius:10px;
resize:vertical;
font-size:15px;
line-height:1.6;
margin-bottom:12px;
outline:none;
transition:border-color 0.2s;
}
.comment-form textarea:focus{border-color:#2563eb;}
.comment-form button{
padding:9px 24px;
background:#2563eb;
color:#fff;
border:none;
border-radius:8px;
font-size:15px;
cursor:pointer;
transition:background 0.2s;
}
.comment-form button:hover{background:#1d4ed8;}
.login-tip{
text-align:center;
padding:30px 0;
color:#6b7280;
font-size:14px;
}
.login-tip a{
color:#2563eb;
text-decoration:none;
font-weight:500;
}
.tip-toast{
position:fixed;
top:30px;
left:50%;
transform:translateX(-50%);
padding:10px 20px;
background:rgba(0,0,0,0.75);
color:#fff;
border-radius:6px;
font-size:14px;
z-index:9999;
opacity:0;
transition:opacity 0.3s;
pointer-events:none;
}
.tip-toast.show{opacity:1;}
@media (max-width: 768px){
body{padding:20px 12px;}
.post-card, .comment-card{padding:24px 20px;}
.post-title{font-size:22px;}
.interact-btn{padding:8px 18px;font-size:14px;}
}
</style>
</head>
<body>
<div class="container">
<article class="post-card">
<?php if(!empty($post->cover) && file_exists($post->cover)): ?>
<img class="post-cover" src="<?=htmlspecialchars($post->cover)?>" alt="<?=htmlspecialchars($post->title)?>">
<?php endif; ?>
<h1 class="post-title"><?=htmlspecialchars($post->title)?></h1>
<div class="post-meta">
<span>作者:<?=htmlspecialchars($post->authorName)?></span>
<span>·</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>
<div class="btn-group">
<?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; ?>
<a href="home.php" class="btn btn-back">返回列表</a>
</div>
</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 if(empty($comments)): ?>
<div class="comment-empty">暂无评论,快来抢沙发吧~</div>
<?php else: ?>
<?php foreach($comments as $c):
$commentLikeCount = CommentLike::getCount($c->id);
$isCommentLiked = false;
if (!empty($_SESSION['logged_in'])) {
$isCommentLiked = CommentLike::checkUserLike($c->id, $_SESSION['user_id']);
}
$canDelete = $isAuthor || (!empty($_SESSION['logged_in']) && $_SESSION['user_id'] == $c->userId);
$firstChar = mb_substr($c->authorName, 0, 1);
?>
<div class="comment-item js-comment-item" data-id="<?=$c->id?>">
<div class="comment-header">
<div class="comment-user">
<div class="comment-avatar"><?=htmlspecialchars($firstChar)?></div>
<span class="comment-name"><?=htmlspecialchars($c->authorName)?></span>
</div>
<span class="comment-time"><?=$c->createdAt?></span>
</div>
<div class="comment-content"><?=nl2br(htmlspecialchars($c->content))?></div>
<div class="comment-footer">
<?php if(!empty($_SESSION['logged_in'])): ?>
<button class="comment-like-btn js-comment-like <?=$isCommentLiked ? 'active' : ''?>" data-id="<?=$c->id?>">
👍 <span class="comment-like-count"><?=$commentLikeCount?></span>
</button>
<?php else: ?>
<span class="comment-like-btn">👍 <?=$commentLikeCount?></span>
<?php endif; ?>
<?php if($canDelete): ?>
<button class="comment-del-btn js-delete-comment" data-id="<?=$c->id?>">删除</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php if(!empty($_SESSION['logged_in'])): ?>
<div class="comment-form">
<form method="post" action="add_comment.php">
<input type="hidden" name="post_id" value="<?=$post->id?>">
<input type="hidden" name="csrf_token" value="<?=htmlspecialchars($csrfToken)?>">
<textarea name="content" placeholder="写下你的评论..." required></textarea>
<div style="text-align:right;">
<button type="submit">发表评论</button>
</div>
</form>
</div>
<?php else: ?>
<p class="login-tip">
<a href="login.php">登录</a> 后即可发表评论、点赞和收藏文章
</p>
<?php endif; ?>
</div>
</div>
<div class="tip-toast js-toast"></div>
<script>
const csrfToken = '<?=htmlspecialchars($csrfToken)?>';
const isLoggedIn = <?= !empty($_SESSION['logged_in']) ? 'true' : 'false' ?>;
const isPostAuthor = <?= $isAuthor ? 'true' : 'false' ?>;
// 提示框
function showToast(msg) {
const toast = document.querySelector('.js-toast');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}
// 通用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());
}
// 生成单条评论HTML
function buildCommentHtml(comment) {
const firstChar = comment.author_name.charAt(0);
const deleteBtn = comment.can_delete || isPostAuthor
? `<button class="comment-del-btn js-delete-comment" data-id="${comment.id}">删除</button>`
: '';
const likeBtn = isLoggedIn
? `<button class="comment-like-btn js-comment-like" data-id="${comment.id}">
👍 <span class="comment-like-count">${comment.like_count}</span>
</button>`
: `<span class="comment-like-btn">👍 ${comment.like_count}</span>`;
return `
<div class="comment-item js-comment-item" data-id="${comment.id}" style="opacity:0;transition:opacity 0.3s;">
<div class="comment-header">
<div class="comment-user">
<div class="comment-avatar">${firstChar}</div>
<span class="comment-name">${comment.author_name}</span>
</div>
<span class="comment-time">${comment.created_at}</span>
</div>
<div class="comment-content">${comment.content.replace(/\n/g, '<br>')}</div>
<div class="comment-footer">
${likeBtn}
${deleteBtn}
</div>
</div>`;
}
// ========== 文章点赞 ==========
document.querySelector('.js-post-like').addEventListener('click', function() {
if (!isLoggedIn) { showToast('请先登录'); return; }
const btn = this;
const postId = btn.dataset.id;
btn.disabled = true;
ajaxPost('toggle_like.php', {
post_id: postId,
csrf_token: csrfToken
}).then(res => {
btn.disabled = false;
if (res.code === 1) {
btn.classList.toggle('active', res.is_liked);
btn.querySelector('.like-count').textContent = res.count;
showToast(res.is_liked ? '点赞成功' : '已取消点赞');
} else {
showToast(res.msg || '操作失败');
}
}).catch(() => {
btn.disabled = false;
showToast('网络错误');
});
});
// ========== 文章收藏 ==========
document.querySelector('.js-post-favorite').addEventListener('click', function() {
if (!isLoggedIn) { showToast('请先登录'); return; }
const btn = this;
const postId = btn.dataset.id;
btn.disabled = true;
ajaxPost('toggle_favorite.php', {
post_id: postId,
csrf_token: csrfToken
}).then(res => {
btn.disabled = false;
if (res.code === 1) {
btn.classList.toggle('active', res.is_favorited);
btn.querySelector('.favorite-count').textContent = res.count;
showToast(res.is_favorited ? '收藏成功' : '已取消收藏');
} else {
showToast(res.msg || '操作失败');
}
}).catch(() => {
btn.disabled = false;
showToast('网络错误');
});
});
// ========== 评论点赞(事件委托,兼容动态新增) ==========
document.querySelector('.js-comment-list').addEventListener('click', function(e) {
const btn = e.target.closest('.js-comment-like');
if (!btn) return;
if (!isLoggedIn) { showToast('请先登录'); return; }
const commentId = btn.dataset.id;
btn.disabled = true;
ajaxPost('toggle_comment_like.php', {
comment_id: commentId,
csrf_token: csrfToken
}).then(res => {
btn.disabled = false;
if (res.code === 1) {
btn.classList.toggle('active', res.is_liked);
btn.querySelector('.comment-like-count').textContent = res.count;
} else {
showToast(res.msg || '操作失败');
}
}).catch(() => {
btn.disabled = false;
showToast('网络错误');
});
});
// ========== 删除评论(事件委托,兼容动态新增) ==========
document.querySelector('.js-comment-list').addEventListener('click', function(e) {
const btn = e.target.closest('.js-delete-comment');
if (!btn) return;
const commentId = btn.dataset.id;
if (!confirm('确定删除这条评论?')) return;
ajaxPost('delete_comment.php', {
id: commentId,
csrf_token: csrfToken
}).then(res => {
if (res.code === 1) {
const item = document.querySelector(`.js-comment-item[data-id="${commentId}"]`);
if (item) {
item.style.opacity = '0';
setTimeout(() => {
item.remove();
updateCommentTotal();
}, 300);
}
showToast('删除成功');
} else {
showToast(res.msg || '删除失败');
}
}).catch(() => showToast('网络错误'));
});
// ========== 发表评论(无刷新) ==========
const commentForm = document.querySelector('.comment-form form');
if (commentForm) {
commentForm.addEventListener('submit', function(e) {
e.preventDefault();
const textarea = this.querySelector('textarea[name="content"]');
const submitBtn = this.querySelector('button[type="submit"]');
const content = textarea.value.trim();
if (!content) {
showToast('评论内容不能为空');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = '发布中...';
ajaxPost('add_comment.php', {
post_id: <?= $post->id ?>,
content: content,
csrf_token: csrfToken
}).then(res => {
submitBtn.disabled = false;
submitBtn.textContent = '发表评论';
if (res.code === 1) {
const list = document.querySelector('.js-comment-list');
// 清空空状态
if (list.querySelector('.comment-empty')) {
list.innerHTML = '';
}
// 追加新评论
list.insertAdjacentHTML('beforeend', buildCommentHtml(res.data));
// 淡入动画
setTimeout(() => {
const newItem = list.lastElementChild;
if (newItem) newItem.style.opacity = '1';
}, 10);
// 清空输入框
textarea.value = '';
// 更新总数
updateCommentTotal();
// 滚动到新评论
list.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'center' });
showToast('评论发表成功');
} else {
showToast(res.msg || '评论失败');
}
}).catch(() => {
submitBtn.disabled = false;
submitBtn.textContent = '发表评论';
showToast('网络错误');
});
});
}
// 更新评论总数
function updateCommentTotal() {
const total = document.querySelectorAll('.js-comment-item').length;
document.querySelector('.comment-total').textContent = total;
if (total === 0) {
document.querySelector('.js-comment-list').innerHTML = '<div class="comment-empty">暂无评论,快来抢沙发吧~</div>';
}
}
</script>
</body>
</html>
6.4 编辑文章 edit_post.php
php
<?php
spl_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();
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
use Blog\Models\Post;
$pid = (int)($_GET['id'] ?? 0);
$post = Post::find($pid);
if(!$post || $post->userId != $_SESSION['user_id']){
header("Location: home.php", true, 302);
exit;
}
if (empty($_SESSION['csrf_edit'])) {
$_SESSION['csrf_edit'] = bin2hex(random_bytes(32));
}
$errors = [];
$uploadDir = __DIR__ . '/uploads/post/';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($_SESSION['csrf_edit'], $_POST['csrf_token'] ?? '')) {
$errors[] = "非法请求";
}
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$newCover = $post->cover;
if (empty($title)) $errors[] = "标题不能为空";
if (empty($content)) $errors[] = "正文不能为空";
// 处理新封面上传
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);
$newCover = "uploads/post/".$name;
} else {
$errors[] = "封面仅支持jpg/png,不超过2MB";
}
}
if (empty($errors)) {
$post->title = $title;
$post->cover = $newCover;
$post->content = $content;
$post->save();
header("Location: view_post.php?id=$pid", true, 302);
exit;
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>编辑文章</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f5f7fa;padding:60px 20px;}
.card{width:720px;margin:0 auto;background:#fff;padding:32px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.08);}
h2{text-align:center;margin-bottom:24px;color:#2d3748;}
.err-list{background:#fee;color:#dc2626;padding:10px 16px;border-radius:6px;margin-bottom:16px;}
.item{margin-bottom:18px;}
label{display:block;margin-bottom:6px;color:#4a5568;font-weight:500;}
input[type="text"]{width:100%;padding:10px 12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;}
textarea{width:100%;padding:12px;border:1px solid #cbd5e0;border-radius:6px;font-size:15px;resize:vertical;min-height:300px;line-height:1.6;}
.cover-preview{max-width:200px;margin:8px 0;border-radius:6px;}
.tip{font-size:12px;color:#999;margin-top:4px;}
button{width:100%;padding:12px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
.back{display:block;text-align:center;margin-top:16px;color:#666;text-decoration:none;}
</style>
</head>
<body>
<div class="card">
<h2>编辑文章</h2>
<?php if(!empty($errors)): ?>
<div class="err-list">
<?php foreach($errors as $e): ?>
<li style="list-style-position:inside;"><?=htmlspecialchars($e)?></li>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?=htmlspecialchars($_SESSION['csrf_edit'])?>">
<div class="item">
<label>文章标题</label>
<input type="text" name="title" value="<?=htmlspecialchars($post->title)?>">
</div>
<div class="item">
<label>文章封面</label>
<?php if(!empty($post->cover) && file_exists($post->cover)): ?>
<img class="cover-preview" src="<?=htmlspecialchars($post->cover)?>" alt="当前封面">
<?php endif; ?>
<input type="file" name="cover" accept="image/jpeg,image/png">
<p class="tip">不上传则保留原封面</p>
</div>
<div class="item">
<label>文章正文</label>
<textarea name="content"><?=htmlspecialchars($post->content)?></textarea>
</div>
<button type="submit">保存修改</button>
</form>
<a class="back" href="view_post.php?id=<?=$pid?>">返回文章详情</a>
</div>
</body>
</html>
6.5 删除文章确认页 delete_post.php
php
<?php
spl_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();
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
use Blog\Models\Post;
$pid = (int)($_GET['id'] ?? 0);
$post = Post::find($pid);
if(!$post || $post->userId != $_SESSION['user_id']){
header("Location: home.php", true, 302);
exit;
}
if (empty($_SESSION['csrf_del'])) {
$_SESSION['csrf_del'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (hash_equals($_SESSION['csrf_del'], $_POST['csrf_token'] ?? '')) {
$post->delete();
}
header("Location: home.php", true, 302);
exit;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>删除文章</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f5f7fa;padding:100px 20px;}
.card{width:420px;margin:0 auto;background:#fff;padding:36px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.08);text-align:center;}
h2{color:#dc2626;margin-bottom:16px;}
p{color:#4a5568;margin-bottom:24px;line-height:1.6;}
.btn-group{display:flex;gap:12px;justify-content:center;}
.btn{padding:10px 24px;border-radius:6px;text-decoration:none;font-size:15px;border:none;cursor:pointer;}
.btn.cancel{background:#e2e8f0;color:#2d3748;}
.btn.confirm{background:#dc2626;color:#fff;}
</style>
</head>
<body>
<div class="card">
<h2>确认删除文章?</h2>
<p>文章标题:<strong><?=htmlspecialchars($post->title)?></strong><br>删除后无法恢复,评论、点赞、收藏数据也会一并清除</p>
<div class="btn-group">
<a href="view_post.php?id=<?=$pid?>" class="btn cancel">取消</a>
<form method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="<?=htmlspecialchars($_SESSION['csrf_del'])?>">
<button type="submit" class="btn confirm">确认删除</button>
</form>
</div>
</div>
</body>
</html>
七、交互与通知体系
7.1 评论提交 add_comment.php
php
<?php
spl_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();
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';
$postId = 0;
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) {
// AJAX模式:返回完整评论数据供前端渲染
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;
}
}
header("Location: home.php", true, 302);
exit;
7.2 删除评论delete_comment.php
php
<?php
spl_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();
use Blog\Models\Comment;
use Blog\Models\Post;
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';
$commentId = (int)($_REQUEST['id'] ?? 0);
$comment = Comment::findById($commentId);
if (!$comment) {
echo json_encode(['code' => 0, 'msg' => '评论不存在']);
exit;
}
$post = Post::find($comment->postId);
$isCommentAuthor = $_SESSION['user_id'] == $comment->userId;
$isPostAuthor = $_SESSION['user_id'] == $post->userId;
if (!$isCommentAuthor && !$isPostAuthor) {
echo json_encode(['code' => 0, 'msg' => '无权删除']);
exit;
}
$comment->delete();
if ($isAjax) {
echo json_encode(['code' => 1, 'msg' => '删除成功']);
exit;
} else {
header("Location: view_post.php?id=".$comment->postId, true, 302);
exit;
}
7.3 点赞切换 toggle_like.php
点赞成功后自动给文章作者发送站内通知。
php
<?php
spl_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();
use Blog\Models\Like;
use Blog\Models\Post;
use Blog\Models\Notification;
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);
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_interact'] ?? '', $token)) {
echo json_encode(['code' => 0, 'msg' => '非法请求']);
exit;
}
if ($postId <= 0) {
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);
}
}
if ($isAjax) {
echo json_encode([
'code' => 1,
'is_liked' => $isLikedAfter,
'count' => $likeCount
]);
exit;
} else {
header("Location: view_post.php?id=$postId", true, 302);
exit;
}
}
7.4 评论点赞切换 toggle_comment_like.php
php
<?php
spl_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();
use Blog\Models\CommentLike;
use Blog\Models\Comment;
use Blog\Models\Notification;
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') {
$commentId = (int)($_POST['comment_id'] ?? 0);
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_interact'] ?? '', $token)) {
echo json_encode(['code' => 0, 'msg' => '非法请求']);
exit;
}
if ($commentId <= 0) {
echo json_encode(['code' => 0, 'msg' => '参数错误']);
exit;
}
$isLikedAfter = CommentLike::toggle($commentId, $_SESSION['user_id']);
$likeCount = CommentLike::getCount($commentId);
$comment = Comment::findById($commentId);
$postId = $comment ? $comment->postId : 0;
// 点赞成功时发送通知
if ($isLikedAfter && $comment) {
$shortContent = mb_substr($comment->content, 0, 15);
if (mb_strlen($comment->content) > 15) $shortContent .= '...';
$content = "赞了你的评论:「".$shortContent."」";
Notification::sendCommentLike($comment->userId, $_SESSION['user_id'], $postId, $content);
}
if ($isAjax) {
echo json_encode([
'code' => 1,
'is_liked' => $isLikedAfter,
'count' => $likeCount
]);
exit;
} else {
header("Location: view_post.php?id=$postId", true, 302);
exit;
}
}
7.5 收藏切换 toggle_favorite.php
php
<?php
spl_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();
use Blog\Models\Favorite;
use Blog\Models\Post;
use Blog\Models\Notification;
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);
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_interact'] ?? '', $token)) {
echo json_encode(['code' => 0, 'msg' => '非法请求']);
exit;
}
if ($postId <= 0) {
echo json_encode(['code' => 0, 'msg' => '参数错误']);
exit;
}
$isFavoritedAfter = Favorite::toggle($postId, $_SESSION['user_id']);
$favoriteCount = Favorite::getCount($postId);
// 收藏成功时发送通知
if ($isFavoritedAfter) {
$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, 'favorite', $_SESSION['user_id'], $postId, $content);
}
}
if ($isAjax) {
echo json_encode([
'code' => 1,
'is_favorited' => $isFavoritedAfter,
'count' => $favoriteCount
]);
exit;
} else {
header("Location: view_post.php?id=$postId", true, 302);
exit;
}
}
7.6 我的收藏列表 my_favorites.php
php
<?php
spl_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_once $file;
}
});
session_start();
use Blog\Models\Favorite;
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
$favoriteList = Favorite::getUserFavorites($_SESSION['user_id']);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我的收藏</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f5f7fa;padding:40px 20px;}
.wrap{max-width:720px;margin:0 auto;}
.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;}
h1{color:#2d3748;font-size:22px;}
.btn{padding:8px 16px;background:#666;color:#fff;text-decoration:none;border-radius:6px;font-size:14px;}
.item{background:#fff;padding:16px;border-radius:10px;box-shadow:0 1px 8px rgba(0,0,0,0.06);margin-bottom:16px;}
.title{font-size:17px;margin-bottom:8px;}
.title a{color:#2563eb;text-decoration:none;}
.meta{color:#666;font-size:14px;}
.empty{text-align:center;padding:40px;color:#888;background:#fff;border-radius:10px;}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<h1>我的收藏</h1>
<a href="welcome.php" class="btn">返回个人中心</a>
</div>
<?php if(empty($favoriteList)): ?>
<div class="empty">你还没有收藏任何文章,快去发现好文吧~</div>
<?php else: ?>
<?php foreach($favoriteList as $p): ?>
<div class="item">
<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>
<?php endforeach; ?>
<?php endif; ?>
</div>
</body>
</html>
7.7 我的通知列表 notifications.php
php
<?php
spl_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_once $file;
}
});
session_start();
use Blog\Models\Notification;
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
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']);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我的通知</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f5f7fa;padding:40px 20px;}
.wrap{max-width:640px;margin:0 auto;}
.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;}
h1{color:#2d3748;font-size:22px;}
.btn{padding:6px 14px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;font-size:14px;}
.btn.gray{background:#666;}
.btn.red{background:#dc2626;}
.item{background:#fff;padding:14px 20px;border-radius:8px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;}
.item.unread{border-left:4px solid #2563eb;}
.item-content{color:#333;}
.item-content strong{color:#2563eb;}
.item-time{color:#999;font-size:13px;flex-shrink:0;margin-left:20px;}
.empty{text-align:center;padding:40px;color:#888;background:#fff;border-radius:10px;}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<h1>我的通知</h1>
<div>
<a href="?action=readall" class="btn">全部标记已读</a>
<a href="?action=clearall" class="btn red" style="margin-left:8px;" onclick="return confirm('确定清空所有通知?')">一键清空</a>
<a href="welcome.php" class="btn gray" style="margin-left:8px;">返回</a>
</div>
</div>
<?php if(empty($list)): ?>
<div class="empty">暂无通知</div>
<?php else: ?>
<?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; ?>
<?php endif; ?>
</div>
</body>
</html>
八、辅助功能
8.1 退出登录 logout.php
php
<?php
session_start();
$_SESSION = [];
session_destroy();
header("Location: home.php", true, 302);
exit;
8.2 注销账号 delete_user.php
php
<?php
spl_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_once $file;
}
});
session_start();
if (empty($_SESSION['logged_in'])) {
header("Location: login.php", true, 302);
exit;
}
use Blog\Models\User;
$currentUser = User::findById($_SESSION['user_id']);
if (empty($_SESSION['csrf_deluser'])) {
$_SESSION['csrf_deluser'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (hash_equals($_SESSION['csrf_deluser'], $_POST['csrf_token'] ?? '')) {
$currentUser->delete();
$_SESSION = [];
session_destroy();
header("Location: home.php", true, 302);
exit;
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>注销账号</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:Microsoft YaHei;}
body{background:#f5f7fa;padding:100px 20px;}
.card{width:420px;margin:0 auto;background:#fff;padding:36px;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,0.08);text-align:center;}
h2{color:#dc2626;margin-bottom:16px;}
p{color:#4a5568;margin-bottom:24px;line-height:1.6;}
.btn-group{display:flex;gap:12px;justify-content:center;}
.btn{padding:10px 24px;border-radius:6px;text-decoration:none;font-size:15px;border:none;cursor:pointer;}
.btn.cancel{background:#e2e8f0;color:#2d3748;}
.btn.confirm{background:#dc2626;color:#fff;}
</style>
</head>
<body>
<div class="card">
<h2>确认永久注销账号?</h2>
<p>注销后你的所有文章、评论、点赞、收藏数据都将被永久删除,无法恢复,请谨慎操作</p>
<div class="btn-group">
<a href="welcome.php" class="btn cancel">取消</a>
<form method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="<?=htmlspecialchars($_SESSION['csrf_deluser'])?>">
<button type="submit" class="btn confirm">确认注销</button>
</form>
</div>
</div>
</body>
</html>
九、上传Github
9.1 准备操作
新建配置文件config.php
php
<?php
/**
* 数据库配置示例
* 填写真实数据库信息
*/
return [
'host' => '127.0.0.1',
'dbname' => 'blog',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4'
];
修改 Core/Database.php 引入配置文件
php
<?php
namespace Blog\Core;
class Database
{
private static ?self $instance = null;
private \PDO $pdo;
private function __construct()
{
// 从根目录配置文件读取
$config = require __DIR__ . '/../config.php';
$dsn = "mysql:host={$config['host']};dbname={$config['dbname']};charset={$config['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, $config['username'], $config['password'], $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() {}
}
编写 .gitignore 过滤无用文件
php
# 敏感配置
config.php
# 用户上传的图片(仅保留空目录结构,不上传真实图片)
uploads/avatar/*
uploads/post/*
!uploads/**/.gitkeep
# IDE/编辑器临时文件
.vscode/
.idea/
*.swp
*.swo
*~
# 系统临时文件
.DS_Store
Thumbs.db
# 日志缓存
*.log
tmp/
cache/
# 本地调试文件
phpinfo.php
test_*.php
保留空目录结构:Git 默认不会提交空目录,而 uploads/avatar、uploads/post 是必须的目录。在每个空目录下新建一个名为 .gitkeep 的空文件,即可保留目录结构。
php
uploads/
├── avatar/
│ └── .gitkeep
└── post/
└── .gitkeep
导出数据库安装脚本:把完整的建表 SQL 导出为 install.sql 放在项目根目录,方便其他人部署时直接导入。
php
blog/
└── install.sql # 包含所有表结构的SQL语句
9.2 创建 GitHub 仓库并上传代码
新建 GitHub 仓库
登录 GitHub,点击右上角 + → New repository
填写信息:
Repository name :比如
php-blog-systemDescription:可选,比如「PHP 原生开发的完整博客系统,支持用户、文章、评论、点赞、通知」
Public/Private:公开选 Public,不想别人看到选 Private
点击 Create repository 创建完成

本地初始化 Git 并推送代码
打开终端(Windows 用 Git Bash / CMD,Mac/Linux 用终端),进入项目根目录,依次执行以下命令:
bash
# 1. 初始化Git仓库(只执行一次)
git init
# 2. 添加所有文件到暂存区(.gitignore里的文件会自动忽略)
git add .
# 3. 提交到本地仓库,写清楚提交说明
git commit -m "初始提交:完整PHP博客系统,支持用户体系、文章CRUD、评论点赞、站内通知"
# 4. 关联远程GitHub仓库(替换成你自己的仓库地址)
git remote add origin https://github.com/TYS656/php-blog-system.git
# 5. 重命名当前分支为 main
git branch -M main
# 6. 推送到GitHub主分支
git push -u origin main

完善项目文档(README.md)
在项目根目录新建 README.md,这是项目的门面,方便别人理解和使用你的项目。
bash
# PHP 原生博客系统
基于 PHP 原生 + PDO + MySQL 开发的轻量化博客系统,全程面向对象设计,内置完整的用户体系、内容管理、交互通知功能。
## 功能特性
### 用户体系
- 用户注册/登录/退出、账号注销
- 图形验证码防暴力破解
- 个人资料修改、头像上传
- 密码修改、忘记密码重置(令牌机制)
### 内容体系
- 文章发布/编辑/删除
- 文章封面图上传
- 文章列表分页
- 文章详情页展示
### 交互体系
- 文章评论、评论删除
- 文章点赞/收藏(切换式)
- 评论点赞
- 站内通知系统(点赞通知、收藏通知、评论点赞通知)
- 通知一键清空、全部标记已读
### 安全特性
- PDO 预处理防SQL注入
- 全局 XSS 转义防护
- CSRF 令牌校验
- 文件上传类型校验
- 权限边界控制
## 环境要求
- PHP >= 7.4
- MySQL >= 5.7
- 开启 GD、PDO_MYSQL 扩展
- Apache / Nginx Web服务
## 部署步骤
1. 克隆项目到网站根目录
2. 复制`config.php`,填写数据库连接信息
3. 新建数据库,导入 `install.sql` 建表
4. 给 `uploads/` 目录赋予写入权限
5. 访问 `home.php` 即可使用
## 目录结构
blog/
├── Core/ # 核心类库
│ └── Database.php # 数据库单例类
├── Models/ # 数据模型层
│ ├── User.php
│ ├── Post.php
│ ├── Comment.php
│ ├── Like.php
│ ├── Favorite.php
│ ├── CommentLike.php
│ └── Notification.php
├── uploads/ # 上传文件目录
│ ├── avatar/
│ └── post/
├── config.php # 配置示例
├── install.sql # 数据库安装脚本
├── 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 # 退出登录
## 开源协议
MIT License
bash
git add README.md
git commit -m "完善README项目文档"
git push
十、安全规范与部署上线
10.1 全链路安全说明
| 安全维度 | 实现方式 |
|---|---|
| SQL注入防护 | 全部数据库操作使用 PDO 预处理语句,参数化查询 |
| XSS 跨站脚本 | 所有用户输入输出到 HTML 时,统一使用 htmlspecialchars 转义 |
| CSRF 跨站伪造 | 所有表单提交携带随机令牌,后端校验一致性 |
| 密码安全 | 使用 password_hash 加盐哈希存储,永不存明文 |
| 文件上传安全 | 使用 finfo 校验真实文件类型,统一重命名,禁止执行权限 |
| 权限控制 | 所有用户操作校验登录态,文章操作校验作者身份 |
| 防暴力破解 | 登录页增加图形验证码,单次有效 |
| 令牌安全 | 密码重置令牌30分钟过期,使用后立即销毁 |
10.2 上线部署步骤
环境:服务器Ubuntu22.04
更新系统软件源
bash
sudo apt update && sudo apt upgrade -y
安装 Web 服务 + PHP 运行环境
bash
# 安装 Nginx
sudo apt install nginx -y
# 安装 PHP 及核心扩展(以PHP8.1为例,Ubuntu22.04默认)
sudo apt install php8.1-fpm php8.1-mysql php8.1-gd php8.1-mbstring php8.1-xml php8.1-curl -y
# 验证安装成功
php -v
php -m | grep -E "pdo_mysql|gd|mbstring"

安装 MySQL(MariaDB)并初始化
bash
# 安装 MariaDB 服务
sudo apt install mariadb-server -y
# 执行安全初始化向导(按提示操作,建议设置root强密码,删除匿名用户,禁用远程root登录)
sudo mysql_secure_installation

安装 Git
bash
sudo apt install git -y

克隆代码到网站根目录
bash
# 进入站点目录
cd /var/www/
# 克隆GitHub仓库(替换为你自己的仓库地址)
# 公开仓库直接用HTTPS地址
sudo git clone https://github.com/TYS656/php-blog-system.git blog
# 查看拉取的文件
ls -la /var/www/blog/

创建数据库与专用用户
bash
# 登录MySQL
sudo mysql -u root -p

bash
-- 创建数据库
CREATE DATABASE blog DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 创建专用数据库用户
CREATE USER 'blog_user'@'localhost' IDENTIFIED BY '设置一个强密码';
-- 授予该库的全部权限
GRANT ALL PRIVILEGES ON blog.* TO 'blog_user'@'localhost';
FLUSH PRIVILEGES;
exit;

导入项目建表 SQL
bash
# 进入项目目录
cd /var/www/blog/
# 导入install.sql(输入刚才设置的数据库用户密码)
mysql -u blog_user -p blog < install.sql
修改项目配置文件
bash
# 复制配置模板为正式配置文件
cd /var/www/blog/
sudo cp config.example.php config.php
# 编辑配置文件,填入刚才创建的数据库信息
sudo vim config.php
bash
return [
'host' => '127.0.0.1',
'dbname' => 'blog',
'username' => 'blog_user',
'password' => '你刚才设置的数据库密码',
'charset' => 'utf8mb4'
];

设置目录读写权限
bash
# 把项目目录所有者设为 www-data
sudo chown -R www-data:www-data /var/www/blog/
# 给上传目录设置读写权限
sudo chmod -R 755 /var/www/blog/uploads/
sudo chmod -R 777 /var/www/blog/uploads/avatar/
sudo chmod -R 777 /var/www/blog/uploads/post/
# 配置文件设置为仅所有者可读写
sudo chmod 600 /var/www/blog/config.php
创建站点配置文件
bash
cat > /etc/nginx/sites-available/blog << 'EOF'
server {
listen 80;
server_name 你的服务器域名或IP;
root /var/www/blog;
index home.php index.html;
# 隐藏.php后缀的URL重写规则
location / {
try_files $uri $uri/ @rewrite;
}
location @rewrite {
rewrite ^/([a-zA-Z0-9_-]+)$ /$1.php last;
rewrite ^/article/([0-9]+)$ /view_post.php?id=$1 last;
}
# PHP-FPM解析
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# 注意:PHP版本不同,sock路径不一样,对应自己的版本
fastcgi_pass unix:/run/php/php8.1-fpm.sock;
}
# 禁止访问敏感文件
location ~ /\.ht {
deny all;
}
location ~* /config\.php$ {
deny all;
}
location ~* /\.git {
deny all;
}
}
EOF
启用站点并测试
bash
# 软链接到启用目录
sudo ln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/
# 删除默认站点(可选,避免冲突)
sudo rm /etc/nginx/sites-enabled/default
# 测试Nginx配置是否正确
sudo nginx -t
# 重载Nginx生效
sudo systemctl reload nginx
PHP.ini 安全配置
bash
# PHP8.1-FPM 配置文件路径
sudo vim /etc/php/8.1/fpm/php.ini
bash
; 关闭页面错误显示(防止泄露敏感信息)
display_errors = Off
; 开启错误日志
log_errors = On
error_log = /var/log/php/error.log
; Session安全配置
session.cookie_httponly = On
session.cookie_samesite = "Lax"
; 配置HTTPS后再开启下面这行
; session.cookie_secure = On
; 禁用危险函数
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
; 上传大小限制(根据需求调整)
upload_max_filesize = 2M
post_max_size = 8M
bash
# 修改完重启 PHP-FPM:
sudo systemctl restart php8.1-fpm
十一、验证上线和功能测试
浏览器输入服务器 IP / 域名,访问

11.1 用户注册
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 正常注册 | 输入未使用的用户名、6 位以上密码、正确邮箱,提交注册 | 注册成功,自动登录,跳转到个人中心,数据库新增用户记录,密码为哈希值 |
| 重复用户名注册 | 使用已存在的用户名再次注册 | 页面提示「用户名或邮箱已被注册」,不新增数据 |
| 邮箱格式错误 | 输入非法格式的邮箱 | 实时 / 提交后提示「邮箱格式错误」 |
| 密码过短 | 输入少于 6 位的密码 | 提示「密码最少 6 位」 |
| 空表单提交 | 所有输入框留空直接提交 | 对应字段给出不能为空的提示 |
| 头像上传注册 | 上传 jpg/png 头像后提交 | 注册成功,个人中心显示新头像,文件存入 uploads/avatar/ 目录 |







11.2 图形验证码
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 验证码正常显示 | 打开登录页,查看验证码图片 | 清晰显示 4 位字符,无裂图、无乱码 |
| 验证码刷新 | 点击验证码图片 | 图片自动刷新,字符变化,无缓存 |
| 验证码错误登录 | 输入错误的验证码提交 | 提示「验证码错误」,登录失败 |
| 验证码正确登录 | 输入与图片一致的验证码提交 | 验证码校验通过,进入账号密码校验 |
| 验证码一次性有效 | 1. 输错一次验证码 2. 不刷新图片,用原验证码再次提交 | 第二次依然提示验证码错误(使用后立即销毁) |


11.3 用户登录
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 正常登录 | 输入正确的用户名 + 密码 + 验证码 | 登录成功,跳转到个人中心,Session 生效 |
| 密码错误登录 | 用户名正确、密码错误 | 提示「用户名或密码错误」,不泄露具体是哪个错误 |
| 用户名不存在 | 输入未注册的用户名 | 提示「用户名或密码错误」,防止用户名枚举 |
| 未登录访问受限页 | 未登录状态直接访问 welcome.php、create_post.php |
自动跳转到登录页,无法进入 |




11.4 密码相关功能
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 修改密码 - 原密码正确 | 个人中心进入修改密码,输入正确原密码 + 新密码 | 修改成功,自动退出登录,用新密码可正常登录,旧密码失效 |
| 修改密码 - 原密码错误 | 输入错误的原密码 | 提示「原密码错误」,密码不更新 |
| 修改密码 - 两次新密码不一致 | 两次输入的新密码不同 | 提示「两次新密码不一致」 |
| 忘记密码 - 生成重置链接 | 登录页点忘记密码,输入注册邮箱 | 生成有效重置链接,数据库 password_resets 表新增记录 |
| 重置密码 - 正常重置 | 点击重置链接,设置新密码 | 重置成功,可用新密码登录,原令牌失效 |
| 重置链接过期 / 无效 | 1. 使用已用过的令牌 2. 修改 URL 里的令牌为随机字符 | 提示「重置链接无效或已过期」,无法重置 |









11.5 个人资料与账号管理
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 修改邮箱 | 个人资料页修改邮箱,提交 | 保存成功,数据库邮箱更新,个人中心显示新邮箱 |
| 更换头像 | 上传新头像,提交 | 保存成功,页面显示新头像,Session 中头像同步更新 |
| 不上传头像修改资料 | 只改邮箱,不选新头像 | 保存成功,原头像保留不变 |
| 退出登录 | 点击「退出登录」 | Session 销毁,跳转到首页,无法再访问个人中心 |
| 账号注销 | 点击注销账号,确认删除 | 账号及所有文章、评论、点赞数据全部删除,无法再登录 |





11.6 发布文章
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 正常发布(带封面) | 填写标题、正文,上传 jpg/png 封面图,提交 | 发布成功,跳转到文章详情,封面正常显示,文件存入 uploads/post/ |
| 正常发布(无封面) | 只填标题正文,不上传封面 | 发布成功,详情页无封面图,列表页显示默认占位 |
| 空标题 / 空正文提交 | 标题或正文留空 | 提示对应字段不能为空,发布失败 |
| 超长标题 | 输入超过 200 字的标题 | 提示标题长度超限,或自动截断(按代码逻辑) |
| 非法封面格式 | 上传 php 文件、txt 文件作为封面 | 提示「仅支持 jpg/png」,上传失败,文件不会存入服务器 |










11.7 文章浏览与管理
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 文章列表分页 | 发布超过 5 篇文章,访问首页 | 分页按钮正常,点击上一页 / 下一页可切换,每页显示 5 篇 |
| 文章详情访问 | 点击列表文章标题进入详情 | 标题、正文、作者、发布时间、封面均正确显示 |
| 编辑自己的文章 | 进入自己发布的文章,点编辑,修改内容 | 保存成功,详情页内容更新 |
| 越权编辑他人文章 | 登录账号 B,修改账号 A 的文章 URL 参数访问 edit_post.php?id=A的文章ID |
无权限,自动跳转或提示错误,文章内容不会被修改 |
| 删除自己的文章 | 点击删除文章并确认 | 文章从列表消失,关联的评论、点赞、收藏数据同步删除 |
| 访问不存在的文章 | 手动修改 URL 为不存在的 ID | 自动跳回文章列表,不报错 |






11.8 交互功能测试
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 文章点赞 | 点击文章详情的「点赞」按钮 | 1. 页面无刷新 2. 按钮变高亮,数字 + 1 3. 再次点击取消点赞,数字 - 1,按钮恢复原状 |
| 文章收藏 | 点击「收藏」按钮 | 1. 页面无刷新 2. 按钮变高亮,数字 + 1 3. 再次点击取消收藏,数字 - 1 4. 「我的收藏」列表同步增减 |
| 发表评论 | 输入评论内容,点击发表 | 1. 页面无刷新,输入框自动清空 2. 评论自动追加到列表末尾,带淡入效果 3. 评论总数 + 1 4. 数据库新增评论记录 |
| 空评论提交 | 输入框留空直接发表 | 提示「评论内容不能为空」,不提交 |
| 删除自己的评论 | 点击自己评论的「删除」按钮并确认 | 1. 页面无刷新,评论淡出消失 2. 评论总数 - 1 3. 数据库对应记录删除 |
| 文章作者删评论 | 登录文章作者账号,删除他人评论 | 可正常删除,权限校验通过 |
| 越权删评论 | 登录普通账号,删除他人评论 | 无删除按钮 |
| 评论点赞 | 点击评论下方的点赞按钮 | 1. 页面无刷新 2. 数字 + 1 3. 再次点击取消,数字 - 1 |








11.9 通知系统功能测试
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| 文章点赞通知 | 1. 账号 A 发布一篇文章 2. 登录账号 B 给这篇文章点赞 | 1. 账号 A 的通知列表新增一条点赞通知 2. 通知内容显示点赞用户和文章标题 3. 个人中心通知图标出现未读红点 |
| 自己点赞自己文章 | 账号 A 给自己的文章点赞 | 不会产生通知,通知列表无新增 |
| 文章收藏通知 | 账号 B 收藏账号 A 的文章 | 账号 A 收到收藏通知,类型为收藏 |
| 评论点赞通知 | 1. 账号 A 在文章下发一条评论 2. 账号 B 给这条评论点赞 | 账号 A 收到评论点赞通知,内容显示评论摘要 |
| 自己点赞自己评论 | 账号 A 给自己的评论点赞 | 不会产生通知 |
| 未读数量显示 | 账号 A 有未读通知时,进入个人中心 | 「我的通知」按钮右上角显示红色数字角标,数量与未读条数一致 |
| 全部标记已读 | 通知页点击「全部标记已读」 | 所有通知左侧蓝条消失,未读数量变为 0,角标消失 |
| 一键清空通知 | 点击「一键清空」并确认 | 所有通知全部删除,列表变为空,数据库记录清除 |











11.10 安全防护验证
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| XSS 跨站脚本防护 | 发布文章 / 发表评论,内容输入 <script>alert(1)</script> |
页面正常显示文本,不会弹出弹窗,代码被转义为纯文本 |
| SQL 注入防护 | 登录框用户名输入 ' or 1=1 -- |
登录失败,不会绕过验证,无数据库报错 |
| 文件上传漏洞 | 1. 将 php 脚本改名为 test.jpg.php 2. 作为头像 / 封面上传 |
上传失败,服务器不会执行脚本,文件不会被保存 |
| CSRF 跨站伪造 | 用开发者工具删除表单里的 csrf_token 隐藏域,再提交 |
提示「非法请求」,操作失败 |
| Session 安全 | 1. F12 查看 Cookie 2. 配置 HTTPS 后查看 secure 属性 |
PHPSESSID 带有 HttpOnly 属性,无法被 JS 读取 |
| 错误信息泄露 | 故意触发一个 PHP 错误(比如访问不存在的类) | 页面不显示具体错误路径、数据库信息,仅返回通用错误页或空白 |

十二、全文总结:PHP 入门之旅圆满收官
完成本项目后,你已经具备了独立开发中小型 Web 系统的完整能力:
-
语法基础:PHP 核心语法、数组字符串、表单处理、文件上传、会话管理
-
数据库:表结构设计、多表关联、外键约束、PDO 预处理、分页查询
-
编程思想:面向对象、命名空间、自动加载、单例模式、MVC 分层思想
-
安全意识:SQL 注入、XSS、CSRF、文件上传漏洞的原理与防护
-
项目能力:从需求分析、架构设计到功能落地的完整开发流程
如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。