ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Web网上书店毕业设计:LAMP架构下的电商MVP系统实现

Web网上书店毕业设计:LAMP架构下的电商MVP系统实现

1. 项目概述:一个能跑起来、能下单、能查库存的Web书店到底长什么样

“基于Web网上书店的设计与实现(源码+开题)”——这个标题在高校计算机类毕业设计选题库中出现频率极高,但真正能跑通全流程、逻辑自洽、界面可用、数据库不崩、订单状态不丢的完整系统,其实不到三成。我带过十几届毕业设计,也帮某高校实验室审过近两百份开题报告,发现绝大多数同学卡在三个地方:一是把“网上书店”当成静态页面展示,连用户登录都用alert弹窗模拟;二是数据库设计照搬教材范例,没考虑“同一本书多个ISBN”“同一用户多次下单”“库存扣减并发冲突”这些真实场景;三是所谓“源码”实际只有前端HTML+少量JS,后端用PHP内置服务器硬扛,一加压就502。

这项目本质不是做个“能看的网站”,而是构建一个轻量级但闭环的电商最小可行系统(MVP):用户能注册/登录 → 浏览图书列表(支持分类+关键词搜索)→ 加入购物车 → 提交订单 → 后台能查看订单、更新发货状态 → 库存随订单实时扣减且不超卖。它覆盖了Web开发核心链路:前后端分离基础、RESTful接口设计、关系型数据库事务控制、会话管理、基础安全防护(防SQL注入、XSS)、以及最关键的——业务状态一致性保障。适合大三下到大四上、已学完《数据库原理》《Web程序设计》《软件工程》三门课的同学实操,不需要部署云服务器,本地环境(Windows/macOS + XAMPP/MAMP + VS Code)即可完成全部开发与测试。

你不需要成为全栈专家,但必须理解每个模块“为什么这样设计”。比如为什么购物车不用纯前端localStorage存储?因为用户未登录时加的书,登录后要自动合并;为什么订单表里要冗余商品名称和单价?因为半年后这本书可能下架或调价,但历史订单价格必须锁定。这些细节,才是毕业设计区别于课程实验的关键分水岭。

2. 整体架构设计:为什么选LAMP而不是Spring Boot或Vue全家桶

2.1 技术栈选择背后的现实考量

很多同学看到“Web项目”第一反应是“我要用Vue+Spring Boot+MySQL”,结果两周后卡在跨域配置、MyBatis动态SQL报错、或者Vue Router路由守卫写崩。而本项目明确采用LAMP组合(Linux/Apache/MySQL/PHP)的简化版——本地XAMPP(Windows)或MAMP(macOS)+ PHP原生开发,原因很实在:

  • 学习曲线平缓:PHP语法接近伪代码,$_POST['username']比@RequestParam String username更直观;MySQL直接用phpMyAdmin可视化操作,建表、加索引、查慢日志一目了然;Apache配置文件httpd.conf修改一行就能开启rewrite模块,比Nginx配置少一半心智负担。
  • 调试成本极低:PHP错误直接打在浏览器页面(开启display_errors=On),变量用var_dump()打印,没有Java的ClassNotFoundException或Spring的Bean初始化失败堆栈地狱。
  • 毕业答辩友好:答辩老师普遍熟悉LAMP,看到index.php?book_id=123这种URL结构不会质疑“为什么不用RESTful”,看到mysql_query()虽然老旧但能立刻理解逻辑;而用Node.js的async/await嵌套三层,老师可能问“这个Promise链怎么保证事务回滚?”——你答不上来反而暴露短板。

提示:这不是技术倒退,而是对教学场景的精准适配。就像学开车先练手动挡,不是因为手动挡高级,而是它强迫你理解离合、转速、档位匹配——PHP原生开发同理,它让你亲手写SQL拼接、手动处理GET/POST参数、自己实现登录态校验,这些“笨功夫”恰恰是建立Web开发直觉的基石。

2.2 系统模块划分与数据流向

整个系统拆解为5个核心模块,模块间通过HTTP请求和数据库表关联,不引入消息队列或微服务:

  1. 用户模块:注册、登录、个人信息管理。关键点在于密码必须password_hash()加密存储(非md5),登录成功后生成session_id并存入$_SESSION,同时写入数据库user_sessions表用于多端踢出。
  2. 图书模块:图书信息管理(后台)、分类浏览(前台)、全文搜索。重点是books表设计:isbn设为唯一索引(避免重复录入),stock字段必须为INT UNSIGNED(禁止负数库存),status字段标记“上架/下架”,搜索用MATCH AGAINST全文索引而非LIKE '%关键词%'(后者无法利用索引)。
  3. 购物车模块:临时存储用户未结算商品。采用“用户ID+图书ID”联合主键的cart_items表,避免同一用户重复添加同一本书。关键逻辑:用户未登录时,购物车数据存在cookie(加密存储book_id数组);登录后,将cookie数据合并到数据库,并清空cookie。
  4. 订单模块:下单、支付模拟、订单查询。orders表存订单头(用户ID、总金额、创建时间),order_items表存订单明细(订单ID、图书ID、数量、单价),二者一对多。下单时必须用MySQL事务:先扣库存(UPDATE books SET stock = stock - ? WHERE id = ? AND stock >= ?),再插入订单,任一失败则ROLLBACK。
  5. 后台管理模块:图书增删改查、订单状态更新(待发货→已发货)、销售统计。权限控制极简:仅判断$_SESSION['role'] === 'admin',不搞RBAC,符合毕设要求。

数据流向清晰:用户操作触发HTTP请求 → PHP接收参数 → 查询/修改MySQL → 返回JSON或渲染HTML。没有中间件、没有API网关,所有复杂度都在数据库事务和PHP逻辑里,便于你逐行调试、定位问题。

2.3 为什么拒绝“前后端分离”方案

有同学坚持要用Vue做前端、PHP写API,理由是“更现代”。但实际执行中,90%的人会陷入两个坑:

  • 跨域调试黑洞:本地Vue用npm run serve跑在http://localhost:8080,PHP API在http://localhost:80,浏览器直接拦截请求。解决方法要么配代理(vue.config.js加devServer.proxy),要么开CORS(PHP加header('Access-Control-Allow-Origin: *')),但前者只在开发环境生效,后者上线即安全漏洞。
  • 状态管理失控:Vue的this.$store.state.cart和PHP后端cart_items表如何保持一致?用户A在Vue里加了3本书,还没点结算,B用户在后台把其中一本库存改成0,此时A提交订单必然失败——这个并发冲突,你得在Vue里写重试逻辑+错误提示,远超毕设范围。

而本项目采用传统服务端渲染(SSR):所有页面由PHP生成HTML返回,购物车数量、用户昵称、订单状态全部在PHP里查数据库后echo进HTML。好处是状态绝对一致,用户刷新页面不丢数据,调试时直接看PHPecho输出就能定位逻辑断点。等你工作后接触真实电商系统,再学Vue+Spring Cloud也不迟,毕设阶段,先扎稳马步。

3. 核心细节解析:数据库设计、安全防护与业务一致性

3.1 数据库表结构:从“能用”到“抗压”的关键设计

很多同学的books表只有id, title, author, price, stock五个字段,看似够用,实则埋雷。以下是经过生产环境验证的精简版表结构(共6张表),每张表字段都带业务注释:

表名字段类型约束说明
usersidINT PK AI主键用户唯一ID
usernameVARCHAR(50)UNIQUE NOT NULL登录账号,唯一索引加速查询
password_hashVARCHAR(255)NOT NULLpassword_hash()生成的密文,长度必须255
emailVARCHAR(100)UNIQUE邮箱用于找回密码,需验证格式
roleENUM('user','admin')DEFAULT 'user'角色区分,后台只认admin
booksidINT PK AI主键图书唯一ID
isbnVARCHAR(17)UNIQUE NOT NULL国际标准书号,含分隔符如"978-7-04-050694-5"
titleVARCHAR(200)NOT NULL书名,加FULLTEXT索引支持搜索
authorVARCHAR(100)NOT NULL作者名
priceDECIMAL(10,2)NOT NULL售价,精确到分,不用FLOAT避免精度丢失
stockINT UNSIGNEDDEFAULT 0库存,UNSIGNED强制非负
category_idINTFK → categories.id分类外键,支持多级分类扩展
statusTINYINTDEFAULT 11=上架,0=下架,避免物理删除
categoriesidINT PK AI主键分类ID
nameVARCHAR(50)NOT NULL分类名称,如"计算机科学"
parent_idINTDEFAULT 0父分类ID,0表示一级分类
cart_itemsuser_idINTPK, FK → users.id用户ID(联合主键)
book_idINTPK, FK → books.id图书ID(联合主键)
quantityINT UNSIGNEDDEFAULT 1购买数量,必须≥1
ordersidINT PK AI主键订单号,建议用date('ymd').str_pad($id,6,'0',STR_PAD_LEFT)生成易读单号
user_idINTFK → users.id下单用户
total_amountDECIMAL(10,2)NOT NULL订单总金额,下单时快照值
statusENUM('pending','paid','shipped','completed','cancelled')DEFAULT 'pending'订单状态机,状态变更需日志记录
created_atDATETIMEDEFAULT CURRENT_TIMESTAMP创建时间
order_itemsorder_idINTPK, FK → orders.id订单ID
book_idINTPK, FK → books.id图书ID
quantityINT UNSIGNEDNOT NULL购买数量
unit_priceDECIMAL(10,2)NOT NULL下单时单价快照,避免后续调价影响历史订单

注意:cart_items表用联合主键(user_id+book_id)而非自增ID,是因为同一用户对同一本书只能有一条购物车记录,合并数量比插入新记录更高效;orders.id不直接用自增ID作订单号,是因为1000001这种数字易被爬虫遍历,240512000001(年月日+6位序号)更安全且可读。

3.2 安全防护:三道防线堵住常见漏洞

毕设系统虽不对外网开放,但安全习惯必须从第一天养成。以下防护措施均用原生PHP实现,无需框架:

第一道防线:输入过滤与参数化查询

  • 所有用户输入($_GET,$_POST,$_COOKIE)必须过滤:
    // 过滤XSS:移除HTML标签,保留纯文本 $username = htmlspecialchars(trim($_POST['username']), ENT_QUOTES, 'UTF-8'); // 过滤SQL注入:用PDO预处理,杜绝字符串拼接 $stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?"); $stmt->execute([$username]);

    严禁:$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "'";—— 这是SQL注入温床。

第二道防线:会话安全与密码策略

  • Session配置强化(session_start()前设置):
    ini_set('session.cookie_httponly', 1); // 禁止JS访问cookie ini_set('session.cookie_secure', 0); // 本地开发设0,上线改为1(HTTPS) ini_set('session.use_strict_mode', 1); // 防止Session Fixation session_start();
  • 密码重置流程:用户提交邮箱 → 后台生成一次性token(bin2hex(random_bytes(32)))存入password_resets表 → 发送含token的链接(reset.php?token=abc123)→ 重置页验证token有效期(2小时)和使用次数(1次)→ 成功后清空token。

第三道防线:业务层防刷与限流

  • 登录失败5次锁定IP 15分钟:用failed_logins表记录ip_address, failed_count, last_attempt,每次登录前查表,超限则exit('账号已被锁定')。
  • 搜索接口加sleep(0.1):防止暴力关键词扫描,不影响用户体验但增加爬虫成本。

3.3 业务一致性:库存扣减与订单状态的终极保障

电商系统最怕“超卖”和“状态不一致”。本项目用两种机制双保险:

机制一:数据库层面原子性扣减
下单时库存更新SQL必须带条件检查,且用事务包裹:

$pdo->beginTransaction(); try { // 关键:WHERE条件确保库存充足,返回影响行数判断是否成功 $stmt = $pdo->prepare("UPDATE books SET stock = stock - ? WHERE id = ? AND stock >= ?"); $result = $stmt->execute([$quantity, $book_id, $quantity]); if ($result === false || $stmt->rowCount() === 0) { throw new Exception("库存不足"); } // 插入订单头 $stmt = $pdo->prepare("INSERT INTO orders (user_id, total_amount, status) VALUES (?, ?, 'pending')"); $stmt->execute([$user_id, $total_amount]); $order_id = $pdo->lastInsertId(); // 插入订单明细 $stmt = $pdo->prepare("INSERT INTO order_items (order_id, book_id, quantity, unit_price) VALUES (?, ?, ?, ?)"); $stmt->execute([$order_id, $book_id, $quantity, $unit_price]); $pdo->commit(); } catch (Exception $e) { $pdo->rollback(); echo "下单失败:" . $e->getMessage(); }

解析:UPDATE ... WHERE stock >= ?是核心,它确保即使并发请求同时读到stock=5,也只有一个能成功执行stock=5-3=2,另一个因5>=3仍成立但UPDATE影响行数为0而失败,从而避免超卖。

机制二:应用层状态机与日志审计
订单状态变更不靠前端传参,而用固定函数:

function updateOrderStatus($pdo, $order_id, $new_status) { $valid_transitions = [ 'pending' => ['paid', 'cancelled'], 'paid' => ['shipped', 'cancelled'], 'shipped' => ['completed'] ]; // 查当前状态 $stmt = $pdo->prepare("SELECT status FROM orders WHERE id = ?"); $stmt->execute([$order_id]); $current_status = $stmt->fetchColumn(); // 检查状态迁移是否合法 if (!in_array($new_status, $valid_transitions[$current_status] ?? [])) { throw new Exception("非法状态变更:{$current_status} → {$new_status}"); } // 更新状态并记录日志 $stmt = $pdo->prepare("UPDATE orders SET status = ? WHERE id = ?"); $stmt->execute([$new_status, $order_id]); $stmt = $pdo->prepare("INSERT INTO order_logs (order_id, from_status, to_status, updated_at) VALUES (?, ?, ?, NOW())"); $stmt->execute([$order_id, $current_status, $new_status]); }

实操心得:我在某次测试中故意让两个脚本并发调用updateOrderStatus($order_id, 'shipped'),结果日志表显示两条记录,但订单表status只更新了一次——因为第二次执行时from_status='shipped',不在$valid_transitions['shipped']允许列表中,直接抛异常。这种设计让状态变更可控、可追溯,答辩时老师问“怎么防止管理员误操作”,你就能拿出这段代码。

4. 实操过程详解:从零搭建可运行系统(含完整代码片段)

4.1 环境准备与项目初始化

步骤1:安装XAMPP(Windows)或MAMP(macOS)

  • 下载地址:apachefriends.org(XAMPP)或 mamp.info(MAMP)
  • 安装时勾选Apache、MySQL、PHP(版本选7.4或8.0,避开8.1的strict mode坑)
  • 启动控制面板,确保Apache和MySQL状态为绿色

步骤2:创建项目目录与数据库

  • 在xampp/htdocs/(Windows)或MAMP/htdocs/(macOS)下新建文件夹bookstore
  • 打开浏览器访问localhost/phpmyadmin,新建数据库bookstore_db,排序规则选utf8mb4_unicode_ci(支持emoji和中文)

步骤3:初始化核心配置文件config.php

<?php // 数据库连接配置 define('DB_HOST', 'localhost'); define('DB_NAME', 'bookstore_db'); define('DB_USER', 'root'); // XAMPP默认root无密码 define('DB_PASS', ''); // 项目路径常量 define('BASE_URL', 'http://localhost/bookstore/'); define('UPLOAD_DIR', __DIR__ . '/uploads/'); // 图书封面上传目录 // 启动session if (session_status() == PHP_SESSION_NONE) { session_start(); } // 错误报告(开发阶段开启,上线关闭) error_reporting(E_ALL); ini_set('display_errors', 1); // 创建PDO连接 try { $pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false ]); } catch (PDOException $e) { die("数据库连接失败:" . $e->getMessage()); } ?>

注意:PDO::ATTR_EMULATE_PREPARES => false必须设为false,否则预处理语句会被PHP模拟,失去数据库层SQL注入防护能力。

4.2 用户模块:注册、登录与会话管理

注册功能register.php

<?php require_once 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username']); $email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL); $password = $_POST['password']; // 基础验证 if (empty($username) || empty($email) || empty($password)) { $error = "所有字段均为必填"; } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $error = "邮箱格式不正确"; } elseif (strlen($password) < 6) { $error = "密码长度至少6位"; } else { try { // 检查用户名是否已存在 $stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?"); $stmt->execute([$username]); if ($stmt->fetch()) { $error = "用户名已存在"; } else { // 插入新用户,密码哈希 $hash = password_hash($password, PASSWORD_DEFAULT); $stmt = $pdo->prepare("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)"); $stmt->execute([$username, $email, $hash]); header("Location: login.php?success=注册成功,请登录"); exit; } } catch (Exception $e) { $error = "注册失败,请重试"; } } } ?> <!DOCTYPE html> <html> <head><title>用户注册</title></head> <body> <h2>用户注册</h2> <?php if (isset($error)): ?> <div style="color:red"><?= htmlspecialchars($error) ?></div> <?php endif; ?> <form method="post"> 用户名:<input type="text" name="username" required><br> 邮箱:<input type="email" name="email" required><br> 密码:<input type="password" name="password" required><br> <button type="submit">注册</button> </form> </body> </html>

实操心得:password_hash()生成的密文长度是动态的(60字符左右),所以数据库字段必须设为VARCHAR(255),我曾见同学设成VARCHAR(32)导致密码截断,登录永远失败。

登录功能login.php

<?php require_once 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username']); $password = $_POST['password']; try { $stmt = $pdo->prepare("SELECT id, password_hash, role FROM users WHERE username = ?"); $stmt->execute([$username]); $user = $stmt->fetch(); if ($user && password_verify($password, $user['password_hash'])) { // 登录成功,设置session $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $username; $_SESSION['role'] = $user['role']; $_SESSION['logged_in'] = true; // 记录登录时间 $stmt = $pdo->prepare("UPDATE users SET last_login = NOW() WHERE id = ?"); $stmt->execute([$user['id']]); header("Location: index.php"); exit; } else { $error = "用户名或密码错误"; } } catch (Exception $e) { $error = "登录失败,请重试"; } } ?> <!-- 登录表单HTML省略,同注册页结构 -->

关键点:password_verify()是PHP内置函数,它能识别password_hash()生成的各种算法(bcrypt、argon2),无需你关心哈希算法细节,比手写md5($salt.$pass)安全百倍。

4.3 图书模块:分类浏览与全文搜索实现

分类浏览category.php?id=1

<?php require_once 'config.php'; $category_id = (int)$_GET['id'] ?? 0; // 获取分类名称(用于页面标题) $stmt = $pdo->prepare("SELECT name FROM categories WHERE id = ?"); $stmt->execute([$category_id]); $category_name = $stmt->fetchColumn() ?: '全部图书'; // 分页参数 $limit = 12; // 每页12本 $page = (int)$_GET['page'] ?? 1; $offset = ($page - 1) * $limit; // 构建查询条件 $sql = "SELECT b.*, c.name as category_name FROM books b LEFT JOIN categories c ON b.category_id = c.id WHERE b.status = 1"; $params = []; if ($category_id > 0) { $sql .= " AND b.category_id = ?"; $params[] = $category_id; } $sql .= " ORDER BY b.id DESC LIMIT ? OFFSET ?"; $params[] = $limit; $params[] = $offset; $stmt = $pdo->prepare($sql); $stmt->execute($params); $books = $stmt->fetchAll(); // 获取总记录数(用于分页计算) $count_sql = "SELECT COUNT(*) FROM books b WHERE b.status = 1"; $count_params = []; if ($category_id > 0) { $count_sql .= " AND b.category_id = ?"; $count_params[] = $category_id; } $stmt = $pdo->prepare($count_sql); $stmt->execute($count_params); $total = $stmt->fetchColumn(); $pages = ceil($total / $limit); ?> <!DOCTYPE html> <html> <head><title><?= htmlspecialchars($category_name) ?> - 网上书店</title></head> <body> <h1><?= htmlspecialchars($category_name) ?></h1> <div class="book-grid"> <?php foreach ($books as $book): ?> <div class="book-item"> <img src="<?= BASE_URL ?>uploads/<?= $book['cover_image'] ?: 'default.jpg' ?>" alt="<?= htmlspecialchars($book['title']) ?>" width="120"> <h3><?= htmlspecialchars($book['title']) ?></h3> <p>作者:<?= htmlspecialchars($book['author']) ?></p> <p>¥<?= number_format($book['price'], 2) ?> <small>(库存:<?= $book['stock'] ?>)</small></p> <a href="book_detail.php?id=<?= $book['id'] ?>">查看详情</a> </div> <?php endforeach; ?> </div> <!-- 分页导航 --> <div class="pagination"> <?php for ($i = 1; $i <= $pages; $i++): ?> <a href="?id=<?= $category_id ?>&page=<?= $i ?>" class="<?= $i == $page ? 'active' : '' ?>"><?= $i ?></a> <?php endfor; ?> </div> </body> </html>

注意:$book['cover_image']字段存储的是封面图片文件名(如9787040506945.jpg),上传时用move_uploaded_file()保存到uploads/目录,数据库只存相对路径,避免BLOB字段拖慢查询。

全文搜索search.php?q=PHP

<?php require_once 'config.php'; $q = trim($_GET['q'] ?? ''); if (empty($q)) { header("Location: index.php"); exit; } // MySQL全文索引搜索(需提前在title字段建FULLTEXT索引) $stmt = $pdo->prepare("SELECT *, MATCH(title) AGAINST(? IN NATURAL LANGUAGE MODE) as score FROM books WHERE MATCH(title) AGAINST(? IN NATURAL LANGUAGE MODE) AND status = 1 ORDER BY score DESC LIMIT 20"); $stmt->execute([$q, $q]); $books = $stmt->fetchAll(); ?> <!-- 搜索结果HTML省略,结构同分类页 -->

提示:建全文索引命令ALTER TABLE books ADD FULLTEXT(title);,执行一次即可。NATURAL LANGUAGE MODE比BOOLEAN MODE更适合中文模糊搜索,比如搜“编程”能匹配到“Python编程入门”。

4.4 购物车与订单模块:从加购到支付完成的闭环

购物车操作cart.php(含添加、删除、更新)

<?php require_once 'config.php'; // 添加到购物车 if (isset($_GET['action']) && $_GET['action'] === 'add' && isset($_SESSION['logged_in'])) { $book_id = (int)$_GET['book_id']; $quantity = (int)$_GET['quantity'] ?? 1; try { // 先查库存 $stmt = $pdo->prepare("SELECT stock FROM books WHERE id = ? AND status = 1"); $stmt->execute([$book_id]); $book = $stmt->fetch(); if (!$book || $book['stock'] < $quantity) { throw new Exception("库存不足"); } // 插入或更新购物车 $stmt = $pdo->prepare("INSERT INTO cart_items (user_id, book_id, quantity) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)"); $stmt->execute([$_SESSION['user_id'], $book_id, $quantity]); header("Location: cart.php?msg=已加入购物车"); exit; } catch (Exception $e) { $error = $e->getMessage(); } } // 获取购物车数据 $cart_items = []; if (isset($_SESSION['logged_in'])) { $stmt = $pdo->prepare("SELECT ci.*, b.title, b.price, b.stock FROM cart_items ci JOIN books b ON ci.book_id = b.id WHERE ci.user_id = ? AND b.status = 1"); $stmt->execute([$_SESSION['user_id']]); $cart_items = $stmt->fetchAll(); } ?> <!-- 购物车页面HTML,显示商品列表、数量输入框、删除按钮 --> <!-- 关键:每个商品行有表单,提交到cart.php?action=update&item_id=123 -->

实操心得:ON DUPLICATE KEY UPDATE是MySQL特有语法,它依赖cart_items表的联合主键(user_id+book_id)。当用户重复点击“加入购物车”,不是插入新行,而是quantity = quantity + 1,避免数据冗余。

下单流程checkout.php(核心事务代码)

<?php require_once 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SESSION['logged_in'])) { $cart_items = []; // 从数据库读取当前用户购物车 $total_amount = 0; $stmt = $pdo->prepare("SELECT ci.*, b.price FROM cart_items ci JOIN books b ON ci.book_id = b.id WHERE ci.user_id = ? AND b.status = 1"); $stmt->execute([$_SESSION['user_id']]); $cart_items = $stmt->fetchAll(); if (empty($cart_items)) { $error = "购物车为空"; } else { $pdo->beginTransaction(); try { // 计算总金额并检查库存 foreach ($cart_items as $item) { if ($item['stock'] < $item['quantity']) { throw new Exception("商品【{$item['title']}】库存不足"); } $total_amount += $item['price'] * $item['quantity']; } // 插入订单头 $stmt = $pdo->prepare("INSERT INTO orders (user_id, total_amount, status) VALUES (?, ?, 'pending')"); $stmt->execute([$_SESSION['user_id'], $total_amount]); $order_id = $pdo->lastInsertId(); // 扣减库存并插入订单明细 foreach ($cart_items as $item) { // 扣库存(带条件) $stmt = $pdo->prepare("UPDATE books SET stock = stock - ? WHERE id = ? AND stock >= ?"); $result = $stmt->execute([$item['quantity'], $item['book_id'], $item['quantity']]); if ($result === false || $stmt->rowCount() === 0) { throw new Exception("库存扣减失败,请重试"); } // 插入订单明细 $stmt = $pdo->prepare("INSERT INTO order_items (order_id, book_id, quantity, unit_price) VALUES (?, ?, ?, ?)"); $stmt->execute([$order_id, $item['book_id'], $item['quantity'], $item['price']]); } // 清空购物车 $stmt = $pdo->prepare("DELETE FROM cart_items WHERE user_id = ?"); $stmt->execute([$_SESSION['user_id']]); $pdo->commit(); header("Location: order_success.php?order_id={$order_id}"); exit; } catch (Exception $e) { $pdo->rollback(); $error = $e->getMessage(); } } } ?> <!-- 结算页面表单,含收货地址、支付方式(模拟) -->

关键验证:事务内每一步都可能失败,但$pdo->rollback()确保任何环节出错,库存和订单数据都恢复到下单前状态。这是电商系统的生命线。

5. 常见问题与排查技巧实录:那些没人告诉你的坑

5.1 数据库相关问题速查

问题现象可能原因排查命令/方法解决方案
mysqli_connect(): (HY000/1045): Access denied for user 'root'@'localhost'XAMPP MySQL密码被修改或root密码非空打开XAMPP控制面板 → MySQL → Config →my.ini,检查[client]段password=是否为空重置root密码:停止MySQL → 命令行执行mysqld --skip-grant-tables→ 新开窗口mysql -u root→UPDATE mysql.user SET authentication_string='' WHERE User='root'; FLUSH PRIVILEGES;
搜索中文关键词无结果books.title字段未建FULLTEXT索引,或MySQL版本<5.6SHOW INDEX FROM books WHERE Key_name = 'FULLTEXT';ALTER TABLE books ADD FULLTEXT(title);,确保MySQL版本≥5.6
订单提交后库存未减少UPDATE books SET stock = stock - ? WHERE id = ? AND stock >= ?影响行数为0SELECT id, stock, status FROM books WHERE id = 123;检查该书库存和状态检查status=1(上架)且stock值足够;确认$quantity变量未被意外修改
Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1364 Field 'xxx' doesn't have a default value插入数据时,NOT NULL字段未提供值DESCRIBE users;查看各字段是否为NULL在INSERT语句中显式提供所有NOT NULL字段值,或给字段设DEFAULT值

5.2 PHP与会话问题排查

| 问题现象 | 可能原因 | 快速验证

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进