Spring Boot(八)——Shiro+FreeMarker

Shiro简介

  Apache Shiro是一个功能强大、灵活的,开源的安全框架。它可以干净利落地处理身份验证、授权、企业会话管理和加密。

Shiro能做什么呢

  • 用户访问权限控制,比如:判断用户是否分配了一定的安全角色,判断用户是否被授予完成某个操作的权限;
  • 在非 web 或 EJB 容器的环境下可以任意使用Session API;
  • 可以响应认证、访问控制,或者 Session 生命周期中发生的事件;
  • 可将一个或以上用户安全数据源数据组合成一个复合的用户 “view”(视图);
  • 支持单点登录(SSO)功能;
  • 支持提供“Remember Me”服务,获取用户关联信息而无需登录;
  • …等等——都集成到一个有凝聚力的易于使用的API。

Apache Shiro Features 特性

  Apache Shiro是一个全面的、蕴含丰富功能的安全框架。下图为描述Shiro功能的框架图:
这里写图片描述
  Authentication(认证), Authorization(授权), Session Management(会话管理), Cryptography(加密)被 Shiro 框架的开发团队称之为应用安全的四大基石。
  Authentication:用户身份识别,通常被称为用户“登录”
  Authorization:访问控制。比如某个用户是否具有某个操作的使用权限。
  Session Management:特定于用户的会话管理,甚至在非web 或 EJB 应用程序。
  Cryptography:在对数据源使用加密算法加密的同时,保证易于使用。
  还有其他的功能来支持和加强这些不同应用环境下安全领域的关注点。特别是对以下的功能支持:
  Web支持:Shiro 提供的 web 支持 api ,可以很轻松的保护 web 应用程序的安全。
  缓存:缓存是 Apache Shiro 保证安全操作快速、高效的重要手段。
  并发:Apache Shiro 支持多线程应用程序的并发特性。
  测试:支持单元测试和集成测试,确保代码和预想的一样安全。
  “Run As”:这个功能允许用户假设另一个用户的身份(在许可的前提下)。
  “Remember Me”:跨 session 记录用户的身份,只有在强制需要时才需要登录。
  注意: Shiro不会去维护用户、维护权限,这些需要我们自己去设计/提供,然后通过相应的接口注入给Shiro

FreeMarker

FreeMarker简介

  FreeMarker是一款模板引擎: 即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页、电子邮件、配置文件、源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。
  FreeMarker是免费的,基于Apache许可证2.0版本发布。其模板编写为FreeMarker Template Language(FTL),属于简单、专用的语言。需要准备数据在真实编程语言中来显示,比如数据库查询和业务运算, 之后模板显示已经准备好的数据。在模板中,主要用于如何展现数据, 而在模板之外注意于要展示什么数据 [1] 。

jsp、freemarker、velocity区别

  在java领域,表现层技术主要有三种:jsp、freemarker、velocity。

jsp用的最多最广泛

优点:

  1、功能强大,可以写java代码
  2、支持jsp标签(jsp tag)
  3、支持表达式语言(el)
  4、官方标准,用户群广,丰富的第三方jsp标签库
  5、性能良好。jsp编译成class文件执行,有很好的性能表现

缺点:

  jsp没有明显缺点,非要挑点骨头那就是,由于可以编写java代码,如使用不当容易破坏mvc结构。

velocity是较早出现的用于代替jsp的模板语言

优点:

  1、不能编写java代码,可以实现严格的mvc分离
  2、性能良好,据说比jsp性能还要好些
  3、使用表达式语言,据说jsp的表达式语言就是学velocity的

缺点:

  1、不是官方标准
  2、用户群体和第三方标签库没有jsp多。
  3、对jsp标签支持不够好

freemarker

优点:

  1、不能编写java代码,可以实现严格的mvc分离
  2、性能非常不错
  3、对jsp标签支持良好
  4、内置大量常用功能,使用非常方便
  5、宏定义(类似jsp标签)非常方便
  6、使用表达式语言

缺点:

  1、不是官方标准
  2、用户群体和第三方标签库没有jsp多

选择freemarker的原因:

  1、性能。velocity应该是最好的,其次是jsp,普通的页面freemarker性能最差(虽然只是几毫秒到十几毫秒的差距)。但是在复杂页面上(包含大量判断、日期金额格式化)的页面上,freemarker的性能比使用tag和el的jsp好。
  2、宏定义比jsp tag方便
  3、内置大量常用功能。比如html过滤,日期金额格式化等等,使用非常方便
  4、支持jsp标签
  5、可以实现严格的mvc分离

使用配置

配置pom.xml

        <!--shiro-->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-core</artifactId>
            <version>${shiro.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>${shiro.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-web</artifactId>
            <version>${shiro.version}</version>
        </dependency>       
        <!--freemarker-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>

配置application.properties

  这里只显示shiro和freemarker有关的依赖包,文章最下方的github里有全部的pom.xml

# jdbc_config
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://120.79.226.103:3306/base?characterEncoding=utf8
spring.datasource.username=root
spring.datasource.password=lghs35123

# jpa 
spring.jpa.show-sql=true
spring.jpa.properties.jadira.usertype.autoRegisterUserTypes=true
#自动创建表,如果表存在就更新
spring.jpa.hibernate.ddl-auto=update

# freemarker config
spring.freemarker.allow-request-override=false
spring.freemarker.allow-session-override=false
spring.freemarker.cache=false
spring.freemarker.charset=UTF-8
spring.freemarker.check-template-location=true
spring.freemarker.content-type=text/html
spring.freemarker.enabled=true
spring.freemarker.expose-request-attributes=false
spring.freemarker.expose-session-attributes=false
spring.freemarker.expose-spring-macro-helpers=true
spring.freemarker.prefer-file-system-access=true
spring.freemarker.suffix=.ftl
spring.freemarker.template-loader-path=classpath:/templates/
spring.freemarker.settings.template_update_delay=0
spring.freemarker.settings.default_encoding=UTF-8
spring.freemarker.settings.classic_compatible=true
spring.freemarker.settings.date_format=yyyy-MM-dd
spring.freemarker.settings.time_format=HH:mm:ss
spring.freemarker.settings.datetime_format=yyyy-MM-dd HH:mm:ss

页面

  登录页面login.ftl
  主页login.ftl
  具体内容请到下方的github查看源码

RBAC

  RBAC 是基于角色的访问控制(Role-Based Access Control )在 RBAC 中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。这就极大地简化了权限的管理。这样管理都是层级相互依赖的,权限赋予给角色,而把角色又赋予用户,这样的权限设计很清楚,管理起来很方便。
  采用jpa技术来自动生成基础表格,配置文件中的“spring.jpa.hibernate.ddl-auto=update”要开启,对应的entity如下:

用户信息

/**
 * <p>
 * 用户表
 * </p>
 *
 * @author LYH
 * @since  2018-3-28
 */
@Entity
@Table(name = "tb_user")
public class User extends BaseEntity {

    private static final long serialVersionUID = 1L;

    /**
     * 用户id
     */
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id", nullable = false)
    private Integer id;

    /**
     * 账户名
     */
    private String userName;

    /**
     * 昵称
     */
    private String nickName;

    /**
     * 头像地址
     */
    private String avatar;

    /**
     * 用户密码
     */
    private String password;

    /**
     * 性别 0 女 1 男
     */
    private Integer sex;

    /**
     * 出生日期
     */
    @JSONField(format = "yyyy-MM-dd")
    private Date birthday;

    /**
     * 电话
     */
    private String telephone;

    /**
     * 邮箱
     */
    private String email;

    /**
     * 住址
     */
    private String address;

    /**
     * 逻辑删除状态 0 未删除 1 删除
     */
    private Integer deleteStatus;

    /**
     * 是否锁定
     * <p>
     * 0 未锁定 1 锁定
     */
    private Integer locked;

    /**
     * 用户描述
     */
    private String description;

    /**
     * 创建时间
     */
    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;

    /**
     * 更新时间
     */
    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
    private Date updateTime;

    @ManyToMany(cascade = {CascadeType.REFRESH}, fetch = FetchType.LAZY)
    @JoinTable(name = "tb_user_role", joinColumns = {@JoinColumn(name = "user_id")}, inverseJoinColumns = {@JoinColumn(name = "role_id")})
    private java.util.Set<Role> roles;
}

角色信息

/**
 * 角色表
 *
 * @author LYH
 * @since 2018-3-28
 */
@Entity
@Table(name = "tb_role")
public class Role extends BaseEntity {

    /**
     *
     */
    private static final long serialVersionUID = -1894163644285296223L;

    /**
     * 角色id
     */
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id", nullable = false)
    private Integer id;

    /**
     * 角色名称
     */
    private String name;

    /**
     * 角色key
     */
    private String roleKey;

    /**
     * 角色状态,0:正常;1:删除
     */
    private Integer status;

    /**
     * 角色描述
     */
    private String description;

    /**
     * 创建时间
     */
    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;

    /**
     * 更新时间
     */
    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
    private Date updateTime;

    @ManyToMany(cascade = {CascadeType.REFRESH}, fetch = FetchType.LAZY)
    @JoinTable(name = "tb_role_resource", joinColumns = {@JoinColumn(name = "role_id")}, inverseJoinColumns = {@JoinColumn(name = "resource_id")})
    private java.util.Set<Permission> resources;
}

权限信息

/**
 * <p>
 * 权限表
 * </p>
 *
 * @author LYH
 * @since  2018-3-28
 */
@Entity
@Table(name = "tb_resource")
public class Permission extends BaseEntity {

    private static final long serialVersionUID = 1L;

    /**
     * 资源id
     */
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id", nullable = false)
    private Integer id;

    /**
     * 资源名称
     */
    private String name;

    /**
     * 资源唯一标识
     */
    private String sourceKey;

    /**
     * 资源类型,0:目录;1:菜单;2:按钮
     */
    private Integer type;

    /**
     * 资源url
     */
    private String sourceUrl;

    /**
     * 层级
     */
    private Integer level;

    /**
     * 排序
     */
    private Integer sort;

    /**
     * 图标
     */
    private String icon;

    /**
     * 是否隐藏
     * 0显示 1隐藏
     */
    private Integer isHide;

    /**
     * 描述
     */
    private String description;

    /**
     * 创建时间
     */
    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;

    /**
     * 更新时间
     */
    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
    private Date updateTime;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Permission parent;
}

  根据以上的代码会自动生成tb_user(用户信息表)、tb_role(角色表)、tb_permission(权限表)、tb_user_role(用户角色表)、tb_role_permission(角色权限表)这五张表,为了方便测试我们给这五张表插入一些初始化数据:

insert  into `tb_permission`(`id`,`create_time`,`description`,`icon`,`is_hide`,`level`,`name`,`sort`,`source_key`,`source_url`,`type`,`update_time`,`parent_id`) values (0,'2018-01-10 13:56:51','系统管理',NULL,0,1,'系统管理',1,'system','javascript:void(0);',1,'2018-01-10 13:59:01',NULL),(1,'2018-01-10 13:56:51','用户管理',NULL,0,2,'用户管理',1,'system:user:index','/admin/user/index',1,'2018-01-10 13:59:01',0),(2,'2018-01-10 13:56:51','用户编辑',NULL,0,3,'用户编辑',1,'system:user:edit','/admin/user/edit*',2,'2018-01-10 16:26:42',1),(3,'2018-01-11 16:48:48','用户添加',NULL,0,3,'用户添加',2,'system:user:add','/admin/user/add',2,'2018-01-11 16:49:26',1),(4,'2018-01-11 16:48:48','用户删除',NULL,0,3,'用户删除',3,'system:user:deleteBatch','/admin/user/deleteBatch',2,'2018-01-18 14:11:41',1),(5,'2018-01-11 16:48:48','角色分配',NULL,0,3,'角色分配',4,'system:user:grant','/admin/user/grant/**',2,'2018-01-18 14:11:51',1),(6,'2018-01-12 16:45:10','角色管理',NULL,0,2,'角色管理',2,'system:role:index','/admin/role/index',1,'2018-01-12 16:46:52',0),(7,'2018-01-12 16:47:02','角色编辑',NULL,0,3,'角色编辑',1,'system:role:edit','/admin/role/edit*',2,'2018-01-18 10:24:06',1),(8,'2018-01-12 16:47:23','角色添加',NULL,0,3,'角色添加',2,'system:role:add','/admin/role/add',2,'2018-01-12 16:49:16',6),(9,'2018-01-12 16:47:23','角色删除',NULL,0,3,'角色删除',3,'system:role:deleteBatch','/admin/role/deleteBatch',2,'2018-01-18 14:12:03',6),(10,'2018-01-12 16:47:23','资源分配',NULL,0,3,'资源分配',4,'system:role:grant','/admin/role/grant/**',2,'2018-01-18 14:12:11',6),(11,'2018-01-17 11:21:12','资源管理',NULL,0,2,'资源管理',3,'system:resource:index','/admin/resource/index',1,'2018-01-17 11:21:42',0),(12,'2018-01-17 11:21:52','资源编辑',NULL,0,3,'资源编辑',1,'system:resource:edit','/admin/resource/edit*',2,'2018-01-17 11:22:36',11),(13,'2018-01-17 11:21:54','资源添加',NULL,0,3,'资源添加',2,'system:resource:add','/admin/resource/add',2,'2018-01-17 11:22:39',11),(14,'2018-01-17 11:21:54','资源删除',NULL,0,3,'资源删除',3,'system:resource:deleteBatch','/admin/resource/deleteBatch',2,'2018-01-18 14:12:31',11);
insert  into `tb_role`(`id`,`create_time`,`description`,`name`,`role_key`,`status`,`update_time`) values (1,'2018-01-09 17:25:30','超级管理员','administrator','administrator',0,'2018-01-09 17:26:25');
insert  into `tb_role_permission`(`role_id`,`resource_id`) values (1,0),(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),(1,8),(1,9),(1,10),(1,11),(1,12),(1,13),(1,14);
insert  into `tb_user`(`id`,`address`,`avatar`,`birthday`,`create_time`,`delete_status`,`description`,`email`,`locked`,`nick_name`,`password`,`sex`,`telephone`,`update_time`,`user_name`) values (1,'广州','','2018-01-09 17:26:39','2018-01-09 17:26:41',0,'超级管理员','lyh0622@126.com',0,'admin','3931MUEQD1939MQMLM4AISPVNE',1,'13112256276','2018-01-09 17:27:11','admin');
insert  into `tb_user_role`(`user_id`,`role_id`) values (1,1);

Shiro 配置

ShiroConfig.class

  配置的是ShiroConfig类,Shiro 核心通过Filter来实现,既然是使用 Filter 也是通过URL规则来进行过滤和权限校验,所以我们需要定义一系列关于URL的规则和访问权限。

@Configuration
@Import(ShiroManager.class)
public class ShiroConfig {

    @Resource
    private IPermissionService resourceService;


    @Bean(name = "realm")
    @DependsOn("lifecycleBeanPostProcessor")
    @ConditionalOnMissingBean
    public Realm realm() {
        return new MyRealm();
    }

    /**
     * 用户授权信息Cache
     */
    @Bean(name = "shiroCacheManager")
    @ConditionalOnMissingBean
    public CacheManager cacheManager() {
        return new MemoryConstrainedCacheManager();
    }

    @Bean(name = "securityManager")
    @ConditionalOnMissingBean
    public DefaultSecurityManager securityManager() {
        DefaultSecurityManager sm = new DefaultWebSecurityManager();
        sm.setCacheManager(cacheManager());
        return sm;
    }

    @Bean(name = "shiroFilter")
    @DependsOn("securityManager")
    @ConditionalOnMissingBean
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultSecurityManager securityManager, Realm realm) {
        securityManager.setRealm(realm);

        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);
        shiroFilter.setLoginUrl("/admin/login");
        shiroFilter.setSuccessUrl("/admin/index");
        shiroFilter.setUnauthorizedUrl("/previlige/no");
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/assets/**", "anon");

        filterChainDefinitionMap.put("/admin/login", "anon");

        List<Permission> list = resourceService.findAll();
        for (Permission resource : list) {
            filterChainDefinitionMap.put(resource.getSourceUrl(), "perms[" + resource.getSourceKey() + "]");
        }

        filterChainDefinitionMap.put("/admin/**", "authc");
        shiroFilter.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilter;
    }
}

ShiroManager.class

  其中@Import(ShiroManager.class)中的ShiroManager.class是使用shiro的方法注解方式进行权限控制的管理类,具体内容如下:

/**
 * Shiro Config Manager.
 * 使用shiro的方法注解方式进行权限控制,也可以在Spring的配置文件中开启shiro的注解支持。
 * @author LYH
 */
public class ShiroManager {

    /**
     * 保证实现了Shiro内部lifecycle函数的bean执行
     */
    @Bean(name = "lifecycleBeanPostProcessor")
    @ConditionalOnMissingBean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    /**
     * 开启注解控制权限的方式,AOP式方法级权限检查
     *
     * @return
     */
    @Bean(name = "defaultAdvisorAutoProxyCreator")
    @ConditionalOnMissingBean
    @DependsOn("lifecycleBeanPostProcessor")
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    /**
     * 开启注解控制权限的方式
     * 配置shiro框架提供的切面类,用于创建代理对象
     * @param securityManager
     * @return
     */
    @Bean
    @ConditionalOnMissingBean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor aasa = new AuthorizationAttributeSourceAdvisor();
        aasa.setSecurityManager(securityManager);
        return aasa;
    }
}

MyRealm.class

  在认证、授权内部实现机制中都有提到,最终处理都将交给Real进行处理。因为在Shiro中,最终是通过Realm来获取应用程序中的用户、角色及权限信息的。通常情况下,在Realm中会直接从我们的数据源中获取Shiro需要的验证信息。可以说,Realm是专用于安全框架的DAO。

/**
 * @author LYH
 * 在认证、授权内部实现机制中都有提到,最终处理都将交给Real进行处理
 */
@Component
public class MyRealm extends AuthorizingRealm {

    public MyRealm() {
        super(new AllowAllCredentialsMatcher());
        setAuthenticationTokenClass(UsernamePasswordToken.class);

        //FIXME: 暂时禁用Cache
        setCachingEnabled(false);
    }

    @Autowired
    private IUserService userService;

    /**
     * 权限授权是通过继承AuthorizingRealm抽象类,重载doGetAuthorizationInfo()
     * 当访问到页面的时候,链接配置了相应的权限或者shiro标签才会执行此方法否则不会执行,
     * 所以如果只是简单的身份认证没有权限的控制的话,那么这个方法可以不进行实现,直接返回null即可。
     * 在这个方法中主要是使用类:SimpleAuthorizationInfo进行角色的添加和权限的添加。
     * @author linyuanhuang
     * 16:27 2018/3/29
     * @return org.apache.shiro.authz.AuthorizationInfo
    */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(
            PrincipalCollection principals) {
        User user = (User) principals.getPrimaryPrincipal();
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        User dbUser = userService.findByUserName(user.getUserName());
        Set<String> shiroPermissions = new HashSet<>();
        Set<String> roleSet = new HashSet<String>();
        Set<Role> roles = dbUser.getRoles();
        for (Role role : roles) {
            Set<Permission> resources = role.getResources();
            for (Permission resource : resources) {
                shiroPermissions.add(resource.getSourceKey());

            }
            roleSet.add(role.getRoleKey());
        }
        authorizationInfo.setRoles(roleSet);
        authorizationInfo.setStringPermissions(shiroPermissions);
        return authorizationInfo;
    }

    /**
     * Shiro的认证过程最终会交由Realm执行,这时会调用Realm的getAuthenticationInfo(token)方法
     * 该方法主要执行以下操作:
         1、检查提交的进行认证的令牌信息;
         2、根据令牌信息从数据源(通常为数据库)中获取用户信息;
         3、对用户信息进行匹配验证;
         4、验证通过将返回一个封装了用户信息的AuthenticationInfo实例;
         5、验证失败则抛出AuthenticationException异常信息。
     * @author linyuanhuang
     * 16:26 2018/3/29
     * @return org.apache.shiro.authc.AuthenticationInfo  
    */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(
            AuthenticationToken token) throws AuthenticationException {
        String username = (String) token.getPrincipal();

        User user = userService.findByUserName(username);
        // 账号不存在
        if (user == null) {
            throw new UnknownAccountException("账号或密码不正确");
        }
        Object credentials = token.getCredentials();
        if (credentials == null) {
            throw new UnknownAccountException("账号或密码不正确");
        }
        String password = new String((char[]) credentials);
        // 密码错误
        if (!MD5Utils.md5(password).equals(user.getPassword())) {
            throw new IncorrectCredentialsException("账号或密码不正确");
        }
        // 账号锁定
        if (user.getLocked() == 1) {
            throw new LockedAccountException("账号已被锁定,请联系管理员");
        }

        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, password, getName());

        return info;
    }
}

登录控制层

@RequestMapping(value = {"/admin/login"}, method = RequestMethod.POST)
    public String login(@RequestParam("username") String username,
                        @RequestParam("password") String password,
                        ModelMap model) {
        try {
            Subject subject = SecurityUtils.getSubject();
            UsernamePasswordToken token = new UsernamePasswordToken(username, password);
            subject.login(token);
            return redirect("/admin/index");
        } catch (AuthenticationException e) {
            model.put("message", e.getMessage());
        }
        return "admin/login";
    }

  这里就先介绍这么多,还有更多的内容如:shiro、freemarker的标签的使用这里就不一一介绍了,可以参考完整的例子:
GitHub地址:https://github.com/lyhkmm/spring-boot-examples/tree/master/spring-boot-shiro-freemarker
原文来自:lyhkmm.com

  • 1
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值