This commit is contained in:
mlogclub
2026-04-09 10:01:23 +08:00
commit efe801b8bf
707 changed files with 110595 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"cs-agent/internal/pkg/config"
"github.com/mlogclub/simple/sqls"
"gorm.io/driver/mysql"
// "gorm.io/driver/sqlite" // Sqlite driver based on CGO
"github.com/glebarez/sqlite" // Pure go SQLite driver, checkout https://github.com/glebarez/sqlite for details
"gorm.io/gorm"
// "gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
func InitDB(cfg config.DBConfig) (*gorm.DB, error) {
var dialector gorm.Dialector
switch cfg.Type {
case "sqlite":
if err := ensureSQLiteDir(cfg.DSN); err != nil {
return nil, err
}
dialector = sqlite.Open(cfg.DSN)
case "mysql":
dialector = mysql.Open(cfg.DSN)
default:
return nil, fmt.Errorf("unsupported db type: %s", cfg.Type)
}
db, err := gorm.Open(dialector, &gorm.Config{
// Logger: logger.New(
// log.New(os.Stdout, "\r\n", log.LstdFlags),
// logger.Config{
// SlowThreshold: time.Second,
// LogLevel: logger.Info,
// IgnoreRecordNotFoundError: true,
// Colorful: true,
// },
// ),
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
if cfg.MaxIdleConns > 0 {
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
}
if cfg.MaxOpenConns > 0 {
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
}
if cfg.ConnMaxIdleTimeSeconds > 0 {
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTimeSeconds) * time.Second)
}
if cfg.ConnMaxLifetimeSeconds > 0 {
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSeconds) * time.Second)
}
sqls.SetDB(db)
return db, nil
}
func ensureSQLiteDir(dsn string) error {
dbPath := sqliteFilePath(dsn)
if dbPath == "" {
return nil
}
dir := filepath.Dir(dbPath)
if dir == "." || dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
func sqliteFilePath(dsn string) string {
if dsn == "" {
return ""
}
path := dsn
if after, ok := strings.CutPrefix(path, "file:"); ok {
path = after
}
if idx := strings.Index(path, "?"); idx >= 0 {
path = path[:idx]
}
normalized := strings.TrimSpace(path)
if normalized == "" || normalized == ":memory:" || strings.Contains(normalized, "mode=memory") {
return ""
}
return normalized
}
+66
View File
@@ -0,0 +1,66 @@
package bootstrap
import (
"os"
"path/filepath"
"testing"
)
func TestSQLiteFilePath(t *testing.T) {
t.Parallel()
cases := []struct {
name string
dsn string
want string
}{
{
name: "plain relative path",
dsn: "./data/app.db",
want: "./data/app.db",
},
{
name: "file uri with query",
dsn: "file:./data/app.db?_busy_timeout=5000",
want: "./data/app.db",
},
{
name: "memory dsn",
dsn: "file::memory:?cache=shared",
want: "",
},
{
name: "memory alias",
dsn: ":memory:",
want: "",
},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := sqliteFilePath(tt.dsn); got != tt.want {
t.Fatalf("sqliteFilePath(%q) = %q, want %q", tt.dsn, got, tt.want)
}
})
}
}
func TestEnsureSQLiteDir(t *testing.T) {
t.Parallel()
baseDir := t.TempDir()
dbPath := filepath.Join(baseDir, "nested", "app.db")
dsn := "file:" + dbPath + "?_busy_timeout=5000"
if err := ensureSQLiteDir(dsn); err != nil {
t.Fatalf("ensureSQLiteDir() error = %v", err)
}
if info, err := os.Stat(filepath.Dir(dbPath)); err != nil {
t.Fatalf("os.Stat() error = %v", err)
} else if !info.IsDir() {
t.Fatalf("expected %q to be a directory", filepath.Dir(dbPath))
}
}
+44
View File
@@ -0,0 +1,44 @@
package bootstrap
import (
"cs-agent/internal/ai/rag/vectordb"
"cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/logx"
"cs-agent/internal/services/cronx"
"cs-agent/internal/wxwork"
"log/slog"
)
func Init(configPath string) error {
cfg, err := config.Load(configPath)
if err != nil {
slog.Error("init config failed", "error", err)
return err
}
config.SetCurrent(cfg)
logx.Init(logx.Config{
Level: cfg.Logger.Level,
Format: cfg.Logger.Format,
AddSource: cfg.Logger.AddSource,
})
if _, err := InitDB(cfg.DB); err != nil {
slog.Error("init db failed", "error", err)
return err
}
if err := InitMigrations(); err != nil {
slog.Error("init migrations failed", "error", err)
return err
}
if err := vectordb.Init(&cfg.VectorDB); err != nil {
slog.Error("init vector db failed", "error", err)
return err
}
// 启动任务调度器
cronx.Init()
wxwork.Init()
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package bootstrap
import (
"cs-agent/internal/migration"
"cs-agent/internal/models"
"github.com/mlogclub/simple/sqls"
)
func InitMigrations() error {
if err := sqls.DB().AutoMigrate(models.Models...); err != nil {
return err
}
return migration.Migrate()
}
+150
View File
@@ -0,0 +1,150 @@
package bootstrap
import (
"log/slog"
"net/http"
"strings"
"time"
"cs-agent/internal/ai/mcps"
_ "cs-agent/internal/ai/runtime"
"cs-agent/internal/controllers/api"
"cs-agent/internal/controllers/console"
"cs-agent/internal/controllers/open"
"cs-agent/internal/controllers/third"
"cs-agent/internal/middleware"
"cs-agent/internal/pkg/config"
"github.com/kataras/iris/v12"
"github.com/kataras/iris/v12/middleware/cors"
"github.com/kataras/iris/v12/middleware/recover"
"github.com/kataras/iris/v12/mvc"
_ "cs-agent/internal/services/wx_callback_handlers"
)
func NewServer() (*iris.Application, error) {
cfg := config.Current()
app := iris.New()
corsHandler := cors.New().
AllowOrigin("*").
AllowHeaders("Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Visitor-Id", "X-Channel-Id", "X-External-Source", "X-External-Id", "X-External-Name").
MaxAge(600).
ExposeHeaders("Content-Length", "Content-Type", "Authorization", "X-Visitor-Id", "X-Channel-Id", "X-External-Source", "X-External-Id", "X-External-Name").
Handler()
app.UseRouter(func(ctx iris.Context) {
// WebSocket upgrade is validated by the upgrader's origin policy.
if isWebsocketUpgrade(ctx) {
ctx.Next()
return
}
corsHandler(ctx)
})
app.UseRouter(recover.New())
app.UseRouter(func(ctx iris.Context) {
start := time.Now()
path := ctx.Path()
method := ctx.Method()
ctx.Next()
slog.Info("http request",
"method", method,
"path", path,
"status", ctx.GetStatusCode(),
"elapsed", time.Since(start).Milliseconds(),
"clientIp", ctx.RemoteAddr(),
)
})
app.UseRouter(func(ctx iris.Context) {
ctx.SetMaxRequestBodySize(cfg.Storage.MaxRequestBodySizeBytes())
ctx.Next()
})
// 注册路由
addRouter(app)
// 注册本地存储静态资源服务
app.HandleDir(cfg.Storage.Local.BaseURL, iris.Dir(cfg.Storage.Local.Root), iris.DirOptions{
ShowList: false,
})
// 注册web静态资源服务
app.HandleDir("/", iris.Dir("web/out"), iris.DirOptions{
IndexName: "index.html",
Compress: true,
ShowList: false,
})
// 注册widget静态资源服务
app.HandleDir("/widget", iris.Dir("widget/out"), iris.DirOptions{
IndexName: "index.html",
Compress: true,
ShowList: false,
})
return app, nil
}
func isWebsocketUpgrade(ctx iris.Context) bool {
if !strings.EqualFold(ctx.GetHeader("Upgrade"), "websocket") {
return false
}
return strings.Contains(strings.ToLower(ctx.GetHeader("Connection")), "upgrade")
}
func addRouter(app *iris.Application) {
mcpHandler := mcps.NewHTTPHandler()
app.Any("/api/mcp", iris.FromStd(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mcpHandler.ServeHTTP(w, r)
})))
app.Get("/api/console/ws", middleware.AuthMiddleware, middleware.ConsoleWsMiddleware)
app.Get("/api/open/im/ws", middleware.OpenImWsMiddleware)
mvc.Configure(app.Party("/api/auth"), func(m *mvc.Application) {
m.Handle(new(api.AuthController))
})
mvc.Configure(app.Party("/api/console", middleware.AuthMiddleware), func(m *mvc.Application) {
m.Party("/dashboard").Handle(new(console.DashboardController))
m.Party("/user").Handle(new(console.UserController))
m.Party("/company").Handle(new(console.CompanyController))
m.Party("/customer").Handle(new(console.CustomerController))
m.Party("/customer-contact").Handle(new(console.CustomerContactController))
m.Party("/role").Handle(new(console.RoleController))
m.Party("/permission").Handle(new(console.PermissionController))
m.Party("/session").Handle(new(console.SessionController))
m.Party("/tag").Handle(new(console.TagController))
m.Party("/conversation").Handle(new(console.ConversationController))
m.Party("/ticket").Handle(new(console.TicketController))
m.Party("/ticket-resolution-code").Handle(new(console.TicketResolutionCodeController))
m.Party("/ticket-priority-config").Handle(new(console.TicketPriorityConfigController))
m.Party("/quick-reply").Handle(new(console.QuickReplyController))
m.Party("/channel").Handle(new(console.ChannelController))
m.Party("/agent").Handle(new(console.AgentController))
m.Party("/agent-team").Handle(new(console.AgentTeamController))
m.Party("/agent-team-schedule").Handle(new(console.AgentTeamScheduleController))
m.Party("/ai-agent").Handle(new(console.AIAgentController))
m.Party("/ai-config").Handle(new(console.AIConfigController))
m.Party("/asset").Handle(new(console.AssetController))
m.Party("/knowledge-base").Handle(new(console.KnowledgeBaseController))
m.Party("/knowledge-document").Handle(new(console.KnowledgeDocumentController))
m.Party("/knowledge-faq").Handle(new(console.KnowledgeFAQController))
m.Party("/knowledge-retrieve").Handle(new(console.KnowledgeRetrieveController))
m.Party("/knowledge-retrieve-log").Handle(new(console.KnowledgeRetrieveLogController))
m.Party("/agent-run-log").Handle(new(console.AgentRunLogController))
m.Party("/skill-definition").Handle(new(console.SkillDefinitionController))
m.Party("/mcp").Handle(new(console.MCPController))
})
mvc.Configure(app.Party("/api/open/im", middleware.OpenImContextMiddleware), func(m *mvc.Application) {
m.Party("/widget").Handle(new(open.ImWidgetController))
m.Party("/conversation").Handle(new(open.ImConversationController))
m.Party("/message").Handle(new(open.ImMessageController))
})
mvc.Configure(app.Party("/api/third"), func(m *mvc.Application) {
m.Party("/wechat").Handle(new(third.WechatController))
})
}