# web-demo **Repository Path**: GuoSIC77/web-demo ## Basic Information - **Project Name**: web-demo - **Description**: 使用 gin + vue3 的web脚手架项目 - **Primary Language**: Go - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 2 - **Created**: 2025-02-25 - **Last Updated**: 2025-02-25 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # web-demo # 1 介绍 使用 gin + vue 完成用户注册登录的web脚手架项目; # 2 前端 ## 2.1 创建空项目 新建 web-demo 文件夹,在此文件夹下创建vue项目: 使用管理员权限打开cmd执行以下命令,否则会报错: ```bash npm create vite@latest # 选择选项 cd web npm install # 安装element-plus npm install element-plus --save ``` 在 main.js 中替换以下内容: ```js import { createApp } from 'vue' import ElementPlus from 'element-plus' // 整体导入 ElementPlus 组件库 import 'element-plus/dist/index.css' // 导入element-plus的css文件 // 引入主文件 import App from './App.vue' const app = createApp(App) app.use(ElementPlus) app.mount('#app') ``` ## 2.2 编写页面 ### 2.2.1 登录页 在登录页面仅居中布置用户名与密码2个必填文本框,还有注册入口; 登录成功后跳转到home页; ### 2.2.2 home页 此次仅展示登录成功后,响应的用户信息; ### 2.2.3 注册页 居中布置各种需要的注册信息; ### 2.2.4 表单校验 通过 element-plus 官网上的方法,编写数据校验; ```vue ``` ### 2.2.5 信息提示 使用 element-plus 的 ElMessage 组件,进行信息提示; ```js // 成功的信息提示 ElMessage({ message: '登录成功,2秒后自动跳转到首页.', type: 'success', duration: 2000, // 等待数据,单位毫秒 }) // 失败的提示 ElMessage.error('用户名或密码错误,请重新输入') ``` ## 2.3 处理请求 使用 axios 提交请求; ```js // 先定义登录对象 const userLoginData = reactive({ username: '', password: '', }) // 编写点击函数 const submitLoginForm = async () => { console.log("提交登录:", userLoginData) // 提交登录数据 try { // 通过axios提交post请求,并获得响应数据 const response = await axios.post('/api/user/login', userLoginData, { headers: { // 此处控制提交json类型的数据 'Content-Type': 'application/json' } }) // 判定登录结果 if (response.data.code != 0) { // 信息提示 ElMessage.error('用户名或密码错误,请重新输入') } else { router.push("/home") } } catch (error) { console.log("submit error"); } } ``` ## 2.4 跨域 vue在使用axios提交请求时,会触发浏览器的有关机制,需要配置跨域才能访问后端; 一般的跨域方式有:前端代理、jsonp请求、后端服务,这里选择配置vue代理; 在 vue 项目根目录下打开 vite.config.js 文件,在 default 中添加以下代码: ```js ,server: { proxy: { // 将外部的后端地址代理到内部的 /api 上 '/api': { // 代理地址 target: 'http://127.0.0.1:58086', // 后端服务地址 changeOrigin: true, // 是否跨域 // 重写匹配的字段,将会把 /api 变成空字符,即将 /api/login 变成 /login rewrite: path => path.replace('/api', '') } } } ``` ## 2.5 路由 使用 vue-router 进行路由管理; 在 /src/router/index.js 中配置路由表; ```js import { createRouter, createWebHistory } from "vue-router"; // 配置路由表 const routes = [ { path: '/', alias: "/login", name: 'Login', component: () => import('@/views/login.vue'), }, { path: '/register', name: 'Register', component: () => import('@/views/register.vue'), }, { path: '/home', name: 'Home', component: () => import('@/views/home.vue'), }, { path: '/form', component: () => import('@/views/form.vue'), }, ] // 配置路由器 const router = createRouter({ history: createWebHistory(), // 传统模式,在端口后直接加路由 routes // 路由表 }) // 导出路由器 export default router ``` 使用: ```js import { useRouter } from 'vue-router'; // 获得路由对象 const router = useRouter() // 跳转页面 router.push("/home") // 延时2秒跳转 setTimeout(() => { router.push("/home") }, 2000); // 单位毫秒 ``` # 3 后端 ## 3.1 目录结构 | 文件夹 | 说明 | 描述 | | ---------- | ---------- | ------------------------- | | api | api层 | 负责处理请求与响应 | | config | 配置包 | 与config.yaml对应的结构体 | | core | 核心文件 | 核心组件的初始化 | | global | 全局变量 | 保存全局对象 | | initialize | 初始化组件 | 各种组件的初始化 | | log | 日志 | 保存日志的目录 | | middleware | 中间件 | 保存gin的中间件 | | model | 实体层 | 实体结构体对应数据表 | | router | 路由层 | 定义后端路由 | | service | service层 | 处理具体业务 | | utils | 工具包 | 各种工具函数 | ## 3.2 加载配置 使用viper加载配置文件,并公开到全局变量; 先定义yaml配置文件,以mysql配置为例: ```yaml mysql: path: '127.0.0.1' port: '3306' dbname: 'web_demo' username: 'root' password: '123456' config: 'charset=utf8mb4&parseTime=True&loc=Local' ``` 先定义mysql配置对象的结构体: ```go // Mysql MysqlConfig mysql配置 type Mysql struct { Path string `mapstructure:"path"` Port string `mapstructure:"port"` DbName string `mapstructure:"dbname"` UserName string `mapstructure:"username"` Password string `mapstructure:"password"` Config string `mapstructure:"config"` } ``` 然后加载到配置文件总结构体中: ```go // ServerConfig 总配置结构体 type ServerConfig struct { *System `mapstructure:"system"` *Mysql `mapstructure:"mysql"` *Zap `mapstructure:"zap"` *Jwt `mapstructure:"jwt"` } ``` 再初始化viper,并将读取的配置对象添加到全局变量中: ```go func Viper() *viper.Viper { vi := viper.New() // 设置配置文件,地址相对项目根目录 vi.SetConfigFile("./config.yaml") // 读取配置文件 if err := vi.ReadInConfig(); err != nil { fmt.Println("read config Error!", err) } // 将配置文件写入全局结构体 if err := vi.Unmarshal(&global.WEB_CONFIG); err != nil { fmt.Println("read config to mod Error!", err) } return vi } ``` ## 3.3 加载日志 使用zap日志系统,整合入gin框架; 开发阶段,日志还需要打印到操作台,更好的调试; ```go // Zap zap配置结构体 type Zap struct { Mode string `mapstructure:"mode"` // 日志模式 Level string `mapstructure:"level"` // 级别 Filename string `mapstructure:"filename"` // 日志前缀 Director string `mapstructure:"director"` // 日志文件夹 MaxSize int `mapstructure:"maxsize"` // 日志文件的最大大小 MaxAge int `mapstructure:"max_age"` // 保留旧文件的最大天数 MaxBackups int `mapstructure:"max_backups"` // 保留旧文件的最大个数 } // InitLogger 初始化Logger,config.Zap为日志配置对象 func InitLogger(cfg *config.Zap) (err error) { // 加载配置文件 writeSyncer := getLogWriter(cfg.Filename, cfg.Director, cfg.MaxSize, cfg.MaxBackups, cfg.MaxAge) encoder := getEncoder() // 加载编码 var l = new(zapcore.Level) // 获得日志级别 err = l.UnmarshalText([]byte(cfg.Level)) // 格式化级别类型 if err != nil { return } // 选择日志打印模式 var core zapcore.Core if cfg.Mode == "dev" { // 进入开发模式,日志输出到终端 consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) core = zapcore.NewTee( zapcore.NewCore(encoder, writeSyncer, l), zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), zapcore.DebugLevel), ) } else { core = zapcore.NewCore(encoder, writeSyncer, l) } global.WEB_LOG = zap.New(core, zap.AddCaller()) // 打印时包括调用者 zap.ReplaceGlobals(global.WEB_LOG) // 替换zap包中全局的logger实例,后续在其他包中只需使用zap.L()调用即可 return } // 设置编码格式 func getEncoder() zapcore.Encoder { // 获得编码对象 encoderConfig := zap.NewProductionEncoderConfig() // 设置时间格式 encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder encoderConfig.TimeKey = "time" encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder encoderConfig.EncodeDuration = zapcore.SecondsDurationEncoder encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder return zapcore.NewJSONEncoder(encoderConfig) } // 设置日志路径 func getLogWriter(filename, dir string, maxSize, maxBackup, maxAge int) zapcore.WriteSyncer { return zapcore.AddSync(&lumberjack.Logger{ Filename: "./" + dir + "/" + filename, MaxSize: maxSize, MaxBackups: maxBackup, MaxAge: maxAge, Compress: false, }) } ``` 编写日志中间件: ```go // GinLogger 接收gin框架默认的日志 func GinLogger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path query := c.Request.URL.RawQuery c.Next() cost := time.Since(start) zap.L().Info(path, zap.Int("status", c.Writer.Status()), zap.String("method", c.Request.Method), zap.String("path", path), zap.String("query", query), zap.String("ip", c.ClientIP()), zap.String("user-agent", c.Request.UserAgent()), zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()), zap.Duration("cost", cost), ) } } // GinRecovery recover掉项目可能出现的panic,并使用zap记录相关日志 func GinRecovery(stack bool) gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { // Check for a broken connection, as it is not really a // condition that warrants a panic stack trace. var brokenPipe bool if ne, ok := err.(*net.OpError); ok { if se, ok := ne.Err.(*os.SyscallError); ok { if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") { brokenPipe = true } } } httpRequest, _ := httputil.DumpRequest(c.Request, false) if brokenPipe { zap.L().Error(c.Request.URL.Path, zap.Any("error", err), zap.String("request", string(httpRequest)), ) // If the connection is dead, we can't write a status to it. c.Error(err.(error)) // nolint: errcheck c.Abort() return } if stack { zap.L().Error("[Recovery from panic]", zap.Any("error", err), zap.String("request", string(httpRequest)), zap.String("stack", string(debug.Stack())), ) } else { zap.L().Error("[Recovery from panic]", zap.Any("error", err), zap.String("request", string(httpRequest)), ) } c.AbortWithStatus(http.StatusInternalServerError) } }() c.Next() } } ``` 将中间件添加到gin框架中,即在创建gin服务时加入即可: ```go rout := gin.New() // 使用 zap 日志系统 rout.Use(middleware.GinLogger(), middleware.GinRecovery(true)) ``` ## 3.4 工具 ### 3.4.1 生成id **1 简述**: 用户 id 不使用数据库自增,而使用 **分布式id生成器** 生成,可以防止分库后出现id重复,同时也要保证id具有时间递增性; 雪花算法(sonyflake)是 sony 开源的由 64 位整数组成的分布式id,基于 Twitter 的 snowflake 改进而来,是原生go语言的算法; 其**生成的密码由4部分组成**:第一位始终为0,39bit的时间戳(约174年),8bit的机器id(5位数据中心,5位节点),16bit的序列号(每毫秒最多4095个); 使用雪花算法理论上同一毫秒可以生成 `1024 * 4096 = 4194304` 个全局id; **2 实现**: 仓库:[GitHub - sony/sonyflake: A distributed unique ID generator inspired by Twitter's Snowflake](https://github.com/sony/sonyflake); ```go import "github.com/sony/sonyflake" var ( sonyFlake *sonyflake.Sonyflake // 创建雪花对象 sonyMachineID uint16 // 创建机器序号 ) // 获得机器序号 func getMachineID() (uint16, error) { return sonyMachineID, nil } // 初始化雪花算法,需传入启动时间与当前的机器ID func Init(startTime string, machineId uint16) (err error) { sonyMachineID = machineId t, _ := time.Parse("2006-01-02", startTime) settings := sonyflake.Settings{ StartTime: t, MachineID: getMachineID, // 此处需要传入函数 } sonyFlake = sonyflake.NewSonyflake(settings) return } // GetID 返回生成的id值 func GetID() (id uint64, err error) { if sonyFlake == nil { err = fmt.Errorf("snoy flake not inited") return } id, err = sonyFlake.NextID() return } ``` ### 3.4.2 加密 有2种加密方式:md5、bcrypt; 其中 bcrypt 方式保密性更高; ```go package utils import ( "crypto/md5" "encoding/hex" "golang.org/x/crypto/bcrypt" ) // MD5Encrypt MD5加密 func MD5Encrypt(pass string) string { h := md5.New() h.Write([]byte("hdwdhdw.cn")) // 加盐 h.Write([]byte(pass)) // 加密本体 // 加密,Sum的参数为前缀 return hex.EncodeToString(h.Sum(nil)) } // BcryptEncrypt 使用 bcrypt 算法加密密码 func BcryptEncrypt(pass string) string { hb, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) return string(hb) } // BcryptCompare 比较 bcrypt 加密值 // @return: 相同为true,不同为false func BcryptCompare(pass, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) return err == nil } ``` ### 3.4.3 jwt **token 鉴权模式**:服务端不存session,服务端认证后生成 token 给客户端,然后客户端每次请求都带上 token,服务端对 token 进行鉴权; 因为token是要由后端响应到客户端,所以在 `/model/request/` 中创建对应的 jwt 结构; ```go // CustomClaims 自定义JWT要求,内容为用户信息+jwt负载 type CustomClaims struct { // 自添加token字段 UID uint64 `json:"uid"` jwt.RegisteredClaims // 内嵌的声明 } ``` 然后就需要 生成token与解析token 的函数,在 /utils/ 中创建jwt文件,注意加盐时要传入 `[]byte` 格式的字符串; ```go // CreateToken 创建 JWT func CreateToken(uid uint64) (string, error) { // 创建自定义 claims claims := request.CustomClaims{ UID: uid, RegisteredClaims: jwt.RegisteredClaims{ // 设置超时 ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(global.WEB_CONFIG.Jwt.ExpiresTime))), Issuer: global.WEB_CONFIG.Jwt.Issuer, // 设置发布人 }, } fmt.Println("claims:", claims) // 创建token对象 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) // 将token字符串化后返回 return token.SignedString([]byte(global.WEB_CONFIG.Jwt.Secret)) } // ParseToken 解析 JWT func ParseToken(tokenString string) (*request.CustomClaims, error) { // 将字符串按自定义的 claims 格式解析为token对象 token, err := jwt.ParseWithClaims(tokenString, &request.CustomClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(global.WEB_CONFIG.Jwt.Secret), nil }) // 判定解析成功与否 if err != nil { return nil, err } // 解析成功,获得 claims 对象m,并校验token if claims, ok := token.Claims.(*request.CustomClaims); ok && token.Valid { return claims, nil } return nil, errors.New("invalid token") } ``` 接下来将jwt的功能整合入 gin 中间件,此处jwt超时与过期操作并未完善,需要之后添加; ```go // JWTAuth JWT中间件 func JWTAuth() gin.HandlerFunc { return func(ctx *gin.Context) { // 获得Token token := utils.GetToken(ctx) // 判定token合法性 if token == "" { response.NoAuth("未登录或非法访问", ctx) ctx.Abort() // 此方法会停止执行本中间件后的函数 return } // 解析token claims, err := utils.ParseToken(token) // 判定解析是否成功 if err != nil { response.NoAuth(err.Error(), ctx) ctx.Abort() // 此方法会停止执行本中间件后的函数 return } // 保存到gin的上下文ctx中 ctx.Set("claims", claims) // 执行后续函数,后续函数可以使用c.Get("claims")获得用户信息 ctx.Next() } } ``` 然后将此中间件添加入gin路由中,这里添加中间件仅需要在路由组中额外加入使用组即可; ```go // BaseRouter 登录路由 func (r *UserRouter) BaseRouter(engine *gin.Engine) { // 用户路由组,为 /user/login userGroup := engine.Group("/user") { // 用户登录 userGroup.POST("/login", userAPi.LoginApi) // 用户注册 userGroup.POST("/register", userAPi.RegisterApi) // 需要jwt鉴权的路由集合 userGroup.Use(middleware.JWTAuth()) { // 修改密码 userGroup.POST("/change-pass", userAPi.ChangePassApi) // 修改用户信息 userGroup.POST("/change-info", userAPi.ChangeInfoApi) } } } ``` 另外,还需要加载 jwt 相关配置; ```yaml jwt: secret: 'hdwdhdw' expires_time: 24 issuer: 'hdw' ``` ```go type Jwt struct { Secret string `mapstructure:"secret"` // 签名加盐 ExpiresTime int `mapstructure:"expires_time"` // 过期时间,小时 Issuer string `mapstructure:"issuer"` // 签发人 } ``` ## 3.5 用户业务 ### 3.5.1 业务逻辑 在此项目中,在包下创建enter文件,用于跨包调用; 在enter中创建结构体,在结构体中公布包下的对象,防止在各个文件中引用导入其它包; 数据流: ```mermaid graph LR router路由 --> api校验和获得数据并响应请求 --> service处理数据 ``` 数据结构: ```go // User 用户表 type User struct { Uid uint64 `form:"uid" json:"uid" gorm:"default:"` Username string `form:"username" json:"username" gorm:"default:"` Password string `form:"password" json:"password" gorm:"default:"` Gender string `form:"gender" json:"gender" gorm:"default:"` Phone string `form:"phone" json:"phone" gorm:"default:"` Email string `form:"email" json:"email" gorm:"default:"` CreateTime time.Time `form:"create_time" json:"create_time" gorm:"default:"` UpdateTime time.Time `form:"update_time" json:"update_time" gorm:"default:"` } ``` 表结构: 在建表时有以下注意事项: 1. 要保留自增id的同时增加额外定义的 user-id ; 2. 要有此记录的创建时间与更新时间,使用数据库自带的时间函数即可,更新时间也可直接写入; 3. 用户名、用户id 要是唯一键,此处默认后端没有施加约束; ```sql # 创建用户表 DROP TABLE IF EXISTS `users`; CREATE TABLE `users` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `uid` bigint(20) NOT NULL, `username` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, `password` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, `email` varchar(64) DEFAULT NULL, `phone` varchar(14) DEFAULT NULL, `gender` tinyint(4) DEFAULT '1', `create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `idx_username` (`username`) USING BTREE, UNIQUE KEY `idx_user_id` (`uid`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ``` ### 3.4.2 用户注册 路由:/register 流程:校验数据、生成uid、密码加密、写入数据库; **不管何时,默认前端没有进行数据校验,要完整的进行数据合法性校验**。 ### 3.4.3 用户登录 路由:/login ### 3.4.4 修改密码 路由:/change-pass 更新时因为模型中未定义主键,需要加上where; ```go db.Model(&frontData).Where("uid = ?", frontData.Uid).Update("password", frontData.Password) ``` ### 3.4.5 修改信息 路由:/change-info # 4 参考 [gin-vue-admin | GVA 文档站](https://www.gin-vue-admin.com/); [李文周的博客 | 总结Go语言学习之路,提供免费的Go语言学习教程,希望与大家一起学习进步。 (liwenzhou.com)](https://www.liwenzhou.com/);