第25章:Python测试金字塔升级——契约、Mock 与 CI
1. 项目背景业务场景食光集市测试团队有 3 名专职 QA 和 1200 条手工测试用例。每次 Sprint 结束前的回归测试需要两个人花整整两天用 Excel 逐条勾选、手工截图、粘贴到测试报告中。上一期 Sprint一切都正常——单元测试全绿、手工回归测试全绿。然而上线当天支付回调的沙箱环境突然不能用了——因为第三方支付平台未经通知地把沙箱的return_url字段从可选改成了必填。食光集市的代码没有 mock 支付回调的异常路径手工测试用的也是沙箱沙箱那时刚好维护完所以测试通过了导致这个场景从未被验证过。上线 4 小时后运营发现所有通过沙箱支付的订单全部卡在待支付状态——实际上用户已经付了钱但回调没被正确处理。这笔资损最后被财务定性为测试覆盖不足导致的线上事故。更糟糕的是——手工回归测试覆盖不到的盲区远不止这一个第三方服务的异常返回超时、500、返回畸形 JSON分布式并发冲突场景两个请求同时操作同一订单数据库迁移后的向后兼容性旧版本客户端调新版本 API这些问题靠手工测试永远测不完。CTO 定下目标“从下个 Sprint 开始CI 流水线中的自动化测试必须覆盖——Lint → 单元测试 → 契约测试 → 集成测试。测试金字塔必须立起来。”痛点缺乏结构化测试体系团队会遭遇手工回归永远测不完1200 个用例每个 Sprint 新增 50 个——人力不可扩展。第三方依赖不稳定支付沙箱、地图 API、短信服务——它们随时可能变慢、不可用、改变返回格式。没有 mock 就无法测试异常路径。单测绿、联调红A 团队改了 API 返回字段但没通知 B 团队——因为缺少一份自动执行的接口契约测试。CI 流水线无质量门禁代码 push 后不跑任何自动化检查——直到手工测试阶段才暴露问题。测试金字塔 ╱ E2E ╲ (少量——关键业务流程) ╱ 集成测试 ╲ (中等——跨服务交互) ╱ 契约测试 ╲ (中等——API 边界) ╱ 单元测试 ╲ (大量——函数级正确性) ╱ Lint/格式化 ╲ (基础——代码风格)2. 项目设计场景CI 流水线搭建讨论会上小胖看到测试金字塔的第一反应是这成本太高了。小胖“大师单元测试我认了——快速、稳定、好写。但契约测试和集成测试那不是又慢又不稳定吗我上次写的集成测试跑一次 12 分钟还经常因为网络问题随机失败。这不是在浪费 CI 分钟数吗”小白“小胖你的直觉部分正确——越往上层的测试确实越慢、越不稳定。但你不能因为它们’不完美’就放弃。关键是——测试金字塔不是为了追求完美而是为了在成本和质量之间找到最优平衡。我有一个更具体的问题——mock 和 stub 和 fake 有什么区别我见过三种术语混用。”大师“小白问到了测试替身Test Double的精确分类。这是写好测试的理论基础”替身类型作用示例是否验证交互Stub返回预设值mock_get.return_value {status: ok}否Mock验证是否被调用mock_send.assert_called_once_with(...)是Fake轻量级真实实现fakeredis、内存 SQLite否Spy记录调用信息记录哪些参数被传递可选“简单记忆法——Stub 管’给我什么’Mock 管’我有没有被正确调用’。Mock 验证的是行为behavior verificationStub 提供的是状态state verification。”“关于集成测试慢和不稳定的问题——用 Fake 替代真实依赖。不是 mock 掉 Redis而是用一个进程内的fakeredis纯 Python 实现的 Redis 兼容库。行为与真实 Redis 基本相同但启动零开销。同理——用 SQLite 的:memory:替代 Postgres 做集成测试速度快 100 倍。”技术映射Stub 自动售货机的测试面板——你按可乐它就出可乐返回值预设但不验证你按了几次。Mock 监控探头——不仅出可乐还记录有人按了 3 次可乐按钮1 次雪碧按钮。Fake 驾校模拟器——坐在上面跟真车感觉一样行为兼容但不是真车。小胖“契约测试呢我听说过 Pact——它好像是消费者驱动的契约测试。但它和单元测试、集成测试的关系是什么放在金字塔的哪一层”小白“契约测试本质是接口边界的握手验证——提供方保证’我会按这个格式返回’消费方验证’我会按这个格式解析’。这解决了我们之前’改字段不通知下游’的问题。但我关心的是——契约测试和 OpenAPI Schema 校验有什么关系有了 OpenAPI 还需要契约测试吗”大师“契约测试和 OpenAPI Schema 校验是互补关系不是替代”OpenAPI Schema 校验验证返回的 JSON 结构是否符合 API 文档——比如order_id必须是字符串。这是语法层的校验。契约测试验证返回的 JSON 内容是否满足消费方的预期——比如order_id必须以SG-开头。这是语义层的校验。“举例——OpenAPI 校验能发现amount字段缺失但不能发现amount从Decimal(28.00)变成了Decimal(27.99)——后者需要消费方的业务规则校验而这正是契约测试的范畴。”金字塔层级契约测试在单元测试之上、E2E 测试之下。它比单元测试慢需要起一个 HTTP 服务但比 E2E 快不需要真实数据库和下游服务。3. 项目实战CI 测试流水线环境准备依赖版本说明Python3.13.14基准版本fastapi0.115HTTP 服务pytest8.3测试框架ruff0.8Lint 格式化httpx0.28契约测试客户端mkdirfoodmarket-ch25cdfoodmarket-ch25 python-mvenv .venv .venv\Scripts\activate pipinstallfastapi uvicorn pytest httpx ruff分步实现步骤1被测试的支付回调服务src/payment_service.py支付回调处理——被测试的目标代码importloggingfromdataclassesimportdataclassfromdecimalimportDecimal loggerlogging.getLogger(__name__)classPaymentGatewayError(Exception):passclassPaymentDeclinedError(PaymentGatewayError):passclassPaymentTimeoutError(PaymentGatewayError):passdataclassclassPaymentResult:order_id:strsuccess:booltransaction_id:stramount:Decimal gateway_message:strclassPaymentGateway:第三方支付网关——真实实现需网络defprocess(self,order_id:str,amount:Decimal)-PaymentResult:# 实际代码会调 HTTP API——这里只定义接口raiseNotImplementedError(需注入具体实现)classOrderService:订单服务——依赖支付网关def__init__(self,gateway:PaymentGateway):self.gatewaygatewaydefprocess_payment(self,order_id:str,amount:Decimal)-dict:try:resultself.gateway.process(order_id,amount)exceptPaymentTimeoutError:return{status:retry,message:支付超时请重试}exceptPaymentDeclinedErrorase:return{status:declined,message:str(e)}exceptPaymentGatewayErrorase:return{status:error,message:str(e)}ifresult.success:logger.info(f支付成功:{order_id}tx{result.transaction_id})return{status:paid,transaction_id:result.transaction_id}else:return{status:failed,message:result.gateway_message}步骤2单元测试——Mock 支付网关tests/test_unit.py单元测试——Mock 支付网关验证 OrderService 行为importpytestfromdecimalimportDecimalfromunittest.mockimportMock,sentinelfromsrc.payment_serviceimport(OrderService,PaymentGateway,PaymentTimeoutError,PaymentDeclinedError,PaymentGatewayError,PaymentResult,)classTestOrderService:纯单元测试——不依赖任何外部服务deftest_successful_payment(self):# ArrangeStub 返回预设结果gatewayMock(specPaymentGateway)gateway.process.return_valuePaymentResult(order_idSG-001,successTrue,transaction_idTXN-ABC,amountDecimal(50.00),gateway_messageOK,)svcOrderService(gateway)# Actresultsvc.process_payment(SG-001,Decimal(50.00))# Assertassertresult[status]paidassertresult[transaction_id]TXN-ABCgateway.process.assert_called_once_with(SG-001,Decimal(50.00))deftest_payment_timeout(self):# ArrangeMock 抛出超时异常gatewayMock(specPaymentGateway)gateway.process.side_effectPaymentTimeoutError(连接超时)svcOrderService(gateway)resultsvc.process_payment(SG-001,Decimal(50.00))assertresult[status]retryassert超时inresult[message]deftest_payment_declined(self):gatewayMock(specPaymentGateway)gateway.process.side_effectPaymentDeclinedError(卡余额不足)svcOrderService(gateway)resultsvc.process_payment(SG-002,Decimal(100.00))assertresult[status]declineddeftest_generic_gateway_error(self):gatewayMock(specPaymentGateway)gateway.process.side_effectPaymentGatewayError(内部错误)svcOrderService(gateway)resultsvc.process_payment(SG-003,Decimal(30.00))assertresult[status]error步骤3契约测试——验证 API 响应格式tests/test_contract.py契约测试——验证 API 接口的请求/响应契约importpytestfromhttpximportAsyncClient,ASGITransport# 被测试的 FastAPI 应用fromfastapiimportFastAPIfrompydanticimportBaseModel,Field appFastAPI()classCallbackRequest(BaseModel):order_id:strField(...,patternr^SG-)transaction_id:stramount:str# Decimal → strstatus:str# success | failedclassCallbackResponse(BaseModel):code:str# OK | ERRORmessage:strapp.post(/payment/callback)asyncdefpayment_callback(body:CallbackRequest):ifbody.statussuccess:returnCallbackResponse(codeOK,message回调处理成功)returnCallbackResponse(codeERROR,messagef支付失败:{body.status})# ── 契约测试 ──pytest.fixtureasyncdefclient():asyncwithAsyncClient(transportASGITransport(appapp),base_urlhttp://test)asac:yieldacclassTestPaymentCallbackContract:契约测试——消费者视角验证 API 行为pytest.mark.asyncioasyncdeftest_success_callback_returns_code_ok(self,client):respawaitclient.post(/payment/callback,json{order_id:SG-20250315-001,transaction_id:TXN-001,amount:50.00,status:success,})assertresp.status_code200dataresp.json()assertdata[code]OK# 契约成功时 code 必须是 OKassertmessageindata# 契约必须有 message 字段pytest.mark.asyncioasyncdeftest_failed_callback_returns_code_error(self,client):respawaitclient.post(/payment/callback,json{order_id:SG-20250315-002,transaction_id:TXN-002,amount:30.00,status:failed,})assertresp.status_code200dataresp.json()assertdata[code]ERRORpytest.mark.asyncioasyncdeftest_invalid_order_id_rejected(self,client):契约order_id 必须以 SG- 开头respawaitclient.post(/payment/callback,json{order_id:INVALID-001,transaction_id:TXN-003,amount:10.00,status:success,})assertresp.status_code422# Pydantic 校验失败pytest.mark.asyncioasyncdeftest_missing_field_rejected(self,client):契约缺少必填字段返回 422respawaitclient.post(/payment/callback,json{order_id:SG-001,})assertresp.status_code422步骤4CI 流水线配置.github/workflows/ci.ymlGitHub Actions 示例name:CI Pipelineon:[push,pull_request]jobs:lint:runs-on:ubuntu-lateststeps:-uses:actions/checkoutv4-uses:actions/setup-pythonv5with:python-version:3.13-run:pip install ruff-run:ruff check src/ tests/-run:ruff format--check src/ tests/unit-test:needs:lintruns-on:ubuntu-lateststeps:-uses:actions/checkoutv4-uses:actions/setup-pythonv5with:python-version:3.13-run:pip install-r requirements.txt-run:python-m pytest tests/test_unit.py-vcontract-test:needs:lintruns-on:ubuntu-lateststeps:-uses:actions/checkoutv4-uses:actions/setup-pythonv5with:python-version:3.13-run:pip install-r requirements.txt-run:python-m pytest tests/test_contract.py-v运行测试ruff check src/ tests/# Lintruffformat--checksrc/ tests/# 格式检查python-mpytest tests/test_unit.py-v# 单元测试python-mpytest tests/test_contract.py-v# 契约测试完整代码清单foodmarket-ch25/ ├── .github/workflows/ │ └── ci.yml ├── src/ │ └── payment_service.py ├── tests/ │ ├── test_unit.py │ └── test_contract.py └── requirements.txt4. 项目总结优点 缺点测试层级速度稳定性覆盖范围维护成本发现 bug 类型Lint★★★★★ 毫秒★★★★★语法/风格★★★★★ 零语法错误单元测试★★★★★★★★★★函数级★★★★逻辑错误契约测试★★★★★★★★接口级★★★接口不兼容集成测试★★★★★★服务级★★★配置/中间件E2E 测试★★★★全链路★★业务流程断裂适用场景CI 质量门禁Lint → 单元测试 → 契约测试 三关全部通过才允许合并到主分支。第三方依赖的异常路径验证用 Mock 模拟支付超时、地图服务宕机、短信网关返回畸形数据。微服务间 API 兼容性提供方 CI 跑契约测试验证不破坏已有消费者的格式约定。重构安全网每次大重构后跑全量单元测试5 分钟内得到改没改坏的反馈。新人 on boarding新人改代码后CI 自动跑测试——不通过就拦下来降低人工 code review 的负担。不适用场景探索性原型需求还没确定的 PoC 阶段测试可能明天就扔。先写功能再补测试。UI 视觉验证按钮颜色、动画效果——单元测试和契约测试都覆盖不了需要视觉回归测试工具。注意事项unittest.mock.patch的 target 必须是 import 路径patch(src.payment_service.PaymentGateway)而不是patch(payment_service.PaymentGateway)——原因是 mock 替换的是被测试模块中 import 的那个名字。契约测试不替代集成测试契约测试保证格式对集成测试保证业务对——二者不重合。Mock(specPaymentGateway)的意义加了specMock 对象只允许访问 PaymentGateway 类上存在的方法/属性——防止你 mock 了一个不存在的方法还以为在测真实接口。CI 中并行跑不同层级的测试Lint、单元测试、契约测试可并行互不依赖缩短整体 CI 时间。常见踩坑经验故障案例1Mock 了一个不存在的方法——测试永远通过现象gateway Mock(); gateway.porcess.return_value ...process拼成porcess测试通过但上线后AttributeError。根因Mock()默认允许任意属性访问——你调用mock.whatever()它都返回一个新的 Mock不报错。修复Mock(specPaymentGateway)——访问不存在的方法时抛出AttributeError。故障案例2assert_called_once_with没考虑对象相等现象mock_fn.assert_called_once_with(user)突然失败因为User类没实现__eq__。根因assert_called_once_with用比较参数——如果参数对象没有__eq__比较的是内存地址两个内容相同的对象被视为不等。修复对复杂的参数用mock_fn.assert_called_once()先验证调用次数再用call_args手写断言。故障案例3CI 中 ruff 版本不一致导致本地和 CI 结果不同现象本地ruff check通过CI 中报 3 个新规则错误。根因本地 ruff 版本是上个月装的CI 每次都pip install ruff最新版新版本引入了新规则。修复在requirements-dev.txt中固定ruff0.8.4CI 用同版本。思考题unittest.mock.MagicMock和unittest.mock.Mock的区别是什么在什么场景下更适合用MagicMock一个微服务的消费者契约测试断言了response[order][amount]必须是字符串29.99。如果提供方把金额从Decimal改为int分序列化后变成2999——契约测试能发现这个 breaking change 吗如果不能还应该加什么层面的验证答案见中级篇综合实战章附录。延伸阅读与资源Python 3实战精进从脚本到高并发订单引擎MongoDB 实战进阶与内核修炼python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析

相关新闻

STM32嵌入式学习路线规划:从零到项目实战的完整自学指南

STM32嵌入式学习路线规划:从零到项目实战的完整自学指南

去年暑假,我帮一个学弟规划他的嵌入式学习路线,他当时问了我一个很典型的问题:“网上都说STM32很重要,我也买了开发板,但教程太多了,从标准库到HAL库,从点灯到RTOS,我该从哪里开始&a…

2026/10/9 6:57:40 阅读更多 →
绘画学习系统指南:从零到一掌握绘画核心技能

绘画学习系统指南:从零到一掌握绘画核心技能

在实际绘画学习过程中,初学者常常会感到挫败和困惑,甚至产生“头要裂开”的无力感。这种感觉并非源于天赋不足,而往往是因为学习方法、练习路径和心态调整上存在误区。绘画作为一项结合了观察、理解、表达和肌肉记忆的复杂技能,其…

2026/10/16 6:54:53 阅读更多 →
如何3步完成NCM文件转换:ncmdumpGUI完整使用指南

如何3步完成NCM文件转换:ncmdumpGUI完整使用指南

如何3步完成NCM文件转换:ncmdumpGUI完整使用指南 【免费下载链接】ncmdumpGUI C#版本网易云音乐ncm文件格式转换,Windows图形界面版本 项目地址: https://gitcode.com/gh_mirrors/nc/ncmdumpGUI 你是否曾经在网易云音乐下载了心爱的歌曲&#xf…

2026/10/15 10:12:21 阅读更多 →

最新新闻

SSM+Vue篮球比赛邀约系统毕设全攻略:核心功能与论文框架

SSM+Vue篮球比赛邀约系统毕设全攻略:核心功能与论文框架

做了几个SSMVue方向的毕设项目辅导之后,我可以很负责任地说一句:篮球比赛邀约系统,是这个技术栈里特别适合毕设落地的题目。它不是纯电商那种“增删改查四件套”堆出来的空壳,也不是短期内根本写不完的分布式大厂项目,…

2026/10/16 6:53:58 阅读更多 →
2026年本科生降AI率工具全解析:从检测原理到实操流程

2026年本科生降AI率工具全解析:从检测原理到实操流程

1. 为什么本科生现在要专门研究“降AI率”先说个我身边的真实场景。某高校一位大三学生,期末课程论文认真写了两个星期,文献读了20来篇,数据分析全自己做,唯一“偷懒”的地方就是用AI帮忙梳理了文章逻辑框架、润色了几段过渡段。结…

2026/10/16 6:53:58 阅读更多 →
集群互联与通信:从硬件拓扑到性能排错实战

集群互联与通信:从硬件拓扑到性能排错实战

集群跑不快,问题往往不在计算,而在互联和通信。这是我做了几年并行系统后最深的体会。很多团队把精力花在优化算法、堆核数上,结果任务一上多节点,性能就断崖式下跌。你查CPU利用率,每个节点都闲得发慌,可任…

2026/10/16 6:53:58 阅读更多 →
IM安卓开发工具箱imakit9.13环境配置与ROM备份实战指南

IM安卓开发工具箱imakit9.13环境配置与ROM备份实战指南

简介:IM安卓开发工具箱imakit 9.13是面向Android ROM开发者、刷机爱好者及系统定制工程师的专业级工具集,专为高效制作、备份、转换与调试刷机包而设计,显著降低img镜像管理、ROM打包及跨格式适配的技术门槛。资源包共283个文件,涵…

2026/10/16 6:53:58 阅读更多 →
安瓿设备分期付款与招投标报价怎么谈

安瓿设备分期付款与招投标报价怎么谈

安瓿设备分期付款与招投标报价怎么谈两种谈法最考验采购功力:一种是资金紧张时的分期付款谈判,一种是正规项目的招投标报价。这两种场景的谈判逻辑完全不同——分期谈的是风险分配,招投标谈的是规则利用。这篇把两种场景的谈法要点拆开讲。一…

2026/10/16 6:53:58 阅读更多 →
电力用采系统通信协议集合与测试工具实战:从报文解析到调试避坑

电力用采系统通信协议集合与测试工具实战:从报文解析到调试避坑

简介:面向电力行业技术人员的电力用户用电信息采集系统通信协议集合与测试工具包,系统整合376.1/376.2/376.3、645、698等常见规约标准,直击协议解析、设备联调与抄表系统开发中的实操痛点,适用于智能电网运维、采集终端研发及协议…

2026/10/16 6:52:58 阅读更多 →

日新闻

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

/* 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 0:00:05 阅读更多 →
信号与系统频域分析作业解题指南

信号与系统频域分析作业解题指南

1. 作业背景与核心要求解析信号与系统作为电子信息类专业的核心基础课,其作业设计往往直接关联课程的核心知识框架。2026年春季第六次作业的布置,通常对应着课程进展到频域分析的关键阶段。根据常规教学进度推测,本次作业可能涉及以下核心内容…

2026/10/16 0:00:05 阅读更多 →
Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

1. 从一个"四不像"的练手项目说起如果你已经看完了Django的官方教程前几章,跟着敲完了投票应用,大概会有一种"我好像会了,又好像什么都不会"的割裂感。教程里每个知识点都是拆开的——这一章讲模型,下一章讲视…

2026/10/16 0:00:05 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/15 19:41:51 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* 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 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/15 9:49:14 阅读更多 →