Files
AI-CS/backend/main.go
T
2026-03-25 18:50:58 +08:00

526 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"log"
"os"
"path/filepath"
"time"
"github.com/2930134478/AI-CS/backend/controller"
"github.com/2930134478/AI-CS/backend/infra"
"github.com/2930134478/AI-CS/backend/infra/mcp"
infra_search "github.com/2930134478/AI-CS/backend/infra/search"
"github.com/2930134478/AI-CS/backend/middleware"
"github.com/2930134478/AI-CS/backend/models"
"github.com/2930134478/AI-CS/backend/repository"
appRouter "github.com/2930134478/AI-CS/backend/router"
"github.com/2930134478/AI-CS/backend/service"
"github.com/2930134478/AI-CS/backend/service/embedding"
"github.com/2930134478/AI-CS/backend/service/rag"
"github.com/2930134478/AI-CS/backend/websocket"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
milvus "github.com/milvus-io/milvus-sdk-go/v2/client"
"golang.org/x/crypto/bcrypt"
)
// 初始化默认管理员账号(如果不存在)
// 用户名从环境变量 ADMIN_USERNAME 读取(默认:admin
// 密码从环境变量 ADMIN_PASSWORD 读取(必须设置)
func initDefaultAdmin(userRepo *repository.UserRepository) {
// 从环境变量读取管理员用户名和密码
adminUsername := os.Getenv("ADMIN_USERNAME")
if adminUsername == "" {
adminUsername = "admin" // 默认用户名
}
adminPassword := os.Getenv("ADMIN_PASSWORD")
if adminPassword == "" {
log.Println("⚠️ 警告:未设置 ADMIN_PASSWORD 环境变量,跳过创建默认管理员账号")
log.Println(" 请在 .env 文件中设置 ADMIN_PASSWORD 后重启服务")
return
}
// 检查管理员账号是否已存在
if _, err := userRepo.FindByUsername(adminUsername); err == nil {
log.Printf("✅ 管理员账号 '%s' 已存在", adminUsername)
return
}
// 加密密码
hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("⚠️ 创建默认管理员失败:密码加密错误 %v", err)
return
}
admin := &models.User{
Username: adminUsername,
Password: string(hash),
Role: "admin",
}
if err := userRepo.Create(admin); err != nil {
log.Printf("⚠️ 创建默认管理员失败:%v", err)
return
}
log.Printf("✅ 默认管理员账号创建成功")
log.Printf(" 用户名: %s", adminUsername)
log.Println(" ⚠️ 请首次登录后立即修改密码!")
}
// logVectorStartup 将向量库(Milvus)启动相关事件写入 system_logs,供前端「日志中心」查询;失败时仅打控制台,不影响启动。
func logVectorStartup(sys *service.SystemLogService, level, event, message string, meta map[string]interface{}) {
if sys == nil {
return
}
if meta == nil {
meta = map[string]interface{}{}
}
if err := sys.Create(service.CreateSystemLogInput{
Level: level,
Category: "vector",
Event: event,
Source: "backend",
Message: message,
Meta: meta,
}); err != nil {
log.Printf("写入 system_logs 失败 (event=%s): %v", event, err)
}
}
// fatalVectorStartup 在启动阶段先写入一条 vector error 日志,再执行 fatal 退出。
func fatalVectorStartup(sys *service.SystemLogService, event, message string, meta map[string]interface{}) {
logVectorStartup(sys, "error", event, message, meta)
log.Fatalf("%s", message)
}
func main() {
// 加载 .env 文件(统一配置真源:优先当前目录 .env,其次上级目录 .env)
wd, _ := os.Getwd()
candidates := []string{
filepath.Join(wd, ".env"),
filepath.Join(wd, "..", ".env"),
}
envPath := ""
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
envPath = p
break
}
}
if envPath == "" {
log.Printf("⚠️ 未找到 .env 文件(已检查: %v", candidates)
log.Println("将仅使用系统环境变量")
} else {
log.Printf("✅ 找到 .env 文件: %s", envPath)
}
// 尝试加载 .env 文件
// 注意:godotenv 不支持 UTF-8 BOM,如果文件有 BOM 会失败
if envPath != "" {
if err := godotenv.Load(envPath); err != nil {
log.Printf("❌ 加载 .env 文件失败: %v", err)
log.Println("⚠️ 提示:如果看到 'unexpected character' 错误,可能是文件编码问题(UTF-8 BOM")
log.Println(" 解决方法:用文本编辑器(如 VS Code)打开 .env,另存为 UTF-8 编码(不要 BOM")
log.Println("将使用系统环境变量")
} else {
log.Println("✅ .env 文件加载成功")
}
}
db, err := infra.NewDB()
if err != nil {
log.Fatalf("数据库连接失败:%v", err)
}
//根据结构体定义自动创建更新表
if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.EmbeddingConfig{}, &models.PromptConfig{}, &models.WidgetOpenEvent{}, &models.SystemLog{}); err != nil {
log.Fatalf("自动创建表失败: %v", err)
}
userRepo := repository.NewUserRepository(db)
conversationRepo := repository.NewConversationRepository(db)
messageRepo := repository.NewMessageRepository(db)
aiConfigRepo := repository.NewAIConfigRepository(db)
faqRepo := repository.NewFAQRepository(db)
kbRepo := repository.NewKnowledgeBaseRepository(db)
docRepo := repository.NewDocumentRepository(db)
embeddingConfigRepo := repository.NewEmbeddingConfigRepository(db)
promptConfigRepo := repository.NewPromptConfigRepository(db)
systemLogRepo := repository.NewSystemLogRepository(db)
systemLogService := service.NewSystemLogService(systemLogRepo)
// 初始化默认管理员账号(如果不存在)
initDefaultAdmin(userRepo)
//gin路由初始化
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// trace_id + 结构化 HTTP 日志 + 控制台日志
r.Use(middleware.TraceID())
r.Use(middleware.StructuredHTTPLogger(systemLogService))
r.Use(middleware.Logger())
//跨域配置
r.Use(middleware.CORS())
// 初始化存储服务(本地存储)
// 存储目录:backend/uploads(相对于工作目录)
// 公共访问路径:/uploads(用于构建URL
// 复用之前获取的工作目录 wd(已在第 56 行声明)
uploadDir := filepath.Join(wd, "uploads")
publicPath := "/uploads"
storageService := infra.NewLocalStorageService(uploadDir, publicPath)
// 初始化 Milvus(向量数据库):默认连接失败时降级为「无向量库」启动;MILVUS_REQUIRED=true 时失败则退出
milvusDisabled := infra.IsMilvusDisabled()
milvusRequired := infra.IsMilvusRequired()
var milvusClient milvus.Client
defer func() {
if milvusClient != nil {
if err := milvusClient.Close(); err != nil {
log.Printf("关闭 Milvus 客户端: %v", err)
}
}
}()
var vectorStore *infra.VectorStore
milvusCfg := infra.GetMilvusConfig()
milvusMeta := map[string]interface{}{
"milvus_host": milvusCfg.Host,
"milvus_port": milvusCfg.Port,
"milvus_required": milvusRequired,
"milvus_disabled": milvusDisabled,
}
if milvusDisabled {
log.Println("️ 已设置 MILVUS_DISABLED / VECTOR_STORE_DISABLED,跳过 Milvus;知识库 RAG 与向量化不可用,直至启用并重启。")
logVectorStartup(systemLogService, "info", "milvus_disabled",
"已跳过 MilvusMILVUS_DISABLED/VECTOR_STORE_DISABLED);知识库 RAG 与向量化不可用,启用后需重启",
milvusMeta)
} else {
c, err := infra.NewMilvusClient()
if err != nil {
if milvusRequired {
m := map[string]interface{}{}
for k, v := range milvusMeta {
m[k] = v
}
m["error"] = err.Error()
fatalVectorStartup(systemLogService, "milvus_required_connect_failed",
"连接 Milvus 失败(已设置 MILVUS_REQUIRED", m)
}
log.Printf("⚠️ 连接 Milvus 失败,将以「无向量库」模式启动: %v", err)
m := map[string]interface{}{}
for k, v := range milvusMeta {
m[k] = v
}
m["error"] = err.Error()
logVectorStartup(systemLogService, "warn", "milvus_connect_failed",
"连接 Milvus 失败,已降级为无向量库模式启动", m)
} else {
milvusClient = c
if err := infra.HealthCheck(milvusClient); err != nil {
_ = milvusClient.Close()
milvusClient = nil
if milvusRequired {
m := map[string]interface{}{}
for k, v := range milvusMeta {
m[k] = v
}
m["error"] = err.Error()
fatalVectorStartup(systemLogService, "milvus_required_health_check_failed",
"Milvus 健康检查失败(已设置 MILVUS_REQUIRED", m)
}
log.Printf("⚠️ Milvus 健康检查失败,将以「无向量库」模式启动: %v", err)
m := map[string]interface{}{}
for k, v := range milvusMeta {
m[k] = v
}
m["error"] = err.Error()
logVectorStartup(systemLogService, "warn", "milvus_health_check_failed",
"Milvus 健康检查失败,已降级为无向量库模式启动", m)
} else {
log.Println("✅ Milvus 连接成功")
}
}
}
// 嵌入服务按需从 DB 配置获取(保存即生效,无需重启)
embeddingConfigService := service.NewEmbeddingConfigService(embeddingConfigRepo, userRepo)
promptConfigService := service.NewPromptConfigService(promptConfigRepo, userRepo)
embeddingFactory := embedding.NewEmbeddingFactory()
embeddingProvider := service.NewConfigBackedEmbeddingProvider(embeddingConfigService, embeddingFactory)
// 启动时获取一次维度用于创建/校验向量集合
initCtx := context.Background()
initSvc, _ := embeddingProvider.Get(initCtx)
if initSvc != nil {
log.Printf("✅ 嵌入服务按需从「知识库向量配置」加载,模型: %s (维度: %d),修改配置后立即生效", initSvc.GetModelName(), initSvc.GetDimension())
} else {
log.Printf("⚠️ 未配置嵌入服务;知识库/RAG 需在「设置 - 知识库向量模型」中配置 API 后再使用")
}
dimension := 1536
if initSvc != nil {
dimension = initSvc.GetDimension()
}
// 向量存储:迁移时通过 getEmbedding 从当前配置重新向量化
getEmbedding := func(ctx context.Context) (infra.EmbeddingService, error) {
svc, err := embeddingProvider.Get(ctx)
if err != nil || svc == nil {
return nil, err
}
return svc, nil
}
if milvusClient != nil {
vs, err := infra.NewVectorStore(milvusClient, "documents", dimension, getEmbedding)
if err != nil {
_ = milvusClient.Close()
milvusClient = nil
if milvusRequired {
m := map[string]interface{}{}
for k, v := range milvusMeta {
m[k] = v
}
m["error"] = err.Error()
fatalVectorStartup(systemLogService, "milvus_required_vector_store_init_failed",
"创建向量存储失败(已设置 MILVUS_REQUIRED", m)
}
log.Printf("⚠️ 创建向量存储失败,将以「无向量库」模式启动: %v", err)
m := map[string]interface{}{}
for k, v := range milvusMeta {
m[k] = v
}
m["error"] = err.Error()
logVectorStartup(systemLogService, "warn", "milvus_vector_store_init_failed",
"创建向量存储(集合)失败,已降级为无向量库模式启动", m)
} else {
vectorStore = vs
}
}
if vectorStore != nil {
okMeta := map[string]interface{}{}
for k, v := range milvusMeta {
okMeta[k] = v
}
okMeta["collection"] = "documents"
logVectorStartup(systemLogService, "info", "milvus_ready",
"Milvus 已连接且向量集合可用", okMeta)
}
vectorStoreService := rag.NewVectorStoreService(vectorStore)
// 文档向量化 / RAG 检索 / 健康检查均使用 provider,配置保存即生效
documentEmbeddingService := rag.NewDocumentEmbeddingService(vectorStoreService, embeddingProvider)
retrievalService := rag.NewRetrievalService(vectorStoreService, embeddingProvider, docRepo, kbRepo)
retrievalService.EnableCache(5 * time.Minute)
healthChecker := rag.NewHealthChecker(embeddingProvider, vectorStoreService)
// 联网搜索(可选):优先通过 MCP 调用 SerperSERPER_MCP_URL),否则使用 Serper HTTP APISERPER_API_KEY
var webSearchProvider infra_search.WebSearchProvider
if mcpURL := os.Getenv("SERPER_MCP_URL"); mcpURL != "" {
mcpClient := mcp.NewClient(mcpURL)
if err := mcpClient.Connect(initCtx); err != nil {
log.Printf("⚠️ Serper MCP 连接失败(SERPER_MCP_URL=%s: %v,联网搜索将不可用", mcpURL, err)
} else {
webSearchProvider = mcp.NewSerperWebSearchProvider(mcpClient)
log.Println("✅ 联网搜索已通过 MCPSerper)接入")
}
}
if webSearchProvider == nil {
if apiKey := os.Getenv("SERPER_API_KEY"); apiKey != "" {
webSearchProvider = infra_search.NewSerperProvider(apiKey)
log.Println("✅ 联网搜索已通过 Serper HTTP API 接入")
}
}
// 初始化服务层
authService := service.NewAuthService(userRepo)
conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo, systemLogService)
profileService := service.NewProfileService(userRepo, storageService)
aiConfigService := service.NewAIConfigService(aiConfigRepo, userRepo)
aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo, retrievalService, webSearchProvider, embeddingConfigService, promptConfigService, storageService, systemLogService)
userService := service.NewUserService(userRepo) // 用户管理服务
faqService := service.NewFAQService(faqRepo, retrievalService, documentEmbeddingService) // FAQ 管理服务
documentService := service.NewDocumentService(docRepo, kbRepo, documentEmbeddingService, retrievalService) // 文档管理服务
knowledgeBaseService := service.NewKnowledgeBaseService(kbRepo, docRepo) // 知识库管理服务
importService := service.NewImportService(docRepo, kbRepo, documentService, documentEmbeddingService) // 导入服务
// 声明 Hub 变量(用于在回调函数中访问)
var wsHub *websocket.Hub
// 创建 WebSocket Hub,设置回调函数来处理客户端连接/断开事件
// 使用闭包来访问 conversationService、messageService、userRepo 和 wsHub
onConnect := func(conversationID uint, isVisitor bool, visitorCount int, agentID uint) {
if isVisitor {
if err := conversationService.UpdateVisitorOnlineStatus(conversationID, true); err != nil {
log.Printf("更新访客在线状态失败: %v", err)
return
}
// 广播状态更新到所有客服端(不管连接到哪个对话)
wsHub.BroadcastToAllAgents("visitor_status_update", map[string]interface{}{
"conversation_id": conversationID,
"is_online": true,
"visitor_count": visitorCount,
})
} else if agentID > 0 {
// 客服连接:创建系统消息 "{客服名}加入了会话"
// 但需要检查是否已经存在该客服的加入消息,避免重复创建
// 获取客服信息
agent, err := userRepo.GetByID(agentID)
if err != nil {
log.Printf("获取客服信息失败: %v", err)
return
}
// 确定显示名称:优先使用昵称,如果没有则使用用户名
agentName := agent.Nickname
if agentName == "" {
agentName = agent.Username
}
// 检查是否已经存在该客服的加入消息
hasJoinMessage, err := messageRepo.HasAgentJoinMessage(conversationID, agentID, agentName)
if err != nil {
log.Printf("检查客服加入消息失败: %v", err)
return
}
// 如果已经存在加入消息,不再创建
if hasJoinMessage {
log.Printf("客服 %s 已经加入过对话 %d,跳过创建系统消息", agentName, conversationID)
return
}
// 创建系统消息
// 需要获取对话信息以确定当前模式
conv, err := conversationRepo.GetByID(conversationID)
if err != nil {
log.Printf("获取对话信息失败: %v", err)
return
}
now := time.Now()
chatMode := conv.ChatMode
if chatMode == "" {
chatMode = "human" // 默认人工模式
}
systemMessage := &models.Message{
ConversationID: conversationID,
SenderID: agentID,
SenderIsAgent: true,
Content: agentName + "加入了会话",
MessageType: "system_message",
ChatMode: chatMode, // 记录系统消息发送时的对话模式
IsRead: true, // 系统消息默认已读
ReadAt: &now,
}
if err := messageRepo.Create(systemMessage); err != nil {
log.Printf("创建客服加入系统消息失败: %v", err)
return
}
// 延迟一小段时间后广播系统消息,确保客服的 WebSocket 连接已经完全建立
// 这样可以确保系统消息能够被客服接收到
go func() {
time.Sleep(100 * time.Millisecond)
wsHub.BroadcastMessage(conversationID, "new_message", systemMessage)
log.Printf("✅ 客服加入系统消息已创建并广播: 对话ID=%d, 客服=%s", conversationID, agentName)
}()
}
}
onDisconnect := func(conversationID uint, isVisitor bool, visitorCount int) {
if isVisitor {
if visitorCount == 0 {
if err := conversationService.UpdateVisitorOnlineStatus(conversationID, false); err != nil {
log.Printf("更新访客离线状态失败: %v", err)
return
}
// 广播状态更新到所有客服端(不管连接到哪个对话)
wsHub.BroadcastToAllAgents("visitor_status_update", map[string]interface{}{
"conversation_id": conversationID,
"is_online": false,
"visitor_count": 0,
})
} else {
// 还有访客在线,只更新最后活跃时间
if err := conversationService.UpdateLastSeenAt(conversationID); err != nil {
log.Printf("更新最后活跃时间失败: %v", err)
return
}
}
}
}
// 创建 Hub(回调函数通过闭包访问 wsHub)
wsHub = websocket.NewHub(onConnect, onDisconnect)
go wsHub.Run() // 启动 Hub(在后台运行)
messageService := service.NewMessageService(conversationRepo, messageRepo, wsHub, aiService)
visitorService := service.NewVisitorService(userRepo, wsHub)
// 初始化控制器
authController := controller.NewAuthController(authService)
conversationController := controller.NewConversationController(conversationService, aiConfigService)
messageController := controller.NewMessageController(messageService, conversationService, storageService)
adminController := controller.NewAdminController(authService, userService)
profileController := controller.NewProfileController(profileService)
aiConfigController := controller.NewAIConfigController(aiConfigService)
faqController := controller.NewFAQController(faqService)
documentController := controller.NewDocumentController(documentService, embeddingConfigService)
embeddingConfigController := controller.NewEmbeddingConfigController(embeddingConfigService)
promptConfigController := controller.NewPromptConfigController(promptConfigService)
knowledgeBaseController := controller.NewKnowledgeBaseController(knowledgeBaseService, embeddingConfigService)
importController := controller.NewImportController(importService, embeddingConfigService) // 导入控制器
visitorController := controller.NewVisitorController(visitorService, embeddingConfigService)
healthController := controller.NewHealthController(healthChecker, retrievalService) // 健康检查控制器
widgetOpenRepo := repository.NewWidgetOpenRepository(db)
analyticsService := service.NewAnalyticsService(db, widgetOpenRepo)
analyticsController := controller.NewAnalyticsController(analyticsService)
systemLogController := controller.NewSystemLogController(systemLogService)
appRouter.RegisterRoutes(
r,
appRouter.ControllerSet{
Auth: authController,
Conversation: conversationController,
Message: messageController,
Admin: adminController,
Profile: profileController,
AIConfig: aiConfigController,
EmbeddingConfig: embeddingConfigController,
PromptConfig: promptConfigController,
FAQ: faqController,
Document: documentController,
KnowledgeBase: knowledgeBaseController,
Import: importController, // 导入控制器
Visitor: visitorController,
Health: healthController, // 健康检查控制器
Analytics: analyticsController,
SystemLog: systemLogController,
},
websocket.HandleWebSocket(wsHub),
)
// 配置静态文件服务(用于访问上传的头像等文件)
// 静态文件路径:/uploads -> backend/uploads
r.Static("/uploads", uploadDir)
//启动服务器
// 监听所有网络接口(0.0.0.0),允许外部设备访问
// 如果只想本地访问,可以改为 "127.0.0.1:8080" 或 ":8080"
host := os.Getenv("SERVER_HOST")
if host == "" {
host = "0.0.0.0" // 默认监听所有网络接口,允许外部访问
}
port := os.Getenv("SERVER_PORT")
if port == "" {
port = "8080"
}
addr := host + ":" + port
log.Println("🚀 服务器启动成功,监听 " + addr)
log.Println("📡 WebSocket 服务已启动,路径: /ws?conversation_id=<对话ID>")
log.Println("💡 提示:如需限制为仅本地访问,请设置环境变量 SERVER_HOST=127.0.0.1")
r.Run(addr)
}