news 2026/9/12 3:27:18

Rust Axum中间件实战:JWT身份验证与类型安全提取器详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Rust Axum中间件实战:JWT身份验证与类型安全提取器详解

Rust的异步Web生态里,Axum已经成了我新项目的默认选择。它由tokio团队维护,底层基于hyper和tower,整个链路非常干净,最大的优势就是类型系统表达力强——很多别的语言要靠运行时判断的事,在Rust这里编译期就能堵住。今天要聊的Axum身份验证中间件,就是这个类型安全设计里最有代表性的场景之一。

这篇文章适合谁看?如果你刚开始用Axum写API,还在每个handler里重复写toke解析;或者你已经在用中间件,但搞不清Extension、State、提取器怎么配合;又或者你想把401/403、token过期、白名单路由这些细节一次性理清,那这篇应该对你有帮助。我会从中间件的基本原理讲起,再给一个能直接落地的JWT方案,最后把那些文档里不会写的坑挨个过一遍。

1. 为什么说身份验证应该放在中间件这一层

1.1 中间件的本质:一层可放行、可拦截的服务栈

先搞明白Axum的中间件到底是怎么运作的,这决定了后面所有代码的写法。Axum自己没有独立的中间件体系,它直接复用tower生态的Service和Layer。你可以把一次请求的流转想象成一个洋葱:请求从最外层依次穿过每一层中间件,最后到达真正处理业务的handler;handler生成Response后,再沿着原路返回给客户端。中间件在这一过程中可以做三件事:改写请求、决定是否放行、改写响应。

axum::middleware::from_fn创建的中间件,签名非常标准:

async fn mw(mut req: Request, next: Next) -> Result<Response, AppError> { // 请求进入handler前,在这里做校验 let resp = next.run(req).await?; // 响应离开时,如果还做点事情,也在这处理 Ok(resp) }

身份验证天然适合放在这个位置。你看,校验token、判断用户是否存在、决定放行还是返回401,这些都是“请求前”的逻辑;而且不管未来加多少个接口,只要把中间件挂在Router上,所有受保护路由都会自动执行一遍。

很多新手会想:直接在handler里写token解析不行吗?能写,但问题很多。一个10个接口的项目你还能忍,等到了30个接口,你会发现大量重复代码、错误处理混乱、偶尔漏掉某个接口忘了校验。中间件就是把这个横切关注点从业务代码里抽离出来的标准做法。在Axum里,你还获得了额外的好处:由于tower本身的抽象层次足够干净,中间件可以随意与其他Layer组合,比如限流、日志、CORS、Trace,它们按顺序叠在一起,互不干扰。

1.2 三种常见实现方式,我为什么推荐中间件

放到具体实现层面,Axum里做身份验证不外乎三种姿势:

方案代码量类型安全灵活性适用场景
handler内手动解析Token原型、单接口、临时调试
from_fn/from_fn_with_state中间件大多数项目首选
手写Service + Layer极高平台级框架、需要精细控制

刚入门的人最容易选第一种,因为看着直接。但我强烈建议至少第二种起步。from_fn能让一个普通async函数变成中间件,在不牺牲可读性的前提下接住大部分需求,这就是它的价值。

至于手写Service + Layer,除非你在做一个需要高度定制的框架,否则没必要。它的优点是完全掌控poll_ready、缓冲、并发行为,代价是代码量和心智负担成倍增长。身份验证通常只是校验一下token、塞点用户信息,用不上这么重的机制。后面我会提到,当中间件和FromRequestParts提取器配合使用时,类型安全其实也能做到接近手写Service的水平,而代码量却少得多。

2. 从零写一个JWT身份验证中间件

2.1 依赖准备与项目结构建议

先看Cargo.toml,选型直接参考这个:

[dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } jsonwebtoken = "9" serde = { version = "1", features = ["derive"] } serde_json = "1"

JWT这块我没有自研,直接用jsonwebtoken这个crate。它是社区里最主流的JWT实现,支持HS256/HS384/RS256/ES256等常见算法,API稳定,对expnbf这类内建声明的校验也做得比较完善。身份验证这种涉及安全的东西,我强烈不建议自己拼接Base64和HMAC,看起来简单但坑极多,签名算法、时间戳格式、填充方式,任何一个细节错了都可能变成安全漏洞。

项目结构上,我倾向于这样组织:

src/ main.rs // 启动、组装Router auth/ mod.rs // 导出 middleware.rs // 中间件 claims.rs // Claims、AuthUser error.rs // AppError

把auth相关的东西单独放一个模块,避免所有代码堆在main.rs里。后续要加OAuth、Session、API Key,都是在auth/里扩展新模块,业务代码完全不用动。

2.2 中间件核心逻辑:读取Token、校验签名、注入用户

直接上完整代码,这是无状态版本的中间件,核心逻辑一目了然:

use axum::{ extract::Request, http::{StatusCode, header}, middleware::Next, response::{IntoResponse, Response}, Json, }; use jsonwebtoken::{decode, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { pub sub: String, pub role: String, pub exp: usize, } #[derive(Debug, Clone)] pub struct AuthUser { pub id: String, pub role: String, } pub struct AppError { pub status: StatusCode, pub message: String, } impl IntoResponse for AppError { fn into_response(self) -> Response { let body = serde_json::json!({ "error": self.message }); (self.status, Json(body)).into_response() } } pub async fn auth_middleware( mut req: Request, next: Next, ) -> Result<Response, AppError> { let token = extract_bearer_token(req.headers()) .ok_or_else(|| AppError { status: StatusCode::UNAUTHORIZED, message: "缺少或非法的Authorization头".into(), })?; let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret".into()); let data = decode::<Claims>( token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::default(), ) .map_err(|e| { let message = match e.kind() { jsonwebtoken::errors::ErrorKind::ExpiredSignature => "Token已过期".into(), _ => "Token无效".into(), }; AppError { status: StatusCode::UNAUTHORIZED, message, } })?; req.extensions_mut().insert(AuthUser { id: data.claims.sub, role: data.claims.role, }); Ok(next.run(req).await) }

我把从header取token的逻辑单独抽出来,是因为这个动作虽然短,但容易写得不严谨:

fn extract_bearer_token(headers: &header::HeaderMap) -> Option<&str> { let header_value = headers .get(header::AUTHORIZATION)? .to_str() .ok()?; let mut parts = header_value.split_whitespace(); if parts.next().map(|s| s.eq_ignore_ascii_case("Bearer")) != Some(true) { return None; } parts.next() }

为什么要用split_whitespace而不是我一开始写的strip_prefix("Bearer ")?因为真实请求里,客户端发出的header值可能是bearer xxxBearer xxx(多个空格)、甚至Bearer xxx后面带多余空格。用strip_prefix严格匹配时,只要大小写不一致就废了,在实际联调时你会被这种小问题烦死。split_whitespaceeq_ignore_ascii_case直接按语义解析,既能容忍大小写,也能容忍多余空格,这才是正确的解析方式。

再说说Validation::default()。它会默认启用HS256校验,并要求token里存在exp字段且未过期。也就是说,签发token时如果你的Claims没有exp,解码直接报错。这种做法是对的,毕竟一个永不过期的token是很危险的东西。如果你需要更细的控制,可以创建一个Validation实例,修改validate_exprequired_spec_claims这些字段。

解码成功后,我把AuthUser塞进了req.extensions_mut()。这是中间件向handler传递身份信息的标准通道。Extension本质上是请求作用域的map,handler那边用Extension<AuthUser>提取器就能拿回来。这个动作必须发生在next.run(req)之前,因为req先要被消费,才轮到handler执行。

2.3 挂载到Router时最容易踩的坑:layer和route_layer

中间件写好之后,怎么挂载决定了它到底保护哪些路由。最直观的写法是:

let app = Router::new() .route("/profile", get(profile)) .route("/login", post(login)) .layer(middleware::from_fn(auth_middleware));

这段代码有个明显的逻辑问题:/login也会被中间件拦下来,导致登录接口返回401。很多人一开始会问:难道每次来请求,login还要先认证吗?

解决思路有两个。第一个是用Router::merge把公开路由和受保护路由拆开:

let public_routes = Router::new() .route("/login", post(login)) .route("/health", get(health)); let protected_routes = Router::new() .route("/profile", get(profile)) .layer(middleware::from_fn(auth_middleware)); let app = Router::new() .merge(public_routes) .merge(protected_routes);

第二个是用route_layer,它只作用于当前Router上显式注册的route,不影响嵌套进来的子Router和fallback:

let app = Router::new() .route("/login", post(login)) .route("/profile", get(profile)) .route_layer(middleware::from_fn(auth_middleware));

这里面的区别非常关键,值得展开说。Router::layer会把中间件包在Router的最外层,所有经过这个Router的请求都会执行中间件,包括嵌套进来的子Router。route_layer则只对本次Router上用route/get/post显式添加的路由生效,嵌套的Router不受影响。

如果你把一个带鉴权中间件的父Router和一个不带鉴权的子Routermerge在一起,父Router的layer很有可能会把子Router的公开接口也拦掉。我的习惯是:公开路由和受保护路由从一开始就分成两个Router,各自管理自己的中间件,最后在顶层merge。这样谁被保护、谁公开,看代码结构就一目了然,不会出现“中间件挂了两层,某个接口却漏了鉴权”这种灵异事件。

2.4 让中间件拿到配置:from_fn_with_state

前面那个版本直接从环境变量读JWT_SECRET,一个请求读一次环境变量,既慢又不利于测试。正常项目里,JWT密钥应该和数据库连接、Redis客户端一样,放进AppState统一管理。

碰到的第一个坑是:middleware::from_fn创建的中间件,函数参数里不能直接提取State。Axum提供的是另一个入口from_fn_with_state

#[derive(Clone)] pub struct AppState { pub jwt_secret: String, } pub async fn auth_middleware( State(state): State<AppState>, mut req: Request, next: Next, ) -> Result<Response, AppError> { let token = extract_bearer_token(req.headers()) .ok_or_else(|| AppError { status: StatusCode::UNAUTHORIZED, message: "缺少或非法的Authorization头".into(), })?; let data = decode::<Claims>( token, &DecodingKey::from_secret(state.jwt_secret.as_bytes()), &Validation::default(), ) .map_err(...)?; req.extensions_mut().insert(AuthUser { ... }); Ok(next.run(req).await) }

挂载时对应改为:

let state = AppState { jwt_secret: std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret".into()), }; let protected_routes = Router::new() .route("/profile", get(profile)) .layer(middleware::from_fn_with_state(state.clone(), auth_middleware));

注意两个细节。第一,AppState必须实现Clone,因为from_fn_with_state需要持有一份state副本;第二,使用from_fn_with_state后,中间件函数的第一个参数必须是State(state): State<AppState>,如果你还想提取Extension等其他类型,顺序要符合FromRequestParts的实现规则。

从环境变量读一次密钥、放进State、之后每次请求只从State里取值,这点开销差异几乎可以忽略,但代码的测试性提升很大。你可以很轻松地在测试里构造一个带固定jwt_secret的AppState,不需要去改进程环境变量。

3. 用FromRequestParts提取器把类型安全拉满

3.1 为什么handler里直接取Extension不够可靠

中间件把AuthUser塞进Extension之后,handler那边最常见的取法是这样:

async fn profile(Extension(user): Extension<AuthUser>) -> Json<serde_json::Value> { Json(serde_json::json!({ "id": user.id, "role": user.role })) }

代码能跑,但它有一个隐患:这个handler对“中间件是否真正执行过”完全无感。如果哪天有人重构路由,把/profile挪到了一个新的Router,却忘了在那边挂auth中间件,那么Extension<AuthUser>提取会直接失败。运行时Axum会返回一个500错误还是写死的400,取决于提取器的Rejection,反正不是你精心设计的401 JSON结构。更麻烦的是,这种问题在编译期根本发现不了,只能在联调时暴露。

真正的解法是让身份信息的获取也走Axum的类型系统。也就是实现FromRequestParts

3.2 实现FromRequestParts,让编译期替你把关

FromRequestParts是Axum为提取器预留的基础trait。handler的参数只要是实现了它的类型,在请求进入handler前就会自动完成提取。如果提取失败,返回的Rejection会被直接转换成HTTP响应,而不会执行handler内部代码。

AuthUser实现它:

use axum::{ extract::FromRequestParts, http::request::Parts, http::{StatusCode, header}, }; impl<S> FromRequestParts<S> for AuthUser where S: Send + Sync, { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, _state: &S, ) -> Result<Self, Self::Rejection> { // 如果中间件已经解析过,直接从extensions取,避免重复解码 if let Some(user) = parts.extensions.get::<AuthUser>() { return Ok(user.clone()); } // 兜底:中间件没生效时,自己从header里解析 let token = extract_bearer_token(&parts.headers) .ok_or_else(|| AppError { status: StatusCode::UNAUTHORIZED, message: "缺少或非法的Authorization头".into(), })?; let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret".into()); let data = decode::<Claims>( token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::default(), ) .map_err(...)?; let user = AuthUser { id: data.claims.sub, role: data.claims.role, }; parts.extensions.insert(user.clone()); Ok(user) } }

实现之后,handler的签名变成:

async fn profile(user: AuthUser) -> Json<serde_json::Value> { Json(serde_json::json!({ "id": user.id, "role": user.role })) }

这段代码的妙处在于:如果一个handler把AuthUser写进了参数列表,那Axum在调用它之前就必须完成身份解析。如果解析不出来,整个handler根本不会执行,客户端收到的是你定制的AppError响应。这相当于把“身份信息必须存在”这个不变量固化到了类型系统里,编译期就能抓住“忘了挂中间件”这一类问题。提取器里的兜底解析逻辑还提供了一层保护,即使有人忘了挂中间件,只要密钥正确,请求依然能通过,只是行为从“中间件统一拦截”退化为“每个接口单独校验”,至少不会直接变成500。

实际项目中我更推荐“中间件 + 提取器”组合起来用,而不是二选一。中间件负责在请求早期拦截明显非法的token,省得业务代码白跑;提取器负责后续每个handler里的类型安全消费。中间件用from_fn_with_state注入密钥,提取器直接把这些信息从extensions里拿出来,两边各干各的。

3.3 可选登录场景:MaybeAuthUser的妙用

有一种常见需求是“可选登录”:接口允许游客访问,但如果带了合法token,就返回一些个性化数据。比如电商的商品详情页,登录用户能看到带会员价的价格,未登录用户只看到原价。

用中间件实现这个需求很别扭,因为中间件要么全部放行、要么直接拦截,很难表达“能解析就解析,解析不了也别拒绝”这种语义。但用提取器就很自然:

pub struct MaybeAuthUser(pub Option<AuthUser>); impl<S> FromRequestParts<S> for MaybeAuthUser where S: Send + Sync, { type Rejection = std::convert::Infallible; async fn from_request_parts( parts: &mut Parts, _state: &S, ) -> Result<Self, Self::Rejection> { if let Some(user) = parts.extensions.get::<AuthUser>() { return Ok(MaybeAuthUser(Some(user.clone()))); } // 自己解析失败也不报错,只是返回None let user = parse_auth_user_from_headers(&parts.headers).ok(); Ok(MaybeAuthUser(user)) } }

handler里的用法:

async fn product_detail( MaybeAuthUser(user): MaybeAuthUser, ) -> Json<serde_json::Value> { match user { Some(u) => Json(serde_json::json!({ "price": 99, "member_price": 89, "user": u.id })), None => Json(serde_json::json!({ "price": 99 })), } }

这种写法把“身份信息是否存在”变成了一种业务状态,而不是一种错误。整个API对游客的友好度一下就上来了。

4. 中间件里的错误语义与安全细节

4.1 401和403的边界,以及统一错误体

身份验证相关的中间件最容易犯的错误,是把所有失败场景都一概返回401。实际上401和403语义不同,用错了会让前端非常困惑:

场景状态码说明
请求头缺失或格式错误401你连身份凭证都没给
Token签名无效401凭证是伪造的或已损坏
Token已过期401凭证失效,需要重新登录
Token有效但权限不足403身份确定了,但没权利访问该资源
用户已被禁用/删除401 或 403取决于产品策略,我一般用401

简单记法:401回答“你是谁”,403回答“你能不能做这件事”。身份验证中间件只负责回答401,权限判断应该放到更靠近业务的地方,比如在handler里检查用户角色,或者用另一个专门做权限控制的中间件去检查。把两个概念混在一个中间件里,代码会迅速腐化。

错误响应体也建议统一。不要有的接口返回{ "error": "..." },有的直接返回纯字符串,前端解析起来会很难受。我习惯为所有错误手写一个AppError,实现IntoResponse,然后在中间件和业务handler里复用。可以看到前面代码里的AppError带有statusmessage两个字段,into_response里统一用{ "error": message }这个结构返回给前端。这样做的好处是,前端拦截器只要写一套逻辑就能处理所有错误。

有时候你还想追加一个code字段,用来区分具体的错误类型,比如TOKEN_EXPIREDINVALID_TOKEN。这个看项目需求,加上也不难:

pub struct AppError { pub status: StatusCode, pub code: &'static str, pub message: String, }

4.2 密钥管理、日志脱敏与性能开销

JWT密钥是所有安全性的根基。如果JWT_SECRET硬编码在代码里,那跟没加密也没什么区别。我的习惯是至少保证下面几条:

  • 本地开发用.env文件,通过dotenvy加载,密钥不进git
  • 生产环境用环境变量或专门的密钥管理服务注入
  • 密钥长度至少32字节,HS256这类对称算法格外如此
  • 定期轮换密钥,轮换期要考虑旧token的存活时间

日志方面有一点必须要说:千万不要把Authorization头原样打出来。这等于把用户的登录凭证写进了日志,万一日志泄露,所有在线用户等于裸奔。我看到不少项目踩过这个坑。如果你确实需要排查问题,可以打印脱敏后的信息,比如用户id、token前缀、过期时间这些。

性能上,HS256的签名验证非常快,单次解码大概在微秒级别,对绝大多数服务来说根本构不成瓶颈。真正需要关注的是token的解析次数。如果中间件解析了一遍,提取器又解析一遍,就等于同一个token做了两次签名验证。前面实现里我在提取器里先用parts.extensions.get::<AuthUser>()拿缓存结果,如果中间件已经注入过,就不再解码了。这个细节在低并发时看不出来,但到了高吞吐场景,能省下不少CPU。

还有一点容易被忽视:验证JWT时,不要每次请求都构建一次DecodingKeyDecodingKey::from_secret涉及一次Base64解码和密钥初始化,放在函数外面构建好,然后通过State或全局变量复用,可以进一步减少无谓开销。

5. 实战中高频踩坑与排查实录

5.1 问题速查表

先给一份速查表,都是我实际开发中被问过很多次、或者自己踩过的问题:

问题现象可能原因解决办法
handler里拿不到Extension中间件没有实际挂载到当前路由层级检查layer/route_layer的作用域,用merge拆分公开与受保护路由
登录接口也被鉴权拦截中间件挂在全局Router上,覆盖了login把login放到独立的公开Router中,或不挂中间件
401响应格式和业务错误不一致中间件返回裸StatusCode统一使用AppError并实现IntoResponse
Token校验一直失败签发和验证的密钥不一致检查JWT_SECRET;如果多环境部署,核对各环境配置
某些请求的Authorization头丢了反向代理没有透传header检查Nginx或网关配置,确认header未被改写或剥离
from_fn函数里提取State报错用错了入口改成from_fn_with_state(state, f)
升级Axum版本后middleware API变了版本间API调整读升级指南,按编译器提示迁移

5.2 几个典型场景的排查过程

第一个高频场景:中间件确实写了,接口却好像没经过鉴权。最经典的例子是用了route_layer但路由是嵌套的。

let inner = Router::new().route("/profile", get(profile)); let app = Router::new() .nest("/api", inner) .route_layer(middleware::from_fn(auth_middleware));

这段代码里,route_layer只对当前Router显式添加的route生效,嵌套进来的inner是不受影响的。所以你访问/api/profile时,中间件全程没被调用。解决方式是理解语义后选择正确的挂载层,比如在inner上挂route_layer,或者更稳妥地在顶层用layer

第二个高频场景:token是合法的,但logger里看不到任何用户信息,甚至在中间件进不去。这时候先去翻反向代理配置。很多Nginx配置会过滤掉一些header,尤其是带下划线或太长的那种。Authorization头本身比较特殊,一般不会被拦,但也确实有团队把header名改成了X-Authorization这种自定义前缀,然后代理层没做透传,导致后端永远拿不到。

第三个场景比较隐蔽:错误响应被业务层的全局异常处理“吃掉”了。Axum里如果你还挂了fallback或者自定义的Handler,它可能拦截掉原本由中间件返回的错误。排查的时候可以先暂时移除fallback,看中间件错误是否恢复,再定位是谁吞了响应。

第四个场景是关于测试的。写集成测试时,很多人直接构造一个Request发给Router,但忘了在测试里注入中间件依赖的State。如果你用的是from_fn_with_state,测试里就必须用同一个state构造Router。我一般把组装Router的逻辑封成一个函数,传入AppState,这样测试和线上共用一套路由构造,就不会出现“测试环境不经过鉴权”这种诡异事。

第五个场景是token过期后客户端行为异常。前端经常遇到的情况是:请求返回401,但前端拦截器只处理了403,没有处理401,于是用户看到白屏或者无限报错。这个问题不算后端代码bug,但后端可以在文档或错误体code里明确TOKEN_EXPIREDTOKEN_INVALID,让前端针对不同code做不同处理。毕竟对客户端来说,清晰的错误码比一个飘逸的message字符串友好太多了。

5.3 一个小技巧:把中间件设计成可关闭的

最后分享一个我自己的习惯。在开发环境或联调阶段,我经常需要临时关闭鉴权,比如手动用curl调试某个接口,不想每次都带token。我不会去改业务代码,而是在中间件里加一个开关:

if !state.auth_enabled { return Ok(next.run(req).await); }

这个auth_enabled字段放在AppState里,测试环境设为false,生产环境设为true。代价极小,但调试体验提升很大。需要注意生产环境千万别忘了打开,这次我不细说了,都是泪。

我个人在实际操作中的体会是,Axum这套中间件机制,设计得比很多传统的Web框架都要“有原则”。它把中间件和提取器拆成两个正交的概念,各管一摊。中间件管流程控制,提取器管类型转换。身份验证恰好同时踩中这两个点,所以也最容易体会到这套设计的精妙之处。你只要把这两个概念摸透,后面再去写权限控制、日志追踪、请求ID、限流,思路都是通的。

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

基于OpenCV的普通摄像头瞳孔跟踪:从Haar级联到椭圆拟合的实现指南

简介&#xff1a;一套基于OpenCV与网络摄像头的瞳孔跟踪算法项目&#xff0c;面向计算机视觉入门与进阶学习者&#xff0c;解决实时捕捉、分析人眼瞳孔运动以获取注意力或生理反馈的需求。项目包内含14个文件&#xff0c;包括4个C源文件与4个头文件构成完整算法实现&#xff0c…

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

Python爬虫实战:构建高效名言数据采集系统

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

作者头像 李华
网站建设 2026/9/12 3:26:11

给每个 Agent 会话一个“家”:WorkBuddy 会话数据目录设计与迁移实战

1. 云盘焦虑从哪来&#xff1a;会话数据才是 Agent 真正的工作成果大概在一个月前的某个晚上&#xff0c;我照常打开 WorkBuddy 准备继续调一个 Agent 任务&#xff0c;结果发现前一天晚上跑完的会话记录全部变成了灰色&#xff0c;点进去只有一串"无法恢复上下文"的…

作者头像 李华
网站建设 2026/9/12 3:25:56

SadTalker 照片说话视频生成完整指南:让一张肖像开口说话

SadTalker 照片说话视频生成完整指南&#xff1a;让一张肖像开口说话 【免费下载链接】SadTalker [CVPR 2023] SadTalker&#xff1a;Learning Realistic 3D Motion Coefficients for Stylized Audio-Driven Single Image Talking Face Animation 项目地址: https://gitcode.…

作者头像 李华
网站建设 2026/9/12 3:24:41

Java List交集与左连接操作详解:从retainAll到Stream性能优化

两个List之间的操作&#xff0c;可以说是Java日常开发里最高频的集合处理场景之一。不管是做订单与商品匹配、用户与权限关联&#xff0c;还是老系统里内存数据比对&#xff0c;凡是涉及到“两拨数据对一对”的需求&#xff0c;最终都会落到List的交集、差集、或者类似SQL左连接…

作者头像 李华
网站建设 2026/9/12 3:23:56

AI建站后如何实现业务增长:从上线到获客的实战框架

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

作者头像 李华