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请求和数据库表关联,不引入消息队列或微服务:
- 用户模块:注册、登录、个人信息管理。关键点在于密码必须
password_hash()加密存储(非md5),登录成功后生成session_id并存入$_SESSION,同时写入数据库user_sessions表用于多端踢出。 - 图书模块:图书信息管理(后台)、分类浏览(前台)、全文搜索。重点是
books表设计:isbn设为唯一索引(避免重复录入),stock字段必须为INT UNSIGNED(禁止负数库存),status字段标记“上架/下架”,搜索用MATCH AGAINST全文索引而非LIKE '%关键词%'(后者无法利用索引)。 - 购物车模块:临时存储用户未结算商品。采用“用户ID+图书ID”联合主键的
cart_items表,避免同一用户重复添加同一本书。关键逻辑:用户未登录时,购物车数据存在cookie(加密存储book_id数组);登录后,将cookie数据合并到数据库,并清空cookie。 - 订单模块:下单、支付模拟、订单查询。
orders表存订单头(用户ID、总金额、创建时间),order_items表存订单明细(订单ID、图书ID、数量、单价),二者一对多。下单时必须用MySQL事务:先扣库存(UPDATE books SET stock = stock - ? WHERE id = ? AND stock >= ?),再插入订单,任一失败则ROLLBACK。 - 后台管理模块:图书增删改查、订单状态更新(待发货→已发货)、销售统计。权限控制极简:仅判断
$_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张表),每张表字段都带业务注释:
| 表名 | 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|---|
users | id | INT PK AI | 主键 | 用户唯一ID |
username | VARCHAR(50) | UNIQUE NOT NULL | 登录账号,唯一索引加速查询 | |
password_hash | VARCHAR(255) | NOT NULL | password_hash()生成的密文,长度必须255 | |
email | VARCHAR(100) | UNIQUE | 邮箱用于找回密码,需验证格式 | |
role | ENUM('user','admin') | DEFAULT 'user' | 角色区分,后台只认admin | |
books | id | INT PK AI | 主键 | 图书唯一ID |
isbn | VARCHAR(17) | UNIQUE NOT NULL | 国际标准书号,含分隔符如"978-7-04-050694-5" | |
title | VARCHAR(200) | NOT NULL | 书名,加FULLTEXT索引支持搜索 | |
author | VARCHAR(100) | NOT NULL | 作者名 | |
price | DECIMAL(10,2) | NOT NULL | 售价,精确到分,不用FLOAT避免精度丢失 | |
stock | INT UNSIGNED | DEFAULT 0 | 库存,UNSIGNED强制非负 | |
category_id | INT | FK → categories.id | 分类外键,支持多级分类扩展 | |
status | TINYINT | DEFAULT 1 | 1=上架,0=下架,避免物理删除 | |
categories | id | INT PK AI | 主键 | 分类ID |
name | VARCHAR(50) | NOT NULL | 分类名称,如"计算机科学" | |
parent_id | INT | DEFAULT 0 | 父分类ID,0表示一级分类 | |
cart_items | user_id | INT | PK, FK → users.id | 用户ID(联合主键) |
book_id | INT | PK, FK → books.id | 图书ID(联合主键) | |
quantity | INT UNSIGNED | DEFAULT 1 | 购买数量,必须≥1 | |
orders | id | INT PK AI | 主键 | 订单号,建议用date('ymd').str_pad($id,6,'0',STR_PAD_LEFT)生成易读单号 |
user_id | INT | FK → users.id | 下单用户 | |
total_amount | DECIMAL(10,2) | NOT NULL | 订单总金额,下单时快照值 | |
status | ENUM('pending','paid','shipped','completed','cancelled') | DEFAULT 'pending' | 订单状态机,状态变更需日志记录 | |
created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | |
order_items | order_id | INT | PK, FK → orders.id | 订单ID |
book_id | INT | PK, FK → books.id | 图书ID | |
quantity | INT UNSIGNED | NOT NULL | 购买数量 | |
unit_price | DECIMAL(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.6 | SHOW 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 >= ?影响行数为0 | SELECT 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与会话问题排查
| 问题现象 | 可能原因 | 快速验证