PHP商城源码解析:从面向过程到安全收编实践
2026/9/16 20:58:48 网站建设 项目流程

简介:这是一份基于PHP+MySQL开发的购物商城管理系统源码包,代码采用原生PHP与面向过程风格编写,未依赖任何框架,结构清晰、逻辑直白,适合正在学习PHP Web开发的初学者,也适合用于毕业设计或课程设计参考。包内共65个文件,包含28个PHP功能页面(登录注册、商品管理、订单处理等)、1个SQL数据库脚本、CSS与JS前端文件、32张PNG图片素材,以及DOC格式的函数说明文档和README说明,压缩包大小仅1.64MB,便于快速下载与本地部署。目前已有116人学习下载,代码注释清晰,常用于学生项目复盘、课程设计与二次开发。通过阅读源码可以快速理解PHP与MySQL的配合方式、表单数据处理、会话管理、文件上传等经典技术点,同时还能借鉴大学生编码风格中的简化实现思路,帮助自己快速搭建商城类应用。

1. 面向过程的PHP商城代码,为什么还值得认真看一遍

搜索“PHP购物商城管理系统”的人,多半不是要一套生产级微服务方案,而是要一份能交差、能讲清原理、能快速改动的课程设计或毕业设计源码。这类包解压之后通常是index.phpconn.phpadd_cart.php这样平铺的PHP文件,配一个.sql导入脚本。它们没有Composer,没有MVC目录,没有命名空间,连接数据库的$conn变量顺着 include 在页面之间来回传。这套“大学生编码手法”并不像老程序员说的那么不堪,它把商品展示、登录注册、购物车、下单这一整条链路压缩到了最小可运行状态,很适合用来讲清PHP+MySQL的基础写法。但代价同样明显:SQL拼接多、页面职责边界模糊、错误处理依赖die()。这篇文章就按这类项目最常见的组织方式,把数据库设计、商品列表、购物车、订单事务和本地部署排错拆开讲,最后给一套不用引入框架也能落地的安全收编方案,适合刚接手这类源码的在校生,也适合需要快速改造旧商城的老手。

2. 原生PHP+Mysql商城的文件结构与数据库设计

2.1 平铺式include:读懂这类代码的入口

面向过程的PHP商城有个非常明显的特征:几乎每个页面顶部都有一段相同的数据库连接代码。不是封装成单例,不是依赖注入,就是复制粘贴。文件结构一般长这样:

shop/ ├─ config.php // 数据库连接配置 ├─ conn.php // 引入config并建立连接,很多项目把两者合并 ├─ index.php // 商品列表首页 ├─ detail.php // 商品详情 ├─ login.php // 登录页 ├─ logout.php // 退出登录 ├─ register.php // 注册 ├─ cart.php // 购物车页面 ├─ add_cart.php // 加购处理 ├─ order.php // 订单确认与提交 ├─ admin/ │ ├─ admin_login.php // 后台登录 │ ├─ goods_add.php // 商品添加 │ └─ goods_list.php // 商品管理 └─ install.sql // 数据库导入脚本

看懂这套代码的关键在于include的调用方向。每个业务页面顶部先include 'config.php',拿到$conn,然后直接写SQL。函数文件通常叫function.php,里面的函数体靠global $conn拿到连接句柄。这种写法的优点是链路短:从浏览器请求到数据返回,只需要经过一个文件,调试时打var_dump()就能定位。缺点是$conn随处可见,一旦页面被直接访问而没有 include,就会报“未定义变量”一类错误。

<?php // config.php 最简版本 $host = '127.0.0.1'; $port = 3306; $user = 'root'; $pass = '123456'; $dbname = 'shop'; // 第5个参数是端口,很多老代码写的是3306,注意不要写成字符串端口和密码颠倒 $conn = @mysqli_connect($host, $user, $pass, $dbname, $port); if (mysqli_connect_errno()) { die('数据库连接失败: ' . mysqli_connect_error()); } mysqli_set_charset($conn, 'utf8mb4');

这段代码里有两处新手老改错。第一,mysqli_connect()的参数顺序是主机、用户名、密码、库名、端口,有人把密码和库名写反,导致“Access denied for user”怎么查都查不出原因。第二,@符号抑制了连接错误输出,连不上时页面白屏,只看到die()里的中文提示,这时应该先去掉@看完整报错,或者用mysqli_connect_errno()拿错误码去mysql命令行里核对账号权限。字符集设置用utf8mb4而不是utf8,否则商品描述里的特殊符号转义写入时会出问题。

2.2 商城最少需要几张表:从install.sql反推业务

拿到安装包第一步不是急着配环境,而是打开install.sql看表结构。一个能跑通“浏览商品→加购→下单”流程的商城,最少需要六张表。

表名用途关键字段注意事项
user前台用户id, username, password, email密码列至少 varchar(255),为后续换 password_hash 留空间
category商品分类id, name, parent_id面向过程项目一般只做一级分类,parent_id 留着扩展
goods商品表id, cid, title, price, stock, image, statusstatus=1 才在前台展示,下架商品不要物理删除
cart购物车id, uid, gid, num, addtime多数用 session 替代,但数据表方案更接近真实商城
orders订单主表id, orderno, uid, total, status, addtime订单号用date('YmdHis') . rand(1000,9999)生成
order_detail订单明细id, order_id, gid, price, num下单时必须把商品价格冗余保存,商品改价不影响历史订单

MySQL 建表时,goods.price不要用float,用decimal(10,2)。浮点类型在累加计算时会出现 29.999999 之类的精度误差,金额计算不是闹着玩的。库存字段stockint unsigned,下单时在事务里做扣减,而不是先查出来再在PHP里减完写回去,并发场景下会超卖。

2.3 用mysql命令验证导入结果和字符集

导入install.sql之前,先在命令行确认 MySQL 服务是活的、账号能登录。常见做法是打开终端执行:

mysql -uroot -p -e "SHOW VARIABLES LIKE 'character_set%';" # 输出中 character_set_server 和 character_set_database 建议都是 utf8mb4

这条命令的作用是直接通过 MySQL 客户端检查服务器字符集配置,绕开 PHP 层,能快速定位“页面显示正常但写入数据库变问号”的问题。如果character_set_serverlatin1,后边 PHP 代码里写再多mysqli_set_charset也会在入库时被转成乱码。修改方式是在 MySQL 配置文件my.cnf[mysqld]段加character-set-server=utf8mb4,然后重启 MySQL 服务。

导入表结构用重定向即可:

mysql -uroot -p shop < install.sql mysql -uroot -p -e "USE shop; SHOW TABLES;"

注意shop这个库要提前建好,CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4;否则会报Unknown database。第二句SHOW TABLES用来确认导入结果,看到六张表都在再进下一步。这一步检查到位了,后续商品页和购物车页的联调会顺很多。

3. 用面向过程+mysqli实现商品、购物车与订单

3.1 商品列表页:查询循环输出

商品列表页是整站访问量最大的页面,面向过程写法通常是先拼一条 SQL,然后用while循环输出。下面这段代码几乎是这类项目的标准模板:

<?php // index.php 商品列表 include 'config.php'; // 分类筛选,参数必须强制转整型,防止SQL注入 $cid = isset($_GET['cid']) ? intval($_GET['cid']) : 0; $where = $cid > 0 ? "WHERE cid = $cid" : ''; // 分页参数:page从1开始,每页显示12个商品 $page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1; $pageSize = 12; $offset = ($page - 1) * $pageSize; $sql = "SELECT id, title, price, image, stock FROM goods WHERE status = 1 $where ORDER BY id DESC LIMIT $offset, $pageSize"; $result = mysqli_query($conn, $sql); ?> <table> <tr><th>商品</th><th>价格</th><th>库存</th><th>操作</th></tr> <?php while ($row = mysqli_fetch_assoc($result)): ?> <tr> <td><?php echo $row['title']; ?></td> <td><?php echo $row['price']; ?></td> <td><?php echo $row['stock']; ?></td> <td><a href="detail.php?id=<?php echo $row['id']; ?>">查看</a></td> </tr> <?php endwhile; ?> </table>

这段代码有几个细节值得拆开讲。第一,intval($_GET['cid'])直接把参数变成整数,注入字符全被干掉,这是面向过程项目里成本最低的防注入手段。第二,分页 SQL 用了LIMIT $offset, $pageSize$offset必须经过计算而不是直接拼$_GET['page'],否则page=-1会让 MySQL 直接报错。第三,mysqli_fetch_assoc()拿的是关联数组,字段名直接对应数据库列名,比mysqli_fetch_row()的数字下标可读性好得多。输出商品名时要记得加htmlspecialchars(),商品标题里如果包含引号或尖括号,会直接破坏页面结构。

3.2 购物车:session方案还是数据表方案

课程设计级别的商城,购物车实现方式分两派。一派用$_SESSION['cart']存数组,刷新页面不丢,关闭浏览器就清空;另一派建cart表,用户登录后购物车永久保留。面向过程的代码两种都有,从“更像真实商城”的角度看,数据表方案更能体现MySQL的增删改查能力。

<?php // add_cart.php 加购处理 include 'config.php'; session_start(); $uid = isset($_SESSION['uid']) ? intval($_SESSION['uid']) : 0; if ($uid <= 0) { die('请先登录'); } $gid = intval($_GET['gid']); $num = isset($_GET['num']) ? max(1, intval($_GET['num'])) : 1; // 先查商品是否存在且上架 $check = mysqli_query($conn, "SELECT id FROM goods WHERE id = $gid AND status = 1"); if (mysqli_num_rows($check) == 0) { die('商品不存在'); } // 已存在则数量+1,不存在则插入 $sql = "INSERT INTO cart (uid, gid, num, addtime) VALUES ($uid, $gid, $num, NOW()) ON DUPLICATE KEY UPDATE num = num + $num"; mysqli_query($conn, $sql); header('Location: cart.php'); exit;

这里用了一个MySQL语法点:ON DUPLICATE KEY UPDATE。前提是cart表有uidgid的联合唯一索引,否则这个语句不会触发数量累加,而是每次加购都插入一行新记录。索引在install.sql里要提前定义好:

ALTER TABLE cart ADD UNIQUE KEY uk_uid_gid (uid, gid);

面向过程函数里处理这类逻辑时,最常见的坑是忘了global $conn。如果在function.php里写function addCart($uid, $gid, $num),函数内部直接写mysqli_query($conn, ...)会报“未定义变量 $conn”。必须在函数第一行声明global $conn;才能访问到config.php里创建的连接。这是和面向对象最大的思维差异:OOP 把连接存成属性,面向过程靠全局变量传递。

3.3 下单事务:commit与rollback的使用边界

下单是整个商城唯一必须用事务的地方。用户点击“提交订单”时,后台要做两件事:往orders表插一条主记录,往order_detail表插多条明细,同时扣减goods表库存。任何一个步骤失败,都不能留下半张订单。

<?php // order_submit.php 下单处理 include 'config.php'; session_start(); $uid = intval($_SESSION['uid']); // 关闭自动提交,开启事务 mysqli_begin_transaction($conn, MYSQLI_TRANS_START_READ_WRITE); try { // 生成订单号并插入主表 $orderno = date('YmdHis') . rand(1000, 9999); $sql = "INSERT INTO orders (orderno, uid, total, status, addtime) VALUES ('$orderno', $uid, 0, 0, NOW())"; if (!mysqli_query($conn, $sql)) { throw new Exception('订单创建失败'); } $orderId = mysqli_insert_id($conn); // 从购物车读取商品并写入明细,同时扣库存 $cartSql = "SELECT gid, num FROM cart WHERE uid = $uid"; $cartResult = mysqli_query($conn, $cartSql); $total = 0; while ($item = mysqli_fetch_assoc($cartResult)) { $gid = intval($item['gid']); $num = intval($item['num']); // 扣库存,条件里带上当前库存值,防止超卖 $updateSql = "UPDATE goods SET stock = stock - $num WHERE id = $gid AND stock >= $num"; if (mysqli_affected_rows($conn) == 0) { throw new Exception("商品 ID $gid 库存不足"); } // 查询商品价格并写入明细,这里做了冗余 $priceResult = mysqli_query($conn, "SELECT price FROM goods WHERE id = $gid"); $price = mysqli_fetch_assoc($priceResult)['price']; $total += $price * $num; $detailSql = "INSERT INTO order_detail (order_id, gid, price, num) VALUES ($orderId, $gid, $price, $num)"; if (!mysqli_query($conn, $detailSql)) { throw new Exception('明细写入失败'); } } // 更新订单总金额 mysqli_query($conn, "UPDATE orders SET total = $total WHERE id = $orderId"); // 清空购物车 mysqli_query($conn, "DELETE FROM cart WHERE uid = $uid"); // 全部通过,提交事务 mysqli_commit($conn); header('Location: order_success.php?orderno=' . $orderno); } catch (Exception $e) { mysqli_rollback($conn); die('下单失败: ' . $e->getMessage()); }

这段代码里有三个面向过程项目必须养成的习惯。mysqli_affected_rows($conn)返回的是上一条语句影响的行数,UPDATE goods SET stock = stock - $num WHERE id = $gid AND stock >= $num这个 SQL 巧妙地利用 WHERE 条件做并发控制:库存不够时影响行数为 0,程序立刻抛异常回滚。mysqli_insert_id($conn)拿的是刚插入的orders.id,必须在插入后马上调用,中间不能穿插其他查询。最后,事务结束后要手动mysqli_commit(),只写mysqli_begin_transaction()不写提交,脚本结束时会自动回滚,订单会神秘消失。

订单状态字段status建议用整数而不是字符串:0待付款、1已付款、2已发货、3已完成、4已取消。面向过程代码里用switch做状态映射,不要在数据库里存“待付款”这种中文值,否则后续统计报表写SQL会非常痛苦。

3.4 面向过程页面常见的两个脏数据出口

商品列表、详情页、搜索页是注入重灾区,购物车和订单页则是 XSS 重灾区。典型的搜索页代码长这样:

// search.php 危险写法 $kw = $_GET['kw']; $sql = "SELECT * FROM goods WHERE title LIKE '%$kw%'"; $result = mysqli_query($conn, $sql);

用户在地址栏输入search.php?kw=%27%20OR%201=1%20--%20,拼出来的 SQL 就变成WHERE title LIKE '%%' OR 1=1 -- %',整张商品表被拖出来。搜索这种场景不适合用intval(),因为关键词本来就是字符串。正确做法是转义或预处理,面向过程里最省事的转义函数是:

$kw = mysqli_real_escape_string($conn, $_GET['kw']);

mysqli_real_escape_string()会对'"\等字符做转义,让它们失去SQL语法含义。注意它依赖数据库连接的字符集,所以必须先mysqli_set_charset()再调用。商品标题输出的 XSS 预防则是另一件事,等到了第五章统一处理。

4. PHP+Mysql商城本地部署:版本、报错与字符集

4.1 部署前先检查PHP版本和已移除的函数

老源码包里偶尔还能见到mysql_connect()这种不带i的老函数。PHP 7.0 已经彻底移除了mysql_*扩展,直接运行会报致命错误Call to undefined function mysql_connect()。部署前先跑两个命令:

php -v php -m | grep mysqli

第一行查看PHP版本,第二行确认mysqli扩展已经启用。如果php -m的输出里没有mysqli,Windows下需要在php.ini里去掉extension=mysqli前面的分号,Linux下需要安装php-mysql扩展包。还有一种常见情况是命令行PHP和集成面板的PHP版本不一致:命令行是 5.6,面板里是 8.1,代码在浏览器里正常,在终端跑脚本却报错。这种问题优先检查环境变量里php指向哪个路径。

PHP 8.0 以后还有几个影响老商城运行的变化:each()函数被移除,原来用来遍历数组的while (list($k, $v) = each($arr))要改成foreach ($arr as $k => $v)count()在参数不是数组或Countable对象时会抛 TypeError;$HTTP_RAW_POST_DATA早就没了,获取原始请求体要用file_get_contents('php://input')。把报错信息复制到搜索框里,基本都能定位到具体是哪一行语法需要改。

4.2 三个常见报错的定位逻辑

学弟学妹把压缩包发给我的时候,最常见的三个报错其实是可预测的。

报错信息原因解决路径
Connection refusedMySQL服务没启动,或端口不是3306检查服务状态,mysql -uroot -p能否登录
Access denied for user 'root'@'localhost'密码错误,或账密写反核对 config.php 参数顺序,去mysql命令行验证
Uncaught Error: Call to undefined function函数名拼错,或扩展未启用php -m检查扩展,确认函数名是 mysqli 前缀
Headers already sent输出内容在 header() 之前产生检查文件开头有没有空格或UTF-8 BOM

最后这个 BOM 问题在大学项目里出现概率极高。用记事本编辑过PHP文件再保存,文件头部会多三个不可见字节,header('Location: xxx')就会报 “headers already sent”。解决方法是用 VS Code 打开文件,右下角编码选择“UTF-8”,重新保存,或在输出前执行ob_start()

4.3 上传图片和商品描述的中文乱码处理

商城后台上传商品图片时,文件名经常是中文,存到数据库再显示出来就变成一堆百分号。原因有两个层面:一是HTTP传输过程中浏览器对中文文件名做了urlencode编码,二是数据库表字符集不是utf8mb4。处理上传文件名的标准做法是丢弃原名,用时间戳重新生成,从根上避开编码问题。

<?php // admin/goods_add.php 图片上传处理 $ext = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION); // 白名单校验,只允许图片格式 $allow = ['jpg', 'jpeg', 'png', 'gif', 'webp']; if (!in_array(strtolower($ext), $allow)) { die('不支持的图片格式'); } // 文件名用时间戳+随机数,避免中文乱码和路径穿越 $newName = date('YmdHis') . '_' . rand(1000, 9999) . '.' . $ext; $target = './uploads/' . $newName; move_uploaded_file($_FILES['image']['tmp_name'], $target);

pathinfo(..., PATHINFO_EXTENSION)提取扩展名,strtolower()统一成小写再和$allow白名单比较,JPGjpg都能通过。文件名里的冒号、斜杠这类字符在 Windows 和 Linux 文件系统里表现不同,用date('YmdHis')加随机数生成新名是最保险的。商品描述字段如果出现中文乱码,先确认 HTML 页面头部有<meta charset="utf-8">,再确认 PHP 文件本身是 UTF-8 保存,最后看数据库连接mysqli_set_charset($conn, 'utf8mb4')是否在查询前执行。三层都对齐了,乱码问题基本绝迹。

5. 无框架商城最小安全收编:预处理、哈希与自检

5.1 把登录和详情页的拼接SQL换成预处理

面向过程代码整体改造工作量大,但有两个页面必须优先处理:登录页和商品详情页。登录页被注入直接等于账号被接管,详情页的id参数虽然用了intval()能防注入,但换成预处理语句后代码风格更统一,后续维护也少踩坑。以登录查询为例:

<?php // login.php 使用mysqli预处理替代拼接 $username = $_POST['username']; $password = $_POST['password']; $stmt = mysqli_prepare($conn, "SELECT id, username, password FROM user WHERE username = ?"); mysqli_stmt_bind_param($stmt, 's', $username); mysqli_stmt_execute($stmt); $result = mysqli_stmt_get_result($stmt); $user = mysqli_fetch_assoc($result); mysqli_stmt_close($stmt);

mysqli_prepare()先把SQL语句发送到MySQL服务端做预编译,?是参数占位符,后续mysqli_stmt_bind_param()s表示绑定一个字符串类型参数。这种方式把数据和SQL结构彻底分开,任何引号、注释符都只是字符串内容,不再具有SQL语法含义。注意mysqli_stmt_get_result()需要mysqlnd驱动,集成环境面板默认都有;如果运行时报“Call to undefined function”,说明PHP装的是老版本libmysqlclient,改用mysqli_stmt_bind_result()配合mysqli_stmt_fetch()也能实现同样的查询。

5.2 把md5密码升级成password_hash

老商城几乎没有例外地用md5($password)存密码,这种散列已经被彩虹表覆盖到接近裸奔。改造方案是用PHP自带函数,一行代码换存储,一行代码换校验:

// 注册时存储 $hash = password_hash($password, PASSWORD_DEFAULT); // 登录时校验 if (password_verify($password, $user['password'])) { // 密码正确,写入session }

PASSWORD_DEFAULT目前会生成bcrypt格式的哈希串,长度60字符,所以前面建表时要求password字段留varchar(255)。老数据里的md5哈希无法反向还原,但可以在登录校验时做兼容:如果$user['password']长度是32且与md5($password)相等,就自动升级为password_hash($password, PASSWORD_DEFAULT)并写回数据库,用户无感知完成了过渡。这个技巧在接手旧项目时非常实用。

5.3 交付前跑一遍语法检查和文件扫描

最后收尾时,别只点开页面点两下就完事。用命令做两轮全量检查,比肉眼强得多:

# 遍历所有php文件,检查语法错误 find . -name "*.php" -exec php -l {} \; # 扫描常见的危险函数和后门特征 grep -rn "eval(" --include="*.php" . grep -rn "base64_decode(" --include="*.php" .

php -l只做语法解析不执行代码,能一次性揪出所有缺分号、少括号的问题。grep扫到evalbase64_decode时,如果代码里没有合理的业务场景,比如模板引擎或加密解密组件,就要打开文件逐行看上下文,这类函数是PHP webshell 最常见的藏身点。检查完这两项,再把第五章改过的登录和详情页重新跑一遍,确认购物车加购和下单事务没有受到预处理改造的影响。一套原本只求能跑的课程设计商城,到了这一步才算真正达到可交付、可演示、可继续扩展的状态。

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

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

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

立即咨询