做过电商小程序的朋友应该都有体会:一套“uniapp微信小程序网上饰品商城售卖系统”听起来挺标准,真落地时却往往要同时拉起PHP和Python两条技术栈。这个组合从外部看有点混搭,实际是把两类需求拆到最省事的状态:PHP管商城本身的交易链路,Python专心处理物流轨迹这种异步业务。这篇文章不打算讲那种放了商城代码就完事的模板套法,而是把整个系统的页面拆分、后端接口、物流打通、部署踩坑完整过一遍,适合刚接触uniapp和后端联调、或者正准备给现有商城加物流模块的人参考。
事情起因很简单,一位做饰品批发的朋友想把手里的现货搬到线上,原话是“搞个小程序,能看货能下单就行”。可真看了需求才知道:戒指的圈号、项链的长度、耳饰的材质、是否孤品,这些SKU属性光是录入就是一堆事。而且饰品客单价高,用户对物流跟踪的要求也高,发货后哪天到、运单到了哪个节点,都要能实时查。于是这套系统最终长成了现在这个结构,前端uniapp跑微信小程序,主业务后端用PHP,物流服务单独用Python拆出来,中间用内部HTTP和一张物流表把两块串起来。
1. 一套饰品小程序,为什么后端会同时出现PHP和Python
1.1 饰品SKU的复杂度,决定了它不能只靠模板页面
一说网上商城,很多人第一反应是MVC增删改查。饰品这个品类有自己的特殊性:同样一条手链,颜色分金、银、玫瑰金,长度分15cm、17cm、19cm,加上镶嵌材质、证书编号,一个商品页经常对应几十个SKU。大多数商城模板的SKU只能挂一张默认图,规格选完才换图,这种交互在饰品场景下必须做得更细。
我这边的前端选择了uniapp,原因很实际:同一个代码可以编译成微信小程序,后续如果要做App或者抖音小程序,页面不用重写。饰品图片多、规格多,页面渲染对图片懒加载、长列表性能天然敏感,uniapp的image组件懒加载和分页加载能力足够撑起来。而且HBuilderX写完代码直接跑微信开发者工具调试,前端联调效率比原生写wxml快不少。
后端的选型就更有意思了。PHP写商城业务速度确实快,商品表、订单表、会员表这些一套下来一周能跑通,后续维护成本也低。但物流这个模块是典型的“异步、外部接口、要定时拉数据”的业务,放在PHP主进程里做会拖慢响应,还容易因为第三方接口超时影响下单。所以我把物流单独拆出来,用Python写了一个独立服务,专门干快递单登记、轨迹查询、状态同步这件事。
1.2 PHP管商城交易、Python管物流跟踪的分工逻辑
有人看到标题里同时出现PHP和Python可能会想:这不就是没事找事吗?我觉得要分清楚业务性质。
商城交易链路是同步的、强实时的:用户下单要立刻扣库存,支付回调要立刻改订单状态,这些环节要求稳定、快,出错要能立刻反馈。PHP在这类业务上非常成熟,一套事务、一个文件拆清楚,运维起来也不复杂。
物流链路恰好相反,依赖第三方快递查询接口,而第三方接口响应慢、有时还会限流,不适合在用户请求的同步链路里强行调用。我见过很多PHP项目直接在发货接口里去请求快递API,结果用户一查物流就把PHP-FPM进程占住十几秒,数据库连接也容易被打满。把它拆成Python服务之后,物流动作用异步加定时轮询来做:发货通知进来先登记,轨迹查询走本地缓存,取不到再实时调第三方,用户体验是稳定的秒开。
这个分工逻辑就是一句话:交易归交易,物流归物流,把慢的、带外部依赖的东西隔离出去。
2. uniapp小程序端怎么拆页、怎么封装请求
2.1 页面清单与入口关系
小程序端我最后保留了这几个核心页面:首页、分类列表、商品详情、购物车、结算页、订单列表、订单详情、物流跟踪,再加个人中心和地址管理。饰品商城的商品详情页是整个前端最重的一页,轮播图、SKU选择、证书信息、售后说明都得放,还要支持规格图切换。分类列表用的是商品标签加筛选,按材质、价格区间、圈号、长度过滤。
页面关系上,我保持了简单直接的tabBar结构:首页、分类、购物车、订单、我的。商品详情从任意列表页进入,加入购物车后统一到结算页,下单成功跳订单详情,订单详情里有“查看物流”按钮再进物流跟踪页。这样每个页面职责单一,开发时也不用维护太复杂的页面栈逻辑。
2.2 request封装:把token、报错、登录态收敛到一处
小程序端最基础也是最值得做的事是把uni.request封装成一个统一方法。
// utils/request.js const BASE_URL = 'https://api.example.com' export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, timeout: 15000, header: { 'Content-Type': 'application/json', 'Authorization': uni.getStorageSync('token') || '' }, success: (res) => { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data) return } if (res.statusCode === 401) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) reject(res) return } uni.showToast({ title: res.data.msg || '请求失败', icon: 'none', duration: 2000 }) reject(res) }, fail: (err) => { uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) reject(err) } }) }) }这个封装看起来简单,但把三件事统一掉了:token自动附加、401统一踢到登录页、错误toast统一提示。页面里就不用每次请求都写一段判断逻辑。toast务必用uni.showToast的icon:'none',因为系统默认的success图标在错误场景下会显得莫名其妙,用户根本不知道发生了什么。
后端接口我统一返回{code, msg, data}这个结构,code为0表示成功,非0代表业务错误。这个约定前后端一定要先说死,后面接Python物流服务时也沿用同一套。
2.3 微信登录换取token的完整过程
饰品商城是C端用户直接下单的,登录用微信授权最顺。流程上,前端先用uni.login取到临时code,再把code发给后端PHP接口,PHP拿code去微信官方接口换openid和session_key。换到openid后查会员表,不存在就自动注册,最后生成你自己的token返回给前端,后续所有请求带在Authorization头里。
这里有个关键点要提醒:不要在PHP接口里把openid当token用,更不能把openid直接返回给小程序端。openid是用户身份标识,直接暴露等于把代理权交出去了。我在项目里是生成一个32位随机串,存到member_sessions表,有效时间设7天,过期了让用户重新登录。饰品客单价高,账号安全问题不能省。
public function wechatLogin($code) { $appid = '你的小程序appid'; $secret = '你的小程序secret'; $url = "https://api.weixin.qq.com/sns/jscode2session" . "?appid={$appid}&secret={$secret}&js_code={$code}&grant_type=authorization_code"; $res = json_decode(file_get_contents($url), true); if (empty($res['openid'])) { return $this->fail('微信登录失败'); } // 查member表,不存在则注册 // 创建session,返回token return $this->success(['token' => $token]); }注意,开发早期用微信开发者工具的测试appid就能跑通整个登录流程,等小程序正式注册后再替换成真实appid和secret,这个替换是纯配置层面的,代码结构不需要变。
3. PHP端订单建模与发货动作
3.1 核心表结构:orders、order_items、track_records
这套系统的核心表,我梳理下来最要紧的是这几张:商品表products、SKU表product_skus、购物车表carts、订单主表orders、订单明细表order_items、物流记录表track_records、会员表members。
最需要花心思的是orders和order_items。订单主表存订单号、用户ID、订单总金额、支付金额、状态、快递公司编码、运单号、支付时间、发货时间、签收时间。订单明细表存订单号、SKU ID、商品名、规格描述、单价、数量、实付金额,还要带上下单瞬间的商品快照。为什么存快照?因为饰品价格会因为金价工费浮动,用户在购买时刻看到的价格和三个月后商品表的价格很可能不一样,订单明细必须固化下单瞬间的信息。
物流记录表track_records是我单独给Python服务准备的。它存订单号、快递公司编码、运单号、最新轨迹文本、轨迹JSON、最后更新时间。这样用户每次查物流都先打这个表,而不是每次都去请求第三方接口。一张简化后的orders表结构供参考:
CREATE TABLE `orders` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` int(11) NOT NULL COMMENT '会员ID', `total_amount` decimal(10,2) NOT NULL COMMENT '商品总额', `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付待发货 2已发货 3已签收 4已取消', `express_code` varchar(20) DEFAULT NULL COMMENT '快递公司编码', `tracking_no` varchar(40) DEFAULT NULL COMMENT '运单号', `paid_at` datetime DEFAULT NULL, `shipping_at` datetime DEFAULT NULL, `signed_at` datetime DEFAULT NULL, `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单号一定要业务生成,不要用自增主键暴露订单量。我用的是“日期时分秒+三位随机数”,对外展示足够好看,也方便客服口头报号。
3.2 下单与库存扣减的事务边界
饰品库存和标品不一样,很多是孤品,比如某个手链就一条。下单时的库存准确性比普通商品更敏感。我的做法是:购物车生成订单的时候,把商品明细、金额、运费全部在PHP端重算一遍,不信任前端传过来的价格。前端传的skuId和数量可以用,金额必须以数据库里SKU价格为准乘数量,另外加运费规则。
扣库存必须和创建订单放在同一个数据库事务里,而且用条件更新防超卖。类似UPDATE product_skus SET stock = stock - ? WHERE id = ? AND stock >= ?,影响行数为0就说明库存不够,直接回滚。库存是在商品SKU上直接锁,而不是最后统一扣,这样并发下单时不会出现负数库存。
支付环节我走的是微信支付统一下单,支付回调里再标记订单为已支付。这里要特别小心回调的幂等性:同一个支付结果回调可能重复收到,必须用订单号加支付状态做一次唯一判断,已经处理过的直接返回成功,千万不能重复发货或者重复退余额。
3.3 发货接口:状态流转 + 触发Python物流服务
发货是管理端操作。管理员在后台操作发货时,选择快递公司编码、填写运单号、提交。PHP这边先校验订单状态,只有已支付待发货(status=1)的订单才能发货,然后把订单状态改成已发货(status=2),写入快递公司编码和运单号,再把发货信息通过内部HTTP调用通知Python物流服务。
为什么发货信息要通知Python,而不是让PHP直接写track_records?因为track_records只是物流服务的入口数据,Python拿到运单号后还要做第一次轨迹抓取,之后定时轮询。这些事PHP做不擅长,强行实现只会让代码越来越绕。所以PHP这里只做一件事:把订单状态、快递编码、运单号这三要素交给Python。
public function ship() { $orderNo = $this->input('order_no'); $expressCode = $this->input('express_code'); $trackingNo = $this->input('tracking_no'); $order = $this->orderModel->where('order_no', $orderNo)->first(); if (!$order || $order['status'] != 1) { return $this->fail('订单不存在或状态不允许发货'); } $order->express_code = $expressCode; $order->tracking_no = $trackingNo; $order->status = 2; $order->shipping_at = date('Y-m-d H:i:s'); $order->save(); // 通知Python物流服务登记并查询首次轨迹 $this->httpPost('http://127.0.0.1:8000/internal/ship_notify', [ 'order_no' => $orderNo, 'express_code' => $expressCode, 'tracking_no' => $trackingNo ]); return $this->success(); }这里我用的是PHP往本机8000端口发内部请求的笨办法。有人会觉得绕了一圈,不如直接写库。但实际项目里Python服务后面还要解析轨迹节点、处理异常件、给通知模块推送,独立服务比写在一起清晰得多。内部的httpPost我建议固定超时3秒,失败只记日志不影响发货主流程,物流数据后续靠轮询补上。
4. Python物流服务:从快递单号到用户看到轨迹
4.1 物流服务要承担的四个职责
Python服务跑起来后,其实就干四件事:第一,接收PHP发货通知,登记物流单;第二,对接第三方快递查询API,把轨迹数据拿回来;第三,定时轮询在途订单,更新本地缓存;第四,给小程序端提供物流轨迹查询接口,并把签收状态回写给PHP。
第一件和第四件是HTTP接口,第二件是核心脏活,第三件是后台任务。用Python的原因就是这三类活都不适合放在常规业务进程里阻塞等待。FastAPI写异步接口很顺手,配合apscheduler做定时任务,一个进程就能把上面四件事全部承担下来。
有一点经验:物流服务的接口只对内网开放,监听127.0.0.1就够了,不要暴露到公网。真正对外的是Nginx反代出来的路径,内网接口可以让PHP直接请求而不必经过公网网关,链路短、排查也简单。
4.2 对接第三方快递查询API时的通用套路
市面上的快递查询服务商,不管哪家,对接套路基本一致:先在它们开放平台注册账号、拿到API Key,然后查一个快递公司编码对照表,再把运单号提交过去,返回一段轨迹数组。轨迹数组的元素一般包含时间、状态文字、城市信息,有的服务商还会额外返回一个状态枚举,比如揽收、运输中、派送中、签收。
我封装了一个最简单的查询函数,实际项目里要换成你的服务商对应地址和鉴权方式:
def query_express_api(express_code, tracking_no): url = "https://api.thirdparty.example/track" headers = {"Authorization": "Bearer 你的API_Key"} payload = { "express_code": express_code, "tracking_no": tracking_no } resp = requests.post(url, json=payload, headers=headers, timeout=8) data = resp.json() if data.get("code") != 0: # 运单号错误、快递公司编码不对,是两大高频错误 raise Exception(data.get("msg")) return data.get("trace")对接时最容易翻车的不是代码,而是快递公司编码写错。第三方服务的编码表跟快递公司官方叫法经常不一致,同一个快递在不同平台可能对应不同的英文简称。我的教训是:上线前先拿真实运单逐家测试,别等到用户下单才暴露编码错误。另外,第三方接口一般都有频率限制,免费额度尤其紧张,这也就是为什么必须在中间加缓存层,而不是每点一次物流就请求一次第三方。
4.3 轨迹缓存、轮询更新、签收状态回写PHP
为了控制对第三方接口的调用次数,我设计了本地轨迹缓存:用户查物流时,Python先看track_records表里的json内容,如果最后更新时间在30分钟以内,直接返回;超过30分钟才重新调用第三方接口。在途订单则由定时任务每两小时批量刷新一次,半夜用户访问量低,轮询压力也小。
轮询逻辑用apscheduler挂了两个任务:一个每两小时刷新在途订单轨迹,一个每小时扫一次长时间没有更新的异常单。异常单包括快递退件、用户拒收、超过三天没揽收这三种情况,需要单独登记,方便客服主动跟进。
from apscheduler.schedulers.blocking import BlockingScheduler def poll_tracks(): rows = get_in_transit_records() # 查询状态在途、超过2小时未更新的记录 for row in rows: try: trace = query_express_api(row['express_code'], row['tracking_no']) update_track(row['order_no'], trace) if is_signed(trace): notify_php_signed(row['order_no']) # 调用PHP接口标记签收 except Exception as exc: logger.error(f"轮询失败 order_no={row['order_no']} err={exc}") scheduler = BlockingScheduler() scheduler.add_job(poll_tracks, 'interval', hours=2) scheduler.start()签收状态回写给PHP,我建议走PHP对外提供的内部接口:POST /php/api/order/mark_signed,入参只有订单号。Python不要直接去改orders表,虽然它能连MySQL,但两个系统都写同一张状态字段,很容易出现覆盖冲突。这个约定在项目一开始就要说清楚:物流服务可以读写track_records,订单状态的变更永远由PHP负责。
5. 联调和部署阶段最容易踩平的几个坑
5.1 用同一个域名反代两个后端,绕开小程序合法域名限制
微信小程序有个硬规矩:request请求的域名必须在公众平台配置为合法域名,并且要求HTTPS、不能带端口。很多第一次开发小程序的人在这里被卡住。如果PHP和Python各配一个域名,后面还要同时维护两个HTTPS证书,麻烦不说,万一漏配一个,线上就白屏。
我的做法是:只买一个API域名,通过Nginx的路径把请求分流到两个后端。小程序端的BASE_URL始终是https://api.example.com,商城接口走/php/前缀,物流接口走/py/前缀。
server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/nginx/cert/api.example.com.pem; ssl_certificate_key /etc/nginx/cert/api.example.com.key; # Python物流服务,/py/ 前缀擦掉后转发到本地8000 location /py/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # PHP商城服务,交给PHP-FPM location /php/ { index index.php; try_files $uri $uri/ /php/index.php?$query_string; fastcgi_pass 127.0.0.1:9000; include fastcgi_params; } }这样微信公众平台只需要配置api.example.com一个域名。开发调试时可以临时在微信开发者工具勾选“不校验合法域名”,但提交审核前一定要把域名配好。另外,如果以后要打包成App,H5端用的域名和证书也要保持一致,别一次配置两套。
5.2 时区不一致导致发货时间错乱
这个坑看起来很小,实际挺恶心。PHP服务器默认时区如果是UTC,date函数生成的时间会比北京时间慢8小时。MySQL连接串如果不指定时区,存进数据库的也是UTC,而Python的datetime.now()又在系统本地时区取。结果就是:订单创建时间、发货时间、轮询更新时间三个字段分别落后或领先8小时,用户看到的物流时间完全对不上。
我在项目里统一做了三件事:PHP入口处设置默认时区为Asia/Shanghai;MySQL连接串里加上时区参数;Python服务启动时把系统时区也固定下来。三个地方全对齐之后,时间显示就再没出过问题。如果只是自己开发环境,也可用MySQL的TIMESTAMP字段配合服务端时间,但多服务器、多语言环境下,统一Asia/Shanghai是最省心的。
5.3 Python服务守护、超时控制与进程重启
Python物流服务如果挂掉,直接表现就是用户物流轨迹查不到、发货时PHP通知发不出去。开发期自然是手动跑,上线后必须有个进程守护。我写了一个systemd服务文件,让它可以开机自启、崩溃自动拉起:
[Unit] Description=Logistics Python Service After=network.target [Service] User=www Group=www WorkingDirectory=/data/services/logistics ExecStart=/data/services/logistics/venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target注意ExecStart里用虚拟环境里的uvicorn,不要用系统的python,避免依赖版本冲突。每次代码更新,先systemctl restart logistics再跑一下自测脚本。我习惯写一个简单的自测脚本,检查 /py/track/ 这个接口是否返回200,部署完就执行一次,比打开小程序点来点去高效很多。
另外,PHP和Python之间的内部HTTP调用,超时要严格控制。PHP的httpPost固定3秒超时,Python请求第三方快递API固定8秒超时。为什么要刻意设这么短?因为这两种调用都属于附属链路,不应该拖住主流程。一旦第三方接口挂掉,宁可让用户暂时看到缓存的旧轨迹,也不能让整个发货操作卡住。
5.4 高频问题速查表
我把项目里反复被问的问题整理成了一个表,基本上新接手的开发照着看就能少踩一半坑:
| 问题现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 小程序请求全部失败,提示url域名不合法 | 没有配置request合法域名或域名不是HTTPS | 公众平台配置域名,且必须为HTTPS、无端口 |
| 商品规格切换后价格不变 | 前端只切换了图片,没有重新读取SKU价格 | 选中SKU时重新赋值selectedSku,联动价格和库存 |
| 下单提示库存不足但页面显示有货 | 并发下单超卖,普通查询才看得到剩余库存 | 扣库存用条件更新stock >= ?,事务内完成 |
| 物流轨迹一直不更新 | 第三方接口调用被限流或本地缓存未过期 | 检查API调用频率,缩短轮询间隔同时加大缓存时间 |
| 用户收到微信支付重复回调 | 支付回调没有做幂等处理 | 按订单号加已支付标志判断,重复回调直接返回 |
| Python服务启动后端口冲突 | uvicorn端口被占用,或旧进程没退出 | 先查端口占用,systemd里正确配置重启 |
| 发货后订单状态变为已发货但用户查不到物流 | PHP通知Python失败,且没有补偿机制 | 轮询任务单独拉取“已发货但在途”订单补轨迹 |
这张表是实际运行两个月后的复盘总结。不是说系统多优秀,而是这些问题都真实发生过,记录下来比下次再踩一遍要值。
最后聊一点我自己的维护体会。这套系统跑顺之后,我最庆幸的不是用了什么高深技术,而是当初坚持把物流拆成独立服务。每次第三方快递接口调整、限流、或者半夜打不通,我都只需要去动Python这一个进程,完全不影响商城主流程;PHP那边哪怕代码写得糙一点,也不会因为外部接口抖动把整个商城拖死。如果你是给类似的小项目做选型,我的建议是:业务逻辑层尽量用你团队最熟的语言,但凡是跟第三方API打交道、需要定时拉数据的模块,永远值得单独拆出来,不管它叫Python服务还是Worker进程,这个边界越早划清,后面越省心。
另外还有个小技巧:上线前一定要准备一个“假运单”联调通道。和第三方物流服务商申请测试单号,经常要等审批,这时候可以直接在Python里写一个mock接口,返回写死的轨迹数组,让前端先把整个物流页面跑通。等真实单号下来再切换,前后端的联调完全不用卡在第三方审批上。就这个小东西,省了我们至少两天时间。