Spring Boot实战(九)9.1安全控制Spring Security

9.1.1 Spring Security 快速入门

1.什么是Spring Security

Spring Security是专门针对基于Spring 的项目的安全框架,充分利用了依赖注入和AOP来实现安全功能。
在早期的Spring Security版本,使用Spring Security需要使用大量的XML配置,而本节将全部基于Java配置来实现Spring Security的功能。
安全框架有两个重要的概念,即认证(Authentication)和授权(Authorization)。认证即确认用户可以访问当前系统;授权即确定用户在当前系统下所拥有的功能权限,本节将围绕认证和授权展开。

2.Security的配置
(1)DelegatingFilterProxy

Spring Security为我们提供了一个多个过滤器来实现所有安全的功能,我们需注册一个特殊的DelegationFilterProxy过滤器到WebApplicationInitializer即可。
而在实际使用中,我们只需让自己的Initializer类继承AbstractSecurityWebApplicationInitializer抽象类即。AbstractSecurityWebApplicationInitializer实现了WebApplicationInitializer接口,并通过onStartup方法调用:

insertSpringSecurityFilterChain(servletContext);

它为我们注册了DelegatingFilterProxy。insertSpringSecurityFilterChain源码如下:

	private void insertSpringSecurityFilterChain(ServletContext servletContext) {
		String filterName = DEFAULT_FILTER_NAME;
		DelegatingFilterProxy springSecurityFilterChain = new DelegatingFilterProxy(
				filterName);
		String contextAttribute = getWebApplicationContextAttribute();
		if (contextAttribute != null) {
			springSecurityFilterChain.setContextAttribute(contextAttribute);
		}
		registerFilter(servletContext, true, filterName, springSecurityFilterChain);
	}

所以我们只需用以下代码即可开启Spring Security的过滤器支持:

public class AppInitializer extends AbstractSecurityWebApplicationInitializer{
}
(2)配置

Spring Security的配置和Spring MVC的配置类似,只需在一个配置类上注解@EnableWebSecurity,并让这个类继承WebSecurityConfigurerAdapter即可。我们可以通过重写configure方法来配置相关的安全配置。

代码如下:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		// TODO Auto-generated method stub
		super.configure(auth);
	}

	@Override
	public void configure(WebSecurity web) throws Exception {
		// TODO Auto-generated method stub
		super.configure(web);
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		// TODO Auto-generated method stub
		super.configure(http);
	}
	
	

}

3. 用户认证

认证需要我们有一套用户数据的来源,而授权则是对于某个用户有相应的角色权限。在Spring Security里我们通过重写

protected void configure(AuthenticationManagerBuilder auth)

方法来实现定制。

(1)内存中的用户

使用AuthenticationManagerBuilder的inMemoryAuthentication方法即可添加在内存中的用户,并可给用户指定角色权限

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.inMemoryAuthentication()
		.withUser("lmz").password("lmz").roles("ROLE_ADMIN")
		.and()
		.withUser("wisely").password("wisely").roles("ROLE_USER");
	}
(2)JDBC中的用户

JDBC中的用户直接指定dataSource即可。

	@Autowired
	DataSource dataSource;

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.jdbcAuthentication().dataSource(dataSource);
	}

不过这看上去很奇怪,其实这里的Spring Security是默认了你的数据库结构的。通过jdbcAuthentication的源码,我们可以看出在jdbcDaoImpl中定义了默认的用户及角色权限获取的SQL语句:

public static final String DEF_USERS_BY_USERNAME_QUERY = "select
username,password,enabled "
		+"from users " + "where username = ?";

public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY = "select
username,authority "
	+ "from authorities " + "where username = ?";

当然我们可以自定义我们的查询用户和权限的SQL语句,例如:

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.jdbcAuthentication().dataSource(dataSource)
		.usersByUsernameQuery("select username,password,true "
				+ "from myusers where username = ?")
		.authoritiesByUsernameQuery("select username,role "
				+"from roles where username = ?");
	}
(3)通用的用户

上面的两种用户和权限的获取方式只限于内存或者JDBC,我们的数据访问方式可以是多种各样的,可以是非关系型数据,也可以是我们常用的JPA等。
这时我们需要定义实现UserDetailsService接口。上在的内存中用户及JDBC用户就是UserDetailsService的实现,定义如下:

	@Autowired
	SysUserRepository userRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		SysUser user = userRepository.findByUsername(username);
		List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
		authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
		
		return new User(user.getUsername,user.getPassword(),authorities);
	}

说明:SysUser是我们系统的用户领域对象类,User来自于org.springframework.security.core.userdetails.User。
除此之外,我们还需要注册这个CustomUserService,代码如下:

@Bean
UserDetailsService customUserService() {
	return new CustomUserService();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
	
	auth.userDetailsService(customUserService());
}

4.请求授权

Spring Security是通过重写

protected void configure(HttpSecurity http)

方法来实现请求拦截的。
Spring Security使用以下匹配器来匹配请求路径:

antMatchers: 使用Ant风格的路径匹配。
regexMatchers:使用正则表达式匹配路径。

anyRequest:匹配所有请求路径。
在匹配了请求路径后,需要针对当前用户的信息对请求路径进行安全处理,Spring Security提供了下表所示的安全处理方法

方法用途
access(String)Spring EL表达式结果为true时可访问
anonymous()匿名可访问
denyAll()用户不能访问
fullyAuthenticated()用户完全认证可访问(非remember me下自动登录)
hasAnyAuthority(String…)如果用户有参数,则其中任一权限可访问
hasAnyRole(String…)如果用户有参数,则其中任一角色可访问
hasAuthority(String)如果用户有参数,则其权限可访问
hasIpAddress(String)如果用户来自参数中的IP则可访问
hasRole(String)若用户有参数中的角色可访问
permitAll()用户可以任意访问
rememberMe()允许通过remember-me登录的用户访问
authenticated()用户登录后可访问

我们可以看一下下面的示例代码:

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.
		authorizeRequests() //通过authorizeRequests方法来开始请求权限配置
		.antMatchers("/admin/**").hasRole("ROLE_ADMIN")   //请求匹配/admin/**,只有拥有ROLE_ADMIN角色的用户可以访问
		.antMatchers("/user/**").hasAnyRole("ROLE_ADMIN","ROLE_USER")  //请求匹配/user/**,拥有ROLE_ADMIN或ROLE_USER角色的用户都可访问。
		.anyRequest().authenticated(); //其余所有的请求都需要认证后(登录后)才可访问。

	}

5.定制登录行为

我们也可以通过重写

prtected void configure(HttpSecurity http)

方法来定制我们的登录行为。
下面将重用的登录行为的定制以简短的代码演示:

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.formLogin()   //通过formLogin方法定制登录操作。
			.loginPage("/login")   //使用loginPage方法定制登录页面的访问地址
			.defaultSuccessUrl("/index")  //defaultSuccessUrl指定登录成功后转向的页面
			.failureUrl("/login?error")   //failureUrl指定登录失败后转向的页面
			.permitAll()
			.and()
			.rememberMe()   //rememberMe开启cookie存储用户信息
				.tokenValiditySeconds(1209600)   //tokenValiditySeconds指定cookie有效期为1209600秒,即2个星期。
				.key("myKey")     //key指定cookie中的私钥
			.and()
			.logout()    //使用logou方法定制注销行为。
				.logoutUrl("/custom-logout")  //logoutUrl指定注销的URL路径
				.logoutSuccessUrl("/logout-success")  //logoutSuccessUrl指定注销成功后转向的页面。
				.permitAll();

	}

9.1.2 Spring Boot的支持

Spring Boot针对Spring Security的自动配置在org.springframework.boot.autoconfigure.security包中。
主要通过SecurityAutoConfiguration和SecurityProperties来完成配置。
SecurityAutoConfiguration导入了SpringBootWebSecurityConfiguration中的配置。在SpringBootWebSecurityConfiguration配置中,我们获得如下的自动配置:

1)自动配置了一个内存中的用户,账号为user,密码在程序启动时出现。
2)忽略/css/**、/js/**、/images/**和/**/favicon.ico等静态文件的拦截。
3)自动配置的securityFilterChainRegistration的Bean。

SecurityProperties使用以“security”为前缀的属性配置Spring Security相关的配置,包含:

security.user.name=user # 内存中的用户默认账号为user
security.user.password = # 1默认的用户密码
security.user.role=USER # 默认用户的角色
security.require-ssl=false # 是否需要ssl支持
security.enable-csrf-false # 是否开启“跨站请求伪造”支持,默认关闭
security.basic.enabled=true
security.basic.realm=Spring
security.basic.path= # /**
security.basic.authorize-mode=
security.filter-order=0
security.headers.xss=false
security.headers.cache=false
security.headers.frame=false
security.headers.content-type=false
security.headers.hsts=all
security.sessions=stateless
security.ignored= #用逗号隔开的无须拦截的路径

Spring Boot为我们做了如此多的配置,当我们需要自己扩展的配置时,只需配置类继承WebSecurityConfigurerAdapter类即可,无须使用@EnableWebSecurity注解,例如

@Configuration
public class WebSecurityConfig extends  WebSecurityConfigurerAdapter{
}

9.1.3 实战

在本节的示例中,我们将演示使用Spring Boot下的Spring Security的配置,完成简单的认证授权的功能。此节我们将通过Spring Data JPA获得用户数据。页面模板使用Thymeleaf,Thymeleaf也为我们提供了支持Spring Security的标签。

1.新建Spring Boot项目

新建Spring Boot项目,依赖为JPA(spring-boot-starter-data-jpa)、Security(spring-boot-starter-security)、Thymeleaf(spring-boot-starter-thymeleaf)。
项目信息

groupId:com.wisely
arctifactId:ch9_1
package:com.wisely.ch9_1

并添加Oracle驱动及Thymeleaf的Spring Security的支持。

		<dependency>
			<groupId>com.oracle</groupId>
			<artifactId>ojdbc6</artifactId>
			<version>11.2.0.2.0</version>
		</dependency>
		
		<dependency>
		    <groupId>org.thymeleaf.extras</groupId>
		    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
		</dependency>

我们的application.properties配置如下:

spring.datasource.driverCClassName=oracle.jdbc.OracleDriver
spring.datasource.url=jdbc\:oracle\:thin\:@localhost\:1521\:xe
spring.datasource.username=boot
spring.datasource.password=boot

logging.level.org.springframework.security= INFO

spring.thymeleaf.cache=false

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

将bootstrap.min.css放置在src/main/resources/static/css下,此路径默认不拦截。

2.用户和角色

我们使用JPA来定义用户和角色.
用户:

package com.wisely.ch9_1.domain;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import javax.persistence.CascadeType;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.ManyToMany;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@Entity
public class SysUser implements UserDetails {   //让我们的用户实体实现UserDetails接口,我们的用户实体即为Spring Security所使用的用户。
	

	private static final long serialVersionUID = 1L;
	@Id
	@GeneratedValue
	private Long id;
	private String username;
	private String password;
	@ManyToMany(cascade= {CascadeType.REFRESH},fetch= FetchType.EAGER)  //配置用户和角色的多对多关系。
	private List<SysRole> roles;

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		List<GrantedAuthority> auths = new ArrayList<GrantedAuthority>();  //重写getAuthorities方法,将用户的角色作为权限。
		List<SysRole> roles = this.getRoles();
		for(SysRole role:roles) {
			auths.add(new SimpleGrantedAuthority(role.getName()));
		}
		return auths;
	}

	@Override
	public boolean isAccountNonExpired() {  //自动继承的四个bool方法要true,
		// TODO Auto-generated method stub
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		// TODO Auto-generated method stub
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		// TODO Auto-generated method stub
		return true;
	}

	@Override
	public boolean isEnabled() {
		// TODO Auto-generated method stub
		return true;
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public List<SysRole> getRoles() {
		return roles;
	}

	public void setRoles(List<SysRole> roles) {
		this.roles = roles;
	}

	public void setUsername(String username) {
		this.username = username;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	@Override
	public String getPassword() {
		// TODO Auto-generated method stub
		return password;
	}

	@Override
	public String getUsername() {
		// TODO Auto-generated method stub
		return username;
	}
	
	
	

}


角色:

package com.wisely.ch9_1.domain;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class SysRole {
	@Id
	@GeneratedValue
	private Long id;
	private String name;  //name为角色名称
	public Long getId() {
		return id;
	}
	public void setId(Long id) {
		this.id = id;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	
	
}

(1)数据结构及初始化

当我们配置用户和角色的多对多关系后,通过设置

spring.jpa.hibernate.dll-auto=update

为我们自动生成用户表:SYS_USER、角色表:SYS_ROLE、关联表:SYS_USER_ROLES。
针对上面的表结构,我们初始化一些数据来方便我们演示。在src/main/resources下,新建data.sql,即新建两个用户,角色分别为ROLE_ADMIN和ROLE_USER,代码如下

insert into SYS_USER(id,username,password) values (1,'lmz','lmz');
insert into SYS_USER(id,username,password) values (2,'wisely','wisely');

insert into SYS_ROLE(id,name) values(1,'ROLE_ADMIN');
insert into SYS_ROLE(id,name) values(2,'ROLE_USER');

insert into SYS_USER_ROLES(SYS_USER_ID,ROLES_ID) values(1,1);
insert into SYS_USER_ROLES(SYS_USER_ID,ROLES_ID) values(2,2);
(2)传值对象

用来测试不同角色用户的数据展示:

package com.wisely.ch9_1.domain;

public class Msg {
	private String title;
	private String content;
	private String etraInfo;
	public Msg(String title,String content,String etraInfo) {
		super();
		this.title=title;
		this.content=content;
		this.etraInfo=etraInfo;
	}
	public String getTitle() {
		return title;
	}
	public void setTitle(String title) {
		this.title = title;
	}
	public String getContent() {
		return content;
	}
	public void setContent(String content) {
		this.content = content;
	}
	public String getEtraInfo() {
		return etraInfo;
	}
	public void setEtraInfo(String etraInfo) {
		this.etraInfo = etraInfo;
	}
	
	

}

3.数据访问

我们这里的数据访问很简单,代码如下

package com.wisely.ch9_1.dao;

import org.springframework.data.jpa.repository.JpaRepository;

import com.wisely.ch9_1.domain.SysUser;

public interface SysUserRepository extends JpaRepository<SysUser, Long> {
	
	SysUser findByUsername(String username);

}

代码解释:这里只需一个用户名查出用户的方法

4.自定义UserDetailsService

package com.wisely.ch9_1.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import com.wisely.ch9_1.dao.SysUserRepository;
import com.wisely.ch9_1.domain.SysUser;

public class CustomUserService implements UserDetailsService {   //自定义需实现UserDetailsService接口
	@Autowired
	SysUserRepository userRepository;    

	@Override
	public UserDetails loadUserByUsername(String username) { //重写loadUserByUsername方法获得用户。
		SysUser user = userRepository.findByUsername(username);
		if(user==null) {
			throw new UsernameNotFoundException("用户名不存在");
		}
		return user;   //我们当前的用户实现了UserDetails接口,可直接返回给Spring Security使用。
	}

}

5.配置

(1)配置Spring MVC:
package com.wisely.ch9_1.config;

import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;


public class WebMvcConfig implements WebMvcConfigurer {

	@Override
	public void addViewControllers(ViewControllerRegistry registry) {
		registry.addViewController("/login").setViewName("login");
	}
	

}

代码解释:注册访问/login转向login.html页面。

(2)Spring Security 配置:
package com.wisely.ch9_1.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;

import com.wisely.ch9_1.security.CustomUserService;
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {  //扩展Spring Security配置需继承WebSecurityConfigurerAdapter

	@Bean
	UserDetailsService customUserService() {   //注册CustomUserService的Bean
		return new CustomUserService();
		
	}
	//由于亲版本密码需要加密,这里因为是测试用,所以先忽略密码加码操作
	@Bean
    public static NoOpPasswordEncoder passwordEncoder() {
        return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
    }

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(customUserService());  //添加我们自定义的user detail service认证
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests()
						.anyRequest().authenticated()     //所有请求需要认证即登录后才能访问
						.and()
						.formLogin()
							.loginPage("/login")
							.failureUrl("/login?error")
							.defaultSuccessUrl("/")
							.permitAll()           //定制登录行为,登录页面可任意访问
						.and()
						.logout().permitAll();		//定制注销行为,注销请求可以任意访问
 	}

	@Override
	public void configure(WebSecurity web) throws Exception {
		web.ignoring().antMatchers("/css/**");  //如果不配置,登录之前静态资源会被拦截
	}
	
	
	
	
}


6.页面

(1)登录页面:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta content="text/html;charset=UTF-8"/>
<title>登录页面</title>
<link th:href="@{/css/bootstrap.min.css}" rel="stylesheet" type="text/css" />
<style type="text/css">
	body {
  padding-top: 50px;
}
.starter-template {
  padding: 40px 15px;
  text-align: center;
}
</style>
</head>
<body>
	
	 <nav class="navbar navbar-inverse navbar-fixed-top">
      <div class="container">
        <div class="navbar-header">
          <a class="navbar-brand" href="#">Spring Security演示</a>
        </div>
        <div id="navbar" class="collapse navbar-collapse">
          <ul class="nav navbar-nav">
           <li><a th:href="@{/}"> 首页 </a></li>
           
          </ul>
        </div><!--/.nav-collapse -->
      </div>
    </nav>
     <div class="container">

      <div class="starter-template">
       <p th:if="${param.logout}" class="bg-warning">已成功注销</p><!-- 1 -->
			<p th:if="${param.error}" class="bg-danger">有错误,请重试</p> <!-- 2 -->
			<h2>使用账号密码登录</h2>
			<form name="form" th:action="@{/login}" action="/login" method="POST"> <!-- 3 -->
				<div class="form-group">
					<label for="username">账号</label>
					<input type="text" class="form-control" name="username" value="" placeholder="账号" />
				</div>
				<div class="form-group">
					<label for="password">密码</label>
					<input type="password" class="form-control" name="password" placeholder="密码" />
				</div>
				<input type="submit" id="login" value="Login" class="btn btn-primary" />
			</form>
      </div>

    </div>
		
</body>
</html>
(2)首页
<!DOCTYPE html>
<!-- Thymeleaf为我们提供的Spring Security的标签支持 -->
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4">
<head>
<meta content="text/html;charset=UTF-8">
<title sec:authentication="name"></title> <!-- 通过sec:authentication="name"来获得当前用户的用户名 -->
<link rel="stylesheet" type="text/css" th:href="@{css/bootstrap.min.css}" />
<style type="text/css">
	body{
		padding-top:50px;
	}
	.starter-template {
		padding:40px 15px;
		text-align:center;
	}
	
</style>
</head>
<body>

	<nav class="navbar navbar-inverse navbar-fixed-top">
	<div class="container">
		<div class="navbar-header">
			<a class="navbar-brand" href="#">Spring Secrity演示</a>
		</div>
		<div id="navbar" class="collapse navbar-collapse">
			<ul class="nav navbar-nav">
				<li><a th:href="@{/}">首页</a></li>
			</ul>
		</div><!-- /.nav-collapse -->
	</div>
	</nav>
	
	<div class="container">
		<div class="starter-template">
			<h1 th:text="${msg.title}"></h1>
			
			<p class="bg-primary" th:text="${msg.content}"></p>
			
			<div sec:authorize="hasRole('ROLE_ADMIN')">  <!-- sec:authorize="hasRole('ROLE_ADMIN')"意味着只有当前用户角色为ROLE_ADMIN时,才可显示标签内容 -->
				<p class="bg-info" th:text="${msg.etraInfo}"></p>			
			</div>
			
			<div sec:authorize="hasRole('ROLE_USER')"><!-- 意味着只有当前用户角色为ROLE_USER时,才可显示标签内内容 -->
				<p class="bg-info">无更多信息显示</p>
			</div>
			
			<form th:action="@{/logout}" method="POST">
				<input type="submit" class="btn btn-primary" value="注销" /> <!-- 注解的默认路径为/logout,需通过POST请求提交 -->
			</form>
		</div>
	</div>

</body>
</html>

7.控制器

此控制器很简单,只为首页显示准备数据:

package com.wisely.ch9_1.web;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

import com.wisely.ch9_1.domain.Msg;

@Controller
public class HomeController {
	
	@RequestMapping("/")
	public String index(Model model) {
		Msg msg = new Msg("测试标题","测试内容","额外信息,只对管理员显示");
		model.addAttribute("msg",msg);
		return "home";
	}

}

8.运行

(1)登录。访问http://loclhost:8080,将会自动转到登录页面http://localhost:8080/login,如图

在这里插入图片描述

使用正确的账号密码登录,如图
在这里插入图片描述
使用错误的账号密码登录,如图
在这里插入图片描述

(2)注销。登录成功后,单击注册按钮,如图

在这里插入图片描述
此时页面显示如图
在这里插入图片描述

(3)用户信息

页面上我们将用户名显示在页面的标题上,如图在这里插入图片描述

(4)视图控制

lmz和wisely用户角色不同,因此获得不同的视图。
lmz用户视图如图
在这里插入图片描述
wisely用户视图如图
在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值