Перейти к содержанию

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). Менять .protomake 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).
  • В Setmarked_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.