一个网页AI聊天,图像识别项目,使用到的技术栈有,Gin框架,GORM,rabbitmq,redis,eino框架,Vue框架等
项目地址:https://github.com/youngyangyang04/GopherAI
本项目能大体分成三个模块:
- 用户模块,负责用户的注册,登录等
- AI聊天模块,创建和管理AI会话,调用大模型API或本地模型
- 图像识别模块,使用本地模型推理图像识别
现在从各个架构层次,自顶向下地介绍用户模块的实现
路由层
首先在router/touter.go中,在创建了gin.Engine之后创建了/user路由组
// 初始化URL路由规则
func InitRouter() *gin.Engine {
r := gin.Default()
enterRouter := r.Group("/api/v1") // 创建路由组
{
RegisterUserRouter(enterRouter.Group("/user"))
}
...
}
一共有三个API,分别是登录,注册和验证码
// 为接口路径设置handler函数
func RegisterUserRouter(r *gin.RouterGroup) {
{
r.POST("/register", user.Register) // 注册
r.POST("/login", user.Login) // 登录
r.POST("/captcha", user.HandleCaptcha) // 验证码
}
}
控制器层
下面来看控制器层,在用户模块中控制器层主要包含将三个API接口绑定的三个handler函数,用于获取参数并调用服务层方法
同时在controller/common.go中,还定义了统一的响应结构体(状态码+状态信息),状态码定义于common/code.go
package code
// 常量Code定义状态码(int64)
type Code int64
const (
CodeSuccess Code = 1000
CodeInvalidParams Code = 2001
CodeUserExist Code = 2002
CodeUserNotExist Code = 2003
CodeInvalidPassword Code = 2004
CodeNotMatchPassword Code = 2005
CodeInvalidToken Code = 2006
CodeNotLogin Code = 2007
CodeInvalidCaptcha Code = 2008
CodeRecordNotFound Code = 2009
CodeIllegalPassword Code = 2010
CodeForbidden Code = 3001
CodeServerBusy Code = 4001
AIModelNotFind Code = 5001
AIModelCannotOpen Code = 5002
AIModelFail Code = 5003
)
// Code和message的映射
var msg = map[Code]string{
CodeSuccess: "success",
CodeInvalidParams: "请求参数错误",
CodeUserExist: "用户名已存在",
CodeUserNotExist: "用户不存在",
CodeInvalidPassword: "用户名或密码错误",
CodeNotMatchPassword: "两次密码不一致",
CodeInvalidToken: "无效的Token",
CodeNotLogin: "用户未登录",
CodeInvalidCaptcha: "验证码错误",
CodeRecordNotFound: "记录不存在",
CodeIllegalPassword: "密码不合法",
CodeForbidden: "权限不足",
CodeServerBusy: "服务繁忙",
AIModelNotFind: "模型不存在",
AIModelCannotOpen: "无法打开模型",
AIModelFail: "模型运行失败",
}
// 将Code转为int64
func (code Code) Code() int64 {
return int64(code)
}
// Msg 获取响应消息
func (code Code) Msg() string {
if m, ok := msg[code]; ok {
return m
}
// 获取映射失败,返回服务器繁忙
return msg[CodeServerBusy]
}
package controller
import "GopherAI/common/code"
// 统一的响应结构
type Response struct {
StatusCode code.Code `json:"status_code"`
StatusMsg string `json:"status_msg,omitempty"`
}
// 设置状态码和消息
func (r *Response) CodeOf(code code.Code) Response {
if nil == r {
r = new(Response)
}
r.StatusCode = code
r.StatusMsg = code.Msg()
return *r
}
// 设置为成功状态
func (r *Response) Success() {
r.CodeOf(code.CodeSuccess)
}
定义了统一的响应格式之后,在controller/user/user.go中实现了三个handler函数
在这之前,当然还是先定义了接口的请求/响应JSON结构体
type (
// 登录请求结构体封装
// 这里的Username只能是账号登录,和我做的另一个项目有区别(邮箱账号均可)
LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// 登录响应结构体封装
// omitempty当字段为空的时候,不返回这个东西
LoginResponse struct {
controller.Response
Token string `json:"token,omitempty"` // omitempty表示如果没有这项就直接不解析出来
}
// 注册请求结构体封装
// 验证码由后端生成,存放到redis中,固然需要先发送一次请求CaptchaRequest,然后用返回的验证码
// 邮箱以及密码进行注册,后续再将账号进行返回
RegisterRequest struct {
Email string `json:"email" binding:"required"` // required表示让Gin自动校验不能为空
Captcha string `json:"captcha"`
Password string `json:"password"`
}
// 注册响应结构体封装
// 注册成功之后,直接让其进行登录状态
RegisterResponse struct {
controller.Response
Token string `json:"token,omitempty"`
}
// 验证码请求结构体封装
CaptchaRequest struct {
Email string `json:"email" binding:"required"`
}
// 验证码响应结构体封装
CaptchaResponse struct {
controller.Response
}
)
先来看/login接口的handler函数,流程是先从响应中获取参数,随后调用service层的Login函数,执行真正的登录逻辑,完成后构造响应并返回
// /login接口的handler函数
func Login(c *gin.Context) {
// 定义请求与响应结构体
req := new(LoginRequest)
res := new(LoginResponse)
// 绑定和验证参数
if err := c.ShouldBindJSON(req); err != nil {
c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
return
}
// 调用service层函数Login
token, code_ := user.Login(req.Username, req.Password)
// 构造响应
if code_ != code.CodeSuccess {
c.JSON(http.StatusOK, res.CodeOf(code_))
return
}
res.Success()
res.Token = token
// 返回响应
c.JSON(http.StatusOK, res)
}
Register和Captcha函数也是类似的流程
// /register接口的handler函数
func Register(c *gin.Context) {
// 定义请求与响应结构体
req := new(RegisterRequest)
res := new(RegisterResponse)
// 绑定和验证参数
if err := c.ShouldBindJSON(req); err != nil {
c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
return
}
// 调用service层函数Register
token, code_ := user.Register(req.Email, req.Password, req.Captcha)
// 构造响应
if code_ != code.CodeSuccess {
c.JSON(http.StatusOK, res.CodeOf(code_))
return
}
res.Success()
res.Token = token
// 返回响应
c.JSON(http.StatusOK, res)
}
// /captcha接口的handler函数
func HandleCaptcha(c *gin.Context) {
// 构造请求和响应结构体
req := new(CaptchaRequest)
res := new(CaptchaResponse)
// 解析参数
if err := c.ShouldBindJSON(req); err != nil {
c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
return
}
// 给service层进行处理,给邮箱发送验证码
code_ := user.SendCaptcha(req.Email)
if code_ != code.CodeSuccess {
c.JSON(http.StatusOK, res.CodeOf(code_))
return
}
// 返回响应
// 匿名字段,其实本身res.Success()调用就是res.Response.Success()
// 即res.Response.Success()
res.Success()
c.JSON(http.StatusOK, res)
}
服务层
控制器层之后是服务层,执行真正的业务逻辑,用户模块的service层主要包含三个函数,也就是登录,注册和发送验证码
用户模块还依赖了MySQL,Redis,JWT,SMTP,MD5加密等服务或功能
先来看登录是如何实现的,首先会调用数据库查询功能,查询用户名是否存在,之后再对比MD5加密后的密码是否和数据库中的一致,然后便可认证用户,生成并返回一个token(该简易登录系统采用了无状态的JWT,token本身包含用户信息,服务端不储存token)
// 尝试登录用户,返回token和状态码
func Login(username, password string) (string, code.Code) {
var userInformation *model.User
var ok bool
// 1:判断用户是否存在
if ok, userInformation = user.IsExistUser(username); !ok {
return "", code.CodeUserNotExist
}
// 2:判断用户是否密码账号正确
if userInformation.Password != utils.MD5(password) {
return "", code.CodeInvalidPassword
}
// 3:返回一个Token
token, err := myjwt.GenerateToken(userInformation.ID, userInformation.Username)
if err != nil {
return "", code.CodeServerBusy
}
return token, code.CodeSuccess
}
随后再来看注册功能的实现,和登录功能一样,会先判断用户是否存在(这里有bug,判断的时候传入的是邮箱,但是数据库存的是11位随机用户名),然后调用redis检查前端发来的验证码是否正确,一切检查通过后再生成11位随机数字用户名,插入数据库,发送邮件告诉用户,最后再返回token
// 尝试注册用户,返回token和状态码
func Register(email, password, captcha string) (string, code.Code) {
var ok bool
var userInformation *model.User
// 1:先判断用户是否已经存在了
if ok, _ := user.IsExistUser(email); ok {
return "", code.CodeUserExist
}
// 2:从redis中验证验证码是否有效
if ok, _ := myredis.CheckCaptchaForEmail(email, captcha); !ok {
return "", code.CodeInvalidCaptcha
}
// 3:生成11位的账号
username := utils.GetRandomNumbers(11)
// 4:注册到数据库中
if userInformation, ok = user.Register(username, email, password); !ok {
return "", code.CodeServerBusy
}
// 5:将账号一并发送到对应邮箱上去,后续需要账号登录
if err := myemail.SendCaptcha(email, username, user.UserNameMsg); err != nil {
return "", code.CodeServerBusy
}
// 6:生成Token
token, err := myjwt.GenerateToken(userInformation.ID, userInformation.Username)
if err != nil {
return "", code.CodeServerBusy
}
return token, code.CodeSuccess
}
最后再来看发送验证码功能的实现,验证码是随机的6位数字,会先存放到redis中,再通过给邮箱发给用户
// 往指定邮箱发送验证码
// 分为以下任务:
// 1:先存放redis
// 2:再进行远程发送
func SendCaptcha(email_ string) code.Code {
send_code := utils.GetRandomNumbers(6)
// 1:先存放到redis
if err := myredis.SetCaptchaForEmail(email_, send_code); err != nil {
return code.CodeServerBusy
}
// 2:再进行远程发送
if err := myemail.SendCaptcha(email_, send_code, myemail.CodeMsg); err != nil {
return code.CodeServerBusy
}
return code.CodeSuccess
}
数据访问层
DAO层里实现了两个方法:判断用户是否存在和将用户插入数据库,使用gorm实现
判断用户是否存在功能会在数据库中查找username为传入字符串的数据项
// 返回用户名是否存在
// 这边只能通过账号进行登录
func IsExistUser(username string) (bool, *model.User) {
user, err := mysql.GetUserByUsername(username)
if err == gorm.ErrRecordNotFound || user == nil {
return false, nil
}
return true, user
}
注册用户到数据库则会将用户信息封装为User数据模型,插入数据库中
// 注册用户到数据库
func Register(username, email, password string) (*model.User, bool) {
// 将用户插入数据库
if user, err := mysql.InsertUser(&model.User{
Email: email,
Name: username,
Username: username,
Password: utils.MD5(password), // 密码进行MD5加密
}); err != nil {
return nil, false
} else {
return user, true
}
}
数据库操作
在model/user.go中定义了用户的数据模型,对应MySQL中的User表,使用ID作为主键,用于唯一标识用户
// 用户结构体(数据模型)
type User struct {
ID int64 `gorm:"primaryKey" json:"id"` // 用户ID
Name string `gorm:"type:varchar(50)" json:"name"` // 用户Name
Email string `gorm:"type:varchar(100);index" json:"email"` // 用户邮箱
Username string `gorm:"type:varchar(50);uniqueIndex" json:"username"` // 用户名(唯一索引)
Password string `gorm:"type:varchar(255)" json:"-"` // 密码(不返回给前端)
CreatedAt time.Time `json:"created_at"` // 创建时间按(自动时间戳)
UpdatedAt time.Time `json:"updated_at"` // 更新时间
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 支持软删除
}
在common/mysql/mysql.go中,写了数据库的初始化以及用户相关的数据库操作函数
先来看数据库初始化函数,使用了一个全局的数据库实例*grom.DB,函数回先从配置文件中获取配置项,包含MySQL的主机,端口,数据库名称,MySQL用户名,密码以及使用的字符编码
随后设置好gorm的日志器,连接数据库
配置连接池,最后使用gorm的自动迁移功能,创建user,session,message的表
// 全局数据库实例
var DB *gorm.DB
// 初始化数据库
func InitMysql() error {
// 获取配置项
host := config.GetConfig().MysqlHost
port := config.GetConfig().MysqlPort
dbname := config.GetConfig().MysqlDatabaseName
username := config.GetConfig().MysqlUser
password := config.GetConfig().MysqlPassword
charset := config.GetConfig().MysqlCharset
// 构建连接数据库字符串
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=Local", username, password, host, port, dbname, charset)
// 设置日志
var log logger.Interface
if gin.Mode() == "debug" {
log = logger.Default.LogMode(logger.Info)
} else {
log = logger.Default
}
// 连接数据库
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: dsn,
DefaultStringSize: 256,
DisableDatetimePrecision: true,
DontSupportRenameIndex: true,
DontSupportRenameColumn: true,
SkipInitializeWithVersion: false,
}), &gorm.Config{
Logger: log,
})
if err != nil {
return err
}
// 获取底层DB实例
sqlDB, err := db.DB()
if err != nil {
return err
}
sqlDB.SetMaxIdleConns(10) // 最大空闲连接数10
sqlDB.SetMaxOpenConns(100) // 最大打开连接数100
sqlDB.SetConnMaxLifetime(time.Hour) // 连接存活最长时间
DB = db
// 迁移,创建表
return migration()
}
// 迁移
func migration() error {
// 自动根据传入的数据模型创建或更新表
return DB.AutoMigrate(
new(model.User),
new(model.Session),
new(model.Message),
)
}
插入用户函数
// 插入用户
func InsertUser(user *model.User) (*model.User, error) {
err := DB.Create(&user).Error
return user, err
}
获取用户函数
// 使用用户名获取用户
func GetUserByUsername(username string) (*model.User, error) {
user := new(model.User)
err := DB.Where("username = ?", username).First(user).Error
return user, err
}
JWT中间件
在utils/myjwt/jwt.go中实现了生成和解析用户token的功能
首先是自定义的Claims结构体,包含了jwt的标准字段
// 包含了jwt标准字段(jwt.RegisteredClaims)的自定义claims
type Claims struct {
ID int64 `json:"id"` // 用户id
Username string `json:"username"` // 用户名
jwt.RegisteredClaims
}
然后来看为用户生成token的函数,构建Claims结构体,使用HS256算法签名,最后返回最终jwt字符串
// 为用户生成token
func GenerateToken(id int64, username string) (string, error) {
claims := Claims{
ID: id, // 用户ID
Username: username, // 用户名
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(config.GetConfig().ExpireDuration) * time.Hour)), // 过期时间(从配置中加载)
Issuer: config.GetConfig().Issuer, // 签发者(从配置中加载)
Subject: config.GetConfig().Subject, // 主题(从配置中加载)
IssuedAt: jwt.NewNumericDate(time.Now()), // 签发时间(从配置中加载)
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) // 生成token
return token.SignedString([]byte(config.GetConfig().Key)) // 返回用密钥签名产生的最终字符串
}
最后来看解析token的函数,逆向解析出token字符串包含的Claims结构体,并校验合法性,最后返回username
// 解析Token
func ParseToken(token string) (string, bool) {
claims := new(Claims)
// 解析
t, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(config.GetConfig().Key), nil
})
// 校验合法性
if !t.Valid || err != nil || claims == nil {
return "", false
}
return claims.Username, true // 解析成功,返回用户名
}
以上是底层的jwt操作,现在来看中间件的逻辑,在middleware/jwt/jwt.go中实现了Auth()函数,会返回一个函数签名为gin.HandlerFunc的函数,用于jwt鉴权
before逻辑是从请求头或者URL中拿到token并解析,获取其中包含的用户名信息,将用户信息写入gin.Context中,随后执行c.Next()
after逻辑这里没写
func Auth() gin.HandlerFunc {
// 返回一个gin.HandleerFunc(中间件函数)
return func(c *gin.Context) {
// 中间件before逻辑
res := new(controller.Response)
// 从请求头Authorization里获取token(前缀为Bearer)
var token string
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
} else {
// 兼容 URL 参数传 token(?token=xxx)
token = c.Query("token")
}
// 如果token为空直接返回错误code并终止请求,handler不会被执行
if token == "" {
c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidToken))
c.Abort()
return
}
// 解析token,返回用户名
userName, ok := myjwt.ParseToken(token)
if !ok {
c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidToken))
c.Abort()
return
}
c.Set("userName", userName) // 把用户信息写入上下文
c.Next() // 中间件调用c.Next(),允许请求执行handler
// 中间件after逻辑(这里没有)
}
}
redis的使用
使用redis来存储邮箱验证码,使用”captcha:email”为key,验证码为value的格式存储
在commom/redis/key.go中实现了key的格式
// 获取邮箱key(由于redis是全局KV,故key需要前缀区分服务)
// key:特定邮箱-> 验证码
func GenerateCaptcha(email string) string {
return fmt.Sprintf(config.DefaultRedisKeyConfig.CaptchaPrefix, email)
}
在commom/redis/redis.go中实现了初始化redis,存验证码,校验验证码的功能
先来看redis的初始化函数,先从配置文件中加载配置项的值,然后调用redis.NewClient连接redis
var Rdb *redis.Client // 全局redis客户端
var ctx = context.Background() // 上下文
// 初始化redis连接
func Init() {
conf := config.GetConfig()
host := conf.RedisConfig.RedisHost
port := conf.RedisConfig.RedisPort
password := conf.RedisConfig.RedisPassword
db := conf.RedisDb
addr := host + ":" + strconv.Itoa(port)
Rdb = redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
}
再来看存邮箱验证码的函数(两分钟后超时删除)
// 存验证码(key:email val:captcha TTL:2min)
func SetCaptchaForEmail(email, captcha string) error {
key := GenerateCaptcha(email)
expire := 2 * time.Minute
return Rdb.Set(ctx, key, captcha, expire).Err()
}
最后来看校验验证码的函数
// 校验验证码
func CheckCaptchaForEmail(email, userInput string) (bool, error) {
// 获取邮箱对应的key
key := GenerateCaptcha(email)
// 从redis读
storedCaptcha, err := Rdb.Get(ctx, key).Result()
if err != nil {
if err == redis.Nil {
return false, nil
}
return false, err
}
// 比较验证码(大小写不敏感)
if strings.EqualFold(storedCaptcha, userInput) {
// 验证成功后删除 key
if err := Rdb.Del(ctx, key).Err(); err != nil {
} else {
}
return true, nil
}
return false, nil
}
SMTP邮件服务
在common/email/email.go中实现了发送邮件的功能,使用goamil,邮箱用了自己的QQ邮箱
const (
CodeMsg = "GopherAI验证码如下(验证码仅限于2分钟有效): "
UserNameMsg = "GopherAI的账号如下,请保留好,后续可以用账号/邮箱登录 "
)
// 发送验证码
func SendCaptcha(email, code, msg string) error {
m := gomail.NewMessage()
// 发件人
m.SetHeader("From", config.GetConfig().EmailConfig.Email)
// 收件人
m.SetHeader("To", email)
// 主题
m.SetHeader("Subject", "来自GopherAI的信息")
// 正文内容(纯文本形式,也可以用 text/html)
m.SetBody("text/plain", msg+" "+code)
// 配置 SMTP 服务器和授权码,587:是 SMTP 的明文/STARTTLS 端口号
d := gomail.NewDialer("smtp.qq.com", 587, config.GetConfig().EmailConfig.Email, config.GetConfig().EmailConfig.Authcode)
// 发送邮件
if err := d.DialAndSend(m); err != nil {
fmt.Printf("DialAndSend err %v:\n", err)
return err
}
fmt.Printf("send mail success\n")
return nil
}
配置系统实现
最后来介绍本项目的配置系统实现,本质是将toml文件解析为结构体提供给全局使用
采用了单例模式
type MainConfig struct {
Port int `toml:"port"`
AppName string `toml:"appName"`
Host string `toml:"host"`
}
type EmailConfig struct {
Authcode string `toml:"authcode"`
Email string `toml:"email" `
}
type RedisConfig struct {
RedisPort int `toml:"port"`
RedisDb int `toml:"db"`
RedisHost string `toml:"host"`
RedisPassword string `toml:"password"`
}
type MysqlConfig struct {
MysqlPort int `toml:"port"`
MysqlHost string `toml:"host"`
MysqlUser string `toml:"user"`
MysqlPassword string `toml:"password"`
MysqlDatabaseName string `toml:"databaseName"`
MysqlCharset string `toml:"charset"`
}
type JwtConfig struct {
ExpireDuration int `toml:"expire_duration"`
Issuer string `toml:"issuer"`
Subject string `toml:"subject"`
Key string `toml:"key"`
}
type Rabbitmq struct {
RabbitmqPort int `toml:"port"`
RabbitmqHost string `toml:"host"`
RabbitmqUsername string `toml:"username"`
RabbitmqPassword string `toml:"password"`
RabbitmqVhost string `toml:"vhost"`
}
type Config struct {
EmailConfig `toml:"emailConfig"`
RedisConfig `toml:"redisConfig"`
MysqlConfig `toml:"mysqlConfig"`
JwtConfig `toml:"jwtConfig"`
MainConfig `toml:"mainConfig"`
Rabbitmq `toml:"rabbitmqConfig"`
}
type RedisKeyConfig struct {
CaptchaPrefix string
}
var DefaultRedisKeyConfig = RedisKeyConfig{
CaptchaPrefix: "captcha:%s",
}
var config *Config
// InitConfig 初始化项目配置
func InitConfig() error {
// 设置配置文件路径(相对于 main.go 所在的目录)
if _, err := toml.DecodeFile("config/config.toml", config); err != nil {
log.Fatal(err.Error())
return err
}
return nil
}
func GetConfig() *Config {
if config == nil {
config = new(Config)
_ = InitConfig()
}
return config
} 



封面Lucy好评φ( ̄∇ ̄o)