Канон документов, каталог задач и OpenSpec
docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью. Конвенции перенесены из jellybit; места, где код им не следует, помечены строкой «Расхождение» как объявленный долг. tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и многопользовательский режим), два направления (все форматы, долгие записи) и пять задач в беклоге. openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет. CLAUDE.md переписан по форме канона: инварианты с severity, семантика гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# Конвенции кода
|
||||
|
||||
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
|
||||
система делает, и от [../architecture.md](../architecture.md), который описывает,
|
||||
как она сложена.
|
||||
|
||||
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
|
||||
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
|
||||
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
|
||||
размазывает внимание по тривиальному — и модель, и человек добросовестно
|
||||
проверят именование и не дойдут до формы решения.
|
||||
|
||||
Обоснование «почему именно так» живёт в [../adr/](../adr/README.md); инварианты с
|
||||
severity — в [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
## Откуда взяты и что с расхождениями
|
||||
|
||||
Все пять записей перенесены из проекта jellybit — тот же Go, тот же автор, те же
|
||||
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
|
||||
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
|
||||
шаге и дублируется воркером, доменные ошибки проверяются приведением типа.
|
||||
|
||||
Каждое такое место названо в своей записи строкой «*Расхождение:*». Читается оно
|
||||
как **долг, а не как нарушение**: правила действуют на новый код, переписывание
|
||||
существующего — отдельная работа. Проходу ревью строка «Расхождение» говорит, что
|
||||
находка на этом месте уже известна и новой не считается.
|
||||
|
||||
## Записи
|
||||
|
||||
- [logging.md](logging.md) — логирование: уровень по адресату, единая логирующая
|
||||
точка на доменной границе, словарь полей, `ext.*`, что не логируем.
|
||||
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is` и
|
||||
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
|
||||
типизированной.
|
||||
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
|
||||
`0600`, самодокументируемый `config.dist.toml`, проверка на старте.
|
||||
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
|
||||
ULID, разбор на входной границе, естественные ключи у деталей.
|
||||
- [web-ui.md](web-ui.md) — веб-UI на htmx: партиал равен странице равен
|
||||
фрагменту, ветвление по `HX-Request`, деградация без JS, ошибка на htmx-пути
|
||||
как 200 плюс фрагмент, самозавершающийся опрос, вендоринг статики. **Записана
|
||||
наперёд: веб-UI ещё нет.**
|
||||
|
||||
## Механизировано
|
||||
|
||||
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
|
||||
промптах ревью не пересказывается.
|
||||
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck` (кроме `defer Close` и `send`) |
|
||||
| Форматирование исходников | `.golangci.yml` → `gofmt` |
|
||||
| Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` |
|
||||
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
||||
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
|
||||
|
||||
Не названное здесь место механизации означает, что проход по конвенциям будет
|
||||
добросовестно проверять уже проверенное.
|
||||
|
||||
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
|
||||
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
|
||||
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
|
||||
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
|
||||
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
|
||||
оставшееся прозой, проверяет человек на каждом ревью заново.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Конфигурация
|
||||
|
||||
Конвенция: *как* устроена и грузится конфигурация transcriber (TOML).
|
||||
Правила оформления кода (How), не спецификация поведения.
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главные: образец называется `config.dist.toml`, а не `config.example.toml`;
|
||||
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
|
||||
проверки пустых ключей внутри адаптеров.
|
||||
|
||||
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера
|
||||
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас
|
||||
участвует.
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
|
||||
используем**: окружение наследуется дочерними процессами и видно через
|
||||
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
||||
*Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла.
|
||||
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
||||
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
|
||||
прикладном коде нет, только загрузчик `internal/config`.
|
||||
- Конфиг **неизменяем** после старта; смена параметров — перезапуск процесса.
|
||||
|
||||
## Файл и поиск
|
||||
|
||||
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
|
||||
процесса.
|
||||
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
|
||||
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный
|
||||
`config.toml` не коммитится.
|
||||
|
||||
## config.dist.toml — самодокументируемый образец
|
||||
|
||||
`config.dist.toml` коммитим как единый справочник по конфигу: все секции и все
|
||||
поля. **Каждое поле снабжаем комментарием**, из которого ясно:
|
||||
|
||||
- **зачем** поле — что оно меняет в поведении;
|
||||
- **допустимые значения** — перечисление или границы;
|
||||
- **единицы измерения**, если применимы — секунды, байты, доля `0–1`.
|
||||
|
||||
```toml
|
||||
[server]
|
||||
port = <N> # порт HTTP-сервера
|
||||
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
|
||||
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
|
||||
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
|
||||
```
|
||||
|
||||
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
|
||||
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
|
||||
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
|
||||
одним домом — таблица «Настройки с числовым значением» в
|
||||
[../database.md](../database.md); `config.dist.toml` — источник истины по составу
|
||||
полей.
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||
|
||||
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
|
||||
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
||||
|
||||
## Поля по дискриминатору `type`
|
||||
|
||||
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
|
||||
бекендов или внешних сервисов), обязательность и опциональность полей определяет
|
||||
значение `type`, а не фиксированный список секции.
|
||||
|
||||
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
|
||||
обязательных полей; поля других значений не требуются. Неизвестное значение —
|
||||
ошибка на старте с перечислением поддерживаемых.
|
||||
- **Образец — по `type`.** В `config.dist.toml`:
|
||||
- основное (умолчательное) значение **предзаполнено** рабочими значениями;
|
||||
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
|
||||
полей (зачем, границы, единицы — как у обычных полей);
|
||||
- так из примера видны все варианты и поля каждого, не открывая код.
|
||||
|
||||
Дискриминатора в transcriber пока нет; правило записано на случай второго
|
||||
распознавателя.
|
||||
|
||||
## Секреты
|
||||
|
||||
Секреты доставляет **выкладка**, рендеря их прямо в `config.toml` (transcriber:
|
||||
Ansible из `pet-project-server`). Приложение просто читает TOML — отдельного слоя
|
||||
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
|
||||
репозиторий и не окружение.
|
||||
|
||||
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
|
||||
`yandex.object_storage_access_key_id`,
|
||||
`yandex.object_storage_secret_access_key`.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
владелец — пользователь процесса (`1000:1000`).
|
||||
- В `config.dist.toml` секретные поля — пустые строки.
|
||||
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
|
||||
строки, и загрузчик их не отличает от настоящего значения.
|
||||
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
|
||||
отрендеренный файл) — см. «Проверка и остановка на старте».
|
||||
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
|
||||
|
||||
## Проверка и остановка на старте
|
||||
|
||||
Конфиг проверяем **на старте, до приёма трафика**. Негодный конфиг — лог `ERROR`
|
||||
и выход с ненулевым кодом, не стартуем наполовину.
|
||||
|
||||
Что проверяем:
|
||||
|
||||
- обязательные поля заданы;
|
||||
- каталоги хранилища существуют и доступны на запись;
|
||||
- границы числовых полей соблюдены;
|
||||
- ключи внешних сервисов не пусты.
|
||||
|
||||
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
|
||||
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и
|
||||
приложение продолжает работу без бота; пустые ключи Yandex ловятся в
|
||||
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
|
||||
места проверки нет.
|
||||
|
||||
## Структура в коде
|
||||
|
||||
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
||||
- Одна корневая структура `Config` с под-структурами по секциям (`Server`,
|
||||
`Database`, `Storage`, `Yandex`, `Telegram`).
|
||||
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
||||
требует правки обоих мест.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Конвенция: база данных и идентификаторы
|
||||
|
||||
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
||||
|
||||
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не
|
||||
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по
|
||||
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации
|
||||
и разбора нет. Правила действуют на новый код; переписывание существующего —
|
||||
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
|
||||
|
||||
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты
|
||||
в transcriber нет.
|
||||
|
||||
## Первичные ключи — ULID, не автоинкремент
|
||||
|
||||
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||
**приложением** в момент создания записи.
|
||||
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
||||
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
|
||||
целиком), глобально уникален между таблицами — поиск по голому id находит все
|
||||
записи сущности в логах.
|
||||
- **Точка генерации и разбора одна**: создание — при вставке записи в
|
||||
репозитории, разбор — на входных границах. Самодельных генераторов по месту
|
||||
вызова не заводим.
|
||||
|
||||
## Канонический вид — lowercase
|
||||
|
||||
- Генерим и храним id в **нижнем регистре**. Сравнение строк в SQLite
|
||||
побайтовое, поэтому любой внешний id (URL, форма, поле запроса) обязательно
|
||||
проходит разбор до запроса к БД — разбор проверяет формат и нормализует
|
||||
регистр (base32 ULID нечувствителен к регистру при декодировании).
|
||||
- Синтаксически неверный id считаем несуществующей сущностью (404), без похода
|
||||
в БД.
|
||||
|
||||
## Естественные и составные ключи — для деталей
|
||||
|
||||
- У таблиц-деталей и связей допустим естественный или составной ключ вместо
|
||||
ULID, когда он есть по природе данных. Отдельный ULID там — мёртвый вес.
|
||||
- Прочие генерируемые идентификаторы — тем же способом, что и ключи сущностей:
|
||||
единый формат, сортируемость, корреляция в логах.
|
||||
|
||||
## Прочее
|
||||
|
||||
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||
значения держит код.
|
||||
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
||||
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||
вставка падает громко. Измерение длительности — не метка времени.
|
||||
- Миграции — goose (`migrations/`): SQL-файлы для DDL; Go-миграции
|
||||
(`goose.AddMigrationContext`) — когда нужен код (генерация id, заполнение
|
||||
задним числом). При изменении структуры обновляем схему
|
||||
[../database.md](../database.md) тем же изменением.
|
||||
- Добавляя колонку, соблюдай инвариант «Новая колонка правится во всех четырёх
|
||||
местах» — [CLAUDE.md](../../CLAUDE.md), «Инварианты».
|
||||
@@ -0,0 +1,145 @@
|
||||
# Ошибки
|
||||
|
||||
Конвенция: *как* устроены и передаются ошибки в transcriber. Правила оформления
|
||||
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
|
||||
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки
|
||||
строятся, оборачиваются и проверяются.
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
|
||||
решают сами, а доменные ошибки проверяются приведением типа, а не `errors.As`.
|
||||
|
||||
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
|
||||
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
|
||||
пакетов ошибок в проекте и так нет.
|
||||
|
||||
## Базовая идиома: stdlib
|
||||
|
||||
- Только стандартный `errors` плюс `fmt.Errorf`: контекст ошибки несёт `slog`, а
|
||||
не стек — стек-трейсы и внешний сборщик избыточны для домашнего сервиса.
|
||||
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
|
||||
пересмотреть, а не умолчание.
|
||||
|
||||
## Обёртка и контекст
|
||||
|
||||
transcriber — **приложение, а не библиотека**: внешнего Go-API нет, весь код наш.
|
||||
Поэтому внутри приложения обёртка `%w` — **умолчание**, чтобы `errors.Is` и
|
||||
`errors.As` работали сквозь слои.
|
||||
|
||||
- Добавляем контекст обёрткой: `fmt.Errorf("convert audio: %w", err)`.
|
||||
- `%w` — когда вызывающий может смотреть причину (наш обычный случай). `%v` —
|
||||
когда причину сознательно **не** раскрываем.
|
||||
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке, а
|
||||
трансляцией на внешней границе (см. ниже).
|
||||
|
||||
Стиль сообщения:
|
||||
|
||||
- со строчной, без точки в конце, без «failed to» и «error» — обёртка и так
|
||||
читается как «контекст: причина»;
|
||||
- контекст — операция или субъект: `"acquire job: %w"`, а не
|
||||
`"something failed"`;
|
||||
- без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний.
|
||||
|
||||
*Расхождение:* в коде преобладает форма `"failed to <действие>: %w"`.
|
||||
|
||||
## Проверка ошибок
|
||||
|
||||
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
|
||||
`sql.ErrNoRows` превращается в доменную ошибку в слое репозитория, чтобы выше
|
||||
по коду не торчал `database/sql`.
|
||||
- Проверяем `errors.Is` и `errors.As`, а не сравнением и не приведением типа.
|
||||
|
||||
*Расхождение, и оно опасно:* `NoopJobError` и `JobNotFoundError` проверяются
|
||||
приведением типа — `err.(*contract.NoopJobError)` в
|
||||
`internal/controller/worker/worker.go` и `err.(*contract.JobNotFoundError)` в
|
||||
`internal/service/transcribe.go`. Работает это только потому, что на этом пути
|
||||
ошибку никто не оборачивает. Первый же `fmt.Errorf("…: %w")` между ними сломает
|
||||
проверку молча: воркер перестанет отличать «задач нет» от отказа и начнёт
|
||||
считать пустой прогон ошибкой раз в секунду.
|
||||
|
||||
## Sentinel и типизированные
|
||||
|
||||
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, на
|
||||
которые ветвится код. Проверяем `errors.Is`.
|
||||
- **Типизированная ошибка** (тип с полями плюс метод `Error()`) — когда
|
||||
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
|
||||
где хватает sentinel.
|
||||
|
||||
Сегодня в проекте три типизированные ошибки, и данные несёт только одна:
|
||||
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
||||
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel).
|
||||
|
||||
## Граница и трансляция: приватный и публичный канал
|
||||
|
||||
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**, и
|
||||
форма зависит от канала и от того, кто его видит:
|
||||
|
||||
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
|
||||
`%w` и контекстом. Пишется один раз на доменной границе — см.
|
||||
[logging.md](logging.md).
|
||||
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
|
||||
API). Сюда отдаём:
|
||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
|
||||
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
|
||||
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
|
||||
найти полную ошибку в логах. «При обработке задачи произошла ошибка, job_id
|
||||
= …», а не «произошла ошибка» и не сырой текст.
|
||||
|
||||
**Ключ есть не у всякого транспорта, и это называется вслух.** Отказ приёма
|
||||
случается до заведения задачи, и ключа у него нет вовсе — тогда сообщение
|
||||
остаётся без якоря, а диагностика ищется по записи доменной границы.
|
||||
Заводить транспорту собственный идентификатор запроса ради ключа — решение
|
||||
уровня спеки, а не умолчание;
|
||||
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
|
||||
HTTP и веба:
|
||||
|
||||
| Доменная ошибка | Статус | Сообщение |
|
||||
| --- | --- | --- |
|
||||
| задача не найдена | 404 | «задача не найдена» |
|
||||
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
|
||||
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | «внутренняя ошибка» |
|
||||
|
||||
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
||||
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
||||
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
||||
|
||||
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go`
|
||||
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на
|
||||
любую ошибку заведения задачи.
|
||||
|
||||
### Разовый ответ и сохранённая диагностика
|
||||
|
||||
У публичной границы две поверхности, и правило сырого текста для них разное.
|
||||
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
|
||||
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
полная ошибка остаётся в логах по идентификатору задачи.
|
||||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
||||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||||
и полезен. Но:
|
||||
- **секреты запрещены** — токены, ключи, пароли, заголовок
|
||||
авторизации. Ошибка транспорта может нести URL с токеном внутри, и её
|
||||
вычищают на границе клиента;
|
||||
- это **не** канал для разовых отказов — те остаются нейтральными;
|
||||
- **внешнее значение в тексте усекается на границе, а его размер называется
|
||||
числом рядом**: без этого непонятно, насколько сокращать.
|
||||
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
|
||||
пользователь Telegram видит отдельный человекочитаемый текст — это часть
|
||||
правила соблюдена.
|
||||
|
||||
## panic
|
||||
|
||||
- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
|
||||
инициализации, из которой нельзя стартовать.
|
||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||||
это значения `error`.
|
||||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||||
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
|
||||
границы **нет**: паника в шаге конвейера роняет процесс целиком.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
|
||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Логирование
|
||||
|
||||
Конвенция: *как* и *когда* писать логи в transcriber. Это правила оформления
|
||||
кода (How), а не спецификация поведения — наблюдаемые требования к логам (что
|
||||
система обязана залогировать как часть контракта capability) живут в спеках
|
||||
OpenSpec.
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главные: обработчик текстовый, а не JSON; уровень зашит `INFO` и не
|
||||
настраивается; `msg` — предложение с заглавной буквы, а не константная
|
||||
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
|
||||
ошибку выше, где её логируют снова.
|
||||
|
||||
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не
|
||||
включены, поэтому правилами не выражено ни одно из перечисленного ниже.
|
||||
|
||||
## Принципы
|
||||
|
||||
- Структурированный JSON (`slog.JSONHandler`), один формат для разработки и для
|
||||
продакшена.
|
||||
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
|
||||
отдельный ключ с типизированным значением: это даёт отбор и сведение через
|
||||
`jq` без регулярных выражений.
|
||||
|
||||
```json
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
|
||||
```
|
||||
|
||||
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
|
||||
|
||||
## Сообщение
|
||||
|
||||
- `msg` — короткая константа в нижнем регистре: `job accepted`,
|
||||
`recognition done`, `conversion failed`. Данные — в атрибутах:
|
||||
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
|
||||
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
|
||||
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
|
||||
- **Смена состояния задачи — единая категория `state transition`** с полями
|
||||
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
|
||||
жизненный цикл собирался одним отбором:
|
||||
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
|
||||
сверх перехода — отдельная запись своей категории (`file converted`,
|
||||
`text delivered`), она запись перехода не подменяет.
|
||||
|
||||
*Расхождение:* сегодня `msg` — предложение вида `Starting conversion job`,
|
||||
поля `capability` нет, отдельной категории перехода нет.
|
||||
|
||||
## Уровни
|
||||
|
||||
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько громко
|
||||
сломалось». `slog` даёт четыре уровня; их и используем.
|
||||
|
||||
| Уровень | Кому и когда | Примеры в transcriber |
|
||||
| --- | --- | --- |
|
||||
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
|
||||
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
|
||||
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
|
||||
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
|
||||
|
||||
Правила:
|
||||
|
||||
- Уровень **не зависит от capability**: `ERROR` в приёме и в распознавании
|
||||
одинаково серьёзны.
|
||||
- `WARN` не значит «ничего страшного». `WARN` значит «может стать проблемой».
|
||||
Если это не «может» — это `INFO`.
|
||||
- Меняется адресат — меняется уровень. Негодный ввод от пользователя — это
|
||||
`DEBUG` (норма, владельцу разбирать нечего), а не `ERROR`.
|
||||
- **Событийное — `INFO`, рутинно-частое — `DEBUG`.** Операция по реальному
|
||||
действию (приём записи, запуск распознавания, отправка текста) идёт на `INFO`.
|
||||
Повторяющаяся служебная операция, которую запускает таймер или опрос и которая
|
||||
сама по себе события не несёт (проверка здоровья, пустой прогон воркера,
|
||||
опрос готовности операции), — на `DEBUG`: на `INFO` она зашумляет разбор.
|
||||
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
|
||||
завершаем процесс с ненулевым кодом.
|
||||
|
||||
*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем.
|
||||
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
|
||||
|
||||
## Время
|
||||
|
||||
- Поле — `time` (ключ `slog` по умолчанию).
|
||||
- UTC, RFC 3339 с долями секунды, суффикс `Z`.
|
||||
- Логи — **в UTC**, как и хранение в БД: это даёт однозначный порядок событий и
|
||||
лексикографическую сортировку. Часовой пояс есть только у **отображения**.
|
||||
|
||||
## Поля: словарь имён
|
||||
|
||||
Главное условие — **единый словарь**: одно поле, одно имя по всему коду.
|
||||
|
||||
- Доменные поля — плоский `snake_case`.
|
||||
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
|
||||
`ext.*`.
|
||||
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
||||
|
||||
| Когда добавляем | Поля |
|
||||
| --- | --- |
|
||||
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`; пока их нет, поле не заполняется), `job_id`, `file_id`, `source` |
|
||||
| на запись об ошибке | `error` |
|
||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||
|
||||
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
||||
|
||||
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
|
||||
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
|
||||
пересечён с этим лишь частично.
|
||||
|
||||
## Корреляция по id сущности
|
||||
|
||||
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
|
||||
осмысленные ключи: идентификаторы задачи и файла, они лежат в базе.
|
||||
|
||||
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
|
||||
Для задачи — логгер с уже подставленным ключом, протаскиваемый сквозь стадии,
|
||||
чтобы ключ дописывался на каждую запись сам:
|
||||
|
||||
```go
|
||||
log := log.With("job_id", job.Id, "capability", "conversion")
|
||||
```
|
||||
|
||||
- Все записи одной задачи собираются одним отбором:
|
||||
`jq 'select(.job_id=="…")' app.jsonl`.
|
||||
|
||||
## Ошибки
|
||||
|
||||
Ошибки Go логируем как атрибут, а не как текст сообщения:
|
||||
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
|
||||
|
||||
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
||||
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
|
||||
накапливается в цепочке `%w`.
|
||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
|
||||
исход операции. Логирует эта единая точка, а не каждый транспорт — так
|
||||
транспорты остаются тонкими. Границы в transcriber:
|
||||
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
|
||||
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||||
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
|
||||
- завершение и отказ задачи (`completeJob`, `failJob`).
|
||||
- Транспорты переводят возвращённую ошибку в свой ответ и **не логируют** её
|
||||
повторно — иначе один сбой даёт дубли.
|
||||
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой доменной
|
||||
ошибки ровно один логирующий; уровень выбирает он.
|
||||
|
||||
| Класс отказа | Кому | Уровень |
|
||||
| --- | --- | --- |
|
||||
| негодный ввод, задача не найдена, действие сейчас недопустимо | пользователю (он уже получил ответ на поверхности) | `DEBUG` |
|
||||
| запись распознана пустой, задача досталась повторно | владельцу, «может стать проблемой» | `WARN` |
|
||||
| сбой БД, диска, недоступность внешнего сервиса | владельцу, в разбор | `ERROR` |
|
||||
|
||||
- **Повторяющийся сбой фонового цикла — `WARN`, а не `ERROR`.** Одиночный
|
||||
промах шага временный: задача останется в своём состоянии, и следующий тик
|
||||
повторит. Тот же класс сбоя в синхронной операции приёма — `ERROR`, потому что
|
||||
операция провалилась целиком и повтора нет. Уровень задаёт не текст ошибки, а
|
||||
наличие штатного повтора.
|
||||
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о поведении
|
||||
зависимости, а не дубль доменной ошибки.
|
||||
- Глушить ошибку без лога — только с однострочным комментарием «почему».
|
||||
|
||||
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
|
||||
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
|
||||
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf`
|
||||
мимо `slog` целиком.
|
||||
|
||||
## Внешние сервисы: логируем все вызовы
|
||||
|
||||
**Каждый** вызов внешнего сервиса логируется. Поля:
|
||||
|
||||
- `ext.service` — `telegram`, `speechkit`, `object-storage`, `ffmpeg`;
|
||||
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
|
||||
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
|
||||
- `ext.status_code` — код ответа, если применим;
|
||||
- `duration_ms` — длительность вызова;
|
||||
- `retry` — номер попытки, если повторы были.
|
||||
|
||||
Уровни вызова:
|
||||
|
||||
- `INFO` — успешный **событийный** вызов (заливка объекта, запуск распознавания,
|
||||
отправка сообщения, конвертация);
|
||||
- `DEBUG` — успешный **рутинно-частый** вызов (опрос готовности операции,
|
||||
длинный опрос обновлений);
|
||||
- `WARN` — попытка не удалась, делаем повтор;
|
||||
- `ERROR` — повторы исчерпаны либо сервис недоступен. Завершённый ответ с 4xx —
|
||||
это успех на транспортном уровне; решение «это ошибка» принимает доменный
|
||||
вызывающий.
|
||||
|
||||
Тело запроса и ответа — только на `DEBUG` и **после** вычистки секретов.
|
||||
|
||||
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
|
||||
конвертация (через метрику длительности) и запуск распознавания; заливка в
|
||||
Object Storage, скачивание файла из Telegram и опрос операции не логируются
|
||||
никак.
|
||||
|
||||
## HTTP и проверка здоровья
|
||||
|
||||
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
||||
`http.status_code`, `duration_ms`, `transport`.
|
||||
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
|
||||
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
|
||||
потребитель молча оставит одно из значений. Правило проверяется чтением,
|
||||
линтером не выражается.
|
||||
- **`GET /health` и `GET /metrics` логируем на `DEBUG`** — их дёргают
|
||||
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
||||
`INFO` они не пишутся.
|
||||
|
||||
*Расхождение:* `sloggin` пишет все запросы одинаково, `/health` и `/metrics`
|
||||
попадают в лог наравне с остальными.
|
||||
|
||||
## Безопасность: что не логируем
|
||||
|
||||
Никаких секретов в полях и сообщениях. Под запретом:
|
||||
|
||||
- токен бота Telegram;
|
||||
- ключ SpeechKit и заголовок `Authorization`;
|
||||
- пара ключей Object Storage;
|
||||
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
|
||||
переписки. Логируем длину текста, а не текст.
|
||||
|
||||
Дополнительно:
|
||||
|
||||
- Тела запросов и ответов внешних сервисов — только на `DEBUG`, с вычисткой
|
||||
секретов и обрезкой по длине.
|
||||
- При сомнении не логируем значение, логируем факт его наличия
|
||||
(`"has_api_key", true`).
|
||||
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
|
||||
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
|
||||
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
|
||||
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
|
||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||
|
||||
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
|
||||
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
|
||||
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
|
||||
строки лога.
|
||||
|
||||
## Куда пишем и уровень
|
||||
|
||||
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
|
||||
раскладываем по файлам.
|
||||
- Базовый уровень в продакшене — `INFO`; `DEBUG` включается конфигом при
|
||||
необходимости. При разработке — `DEBUG`.
|
||||
|
||||
*Расхождение:* поля конфигурации под уровень лога нет.
|
||||
|
||||
## Анализ
|
||||
|
||||
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
|
||||
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Веб-UI (htmx)
|
||||
|
||||
Конвенция: *как* мы пишем код веб-UI — частичная замена фрагментов, опрос живых
|
||||
обновлений, обработчики действий, деградация без JS, ошибки. Это правила
|
||||
оформления кода (How), а не спецификация поведения — что именно UI показывает и
|
||||
какие действия обязан поддерживать, живёт в спеке OpenSpec.
|
||||
|
||||
**Взято из проекта jellybit и записано наперёд: веб-UI в transcriber нет вовсе.**
|
||||
Есть только HTTP API на gin. Ни одного расхождения назвать нельзя — нечему
|
||||
расходиться; правила действуют с первой страницы, которую заведём.
|
||||
|
||||
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
|
||||
наружу — [errors.md](errors.md). Здесь — только особенности htmx-транспорта, без
|
||||
повторения.
|
||||
|
||||
## Стек и границы
|
||||
|
||||
htmx-first: `gin` плюс `html/template` (рендер на сервере) плюс htmx. Ничего
|
||||
сверх этого: **без шага сборки, без Node и сборщика, без реактивных
|
||||
фреймворков**. htmx вендорится и раздаётся с нашего же хоста (`go:embed`,
|
||||
`/static/vendor/`), без CDN.
|
||||
|
||||
- Свой JS сведён к минимуму: только то, чего серверу знать не нужно.
|
||||
**Клиентского пересчёта доменного состояния нет** — состояние считает сервер,
|
||||
клиент лишь подменяет присланную разметку.
|
||||
- Alpine.js и SPA сознательно **не вводим**. Понадобится реактивный клиентский
|
||||
виджет — вводим отдельным изменением и записываем решение, не раньше.
|
||||
|
||||
## Единый источник разметки: партиал равен странице равен фрагменту
|
||||
|
||||
Переиспользуемый кусок — это `{{define "name"}}` в каталоге партиалов. Тот же
|
||||
`{{define}}` рендерится **и** внутри страницы (`{{template "name" .}}`), **и**
|
||||
как ответ-фрагмент того же обработчика. Отдельной разметки под фрагмент не
|
||||
заводим — иначе она разъедется со страницей.
|
||||
|
||||
**Инвариант: корень `{{define}}` — это элемент с целевым `id`**, например
|
||||
`#job-{id}`. `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный
|
||||
фрагмент не несёт тот же корневой `id`, следующее действие или опрос не найдёт
|
||||
цель. Разметку и `id` держим в одном партиале.
|
||||
|
||||
Сборку данных для шаблона выносим в отдельную функцию и зовём её и на полной
|
||||
странице, и во фрагменте — чтобы htmx-ветка не копировала сборку.
|
||||
|
||||
## Обработчик действия: ветвление htmx и редирект
|
||||
|
||||
htmx-запрос определяем по заголовку `HX-Request: true`.
|
||||
|
||||
Обработчик действия зовёт доменную операцию **одинаково** в обеих ветках, а
|
||||
дальше ветвится: без htmx — привычный редирект после POST (303); с htmx —
|
||||
перечитать актуальное состояние, собрать данные тем же сборщиком и отдать
|
||||
фрагмент.
|
||||
|
||||
Рендер именованного шаблона идёт **в буфер** и только затем пишется в ответ: при
|
||||
ошибке шаблона клиент не получит полстраницы.
|
||||
|
||||
## Деградация без JS обязательна
|
||||
|
||||
Формы действий остаются обычными `<form method="post" action="...">`, а
|
||||
`hx-post`, `hx-target` и `hx-swap` лишь **накладываются сверху** на ту же форму.
|
||||
Без JS всё работает через POST и редирект. Атрибут `action` — рабочий запасной
|
||||
путь, а не украшение.
|
||||
|
||||
Фильтр, поиск и разбиение списка на страницы — **серверные**, параметрами
|
||||
запроса, тоже без JS. Клиентской фильтрации нет намеренно.
|
||||
|
||||
## Ошибки на htmx-пути: HTTP 200 и фрагмент
|
||||
|
||||
htmx по умолчанию **не подменяет DOM на ответы 4xx и 5xx**. Поэтому при ошибке
|
||||
действия обработчик отвечает **200 с фрагментом**, несущим сообщение. Доменную
|
||||
ошибку на htmx-пути **не** транслируем в HTTP-статус — в отличие от API и от
|
||||
пути без JS.
|
||||
|
||||
- Сообщение — нейтральный текст публичного канала (см. [errors.md](errors.md));
|
||||
сырой `err.Error()` наружу не идёт.
|
||||
- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая доменные
|
||||
поля: непустое доменное поле перекрыло бы сообщение.
|
||||
- **При ошибке активное состояние не меняем** — перечитанные данные показывают
|
||||
прежний выбор плюс сообщение.
|
||||
|
||||
## Живой опрос
|
||||
|
||||
Приём живого обновления: эндпоинт фрагмента плюс в разметке `hx-get`,
|
||||
`hx-trigger="every Ns"` и `hx-swap="outerHTML"`. Прямой предмет опроса в
|
||||
transcriber — карточка задачи, пока та не дошла до `done` или `failed`.
|
||||
|
||||
- **Один опросчик на обновляемый корень.** Опрашивает себя корень поверхности, а
|
||||
вложенные живые области своего `hx-get` **не несут**: подмена корня уносит их
|
||||
вместе с таймером, и два опроса подменяли бы разметку друг друга.
|
||||
- **Опросчик самозавершается.** Опрос идёт, пока предмет может измениться без
|
||||
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
|
||||
больше не опрашивает. Для задачи это значит: `created`, `converted` и
|
||||
`transcribe` наблюдаемы, `done` и `failed` — нет.
|
||||
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
|
||||
`200` и фрагментом с объяснением **без `hx-*`**: htmx не подменяет DOM на
|
||||
`4xx` и `5xx`, поэтому статус ошибки оставил бы поверхность навсегда прежней, а
|
||||
опрос — бесконечным. Фрагмент отказа обязан нести корневой `id` того узла,
|
||||
который он собой заменяет.
|
||||
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный повтор;
|
||||
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
|
||||
- **Подмена всего фрагмента через `outerHTML`** удаляет старый узел вместе с его
|
||||
опросчиком, и htmx заново размечает новый — двойного опроса нет **при условии
|
||||
совпадения корневого `id`**. Эфемерное состояние разметки подмену не
|
||||
переживает: то, что должно пережить тик (раскрытый `<details>`), помечается
|
||||
`hx-preserve`.
|
||||
- **Частота — по цене тика, и она называется числом** в таблице настроек
|
||||
[../database.md](../database.md) в тот же момент, когда заводится первый
|
||||
опрашиваемый экран; сегодня такой настройки нет. Опрашивать чаще, чем меняется
|
||||
источник, бессмысленно: задачу двигает воркер с шагом в секунду.
|
||||
- **Тик ходит в БД, и это цена решения.** Читать состояние задачи дешевле, чем
|
||||
держать снимок в памяти, но каждый открытый браузер добавляет запросов.
|
||||
|
||||
## Подмена сохраняет контекст; выход — навигация
|
||||
|
||||
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные фильтр,
|
||||
поиск и страницу (они в параметрах запроса). Действие **не должно уводить**
|
||||
пользователя со страницы, если предмет остаётся на ней.
|
||||
|
||||
Действие, после которого предмет **покидает** страницу (удаление записи),
|
||||
остаётся **обычной POST-формой без `hx-*`**, то есть полной навигацией. Признак
|
||||
«это выход» — форма без htmx-атрибутов; так не нужен `HX-Redirect`, а «уйти с
|
||||
экрана» выражено самой навигацией.
|
||||
|
||||
**Асинхронные действия.** Доменное действие асинхронно почти всегда: загрузка
|
||||
записи только заводит задачу, работу доделывают воркеры. Подмена отдаёт
|
||||
**промежуточное** состояние, а не мнимый результат; готовый итог догоняем
|
||||
самозавершающимся опросом. Мгновенный итог в UI не обещаем.
|
||||
|
||||
## Различение поверхности одного действия
|
||||
|
||||
Один и тот же роут действия, вызванный с разных страниц, отдаёт разные фрагменты.
|
||||
Различаем **явным скрытым полем формы** `surface=list|detail`, а не догадкой по
|
||||
`HX-Target` или `Referer`: поле самодокументируемо и не зависит от разрешения
|
||||
цели.
|
||||
|
||||
## Статика, вендоринг, кэш
|
||||
|
||||
- Ресурсы встроены через `go:embed`, отдаются под `/static/` с длинным
|
||||
неизменяемым кэшем (`Cache-Control: public, max-age=31536000, immutable`).
|
||||
- Меняемые ресурсы (css, js) версионируются параметром `?v=<версия>` — коротким
|
||||
sha256 их содержимого, URL строит помощник шаблона. Свежая выкладка не отдаёт
|
||||
устаревший файл.
|
||||
- Вендор (htmx, шрифты) адресуется по **неизменному имени файла**, и параметр
|
||||
версии ему не нужен. В git его **не коммитим**; задача сборки идемпотентно
|
||||
добывает его по манифесту со сверкой sha256.
|
||||
- Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен,
|
||||
внешних ресурсов времени выполнения нет.
|
||||
Reference in New Issue
Block a user