news 2026/9/10 5:40:52

SpringBoot3+SpringSecurity6前后端分离JWT权限认证实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot3+SpringSecurity6前后端分离JWT权限认证实战

前后端分离项目做到权限这块,十个人有八个会直接选中SpringSecurity。但真上手用SpringBoot3去集成,你会发现网上大量的教程还停留在SpringSecurity5的写法——包名全是javax开头,配置类继承WebSecurityConfigurerAdapter,一抄就报错。加上SpringBoot3强制要求JDK17起步,整个体系的变化比想象中大得多。这篇文章我把SpringBoot3 + SpringSecurity6在前后端分离架构下的完整落地思路拆开讲,从依赖引入、SecurityFilterChain配置、JWT集成到接口鉴权,每一步都给出现成代码和踩坑记录,保证你照着能跑通。

先交代一下我的项目背景:一个典型的管理后台,前端Vue3,后端SpringBoot3.3.x,接口走RESTful风格,登录成功返回JWT令牌。这套方案跑下来,最大的感受是SpringSecurity6的配置模型比5清爽很多,但自定义过滤器的坑也更多,稍不注意就会被默认行为背刺。

1. 拆解需求:前后端分离项目到底需要什么样的安全管理

1.1 从Session到Token:为什么前后端分离不能再靠Session

传统单体Web项目里,SpringSecurity默认做的事是:登录成功后把用户信息塞进Session,并写入Cookie给浏览器。浏览器下次请求自动带上Cookie,Security在服务端查Session就知道你是谁。这套机制在服务端渲染时代非常好用,因为你基本不需要考虑跨域、不需要考虑移动端。

但前后端分离之后,情况完全变了:

前端跑在DevServer(比如Vite的5173端口),后端跑在8080端口,两者已经跨域了。即使你配置了CORS,让Cookie可以跨域携带,但麻烦依然很多:不同的客户端(网页、小程序、App)对Cookie的支持不一样;集群部署时Session要额外引入Redis共享;前后端分开部署之后,CSRF等安全模型的成本也变高了。

所以前后端分离项目的通用做法是:摒弃Session和Cookie,改成Token机制。用户登录成功后,后端签一个JWT返回给前端;前端把它存起来(localStorage或者Pinia),每次请求在HTTP头里带上Authorization: Bearer token。后端通过过滤器解析Token,拿到里面的用户名和权限信息,构建SecurityContext,SpringSecurity的授权机制照常运转。

这套方案最大的好处是天然无状态,后端不用存任何会话信息,对水平扩展非常友好。

1.2 SpringBoot3带来的变化:SpringSecurity6有哪些坑位要重新对齐

先理清版本关系。SpringBoot3.0之后,官方把javax.*包迁移到了jakarta.*包,同时SpringSecurity升级到了6.x。很多人跟着老教程写代码,一引入SpringBoot3就发现Security相关的类全部报红,原因就在这里。

还有一点,SpringSecurity6.0把WebSecurityConfigurerAdapter这个类废弃了,以前那种“继承适配器然后重写configure”的写法直接失效。现在主流的做法是:把SecurityFilterChain声明成一个Bean,然后用HttpSecurity的lambda风格去配置。这不算特别难,但从旧版本过来的人确实需要适应一阵。

另外一个需要知道的点:SpringBoot3最低要求JDK17,如果项目还在用JDK8或JDK11,要么先升级JDK,要么老老实实退回SpringBoot2.x。我个人的建议是,新项目直接用JDK21(LTS版本)+ SpringBoot3.3.x,SpringSecurity6.2以上,这套组合用起来非常稳定。

2. 项目结构与安全链路设计

2.1 工程目录怎么规划

如果你打算在项目里引入SpringSecurity,建议从一开始就把结构理清楚,不要全部塞在一个类里面。我常用的一种分层方式是这样的:

com.example.demo ├── common # 公共模块 │ ├── result # 统一返回体 │ └── exception # 全局异常 ├── config # 配置类 │ ├── SecurityConfig.java │ └── CorsConfig.java ├── security # 安全相关 │ ├── JwtUtil.java │ ├── JwtAuthenticationFilter.java │ ├── RestAuthenticationEntryPoint.java │ ├── RestAccessDeniedHandler.java │ └── LoginUser.java ├── controller ├── service ├── mapper └── entity

这样拆分的好处是,认证、授权、工具、异常处理互不干扰,后面排查问题的时候非常快。尤其是自定义过滤器、认证入口点、拒绝处理器这些类,单独放到security包里,看起来一目了然。

2.2 一次登录请求完整走一遍安全链路

在写代码之前,建议先把整个请求的流转链路在脑子里过一遍。一个登录请求加一个访问受限资源的请求,大致是这样:

  • 用户提交用户名和密码到/login接口;
  • 过滤器链把这个请求放行,因为没有配置白名单拦截;
  • Controller里的LoginService收到请求,调用AuthenticationManager.authenticate()做认证;
  • 认证通过后,用UserDetails里的用户ID和角色信息生成JWT,返回给前端;
  • 前端后续请求带上Authorization头;
  • JwtAuthenticationFilter从请求头中取出Token,解析并验证有效性,把用户信息塞进SecurityContextHolder;
  • 请求继续走过滤器链,如果访问的接口需要特定角色,SpringSecurity根据SecurityContext中拿到的权限做判断;
  • 权限足够就放行到Controller;权限不足就走AccessDeniedHandler返回403;Token无效或缺失就走AuthenticationEntryPoint返回401。

这套链路你理解透了,后面所有配置都是往这个骨架里填细节,不会乱。

2.3 明文密码不可取:BCrypt接入

密码存储这个事很多人容易忽略。正常情况下,用户注册或修改密码时,服务端绝对不允许存明文密码。SpringSecurity默认提供了BCryptPasswordEncoder,它是BCrypt强哈希算法的实现,自带盐值,每次加密结果都不一样,破解成本高,是目前最推荐的密码编码器。

在SpringSecurity里面,把它注册成Bean,然后在配置里指定它为PasswordEncoder,后面的用户校验就都交给它处理了:

@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

在注册时加密:

BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); String encodedPassword = encoder.encode(rawPassword);

登录校验时,AuthenticationManager会自动用这个Encoder去做密码比对。千万不要自己写equals判断,那等于没做加密。

3. 核心配置:SecurityFilterChain怎么搭

3.1 基础配置代码

SpringSecurity6的核心就是SecurityFilterChain这个Bean,它决定了哪些请求需要认证、哪些放行、用什么样的认证机制、CORS怎么处理。我直接给一份能跑通的配置作为起点:

@Configuration @EnableWebSecurity public class SecurityConfig { @Resource private JwtAuthenticationFilter jwtAuthenticationFilter; @Resource private RestAuthenticationEntryPoint authenticationEntryPoint; @Resource private RestAccessDeniedHandler accessDeniedHandler; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> {}) // 启用cors,具体由CorsConfigurationSource配置 .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/login", "/auth/captcha", "/doc.html", "/webjars/**", "/v3/api-docs/**").permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex -> ex .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }

这里面的几个关键点:

  • csrf.disable():前后端分离项目不用Session,CSRF本身的意义已经变弱,而且JWT方案天然不怕CSRF,所以直接关闭。
  • sessionManagement:策略设为STATELESS,告诉Security不要创建HttpSession,这是无状态认证的核心。
  • authorizeHttpRequests:这一段是授权配置,白名单接口放行,其余全部要求认证。
  • exceptionHandling:自定义401和403的返回,否则默认返回的是HTML错误页面,前端根本没法处理。
  • addFilterBefore:把JWT过滤器加在用户名密码认证过滤器之前,确保Security在做认证之前,我们已经把Token解析好了。

3.2 白名单里的门道

白名单是配置里最容易出问题的地方。很多人配了requestMatchers("/login").permitAll(),结果发现还是被拦截,多半是路径对不上。SpringSecurity的requestMatchers匹配的是Servlet路径,不走spring.mvc.servlet.path这个前缀设置,这是很多坑的根源。

还有一个非常实际的场景:如果项目里用了knife4j(接口文档组件),你需要把它的路径也放行掉,否则一打开文档页面就401,非常烦人。knife4j在SpringBoot3里至少要放行这些路径:

.requestMatchers( "/doc.html", "/webjars/**", "/v3/api-docs/**", "/favicon.ico" ).permitAll()

如果knife4j版本比较老,还有可能额外访问/swagger-resources,建议一并加上。通常我的做法是,把所有需要放行的路径集中到一个常量数组里,方便统一管理:

private static final String[] WHITE_LIST = { "/auth/login", "/auth/logout", "/doc.html", "/webjars/**", "/v3/api-docs/**", "/favicon.ico" };

3.3 CORS必须排在前面

前后端分离项目,CORS配置是绕不开的。SpringSecurity会在过滤链里处理CORS,但前提是你必须显式启用它,并且提供一个CorsConfigurationSource。常见做法是单独写一个配置类,允许指定来源或全部来源,允许所有方法,暴露Authorization响应头:

@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); config.addExposedHeader("Authorization"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }

注意,allowCredentials(true)和addAllowedOriginPattern("")是可以同时使用的,但用addAllowedOrigin("")就不行,浏览器会直接拒绝。如果你设置了Credentials,来源必须具体到域名或用Pattern去匹配。

在SecurityConfig里还要保证CORS在SpringSecurity的过滤器链最前面。上面的配置里.cors(cors -> {})用空lambda,它就会自动获取CorsConfigurationSource这个Bean。至于顺序,Security框架内部会优先处理CORS预检请求,不需要我们自己调整。

4. JWT认证完整实现

4.1 JwtUtil的编写

JWT的核心就是生成和解析。我一般用jjwt库(0.11.5或0.12.x),并且把必要的参数放到application.yml里。先加依赖:

<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>

注意,0.11.5版本的SecretKey要求长度至少256位。不要拿一串短的字符串去签名,否则启动就会抛异常。我的做法是生成一个至少32字节的密钥,然后放到配置里。下面这个工具类把生成、解析、判断过期都封装好了:

@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") // 单位:小时 private Long expire; private SecretKey getKey() { byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); return Keys.hmacShaKeyFor(keyBytes); } public String generateToken(String username, Long userId, List<String> roles) { Date now = new Date(); Date expireDate = new Date(now.getTime() + expire * 3600 * 1000); Map<String, Object> claims = new HashMap<>(); claims.put("userId", userId); claims.put("roles", roles); return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(now) .setExpiration(expireDate) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getKey()) .build() .parseClaimsJws(token) .getBody(); } }

如果用的是jjwt 0.12.x版本,反射API略有不同,但整体思路一样。核心要点是:Token里缓存了用户名、用户ID和角色列表,解析时验证签名和过期时间,任何一步失败都会抛出异常,交给上层过滤器处理。

有一点我要特别提醒:JWT一旦签发,在过期前是无法主动失效的。如果你需要处理用户下线、改密码踢人、管理员禁号这类场景,必须引入Token黑名单或Redis管理机制。做得简单一点,存一份用户名和Token版本号,请求时比对版本号即可。

4.2 自定义JwtAuthenticationFilter

过滤器是整条认证链路的核心。SpringSecurity通过过滤器链来处理每个请求,我们在这里拦截请求头中的Token,解析并构建Authentication对象放进SecurityContextHolder,这样后面的授权判断才有依据。完整代码如下:

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Resource private JwtUtil jwtUtil; @Resource private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { try { Claims claims = jwtUtil.parseToken(token); String username = claims.getSubject(); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // token解析失败,不做处理,后续过滤器会返回401 SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { return bearer.substring(7); } return null; } }

有一个细节容易被忽略:为什么每次请求都从数据库loadUserByUsername?因为我们需要拿到最新的角色和权限信息,否则用户在服务端修改了角色,旧Token在有效期内还是会带着旧权限访问。这样做缺点是每次请求都查一次库,性能会有损耗。如果项目并发很高,可以考虑把角色和权限缓存到JWT里,只在关键操作时再校验,这是一种性能与安全之间的trade-off。

还有一次我踩过的坑:JwtAuthenticationFilter里如果忘记判断SecurityContextHolder里已有认证信息,重复设置Authentication会导致后续的授权判断异常,而且排查起来非常隐蔽。记得先判断再设置。

4.3 认证失败与权限不足的统一返回

默认的401和403返回是一大段HTML,前端根本没法解析。我们必须把这两个出口替换成JSON。分别实现AuthenticationEntryPoint和AccessDeniedHandler,并用统一返回体包装。参考实现:

@Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JSON.toJSONString(Result.error(401, "未登录或登录已过期"))); } }
@Component public class RestAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JSON.toJSONString(Result.error(403, "没有权限访问该资源"))); } }

这里的核心逻辑其实很简单:不给重定向,不给HTML,直接往Response里写JSON。最后在SecurityConfig中把这两个Bean绑定到exceptionHandling里,就是上一节写的代码。

5. 授权控制与接口权限

5.1 基于角色的接口限制

认证解决了“你是谁”的问题,授权解决的是“你能干什么”的问题。在实际项目里,最常用的是基于角色的控制。比如管理后台,管理员可以删除用户,普通运营人员只能查看数据,这就需要给不同角色分配不同接口权限。

在SpringSecurity中,最简单的做法是在SecurityFilterChain里直接配置:

.authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/user/**").hasAnyRole("ADMIN", "USER") .anyRequest().authenticated() )

注意,这里的hasRole("ADMIN")默认要求用户拥有的权限是"ROLE_ADMIN",而不是"ADMIN"。所以你在构建用户权限时,如果用的是角色,要么在权限列表里存"ROLE_ADMIN",要么用hasAuthority("ADMIN")来匹配。我见过太多初学SpringSecurity的人在这里栽跟头:JWT里写的是"roles":"admin",然后Security匹配hasRole("ADMIN"),大小写不一致,永远403。

我的建议是:角色一律大写,权限带上"ROLE_"前缀,这样配置最顺。登录成功给用户构建权限时,在UserDetails里统一处理:

SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + role);

5.2 注解开权限

除了在配置中心集中管理,更灵活的方式是在Controller方法上使用注解。SpringSecurity支持@PreAuthorize、@PostAuthorize、@Secured等注解,配合@EnableMethodSecurity一起使用:

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { }

然后在接口方法上:

@GetMapping("/user/{id}") @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('user:view')") public Result<UserVO> getUser(@PathVariable Long id) { return Result.success(userService.getUserById(id)); }

注解方式的优势是权限和接口写在一起,逻辑直观。缺点是权限规则散落在各个Controller里,后期梳理起来比较费劲。具体选哪种要看团队习惯。就我自己的项目而言,核心模块我会用集中配置,个别特殊接口再用注解补充。

5.3 动态权限的扩展思路

更复杂一点的场景,是权限规则经常变化,甚至要做到“每个人看到的菜单和接口都不一样”。这种时候,固定写死在配置里就不够灵活了。SpringSecurity支持动态权限,思路是自定义AccessDecisionManager或利用Spring Data的动态数据源,把权限-资源对应关系存到数据库里,每次请求时动态判断。这个方案实现成本比较高,一般项目用不上。如果项目确实需要,我建议先基于注解+角色控制撑住前期版本,后期再逐步演进。

6. 前端对接与常见问题排查

6.1 axios请求头怎么带Token

后端搞完之后,前端对接也是要做一些工作的。最核心的一点:每次发请求都要带上Authorization头。常规做法是在axios请求拦截器里统一处理:

service.interceptors.request.use( config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }, error => Promise.reject(error) );

注意Bearer后面有一个空格,这是JWT认证的标准格式。如果不加Bearer,后端解析的时候容易被自己的逻辑坑到。Backend那边resolveToken方法是先判断startsWith("Bearer "),如果前端只放了token没带Bearer,就解析不到。

还有一点经验:如果存在跨域且后端CORS配置了addExposedHeader("Authorization"),前端就可以在登录成功的响应里通过getResponseHeader("Authorization")读到新的Token,适合做Token自动续期的场景。

6.2 401/403的全局处理

前端遇到401,一般意味着登录过期,旧Token已经失效。这时候最合理的操作是:清除本地用户状态,跳转回登录页。如果你是SPA项目,可以用路由守卫配合axios响应拦截器来做。

service.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );

403则是“你有登录但没权限”,一般提示"无权限访问"即可,不需要强制跳登录。这两种状态码的含义最好和前端同学明确对齐,避免后端返回501或200+code这类怪异的约定。

6.3 6个高频问题排查实录

最后把这些常见问题集中整理一下,都是我实际用这套方案时踩过或帮别人排查过的:

问题现象根本原因解决方案
启动报错找不到Bean,提示PasswordEncoder没有在配置类里注册PasswordEncoder添加@Bean方法返回BCryptPasswordEncoder
每次请求都返回401,日志里没有错误JwtAuthenticationFilter里Token解析失败被catch吞掉,且未处理在catch块打日志,确认密钥与签发时一致;确认前端确实带了Authorization头
登录成功但POST请求被拒绝CSRF没关闭在HttpSecurity里显式.csrf(AbstractHttpConfigurer::disable)
接口文档页面打开401knife4j的路径没加入白名单放行/doc.html、/webjars/**等路径
Redis或其他后端服务正常,但前端请求一直跨域失败CORS预检请求没有在Security里放行配置CorsConfigurationSource,并在HttpSecurity里启用cors
有权限却始终403角色前缀不匹配确认hasRole("ADMIN")对应权限是"ROLE_ADMIN";确认JWT里存的角色名大小写一致

补充一个更隐蔽的问题:如果配了Spring事务代理,然后在同一个类内部调用@PreAuthorize方法,权限注解是不生效的。因为内部调用不走代理,Spring安全的方法拦截器没有被触发。解决办法是拆到不同的Service或者在外部调用。

还有一点,很多人在开发阶段为了方便,把所有请求都permitAll,上线前急着收紧权限,结果漏了几个接口,把自己锁在外面。我的建议是开发阶段就按生产标准配置白名单,只放行必要的路径,其余fallback到authenticated,这样能提前暴露问题。

个人实操小经验

这套方案我在SpringBoot3.3.3 + SpringSecurity6.2.4的组合上完整落地过,稳定性没问题。如果你是从老的SpringBoot2项目迁移过来,最需要留意的就是javax到jakarta的包迁移,还有WebSecurityConfigurerAdapter的退化问题,这两个先搞定,其他的按SpringSecurity6的lambda风格重新组织就行。

建议第一次跑通的时候,把日志级别调到DEBUG,观察一次401请求的过滤器链输出,能非常直观地看到安全过滤器的执行顺序。很多时候权限问题不是配置写错了,而是过滤器顺序和预期不一致,这种问题看日志比看代码更高效。

前后端分离加SpringSecurity这套组合,核心思路总结起来就是:无状态认证 + JWT承载用户信息 + 自定义过滤器注入上下文 + 配置白名单和授权规则。只要你把链路走通,后面无论是加验证码、集成第三方登录还是做权限分级,都是在链路上加环节,不会动到骨架。

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

从零搭建智能学习平台:SpringBoot+Vue全栈开发实战与踩坑总结

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

作者头像 李华
网站建设 2026/9/10 5:37:28

智能体系统架构:隔离、集成与治理的范式重构

1. 为什么“智能体系统”不能照搬微服务那一套&#xff1f;“智能体系统架构&#xff1a;隔离、集成与治理的综合调研”——这个标题乍看像一篇学术综述&#xff0c;但如果你真在一线做过大模型应用落地&#xff0c;就会发现它戳中了当前最棘手的工程现实&#xff1a;我们正用微…

作者头像 李华
网站建设 2026/9/10 5:36:06

AI Agent九维度评估与Prompt发布门禁实战体系

1. 项目概述&#xff1a;为什么“好用”比“能用”更难定义&#xff1f; AI Agent不是写完代码跑起来就完事的玩具&#xff0c;它是个活的系统——会听错、会想歪、会绕远路、会在关键时刻卡壳。我带过三支团队落地Agent项目&#xff0c;从客服对话引擎到内部知识助手&#xff…

作者头像 李华