Files
gochat/backend/internal/app/app.go
T
Rogeeandrogee f36606a4f2 HH-564: harden durable realtime publish boundaries (#139)
* fix(HH-564): harden durable realtime enqueue

* fix(HH-564): wire production SSE stream

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-23 23:38:00 +08:00

387 lines
11 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/canned"
"github.com/gochat/gochat/internal/config"
ws "github.com/gochat/gochat/internal/handler/ws"
"github.com/gochat/gochat/internal/lifecycle"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/pubsub"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/internal/worker"
wspkg "github.com/gochat/gochat/internal/ws"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// App is the central application container holding all shared services.
type App struct {
config *config.Config
reloader *config.ConfigReloader
db *gorm.DB
pubsub pubsub.PubSub
engine *gin.Engine
wsHub *ws.Hub
wsRelay *wspkg.BroadcastRelay
eventPublisher *wspkg.EventPublisher
notificationDeliverySvc *service.NotificationDeliveryService
workerPool *worker.WorkerPool
ready *atomic.Bool
notificationRunning atomic.Bool
handlerGroupOnce sync.Once
handlerGroup *lifecycle.HandlerGroup
}
// New creates and initializes the application.
func New(cfg *config.Config) (*App, error) {
// Initialize database
db, err := initDB(cfg)
if err != nil {
return nil, fmt.Errorf("failed to init database: %w", err)
}
// Auto-migrate core models
if err := autoMigrate(db); err != nil {
return nil, fmt.Errorf("failed to auto-migrate: %w", err)
}
// Initialize gin engine
gin.SetMode(cfg.Server.Mode)
engine := gin.New()
engine.Use(gin.Recovery())
// Initialize pub/sub — Redis-backed when Redis is configured, in-memory fallback
var ps pubsub.PubSub
rdb, redisErr := NewRedisClient(&cfg.Redis)
if redisErr != nil {
applogger.L().Warnf("Redis unavailable, using in-memory pubsub: %v", redisErr)
ps = pubsub.NewInMemoryPubSub()
} else {
redisPubSub, pubsubErr := pubsub.NewRedisPubSub(rdb)
if pubsubErr != nil {
applogger.L().Warnf("Redis PubSub init failed, falling back to in-memory: %v", pubsubErr)
ps = pubsub.NewInMemoryPubSub()
} else {
ps = redisPubSub
applogger.L().Info("Redis PubSub initialized (watermill-redisstream)")
}
}
application := &App{
config: cfg,
db: db,
pubsub: ps,
engine: engine,
}
return application, nil
}
// Run starts only the web process. Background jobs are consumed by RunWorker.
func (a *App) Run() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
return a.runWeb(ctx)
}
func (a *App) runWeb(ctx context.Context) error {
lifecycle, cancel := context.WithCancel(context.Background())
defer cancel()
if a.wsHub != nil {
go a.wsHub.Run(lifecycle)
}
if a.wsRelay != nil {
if err := a.wsRelay.Start(lifecycle); err != nil {
return fmt.Errorf("failed to start WebSocket relay: %w", err)
}
}
server := a.HTTPServer()
serveErr := make(chan error, 1)
go func() {
log.Printf("Starting gochat web server on %s", server.Addr)
serveErr <- server.ListenAndServe()
}()
select {
case err := <-serveErr:
cancel()
_ = a.Shutdown(a.shutdownTimeout())
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
}
if a.ready != nil {
a.ready.Store(false)
}
timeout := a.shutdownTimeout()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), timeout)
defer shutdownCancel()
httpErr := server.Shutdown(shutdownCtx)
cancel()
return errors.Join(httpErr, a.shutdown(shutdownCtx))
}
// RunWorker consumes background jobs and notifications without opening an HTTP listener.
func (a *App) RunWorker() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
return a.runWorker(ctx)
}
func (a *App) runWorker(ctx context.Context) error {
if a.workerPool != nil {
if err := a.workerPool.Start(); err != nil {
return fmt.Errorf("failed to start background worker: %w", err)
}
applogger.L().Info("Background worker started")
}
notificationErr := make(chan error, 1)
if a.notificationDeliverySvc != nil {
a.notificationRunning.Store(true)
go func() { notificationErr <- a.notificationDeliverySvc.Start(a.handlers().Context()) }()
applogger.L().Info("Notification delivery pipeline started")
}
select {
case <-ctx.Done():
case err := <-notificationErr:
if err != nil {
return errors.Join(err, a.Shutdown(a.shutdownTimeout()))
}
}
return a.Shutdown(a.shutdownTimeout())
}
// HTTPServer applies the production request-boundary limits from configuration.
func (a *App) HTTPServer() *http.Server {
cfg := a.config.Server
return &http.Server{
Addr: a.Address(),
Handler: a.Handler(),
ReadHeaderTimeout: positiveDuration(cfg.ReadHeaderTimeoutS, 5*time.Second),
ReadTimeout: positiveDuration(cfg.ReadTimeoutS, 30*time.Second),
WriteTimeout: positiveDuration(cfg.WriteTimeoutS, 30*time.Second),
IdleTimeout: positiveDuration(cfg.IdleTimeoutS, 120*time.Second),
MaxHeaderBytes: positiveInt(cfg.MaxHeaderBytes, 1<<20),
}
}
func (a *App) shutdownTimeout() time.Duration {
return positiveDuration(a.config.Server.ShutdownTimeoutS, 30*time.Second)
}
func positiveDuration(seconds int, fallback time.Duration) time.Duration {
if seconds <= 0 {
return fallback
}
return time.Duration(seconds) * time.Second
}
func positiveInt(value, fallback int) int {
if value <= 0 {
return fallback
}
return value
}
// Address returns the server listen address.
func (a *App) Address() string {
return fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
}
// Handler returns the gin engine as http.Handler for use with http.Server.
func (a *App) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlers := a.handlers()
if !handlers.Begin() {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
defer handlers.Done()
a.engine.ServeHTTP(w, r)
})
}
func (a *App) handlers() *lifecycle.HandlerGroup {
a.handlerGroupOnce.Do(func() {
if a.handlerGroup == nil {
a.handlerGroup = lifecycle.NewHandlerGroup()
}
})
return a.handlerGroup
}
// Config returns the application config.
func (a *App) Config() *config.Config {
return a.config
}
// DB returns the gorm.DB instance.
func (a *App) DB() *gorm.DB {
return a.db
}
// PubSub returns the pub/sub instance.
func (a *App) PubSub() pubsub.PubSub {
return a.pubsub
}
// Reloader returns the config hot-reloader (may be nil if not initialized).
func (a *App) Reloader() *config.ConfigReloader {
return a.reloader
}
func initDB(cfg *config.Config) (*gorm.DB, error) {
gormLogger := logger.New(
log.Default(),
logger.Config{LogLevel: logger.Info},
)
db, err := gorm.Open(postgres.Open(cfg.Database.DSN), &gorm.Config{
Logger: gormLogger,
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
return db, nil
}
func autoMigrate(db *gorm.DB) error {
models := []interface{}{
&model.Account{},
&model.User{},
&model.Conversation{},
&model.Message{},
&model.Contact{},
&model.Inbox{},
&model.Attachment{},
&model.ContactInbox{},
&model.InboxMember{},
&model.Team{},
&model.TeamMember{},
&model.ReportingEvent{},
&model.ReportingEventsRollup{},
&model.DashboardApp{},
// G4: Company module models (CRUD + search + nested contacts/conversations/notes)
&model.Company{},
&model.CompanyNote{},
&automation.CsatSurveyResponse{},
&canned.CannedResponse{},
&model.PushToken{},
&model.WebhookSubscription{},
&model.WebhookDelivery{},
// M13: OIDC enterprise authentication model
&model.AccountOIDCSettings{},
// P9: AgentBot rule engine models (defined in automation package to avoid import cycle)
&automation.BotRule{},
&automation.BotTriggerConfig{},
// M6: Automation & Macro models
&automation.AutomationRule{},
&automation.AutomationExecution{},
&automation.Macro{},
&automation.MacroExecution{},
// M11: WebWidget channel models — theme config, pre-chat form, file upload, offline messages
&model.WidgetThemeConfig{},
&model.PreChatForm{},
&model.WidgetFileUpload{},
&model.WidgetOfflineMessage{},
// M11: SLA Policy models — policy, applied SLA, events, inbox associations
&model.SlaPolicy{},
&model.SlaPolicyInbox{},
&model.AppliedSLA{},
&model.SlaEvent{},
// Chatwoot assignment policies + inbox association.
&model.AssignmentPolicy{},
&model.InboxAssignmentPolicy{},
// Legacy enhanced assignment policy aliases kept for local compatibility.
&model.AssignmentPolicyV2{},
&model.AssignmentPolicyInbox{},
// Enterprise: Audit log model (read-only log of account actions)
&model.Audit{},
// Enterprise: Custom role for fine-grained permissions
&model.CustomRole{},
// Enterprise: Agent capacity policies + inbox capacity limits
&model.AgentCapacityPolicy{},
&model.InboxCapacityLimit{},
// Upload: Direct upload model (account-level + widget direct uploads)
&model.DirectUpload{},
// Channel models — existing + new channels (TikTok, LINE, Twilio SMS)
&channelmodel.ChannelTelegram{},
&channelmodel.ChannelWebWidget{},
&channelmodel.ChannelFacebook{},
&channelmodel.ChannelWhatsApp{},
&channelmodel.ChannelEmail{},
&channelmodel.ChannelTikTok{},
&channelmodel.ChannelLINE{},
&channelmodel.ChannelTwilioSMS{},
// W0: Additional channel models not previously registered
&channelmodel.ChannelAPI{},
&channelmodel.ChannelGoogle{},
&channelmodel.ChannelMicrosoft{},
&channelmodel.ChannelTwitter{},
// Platform: InstallationConfig — global key-value config for super-admin
&model.InstallationConfig{},
// Banner — platform announcement banners
&model.Banner{},
// WidgetTest — platform read-only, widget/UI integration testing
&model.WidgetTest{},
// W0: Activated stub models — previously .go.txt, now compiled
&model.AutomationAction{},
&model.Call{},
&model.CaptainAssistantInbox{},
&model.Contactable{},
&model.ContactExport{},
&model.DataImport{},
&model.EmailTemplate{},
&model.MessageReaction{},
&model.Report{},
&model.BackgroundJob{},
&model.UserSession{},
&model.CaptainMessageReport{},
&model.CaptainSkill{},
&model.CaptainSkillReference{},
&model.CaptainAssistantSkill{},
// S6: WorkingHour — out-of-office / business hours per inbox
&model.WorkingHour{},
&model.ChannelShangwutongConfig{},
// Notification settings — per-user per-account notification preferences
&model.NotificationSetting{},
}
for _, m := range models {
if err := db.AutoMigrate(m); err != nil {
return fmt.Errorf("failed to auto-migrate %T: %w", m, err)
}
}
return nil
}