axe/cmd/api/main.go
2026-10-18 22:07:25 +03:00

737 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Команда 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
}
}
}