在分布式集群项目中解决session共享实现单点登录SSO

先说下为什么要实现session共享,因为在普通的web项目中是不需要考虑这点的,因为都在一个项目当中,但是当分布式或者集群的时候,相同的项目部署在不同的服务器上,这时候就需要考虑这个问题,因为假如一个用户,在tomcat1登录成功,这时候他刷新了一个页面,然后负载均衡服务器nginx把他分配到了tomcat2上的项目中,这个项目中是没有刚才登录成功的session的,但是人家明明登录成功了,这给用户体验极差,所以得考虑session共享问题 .

解决方案一  tomcat可以配置session共享,配置好后,加入用户在tomcat1登录,这时1的tomcat就会发出一个广播,在toncat集群中,广播自己的session信息,其它的tomcat就会接收信息,放到自己的session当中,这时就可以解决session共享的问题,但是如果配置了session共享的话,tomcat节点就会有上限,当tomcat集群节点加到一定层度时,会形成内网的网络风暴,tomcat会把内网的宽带都占用掉,服务器的性能就会降低.这该怎么办呢?

方案二  看下图,可以专门建立一个session服务器,专门存储session的不管从哪台服务器登录都保存到这台服务器上,登录判断也在这台服务器判断.这时tomcat就不存在上限的问题也解决了session共享的问题.但是在这台服务器上不能使用tomcat的session了,可以在这台服务器模拟一个session,session都是key,value形式的,并且有过期时间,正好redis也是key,value形式,也可以设置过期时间,所以可以拿redis来做.

实现过程,在项目中新建一个处理登录的模块

service处理逻辑,就是从数据库查询用户名密码是否正确,正确就存入到redis当中去.然后把token返回回去,E3Result.ok(token)这个东西就是一个返回的自己封装的类.

为啥redis中存的key为token这个uuid,而不用userid呢?

key如果是userid的话,如果换台电脑的话,还是拿同一个用户登录的话,根据userid的话session就重复了,这就导致之前的那台电脑的用户session又刷新了,按理说应该是不同的session,所以key不能够是userid.

package cn.e3mall.sso.service.impl;

import java.util.List;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;

import cn.e3mall.common.jedis.JedisClient;
import cn.e3mall.common.utils.E3Result;
import cn.e3mall.common.utils.JsonUtils;
import cn.e3mall.mapper.TbUserMapper;
import cn.e3mall.pojo.TbUser;
import cn.e3mall.pojo.TbUserExample;
import cn.e3mall.pojo.TbUserExample.Criteria;
import cn.e3mall.sso.service.LoginService;

/**
 * 用户登录处理
 * <p>Title: LoginServiceImpl</p>
 * <p>Description: </p>
 * <p>Company: www.itcast.cn</p> 
 * @version 1.0
 */
@Service
public class LoginServiceImpl implements LoginService {

	@Autowired
	private TbUserMapper userMapper;
	@Autowired
	private JedisClient jedisClient;
	@Value("${SESSION_EXPIRE}")
	private Integer SESSION_EXPIRE;
	
	@Override
	public E3Result userLogin(String username, String password) {
		// 1、判断用户和密码是否正确
		//根据用户名查询用户信息
		TbUserExample example = new TbUserExample();
		Criteria criteria = example.createCriteria();
		criteria.andUsernameEqualTo(username);
		//执行查询
		List<TbUser> list = userMapper.selectByExample(example);
		if (list == null || list.size() == 0) {
			//返回登录失败
			return E3Result.build(400, "用户名或密码错误");
		}
		//取用户信息
		TbUser user = list.get(0);
		//判断密码是否正确
		if (!DigestUtils.md5DigestAsHex(password.getBytes()).equals(user.getPassword())) {
			// 2、如果不正确,返回登录失败
			return E3Result.build(400, "用户名或密码错误");
		}
		// 3、如果正确生成token。这个token就是个自定义令牌,其个以后标识的作用
		String token = UUID.randomUUID().toString();
		// 4、把用户信息写入redis,key:token value:用户信息
		user.setPassword(null);
        //存的时候加个前缀SESSION: 这种以冒号形式开头的就会在工具中形成文件夹的进行方便查找
		jedisClient.set("SESSION:" + token, JsonUtils.objectToJson(user));
		// 5、设置Session的过期时间
		jedisClient.expire("SESSION:" + token, SESSION_EXPIRE);
		// 6、把token返回
		 
		return E3Result.ok(token);
	}

}

存的时候加个前缀SESSION: 这种以冒号形式开头的就会在工具中形成文件夹的进行方便查找

 

#session的过期时间
SESSION_EXPIRE=1800

controller层,把token存入到cookie中

package cn.e3mall.sso.controller;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import cn.e3mall.common.utils.CookieUtils;
import cn.e3mall.common.utils.E3Result;
import cn.e3mall.sso.service.LoginService;

/**
 * 用户登录处理
 * <p>Title: LoginController</p>
 * <p>Description: </p>
 * <p>Company: www.itcast.cn</p> 
 * @version 1.0
 */
@Controller
public class LoginController {

	@Autowired
	private LoginService loginService;
	
	@Value("${TOKEN_KEY}")
	private String TOKEN_KEY;
	
	@RequestMapping("/page/login")
	public String showLogin() {
		return "login";
	}
	
	@RequestMapping(value="/user/login", method=RequestMethod.POST)
	@ResponseBody
	public E3Result login(String username, String password,
			HttpServletRequest request, HttpServletResponse response) {
		E3Result e3Result = loginService.userLogin(username, password);
		//判断是否登录成功
		if(e3Result.getStatus() == 200) {
			String token = e3Result.getData().toString();
			//如果登录成功需要把token写入cookie
			CookieUtils.setCookie(request, response, TOKEN_KEY, token);
		}
		//返回结果
		return e3Result;
	}
}

cookie工具类,这里面有解决cookie跨越的问题,其实cookie有域的限制的,一个cookie只能保存在相同的域名下,一个项目中拆分成许多小模块就会遇到这种问题,它这个解决方法,只要保证后面一部分尾缀相同就可以跨域名.

比如www.jd.com   www.search.jd.com  这两个域名一个是jd首页的,一个京东搜索的域名,我在京东首页登录成功存了一份cookie,按理说到了搜索页面也可以获取到之前存入的cookie,所以得在这里设置一下,cookie.setDomain(".jd.com")就会解决这一问题,domain方法就是截取域名尾缀的.

这样的话就能保证cookie跨二级域名了

很多人都误把带www当成一级域名,把其他前缀的当成二级域名;或者把二级域名当成一级域名。这些都是错误的。以tieba.baidu.com为例,正确的域名划分为:
.com 顶级域名/一级域名
baidu.com 二级域名。 [1] 
tieba.baidu.com 三级域名。
detail.tieba.baidu.com 四级域名
(其他级别域名以此类推)

域名不同就是跨域,也就是ip不同就是跨域 ,域名相同端口不同也是跨域.

package cn.e3mall.common.utils;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


/**
 * 
 * Cookie 工具类
 *
 */
public final class CookieUtils {

    /**
     * 得到Cookie的值, 不编码
     * 
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName) {
        return getCookieValue(request, cookieName, false);
    }

    /**
     * 得到Cookie的值,
     * 
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder) {
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || cookieName == null) {
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookieList.length; i++) {
                if (cookieList[i].getName().equals(cookieName)) {
                    if (isDecoder) {
                        retValue = URLDecoder.decode(cookieList[i].getValue(), "UTF-8");
                    } else {
                        retValue = cookieList[i].getValue();
                    }
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return retValue;
    }

    /**
     * 得到Cookie的值,
     * 
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName, String encodeString) {
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || cookieName == null) {
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookieList.length; i++) {
                if (cookieList[i].getName().equals(cookieName)) {
                    retValue = URLDecoder.decode(cookieList[i].getValue(), encodeString);
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
        	 e.printStackTrace();
        }
        return retValue;
    }

    /**
     * 设置Cookie的值 不设置生效时间默认浏览器关闭即失效,也不编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
            String cookieValue) {
        setCookie(request, response, cookieName, cookieValue, -1);
    }

    /**
     * 设置Cookie的值 在指定时间内生效,但不编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
            String cookieValue, int cookieMaxage) {
        setCookie(request, response, cookieName, cookieValue, cookieMaxage, false);
    }

    /**
     * 设置Cookie的值 不设置生效时间,但编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
            String cookieValue, boolean isEncode) {
        setCookie(request, response, cookieName, cookieValue, -1, isEncode);
    }

    /**
     * 设置Cookie的值 在指定时间内生效, 编码参数
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
            String cookieValue, int cookieMaxage, boolean isEncode) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode);
    }

    /**
     * 设置Cookie的值 在指定时间内生效, 编码参数(指定编码)
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
            String cookieValue, int cookieMaxage, String encodeString) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, encodeString);
    }

    /**
     * 删除Cookie带cookie域名
     */
    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response,
            String cookieName) {
        doSetCookie(request, response, cookieName, "", -1, false);
    }

    /**
     * 设置Cookie的值,并使其在指定时间内生效
     * 
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
            String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else if (isEncode) {
                cookieValue = URLEncoder.encode(cookieValue, "utf-8");
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage > 0)
                cookie.setMaxAge(cookieMaxage);
            if (null != request) {// 设置域名的cookie
            	String domainName = getDomainName(request);
            	System.out.println(domainName);
                if (!"localhost".equals(domainName)) {
                	cookie.setDomain(domainName);
                }
            }
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
        	 e.printStackTrace();
        }
    }

    /**
     * 设置Cookie的值,并使其在指定时间内生效
     * 
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
            String cookieName, String cookieValue, int cookieMaxage, String encodeString) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else {
                cookieValue = URLEncoder.encode(cookieValue, encodeString);
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage > 0)
                cookie.setMaxAge(cookieMaxage);
            if (null != request) {// 设置域名的cookie
            	String domainName = getDomainName(request);
            	System.out.println(domainName);
                if (!"localhost".equals(domainName)) {
                	cookie.setDomain(domainName);
                }
            }
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
        	 e.printStackTrace();
        }
    }

    /**
     * 得到cookie的域名
     */
    private static final String getDomainName(HttpServletRequest request) {
        String domainName = null;

        String serverName = request.getRequestURL().toString();
        if (serverName == null || serverName.equals("")) {
            domainName = "";
        } else {
            serverName = serverName.toLowerCase();
            serverName = serverName.substring(7);
            final int end = serverName.indexOf("/");
            serverName = serverName.substring(0, end);
            final String[] domains = serverName.split("\\.");
            int len = domains.length;
            if (len > 3) {
                // www.xxx.com.cn
                domainName = "." + domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
            } else if (len <= 3 && len > 1) {
                // xxx.com or xxx.cn
                domainName = "." + domains[len - 2] + "." + domains[len - 1];
            } else {
                domainName = serverName;
            }
        }

        if (domainName != null && domainName.indexOf(":") > 0) {
            String[] ary = domainName.split("\\:");
            domainName = ary[0];
        }
        return domainName;
    }

}

上面部分存就写完了接下来写取的功能,在一个电商项目中页面很多,每个页面头部都有请登录,登录成功后就得显示是哪个用户登录的,这该怎么解决呢页面那么多总不能每个页面都访问一下controller获取吧,关键每个页面在不同的项目中,如果每个项目都写一遍的话太麻烦,这时候就得单独写一个接口在sso服务里面,然后每个页面引入相同的js,js里面使用ajax获取到token的信息然后调用这个接口获取数据就可以了,后台代码就不用动,只在前台代码加入js就可以了.

使用ajax访问sso服务的接口的话,也会有跨域的问题,如果使用普通的ajax的dataType是json的话其实是能访问到后台的数据也返回到浏览器,只是浏览器不然把这个数据返回给js.看下图,运行上面js的时候会报错,接收不到消息,但是在请求当中可以明确找到回馈信息,说明这个服务是调用成功的,浏览器也接收到了,只是在js这边没接收到而已.

解决办法就是把dataType类型改为jsonp

jsonp原理:其实就是利用了js的特性,或者说是一个漏洞,在js当中<script>标签是可以跨域引入js文件的,比如在项目中引用的jquery是本地的,也可以直接在src路径里写官网jquer的地址也是可以的,也能使用,所以可以利用这一点,来绕过浏览器的限制.

jsonp就是根据这个特性,它在当前js文件中写一个mycall的方法随便起啥名都可以,然后可以往后台发送一个请求并且携带一个参数,参数名为callback,这个名字是jsonp默认的,值为方法名字mycall,然后后台就可以获取到这个方法名,如果像平常返回一个json串的话肯定解决不了跨域,所以得给它拼接成一个js函数出来,这个js函数的名就是mycall,值为数据库查询出来的用户信息,然后前台接收到后,就会认为这是个js函数,把后台的数据就传给前台定义好的mycall的data参数上,就可以做业务逻辑了.这只是原理,使用的时候不需要这么麻烦自己写函数,只需要在ajax中把dataType改为jsonp类型,它自己就做了这些操作了,只不过后台得需要配置返回js函数,

也就是说相当于把后台的返回结果封装成js函数返回到浏览器,浏览器就让通过了.就解决跨域了.

 

service层

package cn.e3mall.sso.service.impl;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import cn.e3mall.common.jedis.JedisClient;
import cn.e3mall.common.utils.E3Result;
import cn.e3mall.common.utils.JsonUtils;
import cn.e3mall.pojo.TbUser;
import cn.e3mall.sso.service.TokenService;

/**
 * 根据token取用户信息
 * <p>Title: TokenServiceImpl</p>
 * <p>Description: </p>
 * <p>Company: www.itcast.cn</p> 
 * @version 1.0
 */
@Service
public class TokenServiceImpl implements TokenService {

	@Autowired
	private JedisClient jedisClient;
	@Value("${SESSION_EXPIRE}")
	private Integer SESSION_EXPIRE;
	
	@Override
	public E3Result getUserByToken(String token) {
		//根据token到redis中取用户信息
		String json = jedisClient.get("SESSION:" + token);
		//取不到用户信息,登录已经过期,返回登录过期
		if (StringUtils.isBlank(json)) {
			return E3Result.build(201, "用户登录已经过期");
		}
		//取到用户信息更新token的过期时间
		jedisClient.expire("SESSION:" + token, SESSION_EXPIRE);
		//返回结果,E3Result其中包含TbUser对象
		TbUser user = JsonUtils.jsonToPojo(json, TbUser.class);
		return E3Result.ok(user);
	}

}

json工具类

package cn.e3mall.common.utils;

import java.util.List;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * 淘淘商城自定义响应结构
 */
public class JsonUtils {

    // 定义jackson对象
    private static final ObjectMapper MAPPER = new ObjectMapper();

    /**
     * 将对象转换成json字符串。
     * <p>Title: pojoToJson</p>
     * <p>Description: </p>
     * @param data
     * @return
     */
    public static String objectToJson(Object data) {
    	try {
			String string = MAPPER.writeValueAsString(data);
			return string;
		} catch (JsonProcessingException e) {
			e.printStackTrace();
		}
    	return null;
    }
    
    /**
     * 将json结果集转化为对象
     * 
     * @param jsonData json数据
     * @param clazz 对象中的object类型
     * @return
     */
    public static <T> T jsonToPojo(String jsonData, Class<T> beanType) {
        try {
            T t = MAPPER.readValue(jsonData, beanType);
            return t;
        } catch (Exception e) {
        	e.printStackTrace();
        }
        return null;
    }
    
    /**
     * 将json数据转换成pojo对象list
     * <p>Title: jsonToList</p>
     * <p>Description: </p>
     * @param jsonData
     * @param beanType
     * @return
     */
    public static <T>List<T> jsonToList(String jsonData, Class<T> beanType) {
    	JavaType javaType = MAPPER.getTypeFactory().constructParametricType(List.class, beanType);
    	try {
    		List<T> list = MAPPER.readValue(jsonData, javaType);
    		return list;
		} catch (Exception e) {
			e.printStackTrace();
		}
    	
    	return null;
    }
    
}

 

controller,

这里面有两种实现方法,第一种适用任何版本,第二种适用spring4.1以上版本,第一种里面就是自己手动加了个response的响应类型,改成json形式,要不然方法返回值类型为String的话默认是"text/html"的格式.

在第一种方法里设置响应信息,下面这三种都可以,一个是常量一个是自己写的字符串,这个常量也就是下面的字符串两个一个意思,还有一种就是不在注解参数设置,适用response对象设置响应类型.

produces=MediaType.APPLICATION_JSON_UTF8_VALUE

produces="application/json;charset=utf-8"

response.setContentType("application/json;charset=utf-8");

在第二种方法里可以使用spring的对象MappingJacksonValue,这个对象里就封装的jsonp的响应,参数放返回对象,然后再设置一个返回方法名.

package cn.e3mall.sso.controller;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import cn.e3mall.common.utils.E3Result;
import cn.e3mall.common.utils.JsonUtils;
import cn.e3mall.sso.service.TokenService;

/**
 * 根据token查询用户信息Controller
 * <p>Title: TokenController</p>
 * <p>Description: </p>
 * <p>Company: www.itcast.cn</p> 
 * @version 1.0
 */
@Controller
public class TokenController {

	@Autowired
	private TokenService tokenService;
	
	/*@RequestMapping(value="/user/token/{token}", 
			produces=MediaType.APPLICATION_JSON_UTF8_VALUE"application/json;charset=utf-8")
	@ResponseBody
	public String getUserByToken(@PathVariable String token, String callback) {
		E3Result result = tokenService.getUserByToken(token);
		//响应结果之前,判断是否为jsonp请求
		if (StringUtils.isNotBlank(callback)) {
			//把结果封装成一个js语句响应
			return callback + "(" + JsonUtils.objectToJson(result)  + ");";
		}
		return JsonUtils.objectToJson(result);
	}*/

//4.1版本以后可以使用第二种方法
	@RequestMapping(value="/user/token/{token}")
	@ResponseBody
	public Object getUserByToken(@PathVariable String token, String callback) {
		E3Result result = tokenService.getUserByToken(token);
		//响应结果之前,判断是否为jsonp请求
		if (StringUtils.isNotBlank(callback)) {
			//把结果封装成一个js语句响应
			MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result);
			mappingJacksonValue.setJsonpFunction(callback);
			return mappingJacksonValue;
		}
		return result;
	}
}

在页面需要导这两个js,第一个是自己写的,第二个是jquery下面的,自己下载去吧

下载地址https://download.csdn.net/download/kxj19980524/10914570

这里面的逻辑就是页面加载从cookie取出token然后访问controller返回用户信息,然后把页面的内容替换掉,主要是看这个ajax的数据类型是jsonp,它是解决跨越问题的. 

var E3MALL = {
	checkLogin : function(){
		var _ticket = $.cookie("token");
		if(!_ticket){
			return ;
		}
		$.ajax({
			url : "http://localhost:8088/user/token/" + _ticket,
			dataType : "jsonp",
			type : "GET",
			success : function(data){
				if(data.status == 200){
					var username = data.data.username;
					var html = username + ",欢迎来到宜立方购物网!<a href=\"http://www.e3mall.cn/user/logout.html\" class=\"link-logout\">[退出]</a>";
					$("#loginbar").html(html);
				}
			}
		});
	}
}

$(function(){
	// 查看是否已经登录,如果已经登录查询登录信息
	E3MALL.checkLogin();
});

 

session共享其实就是用来实现单点登陆的.

SSO英文全称Single Sign On,单点登录。SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。它包括可以将这次主要的登录映射到其他应用中用于同一个用户的登录的机制。它是目前比较流行的企业业务整合的解决方案之一。

例如客户端访问淘宝服务器进行登录,如果登录成功的话,再访问天猫的话就不用登录了,这两个网址的登录功能在另一个服务上写着,叫中央认证服务器,也就是上面让单独创建的session服务器.不管从哪边登录最终都是到中央认证服务器验证去,登录成功后会在客户端浏览器写一个cookie,记录着认证信息,会发一个令牌.表示认证过了,再次用这个浏览器访问天猫的时候,天猫会把这个令牌再传到中央认证服务器去验证,如果成功就会回馈给天猫,这个令牌有效,就会在天猫里也存个cookie,单点登录就是把登录功能提取出来,统一认证.

上面说的登录成功后显示用户名信息其实就是这么实现的,如果项目需要这种情况的话,直接用就可以了.

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值