1159 lines
90 KiB
Markdown
1159 lines
90 KiB
Markdown
|
|
# building-mgmt-backend
|
|||
|
|
|
|||
|
|
Backend для сервиса управления заявками в здании: админ создаёт клиента,
|
|||
|
|
клиент заводит **проект**, объекты в нём (здания с адресами) и приглашает
|
|||
|
|
мастеров, назначает заявку принятому мастеру, мастер ведёт работу до
|
|||
|
|
завершения — каждый переход остаётся в журнале событий заявки.
|
|||
|
|
|
|||
|
|
**Стек:** Go · Gin · PostgreSQL (pgx v5) · JWT (golang-jwt) · bcrypt
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Быстрый старт
|
|||
|
|
|
|||
|
|
1. Инфраструктура. Один `docker compose up` поднимает базу **и MinIO** —
|
|||
|
|
локальный S3 для фотоотчёта (в `.env` подставляется `DATABASE_URL`
|
|||
|
|
соответствующего вида):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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 Hub `minio/minio` архивирован,
|
|||
|
|
анонимный pull недоступен. В проде подойдёт любой S3-совместимый
|
|||
|
|
(AWS, R2, Wasabi) — ключи задаются в `.env`.
|
|||
|
|
|
|||
|
|
Остановить — `docker compose down` (`make down`), данные остаются в томах.
|
|||
|
|
|
|||
|
|
- **внешняя база через туннель** — как раньше: `localhost:5432`
|
|||
|
|
(MinIO поднимается тем же compose).
|
|||
|
|
|
|||
|
|
2. Положить рядом `.env` (пример ключей — в `.env.example`):
|
|||
|
|
|
|||
|
|
```dotenv
|
|||
|
|
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
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
3. Запустить:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
go run ./cmd/api # или: make run
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Миграции применяются **автоматически при старте** — отдельная команда не нужна.
|
|||
|
|
Но если нужно накатить схему без запуска сервера (CI, релизный шаг):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
go run ./cmd/migrate # или: make migrate
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Проверка: `GET /ping` — приложение, `GET /db-ping` — база (нужен токен админа),
|
|||
|
|
`GET /swagger/index.html` — интерактивная документация API (нужен
|
|||
|
|
`SWAGGER=true` в `.env` — включено в примере выше).
|
|||
|
|
|
|||
|
|
4. *(по желанию)* Демо-данные, чтобы не собирать сценарий руками:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
go run ./cmd/seed --yes # или: make seed
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
В базе появятся клиент, два мастера, админ, проект «Демо-проект»
|
|||
|
|
с двумя объектами и четыре заявки (все ветки статусной машины).
|
|||
|
|
Пароль у демо-аккаунтов один и печатается командой. Повторный запуск
|
|||
|
|
идемпотентен, `--force` пересоздаёт. **Только локальная/демо-база** —
|
|||
|
|
пароли фиксированные.
|
|||
|
|
|
|||
|
|
### Первый админ
|
|||
|
|
|
|||
|
|
Самый быстрый способ — `make seed` (флаг `--yes` создаёт
|
|||
|
|
`demo-admin@example.com`). Ручной способ описан ниже, он нужен, когда
|
|||
|
|
демо-аккаунт не нужен, а админ есть:
|
|||
|
|
|
|||
|
|
Аккаунты создаются **только изнутри системы**: админ — админов,
|
|||
|
|
клиентов и мастеров (`POST /users`), клиент — исключительно мастеров
|
|||
|
|
(роль там разрешена только `worker`), для себя в вебе, раздел
|
|||
|
|
«Клиенты». Саморегистрации нет — отдельного эндпоинта и кнопки на
|
|||
|
|
экране входа. При этом мастер, созданный клиентом, входит лишь после
|
|||
|
|
его подтверждения (раздел «Подтверждение аккаунта (SMTP)»).
|
|||
|
|
То есть для самого первого админа нужно
|
|||
|
|
завести запись руками, например:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
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());
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Сгенерировать хеш:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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` одинаковы:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"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
|
|||
|
|
должно быть один (см. «Известные ограничения»).
|
|||
|
|
|
|||
|
|
Привязка — пункт меню **«Настройки»** (виден всем ролям):
|
|||
|
|
|
|||
|
|
0. При желании — **подключить своего бота**: поле токена →
|
|||
|
|
`POST /auth/telegram/bot` → `getMe` → шифрование → поллер. Отключить —
|
|||
|
|
`DELETE /auth/telegram/bot`. Без `DATA_ENCRYPTION_KEY` поле на сервере
|
|||
|
|
не работает (записать токен некуда) — остаётся глобальный бот.
|
|||
|
|
1. «Получить код привязки» → `POST /auth/telegram/code` отдаёт
|
|||
|
|
одноразовый код и ссылку `https://t.me/<bot>?start=<code>`. Код живёт
|
|||
|
|
15 минут, выпуск нового гасит прежние, в БД лежит только **sha256**
|
|||
|
|
(как токены сброса пароля); лимит — 5 выдач за 5 минут на пользователя.
|
|||
|
|
Бот для ссылки — свой, а если токена нет — глобальный.
|
|||
|
|
2. Человек отправляет код боту → poller гасит код одним `UPDATE`
|
|||
|
|
(одноразовость и срок — в самом `WHERE`), пишет числовой `chat_id` в
|
|||
|
|
`profiles.telegram_target` и отвечает «Готово — уведомления будут
|
|||
|
|
приходить в этот чат».
|
|||
|
|
3. Либо вручную: `PUT /auth/telegram` с телом `{"target": "@mygroup"}`
|
|||
|
|
— группа или канал, куда бот добавлен участником. Формат
|
|||
|
|
(`^@[A-Za-z0-9_]{5,32}$` либо `^-?[0-9]{5,20}$`) проверяется до базы,
|
|||
|
|
затем бот сверяет чат через `getChat`. **Личный `@ник` здесь не
|
|||
|
|
работает намеренно**: Telegram не даёт писать человеку по одному
|
|||
|
|
никну, пока он сам боту не напишет, — для этого и нужен код.
|
|||
|
|
4. `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`) — страницы одинаковой формы:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"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 — это **приглашение, а не членство**:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"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` — ритм «раз в год» сохраняется |
|
|||
|
|
|
|||
|
|
Правила:
|
|||
|
|
|
|||
|
|
1. **Применённую миграцию редактировать нельзя** — при несовпадении checksum
|
|||
|
|
приложение не стартует. Изменение — это всегда новая миграция со следующим
|
|||
|
|
номером (`0008_*.sql`).
|
|||
|
|
2. Все миграции написаны так, чтобы сработать и на пустой базе, и на базе,
|
|||
|
|
где таблицы были созданы руками до появления миграций (`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` включены таймауты: `ReadHeaderTimeout` 10с — защита
|
|||
|
|
от Slowloris, `ReadTimeout`/`WriteTimeout` 60с — с запасом на загрузку
|
|||
|
|
фотоотчёта, `IdleTimeout` 120с — висящие 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`, порядок важен):
|
|||
|
|
|
|||
|
|
1. `middleware.RequestID` — берёт входящий `X-Request-Id`, если он осмысленный
|
|||
|
|
(до 64 символов из `[A-Za-z0-9._-]`), иначе генерирует свой; кладёт id
|
|||
|
|
в контекст и возвращает его тем же заголовком;
|
|||
|
|
2. `middleware.Recovery` — паника не роняет процесс: клиенту `500`
|
|||
|
|
с фиксированным текстом, в лог — причина и стек;
|
|||
|
|
3. `middleware.AccessLog` — одна запись на запрос: `method`, `path`, `status`,
|
|||
|
|
`duration_ms`, `client_ip`, `user_id` (если есть) и `request_id`.
|
|||
|
|
Уровень по статусу: `5xx` → error, `4xx` → warn, остальное → info.
|
|||
|
|
|
|||
|
|
Ошибки, которые логируются отдельно (в `respondError`, логинах, деактивации),
|
|||
|
|
тоже несут `request_id` — один запрос находится в логах по одному значению.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Инженерная культура
|
|||
|
|
|
|||
|
|
- **Линтер** — `golangci-lint` v2, конфиг в `.golangci.yml`: стандартный набор
|
|||
|
|
(`errcheck`, `govet`, `staticcheck`, `ineffassign`, `unused`) плюс `gocritic`,
|
|||
|
|
`misspell`, `revive`, форматтеры `gofmt`/`goimports`. Исключения узкие и
|
|||
|
|
задокументированы (в тестах `t` идёт первым аргументом).
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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` локальной разработки он включён).
|
|||
|
|
|
|||
|
|
Схема генерируется из аннотаций над хендлерами:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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/`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Тесты
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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 получает точные заголовки
|
|||
|
|
и preflight `204` без тела, чужой 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`, обновления переживают.
|
|||
|
|
|
|||
|
|
### Первый запуск
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# 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-контейнера и лимиты логина всех пользователей
|
|||
|
|
сложатся в один общий ключ.
|
|||
|
|
|
|||
|
|
### Обновление и операции
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Обновление: образы новые, данные в томах не трогаются.
|
|||
|
|
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, но обязательно):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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 больше нет.
|
|||
|
|
|
|||
|
|
### Установка (однократно, на прод-сервере)
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# В 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 --check` js → сборка образа
|
|||
|
|
`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`.
|