前前后后做了不少PHP项目,从企业站到后台管理系统都碰过,但真正让我觉得“这类网站有一套自己的设计逻辑”的,还是这个基于PHP的家友家具网站。原因很简单:家具网站不是普通电商,它既有大图展示的视觉压力,又有SKU规格和配送安装这类复杂维度,还要在纯PHP的技术栈下做出不输框架项目的结构。这篇文章就围绕这个项目,从需求梳理、数据库设计、核心模块实现到安全防护和经验排查,完整拆一遍,给你一份能直接参考复现的实操记录。
如果你正在做毕设、接私活或者公司内部要搭一个家具展示与交易类站点,这篇文章适合你。会涉及到PHP会话控制、MySQL多表关联、购物车库存联动、后台权限控制这些重头戏,也会讲一些常规教程里不会写的坑,比如图片批量上传的内存溢出、规格关联的商品检索怎么设计SQL,以及订单状态机为什么要用整型而不是字符串。先说明一下,项目本身是原生PHP + MySQL实现的,没有引入Laravel或ThinkPHP这类重框架,并不是说框架不好,而是对于家具网站这种“业务规模中等、页面逻辑以展示为主”的场景,原生PHP的可控性是最好的,这一点在后文会给出详细理由。
1. 项目定位与整体设计思路
1.1 家具网站的核心需求解析
拿到“家友家具网站”这个题目时,首先要做的是区分“家具展示站”和“家具电商站”。前者只需要公司介绍、产品展示、案例效果图,说白了是个动态官网;后者则需要完整的商品加入购物车、下单、订单管理、后台库存同步。从项目名里的“设计与实现”来看,这套系统偏向后者,但又不是像淘宝那样的大而全,而是一个“会员制家具商城”,核心角色有三种:访客、注册会员、后台管理员。
访客能浏览产品分类、查看家具详情、搜索商品;注册会员在前者基础上增加了加入购物车、提交订单、查看个人订单和修改资料的功能;管理员负责商品上下架、分类维护、订单处理和会员管理。这是最典型的家具电商业务闭环。为什么要单独把分类拿出来说?因为家具产品的分类很特殊,它可以按空间分(客厅、卧室、书房),也可以按材质分(实木、板式、布艺),还可以按风格分(现代、中式、北欧),在设计数据库时如果用单一的分类字段,后面检索会非常被动。我最后采用的是“一级分类 + 二级分类 + 标签”的混合结构,具体见第2章。
还要注意一点:家具类商品价格高、决策周期长,用户很少直接下单,往往是在几个商品详情页之间反复比较。所以详情页设计不能只放一张图,要有产品轮播大图、规格参数表、细节实拍和配送说明。这套系统的详情信息是存在一个独立的 product_detail 字段里的长文本,提交表单时用富文本编辑器填充,而不是为每个参数单独建列。这样做的好处是灵活,运营可以自由排版;代价是无法对详情内容里的单个参数做SQL检索。对家具这种固定参数相对稳定的品类,我建议正文存长文本,价格和材质这些关键参数则必须单独建列,方便做列表页的筛选条件。
1.2 为什么选用原生PHP而非框架
聊一下技术选型。当时面临的选择有三个:原生PHP、ThinkPHP、Laravel,当然如果放宽边界,甚至可以用PHP的Workerman做常驻内存服务,但对这个项目没必要。
ThinkPHP的优势是中文文档全面、上手快,国内很多教学和商业项目都在用它。Laravel的优势是生态丰富、代码规范、模型层强大,缺点是它对服务器要求稍高,而且引入的概念特别多,比如中间件、门面、容器,如果接手的人PHP基础一般,后期维护成本反而上去了。原生PHP则是最“透明”的,一条请求从入口进来,走到哪个文件、查了哪张表、返回什么页面,全部在自己的掌控范围里。
对于“家友家具网站”这个量级的项目——预计商品几百件、日访问量几千、会员几千人——原生PHP加MySQL完全够用。另外还有一个实际考虑:这套项目通常承担的是一次完整的“设计与实现”任务,比如课程设计或毕业设计,需要把需求分析、ER图、数据流图、系统测试这些环节做完整。用框架会掩盖掉很多东西,比如手写SQL关联的功底、Session机制的理解、文件上传处理等,而被隐藏掉的这些恰恰是评分或答辩时最容易被追问的部分。从技术学习和底层还原的角度,原生PHP更合适。
项目采用了一个简化版的MVC分层:index.php 作为前端控制器,把请求分发到 controller 目录下的类,model 目录封装数据库操作,view 目录存放HTML模板。模板不用Smarty,因为Smarty的语法在PHP7以后越来越没有必要,直接在PHP文件里写HTML并采用短标签分隔,配合原生 htmlspecialchars() 函数转义,简单又安全。前端库只用了jQuery和Bootstrap,没有引入Vue和React,这样能在不依赖Node构建工具的前提下快速完成页面开发。整个项目目录结构如下:
code复制home_furniture/
├── public/ # Web根目录,Apache指向这里
│ ├── index.php # 入口文件
│ ├── static/ # css/js/images
│ └── uploads/ # 商品图片存储目录
├── application/
│ ├── controller/ # 控制器
│ ├── model/ # 数据模型
│ ├── view/ # HTML模板
│ ├── core/ # 核心类(Router、DB、Session等)
│ └── config/ # 数据库配置文件
└── sql/ # 建表SQL脚本
1.3 页面架构和用户动线规划
动线设计决定了页面之间的跳转关系。前台部分我梳理出几个关键页:首页(轮播、推荐分类、热销单品)、列表演示页(带左侧分类导航)、搜索结果显示页、商品详情页、购物车页面、订单结算页、支付完成回执页、个人中心。
首页的重要性对家具网站尤其明显。用户进入一个家具网站,最先感知到的是整体氛围,而不是某一个沙发多少钱。所以首页的轮播图区域占了将近一个屏,后面依次是“客厅系列”“餐厅系列”“卧室系列”三个带图片入口的板块,再往下是新品尝鲜和限时特惠两个活动区。每个区域的商品都通过PHP从数据库实时读取,而不是写死HTML。这样运营在后台改了上架状态,前端马上能看到变化。
商场里逛家具店,导购通常会先问“您家是什么装修风格”“要放在客厅还是卧室”,这对应到网站上就是“分类浏览”和“搜索”。因此商品列表页的筛选条件我做了三组:分类筛选、价格区间、材质标签。用户选择了北欧风格、客厅、5000到10000元区间之后,URL会变成 ?cate=3&min_price=5000&max_price=10000&tag=nordic,Java或者PHP开发者一眼就能看出,这其实就是一组干净的GET参数组合,在控制器里把它们拼成SQL的WHERE条件就行。后续如果你要在这个项目上做伪静态,也可以直接基于这套参数生成 /list-cate3-price5000-10000-tagnordic.html 的格式,兼容上不下力气。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与核心功能模块
2.1 数据库表设计与关系说明
数据库是这个家具网站的基座。整个项目我一共设计了8张核心表,这里先说清楚每张表的职责和关联关系,再给一份直接能用的建表SQL。
会员表 user:用户ID、用户名、密码(存的是 password_hash() 生成的加密串)、手机号、邮箱、注册时间、状态。
分类表 category:分类ID、父级ID(0表示一级分类)、分类名、排序值、LOGO图。一级分类比如客厅家具、卧室家具、餐厅家具,二级分类比如布艺沙发、皮沙发、实木床。
商品表 product:商品ID、分类ID(关联二级分类)、商品名、主图、价格(用分做单位,避免浮点数误差)、库存、销量、是否上架、创建时间。另外还有“材质标签”字段,用逗号隔开存多个标签,比如“实木,北欧,白蜡木”。
商品图集表 product_image:因为一个商品有多张轮播图,把图片表和商品表拆开,商品表只保留主图字段,图集表再存剩余图片,是一种更规范的一对多设计。
购物车表 cart:ID、用户ID、商品ID、数量、加入时间。这里没有用Session实现购物车,而是落到数据库。原因我会在第4章细讲。
订单表 orders:订单号、用户ID、总金额、收货人、手机号、详细地址、订单状态、下单时间、支付时间。
订单项表 order_item:订单表的一行只对应一次下单行为,具体买了哪些商品、每个多少件,是多条记录放在订单项表里,通过订单号关联。
管理员表 admin:管理员ID、用户名、密码、最后登录时间、登录IP。
为什么订单表要拆成 orders 和 order_item?这是个非常典型的表设计题。假设一个订单里买了沙发、茶几和台灯三样商品,如果不用订单项表,就得把三个商品和数量拼成一个长字符串塞到一个字段里,等到修改订单、取消某个单品、统计热销商品时全要靠字符串拆解,坑会越来越大。拆表之后,“查询某个订单里有哪些商品”就是一条简单的等值连接SQL,统计销量也直接对 order_item 按 product_id 分组求和。同理,商品图集单独建表也是同样思路,核心原则就是“一个字段只描述一个事实”。
2.2 核心建表SQL与字段设计注释
下面给出几段最关键的建表SQL,编码统一用 utf8mb4。这里多说一句:千万别为了省空间用 utf8,utf8 在MySQL里最多存3个字节,遇到Emoji或某些生僻字会报错或变成问号。用户的收货地址里完全可能填带生僻字的街道名,到时候哭都来不及。
sql复制CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(255) NOT NULL COMMENT '密码hash',
`phone` varchar(20) DEFAULT '' COMMENT '手机号',
`email` varchar(100) DEFAULT '' COMMENT '邮箱',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1正常 0禁用',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员表';
password 字段务必给 varchar(255),因为 password_hash() 默认生成的PASSWORD_BCRYPT长度为60,未来如果换成Argon2算法,长度会超过60,留够余量省得到时候改表。手机号和邮箱为什么允许为空?因为注册时可以只用用户名加密码,会员中心再补全联系方式。电商场景里手机号应该做登录账号之一,但家具站低频复购、浏览为主,我先支持用户名登录。
商品表的重点是价格和库存字段:
sql复制CREATE TABLE `product` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`category_id` int(11) NOT NULL COMMENT '关联category.id',
`name` varchar(150) NOT NULL COMMENT '商品名称',
`main_image` varchar(255) DEFAULT '' COMMENT '主图URL',
`price` int(11) NOT NULL COMMENT '价格,单位分',
`stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存',
`sales` int(11) NOT NULL DEFAULT '0' COMMENT '销量',
`tags` varchar(100) DEFAULT '' COMMENT '标签,逗号分隔',
`detail` text COMMENT '富文本详情',
`is_on` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1上架 0下架',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `category_id` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
价格为什么用 int 而不是 decimal?因为在PHP里操作浮点数做加减乘除容易出现精度问题,比如0.1加0.2结果不等于0.3,这在涉及金额时是致命的。把“元”换算成“分”存成整数,输出时除以100格式化,这是PHP开发中最稳妥的做法。再提一个细节:字段类型为 text 的 detail 无法走索引,所以千万不能把 name 或其他要查询的字段设为 text,商品名称最长150个字符足够,不要因为偷懒全都给成 text。
订单号的设计也有讲究,我采用 date('YmdHis') 拼接随机数和用户ID后缀的方式生成,例如 202501201530451234。这里的思路是确保同一秒内不同用户并发下单也不会撞号,同时从订单号本身能看出下单时间和用户。有同事问过我为什么不用数据库自增ID做订单号,因为订单号会出现在支付接口、短信通知、客服沟通等多个外部场景,是业务号。数据库主键ID一旦泄露,别人就能通过遍历ID推测出你的订单量,这是不必要的风险。
2.3 商品多图和分类检索模型实现
商品图集表结构:
sql复制CREATE TABLE `product_image` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL,
`image_url` varchar(255) NOT NULL,
`sort_order` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品图集表';
查询某个商品的完整信息时,用两条SQL就够:一条查商品主表和分类信息,另一条按 sort_order 排序查图集。PHP端如果用PDO,可以写一个事务把两条查询包起来保证一致性——不过实际读操作通常不需要事务,因为即使两条查询之间有别的操作改了图集,也是极小概率事件。真正用事务的是“下单扣库存”这类写操作串行场景,会在第4章专门讲。
分类检索的SQL有两种写法。第一种是子查询,第二种是JOIN。子查询的好处是直观,适合分类只有一两级、数据量几百条的场景;JOIN在数据量大、使用索引时性能更优。这里给一段在列表页用JOIN查“某分类下所有上架商品,按销量排序,取一页20条”的写法:
sql复制SELECT p.id, p.name, p.main_image, p.price, p.sales, c.name AS cate_name
FROM product p
LEFT JOIN category c ON p.category_id = c.id
WHERE p.is_on = 1
AND (p.category_id = :cid OR c.parent_id = :cid OR c.id = :cid)
ORDER BY p.sales DESC
LIMIT :offset, 20;
看起来简单,但要注意一个问题:如果 :cid 传的是一级分类ID,商品直接挂在某个二级分类下,p.category_id = :cid 只会匹配二级分类ID,所以需要加入 OR c.parent_id = :cid 这个条件。如果你一口气把一级分类下几百个商品全查出来再在PHP里循环判断,性能根本扛不住。写成上面这条SQL就能让MySQL在索引里完成匹配,这是从实际业务反推SQL设计的一个好例子。
最终选用的过滤方式是把限制条件的构造放到PHP里做拼接,而不是直接在SQL里写死。这样搜索参数多的时候也方便扩展。需要提醒的是:拼接SQL条件必须使用PDO预处理语句的 ? 或 :name 占位符,绝对不能用字符串拼接变量,否则就是赤裸裸的SQL注入漏洞。后面安全章节会专门说这个问题,这里先记住结论。
3. 前台页面开发与关键技术细节
3.1 首页数据抽取与公共视图搭建
前台页面以Bootstrap为布局基础,但Bootstrap默认样式比较“平淡”,家具网站需要的是温馨、高档的氛围,所以我在定制主题上下了一些功夫:整体色调用了暖灰和原木色(色值分别是 #f7f3ec 和 #b08968),导航栏固定在顶部,半透明毛玻璃效果,右侧显示当前登录用户的昵称或“登录/注册”入口。
首页的数据抽取是通过入口控制器 IndexController 来做的。先实例化 ProductModel,依次调用 getHomeSlide()、getHotProducts()、getNewProducts() 三个方法,分别获取轮播图和热销、新品两类商品,然后渲染到 home/index.php 模板。模板中每个商品卡片都是同一个结构,用 foreach 循环输出,主要代码片段如下:
php复制<?php foreach ($hotProducts as $item): ?>
<div class="col-md-3 col-sm-6 product-card">
<a href="index.php?c=product&a=detail&id=<?= $item['id'] ?>">
<img src="<?= htmlspecialchars($item['main_image']) ?>" alt="<?= htmlspecialchars($item['name']) ?>" class="img-responsive">
</a>
<h3><?= htmlspecialchars($item['name']) ?></h3>
<p class="price">¥<?= number_format($item['price'] / 100, 2) ?></p>
</div>
<?php endforeach; ?>
看这个模板,有几个地方建议照抄:所有输出到HTML的文本和属性值都必须经过 htmlspecialchars() 转义,防止XSS;价格输出时用 number_format() 格式化,避免出现 9980.5 这种用户看不懂的金额。主图路径虽然是从数据库读的,但既然要拼进 src,就必须用转义函数包一层,防止商品名里的特殊字符破坏 alt 属性结构。
首页的另一个重要区域是品牌故事和家具定制流程,这部分不需要单独建表,因为内容基本不变化。直接在PHP文件里输出静态文案就行。如果后续运营想自己改文案而不动代码,再把它挪到后台的“内容管理”功能里,扩展预案已经留好了。这里需要避免的就是“什么都要做后台维护”,对一个中小型家具站点来说,过度设计是把项目拖垮的最大原因。
3.2 商品列表与详情页的查询实践
列表页左侧是分类导航。分类导航的展开逻辑是:先展示所有一级分类,点击一级分类后右边栏目显示其下二级分类。实现上用PHP递归函数先从 category 表拿到整棵分类树,再嵌套到模板的HTML中。数据量少,即使写成三层嵌套循环也没事。如果是几千个分类的B2B平台,就要考虑用预排序树(左右值法)了,但家具站远远到不了这个级别。
列表页的核心逻辑在 ProductController::getList(),参数处理方式如下:
php复制$where = 'is_on = 1';
$params = [];
if (!empty($_GET['cate'])) {
$where .= ' AND (category_id = :cid OR parent_category_id = :cid)';
$params[':cid'] = intval($_GET['cate']);
}
if (!empty($_GET['min_price'])) {
$where .= ' AND price >= :min_price';
$params[':min_price'] = intval($_GET['min_price']) * 100;
}
参数全部做了 intval() 或类似的类型校验,这能有效避免在SQL字符串中出现非预期内容,和PDO预处理配合构成了双重保险。列表页每条记录带一个“查看详情”按钮,点击后进入 detail 方法。这个 detail 方法中访问量非常高,因为买家详情页通常会搭配关联推荐模块,我在详情页下方加了“同价位热销商品”和“本店推荐”两个栏目。关联推荐的SQL本质上是在查询同一个分类里价格在当前商品上下浮动20%内的其他上架商品,这个SQL用到了 price 字段的索引,所以不需要担心性能。
3.3 搜索功能的SQL实现与分页方案
搜索是个容易做但不容易做好的功能。用户在前台搜索框输入“实木 沙发”,产品模型就会构建一个带 LIKE 条件的查询。为了不破坏上架字段和分类管理,需要把“搜索词里的空格切分”和SQL组合放一起。关键词切分的简单实现:
php复制$keywords = preg_split('/\s+/', trim($keyword));
foreach ($keywords as $index => $kw) {
$where .= " AND (p.name LIKE :kw{$index})";
$params[":kw{$index}"] = '%' . $kw . '%';
}
搜索会用LIKE模糊匹配,所以一旦商品量涨到几万条,这个查询会慢下来。此时可以考虑MySQL全文索引,或引入Xunsearch这类搜索引擎。但这不是当前开发阶段的重点。对于家具站来说,全文索引会出现把“木床”匹配成“木地板”之类的问题吗?其实有可能,所以建议在后期维护中给商品表加一个 search_keywords 字段,由运营手动维护商品的搜索标签,比如用户搜“沙发床”时,也能把“折叠沙发”匹配出来,这样可控性更强。
分页部分,我没有引入现成的分页类,而是自己封装了一个 Pagination 类。它的输入是总记录数、每页条数、当前页码,输出是LIMIT变量和页码HTML。核心逻辑就是 ceil($total / $pageSize) 算总页数,然后根据当前页生成上一页、页码区、下一页的导航。这里有一个SEO层面容易被忽略的点:无论分页还是筛选,都尽量使用GET参数而不是POST,这样用户可以把筛选后的列表地址发送给朋友,搜索引擎也能抓取到具体的列表页。同时所有生成的分页链接也要带上已有的筛选参数,不然用户在第一页选了北欧风格,翻到第二页时筛选条件丢了,体验非常差。
4. 购物车、订单与结算流程实现
4.1 购物车落库还是Session,这个选择题怎么解
很多教材里的购物车就是用Session存的,简单粗暴:登录前也可以加购,付款前登录后再合并。但我在这套系统里选择了数据库购物车,也就是登录用户才允许加入购物车,购物车行的商品以用户ID关联。
两个方案的对比,直接给结果:
| 对比维度 | Session购物车 | 数据库购物车 |
|---|---|---|
| 未登录能否加购 | 能 | 不能(需先登录) |
| 多设备同步 | 不同步,换手机就丢 | 同步 |
| 库存校验时机 | 下单时 | 加购时 + 下单时 |
| 运营能否看到用户加购了哪些商品 | 看不到 | 可以 |
| 实现复杂度 | 低 | 中 |
家具是典型的低频率高客单价消费,一个用户在网上反复看了好几遍才买,而且很可能在公司看完,晚上回家继续看。购物车如果只存在Session里,换了一台设备就丢了,对客户成单率是很大的打击。直接落库还能让后台运营看到“最近哪些商品被加入购物车但迟迟没有下单”,这其实是非常好的二次营销线索,比单纯看商品浏览量还要精准。售后沟通时,客服也能直接帮用户查看购物车里的历史选品,体验会好很多。
购物车的加购接口需要做四件事,核心代码如下:
php复制public function add()
{
// 1. 必须登录
if (!$this->isLogin()) {
$this->json(401, '请先登录后再加入购物车');
}
// 2. 参数校验
$pid = intval($_POST['product_id'] ?? 0);
$num = intval($_POST['num'] ?? 1);
if ($pid <= 0 || $num <= 0) {
$this->json(400, '商品参数有误');
}
// 3. 判断库存是否足够
$prod = ProductModel::getById($pid);
if (!$prod || $prod['is_on'] != 1) {
$this->json(400, '商品不存在或已下架');
}
if ($prod['stock'] < $num) {
$this->json(400, '库存不足');
}
// 4. 已有同款商品则数量累加,否则新增,放在事务中执行
$this->json(200, '加入成功');
}
第4步为什么要放在事务里?因为如果用户已经往购物车里放过一个同款沙发,此时再加一件,购物车表里不应新增一行,而是要把原纪录的 quantity 字段加1,变成两行的话在结算页会出现两个同款商品条目,体验糟透了。这个“先查后插或更新”的过程存在并发竞争的可能,如果不加锁或不用事务,两个请求同时执行,可能查出同样的结果,然后各插入一条,购物车里就有了两条同款。虽然低并发场景概率极低,但这属于一种典型的“连接丢失”问题,应该在一开始就杜绝。
加购之后导航栏的购物车角标数量也要刷新。这里我用了一个简单方案:导航栏是公共视图,每次渲染公共视图时去查询当前登录用户的购物车中所有数量之和,缓存20秒。因为购物车数量变化没有外部依赖,不需要实时推送,20秒的缓存足够让用户感知到“加入成功”。
4.2 库存扣减与订单状态机设计
下单是家具系统中最容易出错的模块。核心是防止超卖,要注意事务边界。下面写下单省略参数检查后的核心流程:
php复制try {
$pdo->beginTransaction();
// 1. 锁定商品行,防止并发超卖
$stmt = $pdo->prepare('SELECT stock FROM product WHERE id = ? FOR UPDATE');
$stmt->execute([$pid]);
$prod = $stmt->fetch();
if ($prod['stock'] < $num) {
throw new Exception('库存不足');
}
// 2. 扣减库存
$pdo->prepare('UPDATE product SET stock = stock - ? WHERE id = ?')->execute([$num, $pid]);
// 3. 写入订单记录、订单项
// 4. 清空用户购物车中的对应商品
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
// 返回错误信息
}
关键点是第一步的 SELECT ... FOR UPDATE,它的作用是对商品表这一行加上行级排他锁,直到事务结束才释放。第二个线程再执行同样的SELECT时会阻塞等待,第一个线程提交后它看到的是扣完的库存,这样就不会把最后一个库存同时卖给两个人。“先查库存再扣库存”这个动作如果没有行锁,两个请求同时查到库存是1,各自都判断“够”,然后都去UPDATE,最终结果是库存变成-1,订单却生成了两张——这就是标准的超卖事故。做在线交易时这个教训必须刻进DNA里。
订单状态是用一个整型字段 status 表示的。为什么不用字符串“pending”“paid”“shipped”这种更直观的写法?因为字符串在数据库里占用空间和索引代价都更大,而且容易手滑录错变体。整型配合项目里用PHP常量定义的状态集合,既能压缩存储空间,又能避免拼写错误,只是在写代码时要用枚举或常量集中定义:
php复制class OrderModel
{
const STATUS_CANCELED = -1; // 已取消
const STATUS_UNPAID = 0; // 待支付
const STATUS_PAID = 1; // 已支付待发货
const STATUS_SHIPPED = 2; // 已发货
const STATUS_FINISHED = 3; // 已完成
}
这个状态机是单向推进的,理论上不能从已发货退回到待支付,只有“取消”操作能发生在待支付阶段。后台修改订单时,前端下拉框要根据当前状态过滤可选项,而不是把五种状态全摆出来。“已取消”和“已完成”是终局状态,不可再变更,这些边界规则要在控制器里加校验,不能只靠页面来控制。很多初学PHP的开发者把逻辑全堆在页面模板里,上线后让人随便拼接URL触发一些非法流程,就是因为缺少服务端的强制校验。
4.3 支付回调与订单状态更新
因为有真实支付场景,这套项目对接了第三方支付,但调试阶段使用的是沙箱环境。支付流程是:用户提交订单后跳到收银台页面,点击“立即支付”后服务器调用统一下单API,把订单号、金额、回调通知地址传过去,然后返回一个支付链接,页面跳转过去让用户完成扫码支付。用户支付完成后,第三方支付平台会主动请求我们在创建订单时配置的那个回调地址,我们在后台接收这个回调。
写回调接口时最容易掉进去的坑是“以回调结果为准”,而不是“以用户前端跳转结果为准”。用户支付成功后浏览器会自动跳转到一个“支付成功”页面,但很多教程会把“更新订单状态”这个动作放在用户跳转的页面里,这是严重错误。因为用户可以伪造请求直接访问这个成功页,而且如果用户支付后断网、浏览器异常关闭,就更收不到通知了。正确的逻辑是:支付平台回调接口收到通知后先做验签,验签通过再更新订单状态,如果成功则响应一个固定的字符串告诉通知平台“不要重试”;如果验签失败则不处理,通知平台会周期性地多次重试。
回调接口里还有一个逻辑细节:回调通知的金额必须和本地订单金额比较,而本地订单金额要从PHP数据库查出来并和回调参数做精确匹配,而不是只看订单号。因为订单号泄露给别人后,对方可能伪造一个同号微额支付来“打通”你的回调,如果不校验金额就标记成已支付,会造成巨大的资金损失。
4.4 收货地址管理与订单列表
会员中心里“收货地址”是独立的一块,这里不开发前端省市区三级联动组件,而是让用户填写一个完整的地址文本。三级联动需要维护全国省市区数据表,对家具站来说收效比不高,大量家具城同城配送,往往只需要填到小区门牌号。所以地址表字段为:收货人、手机、省市区文本、详细地址、是否默认。默认地址只允许一个,如果用户勾选了“设为默认”,那么新增时就要先把同用户的其他地址默认标记全部取消。这个动作和新增地址在同一事务里完成,避免把两个默认地址写进库。
订单列表页在我的订单中展示用户所有订单,每单下拉能看到商品快照、实付款、状态和操作按钮。这里有个值得注意的业务点:如果用户多次购买同一件商品,订单项里并不推荐关联外键去 product 表拿商品名和主图,因为万一运营把商品删了(物理删除)或改成了别的商品名,历史订单会变得非常难看。订单项里应该用冗余字段把商品名称、主图、单价直接存下来,这就是电商里常说的“快照”思想。用户看到的自己当时买的东西永远是对的,哪怕后台商品已经改得面目全非。这套系统里我在 order_item 表中额外加了 product_name 和 product_image 两个冗余字段,插入订单时顺手写入。
5. 后台管理系统与权限安全设计
5.1 管理员后台的功能模块划分
后台入口是一个独立目录 admin/,出于安全考虑入口路径不要用常见的 administrator。管理员后台的功能模块划分非常清晰,按照家具商城日常运营动作分为五个大块:商品管理、订单管理、会员管理、分类管理、系统设置。
商品管理包含商品列表、添加商品、编辑商品、图片上传。订单管理包含待发货订单查询、发货操作、订单详情查看。会员管理允许重置会员密码和禁用恶意账号。分类管理负责维护 category 表。系统设置其实在中小站里就是保存一些网站名称、客服电话、ICP备案号之类的配置项,以JSON的形式存在一个配置表里。如果你公司同时运营多个家具品牌,这套后台架构可以非常容易地扩展出“品牌管理”,但这里我没有额外引入,免得增加项目初期的工作量。
后台首页我加了一个简易的数据看板,展示今日订单数、今日销售额、待发货数量、商品总数和会员总数。实现方式就是每个指标一条聚合SQL:
sql复制SELECT COUNT(*) FROM orders WHERE DATE(created_at) = CURDATE();
SELECT IFNULL(SUM(total_amount), 0) FROM orders WHERE DATE(created_at) = CURDATE() AND status IN (1,2,3);
为什么 SUM 加 IFNULL?因为如果今天一个订单都没有,SUM 会返回NULL,PHP端如果不处理,就会在页面上输出空值甚至报错,用 IFNULL 可以把NULL变成0。这属于SQL基本功,但也确实经常被忽视。在这里不追求秒级实时数据,因为当前站点的量级很小,控制器的每个方法查一次库完全没压力。
5.2 管理员权限控制的模块化实现
管理员功能要用独立的 admin 表存储,不要和前台会员混用一张表。后台权限控制最简单实用的模型是:管理员表加一个 role 字段,值为 super 或 operator。超级管理员拥有全部操作权限,操作员账号只能进行商品管理和订单发货,不能进入会员管理和系统设置。页面上的体现就是后台菜单根据角色决定是否输出,而服务端每个控制器的构造函数里也会做角色判断,防止有人绕过页面直接访问无权接口。
菜单隐藏不等于权限隔离,一定要在服务端判断。这个原则我踩过一次坑,在一套早期项目里只做了菜单控制,某天同事扫描目录发现了后台管理员的控制器路径,直接通过URL修改了其他管理员的角色权限,还好只是内网测试。从那以后所有后台页面操作我都坚持“后端权限拦截优先”。
PHP侧做一个简单的基础控制器 AdminBaseController,每个后台控制器继承它,构造函数中统一校验登录态、角色和CSRF令牌。代码如下:
php复制class AdminBaseController
{
public function __construct()
{
// 会话校验
if (empty($_SESSION['admin_id'])) {
header('Location: /admin/login.php');
exit;
}
// CSRF令牌校验:写操作用POST提交时必须带上token
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'], $token)) {
exit('CSRF token mismatch');
}
}
}
}
这段代码建议直接加进不同后台控制器的公共基类里,如果你用的是原始PHP增强函数,建议复制到Base模型或者入口公共文件中。所有数据修改动作都要求POST提交并带CSRF令牌,能在很大程度上挡住跨站请求伪造,这是很多老PHP项目里缺失的一环。
5.3 商品与图片上传的实现细节
商品编辑页又一大坑是图片上传。HTML表单要定义 enctype="multipart/form-data",然后在PHP端接收 $_FILES。上传图片涉及几个核心处理逻辑:目录生成、文件名校验、防脚本执行、缩略图生成、入库。
上传目录我放在 public/uploads/ 下,按日期分子目录,比如 uploads/20250125/。这样目录内文件数量不会无限膨胀,同时按时间切片维护方便。文件命名直接用 uniqid() 或 md5 加随机数,不要用用户上传的原始文件名,原始文件名里可能带中文、全角字符和特殊符号,而且在Web服务器上如果保留原始后缀和原始文件名,存在上传恶意脚本的风险。这里尤其要注意一个细节:不仅要校验扩展名,还要看MIME类型。两个都校验也不能完全杜绝攻击,最稳妥的是把上传目录的PHP执行权限关闭,如果使用Nginx和PHP-FPM部署,可以把上传目录单独配置为只处理静态文件。
PHP代码里对上传成功的文件还要用 getimagesize() 再检验一次,因为它可以判断文件内容是否真的是一张图片,就算攻击者把PHP代码伪装成图片扩展名,getimagesize() 也会发现它不是合法图像。所有上传相关处理,从入口到落地做四层把关,基本就能把图片上传的隐患堵死:
php复制$ext = strtolower(pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!in_array($ext, $allowed)) {
exit('不允许的文件类型');
}
$tmp = $_FILES['image']['tmp_name'];
$info = @getimagesize($tmp);
if ($info === false) {
exit('文件不是有效图片');
}
$newName = date('YmdHis') . '_' . mt_rand(1000, 9999) . '.' . $ext;
$targetDir = 'uploads/' . date('Ymd') . '/';
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
move_uploaded_file($tmp, $targetDir . $newName);
商品多图上传使用了一个前端库,批量选取后逐个AJAX上传,服务端返回图片URL后前端把URL填进一个隐藏域列表,最后和商品基本资料一起提交。为什么要做成AJAX即时上传?因为如果用户填了半天表单,最后点“保存”时才一次性上传所有图片,网络慢的情况下用户等待时间太长,万一半路断网,前面填的内容全丢了。即时上传让图片在表单尚未提交前就已经落到服务器,用户点保存时只是把已经生成的地址字符串写入数据库。这样体验更顺,也给前端实现一个更友好的预览。
6. 安全防范与常见问题排查实录
6.1 PHP安全防护清单:注入、XSS与弱口令
用原生PHP做开发,因为没有框架帮你过滤输入输出,安全责任全部落在开发者自己身上。这套系统至少落实了下面这些安全措施:
SQL注入防护:所有数据库查询统一走PDO预处理。需要特别注意的是PDO的默认模拟预处理在旧版本里是开启的,如果不设置 PDO::ATTR_EMULATE_PREPARES => false,预处理可能只是做了一次字符串替换,安全性和真正的服务端预处理有差距。我建议连接数据库时加上该配置,示例见下面代码段。
XSS防护:输出到HTML的内容一律过 htmlspecialchars()。如果项目里用了富文本编辑器,不能直接对全文做转义,因为会破坏排版。需要定义一个白名单,例如只允许 <p><img><strong><em><a> 这些必要的标签,其它标签全部过滤或编码。实现上可以给后端增加RichTextPurifier类,也可以对正文 strip_tags 杀一遍再放出来。至少不要让用户输入的 <script> 通过富文本存进数据库。
CSRF防护:后台所有写操作都验证会话里的Token。前台用户登录后如果发布评论或提交订单,也需要在表单里带Token。因为第三方异步支付回调这类接口天然不能靠登录态来校验身份,而是靠签名验证,这个要格外注意区分。
弱口令问题:注册时用户名最短6位、密码最短8位,弱密码列表做一次拦截;同时管理员初始密码必须是强随机密码。凡是涉及密码字段,一律不存明文,入库前用 password_hash() 加密。很多早期教程用 md5() 加盐做密码,现在来看用 password_hash() 和 password_verify() 更省心。下面给出PDO的连接配置:
php复制$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
配置好之后,千万不要因为某段SQL没有用户输入就直接拼接字符串,全部统一用预处理。这套规矩能让你少走很多弯路,也能让接手的人省心。
6.2 当前现状与常见问题排查技巧
列举几类PHP项目中普遍会遇到的问题,以及对应的排查方法,希望以后遇到时能缩短排错时间。
第一类是“页面白屏或500错误”。原因大概率是语法错误、函数不存在或者数据库连接字符集配置问题。直接把PHP的 display_errors 打开不治本,生产环境应该看日志。开发环境中可以在入口文件临时设置 error_reporting(E_ALL); ini_set('display_errors', '1');,排查完必须关闭。如果是Linux服务器,错误日志在 /var/log/php_errors.log 或 /var/log/php-fpm/www-error.log。500错误先看日志,比自己瞎改代码高效得多。
第二类是“文件上传到后台,明明传了图片却提示上传失败”。排查顺序是:第一看 php.ini 的 file_uploads、upload_max_filesize、post_max_size 是否够大;第二看 move_uploaded_file() 的目标目录是否可写,Linux下要给目录设置写权限;第三看 $_FILES['field']['error'] 的错误码,0表示成功,1表示超出 upload_max_filesize,2表示超出表单 MAX_FILE_SIZE,这些错误码是定位问题最直接的线索。
第三类是“前端表单提交中文乱码或问号”。先确认HTML的 <meta charset="utf-8">、PHP文件的编码、数据库连接字符集、数据库表字符集四者一致,全用utf8mb4。PHP字符串处理别用 mb_* 函数时漏传编码参数。排查时用 mb_detect_encoding() 查看字符串当前编码,往往能定位到到底是环节出了问题。
第四类是“改完代码页面无变化”。先检查是否开了PHP自带的OpCache缓存,如果开了要重启PHP-FPM或刷新缓存;其次检查浏览器缓存,强制刷新一次;最后看自己改的是不是生效的那份文件,有时候Apache配置了多个虚拟主机,DocumentRoot指向别的目录,自己一脸懵地改错了项目。确认目录正确性我见过太多次了,建议第一件事是打印 echo __FILE__; 看下当前脚本执行路径。
6.3 性能体检和上线前检查
家具网站的图片体积普遍较大,高性能的静态资源处理是前台体验的重要保障。上线前建议统一处理这几件事:
代码层面,商品列表页的数据查询结果要用Redis缓存存一份,缓存键按“分类+价格区间+页码”拼接。商品上下架的时候执行删除对应缓存操作。这样能扛住详情页的集中访问压力。前面提到的Session扩展也建议从文件改成Redis,避免一台服务器上文件形式的Session在多用户并发时锁冲突。
Nginx和Apache层,PHP要开启Gzip压缩,所有JS、CSS以及HTML全部走text压缩,传输体积能减少70%。图片比较大时,上传过程中生成缩略图:列表缩略图200x200,详情轮播图800x800。不要让前端CSS强行把一张原始大图缩到小尺寸,那会让用户白白浪费大量流量。主图之外的所有图片要先压缩,格式优先用WebP,在兼容现代浏览器(火狐、Chrome、Edge)的场景下推荐全面使用WebP格式。
数据库层,给 orders 表的 user_id 和 status 建组合索引。虽然业务初期数量不大,但后台上传订单列表会频繁按 status 过滤,索引能够省不少开销。另外订单表按月归档也是一个后期可考虑的动作,但初始不在功能需求里。
上线前还要把安装部署、账号初始化、基础数据导入做一份完整清单,同时把项目中涉及第三方支付回调地址、API密钥的配置独立到环境变量或配置文件外置,避免把支付密钥提交到公开仓库,导致资金被恶意调走。我在这里吃过大亏,有一次把某云密钥提交进Git仓库后没注意,第二天一个陌生号码打电话说账户新开通了很多海外服务器。从那以后凡是敏感信息一律不提交代码仓库,这是程序员的职业底线。
7. 项目测试过程与验收要点
7.1 功能测试的维度设计
在本地环境完成编码后,我围着一套家具网站的核心链路做了完整的功能测试。主要分三块:浏览器端功能验证、接口层模拟并发测试、后台上传越权测试。
前台功能测试的关键用例包括:未登录访问购物车页面是否正确跳转登录;注册会员后能否改资料;搜索“实木床”能否命中对应商品;加入购物车后库存和购物车角标是否正确变更;模拟支付回调时订单状态是否从待支付流转为已支付;管理员把某商品下架后,前台详情页是否还有入口等。用例不在多,而在主干链路清晰。核心的回归点就是“浏览商品-加入购物车-下单支付-后台发货-确认收货”这五连环不能断。
接口测试方面,我写了几个简单的PHP脚本模拟并发请求,测试要点是同一件库存为1的商品被两个会员同时下单,最终结果应只有一个订单成功、另一个返回库存不足的提示。如果第二个请求成功或者库存变成负数,说明没有加锁或事务有问题,必须返工。
7.2 移动端适配与浏览器兼容性要点
家具网站现在的大流量入口来自手机端。我在开发时把之前用到的Bootstrap 3升级利用栅格系统做了响应式适配,并额外写了几个断点专门处理商品卡片:在PC上显示4列,平板上2列,手机上1列。字体大小和详情页图片宽度也按屏幕宽度用百分比控制。做完后用Chrome开发者工具逐个设备模拟器过了一遍,重点检查首页轮播图、商品缩略图、购物车表格在窄屏下会不会把表单控件顶出屏幕。
浏览器兼容性方面,主要保证Chrome、Firefox、Edge和移动端WebKit内核的体验一致。对老旧版本IE的适配工作直接放掉,成本高于收益,同时产品的用户画像里使用IE的比例已经很低了。详情页的轮播图封装在jQuery里,传统浏览器几乎不存在选择问题。
7.3 上线部署与环境配置踩坑记录
部署这台家具网站时,我选择的是LNMP环境(Linux + Nginx + MySQL + PHP)。单机部署完全满足初期的小访问量。在这里给出一个Nginx需要重点注意的PHP页面处理配置:Nginx不像Apache那样依赖 .htaccess,所以全部PHP文件的URL重写要在 /etc/nginx/sites-available/ 配置文件里写好:
nginx复制server {
listen 80;
server_name www.example.com;
root /var/www/home_furniture/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php7.4-fpm.sock;
}
location ~* ^/uploads/.*\.(php|php5)$ {
deny all;
}
}
比较重要的是最后这行配置,它把 uploads/ 下所有PHP文件的访问直接拒绝,即使攻击者突破了文件上传校验、传了一张包含PHP代码的图片到上传目录,也无法通过URL访问触发解析,这个配置给整个文件上传链路加上最后一道保险。
部署完以后,还要注意三点:一是PHP的 open_basedir 限制能访问目录,避免任意文件读取漏洞;二是 session.cookie_httponly 设为1,基于脚本获取Session Cookie的XSS窃取行为就失效了;三是 session.cookie_secure 在HTTPS环境下设为1。生产环境必须要上HTTPS,现在随便一个云平台都能免费申请SSL证书,切到HTTPS对支付回调、登录接口都有直接的加密保护。别省这个步骤,一次账号密码明文传输的抓包就能让整个站点的可信度归零。
8. 个人实操心得与后续扩展经验
做完这个PHP家友家具网站,最大的心得之一:PHP项目里业务流程设计比技巧本身重要得多。分页、上传、购物车、后台菜单这些单独拆出来每个都不难,但把它们拧在一起,让“前台用户动线”和“后台运营动线”能够对得上,才是项目真正有价值的部分。做之前花两天时间画清楚业务流程图、列清楚每张表的字段关系和每个状态机的转移条件,后面写代码的速度是真的能快不少。
另外一点小经验:整个项目里最适合“升级扩展”的部分,反而是最开始不起眼的标签体系。商品表的 tags 字段,我一开始只是为了列表页筛选用。做了一段时间后会发现,把它扩展成“场景套餐推荐”的利器,比如运营维护了一个“小户型专区”标签,把所有适合小户型的沙发、茶几、电视柜打上同一个标签,首页就能直接拉出一个专题楼层,不需要新建任何表结构。这类低成本、高价值的玩法在家具行业中特别受用。
关于开发环境还有一个实用建议:不要升级PHP到最新大版本就直接跑老代码。这套项目在PHP 7.4上跑得非常稳,但用户手头如果安装了PHP 8.2,部分函数用法会触发弃用通知,比如在PHP 8里 mysql_* 这类旧函数早就没了,很多老写法的动态属性创建也不同了。先确认好目标服务器的PHP版本,再动手写代码,是最好的方式。我现在会在项目根目录放一个 composer.json,里面标上 "require": {"php": ">=7.4"},用版本约束告诉后来的人最低版本,避免拿错环境调试半天查不出问题。
如果你要在这个基础上继续做二次开发,我个人建议的下一个功能点是“预约到店看货”模块。家具行业里线上引流到线下门店的转化率相当高,提交一个预约表单,后台管理员收到短信提醒,再安排门店导购对接,整体逻辑比支付和库存简单得多,但对真正达成交易却很有帮助。这个模块正好也能把你已经熟悉的用户表、表单提交、后台管理列表再串联一遍,练手和落地双收。再往后可以考虑引入手机验证码登录,这能明显提升注册转化率,难点在于短信服务商的SDK集成,以及验证码发送频率的防刷控制。做之前先想清楚一个问题:一个手机号一分钟最多发几条?同一IP一天最多发多少条?防止被刷爆至少要用Session和Redis做双层限制。
最后再分享一个实操小工具,我在调试订单金额和库存相关问题时,会在数据库客户端里手动扣库存、改订单状态,再刷新页面看效果。但手动改库有风险,特别是改状态这类操作很容易绕过程序里的流转校验。后来我给自己定了一条规矩:凡是订单相关的状态修改,一律通过后台界面操作,不在数据库里直接改。因为后台操作会触发所有该有的日志和行为,而手动SQL只会把数据改“对”了,却把系统的行为轨迹改“没”了。这条建议虽然很简单,但会让你在排查线上问题时少掉很多头发。
