做新能源电池销售商城这个项目,原本不是拍脑袋定的方案。2024年初团队拿到一个真实需求:公司做动力电池和储能电池的分销,线下门店每天都要处理大量询价、报价、下单的琐碎事情,线上又没有一个统一入口,客户想看产品参数只能靠销售发Excel表格。于是老板拍板,上一套商城系统,把商品展示、在线询价、下单、支付、订单管理全部打通。项目最终落地为ThinkPHP + Vue的组合,标题就叫"thinkphp+vue新能源电池销售商城系统"。
这个项目对我个人来说,最大的价值在于它和普通服装、日用品电商完全不是一回事。新能源电池不是标准品,买电池的人可能是经销商、维修门店、终端用户,产品的容量、电压、循环寿命、适配车型都直接影响下单决策。因此商城不能照搬通用电商模板,必须有针对电池这个品类的设计。这篇博文就把整个项目的思考过程和实操细节完整写出来,包括数据库怎么设计、前后端怎么分工、SKU和库存怎么处理、联调时踩了哪些坑。适合准备做行业垂直电商、正在用ThinkPHP+Vue做商城、或者对新能源电池业务系统感兴趣的朋友参考。
1. 项目需求梳理:电池商城和普通电商的根本差异
先别急着写代码,需求层面如果搞不清楚,后面返工的代价会很大。这个项目立项时我们拉了一个月的业务会议,和销售、仓库、财务都聊过,最终把需求归纳成三个核心模块:商品与客户、交易与库存、后台管理与数据。
1.1 商品维度的特殊性
普通电商的商品是"标准品 + 规格",比如一件衣服有颜色、尺码;电池商品的关键属性不是颜色尺码,而是容量(Ah)、标称电压(V)、放电倍率(C)、循环次数、内阻、工作温度范围、适配车型/设备。这些参数直接影响客户能不能用、怎么用。客户下单前通常需要对比不同型号的性能差异。
因此商品模型要拆成 SPU + SKU 两层,SPU表示一个产品线(比如"12V 100Ah 磷酸铁锂电池"),SKU对应具体配置(比如"12V 100Ah,带BMS,标准端子")。电池的参数必须结构化存储,不能塞进一个富文本描述里,否则后期做筛选、对比、导出报价单都很痛苦。这是第一个和通用电商模板不一样的地方。
1.2 客户群与价格策略
第二个差异在价格。商城的客户分两类:经销商和零售客户。经销商的采购价和零售价不同,还可能根据采购量享受阶梯价。普通商城的统一价体系在这里行不通,需要做到"按客户等级显示价格、按采购数量自动匹配阶梯价"。
我们最初讨论过要不要做大客户线下报价、线上只做展示,但销售团队强烈反对——如果线上不能直接下单,客服的工作量一点没减。最后定的是:登录用户按等级看到对应价格,订单金额达到阶梯自动打折,所有价格计算在后端完成,前端只负责展示结果。这样既保证了价格的严肃性,也避免了前端改价格导致的数据不一致。
1.3 交易流程的后端要求
电池类目属于大件,客单价通常从几百到几万,物流需要物流公司打木架、走重货专线,和普通快递不是一个体系。所以订单流程里要区分:订单生成、支付/线下转账、仓库审核、物流发货、收货确认。库存不能像普通电商那样拍下就锁死,我们要做"下单锁库存、超时未支付自动释放"的机制,避免大客户批量询单把库存全占了。
另外支付这块,商城既要支持在线支付,也要支持企业对公转账后人工确认到账。这就意味着订单状态机比通用商城复杂,至少要有:待支付、待确认到账(线下转账)、待发货、已发货、已完成、已取消。状态流转需要后端严格控制,不能用前端的按钮随便改。
2. 技术选型:为什么是ThinkPHP + Vue,而不是其他方案
技术选型这事儿,很多人一上来就陷入框架之争。我自己的态度很明确:选型不是选最流行的,而是选最匹配团队和业务的。这个项目最终定了 ThinkPHP 6.0 + Vue 3.2 + Element Plus + MySQL 8.0,下面说说每一步的考量。
2.1 后端选 ThinkPHP 的核心理由
团队当时的情况是:PHP 开发两名,Java 一名,但 Java 那哥们是中途转岗,不够熟悉 Spring 全家桶。项目周期只有三个月,要快速上线,后端人力最充裕、经验最深的就语言是 PHP。ThinkPHP 作为国内使用率很高的 PHP 框架,有一套成熟的路由、ORM、中间件机制,官方文档详尽,社区资料多,遇到问题基本能搜到答案,这对项目排期是很大的保障。
ThinkPHP 6.0 在架构上做了不少现代化改进,比如采用更加规范的多应用模式,app\controller、app\model的目录划分清晰,ORM模型支持关联查询,中间件机制也够用。和 Laravel 比,ThinkPHP 更轻,学习曲线平缓,模板引擎、验证器、分页这些高频功能开箱即用。对一个商城系统来说,这些能力足够,而且不会有过度设计的问题。
2.2 前端选 Vue 3 的考量
前端选择 Vue 3 + Vite + Element Plus,理由很简单:商城需要大量交互界面,包括商品列表筛选、SKU选择、购物车实时计算、订单状态跟踪,Vue 的响应式系统做这类场景非常顺手。Vue 3 的组合式 API 让逻辑复用比 Vue 2 的 options API 顺手得多,团队在前期调研时也确认了这一点。
组件库用 Element Plus,是因为它的表格、表单、弹窗、步骤条都齐全,中后台页面和商城前端页面都能覆盖。Vite 做开发服务器,冷启动和热更新比 Webpack 快不是一点半点,这在后期反复调布局、改交互时能节省大量时间。
2.3 和 SpringBoot + Vue 方案的对比
我知道很多人会质疑:SpringBoot + Vue 不是更"主流"吗?确实,如果团队 Java 能力强、项目规模大、需要微服务拆分,SpringBoot 是更稳妥的选择。但对我们这个 3 个月要上线、后端只有两名 PHP 开发的项目来说,SpringBoot 意味着更长的学习成本、更高的服务器内存占用、更复杂的部署流程。做一个几百并发级别的行业商城,ThinkPHP 的 PHP-FPM 模型完全够用,强行上 SpringBoot 属于资源错配。
Node.js 中间层方案我们也讨论过,比如用 Express/Nest 做后端,但业务财务、订单对账这些模块需要和现有的进销存系统对接,对方系统只有 PHP 接口,用 ThinkPHP 对接更直接。技术选型最终是"够用、可控、能按期交付"的产物,而不是纯技术喜好。
3. 后端落地:数据库设计与ThinkPHP核心实现
后端是整个商城的底座,数据库设计得合理不合理,直接决定后续开发省不省心。这一节把核心表结构、ThinkPHP 模型关系、接口设计思路讲清楚。
3.1 数据库表结构设计
商城系统主要表我列在这里,大家可以根据自己业务增减:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| users | 用户表 | id, username, password, role, level, company_name, phone |
| goods_spu | 商品SPU表 | id, name, brand, category_id, status, sort |
| goods_sku | 商品SKU表 | id, spu_id, sku_name, price, member_price, stock, sales, image, voltage, capacity, cycles, params_json |
| carts | 购物车表 | id, user_id, sku_id, quantity, checked, created_at |
| orders | 订单表 | id, order_no, user_id, total_amount, pay_amount, status, pay_type, pay_time, address_info (json), remark |
| order_items | 订单明细表 | id, order_id, sku_id, sku_name, price, quantity, subtotal |
| payments | 支付流水表 | id, order_no, pay_no, amount, channel, status, callback_raw, created_at |
| admins | 后台管理员表 | id, username, password, role |
电池参数没有单独建 EAV(实体-属性-值)表,而是直接在 SKU 表里建了几个核心字段(voltage、capacity、cycles),其余松散参数放params_json。原因很实际:筛选时最常用的是电压和容量,这两个提成字段能走索引,查询效率高;其他非高频属性用 JSON 存,灵活且不会产生大量关联查询。EAV 设计更灵活但查询麻烦,对商城这种固定参数模型的场景并不是最优解。
address_info用 JSON 字段存整个地址快照,而不是关联地址表,理由是订单一旦生成,地址必须锁定,关联表如果用户后续修改地址会导致订单历史被污染。这类"快照式"设计在订单领域是标准做法。
数据库索引上有几个重点:goods_sku表的spu_id、voltage、capacity需要建普通索引;orders表的user_id、status需要索引,这是后台订单列表最常用的过滤条件;payments表的order_no要建唯一索引,防止重复回调。这些索引在订单量大的时候会明显影响响应速度,后期我在性能优化部分还会提到。
3.2 ThinkPHP 模型与关联查询
后端开发用的是 ThinkPHP 6.0 的ORM模型。一个典型的模型关系如下:
// app/model/GoodsSku.php class GoodsSku extends Model { // SKU 属于 SPU public function spu() { return $this->belongsTo(GoodsSpu::class, 'spu_id'); } }商品列表要返回 SPU 下的所有 SKU,在控制器里这样写:
public function index(Request $request) { $spuId = $request->param('spu_id'); $list = GoodsSku::with(['spu']) ->where('spu_id', $spuId) ->where('status', 1) ->order('sort desc, id desc') ->paginate($request->param('page_size', 12)); return json(['code' => 0, 'data' => $list]); }with预加载避免N+1查询问题,这是列表接口性能的关键。商城商品列表如果SQL多了几十条甚至上百条,响应时间就压不下来。
订单和订单明细用关联查询也很方便:
$order = Order::with(['items']) ->where('order_no', $orderNo) ->find();关联模型这种写法相比手写 JOIN 的可读性高很多,后期维护时不用去翻SQL拼接逻辑。ThinkPHP 的模型关联支持一对一、一对多、多对多,商城里订单与明细、SPU与SKU都是典型用法。
3.3 JWT认证与权限控制
后端接口不能是"裸奔"的,商城除了商品列表和详情,购物车、下单、订单查询都需要登录态。这里用了 JWT(JSON Web Token)做无状态认证,ThinkPHP 侧引入firebase/php-jwt扩展,在中间件里统一解析 Token。
// app/middleware/JwtAuth.php public function handle($request, \Closure $next) { $token = $request->header('Authorization'); if (!$token) { return json(['code' => 401, 'msg' => '未登录'], 401); } try { $decoded = JWT::decode($token, new Key(config('jwt.secret'), 'HS256')); $request->userId = $decoded->uid; $request->userRole = $decoded->role; } catch (\Exception $e) { return json(['code' => 401, 'msg' => '登录已过期'], 401); } return $next($request); }登录成功后生成 Token,设置合理的过期时间(我们设了 7 天),前端每次请求在 axios 拦截器里带上:
// 前端 request.js service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = token; } return config; });后台管理员权限用简单 RBAC 处理,admin 表上加一个 role 字段,admin是超管,operator是运营。后台接口统一走AdminAuth中间件,根据角色判断是否能访问。小团队项目没必要上复杂的权限框架,够用、清晰、安全才是第一位的。
4. 前端Vue商城实战:从环境配置到核心页面
前端这部分,我在项目开发中走了不少弯路,主要是因为 Vue 3 + Vite 的组合在刚上手时和 Vue 2 的 Webpack 项目差别不小。这里把从零到能跑通的路整理一遍。
4.1 环境安装与项目初始化
如果你还没装 Vue 环境,先把 Node.js 装好,推荐 LTS 版本,我用的是 Node 20。然后用 Vite 初始化项目最省事:
npm create vite@latest battery-shop-web -- --template vue cd battery-shop-web npm install npm install element-plus axios vue-router@4 pinia npm run dev有几个容易踩的坑说下:
- Element Plus 按需引入需要配
unplugin-auto-import和unplugin-vue-components,如果不配,直接全量引入也行,项目初期为了省时间可以全量引入,后期用按需引入优化打包体积。 - Vite 的端口默认是 5173,和后端接口不在同一个端口,所以跨域配置要提前想好,后面联调那节细讲。
npm install偶尔会因为网络原因失败,换成国内镜像源通常能解决,这不是什么高端操作,但确实能省不少排查时间。
项目目录结构建议这样组织:
src/ api/ // 接口请求模块 assets/ // 静态资源 components/ // 公共组件 router/ // 路由配置 stores/ // Pinia 状态管理 views/ // 页面组件 home/ // 首页 product/ // 商品列表/详情 cart/ // 购物车 order/ // 订单流程 user/ // 个人中心4.2 路由设计:静态路由 + 动态路由
商城前端的路由分成两套:访客能看的路由(首页、商品列表、商品详情)和登录后才有的路由(购物车、订单、个人中心)。Vue Router 4 里面用路由守卫控制访问。
// router/index.js const routes = [ { path: '/', name: 'Home', component: () => import('@/views/home/Index.vue') }, { path: '/product/:spuId', name: 'ProductDetail', component: () => import('@/views/product/Detail.vue') }, { path: '/cart', name: 'Cart', component: () => import('@/views/cart/Index.vue'), meta: { requiresAuth: true } }, { path: '/order/list', name: 'OrderList', component: () => import('@/views/order/List.vue'), meta: { requiresAuth: true } }, ]; router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); } else { next(); } });动态路由这块主要用在后台管理:管理员登录后,根据后端返回的角色配置动态注册路由。做法是登录后拿到权限标识,遍历一个映射表,把不匹配权限的路由router.addRoute()加上。这样后台页面就不是写死的,以后扩展新权限时前端不用改太多代码。
动态路由的坑在于:刷新页面后路由会丢失,因为addRoute是内存操作。解决办法是在应用启动时先检查有没有 token,有的话先调getUserInfo接口拿权限,再注册动态路由,最后再next({ ...to, replace: true })重新进入目标页。这个逻辑写不对就会出现"刷新后页面白屏或404"的经典问题。
4.3 状态管理与购物车联动
购物车是商城前端交互最频繁的模块,我用了 Pinia 管理购物车数量角标和选中状态。如果不做统一状态管理,会出现"在商品详情页加购后,首页右上角数量不更新"的尴尬问题。Pinia 比 Vuex 轻量,TypeScript 支持也好,Vue 3 项目里基本是首选。
// stores/cart.js import { defineStore } from 'pinia'; import { getCartList } from '@/api/cart'; export const useCartStore = defineStore('cart', { state: () => ({ items: [], totalCount: 0, }), getters: { checkedItems: (state) => state.items.filter(item => item.checked), totalPrice: (state) => state.items.reduce((sum, item) => sum + (item.price * item.quantity), 0), }, actions: { async fetchCart() { const res = await getCartList(); this.items = res.data; this.totalCount = res.data.reduce((sum, item) => sum + item.quantity, 0); }, }, });购物车页面的 SKU 选择器也是比较耗时的组件。电池的 SKU 不是单纯的颜色尺码,而是"电压 + 容量 + 端子类型"的排列组合。前端做一个参数选择面板,切换参数后要把对应的 SKU 价格、库存同步展示出来。这里有个细节:某个组合如果没库存,选项要做禁选处理,否则用户选了半天到结算页才发现没货,体验会很差。
4.4 商品筛选与参数对比
商品列表页需要支持按电压、容量、品牌筛选,还要支持按销量、价格排序。这个页面一开始我直接用Element Plus的el-select下拉做筛选,参数多了以后页面状态很乱。后来改成把筛选条件统一放在一个reactive对象里,任何条件变化都触发重新请求:
const filter = reactive({ voltage: null, capacity: null, brand: null, sort: 'default', page: 1, }); watch(filter, () => { filter.page = 1; fetchList(); });列表页还有一个需求是参数对比。电池客户经常要把两三款型号放到一起看参数,如果不是结构化存储,这个功能根本做不了。得益于 SKU 表里有独立的 voltage、capacity、cycles 字段,前端拿几个 SKU 的params_json渲染成对比表格即可,这个功能上线后销售普遍反馈好用。
5. 前后端联调实录:遇到的跨域、鉴权、上传、支付回调问题
前后端开发可以并行,但真正耗时间的是联调阶段。这一节都是我实际踩过的坑,每个都很典型。
5.1 跨域问题:开发环境和生产环境的差别处理
ThinkPHP 后端默认跑在 8000 端口(或 8080),Vite 开发服务器跑在 5173,直接请求必然跨域。开发环境最省事的方案是配 Vite 代理:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, });前端所有请求都走/api前缀,Vite 把它代理到后端真实地址。这样浏览器里没有跨域,开发调试舒心得多。
生产环境则相反,我推荐 Nginx 做反向代理,把/api转发到 PHP-FPM 对应的入口,不需要在后端开启跨域头。如果你非要后端处理跨域,ThinkPHP 里可以加一个中间件统一追加响应头,但生产环境这样做的意义不大,Nginx 一层就解决了。
5.2 Token 过期与 401 刷新的处理
JWT 过期后,前端所有请求都会收到 401。我在 axios 响应拦截器里做了统一处理:
service.interceptors.response.use( res => res.data, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(err); } );这个方案的问题是没有处理并发请求:如果同时有 3 个请求都 401,会连续跳 3 次登录页。更完善的方案是用一个"是否正在刷新"的标志配合单次跳转,或者用window.location.href直接跳转并忽略后续 401。对我来说,直接跳转登录页够简单够可靠,细枝末节不必过度设计。
5.3 图片上传:本地存储 vs 对象存储
商品图片、资质文件上传是商城刚需。最初是上传到服务器本地目录,用 ThinkPHP 的think\facade\Filesystem处理,但这个方案的问题很明显:单机磁盘空间有限,而且后期升级多台机器时文件不同步。后来我把图片转移到了云对象存储,上传接口改成了"后端生成临时上传凭证,前端直传对象存储,再把文件地址回填给后端"的模式。
这个流程的好处是大文件传输不占用应用服务器带宽,上传速度体验也好。如果项目初期访问量不大,用本地存储没毛病,但接口在设计时建议尽量返回完整可访问的 URL 而不是相对路径,这样后期切换存储方案时前端改动最小。
5.4 支付回调的验签与幂等处理
这个项目既接入了第三方在线支付,也支持线下转账到账确认。在线支付的回调是异步的,后端收到回调请求后必须做三件事:
- 验签。第三方支付平台会用私钥签名,后端用配置的公钥或密钥验证签名,防止伪造回调。
- 校验金额。回调里的
amount必须和订单表里的pay_amount完全一致,否则拒绝处理。这是支付安全最重要的防线。 - 幂等处理。支付回调可能因为网络原因发送多次,必须用
order_no查询订单状态,发现已经是"已支付"就直接返回成功,不再重复更新。
public function notify() { $data = file_get_contents('php://input'); // 1. 验签 if (!$this->verifySign($data)) { return 'fail'; } $orderNo = $this->getOrderNo($data); $amount = $this->getAmount($data); // 2. 校验订单是否存在、金额是否一致、状态是否未支付 $order = Order::where('order_no', $orderNo)->find(); if (!$order || abs($order->pay_amount - $amount) > 0.01 || $order->status != Order::STATUS_PENDING) { return 'fail'; } // 3. 开启事务,更新订单状态 + 写入支付流水 + 扣减库存 Db::transaction(function () use ($order, $orderNo, $amount) { $order->status = Order::STATUS_PAID; $order->pay_time = time(); $order->save(); Payment::create([ 'order_no' => $orderNo, 'pay_no' => $this->getPayNo($data), 'amount' => $amount, 'status' => 1, ]); }); return 'success'; }这里有两点教训:一是回调接口绝对不能依赖前端传来的任何参数,所有数据都必须从支付平台的原始回调里解析;二是库存扣减放在支付成功之后而非下单时,配合下单时的"锁定库存"逻辑,要设计好锁库存与扣库存的关系,后面一节展开讲。
6. 电池商城特有的SKU、库存与订单状态设计
商城系统最容易做砸的地方不是首页,而是 SKU、库存、订单状态机这些底层的业务逻辑。这一节是全文我认为最核心的部分,也是通用商城教程很难覆盖到的地方。
6.1 下单锁库存与超时释放机制
前面提到,客户可能同时找销售咨询、又在线上看,如果不锁库存,A 客户下单成功了,B 客户也下单成功,最后仓库发货时才发现库存不足,非常被动。但直接"下单即扣库存"又有问题:客户拍下后不付款,库存被白白占用。
我们的方案是:订单创建时锁定库存(把stock拆成available_stock可售库存和locked_stock锁定库存两个字段),支付成功后真正扣减库存,超时未支付自动释放锁定。具体实现:
-- 商品SKU表增加两列 ALTER TABLE goods_sku ADD COLUMN locked_stock INT DEFAULT 0; ALTER TABLE goods_sku ADD COLUMN available_stock INT DEFAULT 0;下单接口里,用"乐观锁 + 条件更新"防止超卖:
$result = GoodsSku::where('id', $skuId) ->where('available_stock', '>=', $quantity) ->dec('available_stock', $quantity) ->inc('locked_stock', $quantity) ->update();where('available_stock', '>=', $quantity)这个条件很关键,它保证了两个人同时下单时,后下单的人会因为库存不足而更新影响行数为 0,从而提示"库存不足"。这种方式比先查后改靠谱得多,因为查和改之间有空窗期。
超时释放用 ThinkPHP 的命令行任务,每分钟跑一次:
// 定时任务:释放超过30分钟未支付的锁定库存 Order::where('status', Order::STATUS_PENDING) ->where('created_at', '<', time() - 1800) ->chunkById(100, function ($orders) { foreach ($orders as $order) { Db::transaction(function () use ($order) { $order->status = Order::STATUS_CANCELLED; $order->save(); // 释放锁定库存 foreach ($order->items as $item) { GoodsSku::where('id', $item->sku_id) ->dec('locked_stock', $item->quantity) ->inc('available_stock', $item->quantity) ->update(); } }); } });这套机制在订单量日几百单的水平下运行很稳定,也没有出现超卖问题。
6.2 订单状态机与权限协同
订单状态不是随便几个字段的事,状态之间的流转必须由后端控制在合理范围。我把订单状态定义成常量:
| 状态值 | 含义 | 可执行操作 |
|---|---|---|
| 0 | 待支付 | 付款、取消 |
| 1 | 已支付/待发货 | 发货、退款申请 |
| 2 | 已发货 | 确认收货 |
| 3 | 已完成 | 申请售后 |
| 4 | 已关闭/已取消 | 无 |
后台管理员可以改订单状态,但只能按规定路径改,不能越级跳转。比如已发货的订单不能直接改成待支付。用代码控制状态机逻辑比靠自觉靠谱,避免运营手滑把数据搞乱。
线下转账场景下,订单在待支付状态时可以流转到"待确认到账",财务人员手动确认后等价于"已支付"。这个字段建议单独加一个pay_type区分支付渠道,而不是混在状态值里,不然逻辑判断会绕晕。
6.3 价格计算全部后端完成
购物车结算时,前端会显示商品价格、合计金额,但最终订单金额必须以后端计算为准。前端只能传 SKU 和数量,后端根据用户等级匹配会员价、根据采购数量匹配阶梯价、计算运费、生成优惠,最后回传总价。前端任何修改金额的尝试在提交订单时都会被后端忽略或校验失败。
阶梯价我是在 SKU 表之外建了一张goods_price_tier表,记录sku_id, min_quantity, max_quantity, price:
| sku_id | 最小数量 | 最大数量 | 价格 |
|---|---|---|---|
| 1001 | 1 | 9 | 899 |
| 1001 | 10 | 49 | 850 |
| 1001 | 50 | 99999 | 799 |
订单创建时用实际购买数量查阶梯价表,得到单价后乘以数量。这样以后调价不用改商品主价格,商务改个表就行,而且价格历史也能追溯,比在代码里写 if-else 干净得多。
7. 性能优化与上线前的安全加固
商城上线后不能光顾着功能,性能和安全才是长期运营的根本。这节讲我在项目上线前做的几项关键优化。
7.1 列表接口的分页与缓存
商品列表页最怕的就是慢。第一个优化是分页,任何列表接口都强制paginate,不允许一次性返回全量数据。第二个优化是 Redis 缓存,商品列表在后台发布商品时主动清除缓存,列表请求优先查缓存:
$cacheKey = 'spu_list_' . md5(json_encode($params)); $list = Cache::get($cacheKey); if (!$list) { $list = GoodsSku::with(...)->paginate(...); Cache::set($cacheKey, $list, 600); }需要注意的是,带用户信息的接口(购物车、订单列表)不能强制缓存,否则会出现用户A看到用户B数据的严重事故。缓存只能用在公开商品数据上。
7.2 SQL层面的索引优化
订单列表页后台有个"按订单号搜索"功能,一开始很慢,后来发现order_no字段没有索引。电商系统中,订单号需要频繁精确查找,加唯一索引后查询毫秒级返回:
ALTER TABLE orders ADD UNIQUE INDEX idx_order_no (order_no);还有一个常见问题,关联查询时两张表的关联字段字符集不一致,导致索引失效。这个在表设计阶段就要统一字符集和排序规则,我们所有表都统一用utf8mb4/utf8mb4_general_ci,避免联调时踩坑。
7.3 越权漏洞与双重校验
前后端分离的项目,最需要防的一个安全问题是越权:用户A通过修改接口参数访问用户B的订单。后端所有与用户相关的接口,都必须强制以$request->userId为准,不能信任前端传的user_id参数。
举例来说,查看订单详情:
public function detail(Request $request) { $orderNo = $request->param('order_no'); $order = Order::where('order_no', $orderNo) ->where('user_id', $request->userId) // 关键:必须带当前登录用户ID ->find(); if (!$order) { return json(['code' => 404, 'msg' => '订单不存在']); } return json(['code' => 0, 'data' => $order]); }如果漏掉where('user_id', $request->userId),这就是一个可以直接越权读取他人订单的漏洞。POST 接口同理,更新操作前要校验资源归属。这类问题代码审查时要重点盯。
另外后台接口还要防 CSRF,前后端分离项目里我用的是 Token 校验,所有非 GET 请求都要求带一个动态生成的 CSRF Token,这个 Token 存在服务端会话里,并要求前端在请求头里带上,第三方站点伪造表单请求就没那么容易得手了。
8. 项目上线后的维护经验与扩展方向
系统上线三个多月,整体稳定,但也有一些后续迭代的点值得分享。
第一个是物流接口的对接。电池大件走物流专线,不同物流公司对体积重量计算规则不同,这个模块我们第二期接入了快递鸟、物流API,下次再做类似项目,一开始就该把物流抽象成独立的服务层,不要等到后期再塞进订单模块。
第二个是数据报表。老板隔三差五要看销售数据:按品牌、按价格区间、按客户等级汇总。最初用 SQL 现查,数据量大了以后查询越来越慢。后来建了一张每日销售汇总表,用定时任务每天凌晨跑一次,把前一天的订单数据聚合好,后台报表直接查汇总表,速度快很多。
第三个是价格的批次管理。电池原材料价格波动大,销售价格需要频繁调整。目前是直接在后台编辑 SKU 价格和阶梯价,后续准备做一个价格调整申请流,运营提交、主管审批、生效并记录历史,避免改价太随意引发价格混乱。
最后说一下我对这套技术方案的感受:ThinkPHP + Vue 不是那种能让你在技术圈里"炫技"的组合,但它非常适合快速落地一个功能完整、能稳定运行的业务系统。这个商城从立项到上线三个月做完,后端两人、前端一人,靠的就是这套组合的低学习成本和强生态。如果你也是中小团队,要做行业垂直电商,参考这套思路完全可以复制。我也是踩了不少坑才把这些细节理清楚,写出来就是希望大家少走弯路。