news 2026/9/28 1:11:17

SpringBoot家政预约系统实战:并发控制、数据库设计与部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot家政预约系统实战:并发控制、数据库设计与部署

每年都有大量家政公司被预约管理搞得焦头烂额:客户电话一个接一个,阿姨排班全靠Excel,时间撞车只能人工协调,月底对账更是噩梦。去年我接了一个家政公司的单子,核心诉求就是做一个家政保洁预约管理系统,让客户能在线选服务、选阿姨、定时间,后台能管订单、管排班、管结算。技术选型我直接用了SpringBoot,从0到1把整套系统落地并部署到客户服务器上。这篇文章就把整个设计和实现过程拆开揉碎讲清楚,从需求分析、表结构设计到核心代码实现、部署上线,全部盘一遍,给正在做相关毕设或者想快速搭一套预约类系统的朋友做个参考。

1. 项目定位与核心需求拆解

1.1 家政保洁预约系统到底解决什么问题

先说业务背景。家政公司传统模式的痛点很集中:预约靠电话和微信,信息记录在纸和Excel里,同一个保洁阿姨被两个客户重复预订的情况经常发生。客户要改时间,必须打电话找客服,客服再去翻记录,效率极低。老板想统计业绩和阿姨工作量,只能手动汇总,经常出错。

这套系统要解决的,就是三件事:第一,把预约流程线上化,客户自助完成服务选择、时间选定、下单支付,不用再打电话;第二,把排班自动化,系统自动检测保洁阿姨的时间冲突,把合适的人分配到订单上;第三,把结算和统计数字化,订单状态清晰可见,每个阿姨干了多少单、收入多少,后台一键导出。

实际开发之前,我项目组内部先做了角色梳理。系统涉及三类角色:普通用户(客户)、保洁阿姨(服务提供者)、平台管理员。客户需要浏览服务项目、查看阿姨信息、预约下单、支付、取消预约、评价;阿姨需要查看自己的工作日程、接单、确认完成服务;管理员需要管理服务项目、审核阿姨信息、处理异常订单、查看经营数据。

1.2 核心业务流程与功能边界

把业务流程画清楚再动手写代码,这个习惯救了我很多次。这个系统的核心流程是这样的:用户打开小程序或网页,浏览服务项目列表,选择一个具体服务(比如日常保洁、深度保洁、开荒保洁),进入预约页面选择上门地址、期望服务时间段,系统根据用户所在区域和时间自动推荐可匹配的保洁阿姨;用户确认预约并完成支付,订单状态变成"待服务";系统在服务前一天和当天上午分别发送提醒;阿姨完成后点击"确认完工",用户可以对服务质量进行评价。

这里有个容易被忽视的需求点:取消预约的策略。用户下单后因为各种原因要取消,如果阿姨还没排入日程,全额退款;如果已经排入,需要管理员介入审核。这块业务规则不提前定清楚,开发到一半就会被迫返工。

功能边界上,我刻意砍掉了几个看起来"应该做"但实际价值不高的模块,比如自建的即时通讯、复杂的营销优惠系统。第一版本先跑通核心预约链路,后续再迭代。

2. 技术选型与SpringBoot方案的优势

2.1 为什么选定SpringBoot做技术底座

说实话,这套系统用其他语言也能做,但SpringBoot在这个场景下是最稳的选择。核心原因是生态成熟和团队熟悉。SpringBoot的自动装配机制把大量配置内置化,我只需要在pom文件里引入依赖,框架自动帮你把数据源、事务管理器、Web服务器配置好,能省下至少两到三天的环境搭建时间。

版本方面我选了SpringBoot 2.7.18,这是2.x系列的最后一个维护版本,有持续的安全补丁,不像3.x那样模块有大变动。网上有很多关于SpringBoot版本太高的吐槽——3.x要求JDK 17,很多学校的服务器和生产环境还在用JDK 8,部署容易出问题。选2.7.18配JDK 8,很多现存的老项目、公共组件都能无缝兼容,这对我来说是最低风险的选择。

2.2 整体技术栈清单与选型理由

  • 持久层框架:MyBatis-Plus。为什么不用Spring Data JPA?家政预约系统的查询条件非常多:订单状态筛选、时间范围查询、多表关联统计。MyBatis的SQL控制力更强,MyBatis-Plus又提供了开箱即用的分页插件和条件构造器,开发效率高且SQL可控。
  • 数据库:MySQL 5.7。业务量级远没到上分布式数据库的程度,MySQL单库单表配合合理索引完全够用。5.7版本稳定成熟,InnoDB行锁和事务能力都能满足预约场景的并发需求。
  • 缓存:Redis。用来做验证码存储、服务项目缓存、分布式锁(预约防并发用)和热数据的缓存。
  • 认证方案:JWT无状态Token。前后端分离架构下JWT很顺手,服务端不需要维护Session,登录态校验直接解Token就行。
  • 定时任务:Spring Task的@Scheduled注解。订单超时未支付自动取消、服务前提醒通知,这两个场景用Quartz会觉得重,Spring自带的调度就够用。
  • 部署:Docker + Docker Compose。统一打包环境,交付到客户服务器时不用重新装JDK和MySQL,一条命令拉起来。

这套组合基本是当前中小型Java Web项目的典型配置,没有特别激进的技术,胜在稳定、资料多、排错容易。

3. 数据库设计与核心表结构

3.1 六张核心业务表逐一拆解

预约系统逃不开资源-时间-人员这三要素,我把表结构设计成围绕订单为中心展开。核心是订单表,外围挂服务项目、保洁阿姨、客户、时间槽、评价这几张表。给大家看一下每张表的重点字段和设计意图。

用户表(user):id、openid(微信登录唯一标识)、昵称、手机号(必带索引)、角色(1用户/2阿姨/3管理员)、微信头像URL、状态。注意手机号要加唯一索引,因为后续登录、找回密码、消息推送都依赖它。

服务项目表(service_item):id、名称、类型(日常保洁/深度保洁/开荒保洁/家电清洗)、计价方式(按次/按小时)、单价、单位、图片URL、描述、排序权重、上架状态。

保洁阿姨表(worker):id、user_id(关联用户表)、真实姓名、身份证号(做展示脱敏)、服务区域(用区县编码)、等级(初级/高级/金牌)、服务次数、好评率、服务状态。等级和好评率影响自动匹配推荐权重,这是后续核心算法的数据底座。

预约订单表(appointment_order):这张表字段最多,也是整个系统的关键所在。包括:id、订单号(唯一业务编号)、user_id、worker_id、service_item_id、服务地址、(省市区+详细地址)、预约开始时间、预约结束时间、服务时长(分钟)、订单金额、支付方式、支付流水号、支付时间、订单状态(0待支付/1已支付-待服务/2服务中/3已完成/4已取消/5售后中)、取消原因、创建时间、更新时间。

评价表(review):id、order_id(唯一,一个订单只能评价一次)、user_id、worker_id、评分(1-5分)、内容、图片列表、回复内容、创建时间。

时间槽表(time_slot):id、worker_id、日期、开始时间、结束时间、状态(可用/已占用/休息)。这张表在冲突检测里起到关键作用,后面我会细讲。

3.2 时间槽设计与预约冲突检测的核心思路

预约系统最麻烦的就是时间冲突。最简单的做法是每次预约时查一下某位阿姨在某个时间段是否已有订单,但纯靠应用层判断在并发场景下有漏洞——两个用户同时提交,都查到"空闲",然后都插入成功,就撞车了。

我的方案是双保险。第一层:在数据库层面建立唯一约束,对worker_id + 预约开始时间做唯一索引,同一时间同一阿姨只能有一条预约记录。第二层:创建订单和预占时间槽放在同一个数据库事务里,先插入时间槽记录,再更新订单,任何一步失败就整体回滚。这样即使并发请求同时进来,数据库的唯一索引也会让后到的插入失败,从而保证数据一致性。

时间槽的粒度我设置为30分钟一个单位。比如阿姨工作时间是9:00到18:00,中间去掉午休,一天大概生成16个槽位。用户选"9:00-10:00"就是连续占两个槽位。表结构里不用存所有槽位,只需要在用户下单成功时生成对应的槽位占用记录就行,这样数据量不会无谓膨胀。

3.3 订单号生成策略

订单号我用的格式是:日期时间戳(14位)+ 用户ID后四位(4位)+ 随机数(4位),一共22位。比如2025022110451200324871。这个方案足够唯一,而且从订单号能直接看出下单日期和用户,排查问题的时候方便。不要纠结用UUID做订单号,可读性差,索引效率也略低,而且拿UUID跟用户沟通体验极差。

4. 核心模块实现:预约流程的完整链路

4.1 下单接口:从用户点击到订单落库

前端的流程是:选择服务项目→选择日期→选择时间段→确认地址→点击支付→微信支付回调。后端接口设计是POST /api/order/create,请求体包含服务项目ID、预约日期、开始时间、服务时长、服务地址、用户备注。

后端处理逻辑我拆成四个步骤:

第一步,校验参数。检查服务项目是否上架、服务时长是否在合法范围(1-8小时)、预约日期是否在可预订范围(当天不可约,最早明天,最长提前30天)。

第二步,计算订单金额。金额不是前端传过来的,必须后端根据服务项目的单价和服务时长重新计算。前端传金额是安全漏洞的高发点,一定要在后端按价格表重新核算。

第三步,匹配阿姨。根据用户地址所在区域,查询状态正常且在该时段空闲的阿姨列表,按照好评率权重排序,优先推荐好评率高、服务次数多的高级阿姨。

第四步,事务内创建订单和占槽。先插入时间槽记录,再创建订单记录,同一个事务提交。这一步如果有任何异常,全部回滚。

核心代码大致是这个形态:

@Transactional(rollbackFor = Exception.class) public OrderResult createOrder(CreateOrderRequest request) { // 1. 校验服务项目 ServiceItem item = serviceItemMapper.selectById(request.getItemId()); if (item == null || item.getStatus() != 1) { throw new BizException("服务项目不存在或已下架"); } // 2. 计算金额(后端重新计算,不信任前端传值) BigDecimal amount = item.getPrice().multiply( BigDecimal.valueOf(request.getServiceMinutes() / 60.0)); // 3. 匹配可用阿姨 List<Worker> availableWorkers = workerMapper.findAvailable( request.getDate(), request.getStartTime(), request.getEndTime(), request.getDistrictCode() ); if (CollectionUtils.isEmpty(availableWorkers)) { throw new BizException("该时间段没有可用阿姨,请调整时间"); } // 按好评率降序,选最优阿姨 Worker selectedWorker = availableWorkers.get(0); // 4. 锁时间槽 + 创建订单 TimeSlot slot = new TimeSlot(); slot.setWorkerId(selectedWorker.getId()); slot.setDate(request.getDate()); slot.setStartTime(request.getStartTime()); slot.setEndTime(request.getEndTime()); slot.setStatus(1); // 已占用 timeSlotMapper.insert(slot); // 唯一索引兜底并发冲突 AppointmentOrder order = buildOrder(request, selectedWorker, amount); orderMapper.insert(order); return new OrderResult(order.getId(), order.getOrderNo(), amount); }

4.2 阿姨自动匹配算法:够用就好

别一听到"算法"就以为要上机器学习。这个业务的匹配只要做规则加权就能出不错的效果。我设定的权重是:距离权重(0.4)+ 好评率权重(0.35)+ 服务次数权重(0.25)。三个指标归一化成0-1分后加权求和,分数最高的排在前面。

距离数据从哪来?用户地址和服务区域都是区县级别的编码,同区即为1分,同市不同区为0.5分。这个颗粒度对于家政保洁场景是合理的——同一个区的阿姨上门成本最低,跨区接单意愿本来就不高。真实上线后再根据数据反馈调整权重系数就行。

4.3 订单状态机和支付回调的幂等处理

订单状态的流转我是用状态机硬编码管理的,明确每种状态下允许哪些操作:

  • 待支付:可取消;超时30分钟自动关闭
  • 已支付:可申请取消(管理员审核);服务前可改期一次
  • 服务中:无用户操作,阿姨确认完工后进入待评价
  • 已完成:用户可评价、可发起售后
  • 已取消:终态,退款按取消时机自动触发

我单独留一个字段refund_status处理退款状态,避免跟订单主状态耦合,不然状态枚举会爆炸。

支付走的是微信支付Native模式(扫码支付)。支付回调是重点,一定要做幂等处理。回调接口POST /api/pay/notify可能因为网络重试被多次调用,所以处理逻辑是:根据订单号查询订单表,如果订单已经处于"已支付"状态,直接返回成功,不再重复修改。

@PostMapping("/pay/notify") public String handlePayNotify(@RequestBody String xmlData) { // 1. 验签 Map<String, String> result = WxPayUtil.parseAndVerify(xmlData); if (!"SUCCESS".equals(result.get("result_code"))) { return "FAIL"; } // 2. 根据订单号查询订单 String orderNo = result.get("out_trade_no"); AppointmentOrder order = orderMapper.selectByOrderNo(orderNo); // 3. 幂等判断:已经支付过就直接返回 if (order != null && order.getStatus() == 1) { return "SUCCESS"; } // 4. 更新订单状态 + 写入支付流水 orderMapper.updateStatus(orderNo, 0, 1); payLogMapper.insert(buildPayLog(result)); return "SUCCESS"; }

4.4 全局异常处理与参数校验

SpringBoot开发里最容易被忽略的就是全局异常处理。如果不做统一处理,每层抛出的异常都会变成一堆堆难看的堆栈信息返回给前端。我在项目里用一个@RestControllerAdvice统一拦截,业务异常返回错误码和提示信息,系统异常返回兜底提示并打印完整日志。

参数校验用@Validated注解组,创建订单的DTO上给每个字段标注约束(@NotNull、@Min等),一个注解搞定,比手写if判断干净得多。

5. 部署上线与性能优化实战

5.1 Docker Compose一键部署

开发环境用mvn spring-boot:run本地跑没问题,但交付给客户时不能让他们按着文档装JDK、MySQL、Redis。我直接用Docker Compose把整个环境编排在一起,客户服务器上只需要装一个Docker。

version: '3.8' services: mysql: image: mysql:5.7 container_name: booking-mysql environment: - MYSQL_ROOT_PASSWORD=root123 - MYSQL_DATABASE=booking_system ports: - "3306:3306" volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql command: - --character-set-server=utf8mb4 redis: image: redis:6.2 container_name: booking-redis ports: - "6379:6379" app: build: . container_name: booking-app depends_on: - mysql - redis ports: - "8080:8080" environment: - SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/booking_system - SPRING_REDIS_HOST=redis

Dockerfile里用多阶段构建,先Maven打包,再用JRE基础镜像运行:

FROM maven:3.8-openjdk-8 AS build COPY . /app WORKDIR /app RUN mvn clean package -Dmaven.test.skip=true FROM openjdk:8-jre-slim COPY --from=build /app/target/booking-system.jar /app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app.jar"]

这样部署的整个流程就是三行命令:安装Docker,把项目文件传到服务器,然后执行docker compose up -d。客户那边找个人稍懂一点Linux就能自己拉起来,后续维护省了我大量远程指导的时间。

5.2 Redis缓存降低数据库压力

预约系统的高频读接口是服务项目列表。用户每次打开小程序首页都会请求一次,如果不做缓存,MySQL每天要扛几万次重复的查询请求。我用Redis缓存服务项目列表,缓存时间设置为30分钟,后台修改服务项目时主动清除缓存,保证数据不滞后。

另外一个用Redis的关键场景是超时取消订单。用户下单后如果30分钟没支付,订单要自动取消并释放占用的时间槽。我用的方案是:下单时向Redis写入一个带过期时间的Key(比如order:timeout:{orderId},过期时间30分钟),同时利用Redis的过期事件监听,过期时触发回调去关单。这样比定时任务每分钟扫表效率高得多,不会产生大量无效查询。

5.3 接口性能优化心得

从客户线上反馈来看,早期有几个慢接口集中在订单列表和管理后台的统计报表。排查发现根因是SQL里多表join时缺少索引。我在订单表的worker_id、status、create_time上建了联合索引,查询效率提升非常明显。统计报表那块我干脆建了一张汇总表,每日凌晨由定时任务聚合前一天的数据,查询走汇总表而不是实时扫描订单明细,这个改动让报表接口从两秒压到一百毫秒以内。

一个心得是:不要等到接口慢了再去优化,在设计表的时候就考虑好查询模式,索引提前加上能避免很多后续麻烦。比如订单表,你已经知道用户端会查user_id + status,阿姨端会查worker_id + date,管理员会查status + create_time,直接三个联合索引一次到位。

6. 实战踩坑记录与代码级避坑指南

6.1 并发预约造成的时间冲突问题

这个坑我掉进去过。第一版做冲突检测时,我只用了"先查再插入"的逻辑,在本地测试单线程怎么跑都没问题。上到测试环境,同事用两个浏览器同时创建同一时段订单,竟然都成功了。原因就是两个请求同时查询,都发现时间槽空闲,然后先后插入,数据库因为没有唯一约束所以两条记录都进去了。

修复方案上文提过:给时间槽表加unique(worker_id, date, start_time)唯一索引,插入时兜底。另外再把插入时间槽和创建订单放在同一个事务里。这两个措施加在一起,并发场景下必然有一个请求插入失败,应用层捕获到唯一键冲突后友好提示"该时间段已被预订,请更换时间",问题解决。

6.2 MyBatis-Plus分页插件在SpringBoot 2.7的配置坑

MyBatis-Plus的分页插件在较新版本里写法有变化。旧教程里写的是PaginationInterceptor,但在MyBatis-Plus 3.5+里这个类已被废弃,需要改用MybatisPlusInterceptor并添加PaginationInnerInterceptor。我一开始照旧写法配置,结果分页一直不生效,查了半天文档才找到原因。

正确写法:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

这是一个典型的小配置坑,遇到分页不生效先看版本,再查插件类名是否匹配。

6.3 全局过滤器处理请求参数的XSS问题

系统上线前做了安全测试,发现一个XSS漏洞:用户在评价内容里故意输入了<script>alert("xss")</script>,刷新页面时弹窗。这表明后端没有对用户输入做过滤。我加了一个全局过滤器,拦截所有请求,对请求参数中的HTML标签进行转义,尤其是<script>、<iframe>这类危险标签直接剥离。

实现上,写一个XssFilter实现Filter接口,包装HttpServletRequest,重写getParameter和相关方法,对参数值做HtmlUtils.htmlEscape处理。核心代码大致是:

public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req = (HttpServletRequest) request; chain.doFilter(new XssHttpServletRequestWrapper(req), response); } } class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { @Override public String getParameter(String name) { String value = super.getParameter(name); return cleanXss(value); } private String cleanXss(String value) { if (value == null) return null; // 移除script标签及事件属性 String cleaned = value.replaceAll("(?i)<script.*?>.*?</script>", ""); cleaned = cleaned.replaceAll("(?i)onerror\\s*=\\s*[\"'][^\"']*[\"']", ""); return cleaned; } }

这个过滤器只对普通请求做处理,上传文件相关的接口要注意跳过,否则会二进制数据误转义。我当时就是在全局过滤里排除掉/api/file/upload路径,否则上传的图片文件都被破坏了。

6.4 时间显示差8小时的问题

这个经典坑不用多介绍,但每次都会有人遇到。MySQL的DATETIME默认时区是服务器的系统时区,如果服务器是UTC,存进去的时间跟北京时间差8小时。我直接在JDBC连接参数上加了serverTimezone=Asia/Shanghai,同时在后端写时间统一用LocalDateTime,不依赖系统默认时区。

排查思路很简单:如果发现线上数据时间都对不上,先查数据库时区,再查应用连接的serverTimezone配置,这两个对齐基本就解决了。

6.5 与SpringBoot面试相关的几点准备

做这个项目的过程顺便把SpringBoot的几大核心机制弄明白了,面试基本绕不开这些:自动装配原理(spring.factories和@ConditionalOnXxx条件注解)、starter机制(spring-boot-starter-parent统一管理版本)、核心注解(@SpringBootApplication由@EnableAutoConfiguration、@ComponentScan、@Configuration组合而来)。

如果你正在准备毕设答辩,务必要搞懂自动装配的原理,面试官几乎必问。简单理解就是SpringBoot启动时,@EnableAutoConfiguration通过AutoConfigurationImportSelector加载META-INF目录下spring.factories里声明的所有自动配置类,再根据当前项目引入的依赖和配置条件(@ConditionalOnClass、@ConditionalOnProperty等)决定哪些配置类生效。回答清楚这个逻辑,就能看出你真的跑过项目,而不是照搬博客。

7. 这套系统的后续扩展方向

第一版上线稳定之后,客户提了几个新需求,基本都是顺着这个架构能自然扩展的:上门服务开始前的GPS签到、阿姨端小程序、阶梯价和优惠券、按周固定保洁的订阅制服务。这些扩展在现有表结构上都只需要加字段或加独立表,说明当初的表设计预留了空间。

如果现在让我重新做一遍,我会在一开始就把阿姨端小程序放进首期范围,因为实际运营中,阿姨确认工单、上传服务照片这个环节非常依赖移动端。网页端让阿姨拍照传图总归不方便。另外,客户端小程序和服务端接口一定要尽早联调,后端不要把接口文档写得太细再动手,先定好字段名和格式,前端可以并行开发,能省出不少时间。

写在最后的一点经验

这套系统从设计到上线前后用了两个月,最大的体会是:预约类系统的难点不在CRUD本身,而在时间资源的并发控制和状态管理的严谨性。只要你把唯一约束、事务边界、状态机这三点想清楚,整个骨架就站得住。另外,在技术选型时别因为"SpringBoot版本太高"或者"新技术炫酷"而去冒险,稳定压倒一切,尤其当系统要去真实服务用户的时候。做这类项目,先把时间和精力砸在业务细节上,比换用再新的框架都更值。

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

酒店评论情感分析系统:规则+轻量CNN混合架构实战

简介&#xff1a;这是一套面向人工智能与自然语言处理初学者的酒店评论情感分析实战项目&#xff0c;适用于计算机科学、软件工程及数据科学相关专业的课程设计、毕业设计与自学实践。项目基于逻辑回归与XGBoost双模型构建&#xff0c;集成TF-IDF文本向量化、Flask轻量级Web服务…

作者头像 李华
网站建设 2026/9/28 1:10:58

C#调用HslCommunication实现FX5U PLC的Modbus TCP通信

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

作者头像 李华
网站建设 2026/9/28 1:10:56

全差分运放高速共模失控根源与CMFB实战设计

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

作者头像 李华
网站建设 2026/9/28 1:10:15

基于TypeScript的ECU诊断开发:UDS、CAN-TP、DoIP与LIN协议栈实战

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

作者头像 李华
网站建设 2026/9/28 1:09:08

速腾激光雷达ERRCODE_MSOPTIMEOUT报错排查实战指南

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

作者头像 李华