从零开始学 PHP 系列(八):综合实战——构建一个完整的博客系统

摘要:历经前几篇的学习,你已从零掌握了 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/avataruploads/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-system

Description:可选,比如「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.phpcreate_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、文件上传漏洞的原理与防护

  • 项目能力:从需求分析、架构设计到功能落地的完整开发流程


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。