一个网页AI聊天,图像识别项目,使用到的技术栈有,Gin框架,GORM,rabbitmq,redis,eino框架,Vue框架等
项目地址:https://github.com/youngyangyang04/GopherAI
总体架构
项目使用分层架构设计,可分为以下几层:
- 前端(Vue组件,发起http请求)
- 路由层(Gin框架提供的路由器匹配路径,将请求分发到相应控制器)
- 中间件层(JWT鉴权,校验token,除了登录界面不需要外其余API访问都需要)
- 控制器层(绑定请求参数,调用服务层方法)
- 服务层(执行业务逻辑)
- 数据访问层(DAO,与MySQL交互)
此外还有通用组件,如Redis缓存验证码,Email发送账号邮件,JWT生成HS256签名的Token等
以及外部第三方服务,如SMTP服务器发送邮件,调用大模型API等
使用分层架构可以使代码逻辑清晰,便于维护测试以及代码复用
路由层
定义了URL路由规则,将请求分发到对应的controller,配置了JWT中间件
router/router.go里,创建了根路由组/api/v1,并在其下创建了/user,/AI,/image三个路由组,每个路由组里又调用了函数来绑定handler方法给各个API接口
为/AI和/image路径使用了jwt中间件
package router
import (
"GopherAI/middleware/jwt"
"github.com/gin-gonic/gin"
)
// 初始化URL路由规则
func InitRouter() *gin.Engine {
r := gin.Default()
enterRouter := r.Group("/api/v1") // 创建路由组
{
RegisterUserRouter(enterRouter.Group("/user"))
}
//后续登录的接口需要jwt鉴权
{
AIGroup := enterRouter.Group("/AI")
AIGroup.Use(jwt.Auth()) // 使用JWT中间件
AIRouter(AIGroup)
}
{
ImageGroup := enterRouter.Group("/image")
ImageGroup.Use(jwt.Auth()) // 使用JWT中间件
ImageRouter(ImageGroup)
}
return r
}
以下是router/user.go,为API接口设置了handler函数(即分发到控制器层)
package router
import (
"GopherAI/controller/user"
"github.com/gin-gonic/gin"
)
// 为接口路径设置handler函数
func RegisterUserRouter(r *gin.RouterGroup) {
{
r.POST("/register", user.Register) // 注册
r.POST("/login", user.Login) // 登录
r.POST("/captcha", user.HandleCaptcha) // 验证码
}
}
控制器层
接收HTTP请求,解析请求参数,进行参数验证,调用Service层处理业务逻辑,构造响应并返回
例如以下是/controller/user/user.go中的/login接口的handler函数
// /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层
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)
}
服务层
实现具体的业务逻辑,协调DAO操作,处理数据转换以及封装
例如以下是Login函数,实现了用户登录的具体业务逻辑
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
}
数据访问(DAO)层
封装数据库操作,提供数据库访问接口,管理数据库连接
以下是/dao/user/user.go中的部分函数
func IsExistUser(username string) (bool, *model.User) {
user, err := mysql.GetUserByUsername(username)
if err == gorm.ErrRecordNotFound || user == nil {
return false, nil
}
return true, 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),
}); err != nil {
return nil, false
} else {
return user, true
}
}
以下是/common/mysql/mysql.go的部分函数,底层使用gorm实现
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
}
第三方组件
邮件服务
使用QQ邮箱的SMTP服务器,使用gomail封装,以下是/common/email/email.go
package email
import (
"GopherAI/config"
"fmt"
"gopkg.in/gomail.v2"
)
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
}
图像识别
使用了微软的ONNXRuntime框架来进行图形推理(没有用cuda,纯CPU跑的),调用onnxruntime.so动态库文件
func (r *ImageRecognizer) PredictFromImage(img image.Image) (string, error) {
resizedImg := image.NewRGBA(image.Rect(0, 0, r.inputW, r.inputH))
draw.CatmullRom.Scale(resizedImg, resizedImg.Bounds(), img, img.Bounds(), draw.Over, nil)
h, w := r.inputH, r.inputW
ch := 3 // R, G, B
data := make([]float32, h*w*ch)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := resizedImg.At(x, y)
r, g, b, _ := c.RGBA()
rf := float32(r>>8) / 255.0
gf := float32(g>>8) / 255.0
bf := float32(b>>8) / 255.0
// NCHW format
data[y*w+x] = rf
data[h*w+y*w+x] = gf
data[2*h*w+y*w+x] = bf
}
}
inData := r.inputTensor.GetData()
copy(inData, data)
if err := r.session.Run(); err != nil {
return "", fmt.Errorf("onnx run error: %w", err)
}
outData := r.outputTensor.GetData()
if len(outData) == 0 {
return "", errors.New("empty output from model")
}
maxIdx := 0
maxVal := outData[0]
for i := 1; i < len(outData); i++ {
if outData[i] > maxVal {
maxVal = outData[i]
maxIdx = i
}
}
if maxIdx >= 0 && maxIdx < len(r.labels) {
return r.labels[maxIdx], nil
}
return "Unknown", nil
}
func loadLabels(path string) ([]string, error) {
f, err := os.Open(filepath.Clean(path))
if err != nil {
return nil, fmt.Errorf("open label file failed: %w", err)
}
defer f.Close()
var labels []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if line != "" {
labels = append(labels, line)
}
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("read labels failed: %w", err)
}
if len(labels) == 0 {
return nil, fmt.Errorf("no labels found in %s", path)
}
return labels, nil
}
AI接入
使用字节的EINO框架接入,支持OpenAI模型API以及本地Ollama模型
type StreamCallback func(msg string)
// AIModel 定义AI模型接口
type AIModel interface {
GenerateResponse(ctx context.Context, messages []*schema.Message) (*schema.Message, error)
StreamResponse(ctx context.Context, messages []*schema.Message, cb StreamCallback) (string, error)
GetModelType() string
}
// =================== OpenAI 实现 ===================
type OpenAIModel struct {
llm model.ToolCallingChatModel
}
func NewOpenAIModel(ctx context.Context) (*OpenAIModel, error) {
key := os.Getenv("OPENAI_API_KEY")
modelName := os.Getenv("OPENAI_MODEL_NAME")
baseURL := os.Getenv("OPENAI_BASE_URL")
llm, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
BaseURL: baseURL,
Model: modelName,
APIKey: key,
})
if err != nil {
return nil, fmt.Errorf("create openai model failed: %v", err)
}
return &OpenAIModel{llm: llm}, nil
}
func (o *OpenAIModel) GenerateResponse(ctx context.Context, messages []*schema.Message) (*schema.Message, error) {
resp, err := o.llm.Generate(ctx, messages)
if err != nil {
return nil, fmt.Errorf("openai generate failed: %v", err)
}
return resp, nil
}
func (o *OpenAIModel) StreamResponse(ctx context.Context, messages []*schema.Message, cb StreamCallback) (string, error) {
stream, err := o.llm.Stream(ctx, messages)
if err != nil {
return "", fmt.Errorf("openai stream failed: %v", err)
}
defer stream.Close()
var fullResp strings.Builder
for {
msg, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("openai stream recv failed: %v", err)
}
if len(msg.Content) > 0 {
fullResp.WriteString(msg.Content) // 聚合
cb(msg.Content) // 实时调用cb函数,方便主动发送给前端
}
}
return fullResp.String(), nil //返回完整内容,方便后续存储
}
func (o *OpenAIModel) GetModelType() string { return "openai" }
// =================== Ollama 实现 ===================
// OllamaModel Ollama模型实现
type OllamaModel struct {
llm model.ToolCallingChatModel
}
func NewOllamaModel(ctx context.Context, baseURL, modelName string) (*OllamaModel, error) {
llm, err := ollama.NewChatModel(ctx, &ollama.ChatModelConfig{
BaseURL: baseURL,
Model: modelName,
})
if err != nil {
return nil, fmt.Errorf("create ollama model failed: %v", err)
}
return &OllamaModel{llm: llm}, nil
}
func (o *OllamaModel) GenerateResponse(ctx context.Context, messages []*schema.Message) (*schema.Message, error) {
resp, err := o.llm.Generate(ctx, messages)
if err != nil {
return nil, fmt.Errorf("ollama generate failed: %v", err)
}
return resp, nil
}
func (o *OllamaModel) StreamResponse(ctx context.Context, messages []*schema.Message, cb StreamCallback) (string, error) {
stream, err := o.llm.Stream(ctx, messages)
if err != nil {
return "", fmt.Errorf("ollama stream failed: %v", err)
}
defer stream.Close()
var fullResp strings.Builder
for {
msg, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("openai stream recv failed: %v", err)
}
if len(msg.Content) > 0 {
fullResp.WriteString(msg.Content) // 聚合
cb(msg.Content) // 实时调用cb函数,方便主动发送给前端
}
}
return fullResp.String(), nil //返回完整内容,方便后续存储
}
func (o *OllamaModel) GetModelType() string { return "ollama" } 



咪咪咪咪