// Package app 是应用的装配根:把 config、logger、DB 池、各业务模块、HTTP // 路由按依赖顺序拼起来,并暴露 Run 给入口程序。本包只做组合,不引入新业务。 // // 启动顺序: // 1. 用 config.Env 派生 logger(FromEnv 在生产环境默认开启 source)。 // 2. 跑 migrations(先 migrate 再开池,避免应用启动时表还没建好;migrate // 失败直接返错由调用方记录、退出)。 // 3. 创建 *db.Pool(实际是 *pgxpool.Pool 别名),失败时不再继续。 // 4. 模块装配:audit recorder -> user repo+service(注入 audit)-> notify // repo+dispatcher(注入 dummy notifier)-> notify handler(复用 userSvc // 作为 SessionResolver,user.Service 接口本身满足该契约)。 // 5. 路由:httpx.NewRouter 返回 chi.Router 接口,直接挂 RequestID/Recover/ // Logger 三个全局中间件,再让模块 Mount 自己的子路由。 // 6. 可选 bootstrap:仅当配置里同时给了 admin 邮箱/密码时尝试。失败仅 // Warn 不阻塞启动。 // 7. 把所有依赖封装到 *App,由 Run 负责启动 http server 与优雅关停。 package app import ( "context" "embed" "errors" "fmt" "io/fs" "log/slog" "net/http" "time" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/config" "github.com/yan1h/agent-coding-workflow/internal/infra/db" "github.com/yan1h/agent-coding-workflow/internal/infra/logger" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/user" ) // App 持有运行期所有需要被关停的资源。字段保持非导出,避免外部直接操作。 type App struct { cfg *config.Config log *slog.Logger pool *db.Pool server *http.Server dispatcher *notify.Dispatcher } // New 把所有模块按启动顺序拼装好。任何阶段失败都会返回错误,不留半成品资源。 // dist 是 cmd/server 用 //go:embed 注入的前端构建产物,根目录为 web/dist; // fs.Sub 之后交给 SPA handler 提供静态文件 + index.html 回退。 func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { log := logger.FromEnv(cfg.Env, nil) // 跑 migrations 必须在开池前;否则首次启动连不存在的表会让 ping 失败。 if err := db.Migrate(ctx, cfg.DB.DSN, "file://migrations"); err != nil { return nil, fmt.Errorf("migrate: %w", err) } pool, err := db.NewPool(ctx, cfg.DB.DSN) if err != nil { return nil, err } auditRec := audit.NewPostgresRecorder(pool) userRepo := user.NewPostgresRepository(pool) userSvc := user.NewService(userRepo, auditRec) notifyRepo := notify.NewPostgresRepository(pool) notifyDispatcher := notify.NewDispatcher(notifyRepo, log) notifyDispatcher.Register(notify.NewDummyNotifier(log)) spaSub, err := fs.Sub(dist, "web/dist") if err != nil { return nil, fmt.Errorf("sub fs: %w", err) } spaHandler := httpx.NewSPAHandler(spaSub) r := httpx.NewRouter(httpx.RouterDeps{ HealthCheck: func(req *http.Request) error { return db.HealthCheck(req.Context(), pool) }, ReadyCheck: func(req *http.Request) error { return db.HealthCheck(req.Context(), pool) }, SPAHandler: spaHandler, }) // chi.Router 接口本身已经声明 Use,无需类型断言到 *chi.Mux。 r.Use(middleware.RequestID) r.Use(middleware.Recover(log)) r.Use(middleware.Logger(log)) // userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。 user.NewHandler(userSvc).Mount(r) notify.NewHandler(notifyRepo, userSvc).Mount(r) if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" { if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil { log.Warn("bootstrap admin failed", "err", err.Error()) } else if u != nil { log.Info("bootstrap admin created", "email", u.Email) } } srv := &http.Server{ Addr: cfg.HTTP.Addr, Handler: r, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } return &App{cfg: cfg, log: log, pool: pool, server: srv, dispatcher: notifyDispatcher}, nil } // Run 启动 HTTP server 并阻塞直到 ctx 取消(Shutdown)或服务器报错。 // 关停路径上保证关闭 db pool;返回 nil 表示由 ctx 取消触发的正常关停。 func (a *App) Run(ctx context.Context) error { a.log.Info("server starting", "addr", a.cfg.HTTP.Addr, "env", a.cfg.Env) errCh := make(chan error, 1) go func() { if err := a.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() select { case <-ctx.Done(): a.log.Info("shutdown signal received, draining...") shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() shutErr := a.server.Shutdown(shutCtx) if shutErr != nil { a.log.Error("server shutdown", "err", shutErr.Error()) } // server 已停止接收新请求;等异步通知投递结束再关 pool if err := a.dispatcher.Shutdown(shutCtx); err != nil { a.log.Warn("notify dispatcher drain", "err", err.Error()) } a.pool.Close() return shutErr case err := <-errCh: drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = a.dispatcher.Shutdown(drainCtx) a.pool.Close() return err } }