news 2026/10/16 8:39:07

uniapp小程序饰品商城:PHP+Python双后端架构实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
uniapp小程序饰品商城:PHP+Python双后端架构实践

做过电商小程序的朋友应该都有体会:一套“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接口,返回写死的轨迹数组,让前端先把整个物流页面跑通。等真实单号下来再切换,前后端的联调完全不用卡在第三方审批上。就这个小东西,省了我们至少两天时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 8:36:48

NARX与BP神经网络实战对比:电力负荷预测中的动态记忆优势

搞时间序列预测的人,估计都纠结过一个问题:同样一堆历史数据摆在面前,到底用BP神经网络硬怼,还是上NARX延时动态神经网络这种带“记忆”的结构。我接手这个项目的时候,需求其实很简单——用某工业园区近一年的用电负荷…

作者头像 李华
网站建设 2026/10/16 8:34:57

@WebServlet注解原理与实战避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 8:33:55

PaperXie 全功能板块 + 实操使用指南|一篇看懂怎么用

很多同学刚上手 PaperXie 的时候,不清楚各个板块分别能干什么、该在论文哪个阶段打开哪个功能。平台把毕业论文整套流程拆分成独立功能板块,从开题、文献搜集、初稿写作、逻辑修改、绘图、排版,再到答辩准备,每个模块分工清晰&…

作者头像 李华
网站建设 2026/10/16 8:33:24

coder怎么下载?一次分清AI编程、本地部署与KH Coder的区别

“coder怎么下载?”这个搜索词最近又热了起来。我猜,点进来的人真正想要的东西可能完全不一样——有人是想找一个叫 coder 的开发工具,有人是想在 Mac 上本地部署 Qwen Coder 这个开源 AI 编程模型,还有一小部分人找的其实是 KH C…

作者头像 李华
网站建设 2026/10/16 8:32:59

堆的建立、插入与删除:从O(n)建堆到sift-down底层原理

1. 堆不是“堆”出来的,是“筛”出来的——从一道被反复误解的算法题说起很多人第一次看到“堆的建立、插入和删除”这个标题,下意识觉得:不就是写三个函数吗?insert()、deleteMin()、buildHeap(),照着教材敲一遍&…

作者头像 李华
网站建设 2026/10/16 8:31:04

C++随机数与计时深度实践:从std::mt19937到std::chrono

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华