news 2026/9/26 9:42:09

financial-services实战:Spring Boot微服务下的数据建模与事件驱动设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
financial-services实战:Spring Boot微服务下的数据建模与事件驱动设计

去年年底我把一个做了半年的个人项目推倒重写,最终落地的产物就是一个非常朴素的financial-services。这个项目没有炫酷的前端界面,没有复杂的机器学习模型,它是一个纯粹的后端服务,负责一件事:把个人和家庭分散在银行卡、信用卡、支付宝、微信里的收支记录统一收口,然后自动打上分类标签,按月生成预算报表,并对异常支出做提醒。

我写这篇文章的原因很简单:市面上讲“账本App”和“记账工具”的文章很多,但真正把financial-services当成一个独立后端项目来拆解、讲清楚数据模型、事务边界、幂等方案和部署细节的很少。我知道很多开发者手里都有一个类似的项目,要么挂在简历上,要么是自用工具,但大部分都卡在“功能能跑”和“生产可用”之间的那道坎上。这篇博文就是围绕我自己从零搭建financial-services的完整过程来写的,适合有 Spring Boot 基础、想了解金融服务类项目建模细节的后端开发者,也适合正在考虑自建个人财务系统的技术爱好者。你可以把它当成一份可直接参考的工程笔记,而不是产品评测。

1. 项目拆解:financial-services 到底在做什么

1.1 一句话讲清楚核心业务流程

financial-services的核心流程可以压缩成一条链路:多渠道导入交易流水,标准化数据,进行账户余额计算,再按预算规则做实时扣减,最后产出维度不同的报表。展开来说,用户手动添加一笔支出或者通过 CSV 导入支付宝账单之后,服务端要做四件事:判断这笔交易属于哪个账户、校验该账户当前状态是否允许入账、更新账户的流水记录、同时异步触发预算模块的金额统计。

这听起来像是一个普通的记账系统,但我真正想解决的痛点是“手工记账的根本矛盾”——大多数人的财务情况并不是单一账户,而是三张银行卡加两张信用卡再加微信零钱,它们各自有独立的余额和账单日。如果不在底层把“账户”和“交易”这两个概念分开,后续所有统计都是乱的。所以我给自己定的设计原则是:一个账户可以有无数条交易记录,但交易记录不能反过来直接修改账户余额——余额永远是账户表中的一个冗余字段,它的正确性由流水表来保证。

这样设计的好处是,当用户对一笔历史交易做修改或删除操作时,我们不需要去逐个更新账户余额,而是通过重放该账户的全部流水来重新计算余额。虽然实时性上比直接updatebalance字段差一点,但数据一致性逻辑简单了许多,也更容易排查那些“莫名其妙多了一分钱”的问题。

1.2 四类核心参与者与权限边界

在financial-services里,我把业务对象收敛成四类:用户、账户、交易、预算。用户是最外层的主权对象,他可以有多个账户;账户是资金容器,分为资产账户和负债账户两类;交易是账户的流水事件,必须有一个明确的类型,比如收入、支出、转账;预算则是用户在某个月份对某个分类设置的上限。

权限边界是根据“谁的数据谁能动”来划分的。用户只能操作自己名下的账户和交易,但转账交易比较特殊,它涉及两个账户,必须校验当前登录用户对两个账户都有操作权限。我在这里吃过一次亏:最初只用transaction.accountId做归属校验,导致用户可以把资金从别人的账户里转出来。后来我加了一个transfer_participant表,把每一笔转账的源账户和目标账户都记录在内,再做二次权限校验,这个问题才算是彻底解决。

如果要把这套模型推广到家庭场景,还需要引入“家庭成员”的角色,但当前版本我没做那么重。我把家庭需求往后放了,原因是核心权限模型一旦复杂化,项目节奏会拖慢,我更想先把单人使用的闭环跑通。文章后面会简单提一下扩展方向。

1.3 为什么需要异步编排而非同步调用

financial-services早期版本是典型的同步单体逻辑:保存交易的时候,同步更新账户余额,同步更新预算已用金额,同步刷新统计报表。这种写法在并发量很低的时候没有任何问题,但很快就暴露出两个隐患:一是单次请求耗时过长,因为要操作多张表;二是如果预算服务的一个字段校验失败,会导致整笔交易入账回滚,连账户流水都保存不了。

于是我决定把链路拆成同步和异步两部分。同步部分只做最核心的两件事:保存交易流水、写入本地事件表。异步部分负责消费事件,更新账户快照、计算预算消耗、生成报表增量数据。异步编排的好处是,账单导入这类高频操作不再被预算计算这类相对慢的任务拖住,用户的感知响应时间可以控制在 200ms 以内。同时每个异步任务都可以独立重试,不会因为某一个下游服务出问题而影响主流程。

我会在第三章详细讲这种“本地事务 + 异步处理”的最终一致性方案,这里先只给出整体思路:同步保证核心数据不丢,异步保证扩展逻辑不影响主流程。

2. 技术选型与架构设计

2.1 技术服务组件选型:为什么是 Java 21 + Spring Boot 3 + PostgreSQL

先交代一下最终版本的技术栈:后端是 Java 21 + Spring Boot 3.2,数据库是 PostgreSQL 15,缓存和分布式锁用 Redis,消息中间件用的是 Kafka(本地调试也可以用 Redpanda),部署环境是 Docker Compose,监控用 Prometheus + Grafana。

选 Java 21 不是因为追求新版本,而是 Spring Boot 3.x 已经进入强制要求 Java 17 以上的阶段,而 Java 21 的虚拟线程可以让我在处理 IO 密集型任务时用更简单的同步写法,却依然保持高吞吐。这里有一个非常现实的考虑:financial-services里有很多文件解析、数据库查询和外部 API 调用的 IO 等待,如果使用传统线程池,要么调优线程数,要么引入 WebFlux 写锯齿形回调。虚拟线程让我可以直接用restTemplate同步调用的方式写代码,不用刻意做响应式改造。

PostgreSQL 的选择同样是基于功能而非流行度。相比 MySQL,PostgreSQL 有一个我非常依赖的特性:BIGINT类型资源占用小,NUMERIC可以精确存储金额,避免浮点数误差。更重要的是它对 JSONB 的支持让我可以在交易表里保留一个额外的ext字段,用来存导入渠道回传的原始数据,这个字段的内容虽然不参与常规查询,但在排查历史问题时非常管用。

有些人可能会觉得个人项目用 Kafka 是杀鸡用牛刀,但我的理解是,如果整个项目的目的就是学习并模拟生产级金融服务的复杂度,那引入 Kafka 是合理的。它让我能练习事件驱动的应用题,而不是什么新鲜事都堆在一个进程里。当然我会在下文给出一套简化替代方案,比如用 Redis Stream 代替 Kafka。

2.2 微服务划分:account / transaction / budget / report

financial-services在物理上不是一个大单体,而是按照领域边界拆成的四个模块,也可以视作四个服务。它们分别是账户服务(account-service)、交易服务(transaction-service)、预算服务(budget-service)和报表服务(report-service)。

账户服务管理账户的开户、销户、冻结状态等;交易服务负责流水的创建和查询;预算服务负责按月统计分类金额,并在超支时发送预警事件;报表服务则从流水表中按照日、周、月维度聚合成指标。每个服务都有自己的数据库 schema,服务之间通过 HTTP 接口同步调用,但关键事件通过 Kafka 异步通知。

做这种拆分时我最深刻的体会是:服务边界不能凭空画,必须从业务行为里找缝隙。一开始我把“预算”功能塞在“交易”模块里,结果交易服务的表越建越多,事务边界越来越长,最后实在理不清了才拆出来。现在每个服务只对自己领域内的表负责,跨服务的数据需求一律通过“查询接口+事件通知”组合解决,比如预算服务不会直接查询交易表,而是消费交易创建成功后发出的事件来更新自己的统计字段。

2.3 中间件与基础设施:Redis 缓存、Kafka 事件流、Docker Compose

Redis 在financial-services里承担了三种职责:分布式锁、幂等判重、热点数据缓存。其中幂等判重我会在第五章单独展开,这里先说说热点数据的缓存策略。用户的账户基本信息会频繁被交易服务读取,但它本身变化频率很低,所以我会在 Redis 里缓存一份序列化后的账户对象,设置 10 分钟过期。为了保证写操作之后的缓存一致性,我采用Cache Aside模式:更新数据库成功后删除缓存,下一次读取时再回填。

Kafka 在这个项目里主要是作为领域事件通道使用。比如交易创建成功后,交易服务会往transaction-createdtopic 里发一条消息,预算服务和报表服务都订阅这个 topic,各自消费并更新自己的数据。这种模式让我可以独立扩展消费能力,也方便在测试环境通过暂停消费来模拟下游故障,从而验证重试机制。

至于 Docker Compose,我用它编排本地环境里的 PostgreSQL、Redis、Kafka 和项目本身。真正上线时,我会把 Kafka 替换成托管版本,但本地开发阶段用 Docker Compose 明显降低了搭建成本。我还写了一个docker-compose.dev.yml,额外挂载了pgadmin和kafdrop两个辅助工具,前者方便查看数据库表内容,后者可以直观看到 topic 里的消息内容,排查问题时很有帮助。

3. 数据模型与业务规则设计

3.1 从“余额字段”到“流水事实表”的演进

financial-services的数据模型经历了两次比较大的重构,第一次发生在我意识到“账户余额不应该是一个可修改的字段”的时候。早期设计确实很天真,账户表里有一个balance字段,用户每次新增一笔支出,业务层就直接做一次balance = balance - amount的减法。这个方案在前 100 笔数据时看不出来问题,但一旦涉及到转账、退款、手工纠错,你就很难说清楚账户余额是怎么变成现在这个数的了。

重构后的方案是建立一张流水表transaction_record,包含account_id、amount、direction(IN/OUT)、type(EXPENSE/INCOME/TRANSFER_OUT/TRANSFER_IN)、transaction_time、category_id等字段。账户表只保留一个latest_balance作为展示用的冗余值,它的更新逻辑由“重放流水”算法统一派生。也就是说,每当需要计算某个账户的最新余额,我可以根据该账户全部交易记录求和得到,也可以定期用快照加增量流水的方式计算。这套逻辑参考了银行对账单的设计思路:流水是事实,余额是视图。

这种设计带来的直接收益是排查成本大幅下降。一个用户投诉说余额不对,我可以直接按时间倒序拉出这个账户的流水,逐步手工推演,而不是去翻业务代码猜哪个 update 语句覆盖了之前的更新。

3.2 预算模型:按月周期与分类约束的设计细节

预算模块是financial-services里容易被人忽略但实际很考验细节的部分。预算表的核心字段包含user_id、category_id、budget_month(口径为YYYY-MM)、limit_amount和used_amount。

麻烦的是used_amount的更新方式。如果每发生一笔支出就去累加used_amount,那么遇到用户修改交易类型或删除交易的时候就会出问题。比如用户把一笔原本属于“餐饮”的交易改成“交通”,那么餐饮预算的used_amount要减少,交通预算的要增加,如果只靠简单的更新逻辑很容易漏掉一种情况。

我最终采用的方式是:预算服务不直接保存used_amount的实时累计值,而是每天凌晨通过离线任务汇总当月的分类支出,生成一个汇总快照;同时实时消费交易创建事件,对当天的支出增量做原子累加更新。这个方案虽然多了一次离线任务,但至少保证了月末对账的准确性。具体实现上,我用了一个budget_schedule表,记录每个预算周期最后汇总到的cursor_date,避免重复统计。

3.3 最终一致性:本地消息表与事件重放机制的配合

既然有异步消费,就会遇到数据一致性的经典问题:本地事务已经提交,但发送 Kafka 消息的时候失败怎么办?又或者消息被消费者成功消费,但消费者在更新自己的数据库时失败了怎么办?

我的做法是使用本地消息表。在交易服务里,transaction_record和domain_event两张表在同一个数据库里,并且同时被同一个本地事务包裹。只有流水记录成功写入,事件才会被标记为待发送。后台有一个定时任务,每 5 秒扫描一次domain_event表里status = PENDING的记录,把事件发送到 Kafka,收到确认后把状态更新为SENT,如果超时则重新发送。消费者的处理逻辑是幂等的,所以同一个事件被发送两次甚至多次也不会产生重复的预算统计。

这套方案的好处是逻辑直白,不依赖分布式事务框架,也和 Java 生态中的 Spring Boot 兼容性很好。坏处是需要自己维护事件表中的数据,时间久了会有积压,所以务必给事件表加上created_at和send_count两个字段,定时任务做数据清理时就会按这两个字段判断。

4. 实操过程:从 API 到数据库的一个完整交易入账实现

4.1 交易接口的代码骨架与 Controller 层设计

这里直接展示一个“创建支出交易”的接口骨架。先看 Controller 层的核心代码:

@RestController @RequestMapping("/api/v1/transactions") public class TransactionController { private final CreateTransactionService createTransactionService; public TransactionController(CreateTransactionService createTransactionService) { this.createTransactionService = createTransactionService; } @PostMapping public ResponseEntity<TransactionResponse> createExpense(@RequestBody @Valid CreateTransactionCommand command, @RequestHeader("X-User-Id") String userId) { return ResponseEntity.ok(createTransactionService.createExpense(command, userId)); } }

很多初学者最容易忽略的地方是用户身份来源。我在做这个项目时没有引入完整的 Spring Security,而是先通过网关解析 JWT,把用户 ID 放在请求头X-User-Id里。Controller 层拿到这个头之后,会在 Service 层里校验这个用户是否有权操作目标账户。这种方式在微服务划分下比较实用,因为每个服务之间不需要关心登录态的完整细节,只要信任上游网关传过来的请求头即可。

如果你把financial-services当成一个对外提供 REST API 的开源项目来使用,我建议你把X-User-Id的校验逻辑抽成一个自定义注解 + HandlerInterceptor,否则每个 Controller 方法里都要手动写String userId参数,代码会很冗余。

4.2 Service 层如何保障“流水与事件”同生共死

Service 层是整个交易入账的核心,因为要同时保证“写流水”和“写事件”在同一个本地事务里。下面是一个简化版实现,重点在事务注解和幂等字段上。

@Service public class CreateTransactionService { private final TransactionRepository transactionRepository; private final DomainEventRepository domainEventRepository; private final IdempotentCache idempotentCache; @Transactional(rollbackFor = Exception.class) public TransactionResponse createExpense(CreateTransactionCommand command, String userId) { String idempotencyKey = command.getIdempotencyKey(); if (idempotentCache.exist(idempotencyKey)) { throw new DuplicateRequestException("重复请求"); } // 1. 校验账户归属 AccountSnapshot account = accountClient.getAccount(command.getAccountId(), userId); if (!account.isActive()) { throw new AccountInactiveException("账户状态不可用"); } // 2. 构建并保存交易流水 TransactionRecord record = TransactionRecord.createExpense( command.getAccountId(), command.getAmount(), command.getCategoryId(), command.getDescription() ); transactionRepository.save(record); // 3. 构建并保存领域事件 DomainEvent event = DomainEvent.fromTransaction(record); domainEventRepository.save(event); // 4. 记录幂等键 idempotentCache.record(idempotencyKey, record.getId(), Duration.ofMinutes(30)); return TransactionResponse.from(record); } }

你可能注意到了,我在同一个事务里既操作了数据库也操作了 Redis 的幂等键。这里有一个比较微妙的点:如果 Redis 写入成功了但数据库回滚了,那么幂等键就会“误伤”下一次请求,导致用户重试时收到重复请求错误。所以我的做法是把幂等键的写入放到事务提交之后,通过TransactionSynchronizationManager.registerSynchronization回调来完成。上面这个简化代码为了展示方便写在一起,你真正实现时可以调整。

4.3 持久化层与建表语句中的关键约束

持久层我用 Spring Data JPA,但个人建议在写实体类时不要直接使用@ManyToOne关联查询,因为跨服务边界的实体关系容易产生隐式查询。我的做法是每个实体类只保留外键 ID,比如交易记录里只有accountId,而不是整个 Account 对象。这样方便服务解耦。

表结构定义中有一个约束值得单独说明:交易表的transaction_no字段必须是全局唯一。它是每笔交易的外部标识,可以由调用方传入,也可以由服务生成。如果是通过导入账单产生的交易,我通常把账单号、交易日期、金额拼起来做 MD5,作为transaction_no。这样做的好处是在重复导入同一个账单的时候,可以直接通过唯一索引拦截,减少不少后续手工过滤的工作。

CREATE TABLE transaction_record ( id BIGSERIAL PRIMARY KEY, transaction_no VARCHAR(64) NOT NULL, account_id BIGINT NOT NULL, amount NUMERIC(12,2) NOT NULL, direction VARCHAR(8) NOT NULL, transaction_type VARCHAR(16) NOT NULL, category_id BIGINT, transaction_time TIMESTAMP NOT NULL, description VARCHAR(255), ext JSONB, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT uk_transaction_no UNIQUE (transaction_no) );

我给transaction_time建了索引,因为用户最常做的操作就是按时间范围查询流水。分类统计和大额支出查询时,我会在category_id和amount上建联合索引。实际操作中,索引宁缺毋滥,毕竟个人项目的数据量不会太大,但过早的优化会让整个表结构变得笨重。

5. 常见问题与排查技巧实录

5.1 幽灵流水:并发重复提交的幂等处理与 Redis 误伤排查

我在上线第一周就碰到了“幽灵流水”问题。用户快速点击两次提交按钮,结果创建了两笔一模一样的消费记录。这其实就是没有做幂等导致的。

解决思路是引入幂等键。用户在创建交易前,先从后端获取一个clientToken,之后每次提交都带上这个 token。后端收到请求后先检查 Redis 中是否存在该 token,如果存在就拒绝重复请求,如果不存在就继续执行。这里有一个非常容易踩的坑:如果在事务内部写入幂等键,事务回滚会导致 token 残留。所以我后来专门把一个IdempotentFilter放在了事务外层来做两层检查,第一层在 Controller 入口快速拦截,第二层在 Service 内部用事务内的数据做最终校验,防止并发穿透。

排查时还有一个思路:在日志里同时打印transaction_no和idempotency_key,一旦发现同样的transaction_no出现两次,就可以快速定位是“没走幂等逻辑”还是“幂等键生成规则有缺陷”。

5.2 预算超支却查不到:时间窗口和时区问题

预算模块上线的第二个星期,有用户反馈说“我 3 月 1 日早上消费了,为什么 3 月的预算统计没把它算进去”。最终发现原因是时区。用户的账单导入时间默认用的是服务器所在的 UTC 时间,但用户实际消费发生在北京时间 3 月 1 日凌晨,服务器时间还是 2 月 28 日晚上。

这个问题并不难解决,但很容易被忽略。我的做法是:交易表中增加一个local_transaction_time字段,保存用户当地的消费时间,所有关于预算和报表的统计都以这个字段为准。另外,在创建预算周期时,也明确按月周期的起点是“用户所在时区的每月 1 日凌晨”。如果你在做其他国际化项目,记得时刻把“数据库时间”、“服务器时间”和“业务时间”区分开。

5.3 Kafka 消费积压导致的“账实不符”

用 Kafka 作为异步事件通道之后,我曾遇到过最典型的问题是:某次数据库连接池配置不当,导致报表服务消费事件的速度远低于交易服务生产事件的速度,积压了十几万条消息。用户去看报表时数据慢了差不多半天,于是产生“账实不符”的错觉。

排查步骤分三步:先查kafka.consumerLag 指标,确认哪些消费者组积压;再查消费者的日志,看主要耗时点在哪里;最后针对耗时点做优化,比如把批量处理从单条改成批量。实施批量消费时要注意幂等性,因为 Kafka 在批量提交 offset 时如果失败,有可能会重放一部分已处理的事件。我在消费端使用transaction_no作为去重依据,对“重复事件”直接跳过,才彻底解决了这类问题。

如果你不想在生产环境里引入 Kafka,建议使用 Redis Stream 来替代。它提供消费者组和 pending list 机制,在低并发场景下的运维成本更低,是个人项目非常好的过渡方案。

6. 影响范围与后续扩展方向

6.1 从个人记账工具到家庭财务管理

目前financial-services只支持一个用户独立管理自己的多个账户。如果想把它扩展到家庭场景,需要引入“家庭组”概念,让多个成员共享账户但各自拥有独立的预算观察视图。之前停掉的家庭功能应该重新提上日程,也就是设计household_member表和成员角色字段。不过我的建议是不要一开始就把权限模型做得太重,先支持“创建家庭组、邀请成员、成员可见全部账户”这种简单模式,等用户习惯后再增加“只读成员”和“独立预算”等细分权限。

这种扩展不会破坏现有交易和账户模型,因为底层的交易记录和账户都存在核心域里,家庭模式只是在上面加了一个聚合根。唯一要注意的是所有查询都必须带上household_id,不然会出现跨家庭的数据泄露风险。

6.2 金融级安全的进一步改造空间

作为个人项目,financial-services现在的安全等级只是一个可运行的水平,还不配叫“金融级”。如果真的把它放到生产环境,还有几个地方需要补:一是敏感数据至少要字段级别的加密存储,包括账户卡号、用户手机号;二是接口需要增加更细粒度的限流与风控规则,比如单用户每天最多创建多少笔交易,单笔最大金额限制,异常时间段的交易进行二次验证;三是审计日志必须完整记录谁在什么时间修改了哪条交易数据,而且这些日志不能只存在普通日志文件中,要入数据库并保留至少一年。

这些改造说起来容易,做起来很花时间。我建议按照“先加密、再审计、最后风控”的顺序逐渐完善,不要一次性把所有功能都加上,否则项目很容易再次陷入“什么都有,但什么都没做好”的状态。

6.3 给同样在自研财务服务的开发者的三条建议

第一,先把流水模型做好,再往上堆功能。很多人一开始就想着做漂亮的图表和报表,但底层数据模型一团糟,后面所有统计功能都会在同一处地方反复返工。第二,为每一笔交易保留一个“原始数据字段”,哪怕是 JSONB 或者一个简单的字符串,都会让你排查问题时省一半力气。第三,同步转异步的时候一定要先想清楚幂等方案,而不是先追求消息队列带来的架构优越感,否则消费重放和重复统计会让你在凌晨三点被自己的报警电话吵醒。

写在最后的一个实操经验

如果这篇文章只能让你记住一段内容,我希望是这句话:financial-services这类项目的复杂度从来不在“增删改查”本身,而在“如何在数据丢失和重复之间找到一条可接受的一致性路线”,以及“如何让自己在一个月以后还能看得懂当初的业务规则”。

我个人在整个项目里最有成就感的一刻,不是系统第一次通过压力测试,而是我终于把一笔 0.01 元的微信红包余额偏差排查清楚,最后发现是导入时没有对“收入”和“支出”的类型前缀做统一规范化。从此我在所有涉及金额字段的入口都强制加了一个类似MoneyNormalizer的组件,专门处理金额舍入、正负号和币种转换。你也应该在项目早期就做这样的公共层,而不是等到累计了上百个接口之后再统一重构。

以上是financial-services从设计到实现再到踩坑的一条完整记录。如果你也正在做类似的东西,建议从最小的流水模型开始,先跑通一两个核心接口,再逐步加入事件和报表。希望这些经验能让你少走几段弯路。

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

宠物猫狗识别检测数据集:3947张xml与yolo双格式实战指南

简介&#xff1a;这份宠物猫狗识别检测数据集面向从事深度学习目标检测的科研人员、学生与工程开发者&#xff0c;用于训练和验证猫狗目标检测模型&#xff0c;解决宠物识别场景中样本不足、标注不规范的问题。资源包共2000个文件&#xff0c;包含3947张jpg图像、3947个xml标注…

作者头像 李华
网站建设 2026/9/26 9:40:53

Chat2DB 本地部署与 AI 写 SQL 实战:多数据源统一客户端避坑指南

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

作者头像 李华
网站建设 2026/9/26 9:38:30

JMeter 5.6.2 压测实战:环境搭建、脚本配置与避坑指南

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

作者头像 李华