DATA_MODEL — схема данных и контракты¶
Здесь: правки моделей wger, схема Postgres у core-service, набросок gRPC-контрактов.
Это ориентир для реализации; финальные поля фиксировать в коде/proto и держать
синхронно с этим файлом.
1. Правки моделей wger¶
Минимально-инвазивно. wger хранит историю в двух объектах:
- WorkoutSession — одна тренировка (дата, заметки, впечатление). Историчиски также
имеет тайминги уровня всей сессии (time_start/time_end) — проверить в модели
и переиспользовать, если есть.
- WorkoutLog — один выполненный подход: weight, repetitions, rir, rest
(факт) + *_target (план).
Добавляем в WorkoutLog:
| Поле | Тип | Назначение |
|---|---|---|
set_started_at |
datetime, nullable | реальное время начала подхода (T1) |
set_finished_at |
datetime, nullable | реальное время конца подхода (T2) |
rest (фактический отдых) уже есть — отдельное поле не нужно; при желании отдых
можно вычислять как next.set_started_at − this.set_finished_at.
Шаги: модель → миграция → поля в DRF-сериализаторе и Viewset → правка экрана логирования (на стороне мини-аппа, не React-фронта wger).
2. Схема Postgres у core-service (ориентир)¶
Отдельная БД от wger. Имена — рабочие, уточнять при реализации.
-- Тренировки (нормализованные из wger)
CREATE TABLE workouts (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'wger',
external_id TEXT, -- id сессии в wger
session_date DATE NOT NULL,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
notes TEXT,
UNIQUE (source, external_id)
);
CREATE TABLE sets (
id BIGSERIAL PRIMARY KEY,
workout_id BIGINT REFERENCES workouts(id),
source TEXT NOT NULL DEFAULT 'wger',
external_id TEXT, -- id WorkoutLog в wger
exercise_name TEXT NOT NULL, -- денормализованное имя/тренажёр
exercise_ext_id TEXT, -- id упражнения в wger
weight NUMERIC,
repetitions INTEGER,
rir NUMERIC,
-- T1/T2, снятые клиентом в момент нажатия. Авторитетны (ADR-0013).
client_started_at TIMESTAMPTZ,
client_finished_at TIMESTAMPTZ,
-- Когда запись дошла до core. Только для аудита отставания синка,
-- временем подхода не считается.
server_received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rest_seconds INTEGER, -- факт. отдых
UNIQUE (source, external_id)
);
CREATE INDEX ON sets (client_started_at);
-- Интрадей-показатели организма (Fitbit Air / Google Health)
CREATE TABLE health_metrics (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'google_health',
metric_type TEXT NOT NULL, -- heart_rate | steps | spo2 | sleep | weight ...
value NUMERIC NOT NULL,
unit TEXT,
measured_at TIMESTAMPTZ NOT NULL,
UNIQUE (source, metric_type, measured_at)
);
CREATE INDEX ON health_metrics (metric_type, measured_at);
-- Отчёты InBody
CREATE TABLE inbody_reports (
id BIGSERIAL PRIMARY KEY,
measured_at TIMESTAMPTZ NOT NULL,
weight NUMERIC,
body_fat_pct NUMERIC,
skeletal_muscle NUMERIC,
visceral_fat NUMERIC,
body_water NUMERIC,
protein NUMERIC,
minerals NUMERIC,
segmental JSONB, -- посегментный анализ
raw JSONB, -- всё распарсенное, на всякий случай
UNIQUE (measured_at)
);
-- Медикаменты и прочее из Apple Health
CREATE TABLE medications (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'apple_health',
name TEXT NOT NULL,
dose TEXT,
taken_at TIMESTAMPTZ NOT NULL,
raw JSONB,
UNIQUE (source, name, taken_at)
);
Идемпотентность. Все таблицы-приёмники имеют натуральный UNIQUE (источник +
внешний id / время) — воркеры пишут через INSERT ... ON CONFLICT DO UPDATE, чтобы
повторный сбор не плодил дубли.
Сопоставление по времени. Главная ценность — связать усилие и реакцию организма. Окно — эпизод усилия, а не подход (ADR-0019):
нагрузка: [marked_at − длительность подхода, marked_at] ← восстанавливается по пульсу
восстановление: (marked_at, marked_at + 60s]
Пульс агрегируется по двум фазам раздельно: пик приходится на первые 5–20 секунд восстановления, поэтому агрегат только по фазе нагрузки промахивается мимо пика по построению.
Вместе с каждым агрегатом обязательно сохраняются число точек и покрытие окна. Это флаг валидности строки, а не метрика: датчик на запястье отбрасывает точки при низком качестве сигнала — а оно падает именно при хвате штанги, — поэтому выборка смещена в сторону спокойных моментов. Среднее по одной точке не должно быть неотличимо от среднего по восьми.
Индексы по времени обязательны на обеих сторонах джойна.
Реализованная схема —
core-service/migrations/0001_init.sql; она и есть актуальная версия этого раздела. Ключевые отличия от наброска выше, появившиеся при реализации:
user_idво всех таблицах и во всех натуральных ключах (ADR-0021).external_id NOT NULL.UNIQUE (source, external_id)молча не дедуплицирует при NULL, аNULLS NOT DISTINCTсхлопнул бы всю сессию мини-аппа в одну строку. Слой приёма обязан синтезировать устойчивый id:<session_id>:<set_index>.marked_at/saved_at/entry_lag_secondsвместо T1/T2 (ADR-0027): телефон в руки во время подхода не берут.tz_offset_minutesиdevice_clock_offset_ms— подходы пишутся по локальному времени, точки пульса приходят в UTC, а искомый сигнал шириной в секунды.- Провенанс в
health_metrics:measured_until,confidence,recording_method,data_source,raw.CHECK-ограничения на порядок меток и неотрицательность величин.
3. gRPC-контракты (репозиторий mybit/proto)¶
Единый источник правды — mybit/proto, подключается потребителям git submodule'ом
(ADR-0014). Менять .proto → make generate → коммитить сгенерированный код;
CI проверяет, что он не устарел.
service CoreService {
rpc IngestWorkout (IngestWorkoutRequest) returns (IngestWorkoutResponse);
rpc IngestSets (IngestSetsRequest) returns (IngestSetsResponse);
rpc IngestHealth (IngestHealthRequest) returns (IngestHealthResponse);
rpc IngestInbody (IngestInbodyRequest) returns (IngestInbodyResponse);
rpc IngestMedications (IngestMedicationsRequest) returns (IngestMedicationsResponse);
rpc Export (ExportRequest) returns (stream ExportResponse);
rpc GetStats (GetStatsRequest) returns (GetStatsResponse);
}
Отличия от первоначального наброска и почему:
CoreService, а неCore; свой response-тип на каждый RPC. Требование стандартного набора правилbuf lint. ОбщийAckникуда не делся — каждый response просто оборачивает его, чтобы не дублировать поля.Export— server-streaming. Год интрадей-пульса ≈ 500 тысяч точек (~20 МБ CSV), это сильно больше дефолтного лимита gRPC-сообщения в 4 МБ (ADR-0016).- В
Set—marked_at/saved_at/entry_lag_seconds(ADR-0027): телефон в руки во время подхода не берут, поэтому фиксируется момент отметки, а не начало подхода. Плюсserver_received_at,tz_offset_minutes,device_clock_offset_ms,set_index.
4. Где живёт маппинг telegram → wger¶
Решено (ADR-0017): в переменных окружения бота — ALLOWED_TELEGRAM_ID и
WGER_TOKEN. Таблицы нет: система однопользовательская, таблица на одну строку —
лишняя сущность и лишняя миграция. Бот отказывает всем остальным id.
При любом изменении полей/контрактов — синхронно править этот файл и
proto/. Расхождение схемы и кода — повод для записи вLESSONS.md.