ByteNoteByteNote

字节笔记本

2026年7月20日

用 Go 搭建流式 AI 对话接口:Gin + JWT + SSE 实战

API中转
¥120

用 Go 实现一个流式 AI 对话接口,核心就是让后端把 OpenAI API 的流式响应(SSE)原样转发给前端。用 Gin 框架搭 API、JWT 做鉴权、前端用 fetch + ReadableStream 接收流式数据并逐步渲染。下面把完整实现拆开讲。

项目结构

text
chat-app/
├── main.go
├── config/
│   └── config.go
├── middleware/
│   └── auth.go
├── handlers/
│   └── chat.go
├── utils/
│   ├── jwt.go
│   └── response.go
├── routes/
│   └── routes.go
└── .env

配置管理

用环境变量管理 API Key 和 JWT 密钥:

go
// config/config.go
package config

import (
	"os"
)

type Config struct {
	Port      string
	JWTSecret string
	OpenAIKey string
	Debug     bool
}

var Config *Config

func Init() {
	Config = &Config{
		Port:      getEnv("PORT", "8080"),
		JWTSecret: getEnv("JWT_SECRET", ""),
		OpenAIKey: getEnv("OPENAI_API_KEY", ""),
		Debug:     os.Getenv("DEBUG") == "true",
	}
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

.env 文件:

env
PORT=8080
DEBUG=true
JWT_SECRET=your-secret-key
OPENAI_API_KEY=your-openai-api-key

JWT 工具

go
// utils/jwt.go
package utils

import (
	"errors"
	"fmt"
	"time"
	"github.com/golang-jwt/jwt/v5"
)

type JWTClaims struct {
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
	jwt.RegisteredClaims
}

func GenerateToken(userID uint, username string) (string, error) {
	claims := JWTClaims{
		UserID:   userID,
		Username: username,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    "chat-app",
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString([]byte(config.Config.JWTSecret))
}

func ParseToken(tokenString string) (*JWTClaims, error) {
	token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return []byte(config.Config.JWTSecret), nil
	})

	if err != nil {
		return nil, err
	}

	if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
		return claims, nil
	}

	return nil, errors.New("invalid token")
}

中间件:JWT 鉴权 + CORS

go
// middleware/auth.go
package middleware

import (
	"net/http"
	"strings"

	"chat-app/utils"

	"github.com/gin-gonic/gin"
)

func AuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
			c.Abort()
			return
		}

		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
		claims, err := utils.ParseToken(tokenString)
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			c.Abort()
			return
		}

		// 将用户信息存入上下文
		c.Set("userID", claims.UserID)
		c.Set("username", claims.Username)
		c.Next()
	}
}

func CorsMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")

		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(http.StatusNoContent)
			return
		}
		c.Next()
	}
}

流式 Chat Handler(核心)

这是整个项目的核心——接收前端请求,转发给 OpenAI API,然后逐块把流式响应返回给前端。

go
// handlers/chat.go
package handlers

import (
	"bufio"
	"bytes"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"strings"

	"chat-app/config"
	"chat-app/utils"

	"github.com/gin-gonic/gin"
)

// ChatRequest 聊天请求体
type ChatRequest struct {
	Model    string                   `json:"model"`
	Messages []map[string]interface{} `json:"messages"`
	Stream   bool                     `json:"stream"`
}

// LoginRequest 登录请求体
type LoginRequest struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
}

// Login 登录接口(简化版,实际应查数据库)
func Login(c *gin.Context) {
	var req LoginRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// 简化验证,实际应对比数据库中的密码哈希
	if req.Username == "admin" && req.Password == "password" {
		token, err := utils.GenerateToken(1, req.Username)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"token": token})
		return
	}

	c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
}

// StreamChat 流式聊天接口
func StreamChat(c *gin.Context) {
	var req ChatRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// 如果前端没传 stream,默认开启
	if !req.Stream {
		req.Stream = true
	}

	// 构造发给 OpenAI 的请求体
	reqBody, err := json.Marshal(map[string]interface{}{
		"model":    req.Model,
		"messages": req.Messages,
		"stream":   true,
	})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal request"})
		return
	}

	// 向 OpenAI 发送流式请求
	apiURL := os.Getenv("OPENAI_BASE_URL")
	if apiURL == "" {
		apiURL = "https://api.openai.com/v1/chat/completions"
	}

	apiReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"})
		return
	}

	apiReq.Header.Set("Content-Type", "application/json")
	apiReq.Header.Set("Authorization", "Bearer "+config.Config.OpenAIKey)
	apiReq.Header.Set("Accept", "text/event-stream")

	client := &http.Client{}
	resp, err := client.Do(apiReq)
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed"})
		return
	}
	defer resp.Body.Close()

	// 设置 SSE 响应头
	c.Header("Content-Type", "text/event-stream")
	c.Header("Cache-Control", "no-cache")
	c.Header("Connection", "keep-alive")
	c.Header("X-Accel-Buffering", "no") // 防止 Nginx 缓冲

	// 逐行读取 OpenAI 的流式响应并转发给前端
	flusher := c.Writer
	reader := bufio.NewReader(resp.Body)

	for {
		line, err := reader.ReadString('\n')
		if err != nil {
			if err == io.EOF {
				// 流结束,发送 [DONE] 标记
				c.Writer.Write([]byte("data: [DONE]\n\n"))
				c.Writer.Flush()
			} else {
				log.Printf("Stream read error: %v", err)
			}
			break
		}

		// 跳过空行和注释行
		trimmed := strings.TrimSpace(line)
		if trimmed == "" || strings.HasPrefix(trimmed, ":") {
			continue
		}

		// 转发 data 行
		if strings.HasPrefix(trimmed, "data: ") {
			c.Writer.Write([]byte(line))
			c.Writer.Write([]byte("\n\n"))
			c.Writer.Flush()
		}
	}
}

关键点:

  • X-Accel-Buffering: no 防止 Nginx 代理时缓冲 SSE 响应。
  • bufio.NewReader 逐行读取,拿到 data: 开头的行就立即转发并 flush。
  • 流结束时手动发送 data: [DONE]\n\n,让前端知道结束了。
  • OPENAI_BASE_URL 环境变量支持替换为 DeepSeek 等兼容 OpenAI 格式的第三方 API。

路由配置

go
// routes/routes.go
package routes

import (
	"chat-app/handlers"
	"chat-app/middleware"

	"github.com/gin-gonic/gin"
)

func SetupRoutes(r *gin.Engine) {
	r.Use(middleware.CorsMiddleware())

	// 公开路由
	public := r.Group("/api")
	{
		public.POST("/auth/login", handlers.Login)
	}

	// 需要鉴权的路由
	protected := r.Group("/api")
	protected.Use(middleware.AuthMiddleware())
	{
		protected.POST("/chat/stream", handlers.StreamChat)
	}
}

主入口

go
// main.go
package main

import (
	"log"

	"chat-app/config"
	"chat-app/routes"

	"github.com/gin-gonic/gin"
	"github.com/joho/godotenv"
)

func main() {
	// 加载 .env 文件
	_ = godotenv.Load()

	config.Init()

	r := gin.Default()
	routes.SetupRoutes(r)

	addr := ":" + config.Config.Port
	log.Printf("Server starting on %s", addr)
	if err := r.Run(addr); err != nil {
		log.Fatal("Server failed to start:", err)
	}
}

前端:单 HTML 文件对接

前端用 fetch + ReadableStream 读取 SSE 流,逐步拼接 AI 回复并渲染。核心是 handleSendMessage 函数中的流式解析逻辑。

html
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>AI Chat</title>
    <style>
        body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
        .message { margin: 10px 0; padding: 10px 15px; border-radius: 8px; max-width: 80%; }
        .message.user { background: #2563eb; color: white; margin-left: auto; }
        .message.bot { background: #e5e7eb; }
        #chatMessages { height: 500px; overflow-y: auto; border: 1px solid #ddd; padding: 15px; }
        #loginSection { display: flex; flex-direction: column; max-width: 300px; gap: 10px; }
        input, button { padding: 8px; }
    </style>
</head>
<body>
    <div id="loginSection">
        <h3>登录</h3>
        <input id="username" placeholder="用户名" value="admin">
        <input id="password" type="password" placeholder="密码" value="password">
        <button onclick="handleLogin()">登录</button>
    </div>

    <div id="chatSection" style="display:none">
        <div style="display:flex;justify-content:space-between;align-items:center">
            <h3>AI Chat</h3>
            <button onclick="handleLogout()">登出</button>
        </div>
        <div id="chatMessages"></div>
        <div style="display:flex;gap:10px;margin-top:10px">
            <input id="messageInput" placeholder="输入消息..." style="flex:1" onkeypress="if(event.key==='Enter')handleSendMessage()">
            <button onclick="handleSendMessage()">发送</button>
        </div>
    </div>

    <script>
        let token = '';
        let messages = [];

        async function handleLogin() {
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;

            const resp = await fetch('/api/auth/login', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ username, password })
            });
            const data = await resp.json();
            if (data.token) {
                token = data.token;
                document.getElementById('loginSection').style.display = 'none';
                document.getElementById('chatSection').style.display = 'block';
            }
        }

        function handleLogout() {
            token = '';
            messages = [];
            document.getElementById('chatMessages').innerHTML = '';
            document.getElementById('loginSection').style.display = 'flex';
            document.getElementById('chatSection').style.display = 'none';
        }

        async function handleSendMessage() {
            const input = document.getElementById('messageInput');
            const message = input.value.trim();
            if (!message || !token) return;

            addMessage(message, 'user');
            input.value = '';

            try {
                const response = await fetch('/api/chat/stream', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `Bearer ${token}`
                    },
                    body: JSON.stringify({
                        model: 'gpt-3.5-turbo',
                        messages: [...messages, { role: 'user', content: message }],
                        stream: true
                    })
                });

                if (response.status === 401) {
                    handleLogout();
                    return;
                }

                const reader = response.body.getReader();
                const decoder = new TextDecoder();
                let currentResponse = '';

                // 预创建 bot 消息气泡
                const messagesDiv = document.getElementById('chatMessages');
                const botEl = document.createElement('div');
                botEl.classList.add('message', 'bot');
                messagesDiv.appendChild(botEl);

                while (true) {
                    const { value, done } = await reader.read();
                    if (done) break;

                    const chunk = decoder.decode(value);
                    const lines = chunk.split('\n');

                    for (const line of lines) {
                        const trimmed = line.trim();
                        if (!trimmed || trimmed.startsWith(':')) continue;

                        if (trimmed.startsWith('data: ')) {
                            const data = trimmed.slice(6);
                            if (data === '[DONE]') continue;

                            try {
                                const parsed = JSON.parse(data);
                                const content = parsed.choices[0].delta.content;
                                if (content) {
                                    currentResponse += content;
                                    botEl.textContent = currentResponse;
                                    messagesDiv.scrollTop = messagesDiv.scrollHeight;
                                }
                            } catch (e) {
                                // 非 JSON 行(如空 data 或事件行),跳过
                            }
                        }
                    }
                }

                messages.push(
                    { role: 'user', content: message },
                    { role: 'assistant', content: currentResponse }
                );

            } catch (error) {
                addMessage('请求失败: ' + error.message, 'bot');
            }
        }

        function addMessage(content, role) {
            const div = document.getElementById('chatMessages');
            const el = document.createElement('div');
            el.classList.add('message', role);
            el.textContent = content;
            div.appendChild(el);
            div.scrollTop = div.scrollHeight;
        }
    </script>
</body>
</html>

SSE 解析的坑

这个项目调试过程中踩过的几个问题:

  • data: 前缀和 JSON 混在一起:后端转发的 SSE 行格式是 data: {"choices":[...]},前端解析时必须先去掉 data: 前缀再 JSON.parse。有些实现返回的是 event:message\ndata:xxx 双行格式,这时只处理 data: 开头的行就行,event: 行直接跳过。

  • Nginx 缓冲:部署到生产环境后 SSE 可能不工作,因为 Nginx 默认缓冲上游响应。解决方案是在 handler 里加 X-Accel-Buffering: no 响应头,或者在 Nginx 配置中 proxy_buffering off

  • data 后面有空行:SSE 协议用空行(\n\n)分隔事件,有些 API 返回的数据中 data: 行后面可能紧跟空 data: 行(data:\n\n),解析前先 trim() 再判断是否为空。

  • 流没有正常结束:OpenAI API 在流结束时发送 data: [DONE],后端转发后前端收到就跳出循环。如果后端自己做聚合或转换,别忘了手动发这个结束标记。

依赖

go
// go.mod
module chat-app

go 1.21

require (
    github.com/gin-gonic/gin v1.9.1
    github.com/gin-contrib/cors v1.4.0
    github.com/golang-jwt/jwt/v5 v5.2.0
    github.com/joho/godotenv v1.5.1
)

整套实现可以直接作为 AI 对话类应用的骨架,换成 DeepSeek、Claude 等 API 只需要改 OPENAI_BASE_URL 环境变量。

分享: