PHP+MySQL图书商城系统:从交易链路到安全部署的完整拆解
2026/9/18 11:10:43 网站建设 项目流程

简介:一份基于PHP的图书销售网站毕业设计文档,面向计算机专业学生和PHP初级开发者,解决从需求分析、功能模块设计到数据库与代码实现全过程的方案参考问题。资源为单个docx文档,压缩包约233KB,正文含完整的28页论文,包括中英文摘要、目录、引言、电子商务分析等结构,便于直接研读与引用。文档以PHP+MySQL为核心技术栈,融合开源社区常用开发模式,详细设计了前台商品搜索、用户评论、在线购买,以及后台商品管理、用户管理、订单管理等模块,并给出系统测试方法、安全性考虑和实现要点。对于需要完成同类选题论文或独立构建图书电商原型的学习者,这份资源能提供实用的功能框架和可参考的写作范例,帮助梳理设计与编码思路。已有185人学习下载,适合毕业设计、课程设计或技术预研场景参考。

1. 图书商城用 PHP+MySQL,真正值得拆的是这条交易链路

朋友接了一个独立书店的线上订单系统,预算不高,日活也就几百,但要求后台能管商品、管用户、管订单,前台能搜书、加购物车、下单。网上一搜,php 源码里十个有八个是这种结构,可真正能跑通完整交易链路的并不多。这套基于 PHP 的图书销售网站,外层看是典型的 B/S 架构,内层则是 PHP + MySQL + Apache 的经典组合。它没有微服务,没有前端框架,但把商品搜索、评论、购物车、下单、后台权限和订单管理拼成了一个可以实际部署的闭环。拆它的价值在于:你能一次性看明白一个电商网站的数据库设计和 PHP 业务代码是怎么衔接的,适合做课程设计、毕业设计,也适合小团队在低成本下自建图书销售站点。

2. 图书商城的数据模型与 PHP/MySQL 交互

2.1 功能模块与表结构:图书、订单、评论怎么放

这套系统的前台模块包括商品搜索、用户评论、在线购买和登录注册,后台模块包括商品管理、用户管理、订单管理和留言管理。用一句话概括:前台是给用户走完“找到书、放进购物车、提交订单”的路径,后台是给管理员维护这些过程依赖的数据。B/S 架构下,浏览器只负责发起请求和渲染 HTML,真正的业务逻辑全部在 PHP 脚本里,数据则落在 MySQL 的几张表上。

表名用途关键字段
book图书基础信息id, title, author, isbn, category_id, price, stock, cover, status
category图书分类id, name, parent_id
user前台注册用户id, username, password, email, status
admin后台管理员id, username, password, role
order订单主表id, order_no, user_id, total_price, status, created_at
order_item订单明细id, order_id, book_id, num, price
comment图书评论id, book_id, user_id, content, status, created_at

这张表结构是这套系统的骨架。order 与 order_item 是一对多,order_item 里冗余一份 price 快照,可以避免日后修改 book.price 时历史订单的成交价也跟着变。user 与 order 也是一对多,book 与 comment 是一对多。之所以把评论单独拆表,是因为评论有审核状态字段,管理员可以决定是否在前台展示。整个设计不追求范式上的极端干净,而是优先保证读写链路直观,这对一个小型图书商城来说比三范式更重要。

2.2 建表 SQL 的细节:InnoDB、utf8mb4 与 DECIMAL

数据库连接直接用 phpStudy 内置的 MySQL 就能建库,但建表语句不能随手写。常见做法是把 book 和 order 都设计成 InnoDB 引擎,字符集用 utf8mb4,理由很直接:MyISAM 不支持事务,而订单创建和库存扣减必须在一个事务里完成;utf8mb4 能存 emoji 和生僻字,避免用户输入评论时出现乱码。

CREATE TABLE `book` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL COMMENT '书名', `author` VARCHAR(100) NOT NULL DEFAULT '', `isbn` VARCHAR(20) NOT NULL DEFAULT '', `category_id` INT UNSIGNED NOT NULL DEFAULT 0, `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, `stock` INT UNSIGNED NOT NULL DEFAULT 0, `cover` VARCHAR(255) NOT NULL DEFAULT '', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_title` (`title`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; CREATE TABLE `order` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` INT UNSIGNED NOT NULL, `total_price` DECIMAL(10,2) NOT NULL, `status` VARCHAR(20) NOT NULL DEFAULT 'pending', `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

price 字段必须用 DECIMAL(10,2),如果图省事用 FLOAT,等 PHP 里做购物车总价相加时会踩浮点精度坑,5.9 + 8.2 这类计算经常出现 14.0999999,订单金额对不上。索引上,title 加普通索引即可,真要支持全文搜索就用 FULLTEXT,但小型图书商城用 LIKE 模糊查询更简单。order_no 加唯一索引不仅是业务编号,也是防重复下单的兜底约束,后面讲并发时还会用到。

注意一点:order 是 MySQL 的保留字,表名加反引号,或者干脆改成 orders。很多旧源码在这里不做处理,直接写SELECT * FROM order,MySQL 会报语法错误。这是搭建环境后最常见的报错之一。

2.3 PDO 连接与预处理:把 SQL 注入挡在入口

老源码里最常见的连接方式是 mysql_connect,这套代码已经把连接统一到 PDO。连接配置写在单独文件里,每个页面 require 一次,后续要改数据库密码只改一个位置。

<?php // config/pdo.php - 统一数据库连接 $host = '127.0.0.1'; $port = '3306'; $dbname = 'bookstore'; $username = 'root'; $password = '123456'; $dsn = "mysql:host=$host;port=$port;dbname=$dbname;charset=utf8mb4"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; try { $pdo = new PDO($dsn, $username, $password, $options); } catch (PDOException $e) { error_log($e->getMessage()); exit('数据库连接失败,请稍后再试'); }

这里几个参数值得细看:ATTR_ERRMODE 设为 ERRMODE_EXCEPTION 后,SQL 执行失败会直接抛异常,而不是返回 false,方便统一捕获;ATTR_DEFAULT_FETCH_MODE 设为 FETCH_ASSOC,让 fetchAll 拿到关联数组,写模板时直接用 $book['title'];ATTR_EMULATE_PREPARES 设为 false,是让 MySQL 端做真正的预处理,而不是 PHP 本地模拟,既能减少 SQL 注入风险,也能让执行计划被 MySQL 缓存。关闭 display_errors 但保留 error_log,是这套源码在线上环境的标准姿势。

3. 前台购物的三条链路:搜索、购物车与订单状态机

3.1 多条件商品搜索:动态拼 SQL 但参数必须绑定

图书销售网站的前台搜索不是单条件的,用户会同时输入书名关键字、选择分类、限定价格区间。实现时不能每多一个条件就复制一份查询语句,常见的做法是先用数组收集条件片段,再拼接 WHERE,最后统一绑定参数。

<?php $keyword = trim($_GET['keyword'] ?? ''); $catId = intval($_GET['cat_id'] ?? 0); $minPrice = floatval($_GET['min_price'] ?? 0); $maxPrice = floatval($_GET['max_price'] ?? 0); $where = []; $params = []; if ($keyword !== '') { $where[] = '(title LIKE ? OR author LIKE ? OR isbn LIKE ?)'; $like = '%' . $keyword . '%'; $params[] = $like; $params[] = $like; $params[] = $like; } if ($catId > 0) { $where[] = 'category_id = ?'; $params[] = $catId; } if ($minPrice >= 0) { $where[] = 'price >= ?'; $params[] = $minPrice; } if ($maxPrice > 0) { $where[] = 'price <= ?'; $params[] = $maxPrice; } $sql = 'SELECT id, title, author, price, cover FROM book'; if ($where) { $sql .= ' WHERE ' . implode(' AND ', $where); } $sql .= ' ORDER BY id DESC LIMIT 20'; $stmt = $pdo->prepare($sql); $stmt->execute($params); $books = $stmt->fetchAll();

这段代码把搜索参数和 SQL 结构完全分开。$where 数组里放的是带问号的 SQL 片段,$params 按出现顺序存参数值,最后交给 execute 时 PHP 再把它们对应起来。LIKE 的 % 通配符放在参数值里,而不是拼进 SQL,这样即使用户输入 % 或 _,也只会被当成普通字符。搜索条件与 SQL 的对应关系可以简化成这张表:

条件参数SQL 片段
关键字keywordtitle LIKE ? OR author LIKE ? OR isbn LIKE ?
分类cat_idcategory_id = ?
最低价min_priceprice >= ?
最高价max_priceprice <= ?

分页时 LIMIT 和 OFFSET 不能直接用 $_GET 里的值,必须 intval 处理,因为 LIMIT 参数在部分 MySQL 版本里即使走预处理也不能绑定。很多旧代码在这里直接把 page 拼进 SQL,就是典型的注入点。

3.2 Session 购物车:合并、更新与下单前的价格重查

购物车在这套系统里用 Session 实现,结构是 $_SESSION['cart'][book_id] => ['num' => 数量]。用图书 ID 做主键的好处是同一本书多次加购会自然合并数量,不需要额外循环判断。我一般会把购物车操作封装成一个小类,便于在多个页面复用。

<?php class Cart { public static function add($bookId, $num = 1) { if (!isset($_SESSION['cart'])) { $_SESSION['cart'] = []; } if (isset($_SESSION['cart'][$bookId])) { $_SESSION['cart'][$bookId]['num'] += $num; } else { $_SESSION['cart'][$bookId] = ['num' => $num]; } } public static function update($bookId, $num) { if ($num <= 0) { unset($_SESSION['cart'][$bookId]); } else { $_SESSION['cart'][$bookId]['num'] = $num; } } public static function items() { return $_SESSION['cart'] ?? []; } }

购物车类的 add 和 update 只维护数量,不存价格和书名。为什么?因为 Session 里的数据虽然存在服务器端,但用户如果开了多个页面,或者直接改 POST 数据再调接口,价格仍可能被污染。正确的做法是进入结算页时,用购物车里的 book_id 重新从 book 表查一次 title、price、stock,再用数据库里的价格计算总价。PHP 的数组操作在这里很顺手,isset 判断键是否存在,unset 删除条目,foreach 遍历计算总价。Session 本身就是把数组序列化后存成文件,所以不要在 Session 里塞太多对象,否则每个请求都要读一次文件,并发稍高就会出现明显的延迟。

3.3 订单状态机与库存扣减:事务里完成闭环

下单是整个系统里最容易出问题的一步。拆这套源码时,我最关注的不是购物车页面长什么样,而是订单表和库存表的操作顺序。正确的顺序是:开启事务、写订单主表、写订单明细、扣库存、提交事务。中间任何一步失败都要回滚。

<?php try { $pdo->beginTransaction(); $orderNo = date('YmdHis') . mt_rand(1000, 9999); $stmt = $pdo->prepare( 'INSERT INTO `order` (order_no, user_id, total_price, status, created_at) VALUES (?, ?, ?, ?, NOW())' ); $stmt->execute([$orderNo, $userId, $totalPrice, 'pending']); $orderId = $pdo->lastInsertId(); $stmtItem = $pdo->prepare( 'INSERT INTO order_item (order_id, book_id, num, price) VALUES (?, ?, ?, ?)' ); $stmtStock = $pdo->prepare( 'UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?' ); foreach ($cartItems as $item) { $stmtItem->execute([$orderId, $item['book_id'], $item['num'], $item['price']]); $stmtStock->execute([$item['num'], $item['book_id'], $item['num']]); if ($stmtStock->rowCount() === 0) { throw new RuntimeException('库存不足:' . $item['book_id']); } } $pdo->commit(); unset($_SESSION['cart']); } catch (Throwable $e) { $pdo->rollBack(); error_log($e->getMessage()); }

这里最关键的是 UPDATE 语句里的 stock >= ? 条件。它是一个原子判断,MySQL 执行这笔 update 时会锁定这行,库存足够才更新行数,不够则影响 0 行。rowCount 返回 0 就抛异常回滚,避免超卖。order_no 唯一索引在这里是第二道防线,即使两个请求同时生成相同的订单号,第二个 insert 也会报错。这套系统把订单状态设计成一组字符串,比用 0、1、2 数字可读性好,状态流转放在后台统一处理。$totalPrice 是在进入事务之前由购物车里的 book_id 重新查库后计算出来的,不能直接相信 Session。

下表是这套源码里订单状态机的约定:

状态值含义允许操作
pending待支付用户取消,或支付后变为 paid
paid已支付管理员配货,或退款后变为 cancelled
shipped已发货用户确认收货后变为 completed
completed已完成用户可以发表评论
cancelled已取消回补库存,若已支付需退款

4. 后台管理:权限模型、商品上传与订单并发处理

4.1 管理员角色拆分与后端权限校验

后台面板不能只靠隐藏入口保护,必须在前台和后台共用一套权限校验。这套系统把管理员分成系统管理员、商品管理员、订单管理员三类,登录后把角色信息写进 Session。每个后台脚本入口调用同一个权限函数,避免在页面里到处写 if 判断。

角色权限范围
system_admin管理员增删改、数据库备份恢复、全模块访问
product_admin图书分类、图书上架下架、评论审核
order_admin订单查看、发货、取消、退款
<?php session_start(); function requireRole(array $roles) { if (empty($_SESSION['admin_id'])) { header('Location: /admin/login.php'); exit; } if (!in_array($_SESSION['admin_role'], $roles, true)) { http_response_code(403); exit('无权访问当前功能'); } }

调用方式是一行requireRole(['product_admin', 'system_admin']);。这里有个 PHP 运算符的坑:in_array 第三个参数必须传 true,也就是严格比较。如果不传,admin_role 是字符串 '0' 或数字 0 时,in_array('0', ['product_admin']) 之类的比较会出现预期外的结果。权限校验只校验角色不校验操作人,所以管理员账号本身也要做登录状态过期处理,常见做法是给 Session 设置过期时间,超过 30 分钟强制重新登录。

4.2 商品上架的图片上传与防 Webshell

商品管理模块里最容易出安全问题的就是图片上传。旧源码很多只判断扩展名,攻击者改个后缀就能传 PHP 脚本。这里的标准做法是三层校验:扩展名白名单、size 上限、getimagesize 验证真实图片内容。

<?php $allowExt = ['jpg', 'jpeg', 'png', 'gif', 'webp']; $ext = strtolower(pathinfo($_FILES['cover']['name'], PATHINFO_EXTENSION)); if (!in_array($ext, $allowExt, true)) { exit('不允许的文件类型'); } if ($_FILES['cover']['size'] > 2 * 1024 * 1024) { exit('图片不能超过 2MB'); } $info = @getimagesize($_FILES['cover']['tmp_name']); if ($info === false) { exit('文件不是有效图片'); } $newName = date('YmdHis') . mt_rand(1000, 9999) . '.' . $ext; $uploadDir = __DIR__ . '/../upload/'; if (!move_uploaded_file($_FILES['cover']['tmp_name'], $uploadDir . $newName)) { exit('上传失败,请检查 upload 目录权限'); }

pathinfo 取扩展名后转小写,再做白名单比较,这是第一层。getimagesize 能识别出常见的图片格式,遇到伪装成图片的 PHP 文件会返回 false,这是第二层。最后把文件名改成时间戳加随机数,避免使用用户提供的原始文件名,也不要去拼接路径。upload 目录应该禁止执行 PHP,Apache 下可以在该目录放一个 .htaccess:

php_flag engine off RemoveHandler .php .phtml .php5

如果是 Nginx,则用 location 块把 upload 目录的 PHP 解析关掉。这套系统还应该限制后台登录失败次数,简单做法是把失败次数写进 Session 或数据库,连续失败超过 5 次锁 15 分钟。很多 php 上传漏洞里最常见的 webshell 就是通过无校验上传进来的,把三层校验和目录执行权限都做上,比事后扫描日志更省心。

4.3 订单取消与库存恢复:状态约束比 delete 重要

后台订单处理里,取消订单不能直接 DELETE order 记录,否则订单明细和财务对账都会乱。正确的做法是修改状态,同时恢复库存,并且这两个操作也要在事务里。取消订单时要限制原状态只能是 pending 或 paid,防止已经 completed 的订单被误取消两次,造成重复回补库存。

<?php $pdo->beginTransaction(); $stmt = $pdo->prepare( "UPDATE `order` SET status = 'cancelled' WHERE id = ? AND status IN ('pending', 'paid')" ); $stmt->execute([$orderId]); if ($stmt->rowCount() !== 1) { $pdo->rollBack(); exit('订单状态不允许取消'); } $pdo->prepare( 'UPDATE book b INNER JOIN order_item oi ON b.id = oi.book_id SET b.stock = b.stock + oi.num WHERE oi.order_id = ?' )->execute([$orderId]); $pdo->commit();

这段 SQL 的特点是更新库存时 join 订单明细,把每本书的数量一次性加回去,不需要在 PHP 里循环。状态约束写在 UPDATE 的 WHERE 里,比先查再判断更安全,因为如果在 PHP 里先 SELECT 再 UPDATE,两个请求同时处理同一订单时可能都读到 pending,然后都执行回补,库存就多回去了。小网站虽然并发不高,但这类问题一旦出现就需要手工修库。

订单量上来之后,下单完成的通知、日志写入、库存预占可以放到 php 队列里异步处理。但要注意,库存扣减必须保留在下单事务中,不能放进队列,否则连续抢购场景下队列还没消费完,库存已经被卖超了。队列只适合做不需要实时一致的辅助动作。

5. 部署排错与上线前要补的 PHP 安全配置

5.1 用 phpStudy 快速跑起来,并打开 PHP 错误日志

本地跑这套系统不需要自己配 Apache。phpStudy 这类集成环境可以一键切换 PHP 版本和 MySQL 版本,推荐 PHP 7.4 或 PHP 8.0,配合 MySQL 5.7。项目目录放在 WWW 下,修改你的 pdo.php 中的账号密码即可。如果页面白屏,第一件事是打开 php.ini 里的错误日志,而不是盲改代码。

display_errors = Off log_errors = On error_log = "D:/phpstudy_pro/Extensions/php/php7.4.3nts/logs/php_error.log" date.timezone = Asia/Shanghai upload_max_filesize = 20M post_max_size = 25M

display_errors 关闭以后,浏览器端看不到报错细节,避免路径和 SQL 结构泄露;log_errors 开启后,所有 E_WARNING 和 E_NOTICE 都会写进日志。PHP 8 还会把 Notice 升级成 Warning,所以一套老代码想在 PHP 8 上线,必须先扫一遍日志里的废弃函数。时区一定要设成 Asia/Shanghai,否则订单 created_at 会和北京时间差 8 小时。最后在 pdo.php 连接成功后再执行一次SELECT 1,能排除大部分环境问题。

5.2 上线前必查的 PHP 安全项:反序列化、上传目录与会话加固

上线前照着这几项过一遍:所有 SQL 走 PDO 预处理;后台登录入口加验证码和失败锁定;upload 目录禁止脚本执行;Session 设置 httponly;不要把用户输入直接传给 unserialize,php 反序列化漏洞大多是用恶意构造的序列化字符串触发对象方法,必须做签名校验。

<?php // 对需要序列化的数据签名,再考虑是否允许反序列化 $payload = json_encode(['user_id' => 123, 'expire' => time() + 3600]); $sign = hash_hmac('sha256', $payload, 'your-app-secret-key'); $token = base64_encode($payload) . '.' . $sign;

如果项目里确实需要把数据从客户端传回来再解析,不要直接使用 unserialize,应该像上面这样用 JSON 加 HMAC 签名,服务端先验签再解析。这个小技巧同时覆盖了 php 序列化和 php 反序列化两类风险。最后下单一笔测试订单,然后取消订单,再对比 book.stock 和 order_item.num,确认库存回补数与下单扣减数一致,这套系统的交易链路才算真正验收通过。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询