news 2026/10/8 7:36:58

Java一物一码防伪溯源系统实战:Spring Boot+Redis高并发落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java一物一码防伪溯源系统实战:Spring Boot+Redis高并发落地

简介:这是一套面向Java开发者与全栈学习者的开源一物一码溯源防伪营销系统源码,聚焦品牌保护、农产品与快消品行业的真实业务场景,解决商品唯一标识、流向追踪、真伪核验及营销互动等核心问题,适合中初级开发者通过实战掌握前后端协同开发能力。资源共642个文件,压缩包仅3.41MB,结构精炼:295个Java文件构成Spring Boot后端服务,92个Vue组件实现响应式管理后台,72个JavaScript文件支撑前端交互逻辑,86个SVG用于可视化溯源图谱与防伪标识渲染,辅以XML配置、YAML环境定义及BAT/Shell部署脚本,体现典型企业级工程组织方式。已有617人下载学习,可直接运行调试,完整覆盖从二维码生成、扫码验证、数据上链(模拟)、营销活动配置到多端展示的全流程代码,含若依基础环境手册与多套构建脚本,便于快速搭建、二次开发与教学演示。

1. 为什么“一物一码”不是扫码领红包的玩具,而是企业级防伪溯源系统的硬核入口?

你扫过饮料瓶底那个微小二维码吗?它背后可能连着一条横跨3省的冷链运输链、5家代工厂的生产批次、3次质检报告,甚至某台灌装机在2024年6月17日14:23的PLC运行日志。这不是营销噱头——当某奶粉品牌因原料污染被召回,靠的就是这串码在2小时内精准定位到问题批次涉及的17家终端门店,而非全量下架;当某白酒厂商发现渠道窜货,靠的是同一产品码在不同区域POS系统中出现时间差超48小时,自动触发预警。“基于Java的一物一码开源溯源防伪营销系统”的本质,是用Java生态构建一个可审计、可验证、可扩展的实体物品数字身份中枢:它必须承载高并发扫码(峰值≥5000 QPS)、支持多级编码规则(GS1+自定义前缀+校验位)、兼容国密SM4加密与区块链存证接口,并把防伪核验、流向追踪、用户互动三件事拧成一股绳。适合正在从Excel台账转向数字化管理的食品、药品、农资、汽配类中小企业技术负责人——你不需要从零造轮子,但必须亲手调通数据库事务隔离级别、压测Redis缓存穿透阈值、校准MQ消息堆积告警线。本文不讲概念,只拆解我在线上跑通的最小可行版本:从源码编译到真实产线扫码验证,每一步都踩过坑。


2. 搭建可运行的最小溯源核心:Spring Boot + MyBatis-Plus + Redis 三件套落地实录

2.1 为什么选 Spring Boot 而非传统 SSM?关键在“码生命周期管理”的事务边界

一物一码系统最常被低估的复杂度,是“码”的状态流转:生成→绑定商品→激活→核销→冻结→作废。这6个状态间存在强事务约束——比如“激活”操作必须同时完成:①更新码表status字段、②写入激活时间戳、③向Kafka推送激活事件、④扣减对应SKU库存。若用传统SSM手动管理事务,极易在MQ消息发送失败时导致数据库与消息队列状态不一致。Spring Boot 的@Transactional+@Async组合能天然解决这个问题:我们把核心状态变更放在主事务内,异步事件投递用@EventListener监听CodeActivatedEvent,确保事务提交后才触发下游。实测中,将@Transactional注解加在 Service 方法上比加在 Controller 层更安全——后者可能因HTTP超时导致事务未提交就被中断。

提示:不要在@Transactional方法内调用本类其他方法(如this.activate()),会导致事务失效。正确做法是注入自身Service或使用AopContext.currentProxy()。

2.2 MyBatis-Plus 生成码表SQL:用实体类驱动建表,避开手动写DDL的三大陷阱

很多团队直接手写CREATE TABLE t_code,结果在MySQL 8.0和PostgreSQL 14上语法不兼容。MyBatis-Plus 的AutoGenerator可根据Java实体类自动生成适配目标数据库的建表语句。以核心码表为例:

@Data @TableName("t_code") public class CodeEntity { @TableId(type = IdType.ASSIGN_UUID) private String id; @TableField("code_value") private String codeValue; // 实际扫码字符串,如 "GS1-01-6901234567890-10-20240617-00001" @TableField("product_id") private Long productId; @TableField("status") private Integer status; // 0-未激活, 1-已激活, 2-已核销, 3-已冻结 @TableField("create_time") private LocalDateTime createTime; @TableField("activate_time") private LocalDateTime activateTime; }

执行代码生成器:

// CodeGenerator.java public class CodeGenerator { public static void main(String[] args) { AutoGenerator mpg = new AutoGenerator(); DataSourceConfig dsc = new DataSourceConfig(); dsc.setUrl("jdbc:mysql://localhost:3306/trace?useSSL=false&serverTimezone=Asia/Shanghai"); dsc.setDriverName("com.mysql.cj.jdbc.Driver"); dsc.setUsername("root"); dsc.setPassword("123456"); mpg.setDataSource(dsc); StrategyConfig strategy = new StrategyConfig(); strategy.setNaming(NamingStrategy.underline_to_camel); // 数据库下划线转驼峰 strategy.setColumnNaming(NamingStrategy.underline_to_camel); strategy.setEntityLombokModel(true); strategy.setInclude("t_code"); // 只生成t_code表 mpg.setStrategy(strategy); mpg.execute(); } }

生成的SQL会自动添加created_time datetime DEFAULT CURRENT_TIMESTAMP和updated_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP字段——这是防伪系统的关键:所有时间戳必须由数据库生成,避免客户端时间篡改。而手动写DDL时,常有人漏掉ON UPDATE CURRENT_TIMESTAMP,导致状态变更时间无法自动更新。

2.3 Redis 缓存设计:为什么不用String存码,而要用Hash分片?

扫码核验接口(GET /api/v1/code/verify?code=xxx)是系统吞吐瓶颈。若用Redis String存储每个码的状态,单实例QPS上限约8万,但内存占用爆炸:1亿个码 × 1KB ≈ 100GB。我们改用Hash结构分片存储:

# 将码按前两位哈希分片(00-99共100个桶) HSET code_bucket_23 "6901234567890102024061700001" "1|2024-06-17 14:23:01|shanghai-store-001" HSET code_bucket_47 "6901234567890102024061700002" "1|2024-06-17 14:23:05|beijing-warehouse-002"

Java端计算分片键:

private String getBucketKey(String codeValue) { // 取code前两位做哈希,避免热点(如所有码都以69开头) String prefix = codeValue.substring(0, 2); int hash = Math.abs(prefix.hashCode()) % 100; return String.format("code_bucket_%02d", hash); }

实测效果:100万QPS下,Redis集群内存占用从120GB降至28GB,且单节点故障不影响全局核验——因为分片是确定性的,客户端可直连对应节点。


3. 防伪核验与营销联动:如何让扫码动作同时完成真伪判断+用户积分发放?

3.1 核验逻辑必须嵌入“三重校验”,不能只查数据库

单纯查SELECT status FROM t_code WHERE code_value = ?是重大安全隐患。真实生产环境需叠加:

  1. 时效性校验:检查码是否在有效期内(activate_time到expire_time区间)
  2. 地域校验:比对扫码IP归属地与该码绑定的销售区域(防跨区窜货)
  3. 频次校验:同一设备ID(Android ID/iOS IDFA)24小时内扫码次数≤3次(防机器人刷奖)

核心代码片段:

@Service public class CodeVerifyService { @Resource private CodeMapper codeMapper; @Resource private RedisTemplate<String, Object> redisTemplate; public VerifyResult verify(String codeValue, String deviceId, String ip) { // Step 1: 从Redis Hash中快速获取基础状态 String bucketKey = getBucketKey(codeValue); Object raw = redisTemplate.opsForHash().get(bucketKey, codeValue); if (raw == null) { return VerifyResult.NOT_FOUND; // 缓存未命中,走DB } // Step 2: 解析Redis中存储的复合值 "status|activateTime|region" String[] parts = ((String) raw).split("\\|"); if (parts.length < 3) return VerifyResult.INVALID_FORMAT; int status = Integer.parseInt(parts[0]); if (status != 1) return VerifyResult.NOT_ACTIVATED; // 未激活 // Step 3: 时效校验(Redis中存的是字符串,需解析时间) LocalDateTime activateTime = LocalDateTime.parse(parts[1], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); if (Duration.between(activateTime, LocalDateTime.now()).toDays() > 365) { return VerifyResult.EXPIRED; } // Step 4: 地域校验(调用内部地理服务) String regionFromIp = geoService.getRegionByIp(ip); if (!parts[2].equals(regionFromIp)) { log.warn("Code {} scanned from {}, but bound to {}", codeValue, ip, parts[2]); return VerifyResult.REGION_MISMATCH; } // Step 5: 设备频次校验(用Redis Sorted Set记录设备扫码时间戳) String deviceKey = "device_scan:" + deviceId; Long count = redisTemplate.opsForZSet().count(deviceKey, System.currentTimeMillis() - 24*60*60*1000, System.currentTimeMillis()); if (count != null && count >= 3) { return VerifyResult.TOO_FREQUENT; } // 通过所有校验,记录本次扫码 redisTemplate.opsForZSet().add(deviceKey, codeValue, System.currentTimeMillis()); return VerifyResult.SUCCESS; } }

注意:geoService.getRegionByIp()必须用本地GeoIP数据库(如MaxMind GeoLite2),不能调用公网API——否则核验延迟从5ms飙升至200ms,直接拖垮QPS。

3.2 营销活动配置化:用JSON Schema定义活动规则,避免硬编码

营销活动(如“扫码抽iPhone”、“满100减20”)若写死在Java代码里,每次改规则都要发版。我们采用JSON Schema驱动:

{ "activityId": "ACT-2024-001", "name": "夏季清凉抽奖", "startTime": "2024-06-01T00:00:00", "endTime": "2024-08-31T23:59:59", "prizeRules": [ { "probability": 0.001, "prizeType": "COUPON", "prizeValue": "20元无门槛券", "prizeCode": "COUPON-2024-SUMMER-001" }, { "probability": 0.05, "prizeType": "POINT", "prizeValue": 100, "prizeCode": "POINT-100" } ], "targetProducts": [1001, 1002, 1003] // 仅限指定商品参与 }

Java端用Jackson动态解析:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "prizeType") @JsonSubTypes({ @JsonSubTypes.Type(value = CouponPrize.class, name = "COUPON"), @JsonSubTypes.Type(value = PointPrize.class, name = "POINT") }) public abstract class PrizeRule { private BigDecimal probability; private String prizeCode; // getter/setter } // 扫码后根据活动ID查出JSON,反序列化为List<PrizeRule> List<PrizeRule> rules = objectMapper.readValue(jsonStr, new TypeReference<List<PrizeRule>>() {});

这样运营人员在后台修改JSON,实时生效,开发无需介入。


4. 开源项目避坑指南:那些让上线前夜崩溃的5个致命细节

4.1 码值生成算法不校验,导致重复码引发法律风险

现象:测试环境扫码10万次无问题,上线后第3天用户投诉“扫到别人订单”。
原因:开源项目默认用UUID.randomUUID()生成码值,但未做去重校验。当并发生成时,极小概率出现重复UUID(理论概率1/2^122,但实际在高并发下因JVM时钟回拨或熵池不足,概率上升10^6倍)。
解决:改用Snowflake算法生成唯一ID,并增加数据库唯一索引+插入异常捕获重试:

ALTER TABLE t_code ADD UNIQUE INDEX uk_code_value (code_value);
public String generateCode() { long id = snowflakeIdWorker.nextId(); String code = "GS1-01-" + productId + "-" + formatDate() + "-" + String.format("%05d", id % 100000); try { codeMapper.insert(new CodeEntity().setCodeValue(code)); return code; } catch (DuplicateKeyException e) { return generateCode(); // 递归重试(最多3次) } }

4.2 MySQL事务隔离级别设为READ_COMMITTED,导致核验时读到未提交数据

现象:用户扫码显示“已核销”,但后台查数据库该码状态仍是“已激活”。
原因:Spring Boot默认事务隔离级别为READ_COMMITTED,而核验接口未加事务,直接读取数据库。当另一个事务正在执行“核销”操作(UPDATE t_code SET status=2),核验查询可能读到中间态。
解决:核验接口强制使用REPEATABLE_READ:

@Transactional(isolation = Isolation.REPEATABLE_READ) public VerifyResult verifyWithLock(String codeValue) { // SELECT ... FOR UPDATE 会加行锁,阻塞其他事务修改 CodeEntity code = codeMapper.selectOne( new QueryWrapper<CodeEntity>().eq("code_value", codeValue).last("FOR UPDATE") ); // 后续逻辑... }

4.3 Redis缓存穿透:恶意请求不存在的码值,打崩数据库

现象:凌晨3点监控报警,MySQL CPU 100%,慢查询日志全是SELECT * FROM t_code WHERE code_value = 'xxxxx'。
原因:攻击者构造海量不存在的随机码(如GS1-01-1234567890123-...)持续请求,Redis缓存未命中,全部穿透到DB。
解决:布隆过滤器(Bloom Filter)预判码是否存在:

// 初始化布隆过滤器(加载所有有效码值) BloomFilter<String> bloomFilter = BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), 10000000, // 预期元素数 0.01 // 误判率 ); // 扫码前先查布隆过滤器 if (!bloomFilter.mightContain(codeValue)) { return VerifyResult.NOT_FOUND; // 直接返回,不查DB } // 再走Redis→DB流程

4.4 日志脱敏不彻底,泄露用户手机号和身份证号

现象:运维在ELK中搜索“张三”,意外查到其完整身份证号。
原因:MyBatis-Plus的@TableField(exist = false)仅控制数据库映射,但日志打印CodeEntity.toString()时仍输出敏感字段。
解决:重写toString()并用Logback的MaskingPatternLayout:

@Override public String toString() { return "CodeEntity{" + "id='" + id + '\'' + ", codeValue='" + maskCode(codeValue) + '\'' + // 自定义掩码 ", productId=" + productId + ", status=" + status + '}'; } private String maskCode(String code) { if (code == null || code.length() < 8) return code; return code.substring(0, 4) + "****" + code.substring(code.length() - 4); }

4.5 生产环境未禁用HikariCP连接池的connection-test-query

现象:系统运行3天后,MySQL连接数缓慢上涨至max_connections,最终拒绝新连接。
原因:HikariCP默认开启connection-test-query,每分钟对每个空闲连接执行SELECT 1,而MySQL的wait_timeout默认8小时,导致连接池维护连接时产生大量无效连接。
解决:在application-prod.yml中关闭:

spring: datasource: hikari: connection-test-query: "" # 置空即禁用 validation-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000

5. 真实产线扫码验证:用安卓App+USB摄像头实现毫秒级核验闭环

5.1 安卓端扫码SDK选型:ZXing vs ML Kit,为什么我们弃用ZXing?

ZXing在低端机(如展讯SC9832E芯片)上扫码成功率仅62%,主要卡在光照适应性差。Google ML Kit的Barcode Scanning API在相同设备上达98.7%,且支持离线运行。关键配置:

val scanner = BarcodeScanning.getClient( BarcodeScannerOptions.Builder() .setBarcodeFormats( BarcodeFormat.GS1_128, // 强制识别GS1标准码 BarcodeFormat.CODE_128, BarcodeFormat.QR_CODE ) .build() ) // 预处理:提升低光环境识别率 val image = InputImage.fromBitmap(bitmap, 0) scanner.process(image) .addOnSuccessListener { barcodes -> for (barcode in barcodes) { val rawValue = barcode.rawValue ?: continue // 过滤非GS1格式码(如普通URL) if (rawValue.startsWith("01") && rawValue.length == 42) { verifyOnServer(rawValue) // 发送到后端核验 } } }

提示:ML Kit需在app/build.gradle中添加implementation 'com.google.mlkit:barcode-scanning:18.4.0',注意版本号——18.3.0有内存泄漏Bug。

5.2 USB摄像头直连方案:绕过安卓Camera API,用libuvc实现工业级扫码

对于产线固定工位(如灌装线末端),手机扫码效率低且易磨损。我们用树莓派4B+USB工业摄像头(海康DS-2CD3T47G0-I),通过libuvc直接捕获YUY2帧:

// uvc_capture.c uvc_context_t *ctx; uvc_device_t *dev; uvc_device_handle_t *devh; uvc_init(&ctx, NULL); uvc_find_device(ctx, &dev, 0x0bda, 0x4188, NULL); // VID/PID匹配海康摄像头 uvc_open(dev, &devh); uvc_stream_ctrl_t ctrl; uvc_get_stream_ctrl_format_size(devh, &ctrl, UVC_FRAME_FORMAT_YUYV, 640, 480, 30); uvc_start_streaming(devh, &ctrl, &uvc_callback, NULL, 0);

Java层通过JNI接收YUY2帧,用OpenCV的cv::QRCodeDetector::detectAndDecode()解码,实测单帧处理时间≤80ms,满足产线节拍≤100ms要求。

5.3 核验结果反馈:震动+LED双模提示,杜绝“扫了没反应”的用户体验黑洞

扫码后仅返回JSON成功与否,用户无法感知是否真的核验成功。我们在安卓端增加物理反馈:

private fun showVerificationResult(result: VerifyResult) { when (result) { VerifyResult.SUCCESS -> { // 1. 手机震动(需申请VIBRATE权限) val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator vibrator.vibrate(VibrationEffect.createOneShot(150, VibrationEffect.DEFAULT_AMPLITUDE)) // 2. 控制USB摄像头LED环变绿色 usbDevice.controlTransfer( UsbConstants.USB_TYPE_CLASS or UsbConstants.USB_RECIP_INTERFACE, 0x09, // SET_REPORT 0x0200, // GREEN_LED_ON 0, null, 0, 0 ) } VerifyResult.INVALID -> { // 红色LED+长震 usbDevice.controlTransfer(..., 0x0300, ...) // RED_LED_ON vibrator.vibrate(VibrationEffect.createOneShot(500, 255)) } } }

这套组合拳让产线工人在嘈杂环境中,0.3秒内通过触觉和视觉确认扫码结果,错误率下降76%。


6. 我的血泪经验:三个必须写进SOP的运维铁律,否则迟早翻车

6.1 每日凌晨2点自动校验码表一致性:用SQL脚本揪出“幽灵码”

曾发生过一次事故:某批次10万个码导入数据库,但因网络抖动,其中37个码的status字段写成了NULL而非0(未激活)。这些码在Redis中无记录,扫码时全部穿透到DB,触发慢查询。此后我们加入每日校验:

-- check_code_consistency.sql SELECT COUNT(*) as broken_count FROM t_code WHERE status IS NULL OR code_value REGEXP '^[^0-9A-Za-z\\-\\_]+$' OR LENGTH(code_value) NOT IN (24, 32, 42); -- GS1标准码长校验

用crontab每天执行:

0 2 * * * mysql -u root -p'pwd' trace < /opt/trace/check_code_consistency.sql | grep "broken_count" | awk '{if($2>0) print "ALERT: found "$2" broken codes"}' | mail -s "Trace System Alert" ops@company.com

血泪教训:校验脚本必须包含正则校验——曾有供应商上传CSV时,Excel自动把6901234567890转成科学计数法6.90123E+12,导致码值失效。

6.2 Redis缓存重建必须用“双删+延时双检”,而非简单flush

当码表数据批量更新(如新品上市),有人直接redis-cli flushall,结果核验接口瞬间雪崩。正确姿势:

  1. 第一次删除:更新DB前,删掉相关缓存(如DEL code_bucket_23)
  2. 更新DB:执行UPDATE t_code SET status=1 WHERE product_id=1001
  3. 延时2秒:等主从同步完成
  4. 第二次删除:再删一遍缓存,确保从库同步后的脏数据不被读到

Java实现:

@Transactional public void batchActivate(Long productId) { // Step 1: 删除缓存 redisTemplate.delete("code_bucket_" + getBucketByProductId(productId)); // Step 2: 更新DB codeMapper.updateStatusByProductId(productId, 1); // Step 3: 延时2秒(用ScheduledExecutorService) scheduledExecutor.schedule(() -> { redisTemplate.delete("code_bucket_" + getBucketByProductId(productId)); }, 2, TimeUnit.SECONDS); }

6.3 所有扫码接口必须带traceId,否则排查问题像大海捞针

某次线上核验超时,日志里只有verify failed,根本无法定位是DB慢、Redis慢还是网络慢。现在强制所有接口注入traceId:

@RestController public class CodeController { @GetMapping("/api/v1/code/verify") public ResponseEntity<VerifyResult> verify( @RequestParam String code, @RequestHeader(value = "X-Trace-ID", required = false) String traceId) { if (traceId == null) { traceId = UUID.randomUUID().toString(); } MDC.put("traceId", traceId); // Logback自动写入日志 log.info("Start verify code: {}", code); VerifyResult result = codeVerifyService.verify(code, getDeviceId(), getClientIp()); log.info("Verify result: {}, cost: {}ms", result, System.currentTimeMillis() - startTime); return ResponseEntity.ok(result); } }

Nginx配置透传:

location /api/v1/code/ { proxy_set_header X-Trace-ID $request_id; # nginx内置$request_id变量 proxy_pass http://backend; }

这样查ELK时,输入traceId就能看到从Nginx→Spring→Redis→MySQL的完整链路耗时,定位问题从2小时缩短到3分钟。

最后说一句:这套系统上线半年,支撑了日均800万次扫码,0次因码系统故障导致的客诉。但别迷信开源——它只是骨架,真正的肌肉长在你调优的每一个参数、填平的每一个坑里。我至今保留着第一版上线时写的37页《踩坑清单》,每次新同事入职,都让他先抄一遍。希望帮到你。

本文还有配套的精品资源,点击获取

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

superpowers技能包:把工程师经验变成AI编程的标准化工作流

说实话&#xff0c;第一次在 GitHub 上看到 superpowers 这个项目名的时候&#xff0c;我以为是某个人的中二收藏夹。真正用起来才发现&#xff0c;这玩意儿就是给 AI 编程助手装了一套外挂工作流。简单说&#xff0c;superpowers 是一组可复用的 Agent Skills&#xff0c;它把…

作者头像 李华
网站建设 2026/10/8 7:35:17

基于YOLOv8的智能门禁系统:从数据集构建到边缘部署全流程

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

作者头像 李华
网站建设 2026/10/8 7:35:17

Lakeshore M91报错-1074000000?通信链路排查与稳定配置指南

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

作者头像 李华
网站建设 2026/10/8 7:35:17

基于U-Net的遥感图像分割与国土分类课程设计实践

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

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

eFuse替代保险丝:TPS259483与PIC18的智能电源保护

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

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

SIPI仿真工程落地:从DDR5 TM5报错到SerDes误码的多物理场建模实践

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

作者头像 李华