基于Springboot拦截器的AES报文解密

从前到后实现一个 springboot 使用Incepter拦截器解析AES密文。

1.什么是AES加密

AES是一种对称加密,这个标准用来替代原先的DES(Data Encryption Standard),已经被多方分析且广为全世界所使用。本文中AES加密方法同样适用于 DES。

AES使用起来非常简单,前后端需要一个相同的密钥,前端加密完后,将密文体发送到后端,后端使用拦截器将加密的报文拦截解析后再转发到相应处理的控制器controller。

那么如何知道哪些请求加密了哪些请求没加密呢?如何去有针对性的拦截加密的报文这也是我们关心的。

2.什么是拦截器

拦截器(Interceptor) 不依赖 Servlet 容器,依赖 Spring 等 Web 框架,在 SpringMVC 框架中是配置在SpringMVC 的配置文件中,在 SpringBoot 项目中也可以采用注解的形式实现。

拦截器它是链式调用,一个应用中可以同时存在多个拦截器Interceptor, 一个请求也可以触发多个拦截器 ,而每个拦截器的调用会依据它的声明顺序依次执行。

首先编写一个简单的拦截器处理类,请求的拦截是通过HandlerInterceptor 来实现,看到HandlerInterceptor 接口中也定义了三个方法。

  • preHandle() :这个方法将在请求处理之前进行调用。注意:如果该方法的返回值为false ,将视为当前请求结束,不仅自身的拦截器会失效,还会导致其他的拦截器也不再执行。
  • postHandle():只有在 preHandle() 方法返回值为true 时才会执行。会在Controller 中的方法调用之后,DispatcherServlet 返回渲染视图之前被调用。 有意思的是:postHandle() 方法被调用的顺序跟 preHandle() 是相反的,先声明的拦截器 preHandle() 方法先执行,而postHandle()方法反而会后执行。
  • afterCompletion():只有在 preHandle() 方法返回值为true 时才会执行。在整个请求结束之后, DispatcherServlet 渲染了对应的视图之后执行。

本文中处理加密解密从preHandle方法中执行

拦截器是 AOP 的一种应用,底层采用 Java 的反射机制来实现的。与过滤器(filter)一个很大的区别是在拦截器中可以注入 Spring 的 Bean,能够获取到各种需要的 Service 来处理业务逻辑,而过滤器则不行。

3.实现

3.1 前端:

 1. 如果您是vue项目请您安装 CryptoJS 

npm install crypto-js --save

2. js 的 AES工具类:

以下代码是 加密解密工具类。

其中key 是与后端约定好的 密钥(不能泄漏)


import CryptoJS from 'crypto-js'//引用AES源码js

const key = CryptoJS.enc.Utf8.parse("1234123412ABCDEF"); //十六位十六进制数作为秘钥

/**
 * 加密,解密工具类
 */
function Encrypt(word){
  var srcs = CryptoJS.enc.Utf8.parse(word);
  var encrypted = CryptoJS.AES.encrypt(srcs, key, {
    mode : CryptoJS.mode.ECB,
    padding : CryptoJS.pad.Pkcs7
  });
  return encrypted.toString() ;
 // return strToHex(encrypted.toString());
}

function Decrypt(word) {
  //word = hexToStr(word);
  var decrypt = CryptoJS.AES.decrypt(word, key, {
    mode : CryptoJS.mode.ECB,
    padding : CryptoJS.pad.Pkcs7
  });

  return CryptoJS.enc.Utf8.stringify(decrypt).toString();
}

function strToHex(str) {
  var val = "";
  for (var i = 0; i < str.length; i++) {
    if (val == "")
      val = str.charCodeAt(i).toString(16);
    else
      val += str.charCodeAt(i).toString(16);
  }
  val += "0a";
  return val;
}

function hexToStr(hex) {
  debugger;
  //hex = JSON.stringify(hex);
  hex=hex+"";
  var arr = hex.split("");
  var out = ""
  for (var i = 0; i < arr.length / 2; i++) {
    var tmp = "0x" + arr[i * 2] + arr[i * 2 + 1]
    var charValue = String.fromCharCode(tmp);
    out += charValue;
  }
  return out;
}


function MD5(body){
  return CryptoJS.MD5(body).toString();
}



export default {
  Decrypt ,
  Encrypt ,
  MD5
}

3. 封装axios 请求。可以根据自身需求选择一些接口请求是否 加密或者不加密。

新建 http.js ,代码如下:

每当有axios请求时就会进入拦截器,判断config.data 是否有数据,config.data中是否存在 sdata 这个键,如果存在就取 sdata键值对应的值先转为字符串再使用 加密方法加密。

当然 你也可以按照自己喜好去封装定义 sdata 只是我自己取名的。

当你是post请求 ,并且参数体严格按照 {sdata:{xxxx}} 格式,那xxxx部分将会加密。

否则其他情况不会被axios 所拦截修改。

import axios from 'axios';
import secret from "./secret";


//拦截器拦截请求
axios.interceptors.request.use(

  config => {

    //如果是post请求,即params中有数据且params包含 secretData 字段说明这是加密内容
    if(config.data){
      if(typeof(config.data.sdata) != 'undefined' || config.data.sdata != null){
        config.data.sdata = secret.Encrypt(JSON.stringify(config.data.sdata)) ;
      }
    }
    return config;
  },
  err => {
    return Promise.reject(err);
  });



//拦截器拦截响应
axios.interceptors.response.use(
  response => {
    return response
  },
  error => {
    return Promise.reject(error.response.data)   // 返回接口返回的错误信息
  })


export  default  axios ;

4.在js代码中使用 封装好的axios 代码发送加密请求

以登录为例,以下调用方法为:

 login({commit, dispatch, state}, payload) {
      return new Promise((resolve, reject) => {

        let loginVo = payload.loginVo;

        axios.post('/api/tokens/login', {
          sdata:{
          username: loginVo.phone,
          password: secret.MD5(loginVo.password)
        }
        })
          .then(function (response) {
            if (response.data.code == 0) {
              var userInfo = response.data.data;

                //登录成功后的逻辑
              resolve(response);
            } else {
      
              reject("用户名或密码错误!");
            }
          })
          .catch(function (error) {
            console.log('login', error)
            reject("登录出错!请重试");
          });
      });
    }

3.2 后端

实现思路是这样的,当前端发起一个请求,首当其冲拦截器先拦截这个请求,对请求进行判断是否是需要解密的请求。 如果满足以下条件就对该请求进行解密:

  1. post请求
  2. content-type为 application/json
  3. controller控制器必须有@SecurityParameter  注解修饰的才能被拦截

我们约定满足以上条件,就能拦截后解密,其他情况就直接放行,无需操作即可。

值的一提的是被@SecurityParameter 注解修饰的控制器就是表示此Controller 需要解密。前端加密的接口,对应的后台控制器必须有@SecurityParameter注解修饰,这就是约定。否则此注解将无意义。

1. AES加密工具类

其中的key 必须与前端 约定一致。

package com.shao.cursort.utils;

import com.alibaba.fastjson.JSONObject;
import org.apache.commons.codec.binary.Base64;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.util.HashMap;
import java.util.Map;

/**
 * 前后端数据传输加密工具类
 * @author shaoduo
 *
 */
public class AesEncryptUtils {
    //可配置到Constant中,并读取配置文件注入,16位,自己定义
    private static final String KEY = "1234123412ABCDEF";

    //参数分别代表 算法名称/加密模式/数据填充方式
    private static final String ALGORITHMSTR = "AES/ECB/PKCS5Padding";

    /**
     * 加密
     * @param content 加密的字符串
     * @param encryptKey key值
     * @return
     * @throws Exception
     */
    public static String encrypt(String content, String encryptKey) throws Exception {
        KeyGenerator kgen = KeyGenerator.getInstance("AES");
        kgen.init(128);
        Cipher cipher = Cipher.getInstance(ALGORITHMSTR);
        cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(encryptKey.getBytes(), "AES"));
        byte[] b = cipher.doFinal(content.getBytes("utf-8"));
        // 采用base64算法进行转码,避免出现中文乱码
        return Base64.encodeBase64String(b);
    }

    /**
     * 解密
     * @param encryptStr 解密的字符串
     * @param decryptKey 解密的key值
     * @return
     * @throws Exception
     */
    public static String decrypt(String encryptStr, String decryptKey) throws Exception {
        KeyGenerator kgen = KeyGenerator.getInstance("AES");
        kgen.init(128);
        Cipher cipher = Cipher.getInstance(ALGORITHMSTR);
        cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(decryptKey.getBytes("utf-8"), "AES"));
        // 采用base64算法进行转码,避免出现中文乱码

        //byte[] b = hex2Bytes(encryptStr) ;
        byte[] encryptBytes = Base64.decodeBase64(encryptStr);
        byte[] decryptBytes = cipher.doFinal(encryptBytes);
        return new String(decryptBytes);
    }

    public static String encrypt(String content) throws Exception {
        return encrypt(content, KEY);
    }
    public static String decrypt(String encryptStr) throws Exception {
        return decrypt(encryptStr, KEY);
    }


/*    public static void main(String[] args) throws Exception {
        Map map=new HashMap<String,String>();
        map.put("key","value");
        map.put("中文","汉字");
        String content = JSONObject.toJSONString(map);
        System.out.println("加密前:" + content);

        String encrypt = encrypt(content, KEY);
        System.out.println("加密后:" + encrypt);

        String decrypt = decrypt(encrypt, KEY);
        System.out.println("解密后:" + decrypt);
    }*/

    /**
     * byte数组 转换成 16进制小写字符串
     */
    public static String bytes2Hex(byte[] bytes) {
        if (bytes == null || bytes.length == 0) {
            return null;
        }

        StringBuilder hex = new StringBuilder();

        for (byte b : bytes) {
            hex.append(HEXES[(b >> 4) & 0x0F]);
            hex.append(HEXES[b & 0x0F]);
        }

        return hex.toString();
    }

    /**
     * 16进制字符串 转换为对应的 byte数组
     */
    public static byte[] hex2Bytes(String hex) {
        if (hex == null || hex.length() == 0) {
            return null;
        }

        char[] hexChars = hex.toCharArray();
        byte[] bytes = new byte[hexChars.length / 2];   // 如果 hex 中的字符不是偶数个, 则忽略最后一个

        for (int i = 0; i < bytes.length; i++) {
            bytes[i] = (byte) Integer.parseInt("" + hexChars[i * 2] + hexChars[i * 2 + 1], 16);
        }

        return bytes;
    }

    private static final char[] HEXES = {
            '0', '1', '2', '3',
            '4', '5', '6', '7',
            '8', '9', 'a', 'b',
            'c', 'd', 'e', 'f'
    };
}

2. 准备@SecurityParameter注解

为了让拦截器准确判断是否需要解密还是放行,加此注解来解决此问题。代码如下:

package com.shao.cursort.annotation;

import org.springframework.web.bind.annotation.Mapping;

import java.lang.annotation.*;

/**
 * 加密解密注解
 */
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Mapping
@Documented
public @interface SecurityParameter {
    /**
     * 入参是否解密,默认解密
     */
    boolean inDecode() default true;

    /**
     * 出参是否加密,默认加密
     */
    boolean outEncode() default true;

}

3. 准备拦截器

需要准备一个SecurityIntercepter 来实现 HandlerInterceptor 的拦截器,实现HandlerInterceptor中的 preHandle () 方法即可。在preHandle()方法中进行解密的逻辑判断。

代码如下:

package com.shao.cursort.interceptor;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.shao.cursort.annotation.SecurityParameter;
import com.shao.cursort.utils.AesEncryptUtils;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.lang.reflect.Method;
import java.net.URLDecoder;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

@Component
public class SecurityIntercepter implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("SecurityIntercepter-preHandle");
        boolean decode = false ; //默认情况是不进行解密
        // 如果不是映射到方法直接通过
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod ();
        String httpMethod = request.getMethod() ;
        SecurityParameter serializedField  = handlerMethod.getMethodAnnotation(SecurityParameter.class) ;

        System.out.println(request.getContentType());
        System.out.println(MediaType.APPLICATION_JSON_VALUE) ;
        //  不拦截get请求
        if(httpMethod.equals("GET")) {
            return true;
            //如果是post请求且是json
        } else if(httpMethod.equals("POST")) {
            //如果类型是空就放行
            if (request.getContentType() == null) {
                return true;
            }
            //如果类型不是json 就放行
            if (!(request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) ||
                    request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)))
                return true ;
            }

        //对没有要求加密的post请求也不拦截
        if(serializedField == null){
            //如果没有用加密注解, 就直接放行。
            return true ;
        }
        decode = serializedField.inDecode() ;
        if(decode){

            //到这只解析有加密要求的注解和 POST请求中是json。不符合以上的直接不涉及数据加密
            RequestWrapper requestWrapper = (RequestWrapper) request;
            String jsonParamBody = requestWrapper.getBodyString();
          //  jsonParamBody = URLDecoder.decode(jsonParamBody,"UTF-8");

            JSONObject obj= JSON.parseObject(jsonParamBody);
            //map对象
            Map<String, Object> data =new HashMap<>();
            //循环转换
            Iterator it =obj.entrySet().iterator();
            while (it.hasNext()) {
                Map.Entry<String, Object> entry = (Map.Entry<String, Object>) it.next();
               //data.put(entry.getKey(), entry.getValue());
                data.put("aesData", entry.getValue().toString());
            }

            System.out.println("加密前 "+data.get("aesData"));
            Object afterBody  = AesEncryptUtils.decrypt(data.get("aesData").toString());
            System.out.println("解密后 "+afterBody.toString());
            requestWrapper.setBody(afterBody.toString());
            String temp = new RequestWrapper(requestWrapper).getBodyString() ;
            System.out.println("过滤器中缓存"+temp);

        }

        return true;
    }
}

4.准备过滤器  RequestWrapper 

我们看到上边的代码中含有RequestWrapper ,RequestWrapper继承于 HttpServletRequestWrapper  其存在的意义是为了调用

String jsonParamBody = requestWrapper.getBodyString();

拿到请求的body体。body体就是您加密。字符串  {sdata:xxxxx} 

我们自定义实现了RequestWrapper ,要了解到在RequestWrapper的实现之前 有人会说我可以通过 request.getBodyString()也能拿到 加密参数体,但为什么不直接用request去直接拿不更方便吗?为什么要去实现HttpServletRequestWrapper ? 这里有两个原因:

  1. 第一个原因是  流只能读取一次,当您拦截器中request.getBodyString()拿到数据后。您将无法在后续的链路中使用该数据了,因为该数据已经读取完,包括控制器中再也无法拿到参数。因此实现一个RequestWrapper 继承 HttpServletRequestWrapper 为了保存数据信息,当您对参数体解密完,后可将解密完的数据再次保存到缓存中,方便后链路获取使用。

      2. 还有一个目的是为了解决 文件上传时 误读文件流而导致文件不能上传的问题。

我们要知道 Fliter 过滤器 在 Intercepter 之前执行。所以当执行到Intercepter的时候,参数流已经被RequestWrapper所缓存,所以Intercepter调用RequestWrapper 拿缓存数据是合理的。

接下来看一下HttpServletRequestWrapper是如何被RequestWrapper 继承实现

package com.shao.cursort.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.http.fileupload.servlet.ServletFileUpload;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/**
 * @author shaoduo
 * @program wrapper-demo
 * @description 包装HttpServletRequest,目的是让其输入流可重复读
 * @since 1.0
 **/
@Slf4j
public class RequestWrapper extends HttpServletRequestWrapper {
    /**
     * 存储body数据的容器
     */
    private byte[] body;

    public RequestWrapper(HttpServletRequest request) throws IOException {
        super(request);

        // 将body数据存储起来
        String bodyStr = getBodyString(request);
        body = bodyStr.getBytes(Charset.defaultCharset());
    }

    /**
     * 获取请求Body
     *
     * @param request request
     * @return String
     */
    public String getBodyString(final ServletRequest request) {
        try {
            return inputStream2String(request.getInputStream());
        } catch (IOException e) {
            log.error("", e);
            throw new RuntimeException(e);
        }
    }

    /**
     * 获取请求Body
     *
     * @return String
     */
    public String getBodyString() {
        InputStream inputStream = new ByteArrayInputStream(body);

        return inputStream2String(inputStream);
    }

    /**
     * 修改body 将json 重新设置成body
     * @param val
     */
    public void setBody(String val){

        body =  val.getBytes(StandardCharsets.UTF_8) ;
    }

    /**
     * 将inputStream里的数据读取出来并转换成字符串
     *
     * @param inputStream inputStream
     * @return String
     */
    private String inputStream2String(InputStream inputStream) {
        StringBuilder sb = new StringBuilder();
        BufferedReader reader = null;

        try {
            reader = new BufferedReader(new InputStreamReader(inputStream, Charset.defaultCharset()));
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
        } catch (IOException e) {
            log.error("", e);
            throw new RuntimeException(e);
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    log.error("", e);
                }
            }
        }


        return  sb.toString();
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {

        final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);

        return new ServletInputStream() {
            @Override
            public int read() throws IOException {
                return inputStream.read();
            }

            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }
        };
    }


}

过滤器 ReplaceStreamFilter类的实现:

package com.shao.cursort.interceptor;

import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.http.fileupload.servlet.ServletFileUpload;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

/**
 * @author shaoduo
 * @description 替换HttpServletRequest
 * @since 1.0
 **/
@Slf4j
public class ReplaceStreamFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("StreamFilter初始化...");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //如果是文件上传则会报错可以判断是否是文件上传不读取流即可
        if(ServletFileUpload.isMultipartContent((HttpServletRequest) request)) {
            chain.doFilter(request, response);
            return;
        }else{
            ServletRequest requestWrapper = new RequestWrapper((HttpServletRequest) request);
            chain.doFilter(requestWrapper, response);
            return;
        }

    }

    @Override
    public void destroy() {
        log.info("StreamFilter销毁...");
    }
}

5.注册Intercepter 和 Filter

注册后才能使用。代码如下:

   在xxx.xx.xxx.config 包下 创建FilterConfig 类:

package com.shao.cursort.config;

import com.shao.cursort.interceptor.ReplaceStreamFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;

/**
 * @author shaoduo
 * @program wrapper-demo
 * @description 过滤器配置类
 * @since 1.0
 **/
@Configuration
public class FilterConfig {
    /**
     * 注册过滤器
     *
     * @return FilterRegistrationBean
     */
    @Bean
    public FilterRegistrationBean someFilterRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(replaceStreamFilter());
        registration.addUrlPatterns("/*");
        registration.setName("streamFilter");
        return registration;
    }

    /**
     * 实例化StreamFilter
     *
     * @return Filter
     */
    @Bean(name = "replaceStreamFilter")
    public Filter replaceStreamFilter() {
        return new ReplaceStreamFilter();
    }
}

同理 在xxx.xx.xxx.config 包下 创建IntercepterConfig 类:

package com.shao.cursort.config;

import com.shao.cursort.interceptor.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;

/**
 * 拦截器配置
 */
@Configuration
public class IntercepterConfig implements WebMvcConfigurer {



    @Bean
    public SecurityIntercepter setSecurityIntercepter(){
        return new SecurityIntercepter();
    }



    /**
     * 参数解析器
     *
     * @param argumentResolvers
     */
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(currentUserMethodArgumentResolver()) ;
        argumentResolvers.add(CurrentoFolderMethodArgumentResolver()) ;

        WebMvcConfigurer.super.addArgumentResolvers(argumentResolvers);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**")
                .addResourceLocations("classpath:/META-INF/resources/")
                .addResourceLocations("classpath:/static/page/")
 
    }


    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(setSecurityIntercepter())
                .addPathPatterns("/**");

        WebMvcConfigurer.super.addInterceptors(registry);

    }
}

到此就结束了么?还没有,请检查您的springboot 启动类是否扫描了config包吗?

如果没有就在启动类主方法之上加此注解

@ComponentScan(basePackages = {"xxx.xx.xxx.config"})

6. 愉快的写控制层controller

以登录的controller 为例

参数无论是@RequestBody Map map 也好还是 java对象也好都能很好的映射进去。。。

        @SecurityParameter
        @RequestMapping (value = "/login",method = RequestMethod.POST)
        public @ResponseBody  Result login (@RequestBody Map map) {
            Assert.notNull (map.get("username"), "username can not be empty");
            Assert.notNull (map.get("password"), "password can not be empty");
            try{
                return  userService.login(map.get("username").toString(),map.get("password").toString()) ;
            }catch (BaseException e){
                return new Result(e.getCode(),e.getInfo());
            }
        }

到此结束你学废了吗? 点点关注!

  • 6
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 22
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值