1. JWT基础概念与工作原理JSON Web TokenJWT是一种开放标准RFC 7519用于在网络应用环境间安全地传递声明信息。它由三部分组成通过点号.分隔Header包含令牌类型typ和签名算法algPayload包含声明claims和其他数据Signature用于验证消息在传递过程中未被篡改典型的JWT结构如下eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c1.1 JWT头部详解头部通常由两部分组成{ alg: HS256, typ: JWT }alg参数表示签名算法常见的有HS256HMAC SHA-256RS256RSA SHA-256ES256ECDSA SHA-256注意在生产环境中建议使用RS256或更强的算法因为HS256使用对称加密密钥需要在客户端和服务端共享存在安全隐患。1.2 有效载荷(Payload)规范有效载荷包含三类声明注册声明建议但不强制使用iss (issuer)签发者sub (subject)主题aud (audience)接收方exp (expiration time)过期时间nbf (not before)生效时间iat (issued at)签发时间jti (JWT ID)唯一标识公共声明可以定义任何名称但建议避免冲突私有声明自定义的声明用于在各方之间共享信息示例Payload{ sub: 1234567890, name: John Doe, admin: true, iat: 1516239022, exp: 1516242622 }1.3 签名生成机制签名是JWT安全性的核心。以HS256算法为例签名生成过程如下HMACSHA256( base64UrlEncode(header) . base64UrlEncode(payload), secret )签名验证过程服务端获取JWT的Header和Payload使用相同的算法和密钥重新计算签名比较计算出的签名与JWT中的签名是否一致2. JWT在API保护中的实现方案2.1 整体认证流程设计完整的JWT认证流程包含以下步骤用户登录服务端验证凭证服务端生成JWT并返回给客户端客户端在后续请求的Authorization头中携带JWT服务端验证JWT有效性服务端处理请求并返回响应2.2 服务端实现Spring Boot示例2.2.1 添加依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency2.2.2 JWT工具类实现import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; import java.util.HashMap; import java.util.Map; Component public class JwtTokenProvider { Value(${jwt.secret}) private String jwtSecret; Value(${jwt.expiration}) private int jwtExpirationInMs; public String generateToken(UserDetails userDetails) { MapString, Object claims new HashMap(); claims.put(roles, userDetails.getAuthorities()); return Jwts.builder() .setClaims(claims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() jwtExpirationInMs)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); return true; } catch (SignatureException ex) { logger.error(Invalid JWT signature); } catch (MalformedJwtException ex) { logger.error(Invalid JWT token); } catch (ExpiredJwtException ex) { logger.error(Expired JWT token); } catch (UnsupportedJwtException ex) { logger.error(Unsupported JWT token); } catch (IllegalArgumentException ex) { logger.error(JWT claims string is empty); } return false; } public String getUsernameFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody() .getSubject(); } private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(jwtSecret.getBytes()); } }2.2.3 配置Spring SecurityEnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private JwtTokenProvider tokenProvider; Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/**).permitAll() .anyRequest().authenticated() .and() .addFilterBefore(new JwtTokenFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); } } public class JwtTokenFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; public JwtTokenFilter(JwtTokenProvider tokenProvider) { this.tokenProvider tokenProvider; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (StringUtils.hasText(token) tokenProvider.validateToken(token)) { Authentication auth tokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken request.getHeader(Authorization); if (StringUtils.hasText(bearerToken) bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); } return null; } }2.3 客户端实现方案2.3.1 登录获取Token// 使用axios示例 async function login(username, password) { const response await axios.post(/api/auth/login, { username, password }); const { token } response.data; localStorage.setItem(jwtToken, token); return token; }2.3.2 在请求中携带Token// 请求拦截器 axios.interceptors.request.use(config { const token localStorage.getItem(jwtToken); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, error { return Promise.reject(error); });3. JWT安全最佳实践3.1 密钥管理策略对称加密HS256密钥长度至少32字节定期轮换密钥不要将密钥硬编码在代码中非对称加密RS256私钥仅用于签发Token公钥用于验证Token密钥长度至少2048位重要无论使用哪种算法密钥都应该通过环境变量或密钥管理系统获取而不是直接写在代码或配置文件中。3.2 Token有效期管理短期有效设置较短的过期时间如15-30分钟刷新令牌实现刷新令牌机制延长会话黑名单对于重要操作实现Token黑名单机制刷新令牌示例public TokenPair generateTokenPair(UserDetails userDetails) { String accessToken generateAccessToken(userDetails); String refreshToken generateRefreshToken(userDetails); return new TokenPair(accessToken, refreshToken); } private String generateAccessToken(UserDetails userDetails) { // 15分钟有效期 return Jwts.builder() .setSubject(userDetails.getUsername()) .setExpiration(new Date(System.currentTimeMillis() 15 * 60 * 1000)) // 其他claims .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } private String generateRefreshToken(UserDetails userDetails) { // 7天有效期 return Jwts.builder() .setSubject(userDetails.getUsername()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 60 * 60 * 1000)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); }3.3 常见安全威胁与防护XSS攻击避免在客户端存储敏感信息使用HttpOnly cookie存储Token实施CSP内容安全策略CSRF攻击使用SameSite cookie属性实现CSRF Token机制Token泄露强制使用HTTPS实现Token绑定将Token与设备指纹绑定监控异常Token使用重放攻击使用jti声明和一次性Nonce短期Token有效期实现请求时间戳验证4. 高级应用场景4.1 微服务架构中的JWT传递在微服务架构中JWT可以在服务间传递用户上下文网关层验证在API网关统一验证JWT有效性服务间传播将验证后的JWT传递给下游服务上下文增强网关可以添加额外的claims如权限信息// 网关添加额外claims示例 public String enhanceToken(String originalToken, MapString, Object additionalClaims) { Claims claims Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(originalToken) .getBody(); JwtBuilder builder Jwts.builder(); // 复制原始claims claims.forEach(builder::claim); // 添加新claims additionalClaims.forEach(builder::claim); return builder .setExpiration(claims.getExpiration()) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); }4.2 权限控制实现JWT可以携带用户权限信息实现细粒度访问控制基于角色的访问控制RBAC{ sub: user123, roles: [USER, EDITOR] }基于声明的访问控制CBAC{ sub: user123, permissions: { api:read: true, api:write: false } }Spring Security中实现权限检查PreAuthorize(hasRole(ADMIN) or hasPermission(#id, post, read)) GetMapping(/posts/{id}) public Post getPost(PathVariable Long id) { // 实现逻辑 }4.3 无状态会话管理JWT特别适合无状态架构服务端不存储会话状态所有必要信息都包含在Token中水平扩展简单不需要会话复制或粘性会话跨域认证适合单点登录(SSO)场景SSO实现示例// 认证服务生成SSO Token public String generateSsoToken(String userId, String clientId) { return Jwts.builder() .setSubject(userId) .claim(client_id, clientId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 5 * 60 * 1000)) // 5分钟有效期 .signWith(getSsoSigningKey(), SignatureAlgorithm.HS256) .compact(); } // 客户端应用验证SSO Token public boolean validateSsoToken(String token, String expectedClientId) { try { Claims claims Jwts.parserBuilder() .setSigningKey(getSsoSigningKey()) .build() .parseClaimsJws(token) .getBody(); String clientId claims.get(client_id, String.class); return expectedClientId.equals(clientId); } catch (Exception e) { return false; } }4.4 性能优化技巧压缩Payload对于大型claims可以考虑压缩精简claims只包含必要信息缓存公钥对于RS256缓存公钥避免重复解析异步验证对于高负载系统考虑异步验证机制压缩Payload示例public String generateCompressedToken(UserDetails userDetails) { // 将claims转换为JSON并压缩 String claimsJson objectMapper.writeValueAsString(buildClaims(userDetails)); byte[] compressed compress(claimsJson.getBytes(StandardCharsets.UTF_8)); return Jwts.builder() .setPayload(Base64.getUrlEncoder().encodeToString(compressed)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } private byte[] compress(byte[] data) throws IOException { ByteArrayOutputStream bos new ByteArrayOutputStream(data.length); GZIPOutputStream gzip new GZIPOutputStream(bos); gzip.write(data); gzip.close(); return bos.toByteArray(); }5. 实战问题排查与解决方案5.1 常见错误与排查SignatureException检查签名算法是否一致验证密钥是否正确确保没有多余的空白字符ExpiredJwtException检查客户端和服务端时间是否同步实现刷新令牌机制对于关键操作提示用户重新认证MalformedJwtException验证Token格式是否正确三部分用点分隔检查Base64编码是否正确确保没有非法字符5.2 调试技巧使用jwt.io调试粘贴Token查看解码结果验证签名检查claims内容日志记录记录验证失败的详细原因但不记录完整Token安全考虑单元测试覆盖各种边界情况模拟过期Token测试无效签名场景5.3 性能监控关键指标Token生成时间Token验证时间无效Token比例报警机制大量无效Token请求Token验证时间突增异常签名错误优化建议对于高频验证场景考虑缓存验证结果使用更高效的签名算法定期评估claims的必要性6. JWT与其他认证方案对比6.1 JWT vs Session Cookie特性JWTSession Cookie状态管理无状态有状态存储位置客户端服务端跨域支持容易需要额外配置扩展性容易需要会话复制或共享存储安全性依赖实现依赖实现适合场景分布式系统、API、移动端传统Web应用6.2 JWT vs OAuth2特性JWTOAuth2协议类型令牌格式授权框架主要用途认证和信息交换授权委托令牌类型自包含通常不透明验证方式本地验证通常需要远程验证复杂度相对简单相对复杂适合场景简单认证、服务间通信第三方授权、复杂权限场景6.3 混合方案实践在实际项目中可以结合多种方案的优势OAuth2 JWT使用OAuth2进行授权用JWT作为访问令牌Session JWT关键操作使用Session辅助信息用JWTJWT 短期有效期 刷新令牌平衡安全性和用户体验混合方案示例OAuth2使用JWT作为访问令牌// 生成OAuth2 JWT访问令牌 public OAuth2AccessToken generateOAuth2AccessToken(OAuth2TokenRequest request) { JwtClaims claims new JwtClaims(); claims.setSubject(request.getUserId()); claims.setIssuedAtToNow(); claims.setExpirationMinutesInTheFuture(60); claims.setClaim(scope, String.join( , request.getScopes())); claims.setClaim(client_id, request.getClientId()); String tokenValue Jwts.builder() .setClaims(claims.getClaimsMap()) .signWith(getOAuth2SigningKey(), SignatureAlgorithm.RS256) .compact(); return new OAuth2AccessToken( tokenValue, Bearer, claims.getExpirationTime().getValueInMillis() - claims.getIssuedAt().getValueInMillis(), claims.getIssuedAt().getValueInMillis() ); }7. 实际项目中的经验总结7.1 性能调优经验密钥选择HS256比RS256验证速度快约10倍但对于分布式系统RS256更安全考虑使用ES256ECDSA作为平衡点验证缓存对于短期有效的Token可以缓存验证结果使用内存缓存如Caffeine设置合理的缓存过期时间// 使用Caffeine缓存验证结果 LoadingCacheString, Boolean tokenValidationCache Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) .maximumSize(10_000) .build(token - { try { tokenProvider.validateToken(token); return true; } catch (Exception e) { return false; } });7.2 安全加固技巧Token绑定将Token与设备指纹或IP绑定防止Token被盗用public String generateDeviceBoundToken(UserDetails userDetails, String deviceFingerprint) { return Jwts.builder() .setSubject(userDetails.getUsername()) .claim(dfp, deviceFingerprint) // 设备指纹 .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public boolean validateDeviceBoundToken(String token, String currentDeviceFingerprint) { Claims claims tokenProvider.parseToken(token); String storedFingerprint claims.get(dfp, String.class); return currentDeviceFingerprint.equals(storedFingerprint); }敏感操作二次认证对于关键操作要求重新输入密码或使用短期有效的操作令牌7.3 监控与审计关键指标监控Token生成/验证失败率Token过期分布异常使用模式检测审计日志记录Token使用情况但不记录完整Token关联用户操作与Token使用// 审计日志示例 public void logTokenUsage(String token, HttpServletRequest request) { if (token null) return; Claims claims tokenProvider.parseToken(token); auditLog.info(Token used - User: {}, Action: {}, Time: {}, claims.getSubject(), request.getMethod() request.getRequestURI(), new Date()); }7.4 迁移策略从Session迁移到JWT的建议步骤并行运行同时支持Session和JWT逐步迁移从新功能开始使用JWT监控比较对比两种机制的性能和稳定性最终切换当JWT稳定后完全切换// 兼容Session和JWT的过滤器 public class HybridAuthFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtProvider; private final SessionAuthService sessionService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { // 尝试JWT认证 String jwtToken jwtProvider.resolveToken(request); if (jwtToken ! null jwtProvider.validateToken(jwtToken)) { Authentication auth jwtProvider.getAuthentication(jwtToken); SecurityContextHolder.getContext().setAuthentication(auth); chain.doFilter(request, response); return; } // 尝试Session认证 if (sessionService.hasValidSession(request)) { Authentication auth sessionService.getAuthentication(request); SecurityContextHolder.getContext().setAuthentication(auth); chain.doFilter(request, response); return; } // 无有效认证 response.sendError(HttpServletResponse.SC_UNAUTHORIZED); } }8. 未来发展与替代方案8.1 JWT的局限性无法即时撤销除非实现黑名单机制Payload膨胀随着claims增加Token变大安全性依赖实现配置错误可能导致安全漏洞标准化程度有限某些高级功能缺乏统一标准8.2 新兴替代方案PASETO更安全的JWT替代品更简单的规范更强的默认安全性版本化协议Biscuit可离线验证的授权令牌支持添加衰减权限基于逻辑编程的授权规则更好的撤销机制DPoP演示证明持有者令牌防止Token重放绑定到特定客户端OAuth2的扩展8.3 演进建议渐进式改进从JWT开始根据需要引入更高级特性评估替代方案的成熟度架构解耦抽象认证层便于未来替换实现统一监控和管理// 认证服务抽象接口 public interface AuthTokenService { String generateToken(UserDetails user); boolean validateToken(String token); Authentication getAuthentication(String token); void revokeToken(String token); } // 可以有不同的实现 public class JwtAuthService implements AuthTokenService { /*...*/ } public class PasetoAuthService implements AuthTokenService { /*...*/ }持续评估定期评估安全需求跟踪新标准的发展平衡安全性与开发成本在实际项目中JWT仍然是大多数API保护场景的最佳选择特别是对于无状态、分布式的系统架构。理解其原理、掌握最佳实践并合理规划演进路线可以构建既安全又高效的认证授权系统。
企业数字化 ERP 产品动态
相关推荐
GPW3鼠标压枪宏开发指南:从原理到实战 1. 项目背景与需求分析 GPW3(罗技G PRO X SUPERLIGHT无线游戏鼠标)作为职业电竞选手和FPS游戏爱好者的热门选择,其轻量化设计和卓越性能使其成为压枪宏开发的理想平台。压枪宏的本质是通过软件模拟人手对枪械后坐力的补偿动作,在《… · 2026/11/11 21:15:56
Mac Mini跑本地AI:内存带宽与稀疏模型才是关键 1. 被“统一内存”光环掩盖的真相 身边不少朋友最近都在问我同一个问题:手头那台 Mac Mini 到底能不能当本地 AI 推理服务器用。问的人多了,我发现一个很有意思的现象——绝大多数人做决策时,盯着的都是“统一内存”这四个字,觉得… · 2026/11/11 5:31:37
腾讯BrowserSkill开源:复用本机登录态,破解浏览器自动化登录难题 浏览器自动化这个方向,过去两年我陆陆续续试过不少方案,从最早的 Selenium 到后来的 Playwright、Puppeteer,再到各种基于 CDP 的封装库,绕来绕去始终卡在同一个坎上:登录态。你要么让脚本自己走一遍登录流程ÿ… · 2026/11/7 5:40:42
电工证学习资料免费,郑州报考避坑指南:外省证书怎么转过来? 电工证学习资料免费,郑州报考避坑指南:外省证书怎么转过来? 之前考的证在外省能不能转过来?这几乎是每个准备在郑州报考电工证的小伙伴最关心的问题。别急,今天我就从 电工证学习资料免费 入手,结合 郑州报考避坑指南… · 2026/11/12 23:00:37
电工证分别几种类型网上查询最全指南:工地太忙根本没时间复习考试 电工证分别几种类型网上查询最全指南:工地太忙根本没时间复习考试 工地太忙根本没时间复习考试,这几乎是每个建筑工人的切身感受。尤其是在周口地区,很多电工在一线作业时,既要保证施工进度,又要兼顾自己的证书考试和复审。而电工证分别几种类型这个问题… · 2026/11/12 23:00:37
工地太忙根本没时间复习考试?2026河南最新政策下复审低压电工证秘诀 工地太忙根本没时间复习考试?2026河南最新政策下复审低压电工证秘诀 工地太忙根本没时间复习考试,这几乎是所有一线电工的共同困扰。特别是在2026年河南最新政策下,低压电工证复审不再是“走过场”,而是直接关系到能否继续从事高危作业。作为一名… · 2026/11/12 23:00:37
四川查特种作业操作证值不值得考?一个应届生的血泪教训 四川查特种作业操作证值不值得考?一个应届生的血泪教训 刚毕业就找不到工作?特种作业证不考就白忙?我认识的几个濮阳工科应届生,现在正为这事发愁。他们和你一样,学历够、专业对口,可一到求职现场就碰壁,招聘方第一句话就是“特种作业证有吗?”这玩意… · 2026/11/12 23:00:36
太原什么地方办电工证保姆级教程:怕考不过白交培训费?这篇讲透 太原什么地方办电工证保姆级教程:怕考不过白交培训费?这篇讲透 你是不是也遇到过这种情况,听说太原什么地方办电工证需要交钱培训,结果一到考试就卡壳,培训费打了水漂?别急,这篇保姆级教程从报考到考试,从避坑到备考,统统给你讲清楚,看完再也不会怕… · 2026/11/12 22:59:33
电工证丢失还能补吗?千万别踩坑,丢了证等于失业 电工证丢失还能补吗?千万别踩坑,丢了证等于失业 你正在找工作,但手里的电工证突然丢了,这下怎么办?别急,先别慌, 电工证丢失还能补吗 ?答案是肯定的,但 千万别踩坑 ,补办流程如果不了解,可能会耽误你上岗、甚至影响到你的收入。… · 2026/11/12 14:09:01
电力电缆作业证直播报名入口与办理流程良心建议 电力电缆作业证直播报名入口与办理流程良心建议 你是不是也担心学历不高报不了名?别急,今天就给你讲讲 电力电缆作业证直播 的报名流程和那些你必须知道的干货, 良心建议 在这里,别错过。 电力电缆作业证直播报名需要什么条件?… · 2026/11/11 19:07:25
英德电工证报考费多少钱,怕考不过白交培训费?看完这篇别再踩坑 英德电工证报考费多少钱,怕考不过白交培训费?看完这篇别再踩坑 怕考不过白交培训费?英德电工证报考费多少钱,你真的了解吗?现在市面上培训机构五花八门,价格参差不齐,很多人一不小心就花了冤枉钱,结果还考不过。别急,今天就来给你说清楚英德电工证的… · 2026/11/11 17:14:54
电工证设年审有效期多久官方回应来了,工地太忙根本没时间复习考试 电工证设年审有效期多久官方回应来了,工地太忙根本没时间复习考试 工地太忙根本没时间复习考试,这是很多一线电工、焊工、高处作业人员的真实写照。尤其是像你这样刚毕业的鹤壁工程类毕业生,一边要找工作,一边要考证,时间紧张,复习效率低。但你可能不知… · 2026/11/12 0:00:56
新乡电工证实操怕挂科?电工作业动漫图+报考避坑全解析 新乡电工证实操怕挂科?电工作业动漫图+报考避坑全解析 实操考试心里没底怕挂科?你不是一个人。新乡的水利工程从业者,很多在报考电工证的时候都担心自己操作不熟练、考试不通过, 千万别踩坑 ,选对资料和方法真的能提高通过率。… · 2026/11/12 0:00:56
网电工证千万别踩坑:实操考试心里没底怕挂科怎么破? 网电工证千万别踩坑:实操考试心里没底怕挂科怎么破? 实操考试心里没底怕挂科,这是很多准备报考【网电工证】的学员最真实的感受。尤其是在高压电工、低压电工等工种上,操作环节的容错率极低,一旦失误就可能直接被判定不合格。很多人因为担心挂科而放弃考… · 2026/11/12 0:00:56
双极步进电机控制实战: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/12 4:35:34
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/12 4:36:01
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/12 21:15:21