• Spring Security 安全框架NOTE


    目录

    1、什么是 Spring Security 安全框架?

    2、关于 SpringSecurity 中的认证

    3、关于 SpringSecurity 中的授权

    3.1 从数据库中查询用户的权限信息

    4、关于自定义失败处理

    5、跨域问题


    前提引入:

    随着科技的完善,现在几乎所有的网站以及软件都需要进行授权认证,使之更加的安全可靠

    认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户

    授权:经过认证后判断当前用户是否有权限进行某个操作

    1、什么是 Spring Security 安全框架?

    定义:Spring Security 是一个功能强大且灵活的身份验证和授权框架,用于保护基于 Spring 的应用程序;它提供了一套综合的安全性解决方案,可以用于 Web 应用程序、REST API、微服务等各种应用场景

    Spring Security 的主要功能和作用如下:

    1. 身份验证(Authentication):Spring Security 提供了多种身份验证方式,包括基于表单、HTTP 基本认证、LDAP、OAuth2 等。它可以集成到应用程序中,通过验证用户提供的凭据(如用户名和密码)来验证用户身份

    2. 授权(Authorization):Spring Security 支持基于角色和权限的授权机制。它允许您定义细粒度的访问控制规则,例如指定哪些用户具有访问某些受保护资源的权限

    3. 攻击防护(Attack Protection):Spring Security 提供了一系列的防护机制来应对常见的安全攻击,例如跨站点请求伪造(CSRF)攻击、会话固定攻击、点击劫持等。它通过配置合适的安全措施来保护应用程序免受这些攻击的风险

    4. 集成第三方认证系统:Spring Security 可以与其他身份认证系统(如LDAP、OAuth2)进行集成,以便使用这些系统中已有的用户凭据进行身份验证

    5. 定制化和扩展性:Spring Security 提供了丰富的配置选项和可插拔的拦截器机制,使开发人员可以根据应用程序的需求进行灵活的定制和扩展

    6. 审计和日志记录:Spring Security 可以记录关于身份验证和授权过程的审计日志,为应用程序的安全性监控和追踪提供支持


    2、关于 SpringSecurity 中的认证

    登录校验流程图解:

    SpringSecurity 安全框架其实是多个过滤器(过滤器链)所组成的,内部包含了各种功能

    SpringSecurity 中过滤器详解图:

    UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要由它负责

    ExceptionTranslationFilter:处理过滤器链中抛出的任何 AccessDeniedException 和AuthenticationException  异常(即 用户授权异常 和 用户认证异常)

    FilterSecurityInterceptor:负责权限校验的过滤器

    认证流程详解图:

    Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息

    AuthenticationManager接口:定义了认证Authentication的方法

    UserDetailsService接口:加载用户特定数据的核心接口;里面定义了一个根据用户名查询用户信息的方法

    UserDetails接口:提供核心用户信息;通过 UserDetailsService 根据用户名获取处理的用户信息要封装成 UserDetails 对象返回;然后将这些信息封装到 Authentication 对象中

    这里设定一个 UserLogin 类,来继承 UserDetails 接口,用来表示登录用户的信息,便于之后的调用(User 为用户类)

    1. @Data
    2. @NoArgsConstructor
    3. @AllArgsConstructor
    4. public class LoginUser implements UserDetails {
    5. private User user;
    6. /**
    7. * 获取权限信息
    8. */
    9. @Override
    10. public Collectionextends GrantedAuthority> getAuthorities() {
    11. return null;
    12. }
    13. @Override
    14. public String getPassword() {
    15. return user.getPassword();
    16. }
    17. @Override
    18. public String getUsername() {
    19. return user.getUserName();
    20. }
    21. /**
    22. * 判断用户账号是否过期
    23. */
    24. @Override
    25. public boolean isAccountNonExpired() {
    26. return true;
    27. }
    28. /**
    29. * 判断用户是否被锁定
    30. */
    31. @Override
    32. public boolean isAccountNonLocked() {
    33. return true;
    34. }
    35. /**
    36. * 判断该用户的认证凭证是否过期
    37. */
    38. @Override
    39. public boolean isCredentialsNonExpired() {
    40. return true;
    41. }
    42. /**
    43. * 用于判断用户是否启用
    44. */
    45. @Override
    46. public boolean isEnabled() {
    47. return true;
    48. }
    49. }

    由于认证的时候,存在自定义的登录接口,需要让 SpringSecurity 将其放行,使用户在不需要登录的时候也能够访问;同时,可以设置过滤器的前后位置(这里将JWT认证过滤器放在用户登录过滤器之前)

    代码如下:

    这里是用户登录验证的接口

    1. /**
    2. * 【用户的验证】
    3. */
    4. @Service
    5. public class LoginServiceImpl implements LoginService {
    6. @Resource
    7. private AuthenticationManager authenticationManager;
    8. @Resource
    9. private RedisCache redisCache;
    10. @Override
    11. public ResponseResult login(User user) {
    12. //1.使用 authenticate 方法进行用户的验证
    13. UsernamePasswordAuthenticationToken authenticationToken
    14. = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
    15. Authentication authenticateResult = authenticationManager.authenticate(authenticationToken);
    16. //2.如果认证没通过,则进行提示
    17. if(Objects.isNull(authenticateResult)){
    18. throw new RuntimeException("用户名或密码错误!");
    19. }
    20. //3.如果认证通过,使用 userId 生成一个 jwt
    21. LoginUser loginUser = (LoginUser) authenticateResult.getPrincipal(); //当前登录用户信息
    22. Long userId = loginUser.getUser().getId();
    23. String jwt = JwtUtil.createJWT(userId.toString()); //使用 jwt 工具类进行生成
    24. HashMap map = new HashMap<>();
    25. map.put("token",jwt);
    26. //4.把完整的用户信息存入 redis ,其中 userId 作为 key
    27. redisCache.setCacheObject("login:"+userId,loginUser);
    28. return new ResponseResult(200,"登录成功!",map);
    29. }
    30. }

    这里是认证放行接口,同时设置了过滤器的前后顺序

    1. @Configuration
    2. public class SecurityConfig extends WebSecurityConfigurerAdapter {
    3. /**
    4. * 【这里是认证放行接口】
    5. */
    6. @Bean
    7. @Override
    8. protected void configure(HttpSecurity http) throws Exception {
    9. http
    10. //关闭csrf保护机制
    11. .csrf().disable()
    12. //不通过Session获取SecurityContext,而是通过 Token 进行获取
    13. .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    14. .and()
    15. .authorizeRequests()
    16. // 对于登录接口 允许匿名访问
    17. .antMatchers("/user/login").anonymous()
    18. // 除上面外的所有请求全部需要鉴权认证
    19. .anyRequest().authenticated();
    20. //将 jwt认证过滤器 放在 用户登录过滤器 之前
    21. http.addFilterBefore(jwtAuthenticationTokenFilter,
    22. UsernamePasswordAuthenticationFilter.class);
    23. }
    24. }

    这里是 JWT认证过滤器,通过请求请求头中发送过来的 Token, 对 Token 中进行解析,从而取出其中的 userId;使用 userId 去 redis 中获取相应的 LoginUser 对象,最后存入 SecurityContextHolder 中(主要用于在整个应用程序中存储和获取用户的安全上下文 )

    1. @Component
    2. public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    3. @Resource
    4. private RedisCache redisCache;
    5. @Override
    6. protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
    7. //1.从请求头中获取 token
    8. String token = request.getHeader("token");
    9. //1.1 若 token 不存在则直接放行(token 不存在说明不需要认证)
    10. if(!StringUtils.hasLength(token)){
    11. chain.doFilter(request,response);
    12. return;
    13. }
    14. //2.解析 token
    15. String userId ;
    16. try {
    17. Claims claims = JwtUtil.parseJWT(token);
    18. userId = claims.getSubject();
    19. } catch (Exception e) {
    20. e.printStackTrace();
    21. throw new RuntimeException("token 非法");
    22. }
    23. //3.从 redis 中获取用户信息
    24. String redisKey = "login:"+ userId;
    25. LoginUser loginUser = redisCache.getCacheObject(redisKey);
    26. if(Objects.isNull(loginUser)){
    27. throw new RuntimeException("该用户不存在!");
    28. }
    29. //4.存入 SecurityContextHolder
    30. //TODO 获取权限信息
    31. UsernamePasswordAuthenticationToken authenticationToken
    32. = new UsernamePasswordAuthenticationToken(loginUser,null,null);
    33. SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    34. //5.进行放行
    35. chain.doFilter(request,response);
    36. }

    前面已经有了用户登录接口,所以现在需要退出登录接口

    由于之前在 JwtAuthenticationTokenFilter 过滤器中,已经将登录成功的用户信息放入了 SecurityContextHolder 对象中,所以这里需要将其从中取出;

    然后通过用户信息获取到用户 ID,最后在 Redis 中根据对应的用户 ID 删除对应用户的 Redis 缓存信息,这样就可以在下一次登录的时候使之前已经退出的用户 Token 失效,从而需要重新登录,才可访问其他需要授权的页面

    1. /**
    2. * 【用户退出登录】
    3. */
    4. @Override
    5. public ResponseResult logout() {
    6. //1.由于用户信息已经保存到了 SecurityContextHolder 中
    7. // 所以从 SecurityContextHolder 中获取用户 ID
    8. UsernamePasswordAuthenticationToken authentication
    9. = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
    10. //1.1 通过 UserDetails 获取用户登录信息
    11. LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    12. User user = loginUser.getUser();
    13. Long userId = user.getId();
    14. //2.根据用户 id 删除 redis 中对应的 key 所对应的 value 值
    15. redisCache.deleteObject("login:"+userId);
    16. return new ResponseResult(200,"退出登录成功!");
    17. }

    3、关于 SpringSecurity 中的授权

    前文引入:

            例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能;但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能

    所以说,设置权限这一项,对于一个完整的软件而言尤为重要......

    第一步:首先在认证放行接口 SecurityConfig 处开启权限控制

    @Configuration
    @EnableGlobalMethodSecurity(prePostEnabled = true)  //【开启权限控制】
    public class SecurityConfig extends WebSecurityConfigurerAdapter {

      ...............

    }

    第二步:这里创建一个测试权限的接口,同时设置权限的信息

    需要说明的是,@PreAuthorize 注解用于在方法上面设置权限的信息 ,SpEL 表达式定义权限规则,这里的表达式 hasAuthority('test') 表示该方法需要具有"test"权限

    1. @RestController
    2. public class HelloController {
    3. @GetMapping("hello")
    4. @PreAuthorize("hasAuthority('system:dept:list')") //设置权限信息
    5. public String hello(){
    6. return "hello";
    7. }
    8. }

    第三步:我们需要重写 LoginUser(继承 UserDetals) 中的 getAuthorities() 方法,将继承 UserDetailService 的类 传过来的权限信息封装到 SimpleGrantedAuthority 对象中进行返回

    这里是 UserDetailsServiceImpl 类(继承了 UserDetailsService 类),这里的用户权限信息先写死

    1. @Slf4j
    2. @Service
    3. public class UserDetailsServiceImpl implements UserDetailsService {
    4. @Resource
    5. private UserMappper userMappper;
    6. @Override
    7. public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    8. LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
    9. queryWrapper.eq(User::getUserName,username);
    10. //1.查询用户信息
    11. User user = userMappper.selectOne(queryWrapper);
    12. if(Objects.isNull(user)){
    13. log.error("用户名或密码错误!");
    14. throw new RuntimeException("用户名或密码错误!");
    15. }
    16. //2.将用户信息封装为 UserDetails 对象返回
    17. ArrayList list = new ArrayList<>(Arrays.asList("test","admin")); //这里权限信息先进行写死
    18. return new LoginUser(user,list); //将用户以及权限信息传入 LoginUser 对象中
    19. }
    20. }

      这里是 LoginUser 类(继承了 UserDetails 类 );其中, SpringSecurity 中的 SimpleGrantedAuthority     对象用于表示授权信息,表示用户被授予的权限或角色

    1. @Data
    2. @NoArgsConstructor
    3. public class LoginUser implements UserDetails {
    4. private User user;
    5. private List permissions; //创建一个集合,用来封装权限信息
    6. @JSONField(serialize = false) //敏感信息,让 JSON 字符串不包含该字段
    7. private List authorities;
    8. /**
    9. * 获取权限信息
    10. */
    11. @Override
    12. public Collectionextends GrantedAuthority> getAuthorities() {
    13. //1.若不为空,则直接返回(说明之前已经存在权限信息)
    14. if(authorities!=null){
    15. return authorities;
    16. }
    17. //2.将 permission 中的权限信息封装到 GrantedAuthority 对象中进行返回
    18. authorities = permissions.stream()
    19. .map(new Function() {
    20. @Override
    21. public SimpleGrantedAuthority apply(String permission) {
    22. SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
    23. return simpleGrantedAuthority;
    24. }
    25. }).collect(Collectors.toList());
    26. return authorities;
    27. }
    28. @Override
    29. public String getPassword() {
    30. return user.getPassword();
    31. }
    32. @Override
    33. public String getUsername() {
    34. return user.getUserName();
    35. }
    36. /**
    37. * 判断用户账号是否过期
    38. */
    39. @Override
    40. public boolean isAccountNonExpired() {
    41. return true;
    42. }
    43. /**
    44. * 判断用户是否被锁定
    45. */
    46. @Override
    47. public boolean isAccountNonLocked() {
    48. return true;
    49. }
    50. /**
    51. * 判断该用户的认证凭证是否过期
    52. */
    53. @Override
    54. public boolean isCredentialsNonExpired() {
    55. return true;
    56. }
    57. /**
    58. * 用于判断用户是否启用
    59. */
    60. @Override
    61. public boolean isEnabled() {
    62. return true;
    63. }
    64. public LoginUser(User user, List permissions) {
    65. this.user = user;
    66. this.permissions = permissions;
    67. }
    68. }

    第四步:由于这时候  LoginUser 中的  getAuthorities() 重写方法已存在用户的权限信息,所以经过 JWT 认证过滤器的时候,需要将其存入 SecurityContextHolder 对象中并进行返回,因为在整个 SpringSecurity 框架中,这个对象是连接整个认证流程的上下文

    1. //4.将用户信息存入 SecurityContextHolder
    2. //4.1获取权限信息
    3. Collectionextends GrantedAuthority> authorities = loginUser.getAuthorities();
    4. //4.2 将用户信息以及权限信息存入 SecurityContextHolder 对象中
    5. UsernamePasswordAuthenticationToken authenticationToken
    6. = new UsernamePasswordAuthenticationToken(loginUser,null,authorities);
    7. SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    8. //5.进行放行
    9. chain.doFilter(request,response);

    第五步:在用户登录退出接口处,使用该上下文对象,获取当前用户权限信息,来进行后续的操作

    这里由于用户的权限信息是写死的,平时通常是动态获取的,所以我们选择从数据库中进行动态的获取权限信息

    3.1 从数据库中查询用户的权限信息

    这里使用的是 RBAC 模型,即基于角色的权限控制

    如图所示:

    这里使用角色的关联表将其他的数据表关联起来

      由于存在关联表,所以需要进行多表联查

    对应的 SQL 语句如下所示:

    1. SELECT DISTINCT sm.perms
    2. FROM sys_user_role sur #用户角色关联表 user_role
    3. LEFT JOIN sys_role sr #角色表 role
    4. ON sur.role_id = sr.id
    5. LEFT JOIN sys_role_menu srm #角色权限关联表 role_menu
    6. ON srm.role_id = sur.role_id
    7. LEFT JOIN sys_menu sm #权限表 menu
    8. ON sm.id = srm.menu_id
    9. WHERE user_id = 2
    10. AND sr.`status` = 0 AND sm.`status` = 0

    MyBatis-Plus 对应的 xml 文件:

    1. "1.0" encoding="UTF-8" ?>
    2. mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
    3. <mapper namespace="MyPro.Mapper2.MenuMapper">
    4. <select id="selectPermsByUserId" resultType="java.lang.String">
    5. SELECT DISTINCT (sm.perms)
    6. FROM sys_user_role sur
    7. LEFT JOIN sys_role sr
    8. ON sur.role_id = sr.id
    9. LEFT JOIN sys_role_menu srm
    10. ON srm.role_id = sur.role_id
    11. LEFT JOIN sys_menu sm
    12. ON sm.id = srm.menu_id
    13. WHERE user_id = {#userId}
    14. AND sr.`status` = 0 AND sm.`status` = 0
    15. select>
    16. mapper>

    这里进行调用 Mapper 方法,动态的获取用户权限信息

    1. @Slf4j
    2. @Service
    3. public class UserDetailsServiceImpl implements UserDetailsService {
    4. @Autowired
    5. private UserMapper userMapper;
    6. @Autowired
    7. private MenuMapper menuMapper;
    8. @Override
    9. public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    10. LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
    11. queryWrapper.eq(User::getUserName,username);
    12. //1.查询用户信息
    13. User user = userMapper.selectOne(queryWrapper);
    14. if(Objects.isNull(user)){
    15. log.error("用户名或密码错误!");
    16. throw new RuntimeException("用户名或密码错误!");
    17. }
    18. //2.将用户信息封装为 UserDetails 对象返回
    19. // ArrayList list = new ArrayList<>(Arrays.asList("test","admin")); //这里权限信息先进行写死
    20. List list = menuMapper.selectPermsByUserId(user.getId()); //这里进行动态的获取用户权限信息
    21. return new LoginUser(user,list); //将用户以及权限信息传入 LoginUser 对象中
    22. }
    23. }


    4、关于自定义失败处理

    前言:

    在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter 捕获到;在 ExceptionTranslationFilter 中会去判断是认证失败还是授权失败出现的异常

    如果是认证过程中出现的异常会被封装成 AuthenticationException 然后调用AuthenticationEntryPoint 对象的方法去进行异常处理

    如果是授权过程中出现的异常会被封装成 AccessDeniedException 然后调用AccessDeniedHandler 对象的方法去进行异常处理

    所以,我们需要进行自定义失败处理,以进行统一的异常处理

    这里是【用户认证】的异常处理类:

    1. @Component
    2. public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    3. @Override
    4. public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
    5. ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(),"用户认证失败!");
    6. String json = JSON.toJSONString(result);
    7. //处理异常
    8. WebUtils.renderString(response,json);
    9. }
    10. }

    这里是【用户授权】的异常处理类:

    1. @Component
    2. public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    3. @Override
    4. public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) throws IOException, ServletException {
    5. ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(),"您的权限不足!");
    6. String json = JSON.toJSONString(result);
    7. //处理异常
    8. WebUtils.renderString(response,json);
    9. }
    10. }

    将上面的异常处理器在 Config 类中进行配置,让 SpringSecurity 框架使用自定义处理器:

    1. protected void configure(HttpSecurity http) throws Exception {
    2. http
    3. //关闭csrf保护机制
    4. .csrf().disable()
    5. //不通过Session获取SecurityContext
    6. .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    7. .and()
    8. .authorizeRequests()
    9. // 对于登录接口 允许匿名访问
    10. .antMatchers("/user/login").anonymous()
    11. // 除上面外的所有请求全部需要鉴权认证
    12. .anyRequest().authenticated();
    13. //将 jwt认证过滤器 放在 用户登录过滤器 之前
    14. http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    15. //这里是进行配置异常处理器
    16. http.exceptionHandling()
    17. .authenticationEntryPoint(authenticationEntryPoint) //用户认证
    18. .accessDeniedHandler(accessDeniedHandler); //用户授权
    19. }


    5、跨域问题

    前言:

    浏览器出于安全的考虑,使用 XMLHttpRequest对象 发起 HTTP请求时必须遵守同源策略(要求源相同才能正常进行通信,即协议、域名、端口号都完全一致,否则就是跨域的HTTP请求,跨域默认情况下是被禁止的

    前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题

    所以我们就要处理一下,让前端能进行跨域请求

    这里对 SpringBoot 配置,进行跨域请求的配置:

    1. @Configuration
    2. public class CorsConfig implements WebMvcConfigurer {
    3. @Override
    4. public void addCorsMappings(CorsRegistry registry) {
    5. // 设置允许跨域的路径
    6. registry.addMapping("/**")
    7. // 设置允许跨域请求的域名
    8. .allowedOriginPatterns("*")
    9. // 是否允许cookie
    10. .allowCredentials(true)
    11. // 设置允许的请求方式
    12. .allowedMethods("GET", "POST", "DELETE", "PUT")
    13. // 设置允许的header属性
    14. .allowedHeaders("*")
    15. // 跨域允许时间
    16. .maxAge(3600);
    17. }
    18. }

    当然,以上只是对 Spring 进行了配置跨域的请求,还需要对 SpringSecurity 进行跨域的配置

    这里,在 Config 配置类中进行跨域的配置:

    @Override
        protected void configure(HttpSecurity http) throws Exception {
    
            http
                    //关闭csrf保护机制
                    .csrf().disable()
                    //不通过Session获取SecurityContext,而是通过Token进行获取
           .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .authorizeRequests()
                    // 对于登录接口 允许匿名访问
                    .antMatchers("/user/login").anonymous()
                    // 除上面外的所有请求全部需要鉴权认证
                    .anyRequest().authenticated();
    
            //将 jwt认证过滤器 放在 用户登录过滤器 之前
            http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    
            //这里是进行配置异常处理器
            http.exceptionHandling()
                    .authenticationEntryPoint(authenticationEntryPoint)  //用户认证
                    .accessDeniedHandler(accessDeniedHandler);  //用户授权
    
            //允许跨域
            http.cors();
        }
    }

    注意:如果使用 PostMan 进行测试是不会成功的,因为它的本质还是在 “同源策略” 中

  • 相关阅读:
    Apache Hudi vs Delta Lake:透明TPC-DS Lakehouse性能基准
    Array类(C#)
    Java技能树评测
    迅为龙芯2K1000开发板通过汇编控制GPIO
    uni-app小零碎(包括封装网络请求)
    关于到年底日常生活的工作计划
    freeswitch隐藏fs标识
    uni-app入门:HBuilderX安装与项目创建
    软考高级系统架构设计师系列论文真题七:基于构件的软件开发
    STM32F429基于TouchGFX进行简单控制LED和显示ADC值
  • 原文地址:https://blog.csdn.net/qq_66862911/article/details/132581961