• Spring Security基于jwt实现权限校验


    一 引言

    基于springsecurity和jwt实现的单体项目token认证中我实现了基于jwt实现的认证,本文在此基础上继续实现权限认证
    在这里插入图片描述

    • 用户认证成功后携带jwt发起请求,请求被AuthenticationFilter拦截到,进行jwt的校验
    • jwt校验成功后,调用JwtAuthenticationProvider从jwt中获得权限信息,加载到Authcation中
    • 将Authcation加载安全上下文SecurityContextHolder
    • FilterSecurityInterceptor从上下文中获得用户权限信息,根据校验规则进行用户数据的权限校验

    二 代码实现

    用户认证成功生成jwt时将权限信息加载到jwt中

    package com.xlcp.xlcpdemo.auth.token;
    
    import cn.hutool.core.collection.CollUtil;
    import cn.hutool.core.convert.Convert;
    import cn.hutool.core.date.DateField;
    import cn.hutool.core.date.DateTime;
    import cn.hutool.core.date.DateUtil;
    import cn.hutool.core.io.resource.ResourceUtil;
    import cn.hutool.core.util.IdUtil;
    import cn.hutool.crypto.asymmetric.SignAlgorithm;
    import cn.hutool.jwt.JWT;
    import cn.hutool.jwt.JWTUtil;
    import cn.hutool.jwt.JWTValidator;
    import cn.hutool.jwt.RegisteredPayload;
    import cn.hutool.jwt.signers.AlgorithmUtil;
    import cn.hutool.jwt.signers.JWTSigner;
    import cn.hutool.jwt.signers.JWTSignerUtil;
    import com.xlcp.xlcpdemo.auth.common.AccessToken;
    import com.xlcp.xlcpdemo.auth.common.AccessTokenType;
    import com.xlcp.xlcpdemo.auth.common.AuthProperties;
    import com.xlcp.xlcpdemo.auth.core.AccessTokenManager;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.security.core.Authentication;
    
    import java.security.KeyPair;
    import java.security.KeyStore;
    import java.security.PrivateKey;
    import java.security.PublicKey;
    import java.util.Date;
    import java.util.HashMap;
    import java.util.List;
    
    /**
     * @author likun
     * @date 2022年07月12日 13:48
     */
    @Slf4j
    public class JwtAccessTokenManager implements AccessTokenManager {
        private final AuthProperties authProperties;
    
        private final JWTSigner jwtSigner;
        // 省略....
        @Override
        public AccessToken createToken(Authentication authentication) {
            AccessToken accessToken = new AccessToken();
            accessToken.setTokenType(AccessTokenType.JWT.name());
            accessToken.setExpireInTimeMills(authProperties.getExpireInTimeMills());
            HashMap<String, Object> payloads = new HashMap<String, Object>();
            payloads.put(RegisteredPayload.AUDIENCE, authentication.getName());
            payloads.put(RegisteredPayload.JWT_ID, IdUtil.fastUUID());
            DateTime expiredAt = DateUtil.offset(new Date(), DateField.MILLISECOND, Convert.toInt(authProperties.getExpireInTimeMills()));
            payloads.put(RegisteredPayload.EXPIRES_AT, expiredAt);
            // todo 数据库查询权限信息
            List permissions = CollUtil.newArrayList("ROLE_BUYER","ROLE_SELLER","user_find_account");
            payloads.put("authDetails", permissions);
            String token = JWTUtil.createToken(payloads, this.jwtSigner);
            accessToken.setAccessToken(token);
            return accessToken;
        }
    
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62

    定义JwtAuthenticationProviderJwtAuthenticationToken用于认证成功从jwt中解析jwt中的权限信息

    package com.xlcp.xlcpdemo.auth.core;
    
    import cn.hutool.jwt.JWT;
    import org.springframework.security.authentication.AuthenticationProvider;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.AuthorityUtils;
    
    import java.util.List;
    
    /**
     * @author likun
     * @date 2022年12月01日 12:25
     */
    public class JwtAuthenticationProvider implements AuthenticationProvider {
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication;
            String token = jwtAuthenticationToken.getToken();
            JWT jwt = JWT.create().parse(token);
            Object authDetails = jwt.getPayload("authDetails");
            Object aud = jwt.getPayload("aud");
            List<GrantedAuthority> permissions;
            if (authDetails!=null&&authDetails instanceof List){
                List<String> auths = (List<String>) authDetails;
                permissions=AuthorityUtils.createAuthorityList(auths.toArray(new String[0]));
            }else {
                permissions = AuthorityUtils.createAuthorityList("");
            }
            return new JwtAuthenticationToken(aud,null,permissions);
        }
    
        @Override
        public boolean supports(Class<?> authentication) {
            return (JwtAuthenticationToken.class.isAssignableFrom(authentication));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    package com.xlcp.xlcpdemo.auth.core;
    
    import lombok.Getter;
    import org.springframework.security.authentication.AbstractAuthenticationToken;
    import org.springframework.security.core.GrantedAuthority;
    
    import java.util.Collection;
    
    /**
     * @author likun
     * @date 2022年12月01日 11:51
     */
    public class JwtAuthenticationToken extends AbstractAuthenticationToken {
        private  Object principal;
    
        private Object credentials;
    
        @Getter
        private String token;
    
        public JwtAuthenticationToken(Object principal,Object credentials,Collection<? extends GrantedAuthority> authorities) {
            super(authorities);
            this.principal= principal;
            this.credentials= credentials;
            setAuthenticated(true);
        }
    
        public JwtAuthenticationToken(String token){
            super(null);
            this.token=token;
            setAuthenticated(false);
        }
    
        @Override
        public Object getCredentials() {
            return credentials;
        }
    
        @Override
        public Object getPrincipal() {
            return principal;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43

    jwt校验成功后解析jwt并加载到安全上下文中

    package com.xlcp.xlcpdemo.auth.core;
    
    import cn.hutool.core.collection.CollUtil;
    import cn.hutool.core.util.StrUtil;
    import cn.hutool.http.Header;
    import cn.hutool.jwt.JWT;
    import cn.hutool.jwt.RegisteredPayload;
    import com.baomidou.mybatisplus.core.toolkit.StringUtils;
    import com.xlcp.xlcpdemo.auth.common.AuthProperties;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.http.HttpStatus;
    import org.springframework.security.access.AccessDeniedException;
    import org.springframework.security.authentication.AuthenticationManager;
    import org.springframework.security.authentication.InsufficientAuthenticationException;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.util.AntPathMatcher;
    import org.springframework.web.filter.OncePerRequestFilter;
    
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.util.Set;
    
    import static com.xlcp.xlcpdemo.entity.PtUser.ACCOUNT;
    
    /**
     * @author likun
     * @date 2022年07月12日 15:14
     */
    @Slf4j
    public class AuthenticationFilter extends OncePerRequestFilter {
    
        private static final String BEARER = "bearer";
    
        private final AuthProperties authProperties;
        private final AccessTokenManager accessTokenManager;
        private final AntPathMatcher antPathMatcher;
        private final AuthenticationManager authenticationManager;
    
        public AuthenticationFilter(AuthProperties authProperties, AccessTokenManager accessTokenManager, AntPathMatcher antPathMatcher,AuthenticationManager authenticationManager){
            this.authProperties=authProperties;
            this.accessTokenManager=accessTokenManager;
            this.antPathMatcher=antPathMatcher;
            this.authenticationManager=authenticationManager;
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            // 判断当前请求是否为忽略的路径
            Set<String> ignorePaths = authProperties.getIgnorePaths();
            if (CollUtil.isNotEmpty(ignorePaths)){
                for (String ignorePath : ignorePaths) {
                    if (antPathMatcher.match(ignorePath,request.getRequestURI())){
                        filterChain.doFilter(request, response);
                        return;
                    }
                }
            }
            // token校验
            String bearerToken = request.getHeader(Header.AUTHORIZATION.getValue());
    
            if (StrUtil.isBlank(bearerToken)){
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                throw new InsufficientAuthenticationException("unauthorized request.");
            }
    
            final String accessToken = bearerToken.trim().substring(BEARER.length()).trim();
            boolean valid = false;
            try {
                valid = accessTokenManager.verify(accessToken);
            } catch (Exception e) {
                log.warn("verify access token [{}] failed.", accessToken);
                throw new InsufficientAuthenticationException("invalid access token + [ " + accessToken + " ].");
            }
            if (!valid) {
                throw new InsufficientAuthenticationException("invalid access token + [ " + accessToken + " ].");
            }
    
            final String account = request.getParameter(ACCOUNT);
            if (StringUtils.isBlank(account)) {
                SetAuthentication(accessToken);
                filterChain.doFilter(request, response);
                return;
            }
            //校验是否本人
            final String audience = JWT.of(accessToken).getPayload(RegisteredPayload.AUDIENCE).toString();
            if (!account.equalsIgnoreCase(audience)) {
                throw new AccessDeniedException("invalid account. parameter [ " + account + " ]. account in token [ " + audience + " ].");
            }
            SetAuthentication(accessToken);
            filterChain.doFilter(request, response);
    
        }
        // 解析jwt并加载到安全上下文中
        private void SetAuthentication(String accessToken) {
            JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(accessToken);
            Authentication authenticate = authenticationManager.authenticate(jwtAuthenticationToken);
            SecurityContextHolder.getContext().setAuthentication(authenticate);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103

    自定义权限不足返回异常处理

    public class CustomAccessDeniedHandler implements AccessDeniedHandler {
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
            response.setStatus(HttpStatus.FORBIDDEN.value());
            R<Object> result = R.failed("无访问权限");
            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write(JSONUtil.toJsonStr(result));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    完成相应的配置
    在这里插入图片描述

    在这里插入图片描述

    二 权限访问

    2.1 理论基础

    Spring Security是一个功能强大且高度可定制的 身份认证访问控制 框架,它是保护基于spring应用程序的事实标准。

    权限访问:就是 给用户角色添加角色权限,使得不同的用户角色只能访问特定的接口资源,对于其他接口无法访问

    2.2 权限分类

    根据业务的不同将权限的控制分为两类,一类是 To-C简单角色的权限控制 ,一类是 To-B基于RBAC数据模型的权限控制

    • To-C简单角色的权限控制

    例如 买家和卖家,这两者都是单独的个体,一般来说都是只有一种独立的角色,比如卖家角色:ROLE_SELLER,买家角色:ROLE_BUYER。这类一般比较粗粒度的将角色划分,且角色比较固定,角色拥有的权限也是比较固定,在项目启动的时候就固定了。

    • To-B基于RBAC数据模型的权限控制

    例如 PC后台的管理端,能登录的是企业的人员,企业人员可以有不同的角色,角色的权限也可以比较随意地去改变,比如总经理角色可以访问所有资源,店铺管理人员只能访问店铺和卖家相关信息,会员管理人员可以访问买家相关信息等等,这时候就可以使用基于RBAC数据模型结合Spring Security的访问控制来实现权限方案。这类一般角色划分较细,角色的权限也是上线后在PC端可任意配置

    在我们的日常开发中一般用得比较多的是第二种

    2.3 To-C:简单角色的权限控制

    定义相应的接口

    @RestController
    @RequestMapping("/buyer")
    public class BuyerController {
    
        /**
         * 买家下订单
         *
         * @return
         */
        @GetMapping("/order:create")
        public String receiveOrder() {
            return "买家下单啦!";
        }
    
        /**
         * 买家订单支付
         *
         * @return
         */
        @GetMapping("/order:pay")
        public String deliverOrder() {
            return "买家付款了!";
        }
    }
    
    @RestController
    @RequestMapping("/seller")
    public class SellerController {
    
        /**
         * 卖家接单
         *
         * @return
         */
        @GetMapping("/order:receive")
    	@Secured("ROLE_SELLER")
        public String receiveOrder() {
            return "卖家接单啦!";
        }
    
        /**
         * 卖家订单发货
         *
         * @return
         */
        @GetMapping("/order:deliver")
        @Secured("ROLE_SELLER")
        public String deliverOrder() {
            return "卖家发货啦!";
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    我们要做到的是,买家角色只拥有买家接口权限,卖家角色只拥有卖家接口权限。而关于配置角色权限有两种实现方式,一种是在核心配置类中统一配置(买家角色演示),还有一种是在接口上以注解的方式配置(卖家角色演示)。

    2.3.1 统一配置

    在核心配置类(WebSecurityConfig)中,统一配置买家角色权限,角色名称是 ROLE_BUYER,拥有访问 /buyer/** 接口的权限。

    protected void configure(HttpSecurity httpSecurity) throws Exception {
            httpSecurity.csrf().disable()
                    .authorizeRequests()
                    .antMatchers("/buyer/**").hasRole("BUYER")
                    .antMatchers("/**").permitAll()
                    .anyRequest().authenticated()
                    .and()
                    .exceptionHandling()
                    .accessDeniedHandler(new CustomAccessDeniedHandler())
                    .and()
                    .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
            httpSecurity.addFilterBefore(authenticationFilter(accessTokenManager()), UsernamePasswordAuthenticationFilter.class);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    2.3.2 注解方式

    可以使用注解的方式配置接口所能访问的角色,比如卖家端两个接口配置了 ROLE_SELLER 角色才能访问

    @RestController
    @RequestMapping("/seller")
    public class SellerController {
    
        /**
         * 卖家接单
         *
         * @return
         */
        @GetMapping("/order:receive")
    	@Secured("ROLE_SELLER")
        public String receiveOrder() {
            return "卖家接单啦!";
        }
    
        /**
         * 卖家订单发货
         *
         * @return
         */
        @GetMapping("/order:deliver")
        @Secured("ROLE_SELLER")
        public String deliverOrder() {
            return "卖家发货啦!";
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    @Secured、@RolesAllowed、@PreAuthorize 注解都可以达到这样的效果,所有注解能发挥有效的前提是需要在核心配置类加上注解 @EnableGlobalMethodSecurity,然后在此注解上启用对应的注解配置方式,注解才能生效,否则无法起作用,比如要使 @Secured 注解生效需要配置@EnableGlobalMethodSecurity(securedEnabled = true)
    在这里插入图片描述

    注解能否生效和启用注解的属性对应关系如下,简单解释就是要使接口上的注解生效,就需要在核心过滤器配置注解 @EnableGlobalMethodSecurity,然后启用注解对应的属性,就是将属性值设为true。

    生效注解启用注解的属性核心配置器上注解配置
    @SecuredsecuredEnabled@EnableGlobalMethodSecurity(securedEnabled = true)
    @RolesAllowedjsr250Enabled@EnableGlobalMethodSecurity(jsr250Enabled= true)
    @PreAuthorizeprePostEnabled@EnableGlobalMethodSecurity(prePostEnabled = true)

    2.3.3 测试

    只设置ROLE_BUYER角色
    在这里插入图片描述
    买家能正常访问
    在这里插入图片描述
    卖家无访问权限
    在这里插入图片描述

    三 To-B:基于RBAC数据模型的权限控制

    RBAC数据模型

    • 全称:Role-Based Access Control(基于角色的访问控制)
    • 一般会有五个表组成,三张主体表(用户、角色、权限),两张关联表(用户-角色、角色-权限)
      在这里插入图片描述

    3.1 案例

    首先关于RBAC的数据模型大家应该都很熟悉,这里不再创建,即不会涉及到存储。其实这一类相对上面那类区别在于这类的权限不是固定的,需要实时的重新查询出来,再进行判断请求是否有权访问,所以判断是否有权访问的逻辑需要自己完善,写好之后再配置进框架中即可。

    申明权限校验基础接口

    public interface PermissionService {
        /**
         * 判断是否拥有权限
         * @param permissions
         * @return
         */
        boolean hasPermission(String... permissions);
    }
    
    @Component("pms")
    public class PermissionServiceImpl implements PermissionService{
        @Override
        public boolean hasPermission(String... permissions) {
            if (ArrayUtil.isEmpty(permissions)){
                return false;
            }
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    
            if (authentication == null) {
                return false;
            }
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            return authorities.stream().map(GrantedAuthority::getAuthority).filter(StringUtils::hasText)
                    .anyMatch(x -> PatternMatchUtils.simpleMatch(permissions, x));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    在相应的接口上申明相应的权限

    在这里插入图片描述

    开启相应权限支持

    在这里插入图片描述

    用户认证时查询数据库 加载不同的权限

    在这里插入图片描述

    没有权限查询结果

    在这里插入图片描述

    有权限时查询结果

    在这里插入图片描述

    在这里插入图片描述

    四 权限表达式

    上面.permitAll()、.hasRole()、.access()表示权限表达式,而权限表达式实际上都是 Spring中强大的Spel表达式,如下还有很多可以使用的权限表达式以及和Spel表达式的转换关系

    权限表达式(ExpressionUrlAuthorizationConfigurer)说明Spel表达式Spel表达式实际执行方法(SecurityExpressionOperations)
    permitAll()表示允许所有,永远返回truepermitAllpermitAll()
    denyAll()表示拒绝所有,永远返回falsedenyAlldenyAll()
    anonymous()当前用户是anonymous时返回trueanonymousisAnonymous()
    rememberMe()当前用户是rememberMe用户时返回truerememberMeisRememberMe()
    authenticated()当前用户不是anonymous时返回trueauthenticatedisAuthenticated()
    fullyAuthenticated()当前用户既不是anonymous也不是rememberMe用户时返回truefullyAuthenticatedisFullyAuthenticated()
    hasRole(“BUYER”)用户拥有指定权限时返回truehasRole(‘ROLE_BUYER’)hasRole(String role)
    hasAnyRole(“BUYER”,“SELLER”)用于拥有任意一个角色权限时返回truehasAnyRole (‘ROLE_BUYER’,‘ROLE_BUYER’)hasAnyRole(String… roles)
    hasAuthority(“BUYER”)同hasRolehasAuthority(‘ROLE_BUYER’)hasAuthority(String role)
    hasAnyAuthority(“BUYER”,“SELLER”)同hasAnyRolehasAnyAuthority (‘ROLE_BUYER’,‘ROLE_BUYER’)hasAnyAuthority(String… authorities)
    hasIpAddress(‘192.168.1.0/24’)请求发送的Ip匹配时返回truehasIpAddress(‘192.168.1.0/24’)hasIpAddress(String ipAddress),该方法在WebSecurityExpressionRoot类中
    access(“@rbacService.hasPermission(request, authentication)”)可以自定义Spel表达式@rbacService.hasPermission (request, authentication)hasPermission(request, authentication) ,该方法在自定义的RbacServiceImpl类中
  • 相关阅读:
    2019CCF非专业级别软件能力认证第一轮
    kubernetes-调度
    【JAVA】栈和队列(Part2 队列)
    [C题]2023 年全国大学生数学建模比赛思路、代码更新中.....
    antd-design的modal结合form表单传值
    `英语` 2022/8/18
    Gcware Python 接口(9)
    jenkins安装部署(前端+后端)自动发布
    centos 内核对应列表 内核升级 linux
    Intellij IDEA--Undo Commit,Revert Commit,Drop Commit的区别
  • 原文地址:https://blog.csdn.net/Instanceztt/article/details/128132940