解决Springboot整合Shiro+Redis退出登录后不清除缓存

news2025/1/6 22:18:49

解决Springboot整合Shiro+Redis退出登录后不清除缓存

  • 问题发现
  • 问题解决

问题发现

如果再使用缓存管理Shiro会话时,退出登录后缓存的数据应该清空。

依赖文件如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.7.18</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.13.0</version>
</dependency>

示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {
    @PostMapping("/login")
    public ModelAndView login(HttpServletRequest request, @RequestParam("username") String username
            , @RequestParam("password") String password) {
        // 提前加密,解决自定义缓存匹配时错误
        UsernamePasswordToken token = new UsernamePasswordToken(
                username,//身份信息
                password);//凭证信息
        ModelAndView modelAndView = new ModelAndView();
        // 对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        if (subject.isAuthenticated() && subject.isRemembered()) {
            modelAndView.setViewName("redirect:main");
            return modelAndView;
        }
        try {
            subject.login(token);
            // 判断savedRequest不为空时,获取上一次停留页面,进行跳转
//            SavedRequest savedRequest = WebUtils.getSavedRequest(request);
//            if (savedRequest != null) {
//                String requestUrl = savedRequest.getRequestUrl();
//                modelAndView.setViewName("redirect:"+ requestUrl);
//                return modelAndView;
//            }
        } catch (AuthenticationException e) {
            e.printStackTrace();
            modelAndView.addObject("responseMessage", "用户名或者密码错误");
            modelAndView.setViewName("redirect:index");
            return modelAndView;
        }
        System.out.println(subject.getSession().getId());
        System.out.println(subject.isAuthenticated());
        modelAndView.setViewName("redirect:main");
        return modelAndView;
    }

    @GetMapping("/logout")
    public void logout() {
        SecurityUtils.getSubject().logout();
    }
}

自定义Realm,示例代码如下:

@Component
public class UserRealm extends AuthorizingRealm {

    @Autowired
    private UserService userService;
    @Autowired
    private RoleService roleService;
    @Autowired
    private PermissionService permissionService;

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String username = (String) authenticationToken.getPrincipal();
        String password = new String((char[]) authenticationToken.getCredentials());
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            throw new UnknownAccountException("账号不存在");
        }
        Sha256Hash sha256Hash = new Sha256Hash(password, username);
        if (!sha256Hash.toHex().equals(user.getPassword())) {
            throw new IncorrectCredentialsException("密码错误");
        }
        SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user, sha256Hash.toHex(), new ByteSourceSerializable(username), getName());
        return simpleAuthenticationInfo;
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        User user = (User) principalCollection.getPrimaryPrincipal();
        List<Role> roleList = roleService.getByUserId(user.getId());
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        roleList.forEach(item ->{
            simpleAuthorizationInfo.addRole(item.getName());
        });
        List<Integer> roleIds = roleList.stream().map(Role::getId).collect(Collectors.toList());
        List<Permission> permissions = permissionService.listByIds(roleIds);
        permissions.forEach(item->{
            simpleAuthorizationInfo.addStringPermission(item.getName());
        });
        return simpleAuthorizationInfo;
    }
}

Config配置文件如下:

package org.example.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.cache.MemoryConstrainedCacheManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.session.mgt.eis.MemorySessionDAO;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.session.mgt.eis.SessionIdGenerator;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.filter.authc.AnonymousFilter;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.apache.shiro.web.mgt.CookieRememberMeManager;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.servlet.SimpleCookie;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.example.realm.UserRealm;
import org.example.shiroTest.CustomSessionManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.web.client.RestTemplate;

import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.servlet.Filter;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * packageName org.example.config
 *
 * @author shanchengwei
 * @className ShiroConfig
 * @date 2024/11/28
 */
@Configuration
public class ShiroConfig {
    /**
     * 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 当未登录的用户尝试访问受保护的资源时,重定向到这个指定的登录页面。
        shiroFilterFactoryBean.setLoginUrl("/user/index");
        // 成功后跳转地址,但是测试时未生效
        shiroFilterFactoryBean.setSuccessUrl("/user/main");
        // 当用户访问没有权限的资源时,系统重定向到指定的URL地址。
        shiroFilterFactoryBean.setUnauthorizedUrl("/user/unauth");
        // 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/user/logout", "logout");
        filterChainDefinitionMap.put("/**", "authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        defaultWebSecurityManager.setSessionManager(defaultWebSessionManager()); // 注册会话管理
//        defaultWebSecurityManager.setRememberMeManager(rememberMeManager());
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }

    /**
     * 创建会话管理
     */
    @Bean
    public DefaultWebSessionManager defaultWebSessionManager() {
        DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
        defaultWebSessionManager.setGlobalSessionTimeout(10000);
//        defaultWebSessionManager.setSessionDAO(sessionDAO());
        defaultWebSessionManager.setCacheManager(cacheManager()); // 设置缓存管理器,自动给sessiondao赋值
        return defaultWebSessionManager;
    }

    @Bean
    public SessionDAO sessionDAO() {
        RedisSessionDao redisSessionDao = new RedisSessionDao();
        redisSessionDao.setActiveSessionsCacheName("shiro:session");
        return redisSessionDao;
    }
    
    /**
     * 指定密码加密算法类型
     */
    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
        hashedCredentialsMatcher.setHashAlgorithmName("SHA-256"); // 设置哈希算法
        return hashedCredentialsMatcher;
    }

    /**
     * 注册Realm的对象,用于执行安全相关的操作,如用户认证、权限查询
     */
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); // 为realm设置指定算法
        userRealm.setCachingEnabled(true); // 启动全局缓存
        userRealm.setAuthenticationCachingEnabled(true); // 启动验证缓存
        userRealm.setCacheManager(cacheManager());
        return userRealm;
    }

    @Bean
    public CacheManager cacheManager() {
        RedisCacheManage redisCacheManage = new RedisCacheManage(redisTemplate());
        return redisCacheManage;
    }

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        //设置了 ObjectMapper 的可见性规则。通过该设置,所有字段(包括 private、protected 和 package-visible 等)都将被序列化和反序列化,无论它们的可见性如何。
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        //启用了默认的类型信息 NON_FINAL 参数表示只有非 final 类型的对象才包含类型信息,这可以帮助在反序列化时正确地将 JSON 字符串转换回对象。
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        //key采用String的序列化方式
        redisTemplate.setKeySerializer(stringRedisSerializer);
        //hash的key也采用String的序列化方式
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        return redisTemplate;
    }
}

当我点击退出登录后报错,如图所示:

在这里插入图片描述
后台日志报错,如图所示:

在这里插入图片描述

Redis保存数据,如图所示:

在这里插入图片描述

问题解决

根据报错可以知道,User对象无法转换为String字符串,就很神奇,存进去和删除的时候为什么参数不一致哦,然后就开启了Debug模式,一步步排查。

调用logout()方法,进入DefaultSecurityManager类,如图所示:

在这里插入图片描述

最后进入CachingRealm类,如图所示:
在这里插入图片描述
根据Debug先进入AuthorizingRealm类(前面介绍过缓存没保存授权的记录,不做讲解,参考AuthenticatingRealm),实际是再AuthenticatingRealm.doClearCache(),然后获取缓存和凭证进行删除操作,如图所示:
在这里插入图片描述

然后我们看下这个Key是如何获取的,实际上也是拿的凭证信息,如图所示:
在这里插入图片描述

然后就联想到这个凭证信息再自定义Realm中存放的,然后我就将凭证中的信息改成了username字段,示例代码如下:

SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user.getUsername(), sha256Hash.toHex(), new ByteSourceSerializable(username), getName());

AuthenticatingRealm中的Redis数据删除后返回到AuthorizingRealm类,继续执行该类的缓存清除(虽然没有缓存数据),如图所示:

在这里插入图片描述

然后就报错了,如图所示:

在这里插入图片描述
我们可以看到又是一个类型转换错误,再getAuthorizationCacheKey()方法中直接将对象返回,如图所示:

在这里插入图片描述

解决该问题的方法有两种:

  • 方法一:子类重写该方法,自定义的Realm中去重写,示例代码如下:
@Component
public class UserRealm extends AuthorizingRealm {
	// 省略其它代码... ...
    @Override
    protected Object getAuthorizationCacheKey(PrincipalCollection principals) {
        return principals.getPrimaryPrincipal();
    }
}
  • 方法二:再Config文件中不启用授权的缓存,这样缓存为null,就不会往下走,示例代码如下:
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); // 为realm设置指定算法
        userRealm.setCachingEnabled(true); // 启动全局缓存
        userRealm.setAuthorizationCachingEnabled(false); // 启动授权缓存
        userRealm.setAuthenticationCachingEnabled(true); // 启动验证缓存
        userRealm.setAuthenticationCacheName("Authentication");
        userRealm.setCacheManager(cacheManager());
        return userRealm;
    }

这两种方式都可以解决类型转换的错误。

解决了这个删除的问题我们再回到最前面的问题:存进去和删除的时候为什么参数不一致哦?

我们进入login()方法,如图所示:
在这里插入图片描述
进入authenticate()方法,最终进入AuthenticatingRealm类的getAuthenticationInfo()方法,如图所示:
在这里插入图片描述
第一次判断缓存为空,进入自定义Realm中查询数据,然后将查询的数据再放入缓存中,如图所示:
在这里插入图片描述
我们看下getAuthenticationCacheKey()方法是如何获取key的,如图所示:

在这里插入图片描述
可以看见直接获取的参数getPrincipal()方法,也就是UsernamePasswordToken中的username字段,如图所示:

在这里插入图片描述

到此也就知道为什么存的时候和删的时候,Key值不一致的原因。

这样又带来了另外一个问题:用username当凭证就会每次都要去查询,非常的繁琐,有没有什么好的办法?还真有,我们知道它删除的时候会去获取自定义Realm中凭证信息,如图所示:

在这里插入图片描述
既然这样的话我就可以重写getAvailablePrincipal()方法,保证删除的时候和登录的凭证信息保持一致就行,示例代码如下:

@Component
public class UserRealm extends AuthorizingRealm {
	// 省略其它代码... ...
    @Override
    protected Object getAvailablePrincipal(PrincipalCollection principals) {
        User availablePrincipal = (User) super.getAvailablePrincipal(principals);
        return availablePrincipal.getUsername();
    }
}

至此退出登录时遇到的所有问题基本都解决了。

不清除缓存基本上就是key不匹配导致的问题,然后再清除过程中碰到的异常错误也都进行了解答。

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

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

相关文章

2024-12-29-sklearn学习(26)模型选择与评估-交叉验证:评估估算器的表现 今夜偏知春气暖,虫声新透绿窗纱。

文章目录 sklearn学习(26) 模型选择与评估-交叉验证&#xff1a;评估估算器的表现26.1 计算交叉验证的指标26.1.1 cross_validate 函数和多度量评估26.1.2 通过交叉验证获取预测 26.2 交叉验证迭代器26.2.1 交叉验证迭代器–循环遍历数据26.2.1.1 K 折26.2.1.2 重复 K-折交叉验…

[TOTP]android kotlin实现 totp身份验证器 类似Google身份验证器

背景&#xff1a;自己或者公司用一些谷歌身份验证器或者microsoft身份验证器&#xff0c;下载来源不明&#xff0c;或者有广告&#xff0c;使用不安全。于是自己写一个&#xff0c;安全放心使用。 代码已开源&#xff1a;shixiaotian/sxt-android-totp: android totp authenti…

Windows11 安卓子系统存储位置更改

文章目录 前言 更改存储位置总结 前言 Windows 11 的安卓子系统&#xff08;Windows Subsystem for Android, WSA&#xff09;为用户提供了在 PC 上运行安卓应用的便利&#xff0c;但默认情况下&#xff0c;WSA 的数据存储路径位于系统盘&#xff08;通常是 C 盘&#xff09;。…

家谱管理系统|Java|SSM|VUE| 前后端分离

【技术栈】 1⃣️&#xff1a;架构: B/S、MVC 2⃣️&#xff1a;系统环境&#xff1a;Windowsh/Mac 3⃣️&#xff1a;开发环境&#xff1a;IDEA、JDK1.8、Maven、Mysql5.7 4⃣️&#xff1a;技术栈&#xff1a;Java、Mysql、SSM、Mybatis-Plus、VUE、jquery,html 5⃣️数据库…

Ubuntu 下使用命令行将 U 盘格式化为 ext4、FAT32 和 exFAT 的详细教程

Ubuntu 下使用命令行将 U 盘格式化为 ext4、FAT32 和 exFAT 的详细教程 作者&#xff1a;Witheart更新时间&#xff1a;20241228 本教程将详细介绍如何将 U 盘格式化为 ext4、FAT32 和 exFAT 文件系统&#xff0c;同时包括如何安装必要工具&#xff08;如 exfat-utils&#x…

基于服务器部署的综合视频安防系统的智慧快消开源了。

智慧快消视频监控平台是一款功能强大且简单易用的实时算法视频监控系统。它的愿景是最底层打通各大芯片厂商相互间的壁垒&#xff0c;省去繁琐重复的适配流程&#xff0c;实现芯片、算法、应用的全流程组合&#xff0c;从而大大减少企业级应用约95%的开发成本。国产化人工智能“…

Uncaught ReferenceError: __VUE_HMR_RUNTIME__ is not defined

Syntax Error: Error: vitejs/plugin-vue requires vue (>3.2.13) or vue/compiler-sfc to be present in the dependency tree. 第一步 npm install vue/compiler-sfc npm run dev 运行成功&#xff0c;本地打开页面是空白&#xff0c;控制台报错 重新下载了vue-loa…

ChatGPT 与 AGI:人工智能的当下与未来走向全解析

在人工智能的浩瀚星空中&#xff0c;AGI&#xff08;通用人工智能&#xff09;无疑是那颗最为璀璨且备受瞩目的星辰。OpenAI 对 AGI 的定义为“在最具经济价值的任务中超越人类的高度自治系统”&#xff0c;并勾勒出其发展的五个阶段&#xff0c;当下我们大多处于以 ChatGPT 为…

【容器化技术 Docker 与微服务部署】详解

容器化技术 Docker 与微服务部署 一、容器化技术概述 &#xff08;一&#xff09;概念 容器化技术是一种操作系统级别的虚拟化方法&#xff0c;它允许将应用程序及其依赖项&#xff08;如运行时环境、系统工具、库等&#xff09;打包成一个独立的、可移植的单元&#xff0c;这…

SSRF服务端请求Gopher伪协议白盒测试

前言 是什么SSRF&#xff1f; 这个简单点说就是 服务端的请求伪造 就是这个如果是个 请求图片的网站 他的目的是请求外部其他网站的 图片 但是 SSRF指的是让他请求本地的图片 再展示出来 请求的是他的服务器上的图片 SSRF(Server-Side Request Forgery:服务器端请求伪造) …

Diffusion Transformer(DiT)——将扩散过程中的U-Net换成ViT:近频繁用于视频生成与机器人动作预测(含清华PAD详解)

前言 本文最开始属于此文《视频生成Sora的全面解析&#xff1a;从AI绘画、ViT到ViViT、TECO、DiT、VDT、NaViT等》 但考虑到DiT除了广泛应用于视频生成领域中&#xff0c;在机器人动作预测也被运用的越来越多&#xff0c;加之DiT确实是一个比较大的创新&#xff0c;影响力大&…

Paperlib(论文管理工具)

Paperlib 是一个简单好用的论文管理工具。软件接入各学科数据库用于匹配论文元数据&#xff0c;逐步为每一个学科&#xff08;例如计算机科学&#xff0c;物理学等&#xff09;定制化数据库组合提高检索精度。尤其是精准的会议论文元数据检索能力。还可以管理你的论文&#xff…

【Linux】Socket编程-UDP构建自己的C++服务器

&#x1f308; 个人主页&#xff1a;Zfox_ &#x1f525; 系列专栏&#xff1a;Linux 目录 一&#xff1a;&#x1f525; UDP 网络编程 &#x1f98b; 接口讲解&#x1f98b; V1 版本 - echo server&#x1f98b; V2 版本 - DictServer&#x1f98b; V3 版本 - 简单聊天室 二&a…

嵌入式系统 第七讲 ARM-Linux内核

• 7.1 ARM-Linux内核简介 • 内核&#xff1a;是一个操作系统的核心。是基于硬件的第一层软件扩充&#xff0c; 提供操作系统的最基本的功能&#xff0c;是操作系统工作的基础&#xff0c;它负责管理系统的进程、内存、设备驱动程序、文件和网络系统&#xff0c; 决定着系统的…

[Qt] 信号和槽(1) | 本质 | 使用 | 自定义

目录 一、信号和槽概述 二、本质 底层实现 1. 函数间的相互调用 2. 类成员中的特殊角色 三、使用 四. 自定义信号和槽 1. 基本语法 (1) 自定义信号函数书写规范 (2) 自定义槽函数书写规范 (3) 发送信号 (4) 示例 A. 示例一 B. 示例二 —— 老师说“上课了”&…

2024 年发布的 Android AI 手机都有什么功能?

大家好&#xff0c;我是拭心。 2024 年是 AI 快速发展的一年&#xff0c;这一年 AI 再获诺贝尔奖&#xff0c;微软/苹果/谷歌等巨头纷纷拥抱 AI&#xff0c;多款强大的 AI 手机进入我们的生活。 今年全球 16% 的智能手机出货量为 AI 手机&#xff0c;到 2028 年&#xff0c;这…

Mac连接云服务器工具推荐

文章目录 前言步骤1. 下载2. 安装3. 常用插件安装4. 连接ssh测试5. 连接sftp测试注意&#xff1a;ssh和sftp的区别注意&#xff1a;不同文件传输的区别解决SSL自动退出 前言 Royal TSX是什么&#xff1a; Royal TSX 是一款跨平台的远程桌面和连接管理工具&#xff0c;专为 mac…

StarRocks 存算分离在得物的降本增效实践

编者荐语&#xff1a; 得物优化数据引擎布局&#xff0c;近期将 4000 核 ClickHouse 迁移至自建 StarRocks&#xff0c;成本降低 40%&#xff0c;查询耗时减半&#xff0c;集群稳定性显著提升。本文详解迁移实践与成果&#xff0c;文末附丁凯剑老师 StarRocks Summit Asia 2024…

【操作系统进程与线程管理:从PCB到多线程并发编程】

&#x1f308;个人主页: Aileen_0v0 &#x1f525;热门专栏: 华为鸿蒙系统学习|计算机网络|数据结构与算法 ​&#x1f4ab;个人格言:“没有罗马,那就自己创造罗马~” 文章目录 操作系统管理进程PCB核心属性线程&多线程编程为什么线程比进程更轻量&#xff1f;为什么线程创…

超越TF-IDF:信息检索之BM25

深入解析BM25&#xff1a;信息检索的优化利器 搜索系列相关文章&#xff08;置顶&#xff09; 1.原始信息再加工&#xff1a;一文读懂倒排索引 2.慧眼识词&#xff1a;解析TF-IDF工作原理 3.超越TF-IDF&#xff1a;信息检索之BM25 4.深入浅出 Beam Search&#xff1a;自然语言处…