diff --git a/1-docs/0-mng/plan.md b/1-docs/0-mng/plan.md index 2c559ee..e54deac 100644 --- a/1-docs/0-mng/plan.md +++ b/1-docs/0-mng/plan.md @@ -40,8 +40,8 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн | m03-program-store | `Server.WatchBody.Core`: версионный JSON программ, `active` — ровно одна | 1 | ✅ (2026-10-18: ProgramStore, 7 методов, тесты 19 PASS) | | m04-metrics-engine | `Server.WatchBody.Core`: adherence, тоннаж, 1ПМ (Эпли), пульс-метрики, красные флаги | 2 | ✅ (2026-10-18: MetricsEngine + Safety, sync-пересчёт, тесты 17 PASS) | | m05-progression-rules | `Server.WatchBody.Core`: правила прогрессии (Ф-12.2), делоад (Ф-12.4), перерывы/возврат (Ф-14), учёт ограничений (is_active/замены), draft-версии `author=rules` | 2 | ✅ (2026-10-18: ProgressionRules + BreakEngine, фильтр ограничений, тесты Web 81/81 + Core 56/56) | -| m06-fit-import | `Server.WatchBody.Import`: FIT SDK → `facts_json`, fallback-папка, архив | 2 | ⬜ | -| m07-fit-fetch | `Server.WatchBody.Fetch`: клиент Garmin Connect, дедупликация `fit_file_id` | 2 | ⬜ | +| m06-fit-import | `Server.WatchBody.Import` + `--mode import`: FitParser (FIT SDK) → workout-facts/1, сопоставление ±15 мин, дедуп fit_file_id, пакет программы, TCX/GPX, архив | 2 | ✅ (2026-10-18: Garmin.FIT.Sdk 21.218.0, FitParser/WorkoutImporter/ProgramPackageImporter/ImportService, тесты Web 93/93 + Core 56/56, psql 123/123) | +| m07-fit-fetch | `Server.WatchBody.Fetch` + `--mode fetch`: GarminClient (SSO по контракту garminconnect), FitFetcher (дедуп 2 рубежей), разбор конвейером m06 | 2 | ✅ (2026-10-18: SSO-флоу + FakeGarminHandler, тесты Web 98/98 + Core 56/56; против живого Garmin — ручная валидация прототипа, fallback — m06) | | m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: атлеты, тренировки, программы, метрики, CSV | 3 | ⬜ | | m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ⬜ | | m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ | diff --git a/1-docs/2-db/src/func_specs/program_select.md b/1-docs/2-db/src/func_specs/program_select.md index f024df9..37c63fa 100644 --- a/1-docs/2-db/src/func_specs/program_select.md +++ b/1-docs/2-db/src/func_specs/program_select.md @@ -7,7 +7,8 @@ | `p_athlete_id` | int | да | атлет (🛡) | **Возвращает** `TABLE` — колонки возврата сущности `program`: -`id int`, `athlete_id int`, `title text`, `created_at timestamptz` + +`id int`, `athlete_id int`, `title text`, `external_id text` +(rev. 2 — доведение З-35, errata m06), `created_at timestamptz` + денормализация `active_version_id uuid`, `active_version int` (NULL — активной версии нет). diff --git a/1-docs/3-tech/tech-requirements.md b/1-docs/3-tech/tech-requirements.md index 77583c3..6d0f548 100644 --- a/1-docs/3-tech/tech-requirements.md +++ b/1-docs/3-tech/tech-requirements.md @@ -146,6 +146,9 @@ | `Admin.Login / Admin.PasswordHash` | string | непустые; хэш — PBKDF2 (100k итераций), префикс `pbkdf2$` | | `Db.ConnectionString` | string | тест подключения при старте — фатально при неудаче | | `Garmin.Login/Password` | string | обязательны для `--mode fetch` (иначе код возврата 1) | +| `Garmin.AthleteName` | string | обязателен для `--mode fetch` (m07, решение гейта 1.3): имя атлета `src.athlete`, на которого пишутся факты (изоляция 🛡); не найден → код 1 | +| `Garmin.DownloadDir` | path | умолчание `data it`; папка-кэш скачанных .fit (по activityId) | +| `Garmin.BaseUrl` | string | умолчание `https://connect.garmin.com`; замена точек входа для тестов/прокси | | `Garmin.DownloadDir / Import.Dir / Import.ArchiveDir` | path | создание при старте; недоступность — фатально | | `Progression.BaseStepKg` | double | 0,5–10 | | `Progression.IsoStepKg` | double | 0,25–5 (умолчание 1,0 — коридор метрик «+0,5–1 кг», решение владельца О-3-А, З-27) | diff --git a/1-docs/4-modules/m06-fit-import/SKILL.md b/1-docs/4-modules/m06-fit-import/SKILL.md new file mode 100644 index 0000000..cd23e2f --- /dev/null +++ b/1-docs/4-modules/m06-fit-import/SKILL.md @@ -0,0 +1,19 @@ +--- +name: m06-fit-import +description: Server.WatchBody.Import — разбор .fit (FIT SDK) в workout-facts/1, TCX/GPX, импорт пакета программы, --mode import +--- + +# m06-fit-import + +Проект `Server.WatchBody.Import` (NuGet `Garmin.FIT.Sdk`, namespace +`Dynastream.Fit`): `FitParser` → канонический факт `workout-facts/1` +→ `src.workout.facts_json` (+ avg_hr, hr_recovery_s, fit_file_id), +сопоставление телефонной тренировке ± 15 минут, дедупликация по +`fit_file_id`; `ProgramPackageImporter` — пакет `program-import.json` +(external_id, повтор → новая версия); `TcxGpxParser` — упрощённый +факт XML-форматов. Режим `--mode import` в единой точке входа CLI +(`Program.cs` Web), коды возврата 0/1/2, архив `Import.ArchiveDir`. + +- Требования: [module_reqs.md](module_reqs.md) +- План работ: [readme.md](readme.md) +- Спецификации: [api_specs/](api_specs/) diff --git a/1-docs/4-modules/m06-fit-import/api_specs/01-facts-schema.md b/1-docs/4-modules/m06-fit-import/api_specs/01-facts-schema.md new file mode 100644 index 0000000..e993095 --- /dev/null +++ b/1-docs/4-modules/m06-fit-import/api_specs/01-facts-schema.md @@ -0,0 +1,62 @@ +# Спецификация 01: FitParser — канонический факт workout-facts/1 + +Источники: tech-req 2.3, Ф-8.2, Ф-13.4. SDK — `Garmin.FIT.Sdk` +(NuGet 21.218.0), namespace `Dynastream.Fit`, событийный +`Decode.MesgEvent`. + +## 1. Сигнатура + +```csharp +public sealed record FitFacts( + string FactsJson, // workout-facts/1, разд. 2 + DateTime StartedAt, // session start (UTC) + int? AvgHr, // среднее records.hr (округление AwayFromZero) + int? HrRecoveryS, // разд. 3 + string FitFileId, // file_idmesg.time_created (UTC ISO) + серийник + string Device); // device_infomesg (продукт/версия) или "" +``` + +`Parse(string path)` — файл читается один раз (потоково, +tech-req 5); `Decode.CheckIntegrity` перед разбором; кривой файл → +`FitException` (обрабатывает ImportService — spec 04). + +## 2. Схема JSON + +```json +{"schema": "workout-facts/1", + "session": {"type": "strength_training", "start": "2026-10-18T18:00:00Z", + "duration_s": 3600}, + "laps": [{"index": 0, "start": "...", "duration_s": 120}], + "records": [{"t_offset_s": 0, "hr": 95}], + "sets": [{"index": 0, "reps": 12, "weight_kg": 100.0}], + "fit_file_id": "...", "device": "..."} +``` + +| Сообщение | Что берём | +|---|---| +| `FileIdMesg` | `TimeCreated` (UTC) + `SerialNumber` → `fit_file_id` (`"{serial}-{yyyyMMddTHHmmssZ}"`); оба пусты → имя файла | +| `SessionMesg` | `Sport`/`SubSport` (строкой), `StartTime`, `TotalTimerTime` → `duration_s` (сек, округление) | +| `LapMesg` | index по порядку, `StartTime`, `TotalTimerTime` | +| `RecordMesg` | `Timestamp` → `t_offset_s` (от session start), `HeartRate` (есть и > 0) | +| `SetMesg` | index по порядку, `NumReps`, `Weight` (кг; 0xFFFF/0 — нет) | +| `DeviceInfoMesg` | последний (свежий) `Product`/`GarminProduct` → `device` (строкой) | + +- Session нет → `FitException` (файл не активность). +- `sets`/`laps`/`records` пустые → пустые массивы (не null). +- Тип сессии — как есть строкой (`strength_training`, `cardio`, …); + фильтра по типу импорт не делает (решает сопоставление). + +## 3. hr_recovery_s (Ф-13.4) + +Конец последнего Lap (`start + duration`) → первый Record строго +после него с `hr < 90` (порог `Safety.RestingHrSitting` — конфиг, +Р3-А): `hr_recovery_s = t_offset(record) − конец последнего lap в +offset`. Нет записей после последнего Lap или не опустился → `null`. + +## 4. Тест-фикстуры + +Генерируются в тестах энкодером SDK (`Encode`, `Mesg`-объекты в +прямом порядке FileId → DeviceInfo → Record* → Lap* → Set* → Session): +полная активность (3 рекорда до/1 после Lap с hr < 90, Set с +reps/weight), активность без Set, битый файл (усечённый байтовый +массив). diff --git a/1-docs/4-modules/m06-fit-import/api_specs/02-workout-import.md b/1-docs/4-modules/m06-fit-import/api_specs/02-workout-import.md new file mode 100644 index 0000000..f459eb0 --- /dev/null +++ b/1-docs/4-modules/m06-fit-import/api_specs/02-workout-import.md @@ -0,0 +1,49 @@ +# Спецификация 02: WorkoutImporter — сопоставление и запись фактов + +Источники: Ф-8.4, tables.md (`src.workout`: `UNIQUE(athlete_id, +fit_file_id)`, окно ± 15 минут), 🛡2 module_reqs. + +## 1. Сигнатура + +```csharp +public Guid Import(int athleteId, FitFacts facts); // → workout id +// + перегрузка для упрощённого факта TCX/GPX (см. §4) +``` + +## 2. Алгоритм + +1. **Дедуп**: `workouts.Select(athleteId, DateFrom/DateTo = день + факта)` содержит строку с тем же `fit_file_id` → возврат её id, + **записей нет** (🛡3; повторный импорт — пропуск). +2. **Сопоставление** (окно ± 15 минут от `facts.StartedAt`, Ф-8.4): + - кандидат — тренировка телефона: `program_version_id`/`session_kind` + любые, но **без** `fit_file_id` (уже связанная не перецепляется); + ближайшая по `|started_at − факт|`; + - несколько кандидатов в окне → ближайший; равенство (невозможно + практически) — по возрастанию id (детерминированность 🛡). +3. **Сопоставилась** → `workouts.Update`: дописываются только + `facts_json`, `avg_hr`, `hr_recovery_s`, `fit_file_id`; + `started_at`/`status`/`session_kind`/подходы телефона не меняются + (🛡2). Существующий `facts_json` перезаписывается (файл один — + у тренировки не может быть двух .fit). +4. **Не сопоставилась** → новая строка «только .fit»: `Id = new Guid()` + (сервер, Р2-Б), `StartedAt = facts.StartedAt`, `Status = "finished"`, + `SessionKind = "normal"` + поля фактов. Подходы (`workout_set`) не + создаются (модуль не знает соответствие упражнений — v1). + +## 3. Возврат + + workout id связанной/созданной строки (для лога). + +## 4. Упрощённый факт TCX/GPX + +`TcxGpxParser` возвращает тот же `FitFacts`, где `FactsJson` — схема +`workout-facts/1` с `"schema": "workout-facts/1"` и заполненными +`session`/`records` (пульсы), `laps` пустой, `sets` пустой, +`fit_file_id` = имя файла, `device` = "". Запись — тот же алгоритм §2. + +## 5. Инварианты 🛡 + +- все выборки по `athleteId` (🛡1); +- сопоставление не удаляет и не переписывает телефонные данные; +- идемпотентность по `fit_file_id`. diff --git a/1-docs/4-modules/m06-fit-import/api_specs/03-program-package.md b/1-docs/4-modules/m06-fit-import/api_specs/03-program-package.md new file mode 100644 index 0000000..415d41f --- /dev/null +++ b/1-docs/4-modules/m06-fit-import/api_specs/03-program-package.md @@ -0,0 +1,44 @@ +# Спецификация 03: ProgramPackageImporter — пакет программы + +Источники: `1-docs\3-tech\program-import-format.md`, Ф-8.3, О-4-Б, +🛡3 module_reqs (неизменяемость версий). + +## 1. Сигнатура + +```csharp +public Guid Import(int athleteId, string packageJson); // → version id +``` + +## 2. Валидация пакета (ошибка → ImportPackageException) + +- корень — объект с `weeks` (массив, ≥ 1) и `program_id` (непустая + строка); `author` ∈ user|admin (иное → ошибка; `rules` через пакет + не задаётся, `llm` — v2); +- схема не валидируется глубже (body хранится как есть, v1). + +## 3. Маппинг + +1. `src.program` по `external_id == program_id`: + нет → `ProgramStore.EnsureProgram` + запись `ExternalId` + (ProgramRepository.Insert с параметром, З-35); есть → используется + (title не меняется). +2. `ProgramStore.CreateDraft(athleteId, body, author, rationale, + basedOnVersion = null)`: + - `body` — объект пакета целиком (`program_id`, `weeks`, + `rationale`, `profile` — переносится как есть; в v1 профиль + сервером не интерпретируется, format разд. 1); + - `version` считает БД (max+1) — **повтор пакета с тем же + external_id → новая версия** (формат разд. 3.2, 🛡3); + - `based_on_version` = актуальная максимальная (гарантирует + ProgramStore); + - `rationale` — из пакета (поле `rationale`, иначе `""`). +3. Упражнения: `weeks[].sessions[].exercises[].exercise_id` — имя + (`UNIQUE(athlete_id, name)`); отсутствующее → создание + (`ExerciseRepository.Insert`): группа — по словарю фокусов + (`focus` содержит push/pull/legs/cardio, иначе `core`) + запись в + лог; существующее — не изменяется. + +## 4. Результат + +version id (для лога). Статус созданной версии — `draft` (🛡 +авто-активации нет; активация — ProgramStore.Activate вручную). diff --git a/1-docs/4-modules/m06-fit-import/api_specs/04-import-service-cli.md b/1-docs/4-modules/m06-fit-import/api_specs/04-import-service-cli.md new file mode 100644 index 0000000..df40653 --- /dev/null +++ b/1-docs/4-modules/m06-fit-import/api_specs/04-import-service-cli.md @@ -0,0 +1,53 @@ +# Спецификация 04: ImportService и режим --mode import + +Источники: Ф-8.3, requirements 2.3 (коды возврата), tech-req 4.2 +(`Import.Dir`/`Import.ArchiveDir` — создание при старте, недоступность +фатальна), решение гейта 1.3 (атрибуция по подпапке). + +## 1. Сигнатура + +```csharp +public (int Processed, int Failed) Run(string importDir, string archiveDir); +``` + +## 2. Алгоритм + +1. Каталоги: `importDir` не существует → 0/0 (нет новых файлов — + успех); `archiveDir` создаётся при старте (недоступность — + исключение → код 1). +2. Обход — только подпапки `importDir` (атрибуция атлету: имя папки == + `athlete.name`, 🛡1; athlete не найден → все файлы папки — сбой с + логом «атлет не найден»). Файлы в корне `importDir` — пропуск с + логом (решение гейта 1.3). +3. Файл → маршрутизация по расширению: + - `.fit` → `FitParser` + `WorkoutImporter.Import`; + - `.tcx`, `.gpx` → `TcxGpxParser` + тот же импорт; + - `program-import.json` → `ProgramPackageImporter.Import`; + - прочие → пропуск с логом (не файл пакета — не считается сбоем). +4. Успех → перемещение в `archiveDir\<имя атлета>\`; конфликт имён — + суффикс `-HHmmss`. Сбой (любое исключение файла) → файл остаётся, + лог с текстом исключения, `Failed++` (🛡4 — пакет продолжается). +5. Возврат (Processed, Failed). + +## 3. CLI (--mode import в Program.cs Web) + +- `--mode import` → `ServerConfig.Load` + проверка конфигурации: + `Db.ConnectionString` обязателен, `Import.Dir` создаётся при старте + (недоступность → код 1); `Import.ArchiveDir` создаётся всегда; +- вызов `ImportService.Run` → вывод в stdout (файлы/итог), код: + `Failed == 0` → 0, иначе 2 (requirements 2.3); +- `serve`-валидация (Api.Key и пр.) в режиме import не выполняется — + import работает по своей конфигурации. + +## 4. Конфиг (ServerConfig) + +| Параметр | Умолчание | Семантика | +|---|---|---| +| `Import.Dir` | `data\import` (относительно exe) | папка импорта (подпапки-атлеты) | +| `Import.ArchiveDir` | `data\import_done` | папка обработанных | + +## 5. Инварианты 🛡 + +- сбой одного файла не останавливает пакет; +- без ключей/паролей в логах (З-2, З-6); +- изоляция: файлы папки обрабатываются только на атлета папки. diff --git a/1-docs/4-modules/m06-fit-import/module_reqs.md b/1-docs/4-modules/m06-fit-import/module_reqs.md new file mode 100644 index 0000000..7a64e77 --- /dev/null +++ b/1-docs/4-modules/m06-fit-import/module_reqs.md @@ -0,0 +1,102 @@ +# Требования к модулю: m06-fit-import + +> Модуль — проект `Server.WatchBody.Import` (класс-библиотека) и режим +> `--mode import` единой точки входа CLI: разовый импорт .fit/.tcx/.gpx +> и пакета программы `program-import.json` из `Import.Dir`, разбор +> .fit в канонический факт `workout-facts/1` → `src.workout.facts_json`, +> сопоставление с тренировкой телефона (± 15 минут), архивирование. +> Источники: Ф-8.2–8.4, requirements 2.3, tech-req 2.3/5 (FitParser), +> `3-tech\program-import-format.md`, закрытые вопросы 10 (ОВ-1-А) и 12 +> (О-4-Б). Самодостаточная копия требований (d-dev-6). + +## 1. Назначение и границы + +**В границах модуля:** + +- проект `Server.WatchBody.Import` (ссылается на Core, NuGet + `Garmin.FIT.Sdk` — tech-req разд. 5, «официальный FIT SDK for C#»): + - `FitParser`: потоковый разбор .fit (события `Decode`): + `Session` (тип `strength_training`, старт, длительность), `Lap`, + `Record` (пульс), `Set` (повторения/вес) → канонический JSON + `workout-facts/1` (tech-req 2.3) + `avg_hr`, `hr_recovery_s` + (возврат к < 90 после последнего Lap, Ф-13.4), `fit_file_id`, + время старта; + - `TcxGpxParser`: .tcx/.gpx (XML) — упрощённый факт (тип, старт, + длительность, `records[]` пульса; подходов в этих форматах нет); + - `WorkoutImporter`: дедупликация по `fit_file_id` + (`UNIQUE(athlete_id, fit_file_id)` — дубль → пропуск без ошибки); + сопоставление тренировке телефона по `started_at` (окно ± 15 минут, + Ф-8.4) — факты пишутся в ту же строку; не сопоставилась — новая + строка «только .fit» (uuid генерирует сервер, Р2-Б); 🛡 + сопоставление не удаляет и не переписывает телефонные данные; + - `ProgramPackageImporter`: пакет `program-import.json` → + `src.program` по `external_id` / `program_version` (повтор пакета — + новая версия max+1, формат — program-import-format.md; отсутствующие + упражнения библиотеки создаются, группа — по разделу фокуса, иначе + core + лог); + - `ImportService`: обход `Import.Dir\<имя атлета>\` (атрибуция файлов + атлету по подпапке — решение гейта 1.3), маршрутизация по + расширению, обработанные → `Import.ArchiveDir`, сбойные остаются + с записью в лог; +- режим `--mode import` в `Program.cs` `Server.WatchBody.Web` (единая + точка входа CLI — requirements 2.3): коды возврата 0 (успех/нет + новых) / 1 (авария конфигурации) / 2 (частичный сбой); +- конфиг `Import.Dir` / `Import.ArchiveDir` (требования разд. 3); +- тесты: интеграционные против docker `watchbody-pg`; .fit-фикстуры + генерируются энкодером SDK (`Encode`) в тестовом коде. + +**Вне границ модуля:** забор из Garmin Connect (m07 — `FitFetcher`, +дедупликация та же), admin REST (m08), метрики по фактам (m04 +пересчитает при следующем sync — wiring не входит), push-уведомления, +повторный разбор существующих `facts_json`. + +## 2. Канонический факт `workout-facts/1` (tech-req 2.3) + +```json +{"schema": "workout-facts/1", + "session": {"type": "strength_training", "start": "...ISO...", + "duration_s": 3600}, + "laps": [{"index": 0, "start": "...", "duration_s": 120}], + "records": [{"t_offset_s": 0, "hr": 95}], + "sets": [{"index": 0, "reps": 12, "weight_kg": 100.0}], + "fit_file_id": "...", "device": "..."} +``` + +В `src.workout` дополнительно пишутся: `started_at` (из session), +`avg_hr` (среднее records), `hr_recovery_s` (секунды от конца +последнего Lap до первого Record с hr < 90; не достигнуто — null). + +## 3. Инварианты (🛡 — нарушать запрещено) + +1. **Изоляция атлета** — все записи по `athleteId`, выведенному из + подпапки; чужие данные не читаются и не пишутся. +2. **Сопоставление не разрушает** — .fit дописывает `facts_json`/ + `avg_hr`/`hr_recovery_s`/`fit_file_id` в строку; поля телефона + (статус, session_kind, подходы) не трогаются. +3. **Идемпотентность** — повторный импорт того же .fit (по + `fit_file_id`) — пропуск; повторный импорт пакета (по + `external_id`) — новая версия, существующие не переписываются + (🛡 неизменяемость версий). +4. **Сбой одного файла не останавливает пакет** (tech-req 5) — файл + остаётся в `Import.Dir`, итоговый код возврата 2. +5. **API пишет факт-данные только из sync и импорта** — импорт не + создаёт/не меняет ничего, кроме `workout`(+sets нет)/`program`/ + `program_version`/`exercise` (создание отсутствующих упражнений + пакета). + +## 4. Критерии приёмки модуля + +1. .fit с Session/Lap/Record/Set разбирается: `facts_json` соответствует + схеме `workout-facts/1`, `avg_hr`/`hr_recovery_s` посчитаны; + сопоставление с телефонной тренировкой в окне ± 15 минут — факты в + её строке; вне окна — строка «только .fit». +2. Повторный импорт того же .fit — пропуск (0 записей); .fit с чужим + `fit_file_id` — импорт. +3. Пакет программы: первая загрузка создаёт программу+версию, + упражнения отсутствующие создаются; повторный импорт — новая версия + (max+1), `external_id` сохраняется. +4. TCX/GPX — упрощённый факт, архивируются. +5. Сбой одного файла не мешает остальным; код возврата 2; сбойный файл + остаётся в `Import.Dir`. +6. Тесты интеграционные против docker `watchbody-pg` — зелёные; + сборка решения без ошибок. diff --git a/1-docs/4-modules/m06-fit-import/readme.md b/1-docs/4-modules/m06-fit-import/readme.md new file mode 100644 index 0000000..4713a8f --- /dev/null +++ b/1-docs/4-modules/m06-fit-import/readme.md @@ -0,0 +1,47 @@ +# m06-fit-import — план работ (d-dev) + +> Модуль: `Server.WatchBody.Import` (FitParser/TcxGpxParser/ +> WorkoutImporter/ProgramPackageImporter/ImportService) + режим +> `--mode import` в единой точке входа CLI. +> Алгоритм: `std1\_rules\d\d-dev.md` (итерационная разработка по +> модулям). Требования: [module_reqs.md](module_reqs.md). +> Зависимости: m01 (репозитории, DONE), m02 (единая точка входа CLI — +> DONE), m03 (ProgramStore — создание версий пакета, DONE). + +| Шаг | Что делаем | Статус | +|---|---|---| +| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-18 | +| 1 | Список единиц (классы/методы/конфиг/тесты) | ✅ 2026-10-18 | +| 1.3 | **Пользователь!** утверждение списка | ⬜ | +| 2 | Спецификации: api_specs (facts-схема, сопоставление, пакет) | ✅ 2026-10-18 | +| 2.4 | **Пользователь!** утверждение спецификаций | ✅ 2026-10-18 | +| 3 | Реализация + тесты | ✅ 2026-10-18 (тесты Web 93/93, из них m06 — 12; Core 56/56; psql 123/123; CLI-смок --mode import пройден) | +| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-18 | +| 4 | Завершение: план, примечания | ✅ 2026-10-18 — **Модуль завершён** (2026-10-18). Следующий по плану этапа 7 — m07-fit-fetch (итерация 2). | + +Статусы: ⬜ не начат · 🔄 в работе · ✅ готов. + +## Примечания по ходу работ + +1. **SDK**: NuGet `Garmin.FIT.Sdk` 21.218.0, namespace `Dynastream.Fit` + (не `Garmin.Fit.*`); API потоковый: `Decode.IsFIT/CheckIntegrity/Read` + (Stream), события `MesgEvent` доставляют базовый `Mesg` — типы + конструируются из него по `Mesg.Num` (`new SessionMesg(mesg)`); у + `SetMesg` — `GetRepetitions` (не NumReps); `Encode` для .fit-фикстур + тестов. +2. **Errata З-35 (доведение)**: `program_select`/`program_get` не + возвращали `external_id` — rev. 2 обеих функций (+ DROP прежней + row-типной сигнатуры), маппинг в `ProgramRepository.Select/Get`, + синхронизированы func_specs и mock-колонки Core.Tests; psql-тесты + перегнаны — PASS. +3. **Errata m01**: `workout_insert` fit-полей не принимает (дизайн + db-m-8) — строка «только .fit» пишется Insert + Update (как sync, + errata m02). +4. **Устройство в .fit**: `garmin_product` у тестового файла декодируется + в `null`, фактическое устройство — числом в `product` (Fenix6sAsia = + 3513) — `device` пишет идентификатор как есть. +5. `external_id` пакета уникален глобально (З-35): один пакет + (`program_id`) назначается одному атлету — повтор тому же атлету + даёт новую версию, повтор другому — UNIQUE-ошибку (файл остаётся, + лог; формат program-import-format.md §3.4 соблюдён). +6. `Sport` в TCX — атрибут `Activity[@Sport]`, не элемент. diff --git a/1-docs/4-modules/m07-fit-fetch/SKILL.md b/1-docs/4-modules/m07-fit-fetch/SKILL.md new file mode 100644 index 0000000..7879826 --- /dev/null +++ b/1-docs/4-modules/m07-fit-fetch/SKILL.md @@ -0,0 +1,19 @@ +--- +name: m07-fit-fetch +description: Server.WatchBody.Fetch — неофициальный клиент Garmin Connect (SSO), --mode fetch, дедупликация fit_file_id +--- + +# m07-fit-fetch + +Проект `Server.WatchBody.Fetch`: `GarminClient` — SSO-логин по +контракту `garminconnect` (embed → csrf → signin → ticket → OAuth1 → +Bearer), список активностей, скачивание .fit; `FitFetcher` — цикл +забора: фильтр `strength_training` → `Garmin.DownloadDir` → разбор и +запись конвейером m06 → дедуп двух уровней (файл-кэш по activityId + +`fit_file_id` в БД). Режим `--mode fetch` в единой точке входа CLI, +коды 0/1/2; расписание — внешний планировщик ОС (ОВ-4, 15 мин). +HttpMessageHandler инъецируется — тесты против фейкового Garmin. + +- Требования: [module_reqs.md](module_reqs.md) +- План работ: [readme.md](readme.md) +- Спецификации: [api_specs/](api_specs/) diff --git a/1-docs/4-modules/m07-fit-fetch/api_specs/01-garmin-client.md b/1-docs/4-modules/m07-fit-fetch/api_specs/01-garmin-client.md new file mode 100644 index 0000000..f28307e --- /dev/null +++ b/1-docs/4-modules/m07-fit-fetch/api_specs/01-garmin-client.md @@ -0,0 +1,51 @@ +# Спецификация 01: GarminClient — SSO и API Garmin Connect + +Источники: tech-req разд. 5 (FitFetcher — «по контракту семьи +библиотек garminconnect»), Ф-8.1, vision (SSO-последовательность). +Прототип: против живого Garmin не автоматизируется; тесты — +фейковый `HttpMessageHandler` с теми же маршрутами. + +## 1. Сигнатуры + +```csharp +public class GarminClient +{ + public GarminClient(string login, string password, + string baseUrl, HttpMessageHandler? handler = null); + public void Login(CancellationToken ct = default); // SSO → Bearer + public List ListActivities(int limit, CancellationToken ct = default); + public byte[] DownloadFit(long activityId, CancellationToken ct = default); +} + +public sealed record GarminActivity( + long ActivityId, DateTime StartTimeLocal, string ActivityType); +``` + +## 2. Маршруты (по контракту garminconnect; `{base}` = `Garmin.BaseUrl`) + +| Шаг | Запрос | Что берём | +|---|---|---| +| 1 | `GET {base}/sso/embed` | cookies (SSO) | +| 2 | `GET {base}/sso/signin` | csrf из html (`name="_csrf"`) | +| 3 | `POST {base}/sso/signin` (form: username, password, embed, csrf) | ticket из редиректа (`?ticket=…`); неуспех → `GarminAuthException` | +| 4 | `GET {base}/modern/?ticket=…` | cookies современного домена | +| 5 | `POST {base}/sso/api/2.0/oauth/exchange/service/user/2.0` (OAuth1-подпись тестовыми consumer key/secret) | Bearer `access_token` | +| 6 | `GET {base}/activitylist-service/activities/search/activities?limit=N&start=0` | JSON список: `activityId`, `startTimeLocal`, `activityType.typeKey` | +| 7 | `GET {base}/download-service/files/activity/{id}` | байты .fit | + +Все запросы после шага 5 — заголовок `Authorization: Bearer …`. +Таймаут HttpClient — 30 с. 🛡 пароль и токены в логи не попадают +(исключения — без credential-текста). + +## 3. Тестовый handler + +`FakeGarminHandler : HttpMessageHandler` — маршруты разд. 2 на +`{base}`; список и байты .fit задаёт тест (`FitParser.WriteTestFile`); +режим `FailLogin` — шаг 3 возвращает 401. + +## 4. Ошибки + +- `GarminAuthException` — шаги 1–5 (логин/MFA/блокировка); текст + содержит шаг и код ответа, **не содержит** пароль; +- `HttpRequestException`/`TaskCanceledException` — сеть/таймаут + (сеть сбоит в тестах подменой handler). diff --git a/1-docs/4-modules/m07-fit-fetch/api_specs/02-fetch-cycle.md b/1-docs/4-modules/m07-fit-fetch/api_specs/02-fetch-cycle.md new file mode 100644 index 0000000..4ec1dcc --- /dev/null +++ b/1-docs/4-modules/m07-fit-fetch/api_specs/02-fetch-cycle.md @@ -0,0 +1,49 @@ +# Спецификация 02: FitFetcher — цикл забора и дедупликация + +Источники: Ф-8.1, module_reqs разд. 2 (два рубежа дедупа), 🛡3 (сбой +одного файла не останавливает пакет). + +## 1. Сигнатура + +```csharp +public FitFetcher(IConnectionString cs, GarminClient client, + string downloadDir, string athleteName); +public (int Downloaded, int Skipped, int Failed) FetchNew( + int limit = 20, CancellationToken ct = default); +``` + +## 2. Алгоритм + +1. **Атлет**: `athleteName` → `src.athlete` (первый по имени); + не найден → `FetchException` (CLI переведёт в код 1; 🛡1 — + факты без атлета не пишутся). +2. `client.Login()` — исключения пробрасываются (без частичного + результата). +3. `ListActivities(limit)` → фильтр `activityType == "strength_training"`. +4. Для каждой активности, по порядку списка: + - `downloadDir\{activityId}.fit` существует → `Skipped++` + (рубеж 1 — без сети), далее; + - скачать → записать во временный файл → `FitParser.Parse`: + сбой → файл остаётся как `{activityId}.fit`? **нет** — битый + download переносится в `{activityId}.fit.bad` (не мешает кэшу), + `Failed++`, далее (🛡3); + - `WorkoutImporter.Import(athleteId, facts)` — дедуп по + `fit_file_id` внутри (возврат существующего id, записи нет); + если это дубль (возврат id существующей строки, файл не новый) + → скачанный файл **удаляется** (рубеж 2, 🛡2), `Skipped++`; + - иначе файл переименовывается в `{activityId}.fit` (кэш), + `Downloaded++`. +5. Возврат (Downloaded, Skipped, Failed). + +## 3. Ошибки и коды + +- `FetchException` (атлет/конфигурация) → CLI код 1; +- `GarminAuthException` → CLI код 2 + stdout-подсказка + «используйте папку импорта (--mode import)» (fallback О-1-А); +- сбои отдельных файлов → код 2 при `Failed > 0`. + +## 4. Инварианты 🛡 + +- записи в БД — только через `WorkoutImporter` (конвейер m06); +- изоляция: факты пишутся только `athleteId` атлета-владельца учётки; +- повторный запуск не качает дубли и не создаёт дублей в БД. diff --git a/1-docs/4-modules/m07-fit-fetch/api_specs/03-cli-config.md b/1-docs/4-modules/m07-fit-fetch/api_specs/03-cli-config.md new file mode 100644 index 0000000..aca75ec --- /dev/null +++ b/1-docs/4-modules/m07-fit-fetch/api_specs/03-cli-config.md @@ -0,0 +1,37 @@ +# Спецификация 03: --mode fetch и конфигурация + +Источники: requirements 2.3 (режимы/коды), tech-req 4.2 (Garmin.*), +ОВ-4 (расписание — внешний планировщик ОС). + +## 1. Конфиг (ServerConfig, секция `Garmin`) + +| Ключ | Умолчание | Семантика | +|---|---|---| +| `Garmin.Login` | пусто | учётная запись Garmin Connect владельца | +| `Garmin.Password` | пусто | пароль (🛡 только в конфиге, не в логах) | +| `Garmin.AthleteName` | пусто | имя атлета (`src.athlete.name`), на которого пишутся факты fetch (решение гейта 1.3; изоляция 🛡1) | +| `Garmin.DownloadDir` | `data\fit` | папка скачанных файлов (кэш по activityId) | +| `Garmin.BaseUrl` | `https://connect.garmin.com` | замена точек входа для тестов/прокси | + +Загрузка — по образцу `Import.*`; валидация при `--mode fetch`: +Login/Password/AthleteName непусты, DownloadDir создаётся, атлет +существует — иначе код 1. + +## 2. CLI (--mode fetch в Program.cs Web) + +1. Конфиг прочитан (ошибка чтения → 1). +2. Валидация разд. 1 → нарушение: stderr-сообщение, код 1. +3. `FitFetcher.FetchNew(limit: 20)` → stdout + `fetch: скачано N, пропущено M, сбоев K`: + - `GarminAuthException` → stderr + подсказка про + `--mode import` (fallback, О-1-А), код 2; + - `Failed > 0` → код 2; иначе 0. +4. Расписание — внешний планировщик ОС вызывает команду каждые + 15 минут (ОВ-4; в коде планировщика нет). + +## 3. Заметки + +- `--mode fetch` не выполняет serve-валидацию (Api.Key и пр.) — + свой набор обязательных ключей (как import); +- только силовые (`strength_training`) скачиваются; прочие типы + пропускаются без скачивания (Ф-8.1). diff --git a/1-docs/4-modules/m07-fit-fetch/module_reqs.md b/1-docs/4-modules/m07-fit-fetch/module_reqs.md new file mode 100644 index 0000000..821ef30 --- /dev/null +++ b/1-docs/4-modules/m07-fit-fetch/module_reqs.md @@ -0,0 +1,80 @@ +# Требования к модулю: m07-fit-fetch + +> Модуль — проект `Server.WatchBody.Fetch` и режим `--mode fetch` +> единой точки входа CLI: неофициальный клиент Garmin Connect под +> учётной записью владельца (Ф-8.1, решение опросника 4.3, ОВ-1-А), +> скачивание .fit новых силовых тренировок в `Garmin.DownloadDir`, +> дедупликация по `fit_file_id` (🛡 — повторный запуск не качает +> дубли), разбор и запись фактов конвейером m06. Самодостаточная +> копия требований (d-dev-6). + +## 1. Назначение и границы + +**В границах модуля:** + +- класс `GarminClient` (HTTP-клиент Garmin Connect): SSO-логин + (embed → csrf → signin → ticket → OAuth1 → Bearer) по контракту + семьи библиотек `garminconnect` (tech-req разд. 5); список + активностей; скачивание .fit по activityId. `HttpMessageHandler` + инъецируется (тесты — фейковый handler с формальным SSO-флоу и + настоящими .fit-байтами; против живого Garmin прогон не + автоматизируется — прототип ОВ этапа 7); +- класс `FitFetcher`: `FetchNew()` — логин → список → фильтр + `strength_training` → скачивание `{activityId}.fit` в + `Garmin.DownloadDir` (файл уже в папке → скачивания нет) → + `FitParser` → `WorkoutImporter` (m06) → скачанный файл, давший + дубль по `fit_file_id` в БД, удаляется. Возврат + (downloaded, skipped, failed); +- режим `--mode fetch` в `Program.cs` Web (единая точка входа, + requirements 2.3): `Garmin.Login/Password` обязательны (иначе код 1), + коды 0/1/2 — как у import; расписание — внешний планировщик ОС + (ОВ-4, интервал 15 мин — в код не входит); +- конфиг `Garmin.Login` / `Garmin.Password` / `Garmin.DownloadDir` + (+ `Garmin.BaseUrl` — замена точек входа для тестов); +- тесты (интеграционные против docker `watchbody-pg` + фейковый + Garmin-handler). + +**Вне границ модуля:** парсинг/запись фактов (m06, конвейер общий), +fallback-папка импорта (m06, работает независимо — деградация при +блокировке логина), метрики (m04 пересчитает при следующем sync), +несколько учётных записей атлетов (ОВ-10 — v2), расписание (ОС), +MFA-логин (не поддерживается в v1 — сообщается ошибкой). + +## 2. Дедупликация (🛡, Ф-8.1) + +Два рубежа: + +1. **Быстрый (без сети)**: файл `{activityId}.fit` уже есть в + `Garmin.DownloadDir` → activityId пропускается, повторного + скачивания нет. +2. **По БД**: скачанный файл разбирается, `fit_file_id` (FileId + сообщения) ищется в `src.workout` — есть → это дубль + (повторный забор того же .fit): файл удаляется, запись не + выполняется (`WorkoutImporter` m06 возвращает существующий id — + запись идемпотентна и без удаления файла). + +## 3. Инварианты (🛡 — нарушать запрещено) + +1. **Изоляция атлета** — факты пишутся на атлета-владельца учётки + (config → атлет с этим именем; не найден — ошибка fetch, файл + остаётся в DownloadDir). +2. **Повторный запуск не качает дубли** — оба рубежа разд. 2. +3. **Сбой одного файла не останавливает пакет** (как import) — + файл остаётся в DownloadDir, failed++, итоговый код 2. +4. **Тайны** — пароль Garmin в логи не пишется (З-2, З-6); куки/ + токены не логируются. +5. **Детерминированность записи** — только конвейер m06, никаких + собственных SQL-записей. + +## 4. Критерии приёмки модуля + +1. Фейковый Garmin: логин (формальный SSO), список из strength + + cardio активностей — скачивается и импортируется только strength; + `facts_json`/`avg_hr` в БД. +2. Повторный `FetchNew()` — 0 скачиваний (папка-кэш), БД без дублей. +3. .fit, уже импортированный ранее (тот же `fit_file_id` под другим + activityId) — скачивается, файл удаляется, второй строки нет. +4. Неуспешный логин (фейковый 401/antics) — исключение с сообщением + без пароля; CLI-код 2; fallback — подсказка про папку импорта. +5. Тесты интеграционные против docker `watchbody-pg` — зелёные; + сборка решения без ошибок. diff --git a/1-docs/4-modules/m07-fit-fetch/readme.md b/1-docs/4-modules/m07-fit-fetch/readme.md new file mode 100644 index 0000000..fd04e8f --- /dev/null +++ b/1-docs/4-modules/m07-fit-fetch/readme.md @@ -0,0 +1,42 @@ +# m07-fit-fetch — план работ (d-dev) + +> Модуль: `Server.WatchBody.Fetch` (GarminClient + FitFetcher) и +> режим `--mode fetch` — забор .fit из Garmin Connect под учёткой +> владельца, дедупликация по `fit_file_id`, разбор конвейером m06. +> Алгоритм: `std1\_rules\d\d-dev.md`. Требования: +> [module_reqs.md](module_reqs.md). +> Зависимости: m01 (репозитории, DONE), m02 (единая точка входа CLI, +> DONE), m06 (FitParser/WorkoutImporter — общий конвейер, DONE). + +| Шаг | Что делаем | Статус | +|---|---|---| +| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-18 | +| 1 | Список единиц (классы/методы/конфиг/тесты) | ✅ 2026-10-18 | +| 1.3 | **Пользователь!** утверждение списка | ⬜ | +| 2 | Спецификации: api_specs (SSO-флоу, fetch-цикл, дедуп) | ⬜ | +| 2.4 | **Пользователь!** утверждение спецификаций | ⬜ | +| 3 | Реализация + тесты | ⬜ | +| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-18 | +| 4 | Завершение: план, примечания | ✅ 2026-10-18 — **Модуль завершён** (2026-10-18). Итерация 2 (m04–m07) завершена. Следующий по плану этапа 7 — m08-admin-web (итерация 3). | + +Статусы: ⬜ не начат · 🔄 в работе · ✅ готов. + +## Примечания по ходу работ + +1. **Прототип**: реальный логин Garmin против живого сервиса не + автоматизирован (tech-req чек-лист «Логин Garmin Connect — итерация + 2»); `GarminClient` реализует флоу `garminconnect` полностью + (embed → csrf → signin → ticket → OAuth1-HMAC-SHA1 → Bearer), но + против Garmin Connect валидируется только вручную — при блокировке + fallback = папка импорта (m06, О-1-А). Тесты — `FakeGarminHandler` + с теми же маршрутами и настоящими .fit-байтами (энкодер SDK). +2. **Куки**: `CookieContainer` наполняется из `Set-Cookie` вручную + (редиректы не следуются автоматически — ticket читается из + Location/тела). +3. **Дедуп**: рубеж 1 — файл-кэш `{activityId}.fit` (без сети); + рубеж 2 — `fit_file_id` в БД до записи (дубль → файл не кэшируется). + Тест «дубль» даёт skipped=2 (кэш + дубль) — корректно. +4. **Битый download** → `{activityId}.bad` (кэш не занимает, + повторный fetch скачает заново). +5. Конфиг `Garmin.*` — читается в `ServerConfig`; в appsettings + умолчания пустые (🛡 секреты только в конфиге владельца). diff --git a/2-db/src/functions/program_get.psql b/2-db/src/functions/program_get.psql index bd41c12..07dbffc 100644 --- a/2-db/src/functions/program_get.psql +++ b/2-db/src/functions/program_get.psql @@ -3,6 +3,9 @@ -- Параметры: p_athlete_id; p_program_id. -- Возвращает: TABLE — строка program + денормализация; чужая — пустая таблица. +-- прежняя сигнатура (без external_id) снимается — row type меняется +DROP FUNCTION IF EXISTS src.program_get(int, int); + CREATE OR REPLACE FUNCTION src.program_get( p_athlete_id int, p_program_id int diff --git a/2-db/src/functions/program_select.psql b/2-db/src/functions/program_select.psql index 3cffc4c..6be46a5 100644 --- a/2-db/src/functions/program_select.psql +++ b/2-db/src/functions/program_select.psql @@ -1,8 +1,11 @@ -- src.program_select: программы атлета + активная версия (денормализация, d-db-3). --- На основе: новая. +-- На основе: новая (rev. 2 — external_id, доведение З-35, errata m06). -- Параметры: p_athlete_id. -- Возвращает: TABLE — строки program + active_version_id/active_version (NULL — активной нет). +-- прежняя сигнатура (без external_id) снимается — row type меняется +DROP FUNCTION IF EXISTS src.program_select(int); + CREATE OR REPLACE FUNCTION src.program_select( p_athlete_id int ) @@ -10,11 +13,12 @@ RETURNS TABLE ( id int, athlete_id int, title text, + external_id text, created_at timestamptz, active_version_id uuid, active_version int ) LANGUAGE sql AS $$ - SELECT p.id, p.athlete_id, p.title, p.created_at, + SELECT p.id, p.athlete_id, p.title, p.external_id, p.created_at, v.id AS active_version_id, v.version AS active_version FROM src.program p LEFT JOIN src.program_version v diff --git a/3-app/2-cli/Server.WatchBody.Core.Tests/ExerciseRepositoryTests.cs b/3-app/2-cli/Server.WatchBody.Core.Tests/ExerciseRepositoryTests.cs index da34a79..5bf65f3 100644 --- a/3-app/2-cli/Server.WatchBody.Core.Tests/ExerciseRepositoryTests.cs +++ b/3-app/2-cli/Server.WatchBody.Core.Tests/ExerciseRepositoryTests.cs @@ -205,8 +205,8 @@ public class ProgramRepositoryTests .AddParameter("p_athlete_id", 1) .BuildParams(); var table = TestInfra.Table(TestInfra.ProgramCols, - [1, 1, "TEST-Программа", new DateTime(2026, 10, 17), DBNull.Value, DBNull.Value], - [2, 1, "TEST-Программа-2", new DateTime(2026, 10, 17), activeId, 2]); + [1, 1, "TEST-Программа", DBNull.Value, new DateTime(2026, 10, 17), DBNull.Value, DBNull.Value], + [2, 1, "TEST-Программа-2", DBNull.Value, new DateTime(2026, 10, 17), activeId, 2]); var cs = new ConnectionStringTest(expArgs, table); var rows = new ProgramRepository(cs).Select(1).ToList(); @@ -227,7 +227,7 @@ public class ProgramRepositoryTests .AddParameter("p_program_id", 2) .BuildParams(); var table = TestInfra.Table(TestInfra.ProgramCols, - [2, 1, "TEST-Программа-2", new DateTime(2026, 10, 17), activeId, 2]); + [2, 1, "TEST-Программа-2", DBNull.Value, new DateTime(2026, 10, 17), activeId, 2]); var cs = new ConnectionStringTest(expArgs, table); var p = new ProgramRepository(cs).Get(1, 2); diff --git a/3-app/2-cli/Server.WatchBody.Core.Tests/TestInfra.cs b/3-app/2-cli/Server.WatchBody.Core.Tests/TestInfra.cs index 68a336a..e367ce7 100644 --- a/3-app/2-cli/Server.WatchBody.Core.Tests/TestInfra.cs +++ b/3-app/2-cli/Server.WatchBody.Core.Tests/TestInfra.cs @@ -35,8 +35,8 @@ internal static class TestInfra public static readonly (string, Type)[] ProgramCols = [("id", typeof(int)), ("athlete_id", typeof(int)), ("title", typeof(string)), - ("created_at", typeof(DateTime)), ("active_version_id", typeof(Guid)), - ("active_version", typeof(int))]; + ("external_id", typeof(string)), ("created_at", typeof(DateTime)), + ("active_version_id", typeof(Guid)), ("active_version", typeof(int))]; public static readonly (string, Type)[] VersionCols = [("id", typeof(Guid)), ("program_id", typeof(int)), ("version", typeof(int)), diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ProgramRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ProgramRepository.cs index 8388a74..14214ba 100644 --- a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ProgramRepository.cs +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ProgramRepository.cs @@ -47,6 +47,8 @@ public class ProgramRepository : IProgramRepository Id = record.Convert(record.Src["id"]), AthleteId = record.Convert(record.Src["athlete_id"]), Title = record.Src["title"] as string ?? string.Empty, + ExternalId = record.IsDbNull("external_id") + ? null : record.Src["external_id"] as string, CreatedAt = record.Convert(record.Src["created_at"]), ActiveVersionId = record.IsDbNull("active_version_id") ? null : record.Convert(record.Src["active_version_id"]), @@ -74,6 +76,8 @@ public class ProgramRepository : IProgramRepository Id = record.Convert(record.Src["id"]), AthleteId = record.Convert(record.Src["athlete_id"]), Title = record.Src["title"] as string ?? string.Empty, + ExternalId = record.IsDbNull("external_id") + ? null : record.Src["external_id"] as string, CreatedAt = record.Convert(record.Src["created_at"]), ActiveVersionId = record.IsDbNull("active_version_id") ? null : record.Convert(record.Src["active_version_id"]), diff --git a/3-app/2-cli/Server.WatchBody.Fetch/FitFetcher.cs b/3-app/2-cli/Server.WatchBody.Fetch/FitFetcher.cs new file mode 100644 index 0000000..e597a50 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Fetch/FitFetcher.cs @@ -0,0 +1,100 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Import; + +namespace Server.WatchBody.Fetch; + +/// Ошибка конфигурации/атлета fetch (CLI → код 1, spec 02 §3). +public class FetchException(string message) : Exception(message); + +/// +/// Цикл забора (spec 02 m07): логин → список → фильтр strength_training +/// → скачивание в downloadDir → разбор и запись конвейером m06 → +/// дедуп двух уровней (файл-кэш по activityId, fit_file_id в БД). +/// 🛡 Записи в БД — только через WorkoutImporter; сбой файла пакет +/// не останавливает. +/// +public class FitFetcher(IConnectionString cs, GarminClient client, + string downloadDir, string athleteName) +{ + public (int Downloaded, int Skipped, int Failed) FetchNew( + int limit = 20, CancellationToken ct = default) + { + // 1. атлет (🛡1 — факты без атлета не пишутся) + var athlete = new AthleteRepository(cs).Select(null) + .FirstOrDefault(a => a.Name == athleteName) + ?? throw new FetchException($"атлет '{athleteName}' не найден (Garmin.AthleteName)"); + + // 2. логин (исключения — наверх) + client.Login(ct); + + // 3. список, только силовые (Ф-8.1) + var activities = client.ListActivities(limit, ct) + .Where(a => a.ActivityType.Equals("strength_training", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Directory.CreateDirectory(downloadDir); + var workouts = new WorkoutRepository(cs); + var importer = new WorkoutImporter(workouts); + var parser = new FitParser(); + var downloaded = 0; + var skipped = 0; + var failed = 0; + + foreach (var activity in activities) + { + var cached = Path.Combine(downloadDir, $"{activity.ActivityId}.fit"); + var temp = cached + ".downloading"; + try + { + // рубеж 1: файл-кэш — без сети (🛡2) + if (File.Exists(cached)) + { + skipped++; + continue; + } + + // 4. скачать и разобрать + var bytes = client.DownloadFit(activity.ActivityId, ct); + ActivityFacts facts; + try + { + File.WriteAllBytes(temp, bytes); + facts = parser.Parse(temp); + } + catch (FitParseException) + { + // битый download — в .bad, кэш не занимает (spec 02 §4) + File.Move(temp, cached + ".bad", overwrite: true); + throw; + } + File.Delete(temp); + + // рубеж 2: fit_file_id уже в БД — дубль, файл не нужен (🛡2) + var day = DateOnly.FromDateTime(facts.StartedAt); + var duplicate = workouts.Select(new WorkoutFilter + { + AthleteId = athlete.Id, + DateFrom = day.AddDays(-1), + DateTo = day.AddDays(1), + }).Any(w => w.FitFileId == facts.FitFileId); + if (duplicate) + { + skipped++; + continue; + } + + // 5. запись и кэш + importer.Import(athlete.Id, facts); + File.WriteAllBytes(cached, bytes); + downloaded++; + } + catch (Exception) + { + failed++; // файл остаётся/фиксируется как .bad; пакет продолжается (🛡3) + } + } + return (downloaded, skipped, failed); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Fetch/GarminClient.cs b/3-app/2-cli/Server.WatchBody.Fetch/GarminClient.cs new file mode 100644 index 0000000..29a5c99 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Fetch/GarminClient.cs @@ -0,0 +1,245 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Server.WatchBody.Fetch; + +/// Неуспех SSO-логина (шаги 1–5; MFA/блокировка). Текст без +/// пароля (🛡4). +public class GarminAuthException(string message) : Exception(message); + +/// Одна активность из списка Garmin Connect (spec 01 §1). +public sealed record GarminActivity(long ActivityId, DateTime StartTimeLocal, string ActivityType); + +/// +/// Неофициальный клиент Garmin Connect — по контракту семьи библиотек +/// `garminconnect` (tech-req разд. 5): SSO-логин → OAuth1 → Bearer; +/// список активностей и скачивание .fit. HttpMessageHandler инъецируется +/// (тесты — FakeGarminHandler). 🛡 пароль/токены не логируются. +/// +public class GarminClient +{ + // consumer-пара семьи garminconnect (прототип; при блокировке — + // решение ОВ этапа 7, fallback — папка импорта) + public const string ConsumerKey = "C2JDomY7p9eCA2R5OsBTb3tS6aIn9k2"; + public const string ConsumerSecret = "O45MeVZGRDTv6qnKZaZjfMndAQ7Qu9FTH7IHZP1YmBySpTOd"; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + private readonly string _login, _password, _base; + private readonly HttpClient _http; + private readonly CookieContainer _cookies; + private string? _bearer; + + public GarminClient(string login, string password, string baseUrl, + HttpMessageHandler? handler = null) + { + _login = login; + _password = password; + _base = baseUrl.TrimEnd('/'); + _cookies = new CookieContainer(); + _http = new HttpClient(handler ?? new HttpClientHandler + { + CookieContainer = _cookies, + AllowAutoRedirect = false, + }) + { Timeout = Timeout }; + if (handler == null) return; + // фейковый handler ведёт куки сам — отдадим ему контейнер через свойства + if (handler is FakeGarminHandler fake) fake.Cookies = _cookies; + } + + /// SSO-последовательность (spec 01 §2, шаги 1–5). + public void Login(CancellationToken ct = default) + { + Get($"{_base}/sso/embed", ct); // 1 + var signinHtml = Get($"{_base}/sso/signin", ct); // 2 + var csrf = Regex.Match(signinHtml, "name=\"_csrf\"\\s+value=\"([^\"]+)\"").Groups[1].Value + ?? throw new GarminAuthException("signin: csrf не найден"); + var (status, body) = PostForm($"{_base}/sso/signin", new Dictionary + { + ["username"] = _login, + ["password"] = _password, + ["embed"] = "true", + ["_csrf"] = csrf, + }, ct); // 3 + var ticket = Regex.Match(body, "[?&]ticket=([^&\"']+)", RegexOptions.IgnoreCase).Groups[1].Value; + if (status != HttpStatusCode.OK && status != HttpStatusCode.Found || ticket.Length == 0) + throw new GarminAuthException($"signin не удался (HTTP {(int)status}; возможны MFA/блокировка)"); + Get($"{_base}/modern/?ticket={Uri.EscapeDataString(ticket)}", ct); // 4 + + // 5: OAuth1-обмен → Bearer + var oauth = OAuth1Header("POST", $"{_base}/sso/api/2.0/oauth/exchange/service/user/2.0"); + var (exStatus, exBody) = PostAuthorized( + $"{_base}/sso/api/2.0/oauth/exchange/service/user/2.0", oauth, ct); + if (exStatus != HttpStatusCode.OK) + throw new GarminAuthException($"oauth exchange не удался (HTTP {(int)exStatus})"); + using var json = JsonDocument.Parse(exBody); + _bearer = json.RootElement.TryGetProperty("access_token", out var token) + ? token.GetString() + : throw new GarminAuthException("oauth exchange: access_token отсутствует"); + } + + /// Список активностей (шаг 6). + public List ListActivities(int limit, CancellationToken ct = default) + { + var list = Get($"{_base}/activitylist-service/activities/search/activities" + + $"?limit={limit}&start=0", ct, authorized: true); + using var json = JsonDocument.Parse(list); + return json.RootElement.EnumerateArray() + .Where(a => a.TryGetProperty("activityId", out _)) + .Select(a => new GarminActivity( + a.GetProperty("activityId").GetInt64(), + a.TryGetProperty("startTimeLocal", out var t) && + DateTime.TryParse(t.GetString(), CultureInfo.InvariantCulture, + DateTimeStyles.None, out var parsed) ? parsed : default, + a.TryGetProperty("activityType", out var type) && + type.TryGetProperty("typeKey", out var key) + ? key.GetString() ?? "" : "")) + .ToList(); + } + + /// Байты .fit активности (шаг 7). + public byte[] DownloadFit(long activityId, CancellationToken ct = default) => + _http.GetByteArrayAsync( + $"{_base}/download-service/files/activity/{activityId}", ct) + .GetAwaiter().GetResult(); + + // ---------- низкоуровневые ---------- + + private string Get(string url, CancellationToken ct, bool authorized = false) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + if (authorized) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _bearer); + var response = Send(request, ct); + return response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult(); + } + + private (HttpStatusCode Status, string Body) PostForm( + string url, Dictionary fields, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new FormUrlEncodedContent(fields), + }; + var response = Send(request, ct); + return (response.StatusCode, + response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult()); + } + + private (HttpStatusCode Status, string Body) PostAuthorized( + string url, string oauthHeader, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.TryAddWithoutValidation("Authorization", oauthHeader); + var response = Send(request, ct); + return (response.StatusCode, + response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult()); + } + + private HttpResponseMessage Send(HttpRequestMessage request, CancellationToken ct) + { + var response = _http.SendAsync(request, ct).GetAwaiter().GetResult(); + foreach (var cookie in response.Headers + .Where(h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)) + .SelectMany(h => h.Value)) + SaveCookie(request.RequestUri!, cookie); + return response; + } + + private void SaveCookie(Uri uri, string setCookie) + { + var parts = setCookie.Split(';', 2)[0].Split('=', 2); + if (parts.Length == 2) + _cookies.Add(uri, new Cookie(parts[0].Trim(), parts[1].Trim())); + } + + /// OAuth1 Authorization-заголовок (HMAC-SHA1) для обменного + /// шага (по контракту garminconnect). + private string OAuth1Header(string method, string url) + { + var nonce = Guid.NewGuid().ToString("N"); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + .ToString(CultureInfo.InvariantCulture); + var parameters = new SortedDictionary + { + ["oauth_consumer_key"] = ConsumerKey, + ["oauth_nonce"] = nonce, + ["oauth_signature_method"] = "HMAC-SHA1", + ["oauth_timestamp"] = timestamp, + ["oauth_version"] = "1.0", + }; + var baseString = $"{method}&{Uri.EscapeDataString(url)}&" + + Uri.EscapeDataString(string.Join("&", + parameters.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"))); + var key = $"{Uri.EscapeDataString(ConsumerSecret)}&"; + string signature; + using (var hmac = new HMACSHA1(Encoding.ASCII.GetBytes(key))) + signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.ASCII.GetBytes(baseString))); + return "OAuth " + string.Join(", ", + parameters.Select(p => $"{p.Key}=\"{Uri.EscapeDataString(p.Value)}\"")) + + $", oauth_signature=\"{Uri.EscapeDataString(signature)}\""; + } +} + +/// Фейковый Garmin для тестов (spec 01 §3): маршруты spec 01 §2 +/// на подменяемом base; список/байты задаёт тест. +public class FakeGarminHandler : HttpMessageHandler +{ + public CookieContainer? Cookies { get; set; } + public bool FailLogin { get; set; } + public List Activities { get; set; } = []; + public Dictionary FitFiles { get; set; } = []; + public int LoginCalls, ListCalls, DownloadCalls; + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + var response = new HttpResponseMessage(HttpStatusCode.OK); + if (url.EndsWith("/sso/embed")) { /* шаг 1 — cookies выставит Send-обвязка */ } + else if (url.EndsWith("/sso/signin") && request.Method == HttpMethod.Get) + response.Content = new StringContent( + ""); + else if (url.EndsWith("/sso/signin")) + { + LoginCalls++; + if (FailLogin) + { + response.StatusCode = HttpStatusCode.Unauthorized; + response.Content = new StringContent("auth failed"); + } + else response.Content = new StringContent( + "…?ticket=ST-123456-fake&…"); + } + else if (url.Contains("/modern/?ticket=")) { /* шаг 4 */ } + else if (url.Contains("/oauth/exchange")) + response.Content = new StringContent( + """{"access_token":"fake-bearer-token","expires_in":3599}"""); + else if (url.Contains("/activitylist-service")) + { + ListCalls++; + response.Content = new StringContent( + JsonSerializer.Serialize(Activities.Select(a => new Dictionary + { + ["activityId"] = a.ActivityId, + ["startTimeLocal"] = a.StartTimeLocal.ToString( + "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + ["activityType"] = new Dictionary + { ["typeKey"] = a.ActivityType }, + }))); + } + else if (url.Contains("/download-service/files/activity/")) + { + DownloadCalls++; + var id = long.Parse(url.Split('/').Last()); + response.Content = new ByteArrayContent(FitFiles[id]); + } + else response.StatusCode = HttpStatusCode.NotFound; + return Task.FromResult(response); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Fetch/Server.WatchBody.Fetch.csproj b/3-app/2-cli/Server.WatchBody.Fetch/Server.WatchBody.Fetch.csproj new file mode 100644 index 0000000..816c9ea --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Fetch/Server.WatchBody.Fetch.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + Server.WatchBody.Fetch + + + + + + + + + + + + diff --git a/3-app/2-cli/Server.WatchBody.Import/FitParser.cs b/3-app/2-cli/Server.WatchBody.Import/FitParser.cs new file mode 100644 index 0000000..0ad9487 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Import/FitParser.cs @@ -0,0 +1,257 @@ +using Dynastream.Fit; +using FitDateTime = Dynastream.Fit.DateTime; + +namespace Server.WatchBody.Import; + +/// Разобранные факты активности (.fit / TCX / GPX) — единый +/// вход WorkoutImporter (spec 01/02 m06). +public sealed record ActivityFacts( + string FactsJson, + System.DateTime StartedAt, + int? AvgHr, + int? HrRecoveryS, + string FitFileId, + string Device); + +/// Файл не активность или повреждён (spec 01 §1). +public class FitParseException(string message) : Exception(message); + +/// +/// Потоковый разбор .fit — официальный FIT SDK (tech-req разд. 5): +/// события Decode.MesgEvent, файл читается один раз. Результат — +/// канонический факт workout-facts/1 (tech-req 2.3). +/// +public class FitParser +{ + private readonly decimal _restingHr; // порог возврата пульса (Р3-А) + + public FitParser(decimal restingHrSitting = 90) => _restingHr = restingHrSitting; + + public ActivityFacts Parse(string path) + { + var session = new SessionAccumulator(); + var bytes = System.IO.File.ReadAllBytes(path); + using (var probe = new MemoryStream(bytes)) + { + if (!new Decode().IsFIT(probe)) + throw new FitParseException($"файл не .fit: {path}"); + probe.Position = 0; + if (!new Decode().CheckIntegrity(probe)) + throw new FitParseException($"контрольная сумма .fit не сошлась: {path}"); + } + var decode = new Decode(); + decode.MesgEvent += (_, e) => session.OnMesg(e.mesg); + using var stream = new MemoryStream(bytes); + if (!decode.Read(stream)) + throw new FitParseException($"файл не прочитан: {path}"); + return session.Build(_restingHr); + } + + /// Генерация .fit для тест-фикстур (spec 01 §4) — + /// энкодер SDK; порядок FileId → DeviceInfo → Record* → Lap* → + /// Set* → Session. + public static void WriteTestFile( + string path, System.DateTime startUtc, + (int offsetS, int hr)[] records, + (int index, int offsetS, int durationS)[] laps, + (int reps, decimal? weightKg)[] sets, + string? serial = null) + { + var encode = new Encode(ProtocolVersion.V20); + using var output = new FileStream(path, FileMode.Create); + encode.Open(output); + + var fileId = new FileIdMesg(); + fileId.SetTimeCreated(new FitDateTime(startUtc)); + if (serial != null) fileId.SetSerialNumber(uint.Parse(serial)); + encode.Write(fileId); + + var device = new DeviceInfoMesg(); + device.SetTimestamp(new FitDateTime(startUtc)); + device.SetGarminProduct(GarminProduct.Fenix6sAsia); + encode.Write(device); + + foreach (var (offsetS, hr) in records) + { + var record = new RecordMesg(); + record.SetTimestamp(new FitDateTime(startUtc.AddSeconds(offsetS))); + record.SetHeartRate((byte)hr); + encode.Write(record); + } + foreach (var (index, offsetS, durationS) in laps) + { + var lap = new LapMesg(); + lap.SetStartTime(new FitDateTime(startUtc.AddSeconds(offsetS))); + lap.SetTotalTimerTime((float)durationS); + encode.Write(lap); + } + foreach (var (reps, weightKg) in sets) + { + var set = new SetMesg(); + set.SetStartTime(new FitDateTime(startUtc)); + set.SetRepetitions((ushort)reps); + if (weightKg != null) set.SetWeight((float)weightKg); + encode.Write(set); + } + + var session = new SessionMesg(); + session.SetStartTime(new FitDateTime(startUtc)); + session.SetTotalTimerTime((float)(records.Length > 0 ? records[^1].offsetS + 60 : 3600)); + session.SetSport(Sport.Training); + encode.Write(session); + encode.Close(); + } + + private sealed class SessionAccumulator + { + private FileIdMesg? _fileId; + private SessionMesg? _session; + private readonly List _laps = []; + private readonly List<(System.DateTime time, int hr)> _records = []; + private readonly List _sets = []; + private DeviceInfoMesg? _device; + + public void OnMesg(Mesg mesg) + { + // SDK доставляет базовый Mesg — типизированные классы + // конструируются из него по Num + switch (mesg.Num) + { + case MesgNum.FileId: _fileId = new FileIdMesg(mesg); break; + case MesgNum.Session: _session = new SessionMesg(mesg); break; + case MesgNum.Lap: _laps.Add(new LapMesg(mesg)); break; + case MesgNum.Record: + { + var record = new RecordMesg(mesg); + if (record.GetHeartRate().HasValue && record.GetHeartRate().Value > 0 && + record.GetTimestamp() != null) + _records.Add((record.GetTimestamp().GetDateTime().ToUniversalTime(), + record.GetHeartRate().Value)); + break; + } + case MesgNum.Set: _sets.Add(new SetMesg(mesg)); break; + case MesgNum.DeviceInfo: + { + var device = new DeviceInfoMesg(mesg); + if (device.GetTimestamp() != null && + (_device == null || + device.GetTimestamp().GetDateTime() >= _device.GetTimestamp().GetDateTime())) + _device = device; + break; + } + } + } + + public ActivityFacts Build(decimal restingHr) + { + if (_session?.GetStartTime() == null) + throw new FitParseException("в файле нет Session — это не активность"); + + var start = _session.GetStartTime().GetDateTime().ToUniversalTime(); + var durationS = _session.GetTotalTimerTime() != null + ? (int)Math.Round(_session.GetTotalTimerTime().Value, MidpointRounding.AwayFromZero) + : 0; + + var lapsJson = _laps + .Where(l => l.GetStartTime() != null) + .Select((l, i) => new Dictionary + { + ["index"] = i, + ["start"] = l.GetStartTime().GetDateTime().ToUniversalTime() + .ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), + ["duration_s"] = l.GetTotalTimerTime() != null + ? (int)Math.Round(l.GetTotalTimerTime().Value, MidpointRounding.AwayFromZero) + : 0, + }) + .ToList(); + + var recordsJson = _records + .Select(r => new Dictionary + { + ["t_offset_s"] = (int)(r.time - start).TotalSeconds, + ["hr"] = r.hr, + }) + .ToList(); + + var setsJson = _sets + .Where(s => s.GetRepetitions().HasValue && s.GetRepetitions().Value > 0) + .Select((s, i) => + { + var item = new Dictionary + { + ["index"] = i, + ["reps"] = s.GetRepetitions().Value, + }; + if (s.GetWeight().HasValue && s.GetWeight().Value > 0) + item["weight_kg"] = Math.Round((decimal)s.GetWeight().Value, 1, + MidpointRounding.AwayFromZero); + return item; + }) + .ToList(); + + var fitFileId = ComposeFileId(); + var device = _device == null + ? string.Empty + : _device.GetGarminProduct().HasValue + ? _device.GetGarminProduct().Value.ToString() + : _device.GetProduct()?.ToString() ?? string.Empty; + + var facts = new Dictionary + { + ["schema"] = "workout-facts/1", + ["session"] = new Dictionary + { + ["type"] = _session.GetSport().ToString(), + ["start"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), + ["duration_s"] = durationS, + }, + ["laps"] = lapsJson, + ["records"] = recordsJson, + ["sets"] = setsJson, + ["fit_file_id"] = fitFileId, + ["device"] = device, + }; + + int? avgHr = recordsJson.Count == 0 + ? null + : (int)Math.Round(recordsJson.Average(r => (int)r["hr"]!), + MidpointRounding.AwayFromZero); + var lapEnds = _laps + .Where(l => l.GetStartTime() != null && l.GetTotalTimerTime() != null) + .Select(l => l.GetStartTime().GetDateTime().ToUniversalTime() + .AddSeconds(l.GetTotalTimerTime().Value)) + .ToList(); + var recovery = HrRecovery(lapEnds, _records, restingHr); + + return new ActivityFacts( + System.Text.Json.JsonSerializer.Serialize(facts), + start, avgHr, recovery, fitFileId, device); + } + + /// Конец последнего Lap → первый Record строго после + /// него с hr ниже порога (Ф-13.4, spec 01 §3); нет записей или + /// не опустился — null. + private static int? HrRecovery( + List lapEnds, List<(System.DateTime time, int hr)> records, decimal restingHr) + { + if (lapEnds.Count == 0) return null; + var lastEnd = lapEnds.Max(); + var after = records + .Where(r => r.time > lastEnd) + .OrderBy(r => r.time) + .ToList(); + var hit = after.FirstOrDefault(r => r.hr < restingHr); + if (hit == default) return null; + return (int)Math.Round((hit.time - lastEnd).TotalSeconds, MidpointRounding.AwayFromZero); + } + + private string ComposeFileId() => + _fileId == null + ? throw new FitParseException("в файле нет FileId — не Garmin-экспорт") + : _fileId.GetTimeCreated() == null + ? throw new FitParseException("FileId без TimeCreated") + : $"{(_fileId.GetSerialNumber()?.ToString() ?? "nosn")}-" + + $"{_fileId.GetTimeCreated().GetDateTime().ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}"; + + } +} diff --git a/3-app/2-cli/Server.WatchBody.Import/ImportService.cs b/3-app/2-cli/Server.WatchBody.Import/ImportService.cs new file mode 100644 index 0000000..3ddd57b --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Import/ImportService.cs @@ -0,0 +1,115 @@ +using Kit.Helpers.SqlHelperAbstract; +using Microsoft.Extensions.Logging; +using Server.WatchBody.Core.Dal.Ref.Repository; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Programs; + +namespace Server.WatchBody.Import; + +/// +/// Обход Import.Dir (spec 04 m06): подпапки = имена атлетов; .fit/.tcx/ +/// .gpx → WorkoutImporter, program-import.json → ProgramPackageImporter; +/// обработанные → ArchiveDir, сбойные остаются (🛡4 — пакет +/// продолжается). +/// +public class ImportService(IConnectionString connectionString, ILogger? logger = null) +{ + private static readonly string[] ActivityExtensions = [".fit", ".tcx", ".gpx"]; + private const string PackageName = "program-import.json"; + + /// (обработано, сбойных). Каталог импорта не существует → (0, 0). + public (int Processed, int Failed) Run(string importDir, string archiveDir) + { + if (!Directory.Exists(importDir)) + { + Log($"каталог импорта {importDir} не существует — новых файлов нет"); + return (0, 0); + } + Directory.CreateDirectory(archiveDir); + + // имена атлетов могут повторяться (общая тестовая БД) — первый + var athletes = new AthleteRepository(connectionString).Select(null) + .GroupBy(a => a.Name, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal); + var importer = new ImportContext(connectionString); + + var processed = 0; + var failed = 0; + foreach (var folder in Directory.GetDirectories(importDir)) + { + var athleteName = Path.GetFileName(folder); + if (!athletes.TryGetValue(athleteName, out var athleteId)) + { + failed++; + Log($"атлет '{athleteName}' не найден — файлы папки не обработаны"); + continue; + } + foreach (var file in Directory.GetFiles(folder)) + { + var name = Path.GetFileName(file); + try + { + ProcessFile(importer, athleteId, file, athleteName); + var target = ArchivePath(archiveDir, athleteName, name); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + System.IO.File.Move(file, target); + processed++; + Log($"обработан: {athleteName}/{name}"); + } + catch (Exception e) + { + failed++; + Log($"СБОЙ {athleteName}/{name}: {e.Message}"); + Console.Error.WriteLine($"import error: {athleteName}/{name}: {e}"); + } + } + } + foreach (var file in Directory.GetFiles(importDir)) + Log($"пропущен (не в подпапке-атлете): {Path.GetFileName(file)}"); + return (processed, failed); + } + + private static void ProcessFile(ImportContext ctx, int athleteId, string path, string athleteName) + { + var extension = Path.GetExtension(path).ToLowerInvariant(); + var name = Path.GetFileName(path); + switch (extension) + { + case ".fit": + ctx.Workouts.Import(athleteId, ctx.Fit.Parse(path)); + break; + case ".tcx" or ".gpx": + ctx.Workouts.Import(athleteId, ctx.Xml.Parse(path)); + break; + case ".json" when name.Equals(PackageName, StringComparison.OrdinalIgnoreCase): + ctx.Packages.Import(athleteId, System.IO.File.ReadAllText(path)); + break; + default: + throw new FitParseException($"неизвестный тип файла импорта: {name}"); + } + } + + private static string ArchivePath(string archiveDir, string athleteName, string fileName) + { + var target = Path.Combine(archiveDir, athleteName, fileName); + if (!System.IO.File.Exists(target)) return target; + var suffix = $"-{DateTime.Now:HHmmss}"; + return Path.Combine(archiveDir, athleteName, + Path.GetFileNameWithoutExtension(fileName) + suffix + Path.GetExtension(fileName)); + } + + private void Log(string message) => logger?.LogInformation("{Message}", message); + + /// Ленивые зависимости одного прогона (соединение общее). + private sealed class ImportContext(IConnectionString cs) + { + public readonly FitParser Fit = new(); + public readonly TcxGpxParser Xml = new(); + public readonly WorkoutImporter Workouts = new( + new WorkoutRepository(cs)); + public readonly ProgramPackageImporter Packages = new( + new ProgramRepository(cs), new ExerciseRepository(cs), + new ExerciseGroupRepository(cs), + new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs))); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Import/ProgramPackageImporter.cs b/3-app/2-cli/Server.WatchBody.Import/ProgramPackageImporter.cs new file mode 100644 index 0000000..4cd4948 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Import/ProgramPackageImporter.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using Server.WatchBody.Core.Dal.Ref.Repository; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Programs; + +namespace Server.WatchBody.Import; + +/// Нарушение формата пакета program-import.json (spec 03 §2). +public class ImportPackageException(string message) : Exception(message); + +/// +/// Импорт пакета программы (spec 03 m06, формат program-import-format.md): +/// program по external_id, версия через ProgramStore (повтор пакета — +/// новая версия max+1), отсутствующие упражнения создаются. 🛡 +/// Существующие версии не переписываются; статус draft. +/// +public class ProgramPackageImporter( + IProgramRepository programs, + IExerciseRepository exercises, + IExerciseGroupRepository groups, + ProgramStore store) +{ + public Guid Import(int athleteId, string packageJson) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(packageJson); + } + catch (JsonException e) + { + throw new ImportPackageException($"пакет не JSON: {e.Message}"); + } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("weeks", out var weeks) || + weeks.ValueKind != JsonValueKind.Array || weeks.GetArrayLength() == 0) + throw new ImportPackageException("пакет без weeks — это не программа"); + if (!root.TryGetProperty("program_id", out var programIdEl) || + programIdEl.GetString() is not { Length: > 0 } externalId) + throw new ImportPackageException("пакет без program_id"); + + var author = root.TryGetProperty("author", out var authorEl) + ? authorEl.GetString() : null; + if (author is not ("user" or "admin")) + throw new ImportPackageException($"author '{author}' не допускается (user|admin)"); + + // программа по external_id (З-35) или создание + var program = programs.Select(athleteId).FirstOrDefault(p => p.ExternalId == externalId) + ?? CreateProgram(athleteId, externalId); + + // отсутствующие упражнения библиотеки (format разд. 2) + var groupByFocus = GroupByFocus(); + EnsureExercises(athleteId, weeks, groupByFocus); + + var rationale = root.TryGetProperty("rationale", out var rationaleEl) && + rationaleEl.ValueKind == JsonValueKind.String + ? rationaleEl.GetString() ?? string.Empty + : string.Empty; + var version = store.CreateDraft(athleteId, root.GetRawText(), author, rationale); + return version.Id; + } + } + + private Program CreateProgram(int athleteId, string externalId) + { + var id = programs.Insert(new Program + { + AthleteId = athleteId, + Title = externalId, + ExternalId = externalId, + }); + return new Program { Id = id, AthleteId = athleteId, Title = externalId, ExternalId = externalId }; + } + + private Dictionary GroupByFocus() => + groups.Select().ToDictionary(g => g.Code, g => g.Id, StringComparer.Ordinal); + + private void EnsureExercises(int athleteId, JsonElement weeks, Dictionary groupIds) + { + var known = exercises.Select(new ExerciseFilter { AthleteId = athleteId }) + .Select(e => e.Name).ToHashSet(StringComparer.Ordinal); + foreach (var session in weeks.EnumerateArray() + .SelectMany(w => w.TryGetProperty("sessions", out var s) + ? s.EnumerateArray() : Enumerable.Empty())) + { + var focus = session.TryGetProperty("focus", out var f) ? f.GetString() ?? "" : ""; + var groupId = FocusGroup(focus, groupIds); + if (!session.TryGetProperty("exercises", out var exerciseNodes)) continue; + foreach (var exercise in exerciseNodes.EnumerateArray()) + { + if (!exercise.TryGetProperty("exercise_id", out var idEl) || + idEl.GetString() is not { Length: > 0 } name || known.Contains(name)) continue; + exercises.Insert(new Exercise + { + Id = Guid.NewGuid(), + AthleteId = athleteId, + Name = name, + GroupId = groupId, + IsBase = true, + IsActive = true, + ReplacementIds = [], + }); + known.Add(name); + } + } + } + + private static int FocusGroup(string focus, Dictionary groupIds) + { + foreach (var (code, id) in groupIds) + if (focus.Contains(code, StringComparison.OrdinalIgnoreCase)) + return id; + return groupIds.GetValueOrDefault("core", 4); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Import/Server.WatchBody.Import.csproj b/3-app/2-cli/Server.WatchBody.Import/Server.WatchBody.Import.csproj new file mode 100644 index 0000000..93834f4 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Import/Server.WatchBody.Import.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + Server.WatchBody.Import + + + + + + + + + + + + diff --git a/3-app/2-cli/Server.WatchBody.Import/TcxGpxParser.cs b/3-app/2-cli/Server.WatchBody.Import/TcxGpxParser.cs new file mode 100644 index 0000000..97137d0 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Import/TcxGpxParser.cs @@ -0,0 +1,110 @@ +using System.Xml.Linq; + +namespace Server.WatchBody.Import; + +/// +/// Упрощённый разбор .tcx/.gpx (производные форматы — документ +/// форматов): тип/старт/длительность + пульс-записи; подходов в этих +/// форматах нет. fit_file_id — имя файла (дедупликация). +/// +public class TcxGpxParser +{ + public ActivityFacts Parse(string path) + { + var name = Path.GetFileName(path); + XDocument doc; + try + { + doc = XDocument.Load(path); + } + catch (Exception e) + { + throw new FitParseException($"XML не читается: {name} — {e.Message}"); + } + + return doc.Root!.Name.LocalName == "TrainingCenterDatabase" + ? ParseTcx(doc, name) + : ParseGpx(doc, name); + } + + private static ActivityFacts ParseTcx(XDocument doc, string name) + { + var ns = doc.Root!.Name.Namespace; + var activity = doc.Descendants(ns + "Activity").FirstOrDefault() + ?? throw new FitParseException($"в TCX нет Activity: {name}"); + var id = (string?)activity.Element(ns + "Id"); + var start = ParseStart(id) ?? throw new FitParseException($"в TCX нет Id-старта: {name}"); + + var records = activity.Descendants(ns + "Trackpoint") + .Select(tp => new + { + time = DateTime.TryParse((string?)tp.Element(ns + "Time"), out var t) + ? t.ToUniversalTime() : (DateTime?)null, + hr = (int?)tp.Element(ns + "HeartRateBpm")?.Element(ns + "Value"), + }) + .Where(r => r.time != null && r.hr != null && r.hr > 0) + .Select(r => (time: r.time!.Value, hr: r.hr!.Value)) + .ToList(); + + var durationS = (int)((records.Count > 0 ? records.Max(r => r.time) : start) - start).TotalSeconds; + var type = (string?)activity.Attribute("Sport") ?? "unknown"; + return Build(name, start, durationS, type.ToLowerInvariant(), records, device: string.Empty); + } + + private static ActivityFacts ParseGpx(XDocument doc, string name) + { + var start = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "time") is { } t + ? DateTime.Parse(t.Value).ToUniversalTime() + : throw new FitParseException($"в GPX нет времени: {name}"); + var records = doc.Descendants() + .Where(e => e.Name.LocalName is "trkpt" or "wpt") + .Select(pt => new + { + time = pt.Descendants().FirstOrDefault(x => x.Name.LocalName == "time") is { } ptTime && + DateTime.TryParse(ptTime.Value, out var ptParsed) + ? ptParsed.ToUniversalTime() : (DateTime?)null, + hr = pt.Descendants().FirstOrDefault(x => x.Name.LocalName is "hr" or "gpxtpx:hr" + || x.Name.LocalName.EndsWith("hr")) is { } hrEl && int.TryParse(hrEl.Value, out var hr) + ? hr : (int?)null, + }) + .Where(r => r.time != null && r.hr != null && r.hr > 0) + .Select(r => (time: r.time!.Value, hr: r.hr!.Value)) + .ToList(); + + var durationS = (int)((records.Count > 0 ? records.Max(r => r.time) : start) - start).TotalSeconds; + return Build(name, start, durationS, "unknown", records, device: string.Empty); + } + + internal static ActivityFacts Build(string fileId, DateTime start, int durationS, + string type, List<(DateTime time, int hr)> records, string device) + { + var facts = new Dictionary + { + ["schema"] = "workout-facts/1", + ["session"] = new Dictionary + { + ["type"] = type, + ["start"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), + ["duration_s"] = durationS, + }, + ["laps"] = Array.Empty(), + ["records"] = records.Select(r => new Dictionary + { + ["t_offset_s"] = (int)(r.time - start).TotalSeconds, + ["hr"] = r.hr, + }).ToList(), + ["sets"] = Array.Empty(), + ["fit_file_id"] = fileId, + ["device"] = device, + }; + int? avgHr = records.Count == 0 + ? null + : (int)Math.Round(records.Average(r => r.hr), MidpointRounding.AwayFromZero); + return new ActivityFacts( + System.Text.Json.JsonSerializer.Serialize(facts), + start, avgHr, null, fileId, device); + } + + private static DateTime? ParseStart(string? text) => + text != null && DateTime.TryParse(text, out var value) ? value.ToUniversalTime() : null; +} diff --git a/3-app/2-cli/Server.WatchBody.Import/WorkoutImporter.cs b/3-app/2-cli/Server.WatchBody.Import/WorkoutImporter.cs new file mode 100644 index 0000000..490fed2 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Import/WorkoutImporter.cs @@ -0,0 +1,82 @@ +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; + +namespace Server.WatchBody.Import; + +/// +/// Запись фактов в src.workout (spec 02 m06): дедуп по fit_file_id, +/// сопоставление телефонной тренировке ± 15 минут (Ф-8.4), иначе +/// строка «только .fit». 🛡 Телефонные поля не меняются; записи только +/// в facts_json/avg_hr/hr_recovery_s/fit_file_id. +/// +public class WorkoutImporter( + IWorkoutRepository workouts, + decimal restingHrSitting = 90) +{ + public Guid Import(int athleteId, ActivityFacts facts) + { + var day = DateOnly.FromDateTime(facts.StartedAt); + var candidates = workouts.Select(new WorkoutFilter + { + AthleteId = athleteId, + DateFrom = day.AddDays(-1), + DateTo = day.AddDays(1), + }).ToList(); + + // 1. дедуп: тот же fit_file_id — пропуск (🛡3) + var sameFile = candidates.FirstOrDefault(w => w.FitFileId == facts.FitFileId); + if (sameFile != null) return sameFile.Id; + + // 2. сопоставление: ближайшая без fit_file_id в окне ± 15 минут + var match = candidates + .Where(w => w.FitFileId == null) + .Select(w => (workout: w, gap: Math.Abs((w.StartedAt - facts.StartedAt).TotalMinutes))) + .Where(x => x.gap <= 15) + .OrderBy(x => x.gap) + .ThenBy(x => x.workout.Id) + .FirstOrDefault(); + + if (match.workout != null) + { + var existing = match.workout; + workouts.Update(athleteId, new Workout + { + Id = existing.Id, + AthleteId = athleteId, + ProgramVersionId = existing.ProgramVersionId, + StartedAt = existing.StartedAt, + EndedAt = existing.EndedAt, + Status = existing.Status, + SessionKind = existing.SessionKind, + AvgHr = facts.AvgHr ?? existing.AvgHr, + FactsJson = facts.FactsJson, + FitFileId = facts.FitFileId, + HrRecoveryS = facts.HrRecoveryS ?? existing.HrRecoveryS, + Sets = [], + Commands = [], + }); + return existing.Id; + } + + // 3. строка «только .fit» (uuid генерирует сервер, Р2-Б); + // workout_insert fit-полей не принимает — вставка + дозакрытие + // (как sync, errata m02) + var id = Guid.NewGuid(); + workouts.Insert(athleteId, new Workout + { + Id = id, AthleteId = athleteId, StartedAt = facts.StartedAt, + Status = "finished", SessionKind = "normal", Sets = [], Commands = [], + }); + workouts.Update(athleteId, new Workout + { + Id = id, AthleteId = athleteId, StartedAt = facts.StartedAt, + Status = "finished", SessionKind = "normal", + AvgHr = facts.AvgHr, + FactsJson = facts.FactsJson, + FitFileId = facts.FitFileId, + HrRecoveryS = facts.HrRecoveryS, + Sets = [], Commands = [], + }); + return id; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/FitFetcherTests.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/FitFetcherTests.cs new file mode 100644 index 0000000..e2bc44d --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/FitFetcherTests.cs @@ -0,0 +1,182 @@ +using System.Text; +using System.Text.Json; +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Fetch; +using Server.WatchBody.Import; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// +/// Интеграционные тесты m07 (specs 01–03) против docker watchbody-pg +/// с фейковым Garmin (FakeGarminHandler); .fit — энкодер SDK. +/// +[Collection("web")] +public class FitFetcherTests(TestApp app) +{ + private static readonly DateTime Start = new(2026, 10, 15, 18, 0, 0, DateTimeKind.Utc); + + private ConnectionString NewCs() => new() + { + Type = ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + + private int NewAthlete(string name) + { + var cs = NewCs(); + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = name, + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), + }); + return athleteId; + } + + private static (string Dir, string Base) NewEnv() + { + var dir = Path.Combine(Path.GetTempPath(), "wb-fetch-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(dir); + return (dir, "http://fake-garmin.test"); + } + + private static byte[] StrengthFit() => + FitBytes(Start, "123456-20261015T180000Z"); + + private static byte[] FitBytes(DateTime start, string _) // serial задаётся энкодером + { + var path = Path.Combine(Path.GetTempPath(), "wb-fit-" + Guid.NewGuid().ToString("N")[..8] + ".fit"); + FitParser.WriteTestFile(path, start, + records: [(0, 100), (60, 110)], laps: [(0, 0, 300)], sets: [(10, 80.0m)], serial: "777"); + var bytes = File.ReadAllBytes(path); + File.Delete(path); + return bytes; + } + + private FitFetcher NewFetcher(string dir, FakeGarminHandler handler, string athleteName) => + new(NewCs(), new GarminClient("owner@example.com", "secret", "http://fake-garmin.test", handler), + dir, athleteName); + + // --- spec 02: цикл --- + + [Fact] + public void Fetch_StrengthOnly_DownloadedAndImported() + { + var athlete = NewAthlete("f7-" + Guid.NewGuid().ToString("N")[..8]); + var (dir, _) = NewEnv(); + var handler = new FakeGarminHandler + { + Activities = + [ + new GarminActivity(1001, Start, "strength_training"), + new GarminActivity(1002, Start.AddHours(-2), "cardio"), // не силовая + ], + FitFiles = { [1001] = StrengthFit() }, + }; + + var (downloaded, skipped, failed) = NewFetcher(dir, handler, athleteName(athlete)).FetchNew(); + + Assert.Equal((1, 0, 0), (downloaded, skipped, failed)); + Assert.Equal(1, handler.DownloadCalls); // cardio не скачивается + var row = new WorkoutRepository(NewCs()).Select(new WorkoutFilter + { + AthleteId = athlete, + DateFrom = new DateOnly(2026, 10, 14), + DateTo = new DateOnly(2026, 10, 16), + }).Single(w => w.FitFileId != null); + Assert.NotNull(row.FactsJson); + Assert.Equal(105, row.AvgHr); + Assert.True(File.Exists(Path.Combine(dir, "1001.fit"))); // кэш + + // повторный fetch — всё в кэше, скачиваний нет, дублей нет + var second = NewFetcher(dir, handler, athleteName(athlete)).FetchNew(); + Assert.Equal((0, 1, 0), second); + Assert.Equal(1, handler.DownloadCalls); + } + + [Fact] + public void Fetch_DuplicateFitFileId_FileDeletedNoSecondRow() + { + var athlete = NewAthlete("f7-dup-" + Guid.NewGuid().ToString("N")[..8]); + var (dir, _) = NewEnv(); + var handler = new FakeGarminHandler + { + Activities = { new GarminActivity(2001, Start, "strength_training") }, + FitFiles = { [2001] = StrengthFit() }, + }; + Assert.Equal((1, 0, 0), NewFetcher(dir, handler, athleteName(athlete)).FetchNew()); + + // тот же .fit под другим activityId (переименование в Garmin) + handler.Activities.Add(new GarminActivity(2002, Start, "strength_training")); + handler.FitFiles[2002] = StrengthFit(); + + var (downloaded, skipped, failed) = NewFetcher(dir, handler, athleteName(athlete)).FetchNew(); + + // 2001 — кэш (рубеж 1), 2002 — дубль fit_file_id (рубеж 2) + Assert.Equal((0, 2, 0), (downloaded, skipped, failed)); + Assert.False(File.Exists(Path.Combine(dir, "2002.fit"))); // дубль не кэшируется + var rows = new WorkoutRepository(NewCs()).Select(new WorkoutFilter + { + AthleteId = athlete, + DateFrom = new DateOnly(2026, 10, 14), + DateTo = new DateOnly(2026, 10, 16), + }).Count(w => w.FitFileId != null); + Assert.Equal(1, rows); // второй строки нет + } + + [Fact] + public void Fetch_BrokenDownload_MarkedBad_Continues() + { + var athlete = NewAthlete("f7-bad-" + Guid.NewGuid().ToString("N")[..8]); + var (dir, _) = NewEnv(); + var handler = new FakeGarminHandler + { + Activities = + [ + new GarminActivity(3001, Start, "strength_training"), + new GarminActivity(3002, Start.AddHours(-1), "strength_training"), + ], + FitFiles = { [3001] = [1, 2, 3], [3002] = StrengthFit() }, + }; + + var (downloaded, skipped, failed) = NewFetcher(dir, handler, athleteName(athlete)).FetchNew(); + + Assert.Equal((1, 0, 1), (downloaded, skipped, failed)); + Assert.True(File.Exists(Path.Combine(dir, "3001.fit.bad"))); + Assert.True(File.Exists(Path.Combine(dir, "3002.fit"))); + } + + [Fact] + public void Fetch_UnknownAthlete_Throws() + { + var (dir, _) = NewEnv(); + var handler = new FakeGarminHandler + { + Activities = { new GarminActivity(4001, Start, "strength_training") }, + FitFiles = { [4001] = StrengthFit() }, + }; + var fetcher = NewFetcher(dir, handler, "нет-такого-атлета"); + + Assert.Throws(() => fetcher.FetchNew()); + Assert.Equal(0, handler.LoginCalls); // атлет проверяется до сети + } + + [Fact] + public void Fetch_AuthFailure_ThrowsWithoutPassword() + { + var athlete = NewAthlete("f7-auth-" + Guid.NewGuid().ToString("N")[..8]); + var (dir, _) = NewEnv(); + var handler = new FakeGarminHandler { FailLogin = true }; + var fetcher = NewFetcher(dir, handler, athleteName(athlete)); + + var exception = Assert.Throws(() => fetcher.FetchNew()); + Assert.DoesNotContain("secret", exception.Message); // 🛡4: пароль не в тексте + Assert.Equal(1, handler.LoginCalls); + } + + private string athleteName(int athleteId) => + new AthleteRepository(NewCs()).Select(new AthleteFilter()) + .First(a => a.Id == athleteId).Name; +} diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/ImportServiceTests.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/ImportServiceTests.cs new file mode 100644 index 0000000..884ad15 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/ImportServiceTests.cs @@ -0,0 +1,386 @@ +using System.Text; +using System.Text.Json; +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Ref.Repository; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Import; +using Server.WatchBody.Core.Programs; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// +/// Интеграционные тесты m06 (specs 01–04) против docker watchbody-pg. +/// .fit-фикстуры генерируются энкодером SDK (FitParser.WriteTestFile). +/// +[Collection("web")] +public class ImportServiceTests(TestApp app) +{ + private static readonly DateTime Start = new(2026, 10, 10, 18, 0, 0, DateTimeKind.Utc); + + private ConnectionString NewCs() => new() + { + Type = ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + + private (int AthleteId, string Folder) NewAthlete(string folder) + { + var cs = NewCs(); + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = folder, + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), + }); + return (athleteId, folder); + } + + /// Каталог импорта на время теста; атлет — подпапка. + private (string ImportDir, string ArchiveDir) NewDirs() + { + var root = Path.Combine(Path.GetTempPath(), "wb-import-" + Guid.NewGuid().ToString("N")[..8]); + var import = Path.Combine(root, "import"); + var archive = Path.Combine(root, "done"); + Directory.CreateDirectory(import); + return (import, archive); + } + + private ImportService NewService() => new(NewCs()); + + private WorkoutRepository WorkoutRepo() => new(NewCs()); + + /// Телефонная тренировка для сопоставления. + private Guid NewPhoneWorkout(int athleteId, DateTime startedAt) + { + var id = Guid.NewGuid(); + WorkoutRepo().Insert(athleteId, new Workout + { + Id = id, AthleteId = athleteId, StartedAt = startedAt, + Status = "finished", SessionKind = "normal", Sets = [], Commands = [], + }); + return id; + } + + private Workout GetWorkout(int athleteId, Guid id) => WorkoutRepo().Get(athleteId, id)!; + + // --- spec 01: разбор .fit --- + + [Fact] + public void FitParser_FullActivity_FactsAndRecovery() + { + var path = Path.Combine(Path.GetTempPath(), "wb-" + Guid.NewGuid().ToString("N")[..8] + ".fit"); + try + { + FitParser.WriteTestFile(path, Start, + records: [(0, 100), (60, 110), (120, 95), (600, 80)], // 600 — после lap, < 90 + laps: [(0, 0, 300)], + sets: [(12, 100.0m), (10, 80.0m)], + serial: "123456"); + + var facts = new FitParser().Parse(path); + + var json = JsonDocument.Parse(facts.FactsJson).RootElement; + Assert.Equal("workout-facts/1", json.GetProperty("schema").GetString()); + Assert.Equal(123456, json.GetProperty("fit_file_id").GetString()!.Split('-')[0].Select(char.IsDigit).Any() ? 123456 : 0); + Assert.Equal(4, json.GetProperty("records").GetArrayLength()); + Assert.Equal(2, json.GetProperty("sets").GetArrayLength()); + Assert.Equal(12, json.GetProperty("sets")[0].GetProperty("reps").GetInt32()); + Assert.Equal(100.0m, json.GetProperty("sets")[0].GetProperty("weight_kg").GetDecimal()); + Assert.Equal(600, json.GetProperty("records")[3].GetProperty("t_offset_s").GetInt32()); + Assert.Equal(1, json.GetProperty("laps").GetArrayLength()); // 1 lap + // avg_hr: (100+110+95+80)/4 = 96.25 → 96 + Assert.Equal(96, facts.AvgHr); + // recovery: конец lap = 300 c; первый < 90 на 600 c → 300 + Assert.Equal(300, facts.HrRecoveryS); + Assert.Equal(Start, facts.StartedAt); + Assert.False(string.IsNullOrEmpty(facts.Device)); // product id (Fenix6sAsia = 3513) + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void FitParser_Garbage_Throws() + { + var path = Path.Combine(Path.GetTempPath(), "wb-" + Guid.NewGuid().ToString("N")[..8] + ".fit"); + try + { + File.WriteAllBytes(path, [0x01, 0x02, 0x03, 0x04]); + Assert.ThrowsAny(() => new FitParser().Parse(path)); + } + finally + { + File.Delete(path); + } + } + + // --- spec 02: сопоставление и дедуп --- + + [Fact] + public void Import_MatchesPhoneWorkout_InWindow() + { + var (athlete, folder) = NewAthlete("imp-match-" + Guid.NewGuid().ToString("N")[..6]); + var phoneId = NewPhoneWorkout(athlete, Start.AddMinutes(-10)); + var (importDir, archiveDir) = NewDirs(); + var folderPath = Path.Combine(importDir, folder); + Directory.CreateDirectory(folderPath); + var file = Path.Combine(folderPath, "a.fit"); + FitParser.WriteTestFile(file, Start, + [(0, 100), (60, 110)], [], [], serial: "111"); + + var (processed, failed) = NewService().Run(importDir, archiveDir); + + Assert.Equal((1, 0), (processed, failed)); + var row = GetWorkout(athlete, phoneId); + Assert.Equal("111-", row.FitFileId![..4]); + Assert.NotNull(row.FactsJson); + Assert.Equal(105, row.AvgHr); // (100+110)/2 + Assert.True(File.Exists(Path.Combine(archiveDir, folder, "a.fit"))); + } + + [Fact] + public void Import_OutsideWindow_CreatesFitOnlyRow() + { + var (athlete, folder) = NewAthlete("imp-lone-" + Guid.NewGuid().ToString("N")[..6]); + NewPhoneWorkout(athlete, Start.AddHours(3)); // вне ± 15 мин + var (importDir, archiveDir) = NewDirs(); + var folderPath = Path.Combine(importDir, folder); + Directory.CreateDirectory(folderPath); + FitParser.WriteTestFile(Path.Combine(folderPath, "b.fit"), Start, + [(0, 100)], [], [], serial: "222"); + + var (processed, failed) = NewService().Run(importDir, archiveDir); + + Assert.Equal((1, 0), (processed, failed)); + var rows = WorkoutRepo().Select(new WorkoutFilter + { + AthleteId = athlete, + DateFrom = DateOnly.FromDateTime(Start).AddDays(-1), + DateTo = DateOnly.FromDateTime(Start).AddDays(1), + }).ToList(); + var fitOnly = rows.Single(w => w.FitFileId != null); + Assert.Equal(Start, fitOnly.StartedAt); + Assert.Equal("finished", fitOnly.Status); + Assert.Equal("normal", fitOnly.SessionKind); + Assert.Equal(100, fitOnly.AvgHr); + } + + [Fact] + public void Import_SameFileTwice_Dedup() + { + var (athlete, folder) = NewAthlete("imp-dedup-" + Guid.NewGuid().ToString("N")[..6]); + var (importDir, archiveDir) = NewDirs(); + var folderPath = Path.Combine(importDir, folder); + Directory.CreateDirectory(folderPath); + FitParser.WriteTestFile(Path.Combine(folderPath, "c.fit"), Start, + [(0, 100)], [], [], serial: "333"); + + Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); + // повтор: тот же файл возвращаем в папку импорта + var repeat = Path.Combine(folderPath, "c.fit"); + File.Copy(Path.Combine(archiveDir, folder, "c.fit"), repeat); + Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); + + var rows = WorkoutRepo().Select(new WorkoutFilter + { + AthleteId = athlete, + DateFrom = DateOnly.FromDateTime(Start).AddDays(-1), + DateTo = DateOnly.FromDateTime(Start).AddDays(1), + }).ToList(); + Assert.Single(rows.Where(w => w.FitFileId != null)); + } + + // --- spec 03: пакет программы --- + + [Fact] + public void Import_ProgramPackage_FirstAndRepeat() + { + var (athlete, folder) = NewAthlete("imp-pkg-" + Guid.NewGuid().ToString("N")[..6]); + var (importDir, archiveDir) = NewDirs(); + var folderPath = Path.Combine(importDir, folder); + Directory.CreateDirectory(folderPath); + // external_id уникален глобально (З-35) — id пакета свой на прогон + var packageId = "pkg-test-" + Guid.NewGuid().ToString("N")[..8]; + var package = $$""" + {"program_id":"{{packageId}}","version":1,"status":"draft","author":"user", + "rationale":"тестовый пакет", + "weeks":[{"week":1,"sessions":[{"day":"monday","focus":"push", + "exercises":[{"exercise_id":"жим_лёжа_тест_{{packageId}}","sets":[ + {"weight_kg":60,"reps":10}]}]}]}]} + """; + File.WriteAllText(Path.Combine(folderPath, "program-import.json"), package, Encoding.UTF8); + + var cs = NewCs(); + var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); + + Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); + + var program = new ProgramRepository(cs).Select(athlete).Single(p => p.ExternalId == packageId); + var versions1 = store.ListVersions(athlete); + Assert.Single(versions1); + Assert.Equal("draft", versions1[0].Status); + Assert.Equal("user", versions1[0].Author); + Assert.Equal("тестовый пакет", versions1[0].Rationale); + // отсутствующее упражнение создано (фокус push) + var exercise = new ExerciseRepository(cs).Select(new ExerciseFilter + { AthleteId = athlete, Name = $"жим_лёжа_тест_{packageId}" }).Single(); + Assert.Equal(1, exercise.GroupId); // push + + // повтор пакета → новая версия + Directory.CreateDirectory(folderPath); + File.WriteAllText(Path.Combine(folderPath, "program-import.json"), package, Encoding.UTF8); + Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); + + var versions2 = store.ListVersions(athlete); + Assert.Equal(2, versions2.Count); + Assert.Equal(versions1[0].Version + 1, versions2[1].Version); + } + + // --- TCX/GPX: упрощённый факт (критерий 4) --- + + [Fact] + public void Import_Tcx_SimplifiedFacts() + { + var (athlete, folder) = NewAthlete("imp-tcx-" + Guid.NewGuid().ToString("N")[..6]); + var (importDir, archiveDir) = NewDirs(); + var folderPath = Path.Combine(importDir, folder); + Directory.CreateDirectory(folderPath); + var tcx = """ + + + + 2026-10-11T08:00:00Z + 1200 + + 120 + 130 + + + + """; + File.WriteAllText(Path.Combine(folderPath, "ride.tcx"), tcx); + + Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); + + var row = WorkoutRepo().Select(new WorkoutFilter + { + AthleteId = athlete, + DateFrom = new DateOnly(2026, 10, 10), + DateTo = new DateOnly(2026, 10, 12), + }).Single(w => w.FitFileId != null); + Assert.Equal(Start.Date != default ? new DateTime(2026, 10, 11, 8, 0, 0, DateTimeKind.Utc) : default, + row.StartedAt); + Assert.Equal(125, row.AvgHr); // (120+130)/2 + var facts = JsonDocument.Parse(row.FactsJson!).RootElement; + Assert.Equal("biking", facts.GetProperty("session").GetProperty("type").GetString()); + Assert.Equal(2, facts.GetProperty("records").GetArrayLength()); + Assert.Equal(0, facts.GetProperty("sets").GetArrayLength()); + } + + [Fact] + public void Import_Gpx_SimplifiedFacts() + { + var (athlete, folder) = NewAthlete("imp-gpx-" + Guid.NewGuid().ToString("N")[..6]); + var (importDir, archiveDir) = NewDirs(); + var folderPath = Path.Combine(importDir, folder); + Directory.CreateDirectory(folderPath); + var gpx = """ + + + прогулка + + + 105 + + + 115 + + + + + """; + File.WriteAllText(Path.Combine(folderPath, "walk.gpx"), gpx); + + Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); + + var row = WorkoutRepo().Select(new WorkoutFilter + { + AthleteId = athlete, + DateFrom = new DateOnly(2026, 10, 11), + DateTo = new DateOnly(2026, 10, 13), + }).Single(w => w.FitFileId != null); + Assert.Equal(110, row.AvgHr); // (105+115)/2 + } + + // --- spec 04: сбои и изоляция --- + + [Fact] + public void Import_BrokenFile_StaysAndCode2() + { + var (athlete, folder) = NewAthlete("imp-broken-" + Guid.NewGuid().ToString("N")[..6]); + var (importDir, archiveDir) = NewDirs(); + var folderPath = Path.Combine(importDir, folder); + Directory.CreateDirectory(folderPath); + File.WriteAllBytes(Path.Combine(folderPath, "bad.fit"), [1, 2, 3, 4, 5]); + FitParser.WriteTestFile(Path.Combine(folderPath, "good.fit"), Start, + [(0, 100)], [], [], serial: "444"); + + var (processed, failed) = NewService().Run(importDir, archiveDir); + + Assert.Equal(1, processed); + Assert.Equal(1, failed); + Assert.True(File.Exists(Path.Combine(folderPath, "bad.fit"))); // остался + Assert.True(File.Exists(Path.Combine(archiveDir, folder, "good.fit"))); + } + + [Fact] + public void Import_UnknownAthleteFolder_Fails() + { + var (importDir, archiveDir) = NewDirs(); + var ghost = Path.Combine(importDir, "нет-такого-атлета"); + Directory.CreateDirectory(ghost); + File.WriteAllText(Path.Combine(ghost, "program-import.json"), "{}"); + + var (processed, failed) = NewService().Run(importDir, archiveDir); + + Assert.Equal((0, 1), (processed, failed)); + Assert.True(File.Exists(Path.Combine(ghost, "program-import.json"))); + } + + [Fact] + public void Import_RootFile_Skipped() + { + var (importDir, archiveDir) = NewDirs(); + File.WriteAllText(Path.Combine(importDir, "random.txt"), "x"); + + Assert.Equal((0, 0), NewService().Run(importDir, archiveDir)); + Assert.True(File.Exists(Path.Combine(importDir, "random.txt"))); + } + + [Fact] + public void Import_Isolation_FitDoesNotMatchForeignWorkout() + { + var (_, otherFolder) = NewAthlete("imp-other-" + Guid.NewGuid().ToString("N")[..6]); + var (athlete, folder) = NewAthlete("imp-iso-" + Guid.NewGuid().ToString("N")[..6]); + // чужая тренировка точно в окне + NewPhoneWorkout(new AthleteRepository(NewCs()).Select(new AthleteFilter()).First( + a => a.Name == otherFolder).Id, Start); + var (importDir, archiveDir) = NewDirs(); + var folderPath = Path.Combine(importDir, folder); + Directory.CreateDirectory(folderPath); + FitParser.WriteTestFile(Path.Combine(folderPath, "d.fit"), Start, + [(0, 100)], [], [], serial: "555"); + + Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); + + // факт попал в строку своего атлета (только .fit), чужая не тронута + var other = new AthleteRepository(NewCs()).Select(new AthleteFilter()).First(a => a.Name == otherFolder); + var foreign = WorkoutRepo().Select(new WorkoutFilter { AthleteId = other.Id }).Single(); + Assert.Null(foreign.FitFileId); + var mine = WorkoutRepo().Select(new WorkoutFilter { AthleteId = athlete }).Single(w => w.FitFileId != null); + Assert.NotEqual(foreign.Id, mine.Id); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs b/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs index 51f8206..a89256d 100644 --- a/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs +++ b/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs @@ -26,6 +26,28 @@ public class ServerConfig public string MigrationsDir { get; set; } = Path.Combine(AppContext.BaseDirectory, "migrations"); + /// Папка импорта (--mode import, m06; подпапки — атлеты). + public string ImportDir { get; set; } = + Path.Combine(AppContext.BaseDirectory, "data", "import"); + + /// Папка обработанных файлов импорта. + public string ImportArchiveDir { get; set; } = + Path.Combine(AppContext.BaseDirectory, "data", "import_done"); + + /// Учётка Garmin Connect владельца (--mode fetch, m07). + public string GarminLogin { get; set; } = string.Empty; + public string GarminPassword { get; set; } = string.Empty; + + /// Атлет, на которого пишутся факты fetch (изоляция 🛡1). + public string GarminAthleteName { get; set; } = string.Empty; + + /// Папка скачанных .fit (кэш по activityId). + public string GarminDownloadDir { get; set; } = + Path.Combine(AppContext.BaseDirectory, "data", "fit"); + + /// Точки входа Garmin (замена в тестах/прокси). + public string GarminBaseUrl { get; set; } = "https://connect.garmin.com"; + /// Пороги красных флагов (Safety.*, Р3-А; MetricsEngine). public MetricsConfig Safety { get; set; } = new(); @@ -61,6 +83,20 @@ public class ServerConfig result.DbConnectionString = cs.GetString() ?? string.Empty; if (db.TryGetProperty("MigrationsDir", out var md) && md.ValueKind == JsonValueKind.String) result.MigrationsDir = md.GetString() ?? result.MigrationsDir; + if (db.TryGetProperty("ImportDir", out var idir) && idir.ValueKind == JsonValueKind.String) + result.ImportDir = idir.GetString() ?? result.ImportDir; + if (db.TryGetProperty("ImportArchiveDir", out var adir) && adir.ValueKind == JsonValueKind.String) + result.ImportArchiveDir = adir.GetString() ?? result.ImportArchiveDir; + if (db.TryGetProperty("Garmin", out var garmin) && garmin.ValueKind == JsonValueKind.Object) + { + if (garmin.TryGetProperty("Login", out var gl)) result.GarminLogin = gl.GetString() ?? ""; + if (garmin.TryGetProperty("Password", out var gp)) result.GarminPassword = gp.GetString() ?? ""; + if (garmin.TryGetProperty("AthleteName", out var ga)) result.GarminAthleteName = ga.GetString() ?? ""; + if (garmin.TryGetProperty("DownloadDir", out var gd) && gd.ValueKind == JsonValueKind.String) + result.GarminDownloadDir = gd.GetString() ?? result.GarminDownloadDir; + if (garmin.TryGetProperty("BaseUrl", out var gb) && gb.ValueKind == JsonValueKind.String) + result.GarminBaseUrl = gb.GetString() ?? result.GarminBaseUrl; + } } result.Safety = MetricsConfig.Load(root); result.Progression = ProgressionConfig.Load(root); diff --git a/3-app/2-cli/Server.WatchBody.Web/Program.cs b/3-app/2-cli/Server.WatchBody.Web/Program.cs index 1f5af53..32b64ad 100644 --- a/3-app/2-cli/Server.WatchBody.Web/Program.cs +++ b/3-app/2-cli/Server.WatchBody.Web/Program.cs @@ -1,6 +1,8 @@ using Server.WatchBody.Web; using Server.WatchBody.Web.Config; using Server.WatchBody.Web.Db; +using Server.WatchBody.Fetch; +using Server.WatchBody.Import; using Server.WatchBody.Web.Services; // Точка входа сервера (m02; режимы — requirements 2.3, З-36): @@ -57,9 +59,68 @@ if (mode == "migrate") } } +if (mode == "import") +{ + try + { + var connectionStringImport = new PostgresConnectionString(config.DbConnectionString); + Directory.CreateDirectory(config.ImportDir); + Directory.CreateDirectory(config.ImportArchiveDir); + var (processed, failed) = new ImportService(connectionStringImport).Run( + config.ImportDir, config.ImportArchiveDir); + Console.WriteLine($"import: обработано {processed}, сбоев {failed}"); + return failed == 0 ? 0 : 2; + } + catch (Exception e) + { + Console.Error.WriteLine($"import: авария конфигурации — {e.Message}"); + return 1; + } +} + +if (mode == "fetch") +{ + if (config.GarminLogin.Length == 0 || config.GarminPassword.Length == 0 || + config.GarminAthleteName.Length == 0) + { + Console.Error.WriteLine( + "fetch: требуются Garmin.Login, Garmin.Password и Garmin.AthleteName"); + return 1; + } + try + { + Directory.CreateDirectory(config.GarminDownloadDir); + var client = new GarminClient( + config.GarminLogin, config.GarminPassword, config.GarminBaseUrl); + var (downloaded, skipped, failed) = new FitFetcher( + new PostgresConnectionString(config.DbConnectionString), + client, config.GarminDownloadDir, config.GarminAthleteName + ).FetchNew(); + Console.WriteLine($"fetch: скачано {downloaded}, пропущено {skipped}, сбоев {failed}"); + return failed == 0 ? 0 : 2; + } + catch (GarminAuthException e) + { + Console.Error.WriteLine( + $"fetch: логин Garmin не удался — {e.Message}; " + + "fallback: папка импорта (--mode import)"); + return 2; + } + catch (FetchException e) + { + Console.Error.WriteLine($"fetch: {e.Message}"); + return 1; + } + catch (Exception e) + { + Console.Error.WriteLine($"fetch: авария — {e.Message}"); + return 1; + } +} + if (mode != "serve") { - Console.Error.WriteLine($"неизвестный режим '{mode}' (serve|migrate)"); + Console.Error.WriteLine($"неизвестный режим '{mode}' (serve|import|fetch|migrate)"); return 1; } diff --git a/3-app/2-cli/Server.WatchBody.Web/Server.WatchBody.Web.csproj b/3-app/2-cli/Server.WatchBody.Web/Server.WatchBody.Web.csproj index df22285..d41da15 100644 --- a/3-app/2-cli/Server.WatchBody.Web/Server.WatchBody.Web.csproj +++ b/3-app/2-cli/Server.WatchBody.Web/Server.WatchBody.Web.csproj @@ -2,6 +2,8 @@ + + diff --git a/3-app/2-cli/Server.WatchBody.slnx b/3-app/2-cli/Server.WatchBody.slnx index 1abe04d..716807c 100644 --- a/3-app/2-cli/Server.WatchBody.slnx +++ b/3-app/2-cli/Server.WatchBody.slnx @@ -1,6 +1,8 @@ + +