0

0

Springboot如何实现认证和动态权限管理

WBOY

WBOY

发布时间:2023-06-03 21:46:06

|

1946人浏览过

|

来源于亿速云

转载

知识点补充

shiro缓存

流程分析

在原来的项目当中,由于没有配置缓存,因此每次需要验证当前主体有没有访问权限时,都会去查询数据库。由于权限数据是典型的读多写少的数据,因此,我们应该要对其加入缓存的支持。

当我们加入缓存后,shiro在做鉴权时先去缓存里查询相关数据,缓存里没有,则查询数据库并将查到的数据写入缓存,下次再查时就能从缓存当中获取数据,而不是从数据库中获取。这样就能改善我们的应用的性能。

接下来,我们去实现shiro的缓存管理部分。

Shiro会话机制

Shiro 提供了完整的企业级会话管理功能,不依赖于底层容器(如 web 容器 tomcat),不管 JavaSE 还是 JavaEE 环境都可以使用,提供了会话管理、会话事件监听、会话存储 / 持久化、容器无关的集群、失效 / 过期支持、对 Web 的透明支持、SSO 单点登录的支持等特性。

我们将使用 Shiro 的会话管理来接管我们应用的web会话,并通过Redis来存储会话信息。

整合步骤

添加缓存

CacheManager

在Shiro当中,它提供了CacheManager这个类来做缓存管理。

使用Shiro默认的EhCache实现

在shiro当中,默认使用的是EhCache缓存框架。EhCache 是一个纯Java的进程内缓存框架,具有快速、精干等特点。

引入shiro-EhCache依赖
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-ehcache</artifactId>
    <version>1.4.0</version>
</dependency>

在SpringBoot整合Redis的过程中,还要注意版本匹配的问题,不然有可能报方法未找到的异常。

在ShiroConfig中添加缓存配置
private void enableCache(MySQLRealm realm){
    //开启全局缓存配置
    realm.setCachingEnabled(true);
    //开启认证缓存配置
    realm.setAuthenticationCachingEnabled(true);
    //开启授权缓存配置
    realm.setAuthorizationCachingEnabled(true);

    //为了方便操作,我们给缓存起个名字
    realm.setAuthenticationCacheName("authcCache");
    realm.setAuthorizationCacheName("authzCache");
    //注入缓存实现
    realm.setCacheManager(new EhCacheManager());
}

然后再在getRealm中调用这个方法即可。

提示:在这个实现当中,只是实现了本地的缓存。也就是说缓存的数据同应用一样共用一台机器的内存。如果服务器发生宕机或意外停电,那么缓存数据也将不复存在。当然你也可通过cacheManager.setCacheManagerConfigFile()方法给予缓存更多的配置。

接下来我们将通过Redis缓存我们的权限数据

使用Redis实现

添加依赖
<!--shiro-redis相关依赖-->
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis</artifactId>
            <version>3.1.0</version>
            <!--    里面这个shiro-core版本较低,会引发一个异常
					ClassNotFoundException: org.apache.shiro.event.EventBus
                    需要排除,直接使用上面的shiro
                    shiro1.3 加入了时间总线。-->
            <exclusions>
                <exclusion>
                    <groupId>org.apache.shiro</groupId>
                    <artifactId>shiro-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
配置redis

在application.yml中添加redis的相关配置

spring:
   redis:
     host: 127.0.0.1
     port: 6379
     password: hewenping
     timeout: 3000
     jedis:
       pool:
         min-idle: 5
         max-active: 20
         max-idle: 15

修改ShiroConfig配置类,添加shiro-redis插件配置

/**shiro配置类
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/10/6 9:11
 */
@Configuration
public class ShiroConfig {

    private static final String CACHE_KEY = "shiro:cache:";
    private static final String SESSION_KEY = "shiro:session:";
    private static final int EXPIRE = 18000;
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;
    @Value("${spring.redis.timeout}")
    private int timeout;
    @Value("${spring.redis.password}")
    private String password;

    @Value("${spring.redis.jedis.pool.min-idle}")
    private int minIdle;
    @Value("${spring.redis.jedis.pool.max-idle}")
    private int maxIdle;
    @Value("${spring.redis.jedis.pool.max-active}")
    private int maxActive;

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }


    /**
     * 创建ShiroFilter拦截器
     * @return ShiroFilterFactoryBean
     */
    @Bean(name = "shiroFilterFactoryBean")
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);

        //配置不拦截路径和拦截路径,顺序不能反
        HashMap<String, String> map = new HashMap<>(5);

        map.put("/authc/**","anon");
        map.put("/login.html","anon");
        map.put("/js/**","anon");
        map.put("/css/**","anon");

        map.put("/**","authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);

        //覆盖默认的登录url
        shiroFilterFactoryBean.setLoginUrl("/authc/unauthc");
        return shiroFilterFactoryBean;
    }

    @Bean
    public Realm getRealm(){
        //设置凭证匹配器,修改为hash凭证匹配器
        HashedCredentialsMatcher myCredentialsMatcher = new HashedCredentialsMatcher();
        //设置算法
        myCredentialsMatcher.setHashAlgorithmName("md5");
        //散列次数
        myCredentialsMatcher.setHashIterations(1024);
        MySQLRealm realm = new MySQLRealm();
        realm.setCredentialsMatcher(myCredentialsMatcher);
        //开启缓存
        realm.setCachingEnabled(true);
        realm.setAuthenticationCachingEnabled(true);
        realm.setAuthorizationCachingEnabled(true);
        return realm;
    }

    /**
     * 创建shiro web应用下的安全管理器
     * @return DefaultWebSecurityManager
     */
    @Bean
    public DefaultWebSecurityManager getSecurityManager( Realm realm){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(realm);
    
        securityManager.setCacheManager(cacheManager());
        SecurityUtils.setSecurityManager(securityManager);
        return securityManager;
    }



    /**
     * 配置Redis管理器
     * @Attention 使用的是shiro-redis开源插件
     * @return
     */
    @Bean
    public RedisManager redisManager() {
        RedisManager redisManager = new RedisManager();
        redisManager.setHost(host);
        redisManager.setPort(port);
        redisManager.setTimeout(timeout);
        redisManager.setPassword(password);
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(maxIdle+maxActive);
        jedisPoolConfig.setMaxIdle(maxIdle);
        jedisPoolConfig.setMinIdle(minIdle);
        redisManager.setJedisPoolConfig(jedisPoolConfig);
        return redisManager;
    }


    @Bean
    public RedisCacheManager cacheManager() {
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        redisCacheManager.setKeyPrefix(CACHE_KEY);
        // shiro-redis要求放在session里面的实体类必须有个id标识
        //这是组成redis中所存储数据的key的一部分
        redisCacheManager.setPrincipalIdFieldName("username");
        return redisCacheManager;
    }

}

修改MySQLRealm中的doGetAuthenticationInfo方法,将User对象整体作为SimpleAuthenticationInfo的第一个参数。shiro-redis将根据RedisCacheManagerprincipalIdFieldName属性值从第一个参数中获取id值作为redis中数据的key的一部分。

/**
 * 认证
 * @param token
 * @return
 * @throws AuthenticationException
 */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

    if(token==null){
        return null;
    }
    String principal = (String) token.getPrincipal();
    User user = userService.findByUsername(principal);
    SimpleAuthenticationInfo simpleAuthenticationInfo = new MyAuthcInfo(
            //由于shiro-redis插件需要从这个属性中获取id作为redis的key
            //所有这里传的是user而不是username
            user,
            //凭证信息
            user.getPassword(),
            //加密盐值
            new CurrentSalt(user.getSalt()),
            getName());
    
    return simpleAuthenticationInfo;
}

并修改MySQLRealm中的doGetAuthorizationInfo方法,从User对象中获取主身份信息。

/**
 * 授权
 * @param principals
 * @return
 */
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
   User user = (User) principals.getPrimaryPrincipal();
    String username = user.getUsername();
    List<Role> roleList = roleService.findByUsername(username);
    SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
    for (Role role : roleList) {
        authorizationInfo.addRole(role.getRoleName());
    }
    List<Long> roleIdList  = new ArrayList<>();
    for (Role role : roleList) {
        roleIdList.add(role.getRoleId());
    }

    List<Resource> resourceList = resourceService.findByRoleIds(roleIdList);
    for (Resource resource : resourceList) {
        authorizationInfo.addStringPermission(resource.getResourcePermissionTag());
    }
    return authorizationInfo;
}
自定义Salt

由于Shiro里面默认的SimpleByteSource没有实现序列化接口,导致ByteSource.Util.bytes()生成的salt在序列化时出错,因此需要自定义Salt类并实现序列化接口。并在自定义的Realm的认证方法使用new CurrentSalt(user.getSalt())传入盐值。

/**由于shiro当中的ByteSource没有实现序列化接口,缓存时会发生错误
 * 因此,我们需要通过自定义ByteSource的方式实现这个接口
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/10/8 16:17
 */
public class CurrentSalt extends SimpleByteSource implements Serializable {
    public CurrentSalt(String string) {
        super(string);
    }

    public CurrentSalt(byte[] bytes) {
        super(bytes);
    }

    public CurrentSalt(char[] chars) {
        super(chars);
    }

    public CurrentSalt(ByteSource source) {
        super(source);
    }

    public CurrentSalt(File file) {
        super(file);
    }

    public CurrentSalt(InputStream stream) {
        super(stream);
    }
}

添加Shiro自定义会话

添加自定义会话ID生成器

/**SessionId生成器
 * <p>@author 赖柄沣 laibingf_dev@outlook.com</p>
 * <p>@date 2020/8/15 15:19</p>
 */
public class ShiroSessionIdGenerator implements SessionIdGenerator {

    /**
     *实现SessionId生成
     * @param session
     * @return
     */
    @Override
    public Serializable generateId(Session session) {
        Serializable sessionId = new JavaUuidSessionIdGenerator().generateId(session);
        return String.format("login_token_%s", sessionId);
    }
}

添加自定义会话管理器

/**
 * <p>@author 赖柄沣 laibingf_dev@outlook.com</p>
 * <p>@date 2020/8/15 15:40</p><div class="aritcle_card flexRow">
                                                        <div class="artcardd flexRow">
                                                                <a class="aritcle_card_img" href="/xiazai/code/11168" title="Shoping购物网源码"><img
                                                                                src="https://img.php.cn/upload/webcode/000/000/008/176519520225383.jpg" alt="Shoping购物网源码"  onerror="this.onerror='';this.src='/static/lhimages/moren/morentu.png'" ></a>
                                                                <div class="aritcle_card_info flexColumn">
                                                                        <a href="/xiazai/code/11168" title="Shoping购物网源码">Shoping购物网源码</a>
                                                                        <p>该系统采用多层模式开发,这个网站主要展示女装的经营,更易于网站的扩展和后期的维护,同时也根据常用的SQL注入手段做出相应的防御以提高网站的安全性,本网站实现了购物车,产品订单管理,产品展示,等等,后台实现了动态权限的管理,客户管理,订单管理以及商品管理等等,前台页面设计精致,后台便于操作等。实现了无限子类的添加,实现了动态权限的管理,支持一下一个人做的辛苦</p>
                                                                </div>
                                                                <a href="/xiazai/code/11168" title="Shoping购物网源码" class="aritcle_card_btn flexRow flexcenter"><b></b><span>下载</span> </a>
                                                        </div>
                                                </div>
 */
public class ShiroSessionManager extends DefaultWebSessionManager {

    //定义常量
    private static final String AUTHORIZATION = "Authorization";
    private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";
    //重写构造器
    public ShiroSessionManager() {
        super();
        this.setDeleteInvalidSessions(true);
    }

    /**
     * 重写方法实现从请求头获取Token便于接口统一
     *      * 每次请求进来,
     *      Shiro会去从请求头找Authorization这个key对应的Value(Token)
     * @param request
     * @param response
     * @return
     */
    @Override
    public Serializable getSessionId(ServletRequest request, ServletResponse response) {
        String token = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
        //如果请求头中存在token 则从请求头中获取token
        if (!StringUtils.isEmpty(token)) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return token;
        } else {
            // 这里禁用掉Cookie获取方式
            return null;
        }
    }
}

配置自定义会话管理器

在ShiroConfig中添加对会话管理器的配置

/**
 * SessionID生成器
 *
 */
@Bean
public ShiroSessionIdGenerator sessionIdGenerator(){
    return new ShiroSessionIdGenerator();
}

/**
 * 配置RedisSessionDAO
 */
@Bean
public RedisSessionDAO redisSessionDAO() {
    RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
    redisSessionDAO.setRedisManager(redisManager());
    redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
    redisSessionDAO.setKeyPrefix(SESSION_KEY);
    redisSessionDAO.setExpire(EXPIRE);
    return redisSessionDAO;
}

/**
 * 配置Session管理器
 * @Author Sans
 *
 */
@Bean
public SessionManager sessionManager() {
    ShiroSessionManager shiroSessionManager = new ShiroSessionManager();
    shiroSessionManager.setSessionDAO(redisSessionDAO());
    //禁用cookie
    shiroSessionManager.setSessionIdCookieEnabled(false);
    //禁用会话id重写
    shiroSessionManager.setSessionIdUrlRewritingEnabled(false);
    return shiroSessionManager;
}

目前最新版本(1.6.0)中,session管理器的setSessionIdUrlRewritingEnabled(false)配置没有生效,导致没有认证直接访问受保护资源出现多次重定向的错误。将shiro版本切换为1.5.0后就解决了这个bug。

本来这篇文章应该是昨晚发的,因为这个原因搞了好久,所有今天才发。。。

修改自定义Realm的doGetAuthenticationInfo认证方法

在认证信息返回前,我们需要做一个判断:如果当前用户已在旧设备上登录,则需要将旧设备上的会话id删掉,使其下线。

/**
 * 认证
 * @param token
 * @return
 * @throws AuthenticationException
 */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

    if(token==null){
        return null;
    }
    String principal = (String) token.getPrincipal();
    User user = userService.findByUsername(principal);
    SimpleAuthenticationInfo simpleAuthenticationInfo = new MyAuthcInfo(
            //由于shiro-redis插件需要从这个属性中获取id作为redis的key
            //所有这里传的是user而不是username
            user,
            //凭证信息
            user.getPassword(),
            //加密盐值
            new CurrentSalt(user.getSalt()),
            getName());

    //清除当前主体旧的会话,相当于你在新电脑上登录系统,把你之前在旧电脑上登录的会话挤下去
    ShiroUtils.deleteCache(user.getUsername(),true);
    return simpleAuthenticationInfo;
}

修改login接口

我们将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。

@PostMapping("/login")
public HashMap<Object, Object> login(@RequestBody LoginVO loginVO) throws AuthenticationException {
    boolean flags = authcService.login(loginVO);
    HashMap<Object, Object> map = new HashMap<>(3);
    if (flags){
        Serializable id = SecurityUtils.getSubject().getSession().getId();
        map.put("msg","登录成功");
        map.put("token",id);
        return map;
    }else {
        return null;
    } 
}

添加全局异常处理

/**shiro异常处理
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/10/7 18:01
 */
@ControllerAdvice(basePackages = "pers.lbf.springbootshiro")
public class AuthExceptionHandler {

    //==================认证异常====================//

    @ExceptionHandler(ExpiredCredentialsException.class)
    @ResponseBody
    public String expiredCredentialsExceptionHandlerMethod(ExpiredCredentialsException e) {
        return "凭证已过期";
    }

    @ExceptionHandler(IncorrectCredentialsException.class)
    @ResponseBody
    public String incorrectCredentialsExceptionHandlerMethod(IncorrectCredentialsException e) {
        return "用户名或密码错误";
    }

    @ExceptionHandler(UnknownAccountException.class)
    @ResponseBody
    public String unknownAccountExceptionHandlerMethod(IncorrectCredentialsException e) {
        return "用户名或密码错误";
    }

    
    @ExceptionHandler(LockedAccountException.class)
    @ResponseBody
    public String lockedAccountExceptionHandlerMethod(IncorrectCredentialsException e) {
        return "账户被锁定";
    }

    //=================授权异常=====================//

    @ExceptionHandler(UnauthorizedException.class)
    @ResponseBody
    public String unauthorizedExceptionHandlerMethod(UnauthorizedException e){
        return "未授权!请联系管理员授权";
    }
}

实际开发中,应该对返回结果统一化,并给出业务错误码。这已经超出了本文的范畴,如有需要,请根据自身系统特点考量。

进行测试

认证

登录成功的情况

Springboot如何实现认证和动态权限管理

用户名或密码错误的情况

Springboot如何实现认证和动态权限管理

为了安全起见,不要暴露具体是用户名错误还是密码错误。

访问受保护资源

认证后访问有权限的资源

Springboot如何实现认证和动态权限管理

认证后访问无权限的资源

Springboot如何实现认证和动态权限管理

未认证直接访问的情况

Springboot如何实现认证和动态权限管理

查看redis

Springboot如何实现认证和动态权限管理

三个键值分别对应认证信息缓存、授权信息缓存和会话信息缓存。

热门AI工具

更多
DeepSeek
DeepSeek

幻方量化公司旗下的开源大模型平台

豆包大模型
豆包大模型

字节跳动自主研发的一系列大型语言模型

通义千问
通义千问

阿里巴巴推出的全能AI助手

腾讯元宝
腾讯元宝

腾讯混元平台推出的AI助手

文心一言
文心一言

文心一言是百度开发的AI聊天机器人,通过对话可以生成各种形式的内容。

讯飞写作
讯飞写作

基于讯飞星火大模型的AI写作工具,可以快速生成新闻稿件、品宣文案、工作总结、心得体会等各种文文稿

即梦AI
即梦AI

一站式AI创作平台,免费AI图片和视频生成。

ChatGPT
ChatGPT

最最强大的AI聊天机器人程序,ChatGPT不单是聊天机器人,还能进行撰写邮件、视频脚本、文案、翻译、代码等任务。

相关专题

更多
如何配置Tomcat环境变量
如何配置Tomcat环境变量

配置Tomcat环境变量需要在系统中添加CATALINA_HOME变量,并将Tomcat的安装路径添加到PATH变量中。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

117

2023.10.26

idea如何集成Tomcat
idea如何集成Tomcat

idea集成Tomcat的步骤:1、添加Tomcat服务器配置;2、配置项目部署;3、运行Tomcat服务器;4、访问项目;5、注意事项;6、关闭Tomcat服务器。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

173

2024.02.23

怎么查看Tomcat源代码
怎么查看Tomcat源代码

查看Tomcat源代码的步骤:1、下载Tomcat源代码;2、在IDEA中导入Tomcat源代码;3、查看源代码;4、理解Tomcat的工作原理;5、参与社区和贡献;6、注意事项;7、持续学习和更新;8、使用工具和插件。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

98

2024.02.23

常见的tomcat漏洞有哪些
常见的tomcat漏洞有哪些

常见的tomcat漏洞有:1、跨站脚本攻击;2、跨站请求伪造;3、目录遍历漏洞;4、缓冲区溢出漏洞;5、配置漏洞;6、第三方组件漏洞。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

170

2024.02.23

tomcat日志乱码怎么解决
tomcat日志乱码怎么解决

tomcat日志乱码的解决办法:1、修改tomcat的日志编码设置;2、检查ide的编码设置;3、检查操作系统的编码设置;4、使用过滤器处理日志;5、检查外部系统的编码设置;6、检查文件编码方式等等。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

158

2024.02.23

weblogic和tomcat有哪些区别
weblogic和tomcat有哪些区别

weblogic和tomcat的区别:1、功能;2、性能;3、规模;4、价格;5、安全性;6、配置和管理;7、社区支持;8、集成能力;9、升级和更新;10、可靠性。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

200

2024.02.23

tomcat和nginx有哪些区别
tomcat和nginx有哪些区别

tomcat和nginx的区别:1、应用领域;2、性能;3、功能;4、配置;5、安全性;6、扩展性;7、部署复杂性;8、社区支持;9、成本;10、日志管理。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

244

2024.02.23

tomcat启动闪退怎么解决
tomcat启动闪退怎么解决

tomcat启动闪退的解决办法:1、检查java环境;2、检查环境变量配置;3、检查端口被占用;4、检查配置文件编码;5、检查启动时需要的配置文件;6、检查相关文件是否丢失;7、检查防火墙和杀毒软件设置。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

169

2024.02.23

Go高并发任务调度与Goroutine池化实践
Go高并发任务调度与Goroutine池化实践

本专题围绕 Go 语言在高并发任务处理场景中的实践展开,系统讲解 Goroutine 调度模型、Channel 通信机制以及并发控制策略。内容包括任务队列设计、Goroutine 池化管理、资源限制控制以及并发任务的性能优化方法。通过实际案例演示,帮助开发者构建稳定高效的 Go 并发任务处理系统,提高系统在高负载环境下的处理能力与稳定性。

22

2026.03.10

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
Redis6入门到精通超详细教程
Redis6入门到精通超详细教程

共47课时 | 5.6万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号