《零基础学 PHP:从入门到实战》模块十:从应用到精通——掌握PHP进阶技术与现代化开发实战-2

第2章:数据库操作进阶:PDO、预处理与事务处理

章节介绍

学习目标

通过本章学习,您将能够:

  1. 理解并运用PDO(PHP Data Objects)扩展进行类型统一、安全高效的数据库操作。
  2. 掌握预处理语句(Prepared Statements)的原理与用法,从根本上杜绝SQL注入漏洞。
  3. 学会使用事务(Transaction)来保证数据库操作的原子性与数据一致性。
  4. 掌握PDO的错误处理机制,编写更健壮的数据访问代码。
  5. 完成从过时、不安全的mysql_*函数向现代化、安全的数据库操作方式的升级。

在教程中的作用

本章是《零基础学PHP》系列从基础语法迈向工程化开发的关键一步。数据库是动态网站的核心,安全、可靠的数据存取是任何Web应用的基石。本章所讲解的PDO、预处理和事务,是PHP进行专业级数据库开发的必备技能,直接关系到应用的安全性、稳定性和可维护性。掌握本章内容,是为后续学习会话安全、框架原理以及完成综合实战项目铺平道路。

与前面章节的衔接

在第1章中,我们学习了面向对象编程(OOP),理解了类、对象、封装等概念。本章将首次在实战中运用OOP思想,例如,我们可以创建一个Database类来封装PDO连接和操作。同时,我们将使用PDO来操作MySQL数据库,这要求您已经掌握了基本的SQL语法(如SELECT, INSERT, UPDATE, DELETE)和MySQL的基本使用,这些知识在更早的模块中已经学习过。

本章主要内容概览

本章将首先介绍PDO的优越性及其建立连接的方法。然后,核心部分将深入讲解预处理语句 ,包括其原理、使用方法(prepare, execute, bindParam)以及如何有效防止SQL注入。接着,我们将学习事务 的概念,并通过beginTransaction, commit, rollBack来管理复杂操作。最后,我们会探讨PDO的错误处理模式,确保程序在遇到数据库问题时能优雅地应对。所有知识点都将通过代码示例和实战项目进行巩固。

核心概念讲解

1. PDO:PHP数据对象

概念 :PDO提供了一个轻量级、一致性的接口用于访问多种数据库(如MySQL, PostgreSQL, SQLite)。这意味着使用PDO编写的代码,在更换数据库类型时,通常只需修改连接字符串(DSN),极大地提高了代码的可移植性。
为何要取代mysql_*函数?

  • 安全性mysql_*函数不支持原生的预处理语句,容易导致SQL注入。自PHP 5.5.0起已被废弃,PHP 7.0.0中已被移除。
  • 一致性:为不同数据库提供统一的API。
  • 面向对象:完全的面向对象接口,与现代PHP开发范式一致。
  • 性能:支持预处理语句的复用,在某些场景下性能更优。

2. 预处理语句与参数绑定

原理:预处理语句将SQL查询的结构与数据分离。它分为两步:

  1. 准备阶段 :发送一个SQL语句模板到数据库服务器进行编译。例如:SELECT * FROM users WHERE username = ? AND email = ?。此处的?是占位符。
  2. 执行阶段 :将具体的数据(参数)绑定到占位符上,然后执行。数据库服务器将数据视为纯数据,而非SQL代码的一部分,因此恶意注入的SQL代码不会被解析执行。
    关键方法
  • PDO::prepare($sql): 准备一个SQL语句,返回一个PDOStatement对象。
  • PDOStatement::execute($params): 执行准备好的语句,可传入一个参数数组。
  • PDOStatement::bindParam($param, &$variable): 将一个PHP变量绑定到占位符(引用绑定)。
  • PDOStatement::bindValue($param, $value): 将一个绑定到占位符。
  • PDOStatement::fetch() / fetchAll(): 从结果集中获取数据。
    应用场景 :任何包含用户输入或可变数据的SQL操作(SELECT, INSERT, UPDATE, DELETE)都必须使用预处理语句。

3. 事务处理

概念:事务是将一系列数据库操作作为一个不可分割的单元来执行的机制。它遵循ACID原则:

  • 原子性(Atomicity):事务内的所有操作要么全部成功,要么全部失败回滚。
  • 一致性(Consistency):事务必须使数据库从一个一致性状态变换到另一个一致性状态。
  • 隔离性(Isolation):并发执行的事务之间互不干扰。
  • 持久性(Durability) :事务一旦提交,其结果就是永久性的。
    关键方法
  • PDO::beginTransaction(): 启动一个事务。
  • PDO::commit(): 提交事务,使所有更改永久生效。
  • PDO::rollBack(): 回滚事务,撤销所有未提交的更改。
    应用场景:银行转账(A账户扣款和B账户加款必须同时成功或失败)、订单创建(减库存、生成订单、记录日志等多个操作)。

4. 错误处理模式

PDO提供了三种错误处理模式:

  • PDO::ERRMODE_SILENT (默认):发生错误时不主动提示,需要手动检查$pdo->errorCode()$pdo->errorInfo()
  • PDO::ERRMODE_WARNING:产生一个E_WARNING级别的PHP警告。
  • PDO::ERRMODE_EXCEPTION:抛出PDOException异常。这是推荐模式 ,可以方便地使用try...catch块进行集中处理。

代码示例

示例1:使用PDO连接数据库

php 复制代码
<?php
/**
 * 使用PDO连接MySQL数据库
*/
// 数据库配置信息
$host = 'localhost';
$dbname = 'test_db';
$username = 'root';
$password = '';
$charset = 'utf8mb4'; // 推荐使用utf8mb4以支持完整的Unicode(如表情符号)
// 构造数据源名称(DSN)
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";

// 设置PDO连接选项
$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 设置错误模式为异常模式,便于捕获错误
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 设置默认的获取模式为关联数组
PDO::ATTR_EMULATE_PREPARES => false, // 禁用预处理语句的模拟,使用真正的MySQL预处理,更安全
];

try {
    // 创建PDO实例,建立数据库连接
$pdo = new PDO($dsn, $username, $password, $options);
    echo "数据库连接成功!<br>";
} catch (PDOException $e) {
    // 连接失败时,捕获异常并安全地显示错误信息(生产环境中应记录日志,而非直接输出)
die('数据库连接失败: ' . $e->getMessage());
}

// ... 后续的数据库操作
?>

示例2:使用query执行简单查询与fetch获取数据

php 复制代码
<?php
// 假设 $pdo 已成功连接(来自示例1)
$sql = "SELECT id, username, email FROM users WHERE status = 1";
try {
    // 使用query方法执行不包含变量的简单查询
$stmt = $pdo->query($sql);
    
    echo "<h3>用户列表:</h3>";
    // 使用fetchAll一次性获取所有结果行(适用于结果集不大的情况)
$users = $stmt->fetchAll();
    
    if (empty($users)) {
        echo "没有找到活跃用户。";
    } else {
        echo "<ul>";
        foreach ($users as $user) {
            echo "<li>ID: {$user['id']}, 用户名: {$user['username']}, 邮箱: {$user['email']}</li>";
        }
        echo "</ul>";
    }
    
    // 或者,使用fetch逐行获取(适用于处理大型结果集,节省内存)
// $stmt->execute(); // 对于query()返回的语句,通常不需要再execute
    // while ($row = $stmt->fetch()) {
    // print_r($row);
    // }
    
} catch (PDOException $e) {
    echo "查询失败: " . $e->getMessage();
}
?>

示例3:使用预处理语句防止SQL注入(INSERT/UPDATE/DELETE)

php 复制代码
<?php
// 假设 $pdo 已成功连接
// 这是一个用户注册的场景,接收来自表单的用户名和邮箱
// 【模拟攻击】恶意用户输入
$maliciousUsername = "admin' -- ";
$maliciousEmail = "hacker@example.com";

// **危险!直接拼接SQL(SQL注入攻击演示)**
$unsafeSql = "INSERT INTO users (username, email) VALUES ('$maliciousUsername', '$maliciousEmail')";
// 执行后,SQL变为:INSERT INTO users (username, email) VALUES ('admin' -- ', 'hacker@example.com')
// `--` 是SQL注释符,导致后面的单引号被注释,SQL语句可能被篡改,例如绕过验证或删除数据。
echo "<b>危险的不安全SQL示例(请勿在生产环境使用):</b><br>";
echo "SQL: " . htmlspecialchars($unsafeSql) . "<br><br>";

// **安全!使用PDO预处理语句**
$safeSql = "INSERT INTO users (username, email) VALUES (?, ?)"; // 使用`?`作为匿名占位符
try {
    // 1. 准备SQL语句
$stmt = $pdo->prepare($safeSql);
    
    // 2. 执行语句,并绑定参数(即使参数是恶意的,也会被安全地当作数据处理)
$stmt->execute([$maliciousUsername, $maliciousEmail]);
    
    // 获取最后插入的ID
    $lastInsertId = $pdo->lastInsertId();
    echo "<b>安全的预处理SQL执行成功!</b><br>";
    echo "新用户的ID是:$lastInsertId<br>";
    echo "即使输入包含SQL关键字(如admin' --),也被安全地存储为普通字符串,不会破坏SQL结构。<br>";
    
} catch (PDOException $e) {
    echo "插入数据失败: " . $e->getMessage();
}

// **另一种绑定方式:使用命名占位符**
$safeSqlNamed = "UPDATE users SET email = :email WHERE id = :id";
$stmt2 = $pdo->prepare($safeSqlNamed);
$newEmail = 'updated@example.com';
$userId = 1;
// 使用命名占位符,参数数组的键名需与占位符一致
$stmt2->execute([':email' => $newEmail, ':id' => $userId]);
echo "<br>更新操作完成。";
?>

示例4:使用预处理语句进行查询(SELECT)与fetch

php 复制代码
<?php
// 假设 $pdo 已成功连接
// 用户登录验证场景:根据用户名查找用户
$inputUsername = $_POST['username'] ?? ''; // 模拟从表单获取的用户名
$sql = "SELECT id, username, password_hash FROM users WHERE username = :username LIMIT 1";
try {
    $stmt = $pdo->prepare($sql);
    // 将用户输入绑定到 :username 占位符
$stmt->bindParam(':username', $inputUsername, PDO::PARAM_STR); // 显式指定参数类型为字符串
$stmt->execute();
    
    // 获取单条结果
$user = $stmt->fetch();
    
    if ($user) {
        echo "找到用户: {$user['username']} (ID: {$user['id']})<br>";
        // 后续可以在此处进行密码验证(如使用 password_verify)
// if (password_verify($_POST['password'], $user['password_hash'])) { ... }
    } else {
        echo "用户名 '$inputUsername' 不存在。<br>";
    }
    
} catch (PDOException $e) {
    echo "查询失败: " . $e->getMessage();
}

// **使用 bindValue 和 fetchAll 的示例**
$minId = 5;
$sql2 = "SELECT * FROM users WHERE id > ?";
$stmt2 = $pdo->prepare($sql2);
$stmt2->bindValue(1, $minId, PDO::PARAM_INT); // 将值 5 绑定到第一个占位符,类型为整数
$stmt2->execute();
$users = $stmt2->fetchAll(PDO::FETCH_ASSOC); // 明确指定获取模式
echo "<pre>";
print_r($users);
echo "</pre>";
?>

示例5:使用事务处理银行转账场景

php 复制代码
<?php
// 假设 $pdo 已成功连接,且有一个accounts表(id, username, balance)
// 模拟从A账户向B账户转账
$fromAccountId = 1;
$toAccountId = 2;
$amount = 100.00;

try {
    // 1. 开启事务
$pdo->beginTransaction();
    echo "事务开始。<br>";
    
    // 2. 执行一系列数据库操作(这些操作要么全部成功,要么全部失败)
// 操作1:检查转出账户余额是否充足
$checkSql = "SELECT balance FROM accounts WHERE id = ? FOR UPDATE";
    // `FOR UPDATE` 为当前行加锁,防止并发修改导致余额不一致(进阶知识)。
$stmtCheck = $pdo->prepare($checkSql);
    $stmtCheck->execute([$fromAccountId]);
    $fromAccount = $stmtCheck->fetch();
    
    if (!$fromAccount) {
        throw new Exception("转出账户不存在!");
    }
    if ($fromAccount['balance'] < $amount) {
        throw new Exception("转出账户余额不足!");
    }
    
    // 操作2:从转出账户扣款
$deductSql = "UPDATE accounts SET balance = balance - ? WHERE id = ?";
    $stmtDeduct = $pdo->prepare($deductSql);
    $stmtDeduct->execute([$amount, $fromAccountId]);
    echo "从账户 {$fromAccountId} 扣除 {$amount} 元。<br>";
    
    // 操作3:向转入账户加款
$addSql = "UPDATE accounts SET balance = balance + ? WHERE id = ?";
    $stmtAdd = $pdo->prepare($addSql);
    $stmtAdd->execute([$amount, $toAccountId]);
    echo "向账户 {$toAccountId} 转入 {$amount} 元。<br>";
    
    // 模拟一个可能失败的操作(例如,网络中断、唯一键冲突等)
// $someError = true; // 取消注释此行以测试事务回滚
// if ($someError) {
    // throw new Exception("模拟的意外错误!");
    // }
    
    // 3. 所有操作成功,提交事务
$pdo->commit();
    echo "事务提交成功,转账完成!<br>";
    
} catch (Exception $e) {
    // 4. 如果任何一步出现异常,回滚事务,撤销所有更改
$pdo->rollBack();
    echo "事务失败,已回滚。原因:" . $e->getMessage() . "<br>";
}
?>

实战项目

项目:重构用户注册/登录模块并模拟银行转账

1. 需求分析与技术方案

目标:综合运用PDO、预处理语句和事务,创建一个更安全、健壮的用户数据操作模块。

  • 功能1:用户注册。安全地将新用户信息(用户名、邮箱、加密密码)存入数据库。
  • 功能2:用户登录。安全地验证用户名和密码。
  • 功能3:账户转账模拟 。演示使用事务保证资金转移的原子性。
    技术选型
  • 数据库:MySQL。
  • PHP扩展:PDO。
  • 密码存储:password_hash()password_verify()
  • 错误处理:PDO异常模式。
2. 数据库表结构

在MySQL中执行以下SQL语句创建示例表:

sql 复制代码
-- 创建数据库
CREATE DATABASE IF NOT EXISTS php_advance CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE php_advance;

-- 用户表
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) UNIQUE NOT NULL COMMENT '用户名',
    email VARCHAR(100) UNIQUE NOT NULL COMMENT '邮箱',
    password_hash VARCHAR(255) NOT NULL COMMENT '加密后的密码',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 账户表(模拟银行账户)
CREATE TABLE accounts (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL COMMENT '关联的用户ID',
    balance DECIMAL(10, 2) DEFAULT 0.00 COMMENT '账户余额',
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模拟账户表';

-- 插入初始测试用户(密码均为'123456'的hash值)
INSERT INTO users (username, email, password_hash) VALUES
('小明', 'xiaoming@example.com', '$2y$10$SomeRandomHashForDemo123456789012'),
('小红', 'xiaohong@example.com', '$2y$10$AnotherRandomHashForDemo987654321');

-- 为初始用户创建账户
INSERT INTO accounts (user_id, balance) VALUES (1, 1000.00), (2, 500.00);
3. 分步骤实现代码

步骤1:创建数据库连接公共文件 (config/database.php)

php 复制代码
<?php
// config/database.php
/**
 * 数据库配置及连接
*/
class Database {
    private static $instance = null; // 单例模式的实例
private $pdo;
    
    // 私有构造函数,防止外部直接实例化
private function __construct() {
        $host = 'localhost';
        $dbname = 'php_advance';
        $username = 'root';
        $password = '';
        $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,
        ];
        
        try {
            $this->pdo = new PDO($dsn, $username, $password, $options);
        } catch (PDOException $e) {
            die('数据库连接失败: ' . $e->getMessage()); // 生产环境应记录日志
}
    }
    
    // 获取单例实例的公共静态方法
public static function getInstance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    
    // 获取PDO实例
public function getConnection() {
        return $this->pdo;
    }
    
    // 防止克隆和反序列化
private function __clone() {}
    public function __wakeup() {}
}

// 获取数据库连接的快捷方式
function getDB() {
    return Database::getInstance()->getConnection();
}
?>

步骤2:用户注册功能 (register.php)

php 复制代码
<?php
// register.php
require_once 'config/database.php';

// 处理表单提交
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';
    $confirmPassword = $_POST['confirm_password'] ?? '';
    
    $errors = [];
    
    // 简单的表单验证
if (empty($username)) $errors[] = '用户名不能为空。';
    if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = '邮箱格式不正确。';
    if (strlen($password) < 6) $errors[] = '密码长度至少6位。';
    if ($password !== $confirmPassword) $errors[] = '两次输入的密码不一致。';
    
    if (empty($errors)) {
        $pdo = getDB();
        
        // 检查用户名和邮箱是否已存在
$checkSql = "SELECT COUNT(*) FROM users WHERE username = ? OR email = ?";
        $stmtCheck = $pdo->prepare($checkSql);
        $stmtCheck->execute([$username, $email]);
        if ($stmtCheck->fetchColumn() > 0) {
            $errors[] = '用户名或邮箱已被注册。';
        } else {
            // 使用强密码哈希算法
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
            
            // 使用预处理语句插入新用户
$insertSql = "INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)";
            try {
                $pdo->beginTransaction(); // 虽然不是必须,但可以在这里开始事务,用于演示
$stmtInsert = $pdo->prepare($insertSql);
                $stmtInsert->execute([$username, $email, $passwordHash]);
                $newUserId = $pdo->lastInsertId();
                
                // 同时为用户创建一个初始账户(余额为0)
$accountSql = "INSERT INTO accounts (user_id, balance) VALUES (?, 0.00)";
                $stmtAccount = $pdo->prepare($accountSql);
                $stmtAccount->execute([$newUserId]);
                
                $pdo->commit();
                $success = "注册成功!您的用户ID是:$newUserId";
            } catch (PDOException $e) {
                $pdo->rollBack();
                $errors[] = '注册失败,请重试。系统错误:' . $e->getMessage();
            }
        }
    }
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>用户注册</title>
</head>
<body>
    <h2>用户注册</h2>
    <?php if (!empty($errors)): ?>
        <div style="color: red;">
            <ul>
                <?php foreach ($errors as $error): ?>
                    <li><?= htmlspecialchars($error) ?></li>
                <?php endforeach; ?>
            </ul>
        </div>
    <?php endif; ?>
    <?php if (isset($success)): ?>
        <div style="color: green;"><?= htmlspecialchars($success) ?></div>
    <?php endif; ?>
    <form method="POST">
        <div>
            <label for="username">用户名:</label>
            <input type="text" id="username" name="username" required>
        </div>
        <div>
            <label for="email">邮箱:</label>
            <input type="email" id="email" name="email" required>
        </div>
        <div>
            <label for="password">密码:</label>
            <input type="password" id="password" name="password" required>
        </div>
        <div>
            <label for="confirm_password">确认密码:</label>
            <input type="password" id="confirm_password" name="confirm_password" required>
        </div>
        <div>
            <button type="submit">注册</button>
        </div>
    </form>
    <p><a href="login.php">已有账号?去登录</a></p>
</body>
</html>

步骤3:用户登录功能 (login.php)

php 复制代码
<?php
// login.php
session_start(); // 为后续使用Session保存登录状态做准备
require_once 'config/database.php';

// 处理登录表单
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    
    if (empty($username) || empty($password)) {
        $error = '用户名和密码不能为空。';
    } else {
        $pdo = getDB();
        // 使用预处理语句根据用户名查询用户
$sql = "SELECT id, username, password_hash FROM users WHERE username = ?";
        $stmt = $pdo->prepare($sql);
        $stmt->execute([$username]);
        $user = $stmt->fetch();
        
        if ($user && password_verify($password, $user['password_hash'])) {
            // 登录成功
$_SESSION['user_id'] = $user['id'];
            $_SESSION['username'] = $user['username'];
            // 重定向到欢迎页面或主页
header('Location: dashboard.php');
            exit;
        } else {
            $error = '用户名或密码错误。';
        }
    }
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>用户登录</title>
</head>
<body>
    <h2>用户登录</h2>
    <?php if (isset($error)): ?>
        <div style="color: red;"><?= htmlspecialchars($error) ?></div>
    <?php endif; ?>
    <form method="POST">
        <div>
            <label for="username">用户名:</label>
            <input type="text" id="username" name="username" required>
        </div>
        <div>
            <label for="password">密码:</label>
            <input type="password" id="password" name="password" required>
        </div>
        <div>
            <button type="submit">登录</button>
        </div>
    </form>
    <p><a href="register.php">没有账号?去注册</a></p>
</body>
</html>

步骤4:模拟转账功能 (transfer.php - 需登录后访问)

php 复制代码
<?php
// transfer.php
session_start();
require_once 'config/database.php';

// 简单的登录检查
if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$currentUserId = $_SESSION['user_id'];
$currentUsername = $_SESSION['username'];
$message = '';
$pdo = getDB();

// 获取当前用户账户信息
$sql = "SELECT a.id, a.balance, u.username FROM accounts a JOIN users u ON a.user_id = u.id WHERE u.id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$currentUserId]);
$currentAccount = $stmt->fetch();

// 获取所有其他用户的列表(用于选择转账目标)
$otherUsersSql = "SELECT u.id, u.username, a.balance FROM users u JOIN accounts a ON u.id = a.user_id WHERE u.id != ?";
$stmtOthers = $pdo->prepare($otherUsersSql);
$stmtOthers->execute([$currentUserId]);
$otherUsers = $stmtOthers->fetchAll();

// 处理转账请求
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['to_user_id'], $_POST['amount'])) {
    $toUserId = (int)$_POST['to_user_id'];
    $amount = (float)$_POST['amount'];
    
    if ($amount <= 0) {
        $message = '转账金额必须大于0。';
    } elseif ($amount > $currentAccount['balance']) {
        $message = '您的账户余额不足。';
    } else {
        try {
            $pdo->beginTransaction();
            
            // 1. 从当前账户扣款
$deductSql = "UPDATE accounts SET balance = balance - ? WHERE user_id = ? AND balance >= ?";
            $stmtDeduct = $pdo->prepare($deductSql);
            $stmtDeduct->execute([$amount, $currentUserId, $amount]);
            if ($stmtDeduct->rowCount() === 0) {
                throw new Exception("扣款失败,余额可能已不足。");
            }
            
            // 2. 向目标账户加款
$addSql = "UPDATE accounts SET balance = balance + ? WHERE user_id = ?";
            $stmtAdd = $pdo->prepare($addSql);
            $stmtAdd->execute([$amount, $toUserId]);
            if ($stmtAdd->rowCount() === 0) {
                throw new Exception("目标账户不存在或更新失败。");
            }
            
            // 3. 记录交易日志(可选,此处为演示添加一个简单的日志表插入)
// $logSql = "INSERT INTO transfer_logs (from_user_id, to_user_id, amount) VALUES (?, ?, ?)";
            // $pdo->prepare($logSql)->execute([$currentUserId, $toUserId, $amount]);
            
            $pdo->commit();
            $message = "成功向用户ID {$toUserId} 转账 {$amount} 元。";
            
            // 重新查询当前账户余额以刷新显示
$stmt->execute([$currentUserId]);
            $currentAccount = $stmt->fetch();
            
        } catch (Exception $e) {
            $pdo->rollBack();
            $message = "转账失败:" . $e->getMessage();
        }
    }
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>模拟转账</title>
</head>
<body>
    <h2>欢迎,<?= htmlspecialchars($currentUsername) ?>!</h2>
    <p>您的账户余额:<strong><?= htmlspecialchars($currentAccount['balance']) ?></strong> 元</p>
    
    <?php if ($message): ?>
        <div style="padding: 10px; background-color: #dff0d8; border: 1px solid #3c763d; color: #3c763d;"><?= htmlspecialchars($message) ?></div>
    <?php endif; ?>
    
    <h3>转账操作</h3>
    <form method="POST">
        <div>
            <label for="to_user_id">转账给:</label>
            <select id="to_user_id" name="to_user_id" required>
                <option value="">-- 请选择用户 --</option>
                <?php foreach ($otherUsers as $user): ?>
                    <option value="<?= $user['id'] ?>"><?= htmlspecialchars($user['username']) ?> (余额: <?= $user['balance'] ?>元)</option>
                <?php endforeach; ?>
            </select>
        </div>
        <div>
            <label for="amount">转账金额(元):</label>
            <input type="number" id="amount" name="amount" step="0.01" min="0.01" required>
        </div>
        <div>
            <button type="submit">确认转账</button>
        </div>
    </form>
    <p><a href="logout.php">退出登录</a></p>
</body>
</html>

步骤5:简单的注销和仪表板页面

php 复制代码
<?php
// logout.php
session_start();
session_destroy(); // 销毁所有Session数据
header('Location: login.php');
exit;
?>
php 复制代码
<?php
// dashboard.php (简单的仪表板,展示登录状态)
session_start();
if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>仪表板</title></head>
<body>
    <h1>欢迎回来,<?= htmlspecialchars($_SESSION['username']) ?>!</h1>
    <p>您已成功登录。</p>
    <ul>
        <li><a href="transfer.php">去转账</a></li>
        <li><a href="logout.php">退出登录</a></li>
    </ul>
</body>
</html>
4. 项目测试和部署指南

测试

  1. 数据库连接:访问任意一个页面,检查是否能正确连接到数据库。
  2. 用户注册:尝试注册新用户,包括使用特殊字符作为用户名和邮箱,观察是否能被正确处理和安全存储。
  3. SQL注入测试 :在登录或注册表单的输入框中,尝试输入类似 admin' --' OR '1'='1 的字符串,验证系统是否仍然安全(应返回"用户名或密码错误"或"已被注册",而不是异常或登录成功)。
  4. 登录功能:用注册的账号登录,检查Session是否正确设置。
  5. 转账功能
  • 登录用户A,向用户B转账。
  • 检查转账后A、B的余额变化是否正确。
  • 测试事务回滚 :在transfer.php$pdo->commit();前,手动抛出一个异常(如throw new Exception("测试异常");),然后尝试转账。观察是否A的余额没有减少,B的余额没有增加。
    部署
  1. 将项目文件上传至支持PHP和MySQL的Web服务器。
  2. 修改config/database.php中的数据库连接信息(主机名、数据库名、用户名、密码)。
  3. 在服务器上创建对应的数据库和表(执行数据库表结构部分的SQL)。
  4. 确保Web服务器对config目录有读取权限,但最好将其置于Web根目录之外,或使用.htaccess等限制访问。
5. 项目扩展和优化建议
  • 封装数据库操作类 :将常见的CRUD操作封装到一个UserModelAccountModel类中,使代码更清晰。
  • 添加验证类:创建独立的表单验证类,提供更丰富的验证规则(如邮箱唯一性、密码强度)。
  • 完善事务日志 :创建transfer_logs表,记录每笔转账的详细信息(时间、双方ID、金额、状态等)。
  • 实现更复杂的权限:区分普通用户和管理员,管理员可以查看所有转账记录。
  • 前端增强:使用Ajax进行表单提交,提升用户体验。

最佳实践

1. 行业标准和开发规范

  • 始终使用PDO或MySQLi :完全弃用古老的mysql_*函数。
  • 坚持使用预处理语句 :对于任何包含变量的SQL,无论是来自用户输入还是内部变量,都使用prepareexecute
  • 采用异常处理错误 :设置PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,并用try...catch块包裹数据库操作。
  • 使用UTF-8字符集 :在DSN中指定charset=utf8mb4,并在HTML头部设置<meta charset="UTF-8">
  • 遵循PSR编码规范:保持代码风格一致。

2. 常见错误和避坑指南

  • 错误1:连接参数错误或未设置字符集 。确保DSN字符串正确,并始终 设置charset
  • 错误2:忘记调用execute()方法prepare()之后必须调用execute()才能执行。
  • 错误3:混淆bindParambindValuebindParam绑定变量引用,执行时取变量的当前值;bindValue绑定的是调用时的值。在循环中绑定值到同一条语句时,要特别注意。
  • 错误4:事务未正确关闭 。确保每个beginTransaction()都有对应的commit()rollBack(),避免留下未完成的事务锁。
  • 错误5:在生产环境显示详细错误。捕获异常后,应向用户显示友好信息,而将详细错误记录到日志文件。

3. 性能优化技巧

  • 复用预处理语句对象 :如果一个SQL语句需要在循环中执行多次,应在循环外prepare一次,然后在循环内execute多次。
  • 选择合适的数据获取方式
  • 小结果集:fetchAll()
  • 大结果集:循环使用fetch(),或使用PDOStatement::fetchAll(PDO::FETCH_COLUMN)获取单列。
  • 考虑使用持久连接(PDO::ATTR_PERSISTENT => true:在高并发场景下可能提升性能,但需妥善管理连接数,且不适用于所有环境。
  • 为频繁查询的列添加索引:这是数据库层面的优化,但对PHP应用性能影响巨大。

4. 安全性考虑和建议(重点:SQL注入)

  • 核心防护:预处理语句:这是防止SQL注入最有效、最根本的方法。PDO和MySQLi都支持。
  • 漏洞案例(再现)
php 复制代码
        // 攻击者输入:$userInput = "admin' -- ";
        $sql = "SELECT * FROM users WHERE username = '$userInput' AND password = 'xxx'";
        // 执行后: SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'xxx'
        // 密码验证被注释掉,攻击者可能以admin身份登录。
复制代码
-   **防护代码**:
php 复制代码
        $sql = "SELECT * FROM users WHERE username = ? AND password = ?";
        $stmt = $pdo->prepare($sql);
        $stmt->execute([$userInput, $passwordInput]); // $userInput中的单引号会被转义/视为数据
  • 绝不信任用户输入:即使使用了预处理,也应对输入进行业务逻辑上的验证和过滤(如长度、格式)。
  • 最小权限原则 :为PHP连接数据库使用的MySQL账户分配最小的必要权限(通常只需SELECT, INSERT, UPDATE, DELETE),避免使用root账户。
  • 密码安全存储 :务必使用password_hash()password_verify()绝对不要 明文存储密码或使用弱哈希(如md5, sha1)。
  • 错误信息不泄露细节:生产环境中,不要将PDO异常信息直接输出给用户,以免泄露数据库结构。

练习题与挑战

基础练习题

1. 建立PDO连接

  • 题目 :编写一个PHP脚本connect.php,使用PDO连接到本地名为exercise_db的MySQL数据库。用户名为dev_user,密码为DevPass123!。要求设置字符集为utf8mb4,错误模式为异常模式。连接成功后输出"连接成功",失败时捕获异常并输出友好信息。
  • 难度:★☆☆☆☆
  • 提示:参考核心概念讲解中的DSN构造和选项设置。
  • 参考思路
php 复制代码
    // ... 定义连接参数 ...
    try {
        $pdo = new PDO($dsn, $username, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ...]);
        echo "连接成功";
    } catch (PDOException $e) {
        echo "连接失败,请检查配置。"; // 生产环境应记录$e->getMessage()到日志
}

2. 使用预处理语句查询数据

  • 题目 :假设有一个products表(id, name, price)。编写脚本search.php,接收一个GET参数keyword,使用PDO预处理语句查询产品名称中包含该关键词的所有产品,并列出它们的ID、名称和价格。如果没有输入关键词,则列出所有产品。
  • 难度:★★☆☆☆
  • 提示 :在SQL的LIKE子句中使用占位符时,需要将通配符%与参数值一起绑定,如 WHERE name LIKE ?,绑定值为 "%{$keyword}%"
  • 参考思路
php 复制代码
    $keyword = $_GET['keyword'] ?? '';
    $sql = "SELECT * FROM products";
    $params = [];
    if (!empty($keyword)) {
        $sql .= " WHERE name LIKE ?";
        $params[] = "%$keyword%";
    }
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $products = $stmt->fetchAll();
    // ... 循环输出 ...

进阶练习题

3. 实现安全的批量插入

  • 题目 :编写一个脚本batch_insert.php,模拟接收一批新用户数据(例如,一个包含多个[username, email]对的数组)。使用PDO预处理语句,以高效且安全的方式将所有用户批量插入到users表中。注意处理可能出现的重复用户名或邮箱错误。
  • 难度:★★★☆☆
  • 提示 :可以在循环外prepare一次INSERT语句,在循环内execute多次。使用try...catch在单个插入失败时决定是继续还是停止(或使用事务回滚整个批量操作)。
  • 参考思路
php 复制代码
    $newUsers = [ ['alice', 'alice@example.com'], ['bob', 'bob@example.com'] ];
    $pdo->beginTransaction();
    $sql = "INSERT INTO users (username, email) VALUES (?, ?)";
    $stmt = $pdo->prepare($sql);
    try {
        foreach ($newUsers as $user) {
            $stmt->execute($user);
        }
        $pdo->commit();
    } catch (PDOException $e) {
        $pdo->rollBack();
        echo "批量插入失败: " . $e->getMessage();
    }

4. 封装一个简单的数据库操作类

  • 题目 :基于单例模式(或直接实例化),创建一个DB类。该类应提供以下方法:
  • __construct() 或静态方法 getInstance():负责建立PDO连接。
  • query($sql, $params=[]):执行带参数的查询,并返回结果集(数组)。
  • execute($sql, $params=[]):执行带参数的写操作(INSERT/UPDATE/DELETE),并返回影响的行数。
  • lastInsertId():返回最后插入行的ID。
  • 难度:★★★☆☆
  • 提示 :参考实战项目中的Database类,并扩展其功能。
  • 参考思路:(部分代码)
php 复制代码
    class DB {
        private $pdo;
        // ... 单例或构造方法 ...
        public function query($sql, $params = []) {
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute($params);
            return $stmt->fetchAll();
        }
        // ... 其他方法 ...
    }
    // 使用:$db = DB::getInstance(); $users = $db->query("SELECT * FROM users WHERE status=?", [1]);

综合挑战题

5. 实现一个简易的"问答"功能

  • 题目:创建一个简单的问答系统,包含两张表:
  • questions (id, title, content, user_id, created_at)
    • answers (id, content, question_id, user_id, created_at)
      你需要实现以下功能页面(需考虑基本的XSS防护,使用htmlspecialchars输出内容):
  1. index.php:列出所有问题(标题、提问者、时间),最新问题在前。
  2. ask.php:登录用户(使用Session模拟)可以在此页面提交新问题(表单包含标题和内容)。
  3. question.php?id=XXX:显示单个问题的详情及其所有回答。页面底部有一个表单,允许登录用户提交回答。
  4. (进阶)在index.php实现分页功能。
  • 要求
  • 所有数据库操作必须使用PDO和预处理语句。
  • 用户ID可以通过Session模拟(硬编码一个用户ID即可)。
  • 提交问题和回答时,需插入user_id和当前时间。
  • 难度:★★★★☆
  • 提示 :这是对本章及之前章节知识的综合运用。先设计数据库表,然后按功能模块逐个实现。注意SQL中JOIN的使用来关联用户信息。
  • 参考思路 :这是一个小型项目,没有标准答案。建议分模块开发,先完成后台数据操作(创建问题、查询问题列表、创建回答、查询回答列表),再套上前端页面。注意处理未登录用户访问ask.php和提交回答的情况。

章节总结

本章重点知识回顾

  1. PDO的优势与连接:理解了PDO作为现代、安全、可移植的数据库扩展的重要性,掌握了建立PDO连接的正确方法(DSN、选项设置)。
  2. 预处理语句 :这是本章的核心 。你学会了使用prepare()execute()以及bindParam()/bindValue()来执行安全的SQL操作,并深刻理解了其防止SQL注入的原理------将SQL代码与数据分离。
  3. 事务处理 :掌握了事务的概念(ACID)和基本操作(beginTransactioncommitrollBack),能够在需要保证数据一致性的业务场景(如转账)中正确应用事务。
  4. 错误处理 :学会了将PDO设置为异常错误模式,并使用try...catch块来优雅地处理数据库操作中可能出现的异常,使程序更健壮。
  5. 实战应用:通过重构用户注册/登录模块和模拟转账项目,你将上述知识融会贯通,体验了如何在实际项目中运用PDO、预处理和事务来构建安全、可靠的数据层。

技能掌握要求

完成本章学习后,你应该能够:

  • 熟练地使用PDO连接MySQL数据库并进行配置。
  • 在任何涉及数据库操作的场景中,毫不犹豫地使用预处理语句
  • 判断何时需要使用事务,并能够正确编写事务代码。
  • 识别并修正使用老式mysql_*函数或直接拼接SQL的不安全代码。
  • 开始尝试用面向对象的思想来组织你的数据库访问代码(如创建工具类)。

进一步学习建议

  • 深入PDO :探索PDO更多高级特性,如设置不同的获取模式(PDO::FETCH_CLASS映射到对象)、处理大数据集(fetch的游标)、调用存储过程等。
  • 学习ORM:了解对象关系映射(ORM)概念和流行的PHP ORM库,如Doctrine或Eloquent(Laravel框架内置)。ORM能让你用操作对象的方式来操作数据库,进一步提升开发效率。
  • 数据库设计:本章聚焦于操作,下一步可以深入学习数据库设计原则(范式、索引优化、关系设计)。
  • 准备下一章 :第3章将聚焦于会话控制(Session/Cookie)Web安全基础。你将利用本章学到的安全数据库操作,结合Session来构建完整的用户认证系统,并学习防御XSS、CSRF等常见Web攻击。请确保本章的知识已经牢固掌握。
相关推荐
釉色清风10 小时前
在openEuler玩转Python
linux·开发语言·python
han_hanker10 小时前
这里使用 extends HashMap<String, Object> 和 类本身定义变量的优缺点
java·开发语言
@小码农10 小时前
2025年北京海淀区中小学生信息学竞赛第二赛段C++真题
开发语言·数据结构·c++·算法
小李独爱秋10 小时前
计算机网络经典问题透视:TCP的“误判”——非拥塞因素导致的分组丢失
服务器·网络·tcp/ip·计算机网络·智能路由器·php
sulikey10 小时前
C++模板初阶详解:从函数模板到类模板的全面解析
开发语言·c++·模板·函数模板·类模板
0 0 010 小时前
CCF-CSP第39次认证第三题——HTTP 头信息(HPACK)【C++】
开发语言·c++·算法
沐风。5610 小时前
Object方法
开发语言·前端·javascript
IT_阿水11 小时前
C语言之printf函数用法
c语言·开发语言·printf
laocooon52385788611 小时前
C语言,少了&为什么报 SegmentationFault
c语言·开发语言