news 2026/9/10 10:03:09

Spring Boot 请求体重复读取实战:过滤器与RequestWrapper解决RequestBody为null

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 请求体重复读取实战:过滤器与RequestWrapper解决RequestBody为null

做后端时间长了,一定会遇到一个让人抓狂的场景:我想在过滤器里把请求体的 JSON 打出来看日志,或者做一个统一的签名校验,结果日志打完了,Controller 里的 @RequestBody 参数变成了 null。明明 Postman 里传得好好的,一上代码就翻车。我第一次踩这个坑的时候排查了大半天,最后才意识到问题出在 Servlet 规范本身——HttpServletRequest 的输入流只能读一次,读完之后就跟倒空的矿泉水瓶一样,再也倒不出水了。

这篇博文的主题很明确:如何让 Spring Boot 里的 RequestBody 支持“重复读取”,并且不只是在 Demo 里能用,而是能直接放进生产项目里的工程化实现。我会先讲清楚底层原理,再把三种主流方案的优缺点掰开揉碎,最后给出我目前在项目里实际使用的 Filter + 自定义 RequestWrapper 完整代码,连排坑经验一起打包。适合正在被这个坑折磨的后端开发,以及想搞懂 Spring MVC 请求体处理链路的人。

1. 为什么 RequestBody“读一次就没了”——先从底层原理说起

1.1 Servlet 流模型:InputStream 为什么不能回头

在 Servlet 规范里,请求体(Request Body)是通过一个 InputStream 暴露给开发者的,只要是流,就天然是单向、一次性消费的。这不是 Spring 的限制,而是整个 Java Web 容器(Tomcat、Jetty、Undertow 都一样)的底层设计。

我经常用一个水管来类比:请求体是一条水流,你拿一个杯子去接,接完之后水就流走了。想再接第二杯?对不起,水管里的水已经流到下游了。HTTP 请求从客户端传来时本质是一个字节流,服务器端为了性能和内存考虑,不会把整个请求体在内存里存一份副本,而是让应用边读边用。这样做的好处是能支撑大文件上传、大 body 传输,代价就是流一旦读完,就没办法回到开头重新读。

Java 的 InputStream 本身也不是完全不能重读的,比如 ByteArrayInputStream 就可以反复读,但它要求数据已经完整地存在于内存字节数组里。Servlet 容器给我们的是 Socket 上来的网络流,数据没被完整读出来之前,服务器不知道后面还有多少字节,也没有办法把数据“塞回去”,所以只能设计成一次性。

1.2 @RequestBody 读取请求体的完整链路

明白了流是一次性的,接下来看 Spring MVC 是怎么用这个流的。你可能会觉得 @RequestBody 是 Spring 直接帮我们从请求里取参数,其实它的内部链路比想象中要长得多。

一个典型的 JSON POST 请求进入 Spring Boot 应用后,会走这样一条链路:

Tomcat/Servlet 容器 -> FilterChain(过滤器链) -> DispatcherServlet(前端控制器) -> HandlerAdapter(HandlerMethodArgumentResolver 解析参数) -> AbstractMessageConverterMethodArgumentResolver -> HttpMessageConverter(消息转换器) -> MappingJackson2HttpMessageConverter -> ObjectMapper.readValue(inputStream, User.class)

Spring MVC 在解析 @RequestBody 时,真正干活的组件是RequestResponseBodyMethodProcessor,它会调用readWithMessageConverters()方法,把请求体包装成一个HttpInputMessage。这个HttpInputMessage本质上就是对HttpServletRequest的一层抽象,其中核心的一个方法是:

HttpInputMessage.getBody()

对于 Web 请求来说,getBody()最终返回的就是request.getInputStream()。也就是说,Spring 本身并没有魔法,它也是通过流来读取请求体的。如果这个流在你自己的过滤器里已经被读过了,那么 Spring 再去读的时候,流已经处于 EOF(文件结束)状态,InputStream 读出来是 -1,RequestBody 自然就解析失败或得到 null。

还有一个隐藏坑:Servlet 规范要求 getReader() 和 getInputStream() 只能调用其中一个,一旦调用过其中任意一个,再调用另一个就会抛IllegalStateException。这是因为同一个请求的 body stream 是被容器标记的,不能同时以字符流和字节流两种方式消费。

1.3 什么时候最容易踩中这个坑

我总结了一下,下面这几类场景几乎是必踩的:

  • 全局日志打印:想在 Filter 里打印所有请求的 body 内容,结果日志有了,业务接口参数全没了。
  • AOP 参数审计:想在方法执行前后记录请求参数,在 AOP 里读了一次 body,后面真正执行业务时参数缺失。
  • 签名/防重放校验:在拦截器或过滤器里出于安全需要读取 body 计算签名或加密字段,校验通过后放行,业务接口读不到参数。
  • 网关或公共组件:上游组件读了 body 之后传给下游 Filter,下游再需要读时就没了。

其实不只是你自己写的代码会读 body,很多第三方框架也会读。比如 Spring Security 的 CSRF Token、OAuth2 资源服务器、某些 API 网关组件,都可能在过滤器链的某一环消耗掉请求体。只要过滤器链里有任何一环读过 body,且没有重新包装请求,后面的环节都有概率拿不到 body。这就是为什么解决“可重复读取”不只是一个技巧,而是一个工程化的必选项。

2. 方案选型:三种“可重复读”实现思路对比

2.1 Spring 自带的 ContentCachingRequestWrapper:最常用但坑也不少

Spring Web 里提供了一个现成的ContentCachingRequestWrapper,很多初学者一看名字就叫“能缓存请求”.实际上它确实能缓存,但它并不是万能的,我在项目里用它踩过几个大坑。

它的工作机制是这样的:当你通过 wrapper 的getInputStream()读取 body 时,它会一边向业务代码返回数据,一边把读过的内容复制一份到内部的ByteArrayOutputStream缓存中。到了请求处理完之后,可以通过getContentAsByteArray()拿到缓存内容。

问题来了:这个 wrapper 不会自动预读 body。也就是说,如果你在 Filter 里只是把原始 request 包装成ContentCachingRequestWrapper,然后直接放行,后面的 @RequestBody 确实能正常读取,因为数据流向没有改变;但如果你想在 Filter 里先打印 body 再放行,你手动调了一次getInputStream()并读完,流就被消费了,后面的 @RequestBody 拿到空流。

Spring 官方提供了一个AbstractRequestLoggingFilter,它有个属性叫afterContentRead,默认是 false。如果设置成 true,它会在请求结束后从 wrapper 里拿缓存内容打日志,但它不会主动读 body。要是你在自己的业务过滤器中硬读了一次 body,再传下去就是坑。

我在一个项目里曾经图省事用这个 wrapper 做请求日志,结果发现 POST 请求被记录后 Controller 参数为空。仔细排查才发现:RequestLoggingFilter为了打日志,已经把 body 读完了,后面的组件跟着遭殃。这个方案适合只做单向日志记录的场景,不适合“又要读又要用”的双向场景

2.2 自定义 RequestWrapper 缓存全部 body:最稳的工程化方案

既然 Spring 自带的类不够用,最直接的做法就是自己造一个轮子。思路很简单:在请求进入过滤器时,立刻把 body 读出来存成byte[]数组,然后每次调用getInputStream()都返回一个基于这个byte[]的新的字节流。这样无论 @RequestBody 读几次,每次拿到的都是同一份数据。

这个方案本质上是“一次拷贝,多次使用”,比 ContentCachingRequestWrapper 的“边读边记录”更彻底。它没有预读不预读的问题,因为你知道 body 内容已经完整地存在内存里了,后面任何组件随便读,读多少遍都行。

我目前所有需要日志、签名、审计功能的项目,都是用这个方案来实现的。代码量不大,但需要理解 Servlet API 的几个细节,后面第 3 章会给出完整代码。

2.3 另类路线:自定义 HandlerMethodArgumentResolver 或装饰器模式

除了 Filter 方案,还有一些进阶思路。比如实现一个自定义的HandlerMethodArgumentResolver,专门处理被注解标注的参数,在解析时直接读取缓存的 body 并转换成对象。这种方式的好处是:只在 Spring MVC 层生效,不需要经过 Servlet 过滤器链,性能更可控。

但我不太推荐这个方案。第一,实现复杂度较高,你可能要与 Spring 的消息转换器体系做对抗,处理各种 Content-Type;第二,它只能解决 Controller 层参数解析的问题,不能解决过滤器链里其它组件的读取需求,比如 Spring Security、日志过滤器等。

装饰器模式(Decorator Pattern)在这里其实就是HttpServletRequestWrapper的思想。因为它内部持有一个原始 HttpServletRequest 实例,默认把所有方法转发给这个实例,所以你只需要覆写getInputStream()getReader()这两个方法,就能在不改动业务代码的情况下改变请求读取行为。这种方式也是把“可重复读取”和业务解耦的最好形式。

2.4 选型表格对比

方案优点缺点适用场景
ContentCachingRequestWrapper零依赖、Spring 内置集成方便不会预读 body;日志和业务可能相互影响;对 form 表单支持一般仅做被动记录日志
自定义 Filter + RequestWrapper实现彻底、可重复读取无限次、兼容性好、逻辑完全可控需要自己写代码;大 body 时内存会翻倍日志+业务并行、签名校验、审计
自定义 HandlerMethodArgumentResolver只影响 Spring MVC 层,性能好实现复杂、无法覆盖 Servlet 层其它组件需求、侵入性强对性能极敏感的特定接口

我个人的建议是:如果是新项目,直接采用自定义 Filter + RequestWrapper 方案;如果只是想临时给老项目加个日志,可以先用 ContentCachingRequestWrapper 解燃眉之急,但要明白它的边界。

3. 工程化落地:基于过滤器 + 自定义 Wrapper 的完整实现

3.1 准备工作:项目环境和基础假设

我下面的代码基于 Spring Boot 2.x / 3.x,核心依赖只有spring-boot-starter-web,不需要任何额外引入。因为包装请求是 Servlet 层的功能,和 Spring 版本没有强绑定,Spring Boot 2.x 到 3.x 都能用。

在设计之前,先定几个约定:

  • 只处理POSTPUTPATCH这类请求体可能有内容的请求。GET 请求虽然理论上也能带 body,但大多数场景下没人这么用,我的过滤器不会去处理。
  • Content-Type 是application/jsonapplication/xmltext/plain等需要读 body 的场景。文件上传的multipart/form-data直接跳过,因为 multipart 的解析机制不同,不在本次缓存范围内。
  • 请求体大小如果超过 10MB,放弃缓存,直接使用原始请求流,防止内存被大 body 打爆。

3.2 自定义 RepeatableReadRequestWrapper 完整代码

先看核心类,我要实现一个继承了HttpServletRequestWrapper的类,在构造时读取一次原始 body,之后所有读取操作都在缓存副本上进行。

import jakarta.servlet.ReadListener; import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequestWrapper; import org.springframework.util.StreamUtils; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; public class RepeatableReadRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public RepeatableReadRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 构造时就把原始请求体完整读取并缓存到内存中 this.body = StreamUtils.copyToByteArray(request.getInputStream()); } @Override public ServletInputStream getInputStream() throws IOException { final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body); return new ServletInputStream() { @Override public boolean isFinished() { return byteArrayInputStream.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { // 同步读取场景下一般不需要异步监听 throw new UnsupportedOperationException("setReadListener is not supported"); } @Override public int read() { return byteArrayInputStream.read(); } @Override public int read(byte[] b, int off, int len) { return byteArrayInputStream.read(b, off, len); } }; } @Override public BufferedReader getReader() throws IOException { Charset charset = StandardCharsets.UTF_8; String encoding = getCharacterEncoding(); if (encoding != null && !encoding.isEmpty()) { charset = Charset.forName(encoding); } return new BufferedReader(new InputStreamReader(getInputStream(), charset)); } /** * 暴露一个方法,让外部代码直接拿原始 body 字节数组。 */ public byte[] getBodyBytes() { return this.body; } }

有几点要解释一下:

StreamUtils.copyToByteArray()是 Spring 提供的工具方法,会把输入流完整读成字节数组,省得自己写循环。如果没有 Spring,也可以自己用 ByteArrayOutputStream 加 byte[] 缓冲来读,效果一样。

重写getInputStream()时,我每次返回的都是一个新的ByteArrayInputStream。这意味着每次调用getInputStream()都会从缓存副本的起始位置开始读,这是实现“可重复读取”的关键。另外,ServletInputStream是抽象类,必须实现isFinished()isReady()setReadListener()read()。其中isFinished()用于判断流是否读完,isReady()表示是否可读,同步场景下直接返回 true。异步场景如果你不需要,setReadListener 抛异常即可,大部分 Web 场景用不到。

getReader()的字符编码处理是个容易被忽略的细节。如果请求头里没带 Content-Type 的 charset,request.getCharacterEncoding()可能返回 null,直接使用无参的InputStreamReader会导致编码不确定。所以我这里显式判断,默认使用 UTF-8,减少中文乱码的坑。

3.3 过滤器注册与顺序调整

光有 Wrapper 不够,还需要一个过滤器来把原始请求替换成可重复读的包装请求。

import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) public class RepeatableReadFilter extends OncePerRequestFilter { /** * 超过 10MB 的请求体不再缓存,避免内存压力 */ private static final long MAX_CACHE_BODY_SIZE = 10 * 1024 * 1024L; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 只处理有 body 的请求方法 boolean hasBody = HttpMethod.POST.matches(request.getMethod()) || HttpMethod.PUT.matches(request.getMethod()) || HttpMethod.PATCH.matches(request.getMethod()); if (hasBody && request.getContentLengthLong() <= MAX_CACHE_BODY_SIZE) { filterChain.doFilter(new RepeatableReadRequestWrapper(request), response); } else { // GET、DELETE、上传文件等场景直接放行原请求 filterChain.doFilter(request, response); } } }

这里我用了 Spring 的OncePerRequestFilter,而不是原生的Filter接口,原因是OncePerRequestFilter能保证一次请求只被过滤一次。你可能会问:Feign 内部转发、Servlet 容器内部 dispatch 时,原始 Filter 可能会执行两次,导致 Wrapper 被重复包装。实际上重复包装并不会太致命,但会白白多读一次 body,性能损耗不划算。所以优先用 OncePerRequestFilter。

@Order注解用来控制过滤器执行顺序。这里我设置为HIGHEST_PRECEDENCE + 10,在一个非常靠前的位置执行,确保它在业务代码、Spring Security、AOP 之前就已经把 body 缓存好了。如果你项目里用了 Spring Security,这个顺序需要注意,因为 Security 的过滤器链默认会包一层HttpServlet请求,理论上你在这个优先级下能把请求放心地传给 Security。

还有一个细节:Content-Length可能为 -1(比如 chunked 传输),所以request.getContentLengthLong() <= MAX_CACHE_BODY_SIZE时,-1 也会进入缓存分支。对于 chunked 请求,这个判断不完全准确,但一般请求 body 都不大,问题不大。如果你的接口就是纯大文件,建议再多判断一层 Content-Type 是不是 multipart。

3.4 日志打印、签名校验等真实场景用法

有了上面的 Wrapper,业务代码就变得很自由。看下面两个典型场景。

场景 1:在过滤器里打印请求日志,又不想影响后面的 @RequestBody

@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { RepeatableReadRequestWrapper requestWrapper = new RepeatableReadRequestWrapper(request); // 读取 body 并打印(这里只是示例,可以考虑异步输出到日志系统) String body = new String(requestWrapper.getBodyBytes(), StandardCharsets.UTF_8); System.out.println("==> 请求body: " + body); // 注意!这里传下去的是 requestWrapper,而不是原始 request filterChain.doFilter(requestWrapper, response); }

关键点在于:过滤器里读取 body 使用的是 wrapper 的缓存副本,即使打印完之后,wrapper 依然保有完整 body。后续链路上的 @RequestBody 调用request.getInputStream()时,由于传入的是 wrapper,它拿到的还是同一份缓存副本。如果这里传下去的是原始 request,那后面的 @RequestBody 还是会读到空。

场景 2:签名校验 + 防重放校验

我在做开放 API 网关时,需要在过滤器里读取 body 和某个签名头做 HMAC 校验,校验通过后还要让 Controller 继续接参。

boolean valid = verifySignature(requestWrapper.getBodyBytes(), request.getHeader("X-Signature")); if (!valid) { response.sendError(403, "invalid signature"); return; } filterChain.doFilter(requestWrapper, response);

这种方式最大的价值是:校验逻辑和业务逻辑共用了同一份 body,而不是校验的时候读一次、业务执行的时候又得读一次。对于整个请求链路来说,只需在过滤器一开始做一次字节拷贝,后面都是内存操作,性能开销完全可以接受。

3.5 一个常见但容易忽略的细节:JSON 日志格式化

如果你只是想记录 JSON 格式的请求体,建议不要直接new String(bodyBytes)就完事,最好先用 Jackson 把它转成一个 JsonNode 再格式化输出。这样避免中文乱码显示不完整、也便于日志系统结构化存储。比如:

ObjectMapper mapper = new ObjectMapper(); JsonNode node = mapper.readTree(bodyBytes); String prettyJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node);

这里有个细节:我们在过滤器里已经取到了 body 字节数组,不管后面业务是否解析成功,过滤器的日志都不会被影响。如果业务解析 JSON 失败,至少我们能从日志里看到原始请求内容,这是排查问题的利器。

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

4.1 @RequestBody 读到的 body 为空:两个典型的“灵异事件”

第一个灵异事件:用了 ContentCachingRequestWrapper 之后,@RequestBody 还是 null。原因我前面分析过,ContentCachingRequestWrapper 并不会预读 body,它只是在读取时记录。如果你在过滤器里先调用了getInputStream()去读数据(比如为了打印日志),原始流转到 @RequestBody 时已经 EOF。不是 wrapper 的问题,是你对它的预期不对。

第二个灵异事件:自定义 wrapper 后,Controller 明明拿到了数据,但到了某个第三方过滤器中又读不到。这是因为过滤器链里某个组件自己又从原始 request 里取了一次流,而你这个组件没把 wrapper 传下去。解决办法很朴素:一旦在链路起始处包装了请求,后面的所有传递都要用包装后的对象,不要让任何中间环节把 request 重新替换成原始对象。

排查技巧:在过滤器入口和业务入口各打一行日志,输出同一个 request 对象的哈希码,或者输出request.getClass().getName(),就能快速判断传入业务层的到底是不是你包装后的类。这个方法治好了我无数次的“怎么又没了”。

4.2 过滤器和请求参数解析器的顺序冲突

如果你在项目里同时启用了自定义 Filter 和 Spring MVC 的拦截器(Interceptor),要注意时序。Filter 是 Servlet 层面的,Interceptor 是 Spring MVC 层面的,Filter 执行时机一定在 Interceptor 之前。也就是说,只要你的 Filter 正确包装了 request,Interceptor 里再去读 request 就是可重复读的。

但如果你在 Filter 里读了一次 body 之后,放行时传的还是原始 request,Interceptor 和 Controller 都会受影响。所以一条铁律:包装后的 wrapper 对象要从触发它的那个 Filter 开始一直向下传递,直到请求结束

4.3 GET/POST 混合场景、文件上传时不要做缓存

GET 请求不带 body,所以过滤器的getContentLengthLong()可能是 -1 或者 0,这时候去缓存 body 会得到一个空数组,没有意义。文件上传走的是multipart/form-data,body 里不只是 JSON,还有二进制数据和 boundary 分隔符。如果把这个 body 整个缓存下来再传给 Spring 的 multipart 解析器,很容易出问题,因为 multipart 解析器可能会直接读取原始请求的输入流或临时文件。

所以我在设计时加了一层判断:Content-Type 包含multipart/form-data就跳过缓存,使用原始 request。另外,如果 body 超过 10MB,为了保险也跳过缓存。

4.4 性能与内存优化建议

自定义 wrapper 的本质是用内存换可重复读取,所以有一个潜在的 OOM 风险。如果一个请求的 body 有 500MB,你的应用要把它整段复制到内存,明显不现实。有三条优化建议:

  • 设置合理的 body 大小上限,超过限制直接返回 413 或交给原始流处理。
  • 如果项目里的请求普遍是几百 KB 级别,完全不用担心内存,一次字节数组拷贝的成本毫秒级都不到。
  • 如果确实需要缓存超大 body,考虑把数据先落盘或者放到本地缓存,不要全放堆内存。

另外,注意线程安全问题。每个请求都会 new 一个 RepeatableReadRequestWrapper,这个 wrapper 只会被当前请求的线程使用,不会跨线程共享,所以不需要考虑并发访问问题。但如果你在 wrapper 里加了新的字段,也要注意别把它设计成静态或全局共享状态。

4.5 常见的其它坑:编码、压缩、请求体为空

编码:客户端如果发送Content-Type: application/json; charset=GBK,而你服务端默认用 UTF-8 去解码,中文就会乱码。我在 getReader() 里根据 getCharacterEncoding() 动态设置编码,正是为了规避这一点。

压缩:如果客户端使用Content-Encoding: gzip发送 body,你的过滤器拿到的 inputStream 读出来的还是压缩后的字节,直接缓存下来,后面 @RequestBody 也解不了压。这种情况需要在过滤器里先做解压再缓存。不过绝大多数后端服务都不会直接接收 gzip 的 body,我在这里提醒一下,真遇到的时候不要懵。

请求体为空:有些 POST 请求的 body 为空字符串,这个时候copyToByteArray会得到一个空数组,@RequestBody 的解析会失败。通常这类请求的 Content-Type 可能并不匹配,建议在业务层做兼容,也可以在过滤器里判断 body 长度,空内容直接放行原请求。

4.6 工程化落地后的调试建议

最后给一个调试套路:用浏览器或 Postman 测试时,请求体打印为空,不要一上来就改代码。先打开spring-boot-starter-logging的 debug 日志,看Spring MVC HandlerMapping这层有没有加载到你的过滤器。然后在过滤器的 doFilterInternal 第一行输出原始请求的 URI、Method、Content-Type,再输出包装后对象的getBodyBytes()长度。只要长度和 Postman 里的一致,后面基本不会有大问题。

我自己的使用习惯是:过滤器里只做 body 缓存,业务逻辑需要 body 的时候统一通过request.getAttribute("requestBodyCache")或者直接强转RepeatableReadRequestWrapper来获取,这样不会把日志、验签代码全部堆在过滤器里,后期维护也轻松。

结尾:一点个人经验和最后的建议

这个“RequestBody 重复读取”的需求,看起来是个小功能,但真正把它做好、做对,要对 Servlet 规范、Spring MVC 的消息转换链路、请求包装机制都有一定的了解。我在从 ContentCachingRequestWrapper 转向自定义 wrapper 的过程中踩过不少坑,最后总结出一个结论:如果只是记录日志,Spring 自带的缓存包装器能用;如果是日志、验签、审计、业务参数解析共存,一定要自定义 Filter + Wrapper 的组合,并且保证从过滤器入口开始,整个链路都用包装后的请求对象。

最后再分享一个小技巧:包装器的缓存逻辑其实也能抽出复用,我一般会做两个工具方法,一个负责判断请求是否需要缓存(Content-Type 白名单、大小限制),一个负责把原始请求包装成可重复读的 wrapper。这样在多个项目里只需要复制一个类加一个过滤器,几行配置就能生效,不必每次重复造轮子。如果你也在维护一些公共服务或者基础组件,值得提前把这件事沉淀下来。

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

4步本地解锁WeMod专业版功能:Wand-Enhancer开源补丁操作指南

4步本地解锁WeMod专业版功能&#xff1a;Wand-Enhancer开源补丁操作指南 【免费下载链接】Wand-Enhancer Advanced UX and interoperability extension for Wand (WeMod) app 项目地址: https://gitcode.com/GitHub_Trending/we/Wand-Enhancer WeMod&#xff08;Wand&am…

作者头像 李华