90 KiB
building-mgmt-backend
Backend для сервиса управления заявками в здании: админ создаёт клиента, клиент заводит проект, объекты в нём (здания с адресами) и приглашает мастеров, назначает заявку принятому мастеру, мастер ведёт работу до завершения — каждый переход остаётся в журнале событий заявки.
Стек: Go · Gin · PostgreSQL (pgx v5) · JWT (golang-jwt) · bcrypt
Быстрый старт
-
Инфраструктура. Один
docker compose upподнимает базу и MinIO — локальный S3 для фотоотчёта (в.envподставляетсяDATABASE_URLсоответствующего вида):docker compose up -d --wait # или: make up-
PostgreSQL — локальная в docker, не зависит от чужих серверов и туннеля. Порт наружу 5433, чтобы не конфликтовать с пробросом рабочей базы на 5432:
postgres://building_app:building_app@localhost:5433/building_mgmt?sslmode=disable. Контейнер ограничен по ресурсам (mem_limit: 512m,cpus: 1): dev-база не должна сожрать машину;max_connectionsдефолтный (100), пул приложения и так маленький. -
MinIO — S3-совместимое хранилище фотоотчёта: API на 5434, веб-консоль на 5435 (вход/пароль
minioadmin/minioadmin), бакет создаётся сам при старте сервера. Образ собирается из официального бинарника релизаRELEASE.2024-07-26T20-48-21Z(см.deploy/minio/Dockerfile): на Docker Hubminio/minioархивирован, анонимный pull недоступен. В проде подойдёт любой S3-совместимый (AWS, R2, Wasabi) — ключи задаются в.env.
Остановить —
docker compose down(make down), данные остаются в томах.- внешняя база через туннель — как раньше:
localhost:5432(MinIO поднимается тем же compose).
-
-
Положить рядом
.env(пример ключей — в.env.example):DATABASE_URL=postgres://user:pass@localhost:5432/building_mgmt?sslmode=disable JWT_SECRET=минимум-32-случайных-символа PORT=8080 SWAGGER=true # включить Swagger UI (в проде не ставить) # TRUSTED_PROXIES=172.0.0.0/12 # за reverse proxy — его подсеть # (по умолчанию доверен только loopback) S3_ENDPOINT=http://localhost:5434 # локальный MinIO; у штатного AWS — пусто S3_BUCKET=building-mgmt # обязателен, иначе сервер не стартует # LOG_FORMAT=json|text и LOG_LEVEL=debug|info|warn|error — необязательно, # все ключи с пояснениями перечислены в .env.example -
Запустить:
go run ./cmd/api # или: make run
Миграции применяются автоматически при старте — отдельная команда не нужна. Но если нужно накатить схему без запуска сервера (CI, релизный шаг):
go run ./cmd/migrate # или: make migrate
Проверка: GET /ping — приложение, GET /db-ping — база (нужен токен админа),
GET /swagger/index.html — интерактивная документация API (нужен
SWAGGER=true в .env — включено в примере выше).
-
(по желанию) Демо-данные, чтобы не собирать сценарий руками:
go run ./cmd/seed --yes # или: make seedВ базе появятся клиент, два мастера, админ, проект «Демо-проект» с двумя объектами и четыре заявки (все ветки статусной машины). Пароль у демо-аккаунтов один и печатается командой. Повторный запуск идемпотентен,
--forceпересоздаёт. Только локальная/демо-база — пароли фиксированные.
Первый админ
Самый быстрый способ — make seed (флаг --yes создаёт
demo-admin@example.com). Ручной способ описан ниже, он нужен, когда
демо-аккаунт не нужен, а админ есть:
Аккаунты создаются только изнутри системы: админ — админов,
клиентов и мастеров (POST /users), клиент — исключительно мастеров
(роль там разрешена только worker), для себя в вебе, раздел
«Клиенты». Саморегистрации нет — отдельного эндпоинта и кнопки на
экране входа. При этом мастер, созданный клиентом, входит лишь после
его подтверждения (раздел «Подтверждение аккаунта (SMTP)»).
То есть для самого первого админа нужно
завести запись руками, например:
INSERT INTO profiles (id, email, password_hash, name, role, is_active, confirmed_at)
VALUES (gen_random_uuid(), 'admin@example.com',
'<hash из cmd ниже>', 'Admin', 'admin', true, now());
Сгенерировать хеш:
go run - <<'EOF'
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
h, _ := bcrypt.GenerateFromPassword([]byte("ваш-пароль"), 12)
fmt.Println(string(h))
}
EOF
Роли и эндпоинты
| Метод | Путь | Доступ |
|---|---|---|
| POST | /auth/login |
открытый (лимит 10/мин на IP); неподтверждённый аккаунт → 403 account not confirmed, отключённый → 403 account disabled |
| POST | /auth/refresh |
открытый, без тела: refresh-токен в httpOnly-куке refresh_token (лимит 30/мин) |
| POST | /auth/logout |
открытый, без тела: гасит сессию из куки и саму куку |
| POST | /auth/logout-all |
любой авторизованный |
| POST | /auth/forgot-password |
открытый, тело {email}; ответ всегда 200 (ящик не «светится»), письмо — только подтверждённым активным; лимит 10/мин на IP и 3 письма/сутки на ящик → 429 |
| POST | /auth/reset-password |
открытый, тело {token, password} (лимит 30/мин) |
| GET | /auth/me |
любой авторизованный |
| GET | /auth/telegram |
любой авторизованный — свой статус уведомлений {enabled, bound, target, bot_username, has_bot_token, token_configurable} (токен и другим полям чужим не отдаётся) |
| POST | /auth/telegram/code |
любой авторизованный — одноразовый код привязки + ссылка t.me/<bot>?start=<code> (лимит 5 кодов за 5 минут на пользователя) |
| PUT | /auth/telegram |
любой авторизованный, тело {target} — @группа/chat_id либо пусто (отключить); неверный формат или чат, которого бот не видит → 400 invalid telegram target |
| DELETE | /auth/telegram |
любой авторизованный — отключить уведомления |
| POST | /auth/telegram/bot |
любой авторизованный, тело {token} — подключить своего бота: формат → getMe → шифрование → поллер (лимит 5 попыток за 5 минут); нет DATA_ENCRYPTION_KEY → 400 telegram bot token not configured, не тот токен → 400 bot token rejected |
| DELETE | /auth/telegram/bot |
любой авторизованный — отключить своего бота: стирает токен и гасит его поллер (повтор безопасен) |
| GET | /projects |
client (свои) / admin (все, ?client_id=) |
| POST | /projects |
client (себе) / admin (для клиента, тело {name, client_id}) |
| GET | /projects/:id |
client (свой) / admin |
| PATCH | /projects/:id |
client (свой) / admin, тело {name?, is_active?} |
| DELETE | /projects/:id |
только admin — удалить проект вместе с объектами, участиями и всеми заявками (каскад 0013, события/фото заявок уходят каскадом 0006/0008); объекты фото из хранилища убираются после коммита (сбой — в лог) |
| GET | /projects/:id/objects |
client (свой) / admin |
| POST | /projects/:id/objects |
client (свой) / admin, тело {name, address} |
| PATCH | /objects/:id |
client (свой) / admin, тело {name?, address?, is_active?} |
| GET | /projects/:id/workers |
client (свой) / admin — состав со статусами |
| POST | /projects/:id/workers |
client (свой) / admin, тело {worker_id} — приглашение |
| DELETE | /projects/:id/workers/:worker_id |
client (свой) / admin — отзыв, в том числе непринятого приглашения |
| GET | /invitations |
worker (только свои приглашения) |
| POST | /projects/:id/invitation/accept |
worker (принять своё приглашение) |
| POST | /projects/:id/invitation/decline |
worker (отклонить своё приглашение) |
| POST | /tickets |
client (тело {object_id, floor?, category, description, priority?, recurring?}; recurring = {anchor_at, period_days} — той же операцией создаётся связанное правило повторения, в ответе заявки поле recurring) |
| GET | /tickets |
любой (список режется по роли) |
| GET | /tickets/:id |
любой (проверка владельца внутри) |
| GET | /tickets/:id/events |
любой (кто видит заявку) — журнал событий |
| GET | /tickets/:id/photos |
любой (кто видит заявку) — фотоотчёт, url во временной подписи |
| POST | /tickets/:id/photos |
worker (заявка in_progress — после «Работа выполнена» отчёт заморожен, активный мастер проекта) / admin; multipart-поле file, jpeg/png/webp |
| DELETE | /tickets/:id/photos/:photo_id |
worker (своё, пока заявка in_progress) / admin (любое) |
| PATCH | /tickets/:id/assign |
admin / client (владелец), тело {worker_id} |
| PATCH | /tickets/:id/take |
worker (свободная new из проектов, где он активный участник) — назначает себя |
| PATCH | /tickets/:id/start |
worker (только назначенный ему) |
| PATCH | /tickets/:id/done |
worker (только назначенный ему) |
| PATCH | /tickets/:id/confirm |
client (владелец, только из done) |
| PATCH | /tickets/:id/reject |
client (владелец, только из done, тело {reason}) |
| PATCH | /tickets/:id/cancel |
client (своя) / admin (любая), тело {reason} |
| GET | /recurring |
client (свои) / admin (все), &project_id= — правила повторяющихся заявок, обычная страница |
| POST | /recurring |
client (объект своего проекта) / admin (любой — владелец берётся из проекта), тело {object_id, floor?, category, description, priority?, anchor_at, period_days} (anchor_at — дата-время первого запуска в RFC3339, period_days 1..3650 — «каждые N дней/недель») |
| GET | /recurring/:id |
client (своё) / admin — чужое неотличимо от несуществующего (404) |
| PATCH | /recurring/:id |
client (своё) / admin, тело — любые из {floor, category, description, priority, anchor_at, period_days, is_active} (минимум одно поле, object_id не меняется); выключенное правило планировщик пропускает, изменение расписания пересчитывает срок |
| DELETE | /recurring/:id |
client (своё) / admin — правило удаляется вместе с историей срабатываний (каскад 0014), созданные заявки не трогаются |
| GET | /recurring/:id/runs |
client (своё) / admin — история срабатываний: какие заявки создавались по правилу и когда |
| POST | /recurring/:id/run |
client (своё) / admin — ручной запуск: заявка создаётся сразу, не дожидаясь срока; 409, если правило/проект/объект выключены или есть незакрытая заявка (дубль не создаётся); ответ 201 {ok, ticket_id} |
| POST | /users |
admin (мастеры, админы и клиенты; созданные админом подтверждаются сразу) / client (только мастера; созданные ждут его подтверждения) |
| GET | /users?role=worker |
admin (любая роль; &include_inactive=1 — и отключённые) / client (свои мастера: активные участники его проектов плюс созданные им, с &include_inactive=1 — и отключённые); &q= — поиск по имени/email (мин. 2 символа), у клиента всегда только активные |
| POST | /users/:id/confirm |
admin (любой неподтверждённый) / client (только созданный им мастер); чужой и несуществующий → 404, повтор → 409 already confirmed; после успеха пользователю шлётся письмо |
| PATCH | /users/:id |
admin (любой) / client (свой мастер: активный участник его проекта либо создан им), тело {is_active} — включить/выключить аккаунт |
| DELETE | /users/:id |
admin — удалить «чистый» аккаунт; client — только своих мастеров (участники его проектов либо созданные им; членства в проектах клиента удаляются в той же транзакции); если есть заявки/проекты/чужие участия → 409 (вместо удаления — отключение через PATCH) |
| POST | /users/:id/password-reset |
admin — выпустить токен смены пароля |
| POST | /users/:id/password-set |
admin — задать пароль напрямую, тело {password} (политика та же: 10..72 символа, буквы и цифры); успех гасит все refresh-сессии пользователя и его неиспользованные токены сброса; 404 на несуществующий id |
| GET | /db-ping |
admin |
Ответы login и refresh одинаковы:
{"token": "<JWT на 1 час>", "expires_in": 3600,
"user": {"id": "…", "email": "…", "role": "client"}}
Создание аккаунта — только POST /users: ответ сразу профиль
(см. таблицу выше), никакого отдельного статуса ожидания нет.
Refresh-токен в JSON не входит: он уходит только в httpOnly-куку
refresh_token (Path=/auth, SameSite=Lax, HttpOnly; Secure
включается в проде флагом COOKIE_SECURE=true — по HTTP браузер
Secure-куку отвергает). Фронт в localStorage хранит лишь access-токен,
профиль и срок — XSS из куки ничего не вытащит, а в cross-site POST
кука не уходит вовсе (CSRF закрыт на стороне браузера).
Парольная политика (auth.ValidatePassword, только на установку —
вход со старыми паролями работает): 10–72 символа (72 — предел bcrypt),
обязательны буквы и цифры. Применяется при создании
пользователя и смене пароля; ошибка — 400 с текстом вида
password must be at least 10 characters.
Подтверждение аккаунта (SMTP)
Неподтверждённый аккаунт — это в первую очередь мастер, созданный
клиентом: токенов нет, вход до подтверждения — 403 account not confirmed (не путать с account disabled). Письмо «Аккаунт подтверждён»
уходит пользователю после подтверждения: multipart text/plain +
text/html — email аккаунта, роль, дата подтверждения и кнопка входа
(${APP_BASE_URL}/#/login), имя/email в HTML экранируются.
Кто подтверждает — правило целиком в SQL (колонки confirmed_at /
confirmed_by / created_by, миграция 0010):
- админ — любой неподтверждённый аккаунт (
POST /users/:id/confirm); - клиент — только созданного им мастера: чужой и несуществующий
неразличимы (
404), повторное подтверждение —409 already confirmed; - создание админом = подтверждение сразу («создатель и есть
подтверждающий»): админские
POST /usersне требуют второго клика; - созданный клиентом мастер ждёт подтверждения этим же клиентом — в вебе у неподтверждённых бейдж «не подтверждён» и кнопка «Подтвердить» (раздел «Мастера»);
- существующие на момент обновления аккаунты подтверждены бэкфиллом миграции.
Неподтверждённого мастера нельзя пригласить в проект
(POST /projects/:id/workers → 400 user is not confirmed) и назначить
на заявку — обе проверки в SQL.
SMTP настраивается env-переменными: SMTP_HOST (пусто — письма не
шлются, dev), SMTP_PORT (по умолчанию 587), SMTP_USER (пусто — без
аутентификации), SMTP_PASSWORD, SMTP_FROM (пусто — берётся
SMTP_USER), SMTP_TLS (tls | starttls | none; пусто — tls
для порта 465, иначе starttls) и APP_BASE_URL (ссылка на вход в
письме). Некорректные порт/режим — ошибка на старте. Отправка
fail-open: недоступный SMTP не откатывает подтверждение, в лог
уходит warn. Локальная замена боевому SMTP — Mailpit:
docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit
и SMTP_HOST=localhost SMTP_PORT=1025 SMTP_TLS=none.
Сброс пароля (по письму)
Пользователь жмёт «Забыли пароль?» на экране входа и вводит email —
POST /auth/forgot-password шлёт письмо со ссылкой
(${APP_BASE_URL}/#/reset-password?token=…). Ответ всегда одинаковый
(200 {"ok": true}): эндпоинт не перечисляет аккаунты — неизвестный,
неподтверждённый и выключенный ящик неотличимы от существующего, но
письмо в этих случаях не уходит (получают только подтверждённые
активные аккаунты).
Лимиты: 10 запросов в минуту с IP; 3 письма в сутки на один
ящик — счётчик тикает при любом запросе (и для несуществующих ящиков
тоже), при превышении 429. Счётчики — в БД (rate_limits), переживают
рестарт.
Свойства токена: одноразовый, живёт 24 часа (repository.ResetTTL),
выпуск нового гасит прежние неиспользованные токены пользователя
(включая свежие из писем); в БД хранится только sha256 — утечка базы
не даёт применить выпущенные токены. Пользователь меняет пароль:
POST /auth/reset-password с телом
{"token": "…", "password": "…"} — политика та же, что и при создании
пользователя.
Вместе со сменой пароля закрываются все refresh-сессии (прежний
access-токен живёт максимум час). Применение лимитировано 30/мин с IP;
неизвестный, использованный и истёкший токен дают одно и то же
400 invalid token — причина не раскрывается.
Альтернатива письму — админские способы (оба только для роли admin):
POST /users/:id/password-resetвозвращает{"ok": true, "token": "…", "expires_at": "…"}, и админ передаёт ссылку (${APP_BASE_URL}/#/reset-password?token=…) пользователю сам любым каналом — пароль выбирает тот;POST /users/:id/password-setс телом{"password": "…"}задаёт пароль сразу, без участия пользователя: политика та же (10..72 символа, буквы и цифры). Успех закрывает все refresh-сессии пользователя и гасит его неиспользованные токены сброса — со старым паролем в системе остаться нельзя, старые ссылки перестают работать. Слабый пароль и кривой id —400, несуществующий пользователь —404, не-админ —403.
Оба способа вынесены в одно окно разделов «Клиенты» и «Мастера» веб-интерфейса.
Фильтры GET /tickets:
?status=new— по статусу, плюс?category=plumbingи?priority=high;?date_from=2026-10-01&date_to=2026-10-05— диапазонcreated_at; даты трактуются в UTC:date_from— с полуночи UTC,date_to— включая весь день до полуночи UTC следующего дня (сервер парситYYYY-MM-DDчерезtime.Parse, у которого часовой пояс всегда UTC) в форматеYYYY-MM-DD, обе границы включительно;- клиент видит только свои заявки, мастер — назначенные ему плюс свободные
(
new) в проектах, где он активный участник (такие он вправе взять сам черезPATCH /tickets/:id/take); - админ дополнительно может сузить:
?client_id=…,?worker_id=…; - всем ролям доступны структурные фильтры
?project_id=…и?object_id=…(они накладываются поверх выборки по роли — чужое через них не подсмотреть).
Мусор в фильтре — это 400 с именем параметра (invalid status,
invalid category, invalid date_from), а не пустой список: «введено
не то» и «таких записей нет» — разные вещи.
Уведомления в Telegram
Если заявка пролежала в статусе new дольше порога (по умолчанию
24 часа), её клиенту уходит сообщение в Telegram со ссылкой на заявку
(${APP_BASE_URL}/#/tickets/:id). Пока заявку не взяли — напоминание
повторяется раз в сутки, после PATCH /tickets/:id/assign|take
условие гаснет само (status = 'new' в выборке кандидатов).
Чём слать — два источника бота, и работает это так:
- свой бот пользователя — токен от @BotFather вводится в разделе
«Настройки» (
POST /auth/telegram/bot): сначала формат, потомgetMe(неверный токен не сохраняется), затем шифрование AES-256-GCM (ключDATA_ENCRYPTION_KEY) и запись вprofiles.telegram_bot_token. Как только токен сохранён, поднимается поллер этого бота, а нотификатор шлёт уведомления через него. Токен наружу не отдаётся никогда — в статусе толькоhas_bot_tokenи имя бота; - глобальный бот сервера
TELEGRAM_BOT_TOKEN— запасной вариант для тех, кто токен не вводил.
Ни того, ни другого нет — GET /auth/telegram отдаёт enabled:false,
выдача кода 400 telegram not configured, а заявки не помечаются
отправленными: уведомление уйдёт, как только пользователь подключит
бота, а не потеряется.
Фоновые задачи живут в cmd/api (internal/notify), все на контексте,
как чистка лимитов:
- notifier — тик каждые
TELEGRAM_TICK_EVERY(по умолчанию1m): кандидаты —repository.StaleTickets(частичный индексtickets_new_created_idx); для каждой заявки бот выбирается по её клиенту (notify.Registry: личный токен, иначе глобальный). Отправка fail-open: недоступный Telegram не роняет API, а заявка остаётся кандидатом на следующий тик; «чат недоступен» (chat not found/ бот заблокирован) повтора не ждёт — сообщение помечается отправленным, чтобы не писать в лог каждую минуту; - poller — длинный
getUpdatesдля привязки чата: на ошибке экспоненциальный backoff 1–30 с,offsetсдвигается всегда (иначе одно необработанное сообщение встало бы в очередь навечно). Поллер на каждый бот свой: у личного токена — свой экземпляр, у глобального — свой (notify.Registryдержит дочерние контексты, остановка процесса гасит их разом). ОчередьgetUpdatesодна на бота, поэтому инстансов API должно быть один (см. «Известные ограничения»).
Привязка — пункт меню «Настройки» (виден всем ролям):
- При желании — подключить своего бота: поле токена →
POST /auth/telegram/bot→getMe→ шифрование → поллер. Отключить —DELETE /auth/telegram/bot. БезDATA_ENCRYPTION_KEYполе на сервере не работает (записать токен некуда) — остаётся глобальный бот. - «Получить код привязки» →
POST /auth/telegram/codeотдаёт одноразовый код и ссылкуhttps://t.me/<bot>?start=<code>. Код живёт 15 минут, выпуск нового гасит прежние, в БД лежит только sha256 (как токены сброса пароля); лимит — 5 выдач за 5 минут на пользователя. Бот для ссылки — свой, а если токена нет — глобальный. - Человек отправляет код боту → poller гасит код одним
UPDATE(одноразовость и срок — в самомWHERE), пишет числовойchat_idвprofiles.telegram_targetи отвечает «Готово — уведомления будут приходить в этот чат». - Либо вручную:
PUT /auth/telegramс телом{"target": "@mygroup"}— группа или канал, куда бот добавлен участником. Формат (^@[A-Za-z0-9_]{5,32}$либо^-?[0-9]{5,20}$) проверяется до базы, затем бот сверяет чат черезgetChat. Личный@никздесь не работает намеренно: Telegram не даёт писать человеку по одному никну, пока он сам боту не напишет, — для этого и нужен код. DELETE /auth/telegram(илиPUTс пустымtarget) — отключить.
Поле telegram_target отдаётся только владельцу (GET /auth/telegram
и сама цель): в списках GET /users его нет. Токен бота не отдаётся
никому — в статусе только факт его наличия (has_bot_token) и имя
бота, а в логи он не попадает (там только getMe-результаты).
Настройка env-переменными:
| Переменная | Назначение |
|---|---|
DATA_ENCRYPTION_KEY |
ключ шифрования токенов ботов (AES-256-GCM, 32 байта в base64 или hex: openssl rand -base64 32); пусто — ввод токена выключен: POST /auth/telegram/bot → 400 telegram bot token not configured, а сохранённые ранее токены не расшифровываются (warn при старте). Неверный формат — фейл-фаст до подключения к БД |
TELEGRAM_BOT_TOKEN |
токен глобального бота (секрет, задаётся env-ом); пусто — не страшно: уведомления пойдут через личные токены пользователей, а если не будет ни того, ни другого, GET /auth/telegram отдаёт enabled:false и выдача кода — 400 telegram not configured |
TELEGRAM_API_URL |
база Bot API; по умолчанию https://api.telegram.org |
TELEGRAM_NOTIFY_AFTER |
порог напоминания, duration; по умолчанию 24h |
TELEGRAM_TICK_EVERY |
период тика notifier'а; по умолчанию 1m |
Бот заводится через @BotFather и должен быть добавлен в группу или канал,
если цель — общая. Отказ бота в getChat и неверный формат дают одно и
то же 400 invalid telegram target — причина уходит в лог, наружу не
попадает.
Пагинация и сортировка
Все списки (/tickets, /projects, /projects/:id/objects,
/projects/:id/workers, /users, /invitations,
/tickets/:id/events, /tickets/:id/photos) — страницы одинаковой формы:
{"items": [ … ], "total": 137, "limit": 20, "offset": 40}
limit—1…100, по умолчанию20; больше потолка →400 invalid limit;offset— сдвиг от начала выборки, по умолчанию0; сдвиг за концом не ошибка:items: [], аtotalостаётся прежним;sort— ключ из белого списка у каждого списка, неизвестный →400 invalid sort;order—asc|desc; направление по умолчанию зависит от поля.
| Список | sort= |
по умолчанию |
|---|---|---|
/tickets |
created_at, priority (сначала важные), id |
created_at desc |
/projects |
created_at, name, id |
created_at desc |
/projects/:id/objects |
name, created_at, id |
name asc |
/projects/:id/workers, /users |
name, created_at, email, id |
name asc |
/invitations |
invited_at, expires_at, project_name |
invited_at desc |
/tickets/:id/events |
id, created_at |
id asc (хронология) |
/tickets/:id/photos |
id, created_at |
id asc (хронология) |
Сортировка всегда дополняется id, чтобы одинаковые значения не
«перетасовывались» между страницами. Колонка в ORDER BY берётся из
словаря в коде — пользовательская строка в SQL не попадает никогда.
total считается до обрезки страницы (COUNT(*) OVER() одним
запросом; для пустого окна — отдельным COUNT с тем же WHERE),
поэтому число страниц стабильно при листании.
Ломающий формат: раньше списки отдавали голый массив. Потребителям списка нужно читать
items(Swagger и README обновлены).
Структура: проект → объект → мастер → заявка
admin ──создаёт──> client ──создаёт──> project ──┬──> project_objects (адреса)
└──> project_workers (мастера, M2M)
│
заявка ──object_id──> объект проекта
- Проект — контур клиента: что и для кого делаем. Уникальность —
(client_id, name), у админа при создании обязателенclient_id. - Объект — здание/корпус внутри проекта с адресом. Адрес заявки копируется из объекта на момент создания (история показывает адрес, который был тогда; правка объекта старые заявки не меняет).
- Мастера — M2M
project_workersсо статусами (миграция0005):invited → active | declined. Мастер может состоять в нескольких проектах; «свои мастера» клиента — этоGET /users?role=worker, то есть только принявшие приглашение. Состав проекта (GET /projects/:id/workers) отдаёт профили вместе со статусом и сроком ответа. Существующего мастера, ещё не состоящего в проектах, клиент находит поискомGET /users?role=worker&q=<имя или email>(минимум 2 символа; так клиент узнаёт только того, чей адрес ввёл сам, не видя полного списка) — этим же запросом пользуется поиск в вебе. - Заявка всегда привязана к объекту (
object_idобязателен) и проекту; адрес в телеPOST /ticketsбольше не передаётся.
Принадлежность проверяется на уровне БД (составные FK, миграция 0004):
tickets (object_id, project_id) → project_objects (id, project_id)— объект входит именно в этот проект;tickets (project_id, client_id) → projects (id, client_id)— проект принадлежит клиенту заявки;- членство мастера — проверка внутри самого
UPDATEназначения (FK отдалял бы уход мастера из проекта навсегда: ссылки остаются от закрытых заявок); - чужой проект/объект неотличим от несуществующего:
404, а не403.
Правила жизни структуры:
- выключенный проект (
is_active=false) не принимает новые объекты и заявки —409 project is not active; - мастера нельзя убрать из проекта, пока за ним есть незакрытые заявки
этого проекта —
409 worker has open tickets in this project; после закрытия отзыв проходит, история заявок при этом сохраняется; - назначить заявку можно только принявшему приглашение мастеру её
проекта: посторонний —
400 worker is not in the project of this ticket, приглашённый, но не ответивший —400 invitation is pending: the worker has not accepted it, отказавшийся —400 invitation was declined. Рубеж стоит в самомUPDATEназначения (status = 'active').
Приглашения мастеров
POST /projects/:id/workers с 0005 — это приглашение, а не членство:
{"ok": true, "status": "invited", "expires_at": "2026-10-09T…Z"}
- приглашение живёт 7 дней (
repository.InvitationTTL); после этого мастеру —409 invitation expired, а клиент может отозвать строку обычнымDELETE /projects/:id/workers/:worker_id; - мастер смотрит свои приглашения в
GET /invitations(просроченные там не показываются) и решает:POST /projects/:id/invitation/accept—invited → active, и только тогда проект начинает назначать ему заявки; либо…/decline—invited → declined; - принудительного членства (
force) нет: единственный путь стать участником — принять приглашение самому; - повторное приглашение после отказа перезапускает ту же строку;
дубликаты —
409 worker is already in the project(уже активен) и409 invitation is already pending(ждём ответа); вторичный accept/decline —409 invitation is not pending; DELETEотзывает строку любого статуса, непринятое приглашение — тоже.
Статусная машина заявки
new ──assign(admin/client) или take(worker)──> assigned ──start(worker)──> in_progress ──done(worker)──> done
│ │ │
│ confirm(client) reject(client, причина)
│ │ │
│ ▼ ▼
└──cancel(client/admin, причина)──────────┐ closed rejected (доработка)
│ confirmed_at │
│ + closed_at start(worker) → in_progress
└───────────────────── (второй круг, rework_count+1)
└── cancel(…) ──> closed + cancel_reason
| Переход | Кто | Условие в SQL |
|---|---|---|
new → assigned |
админ, клиент-владелец или сам мастер (take) |
status = 'new', мастер принят в проекте заявки (status = 'active') |
assigned → in_progress |
назначенный мастер | worker_id = current |
in_progress → done |
назначенный мастер | worker_id = current |
done → closed (принято) |
клиент-владелец | client_id = current, ставит confirmed_at + closed_at |
done → rejected (доработка) |
клиент-владелец | client_id = current, причина обязательна, rework_count+1 |
rejected → in_progress |
назначенный мастер | второй круг, started_at не затирается |
new/assigned/done/rejected → closed |
клиент (свой) / админ (любой, кроме closed) |
ставит cancel_reason + closed_by |
Ключевое: rejected — это доработка, а не финал. Финал у заявки один —
closed; исход различают confirmed_at (работа принята) и cancel_reason
(отменено/отказ). Клиенту нельзя отменить заявку в in_progress — мастер уже
выехал; там вправе только админ.
Переходы защищены на уровне БД, а не только в коде:
tickets_needs_worker— мастер обязателен во всех статусах, кромеnewи закрытых (отменённая заявка может остаться без мастера);tickets_assigned_time,tickets_started_time,tickets_done_time,tickets_confirmed_time,tickets_closed_time,tickets_rejected_time— достигнутый статус обязан иметь свой таймстамп;tickets_rejected_reason— отклонение только изdoneи только с причиной;tickets_closed_reason— закрыта либо принята клиентом, либо отменена с причиной;tickets_status_check,tickets_category_check,tickets_priority_check— белые списки значений.
Журнал событий (0006, GET /tickets/:id/events): каждый переход
оставляет запись — created, assigned, started, done, confirmed,
rejected, canceled — с автором, парой from_status → to_status,
причиной (для rejected/canceled) и временем. Запись создаётся в той же
SQL-операции, что и переход (CTE UPDATE … RETURNING → INSERT в журнал):
«заявка перешла, а истории нет» невозможно — либо обе части, либо ни одной.
У события created нет from_status, при удалении аккаунта actor_id
обнуляется, но сама запись остаётся. Список пагинирован, по умолчанию —
хронология (id asc), total — все события заявки.
Пул свободных заявок (scope=free) удалён: очередь не нужна — назначает
админ или клиент-владелец заявки. Мастер видит свободные (new) заявки
только своих проектов и может взять их сам (PATCH /tickets/:id/take).
Фотоотчёт (S3)
Мастер показывает ход работ фото: только фото jpeg/png/webp (без видео),
формат определяется по сигнатуре байтов, а не по заголовку Content-Type.
Байты живут в S3-совместимом хранилище (локально MinIO в compose, в проде —
любой S3), в БД — только метаданные (ticket_photos, миграция 0008).
| Метод | Путь | Доступ |
|---|---|---|
POST |
/tickets/:id/photos |
worker / admin, multipart-поле file → 201 + url (временная ссылка) |
GET |
/tickets/:id/photos |
все, кто видит заявку; страница как у остальных списков |
DELETE |
/tickets/:id/photos/:photo_id |
worker — своё, пока заявка in_progress; admin — любое |
Правила живут в SQL (та же CTE-схема, что и переходы статусов):
- загрузка разрешена только в статусе
in_progress— и мастеру, и админу: после «Работа выполнена» (done) отчёт заморожен, а после отклонения клиентом (rejected) откроется снова вместе со стартом доработки; мастер должен быть активным участником проекта заявки; - лимиты из окружения:
PHOTO_MAX_BYTES(по умолчанию 10 МБ на файл) иPHOTO_MAX_PER_TICKET(20 файлов на заявку); превышение —400 photo too largeи409 too many photos; - формат вне белого списка —
400 unsupported photo type; - каждая загрузка и удаление оставляет событие
photo_added/photo_removedв журнале той же SQL-операцией (comment — имя файла); у фото-событий статус не меняется:from_status = to_status.
Порядок в POST: валидация → заливка в S3 → INSERT строки и события;
если вставка не прошла (гонка за лимит) — объект компенсаторно удаляется,
«сирот» в хранилище не остаётся. При удалении наоборот: сначала строка и
событие, затем объект (сбой чистки уходит в лог, журнал не разъезжается
со строкой).
Бакет приватный: наружу уходят только подписанные ссылки (SigV4, 15 минут),
ключ объекта клиенту не виден — его генерирует сервер
(tickets/<ticketID>/<uuid>.<ext>). Файл переживает удаление аккаунта
загрузившего (uploaded_by → SET NULL).
Как и JWT_SECRET, конфигурация S3 проверяется на старте: без S3_BUCKET
(и без ключей при своём S3_ENDPOINT) сервер не стартует — ошибка видна
сразу, а не на первой загрузке.
Очистка старых заявок
Фоновая задача удаляет закрытые заявки, чей closed_at старше
TICKET_RETENTION_DAYS (по умолчанию 60 дней; 0 — чистка выключена,
кривое значение — сервер не стартует, как у PHOTO_MAX_*). Прогон —
при старте сервера и дальше раз в сутки.
- удаляются только заявки в
closed: незакрытые (новые, в работе, на доработке) не трогаются никогда — иначе терялась бы незакрытая работа; возраст считается от закрытия, а не от создания: заявка, закрытая вчера, но созданная два месяца назад, остаётся; - события журнала (
ticket_events) и строки фото (ticket_photos) уходят каскадом (ON DELETE CASCADE) той же операцией; строка истории повторяющейся заявки (recurring_runs) удаляется отдельным шагом в конце — у удалённой заявки она осталась бы сиротой (ticket_id → SET NULL), жить без заявки ей не на что; - ключи объектов фото снимаются до удаления строк, затем файлы
удаляются из хранилища best-effort: сбой уходит в лог
(
photo object cleanup failed), строка в БД уже удалена — тот же порядок «сначала строка, потом объект», что в чистке фотоотчёта; - удалённая заявка исчезает из списков,
GET /tickets/:idотвечает 404; - если что-то удалилось, в лог пишется
tickets cleanup deleted=N photo_objects=M.
Повторяющиеся заявки
Периодические работы («осмотр вентиляции каждые 14 дней») заводятся
один раз правилом — шаблоном будущей заявки (объект, категория,
описание, приоритет) плюс расписанием anchor_at (дата-время первого
запуска) и period_days (период в днях): заявка создаётся каждые
N дней/недель от точки отсчёта (recurring_rules, миграции
0014/0016).
Обычный путь — галочка «Повторяющаяся заявка» в POST /tickets: заявка,
правило и связь создаются одной операцией, в ответе заявки — поле
recurring (расписание, состояние, ближайший запуск). Управление на
странице заявки: PATCH /recurring/:id вкл/выкл и история
GET /recurring/:id/runs. Отдельный CRUD GET/POST /recurring,
GET/PATCH/DELETE /recurring/:id сохранён для API-клиентов (роли —
как у проектов): PATCH правит шаблон и расписание частично — меняются
только присланные поля, объект не переносится (это удаление и
создание). POST /recurring/:id/run — ручной запуск: заявка создаётся
сразу, не дожидаясь срока, но рубежи остаются — выключенное
правило/проект/объект или незакрытая прошлая заявка дают 409
(дубль не создаём). После ручного запуска last_run_at сдвигается,
поэтому следующий срок отсчитывается уже от него.
Как считается срок (считается Go-функцией nextDue, не SQL):
- срок — сетка точек
anchor_at + k·period_days(k ≥ 0), ближайшая строго после отправной точкиCOALESCE(last_run_at, created_at): первая заявка — первая точка сетки после создания правила (anchor_atв будущем — это самanchor_at: пользователь выбрал первый запуск), дальше — точка после каждой созданной заявки (и после ручного запуска — тоже); - сетка не «плывёт» после каждого срабатывания: опоздание на тик не накапливается, дата отсчёта не сдвигается;
- пока прошлая заявка по правилу не закрыта, новая не создаётся
(ждём закрытия, без дублей); пропуск не сдвигает
last_run_at— после закрытия следующий тик создаст заявку сразу; - выключенное правило, проект или объект — пропуск без сдвига срока: когда включат, заявка создастся (возможно, с опозданием);
- точность расписания — до суток: тик планировщика раз в сутки,
выбранное в
anchor_atвремя ориентировочное (заявка появится ближайшим тиком после срока).
Сам планировщик (runRecurring в cmd/api) — та же схема, что у чистки
заявок: прогон при старте сервера и дальше раз в сутки (срок считается
в днях, суточной точности хватает). Каждое правило обрабатывается в
своей транзакции с FOR UPDATE строки правила — параллельный прогон
(второй инстанс) не создаст вторую заявку. Заявка, её событие created
и строка recurring_runs со сдвигом last_run_at — одной операцией:
«заявка есть, а правило думает, что не запускалось», невозможно.
Клиенту при авто-создании уходит уведомление в Telegram — как при
напоминаниях, через бота (BotFor(clientID): собственный токен
пользователя, иначе глобальный) в telegram_target; ручной запуск
шлёт тем же текстом общим кодом. Отправка fail-open: недоступный
Telegram не роняет ни планировщик, ни запрос, ошибка уходит в warn,
заявка остаётся созданной.
Удаление правила (или каскад проекта/объекта — 0014) не трогает уже
созданные заявки: они живут обычным порядком.
Миграции
Лежат в internal/db/migrations/ и встраиваются в бинарник через embed.
- имя файла —
<version>_<name>.sql, например0001_init.sql; - применяются по возрастанию версии, каждая в своей транзакции;
- применённые версии пишутся в таблицу
schema_migrationsвместе с checksum файла.
Сейчас в наборе шестнадцать миграций:
| Версия | Что делает |
|---|---|
0001_init |
таблицы profiles и tickets, базовые ограничения и индексы |
0002_ticket_resolution |
колонки reject_reason, cancel_reason, rejected_at, closed_by, rework_count; ограничения закрытия цикла |
0003_hardening |
индекс profiles_email_lower_key (email без учёта регистра), таблица refresh_tokens |
0004_projects |
таблицы projects, project_objects, project_workers; у tickets — обязательные project_id/object_id и составные FK (перед этим удаляет тестовые заявки, сделанные до объектов) |
0005_project_invitations |
у project_workers — статусы invited/active/declined, invited_by, invited_at, responded_at, expires_at и индексы списков; существующие строки становятся active |
0006_ticket_events |
таблица ticket_events (журнал переходов заявки) и индекс (ticket_id, id) |
0007_password_reset |
таблица password_reset_tokens (sha256 одноразового токена, срок, использование) и индекс по пользователю |
0008_ticket_photos |
таблица ticket_photos (метаданные фотоотчёта; uploaded_by → SET NULL, CHECK на тип/размер) и расширение CHECK журнала на действия photo_added/photo_removed |
0009_rate_limits |
таблица rate_limits — счётчики брутфорс-лимитов в БД вместо памяти (переживают рестарт и общие для нескольких инстансов) |
0010_user_confirmation |
колонки confirmed_at / confirmed_by у profiles — подтверждение аккаунта админом или клиентом; правило доступа целиком в SQL |
0011_telegram_notifications |
profiles.telegram_target (куда слать уведомления), tickets.tg_notified_at (дедуп «раз в сутки»), таблица telegram_bind_codes (sha256 одноразового кода привязки, TTL 15 минут) и частичный индекс tickets_new_created_idx |
0012_user_bot_tokens |
profiles.telegram_bot_token (зашифрованный токен собственного бота пользователя, bytea) и telegram_bot_username (его имя из getMe) |
0013_project_delete_cascade |
два FK в tickets (object_project, project_client) пересоздаются с ON DELETE CASCADE — проект удаляется админом вместе с заявками (события/фото и так каскадные с 0006/0008) |
0014_recurring_rules |
таблицы recurring_rules (правило повторяющейся заявки: шаблон + расписание, составные FK как у заявки, каскад с проектом/объектом) и recurring_runs (история срабатываний: какая заявка создана по какому правилу; ticket_id → SET NULL) |
0015_recurring_annual |
recurring_rules: interval_months заменяется на run_month/run_day (повтор ежегодно в эту дату, месяц 1..12, день 1..31; существующие правила переводятся на день/месяц создания) |
0016_recurring_period |
recurring_rules: run_month/run_day заменяются на anchor_at (дата-время первого запуска) и period_days (период в днях, 1..3650 — «каждые N дней/недель»); существующие правила получают anchor_at = COALESCE(last_run_at, created_at) и period_days = 365 — ритм «раз в год» сохраняется |
Правила:
- Применённую миграцию редактировать нельзя — при несовпадении checksum
приложение не стартует. Изменение — это всегда новая миграция со следующим
номером (
0008_*.sql). - Все миграции написаны так, чтобы сработать и на пустой базе, и на базе,
где таблицы были созданы руками до появления миграций (
IF NOT EXISTS- приведение ограничений к эталону).
Ошибки API
Handlers не отдают тексты ошибок БД наружу — они пишутся в лог, клиенту уходит фиксированное сообщение:
| Код | Класс (internal/repository) |
Ответ |
|---|---|---|
| 404 | ErrNotFound |
{"error":"not found"} |
| 409 | ErrConflict (уникальность, конфликт состояния) |
{"error":"conflict"} |
| 422 | ErrInvalid (нарушение CHECK/FK) |
{"error":"invalid request"} |
| 500 | инфраструктура (сеть, падение БД) | {"error":"internal server error"} |
Исключение — сообщения, которые полезны клиенту и не раскрывают внутренности:
email already exists, invalid credentials, worker not found,
invalid token, invitation is already pending,
worker is already in the project, invitation expired,
invitation is not pending,
invitation is pending: the worker has not accepted it,
invitation was declined, photo too large, invalid file,
unsupported photo type, too many photos, photo cannot be removed,
user has related records.
Надёжность и безопасность
Токены и сессии
- access-токен — JWT HS256, живёт 1 час (
auth.AccessTTL); refresh-токен — случайный секрет на 30 дней, в БД хранится только его sha256 (auth.HashToken), а клиенту секрет отдаётся только в httpOnly-кукеrefresh_token(Path=/auth,SameSite=Lax,Secure— черезCOOKIE_SECURE=true); в JSON его нет и вlocalStorageон не попадает; POST /auth/refreshделает ротацию: на входе старый токен (из куки), на выходе новая пара, старая сессия гасится. Повторное предъявление отозванного токена закрывает все сессии пользователя — это признак того, что секретом владеет кто-то второй; событие пишется в лог с префиксомsecurity:;POST /auth/logoutгасит одну сессию,POST /auth/logout-all— все;- протухшие и отозванные записи чистятся при старте приложения.
Брутфорс
/auth/login— 10 попыток в минуту с одного IP,/auth/refresh,/auth/logoutи/auth/reset-password— 30/мин,/auth/forgot-password— 10/мин на IP плюс 3 письма в сутки на один ящик (ключ —lower(email), счётчик тикает и для несуществующих ящиков: 429 не раскрывает, что адрес зарегистрирован);- при превышении —
429 {"error":"too many attempts, try again later"}; - лимитер в PostgreSQL (таблица
rate_limits, миграция0009, ключ —«лимит»|IP): счётчик переживает рестарт API и общий для нескольких инстансов; при недоступности БД лимитер fail-open (пропускает запрос и пишетwarnв лог) — вход не должен падать из-за счётчика; - ключ берётся из
c.ClientIP(), поэтомуgin.SetTrustedProxiesнастроен изTRUSTED_PROXIES(по умолчанию только loopback, за reverse proxy — его подсеть): без этого gin верил бы любомуX-Forwarded-For— левый элемент списка подделал бы и ключ лимита, иclient_ipв логах. Прокси обязан перезаписывать заголовок ($remote_addr), а не дополнять: иначе левым останется присланный клиентом мусор.
Сеть
- у
http.Serverвключены таймауты:ReadHeaderTimeout10с — защита от Slowloris,ReadTimeout/WriteTimeout60с — с запасом на загрузку фотоотчёта,IdleTimeout120с — висящие keep-alive соединения; - Swagger UI по умолчанию выключен (открытая схема в проде не нужна),
включается флагом
SWAGGER=true— так в.envлокальной разработки.
CORS
- разрешённые источники фронта задаются
CORS_ORIGINSчерез запятую (напримерhttp://localhost:3000,http://127.0.0.1:3000); - пустое значение — заголовки не отдаются вовсе: одностраничнику на том же домене CORS не нужен, а чужому домену их и не должно быть;
- разрешённый origin получает свой точный адрес в
Access-Control-Allow-Origin(не*: рядомAuthorization, звёздочка запрещена спецификацией), плюсVary: Origin,Methods/Headers иAccess-Control-Max-Age: 600; - preflight
OPTIONSотвечает204до авторизации — браузер спрашивает «можно ли» заранее, запроса с токеном ещё нет.
Учётные записи
- email нормализуется при записи (обрезка + нижний регистр) и ищется
без учёта регистра:
Admin@x.ruиadmin@x.ru— один пользователь, дубликат любого регистра —409(держит индексprofiles_email_lower_key); - выключить аккаунт —
PATCH /users/:id {"is_active": false}: админ — любой, клиент — только свой мастер (активный участник его проектов либо созданный им; граница в SQL: чужой мастер неотличим от несуществующего →404). Выключенный не войдёт (login →403 account disabled), все его сессии закрываются сразу, а не по истечении access-токена; выключить самого себя нельзя. Флаг общий на все проекты мастера — осознанный побочный эффект: он перестаёт работать везде; включить обратно может и админ (GET /users?include_inactive=1показывает отключённых — в обоих разделах), и владелец проекта (кнопкой в проекте); - удалить аккаунт —
DELETE /users/:id: админ удаляет любой «чистый» профиль, его refresh-сессии и токены сброса уходят каскадом. Клиент — только своего мастера (состоит в одном из его проектов — любой статус членства — либо создан им): членства в проектах клиента удаляются в той же транзакции, чужие проекты не трогаются. Если на мастера ссылаются заявки либо чужие участия (FK без каскада), БД не отдаёт строку →409 user has related records, и вся транзакция откатывается — членства остаются на месте; в этом случае аккаунт отключают, историю не стираем. Чужой мастер и несуществующий id неразличимы (404), удалить себя нельзя; - подтвердить аккаунт —
POST /users/:id/confirm: админ — любой неподтверждённый, клиент — только созданного им мастера (границы в SQL:404на чужого,409 already confirmedна повтор); после успеха пользователю уходит письмо «аккаунт подтверждён», SMTP недоступен — подтверждение остаётся, в лог пишетсяwarn; GET /db-pingзакрыт от посторонних: по ответу наружу уходило состояние базы.
Логи и request-id
Логирование — структурное, через log/slog (internal/logging):
LOG_FORMAT=text(по умолчанию, читаемо в консоли) илиjson(для агрегатора логов);LOG_LEVEL=debug|info|warn|error— по умолчаниюinfo.
Каждый запрос проходит цепочку middleware (cmd/api, порядок важен):
middleware.RequestID— берёт входящийX-Request-Id, если он осмысленный (до 64 символов из[A-Za-z0-9._-]), иначе генерирует свой; кладёт id в контекст и возвращает его тем же заголовком;middleware.Recovery— паника не роняет процесс: клиенту500с фиксированным текстом, в лог — причина и стек;middleware.AccessLog— одна запись на запрос:method,path,status,duration_ms,client_ip,user_id(если есть) иrequest_id. Уровень по статусу:5xx→ error,4xx→ warn, остальное → info.
Ошибки, которые логируются отдельно (в respondError, логинах, деактивации),
тоже несут request_id — один запрос находится в логах по одному значению.
Инженерная культура
-
Линтер —
golangci-lintv2, конфиг в.golangci.yml: стандартный набор (errcheck,govet,staticcheck,ineffassign,unused) плюсgocritic,misspell,revive, форматтерыgofmt/goimports. Исключения узкие и задокументированы (в тестахtидёт первым аргументом).golangci-lint run ./... # или: make lint -
CI — Drone (
.drone.yml, сервер подключён к Gitea):vet→ накат миграций на сервисный PostgreSQL →go test -race ./...→go build→golangci-lint→ сборка docker-образа. Тесты в CI идут на живой базе, а не скипаются: безDATABASE_URLинтеграционные тесты пропускаются молча.-raceна живой базе дороже, зато ловит гонки между пулом соединений, лимитером и горутинами хендлеров. Деплой в прод — ручным запуском того же пайплайна (см. «CI/CD (Drone + Gitea)»). -
Команды —
Makefile:run,build,test,race,vet,lint,fmt,swag,migrate,seed,up,down,tidy. На Windows безmakeте же пункты — обычныеgo-команды (список выше и есть канон);up/down— этоdocker compose up -d --wait/docker compose down. -
Graceful shutdown — сервер слушает
SIGINT/SIGTERMи останавливается аккуратно:http.Server.Shutdownждёт текущие запросы (до 15 секунд), затем закрывается пул соединений; процесс завершается с кодом0. Точка входа устроена какmain()→os.Exit(run()), чтобыdefer(в частностиpool.Close()) гарантированно отработал —log.Fatalвнутриrunих бы не выполнил. -
Первый запуск в новой базе:
go run ./cmd/migrate, затемgo run ./cmd/api.
Swagger UI
GET /swagger/index.html — интерактивная документация по всем эндпоинтам API:
схемы тел, коды ответов, кнопка Authorize (подставляет Bearer <token>).
Роут регистрируется только при SWAGGER=true (по умолчанию выключен —
на проде открытая схема не нужна; в .env локальной разработки он включён).
Схема генерируется из аннотаций над хендлерами:
go install github.com/swaggo/swag/cmd/swag@latest
swag init -g cmd/api/main.go -o docs --parseInternal
На выходе — каталог docs/ (docs.go, swagger.json, swagger.yaml),
встроенный в бинарник через импорт _ "building-mgmt/docs" в cmd/api/main.go.
Поменяли эндпоинт — перегенерируйте (иначе документация отстаёт от кода).
Забыли — свидетельство прогресса, а не дыра: работает ровно то, что
зашито в docs/.
Тесты
go test ./... # или: make test
go test -race ./... # или: make race — как в CI
-race требует CGO и C-компилятора: в CI (linux-контейнер) они есть, на
Windows без MinGW команда отвечает go: -race requires cgo — там гонки
ищет CI.
internal/auth— JWT: подделкаalg, чужой секрет, просроченный токен, пустой секрет; генерация refresh-секрета;internal/ratelimit— лимитер: исчерпание бюджета, сброс окна, чистка протухших ключей,429от middleware;internal/middleware— CORS: разрешённый origin получает точные заголовки и preflight204без тела, чужой origin и пустой список — ничего вовсе;internal/db— пул соединений (applyPoolEnv): пустыеDB_*не трогают дефолты pgxpool, кривые значения — фейл-фаст на старте, а не молчаливый дефолт;internal/repository— интеграционные на реальной БД: статусная машина, ограничения схемы (включая констрейнты0002/0003), фильтры списка, конфликт уникальности, нормализация email, жизненный цикл refresh-сессий (выпуск, отзыв,logout-all, чистка протухших), изоляция проектов (project_test.go): чужой проект/объект неотличим от несуществующего, дубликаты имён, членство мастера, отзыв мастера при открытых заявках, назначение только «своего» мастера и составной FK на объект чужого проекта;- приглашения (
0005): invite → accept/decline, отказ, просрочка (принять нельзя, отклонить можно), повторное приглашение по той же строке, точные причины дубликатов, назначение только послеaccept; - журнал событий (
TestTicketEvents): полный цикл заявки оставляет точную хронологию с авторами и причинами,from_statusотмены — настоящий статус на момент отмены, пагинация не сбрасываетtotal; - сброс пароля (
TestPasswordReset,TestPasswordResetExpired,TestSetPasswordDirect): токен одноразовый, выпуск нового гасит прежний, просроченный не меняет пароль; прямая установка админом меняет хеш, гасит выпущенные токены и отдаётErrNotFoundна неизвестный id; - фотоотчёт (
TestPhotoReportLifecycle): запрет до старта работ даже для админа, загрузка активным мастером проекта и отказ чужому, лимит файлов (ErrTooManyPhotos), событияphoto_added/photo_removedсfrom_status = to_status, удаление (своё — можно, повторное — 404, чужая заявка — 404, закрытая заявка мастеру — 409, админу — можно); юнит-тесты хендлера проверяют сигнатуры форматов и вычистку имени файла; - повторяющиеся заявки (
recurring_test.go): срок (nextDue: точка сетки в будущем, граница точки, пропуск многих периодов, недельный и годовой периоды), правило не созрело до срока, по созревании создаётся заявка-копия (адрес, событиеcreatedот автора правила,last_run_atиrecurring_runsодной транзакцией), незакрытая заявка блокирует следующий запуск без сдвига срока, после закрытия цикл продолжается; чужое правило неотличимо от несуществующего (чтение/правка/удаление —ErrNotFound), выключенное правило планировщик пропускает, каскад при удалении проекта; частичныйUpdateRuleменяет только переданные поля (ручной запуск (RunRuleNow) обходит ожидание срока, но не рубежи: открытая заявка —ErrConflict+ErrRuleOpen, выключенное правило —ErrRuleNotActive, чужое —ErrNotFound); заявка с галочкой повтора создаёт правило одной операцией (CreateWithRule) и видит его в детали (GetByID); автоочистка удаляет строку истории сироты вместе с заявкой (DeleteExpired).
Интеграционные тесты берут DATABASE_URL, выполняют сценарий в транзакции
и откатывают её — данные в базу не остаются. Без DATABASE_URL они
пропускаются.
В CI (make test не нужен — там свои шаги) DATABASE_URL задан, база
поднимается сервисным контейнером, схема накатывается через cmd/migrate,
поэтому «зелёный прогон» означает «тесты действительно выполнены».
Деплой в прод
Прод — один сервер и один compose-стек (compose.prod.yaml): postgres,
MinIO, API и веб-nginx в одной docker-сети; наружу отдаёт только
веб (порт 80). Postgres и MinIO портов не пробрасывают вовсе — видны
только контейнерам приложения. БД и S3 живут внутри стека, данные — в
томах pgdata/miniodata, обновления переживают.
Первый запуск
# 1. Код и env-файл (env — на сервере, в git его нет)
git clone http://<gitea>/building-mgmt-backend.git /opt/building-mgmt-repo
mkdir -p /opt/building-mgmt
cp /opt/building-mgmt-repo/.env.prod.example /opt/building-mgmt/.env
chmod 600 /opt/building-mgmt/.env
$EDITOR /opt/building-mgmt/.env # пароли, секреты, домен — см. комментарии
# 2. Образы (Drone собирает их сам; вручную — если CI ещё не подключён)
docker build -t building-mgmt-api:latest /opt/building-mgmt-repo
docker build -t building-mgmt-web:latest <клон репо web>
# 3. Стек
docker compose -p building-mgmt \
--env-file /opt/building-mgmt/.env \
-f /opt/building-mgmt-repo/compose.prod.yaml up -d --wait
-p building-mgmt обязателен и в ручных запусках, и в Drone: имя
проекта задаёт имя сети, разные имена разнесут web и api по разным
сетям, и прокси перестанет находить api. Проверка: http://<домен>/ping
→ {"message":"pong"}. APP_ENV=production (задан в шаблоне) блокирует
seed — демо-аккаунты на бою не заводятся.
TLS и домен: сертификат вешается на терминал перед веб-nginx
(балансировщик / Caddy / Traefik); сам nginx отдаёт HSTS и проставляет
X-Forwarded-Proto. TRUSTED_PROXIES=172.16.0.0/12 обязателен: без
него api увидит IP nginx-контейнера и лимиты логина всех пользователей
сложатся в один общий ключ.
Обновление и операции
# Обновление: образы новые, данные в томах не трогаются.
docker compose -p building-mgmt --env-file /opt/building-mgmt/.env \
-f compose.prod.yaml up -d --wait
docker compose -p building-mgmt logs -f api # логи
docker compose -p building-mgmt exec api migrate # накат схемы вручную
Бэкапы (вне зоны compose, но обязательно):
docker compose -p building-mgmt exec db pg_dump -U building building_mgmt \
> backup-$(date +%F).sql
docker run --rm -v building-mgmt_miniodata:/data -v "$PWD":/backup alpine \
tar czf /backup/minio-$(date +%F).tgz -C /data .
Ограничение: API — ровно один инстанс (поллер Telegram держит очередь
getUpdates на процесс; см. «Известные ограничения») — в стеке это
соблюдается само.
CI/CD (Drone + Gitea)
CI живёт в Drone: сервер подключён к Gitea, пайплайны — .drone.yml в
обоих репозиториях (бэкенд и веб). GitHub Actions больше нет.
Установка (однократно, на прод-сервере)
# В Gitea: Настройки → Applications → OAuth2-приложение,
# Redirect URI: http://<хост-drone>:8081/authorize
mkdir -p /opt/drone && cp deploy/drone/compose.yaml /opt/drone/
# /opt/drone/.env: GITEA_SERVER, DRONE_GITEA_CLIENT_ID,
# DRONE_GITEA_CLIENT_SECRET, DRONE_GITEA_ADMIN_USERNAME,
# DRONE_RPC_SECRET= # openssl rand -hex 32
docker compose -p drone --env-file /opt/drone/.env \
-f /opt/drone/compose.yaml up -d
Drone UI — http://<хост>:8081 (за TLS-терминалом); оттуда же Gitea
шлёт webhook. Runner исполняет шаги пайплайнов в docker на этом же
сервере, одна задача за раз (capacity=1 — деплои не пересекаются).
Секреты пайплайна (UI Drone, репозиторий веба)
gitea_host,gitea_user,gitea_token— доступ к приватному репо бэкенда: web-пайплайн клонирует его за токен, чтобы перезапуститьwebтем жеcompose.prod.yaml(один стек, один проект).
Секреты стека (/opt/building-mgmt/.env) в Drone не хранятся: пайплайн
только читает файл на сервере флагом --env-file.
Что делает каждый пайплайн
- бэкенд (push/PR и ручной запуск):
vet→migrate→test -race→go build→golangci-lint→ сборка образаbuilding-mgmt-api:latest+ тег по SHA; - веб (push/PR и ручной запуск):
node --checkjs → сборка образаbuilding-mgmt-web:latest+ тег по SHA; - деплой — только ручным запуском (кнопка Run Pipeline в UI,
событие
custom): бэкенд —docker compose -p building-mgmt --env-file /opt/building-mgmt/.env -f compose.prod.yaml up -d --wait(пересоздаёт api новым образом, данные не трогает); веб — то же с--no-deps web. Push вmainобразы собирает, но на сервер ничего не ставит.
Известные ограничения
- подтверждение email — реализовано (см. раздел
«Подтверждение аккаунта (SMTP)»): мастер, созданный клиентом,
активен только после
POST /users/:id/confirm, до этого вход закрыт (403 account not confirmed); саморегистрации нет, аккаунты заводит только админ (и клиент — мастеров); сброс пароля по письму (POST /auth/forgot-password) работает, админу доступны оба своих способа — выпустить токен (POST /users/:id/password-reset) либо задать пароль напрямую (POST /users/:id/password-set); - несколько инстансов API: лимиты запросов уже считают на общей БД, но их
ключ —
client_ip, поэтому reverse proxy каждого инстанса обязан перезаписыватьX-Forwarded-Forодинаково (см.TRUSTED_PROXIES); к ним же — поллеры Telegram (internal/notify): очередьgetUpdatesодна на бота, поэтому инстансов API должно быть один, пока у кого-то есть включённый бот (глобальныйTELEGRAM_BOT_TOKENили личный токен из «Настроек»), иначе привязка чата достанется случайному; - выключенный пользователь теряет сессии сразу, но существующий access-токен
остаётся валидным до часа — проверка
is_activeна каждый запрос не делается, чтобы не ходить в БД за каждой операцией. - мастер, заведённый через
POST /users, но не принявший приглашение ни в один проект, виден только создавшему его клиенту и админу:GET /users?role=workerбезqвозвращает мастеров проектов клиента со статусомactiveплюс созданных им самим. Найти для приглашения можно поиском&q=<имя или email>(мин. 2 символа) — или сразу пригласить тем же запросомPOST /users, получивworker_idв ответе; - журнал событий ведётся с момента наката
0006: у более старых заявокGET /tickets/:id/eventsпуст, хотя статусы у них есть; - подписанные ссылки на фото живут 15 минут и по истечении перестают
работать: UI перезапрашивает список, получая свежие
url; у битых ссылок нет фонового переиздания на стороне API; - просроченные приглашения фоновой задачей не чистятся: они просто
перестают приниматься и показываться, отозвать строку — ручной
DELETE.