Spring Cloud Gateway实战WebFlux解析请求体及抛出指定错误代码和信息

概述

基于Spring Cloud开发微服务时,使用Spring Cloud原生自带的Gateway作为网关,所有请求都需要经过网关服务转发。

为了防止恶意请求刷取数据,对于业务请求需要进行拦截,故而可在网关服务增加拦截过滤器。基于此,有如下源码:


@Slf4j
@Component
public class BlockListFilter extends AbstractGatewayFilterFactory {private static final String DIALOG_URI = "/dialog/nextQuestion";@Resourceprivate AssessmentBlockListService assessmentBlockListService;@Lazy@Resourceprivate RemoteUserService remoteUserService;@Lazy@Resourceprivate RemoteRcService remoteRcService;@Lazy@Autowiredprivate RemoteOAuthService remoteOAuthService;@Value("${blockListSwitch:true}")private Boolean blockListSwitch;@Overridepublic GatewayFilter apply(Object config) {return (exchange, chain) -> {ServerHttpResponse response = exchange.getResponse();response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);ServerHttpRequest serverHttpRequest = exchange.getRequest();String uri = serverHttpRequest.getURI().getPath();HttpHeaders httpHeaders = serverHttpRequest.getHeaders();// 只处理相关urlif (blockListSwitch && StringUtils.equalsIgnoreCase(uri, DIALOG_URI)) {String token = httpHeaders.getFirst("Authorization");String pureToken = StringUtils.replaceIgnoreCase(token, "Bearer ", "");BaseUserInfo baseUserInfo = UserUtil.getBaseUserInfo(pureToken);if (baseUserInfo == null) {log.warn("传入的token非法:{}", token);return chain.filter(exchange);}// 从JWT token中解析用户信息(不含mobile等敏感信息)String channel = baseUserInfo.getChannel();String userKey = baseUserInfo.getUserkey();UserParam userParam = new UserParam();userParam.setKey(userKey);userParam.setChannel(channel);// Feign远程请求user服务获取mobile信息Response<UserAccountVO> userAccountResponse = remoteUserService.baseQuery(userParam);if (userAccountResponse.getCode() != 0) {log.warn("未获取到userKey={}的用户信息!", userKey);return chain.filter(exchange);}UserAccountVO userAccountVO = userAccountResponse.getData();log.info("blocklist filter user={}", JsonUtil.beanToJson(userAccountVO));String mobile = userAccountVO.getMobile();if (StringUtils.isNotBlank(userKey)) {// 具体的拦截业务逻辑this.process(uri, userKey, mobile, channel, pureToken);}return chain.filter(exchange);}return chain.filter(exchange);};}private String process(String uri, String userKey, String mobile, String channel, String token) {DetectUserDTO detectUserDTO = new DetectUserDTO();detectUserDTO.setChannel(channel);detectUserDTO.setMobile(mobile);detectUserDTO.setUserKey(userKey);detectUserDTO.setUri(uri);Response<DetectUserVO> detectUserVOResponse = remoteRcService.detectUser(detectUserDTO);if (detectUserVOResponse.getCode() != 0) {log.warn("mobile={} 风控接口返回异常:{}", mobile, JsonUtil.beanToJson(detectUserVOResponse));return null;}DetectUserVO detectUserVO = detectUserVOResponse.getData();if (detectUserVO.getIsInAllowList()) {log.info("在白名单中,放行");} else if (detectUserVO.getIsInBlockList()) {log.info("在黑名单中,拦截处理...");this.logout(token);return "当前手机号问诊次数已达今日上限!";}return null;}private void logout(String token) {// 强制下线,踢出登录态LogoutDto logoutDto = new LogoutDto();logoutDto.setToken(pureToken);remoteOAuthService.logout(logoutDto);}
}

上面的代码只是实现:判断流量请求URL,进而判断用户是否触发风控黑名单机制,如果触发黑名单,则踢出登录态强制用户下线,即用户不能使用App。

需求

异常捕获

上面的踢出登录态做法过于简单粗暴,App有若干个功能模块,某些请求URL触发黑名单机制,就强制用户下线,不能使用App其他功能。应该允许用户使用除了触发黑名单模块的其他模块功能。当然,对于是否强制下线的交互设计,每个人有每个人的看法。

就我遇到的情况来细说,痛点在于,前端(所谓大前端概念,含iOS和安卓App)同学针对此种情况直接返回统一的错误页:【抱歉,请返回再试一次!】,并且这个页面没有任何UI设计,仅仅是前面提到的一句提示文案。用户看到这个文案,会重新登录(因为后端做了踢出登录态逻辑),App成功后经过网关校验,发现用户依旧触发黑名单,然后再次踢出登录态,陷入死循环。

基于此,做如下改造:不踢出登录态,可以继续使用其他模块功能,在使用某个触发黑名单机制的模块时后端返回错误码,前端弹窗提示【无法使用此功能】。

故而对上面的代码进行调整:

String msg = this.process(uri, userKey, mobile, channel, pureToken,);
if (StringUtils.isNotBlank(msg)) {return Mono.error(new CustomException(BlockTypeEnum.getCodeByMsg(msg), msg));
}

本地启动Gateway网关服务,postman模拟请求,得到接口返回数据:
在这里插入图片描述
咦?怎么Code不是枚举类里定义的错误文案msg对应的错误码,而是500呢?

看代码,发现有个继承DefaultErrorWebExceptionHandler的JsonErrorWebExceptionHandler类,那这个类也需要调整:


@Slf4j
public class JsonErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler {public JsonErrorWebExceptionHandler(ErrorAttributes errorAttributes,ResourceProperties resourceProperties,ErrorProperties errorProperties,ApplicationContext applicationContext) {super(errorAttributes, resourceProperties, errorProperties, applicationContext);}@Overrideprotected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {Map<String, Object> errorAttributes = new HashMap<>(8);Throwable error = super.getError(request);errorAttributes.put("message", error.getMessage());if (error instanceof CustomException) {errorAttributes.put("code", ((CustomException) error).getCode());} else {errorAttributes.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());}errorAttributes.put("method", request.methodName());errorAttributes.put("path", request.path());log.warn("网关异常,path:{},method:{},message:{}", request.path(), request.methodName(), error.getMessage());return errorAttributes;}@Overrideprotected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);}@Overrideprotected HttpStatus getHttpStatus(Map<String, Object> errorAttributes) {// 这里其实可以根据errorAttributes里面的属性定制HTTP响应码return HttpStatus.INTERNAL_SERVER_ERROR;}
}

经过调整后,断点调试,万事大吉:
在这里插入图片描述
但是!!!

后来在和前端联调时才发现有点不对劲:
在这里插入图片描述
如上图,枚举类里有几个code,每个code都有对应的系统功能模块黑名单msg。之前没有注意到右上角的接口状态码是500,这个500表示请求未成功。也就是说,接口未成功,接口响应码是自定义的code也没啥用。status必须得是200、204、202这种才行。

总结一下:虽然使用Mono.error()返回业务自定义错误信息,但错误代码9996被转为500。

继续排查。找到下面参考2里的文章,做如下调整:

String msg = this.process(uri, userKey, mobile, channel, pureToken,);
if (StringUtils.isNotBlank(msg)) {// 对应200,表明接口请求是成功的,但是触发业务异常错误码exchange.getResponse().setStatusCode(HttpStatus.OK);return exchange.getResponse().writeWith(Flux.just(exchange.getResponse().bufferFactory().wrap(JSON.toJSONString(msg).getBytes())));
}

调试结果,右上角的Status变成200:
在这里插入图片描述
注意看,Postman可以返回中文msg。

发布测试环境后,在Chrome浏览器里却发现返回数据乱码!?
在这里插入图片描述
IDEA调整截图如上没有乱码问题,console控制台打印也是正常:
在这里插入图片描述
乱码不是什么大问题,研发这么多年遇到无数次。继续排查,做如下调整解决问题:

return exchange.getResponse().writeWith(Flux.just(exchange.getResponse().bufferFactory().wrap(JSON.toJSONString(msg).getBytes(StandardCharsets.UTF_8))));

获取请求体

前面提到,我们的App有几个功能模块,其中一个模块的请求全部dialog/nextQuestion接口。用户恶意刷数据,多次请求此接口就会触发黑名单。但是在我们的交互设计上,又希望用户从其他功能模块切换到此功能时,可以请求一次此接口。那怎么判断是第一次呢?那就需要解析requestBody。

根据下面的参考1文章,增加如下代码:

public String resolveBodyForDialog(ServerHttpRequest serverHttpRequest) {String uri = serverHttpRequest.getURI().getPath();// 只有某些请求才解析if (!StringUtils.equalsAnyIgnoreCase(uri, "dialog/nextQuestion")) {return "";}StringBuilder sb = new StringBuilder();Flux<DataBuffer> body = serverHttpRequest.getBody();body.subscribe(buffer -> {byte[] bytes = new byte[buffer.readableByteCount()];buffer.read(bytes);DataBufferUtils.release(buffer);String bodyString = new String(bytes, StandardCharsets.UTF_8);sb.append(bodyString);});return sb.toString();
}

本地调试时没有问题,可以获取到requestBody:
在这里插入图片描述
但是测试环境却又问题:
在这里插入图片描述
详细的错误日志:

500 Server Error for HTTP POST "/api/open/dialog/nextQuestion"
io.netty.handler.codec.EncoderException: io.netty.util.IllegalReferenceCountException: refCnt: 0, decrement: 1
at io.netty.handler.codec.MessageToMessageEncoder.write(MessageToMessageEncoder.java:107)
at io.netty.channel.CombinedChannelDuplexHandler.write(CombinedChannelDuplexHandler.java:348)
at io.netty.channel.AbstractChannelHandlerContext.invokeWrite0(AbstractChannelHandlerContext.java:716)
at io.netty.channel.AbstractChannelHandlerContext.invokeWrite(AbstractChannelHandlerContext.java:708)
at io.netty.channel.AbstractChannelHandlerContext.write(AbstractChannelHandlerContext.java:791)
at io.netty.channel.AbstractChannelHandlerContext.write(AbstractChannelHandlerContext.java:701)
at reactor.netty.channel.MonoSendMany$SendManyInner.run(MonoSendMany.java:286)
at reactor.netty.channel.MonoSendMany$SendManyInner.trySchedule(MonoSendMany.java:368)
at reactor.netty.channel.MonoSendMany$SendManyInner.onSubscribe(MonoSendMany.java:221)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onSubscribe(FluxMapFuseable.java:90)
at reactor.core.publisher.FluxContextStart$ContextStartSubscriber.onSubscribe(FluxContextStart.java:97)
at reactor.core.publisher.MonoJust.subscribe(MonoJust.java:54)
at reactor.core.publisher.MonoSubscriberContext.subscribe(MonoSubscriberContext.java:47)
at reactor.core.publisher.FluxSourceMonoFuseable.subscribe(FluxSourceMonoFuseable.java:38)
at reactor.core.publisher.FluxMapFuseable.subscribe(FluxMapFuseable.java:63)
at reactor.core.publisher.Flux.subscribe(Flux.java:7921)
at reactor.netty.channel.MonoSendMany.subscribe(MonoSendMany.java:81)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.drain(MonoIgnoreThen.java:153)
at reactor.core.publisher.MonoIgnoreThen.subscribe(MonoIgnoreThen.java:56)
at reactor.core.publisher.Mono.subscribe(Mono.java:3848)
at reactor.netty.NettyOutbound.subscribe(NettyOutbound.java:305)
at reactor.core.publisher.MonoSource.subscribe(MonoSource.java:51)
at reactor.core.publisher.MonoDefer.subscribe(MonoDefer.java:52)
at reactor.netty.http.client.HttpClientConnect$HttpIOHandlerObserver.onStateChange(HttpClientConnect.java:441)
at reactor.netty.ReactorNetty$CompositeConnectionObserver.onStateChange(ReactorNetty.java:470)
at reactor.netty.resources.PooledConnectionProvider$DisposableAcquire.onStateChange(PooledConnectionProvider.java:512)
at reactor.netty.resources.PooledConnectionProvider$PooledConnection.onStateChange(PooledConnectionProvider.java:451)
at reactor.netty.channel.ChannelOperationsHandler.channelActive(ChannelOperationsHandler.java:62)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:225)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:211)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelActive(AbstractChannelHandlerContext.java:204)
at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelActive(CombinedChannelDuplexHandler.java:414)
at io.netty.channel.ChannelInboundHandlerAdapter.channelActive(ChannelInboundHandlerAdapter.java:69)
at io.netty.channel.CombinedChannelDuplexHandler.channelActive(CombinedChannelDuplexHandler.java:213)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:225)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:211)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelActive(AbstractChannelHandlerContext.java:204)
at io.netty.channel.DefaultChannelPipeline$HeadContext.channelActive(DefaultChannelPipeline.java:1396)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:225)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:211)
at io.netty.channel.DefaultChannelPipeline.fireChannelActive(DefaultChannelPipeline.java:906)
at io.netty.channel.epoll.AbstractEpollChannel$AbstractEpollUnsafe.fulfillConnectPromise(AbstractEpollChannel.java:618)
at io.netty.channel.epoll.AbstractEpollChannel$AbstractEpollUnsafe.finishConnect(AbstractEpollChannel.java:651)
at io.netty.channel.epoll.AbstractEpollChannel$AbstractEpollUnsafe.epollOutReady(AbstractEpollChannel.java:527)
at io.netty.channel.epoll.EpollEventLoop.processReady(EpollEventLoop.java:422)
at io.netty.channel.epoll.EpollEventLoop.run(EpollEventLoop.java:333)
at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:906)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)

具体研究这篇文章,做如下调整:

String uri = serverHttpRequest.getURI().getPath();
// 只有某些请求才解析
if (!StringUtils.equalsAnyIgnoreCase(uri, DIALOG_URI)) {return "";
}
Flux<DataBuffer> body = serverHttpRequest.getBody();
AtomicReference<String> bodyRef = new AtomicReference<>();
body.subscribe(buffer -> {CharBuffer charBuffer = StandardCharsets.UTF_8.decode(buffer.asByteBuffer());bodyRef.set(charBuffer.toString());
});
return bodyRef.get();

上面的报错消失。问题虽然是解决,但是不明就里。

后面又发现一个乱码问题,针对如下requestBody:

{"stateId": "DASHBOARD","answer": {"transitionId": "GET_HEALTH_ADVICE","label": "开始评估症状"}
}

bodyRef.get()获取到的中文数据乱码。参考3,解决方法:

String encoding = System.getProperty("file.encoding");
CharBuffer charBuffer = Charset.forName(encoding).decode(buffer.asByteBuffer());
bodyRef.set(charBuffer.toString());

参考

  • Spring Cloud Gateway读取RequestBody数据
  • 使用Spring Cloud-Gateway WebFlux抛出指定错误代码和信息
  • 从String获得ByteBuffer、从ByteBuffer获得CharBuffer的正确姿势

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.xdnf.cn/news/143882.html

如若内容造成侵权/违法违规/事实不符,请联系一条长河网进行投诉反馈,一经查实,立即删除!

相关文章

VM虚拟机连接NAT虚拟网络并上网的总结

关键字 VMware、NAT、VM虚拟机、ip route get、网关、私有云 设置 虚拟网络 VMware虚拟网络管理器中显示当前所有VMware的虚拟网络&#xff0c;根据显示&#xff0c;这里是"VMnet8"网络是NAT模式&#xff08;寄主机只能存在一个NAT虚拟网络&#xff0c;也就是说&a…

制作PE启动盘

文章目录 ⭐️写在前面的话⭐️1、下载微PE2、格式化U盘3、安装PE到U盘4、下载镜像 ⭐️写在前面的话⭐️ &#x1f4d2;博客主页&#xff1a; 程序员好冰 &#x1f389;欢迎 【点赞&#x1f44d; 关注&#x1f50e; 收藏⭐️ 留言&#x1f4dd;】 &#x1f4cc;本文由 程序员好…

通俗易懂了解大语言模型LLM发展历程

1.大语言模型研究路程 NLP的发展阶段大致可以分为以下几个阶段&#xff1a; 词向量词嵌入embedding句向量和全文向量理解上下文超大模型与模型统一 1.1词向量 将自然语言的词使用向量表示&#xff0c;一般构造词语字典&#xff0c;然后使用one-hot表示。   例如2个单词&…

【STM32】IAP升级01 bootloader实现以及APP配置(主要)

APP程序以及中断向量表的偏移设置 前言 通过之前的了解 之前的了解&#xff0c;我们知道实现IAP升级需要两个条件&#xff1a; 1.APP程序必须在 IAP 程序之后的某个偏移量为 x 的地址开始&#xff1b; 2.APP程序的中断向量表相应的移动&#xff0c;移动的偏移量为 x&#xff…

深入理解 pytest.main():Python 测试框架的核心功能解析

前言 笔者平常运行pytest用例时&#xff0c;通常使用命令行方式&#xff0c;像这样 pytest -v pxl/test_dir/test_demo.py::TestDemo::test_my_var&#xff0c;执行某一条case&#xff0c;但每次命令行敲也挺麻烦的。那如何在python代码中调用pytest呢&#xff1f;带着疑问一…

APP开发费用计算方法

计算开发移动应用&#xff08;APP&#xff09;的费用涉及多个因素&#xff0c;包括项目的规模、复杂性、所需功能、技术选择、开发团队的经验、地理位置和市场需求等。以下是一些考虑开发APP费用的关键因素以及一般的费用计算方法&#xff0c;希望对大家有所帮助。北京木奇移动…

第八天:gec6818arm开发板和Ubuntu中安装并且编译移植mysql驱动连接QT执行程序

一、Ubuntu18.04中安装并且编译移植mysql驱动程序连接qt执行程序 1 、安装Mysql sudo apt-get install mysql-serverapt-get isntall mysql-clientsudo apt-get install libmysqlclient-d2、查看是否安装成功&#xff0c;即查看MySQL版本 mysql --version 3、MySQL启动…

PHP8中伪变量“$this->”和操作符“::”的使用-PHP8知识详解

对象不仅可以调用自己的变量和方法&#xff0c;也可以调用类中的变量和方法。PHP8通过伪变量“$this->”和操作符“::”来实现这些功能。 1.伪变量“$this->” 在通过对象名->方法调用对象的方法时&#xff0c;如果不知道对象的名称&#xff0c;而又想调用类中的方法…

【新版】系统架构设计师 - 层次式架构设计理论与实践

个人总结&#xff0c;仅供参考&#xff0c;欢迎加好友一起讨论 文章目录 架构 - 层次式架构设计理论与实践考点摘要层次式体系结构概述表现层框架设计MVC模式MVP模式MVVM模式使用XML设计表现层表现层中UIP设计思想 中间层架构设计业务逻辑层工作流设计业务逻辑层设计 数据访问层…

三维模型3DTile格式轻量化压缩处理重难点分析

三维模型3DTile格式轻量化压缩处理重难点分析 在对三维模型3DTile格式进行轻量化压缩处理的过程中&#xff0c;存在一些重要而又困难的问题需要解决。以下是几个主要的重难点&#xff1a; 1、压缩率和模型质量之间的平衡&#xff1a;压缩技术的目标是尽可能地减少数据大小&…

【机器学习】期望最大算法(EM算法)解析:Expectation Maximization Algorithm

【机器学习】期望最大算法&#xff08;EM算法&#xff09;&#xff1a;Expectation Maximization Algorithm 文章目录 【机器学习】期望最大算法&#xff08;EM算法&#xff09;&#xff1a;Expectation Maximization Algorithm1. 介绍2. EM算法数学描述3. EM算法流程4. 两个问…

【AI视野·今日NLP 自然语言处理论文速览 第四十一期】Tue, 26 Sep 2023

AI视野今日CS.NLP 自然语言处理论文速览 Tue, 26 Sep 2023 Totally 75 papers &#x1f449;上期速览✈更多精彩请移步主页 Daily Computation and Language Papers Physics of Language Models: Part 3.1, Knowledge Storage and Extraction Authors Zeyuan Allen Zhu, Yuanz…

Databend 源码阅读:配置管理

作者&#xff1a;尚卓燃&#xff08;PsiACE&#xff09;澳门科技大学在读硕士&#xff0c;Databend 研发工程师实习生 Apache OpenDAL(Incubating) Committer https://github.com/PsiACE 对于 Databend 这样复杂的数据库服务端程序&#xff0c;往往需要支持大量的可配置选项&am…

k8s安装master节点遇到问题解决

1、安装k8s-1.19安装文档地址&#xff1a; https://kuboard.cn/install/history-k8s/install-k8s-1.19.x.html 2、按照文档中内容执行完master节点的操作报异常&#xff1a; 在执行&#xff1a; curl -sSL https://kuboard.cn/install-script/v1.19.x/init_master.sh | sh …

npm安装心得(依赖库Python及node-sass依赖环境)

在使用vue的开发环境过程中&#xff0c;总会遇到这样哪样的安装或者打包错误&#xff0c; vue运行或打包常见错误如下&#xff1a; 1. npm install时 node-sass npm ERR command failed &#xff08;可能是node.js的版本和node-sass的版本不符&#xff0c;就是卸掉原来的node.…

[滴水逆向]03-12 pe头字段说明课后作业,输出pe结构

#include <iostream> #include <windows.h> using namespace std; #pragma warning(disable:4996) //DOC结构 typedef struct _DOC_HEADER {WORD e_magic;WORD e_cblp;WORD e_cp;WORD e_crlc;WORD e_cparhar;WORD e_minalloc;WORD e_maxalloc;WORD e_ss;WO…

RHCE---Web 服务器

文章目录 目录 文章目录 前言 一.Web服务器概述 网址及HTTP协议概述&#xff1a; HTTP协议请求过程&#xff1a; 二.搭建动态HTTP网页 动态网页概述&#xff1a; 搭建动态的HTTP协议网页&#xff1a; 总结 前言 通过上一个章节的学习了解了时间服务器以及远程连接服务器&a…

C++中实现雪花算法来在秒级以及毫秒及时间内生成唯一id

1、雪花算法原理 雪花算法&#xff08;Snowflake Algorithm&#xff09;是一种用于生成唯一ID的算法&#xff0c;通常用于分布式系统中&#xff0c;以确保生成的ID在整个分布式系统中具有唯一性。它的名称来源于雪花的形状&#xff0c;因为生成的ID通常是64位的整数&#xff0…

Prometheus-Rules 实战

文章目录 1 node rules2 nginx rule2.1 Nginx 4xx 错误率太多2.2 Nginx 5xx 错误率太多2.3 Nginx 延迟高 3 mysql rule3.1 MySQL 宕机3.2 实例连接数过多3.3 MySQL高线程运行3.4 MySQL 从服务器 IO 线程没有运行3.5 MySQL 从服务器 SQL 线程没有运行3.6 MySQL复制滞后3.7 慢查询…

作为SiteGPT替代品,HelpLook的优势是什么?

在当今快节奏的数字化世界中&#xff0c;企业不断寻求创新方式来简化运营并增强客户体验。由于聊天机器人能够自动化任务、提供快速响应并提供个性化互动&#xff0c;它们在业务运营中的使用变得非常重要。因此&#xff0c;企业越来越意识到像SiteGPT和HelpLook这样高效的聊天机…