ThinkPHP数码商城开发:路由设计与订单流程实战解析
做数码电商这块大家最关心的是业务闭环能不能走通。我前阵子用 ThinkPHP 完整搭了一套数码、手机、相机商城购买平台从商品展示、购物车、下单、支付回调到后台管理都梳理了一遍。整个过程踩了不少坑尤其是路由和地址跳转配置这一块稍不注意就出现 404 或者跳转死循环。今天这篇就把这套商城的设计思路和核心实现拆开讲清楚适合刚接触 PHP 框架、想自己动手写商城项目的开发者也适合已经在做电商项目、想优化路由和订单流程的朋友参考。1. 整体设计思路先想清楚再动手1.1 为什么用 ThinkPHP 做数码商城选 ThinkPHP 来承接数码商城并不是因为它比 Laravel 更潮而是它非常适合中小型电商项目尤其是国内开发者。第一ThinkPHP 的生态足够成熟中文文档和网上技术帖非常多。做商城这种涉及用户注册、商品管理、订单流转的系统几乎每个功能都能在社区里找到对应的参考实现。对团队来说新人上手成本也低拉一个 PHP 文件就能跑起来。第二它的 ORM 和数据库操作非常直观。数码商城数据表多关联关系复杂比如商品和 SKU 是一对多订单和订单商品又是一对多。用 ThinkPHP 的模型关联可以少写大量 SQL也更容易维护。第三数码手机相机这类 3C 商品有个明显特点规格多、单价高、库存敏感。一台手机往往有多个颜色和内存版本一台相机也会搭配不同的镜头套装。这意味着系统必须有一套完善的 SKU 设计同时下单时要严格控制库存防止超卖。ThinkPHP 的模型机制配合数据库事务处理这种场景时很顺手。对比纯原生 PHP用框架的好处是路由、请求、数据库、缓存这些底层能力都替你封装好了你只需要把精力集中在业务规则上。对比 LaravelThinkPHP 要轻量不少服务器配置要求也没那么高虚拟主机环境下也能顺利部署。1.2 核心数据表设计商品、SKU 与订单商城项目最忌讳上来就写代码先把表结构设计清楚后面能省一半事。我这套商城的核心表大概有这些表名核心字段说明userid, username, password, nickname, avatar, create_time用户表categoryid, pid, name, sort, status商品分类支持无限级goodsid, cat_id, name, sub_title, main_image, images, price, market_price, stock, sale_num, is_on_sale商品主表goods_skuid, goods_id, spec_name, spec_value, price, stock, image商品规格库存表cartid, user_id, goods_id, sku_id, num, checked购物车表orderid, order_no, user_id, pay_amount, order_status, pay_type, pay_time, consignee, phone, address, remark订单主表order_goodsid, order_id, goods_id, sku_id, goods_name, goods_image, spec, price, num订单商品快照order_logid, order_id, operator, action, content, create_time订单状态日志商品和 SKU 拆成两张表是数码商城必须做的事。如果把颜色、内存、价格全写在商品主表里一条商品会拆成很多行记录用户看详情页面时会很痛苦后台维护也麻烦。正确的做法是 goods 表存通用信息goods_sku 表存每个规格组合对应的价格和库存。订单表侧重“快照”而不是“关联”。下单时会把商品名称、规格、价格、图片等信息复制到 order_goods 表这样即使后面修改商品或删除商品历史订单依然能完整展示这在电商订单设计里非常关键。订单状态我用了 0 到 5 的数字表示0 待支付1 已支付2 待发货3 已发货4 已完成5 已取消。每个状态变化都写入 order_log以后排查纠纷或者对账时能清楚知道订单在什么时间点发生了什么变化。1.3 分层目录Controller、Service、Model 怎么划分ThinkPHP 默认结构比较扁平控制器里直接调用模型就能干活。但商城业务稍微复杂后控制器会越来越臃肿光一个下单操作就可能包含校验库存、写入订单、写入订单商品、扣减库存、写日志五六个步骤全塞在控制器里后期根本没法维护。我在项目里引入了 Service 层。Controller 只负责接收请求、调用服务、返回结果把业务逻辑拆到 service 目录下通过依赖注入或者助手函数调用。举例来说新增了一个app\service\OrderService类里面专门处理下单、取消、发货这些操作控制器里的代码可以缩减到十行以内。Model 层则专注于数据操作和模型关联。比如商品模型里定义分类关联和 SKU 关联订单模型里定义订单商品关联查询时通过with一次性把关联数据带出来避免 N1 查询。这样分层以后代码结构清晰每个文件职责单一想改业务规则时只看对应 Service 层就够了。2. 核心功能模块的实现与细节2.1 商品列表、搜索与详情页的常见实现商品列表页是用户进入商城后看到的第一屏体验好不好直接影响转化率。我的列表页支持分类筛选、价格区间、排序、分页还有简单的关键词搜索。ThinkPHP 的查询构造器写这个很顺手。控制器接收category_id、keyword、sort等参数组装一个查询对象最后调用paginate()分页。$query \think\facade\Db::name(goods)-where(is_on_sale, 1); if ($categoryId request()-param(category_id)) { $query-where(cat_id, $categoryId); } if ($keyword request()-param(keyword)) { $query-whereLike(name, %{$keyword}%); } if ($minPrice request()-param(min_price)) { $query-where(price, , (float)$minPrice); } if ($maxPrice request()-param(max_price)) { $query-where(price, , (float)$maxPrice); } switch (request()-param(sort)) { case price_asc: $query-order(price, asc); break; case price_desc: $query-order(price, desc); break; case sales: $query-order(sale_num, desc); break; default: $query-order(id, desc); break; } $list $query-paginate(12, false, [ query request()-param(), ]);注意paginate第三个参数要带上当前请求的参数否则翻页时筛选条件会丢失。这个细节我见过很多新手踩坑。商品详情页要展示商品主图、轮播图、规格选择、价格区间和库存信息。我这里把商品主图和 SKU 列表一次性查出来前端页面里根据用户选择的规格动态展示价格和库存。如果是更复杂的场景还可以把 SKU 以 JSON 形式返回给页面用户每次选择规格时通过 JS 计算出匹配结果。为了减轻数据库压力详情页我用了Cache::remember()做简单的数据缓存缓存时间设置成 10 分钟。管理员在后台修改商品时再主动清除该商品的缓存。这样既能保证数据时效性又能降低高频访问对数据库的冲击。2.2 购物车与订单流程事务和防超卖购物车表我设计得比较轻只记录用户选择的商品、SKU 和数量不存价格。因为价格应该以提交订单时的商品价格为准购物车只是临时选择区。用户加购时要注意校验商品是否存在、是否上架、数量是否大于 0。下单是整个系统中风险最高的环节我做了三层保护第一库存检查。提交订单后先查一次 SKU 库存如果库存不足直接返回错误。第二事务处理。订单主表、订单商品表、库存扣减、订单日志都放在同一个数据库事务里任何一个环节失败都回滚。ThinkPHP 6 的事务写法很简洁use think\facade\Db; Db::startTrans(); try { // 生成订单号 $orderNo date(YmdHis) . rand(1000, 9999); // 写入订单主表 $orderId Db::name(order)-insertGetId([ order_no $orderNo, user_id $userId, pay_amount $totalAmount, order_status 0, consignee $consignee, phone $phone, address $address, create_time time(), ]); // 写入订单商品快照 $orderGoodsData []; foreach ($cartList as $item) { $orderGoodsData[] [ order_id $orderId, goods_id $item[goods_id], sku_id $item[sku_id], goods_name $item[goods_name], goods_image $item[goods_image], spec $item[spec], price $item[price], num $item[num], ]; } Db::name(order_goods)-insertAll($orderGoodsData); // 扣减库存防止超卖 foreach ($cartList as $item) { $result Db::name(goods_sku) -where(id, $item[sku_id]) -where(stock, , $item[num]) -dec(stock, $item[num]) -update(); if (!$result) { throw new \Exception(商品库存不足); } } // 清空购物车 Db::name(cart)-where(user_id, $userId)-delete(); // 写入订单日志 Db::name(order_log)-insert([ order_id $orderId, operator $userId, action create, content 用户提交订单等待支付, create_time time(), ]); Db::commit(); } catch (\Exception $e) { Db::rollback(); return json([code 0, msg $e-getMessage()]); }第三扣库存语句带条件。where(stock, , $item[num])再加dec()放在同一个 update 里这个操作在数据库层面是原子性的两个用户同时下单时只有库存足够的那个人能成功扣减另一个人的 update 会返回 0 从而触发回滚从根上避免超卖。订单号不能只用时间戳并发下单时会撞号。我用了年月日时分秒 4位随机数在单个小型商城项目里够用如果并发量更高可以再加用户 ID 后缀或者接入专门的发号器。2.3 用户登录、权限拦截与订单状态流转用户体系我用的 Session 方式毕竟传统的 Web 商城场景居多。用户表里的密码不要存明文先password_hash($password, PASSWORD_DEFAULT)再入库验证时用password_verify比对。这个习惯无论做多大的项目都应该坚持。页面访问权限是一个容易被忽略的地方。不是登录之后所有页面就都能访问了比如订单详情、收货地址、个人中心这些页面必须校验当前用户是否登录而且还要校验这个订单是不是当前用户的订单。我在项目里写了一个简单的 Auth 中间件后端请求都会先经过它判断 Session 里有没有用户信息。没有登录就跳转到登录页登录完成后跳回原来的页面这个“回跳”逻辑用到的是地址跳转配置后面详细讲。订单状态流转是商城的生命线。为了让状态变化可追溯我专门写了OrderService::changeOrderStatus($orderId, $newStatus, $operator)方法每次状态变化都要在 order_log 里记录。比如用户取消订单要判断当前状态是否允许取消只有待支付状态可以取消后还要释放库存把扣减的库存加回去。这个回补库存的步骤我见过不少项目漏掉短时间看不出来但订单量大了之后库存数字会和实际严重不符。3. ThinkPHP 路由设计与地址跳转配置3.1 搞清楚 URL 默认访问方式与伪静态很多新手在本地用的 URL 是http://localhost/index.php?s/goods/detailid1这种地址丑且不友好而且经常被搜索引擎嫌弃。部署到线上之后我们要通过路由和伪静态把地址改成http://localhost/goods/1.html这样简洁的形式。ThinkPHP 默认支持四种访问模式的自动识别但生产环境一般推荐 PathInfo 模式也就是把参数放在 URL 路径里。要让这个模式生效需要两步第一步修改config/route.php里的url_html_suffix html这样 URL 可以以.html结尾。第二步配置 Web 服务器的 Rewrite 规则。Nginx 的典型配置如下location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } }Apache 环境则在项目根目录放.htaccess文件IfModule mod_rewrite.c Options FollowSymlinks -Multiviews RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php?s/$1 [QSA,PT,L] /IfModule这段规则的含义是如果请求的文件或目录真实存在直接访问如果不存在就把整个 URL 交给index.php处理由 ThinkPHP 解析后面的s参数。配置完之后/goods/1.html才能正常路由到对应控制器。3.2 自定义路由规则把 URL 变成自己想要的样子ThinkPHP 6 的路由定义文件在route/app.php比如我想让商品详情地址是/goods/1.html可以这样定义use think\facade\Route; Route::get(goods/:id, Goods/detail);:id是动态参数最后生成的 URL 是/goods/1.html。控制器里通过request()-param(id)接收参数。如果怕 URL 被猜出规律还可以给参数加规则约束Route::get(goods/:id, Goods/detail)-pattern([id \d]);这样id必须是纯数字否则路由不匹配返回 404。这种约束非常有用能减少无效请求对控制器的干扰。分类页的地址可以做成这样Route::get(category/:id, Category/index); Route::get(search, Goods/search);路由规划得好URL 页面层级一目了然用户一看就知道自己在哪个位置。而且在代码里写跳转时可以用url()助手函数根据路由规则反向生成地址这样以后即使修改 URL 规则也不需要去每个模板里改链接。3.3 控制器中的常见跳转写法与参数传递地址跳转配置包含两块路由规则的配置和控制器里实际执行跳转的代码。跳转的写法如果不对轻则参数丢失重则循环跳转。ThinkPHP 6 里跳转有两个常用助手函数// 跳转到指定 URL redirect(Order/detail, [order_no $orderNo]); // 生成 URL 地址 $url url(Order/detail, [order_no $orderNo]);第一个参数可以写控制器方法名也可以写完整的路由地址。比如支付成功后要跳回订单详情页return redirect((string)url(Order/detail, [order_no $orderNo]));登录后回跳原来的页面这是电商系统非常高频的需求。我通常的做法是在登录页链接里带上back_url参数$backUrl request()-param(back_url, url(Index/index)); // 防止开放重定向攻击域名必须可信 if (!preg_match(/^\/[a-zA-Z0-9\/\?\\\.\-]*$/, $backUrl)) { $backUrl url(Index/index); } return redirect($backUrl);只允许以/开头的站内地址防止被恶意拼外部链接。其实在 Auth 中间件里也可以用同样的思路实现未登录拦截后的回跳if (!$user) { return redirect(url(Login/index, [ back_url request()-url(), ])); }3.4 路由缓存和跳转中的几处深坑ThinkPHP 6 的路由在开发模式下没问题但生产环境中性能是个隐患因为每次请求都要重新解析路由规则。官方提供了路由缓存命令php think route:cache但有个坑如果你在route/app.php里用了闭包或者其他不能序列化的内容执行缓存命令时会直接报错。所以要么全部使用控制器方法作为路由目标要么在每次修改路由后重新缓存。另一个容易踩的坑是redirect()默认状态码是 302如果两个页面互相重定向比如 A 页面要求登录跳 BB 登录成功后跳回 AA 又判断未登录跳 B就会形成死循环。排查时可以先用浏览器无痕模式或者检查一下是否在中间件里写了重复的登录判断。还有url()生成地址后如果直接传给前端模板要注意浏览器可能会把转义成amp;在模板里输出链接时务必要确认最终 HTML 里的地址是正确的尤其是带多个参数的 URL。4. 实操过程从零搭建一个可用的商城核心链路4.1 环境准备与项目初始化我用的是 ThinkPHP 6.0PHP 版本要求 7.2.5 以上数据库用 MySQL 5.7。初始化项目推荐用 Composer命令很简单composer create-project topthink/think tp-shop cd tp-shop启动本地开发服务器php think run访问http://localhost:8000能看到默认欢迎页说明环境没问题。接下来配置.env文件把数据库连接信息填好[DATABASE] TYPE mysql HOSTNAME 127.0.0.1 DATABASE tp_shop USERNAME root PASSWORD 123456 HOSTPORT 3306 CHARSET utf8mb4 PREFIX shop_建议把数据表前缀统一加上比如shop_goods这样以后多项目共存时不会搞混。4.2 用模型实现商品列表与详情建立商品模型app\model\Goods.php?php namespace app\model; use think\Model; use think\model\relation\HasMany; class Goods extends Model { protected $name goods; protected $jsonAssoc true; public function skus(): HasMany { return $this-hasMany(GoodsSku::class, goods_id, id); } public function category(): \think\model\relation\BelongsTo { return $this-belongsTo(Category::class, cat_id, id); } }控制器app\controller\Goods.php读取详情?php namespace app\controller; use app\model\Goods; use think\facade\Cache; use think\Response; class Goods { public function detail(int $id) { $goods Cache::remember(goods_detail_ . $id, function () use ($id) { return Goods::with([skus, category]) -where(is_on_sale, 1) -find($id); }, 600); if (!$goods) { abort(404, 商品不存在或已下架); } return view(goods/detail, [ goods $goods-toArray(), ]); } }find($id)直接返回模型对象toArray()转数组模板里就能用点语法输出字段了。如果数据量变大还可以在列表查询用with(skus)预加载避免循环查询。4.3 订单提交完整流程与日志记录订单提交是核心中的核心我封装在了app\service\OrderService.php里。流程上前端把购物车里选中的商品提交过来后端先重新查一遍价格防止前端篡改价格然后再走事务流程。这个“重新查价格”特别重要。如果直接信任前端传过来的商品 ID 和数量等于把价格的决定权交给了用户很容易被利用。我一般会根据前端传来的 SKU ID 重新查数据库价格一律以数据库为准。具体下单方法可以设计成这样?php namespace app\service; use think\facade\Db; class OrderService { public function createOrder(int $userId, array $skuItems, array $addressData): array { $totalAmount 0; $orderGoods []; foreach ($skuItems as $item) { $sku Db::name(goods_sku) -alias(s) -join(goods g, g.id s.goods_id) -where(s.id, $item[sku_id]) -where(g.is_on_sale, 1) -find(); if (!$sku) { throw new \Exception(商品不存在或已下架); } if ($sku[stock] $item[num]) { throw new \Exception(商品库存不足 . $sku[spec_name]); } $totalAmount $sku[price] * $item[num]; $orderGoods[] [ goods_id $sku[goods_id], sku_id $sku[id], goods_name $sku[name], goods_image $sku[main_image], spec $sku[spec_name] . : . $sku[spec_value], price $sku[price], num $item[num], total_price $sku[price] * $item[num], ]; } // 剩余的事务逻辑与前面示例一致 // ... } }代码先做价格和库存校验收集订单商品数据然后再进入事务写入。整个过程用一个 try-catch 包住任何一步抛错都会回滚。无论代码怎么写核心原则是订单操作必须保证原子性不允许出现“订单建了但库存没扣”或者反过来。4.4 后台管理基础功能后台管理不需要花哨但增删改查必须稳定。我用了一个简单的Admin中间件判断管理员身份后台路由加个admin前缀Route::group(admin, function () { Route::get(goods/list, Admin.Goods/list); Route::get(goods/edit/:id, Admin.Goods/edit); Route::post(goods/save, Admin.Goods/save); })-middleware(\app\middleware\AdminAuth::class);中间件里做权限校验这样不用每个方法里都写一遍权限判断。后台商品编辑页支持上传图片我用的上传函数是 ThinkPHP 自带的\think\facade\Filesystem::disk(public)-putFile(images, $file)上传完把生成的文件路径存到商品表的images字段里。图片存储建议用date(Ymd)分目录避免一个目录文件太多导致访问变慢。5. 常见问题与排查技巧实录5.1 部署后路由 404 的排查清单本地用php think run一切正常部署到 Nginx 之后访问非首页的路由全部 404这是最典型的部署问题。我一般按这个顺序排查现象可能原因解决办法首页正常内页 404Nginx rewrite 规则没配添加rewrite ^(.*)$ /index.php?s$1 last;访问/index.php?s/goods/1正常伪静态 404URL 重写模块未开检查 rewrite 模块和 try_files 配置全站都能访问但路由报错路由缓存过期或损坏php think route:clear后重新route:cache修改路由后不生效路由文件缓存重新生成路由缓存避免直接改完就测试服务器 PHP 版本过低PHP 7.2.5升级 PHP 版本至少 7.4 或 8.0查看 Nginx 错误日志通常是/var/log/nginx/error.log里面会直接告诉你 rewrite 是否生效、PHP-FPM 有没有正常处理请求。真有问题时不要瞎猜先看日志。5.2 支付回调后跳转丢失参数的问题支付回调是异步请求支付成功之后要更新订单状态然后跳转到订单详情页。我在测试时发现回调里跳转后的订单号经常丢失排查发现是回调 URL 被商户平台截断或者编码不一致导致的。解决方法是回调里不要直接信任原参数顺序尽量从已更新的订单表里重新查一遍把订单号作为参数传给跳转地址并校验订单归属用户。如果是在小程序或移动端 H5回调跳转后不要依赖后端 Session而是让前端重新拉取订单状态接口这样更可靠。还有一个细节支付回调接口要保证幂等。就是说同一笔订单被回调两次第二次不能再创一笔新订单记录而是直接返回成功。我在回调方法开头就会查一遍订单当前状态如果已经是已支付直接返回成功响应不再向下执行。5.3 商品列表慢、点击响应慢的常规优化商城流量上来后最常遇到的问题就是商品列表查询慢尤其是商品多了以后关联 SKU 查询容易产生 N1 问题。N1 的意思是最开始一条 SQL 查出 20 个商品然后循环 20 次每个商品再去查 SKU这样一共执行了 21 条 SQL。解决办法是用with预加载$list Goods::with([skus, category]) -where(is_on_sale, 1) -order(id, desc) -paginate(20);框架会一次性把关联数据查出来再映射SQL 数量从 21 条降到 3 条左右。另外一个优化是数据库索引。商品表的cat_id、is_on_sale订单表的user_id、order_no这些字段一定要加索引。order_no最好建唯一索引既能防重复又能加快查询。页面响应慢还有一个常见原因模板里频繁调用url()生成链接。如果模板循环里大量使用url()每次都会走路由解析性能会下降。生产环境建议开启路由缓存同时把商品列表页做成整页缓存或片段缓存数据变化时再清缓存效果立竿见影。6. 两个我强烈建议保留的开发习惯6.1 状态流转永远配日志商城项目的订单状态如果只靠 memorize 一个字段保存开发到后期一定会懵。谁改的、什么时候改的、改成什么全都没有记录。我每次写订单相关功能都强制要求同步写 order_log。这不只是给运维看也是给自己排查 bug 用的。页面报错时翻一下订单日志整个链路一清二楚。6.2 路由配置写成规划而不是临场发挥路由这块我吃过亏。一开始项目小直接在控制器里用默认路由后来加了伪静态才发现地址全乱了。现在我习惯在开工前就把 URL 规划好哪些页面需要对外展示哪些是后台管理哪些是接口全部提前定义好路由规则并且给每个路由加清晰注释。改地址时只用改一处路由定义模板里的链接全部用url()生成就不会出现改漏的情况。这个小习惯让后台上线之后的维护轻松很多也是我给刚接触 ThinkPHP 的开发者最实在的建议。