简介这份资源面向想入门推荐系统、又希望快速跑通完整项目的 Java 开发者用 SpringBoot 搭建服务层、Apache Mahout 提供协同过滤算法基于用户对电影的评分数据完成相似用户或相似物品的挖掘与推荐适合作为课程设计、毕业项目或技术选型的实践样例。压缩包共 18 个文件约 24.15MB以 8 个 Java 源码文件为核心配合 4 个 CSV 评分与书籍数据集、pom.xml 依赖配置、properties 配置文件及 Maven 包装脚本覆盖从数据加载、算法调用到 REST 接口输出的完整链路。已有 617 人学习下载说明其在同类入门项目中具备一定参考度。读者可据此理解基于用户与基于物品两类协同过滤的差异掌握 Mahout API 与 SpringBoot 的集成方式并借助现成数据集快速验证推荐结果省去从零搭建环境与准备数据的时间。1. 从一张评分表说起这套推荐引擎到底解决什么问题假设你手里有一份用户对电影的评分数据几千个用户、几百部电影、几万条 1 到 5 分的打分记录。产品经理的需求很朴素给每个用户推荐他没看过、但大概率会喜欢的电影。听起来像是个算法题实际落地时你会发现真正的难点不在算法本身而在于怎么把「读数据、算相似度、出推荐、对外提供接口」这条链路搭成一个能跑、能改、能验收的 Java 工程。这也是很多推荐系统毕设卡住的地方——算法跑通了工程却散成一堆脚本。这套方案用 SpringBoot 做服务骨架、Apache Mahout 做协同过滤内核把基于用户评分数据的电影推荐做成一个标准的分层后端项目。它适合两类人正在做推荐系统毕设或课程设计、需要一套结构完整可交付工程的学生以及要快速搭一个推荐服务原型、后续再替换算法内核的开发者。下面按需求、设计、实现、避坑、验收的顺序把这份方案拆开讲清楚。2. 需求与技术选型为什么是 SpringBoot 加 Mahout 这套组合2.1 先把功能需求钉死别一上来就谈算法推荐系统最容易翻车的地方是需求边界没定清楚就开始调参。我一般会先把需求拆成四层每层对应一个可验收的交付物。第一层是数据层系统要能导入用户-电影-评分三元组支持 CSV 批量导入评分范围限定 1 到 5同一用户对同一电影重复评分以最后一次为准。第二层是计算层基于用户评分计算用户之间的相似度找出目标用户的 K 个最近邻聚合邻居评分预测目标用户对未看过的电影的偏好分。第三层是服务层对外提供「给指定用户推荐 N 部电影」和「查询某部电影相似电影」两类接口。第四层是展示层一个简单的页面或接口文档能输入用户 ID 看到推荐结果。这四层定下来技术选型其实就没多少悬念了。数据层用 MySQL 存评分明细计算层用 Mahout 的协同过滤实现服务层用 SpringBoot 的 REST 接口展示层用最轻量的模板或接口调试页。需求先立住后面每一章才有验收依据。2.2 选型对比Mahout 与手写协同过滤的取舍很多人会问协同过滤算法本身不难为什么不自己手写一个余弦相似度就完事这里有个血泪经验手写一版 demo 确实两小时能跑通但一旦数据量上去、要处理稀疏矩阵、要做相似度缓存、要支持多种相似度度量代码量会迅速膨胀而且很难保证数值稳定性。Mahout 的价值在于它把这些都封装好了UserSimilarity、UserNeighborhood、Recommender三层抽象直接对应协同过滤的标准流程换相似度度量只需要改一个实现类。维度手写协同过滤Apache Mahout开发成本低小数据到高工程化中接口固定相似度度量需自己实现内置余弦、皮尔逊、欧氏等稀疏数据处理需自行处理框架内已考虑可替换性差好面向接口学习价值高适合理解原理中适合工程落地选 Mahout 的另一个理由是它和 SpringBoot 的集成成本低。Mahout 的核心计算是纯 Java 库调用不需要额外部署计算集群直接作为 Maven 依赖引入在 Service 层组装DataModel和Recommender即可。对于毕设和原型场景这个组合的性价比明显高于自己造轮子也高于引入需要独立集群的分布式方案。2.3 数据模型设计评分表是整个系统的地基推荐质量的上限由数据决定所以表结构要一开始就设计对。核心表只有一张评分表外加用户表和电影表做展示关联。CREATE TABLE movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, genre VARCHAR(64), release_year INT ); CREATE TABLE app_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE ); CREATE TABLE rating ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, score DECIMAL(2,1) NOT NULL, -- 1.0 ~ 5.0 rated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_movie (user_id, movie_id), KEY idx_movie (movie_id) );rating表上的唯一索引uk_user_movie是必须的它保证同一用户对同一电影只有一条有效评分导入时用INSERT ... ON DUPLICATE KEY UPDATE覆盖旧值。idx_movie索引服务于「查某部电影的评分列表」这类反向查询。评分字段用DECIMAL(2,1)而不是整数是为了给后续可能的半星评分留余地Mahout 的GenericDataModel接受float类型的偏好值读取时做一次类型转换即可。提示评分数据导入前一定要做一次清洗去掉评分为 0、负数、超过 5 的记录以及用户或电影 ID 不存在的孤儿记录。脏数据进模型推荐结果会以很难排查的方式失真。3. 总体架构设计四层结构与核心计算链路3.1 分层结构Controller、Service、Recommender、DataModel整个工程按标准 SpringBoot 分层组织但推荐计算这一块要单独抽出来不能和业务 Service 混在一起。我的做法是分四层controller层只做参数校验和响应封装service层负责业务编排比如「先查用户是否存在再调推荐器」recommender包放 Mahout 相关的组装逻辑包括数据模型构建、相似度配置、推荐器实例管理repository层用 MyBatis 或 JPA 访问数据库。这样分层的好处是Mahout 的 API 全部收敛在recommender包里将来如果要换成别的算法内核只需要替换这个包Controller 和业务 Service 不用动。很多毕设项目把UserSimilarity直接写在 Controller 里结果接口和算法耦合死答辩时被问「怎么换算法」就答不上来。3.2 核心计算链路从评分表到推荐列表的五步推荐请求进来后实际发生的是这样一条链路根据用户 ID 从rating表拉取全量评分数据构建 Mahout 的DataModel用DataModel初始化UserSimilarity指定相似度度量用相似度构建UserNeighborhood确定最近邻范围用相似度和邻域构建Recommender调用recommend(userId, n)拿到推荐结果再回表补全电影标题等信息。这条链路里有两个性能敏感点DataModel的构建和Recommender的初始化。如果每次请求都重新构建数据量一大接口就会明显变慢。通常的做法是把DataModel做成单例 Bean启动时加载一次评分数据变更时通过定时任务或手动接口触发重建。这个设计决策在答辩时是个加分项因为它体现了对「计算密集与 IO 密集分离」的理解。3.3 接口设计两个核心端点加一个管理端点对外接口保持精简三个端点足够覆盖演示和验收RestController RequestMapping(/api/recommend) public class RecommendController { private final RecommendService recommendService; public RecommendController(RecommendService recommendService) { this.recommendService recommendService; } // 给指定用户推荐 N 部电影 GetMapping(/user/{userId}) public ListMovieRecommendVO recommendForUser( PathVariable Long userId, RequestParam(defaultValue 10) int size) { return recommendService.recommendForUser(userId, size); } // 查询与指定电影相似的电影 GetMapping(/similar/{movieId}) public ListMovieRecommendVO similarMovies( PathVariable Long movieId, RequestParam(defaultValue 10) int size) { return recommendService.similarMovies(movieId, size); } // 触发数据模型重建评分数据批量导入后调用 PostMapping(/model/rebuild) public MapString, Object rebuildModel() { return recommendService.rebuildDataModel(); } }size参数做了默认值处理避免调用方不传时返回全量结果。rebuild端点单独暴露是因为评分数据导入是低频批量操作没必要每次导入都自动重建模型手动触发更可控。返回值统一用 VO 包装把 Mahout 返回的RecommendedItem只有 itemId 和 value补全成带电影标题、类型的对象前端才能直接展示。4. 核心模块实现数据模型、相似度与推荐器组装4.1 DataModel 构建从数据库到 Mahout 的桥Mahout 不认识 MySQL它只认DataModel接口。最常用的实现是GenericDataModel用FastByIDMap组织用户偏好。构建逻辑放在一个Component里启动时加载。Component public class MovieDataModelHolder { private final RatingRepository ratingRepository; private volatile DataModel dataModel; public MovieDataModelHolder(RatingRepository ratingRepository) { this.ratingRepository ratingRepository; } // 双重检查锁保证模型只构建一次 public DataModel getDataModel() { if (dataModel null) { synchronized (this) { if (dataModel null) { dataModel build(); } } } return dataModel; } public synchronized DataModel rebuild() { dataModel build(); return dataModel; } private DataModel build() { ListRating ratings ratingRepository.findAll(); FastByIDMapPreferenceArray userData new FastByIDMap(); MapLong, ListRating grouped ratings.stream() .collect(Collectors.groupingBy(Rating::getUserId)); for (Map.EntryLong, ListRating entry : grouped.entrySet()) { ListRating list entry.getValue(); PreferenceArray prefs new GenericUserPreferenceArray(list.size()); for (int i 0; i list.size(); i) { Rating r list.get(i); prefs.setUserID(i, r.getUserId()); prefs.setItemID(i, r.getMovieId()); prefs.setValue(i, r.getScore().floatValue()); } userData.put(entry.getKey(), prefs); } return new GenericDataModel(userData); } }这段代码的关键点有三个。第一按用户分组后构建GenericUserPreferenceArray这是 Mahout 用户基协同过滤的标准数据结构比逐条setPreference效率高得多。第二volatile加双重检查锁保证并发下模型只构建一次rebuild方法加synchronized保证重建时不会读到半成品。第三setValue传入的是float评分表里的DECIMAL要先转float精度损失在 1 到 5 的评分区间内可以忽略。注意GenericDataModel是全内存结构评分数据超过百万级时要评估堆内存。毕设规模的数据通常没问题但如果要演示大数据量建议在启动参数里把-Xmx调到 2G 以上并在文档里写明这个约束。4.2 相似度与邻域配置参数怎么定才不玄学相似度度量和邻域大小是协同过滤里最容易被问、也最容易调错的参数。Mahout 提供PearsonCorrelationSimilarity、EuclideanDistanceSimilarity、CosineSimilarity等实现。电影评分场景我一般首选皮尔逊相关系数因为它对用户的评分尺度差异不敏感——有人习惯打高分、有人习惯打低分皮尔逊能抵消这种偏移。Service public class RecommenderFactory { private final MovieDataModelHolder modelHolder; public RecommenderFactory(MovieDataModelHolder modelHolder) { this.modelHolder modelHolder; } public UserBasedRecommender createUserBased() throws TasteException { DataModel model modelHolder.getDataModel(); // 皮尔逊相似度对评分尺度差异更鲁棒 UserSimilarity similarity new PearsonCorrelationSimilarity(model); // 最近邻取 20 个样本太少时降级为全量 UserNeighborhood neighborhood new NearestNUserNeighborhood(20, similarity, model); return new GenericUserBasedRecommender(model, neighborhood, similarity); } }邻域大小20不是拍脑袋定的。太小比如 5会导致推荐覆盖面窄、容易过拟合到少数邻居太大比如 100会把弱相关用户也拉进来推荐精度下降。经验做法是从 10 到 30 之间做几组对比实验用离线指标选一个。这里有个玄学点数据稀疏时皮尔逊相似度可能算出NaN因为两个用户共同评分的电影太少方差为零。Mahout 的PearsonCorrelationSimilarity有个重载构造可以传Weighting配合SignificanceWeighting能缓解这个问题答辩时能讲清楚这一点会显得很扎实。4.3 推荐结果组装从 itemId 到可展示的电影信息Mahout 返回的RecommendedItem只有itemId和value直接返回给前端体验很差。Service 层要做一次回表补全并且要处理「推荐结果为空」的情况。Service public class RecommendService { private final RecommenderFactory recommenderFactory; private final MovieRepository movieRepository; public RecommendService(RecommenderFactory recommenderFactory, MovieRepository movieRepository) { this.recommenderFactory recommenderFactory; this.movieRepository movieRepository; } public ListMovieRecommendVO recommendForUser(Long userId, int size) { try { UserBasedRecommender recommender recommenderFactory.createUserBased(); ListRecommendedItem items recommender.recommend(userId, size); if (items.isEmpty()) { // 冷启动兜底返回评分最高的热门电影 return movieRepository.findTopRated(size); } ListLong movieIds items.stream() .map(RecommendedItem::getItemID) .collect(Collectors.toList()); MapLong, Movie movieMap movieRepository.findByIds(movieIds) .stream().collect(Collectors.toMap(Movie::getId, m - m)); return items.stream().map(item - { Movie movie movieMap.get(item.getItemID()); MovieRecommendVO vo new MovieRecommendVO(); vo.setMovieId(item.getItemID()); vo.setTitle(movie ! null ? movie.getTitle() : 未知电影); vo.setScore(item.getValue()); return vo; }).collect(Collectors.toList()); } catch (TasteException e) { throw new RecommendException(推荐计算失败: e.getMessage(), e); } } }这里有两个设计说明。第一recommend抛出的TasteException是受检异常必须捕获后转成业务异常否则会污染接口层。第二冷启动兜底很重要——新用户没有评分记录时协同过滤算不出结果直接返回空列表会让演示很尴尬。兜底策略用「评分最高的热门电影」虽然简单但能保证接口永远有返回这是我在实际项目里踩过坑之后加上的。5. 避坑与排查五个最容易翻车的点5.1 推荐结果全是同一批电影现象是不同用户请求推荐返回的电影高度重合。原因通常是邻域太大或者相似度度量选错导致所有用户都被归到同一批「大众邻居」上。解决办法是把NearestNUserNeighborhood的 N 从 20 降到 10 左右同时检查相似度实现是否用了EuclideanDistanceSimilarity这类对绝对分值敏感的度量电影评分场景换成皮尔逊通常能改善。5.2 接口第一次调用特别慢后面正常现象是服务刚启动后第一个推荐请求要等好几秒。原因是DataModel懒加载第一次请求才触发全量评分读取和模型构建。解决办法是在PostConstruct里预热一次模型或者把getDataModel的首次构建放到启动流程里。这个坑很典型答辩演示时如果第一个请求卡住观感很差。5.3 评分数据更新后推荐结果不变现象是往rating表插了新数据推荐结果还是旧的。原因是DataModel是内存单例不会自动感知数据库变化。解决办法是导入数据后显式调用/api/recommend/model/rebuild或者在导入任务末尾加一步重建。这个设计是有意为之自动重建会让批量导入变得很慢手动触发更可控。5.4 相似电影接口报数组越界现象是调用/similar/{movieId}时抛ArrayIndexOutOfBoundsException。原因是ItemSimilarity在计算时要求 item ID 在数据模型里存在传了一个模型里没有的电影 ID 就会越界。解决办法是在 Service 层先校验movieId是否在DataModel的 item 集合里不存在直接返回空列表并给出提示不要让异常穿透到接口层。5.5 皮尔逊相似度返回 NaN 导致推荐为空现象是某些用户明明有评分推荐结果却是空。原因是这些用户与其他用户的共同评分项太少皮尔逊相关系数分母为零算出NaNMahout 内部过滤掉了这些邻居。解决办法是给相似度加SignificanceWeighting对共同评分项少的用户对降权而不是直接丢弃或者在邻域构建时设置最小共同评分数阈值低于阈值的用户走热门兜底。6. 验收与答辩怎么证明这套推荐引擎真的能用验收这件事我一般分三步走先验数据链路再验算法效果最后验接口健壮性。数据链路验收最简单导入一份已知的评分 CSV查数据库确认条数、去重、评分范围都对再调一次重建接口看日志里模型加载的用户数和电影数是否与数据库一致。这一步能挡住大部分「数据没进去但代码看起来没问题」的情况。算法效果验收需要一点技巧。毕设答辩时导师常问「你怎么证明推荐是准的」这时候不能只说「跑出来了」。通常的做法是留出一部分评分做离线评估从rating表随机抽 20% 作为测试集用剩下的 80% 训练对测试集里的每个用户预测其未评分电影的分数计算 RMSE 或 MAE。RMSE 在 0.8 到 1.2 之间对电影评分场景是合理区间如果超过 1.5 说明模型有问题。这个评估脚本可以写成一个独立的 JUnit 测试或命令行入口答辩时直接展示数字比口头描述有说服力得多。接口健壮性验收要覆盖边界情况我一般会准备一张检查表场景输入预期结果正常推荐有评分记录的用户 ID返回 N 部电影带标题和预测分冷启动用户无评分记录的用户 ID返回热门电影兜底列表不存在的用户非法用户 ID返回空列表或明确提示不抛 500推荐数量边界size0 或 size1000参数校验拦截或截断相似电影模型内存在的电影 ID返回相似电影列表相似电影模型内不存在的电影 ID返回空列表不抛异常答辩时还有一个高频问题「你的系统和直接用 Mahout 命令行有什么区别」回答的落点应该在工程化上——命令行工具是一次性计算这套方案把数据模型、相似度配置、推荐器生命周期都做成了可管理的 Spring Bean接口可复用、模型可重建、异常有兜底这是从脚本到服务的差别。把这句话讲清楚基本就稳了。从那以后我每次做推荐类项目都会在写第一行业务代码之前先把「数据怎么进、模型怎么建、结果怎么出、空了怎么办」这四个问题在文档里写死再动手。这套习惯帮我省掉了大量返工。希望这份方案能帮到你照着这个结构搭毕设和原型交付都能少走弯路。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
电商限额系统设计:Redis与分布式锁实践 1. 用户限额系统设计背景与核心挑战在电商和虚拟商品交易场景中,用户购买限额控制是一个常见但极具挑战性的需求。最近我负责设计了一个通用限额系统,用于限制用户对特定商品的购买次数。这个系统需要支持多种维度的限额控制,包括用户ID、设备… · 2026/11/5 4:46:54
机器人端到端学习实现客厅自主整理技术解析 1. 项目概述:当机器人学会整理客厅上周在实验室里,我亲眼目睹了这样一幕:一台配备机械臂的移动机器人,在完全无人干预的情况下,用了23分钟将散落着玩具、书籍和零食的15平米客厅恢复到整洁状态。这背后是英伟达最新投资… · 2026/11/5 4:46:54
AI与混合现实融合的技术挑战与优化实践 1. AI驱动混合现实应用的核心挑战解析混合现实(MR)技术正在重塑人机交互的边界,而AI的融入让这个领域呈现出指数级增长的复杂性。作为从业者,我经历过从传统AR开发转向AI增强型MR系统的完整转型周期,深刻体会到这个交叉… · 2026/11/5 4:46:54
360度评估工具计费方式解析:四种口径、四个必问项与成本折算方法 更新时间:2026 年 10 月摘要
360度评估工具的计费方式可归纳为四种口径。选择哪种取决于企业用量的形状——被评人数、年度轮次与评价人构成三者共同决定成本结构。本文给出四种口径的适用边界、采购阶段必须确认的四项条款,以及用人力成本折算投入产出的… · 2026/11/5 5:31:08
稀土功能纤维的制备原理与功能特性 稀土在很多人印象中,是用于永磁电机、催化剂、激光晶体的工业原料。但近两年,稀土正在以一种更贴近生活的方式进入日常——功能纤维。稀土如何“织”进衣服里稀土功能纤维的制备,并不是简单地把稀土粉末混入纺丝液。以包头稀土研究院发布的“… · 2026/11/5 5:31:08
10-03-互操作-JobSystem与GC隔离 JobSystem 与 GC 隔离篇章:10-核心技术-托管互操作篇 | 阅读时间:约 35 分钟一、引言Unity Job System 要求 Job 中不能访问托管对象。这不是一个"推荐"规则,而是一条硬性约束——违反会导致运行时错误(InvalidOperatio… · 2026/11/5 5:31:08
从一家奶茶店讲透扫码登录:手机怎么让电脑登录 目录 一、扫码登录要解决的真问题 二、完整流程:从生成二维码到换取登录态 三、动手写一个能跑的扫码登录后端 四、为什么是长轮询,而不是 WebSocket 五、一次领取:三层保护缺一不可 六、状态机:pending 到 confirmed 到 con… · 2026/11/5 5:31:08
LInux | sudo 与 su 操作 sudo 与 su 操作手册
目录
sudoers 配置sudo 常用操作sudo 安全机制sudo 高级用法安全规范故障排查同类工具对比su、sudo 环境与 PATH 行为 1 sudoers 配置
sudo 的权限控制依托 /etc/sudoers,文件默认权限 440。
不直接使用 vi、vim 编辑,语法错误会… · 2026/11/5 5:31:08
焊接还是压接?USB、DC 线焊点虚焊的判定与工艺边界 焊点是冶金连接,压接是冷变形机械连接,失效模式不同:压接坏了是电阻慢慢爬、通电发热;焊点坏了常常一次震动就断,而静态通电还是通的。判焊点好坏,第一判据是润湿角,不是「亮不亮」。大电流、强… · 2026/11/5 5:30:08
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38