主要改动: fix(docker/dify-deploy): 修复脚本核心功能 - heredoc 单引号 bug: 'ENVEOF' 改为 ENVEOF,变量正确展开 - 端口默认值 8083/8084/8085 对齐 .env.prod 生产配置 - 加 dc_cmd() 兼容 docker-compose v1/v2 plugin - openssl rand 生成强随机密码与 SECRET_KEY(42 字符) - install 跳过已存在 .env,保护用户配置(管理员密码/SECRET_KEY) - read -p < /dev/tty 兼容非 tty 环境(CI/CD) - show-config 改用 DIFY_NGINX_PORT(nginx 入口)而非 APP_WEB_PORT docs(mvp-design): 修正 §3.2 workflow inputs 描述 - 实际只有 query,删除错误的 user_id input 声明 - 节点序列图同步更新 feat(aiChatService): 新增 Dify 客户端与适配器 - service/dify_client.go: Dify Workflow 调用 + SSE 解析 - service/dify_adapter.go: 与现有 chat_service 桥接 - provider/ai_chat_provider.go: Dubbo 入口简化 - main.go: 装配 ConversationRepository + DifyClient feat(migrations): 新增 AI 搭子会话表 ai_chat.sql - ai_conversations / ai_messages 表 + 索引 docs: 新增 Dify 集成设计文档 - 2026-06-29-ai-chat-dify-mvp-design.md (MVP 实施级) - 2026-06-29-ai-chat-dify-integration-v2-design.md (V2 演进路线图) - docs/dify/角角.yml (Workflow DSL 导出) config: 更新 env 模板与 docker 配置 - backend/.env.example: DIFY_* 环境变量声明 - docker/.env.prod: DIFY_API_BASE 对齐 8083 - docker/build.sh: 微调 - CLAUDE.md: 项目规范补充 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
294 lines
8.6 KiB
Go
294 lines
8.6 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"os/signal"
|
||
"strconv"
|
||
"syscall"
|
||
"time"
|
||
|
||
_ "dubbo.apache.org/dubbo-go/v3/imports"
|
||
_ "dubbo.apache.org/dubbo-go/v3/protocol/triple"
|
||
"dubbo.apache.org/dubbo-go/v3/protocol"
|
||
"dubbo.apache.org/dubbo-go/v3/server"
|
||
"github.com/joho/godotenv"
|
||
"github.com/redis/go-redis/v9"
|
||
|
||
"github.com/topfans/backend/pkg/database"
|
||
"github.com/topfans/backend/pkg/health"
|
||
"github.com/topfans/backend/pkg/logger"
|
||
"github.com/topfans/backend/services/aiChatService/model"
|
||
"github.com/topfans/backend/services/aiChatService/provider"
|
||
"github.com/topfans/backend/services/aiChatService/repository"
|
||
"github.com/topfans/backend/services/aiChatService/service"
|
||
pbAIChat "github.com/topfans/backend/pkg/proto/ai_chat"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
var (
|
||
port = flag.Int("port", getEnvInt("PORT", 20008), "Dubbo service port")
|
||
dbHost = flag.String("db-host", getEnv("DB_HOST", "localhost"), "Database host")
|
||
dbPort = flag.Int("db-port", getEnvInt("DB_PORT", 5432), "Database port")
|
||
dbUser = flag.String("db-user", getEnv("DB_USER", "postgres"), "Database user")
|
||
dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "Database password")
|
||
dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "Database name")
|
||
redisHost = flag.String("redis-host", getEnv("REDIS_HOST", "127.0.0.1"), "Redis host")
|
||
redisPort = flag.Int("redis-port", getEnvInt("REDIS_PORT", 6379), "Redis port")
|
||
redisPassword = flag.String("redis-password", getEnv("REDIS_PASSWORD", ""), "Redis password")
|
||
redisDB = flag.Int("redis-db", getEnvInt("REDIS_DB", 0), "Redis db")
|
||
contextTTL = flag.Int("context-ttl", getEnvInt("CONTEXT_TTL", 86400), "Context TTL in seconds")
|
||
triggerTurns = flag.Int("trigger-turns", getEnvInt("TRIGGER_TURNS", 5), "Turns to trigger memory extraction")
|
||
healthHandler *health.Handler
|
||
)
|
||
|
||
func getEnv(key, fallback string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func getEnvInt(key string, fallback int) int {
|
||
if v := os.Getenv(key); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil {
|
||
return n
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func main() {
|
||
godotenv.Load()
|
||
flag.Parse()
|
||
|
||
env := os.Getenv("ENV")
|
||
if env == "" {
|
||
env = "development"
|
||
}
|
||
|
||
if err := logger.Init(logger.Config{
|
||
ServiceName: "ai-chat-service",
|
||
Environment: env,
|
||
LogLevel: os.Getenv("LOG_LEVEL"),
|
||
}); err != nil {
|
||
panic(fmt.Sprintf("Failed to initialize logger: %v", err))
|
||
}
|
||
defer logger.Sync()
|
||
|
||
logger.Logger.Info("Starting AI Chat Service...")
|
||
|
||
// 初始化数据库
|
||
dbConfig := database.Config{
|
||
Host: *dbHost,
|
||
Port: *dbPort,
|
||
User: *dbUser,
|
||
Password: *dbPassword,
|
||
DBName: *dbName,
|
||
SSLMode: "disable",
|
||
TimeZone: "Asia/Shanghai",
|
||
}
|
||
|
||
if err := database.Init(dbConfig); err != nil {
|
||
logger.Logger.Fatal(fmt.Sprintf("Failed to initialize database: %v", err))
|
||
}
|
||
logger.Logger.Info("Database initialized successfully")
|
||
|
||
// 初始化 Redis
|
||
redisClient := redis.NewClient(&redis.Options{
|
||
Addr: fmt.Sprintf("%s:%d", *redisHost, *redisPort),
|
||
Password: *redisPassword,
|
||
DB: *redisDB,
|
||
})
|
||
|
||
// 测试 Redis 连接
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||
logger.Logger.Fatal(fmt.Sprintf("Failed to connect to Redis: %v", err))
|
||
}
|
||
cancel()
|
||
logger.Logger.Info("Redis connected successfully")
|
||
|
||
// 启动健康检查 HTTP 服务器
|
||
healthPort := *port + 1000
|
||
healthHandler = health.NewHandler("ai-chat-service", healthPort)
|
||
healthHandler.Start()
|
||
|
||
// 自动迁移数据库表
|
||
if err := autoMigrate(); err != nil {
|
||
logger.Logger.Fatal(fmt.Sprintf("Failed to migrate database: %v", err))
|
||
}
|
||
|
||
// 创建 Repository 层实例
|
||
personaRepo := repository.NewPostgreSQLPersonaRepository(database.GetDB())
|
||
configRepo := repository.NewPostgreSQLConfigRepository(database.GetDB())
|
||
shortTermMemoryRepo := repository.NewRedisMemoryRepository(redisClient, *contextTTL)
|
||
longTermMemoryRepo := repository.NewPostgreSQLMemoryRepository(database.GetDB())
|
||
logger.Logger.Info("Repository layer initialized")
|
||
|
||
// 从数据库加载 LLM 配置
|
||
loadCtx := context.Background()
|
||
llmConfigs, err := configRepo.GetByCategory(loadCtx, "llm")
|
||
if err != nil {
|
||
logger.Logger.Warn("Failed to load LLM configs from database, using env defaults", zap.Error(err))
|
||
}
|
||
|
||
// 获取 MiniMax 配置
|
||
miniMaxAPIKey := getEnv("MINIMAX_API_KEY", "")
|
||
miniMaxAPIURL := getEnv("MINIMAX_API_URL", "https://api.minimaxi.com/v1")
|
||
miniMaxModel := getEnv("MINIMAX_MODEL", "M2-her")
|
||
if val, ok := llmConfigs["minimax.api_key"]; ok && val != "" {
|
||
miniMaxAPIKey = val
|
||
}
|
||
if val, ok := llmConfigs["minimax.api_url"]; ok && val != "" {
|
||
miniMaxAPIURL = val
|
||
}
|
||
if val, ok := llmConfigs["minimax.model"]; ok && val != "" {
|
||
miniMaxModel = val
|
||
}
|
||
|
||
// 获取 Qwen 配置
|
||
qwenAPIKey := getEnv("QWEN_API_KEY", "")
|
||
qwenAPIURL := getEnv("QWEN_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||
qwenModel := getEnv("QWEN_MODEL", "qwen-plus")
|
||
if val, ok := llmConfigs["qwen.api_key"]; ok && val != "" {
|
||
qwenAPIKey = val
|
||
}
|
||
if val, ok := llmConfigs["qwen.api_url"]; ok && val != "" {
|
||
qwenAPIURL = val
|
||
}
|
||
if val, ok := llmConfigs["qwen.model"]; ok && val != "" {
|
||
qwenModel = val
|
||
}
|
||
|
||
logger.Logger.Info("LLM configs loaded",
|
||
zap.String("minimax_url", miniMaxAPIURL),
|
||
zap.String("minimax_model", miniMaxModel),
|
||
zap.Bool("has_minimax_key", miniMaxAPIKey != ""),
|
||
zap.String("qwen_url", qwenAPIURL),
|
||
zap.String("qwen_model", qwenModel),
|
||
zap.Bool("has_qwen_key", qwenAPIKey != ""),
|
||
)
|
||
|
||
// 获取 Dify 配置(MVP 阶段从环境变量读)
|
||
difyAPIKey := getEnv("DIFY_API_KEY", "")
|
||
difyAPIBase := getEnv("DIFY_API_BASE", "")
|
||
if val, ok := llmConfigs["dify.api_key"]; ok && val != "" {
|
||
difyAPIKey = val
|
||
}
|
||
if val, ok := llmConfigs["dify.api_base"]; ok && val != "" {
|
||
difyAPIBase = val
|
||
}
|
||
logger.Logger.Info("Dify config loaded",
|
||
zap.String("dify_api_base", difyAPIBase),
|
||
zap.Bool("has_dify_key", difyAPIKey != ""),
|
||
)
|
||
|
||
// 创建 AI Provider(Dify 或 LLM)
|
||
var aiProvider service.AIProvider
|
||
if difyAPIKey != "" {
|
||
difyClient := service.NewDifyClient(service.DifyConfig{
|
||
APIBase: difyAPIBase,
|
||
APIKey: difyAPIKey,
|
||
TimeoutSec: 120,
|
||
})
|
||
aiProvider = service.NewDifyAdapter(difyClient)
|
||
logger.Logger.Info("Using Dify as AI provider")
|
||
} else {
|
||
llmService := service.NewLLMService(
|
||
miniMaxAPIURL,
|
||
miniMaxAPIKey,
|
||
miniMaxModel,
|
||
qwenAPIURL,
|
||
qwenAPIKey,
|
||
qwenModel,
|
||
)
|
||
aiProvider = llmService
|
||
logger.Logger.Info("Using LLM (MiniMax) as AI provider")
|
||
}
|
||
|
||
// 创建 Service 层实例
|
||
personaService := service.NewPersonaService(personaRepo)
|
||
memoryService := service.NewMemoryService(shortTermMemoryRepo, longTermMemoryRepo)
|
||
auditService := service.NewAuditService()
|
||
chatService := service.NewChatService(
|
||
aiProvider,
|
||
personaService,
|
||
memoryService,
|
||
auditService,
|
||
*contextTTL,
|
||
*triggerTurns,
|
||
)
|
||
logger.Logger.Info("Service layer initialized")
|
||
|
||
// 创建 Provider 层实例
|
||
aiChatProvider := provider.NewAIChatProvider(
|
||
chatService,
|
||
personaService,
|
||
memoryService,
|
||
auditService,
|
||
)
|
||
logger.Logger.Info("Provider layer initialized")
|
||
|
||
// 创建 Dubbo 服务器
|
||
srv, err := server.NewServer(
|
||
server.WithServerProtocol(
|
||
protocol.WithPort(*port),
|
||
protocol.WithTriple(),
|
||
),
|
||
)
|
||
if err != nil {
|
||
logger.Logger.Fatal(fmt.Sprintf("Failed to create Dubbo server: %v", err))
|
||
}
|
||
|
||
// 注册 AI Chat Service
|
||
if err := pbAIChat.RegisterAIChatServiceHandler(srv, aiChatProvider); err != nil {
|
||
logger.Logger.Fatal(fmt.Sprintf("Failed to register AI Chat Service: %v", err))
|
||
}
|
||
|
||
// 启动服务
|
||
if err := srv.Serve(); err != nil {
|
||
logger.Logger.Fatal(fmt.Sprintf("Failed to start AI Chat Service: %v", err))
|
||
}
|
||
|
||
logger.Logger.Info(fmt.Sprintf("AI Chat Service started successfully on port %d", *port))
|
||
|
||
// 等待退出信号
|
||
quit := make(chan os.Signal, 1)
|
||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||
<-quit
|
||
|
||
logger.Logger.Info("Shutting down AI Chat Service...")
|
||
|
||
if healthHandler != nil {
|
||
healthHandler.Stop()
|
||
}
|
||
|
||
redisClient.Close()
|
||
}
|
||
|
||
// autoMigrate 自动迁移数据库表
|
||
func autoMigrate() error {
|
||
db := database.GetDB()
|
||
if db == nil {
|
||
return fmt.Errorf("database is not initialized")
|
||
}
|
||
|
||
tables := []interface{}{
|
||
&model.Persona{},
|
||
&model.UserMemory{},
|
||
&model.Config{},
|
||
}
|
||
|
||
for _, table := range tables {
|
||
if err := db.AutoMigrate(table); err != nil {
|
||
// 如果约束删除失败(表已存在该约束),忽略并继续
|
||
logger.Logger.Warn(fmt.Sprintf("Migration warning for %T: %v", table, err))
|
||
}
|
||
}
|
||
|
||
logger.Logger.Info("Database migration completed successfully")
|
||
return nil
|
||
} |