// Команда api — точка входа HTTP-сервера: конфигурация, роуты, // лимитеры и graceful shutdown. Запуск: go run ./cmd/api. package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "strconv" "strings" "syscall" "time" "github.com/gin-gonic/gin" "github.com/joho/godotenv" "building-mgmt/internal/auth" "building-mgmt/internal/crypt" "building-mgmt/internal/db" "building-mgmt/internal/handlers" "building-mgmt/internal/logging" "building-mgmt/internal/mailer" "building-mgmt/internal/middleware" "building-mgmt/internal/notify" "building-mgmt/internal/ratelimit" "building-mgmt/internal/repository" "building-mgmt/internal/storage" "building-mgmt/internal/telegram" // Импорт регистрирует сгенерированную схему (docs/swagger.json) // для Swagger UI — без него /swagger/index.html не найдёт doc.json. _ "building-mgmt/docs" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) // @title Building Management API // @version 1.0 // @description Управление заявками на обслуживание: админ создаёт клиента, клиент заводит проект, объекты в нём и мастеров, назначает заявку мастеру проекта, мастер выполняет, клиент подтверждает работу или отклоняет её на доработку. Финальный статус — closed. // @BasePath / // @host localhost:8080 // @schemes http // @securityDefinitions.apikey Bearer // @in header // @name Authorization // @description JWT формата Bearer, получить через POST /auth/login. Срок жизни — 1 час, дальше POST /auth/refresh. func main() { os.Exit(run()) } // run возвращает код выхода, а не вызывает os.Exit внутри: так defer // (закрытие пула) гарантированно отрабатывает — в том числе после // graceful shutdown. `log.Fatal` здесь был бы той же ловушкой. func run() int { _ = godotenv.Load() logger := logging.Setup() // Фейл-фаст до подключения к БД: без секрета токены подписывались бы // пустой строкой, и любой знал бы «секрет». jwtSecret := os.Getenv("JWT_SECRET") if jwtSecret == "" { logger.Error("JWT_SECRET is not set — refusing to start") return 1 } if len(jwtSecret) < 32 { logger.Warn("JWT_SECRET is short", "length", len(jwtSecret), "recommended", 32) } // gin по умолчанию пишет баннер и свой цветной access-log поверх // структурных логов. GIN_MODE задаётся явно — режим не трогаем. if os.Getenv("GIN_MODE") == "" { gin.SetMode(gin.ReleaseMode) } ctx := context.Background() pool, err := db.Connect(ctx, os.Getenv("DATABASE_URL")) if err != nil { logger.Error("db connect failed", "err", err) return 1 } defer pool.Close() logger.Info("DB connected") if err := db.Migrate(ctx, pool); err != nil { logger.Error("migrations failed", "err", err) return 1 } profileRepo := repository.NewProfileRepo(pool) refreshRepo := repository.NewRefreshTokenRepo(pool) // Secure-флаг на refresh-куке: локально сайт по HTTP и браузер // отверг бы Secure-куку, поэтому включается только в проде (COOKIE_SECURE). cookieSecure := os.Getenv("COOKIE_SECURE") == "true" logger.Info("refresh cookie", "secure", cookieSecure) // Почта подтверждений (SMTP, go-smtp): пустой SMTP_HOST — заглушка // без отправки (dev), неверные настройки — фейл-фаст на старте, // как у S3 и JWT: сюрприз на первом подтверждении не нужен. ml, err := mailer.New(mailer.Config{ Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), User: os.Getenv("SMTP_USER"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM"), TLS: os.Getenv("SMTP_TLS"), BaseURL: os.Getenv("APP_BASE_URL"), }, logger) if err != nil { logger.Error("smtp config failed", "err", err) return 1 } // Лимит писем «забыли пароль» на ящик: 3 в сутки, ключ — email // (смотрит сам хендлер — middleware тут не подходит, ключ зависит // от тела запроса). Стоит здесь, потому что уходит в конструктор // хендлера; IP-лимиты того же эндпоинта — в общем блоке ниже. forgotMailLimiter := ratelimit.NewDB(pool, "forgot_mail", 3, 24*time.Hour) authHandler := handlers.NewAuthHandler(profileRepo, refreshRepo, jwtSecret, cookieSecure, ml, forgotMailLimiter) userHandler := handlers.NewUserHandler(profileRepo, refreshRepo, ml) projectRepo := repository.NewProjectRepo(pool) ticketRepo := repository.NewTicketRepo(pool) ticketHandler := handlers.NewTicketHandler(ticketRepo, profileRepo, projectRepo) // Правила повторяющихся заявок (миграция 0014): репозиторий — здесь, // хендлер собирается ниже, после Telegram-блока (ему нужно на уведомление // при ручном запуске), фоновое создание заявок — горутина runRecurring. recurringRepo := repository.NewRecurringRepo(pool) // Объектное хранилище фото (S3): локально MinIO в compose, в проде — // любой S3-совместимый. Фейл-фаст как с JWT: без бакета загрузка // упала бы на первом же фото, а не на старте. s3Cfg, err := storage.ConfigFromEnv() if err != nil { logger.Error("s3 config failed", "err", err) return 1 } photoStore, err := storage.NewS3(ctx, s3Cfg) if err != nil { logger.Error("s3 init failed", "err", err) return 1 } if err := photoStore.EnsureBucket(ctx); err != nil { logger.Error("s3 bucket ensure failed", "err", err) return 1 } logger.Info("S3 ready", "endpoint", s3Cfg.Endpoint, "bucket", photoStore.Bucket()) // Проектный хендлер — после хранилища: удаление проекта чистит // объекты фото заявок (store в конструкторе). projectHandler := handlers.NewProjectHandler(projectRepo, profileRepo, photoStore) // Лимиты фотоотчёта: 0 → дефолт внутри хендлера (10 МБ / 20 файлов). photoMaxBytes := int64(0) if v := os.Getenv("PHOTO_MAX_BYTES"); v != "" { n, err := strconv.ParseInt(v, 10, 64) if err != nil || n <= 0 { logger.Error("PHOTO_MAX_BYTES must be a positive number of bytes", "value", v) return 1 } photoMaxBytes = n } photoMaxPer := 0 if v := os.Getenv("PHOTO_MAX_PER_TICKET"); v != "" { n, err := strconv.Atoi(v) if err != nil || n <= 0 { logger.Error("PHOTO_MAX_PER_TICKET must be a positive number", "value", v) return 1 } photoMaxPer = n } photoRepo := repository.NewPhotoRepo(pool) photoHandler := handlers.NewPhotoHandler(ticketRepo, photoRepo, projectRepo, photoStore, photoMaxBytes, photoMaxPer) // Протухшие сессии никому не нужны: чистим один раз при старте, // дальше таблица держится в актуальном состоянии сама. if n, err := refreshRepo.DeleteExpired(ctx); err != nil { logger.Warn("refresh tokens cleanup failed", "err", err) } else if n > 0 { logger.Info("refresh tokens cleanup", "removed", n) } // Автоочистка старых заявок: закрытые старше TICKET_RETENTION_DAYS // (дефолт 60 дней, 0 — чистка выключена). Прогон сразу при старте и // дальше раз в сутки: заявки накапливаются медленно, суточного тика // хватает, а таблица не растёт без верхней границы. retentionDays := 60 if v := os.Getenv("TICKET_RETENTION_DAYS"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 0 { logger.Error("TICKET_RETENTION_DAYS must be a non-negative number of days", "value", v) return 1 } retentionDays = n } if retentionDays > 0 { ticketsCtx, ticketsCancel := context.WithCancel(context.Background()) defer ticketsCancel() go runTicketsCleanup(ticketsCtx, logger, ticketRepo, photoStore, time.Duration(retentionDays)*24*time.Hour) } // Брутфорс-лимиты по IP — в БД (таблица rate_limits, миграция 0009): // счётчик переживает рестарт API и общий для нескольких инстансов. // Числа — здесь же, рядом с тем, куда вешаются. loginLimiter := ratelimit.NewDB(pool, "login", 10, time.Minute) refreshLimiter := ratelimit.NewDB(pool, "refresh", 30, time.Minute) resetLimiter := ratelimit.NewDB(pool, "reset", 30, time.Minute) // forgot — IP-лимит «забыли пароль»; лимит на сам ящик (3 письма // в сутки) живёт в forgotMailLimiter рядом с конструктором хендлера. forgotLimiter := ratelimit.NewDB(pool, "forgot", 10, time.Minute) const rateLimitMsg = "too many attempts, try again later" // Чистка протухших строк лимитов — фоном, до самого выхода процесса. limiterCtx, limiterCancel := context.WithCancel(context.Background()) defer limiterCancel() go ratelimit.RunCleanup(limiterCtx, pool, time.Minute) // Telegram: напоминание клиенту о заявке, которая простояла в статусе // 'new' дольше порога, и привязка его чата по одноразовому коду. // Два источника бота: собственный токен пользователя (вводится в // «Настройках», хранится зашифрованным) и глобальный бот сервера // TELEGRAM_BOT_TOKEN как запасной для тех, кто токен не ввёл. tgCodeLimiter := ratelimit.NewDB(pool, "tg_code", 5, 5*time.Minute) // Сохранение токена — сетевой getMe, поэтому свой лимит: подбор // токенов упирается в потолок до сети и до базы. tgTokenLimiter := ratelimit.NewDB(pool, "tg_token", 5, 5*time.Minute) tgRepo := repository.NewTelegramRepo(pool) tgAPIURL := os.Getenv("TELEGRAM_API_URL") tgBot := telegram.New(telegram.Config{ Token: os.Getenv("TELEGRAM_BOT_TOKEN"), APIURL: tgAPIURL, }) // Контекст фоновых задач Telegram живёт до выхода процесса: на нём // висят нотификатор, поллер глобального бота и все поллеры личных // токенов (defer гасит их разом при graceful shutdown). tgCtx, tgCancel := context.WithCancel(context.Background()) defer tgCancel() // Реестр решает, чьим ботом слать (личный токен приоритетнее // глобального) и владеет поллерами личных ботов. tgRegistry := notify.NewRegistry(tgCtx, tgRepo, tgBot, logger) // Шифрование токенов ботов (AES-256-GCM, ключ — DATA_ENCRYPTION_KEY). // Без ключа ввод токена выключен: сохранять его некуда, а лежащие // в БД прочитать невозможно — это осознанная блокировка, а не сбой. var tgCipher *crypt.Cipher if v := os.Getenv("DATA_ENCRYPTION_KEY"); v != "" { c, err := crypt.New(v) if err != nil { logger.Error("DATA_ENCRYPTION_KEY is invalid (32 bytes, base64 or hex)", "err", err) return 1 } tgCipher = c } else { logger.Info("telegram user bot tokens disabled (DATA_ENCRYPTION_KEY is empty)") } // Порог напоминания и период тика — env с явными дефолтами: // 24 часа, как в требованиях, и минута — чтобы «взяли в работу» // заметилось почти сразу после гашения уведомления. tgAfter := 24 * time.Hour if v := os.Getenv("TELEGRAM_NOTIFY_AFTER"); v != "" { d, err := time.ParseDuration(v) if err != nil || d <= 0 { logger.Error("TELEGRAM_NOTIFY_AFTER must be a positive duration, e.g. 24h", "value", v) return 1 } tgAfter = d } tgTick := time.Minute if v := os.Getenv("TELEGRAM_TICK_EVERY"); v != "" { d, err := time.ParseDuration(v) if err != nil || d <= 0 { logger.Error("TELEGRAM_TICK_EVERY must be a positive duration, e.g. 1m", "value", v) return 1 } tgTick = d } // Ссылка в уведомлении — как и в письмах: пустой APP_BASE_URL // означает локальный запуск (см. mailer). appBaseURL := os.Getenv("APP_BASE_URL") if appBaseURL == "" { appBaseURL = "http://localhost:3000" } tgHandler := handlers.NewTelegramHandler(handlers.TelegramConfig{ Repo: tgRepo, Bots: tgRegistry, Cipher: tgCipher, // Фабрика без сети: клиент создаётся здесь, getMe делает сам // хендлер — он же отвечает за то, чтобы невалидный токен не // добрался до базы. NewBot: func(token string) telegram.API { return telegram.New(telegram.Config{Token: token, APIURL: tgAPIURL}) }, CodeLimiter: tgCodeLimiter, TokenLimiter: tgTokenLimiter, }) // Сохранённые токены читаем один раз: поднимаем клиенты и поллеры // личных ботов. Ключ сменили — токены не расшифровываются: такие // строки пропускаем с warn'ом, пользователь введёт токен заново. if tgCipher != nil { saved, err := tgRepo.BotTokens(ctx) if err != nil { logger.Error("telegram bot tokens load failed", "err", err) return 1 } for _, ub := range saved { token, err := tgCipher.Open(ub.TokenEnc) if err != nil { logger.Warn("telegram bot token unreadable (encryption key changed?)", "user_id", ub.UserID, "err", err) continue } tgRegistry.Register(ub.UserID, telegram.New(telegram.Config{ Token: token, APIURL: tgAPIURL, })) } if n := tgRegistry.Users(); n > 0 { logger.Info("telegram user bots loaded", "count", n) } } if tgBot.Enabled() { // getMe — имя бота для ссылки привязки; сбой не фатален: // повтор сделает редкий запрос кода (см. TelegramHandler.username). if err := tgBot.Init(tgCtx); err != nil { logger.Warn("telegram getMe failed", "err", err) } else { logger.Info("telegram bot ready", "username", tgBot.Username()) } // Поллер глобального бота один на процесс: несколько инстансов // драли бы одну очередь getUpdates (в контейнере их один — см. README). go notify.RunPoller(tgCtx, tgRepo, tgBot, logger) } else { logger.Info("telegram global bot disabled (TELEGRAM_BOT_TOKEN is empty)") } // Нотификатор крутится, пока есть хоть какой-то бот: без единого // токена он просто не найдёт кандидатов и не шлёт ничего. if tgBot.Enabled() || tgCipher != nil { go notify.Run(tgCtx, tgRepo, tgRegistry, tgAfter, tgTick, appBaseURL, logger) } else { logger.Info("telegram notifications disabled (no bot configured)") } // Повторяющиеся заявки: прогон сразу при старте и дальше раз в сутки // (интервалы в месяцах, суточной точности хватает). Созданные заявки // клиенту уведомляются тем же Telegram, что и обычные напоминания; // сбой отправки — warn, заявка уже создана (fail-open, как в notify). recurringCtx, recurringCancel := context.WithCancel(context.Background()) defer recurringCancel() go runRecurring(recurringCtx, logger, recurringRepo, tgRegistry, appBaseURL) // Хендлер правил — после Telegram-блока: ручной «Запустить сейчас» // шлёт уведомление тем же кодом, что и фоновое срабатывание. recurringHandler := handlers.NewRecurringHandler(recurringRepo, projectRepo, func(ctx context.Context, run repository.DueRun) { notifyRecurring(ctx, logger, tgRegistry, appBaseURL, run) }) // CORS: разрешённые источники фронта через запятую (CORS_ORIGINS). // Пусто — заголовки не ставятся вовсе: одностраничнику на том же // домене они не нужны, а чужому домену их и не должно быть. allowedOrigins := middleware.ParseOrigins(os.Getenv("CORS_ORIGINS")) if len(allowedOrigins) > 0 { logger.Info("CORS origins", "origins", allowedOrigins) } r := gin.New() // Доверенные прокси: gin по умолчанию верит любому X-Forwarded-For — // это подделка ключа рейт-лимитов (login/reset/forgot) и ложный // client_ip в логах. Без TRUSTED_PROXIES доверяем только loopback; // за reverse proxy (app-nginx в контейнерной сети) указываем его // подсеть: TRUSTED_PROXIES=172.0.0.0/12 (loopback доверяем всегда). // Прокси обязан ПЕРЕЗАПИСЫВАТЬ X-Forwarded-For ($remote_addr), // а не дополнять: gin берёт левый элемент списка. trusted := []string{"127.0.0.1/32", "::1/32"} if v := os.Getenv("TRUSTED_PROXIES"); v != "" { for _, s := range strings.Split(v, ",") { if s = strings.TrimSpace(s); s != "" { trusted = append(trusted, s) } } } if err := r.SetTrustedProxies(trusted); err != nil { logger.Error("invalid TRUSTED_PROXIES", "err", err) return 1 } logger.Info("trusted proxies", "proxies", trusted) // Порядок важен: request_id — первый, чтобы recovery и access-log // уже видели id запроса; CORS — сразу после логов, но до роутов, // чтобы preflight-OPTIONS отвечал 204 ещё до авторизации. r.Use( middleware.RequestID(), middleware.Recovery(), middleware.AccessLog(), middleware.CORS(allowedOrigins), ) r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) // Swagger UI: /swagger/index.html. Схема генерируется из аннотаций // над хендлерами командой `swag init -g cmd/api/main.go` (см. README). // По умолчанию выключен: открытая схема в проде не нужна — // включается флагом SWAGGER=true (так в локальной разработке). if os.Getenv("SWAGGER") == "true" { r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) logger.Info("swagger enabled") } // Состояние БД — отдельный хелпер, раньше был открыт всем: // по ответу было видно, что база живёт и какая версия (500 vs 200). adminOnly := r.Group("/") adminOnly.Use(auth.Middleware(jwtSecret)) adminOnly.Use(auth.RequireRole("admin")) adminOnly.GET("/db-ping", func(c *gin.Context) { if err := pool.Ping(c.Request.Context()); err != nil { // Детали (host, user, DSN) наружу не уходят — только в лог. logger.Error("db ping failed", "err", err, "request_id", middleware.RequestIDFrom(c)) c.JSON(http.StatusInternalServerError, gin.H{"error": "db unavailable"}) return } c.JSON(http.StatusOK, gin.H{"db": "ok"}) }) // Auth — открытые роуты (лимитируются, чтобы логин нельзя было перебирать) r.POST("/auth/login", ratelimit.Middleware(loginLimiter, ratelimit.ByIP, rateLimitMsg), authHandler.Login) r.POST("/auth/refresh", ratelimit.Middleware(refreshLimiter, ratelimit.ByIP, rateLimitMsg), authHandler.Refresh) r.POST("/auth/logout", ratelimit.Middleware(refreshLimiter, ratelimit.ByIP, rateLimitMsg), authHandler.Logout) // Сброс пароля: письмо со ссылкой запрашивается здесь (лимит IP + // лимит на ящик в самом хендлере), токен применяется ниже. // Лимит отдельный от логина: подбор токена должен упереться // в потолок раньше, чем в чужую сессию. r.POST("/auth/forgot-password", ratelimit.Middleware(forgotLimiter, ratelimit.ByIP, rateLimitMsg), authHandler.ForgotPassword) r.POST("/auth/reset-password", ratelimit.Middleware(resetLimiter, ratelimit.ByIP, rateLimitMsg), authHandler.ResetPassword) // Защищённые роуты — любой авторизованный authGroup := r.Group("/") authGroup.Use(auth.Middleware(jwtSecret)) { authGroup.GET("/auth/me", authHandler.Me) authGroup.POST("/auth/logout-all", authHandler.LogoutAll) // Привязка Telegram для уведомлений: свой статус, одноразовый // код привязки, ручной ввод группы/канала, отключение — плюс // собственный бот пользователя (токен хранится зашифрованным, // наружу не отдаётся; см. internal/crypt). authGroup.GET("/auth/telegram", tgHandler.Status) authGroup.POST("/auth/telegram/code", tgHandler.IssueCode) authGroup.PUT("/auth/telegram", tgHandler.Set) authGroup.DELETE("/auth/telegram", tgHandler.Unbind) authGroup.POST("/auth/telegram/bot", tgHandler.SetBot) authGroup.DELETE("/auth/telegram/bot", tgHandler.DeleteBot) } // Проекты клиентов: объекты внутри проекта и мастера проекта. // Роли проверяются внутри хендлера (админ — всё, клиент — своё); // удаление проекта вынесено на RequireRole("admin") в роуте. projectGroup := r.Group("/projects") projectGroup.Use(auth.Middleware(jwtSecret)) { projectGroup.GET("", projectHandler.List) projectGroup.POST("", projectHandler.Create) projectGroup.GET("/:id", projectHandler.Get) projectGroup.PATCH("/:id", projectHandler.Update) // Удаление проекта — только админ (решение): проект уходит // вместе с заявками, объектами и участиями (каскад 0013). projectGroup.DELETE("/:id", auth.RequireRole("admin"), projectHandler.Delete) projectGroup.GET("/:id/objects", projectHandler.ListObjects) projectGroup.POST("/:id/objects", projectHandler.CreateObject) projectGroup.GET("/:id/workers", projectHandler.ListWorkers) projectGroup.POST("/:id/workers", projectHandler.AddWorker) projectGroup.DELETE("/:id/workers/:worker_id", projectHandler.RemoveWorker) // Приглашения: решает только сам мастер (роль worker). projectGroup.POST("/:id/invitation/accept", auth.RequireRole("worker"), projectHandler.AcceptInvitation) projectGroup.POST("/:id/invitation/decline", auth.RequireRole("worker"), projectHandler.DeclineInvitation) } // Мои приглашения: каждый мастер видит только своё (worker_id берётся // из токена, не из запроса). invitationsGroup := r.Group("/invitations") invitationsGroup.Use(auth.Middleware(jwtSecret)) invitationsGroup.GET("", auth.RequireRole("worker"), projectHandler.ListInvitations) // Правка объекта вынесена на свой путь: /objects/:id, те же права. objectGroup := r.Group("/objects") objectGroup.Use(auth.Middleware(jwtSecret)) { objectGroup.PATCH("/:id", projectHandler.UpdateObject) } ticketGroup := r.Group("/tickets") ticketGroup.Use(auth.Middleware(jwtSecret)) { // Создавать могут только клиенты ticketGroup.POST("", auth.RequireRole("client"), ticketHandler.Create) // Список и детали — все авторизованные, но фильтруются по роли внутри ticketGroup.GET("", ticketHandler.List) ticketGroup.GET("/:id", ticketHandler.Get) // Журнал — те же права, что на чтение заявки (проверка внутри). ticketGroup.GET("/:id/events", ticketHandler.Events) // Фотоотчёт: список видят все, кто видит заявку; загружает и // удаляет мастер или админ — дальше те же правила проверяются // в canView и в самом SQL (статус, членство, лимит). ticketGroup.GET("/:id/photos", photoHandler.List) ticketGroup.POST("/:id/photos", auth.RequireRole("worker", "admin"), photoHandler.Upload) ticketGroup.DELETE("/:id/photos/:photo_id", auth.RequireRole("worker", "admin"), photoHandler.Delete) // Назначает админ или клиент-владелец, либо сам мастер берёт // свободную заявку (take) — дальше проверка внутри и в SQL. ticketGroup.PATCH("/:id/assign", auth.RequireRole("admin", "client"), ticketHandler.Assign) ticketGroup.PATCH("/:id/take", auth.RequireRole("worker"), ticketHandler.Take) ticketGroup.PATCH("/:id/start", auth.RequireRole("worker"), ticketHandler.Start) ticketGroup.PATCH("/:id/done", auth.RequireRole("worker"), ticketHandler.Done) // Закрытие цикла: принимаем / отклоняем (на доработку) / отменяем ticketGroup.PATCH("/:id/confirm", auth.RequireRole("client"), ticketHandler.Confirm) ticketGroup.PATCH("/:id/reject", auth.RequireRole("client"), ticketHandler.Reject) ticketGroup.PATCH("/:id/cancel", auth.RequireRole("client", "admin"), ticketHandler.Cancel) } // Повторяющиеся заявки: правила периодических работ («осмотр // вентиляции раз в 3 месяца»). Роли — админ и клиент; чужое правило // неотличимо от несуществующего (проверка внутри хендлера). recurringGroup := r.Group("/recurring") recurringGroup.Use(auth.Middleware(jwtSecret)) { recurringGroup.GET("", auth.RequireRole("admin", "client"), recurringHandler.List) recurringGroup.POST("", auth.RequireRole("admin", "client"), recurringHandler.Create) recurringGroup.GET("/:id", auth.RequireRole("admin", "client"), recurringHandler.Get) recurringGroup.PATCH("/:id", auth.RequireRole("admin", "client"), recurringHandler.Update) recurringGroup.DELETE("/:id", auth.RequireRole("admin", "client"), recurringHandler.Delete) // Ручной запуск: заявка сразу, без ожидания интервала (иначе — 409). recurringGroup.POST("/:id/run", auth.RequireRole("admin", "client"), recurringHandler.Run) // История срабатываний: какая заявка создавалась по правилу и когда. recurringGroup.GET("/:id/runs", auth.RequireRole("admin", "client"), recurringHandler.Runs) } // Пользователи: создание и список открыты админу и клиенту // (клиенту — только мастера и только свои), удаление — только админу, // деактивация — админу и клиенту (клиенту — только свой мастер). usersGroup := r.Group("/users") usersGroup.Use(auth.Middleware(jwtSecret)) { usersGroup.POST("", auth.RequireRole("admin", "client"), userHandler.Create) usersGroup.GET("", auth.RequireRole("admin", "client"), userHandler.List) // Подтверждение аккаунта: админ — любой неподтверждённый, клиент — // только созданного им мастера (право целиком в SQL). usersGroup.POST("/:id/confirm", auth.RequireRole("admin", "client"), userHandler.Confirm) usersGroup.PATCH("/:id", auth.RequireRole("admin", "client"), userHandler.Update) usersGroup.DELETE("/:id", auth.RequireRole("admin", "client"), userHandler.Delete) // Токен сброса пароля — только админ: он и передаст его пользователю. usersGroup.POST("/:id/password-reset", auth.RequireRole("admin"), userHandler.PasswordReset) // Прямая установка пароля админом — та же граница: пароль меняется // без участия пользователя, сессии при этом закрываются. usersGroup.POST("/:id/password-set", auth.RequireRole("admin"), userHandler.SetPassword) } port := os.Getenv("PORT") if port == "" { port = "8080" } addr := ":" + port // Таймауты — защита от Slowloris и висящих соединений: заголовки // должны прийти быстро, тело и ответ — с запасом на загрузку // фотоотчёта (PHOTO_MAX_BYTES, по умолчанию 10 МБ). srv := &http.Server{ Addr: addr, Handler: r, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } errCh := make(chan error, 1) go func() { errCh <- srv.ListenAndServe() }() logger.Info("listening", "addr", addr) // SIGTERM (деплой) и SIGINT (Ctrl+C) — оба повод остановиться // аккуратно: дождаться текущих запросов, закрыть пул. quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) select { case err := <-errCh: if !errors.Is(err, http.ErrServerClosed) { logger.Error("server failed", "err", err) return 1 } return 0 case sig := <-quit: logger.Info("shutdown signal received", "signal", sig.String()) } shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("graceful shutdown failed", "err", err) return 1 } <-errCh // ListenAndServe вернул ErrServerClosed logger.Info("server stopped") return 0 } // runTicketsCleanup — фоновая чистка закрытых заявок старше retention: // прогон сразу при старте и дальше каждые сутки, пока жив контекст. // Файлы в хранилище удаляются после строк best-effort: сбой чистки // объекта уходит в лог, строка в БД уже удалена — порядок «сначала // строка, потом объект», как в чистке фотоотчёта. func runTicketsCleanup(ctx context.Context, logger *slog.Logger, repo *repository.TicketRepo, store *storage.S3Store, retention time.Duration, ) { cleanup := func() { ids, keys, err := repo.DeleteExpired(ctx, time.Now().Add(-retention)) if err != nil { logger.Warn("tickets cleanup failed", "err", err) return } photos := 0 for _, key := range keys { if err := store.Delete(ctx, key); err != nil { logger.Warn("photo object cleanup failed", "key", key, "err", err) continue } photos++ } if len(ids) > 0 { logger.Info("tickets cleanup", "deleted", len(ids), "photo_objects", photos) } } cleanup() ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: cleanup() case <-ctx.Done(): return } } } // notifyRecurring — уведомление клиенту о заявке, созданной по правилу // повторения (фоновым планировщиком или ручным «Запустить сейчас»): // один текст и одна точка отказа на двоих. Fail-open, как в notify: // созданная заявка остаётся созданной, даже если Telegram недоступен — // ошибка уходит в warn, а напоминание уйдёт штатным нотификатором позже. func notifyRecurring(ctx context.Context, logger *slog.Logger, bots notify.Bots, baseURL string, run repository.DueRun, ) { if run.Target == "" { return // слать некуда — уведомит штатный нотификатор } bot := bots.BotFor(run.ClientID) if bot == nil || !bot.Enabled() { return } if err := bot.SendMessage(ctx, run.Target, notify.RecurringMessage(run, baseURL)); err != nil { logger.Warn("recurring notify failed", "rule_id", run.RuleID, "ticket_id", run.TicketID, "err", err) } } // runRecurring — фоновый планировщик повторяющихся заявок (миграция 0014): // прогон сразу при старте и дальше каждые сутки, пока жив контекст. // Интервалы в месяцах, поэтому суточного тика хватает. // // Уведомления шлётся общим хелпером notifyRecurring — fail-open, // как в notify. func runRecurring(ctx context.Context, logger *slog.Logger, repo *repository.RecurringRepo, bots notify.Bots, baseURL string, ) { run := func() { created, err := repo.RunDueRules(ctx, time.Now()) if err != nil { logger.Warn("recurring rules run failed", "err", err) } if len(created) == 0 { return } logger.Info("recurring tickets created", "count", len(created)) for _, t := range created { if ctx.Err() != nil { return } notifyRecurring(ctx, logger, bots, baseURL, t) } } run() ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: run() case <-ctx.Done(): return } } }