不就是个聊天室,我从头撸给你看:2.怎样实现登录注册

前言

Hello,我是单木。接下来我将会开启一个新的博客系列,使用 GoLang 从 0 到 1 实现一个IM聊天室项目。在上一篇文章中,我们已经实现了一个简单的聊天室 Demo ,接下来我们就要开始具体业务的实现部分。在这篇文章中,我们将会完成用户的登录和注册。

技术选型

HTTP 作为一个无状态的协议,是无法区别这个请求的发起者是谁的。为了能够区分不同的客户端,我们需要给每一个客户端设计一个标识,然后让客户端在请求的时候携带对应的标识,来达到区分不同用户的目标。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
同时,对于每一个客户端,我们可能还需要维护与它相关的一些信息,比如用户名,状态等信息。在这个基础上衍生出了许多技术,现在比较主流的技术主要是三种:cookies、session 以及 JWT

Cookies

cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。cookie 是不可跨域的, 每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的(靠的是 domain)。
优点:

  1. 结构简单。cookie 是一种基于文本的轻量结构,包含简单的键值对。
  2. 数据持久。虽然客户端计算机上 cookie 的持续时间取决于客户端上的 cookie 过期处理和用户干预, cookie 通常是客户端上持续时间最长的数据保留形式。

缺点:

  1. 大小受限。大多数浏览器对 cookie 的大小有5096的字节限制,尽管在当今新的浏览器和客户端设备版本中,支持8192字节的 cookie 大小已愈发常见。
  2. 非常不安全。 cookie 将数据裸露在浏览器中,这样大大增大了数据被盗取的风险,所以我们不应该将重要的数据放在 cookie 中,或者将数据加密处理。
  3. 容易被 csrf 攻击。可以设置 csrf_token 来避免攻击。

Session

在计算机中,尤其是在网络应用中,称为“会话控制”。Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 Session 对象。当会话过期或被放弃后,服务器将终止该会话。
优点:

  1. Session 的信息存储在服务器,相比于 cookie 应在一定程度上加大了数据的完全性,相比于 JWT 方便进行管理,也就是说当用户登录和主动注销,只需要添加删除对应的 Session 就可以,这样管理起来很方便

缺点:

  1. session 存储在服务端,这就增大了服务器的开销,当用户多的情况下,服务器性能应付大大降低。
  2. 因为是基于 cookie 来进行用户识别的 cookie 如果被截获,用户就会很容易受到跨站请求伪造的攻击。
  3. 用户认证之后,服务端做认证记录,如果认证的记录被保存的内存中的话, 这意味着用户下次请求还必须要请求在这台服务器上的,这样才能拿到授权的资源,这样在分布式的应用上,会限制负载均衡和集群水平拓展的能力。

JWT

JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为 JSON 对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。
优点:

  1. 因为 JSON 的通用性,jwt可支持跨语言请求,像 Java、JavaScript、PHP 等很多语言都可以使用。
  2. 因为有了 payload 部分,所以 JWT 可以在自身存储一些其他业务逻辑所必要的非敏感信息。
  3. 便于传输,JWT 的构成非常简单,字节占用很小,所以它是非常便于传输的。
  4. 不需要在服务端保存会话信息,篮球服务器横向拓展。

缺点:

  1. 登录状态续签问题。比如设置 token 的有效期为一个小时,那么一个小时后,如果用户仍然在这个 web 应用上,这个时候当然不能指望用户再登录一次。目前可用的解决办法是在每次用户发现请求都返回一个新的token,前端再用这个 token 来替代旧的,这样每一次请求都会刷新 token 的有效期。但是这样需要频繁的生成 token 。另外一种方案是判断还有多久这个 token 会过期,在 token 快要过期时,返回一个新的 token 。
  2. 用户主动注销。 JWT 并不支持用户主动退出登录,客户端在别处使用 token 仍然可以正常访问。为了支持注销。有一个解决方案可用,就是在注销时将该 token 加入到服务器的 redis 黑名单中。

在现在的时间中,JWT 已经成为实际上的主要标准,因此 DiTing 中同样将采用 JWT 实现授权功能。

表结构设计

用户的登录和注册这个功能实现起来并不复杂,实际上就是给定前端提交用户的用户名和密码,在注册时把它记录到数据库中,在登录时根据用户名和密码来进行查询就行了。因此我们根据这个简单的创建一张表

CREATE TABLE `user` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `name` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户昵称',
  `password` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户密码',
  `status` int(11) DEFAULT '0' COMMENT '使用状态 0.正常 1拉黑',
  `create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
  `update_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uniq_name` (`name`) USING BTREE,
  KEY `idx_create_time` (`create_time`) USING BTREE,
  KEY `idx_update_time` (`update_time`) USING BTREE,
) ENGINE=InnoDB AUTO_INCREMENT=20000 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户表';

但是,我希望在DiTing聊天室在之后不仅仅能通过用户的账号和密码进行登录,还可以通过微信进行扫码登录,以及在之后每个用户可能可以携带不同的头衔,徽章,归属地等等信息,因此我对这些字段做了一些拓展,最后的表结构为

CREATE TABLE `user` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `password` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户密码',
  `name` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户昵称',
  `avatar` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户头像',
  `sex` int(11) DEFAULT NULL COMMENT '性别 1为男性,2为女性',
  `open_id` char(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '微信openid用户标识',
  `active_status` int(11) DEFAULT '2' COMMENT '在线状态 1在线 2离线',
  `last_opt_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '最后上下线时间',
  `ip_info` json DEFAULT NULL COMMENT 'ip信息',
  `item_id` bigint(20) DEFAULT NULL COMMENT '佩戴的徽章id',
  `status` int(11) DEFAULT '0' COMMENT '使用状态 0.正常 1拉黑',
  `create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
  `update_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uniq_open_id` (`open_id`) USING BTREE,
  UNIQUE KEY `uniq_name` (`name`) USING BTREE,
  KEY `idx_create_time` (`create_time`) USING BTREE,
  KEY `idx_update_time` (`update_time`) USING BTREE,
  KEY `idx_active_status_last_opt_time` (`active_status`,`last_opt_time`)
) ENGINE=InnoDB AUTO_INCREMENT=20000 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户表';

实现

配置管理

在实现具体业务之前,让我们先配置一下应用中必要的配置信息。还记得之前的目录结构吗,不记得也没关系,看这里

├── conf                    #项目配置文件目录
│   └── config.yaml         #例如:toml、yaml等等
├── controllers             #控制层目录
├── services                #服务层目录
├── models                  #模型层目录,和数据库表的映射保存在这里
├── routes                  #路由目录,负责分发请求
├── logs                    #日志文件目录,保存项目运行过程中产生的日志。
├── main.go                 #项目入口
├── README.md
├── .gitignore

config.yaml中我们先配置一下数据库的地址以及账号密码

mysql:
  host: localhost
  port: 4900
  username: diting
  password: diting
jwt:
// jwt签名,用于验证jwt
  secret: 123456

接下来,我们还需要能够读取配置文件中的信息。在 DiTing 中采用viper来进行配置文件的读取

安装
// cmd
go get github.com/spf13/viper
使用

接下来,我们在项目根目录中新建一个文件夹pkg,这个包用于存放公用的模块。在pkg下新建setting/setting.go,对viper进行初始化

package setting

import (
	"github.com/spf13/viper"
	"log"
)

func init() {
	// 设置配置文件的名字
	viper.SetConfigName("config")
	// 设置配置文件的类型
	viper.SetConfigType("yaml")
	// 添加配置文件的路径,指定 config 目录下寻找
	viper.AddConfigPath("./conf")
	err := viper.ReadInConfig()
	if err != nil {
		log.Fatalf("Fail to parse 'conf/config.yml': %v", err)
	}
}

接下来我们只需要通过简单的get就可以读取配置文件中的内容

// eg:
viper.GetString("mysql.username")

使用Gen框架减少简单CURD

在项目中有大量简单的CURD操作,全部自己写未免太浪费时间,因此DiTing中采用Gen框架自动生成简单的CURD代码,加快开发效率。
什么是Gen框架呢?

Gen是一个基于GORM的安全ORM框架,其主要通过代码生成方式实现GORM代码封装。使用Gen框架能够自动生成Model结构体和类型安全的CRUD代码,极大提升CRUD效率。

安装
// cmd
go get -u gorm.io/gen
自动生成CURD代码

在项目根目录下新建cmd/gen这两个文件夹,在其中新建generate.go用于编写自动生成的代码

package main

// gorm gen configure

import (
	_ "DiTing-Go/pkg/setting"
	"fmt"
	"github.com/spf13/viper"
	"gorm.io/driver/mysql"
	"gorm.io/gen"
	"gorm.io/gorm"
)
// 请确保你已经创建了diting数据库
var MySQLDSN = fmt.Sprintf("%s:%s@tcp(%s:%s)/diting?charset=utf8mb4&parseTime=True", viper.GetString("mysql.username"), viper.GetString("mysql.password"), viper.GetString("mysql.host"), viper.GetString("mysql.port"))

func connectDB(dsn string) *gorm.DB {
	db, err := gorm.Open(mysql.Open(dsn))
	if err != nil {
		panic(fmt.Errorf("connect db fail: %w", err))
	}
	return db
}

func main() {
	println(MySQLDSN)
	// 指定生成代码的具体相对目录(相对当前文件),默认为:./query
	// 默认生成需要使用WithContext之后才可以查询的代码,但可以通过设置gen.WithoutContext禁用该模式
	g := gen.NewGenerator(gen.Config{
		// 默认会在 OutPath 目录生成CRUD代码,并且同目录下生成 model 包
		// 所以OutPath最终package不能设置为model,在有数据库表同步的情况下会产生冲突
		// 若一定要使用可以通过ModelPkgPath单独指定model package的名称
		OutPath: "./dal/query",
		/* ModelPkgPath: "dal/model"*/

		// gen.WithoutContext:禁用WithContext模式
		// gen.WithDefaultQuery:生成一个全局Query对象Q
		// gen.WithQueryInterface:生成Query接口
		Mode: gen.WithDefaultQuery | gen.WithQueryInterface,
	})

	// 通常复用项目中已有的SQL连接配置db(*gorm.DB)
	// 非必需,但如果需要复用连接时的gorm.Config或需要连接数据库同步表信息则必须设置
	g.UseDB(connectDB(MySQLDSN))

	// 从连接的数据库为所有表生成Model结构体和CRUD代码
	// 也可以手动指定需要生成代码的数据表
	g.ApplyBasic(g.GenerateAllTable()...)

	// 执行并生成代码
	g.Execute()
}

执行这段代码,你会发现在你的项目根目录下多出了一个dal文件夹,其中包含两个子文件夹modelquery,到这里,我们所需要的代码就已经自动生成好了。
接下来我们在dal下新建一个db.go,在这里初始化数据库连接

package dal

import (
	"fmt"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

var DB *gorm.DB

// ConnectDB 初始化数据库连接
func ConnectDB(dsn string) *gorm.DB {
	db, err := gorm.Open(mysql.Open(dsn))
	if err != nil {
		panic(fmt.Errorf("connect db fail: %w", err))
	}
	return db
}

JWT

一样的,先安装一下依赖的包

go get github.com/dgrijalva/jwt-go

在这里,需要实现 JWT 的生成和解析。在pkg下新建util/jwt.go,JWT 相关的逻辑都会在这里实现。这一块大家可以直接拷贝就完事了。

// pkg/utils/jwt.go
package utils

import (
	"fmt"
	"github.com/dgrijalva/jwt-go"
	"github.com/spf13/viper"
	"time"
)

var jwtSecret = []byte(viper.GetString("jwt.secret"))

type Claims struct {
	Username string `json:"username"`
	jwt.StandardClaims
}

// GenerateToken 生成token
func GenerateToken(username string) (string, error) {
	nowTime := time.Now()
	expireTime := nowTime.Add(3 * time.Hour)

	claims := Claims{
		username,
		jwt.StandardClaims{
			ExpiresAt: expireTime.Unix(),
			Issuer:    "diting-go",
		},
	}

	tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	token, err := tokenClaims.SignedString(jwtSecret)
	if err != nil {
		fmt.Errorf("generate token failed: %v", err)
	}
	return token, err
}

// 解析token
func ParseToken(token string) (*Claims, error) {
	tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		return jwtSecret, nil
	})

	if tokenClaims != nil {
		if claims, ok := tokenClaims.Claims.(*Claims); ok && tokenClaims.Valid {
			return claims, nil
		}
	}

	return nil, err
}

统一返回值

为了便于和前端进行交互,我们规定一下 DiTing 中的响应格式

{
  "code": xxx,
  "message": "返回信息",
  "data": 返回的数据
}

为此我们简单实现封装几个方法,提供可以复用的代码。在pkg下新建resp/response.go

// pkg/resp/response.go
package resp

import (
    "DiTing-Go/pkg/e"
    "github.com/gin-gonic/gin"
)

// ResponseData 表示统一响应的JSON格式
type ResponseData struct {
    Code    int         `json:"code"`    // 状态码
    Message string      `json:"message"` // 响应消息
    Data    interface{} `json:"data"`    // 响应数据
}

// ErrorResponse 是一个辅助函数,用于创建错误响应
// 参数:
//
//	c *gin.Context:Gin上下文对象,用于处理HTTP请求和响应
//	code int:500
//	message string:响应消息,用于描述响应的错误信息或提示信息
func ErrorResponse(c *gin.Context, message string) {
    c.JSON(e.ERROR, ResponseData{
        Code:    e.ERROR,
        Message: message,
        Data:    nil,
    })
}

// SuccessResponse 是一个辅助函数,用于创建成功响应
// 参数:
//
//	c *gin.Context:Gin上下文对象,用于处理HTTP请求和响应
//	code int:200
//	data interface{}:响应数据,用于描述请求处理成功后返回的具体数据
func SuccessResponse(c *gin.Context, data interface{}) {
    c.JSON(e.SUCCESS, ResponseData{
        Code:    e.SUCCESS,
        Message: "success",
        Data:    data,
    })
}

// 不带值的响应
func SuccessResponseWithMsg(c *gin.Context, msg string) {
    c.JSON(e.SUCCESS, ResponseData{
        Code:    e.SUCCESS,
        Message: msg,
        Data:    nil,
    })
}

实现登录和注册

编写路由

我们需要为登录和注册分别指定对应的连接地址,并绑定上对应的处理函数,我将这块逻辑统一放在initGin

// routers/init_router.go

// 初始化gin
func initGin() {
	router := gin.Default()

	//添加swagger访问路由,这里先别管,也可以注释掉
	router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
	// 不需要身份验证的路由
	apiPublic := router.Group("/api/public")
	{
		//注册
		apiPublic.POST("/register", service.Register)
		//登录
		apiPublic.POST("/login", service.Login)
	}

	err := router.Run(":5000")
	if err != nil {
		return
	}
}
编写业务逻辑
// service/user_service.go
package service

import (
	"DiTing-Go/dal"
	"DiTing-Go/dal/model"
	"DiTing-Go/dal/query"
	"DiTing-Go/pkg/resp"
	_ "DiTing-Go/pkg/setting"
	"DiTing-Go/pkg/utils"
	"context"
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/spf13/viper"
)
// 数据库
var MySQLDSN = fmt.Sprintf("%s:%s@tcp(%s:%s)/diting?charset=utf8mb4&parseTime=True", viper.GetString("mysql.username"), viper.GetString("mysql.password"), viper.GetString("mysql.host"), viper.GetString("mysql.port"))

func init() {
	dal.DB = dal.ConnectDB(MySQLDSN).Debug()
	// 设置默认DB对象
	query.SetDefault(dal.DB)
}

// Register 用户注册
//
//	@Summary	用户注册
//	@Produce	json
//	@Param		name		body		string				true	"用户名"
//	@Param		password	body		string				true	"密码"
//	@Success	200			{object}	resp.ResponseData	"成功"
//	@Failure	500			{object}	resp.ResponseData	"内部错误"
//	@Router		/api/public/register [post]
func Register(c *gin.Context) {
	user := model.User{}
    // 将前端传来的参数转换为User struct
	if err := c.ShouldBind(&user); err != nil { //ShouldBind()会自动推导
		resp.ErrorResponse(c, "参数错误")
		return
	}

	u := query.User
	// 用户名是否已存在
	exist, _ := u.WithContext(context.Background()).Where(u.Name.Eq(user.Name)).First()
	if exist != nil {
		resp.ErrorResponse(c, "用户名已存在")
		return
	}

	// 创建对象
	err := u.WithContext(context.Background()).Omit(u.OpenID).Create(&user)
	if err != nil {
		resp.SuccessResponseWithMsg(c, "注册成功")
		return
	}
}

// Login 用户登录
//
//	@Summary	用户登录
//	@Produce	json
//	@Param		name		body		string				true	"用户名"
//	@Param		password	body		string				true	"密码"
//	@Success	200			{object}	resp.ResponseData	"成功"
//	@Failure	500			{object}	resp.ResponseData	"内部错误"
//	@Router		/api/public/login [post]
func Login(c *gin.Context) {
	login := model.User{}
	if err := c.ShouldBind(&login); err != nil { //ShouldBind()会自动推导
		resp.ErrorResponse(c, "参数错误")
		return
	}

	u := query.User
	// 检查密码是否正确
	user, _ := u.WithContext(context.Background()).Where(u.Name.Eq(login.Name), u.Password.Eq(login.Password)).First()
	if user == nil {
		resp.ErrorResponse(c, "用户名或密码错误")
		return
	}
	// 生成jwt
	token, _ := utils.GenerateToken(user.Name)
	resp.SuccessResponse(c, token)
}

到这里,登录和注册功能就已经全部实现好了

集成Swagger

为了便于和前端交互,我们可以集成一下 Swagger 框架,来帮助我们高效生成接口文档

安装
go get -u github.com/swaggo/swag/cmd/swag
go get -u -v github.com/swaggo/gin-swagger
go get -u -v github.com/swaggo/files
使用

接下来我们只需要按照规定给代码添加上对应的注释,在前面的登录和注册中也有对应的例子。

// eg:
// 函数名 函数描述
// @Summary 操作的简短摘要。
// @Description 操作行为的详细说明。
// @Tags 每个 API 操作的标签列表,以逗号分隔。
// @Accept application/json (API 可以使用的 MIME 类型列表。值必须如 Mime 类型中所述。)
// @Produce application/json (API 可以生成的 MIME 类型列表。值必须如 Mime 类型中所述。)
// @Param Authorization header string true "Bearer 用户令牌" (以空格分隔的参数。参数名称 参数类型 数据类型 是否必填 注释属性(可选))
// @Param object query models.ParamPostList false "查询参数" (同上)
// @Security ApiKeyAuth (每个 API 操作的安全性。)
// @Success 200 {object} ResponseData (以空格分隔的成功响应。返回码 {参数类型} 数据类型 注释)
// @Faliure 1001 {object} ResponseData (以空格分隔的失败响应。返回码 {参数类型} 数据类型 注释)
// @Router /LoginHandler [post] (以空格分隔的失败响应。路径,[http方法])
func LoginHandler(c *gin.Context) {
	/**
		...
			**/
}

然后我们还需要再路由中添加上渲染 swagger 的路径,在前面的路由中同样有对应的例子。

r.GET("/swagger/*any", gs.WrapHandler(swaggerFiles.Handler))

接下来我们需要再控制台执行以下命令

// cmd
 swag fmt 
 swag init 

这个时候可能会遇到,见问题

swag : 无法将“swag”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。请检查名称的拼写,如果包括路径,请确保路径正确,然后再试一次。

重启项目然后我们就可以通过对应的地址访问到我们的 swagger 文档了。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

测试

测试用户注册,这里同样采用 Postman。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如果再次注册,则会提示用户已存在
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

测试用户登录

在这里插入图片描述
在这里插入图片描述

问题

swag : 无法将“swag”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。请检查名称的拼写,如果包括路径,请确保路径正确,然后再试一次。

这是因为GOPATH没有被加入到系统路径,只需要添加即可。
首先查看GOPATH,然后把它添加到环境变量中

// cmd
PS D:\code\DiTing-Go> go env
...
set GOPATH=xxxxxxx
...

点关注,不迷路

好了,以上就是这篇文章的全部内容了,如果你能看到这里,非常感谢你的支持!
如果你觉得这篇文章写的还不错, 求点赞👍 求关注❤️ 求分享👥 对暖男我来说真的 非常有用!!!
白嫖不好,创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!
如果本篇博客有任何错误,请批评指教,不胜感激 !
本文的完整代码可以在 https://github.com/danmuking/DiTing-Go 中查看,欢迎各位人才Star⭐。如果想要加入这个项目或者有任何建议,欢迎联系

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值