IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> 前后分离项目整合spring security,自定义登录验证接口,并解决maximumSessions(1)不生效的问题 -> 正文阅读

[Java知识库]前后分离项目整合spring security,自定义登录验证接口,并解决maximumSessions(1)不生效的问题

目录

一、引入依赖

二、编写核心配置类

1、配置类注解,以及继承父类

2、在配置类中注入所需属性

3、配置密码加密所需的bean

4、编写核心配置

5、配置登录所需的service类,以及实现加密的对象

6、解决跨域问题,并在上面的核心配置方法中配置

7、对并发session进行管理

三、定义一个保存用户信息的类,需要继承 UserDetails 接口

四、定义一个类实现UserDetailsService接口

五、定义一个类继承UsernamePasswordAuthenticationFilter

1、定义实现类,并继承接口

2、定义相关属性

3、通过构造方法来接收传入属性值

4、重写attemptAuthentication()方法,用于获取请求的json数据,实现登录功能

5、重写登录成功方法

6、重写登录失败方法

7、响应工具类

六、重写两个异常类

七、重写用户退出的处理类

八、重写未登录导致未授权的处理方法

九、定义一个类继承ConcurrentSessionFilter

十、定义一个全局异常处理类

十一、补充知识

十二、最终说明

1、Result 类

2、响应状态码

3、token问题

4、本篇文章参考


一、引入依赖

首先引入 spring security 启动器的依赖

<!-- spring security 启动器-->
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-security</artifactId>
</dependency>

二、编写核心配置类

1、配置类注解,以及继承父类

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecuConfig extends WebSecurityConfigurerAdapter{

}

2、在配置类中注入所需属性

将业务所需属性全部注入,除 redis 其他都是必须注入(我这里需要用到缓存),loginService 是自己登录实体类的service接口,根据自己项目来命名

如果 redis 不知道怎么配置,可以查看 =>? 整合redis并配置mybatis的二级缓存

    // 在配置类中注入所需属性,传入Bean对象
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired  // redis
    private StringRedisTemplate redisTemplate;

    @Autowired
    private UserDetailsServiceImpl userDetailsServiceImpl;

    @Autowired // 用户实体类的service
    private LoginService loginService;

    private SessionRegistry sessionRegistry = new SessionRegistryImpl();

3、配置密码加密所需的bean

我这里没有打开密码加密,需要的话解开这个注释即可 //return new BCryptPasswordEncoder();

// 配置密码加密所需的bean
@Bean
public PasswordEncoder getPasswordEncoder(){
    //return new BCryptPasswordEncoder();
    return NoOpPasswordEncoder.getInstance();
}

4、编写核心配置

其中配置了未登录导致授权失败的处理类、登出需要访问的路径、登出的处理类、以及一些过滤器、跨域的相关处理等。
我原先在这里配置了sessionManagement().maximumSessions(1),也就是不允许一个账号同时在多个地方登录,一个账号只能拥有一个session对象,但是配置了以后始终不生效,所以在后面的其他类里实现了

我这里没有加入登出相关类,因为在前端实现了,并不需要后端登出

放行的请求一般都是登录有关的请求,因为此时还没有session,如果不放行将无法访问

// 编写核心配置
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling()
                .authenticationEntryPoint(new UnauthorizedEntryPoint())
                .and().csrf().disable() //关闭csrf
                .authorizeRequests().antMatchers(
                        "/basic-api/**",
                        "/sys/basic-api/registerDept/tree").permitAll()  //放行的请求路径
                .anyRequest().authenticated()
                //.and().logout(logout -> logout.deleteCookies("JSESSIONID")).logout().logoutUrl("/sys/logout")
                //.addLogoutHandler(new TokenLogoutHandler())
                .and()
                //传入bean对象给TokenLoginFilter
                .addFilter(new TokenLoginFilter(authenticationManager(),sessionRegistry,loginService,redisTemplate)) 
                .addFilter(new MyConcurrentSessionFilter(sessionRegistry))
                .cors().configurationSource(corsConfigurationSource());
    }

5、配置登录所需的service类,以及实现加密的对象

这里的service类需要后面编写自定义登录类来引入

// 配置登录所需的service类,以及实现加密的对象
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
     auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(getPasswordEncoder());
}

6、解决跨域问题,并在上面的核心配置方法中配置

// 解决跨域问题,并在上面的核心配置方法中配置
    private CorsConfigurationSource corsConfigurationSource() {
        CorsConfigurationSource source =   new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");    //同源配置,*表示任何请求都视为同源,若需指定ip和端口可以改为如“localhost:8080”,多个以“,”分隔;
        corsConfiguration.addAllowedHeader("*");//header,允许哪些header,本案中使用的是token,此处可将*替换为token;
        corsConfiguration.addAllowedMethod("*");    //允许的请求方法,PSOT、GET等
        ((UrlBasedCorsConfigurationSource) source).registerCorsConfiguration("/**",corsConfiguration); //配置允许跨域访问的url
        return source;
    }

7、对并发session进行管理

// 对并发session进行管理
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
     return new HttpSessionEventPublisher();
}

三、定义一个保存用户信息的类,需要继承 UserDetails 接口

直接传入自己登录用户的实体类,然后修改 getPassword()和 getUsername()两个方法返回的值

其他方法可以暂时全部默认返回为空,具体的是用户是否启用、是否有效等等判断,可以自行编辑逻辑,我就统一在自定义登陆类里做判断了

@Data
@NoArgsConstructor
@AllArgsConstructor
public class SecurityUser implements UserDetails {
    // 传入自己的登录用户实体类
    private LoginOper loginOper;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return loginOper.getPassword();
    }

    @Override
    public String getUsername() {
        return loginOper.getOperName();
    }

    // 暂时不经过判断,直接默认为true
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

}

四、定义一个类实现UserDetailsService接口

这里是整个 security 获取数据 => 查询信息验证 => 登录成功 => 登录失败 流程的第二步

重写loadUserByUsername()方法,用于查询用户信息,并保存到SecurityUser里面,进行返回

返回之后?security会自动根据查询出来的密码和表单输入的密码来判断是否登录成功

成功则直接调用成功方法,失败则调用失败方法,无需自己判断密码

以下其他部分是我自己编写的业务逻辑,根据传入的用户名称查询用户信息,然后判断用户状态,密码状态等等,抛出相应的异常(用重写登陆失败方法来接收判断返回给前端)

这个类就是需要引入到核心配置类当中的

@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private LoginService loginService;

    /***
     * 根据用户名获取用户信息
     * @param operName
     * @return: org.springframework.security.core.userdetails.UserDetails
     */
    // 第二步:验证用户名状态
    @SneakyThrows
    @Override
    public UserDetails loadUserByUsername(String operName) {
        // 从数据库中取出用户信息
        LoginOper loginOper = loginService.getOne(new QueryWrapper<LoginOper>().eq("oper_name", operName));
        SimpleDateFormat formatter = new SimpleDateFormat("dd-MM-yyyy");
        String date = formatter.format(new Date());
        // 判断用户是否存在
        if (loginOper == null){
            throw new UsernameNotFoundException("用户名不存在,请重新输入!");
        }
        // 判断用户状态是否已停用
        else if("0".equals(loginOper.getOperState())) {
            throw new UserCountLockException("该用户账号已停用");
        }
        // 判断用户密码状态是否正常,0为失效,1为正常,2为过期,3为冻结,4为初始化
        else if(!"1".equals(loginOper.getPasswordState())) {
            if("0".equals(loginOper.getPasswordState())){
                throw new UserCountLockException("该用户密码已失效,请联系管理员");
            }else if ("2".equals(loginOper.getPasswordState())){
                throw new UserCountLockException("该用户密码已过期,请修改后再登陆");
            }else if ("3".equals(loginOper.getPasswordState())){
                throw new UserCountLockException("该用户密码已冻结,请联系管理员");
            }else if ("4".equals(loginOper.getPasswordState())){
                throw new UserCountLockException("该用户密码已初始化,请修改后再登录");
            }
        }
        // 判断用户当日密码错误次数是否大于5次
        else if(loginOper.getPasswordWrongnum() >= 5) {
            // 已冻结
            if(!"3".equals(loginOper.getPasswordState())){
                loginOper.setPasswordState("3");
                loginService.updateById(loginOper);
            }
            throw new UserCountLockException("当日密码错误次数大于5次,账户已冻结");
        }
        // 判断用户是否过期
        else if(formatter.parse(formatter.format(loginOper.getPasswordExpireDate())).before(formatter.parse(date))) {
            // 已过期,将密码状态修改为2
            if(!"2".equals(loginOper.getPasswordState())){
                loginOper.setPasswordState("2");
                loginService.updateById(loginOper);
            }
            throw new UserCountLockException("该用户密码已过期,请修改后再登陆");
        }
        // 返回UserDetails实现类
        SecurityUser securityUser = new SecurityUser(loginOper);
        return securityUser;
    }

}

五、定义一个类继承UsernamePasswordAuthenticationFilter

由于当前项目使用json格式获取用户登录的手机号和密码,但是springsecurity默认不支持json格式登录,所以只能自己去重写过滤器,定义一个类继承UsernamePasswordAuthenticationFilter,也用于配置登录相关信息,解决maximumSessions(1)不生效的问题

1、定义实现类,并继承接口

public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter

2、定义相关属性

//用于存放表单输入的用户名
private String operName = "";

// 用于接收传进来的bean对象
private StringRedisTemplate redisTemplate;

private AuthenticationManager authenticationManager;

private LoginService loginService;

private SessionRegistry sessionRegistry;

3、通过构造方法来接收传入属性值

接收传过来的Bean对象,指定登录接口路径和请求方式,并且调用了并发session的api,用于解决在核心配置类当中maximumSessions(1)不生效的问题,因为重写过滤器会覆盖springsecurity原有的逻辑,并发session只能自己调用方法去实现。

public TokenLoginFilter(AuthenticationManager authenticationManager,SessionRegistry sessionRegistry,LoginService loginService,StringRedisTemplate redisTemplate) {
        this.authenticationManager = authenticationManager;
        this.sessionRegistry = sessionRegistry;
        this.loginService = loginService;
        this.redisTemplate = redisTemplate;
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/basic-api/_user/login","POST"));
        //设置一个账号只能拥有一个session对象
        ConcurrentSessionControlAuthenticationStrategy sessionStrategy=new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
        sessionStrategy.setMaximumSessions(1);
        this.setSessionAuthenticationStrategy(sessionStrategy);
    }

4、重写attemptAuthentication()方法,用于获取请求的json数据,实现登录功能

这里是整个 security 获取数据 => 查询信息验证 => 登录成功 => 登录失败 流程的第一步

security会在请求拦截器之前执行

// 第一步:获取表单信息,security会在请求拦截器之前
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
            throws AuthenticationException {
        //获取表单提交的数据
        Map map = new ObjectMapper().readValue(req.getInputStream(), Map.class);
        String operName = (String) map.get("operName");
        String password = (String) map.get("password");
        this.operName = operName;
        // 最终交给security校验密码
        System.out.println("账号为:"+operName);
        return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(operName, password));
    }

5、重写登录成功方法

这里是整个 security 获取数据 => 查询信息验证 => ???????登录成功 => 登录失败 流程的第三步

这里面主要编写?密码验证成功(登录成功)后的一些逻辑,例如下面我自己写的一些业务逻辑

登录成功后,获取securityUser对象,然后根据其中的用户id和用户等级查询数据库,得到用户的权限,再保存当前已认证的用户信息。其中,这行代码很重要

SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(securityUser,securityUser.getId(),authorities));

用于保存已认证的用户信息,并自动加入session缓存,springsecurity就是根据这里面的信息获取当前用户的权限,也可以在其他地方调用SecurityContextHolder类,来获取已登录的用户信息

// 第三步:重写登录成功方法:也可以在此做业务判断,将用户信息返回到前端,再做判断,但是不安全
    @SneakyThrows
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // 判断得到的用户信息是否为空,如果为空说明用户名不存在
        if(authResult.getPrincipal() == null){
            ResponseUtil.out(response,null);
        }else {
            SecurityUser securityUser = (SecurityUser) authResult.getPrincipal();
            LoginOper loginOper = new LoginOper(securityUser.getLoginOper());
            // 用户权限,暂时设定为空
            List<GrantedAuthority> authorities = null;
            SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(securityUser,securityUser.getLoginOper().getOperNo(),authorities));
            sessionRegistry.registerNewSession(request.getSession().getId(),securityUser);
            // 登录成功,将密错误次数清零
            loginOper.setPasswordWrongnum(0);
            // 查询该用户所拥有的角色id,并插入
            List<Integer> roleIdHaveList = loginService.getRoleIdHaveList(loginOper.getOperNo());
            loginOper.setRoleList(roleIdHaveList);
            ResponseUtil.out(response,loginOper);
            // 登录成功,记录本次成功时间和成功状态,必须在最后set,否则会将本次登录时间状态信息返回给前端,前端应展示上次登录时间状态信息
            loginOper.setLastLoginDate(new Date());
            loginOper.setLastLoginState("1");
            loginService.updateById(loginOper);
        }
    }

6、重写登录失败方法

这里是整个 security 获取数据 => 查询信息验证 => 登录成功 => 登录失败 流程的第四步

这里面主要编写?密码验证失败、用户名不存在等等(登录失败)后的一些逻辑,例如下面我自己写的一些业务逻辑

由于我在自定义登录验证信息类中加了其他逻辑,抛出了很多其他不同信息的异常,在这里都可以做一个判断,判断具体是哪种异常,然后反馈给前端。

如果是默认“Bad credentials”信息,而不是我自己抛出的异常信息,那就只有一种可能,是密码错误。如果不抛出不同的错误信息,那“Bad credentials”就有很多种可能,从而无法判断具体是什么原因导致登陆失败(用户名不存在、密码错误等等)

 // 第四步:重写登录失败方法
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException e) throws IOException, ServletException {
        System.err.println("账号为"+this.operName);
        LoginOper loginOper = loginService.getOne(new QueryWrapper<LoginOper>().eq("oper_name", this.operName));
        // 登录失败,记录本次失败时间和失败状态
        loginOper.setLastLoginDate(new Date());
        loginOper.setLastLoginState("0");
        if("Bad credentials".equals(e.getMessage())){
            int num = 0;
            // 密码错误,增加错误次数, 如果修改后大于5,则修改密码状态为3
            if(loginOper.getPasswordWrongnum() < 5){
                if(loginOper.getPasswordWrongnum() + 1 >= 5){
                    loginOper.setPasswordState("3");
                }
                loginOper.setPasswordWrongnum(loginOper.getPasswordWrongnum() + 1);
            }else {
                loginOper.setPasswordState("3");
            }
            num = 5 - loginOper.getPasswordWrongnum();
            ResponseUtil.out(response,412,new Result(1,"密码错误,"+(num != 0?"密码错误5次将冻结密码,今日还剩"+num+"次":"密码已冻结"),"密码错误,密码错误次数至5次将冻结账户,今日还剩"+num+"次"));
        }
        // 其他信息验证错误
        else {
            ResponseUtil.out(response,412,new Result(1,e.getMessage(),e.getMessage()));
        }
        loginService.updateById(loginOper);
    }

用于向前台输出错误信息,这里用到了响应的工具类

7、响应工具类

这里我写了两种构造方法,具体可以根据自己的业务需求来灵活编写

  • 自定义状态码,且返回自定义Result类
  • 固定返回200状态码,且固定返回 LoginIper 类(登录用户实体类)
public class ResponseUtil {

    public static void out(HttpServletResponse response,int state, Result result) {
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(state);
        response.setContentType("text/json;charset=UTF-8");
        try {
            mapper.writeValue(response.getWriter(), result);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void out(HttpServletResponse response, LoginOper loginOper) {
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(HttpStatus.OK.value()); // 永远返回 200
        response.setContentType("text/json;charset=UTF-8");
        try {
            mapper.writeValue(response.getWriter(), loginOper);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

到这里,整个登录过滤器就写完了,添加到核心配置类即可

六、重写两个异常类

自定义重写用户失效异常,和用户不存在异常,用于在用户信息验证方法类中抛出相应异常,然后在登录失败方法中去接收判断,都需要继承 AuthenticationException 接口才可以

/**
 * 自定义用户失效异常
 */
public class UserCountLockException extends AuthenticationException {

    public UserCountLockException(String msg,Throwable t){
        super(msg, t);
    }

    public UserCountLockException(String msg){
        super(msg);
    }
}
/**
 * 自定义用户不存在异常类
 */
public class UsernameNotFoundException extends AuthenticationException {
    public UsernameNotFoundException(String msg, Throwable cause) {
        super(msg, cause);
    }

    public UsernameNotFoundException(String msg) {
        super(msg);
    }
}

七、重写用户退出的处理类

这里我没有用到这个类,可以根据业务需求自行添加,然后添加到核心配置类当中。

public class TokenLogoutHandler implements LogoutHandler {

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        ResponseUtil.out(response,200, new Result(1,"成功退出","成功退出"));
    }

}

八、重写未登录导致未授权的处理方法

重写未登录导致未授权的处理方法,向前台输出未登录的信息。添加到核心配置类当中。

主要用于已经失去权限,或者权限过期却依旧停留在主页面没有退出的情况,再次访问就会提示未登录没有权限。

public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        ResponseUtil.out(response,409,new Result(1,"未登录没有权限","未登录没有权限"));
    }
}

九、定义一个类继承ConcurrentSessionFilter

用于解决maximumSessions(1)不生效的问题,并重写同一个账号在多个地方登录后被踢下线的处理逻辑,向前台输出未登录的信息

public class MyConcurrentSessionFilter extends ConcurrentSessionFilter {

    public MyConcurrentSessionFilter(SessionRegistry sessionRegistry) {
        super(sessionRegistry,event -> {
            HttpServletResponse response = event.getResponse();
            ResponseUtil.out(response,411,new Result(1,"该账号已在别处登录","该账号已在别处登录"));
        });
    }
}

十、定义一个全局异常处理类

用于处理用户名不存在、用户没有权限访问的处理逻辑,向前台输出提示信息

@RestControllerAdvice
public class SecurityExceptionHandler {

    // 无用
    @ExceptionHandler(UsernameNotFoundException.class)
    public Result error(HttpServletRequest request, HttpServletResponse response, UsernameNotFoundException e){
        response.setStatus(413);  // 用户名不存在
        e.printStackTrace();
        return new Result(1,"用户名不存在",null);
    }
    // 防止身份过期,session和请求拦截的token依旧没有使用户退出界面或者拦截请求
    @ExceptionHandler(AccessDeniedException.class)
    public Result error(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e){
        response.setStatus(410);  // 未登录,身份验证过期失效
        e.printStackTrace();
        return new Result(1,"未登录","未登录");
    }
}

十一、补充知识

springsecurity会在每次访问时,通过过滤器对用户进行授权,如果要在授权时执行一定的逻辑,需要定义一个类,继承BasicAuthenticationFilter,并重写doFilterInternal方法,最后需要保存已认证的用户信息

SecurityContextHolder.getContext().setAuthentication(authentication);

根据情况,判断是否执行下一个过滤链

chain.doFilter(req, res);

本项目中不需要做这些。

十二、最终说明

1、Result 类

本项目用的 Result 类是我自定义的一个接口返回类,专门用于向前端返回数据,具体可以自行灵活定义使用

定义如下:

@Data
@AllArgsConstructor
public class Result {

    private Integer code;
    private String message;
    private Object result;

}

2、响应状态码

代码中我写了很多奇奇怪怪的状态码,是因为想要让前端可以更好的处理特殊自定义状态码,呈现特殊的页面效果反馈给用户,具体可以自行灵活使用

3、token问题

本篇博客中没有涉及到token的原因并非不用,而是没有放在登录验证处理代码中,我的业务需求是登录后还需要进行身份验证(手机短信验证),所以我把token处理放在了身份验证里面,需要的话可以自行在登录成功方法中生成token并添加到redis中

建议加上token,配合请求拦截,这样系统会更加安全

如果不知道怎么配置 token,可以查看文章 => 整合JWT配和请求拦截器进行安全校验

4、本篇文章参考:

SpringSecurity用法详解,解决maximumSessions(1)不生效的问题_woshihedayu的博客-CSDN博客_maximumsessions没有用

?对其做出了更加细节化和描述,添加了更多业务需求操作。

  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-09-21 00:12:55  更:2022-09-21 00:13:25 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/23 13:24:45-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码