axe/cmd/api/main.go

737 lines
35 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
// Команда 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
}
}
}