ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP图书商城系统实战:ThinkPHP+Laravel+Vue+ECharts全栈实现

PHP图书商城系统实战:ThinkPHP+Laravel+Vue+ECharts全栈实现

做一个图书商城这件事,几乎是每个PHP开发者的必经之路。早些年我跟着教程用ThinkPHP写过一个能跑通的版本,商品列表、购物车、下单,看起来都齐了,但心里清楚那只是"演示",离真正能被书店老板用起来还差很远。直到后来接手一个需求:商家要能在后台看到销售趋势、热销榜、库存分布,登录、下单、后台操作还要有验证码保护,前端指定用Vue,服务端要在ThinkPHP和Laravel之间做选择,我才意识到,同一个"图书在线销售商城"在不同阶段遇到的问题完全不同。这篇文章就把这套系统的设计与实现完整梳理一遍,重点拆解两套PHP框架如何分工、Vue前端如何对接、ECharts商家可视化怎么做、验证码安全闭环如何落地。如果你正打算拿图书商城当课程设计、毕业设计,或者想找一套练手的真实电商项目,这篇应该比碎片化教程更有参考价值。

1. 为什么偏偏是图书商城,又为什么同时用两套PHP框架

1.1 图书这个品类自带"练手价值"

选图书而不是数码、服装,是因为图书电商在业务复杂度上非常适中,但又不至于只有一张表。

图书天然具备SPU(标准产品单元)和SKU(库存量单位)的概念。拿一本书来说,同名同作者但精装版、平装版、电子版是三个SKU,它们的ISBN、装帧、价格、库存都不同,但在商品详情页里又必须归到同一个SPU下展示。这就逼着你认真设计商品表和SKU表,而不是把所有字段堆在一张表里。

图书还自带几个电商通用难题:出版社、作者、版次、出版日期这些元数据字段繁多;库存要跟实际仓库对得上;订单里需要快照(用户下单时商品名称和价格,之后不能随商品修改而变化);商家入驻之后还要做订单结算。这些业务点几乎覆盖了电商系统最核心的模块,练一遍下来,再做别的行业电商会轻松很多。

1.2 两套框架一起上的启发

很多人会问:做一个项目,为什么不用一个框架,非要ThinkPHP和Laravel一起用?这不是炫技,而是这套系统的实际需求决定的。

我在这套系统里做了明确分工:商城前台业务(商品、购物车、订单、支付回调)由ThinkPHP实现,商家后台管理与数据聚合接口由Laravel实现,两者暴露同一套RESTful API,Vue前端统一对接。

这样分的原因是ThinkPHP在快速开发和中文文档方面确实省心,适合把前台这种逻辑直接、交互频繁的场景快速做扎实;而Laravel的Eloquent ORM、任务队列、中间件、表单验证机制在后台管理这种需要大量数据聚合、异步处理、权限控制的场景里表达能力更强。两个框架各有擅长,实际使用中并不冲突。

当然这不是唯一方案。如果你的团队只会其中一个框架,那就用那个框架把前后台一起写了,完全没问题。真正重要的是理解框架各自的优缺点,而不是为了选型而选型。

1.3 这套系统的技术栈全景

模块技术选型说明
前端框架Vue 3 + Vue Router + Pinia + Axios商城端与商家后台用同一套工程,通过路由和权限区分
商城后端ThinkPHP 8 + MySQL 8 + Redis负责商品浏览、购物车、下单、支付对接
管理后端Laravel 10 + MySQL 8 + Redis负责商家管理、数据报表、可视化接口
数据可视化ECharts 5商家后台的销售趋势、品类占比、商品排行
验证码体系自研GD图形验证码 + 第三方滑块验证码登录、注册、下单、后台操作分级防护
部署环境Nginx + 云服务器前后端分离部署,API反向代理

提示:版本号不必追求最新,选你当前团队最熟、社区资料最多的版本。ThinkPHP 8和Laravel 10在这套需求下已经足够稳定。

2. 两套框架的分工实现:别让代码变成两座孤岛

2.1 ThinkPHP端:把业务逻辑从控制器里"赶出去"

ThinkPHP项目里最容易出现的问题,就是控制器越来越胖,SQL和业务规则全写在方法里。这套图书商城在前台有三个核心模块:商品、购物车、订单,每块我都拆成独立的Service类。

以订单创建为例,控制器只负责接收请求、调用服务、返回结果,真实的校验和事务由OrderService完成:

// app/api/controller/OrderController.php public function create(Request $request) { $params = $request->post(); try { $result = (new OrderService())->createOrder($params, $this->userId); return json(['code' => 0, 'data' => $result]); } catch (BizException $e) { return json(['code' => $e->getCode(), 'msg' => $e->getMessage()]); } }
// app/service/OrderService.php public function createOrder(array $params, int $userId): array { Validate::make([ 'sku_id' => 'require|integer', 'quantity' => 'require|integer|egt:1', ])->check($params); Db::startTrans(); try { $sku = Sku::lock(true)->find($params['sku_id']); if (!$sku || $sku->stock < $params['quantity']) { throw new BizException('库存不足'); } // 扣减库存、创建订单、记录订单商品快照... Db::commit(); return ['order_id' => $orderId]; } catch (\Throwable $e) { Db::rollback(); throw $e; } }

这里有两个关键点。一是Sku::lock(true)会对该行加锁,在高并发秒杀场景下能防止超卖,代价是吞吐量下降,后面在优化章节我再讲Redis预扣的方案。二是订单商品表必须保存商品名称、ISBN、封面、单价、购买数量这份快照,这样商家以后改商品资料,也不会影响历史订单展示。

2.2 Laravel端:Eloquent聚合让报表接口写起来很舒服

Laravel在商家后台的价值,主要体现在数据聚合上。商家要看自己店铺的销售趋势、品类占比、热销商品,如果手写SQL去拼接GROUP BY和日期补零,代码会非常啰嗦。Eloquent的查询构造器配合模型关联,表达这类需求很顺手。

举一个销售额趋势接口的例子:

// app/Http/Controllers/Api/Merchant/StatController.php public function dailySales(Request $request) { $merchantId = $request->user()->merchant_id; $days = (int) $request->get('days', 30); $rows = OrderItem::query() ->join('orders', 'orders.id', '=', 'order_items.order_id') ->where('orders.merchant_id', $merchantId) ->where('orders.pay_status', Order::PAID) ->where('orders.created_at', '>=', now()->subDays($days - 1)) ->selectRaw('DATE(orders.created_at) as date, SUM(order_items.total_price) as amount') ->groupBy('date') ->orderBy('date') ->get() ->keyBy('date'); // 补零:生成连续的日期数组,没有销售的日期金额补0 $result = collect(range(0, $days - 1))->map(function ($i) use ($rows) { $date = today()->subDays($days - 1 - $i)->toDateString(); return [ 'date' => $date, 'amount' => $rows->get($date)?->amount ?? '0', ]; }); return response()->json(['code' => 0, 'data' => $result]); }

collect(range(...))->map(...)这段补零逻辑很值得单独说:数据库里没有销售记录的日期,GROUP BY之后压根不会出现。你要是不补零,直接返回给ECharts,折线图会变成断断续续的点,视觉上像数据丢失,商家会误以为系统出bug。这是做图表接口第一个要处理的细节。

权限控制方面,Laravel中间件做得很规范,我给商家API统一挂了一个check_token中间件,里面用守卫解析JWT,取出merchant_id塞进请求对象,后台每次查询都带上这个商家ID,确保A商家永远看不到B商家的数据。

2.3 一套Vue工程同时服务商城端和商家后台

前端工程没有拆成两个项目,而是用Vue Router的嵌套路由和动态权限菜单来区分。商城端路由是公开的:首页、分类、商品详情、购物车、结算;商家后台路由进入前先做鉴权,没有商家身份的跳去登录。

Axios统一封装里做三件事:

  • 请求拦截器携带Token,Token失效用Refresh Token静默续期;
  • 响应拦截器统一解包{code, msg, data}结构,业务码非0时自动弹出提示;
  • 环境变量区分开发与生产环境的API前缀,本地走/api代理,线上走正式域名。

Vue这种前后端分离架构的好处是,商城端和商家后台虽然功能完全不同,但登录、支付组件、图表封装这类公共部分可以复用。比如ECharts我抽了一个ChartBox.vue公共组件,商家后台各个图表页面只需要传配置项和请求函数,不用每个页面都写一遍初始化逻辑。

3. 商城核心链路:数据表、购物车、订单状态机

3.1 围绕图书商品的数据表设计

图书商城最核心的几张表,设计时需要考虑这几层关系:

数据表关键字段设计目的
book(图书SPU表)id、title、author、publisher、isbn、cover、description、status同一本书不同版本共享基础信息
book_sku(库存单位表)id、book_id、edition、binding_type、price、stock、sku_code精装/平装/电子版区分库存
merchant(商家表)id、name、contact_phone、status多商家入驻支持
cart(购物车表)id、user_id、sku_id、quantity、checked记录未下单的选购状态
order(订单表)id、order_no、user_id、merchant_id、total_amount、pay_status、order_status、created_at主订单,一个商家一个订单
order_item(订单商品快照表)id、order_id、sku_id、book_title、cover、price、quantity、total_price保存下单时的商品快照
captcha_log(验证码记录表)id、scene、key、code_md5、expired_at、consumed_at、ip验证码发送与校验日志

订单表这里要提一个设计细节:一个用户可能在A商家买了书又在B商家买了书,如果把所有商品塞进一个订单,商家之间没法独立结算。所以主订单下面其实还要按商家拆成子订单,或者直接主表就突出merchant_id,保证一个订单只属于一个商家。我当时用的是后者,简单直接,商家后台查订单也不需要多层join。

3.2 购物车到订单的状态流转

购物车表里的数据本质上是"待确认的意向",所以字段必须记录数量、勾选状态、创建时间。用户结算时,前端把勾选的商品ID列表传给后端,后端逐条校验:SKU是否下架、价格是否变动(以前端传为准,但后台要重新计算总价)、库存是否足够。

订单状态我定义了一套常量,和其他业务代码耦合很紧:

// 支付状态 const PAY_UNPAID = 0; // 未支付 const PAY_PAID = 1; // 已支付 // 订单状态,按顺序流转 const ORDER_PENDING = 0; // 待付款 const ORDER_PAID = 1; // 已付款 const ORDER_SHIPPED = 2; // 已发货 const ORDER_COMPLETED = 3; // 已完成 const ORDER_CANCELLED = 4; // 已取消

这里值得说明的是订单取消:未付款订单在超时后由定时任务批量取消,释放库存;已付款订单取消则要走退款流程,不能简单改状态。退款金额还要算清楚,优惠券分摊部分按比例退回,这部分逻辑最容易出财务纠纷,我单独拎出来写了一个RefundService。

3.3 库存扣减:从悲观锁到Redis预扣

前面订单创建用的Sku::lock(true)是悲观锁方案,优点是绝对不会超卖,缺点是每秒能处理的订单量有限。图书商城正常流量下这个方案完全够用,但如果你要压测高并发,或者未来要加秒杀,就得换成Redis预扣库存:

// 下单前:Redis预扣 $redisKey = 'sku_stock:' . $skuId; $stock = Redis::get($redisKey); if ($stock === false) { Redis::set($redisKey, $sku->stock); $stock = $sku->stock; } if ($stock < $quantity) return '库存不足'; Redis::decrby($redisKey, $quantity); // 创建订单... // 支付失败/超时取消:回补库存 Redis::incrby($redisKey, $quantity);

Redis和MySQL库存的最终一致性,靠定时任务对账兜底:每隔一段时间扫描订单状态异常的记录,用数据库真实库存重刷Redis。这个方案我在测试环境验证过,并发能力提升明显,代价是逻辑复杂度上升。你在自己的项目里,可以先从悲观锁起步,等真正遇到瓶颈再切换。

4. 商家可视化不是画图,而是把经营问题翻译成图表

4.1 先定义商家到底要看什么

做ECharts可视化最容易犯的错,是只想着"我要做几个图表"凑页面,却不思考商家每天打开后台到底想回答什么问题。我在做商家后台之前,把需求列成了这么几个经营问题:

  • 今天卖了多少,最近30天是涨是跌?——对应销售趋势折线图;
  • 哪种类型的书卖得好,文学、科技、少儿哪个是主营收?——对应品类占比饼图;
  • 哪些商品是爆款,是否该补库存?——对应商品销量排行条形图;
  • 每天各个时段的订单量分布,什么时候做促销效果最好?——对应24小时热度柱状图。

这四个图表够用但不啰嗦,每一个都能直接指导商家做决策。给商家用的后台,宁可少而清晰,不要十几个图表堆一屏。

4.2 后端聚合接口的粒度控制

图表数据接口和列表接口不一样,列表可以一次查很多字段让前端慢慢处理,图表接口必须直接给到"能画图"的粒度。比如饼图接口直接返回[{name:'文学', value: 1024}, {name:'科技', value: 831}],前端拿到直接塞进data,不需要再做二次计算。

聚合接口还有一个性能细节:如果数据量大了,不要在SQL里做太重的多表关联。我这边销售趋势是按日聚合,订单量在十万级以下直接查也行;但如果单日订单量超过几十万,就得考虑用定时任务把前一天的销售汇总写进daily_stat表,图表接口只查汇总表。这个优化我在第二阶段才做,前期监控到慢查询后加上的。

4.3 前端渲染的三个常见坑

ECharts接入本身不复杂,装包、初始化、setOption三步。真正让我折腾一晚上的是这三个坑。

坑一:容器隐藏或未渲染完成时初始化,图表宽度为0。商家后台用了Tab页签切换,Vue的v-show隐藏状态下如果执行了echarts.init,图表是画在宽度为0的容器里,切回来怎么resize都救不回来。我的解决办法是:切到对应Tab并且确保nextTick之后再初始化,或者在Tab激活事件里调用chart.resize()。

坑二:表格页点击搜索后,旧图表的loading状态残留。每次重新请求数据前必须手动chart.showLoading(),请求完后chart.hideLoading(),还要在组件销毁时chart.dispose(),否则内存会越占越多。

坑三:ECharts地图需要单独注册GeoJSON。如果商家要看全国销售分布,普通柱状图用不到,但地图必须注册地图数据。不注册的话直接白屏,控制台只会报一个不太好定位的错。建议提前把中国地图GeoJSON放进静态资源目录,按需注册。

5. 验证码体系:从图形码到场景化防护

5.1 哪些接口必须上验证码

很多人以为验证码就是登录页一个图形,其实不安全。我在这套系统里给四类接口做了防护:

  • 登录、注册接口:防撞库和批量扫号,连续失败超过5次必须出验证码;
  • 下单接口:防刷单,同一用户60秒内下单超过2次弹出滑块验证码;
  • 商家后台的敏感操作(改价格、提现、改库存):防CSRF和越权,每次操作都验一次;
  • 找回密码接口:防止批量遍历手机号或邮箱。

验证码不是每时每刻都需要,而是在"行为可疑"的时候才出现。这套策略对正常用户干扰很小,又能挡住绝大多数自动化脚本。

5.2 自研GD图形验证码的实现要点

图形验证码我第一版用自研GD库来实现,代码不长但容易踩细节:

public function generate(int $length = 4): array { $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆的0/O/1/I $code = ''; for ($i = 0; $i < $length; $i++) { $code .= $chars[random_int(0, strlen($chars) - 1)]; } return [ 'code' => $code, 'image' => $this->render($code), ]; } private function render(string $code): string { $width = 130; $height = 46; $img = imagecreatetruecolor($width, $height); // 背景、干扰线、噪点、文字……具体往GD函数里填即可 ob_start(); imagepng($img); return ob_get_clean(); }

几个经验:字符集必须去掉容易混淆的字符,用户输半天看不清是O还是0,体验很差;背景加两条干扰线、撒一点噪点就足够,不要把验证码做得像抽象画;字体文件路径一定要写绝对路径或者用框架的base_path()拼接,直接用相对路径在Linux部署时经常崩。还有更关键的,验证码文字要保存对应关系,生成后不能只返回给前端,服务端必须留底。

5.3 服务端校验闭环:一次性、限频、防重放

图形验证码的校验是教科书式的逻辑,但很多人实现得不对。正确的闭环是:

  1. 生成验证码时,把验证码明文MD5后和唯一captcha_key绑定,存到Redis,设置5分钟过期;
  2. 前端展示验证码的同时拿到captcha_key,用户提交表单时一起带上;
  3. 后端校验时取Redis里的值和用户输入做比对,比对完无论成功失败都删除这个key,保证一次性;
  4. 失败次数用另一个Redis key计数,同一IP或同一手机号5次失败就锁定15分钟。
public function verify(string $captchaKey, string $code): bool { $redisKey = 'captcha:' . $captchaKey; $md5 = Redis::get($redisKey); Redis::del($redisKey); // 一次性使用 if (!$md5 || md5(strtoupper($code)) !== $md5) { return false; } // 记录消费日志,供审计使用 return true; }

至于第三方滑块验证码,我是在自研图形码跑通后才接的,用在下单场景。滑动拼图这类验证码用户体验确实好,但要注意它需要前端向第三方服务发起验证并取回凭证,后端再拿着凭证去服务端验签,不能光靠前端判断"滑对了"就放行。要把验证服务当作一个黑盒:前端通过、第三方服务端二次验签、再走自己的业务逻辑,三关缺一不可。

6. 部署上线前后,那些必须要处理的硬问题

6.1 Nginx配置:历史路由、反向代理、静态资源

前后端分离部署第一关就是Nginx。Vue用history模式时,用户刷新一个/checkout页面,Nginx如果找不到对应文件会直接404,必须把所有请求回退到index.html:

server { listen 80; server_name bookstore.example.com; root /www/web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

location /api/这段是反向代理,Vue工程里的请求都走这个前缀,后端不用处理跨域,浏览器看到的始终是同源请求,省掉一大票CORS问题。如果你非要搞跨域,那后端还得处理OPTIONS预检请求,Laravel里要加中间件,TP里要挂行为,麻烦得多。

6.2 上线后观察到的慢查询和性能优化

项目上线第一周,商家后台销售趋势接口偶尔会卡到两秒以上。排查后发现问题出在orders表和order_items表没有建立联合索引,WHERE merchant_id = ? AND pay_status = ? AND created_at BETWEEN ? AND ?这种查询在数据量稍涨后就开始变慢。

解决方式很简单,在表上补联合索引即可:

ALTER TABLE orders ADD INDEX idx_merchant_pay_time (merchant_id, pay_status, created_at);

索引是低级优化,但很有效。第二处优化是给商品图加了一级Redis缓存,商家后台首页的统计数字缓存10分钟,用户浏览频次高的热销商品列表缓存5分钟,数据库压力肉眼可见降下来了。第三处是订单创建从悲观锁换成了Redis预扣,这个前面写过。这三步做完,峰值流量下系统响应基本都稳定在200毫秒以内。

6.3 三个印象最深的"莫名奇妙的坑"

第一个坑在ECharts。商家后台的图表在本地开发环境显示正常,一上线就白屏,排查半天发现生产环境ECharts包体积太大被webpack拆分懒加载,图表组件在切Tab时才异步加载,但容器还没显示出来就初始化了。最后改成Tab激活后再请求数据并初始化,彻底解决。

第二个坑在验证码字体。自研GD图形验证码在本地找不到中文字体时自动用了默认字体,但生产环境的Linux系统里连默认字体都缺失,导致验证码图片上文字是乱码。后来我把一个TTF字体文件放进public/fonts目录,用绝对路径加载,才稳定下来。

第三个坑在ThinkPHP的跨域Session。商城端两个不同域名(H5和商家后台)共用同一套登录态时,Session ID在跨域请求下丢失,用户明明登录了,商家后台却反复跳到登录页。这套系统后来统一改成了JWT无状态认证,问题才算根治。如果你也遇到类似情况,建议一开始就上JWT,别在Session跨域上浪费太多时间。

7. 关于这套系统,最后想分享的一点经验

整个项目从零开始到商家真正用起来,我最大的体会是:图书商城这样"看起来简单"的业务,真正的难度从来不在增删改查,而在数据一致性、安全校验和数据处理这些看不见的地方。购物车到订单的库存扣减、验证码的一次性校验、商家图表的数据补零,哪一块单拎出来都不难,但把它们组合在一起,系统才像真的能卖货的样子。

如果你也想复刻这套方案,我的建议是:先跑通ThinkPHP和Laravel各自的Hello World,再约定接口文档,最后才动手写Vue页面;可视化先做销售趋势和品类占比两个图就够,验证码先实现登录一个场景,跑通后再复制到下单等环节。踩过的坑我已经写在前面了,你大概率会少走不少弯路。真到了商家后台图表上线那一刻,你会觉得前面折腾一晚上改文件、调Nginx、补索引,全都值得。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进