SpringBoot整合Security数据库版本入门

1.SpringBoot-Security技术

1.1 技术介绍

​ 安全框架,权限控制的框架,包括认证功能、授权功能、加密功能、会话管理、缓存支持、记住登录信息等功能。

1.2 技术注意点

​ 1.2.1 使用了spring security 后,@controller注解不能写地址

​ 1.2.2 @PreAuthorize注解一定要使用权限时才使用,如需要开放某接口时,则禁止使用,否则无法访问,并且排查异常麻烦,没有错误日志。
1.2.3 权限控制的方法

方法作用用法
hasAuthority()是否有指定权限,有则true,没有则falsehttp.hasAuthority(“admins”)
hasAnyAuthority()是否有多个指定权限,有则true,没有则falsehttp.hasAnyAuthoity(“admins”,“hellos”)
hasRole()是否有指定角色就能够访问,否则403http.hasRole(“ADMIN”)
hasAnyRole()用户具备一个角色权限就可以访问,否则403http.hasAnyRole(“ADMIN”,“TEST”)
@Secured()判断是否有某个角色权限,需要匹配前缀,添加字符串"ROLE_"@Secured({“ROLE_ADMIN”,“ROLE_SALE”})
@PreAuthorize()判断进入方法前的权限验证,不需要加字符串"ROLE_",底层默认添加@PreAuthorize(“hasAnyAuthority(‘sys:test’)”)
@PostAuthorize()判断在方法执行后的权限验证,适合带有返回值的验证@PostAuthorize(“hasRole(‘ADMIN’)”)
1.3 完整项目地址

链接:项目地址
提取码:d1e9

1.5.2 数据库进阶案例
1. 增加新依赖
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.9</version>
</dependency>
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.6</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
2.准备sql
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = MyISAM AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;

-- ----------------------------
-- Records of users
-- ----------------------------
INSERT INTO `users` VALUES (1, 'admin', '$2a$10$Ug6eGqhTX7wY8ZVIe2PbC.ljVWEko5h7ZH92N.rg0ZjZBcgg2VOqm');

SET FOREIGN_KEY_CHECKS = 1;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for persistent_logins
-- ----------------------------
DROP TABLE IF EXISTS `persistent_logins`;
CREATE TABLE `persistent_logins`  (
  `username` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `series` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `token` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `last_used` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL
) ENGINE = MyISAM CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;	
表名作用
users存储用户信息表
persistent_logins存储用户登录token有效时间表
3.修改application.yml

不在配置spring security的用户名和密码

server:
  port: 8080
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
    password: 123
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
  jpa:
    show-sql: true
    database: mysql
    hibernate:
      ddl-auto: update
    open-in-view: true
4.准备pojo
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@Table(name="users")
public class User implements Serializable {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private String username;

	private String password;
}
@Data
@ToString
@NoArgsConstructor
public class ImageCode {
	//验证码
	private String code;
	//过期时间
	private LocalDateTime expireTime;
	//图片
	private BufferedImage image;

	public ImageCode(String code, int expireTime, BufferedImage image) {
		this.code = code;
		this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
		this.image = image;
	}

	public boolean isExpried() {
		return LocalDateTime.now().isAfter(expireTime);
	}
}	
5.准备自定义的HTML

在这里插入图片描述

login.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>登录页面</title>
</head>
<body>
<h2>标准登录页面</h2>
<h3>表单登录</h3>
<form th:action="@{/login}" method="post">
    <table>
        <tr>
            <td>用户名:</td>
            <td><input type="text" name="username"></td>
        </tr>
        <tr>
            <td>密码:</td>
            <td><input type="password" name="password"></td>
        </tr>
        <tr>
            <td>图形验证码</td>
            <td><input type="text" name="imageCode"><img src="/code/image" alt="图形验证码"></td>
        </tr>
        <tr>
            <td>记住我:
            <!--name必须是remember-me-->
            <td><input type="checkbox" name="remember-me" id=""></td>
        </tr>
        <tr>
            <td colspan="2">
                <button type="submit">登录</button>
            </td>
        </tr>
    </table>
</form>
</body>
</html>

logout.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>退出登录</title>
</head>
<body>
<a href="/logout">退出登录</a>
</body>
</html>

success.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    登录成功!
</body>
</html>

failure.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    登录失败!
</body>
</html>

40x.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>403</title>
</head>
<body>
    对不起,你没有权限访问!
</body>
</html>
6. 编写接口
public interface UserDao extends JpaRepository<User,Long>, JpaSpecificationExecutor<User> {
}
7.编写配置

webMvcConfig:html页面存放地址,需要进行跨域,所以需要配置开放前端访问地址。

SpringSecurityConfig:主体实现功能,报错页面403,该页面会提示你没有权限访问;记住登录信息,该功能实现了数据库持久化,主要将用户登录详细信息,token存放在persistent_logins表中;验证码的接口调用;以及授权和相关配置处理。

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

	/**
	 * 开放前端访问地址
	 * @param registry
	 */
	@Override
	public void addViewControllers(ViewControllerRegistry registry) {
		registry.addViewController("/login.html").setViewName("login");
		registry.addViewController("/").setViewName("login");
//        registry.addViewController("/login").setViewName("login");
//        registry.addViewController("/index").setViewName("login");
		registry.addViewController("/success").setViewName("success");
		registry.addViewController("/failure").setViewName("failure");
	}

}
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

	private static final String USERNAME = "user";
	private static final String PASSWORD = "123456";
	@Autowired
	@Qualifier(value = "userDetailsService")
	private UserDetailsServiceImpl userDetailsService;
	@Autowired
	private DataSource dataSource; // 数据源
	@Autowired
	private ValidateCodeFilter validateCodeFilter;
	@Autowired
	private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
	@Autowired
	private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
	@Autowired
	private MyLogoutSuccessHandler myLogoutSuccessHandler;

	/**
	 * 持久化token
	 * <p>
	 * Security中,默认是使用PersistentTokenRepository的子类InMemoryTokenRepositoryImpl,将token放在内存中
	 * 如果使用JdbcTokenRepositoryImpl,会创建表persistent_logins,将token持久化到数据库
	 *
	 * @return
	 */
	@Bean
	public PersistentTokenRepository persistentTokenRepository() {
		JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
		jdbcTokenRepository.setDataSource(dataSource); // // 设置数据源
		//自动创建表,第一次执行会创建,以后执行需要注释掉
//        jdbcTokenRepository.setCreateTableOnStartup(true);
		return jdbcTokenRepository;
	}

	/**
	 * 支持 password 模式
	 * @return
	 * @throws Exception
	 */
	@Override
	@Bean
	public AuthenticationManager authenticationManagerBean() throws Exception {
		return super.authenticationManagerBean();
	}

	/**
	 * 密码加密算法
	 *
	 * @return
	 */
	@Bean
	public BCryptPasswordEncoder bCryptPasswordEncoder() {
		return new BCryptPasswordEncoder();
	}

	/**
	 * 授权验证服务(模拟没有用户详细信息实现类验证)
	 *
	 * @param auth
	 * @throws Exception
	 */
	// @Override
	// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
	// 	BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
	// 	// 加密(SHA-256+随机盐+密钥)
	// 	String password = encoder.encode(PASSWORD);
	// 	// 验证管理员身份
	// 	auth.inMemoryAuthentication().withUser(USERNAME).password(password).roles("ADMIN");
	// }

	/**
	 * 授权验证服务(通过业务层验证后返回用户详细信息)
	 *
	 * @param auth
	 * @throws Exception
	 */
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		//
		validateCodeFilter.setMyAuthenticationFailureHandler(myAuthenticationFailureHandler);

		//自定义403页面
		http.exceptionHandling().accessDeniedPage("/403");

		//添加退出的映射地址
		http.logout() // 退出登录相关配置
				.logoutUrl("/logout") // 自定义退出登录页面
				// .logoutSuccessHandler(myLogoutSuccessHandler) //退出成功后要做的操作(如记录日志),和logoutSuccessUrl互斥
				// .logoutSuccessUrl("/login") // 退出成功后跳转页面
				.deleteCookies("JSESSIONID")    //退出时要删除的Cookies的名字
				.permitAll();

		http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) //
				// 加载用户名密码过滤器的前面,调用自定义验证码过滤器
				.formLogin() // 表单登录
				.loginPage("/login.html") //登录页面设置
				.usernameParameter("username").passwordParameter("password").loginProcessingUrl("/login")//登录访问的路径
				.defaultSuccessUrl("/success") //登录成功,跳转的路径
				.failureUrl("/failure") // 失败的路径
				// .successHandler(myAuthenticationSuccessHandler) // 成功处理
				// .failureHandler(myAuthenticationFailureHandler) // 失败处理
				.permitAll() //指定路径,无需保护
				.and().authorizeRequests() // 身份认证设置
				.antMatchers("/", "/login", "/code/image") // 此路径不需要身份认证
				.permitAll().anyRequest() //其他路径,需要认证
				.authenticated().and().csrf().disable();// 关闭CSRF(防止跨站脚本攻击的csrf token)

		// 开启记住我功能,并设置记住我为一周
		http.rememberMe().tokenRepository(persistentTokenRepository()) // 设置数据访问层
				.userDetailsService(userDetailsService).tokenValiditySeconds(7 * 24 * 60 * 60);

	}

	/**
	 * 开放资源文件
	 * @param web
	 */
	@Override
	public void configure(WebSecurity web) {
		web.ignoring().antMatchers("/css/**", "/js/**", "/plugins/**", "/images/**", "/fonts" + "/**");
	}
}
8.编写验证码过滤器

主要用于验证验证码是否正确,正确则通过,不正确则将异常抛出,由最上层MyAuthenticationFailureHandler捕获,返回相应的错误信息。

@Slf4j
@Component
public class ValidateCodeFilter extends OncePerRequestFilter {

   private MyAuthenticationFailureHandler myAuthenticationFailureHandler;

   public MyAuthenticationFailureHandler getMyAuthenticationFailureHandler() {
      return myAuthenticationFailureHandler;
   }

   public void setMyAuthenticationFailureHandler(MyAuthenticationFailureHandler myAuthenticationFailureHandler) {
      this.myAuthenticationFailureHandler = myAuthenticationFailureHandler;
   }

   /**
    * 操作session的工具类
    */
   private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

   /**
    * 确保请求只通过一次filter
    * @param request
    * @param response
    * @param filterChain
    * @throws ServletException
    * @throws IOException
    */
   @Override
   protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
      // 只有当处理登录功能时才执行
      if (StringUtils.equals("/login", request.getRequestURI()) && StringUtils.equalsIgnoreCase(request.getMethod(),
            "post")) {
         // 最好做try catch 处理异常
         try {
            validate(new ServletWebRequest(request));
         } catch (AuthenticationException e) {
            myAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
            return;
         }
      }
      filterChain.doFilter(request, response);
   }

   // 执行验证码逻辑
   private void validate(ServletWebRequest request) throws AuthenticationException, ServletRequestBindingException {
      // 取出session中的验证码对象
      ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request,
            ValidateCodeController.SESSION_KEY);
      // 取出表单提交中的验证码信息
      String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");

      if (StringUtils.isBlank(codeInRequest)) {
         throw new ValidateCodeException("code in request is null");
      }

      if (codeInSession == null) {
         throw new ValidateCodeException("code in session is null");
      }

      if (codeInSession.isExpried()) {
         sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
         throw new ValidateCodeException("code has expreied");
      }

      if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
         throw new ValidateCodeException("code is not right");
      }

      // 验证通过,清除session中的验证码信息
      sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
   }
}
@Component
@Slf4j
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

   @Autowired
   private ObjectMapper objectMapper;

   public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                       AuthenticationException exception) throws IOException {
      log.info(String.format("IP %s 于 %s 尝试登录系统失败,失败原因:%s", request.getRemoteHost(), LocalDateTime.now(), exception.getMessage()));
      // 这里处理登录失败后就会输出错误信息
      response.setCharacterEncoding("UTF-8"); // 设置编码格式
      response.setContentType("application/json"); // 设置数据格式
      PrintWriter out = response.getWriter();
      Map<String, Object> map = new HashMap<>();
      map.put("status", 401);
      if (exception instanceof LockedException) {
         map.put("msg", "账户被锁定,登录失败!");
      } else if (exception instanceof BadCredentialsException) {
         map.put("msg", "用户名或密码输入错误,登录失败!");
      } else if (exception instanceof DisabledException) {
         map.put("msg", "账户被禁用,登录失败!");
      } else if (exception instanceof AccountExpiredException) {
         map.put("msg", "账户过期,登录失败!");
      } else if (exception instanceof CredentialsContainer) {
         map.put("msg", "密码过期,登录失败");
      } else {
         map.put("msg", "登录失败!");
      }
      out.write(objectMapper.writeValueAsString(map));
      out.flush();
      out.close();
   }
}
9.编写用户业务类
@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {
	/**
	 * 用户密码加密器(这里应该是注入dao或mapper来实现业务逻辑操作)
	 */
	@Autowired
	private PasswordEncoder passwordEncoder;
	private static final String ADMIN = "admin";
	private static final String PASSWORD = "123456";
	@Autowired
	private UserDao userDao;

	/**
	 * 鉴权服务
	 * @param username
	 * @return
	 * @throws UsernameNotFoundException
	 */
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		// 创建一个构造器,该对象只有用户名
		User userBuild = User.builder().username(username).build();
		// 创建一个用户对象实例
		Example<User> userExample = Example.of(userBuild);
		// 根据用户名查询可用的用户对象
		Optional<User> userOptional = userDao.findOne(userExample);
		// 可用的用户对象是否存在
		User user = userOptional.orElseThrow(() -> new UsernameNotFoundException("用户不存在或密码错误!"));
		// 授权集合
		// List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("ADMIN");
		List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("sys.test,sys:add");
		// 返回一个用户详细信息
		return new org.springframework.security.core.userdetails.User(username,user.getPassword()
				,true // 账户可用
				,true // 账户是否过期
				,true // 密码是否过期
				,true // 账户是否被锁定
						,authorities);
	}

	/**
	 * 鉴权服务(模拟没有业务层鉴权)
	 *
	 * @param username 用户名
	 * @return
	 * @throws UsernameNotFoundException
	 */
	// @Override
	// public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
	// 	if (!ADMIN.equals(username)) {
	// 		throw new UsernameNotFoundException("用户不存在或密码错误!");
	// 	}
	// 	// 这里的密码应该从数据库中取出
	// 	String password = passwordEncoder.encode(PASSWORD);
	// 	// 授予admin用户权限
	// 	Collection<? extends GrantedAuthority> authorities =
	// 			Stream.of(new SimpleGrantedAuthority("ADMIN")).collect(Collectors.toList());
	// 	// 返回一个用户对象
	// 	User user = new User(username, password, authorities);
	// 	return user;
	// }

}
10.编写剩余的handler
@Component
@Slf4j
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

   @Autowired
   private ObjectMapper objectMapper;

   @Override
   public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                       Authentication authentication) throws IOException {
      log.info(String.format("IP %s,用户 %s, 于 %s 成功登录。", request.getRemoteHost(), authentication.getName(),
            LocalDateTime.now()));
      //这里处理登录成功后就会json输出authentication里面的数据
      response.setCharacterEncoding("UTF-8"); // 设置编码格式
      response.setContentType("application/json"); // 设置数据格式
      PrintWriter out = response.getWriter();
      Map<String, Object> map = new HashMap<>();
      map.put("status", 200);
      map.put("msg", authentication.getPrincipal());  // 登录成功的对象
      out.write(objectMapper.writeValueAsString(map));
      out.flush();
      out.close();
   }
}
@Component
@Slf4j
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

   @Autowired
   private ObjectMapper objectMapper;

   @Override
   public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
                               Authentication authentication) throws IOException {
      log.info(String.format("IP %s,用户 %s, 于 %s 注销登录。", request.getRemoteHost(), authentication.getName(),
            LocalDateTime.now()));
      response.setCharacterEncoding("UTF-8"); // 设置编码格式
      response.setContentType("application/json"); // 设置数据格式
      PrintWriter out = response.getWriter();
      Map<String, Object> map = new HashMap<>();
      map.put("status", 200);
      map.put("msg", "注销登录成功!");
      out.write(objectMapper.writeValueAsString(map));
      out.flush();
      out.close();
   }
}
11.编写验证码控制器
@RestController
public class ValidateCodeController {
   public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
   // 操作session的工具类
   private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

   @GetMapping("/code/image")
   public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
      ImageCode imageCode = createImageCode(request);
      // 将验证码存入session
      sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);
      // 输出图片
      ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
   }

   // 创建验证码对象
   private ImageCode createImageCode(HttpServletRequest request) {
      int width = 67;
      int height = 23;
      BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

      Graphics g = image.getGraphics();

      Random random = new Random();

      g.setColor(getRandColor(200, 250));
      g.fillRect(0, 0, width, height);
      g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
      g.setColor(getRandColor(160, 200));
      for (int i = 0; i < 155; i++) {
         int x = random.nextInt(width);
         int y = random.nextInt(height);
         int xl = random.nextInt(12);
         int yl = random.nextInt(12);
         g.drawLine(x, y, x + xl, y + yl);
      }

      String sRand = "";
      for (int i = 0; i < 4; i++) {
         String rand = String.valueOf(random.nextInt(10));
         sRand += rand;
         g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
         g.drawString(rand, 13 * i + 6, 16);
      }

      g.dispose();

      return new ImageCode(sRand, 60, image);
   }

   /**
    * 生成随机背景条纹
    *
    * @param fc
    * @param bc
    * @return
    */
   private Color getRandColor(int fc, int bc) {
      Random random = new Random();
      if (fc > 255) {
         fc = 255;
      }
      if (bc > 255) {
         bc = 255;
      }
      int r = fc + random.nextInt(bc - fc);
      int g = fc + random.nextInt(bc - fc);
      int b = fc + random.nextInt(bc - fc);
      return new Color(r, g, b);
   }

}
12.测试

访问 http://localhost:8080/ 进行登录验证,用户名:admin,密码:123456

具体演示,自行测试。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

hikktn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值