# 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/);