记得我是在大学里搞了一个代购跑腿的创业项目,后来慢慢变成帮零食品牌跑校园市场,就是你们看到的那种“扫码进小程序,寝室零食10分钟送到”的模式。2023年那会儿接了学校附近一个连锁便利店的需求,要把整个寝室小卖部的业务线上化,我选型的时候,前端用了 Vue,后端 PHP,中间加了一层 Node.js,当时很多人问我为什么这么折腾,今天就把这套 nodejs+php+vue 的校园寝室小卖部系统怎么从零搭起来、选型逻辑、核心模块怎么实现、踩过哪些坑,一次性讲清楚。
这不是一个“全栈营销演示项目”那种灌水代码,而是真真实实跑过一年的校园零食配送系统。目标用户是学生,订单高峰集中在晚上9点到11点,宿舍区网络环境复杂,弱网场景多,配送距离短但频率高,客单价低。所以整个项目的核心矛盾,不是功能多不多,而是能不能在低客单价的情况下把运营成本压下来。我所有的技术选型、架构调整,都是围绕这个逻辑来做的,下面会一条条拆开聊。
1. 项目拆解:寝室小卖部线上化到底在解决什么问题
1.1 先看校园零食配送的业务本质
校园寝室小卖部跟普通电商不一样。普通电商下单之后等快递,校园零食配送是“即时零售”的一种变体,核心体验是“下单后半小时内送到寝室”。寝室小卖部的SKU可能就两三百个,饮料、泡面、辣条、饼干、日用品为主,平均客单价大概20到40元。毛利不高,走量为主,所以系统的关键指标不是转化率,而是“履约时效”和“复购率”。
我当时给客户做的用户端是一个H5商城(后续套壳成了小程序),商家端走的是 PC 管理后台,配送端是给兼职学生用的小程序,三端共用一套接口。技术上就是标题里说的 nodejs+php+vue 三套件:Vue 负责用户端和后台的页面,PHP 负责核心业务接口和数据处理,Node.js 夹在中间做接口聚合、鉴权和部分实时逻辑。听起来有点“重复造轮子”,但实际上每一层都有它必须存在的理由。
系统从本质上分成三个角色链:
- 学生(消费者):在小程序/H5里逛商品、加购物车、下单支付、查看配送进度
- 店主/运营(商家):上架商品、调整价格、查看订单、处理售后退款
- 配送员(兼职学生):接单、取货、确认送达、结算配送费
第四个隐藏角色是“平台管理员”,负责对账、统计、管理所有门店数据。这个小卖部系统其实还有一个功能大家容易忽略,就是“预订单 + 拼单”,这是我在校园场景里迭代出来的核心功能。比如一个寝室四个人都要买辣条,一个人下了单,其他人可以通过拼单链接把东西加到同一个订单里,商家一次拣货,配送员一次配送,这样配送成本能下降40%左右,这个后面在订单模块里展开讲。
1.2 为什么需要一套系统而不是直接在群里接龙
很多校园小店最初都是靠微信群接龙做生意的。但跑成交量大了之后,接龙的问题非常明显:
第一,库存不可控。接龙里下了单,但店里货不够,要在群里挨个解释退款,体验极差。第二,财务对账靠人工,每天接龙几十上百单,收款和订单对不上是常有的事。第三,没有用户沉淀,群里的流量不属于小店,今天用了接龙工具,明天换了平台,用户就丢了。第四,多门店协同完全没法做,寝室楼有七八栋,一个门店接单送全校区,配送路线全靠人工安排,高峰期乱成一锅粥。
这套系统核心解决的,就是库存实时扣减、订单自动状态流转、用户账号体系沉淀、配送任务智能分配,以及和微信支付直接打通的对账体系。
2. 整体架构设计与技术选型说明
2.1 前后端分离跟“伪分离”的区别
先明确一点,现在的 Vue 项目基本都是前后端分离的开发模式。但我见过很多“假分离”项目,前端代码里夹着一堆 PHP 模板语法或者 Node 的模板字符串,这其实是走了老路。真正的前后端分离是:前端只关心 UI 渲染和用户交互,通过 HTTP 接口拿数据;后端只关心数据结构和业务逻辑,不关心页面长什么样。
在这套系统里,用户端是 Vue 3 写的单页应用,部署在 Nginx 上,PHP 接口跑在另一台服务器的 Apache/PHP-FPM 上,Node.js 中间层跑在单独端口,三者互不干扰。这样做的第一个好处是流量高峰时可以单独给 PHP 后端扩容,第二个好处是前端开发迭代不影响后端。后面我们做活动页面,比如“开学季零食大礼包”,只动了 Vue 代码,PHP 接口一个没碰,前端重新打包发布就完事了,非常省事。
开始的时候我只用了 Vue + PHP 两层结构,后来慢慢加入 Node.js,是因为遇到一个比较具体的痛点。小程序端调用后端接口的时候,因为业务越拆越细,一个订单详情页面要同时请求商品信息、库存状态、促销价格、配送员位置等四五个接口,客户端要串行等待多次网络往返,导致首屏渲染特别慢。在宿舍楼里学生用的是校园网,高峰期网络抖动非常严重,这种体验基本没法用。
后来我把 Node.js 作为 BFF(Backend For Frontend)层引进来,它的职责是:
- 接收前端的请求,并行调用 PHP 接口,把结果聚合后返回给前端
- 统一处理鉴权和请求日志,前端不需要知道 PHP 接口在哪台服务器
- 做 Socket.IO 实时推送,比如订单状态变化、配送员位置更新
- 处理一些轻量级的数据转换,比如把 PHP 返回的字符串日期转换成时间戳
Node.js 的选择也考虑过 Java 网关,比如 Spring Cloud Gateway 或者 Netty 那套,但对这个体量的项目来说太重了,光 JVM 内存就吃掉不少宿舍服务器。Node.js 是单线程事件驱动模型,特别适合这种 IO 密集的接口聚合场景,起一个服务占用内存不到100MB,跑得很舒服。
2.2 技术栈分工:不是越统一越好
先说 PHP。很多人觉得 PHP 是老古董,但在这种小体量的即时零售系统里,PHP 依然是开发效率最高的选择之一。特别是 Laravel 这类框架,内置了 ORM、中间件、队列、任务调度、事件系统,对接微信支付也有一堆成熟的扩展包。我个人的看法是,PHP 写业务逻辑的效率,在 PHP 8 之后真的是非常能打,而且维护成本低,很多大学在校生就能上手,招人也好招。
然后是 Vue。用户端和后台管理端都用了 Vue 3 + Vite + Pinia + Vue Router,移动端用了 Vant UI 库,PC 后台用了 Element Plus。用 Vue 的核心原因有两个:一是响应式开发效率高,写商城这种表单密集、状态多的页面非常顺手;二是生态成熟,Vant 和 Element Plus 都是开箱即用的,几乎不需要怎么写 CSS。Vue 3 的 Composition API 对复杂状态管理(购物车、订单流程)的可维护性提升很明显,后面代码量大了之后,我更确定这个选择是对的。
再说 Node.js。除了当 BFF 聚合层,Node.js 还承担了 WebSocket 服务。配送员位置更新、订单状态通知、商家接单提醒这些能力,如果全部依赖 HTTP 轮询,不仅浪费资源,而且实时性太差。我直接用 Socket.IO 做了消息推送通道,前端连接之后按订单房间号订阅,后端状态变化就推给对应客户端。
2.3 数据库和缓存选型
数据库用的是 MySQL 8.0,存储商品、订单、用户、门店等核心数据。缓存用的是 Redis 6.0,主要负责三块:
- 购物车会话(未登录状态下的临时购物车)
- 库存扣减的原子操作(后面在订单并发里细说)
- 微信支付回调幂等和订单状态机缓存
Redis 在整个项目中的地位非常高,尤其是秒杀场景和库存扣减场景,基本靠 Redis 的 Lua 脚本保证原子性。后面还有个锦上添花的模块,就是首页的“热卖榜单”和“猜你喜欢”,我用的是 Redis 里的 ZSet 做 PV/UV 排名,数据想到什么程度就想到什么程度,反正比每次查 MySQL 做统计快得多。
3. 核心模块实现与关键细节拆解
3.1 用户登录与权限控制:Token 双令牌模型
校园零食商城的用户大部分是学生,微信小程序端可以直接用微信登录,H5 端用的是手机号 + 验证码。但不管是哪种方式,后端统一颁发 Token,前端每次请求接口都带上。我这里用了 access_token + refresh_token 双令牌模型,access_token 有效期 2 小时,refresh_token 有效期 14 天。
为什么不用单 Token?因为在校园网环境下,学生经常切 Wi-Fi 和流量,网络请求容易断掉。如果 access_token 过期了,前端拿 refresh_token 去换新的 access_token,用户完全无感知,不会出现“请重新登录”的弹窗。这个体验细节在校园交付里特别重要,学生不会愿意为了买包辣条重新输一遍手机号验证码。
PHP 后端负责签 Token,Node.js 中间层负责验 Token。Access Token 我用的是 JWT,密钥存在环境变量里,Node.js 只校验签名,不查数据库,压力非常小。PHP 的 login 接口签完 JWT 返回给前端,前端存到 localStorage,请求拦截器里统一带到 Authorization 头。
有一点要注意:JWT 是无状态的,如果想实现“踢人下线”或者“修改密码后失效所有 Token”这种需求,就需要额外把 Token 的 jti 存到 Redis 里做黑名单。我做了一个 clear_token 机制,用户注销或者被管理员封禁之后,把 jti 塞进 Redis 黑名单,有效期跟 Token 剩余时间一致,中间层校验的时候先查黑名单,一秒钟内生效。
3.2 购物车和库存扣减:Redis Lua 脚本防止超卖
库存扣减是商城系统的老生常谈,也是最容易出问题的点。常规做法是:下单时 MySQL 里执行update goods set stock = stock - 1 where id = ? and stock > 0。这句话能保证不超卖,但高并发下会出现行锁竞争,订单量一大,数据库 CPU 直接飙高。而且如果下单流程里还有其他逻辑,比如创建订单、锁定优惠券、扣减积分,这些不在一个事务里,很容易出现数据不一致。
我的做法是把库存预扣放到 Redis 里,用 Lua 脚本保证原子性。用户点击“提交订单”后,Node.js 中间层先把请求发到 PHP,PHP 脚本执行 Redis 的预扣库存操作,脚本逻辑大致是:
$lua = <<<LUA local stock = redis.call('get', KEYS[1]) if not stock then return -1 end if tonumber(stock) < tonumber(ARGV[1]) then return 0 end redis.call('decrby', KEYS[1], ARGV[1]) return 1 LUA;如果预扣成功,PHP 才开始创建订单记录,进入支付流程。如果用户超时未支付,订单自动取消,库存回补。回补操作同样用 Lua 脚本做 incrby,同时把取消的订单状态改成已关闭。
这里想提醒一个坑:不要只扣 Redis 不扣 MySQL。Redis 里的库存只是“缓存”,MySQL 里的库存才是“账本”。我的做法是下单预扣 Redis,支付成功后再异步把 MySQL 的库存字段更新掉,最终一致。虽然 Redis 和 MySQL 之间有几秒延迟,但用户和商家都不会感知到这个差异。另外,Redis 里每个门店的商品库存 key 一定要带门店 ID,比如 stock:1001:sk_8899,不然多门店共用一套库存就乱套了。
3.3 拼单和配送逻辑:多寝室合并订单
刚才提到拼单功能,这是这个系统跟普通商城最不一样的地方。校园配送的特点是单量集中、单均商品件数少、配送距离近。如果不做合并,一个配送员跑一趟只送一份辣条,成本完全扛不住。拼单的逻辑是这样的:
学生 A 在店铺选择商品后,点击“发起拼单”,系统生成一个拼单码,有效期 20 分钟。室友 B 在首页输入拼单码,或者通过 A 分享的链接进来,可以把商品加到同一个订单里。所有参与拼单的人支付完成后,订单统一变成“待拣货”,商家按订单号一次性拣货。这个功能运营上简单,但技术上有几个难点:
- 多人同时加入同一个拼单组,购物车数据要合并且互相可见
- 每个人支付了不同的金额,但商家只收到一笔钱,需要拆分账单
- 配送费按寝室楼统一算一笔,不能每个人都收配送费
我的做法是:拼单组在 Redis 里存一个 hash,key 是拼单码,field 是 user_id,value 是商品列表 JSON。加入拼单时请求 PHP 接口,通过 Lua 脚本把商品追加到 hash 里。等创建正式订单时,把拼单组里的商品聚合去重,生成一个订单主表(order 表),再为每个拼单用户生成一条子订单(order_item 表),支付状态各自独立。这样如果室友 B 一直没支付,不影响室友 A 的订单发货,商家拣货时也能看到哪个子订单没支付。
配送模块我单独建了一张 delivery_task 表,字段包括订单号、门店 ID、配送员 ID、寝室楼栋、联系方式、状态、预计送达时间。商家“确认出餐”后,系统通过 Node.js 向附近配送员的在线列表推送新任务,配送员抢单。如果 5 分钟没人抢,系统自动把任务转给当前接单量最少的配送员。这里用到 Node.js 的是“在线列表”和“推送”,PHP 负责写库,Node.js 的 Socket.IO 广播给对应楼栋的配送员客户端。
配单的核心是楼栋分组。相同楼栋的任务会优先合并给同一个配送员,这也是我后期优化的重点。配送员端按楼栋展示任务列表,配送路线的规划其实没做太复杂,因为校区范围小,按楼栋排序就够用了,不需要接入地图导航 API,那套成本太高,对项目来说是过度设计。
3.4 支付与卡密充值:不只是微信支付
校园场景下学生没有信用卡,支付宝和微信都能用,但还有一部分用户用的是“校园卡余额”。为了让充值体验更顺滑,我做了两个支付渠道:一个是微信支付 Native 扫码付,另一个是卡密充值,就是运营人员在后台批量生成卡密,学生输入卡密直接充值到账户余额。标题里提到“充值卡密代码”,跟我们系统里的卡密池模块是同一个概念。
卡密生成的逻辑不复杂,但有两个坑:
- 卡密必须按批次生成,单次生成 1000 个,每个卡密对应一个批次号,方便对账。
- 卡密不能明文存库,要存哈希值。用户输入卡密后,PHP 用
password_hash()或者 MD5 加盐做比对,确认有效后标记为“已使用”,并给用户账户余额加上对应金额。
实现上我用了一个非常简单而安全的方案。卡密格式类似BSA7-9K2M-4XWQ,生成时其实是一个 16 位随机字符串,存库时只存 SHA256 哈希。用户充值时,系统把输入字符串拿 SHA256 加密后去数据库比对,匹配到了就更新状态。这样即使数据库泄露,攻击者也没法用卡密池的明文数据,安全性有保障。
支付回调是最容易踩坑的地方。微信支付的异步回调,PHP 接口必须做幂等处理,否则用户支付成功后回调发送了两次,就会出现重复加余额。我的处理方式是用 Redis 分布式锁:回调进来先尝试获取锁(锁 key 是订单号),获取成功后才更新支付状态,更新完成后释放锁。锁的过期时间设置 10 秒,避免业务处理时间过长导致死锁。
3.5 管理后台:Vue + Element Plus 的运营效率
商家端和后端管理用的都是 Vue 3 + Element Plus。管理后台承担的功能模块有:商品管理(增删改查、上下架、库存调整)、订单管理(按状态筛选、导出 Excel)、用户管理(查看注册信息、禁用账号)、拼单管理(查看活跃拼单组、手动关闭异常拼单)、配送员管理(审核、绑定楼栋、查看配送记录)、财务报表(按日/周/月合计营收)。
商品管理的设计有点小特色:支持“多规格”,比如一包泡面可以选“单包/整箱”,价格和库存都不同。这就是典型的 SKU 设计。数据库里我把 SPU 和 SKU 分成两张表,SPU 表存公共信息,SKU 表存每种规格的价格、库存、条码。前端商品详情页根据选择的规格动态切换价格和库存。这个用 Vue 的 computed 属性实现起来非常顺手,选规格 -> 查SKU -> 更新展示,比之前用 jQuery 写 DOM 操作省了不止一半工作量。
后台还有个实用的功能是“一键补货”。因为校园零食的消费带有很强的周期性,某个商品在某个时间段会突然缺货,比如考试周的咖啡和薄荷糖。我写了一个补货建议接口,统计过去 7 天商品销量趋势,当库存低于安全阈值时,后台自动在商品列表打上红色“建议补货”标签,运营只需要按列表给供应商下单就行。这算是我给项目加的一个小彩蛋,但效果出奇地好,客户特别认这个功能。
4. 实操过程与项目落地:从环境搭建到联调上线
4.1 Node.js 安装与环境配置避坑指南
这个项目的第一步,就是搭 Node.js 环境。标题的热搜词里有“nodejs安装及环境配置”“nodejs安装教程”“npm无法加载文件”,说明大家在这一步确实容易卡住。我先说一下我在 Windows 和 Linux 两类服务器上的做法。
如果你的电脑是 Windows,开发阶段直接去 Node.js 官网下载 LTS 版本安装包,一路 Next 就行。但装完有一个高概率出现的问题:在 PowerShell 里执行npm -v会报错,提示“npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本”。
这个错误不是 Node.js 装坏了,而是 PowerShell 的执行策略默认为 Restricted,禁止运行 .ps1 脚本。解决办法有两种:
- 以管理员身份打开 PowerShell,执行
Set-ExecutionPolicy RemoteSigned,再输入Y确认 - 或者在项目里不用 PowerShell,改用 CMD 或 Git Bash 执行 npm 命令
我个人更推荐第二种,不是说不让你改执行策略,而是 CMD 和 Git Bash 能避免很多后续的权限坑。后来在 Linux 服务器上部署,我没有用官网的编译安装,而是用 nvm 安装,命令大概是:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash nvm install 18.19.0 nvm use 18.19.0 node -v npm -v用 nvm 的核心好处是方便切换 Node 版本,因为不同项目可能依赖不同版本。校园系统如果是老服务器,可能还用着 Node 14,新项目用 Node 18,通过 nvm 切换互不影响,不会出现“为了一个项目把系统 Node 全局升级,结果另一个项目跑不起来”的问题。
项目初始化时我习惯把 package.json 里的 type 设成 module,整个项目统一用 ES Module 语法。然后在根目录造三个子目录:client(Vue 前端)、server(Node.js 中间层)、api(PHP 后端),三个目录分别维护自己的依赖。这样职责清晰,部署时也方便用 Docker 或者直接分服务启动。
4.2 Vue 前端项目初始化与依赖安装
Vue 前端的搭建我用的是 Vite,不是 Vue CLI,因为 Vite 冷启动快、热更新快,体积也小。执行初始化命令:
npm create vite@latest client -- --template vue cd client npm install npm install vue-router@4 pinia axios npm install vant element-plus @element-plus/icons-vue npm install socket.io-client安装依赖玩得最多的坑,就是网络问题导致某个包装不上,报ETIMEDOUT或者EINTEGRITY。解决办法是设置 npm 镜像源:
npm config set registry https://registry.npmmirror.comVue 项目的目录结构我按功能模块来划分,而不是按文件类型。比如:
src/ api/ # 接口请求封装 assets/ # 静态资源 components/ # 公共组件 router/ # 路由配置 store/ # Pinia 状态管理 views/ # 页面组件 home/ product/ cart/ order/ user/这样做的原因是,商城项目的页面逻辑通常按业务域来组织,商品、购物车、订单、用户这些模块之间联动很多,按功能模块组织代码,改起来不用跨目录到处找文件。
前端还有一个关键配置:路由守卫。因为用户端有“需要登录才能下单”和“游客也能逛首页”的混合场景,我在 Vue Router 的 beforeEach 里判断 localStorage 里有没有 access_token,以及当前路由 meta 里是否标记了 requiresAuth。没有 Token 却访问购物车、订单页的,直接重定向到登录页。
状态管理这块用 Pinia,主要是购物车和用户信息两个 store。购物车 store 里维护cartItems、totalCount、totalPrice三个核心状态,加购、减购、清空购物车都是通过 action 修改状态,页面通过 computed 派生数据,保证所有用到购物车的组件渲染一致。这个模式非常推荐,比用全局事件总线或者 provide/inject 维护跨页状态要清晰得多。
4.3 PHP 后端接口开发示例
PHP 端的接口开发我用了 Laravel,版本是 10。Laravel 的路由、中间件、Eloquent ORM 能省掉大量重复代码。比如创建一个商品查询接口,Controller 里的核心代码就几行:
public function detail(Request $request, $sku_id) { $sku = Sku::with(['spu', 'stock']) ->where('sku_id', $sku_id) ->first(); if (!$sku) { return response()->json(['code' => 404, 'msg' => '商品不存在']); } return response()->json(['code' => 0, 'data' => $sku]); }Laravel 自带的 Eloquent 关联查询特别方便,比如 SKU 关联 SPU、SKU 关联库存表,只要在模型里定义好关系,查询时 with 一把梭,避免了手写 JOIN 的繁琐。
但 Laravel 也有一点要注意:它默认的异常处理会把错误堆栈直接返回给客户端,这在开发环境方便,但生产环境是安全隐患。上线前一定要把.env里的APP_DEBUG设为 false,并且把LOG_CHANNEL配置成 daily,日志按天切割,避免日志文件无限增大。
接口层的另一个重要模块是跨域处理。前端跑在 8080 端口,PHP 接口跑在 9000 端口,一定会触发跨域。Laravel 里我用 fruitcake/laravel-cors 包,配置允许的来源、方法和请求头。注意加上exposed_headers,让前端能读响应头里的Authorization和X-Token,否则刷新 Token 的时候前端拿不到新 Token。
跨域配置有个安全细节:生产环境要把allowed_origins写死为你的域名,不要直接设成*。因为如果允许任意来源,等于任意网站都能通过用户的浏览器向你的接口发请求,CSRF 风险极高。
4.4 前后端联调和上线部署
前后端联调阶段最容易出现的问题是接口文档不一致。我在项目里用的方案是 Swagger 2.0(OpenAPI),PHP 端用注解生成文档,前端 axios 封装时按文档把请求和响应类型定义好。这里有个建议:接口数据格式尽量统一,比如所有接口的返回结构都是{ "code": 0, "msg": "success", "data": {} },前端 axios 封装一个统一的拦截器,code 为 0 才返回 data,非 0 就提示 msg。这样前端处理错误时不用每个接口单独写一套逻辑。
上线的时候,三个服务的部署位置不一样。Vue 打包之后放到 Nginx 的静态目录,PHP 用 PHP-FPM 跑在 9000 端口,Node.js 中间层跑在 3000 端口。域名解析之后,Nginx 配置里把/api/开头的请求代理到 PHP-FPM 或 Node.js,具体按接口路径来区分:
location /api/php/ { proxy_pass http://127.0.0.1:9000; } location /api/bff/ { proxy_pass http://127.0.0.1:3000; } location / { try_files $uri $uri/ /index.html; }Nginx 配置里有一个特别容易忽略的点:前端是 history 模式的路由,如果直接刷新某个子页面,比如/order/detail/123,Nginx 会按路径找文件,找不到就 404。所以必须加上try_files $uri $uri/ /index.html;,把所有没有对应文件的路径都回归到 index.html,由 Vue Router 接管路由。
上线后的日志监控我用的 PM2 管理 Node.js 进程。PM2 的配置文件 ecosystem.config.js 里设置了进程数、内存上限、日志路径:
module.exports = { apps: [ { name: 'shop-bff', script: 'server/index.js', instances: 2, exec_mode: 'cluster', max_memory_restart: '256M', env: { NODE_ENV: 'production' } } ] };用了 PM2 之后,进程崩溃会自动重启,日志会写到指定文件,排查问题方便很多。如果没有 PM2,直接用node server.js挂在后台,进程一崩就全站访问不了,很难受。
5. 常见问题与排查技巧实录
5.1 环境类问题速查表
这里整理一下我实际开发中碰到的高频环境类问题,每个都值得记一下。
| 问题现象 | 原因分析 | 解决办法 |
|---|---|---|
npm 安装依赖报ETIMEDOUT | 默认镜像源连不上 | 换成 npmmirror 镜像源 |
| PowerShell 执行 npm 报禁止运行脚本 | 执行策略 Restricted | 用 CMD 执行,或Set-ExecutionPolicy RemoteSigned |
node -v有版本,但nodemon找不到 | 全局依赖没装或路径没配 | npm install -g nodemon,确认 NODE_PATH 包含全局目录 |
已经安装 PHP 但php -v报找不到命令 | Windows 环境变量缺少 PHP 目录 | 把 php.exe 所在目录加到 PATH |
| Vue 项目启动后页面空白,控制台一堆模块报错 | 可能是依赖版本冲突 | 删除 node_modules 和 package-lock.json 后重新安装 |
| 接口返回 502 Bad Gateway | Nginx 无法连接到后端服务 | 确认 PHP-FPM 和 Node.js 进程是否存活、端口是否监听 |
| 上传的图片访问 403 | 静态资源目录权限不够 | 给 storage 目录设置 755 或 775 权限 |
环境问题其实占整个项目调试时间的很大比例。我第一次部署的时候,忘了把 Nginx 的client_max_body_size调大,结果运营在后台传商品图超过 1MB 就直接 413,排查了很久才发现是 Nginx 默认限制,而不是后端上传接口有 Bug。后来我统一设置成client_max_body_size 10m;,订单的截图、商品详情大图、门店头图都不用担心了。
5.2 业务逻辑类问题
业务逻辑的坑,更多出在支付和订单状态上。
第一个经典问题是订单超时未支付,但库存没回补。原因是订单创建时做 Redis 预扣,但定时任务扫描超时订单时,发现订单记录在 MySQL 里还没落库,就漏掉了。我的解决方案是:订单创建时同时把“订单过期时间”写入 Redis,key 是order_expire:{order_no},值是对应的商品库存 key 列表。定时任务直接扫描 Redis 里的过期 key,过期未支付的订单立刻回补库存,同时把 MySQL 里的订单状态改成已关闭。这样即使订单创建一半崩溃了,也不会出现库存泄漏。
第二个经典问题是微信支付回调丢单。用户支付成功了,但订单状态一直显示“待支付”。排查后发现,微信支付回调在校园网络环境下访问内网服务器不通,因为开发环境用的内网穿透工具地址在某段时间不稳定。后来我给回调地址做了内网穿透,但本地又没加白名单,导致微信服务器多次回调都被拒。解决办法是:在微信支付商户平台配置回调地址为公网 HTTPS 域名,同时 PHP 的验证签名逻辑里只校验微信平台证书和签名,不校验来源 IP。因为校验来源 IP 的话,如果微信服务器扩容,新 IP 不在白名单里,回调就会漏。
第三个问题是库存“无中生有”。运营在后台手工改库存,把库存调成 0 并保存,但前台还能下单成功。原因是前台库存读的是 Redis,后台改库存只改了 MySQL,Redis 里的缓存没失效。所以后来所有后台改库存的操作,都必须先改 MySQL,再删掉 Redis 里的对应 stock key,下次读取时重新从 MySQL 加载。这套联动逻辑看似简单,但如果后台有多个入口都能改库存(商品管理、批量导入、订单售后),很容易漏掉某个入口的缓存清理。我的做法是统一封装一个StockService::refresh($sku_id)方法,所有改库存的地方都必须调用这个方法,不允许直接操作字段。
5.3 性能与安全优化
性能上最立竿见影的优化是给 PHP 加了一层 OpenResty(Nginx + Lua)做缓存。商品详情这种读多写少的接口,直接把 Redis 的缓存前置到 Nginx 层,命中缓存的时候根本不会进入 PHP-FPM,压力瞬间小一大截。这套方案没引入额外的 CDN 成本,效果却很好。
安全方面,除了前面提到的 CSRF、Token 黑名单、日志安全,还有一个容易被忽略的点:手机号验证码接口的防刷。校园系统很容易被黄牛盯上,拿手机号批量注册账号领优惠券。我用的是最简单的防刷方案:一个手机号 60 秒内只能发送一次,一个 IP 一天最多发送 20 次,Redis 里存计数器,超出直接拒绝。虽然不及滑块验证那么复杂,但已经能挡住 99% 的脚本刷量。
订单接口的价格校验也很重要。下单接口必须由后端从数据库读取商品当前价格,不能信任前端传过来的价格。如果前端把价格参数当成可信数据,攻击者用抓包工具把单价改了,系统就会赔穿。我做了双重校验:下单时 PHP 重新计算总价,跟前端传的总价比对,不一致直接拒绝下单。支付回调时再次校验订单金额跟回调金额一致。这套逻辑在电商里叫“金额服务端校验”,是红线,绝对不能省。
6. 项目后续还能怎么扩展
虽然这个项目在学校里跑了挺久,但老实说迭代空间还是很大。我个人在实际操作中的体会是,如果想在下一期做得更好,有几个方向是明确的。第一个是引入更细粒度的数据统计,比如每个楼栋的销量热力图,用 Node.js 做定时任务把订单数据按时间和位置聚合,给运营调整备货结构参考。第二个是接入语音提醒,商家端和配送员端除了 App 的推送,还能通过微信公众号模板消息或者短信通知,减少在嘈杂环境下漏单的概率。第三个是小程序端可以考虑用 uni-app 重构,一次编写就能同时跑微信、支付宝和 H5,减少重复开发。
踩过几次坑之后,我觉得对这种中小型校园项目,技术选型真的不用追新,稳才是第一位。Node.js 做中间层也不是为了炫技,而是确实解决了接口聚合和实时推送的问题;PHP 也不是老技术不能用,它写业务逻辑的效率至今都很能打;Vue 更是让前端开发和维护的门槛大大降低。这套组合跑了一年多,没有出过重大的线上事故,说明选型是合理的,如果你也准备做类似的校园场景系统,可以参考这套路径。最后再分享一个小技巧:上线前一定要做好完整的支付回调日志,所有支付相关记录都单独写一个日志文件,排查问题的时候,日志就是你的命根子。