【尚筹网】IDEA版实现(七)权限控制

由于尚硅谷的视频是通过Eclipse软件来做的,其中有些操作与IDEA上有所区别,所以我在这里将IDEA区别于Eclipse的操作、操作过程中涉及的源码(与视频的源码略有出入)以及大家可能遇到的种种问题分享给大家,这些源码在我这里均是通过测试的,仅供参考!

1 SpringSecurity环境搭建

修改parent\pom.xml

<properties>
        <!-- 声明属性, 对 Spring 的版本进行统一管理 -->
        <!-- spring.version是别名,随便起,但要跟dependences标签中一致 -->
        <spring.version>5.2.11.RELEASE</spring.version>
        <!-- 声明属性, 对 SpringSecurity 的版本进行统一管理 -->
        <spring.security.version>5.4.2</spring.security.version>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
    </properties>
<!-- SpringSecurity 对 Web 应用进行权限管理 -->
<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-web -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-web</artifactId>
    <version>4.2.10.RELEASE</version>
</dependency>
<!-- SpringSecurity 配置 -->
<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-config -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
    <version>4.2.10.RELEASE</version>
</dependency>
<!-- SpringSecurity 标签库 -->
<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-taglibs -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-taglibs</artifactId>
    <version>4.2.10.RELEASE</version>
</dependency>

修改component\pom.xml

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-taglibs</artifactId>
</dependency>

修改webui\src\main\webapp\WEB-INF\web.xml

<!-- SpringSecurity 的配置,filter-name必须是springSecurityFilterChain -->
<filter>
    <filter-name>springSecurityFilterChain</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>springSecurityFilterChain</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

新建component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

package com.atguigu.security.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity 
public class WebApplicationConfig extends WebSecurityConfigurerAdapter {
}

2 SSM与SpringSecurity整合

新建component\src\main\java\org\springframework\web\filter\DelegatingFilterProxy.java

package org.springframework.web.filter;

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

import java.io.IOException;
import javax.servlet.*;

import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.servlet.FrameworkServlet;

public class DelegatingFilterProxy extends GenericFilterBean {
    @Nullable
    private String contextAttribute;
    @Nullable
    private WebApplicationContext webApplicationContext;
    @Nullable
    private String targetBeanName;
    private boolean targetFilterLifecycle;
    @Nullable
    private volatile Filter delegate;
    private final Object delegateMonitor;

    public DelegatingFilterProxy() {
        this.targetFilterLifecycle = false;
        this.delegateMonitor = new Object();
    }

    public DelegatingFilterProxy(Filter delegate) {
        this.targetFilterLifecycle = false;
        this.delegateMonitor = new Object();
        Assert.notNull(delegate, "Delegate Filter must not be null");
        this.delegate = delegate;
    }

    public DelegatingFilterProxy(String targetBeanName) {
        this(targetBeanName, (WebApplicationContext)null);
    }

    public DelegatingFilterProxy(String targetBeanName, @Nullable WebApplicationContext wac) {
        this.targetFilterLifecycle = false;
        this.delegateMonitor = new Object();
        Assert.hasText(targetBeanName, "Target Filter bean name must not be null or empty");
        this.setTargetBeanName(targetBeanName);
        this.webApplicationContext = wac;
        if (wac != null) {
            this.setEnvironment(wac.getEnvironment());
        }

    }

    public void setContextAttribute(@Nullable String contextAttribute) {
        this.contextAttribute = contextAttribute;
    }

    @Nullable
    public String getContextAttribute() {
        return this.contextAttribute;
    }

    public void setTargetBeanName(@Nullable String targetBeanName) {
        this.targetBeanName = targetBeanName;
    }

    @Nullable
    protected String getTargetBeanName() {
        return this.targetBeanName;
    }

    public void setTargetFilterLifecycle(boolean targetFilterLifecycle) {
        this.targetFilterLifecycle = targetFilterLifecycle;
    }

    protected boolean isTargetFilterLifecycle() {
        return this.targetFilterLifecycle;
    }

    @Override
    protected void initFilterBean() throws ServletException {
        synchronized(this.delegateMonitor) {
            if (this.delegate == null) {
                if (this.targetBeanName == null) {
                    this.targetBeanName = this.getFilterName();
                }

//                WebApplicationContext wac = this.findWebApplicationContext();
//                if (wac != null) {
//                    this.delegate = this.initDelegate(wac);
//                }
            }

        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // Lazily initialize the delegate if necessary.
        Filter delegateToUse = this.delegate;
        if (delegateToUse == null) {
            synchronized (this.delegateMonitor) {
                delegateToUse = this.delegate;
                if (delegateToUse == null) {
                    // 把原来的查找 IOC 容器的代码注释掉
                    // WebApplicationContext wac = findWebApplicationContext();

                    // 按我们自己的需要重新编写
                    // 1.获取 ServletContext 对象
                    ServletContext sc = this.getServletContext();

                    // 2.拼接 SpringMVC 将 IOC 容器存入 ServletContext 域的时候使用的属性名
                    String servletName = "springDispatcherServlet";
                    String attrName = FrameworkServlet.SERVLET_CONTEXT_PREFIX + servletName;

                    // 3.根据 attrName 从 ServletContext 域中获取 IOC 容器对象
                    WebApplicationContext wac = (WebApplicationContext)sc.getAttribute(attrName);

                    if (wac == null) {
                        throw new IllegalStateException("No WebApplicationContext found: " +
                                "no ContextLoaderListener or DispatcherServlet registered?");
                    }
                    delegateToUse = initDelegate(wac);
                }
                this.delegate = delegateToUse;
            }
        }

        // Let the delegate perform the actual doFilter operation.
        invokeDelegate(delegateToUse, request, response, filterChain);
    }

    public void destroy() {
        Filter delegateToUse = this.delegate;
        if (delegateToUse != null) {
            this.destroyDelegate(delegateToUse);
        }

    }

    @Nullable
    protected WebApplicationContext findWebApplicationContext() {
        if (this.webApplicationContext != null) {
            if (this.webApplicationContext instanceof ConfigurableApplicationContext) {
                ConfigurableApplicationContext cac = (ConfigurableApplicationContext)this.webApplicationContext;
                if (!cac.isActive()) {
                    cac.refresh();
                }
            }

            return this.webApplicationContext;
        } else {
            String attrName = this.getContextAttribute();
            return attrName != null ? WebApplicationContextUtils.getWebApplicationContext(this.getServletContext(), attrName) : WebApplicationContextUtils.findWebApplicationContext(this.getServletContext());
        }
    }

    protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
        String targetBeanName = this.getTargetBeanName();
        Assert.state(targetBeanName != null, "No target bean name set");
        Filter delegate = (Filter)wac.getBean(targetBeanName, Filter.class);
        if (this.isTargetFilterLifecycle()) {
            delegate.init(this.getFilterConfig());
        }

        return delegate;
    }

    protected void invokeDelegate(Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        delegate.doFilter(request, response, filterChain);
    }

    protected void destroyDelegate(Filter delegate) {
        if (this.isTargetFilterLifecycle()) {
            delegate.destroy();
        }

    }
}

3 SpringSecurity配置

3.1 放行登录页和静态资源

修改component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

@Override
    protected void configure(HttpSecurity security) throws Exception {

        security
                .authorizeRequests()                            // 对请求进行授权
                .antMatchers("/admin/to/login/page.html")  // 针对登录页进行设置
                .permitAll()
                .antMatchers("/bootstrap/**")       // 针对静态资源进行设置, 无条件访问
                .permitAll()
                .antMatchers("/css/**")
                .permitAll()
                .antMatchers("/fonts/**")
                .permitAll()
                .antMatchers("/img/**")
                .permitAll()
                .antMatchers("/jquery/**")
                .permitAll()
                .antMatchers("/js/**")
                .permitAll()
                .antMatchers("/layer/**")
                .permitAll()
                .antMatchers("/script/**")
                .permitAll()
                .antMatchers("/ztree/**")
                .permitAll()
                .anyRequest()
                .authenticated();

    }

测试结果为可以正常登录http://localhost:8080/atcrowdfunding02_admin_webui_war_exploded/admin/to/login/page.html即可

3.2 提交登录表单做内存认证

修改webui\src\main\webapp\WEB-INF\admin-login.jsp

<form action="security/do/login.html" method="post" class="form-signin" role="form">
    <h2 class="form-signin-heading"><i class="glyphicon glyphicon-log-in"></i>管理员登录</h2>
    <p>${requestScope.exception.message}</p>
    <p>${SPRING_SECURITY_LAST_EXCEPTION.message }</p>
    <div class="form-group has-success has-feedback">
        <input type="text" name="loginAcct" class="form-control" id="inputSuccess4" placeholder="请输入登录账号" autofocus>
        <span class="glyphicon glyphicon-user form-control-feedback"></span>
    </div>
    <div class="form-group has-success has-feedback">
        <input type="text" name="userPswd" class="form-control" id="inputSuccess5" placeholder="请输入登录密码" style="margin-top:10px;">
        <span class="glyphicon glyphicon-lock form-control-feedback"></span>
    </div>
    <button type="submit" class="btn btn-lg btn-success btn-block">登录</button>
</form>

修改component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

@Override protected void configure(AuthenticationManagerBuilder builder) throws Exception {
    // 临时使用内存版登录的模式测试代码
    builder.inMemoryAuthentication().withUser("tom").password("123123").roles("ADMIN");
}
.and()
    .csrf()
    .disable()                               // 关闭防跨站请求伪造
    .formLogin()                                    // 开启表单登录功能
    .loginPage("/admin/to/login/page.html")                // 指定登录页面
    .loginProcessingUrl("/security/do/login.html")    // 指定处理登录请求的地址
    .defaultSuccessUrl("/admin/to/main.html")         // 指定登录成功后前往的地址
    .usernameParameter("loginAcct")                 // 账号的请求参数名称
    .passwordParameter("userPswd")                  // 密码的请求参数名称

注册拦截器关闭:修改webui\src\main\resources\spring-web-mvc.xml

<!--    &lt;!&ndash;注册拦截器&ndash;&gt;-->
<!--    <mvc:interceptors>-->
<!--        <mvc:interceptor>-->
<!--            &lt;!&ndash; mvc:mapping配置要拦截的资源&ndash;&gt;-->
<!--            &lt;!&ndash; /*对应一层路径,比如:/aaa &ndash;&gt;-->
<!--            &lt;!&ndash; /**对应多层路径,比如:/aaa/bbb或/aaa/bbb/ccc或/aaa/bbb/ccc/ddd &ndash;&gt;-->
<!--            <mvc:mapping path="/**"/>-->

<!--            &lt;!&ndash; mvc:exclude-mapping配置不拦截的资源 &ndash;&gt;-->
<!--            <mvc:exclude-mapping path="/admin/to/login/page.html"/>-->
<!--            <mvc:exclude-mapping path="/admin/do/login.html"/>-->
<!--            <mvc:exclude-mapping path="/admin/do/logout.html"/>-->
<!--            &lt;!&ndash;配置拦截器类&ndash;&gt;-->
<!--            <bean class="com.atguigu.crowd.mvc.interceptor.LoginInterceptor"/>-->
<!--        </mvc:interceptor>-->
<!--    </mvc:interceptors>-->

测试结果为可以正常登录http://localhost:8080/atcrowdfunding02_admin_webui_war_exploded/admin/to/main.html即可

3.3 退出登录

修改component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

.and()
    .logout()                                       // 退出登录功能
    .logoutUrl("/security/do/logout.html")
    .logoutSuccessUrl("/security/do/login.html")

修改webui\src\main\webapp\WEB-INF\include-nav.jsp

<li><a href="security/do/logout.do"><i class="glyphicon glyphicon-off"></i> 退出系统</a></li>

测试结果在进入main界面后右上角可以退出到login-page界面即可

3.4 把内存登录改成数据库登录

修改component\src\main\java\com\atguigu\crowd\service\api\AuthService.java

List<String> getAssignedAuthNameByAdminId(Integer adminId);

修改component\src\main\java\com\atguigu\crowd\service\impl\AuthServiceImpl.java

@Override
public List<String> getAssignedAuthNameByAdminId(Integer adminId) {

    return authMapper.selectAssignedAuthNameByAdminId(adminId);
}

并新增selectAssignedAuthNameByAdminId()方法

修改webui\src\main\resources\mybatis\mapper\AuthMapper.xml

<select id="selectAssignedAuthNameByAdminId" resultType="string">
    select distinct t_auth.name
    from t_auth
    left join inner_role_auth on t_auth.id=inner_role_auth.auth_id
    left join inner_admin_role iar on inner_role_auth.role_id = iar.role_id
    where iar.admin_id=#{adminId} AND t_auth.name != "" AND t_auth.name IS NOT NULL;
</select>

新建component\src\main\java\com\atguigu\crowd\mvc\config\SecurityAdmin.java

package com.atguigu.crowd.mvc.config;

import com.atguigu.crowd.entity.Admin;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.List;

// config包下
public class SecurityAdmin extends User {

    private static final long serialVersionUID = 1L;

    // 原始的 Admin 对象, 包含 Admin 对象的全部属性
    private Admin originalAdmin;

    public SecurityAdmin(Admin originalAdmin, List<GrantedAuthority> authorities) {
        super(originalAdmin.getLoginAcct(), originalAdmin.getUserPswd(), authorities);
        this.originalAdmin = originalAdmin;
    }

    // 对外提供的获取原始 Admin 对象的 getXxx()方法
    public Admin getOriginalAdmin() {
        return originalAdmin;
    }
}

修改component\src\main\java\com\atguigu\crowd\service\api\AdminService.java

Admin getAdminByLoginAcct(String username);

修改component\src\main\java\com\atguigu\crowd\service\impl\AdminServiceImpl.java

@Override
public Admin getAdminByLoginAcct(String username) {

    AdminExample example = new AdminExample();

    AdminExample.Criteria criteria = example.createCriteria();

    criteria.andLoginAcctEqualTo(username);

    return adminMapper.selectByExample(example).get(0);

}

修改component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

@Autowired
private UserDetailsService userDetailsService;

@Override protected void configure(AuthenticationManagerBuilder builder) throws Exception {
    //        // 临时使用内存版登录的模式测试代码
    //        builder.inMemoryAuthentication().withUser("tom").password("123123").roles("ADMIN");
    // 正式功能中使用基于数据库的认证 
    builder.userDetailsService(userDetailsService);
}

并将数据库中MD5加密的密码暂时改回来:

在这里插入图片描述

测试结果为可以正常登录http://localhost:8080/atcrowdfunding02_admin_webui_war_exploded/admin/to/main.html即可

3.5 密码加密

在SQLyog中修改t_admin表格:

ALTER TABLE t_admin MODIFY user_pswd CHAR(100) NOT NULL;

修改webui\src\main\resources\spring-persist-tx.xml

<!-- 配置 BCryptPasswordEncoder -->
<bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>

修改component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

@Autowired
private PasswordEncoder passwordEncoder;

@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
    //        // 临时使用内存版登录的模式测试代码
    //        builder.inMemoryAuthentication().withUser("tom").password("123123").roles("ADMIN");
    // 正式功能中使用基于数据库的认证
    builder
        .userDetailsService(userDetailsService)
        .passwordEncoder(passwordEncoder);
}

修改component\src\main\java\com\atguigu\crowd\service\impl\AdminServiceImpl.java

@Autowired
private BCryptPasswordEncoder passwordEncoder;

@Override
public void saveAdmin(Admin admin) {
    // 1 密码加密
    String userPswd = admin.getUserPswd();
    //        userPswd = CrowdUtil.md5(userPswd);
    userPswd = passwordEncoder.encode(userPswd);
    admin.setUserPswd(userPswd);
}

将t_admin密码改为盐值加密的密码:

在这里插入图片描述

测试结果为可以正常登录http://localhost:8080/atcrowdfunding02_admin_webui_war_exploded/admin/to/main.html即可

3.6 在页面上显示用户昵称

修改webui\src\main\webapp\WEB-INF\include-nav.jsp

<%@taglib prefix="security" uri="http://www.springframework.org/security/tags" %>
<%--${sessionScope.loginAdmin.userName}--%>
<security:authentication property="principal.originalAdmin.userName"/>

测试结果为登录http://localhost:8080/atcrowdfunding02_admin_webui_war_exploded/admin/to/main.html后在右上角成功显示用户昵称即可

3.7 密码的擦除

修改component\src\main\java\com\atguigu\crowd\mvc\config\SecurityAdmin.java

public SecurityAdmin(Admin originalAdmin, List<GrantedAuthority> authorities) {

    // 调用父类构造器
    super(originalAdmin.getLoginAcct(), originalAdmin.getUserPswd(), authorities);

    // 给本类的 this.originalAdmin 赋值
    this.originalAdmin = originalAdmin;

    // 将原始 Admin 对象中的密码擦除,密码已经传给父类User构造器,因此这里擦除不影响
    this.originalAdmin.setUserPswd(null);
}

3.8 权限控制

在SQLyog中修改t_auth表格:

INSERT INTO t_auth(id,`name`,title,category_id) VALUES(8,'user:save','保存',1);

并按照视频要求分配好admin和role的关系(见第六章)

3.8.1 访问 Admin 分页功能时具备“经理” 角色

修改component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

.antMatchers("/admin/get/page.html")
.hasRole("经理")
.anyRequest()                                   // 其它任意请求
.authenticated()                                // 认证登录后访问

测试:当使用roleOperator进入用户维护时,会显示

在这里插入图片描述

3.8.2 访问 Role 的分页功能时具备“部长” 角色

修改component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

@Configuration // 当前类为配置类
@EnableWebSecurity  // 启用Web环境下权限控制功能
@EnableGlobalMethodSecurity(prePostEnabled = true) // 启用全局方法权限控制功能, 并且设置 prePostEnabled = true。 保证@PreAuthority、@PostAuthority、 @PreFilter、 @PostFilter 生效
public class WebAppSecurityConfig extends WebSecurityConfigurerAdapter {}

修改component\src\main\java\com\atguigu\crowd\mvc\handler\RoleHandler.java

// @ResponseBody
@PreAuthorize("hasRole('部长')")
@RequestMapping("/role/get/page/info.json")
public ResultEntity<PageInfo<Role>> getPageInfo(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
                                                @RequestParam(value = "pageSize", defaultValue = "5") Integer pageSize,
                                                @RequestParam(value = "keyword", defaultValue = "") String keyword) {}

修改component\src\main\java\com\atguigu\crowd\mvc\config\CrowdExceptionResolver.java

@ExceptionHandler(value = Exception.class)
public ModelAndView resolveException(Exception exception,
                                     HttpServletRequest request,
                                     HttpServletResponse response) throws IOException {
    String viewName = "admin-login";
    return commonResolveException(viewName,exception,request,response);
}

测试:当使用adminOperator进入角色维护时,会显示

在这里插入图片描述

3.8.3 访问 Admin 保存功能时具备 user:save 权限

修改component\src\main\java\com\atguigu\crowd\mvc\handler\AdminHandler.java

// 新增成员
@PreAuthorize("hasAuthority('user:save')")
@RequestMapping("/admin/save.html")
public String save(Admin admin) {
    adminService.saveAdmin(admin);
    return "redirect:/admin/get/page.html?pageNum="+Integer.MAX_VALUE;
}

3.8.4 访问 Admin 分页功能时具备“经理” 角色或“user:get” 权限二者之一

修改component\src\main\java\com\atguigu\crowd\mvc\config\WebAppSecurityConfig.java

.antMatchers("/admin/get/page.html")
    .access("hasRole('经理') OR hasAuthority('user:get')")
    //                .hasRole("经理")
    .anyRequest() 

测试:使用roleOperator账号登录,进入角色维护,修改部长的权限:

在这里插入图片描述

重启服务器,可以通过roleOperator账号进入用户维护界面:

在这里插入图片描述

3.9 页面元素的权限控制

修改webui\src\main\webapp\WEB-INF\admin-main.jsp

<%@taglib uri="http://www.springframework.org/security/tags" prefix="security" %>
<security:authorize access="hasRole('经理')">
    <div class="col-xs-6 col-sm-3 placeholder">
        <img data-src="holder.js/200x200/auto/sky" class="img-responsive" alt="Generic placeholder thumbnail">
        <h4>Label</h4>
        <span class="text-muted">Something else</span>
    </div>
</security:authorize>

新建component\src\main\java\org\springframework\security\taglibs\authz\AbstractAuthorizeTag.java,并将源码复制过来,再修改:

    private SecurityExpressionHandler<FilterInvocation> getExpressionHandler() throws IOException {
        // ApplicationContext appContext = SecurityWebApplicationContextUtils.findRequiredWebApplicationContext(getServletContext());

        // 1.获取 ServletContext 对象
        ServletContext servletContext = getServletContext();

        // 2.拼接 SpringMVC 在 ServletContext 域中的属性名
        String attrName = FrameworkServlet.SERVLET_CONTEXT_PREFIX + "springDispatcherServlet";

        // 3.从 ServletContext 域中获取 IOC 容器对象
        ApplicationContext appContext = (ApplicationContext)servletContext.getAttribute(attrName);

        Map<String, SecurityExpressionHandler> handlers = appContext.getBeansOfType(SecurityExpressionHandler.class);
        Iterator var3 = handlers.values().iterator();

        SecurityExpressionHandler h;
        do {
            if (!var3.hasNext()) {
                throw new IOException("No visible WebSecurityExpressionHandler instance could be found in the application context. There must be at least one in order to support expressions in JSP 'authorize' tags.");
            }

            h = (SecurityExpressionHandler)var3.next();
        } while(!FilterInvocation.class.equals(GenericTypeResolver.resolveTypeArgument(h.getClass(), SecurityExpressionHandler.class)));

        return h;
    }

测试:登录roleOperator账号:

在这里插入图片描述

而登录adminOperator账号:

在这里插入图片描述

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值