基于redis的小程序登录实现

基于redis的小程序登录实现

作者:gigass
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

你好,这是我的第一篇博客.
因为前段时间做过一个小程序,所以去学习了一下小程序的登录流程.废话不多说,下面附上我的学习结果.

这张图是小程序的登录流程解析:
在这里插入图片描述
小程序登陆授权流程:

  1. 在小程序端调用wx.login()获取code,由于我是做后端开发的这边不做赘述,直接贴上代码了.有兴趣的直接去官方文档看下,链接放这里: wx.login()
wx.login({
  success (res) {
    if (res.code) {
      //发起网络请求
      wx.request({
        url: 'https://test.com/onLogin',
        data: {
          code: res.code
        }
      })
    } else {
      console.log('登录失败!' + res.errMsg)
    }
  }
})

小程序前端登录后会获取code,调用自己的开发者服务接口,调用个get请求:

GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code

需要得四个参数:
appid:小程序appid
secret: 小程序密钥
js_code: 刚才获取的code
grant_type:‘authorization_code’ //这个是固定的

如果不出意外的话,微信接口服务器会返回四个参数:
在这里插入图片描述

详情可以看下官方文档: jscode2session

下面附上我的代码:

 @AuthIgnore
    @RequestMapping("/login")
    @ResponseBody
    public ResponseBean openId(@RequestParam(value = "code", required = true) String code,
                               @RequestParam(value = "avatarUrl") String avatarUrl,
                               @RequestParam(value = "city") String city,
                               @RequestParam(value = "country") String country,
                               @RequestParam(value = "gender") String gender,
                               @RequestParam(value = "language") String language,
                               @RequestParam(value = "nickName") String nickName,
                               @RequestParam(value = "province") String province,
                               HttpServletRequest request) { // 小程序端获取的CODE
        ResponseBean responseBean = new ResponseBean();
        try {
            boolean check = (StringUtils.isEmpty(code)) ? true : false;
            if (check) {
                responseBean = new ResponseBean(false, UnicomResponseEnums.NO_CODE);
                return responseBean;
            }
            //将获取的用户数据存入数据库;
            Map<String, Object> msgs = new HashMap<>();
            msgs.put("appid", appId);
            msgs.put("secret", secret);
            msgs.put("js_code", code);
            msgs.put("grant_type", "authorization_code");
            // java的网络请求,返回字符串
            String data = HttpUtils.get(msgs, Constants.JSCODE2SESSION);
            logger.info("======> " + data);
            String openId = JSONObject.parseObject(data).getString("openid");
            String session_key = JSONObject.parseObject(data).getString("session_key");
            String unionid = JSONObject.parseObject(data).getString("unionid");
            String errcode = JSONObject.parseObject(data).getString("errcode");
            String errmsg = JSONObject.parseObject(data).getString("errmsg");

            JSONObject json = new JSONObject();
            int userId = -1;

            if (!StringUtils.isBlank(openId)) {
                Users user = userService.selectUserByOpenId(openId);
                if (user == null) {
                    //新建一个用户信息
                    Users newUser = new Users();
                    newUser.setOpenid(openId);
                    newUser.setArea(city);
                    newUser.setSex(Integer.parseInt(gender));
                    newUser.setNickName(nickName);
                    newUser.setCreateTime(new Date());
                    newUser.setStatus(0);//初始
                    if (!StringUtils.isBlank(unionid)) {
                        newUser.setUnionid(unionid);
                    }
                    userService.instert(newUser);
                    userId = newUser.getId();
                } else {
                    userId = user.getId();
                }
                json.put("userId", userId);
            }
            if (!StringUtils.isBlank(session_key) && !StringUtils.isBlank(openId)) {
               //这段可不用redis存,直接返回session_key也可以
                String userAgent = request.getHeader("user-agent");
                String sessionid = tokenService.generateToken(userAgent, session_key);
                //将session_key存入redis
                redisUtil.setex(sessionid, session_key + "###" + userId, Constants.SESSION_KEY_EX);
                json.put("token", sessionid);

                responseBean = new ResponseBean(true, json);
            } else {
                responseBean = new ResponseBean<>(false, null, errmsg);
            }
            return responseBean;
        } catch (Exception e) {
            e.printStackTrace();
            responseBean = new ResponseBean(false, UnicomResponseEnums.JSCODE2SESSION_ERRO);
            return responseBean;
        }
    }

解析:

这边我的登录获取的session_key出于安全性考虑没有直接在前端传输,而是存到了redis里面给到前端session_key的token传输,
而且session_key的销毁时间是20分钟,时间内可以重复获取用户数据.
如果只是简单使用或者对安全性要求不严的话可以直接传session_key到前端保存.

session_key的作用:

校验用户信息(wx.getUserInfo(OBJECT)返回的signature);
解密(wx.getUserInfo(OBJECT)返回的encryptedData);

按照官方的说法,wx.checksession是用来检查 wx.login(OBJECT) 的时效性,判断登录是否过期;
疑惑的是(openid,unionid )都是用户唯一标识,不会因为wx.login(OBJECT)的过期而改变,所以要是没有使用wx.getUserInfo(OBJECT)获得的用户信息,确实没必要使用wx.checksession()来检查wx.login(OBJECT) 是否过期;
如果使用了wx.getUserInfo(OBJECT)获得的用户信息,还是有必要使用wx.checksession()来检查wx.login(OBJECT) 是否过期的,因为用户有可能修改了头像、昵称、城市,省份等信息,可以通过检查wx.login(OBJECT) 是否过期来更新着些信息;

小程序的登录状态维护本质就是维护session_key的时效性

这边附上我的HttpUtils工具代码,如果只要用get的话可以复制部分:


/**
 * HttpUtils工具类
 *
 * @author
 */
public class HttpUtils {

    /**
     * 请求方式:post
     */
    public static String POST = "post";

    /**
     * 编码格式:utf-8
     */
    private static final String CHARSET_UTF_8 = "UTF-8";

    /**
     * 报文头部json
     */
    private static final String APPLICATION_JSON = "application/json";

    /**
     * 请求超时时间
     */
    private static final int CONNECT_TIMEOUT = 60 * 1000;

    /**
     * 传输超时时间
     */
    private static final int SO_TIMEOUT = 60 * 1000;

    /**
     * 日志
     */
    private static final Logger logger = LoggerFactory.getLogger(HttpUtils.class);

    /**
     * @param protocol
     * @param url
     * @param paraMap
     * @return
     * @throws Exception
     */
    public static String doPost(String protocol, String url,
                                Map<String, Object> paraMap) throws Exception {
        CloseableHttpClient httpClient = null;
        CloseableHttpResponse resp = null;
        String rtnValue = null;
        try {
            if (protocol.equals("http")) {
                httpClient = HttpClients.createDefault();
            } else {
                // 获取https安全客户端
                httpClient = HttpUtils.getHttpsClient();
            }

            HttpPost httpPost = new HttpPost(url);
            List<NameValuePair> list = msgs2valuePairs(paraMap);
//            List<NameValuePair> list = new ArrayList<NameValuePair>();
//            if (null != paraMap &&paraMap.size() > 0) {
//                for (Entry<String, Object> entry : paraMap.entrySet()) {
//                    list.add(new BasicNameValuePair(entry.getKey(), entry
//                            .getValue().toString()));
//                }
//            }

            RequestConfig requestConfig = RequestConfig.custom()
                    .setSocketTimeout(SO_TIMEOUT)
                    .setConnectTimeout(CONNECT_TIMEOUT).build();// 设置请求和传输超时时间
            httpPost.setConfig(requestConfig);
            httpPost.setEntity(new UrlEncodedFormEntity(list, CHARSET_UTF_8));
            resp = httpClient.execute(httpPost);
            rtnValue = EntityUtils.toString(resp.getEntity(), CHARSET_UTF_8);

        } catch (Exception e) {
            logger.error(e.getMessage());
            throw e;
        } finally {
            if (null != resp) {
                resp.close();
            }
            if (null != httpClient) {
                httpClient.close();
            }
        }

        return rtnValue;
    }

    /**
     * 获取https,单向验证
     *
     * @return
     * @throws Exception
     */
    public static CloseableHttpClient getHttpsClient() throws Exception {
        try {
            TrustManager[] trustManagers = new TrustManager[]{new X509TrustManager() {
                public void checkClientTrusted(
                        X509Certificate[] paramArrayOfX509Certificate,
                        String paramString) throws CertificateException {
                }

                public void checkServerTrusted(
                        X509Certificate[] paramArrayOfX509Certificate,
                        String paramString) throws CertificateException {
                }

                public X509Certificate[] getAcceptedIssuers() {
                    return null;
                }
            }};
            SSLContext sslContext = SSLContext
                    .getInstance(SSLConnectionSocketFactory.TLS);
            sslContext.init(new KeyManager[0], trustManagers,
                    new SecureRandom());
            SSLContext.setDefault(sslContext);
            sslContext.init(null, trustManagers, null);
            SSLConnectionSocketFactory connectionSocketFactory = new SSLConnectionSocketFactory(
                    sslContext,
                    SSLConnectionSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
            HttpClientBuilder clientBuilder = HttpClients.custom()
                    .setSSLSocketFactory(connectionSocketFactory);
            clientBuilder.setRedirectStrategy(new LaxRedirectStrategy());
            CloseableHttpClient httpClient = clientBuilder.build();
            return httpClient;
        } catch (Exception e) {
            throw new Exception("http client 远程连接失败", e);
        }
    }

    /**
     * post请求
     *
     * @param msgs
     * @param url
     * @return
     * @throws ClientProtocolException
     * @throws UnknownHostException
     * @throws IOException
     */
    public static String post(Map<String, Object> msgs, String url)
            throws ClientProtocolException, UnknownHostException, IOException {
        CloseableHttpClient httpClient = HttpClients.createDefault();
        try {
            HttpPost request = new HttpPost(url);
            List<NameValuePair> valuePairs = msgs2valuePairs(msgs);
//            List<NameValuePair> valuePairs = new ArrayList<NameValuePair>();
//            if (null != msgs) {
//                for (Entry<String, Object> entry : msgs.entrySet()) {
//                    if (entry.getValue() != null) {
//                        valuePairs.add(new BasicNameValuePair(entry.getKey(),
//                                entry.getValue().toString()));
//                    }
//                }
//            }
            request.setEntity(new UrlEncodedFormEntity(valuePairs, CHARSET_UTF_8));
            CloseableHttpResponse resp = httpClient.execute(request);
            return EntityUtils.toString(resp.getEntity(), CHARSET_UTF_8);
        } finally {
            httpClient.close();
        }
    }

    /**
     * post请求
     *
     * @param msgs
     * @param url
     * @return
     * @throws ClientProtocolException
     * @throws UnknownHostException
     * @throws IOException
     */
    public static byte[] postGetByte(Map<String, Object> msgs, String url)
            throws ClientProtocolException, UnknownHostException, IOException {
        CloseableHttpClient httpClient = HttpClients.createDefault();
        InputStream inputStream = null;
        byte[] data = null;
        try {
            HttpPost request = new HttpPost(url);
            List<NameValuePair> valuePairs = msgs2valuePairs(msgs);
//            List<NameValuePair> valuePairs = new ArrayList<NameValuePair>();
//            if (null != msgs) {
//                for (Entry<String, Object> entry : msgs.entrySet()) {
//                    if (entry.getValue() != null) {
//                        valuePairs.add(new BasicNameValuePair(entry.getKey(),
//                                entry.getValue().toString()));
//                    }
//                }
//            }
            request.setEntity(new UrlEncodedFormEntity(valuePairs, CHARSET_UTF_8));
            CloseableHttpResponse response = httpClient.execute(request);
            try {
                // 获取相应实体
                HttpEntity entity = response.getEntity();
                if (entity != null) {
                    inputStream = entity.getContent();
                    data = readInputStream(inputStream);
                }
                return data;
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                httpClient.close();
                return null;
            }
        } finally {
            httpClient.close();
        }
    }
    /**  将流 保存为数据数组
     * @param inStream
     * @return
     * @throws Exception
     */
    public static byte[] readInputStream(InputStream inStream) throws Exception {
        ByteArrayOutputStream outStream = new ByteArrayOutputStream();
        // 创建一个Buffer字符串
        byte[] buffer = new byte[1024];
        // 每次读取的字符串长度,如果为-1,代表全部读取完毕
        int len = 0;
        // 使用一个输入流从buffer里把数据读取出来
        while ((len = inStream.read(buffer)) != -1) {
            // 用输出流往buffer里写入数据,中间参数代表从哪个位置开始读,len代表读取的长度
            outStream.write(buffer, 0, len);
        }
        // 关闭输入流
        inStream.close();
        // 把outStream里的数据写入内存
        return outStream.toByteArray();
    }

    /**
     * get请求
     *
     * @param msgs
     * @param url
     * @return
     * @throws ClientProtocolException
     * @throws UnknownHostException
     * @throws IOException
     */
    public static String get(Map<String, Object> msgs, String url)
            throws ClientProtocolException, UnknownHostException, IOException {
        CloseableHttpClient httpClient = HttpClients.createDefault();
        try {
            List<NameValuePair> valuePairs = msgs2valuePairs(msgs);
//            List<NameValuePair> valuePairs = new ArrayList<NameValuePair>();
//            if (null != msgs) {
//                for (Entry<String, Object> entry : msgs.entrySet()) {
//                    if (entry.getValue() != null) {
//                        valuePairs.add(new BasicNameValuePair(entry.getKey(),
//                                entry.getValue().toString()));
//                    }
//                }
//            }
            // EntityUtils.toString(new UrlEncodedFormEntity(valuePairs),
            // CHARSET);
            url = url + "?" + URLEncodedUtils.format(valuePairs, CHARSET_UTF_8);
            HttpGet request = new HttpGet(url);
            CloseableHttpResponse resp = httpClient.execute(request);
            return EntityUtils.toString(resp.getEntity(), CHARSET_UTF_8);
        } finally {
            httpClient.close();
        }
    }

    public static <T> T post(Map<String, Object> msgs, String url,
                             Class<T> clazz) throws ClientProtocolException,
            UnknownHostException, IOException {
        String json = HttpUtils.post(msgs, url);
        T t = JSON.parseObject(json, clazz);
        return t;
    }

    public static <T> T get(Map<String, Object> msgs, String url, Class<T> clazz)
            throws ClientProtocolException, UnknownHostException, IOException {
        String json = HttpUtils.get(msgs, url);
        T t = JSON.parseObject(json, clazz);
        return t;
    }

    public static String postWithJson(Map<String, Object> msgs, String url)
            throws ClientProtocolException, IOException {
        CloseableHttpClient httpClient = HttpClients.createDefault();
        try {
            String jsonParam = JSON.toJSONString(msgs);

            HttpPost post = new HttpPost(url);
            post.setHeader("Content-Type", APPLICATION_JSON);
            post.setEntity(new StringEntity(jsonParam, CHARSET_UTF_8));
            CloseableHttpResponse response = httpClient.execute(post);

            return new String(EntityUtils.toString(response.getEntity()).getBytes("iso8859-1"), CHARSET_UTF_8);
        } finally {
            httpClient.close();
        }
    }

    public static <T> T postWithJson(Map<String, Object> msgs, String url, Class<T> clazz) throws ClientProtocolException,
            UnknownHostException, IOException {
        String json = HttpUtils.postWithJson(msgs, url);
        T t = JSON.parseObject(json, clazz);
        return t;
    }


    public static List<NameValuePair> msgs2valuePairs(Map<String, Object> msgs) {
        List<NameValuePair> valuePairs = new ArrayList<NameValuePair>();
        if (null != msgs) {
            for (Entry<String, Object> entry : msgs.entrySet()) {
                if (entry.getValue() != null) {
                    valuePairs.add(new BasicNameValuePair(entry.getKey(),
                            entry.getValue().toString()));
                }
            }
        }
        return valuePairs;

    }

}


如果是直接传session_key到前端的,下面的可以不用看了.
如果是redis存的sesssion_key的token的话,这边附上登陆的时候的token转换为session_key.

自定义拦截器注解:

import java.lang.annotation.*;


@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuthIgnore {

}

拦截器部分代码:



    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        AuthIgnore annotation;
        if(handler instanceof HandlerMethod) {
            annotation = ((HandlerMethod) handler).getMethodAnnotation(AuthIgnore.class);
        }else{
            return true;
        }

        //如果有@AuthIgnore注解,则不验证token
        if(annotation != null){
            return true;
        }

//        //获取微信access_token;
//        if(!redisUtil.exists(Constants.ACCESS_TOKEN)){
//            Map<String, Object> msgs = new HashMap<>();
//            msgs.put("appid",appId);
//            msgs.put("secret",secret);
//            msgs.put("grant_type","client_credential");
//            String data = HttpUtils.get(msgs,Constants.GETACCESSTOKEN); // java的网络请求,返回字符串
//            String errcode= JSONObject.parseObject(data).getString("errcode");
//            String errmsg= JSONObject.parseObject(data).getString("errmsg");
//            if(StringUtils.isBlank(errcode)){
//                //存储access_token
//                String access_token= JSONObject.parseObject(data).getString("access_token");
//                long expires_in=Long.parseLong(JSONObject.parseObject(data).getString("expires_in"));
//                redisUtil.setex("ACCESS_TOKEN",access_token, expires_in);
//            }else{
//                //异常返回数据拦截,返回json数据
//                response.setCharacterEncoding("UTF-8");
//                response.setContentType("application/json; charset=utf-8");
//                PrintWriter out = response.getWriter();
//                ResponseBean<Object> responseBean=new ResponseBean<>(false,null, errmsg);
//                out = response.getWriter();
//                out.append(JSON.toJSON(responseBean).toString());
//                return false;
//            }
//        }



       //获取用户凭证
       String token = request.getHeader(Constants.USER_TOKEN);
//        if(StringUtils.isBlank(token)){
//            token = request.getParameter(Constants.USER_TOKEN);
//        }
//        if(StringUtils.isBlank(token)){
//            Object obj = request.getAttribute(Constants.USER_TOKEN);
//            if(null!=obj){
//                token=obj.toString();
//            }
//        }
//        //token凭证为空
//        if(StringUtils.isBlank(token)){
//            //token不存在拦截,返回json数据
//            response.setCharacterEncoding("UTF-8");
//            response.setContentType("application/json; charset=utf-8");
//            PrintWriter out = response.getWriter();
//            try{
//                ResponseBean<Object> responseBean=new ResponseBean<>(false,null, UnicomResponseEnums.TOKEN_EMPTY);
//                out = response.getWriter();
//                out.append(JSON.toJSON(responseBean).toString());
//                return false;
//            }
//            catch (Exception e) {
//                e.printStackTrace();
//                response.sendError(500);
//                return false;
//            }
//        }

        if(token==null||!redisUtil.exists(token)){
            //用户未登录,返回json数据
            response.setCharacterEncoding("UTF-8");
            response.setContentType("application/json; charset=utf-8");
            PrintWriter out = response.getWriter();
            try{
                ResponseBean<Object> responseBean=new ResponseBean<>(false,null, UnicomResponseEnums.DIS_LOGIN);
                out = response.getWriter();
                out.append(JSON.toJSON(responseBean).toString());
                return false;
            }
            catch (Exception e) {
                e.printStackTrace();
                response.sendError(500);
                return false;
            }
        }

        tokenManager.refreshUserToken(token);
        return true;
    }

}

过滤器配置:

@Configuration
public class InterceptorConfig extends WebMvcConfigurerAdapter {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authorizationInterceptor())
                .addPathPatterns("/**")// 拦截所有请求,通过判断是否有 @AuthIgnore注解 决定是否需要登录
                .excludePathPatterns("/user/login");//排除登录
    }
    @Bean
    public AuthorizationInterceptor authorizationInterceptor() {
        return new AuthorizationInterceptor();
    }
}

token管理:


@Service
public class TokenManager {

    @Resource
    private RedisUtil redisUtil;

    //生成token(格式为token:设备-加密的用户名-时间-六位随机数)
    public String generateToken(String userAgentStr, String username) {
        StringBuilder token = new StringBuilder("token:");
        //设备
        UserAgent userAgent = UserAgent.parseUserAgentString(userAgentStr);
        if (userAgent.getOperatingSystem().isMobileDevice()) {
            token.append("MOBILE-");
        } else {
            token.append("PC-");
        }
        //加密的用户名
        token.append(MD5Utils.MD5Encode(username) + "-");
        //时间
        token.append(new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()) + "-");
        //六位随机字符串
        token.append(UUID.randomUUID().toString());
        System.out.println("token-->" + token.toString());
        return token.toString();
    }


    /**
     * 登录用户,创建token
     *
     * @param token
     */
    //把token存到redis中
    public void save(String token, Users user) {
        if (token.startsWith("token:PC")) {
            redisUtil.setex(token,JSON.toJSONString(user), Constants.TOKEN_EX);
        } else {
            redisUtil.setex(token,JSON.toJSONString(user), Constants.TOKEN_EX);
        }
    }

    /**
     * 刷新用户
     *
     * @param token
     */
    public void refreshUserToken(String token) {
        if (redisUtil.exists(token)) {
            String value=redisUtil.get(token);
            redisUtil.setex(token, value, Constants.TOKEN_EX);

        }
    }

    /**
     * 用户退出登陆
     *
     * @param token
     */
    public void loginOut(String token) {
        redisUtil.remove(token);
    }


    /**
     * 获取用户信息
     *
     * @param token
     * @return
     */
    public Users getUserInfoByToken(String token) {
        if (redisUtil.exists(token)) {
            String jsonString = redisUtil.get(token);
            Users user =JSONObject.parseObject(jsonString, Users.class);
            return user;
        }
        return null;
    }



}

redis工具类:


@Component
public class RedisUtil {

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    public void set(String key, String value) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        valueOperations.set(key, value);
    }

    public void setex(String key, String value, long seconds) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        valueOperations.set(key, value, seconds,TimeUnit.SECONDS);

    }

    public Boolean exists(String key) {
        return redisTemplate.hasKey(key);
    }

    public void remove(String key) {
        redisTemplate.delete(key);
    }

    public String get(String key) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        return valueOperations.get(key);
    }
}

最后redis要实现序列化,序列化最终的目的是为了对象可以跨平台存储,和进行网络传输。而我们进行跨平台存储和网络传输的方式就是IO,而我们的IO支持的数据格式就是字节数组。本质上存储和网络传输 都需要经过 把一个对象状态保存成一种跨平台识别的字节格式,然后其他的平台才可以通过字节信息解析还原对象信息。



@Configuration
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
public class RedisConfig {

    @Bean
    @ConditionalOnMissingBean(name = "redisTemplate")
    public RedisTemplate<Object, Object> redisTemplate(
            RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();

        //使用fastjson序列化
        FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer(Object.class);
        // value值的序列化采用fastJsonRedisSerializer
        template.setValueSerializer(fastJsonRedisSerializer);
        template.setHashValueSerializer(fastJsonRedisSerializer);
        // key的序列化采用StringRedisSerializer
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }

    @Bean
    @ConditionalOnMissingBean(StringRedisTemplate.class)
    public StringRedisTemplate stringRedisTemplate(
            RedisConnectionFactory redisConnectionFactory) {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }

}

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值