用户web服务

本文围绕Golang微服务下的用户服务Web服务展开。先介绍基础项目架构,包括新建项目、使用zap日志库、集成到gin启动过程、viper配置文件管理等;接着阐述web层接口开发,如表单验证、自定义验证器、登录逻辑完善、集成JWT、解决跨域问题、获取验证码、短信发送、验证码缓存及用户注册接口测试等。

用户服务的web服务

1.基础项目架构
1.1新建项目和目录结构构建

在这里插入图片描述

1.2go高性能日志库-zap使用

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

1.3zap的文件输出

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

1.4集成zap和理由初始到gin的启动过程

1.在initalize目录下建立logger.go初始化日志

package initalize

import "go.uber.org/zap"

func InitLogger() {
	logger, _ := zap.NewDevelopment()
	zap.ReplaceGlobals(logger)
}

2.在router目录下建立user.go初始化路由

package router

import (
	"github.com/gin-gonic/gin"
	"mxshop_api/user-web/api"
)

func InitUserRouter(Router *gin.RouterGroup) {
	UserRouter := Router.Group("user")
	{
		UserRouter.GET("list", api.GetUserList)
	}

}

3.在initalize目录下建立router.go初始化路由

package initalize

import (
	"github.com/gin-gonic/gin"
	router2 "mxshop_api/user-web/router"
)

func Routers() *gin.Engine {
	Router := gin.Default()

	ApiGroup := Router.Group("/v1")
	router2.InitUserRouter(ApiGroup)

	return Router
}

4.在main.go调用初始化函数,并启动

package main

import (
	"fmt"
	"go.uber.org/zap"
	"mxshop_api/user-web/initalize"
)

func main() {
	port := 8021
	//1.初始化logger
	initalize.InitLogger()
	//2.初始化routers
	Router := initalize.Routers()
	zap.S().Debugf("启动服务器,端口:%d", port)

	if err := Router.Run(fmt.Sprintf(":%d", port)); err != nil {
		zap.S().Panic("启动失败:", err.Error())
	}
}
1.5gin调用grpc服务
  1. apii目录下建立user.api文件,并创建调用获取用户列表方法

    import (
    	"context"
    	"fmt"
    	"github.com/gin-gonic/gin"
    	"go.uber.org/zap"
    	"google.golang.org/grpc"
    	"google.golang.org/grpc/codes"
    	"google.golang.org/grpc/status"
    	"mxshop_api/user-web/global"
    	"mxshop_api/user-web/global/response"
    	"mxshop_api/user-web/proto"
    	"net/http"
    	"strconv"
    	"time"
    )
    
    func HandleGrpcErrorToHttp(err error, c *gin.Context) {
    	//将grpc的code转换成http的状态码
    	if err != nil {
    		if e, ok := status.FromError(err); ok {
    			switch e.Code() {
    			case codes.NotFound:
    				c.JSON(http.StatusNotFound, gin.H{
    					"msg": e.Message(),
    				})
    			case codes.Internal:
    				c.JSON(http.StatusInternalServerError, gin.H{
    					"msg:": "内部错误",
    				})
    			case codes.InvalidArgument:
    				c.JSON(http.StatusBadRequest, gin.H{
    					"msg": "参数错误",
    				})
    			case codes.Unavailable:
    				c.JSON(http.StatusInternalServerError, gin.H{
    					"msg": "用户服务不可用",
    				})
    			default:
    				c.JSON(http.StatusInternalServerError, gin.H{
    					"msg": e.Code(),
    				})
    			}
    			return
    		}
    	}
    }
    
    func GetUserList(ctx *gin.Context) {
    	userConn, err := grpc.Dial(fmt.Sprintf("%s:%d", global.ServerConfig.UserSrvInfo.Host,
    		global.ServerConfig.UserSrvInfo.Port), grpc.WithInsecure())
    	if err != nil {
    		zap.S().Errorw("[GetUserList] 连接【用务服务失败】", "msg", err.Error())
    	}
    	//调用接口
    
    	//生成grpc的client并调用接口
    	userSrvClient := proto.NewUserClient(userConn)
    
    	pn := ctx.DefaultQuery("pn", "0")
    	pnInt, _ := strconv.Atoi(pn)
    	pSize := ctx.DefaultQuery("pSize", "0")
    	pSizeInt, _ := strconv.Atoi(pSize)
    	rsp, err := userSrvClient.GetUserList(context.Background(), &proto.PageInfo{
    		Pn:    uint32(pnInt),
    		PSize: uint32(pSizeInt),
    	})
    	if err != nil {
    		zap.S().Errorw("[GetUserList] 查询【用户列表失败】")
    		HandleGrpcErrorToHttp(err, ctx)
    		return
    	}
    	reMap := gin.H{
    		"total": rsp.Total,
    	}
    	result := make([]interface{}, 0)
    	for _, value := range rsp.Data {
    		user := response.UserResponse{
    			Id:       value.Id,
    			NickName: value.NickName,
    			Birthday: response.JsonTime(time.Unix(int64(value.BirthDay), 0)),
    			Gender:   value.Gender,
    			Mobile:   value.Mobile,
    		}
    		result = append(result, user)
    	}
    	reMap["data"] = result
    	ctx.JSON(http.StatusOK, reMap)
    }
    
  2. 在global目录下建立response,并在这个目录下建立user.go

    package response
    
    import (
       "fmt"
       "time"
    )
    
    type JsonTime time.Time
    
    func (j JsonTime) MarshalJSON() ([]byte, error) {
       var stmp = fmt.Sprintf("\"%s\"", time.Time(j).Format("2006-01-02"))
       return []byte(stmp), nil
    }
    
    type UserResponse struct {
       Id       int32  `json:"id"`
       NickName string `json:"name"`
       //Birthday string `json:"birthday"`
       Birthday JsonTime `json:"birthday"`
       Gender   string   `json:"gender"`
       Mobile   string   `json:"mobile"`
    }
    
  3. 启动 user_srv服务和user_web服务,并测试

在这里插入图片描述

1.6配置文件-viper
package main

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

type ServerConfig struct {
   Name string `mapstructure:"name"`
   Port int    `mapstructure:"port"`
}

func main() {
   v := viper.New()
   v.SetConfigFile("user-web/viptest/config.yaml")
   if err := v.ReadInConfig(); err != nil {
      panic(err)
   }
   serverConfig := ServerConfig{}
   if err := v.Unmarshal(&serverConfig); err != nil {
      panic(err)
   }
   fmt.Println(serverConfig)
   fmt.Println(serverConfig.Port)
}
1.7viper的配置环境开发环境和生产环境隔离
1.7.1配置环境变量

在这里插入图片描述

1.7.2配置线上环境和开发环境文件

config-pro.yaml

name: user-web
port: 8021
mysql:
  host: '127.0.0.1'
  port: 3306

config-debug.yaml

name: user-web
port: 8022
mysql:
  host: '127.0.0.1'
  port: 3308
1.7.3读取配置文件
package main

import (
   "fmt"
   "github.com/fsnotify/fsnotify"
   "github.com/spf13/viper"
   "time"
)

type MysqlConfig struct {
   Host string `mapstructure:"host"`
   Port int    `mapstructure:"port"`
}

type ServerConfig struct {
   Name      string      `mapstructure:"name"`
   Port      int         `mapstructure:"port"`
   MysqlInfo MysqlConfig `mapstructure:"mysql"`
}

func GetEnvInfo(env string) bool {
   viper.AutomaticEnv()
   return viper.GetBool(env)
}

func main() {
   debug := GetEnvInfo("MXSHOP_DEBUG")
   configFilePrefix := "config"
   configFileName := fmt.Sprintf("user-web/viptest/%s-pro.yaml", configFilePrefix)
   if debug {
      configFileName = fmt.Sprintf("user-web/viptest/%s-debug.yaml", configFilePrefix)
   }
   v := viper.New()
   v.SetConfigFile(configFileName)
   if err := v.ReadInConfig(); err != nil {
      panic(err)
   }
   serverConfig := ServerConfig{}
   if err := v.Unmarshal(&serverConfig); err != nil {
      panic(err)
   }
   fmt.Println(serverConfig)
   fmt.Println(serverConfig.Port)
   fmt.Println(serverConfig.MysqlInfo.Host)

   //viper功能,动态监控变化
   v.WatchConfig()
   v.OnConfigChange(func(e fsnotify.Event) {
      fmt.Println("config file changed:", e.Name)
      _ = v.ReadInConfig()
      _ = v.Unmarshal(&serverConfig)
      fmt.Println(serverConfig)
   })

   time.Sleep(time.Second * 300)
}
1.8viper集成到gin的web服务中
1.8.1建立配置文件

config-debug.yaml

name: 'user-web'
port: 8021
user_srv:
  host: '127.0.0.1'
  port: 50051

config-pro.yaml

name: 'user-web'
port: 8021
user_srv:
  host: '127.0.0.1'
  port: 50051
1.8.2建立映射文件

config/config.go

package config

type UserSrvConfig struct {
   Host string `mapstructure:"host"`
   Port int    `mapstructure:"port"`
}

type ServerConfig struct {
   Name        string        `mapstructure:"name"`
   Port        int           `mapstructure:"port"`
   UserSrvInfo UserSrvConfig `mapstructure:"user_srv"`
}

1.8.3初始化配置文件

initalize/config.go

package initalize

import (
	"fmt"
	"github.com/fsnotify/fsnotify"
	"github.com/spf13/viper"
	"go.uber.org/zap"
	"mxshop_api/user-web/global"
)

func GetEnvInfo(env string) bool {
	viper.AutomaticEnv()
	return viper.GetBool(env)
}

func InitConfig() {
	debug := GetEnvInfo("MXSHOP_DEBUG")
	configFilePrefix := "config"
	configFileName := fmt.Sprintf("user-web/%s-pro.yaml", configFilePrefix)
	if debug {
		configFileName = fmt.Sprintf("user-web/%s-debug.yaml", configFilePrefix)
	}
	v := viper.New()
	v.SetConfigFile(configFileName)
	if err := v.ReadInConfig(); err != nil {
		panic(err)
	}
	if err := v.Unmarshal(global.ServerConfig); err != nil {
		panic(err)
	}
	zap.S().Infof("配置信息:%v", global.ServerConfig)
	//viper功能,动态监控变化
	v.WatchConfig()
	v.OnConfigChange(func(e fsnotify.Event) {
		zap.S().Infof("配置信息产生变化:%v", e.Name)
		_ = v.ReadInConfig()
		_ = v.Unmarshal(global.ServerConfig)
		fmt.Println(global.ServerConfig)
	})
}

1.8.4整合入口文件

main.go

package main

import (
   "fmt"
   "go.uber.org/zap"
   "mxshop_api/user-web/global"
   "mxshop_api/user-web/initalize"
)

func main() {
   //1.初始化logger
   initalize.InitLogger()

   //2. 初始化配置文件
   initalize.InitConfig()

   //3.初始化routers
   Router := initalize.Routers()
   zap.S().Debugf("启动服务器,端口:%d", global.ServerConfig.Port)

   if err := Router.Run(fmt.Sprintf(":%d", global.ServerConfig.Port)); err != nil {
      zap.S().Panic("启动失败:", err.Error())
   }
}
2.web层接口开发
2.1表单验证的初始化
2.1.1在forms建立验证文件user.go
package forms

type PassWordLoginForm struct {
   Mobile   string `form:"mobile" json:"mobile" binding:"required"` //手机号码格式有规范可寻, 自定义validator
   PassWord string `form:"password" json:"password" binding:"required,min=3,max=20"`
}
2.1.2建立初始化验器

在initalize这个目录上建立validator.go

package initalize

import (
   "fmt"
   "mxshop_api/user-web/global"
   "reflect"
   "strings"

   "github.com/gin-gonic/gin/binding"
   "github.com/go-playground/locales/en"
   "github.com/go-playground/locales/zh"
   ut "github.com/go-playground/universal-translator"
   "github.com/go-playground/validator/v10"
   en_translations "github.com/go-playground/validator/v10/translations/en"
   zh_translations "github.com/go-playground/validator/v10/translations/zh"
)

func InitTrans(locale string) (err error) {
   //修改gin框架中的validator引擎属性, 实现定制
   if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
      //注册一个获取json的tag的自定义方法
      v.RegisterTagNameFunc(func(fld reflect.StructField) string {
         name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
         if name == "-" {
            return ""
         }
         return name
      })

      zhT := zh.New() //中文翻译器
      enT := en.New() //英文翻译器
      //第一个参数是备用的语言环境,后面的参数是应该支持的语言环境
      uni := ut.New(enT, zhT, enT)
      global.Trans, ok = uni.GetTranslator(locale)
      if !ok {
         return fmt.Errorf("uni.GetTranslator(%s)", locale)
      }

      switch locale {
      case "en":
         en_translations.RegisterDefaultTranslations(v, global.Trans)
      case "zh":
         zh_translations.RegisterDefaultTranslations(v, global.Trans)
      default:
         en_translations.RegisterDefaultTranslations(v, global.Trans)
      }
      return
   }
   return
}
2.1.3定义全局变量trans

在global目录下的global.go

package global

import (
   ut "github.com/go-playground/universal-translator"
   "mxshop_api/user-web/config"
)

var (
   Trans        ut.Translator
   ServerConfig *config.ServerConfig = &config.ServerConfig{}
)
2.1.4在 main.go文件调用初始化
func main() {
   //1.初始化logger
   initalize.InitLogger()

   //2. 初始化配置文件
   initalize.InitConfig()

   //3.初始化routers
   Router := initalize.Routers()
   zap.S().Debugf("启动服务器,端口:%d", global.ServerConfig.Port)

   //4. 初始化翻译
   if err := initalize.InitTrans("zh"); err != nil {
      panic(err)
   }

   if err := Router.Run(fmt.Sprintf(":%d", global.ServerConfig.Port)); err != nil {
      zap.S().Panic("启动失败:", err.Error())
   }
}
2.1.5在api目录下建立登录方法
func removeTopStruct(fileds map[string]string) map[string]string {
	rsp := map[string]string{}
	for field, err := range fileds {
		rsp[field[strings.Index(field, ".")+1:]] = err
	}
	return rsp
}
func HandleValidatorError(c *gin.Context, err error) {
	errs, ok := err.(validator.ValidationErrors)
	if !ok {
		c.JSON(http.StatusOK, gin.H{
			"msg": err.Error(),
		})
	}
	c.JSON(http.StatusBadRequest, gin.H{
		"error": removeTopStruct(errs.Translate(global.Trans)),
	})
	return
}
func PassWordLogin(c *gin.Context) {
   //表单验证
   passwordLoginForm := forms.PassWordLoginForm{}
   if err := c.ShouldBindJSON(&passwordLoginForm); err != nil {
      //如何返回错误信息
      HandleValidatorError(c, err)
      return
   }
}
2.1.6建立相应的登录路由
package router

import (
   "github.com/gin-gonic/gin"
   "mxshop_api/user-web/api"
)

func InitUserRouter(Router *gin.RouterGroup) {
   UserRouter := Router.Group("user")
   {
      UserRouter.GET("list", api.GetUserList)
      UserRouter.POST("pwd_login", api.PassWordLogin)
   }
}
2.2自定义mobile验证器
2.2.1在validator目录下建立文件validator.go
package validator

import (
   "github.com/go-playground/validator/v10"
   "regexp"
)

func ValidateMobile(fl validator.FieldLevel) bool {
   mobile := fl.Field().String()
   //使用正则表达式判断是否合法
   ok, _ := regexp.MatchString(`^1([38][0-9]|14[579]|5[^4]|16[6]|7[1-35-8]|9[189])\d{8}$`, mobile)
   if !ok {
      return false
   }
   return true
}
2.2.2绑定验证器
package forms

type PassWordLoginForm struct {
   Mobile   string `form:"mobile" json:"mobile" binding:"required,mobile"` //手机号码格式有规范可寻, 自定义validator
   PassWord string `form:"password" json:"password" binding:"required,min=3,max=20"`
}
2.2.3入口文件绑定
package main

import (
   "fmt"
   "github.com/gin-gonic/gin/binding"
   ut "github.com/go-playground/universal-translator"
   "github.com/go-playground/validator/v10"
   "go.uber.org/zap"
   "mxshop_api/user-web/global"
   "mxshop_api/user-web/initalize"
   myvalidator "mxshop_api/user-web/validator"
)

func main() {
   //1.初始化logger
   initalize.InitLogger()

   //2. 初始化配置文件
   initalize.InitConfig()

   //3.初始化routers
   Router := initalize.Routers()
   zap.S().Debugf("启动服务器,端口:%d", global.ServerConfig.Port)

   //4. 初始化翻译
   if err := initalize.InitTrans("zh"); err != nil {
      panic(err)
   }

   if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
      v.RegisterValidation("mobile", myvalidator.ValidateMobile)
      _ = v.RegisterTranslation("mobile", global.Trans, func(ut ut.Translator) error {
         return ut.Add("mobile", "{0} 非法的手机号码!", true) // see universal-translator for details
      }, func(ut ut.Translator, fe validator.FieldError) string {
         t, _ := ut.T("mobile", fe.Field())
         return t
      })
   }

   if err := Router.Run(fmt.Sprintf(":%d", global.ServerConfig.Port)); err != nil {
      zap.S().Panic("启动失败:", err.Error())
   }
}
2.3登录逻辑完善
func PassWordLogin(c *gin.Context) {
   //表单验证
   passwordLoginForm := forms.PassWordLoginForm{}
   if err := c.ShouldBindJSON(&passwordLoginForm); err != nil {
      //如何返回错误信息
      HandleValidatorError(c, err)
      return
   }

   userConn, err := grpc.Dial(fmt.Sprintf("%s:%d", global.ServerConfig.UserSrvInfo.Host,
      global.ServerConfig.UserSrvInfo.Port), grpc.WithInsecure())
   if err != nil {
      zap.S().Errorw("[GetUserList] 连接【用务服务失败】", "msg", err.Error())
   }
   //调用接口

   //生成grpc的client并调用接口
   userSrvClient := proto.NewUserClient(userConn)
   //登录的逻辑
   if rsp, err := userSrvClient.GetUserByMobile(context.Background(), &proto.MobileRequest{
      Mobile: passwordLoginForm.Mobile,
   }); err != nil {
      if e, ok := status.FromError(err); ok {
         switch e.Code() {
         case codes.NotFound:
            c.JSON(http.StatusBadRequest, map[string]string{
               "mobile": "用户不存在",
            })
         default:
            c.JSON(http.StatusInternalServerError, map[string]string{
               "mobile": "登录失败",
            })
         }
         return
      }
   } else {
      //只是查询到了用户而已,并没有检查密码
      if passRsp, pasERR := userSrvClient.CheckPassWord(context.Background(), &proto.PasswordCheckInfo{
         Password:          passwordLoginForm.PassWord,
         EncryptedPassword: rsp.PassWord,
      }); pasERR != nil {
         c.JSON(http.StatusInternalServerError, map[string]string{
            "password": "登录失败",
         })
      } else {
         if passRsp.Success {
            c.JSON(http.StatusOK, map[string]string{
               "msg": "登录成功",
            })
         } else {
            c.JSON(http.StatusBadRequest, map[string]string{
               "msg": "登录失败",
            })
         }

      }
   }
}
2.4session机制在微服务下的问题

在这里插入图片描述

2.5集成jwt到gin中
2.5.1增加目录models

在models目录下增加request.go文件

package models

import (
   "github.com/dgrijalva/jwt-go"
)

type CustomClaims struct {
   ID          uint
   NickName    string
   AuthorityId uint
   jwt.StandardClaims
}
2.5.2在middlewares增加jwt.go
package middlewares

import (
   "errors"
   "github.com/dgrijalva/jwt-go"
   "github.com/gin-gonic/gin"
   "mxshop_api/user-web/global"
   "mxshop_api/user-web/models"
   "net/http"
   "time"
)

func JWTAuth() gin.HandlerFunc {
   return func(c *gin.Context) {
      // 我们这里jwt鉴权取头部信息 x-token 登录时回返回token信息 这里前端需要把token存储到cookie或者本地localSstorage中 不过需要跟后端协商过期时间 可以约定刷新令牌或者重新登录
      token := c.Request.Header.Get("x-token")
      if token == "" {
         c.JSON(http.StatusUnauthorized, map[string]string{
            "msg": "请登录",
         })
         c.Abort()
         return
      }
      j := NewJWT()
      // parseToken 解析token包含的信息
      claims, err := j.ParseToken(token)
      if err != nil {
         if err == TokenExpired {
            if err == TokenExpired {
               c.JSON(http.StatusUnauthorized, map[string]string{
                  "msg": "授权已过期",
               })
               c.Abort()
               return
            }
         }

         c.JSON(http.StatusUnauthorized, "未登陆")
         c.Abort()
         return
      }
      c.Set("claims", claims)
      c.Set("userId", claims.ID)
      c.Next()
   }
}

type JWT struct {
   SigningKey []byte
}

var (
   TokenExpired     = errors.New("token is expired")
   TokenNotValidYet = errors.New("token not active yet")
   TokenMalformed   = errors.New("that's not even a token")
   TokenInvalid     = errors.New("couldn't handle this token")
)

func NewJWT() *JWT {
   return &JWT{
      []byte(global.ServerConfig.JWTInfo.SigningKey), //可以设置过期时间
   }
}

// CreateToken 创建一个token
func (j *JWT) CreateToken(claims models.CustomClaims) (string, error) {
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
   return token.SignedString(j.SigningKey)
}

// ParseToken 解析 token
func (j *JWT) ParseToken(tokenString string) (*models.CustomClaims, error) {
   token, err := jwt.ParseWithClaims(tokenString, &models.CustomClaims{}, func(token *jwt.Token) (i interface{}, e error) {
      return j.SigningKey, nil
   })
   if err != nil {
      if ve, ok := err.(*jwt.ValidationError); ok {
         if ve.Errors&jwt.ValidationErrorMalformed != 0 {
            return nil, TokenMalformed
         } else if ve.Errors&jwt.ValidationErrorExpired != 0 {
            // Token is expired
            return nil, TokenExpired
         } else if ve.Errors&jwt.ValidationErrorNotValidYet != 0 {
            return nil, TokenNotValidYet
         } else {
            return nil, TokenInvalid
         }
      }
   }
   if token != nil {
      if claims, ok := token.Claims.(*models.CustomClaims); ok && token.Valid {
         return claims, nil
      }
      return nil, TokenInvalid

   } else {
      return nil, TokenInvalid

   }

}

// RefreshToken 更新token
func (j *JWT) RefreshToken(tokenString string) (string, error) {
   jwt.TimeFunc = func() time.Time {
      return time.Unix(0, 0)
   }
   token, err := jwt.ParseWithClaims(tokenString, &models.CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
      return j.SigningKey, nil
   })
   if err != nil {
      return "", err
   }
   if claims, ok := token.Claims.(*models.CustomClaims); ok && token.Valid {
      jwt.TimeFunc = time.Now
      claims.StandardClaims.ExpiresAt = time.Now().Add(1 * time.Hour).Unix()
      return j.CreateToken(*claims)
   }
   return "", TokenInvalid
}
2.5.3配置JWT

在config/config.go中增加JWT的配置

package config

//新增加的
type JWTConfig struct {
   SigningKey string `mapstructure:"key" json:"key"`
}

type UserSrvConfig struct {
   Host string `mapstructure:"host"`
   Port int    `mapstructure:"port"`
}

type ServerConfig struct {
   Name        string        `mapstructure:"name"`
   Port        int           `mapstructure:"port"`
   UserSrvInfo UserSrvConfig `mapstructure:"user_srv"`
   JWTInfo     JWTConfig     `mapstructure:"jwt" json:"jwt"`
}
2.5.4在yaml文件中增加配置
name: 'user-web'
port: 8021
user_srv:
  host: '127.0.0.1'
  port: 50051

// 新增加的
jwt:
  key: 'OdpJe6U9GF5*H#JUyO2qzpaz69t1dojd'
2.5.5在登录中生成token
func PassWordLogin(c *gin.Context) {
   //表单验证
   passwordLoginForm := forms.PassWordLoginForm{}
   if err := c.ShouldBindJSON(&passwordLoginForm); err != nil {
      //如何返回错误信息
      HandleValidatorError(c, err)
      return
   }

   userConn, err := grpc.Dial(fmt.Sprintf("%s:%d", global.ServerConfig.UserSrvInfo.Host,
      global.ServerConfig.UserSrvInfo.Port), grpc.WithInsecure())
   if err != nil {
      zap.S().Errorw("[GetUserList] 连接【用务服务失败】", "msg", err.Error())
   }
   //调用接口

   //生成grpc的client并调用接口
   userSrvClient := proto.NewUserClient(userConn)
   //登录的逻辑
   if rsp, err := userSrvClient.GetUserByMobile(context.Background(), &proto.MobileRequest{
      Mobile: passwordLoginForm.Mobile,
   }); err != nil {
      if e, ok := status.FromError(err); ok {
         switch e.Code() {
         case codes.NotFound:
            c.JSON(http.StatusBadRequest, map[string]string{
               "mobile": "用户不存在",
            })
         default:
            c.JSON(http.StatusInternalServerError, map[string]string{
               "mobile": "登录失败",
            })
         }
         return
      }
   } else {
      //只是查询到了用户而已,并没有检查密码
      if passRsp, pasERR := userSrvClient.CheckPassWord(context.Background(), &proto.PasswordCheckInfo{
         Password:          passwordLoginForm.PassWord,
         EncryptedPassword: rsp.PassWord,
      }); pasERR != nil {
         c.JSON(http.StatusInternalServerError, map[string]string{
            "password": "登录失败",
         })
      } else {
         if passRsp.Success {
            //生成token
            j := middlewares.NewJWT()
            claims := models.CustomClaims{
               ID:          uint(rsp.Id),
               NickName:    rsp.NickName,
               AuthorityId: uint(rsp.Role),
               StandardClaims: jwt.StandardClaims{
                  NotBefore: time.Now().Unix(),               //签名的生效时间
                  ExpiresAt: time.Now().Unix() + 60*60*14*30, //30天过期
                  Issuer:    "lisus",
               },
            }
            token, err := j.CreateToken(claims)
            if err != nil {
               c.JSON(http.StatusInternalServerError, gin.H{
                  "msg": "生成token失败",
               })
               return
            }
            c.JSON(http.StatusOK, gin.H{
               "id":         rsp.Id,
               "nick_name":  rsp.NickName,
               "token":      token,
               "expired_at": (time.Now().Unix() + 60*60*14*30) * 1000,
            })
         } else {
            c.JSON(http.StatusBadRequest, map[string]string{
               "msg": "登录失败",
            })
         }

      }
   }
}
2.6给url添加登录权限验证
2.6.1在路由上添加权限验证

在router/user.go

package router

import (
   "github.com/gin-gonic/gin"
   "mxshop_api/user-web/api"
   "mxshop_api/user-web/middlewares"
)

func InitUserRouter(Router *gin.RouterGroup) {
   UserRouter := Router.Group("user")
   {
      UserRouter.GET("list", middlewares.JWTAuth(), middlewares.IsAdminAuth(), api.GetUserList)
      UserRouter.POST("pwd_login", api.PassWordLogin)
   }
}
2.6.2在中间件middlewars中增加isadmin判断方法
package middlewares

import (
   "github.com/gin-gonic/gin"
   "mxshop_api/user-web/models"
   "net/http"
)

func IsAdminAuth() gin.HandlerFunc {
   return func(ctx *gin.Context) {
      claims, _ := ctx.Get("claims")
      currentUser := claims.(*models.CustomClaims)

      if currentUser.AuthorityId != 2 {
         ctx.JSON(http.StatusForbidden, gin.H{
            "msg": "无权限",
         })
         ctx.Abort()
         return
      } else {
         ctx.Next()
      }
   }

}
2.7如何解决前后端的跨域问题
2.7.1增加中间件cor.go
package middlewares

import (
   "github.com/gin-gonic/gin"
   "net/http"
)

func Cors() gin.HandlerFunc {
   return func(c *gin.Context) {
      method := c.Request.Method

      c.Header("Access-Control-Allow-Origin", "*")
      c.Header("Access-Control-Allow-Headers", "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token, x-token")
      c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PATCH, PUT")
      c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type")
      c.Header("Access-Control-Allow-Credentials", "true")

      if method == "OPTIONS" {
         c.AbortWithStatus(http.StatusNoContent)
      }
   }
}
2.7.2增加跨域

在initalize目录下的router.go修改

package initalize

import (
   "github.com/gin-gonic/gin"
   "mxshop_api/user-web/middlewares"
   router2 "mxshop_api/user-web/router"
)

func Routers() *gin.Engine {
   Router := gin.Default()
   //配置跨域
   Router.Use(middlewares.Cors())
   ApiGroup := Router.Group("/u/v1")
   router2.InitUserRouter(ApiGroup)

   return Router
}
2.8获取图片验证码
2.8.1增加路由

在router目录下增加base.go

package router

import (
   "github.com/gin-gonic/gin"
   "mxshop_api/user-web/api"
)

func InitBaseRouter(Router *gin.RouterGroup) {
   BaseRouter := Router.Group("base")
   {
      BaseRouter.GET("captcha", api.GetCaptcha)
   }

}
2.8.2增加API

在api目录下增加captcha.go

package api

import (
   "github.com/gin-gonic/gin"
   "github.com/mojocn/base64Captcha"
   "go.uber.org/zap"
   "net/http"
)

var store = base64Captcha.DefaultMemStore

func GetCaptcha(ctx *gin.Context) {
   driver := base64Captcha.NewDriverDigit(80, 240, 5, 0.7, 80)
   cp := base64Captcha.NewCaptcha(driver, store)
   id, b64s, err := cp.Generate()
   if err != nil {
      zap.S().Errorf("生成验证码错误,: ", err.Error())
      ctx.JSON(http.StatusInternalServerError, gin.H{
         "msg": "生成验证码错误",
      })
      return
   }
   ctx.JSON(http.StatusOK, gin.H{
      "captchaId": id,
      "picPath":   b64s,
   })
}
2.8.3登录表单修改

在forms目录下的user.go修改

package forms

type PassWordLoginForm struct {
   Mobile    string `form:"mobile" json:"mobile" binding:"required,mobile"` //手机号码格式有规范可寻, 自定义validator
   PassWord  string `form:"password" json:"password" binding:"required,min=3,max=20"`
   //增加的 
   Captcha   string `form:"captcha" json:"captcha" binding:"required,min=5,max=5"`
   CaptchaId string `form:"captcha_id" json:"captcha_id" binding:"required"`
}
2.8.4登录验证
func PassWordLogin(c *gin.Context) {
   //表单验证
   passwordLoginForm := forms.PassWordLoginForm{}
   if err := c.ShouldBindJSON(&passwordLoginForm); err != nil {
      //如何返回错误信息
      HandleValidatorError(c, err)
      return
   }
	//新增加的
   if !store.Verify(passwordLoginForm.CaptchaId, passwordLoginForm.Captcha, true) {
      c.JSON(http.StatusBadRequest, gin.H{
         "captcha": "验证码错误",
      })
      return
   }

   userConn, err := grpc.Dial(fmt.Sprintf("%s:%d", global.ServerConfig.UserSrvInfo.Host,
      global.ServerConfig.UserSrvInfo.Port), grpc.WithInsecure())
   if err != nil {
      zap.S().Errorw("[GetUserList] 连接【用务服务失败】", "msg", err.Error())
   }
   //调用接口

   //生成grpc的client并调用接口
   userSrvClient := proto.NewUserClient(userConn)
   //登录的逻辑
   if rsp, err := userSrvClient.GetUserByMobile(context.Background(), &proto.MobileRequest{
      Mobile: passwordLoginForm.Mobile,
   }); err != nil {
      if e, ok := status.FromError(err); ok {
         switch e.Code() {
         case codes.NotFound:
            c.JSON(http.StatusBadRequest, map[string]string{
               "mobile": "用户不存在",
            })
         default:
            c.JSON(http.StatusInternalServerError, map[string]string{
               "mobile": "登录失败",
            })
         }
         return
      }
   } else {
      //只是查询到了用户而已,并没有检查密码
      if passRsp, pasERR := userSrvClient.CheckPassWord(context.Background(), &proto.PasswordCheckInfo{
         Password:          passwordLoginForm.PassWord,
         EncryptedPassword: rsp.PassWord,
      }); pasERR != nil {
         c.JSON(http.StatusInternalServerError, map[string]string{
            "password": "登录失败",
         })
      } else {
         if passRsp.Success {
            //生成token
            j := middlewares.NewJWT()
            claims := models.CustomClaims{
               ID:          uint(rsp.Id),
               NickName:    rsp.NickName,
               AuthorityId: uint(rsp.Role),
               StandardClaims: jwt.StandardClaims{
                  NotBefore: time.Now().Unix(),               //签名的生效时间
                  ExpiresAt: time.Now().Unix() + 60*60*14*30, //30天过期
                  Issuer:    "lisus",
               },
            }
            token, err := j.CreateToken(claims)
            if err != nil {
               c.JSON(http.StatusInternalServerError, gin.H{
                  "msg": "生成token失败",
               })
               return
            }
            c.JSON(http.StatusOK, gin.H{
               "id":         rsp.Id,
               "nick_name":  rsp.NickName,
               "token":      token,
               "expired_at": (time.Now().Unix() + 60*60*14*30) * 1000,
            })
         } else {
            c.JSON(http.StatusBadRequest, map[string]string{
               "msg": "登录失败",
            })
         }

      }
   }
}
2.9阿里云发送短信
2.9.1增加路由
package router

import (
   "github.com/gin-gonic/gin"
   "mxshop_api/user-web/api"
)

func InitBaseRouter(Router *gin.RouterGroup) {
   BaseRouter := Router.Group("base")
   {
      BaseRouter.GET("captcha", api.GetCaptcha)
       //发送验证码,新增加的
      BaseRouter.POST("send_sms", api.SendSms)
   }

}
2.9.2增加API

在api目录增加sms.go

package api

import (
   "fmt"
   "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
   "github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi"
   "github.com/gin-gonic/gin"
   "math/rand"
   "mxshop_api/user-web/forms"
   "mxshop_api/user-web/global"
   "net/http"
   "strings"
   "time"
)

func GenerateSmsCode(witdh int) string {
   //生成width长度的短信验证码

   numeric := [10]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
   r := len(numeric)
   rand.Seed(time.Now().UnixNano())

   var sb strings.Builder
   for i := 0; i < witdh; i++ {
      fmt.Fprintf(&sb, "%d", numeric[rand.Intn(r)])
   }
   return sb.String()
}
func SendSms(ctx *gin.Context) {
   sendSmsForm := forms.SendSmsForm{}
   if err := ctx.ShouldBind(&sendSmsForm); err != nil {
      HandleValidatorError(ctx, err)
      return
   }

   client, err := dysmsapi.NewClientWithAccessKey("cn-beijing", global.ServerConfig.AliSmsInfo.ApiKey, global.ServerConfig.AliSmsInfo.ApiSecrect)
   if err != nil {
      panic(err)
   }
   smsCode := GenerateSmsCode(6)
   request := requests.NewCommonRequest()
   request.Method = "POST"
   request.Scheme = "https" // https | http
   request.Domain = "dysmsapi.aliyuncs.com"
   request.Version = "2017-05-25"
   request.ApiName = "SendSms"
   request.QueryParams["RegionId"] = "cn-beijing"
   request.QueryParams["PhoneNumbers"] = sendSmsForm.Mobile            //手机号
   request.QueryParams["SignName"] = "*********"                           //阿里云验证过的项目名 自己设置
   request.QueryParams["TemplateCode"] = "*********"               //阿里云的短信模板号 自己设置
   request.QueryParams["TemplateParam"] = "{\"code\":" + smsCode + "}" //短信模板中的验证码内容 自己生成   之前试过直接返回,但是失败,加上code成功。
   response, err := client.ProcessCommonRequest(request)
   fmt.Println("err", err)
   fmt.Println("mobile", sendSmsForm.Mobile)
   fmt.Print(client.DoAction(request, response))
   if err != nil {
      fmt.Print(err.Error())
   }
   ctx.JSON(http.StatusOK, gin.H{
      "msg": "发送成功",
   })
}
2.9.3增加配置

修改config/config.go

package config

type JWTConfig struct {
   SigningKey string `mapstructure:"key" json:"key"`
}
//新增加的
type AliSmsConfig struct {
   ApiKey     string `mapstructure:"key" json:"key"`
   ApiSecrect string `mapstructure:"secrect" json:"secrect"`
}

type UserSrvConfig struct {
   Host string `mapstructure:"host"`
   Port int    `mapstructure:"port"`
}

type ServerConfig struct {
   Name        string        `mapstructure:"name"`
   Port        int           `mapstructure:"port"`
   UserSrvInfo UserSrvConfig `mapstructure:"user_srv"`
   JWTInfo     JWTConfig     `mapstructure:"jwt" json:"jwt"`
    //新增加的
   AliSmsInfo  AliSmsConfig  `mapstructure:"sms" json:"sms"`
}
2.9.4YAML配置
name: 'user-web'
port: 8021
user_srv:
  host: '127.0.0.1'
  port: 50051

jwt:
  key: 'OdpJe6U9GF5*H#JUyO2qzpaz69t1dojd'

sms:
  key: '******************'
  secrect: '*********************'
2.10redis保存验证码
2.10.1增加config配置

在config/config.go中修改

package config

type JWTConfig struct {
   SigningKey string `mapstructure:"key" json:"key"`
}

type AliSmsConfig struct {
   ApiKey     string `mapstructure:"key" json:"key"`
   ApiSecrect string `mapstructure:"secrect" json:"secrect"`
}
// 新增加
type RedisConfig struct {
   Host   string `mapstructure:"host" json:"host"`
   Port   int    `mapstructure:"port" json:"port"`
   Expire int    `mapstructure:"expire" json:"expire"`
}

type UserSrvConfig struct {
   Host string `mapstructure:"host"`
   Port int    `mapstructure:"port"`
}

type ServerConfig struct {
   Name        string        `mapstructure:"name"`
   Port        int           `mapstructure:"port"`
   UserSrvInfo UserSrvConfig `mapstructure:"user_srv"`
   JWTInfo     JWTConfig     `mapstructure:"jwt" json:"jwt"`
   AliSmsInfo  AliSmsConfig  `mapstructure:"sms" json:"sms"`
   RedisInfo   RedisConfig   `mapstructure:"redis" json:"redis"`
}
2.10.2增加yaml配置
name: 'user-web'
port: 8021
user_srv:
  host: '127.0.0.1'
  port: 50051

jwt:
  key: 'OdpJe6U9GF5*H#JUyO2qzpaz69t1dojd'

sms:
  key: '******************'
  secrect: '*********************'

redis:
  host: '127.0.0.1'
  port: 6379
  expire: 3600
2.10.3验证码写入缓存
package api

import (
   "context"
   "fmt"
   "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
   "github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi"
   "github.com/gin-gonic/gin"
   "github.com/go-redis/redis/v8"
   "math/rand"
   "mxshop_api/user-web/forms"
   "mxshop_api/user-web/global"
   "net/http"
   "strings"
   "time"
)

func GenerateSmsCode(witdh int) string {
   //生成width长度的短信验证码

   numeric := [10]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
   r := len(numeric)
   rand.Seed(time.Now().UnixNano())

   var sb strings.Builder
   for i := 0; i < witdh; i++ {
      fmt.Fprintf(&sb, "%d", numeric[rand.Intn(r)])
   }
   return sb.String()
}
func SendSms(ctx *gin.Context) {
   sendSmsForm := forms.SendSmsForm{}
   if err := ctx.ShouldBind(&sendSmsForm); err != nil {
      HandleValidatorError(ctx, err)
      return
   }

   client, err := dysmsapi.NewClientWithAccessKey("cn-beijing", global.ServerConfig.AliSmsInfo.ApiKey, global.ServerConfig.AliSmsInfo.ApiSecrect)
   if err != nil {
      panic(err)
   }
   smsCode := GenerateSmsCode(6)
   request := requests.NewCommonRequest()
   request.Method = "POST"
   request.Scheme = "https" // https | http
   request.Domain = "dysmsapi.aliyuncs.com"
   request.Version = "2017-05-25"
   request.ApiName = "SendSms"
   request.QueryParams["RegionId"] = "cn-beijing"
   request.QueryParams["PhoneNumbers"] = sendSmsForm.Mobile            //手机号
   request.QueryParams["SignName"] = "黑马旅游网"                           //阿里云验证过的项目名 自己设置
   request.QueryParams["TemplateCode"] = "SMS_205126318"               //阿里云的短信模板号 自己设置
   request.QueryParams["TemplateParam"] = "{\"code\":" + smsCode + "}" //短信模板中的验证码内容 自己生成   之前试过直接返回,但是失败,加上code成功。
   response, err := client.ProcessCommonRequest(request)
   fmt.Print(client.DoAction(request, response))
   if err != nil {
      fmt.Print(err.Error())
   }
   //将验证码保存起来 - redis
   rdb := redis.NewClient(&redis.Options{
      Addr: fmt.Sprintf("%s:%d", global.ServerConfig.RedisInfo.Host, global.ServerConfig.RedisInfo.Port),
   })
   rdb.Set(context.Background(), sendSmsForm.Mobile, smsCode, time.Duration(global.ServerConfig.RedisInfo.Expire)*time.Second)

   ctx.JSON(http.StatusOK, gin.H{
      "msg": "发送成功",
   })
}
2.11用户注册接口
2.11.1增加路由

在router目录下的user.go修改

package router

import (
   "github.com/gin-gonic/gin"
   "mxshop_api/user-web/api"
   "mxshop_api/user-web/middlewares"
)

func InitUserRouter(Router *gin.RouterGroup) {
   UserRouter := Router.Group("user")
   {
      UserRouter.GET("list", middlewares.JWTAuth(), middlewares.IsAdminAuth(), api.GetUserList)
      UserRouter.POST("pwd_login", api.PassWordLogin)
       //注册
      UserRouter.POST("register", api.Register)
   }
}
2.11.2增加API
func Register(c *gin.Context) {
   //用户注册
   registerForm := forms.RegisterForm{}
   if err := c.ShouldBind(&registerForm); err != nil {
      HandleValidatorError(c, err)
      return
   }

   //验证码
   rdb := redis.NewClient(&redis.Options{
      Addr: fmt.Sprintf("%s:%d", global.ServerConfig.RedisInfo.Host, global.ServerConfig.RedisInfo.Port),
   })
   value, err := rdb.Get(context.Background(), registerForm.Mobile).Result()
   if err == redis.Nil {
      c.JSON(http.StatusBadRequest, gin.H{
         "code": "验证码错误",
      })
      return
   } else {
      if value != registerForm.Code {
         c.JSON(http.StatusBadRequest, gin.H{
            "code": "验证码错误",
         })
         return
      }
   }

   userConn, err := grpc.Dial(fmt.Sprintf("%s:%d", global.ServerConfig.UserSrvInfo.Host,
      global.ServerConfig.UserSrvInfo.Port), grpc.WithInsecure())
   if err != nil {
      zap.S().Errorw("[Register] 连接【用务服务失败】", "msg", err.Error())
   }
   //调用接口

   //生成grpc的client并调用接口
   userSrvClient := proto.NewUserClient(userConn)
   user, err := userSrvClient.CreateUser(context.Background(), &proto.CreateUserInfo{
      NickName: registerForm.Mobile,
      PassWord: registerForm.PassWord,
      Mobile:   registerForm.Mobile,
   })

   if err != nil {
      zap.S().Errorf("[Register] 查询 【新建用户失败】失败: %s", err.Error())
      HandleGrpcErrorToHttp(err, c)
      return
   }
   j := middlewares.NewJWT()
   claims := models.CustomClaims{
      ID:          uint(user.Id),
      NickName:    user.NickName,
      AuthorityId: uint(user.Role),
      StandardClaims: jwt.StandardClaims{
         NotBefore: time.Now().Unix(),               //签名的生效时间
         ExpiresAt: time.Now().Unix() + 60*60*24*30, //30天过期
         Issuer:    "imooc",
      },
   }
   token, err := j.CreateToken(claims)
   if err != nil {
      c.JSON(http.StatusInternalServerError, gin.H{
         "msg": "生成token失败",
      })
      return
   }

   c.JSON(http.StatusOK, gin.H{
      "id":         user.Id,
      "nick_name":  user.NickName,
      "token":      token,
      "expired_at": (time.Now().Unix() + 60*60*24*30) * 1000,
   })
}
2.11.3测试

在postman上运行,结果如下

输入的参数
{
	"mobile":"13799378765",
	"password":"123456",
	"code":"487015"
}
返回的参数
{
    "expired_at": 1699770928000,
    "id": 12,
    "nick_name": "13799378765",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6MTIsIk5pY2tOYW1lIjoiMTM3OTkzNzg3NjUiLCJBdXRob3JpdHlJZCI6MSwiZXhwIjoxNjk5NzcwOTI4LCJpc3MiOiJpbW9vYyIsIm5iZiI6MTY5NzE3ODkyOH0.C8qrThtSVgGCS8qCVq_D1svqi0OPbHPwbagnCABubC0"
}
JSON(http.StatusBadRequest, gin.H{
         "code": "验证码错误",
      })
      return
   } else {
      if value != registerForm.Code {
         c.JSON(http.StatusBadRequest, gin.H{
            "code": "验证码错误",
         })
         return
      }
   }

   userConn, err := grpc.Dial(fmt.Sprintf("%s:%d", global.ServerConfig.UserSrvInfo.Host,
      global.ServerConfig.UserSrvInfo.Port), grpc.WithInsecure())
   if err != nil {
      zap.S().Errorw("[Register] 连接【用务服务失败】", "msg", err.Error())
   }
   //调用接口

   //生成grpc的client并调用接口
   userSrvClient := proto.NewUserClient(userConn)
   user, err := userSrvClient.CreateUser(context.Background(), &proto.CreateUserInfo{
      NickName: registerForm.Mobile,
      PassWord: registerForm.PassWord,
      Mobile:   registerForm.Mobile,
   })

   if err != nil {
      zap.S().Errorf("[Register] 查询 【新建用户失败】失败: %s", err.Error())
      HandleGrpcErrorToHttp(err, c)
      return
   }
   j := middlewares.NewJWT()
   claims := models.CustomClaims{
      ID:          uint(user.Id),
      NickName:    user.NickName,
      AuthorityId: uint(user.Role),
      StandardClaims: jwt.StandardClaims{
         NotBefore: time.Now().Unix(),               //签名的生效时间
         ExpiresAt: time.Now().Unix() + 60*60*24*30, //30天过期
         Issuer:    "imooc",
      },
   }
   token, err := j.CreateToken(claims)
   if err != nil {
      c.JSON(http.StatusInternalServerError, gin.H{
         "msg": "生成token失败",
      })
      return
   }

   c.JSON(http.StatusOK, gin.H{
      "id":         user.Id,
      "nick_name":  user.NickName,
      "token":      token,
      "expired_at": (time.Now().Unix() + 60*60*24*30) * 1000,
   })
}
2.11.3测试

在postman上运行,结果如下

输入的参数
{
	"mobile":"13799378765",
	"password":"123456",
	"code":"487015"
}
返回的参数
{
    "expired_at": 1699770928000,
    "id": 12,
    "nick_name": "13799378765",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6MTIsIk5pY2tOYW1lIjoiMTM3OTkzNzg3NjUiLCJBdXRob3JpdHlJZCI6MSwiZXhwIjoxNjk5NzcwOTI4LCJpc3MiOiJpbW9vYyIsIm5iZiI6MTY5NzE3ODkyOH0.C8qrThtSVgGCS8qCVq_D1svqi0OPbHPwbagnCABubC0"
}
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值