会话技术——Cookies和Session详解

会话技术

(一) 概述、用途以及分类

(1) 基本概述

概述:会话是浏览器和服务器之间的多次请求和响应

也就是说,从浏览器访问服务器开始,到访问服务器结束浏览器关闭为止的这段时间内容产生的多次请求和响应,合起来叫做浏览器和服务器之间的一次会话

(2) 为什么要使用会话技术呢?

实际上会话问题解决的还是客户端与服务器之间的通信问题,通过一些会话技术,可以将每个用户的数据以例如cookie/session的形式存储,方便以后用户访问web资源的时候使用

假定场景:A和B两人在某个网上购物商场登陆账号后,A买了一个HHKB的键盘,而B则购买了一把民谣吉他,这些信息都会被保存下来

用途是:保存账户信息,登录时询问日后是否自动登录,或者根据之前浏览,购买过的商品,分析用户喜欢什么类型的商品,做出精准推送

那么能不能用我们之前学过的 HttpServletRequest 对象和 ServletContext 对象来保存这些数据呢?答案是否定的

**不能用 HttpServletRequest 的原因:**我们的一次会话中,存在多次请求和响应,而浏览器客户端的每一次请求都会产生一个 HttpServletRequest 对象,它只会保存此次请求的信息,例如放入购物车与购买付款是不同的请求,很显然数据没有得到很好的保存处理

**不能用 ServletContext 的原因:**ServletContext对象是被整个web应用所共享的,将数据都存到这里,无疑会无法区分具体信息的归属

(3) 分类

客户端会话技术 —— Cookie

服务器会话技术 —— Session

Cookie 技术

(一) 基本认识

(1) 概述

Cookies 可以简单的理解为服务器暂存在你浏览器中的一些信息文件,它将你在网站上所输入的一些内容,或者一些选项记录下来,当下一次你访问同一个网站的时候,服务器就会主动去查询这个cookie资料,如果存在的话,将会根据其中的内容,提供一些特别的功能,例如记住账号密码等

总结一下就是:

  • 网页之间的交互是通过HTTP协议传输数据的,而Http协议是无状态的协议 (数据提交后,浏览器和服务器的链接就会关闭,在此交互的时候 需要重新建立新的连接)
  • 服务器无法确认用户的信息,于是给每一个用户发一个通行证,通过此确认用户的信息
(2) 具体流程

浏览器访问服务器,如果服务器需要记录该用户的状态,就用response向浏览器发送一个cookie,浏览器会把Cookie保存起来。当浏览器再次访问服务器的时候,浏览器会把请求的网址以及Cookie一同提交给服务器

(3) 规范
  • Cookie大小上限为4KB;

  • 一个服务器最多在客户端浏览器上保存20个Cookie;

  • 一个浏览器最多保存300个Cookie

    面的数据是HTTP对Cookie的规范,但是现在一些浏览器可能会对Cookie规范 做了一些扩展,例如每个Cookie的大小为8KB,最多可保存500个Cookie等

不同的浏览器之间是不共享Cookie的

(二) 常用 API

//用于在其响应头中增加一个相应的Set-Cookie头字段
addCookie

//用于获取客户端提交的Cookie
GetCookie
public Cookie(String name,String value)
   
//该方法设置与 cookie 关联的值。
setValue

//该方法获取与 cookie 关联的值。
getValue

//该方法设置 cookie 过期的时间(以秒为单位)。如果不这样设置,cookie只会在当前 session 会话中持续有效。
setMaxAge

//该方法返回 cookie 的最大生存周期(以秒为单位),默认情况下,-1 表示 cookie 将持续下去,直到浏览器关闭
getMaxAge

//该方法设置 cookie 适用的路径。如果您不指定路径,与当前页面相同目录下的(包括子目录下的)所有 URL 都会返回 cookie。
setPath

//该方法获取 cookie 适用的路径。
getPath

//该方法设置 cookie 适用的域
setDomain

//该方法获取 cookie 适用的域
getDomain
(1) 注意:
  • Cookie具有不可跨域名性
  • ookie保存中文乱码问题:中文属于Unicode字符,英文数据Ascii字符,中文占4个字符或者3个字符,英文占2个字符,Cookie使用Unicode字符时需要对Unicode字符进行编码
Cookie cookie = new Cookie("xxx",URLEncoder.encode(name,"UTF-8"));
(2) 有效期

通过setMaxAge()方法可以设置Cookie的有效期

  • 如果MaxAge为正数,浏览器会把Cookie写到硬盘中,只要还在MaxAge秒之前,登录网站时该Cookie就有效【不论关闭了浏览器还是电脑】
    如果MaxAge为负数,Cookie是临时性的,尽在本浏览器内有效,关闭浏览器Cookie 就失效了,Cookie不会写到硬盘中。Cookie默认值就是 -1
  • 如果MaxAge为0,则表示删除该Cookie
(3) 删除和修改

Cookie存储的方式类似于Map集合,分为名字和值,只不过两者都是String类型的

修改

String name = "刮风这天";
Cookie cookie = new Cookie("country",URLEncoder.encode(name,"UTF-8"));

删除

String name = "刮风这天";
Cookie cookie = new Cookie("country",URLEncoder.encode(name,"UTF-8"));

cookie.setMaxAge(0);
response.addCookie(cookie);

printWriter.writer("Cookie已经被删除了")

(三) Cookie的域名

Cookie的domain属性决定运行访问Cookie的域名,Deomain的值规定为“.域名”

Cookie的隐私安全机制决定Cookie是不可跨域名的。及时是同一级域名,不同的二级域名也不能交接,eg:www.ideal.com 和 www.image..com

如果我希望一级域名相同的网页之间的Cookie之间可以互相访问,需要使用到domain方法

Cookie cookie = new Cookie("name","admin");
cookie.setMaxAge(1000);
cookie.setDomain(".ideal.com);
response.addCookie(cookie);
printWriter.writer("使用www.ideal.com域名添加了一个Cookie,只要一级域名是ideal.com即可访问")

(四) Cookie的路径

Cookie的path属性决定允许访问Cookie的路径

一般来说,Cookie发布出来,整个网页的资源都可以使用,但是如果只需要某一个Servlet可以获取到Cookie,其他的资源不能或不需要获取

Cookie cookie = new Cookie("name","admin");
cookie.setPath("/Servlet);
cookie.setMaxAge(1000);
response.addCookie(cookie);
printWriter.writer("该Cookie只能在Servlet1中可以访问到")

(五) Cookie的安全属性

HTTP协议不仅是无状态的,而且是不安全的!如果不希望Cookie在非安全协议中传输,可以设置Cookie的secure属性为true,浏览器只会在HTTPS和SSL等安全协议中传输该Cookie

设置secure属性不会将Cookie的内容加密,如果想保证安全,最好使用md5算法加密

protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //设置响应的消息体的数据格式以及编码
        resp.setContentType("text/html;charset=utf-8");

        //获取所有Cookie
        Cookie[] cookies = req.getCookies();
    	没有cookie为lastTime
        boolean flag = false;
        //遍历cookie数组
        if(cookies != null && cookies.length > 0){
            for (Cookie cookie : cookies) {
                //获取cookie的名称
                String name = cookie.getName();
                //判断名称是否是:lastTime
                if("lastTime".equals(name)){
                    //非第一次访问

                    flag = true;//有访问记录的time

                    //设置Cookie的value
                    //获取当前时间的字符串,重新设置Cookie的值,重新发送cookie
                    Date date  = new Date();
                    SimpleDateFormat sdf = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss");
                    String str_date = sdf.format(date);
                    System.out.println("编码前:"+str_date);
                    //URL编码
                    str_date = URLEncoder.encode(str_date,"utf-8");
                    System.out.println("编码后:"+str_date);
                    cookie.setValue(str_date);
                    //设置cookie的存活时间
                    cookie.setMaxAge(60 * 60 * 24 * 30);//一个月
                    resp.addCookie(cookie);

                    //响应数据
                    //获取Cookie的value,时间
                    String value = cookie.getValue();
                    System.out.println("解码前:"+value);
                    //URL解码:
                    value = URLDecoder.decode(value,"utf-8");
                    System.out.println("解码后:"+value);
                    resp.getWriter().write("<h1>欢迎回来,您上次访问时间为:"+value+"</h1>");

                    break;
                }
            }
        }

        if(cookies == null || cookies.length == 0 || flag == false){
            //没有,第一次访问

            //设置Cookie的value
            //获取当前时间的字符串,重新设置Cookie的值,重新发送cookie
            Date date  = new Date();
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss");
            String str_date = sdf.format(date);
            System.out.println("编码前:"+str_date);
            //URL编码
            str_date = URLEncoder.encode(str_date,"utf-8");
            System.out.println("编码后:"+str_date);

            Cookie cookie = new Cookie("lastTime",str_date);
            //设置cookie的存活时间
            cookie.setMaxAge(60 * 60 * 24 * 30);//一个月
            resp.addCookie(cookie);

            resp.getWriter().write("<h1>您好,欢迎您首次访问</h1>");
        }
    }

Character[32]在ASSCI码中代表空格 所以在日期表示格式中尽量不要出现空格,但若想要要求出现空格,或者特殊字符,

此外呢,我么你还可以做一个模拟显示上次浏览过商品记录的Demo,自行练习

Session

(一) 概述

Session是另一种记录浏览器状态的机制,Cookie保存在浏览器中,Session保存在服务器中。用户使用浏览器访问服务器的时候,服务把用户的信息,以某种形式记录在服务器,这就是Session

为何使用Session因为Session可以存储对象,Cookie只能存储字符串可以解决很多Cookie解决不了的问题

(二) API

//获取Session被创建时间
long getCreationTime()

//获取Session的id
String getId()

//返回Session最后活跃的时间
long getLastAccessedTime()

//获取ServletContext对象
ServletContext getServletContext()

//设置Session超时时间
void setMaxInactiveInterval(int var1)

//获取Session超时时间
int getMaxInactiveInterval()

//获取Session属性
Object getAttribute(String var1)

//获取Session所有的属性名
Enumeration getAttributeNames()

//设置Session属性
void setAttribute(String var1, Object var2)

//移除Session属性
void removeAttribute(String var1)

//销毁该Session
void invalidate()

//该Session是否为新的
boolean isNew()

Session有着request和ServletContext类似的方法。其实Session也是一个域对象。Session作为一种记录浏览器状态的机制,只要Session对象没有被销毁,Servlet之间就可以通过Session对象实现通讯

设置

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		HttpSession httpSession = request.getSession();
		httpSession.setAttribute("name", "test");
	}

获取

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		HttpSession httpSession = request.getSession();
		String value = (String) httpSession.getAttribute("name");
		System.out.println(value);
	}

(三) 生命周期和有效期

用户第一次访问服务器Servlet,jsp等动态资源就会自动创建Session,Session对象保存在内存里,这也就为什么上面的例子可以直接使用request对象获取得到Session对象

如果访问HTML,Image等静态资源Session不会被创建

Session生成后,只要用户继续访问,服务器就会更新Session的最后访问时间,无论是否对Session进行读写,服务器都会认为Session活跃了一次。

由于会有越来越多的用户访问服务器,因此Session也会越来越多。为了防止内存溢出,服务器会把长时间没有活跃的Session从内存中删除,这个时间也就是Session的超时时间

Session的超时时间默认是30分钟,有三种方式可以对Session的超时时间进行修改

**第一种方式:**在tomcat/conf/web.xml文件中设置,时间值为20分钟,所有的WEB应用都有效————<session-timeout>20<session-timeout>

**第二种方式:**在单个的web.xml文件中设置,对单个web应用有效,如果有冲突,以自己的web应用为准

第三种方式:通过setMaxInactiveInterval()方法设置

httpSession.setMaxInactiveInterval(60);

(四) Session 和 Cookie的小区别

  • Session周期指的是不活动的时间,如果我们设置Session是10s,在10s内,没有访问session,session中属性失效,如果在9s的时候,你访问了session,则会重新计时

  • 如果重启了tomcat,或者reload web应用,或者关机了,Session也会失效,我们也可以通过函数让Session失效,invalidate()该方法是让Session中的所有属性失效,常常用于安全退出

  • 如果你希望某个Session属性失效,可以使用方法removeAttribute

  • Cookie的生命周期就是按累积的时间来算的,不管用户有没有访问过Session

(五) Session

问题:我再Aservlet中设置了Session属性,在Bservlet中获取A的属性

在浏览器中新建一个页面再次访问Bservlet 报空指针异常

现在问题来了:服务器是如何实现一个session为一个用户浏览器服务的?换个说法:为什么服务器能够为不同的用户浏览器提供不同session?

HTTP协议是无状态的,Session不能依据HTTP连接来判断是否为同一个用户。于是乎:服务器向用户浏览器发送了一个名为JESSIONID的Cookie,它的值是Session的id值。其实Session依据Cookie来识别是否是同一个用户。

简单来说:Session 之所以可以识别不同的用户,依靠的就是Cookie

该Cookie是服务器自动颁发给浏览器的,不用我们手工创建的。该Cookie的maxAge值默认是-1,也就是说仅当前浏览器使用,不将该Cookie存在硬盘中

流程概述:

  • 访问Aservlet时,服务器就会创建一个Session对象,执行我们的程序代码,执行我们的程序代码,并自动颁发一个Cookie给用户浏览器
  • 当我用同一个浏览器访问BServlet的时候,浏览器会把Cookie的值通过Http协议带过去给服务器,服务器就知道用哪一个Session
  • 而当我们使用新会话的浏览器访问BServlet的时候,该新浏览器并没有Cookie,服务器无法辨认使用哪一个Session,所以获取不到值。

(六) 浏览器禁用Cookie后Session的使用

遇到两种情况:1.用户浏览器禁用了Cookie绝大多数手机浏览器都不支持Cookie

Java Web提供了解决方法:URL地址重写

HttpServletResponse类提供了两个URL地址重写的方法:

encodeURL(String url)

encodeRedirectURL(String url)

需要值得注意的是:这两个方法会自动判断该浏览器是否支持Cookie,如果支持Cookie,重写后的URL地址就不会带有jsessionid了【当然了,即使浏览器支持Cookie,第一次输出URL地址的时候还是会出现jsessionid(因为没有任何Cookie可带)】

例子

String url = "/web-01/Servlet5";
response.sendRedirect(response.encodeURL(url));

URL地址重写的原理:

将Session的id信息重写到URL地址汇总,服务器解析重写后URL获取Session的id,这样一来即使浏览器禁用掉了Cookie,但是Session的id通过服务端传递,还是可以使用Session来记录用户的状态。

(七) Session案例

案例一:使用Session完成用户简单登录

先创建User类

public class User {
	private String username = null;
	private String password = null;
	
	public User() {
	}

	public User(String username, String password) {
		super();
		this.username = username;
		this.password = password;
	}

......各种set get方法

使用简单的集合模拟一个数据库

public class UserDB {
	private static List<User> list =new ArrayList<>();
	
	static {
		list.add(new User("admin","888"));
		list.add(new User("aaa","111"));
		list.add(new User("bbb","222"));
	}
	
	//通过用户名密码查找用户
	public static User find(String username, String password) {
		
		for (User user:list) {
			if (user.getUsername().equals(username)&& user.getPassword().equals(password)) {
				return user;
			}
		}
		return null;
	}
}

表单提交我们写在jsp里面(模仿即可后期说jsp)

public class UserDB {
	private static List<User> list =new ArrayList<>();
	
	static {
		list.add(new User("admin","888"));
		list.add(new User("aaa","111"));
		list.add(new User("bbb","222"));
	}
	
	//通过用户名密码查找用户
	public static User find(String username, String password) {
		
		for (User user:list) {
			if (user.getUsername().equals(username)&& user.getPassword().equals(password)) {
				return user;
			}
		}
		return null;
	}
}

获取表单提交的数据,查找数据库是否有相对应的用户名和密码

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		response.setContentType("text/html;charset=UTF-8");
		
		String username = request.getParameter("username");
		String password = request.getParameter("password");
		
		User user = UserDB.find(username, password);
		
		//如果找不到,就是用户名或者密码出错了
		if (user == null) {
			response.getWriter().write("用户名或者密码错误,登陆失败 !");
			return;
		}
		
		//标志着用户已经登录
		HttpSession httpSession = request.getSession();
		httpSession.setAttribute("user", user);
		
		//跳转到其他页面,告诉用户已经登录成功
		response.sendRedirect(response.encodeURL("test.jsp"));
	}

案例二:利用Session防止表单重复提交

重复提交的危害:

在投票的网页上不停地提交,实现了刷票的效果。

注册多个用户,不断发帖子,扰乱正常发帖秩序。

常见的两种重复提交

第一种:后退再提交

第二种:网络延迟,多次点击提交按钮

略图

解决方案:

网络延迟问题:

对于第二种网络延而造成多次提交数据给服务器,其实是客户端的问题,我们可以使用javaScript来防止

→ 当用户第一次点击提交按钮是,把数据提交给服务器,当用户再次点击提交按钮时,就不把数据提交给服务器了

监听用监听事件。只能让用户提交一次表单:

<%@ page language="java" import="java.util.*" pageEncoding="UTF-8"%>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>表单提交</title>
<script type="text/javascript">
	//定义一个全局标识量:是否已经提交过表单数据
	var isCommitted = false;

	function doSubmit() {
		//false表示的是没有提交过,于是就可以让表单提交给Servlet
		if (isCommited == false){
			is Commited = true;
			return true;
		}else{
			return false;
		}	
	}
</script>
</head>
<body>
	<form action="/web-01/Lservlet" method="post" οnsubmit="return doSubmit()">
		用户名:<input type="text" name="username"><br /> <input
			type="submit" value="提交">
	</form>
</body>
</html>

刷新后退再提交问题:

我们知道Session可以用来标识一个用户是否登陆了。Session的原理也说了:不同的用户浏览器会拥有不同的Session。而request和ServletContext为什么就不行呢?request的域对象只能是一次http请求,提交表单数据的时候request域对象的数据取不出来。ServletContext代表整个web应用,如果有几个用户浏览器同时访问,ServletContext域对象的数据会被多次覆盖掉,也就是说域对象的数据就毫无意义了。

此时,我们就想到了,在表单中还有一个隐藏域,可以通过隐藏域把数据交给服务器。

A:判断Session域对象的数据和jsp隐藏域提交的数据是否对应。

B:判断隐藏域的数据是否为空【如果为空,就是直接访问表单处理页面的Servlet】

C:判断Session的数据是否为空【servlet判断完是否重复提交,最好能立马移除Session的数据,不然还没有移除的时候,客户端那边儿的请求又来了,就又能匹配了,产生了重复提交。如果Session域对象数据为空,证明已经提交过数据了!】

D:我们向Session域对象的存入数据究竟是什么呢?简单的一个数字?好像也行啊。因为只要Session域对象的数据和jsp隐藏域带过去的数据对得上号就行了呀,反正在Servlet上判断完是否重复提交,会立马把Session的数据移除掉的。更专业的做法是:向Session域对象存入的数据是一个随机数【Token–令牌】

public class TokenProcessor {
	private TokenProcessor() {
	}

	private final static TokenProcessor TOKEN_PROCESSOR = new TokenProcessor();

	public static TokenProcessor getInstance() {
		return TOKEN_PROCESSOR;
	}

	public String makeToken() {
		// 这个随机生成出来的Token的长度是不确定的
		String token = String.valueOf(System.currentTimeMillis() + new Random().nextInt(99999999));
		try {
			// 我们想要随机数的长度一致,就要获取到数据指纹
			MessageDigest messageDigest = MessageDigest.getInstance("md5");
			byte[] md5 = messageDigest.digest(token.getBytes());

			// 如果我们直接 return new String(md5)出去,得到的随机数会乱码
			// 因为随机数是任意的01010101010,在转换成字符串的时候,会差gb2312的码表
			// gb2312码表不一定支持该二进制数据,得到的就是乱码
			// 于是经过base64编码成了明文的数据
			BASE64Encoder base64Encoder = new BASE64Encoder();
			return base64Encoder.encode(md5);
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
		}
		return null;
	}
}

创建Token随机数,利用getRequestDispatcher跳转到jsp页面(地址还是Servlet的)

	protected void doGet(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
		// 生出随机数
		TokenProcessor tokenProcessor = TokenProcessor.getInstance();
		String token = tokenProcessor.makeToken();

		// 将随机数存进Session中
		request.getSession().setAttribute("token", token);

		// 跳转到显示页面
		request.getRequestDispatcher("/login3.jsp").forward(request, response);

Jsp隐藏域获取到Session的值

<form action="/web-01/Mservlet" >

    用户名:<input type="text" name="username">
    <input type="submit" value="提交" id="button">

    <%--使用EL表达式取出session中的Token--%>
    <input type="hidden" name="token" value="${token}" >

</form>

在处理表单提交页面中判断:jsp隐藏域是否有带值过来,Session中的值是否为空,Session中的值和jsp隐藏域带过来的值是否相等

	protected void doGet(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
		response.setContentType("text/html;charset=UTF-8");
		PrintWriter printWriter = response.getWriter();
		String serverValue = (String) request.getSession().getAttribute("token");
		String clienValue = request.getParameter("token");

		if (serverValue != null && clienValue != null && serverValue.equals(clienValue)) {
			printWriter.write("处理请求");
			// 清除Session域对象数据
			request.getSession().removeAttribute("token");
		} else {
			printWriter.write("请不要重复提交数据");
		}
	}

实现原理是非常简单的

在session域中存储一个token

然后前台页面的隐藏域获取得到这个token

在第一次访问的时候,我们就判断seesion有没有值,如果有就比对。对比正确后我们就处理请求,接着就把session存储的数据给删除了

等到再次访问的时候,我们session就没有值了,就不受理前台的请求了!

(八) Session和Cookie的区别

从存储方式上比较

Cookie只能存储字符串,如果要存储非ASCII字符串还要对其编码。

Session可以存储任何类型的数据,可以把Session看成是一个容器

从隐私安全上比较

Cookie存储在浏览器中,对客户端是可见的。信息容易泄露出去。如果使用Cookie,最好将Cookie加密

Session存储在服务器上,对客户端是透明的。不存在敏感信息泄露问题。

从有效期上比较

Cookie保存在硬盘中,只需要设置maxAge属性为比较大的正整数,即使关闭浏览器,Cookie还是存在的

Session的保存在服务器中,设置maxInactiveInterval属性值来确定Session的有效期。并且Session依赖于名为JSESSIONID的Cookie,该Cookie默认的maxAge属性为-1。如果关闭了浏览器,该Session虽然没有从服务器中消亡,但也就失效了。

从对服务器的负担比较

Session是保存在服务器的,每个用户都会产生一个Session,如果是并发访问的用户非常多,是不能使用Session的,Session会消耗大量的内存。

Cookie是保存在客户端的。不占用服务器的资源。像baidu、Sina这样的大型网站,一般都是使用Cookie来进行会话跟踪。

从浏览器的支持上比较

如果浏览器禁用了Cookie,那么Cookie是无用的了!

如果浏览器禁用了Cookie,Session可以通过URL地址重写来进行会话跟踪。

从跨域名上比较

Cookie可以设置domain属性来实现跨域名

Session只在当前的域名内有效,不可跨域名

结尾:

如果内容中有什么不足,或者错误的地方,欢迎大家给我留言提出意见, 蟹蟹大家 !_

如果能帮到你的话,那就来关注我吧!(系列文章均会在公众号第一时间更新)

在这里的我们素不相识,却都在为了自己的梦而努力 ❤

一个坚持推送原创Java技术的公众号:理想二旬不止

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值