Этап 7, итерация 2 завершена: m06-fit-import + m07-fit-fetch

m06 (Server.WatchBody.Import + --mode import): FitParser на официальном
Garmin FIT SDK 21.218.0 (Dynastream.Fit; события Decode, типы по Mesg.Num)
→ канонический факт workout-facts/1 (+avg_hr, hr_recovery_s, fit_file_id);
TcxGpxParser (упрощённый факт); WorkoutImporter — дедуп fit_file_id,
сопоставление телефонной тренировке ±15 мин (Ф-8.4), иначе строка
«только .fit»; ProgramPackageImporter — пакет program-import.json по
external_id (повтор → новая версия max+1, отсутствующие упражнения
создаются); ImportService — подпапки-атлеты, архив, коды 0/1/2.
Errata З-35: program_select/get rev.2 (+external_id, DROP старой
сигнатуры), маппинг ProgramRepository.

m07 (Server.WatchBody.Fetch + --mode fetch): GarminClient — SSO по
контракту garminconnect (embed→csrf→signin→ticket→OAuth1→Bearer),
список активностей, скачивание .fit; FitFetcher — атлет по
Garmin.AthleteName, фильтр strength_training, дедуп 2 рубежей
(кэш activityId + fit_file_id в БД); коды 0/1/2, auth-fail →
подсказка про m06-fallback. Против живого Garmin — ручная валидация
прототипа; тесты — FakeGarminHandler + .fit-энкодер SDK.

Тесты: Web 98/98 (m06 — 12, m07 — 5), Core 56/56, psql 123/123.
Конфиг: Import.Dir/ArchiveDir, Garmin.* (в т.ч. новый AthleteName —
tech-req 4.2). Эксплуатационные файлы в коммит не включены.
This commit is contained in:
virus-home 2026-10-18 23:58:53 +05:00
parent f232d592a4
commit f41bf9a0d2
36 changed files with 2416 additions and 11 deletions

View File

@ -40,8 +40,8 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн
| m03-program-store | `Server.WatchBody.Core`: версионный JSON программ, `active` — ровно одна | 1 | ✅ (2026-10-18: ProgramStore, 7 методов, тесты 19 PASS) | | 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) | | 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) | | 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 | ⬜ | | 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`: клиент Garmin Connect, дедупликация `fit_file_id` | 2 | ⬜ | | 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 | ⬜ | | 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 | ⬜ | | m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ⬜ |
| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ | | m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ |

View File

@ -7,7 +7,8 @@
| `p_athlete_id` | int | да | атлет (🛡) | | `p_athlete_id` | int | да | атлет (🛡) |
**Возвращает** `TABLE` — колонки возврата сущности `program`: **Возвращает** `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 — денормализация `active_version_id uuid`, `active_version int` (NULL —
активной версии нет). активной версии нет).

View File

@ -146,6 +146,9 @@
| `Admin.Login / Admin.PasswordHash` | string | непустые; хэш — PBKDF2 (100k итераций), префикс `pbkdf2$` | | `Admin.Login / Admin.PasswordHash` | string | непустые; хэш — PBKDF2 (100k итераций), префикс `pbkdf2$` |
| `Db.ConnectionString` | string | тест подключения при старте — фатально при неудаче | | `Db.ConnectionString` | string | тест подключения при старте — фатально при неудаче |
| `Garmin.Login/Password` | string | обязательны для `--mode fetch` (иначе код возврата 1) | | `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 | создание при старте; недоступность — фатально | | `Garmin.DownloadDir / Import.Dir / Import.ArchiveDir` | path | создание при старте; недоступность — фатально |
| `Progression.BaseStepKg` | double | 0,5–10 | | `Progression.BaseStepKg` | double | 0,5–10 |
| `Progression.IsoStepKg` | double | 0,25–5 (умолчание 1,0 — коридор метрик «+0,5–1 кг», решение владельца О-3-А, З-27) | | `Progression.IsoStepKg` | double | 0,25–5 (умолчание 1,0 — коридор метрик «+0,5–1 кг», решение владельца О-3-А, З-27) |

View File

@ -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/)

View File

@ -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, битый файл (усечённый байтовый
массив).

View File

@ -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`.

View File

@ -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 вручную).

View File

@ -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);
- изоляция: файлы папки обрабатываются только на атлета папки.

View File

@ -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` — зелёные;
сборка решения без ошибок.

View File

@ -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]`, не элемент.

View File

@ -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/)

View File

@ -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<GarminActivity> 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).

View File

@ -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` атлета-владельца учётки;
- повторный запуск не качает дубли и не создаёт дублей в БД.

View File

@ -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).

View File

@ -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` — зелёные;
сборка решения без ошибок.

View File

@ -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
умолчания пустые (🛡 секреты только в конфиге владельца).

View File

@ -3,6 +3,9 @@
-- Параметры: p_athlete_id; p_program_id. -- Параметры: p_athlete_id; p_program_id.
-- Возвращает: TABLE — строка program + денормализация; чужая — пустая таблица. -- Возвращает: TABLE — строка program + денормализация; чужая — пустая таблица.
-- прежняя сигнатура (без external_id) снимается — row type меняется
DROP FUNCTION IF EXISTS src.program_get(int, int);
CREATE OR REPLACE FUNCTION src.program_get( CREATE OR REPLACE FUNCTION src.program_get(
p_athlete_id int, p_athlete_id int,
p_program_id int p_program_id int

View File

@ -1,8 +1,11 @@
-- src.program_select: программы атлета + активная версия (денормализация, d-db-3). -- src.program_select: программы атлета + активная версия (денормализация, d-db-3).
-- На основе: новая. -- На основе: новая (rev. 2 — external_id, доведение З-35, errata m06).
-- Параметры: p_athlete_id. -- Параметры: p_athlete_id.
-- Возвращает: TABLE — строки program + active_version_id/active_version (NULL — активной нет). -- Возвращает: 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( CREATE OR REPLACE FUNCTION src.program_select(
p_athlete_id int p_athlete_id int
) )
@ -10,11 +13,12 @@ RETURNS TABLE (
id int, id int,
athlete_id int, athlete_id int,
title text, title text,
external_id text,
created_at timestamptz, created_at timestamptz,
active_version_id uuid, active_version_id uuid,
active_version int active_version int
) LANGUAGE sql AS $$ ) 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 v.id AS active_version_id, v.version AS active_version
FROM src.program p FROM src.program p
LEFT JOIN src.program_version v LEFT JOIN src.program_version v

View File

@ -205,8 +205,8 @@ public class ProgramRepositoryTests
.AddParameter("p_athlete_id", 1) .AddParameter("p_athlete_id", 1)
.BuildParams(); .BuildParams();
var table = TestInfra.Table(TestInfra.ProgramCols, var table = TestInfra.Table(TestInfra.ProgramCols,
[1, 1, "TEST-Программа", new DateTime(2026, 10, 17), DBNull.Value, DBNull.Value], [1, 1, "TEST-Программа", DBNull.Value, new DateTime(2026, 10, 17), DBNull.Value, DBNull.Value],
[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 cs = new ConnectionStringTest(expArgs, table);
var rows = new ProgramRepository(cs).Select(1).ToList(); var rows = new ProgramRepository(cs).Select(1).ToList();
@ -227,7 +227,7 @@ public class ProgramRepositoryTests
.AddParameter("p_program_id", 2) .AddParameter("p_program_id", 2)
.BuildParams(); .BuildParams();
var table = TestInfra.Table(TestInfra.ProgramCols, 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 cs = new ConnectionStringTest(expArgs, table);
var p = new ProgramRepository(cs).Get(1, 2); var p = new ProgramRepository(cs).Get(1, 2);

View File

@ -35,8 +35,8 @@ internal static class TestInfra
public static readonly (string, Type)[] ProgramCols = public static readonly (string, Type)[] ProgramCols =
[("id", typeof(int)), ("athlete_id", typeof(int)), ("title", typeof(string)), [("id", typeof(int)), ("athlete_id", typeof(int)), ("title", typeof(string)),
("created_at", typeof(DateTime)), ("active_version_id", typeof(Guid)), ("external_id", typeof(string)), ("created_at", typeof(DateTime)),
("active_version", typeof(int))]; ("active_version_id", typeof(Guid)), ("active_version", typeof(int))];
public static readonly (string, Type)[] VersionCols = public static readonly (string, Type)[] VersionCols =
[("id", typeof(Guid)), ("program_id", typeof(int)), ("version", typeof(int)), [("id", typeof(Guid)), ("program_id", typeof(int)), ("version", typeof(int)),

View File

@ -47,6 +47,8 @@ public class ProgramRepository : IProgramRepository
Id = record.Convert<int>(record.Src["id"]), Id = record.Convert<int>(record.Src["id"]),
AthleteId = record.Convert<int>(record.Src["athlete_id"]), AthleteId = record.Convert<int>(record.Src["athlete_id"]),
Title = record.Src["title"] as string ?? string.Empty, Title = record.Src["title"] as string ?? string.Empty,
ExternalId = record.IsDbNull("external_id")
? null : record.Src["external_id"] as string,
CreatedAt = record.Convert<DateTime>(record.Src["created_at"]), CreatedAt = record.Convert<DateTime>(record.Src["created_at"]),
ActiveVersionId = record.IsDbNull("active_version_id") ActiveVersionId = record.IsDbNull("active_version_id")
? null : record.Convert<Guid>(record.Src["active_version_id"]), ? null : record.Convert<Guid>(record.Src["active_version_id"]),
@ -74,6 +76,8 @@ public class ProgramRepository : IProgramRepository
Id = record.Convert<int>(record.Src["id"]), Id = record.Convert<int>(record.Src["id"]),
AthleteId = record.Convert<int>(record.Src["athlete_id"]), AthleteId = record.Convert<int>(record.Src["athlete_id"]),
Title = record.Src["title"] as string ?? string.Empty, Title = record.Src["title"] as string ?? string.Empty,
ExternalId = record.IsDbNull("external_id")
? null : record.Src["external_id"] as string,
CreatedAt = record.Convert<DateTime>(record.Src["created_at"]), CreatedAt = record.Convert<DateTime>(record.Src["created_at"]),
ActiveVersionId = record.IsDbNull("active_version_id") ActiveVersionId = record.IsDbNull("active_version_id")
? null : record.Convert<Guid>(record.Src["active_version_id"]), ? null : record.Convert<Guid>(record.Src["active_version_id"]),

View File

@ -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;
/// <summary>Ошибка конфигурации/атлета fetch (CLI → код 1, spec 02 §3).</summary>
public class FetchException(string message) : Exception(message);
/// <summary>
/// Цикл забора (spec 02 m07): логин → список → фильтр strength_training
/// → скачивание в downloadDir → разбор и запись конвейером m06 →
/// дедуп двух уровней (файл-кэш по activityId, fit_file_id в БД).
/// 🛡 Записи в БД — только через WorkoutImporter; сбой файла пакет
/// не останавливает.
/// </summary>
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);
}
}

View File

@ -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;
/// <summary>Неуспех SSO-логина (шаги 1–5; MFA/блокировка). Текст без
/// пароля (🛡4).</summary>
public class GarminAuthException(string message) : Exception(message);
/// <summary>Одна активность из списка Garmin Connect (spec 01 §1).</summary>
public sealed record GarminActivity(long ActivityId, DateTime StartTimeLocal, string ActivityType);
/// <summary>
/// Неофициальный клиент Garmin Connect — по контракту семьи библиотек
/// `garminconnect` (tech-req разд. 5): SSO-логин → OAuth1 → Bearer;
/// список активностей и скачивание .fit. HttpMessageHandler инъецируется
/// (тесты — FakeGarminHandler). 🛡 пароль/токены не логируются.
/// </summary>
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;
}
/// <summary>SSO-последовательность (spec 01 §2, шаги 1–5).</summary>
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<string, string>
{
["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 отсутствует");
}
/// <summary>Список активностей (шаг 6).</summary>
public List<GarminActivity> 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();
}
/// <summary>Байты .fit активности (шаг 7).</summary>
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<string, string> 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()));
}
/// <summary>OAuth1 Authorization-заголовок (HMAC-SHA1) для обменного
/// шага (по контракту garminconnect).</summary>
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<string, string>
{
["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)}\"";
}
}
/// <summary>Фейковый Garmin для тестов (spec 01 §3): маршруты spec 01 §2
/// на подменяемом base; список/байты задаёт тест.</summary>
public class FakeGarminHandler : HttpMessageHandler
{
public CookieContainer? Cookies { get; set; }
public bool FailLogin { get; set; }
public List<GarminActivity> Activities { get; set; } = [];
public Dictionary<long, byte[]> FitFiles { get; set; } = [];
public int LoginCalls, ListCalls, DownloadCalls;
protected override Task<HttpResponseMessage> 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(
"<input name=\"_csrf\" value=\"csrf-token-1\" type=\"hidden\"/>");
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<string, object?>
{
["activityId"] = a.ActivityId,
["startTimeLocal"] = a.StartTimeLocal.ToString(
"yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["activityType"] = new Dictionary<string, object?>
{ ["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);
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Server.WatchBody.Fetch</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Server.WatchBody.Core\Server.WatchBody.Core.csproj" />
<ProjectReference Include="..\Server.WatchBody.Import\Server.WatchBody.Import.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,257 @@
using Dynastream.Fit;
using FitDateTime = Dynastream.Fit.DateTime;
namespace Server.WatchBody.Import;
/// <summary>Разобранные факты активности (.fit / TCX / GPX) — единый
/// вход WorkoutImporter (spec 01/02 m06).</summary>
public sealed record ActivityFacts(
string FactsJson,
System.DateTime StartedAt,
int? AvgHr,
int? HrRecoveryS,
string FitFileId,
string Device);
/// <summary>Файл не активность или повреждён (spec 01 §1).</summary>
public class FitParseException(string message) : Exception(message);
/// <summary>
/// Потоковый разбор .fit — официальный FIT SDK (tech-req разд. 5):
/// события Decode.MesgEvent, файл читается один раз. Результат —
/// канонический факт workout-facts/1 (tech-req 2.3).
/// </summary>
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);
}
/// <summary>Генерация .fit для тест-фикстур (spec 01 §4) —
/// энкодер SDK; порядок FileId → DeviceInfo → Record* → Lap* →
/// Set* → Session.</summary>
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<LapMesg> _laps = [];
private readonly List<(System.DateTime time, int hr)> _records = [];
private readonly List<SetMesg> _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<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["schema"] = "workout-facts/1",
["session"] = new Dictionary<string, object?>
{
["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);
}
/// <summary>Конец последнего Lap → первый Record строго после
/// него с hr ниже порога (Ф-13.4, spec 01 §3); нет записей или
/// не опустился — null.</summary>
private static int? HrRecovery(
List<System.DateTime> 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'}";
}
}

View File

@ -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;
/// <summary>
/// Обход Import.Dir (spec 04 m06): подпапки = имена атлетов; .fit/.tcx/
/// .gpx → WorkoutImporter, program-import.json → ProgramPackageImporter;
/// обработанные → ArchiveDir, сбойные остаются (🛡4 — пакет
/// продолжается).
/// </summary>
public class ImportService(IConnectionString connectionString, ILogger<ImportService>? logger = null)
{
private static readonly string[] ActivityExtensions = [".fit", ".tcx", ".gpx"];
private const string PackageName = "program-import.json";
/// <summary>(обработано, сбойных). Каталог импорта не существует → (0, 0).</summary>
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);
/// <summary>Ленивые зависимости одного прогона (соединение общее).</summary>
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)));
}
}

View File

@ -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;
/// <summary>Нарушение формата пакета program-import.json (spec 03 §2).</summary>
public class ImportPackageException(string message) : Exception(message);
/// <summary>
/// Импорт пакета программы (spec 03 m06, формат program-import-format.md):
/// program по external_id, версия через ProgramStore (повтор пакета —
/// новая версия max+1), отсутствующие упражнения создаются. 🛡
/// Существующие версии не переписываются; статус draft.
/// </summary>
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<string, int> GroupByFocus() =>
groups.Select().ToDictionary(g => g.Code, g => g.Id, StringComparer.Ordinal);
private void EnsureExercises(int athleteId, JsonElement weeks, Dictionary<string, int> 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<JsonElement>()))
{
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<string, int> groupIds)
{
foreach (var (code, id) in groupIds)
if (focus.Contains(code, StringComparison.OrdinalIgnoreCase))
return id;
return groupIds.GetValueOrDefault("core", 4);
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Server.WatchBody.Import</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Garmin.FIT.Sdk" Version="21.218.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Server.WatchBody.Core\Server.WatchBody.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,110 @@
using System.Xml.Linq;
namespace Server.WatchBody.Import;
/// <summary>
/// Упрощённый разбор .tcx/.gpx (производные форматы — документ
/// форматов): тип/старт/длительность + пульс-записи; подходов в этих
/// форматах нет. fit_file_id — имя файла (дедупликация).
/// </summary>
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<string, object?>
{
["schema"] = "workout-facts/1",
["session"] = new Dictionary<string, object?>
{
["type"] = type,
["start"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"),
["duration_s"] = durationS,
},
["laps"] = Array.Empty<object>(),
["records"] = records.Select(r => new Dictionary<string, object?>
{
["t_offset_s"] = (int)(r.time - start).TotalSeconds,
["hr"] = r.hr,
}).ToList(),
["sets"] = Array.Empty<object>(),
["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;
}

View File

@ -0,0 +1,82 @@
using Server.WatchBody.Core.Dal.Src.Domain;
using Server.WatchBody.Core.Dal.Src.Repository;
namespace Server.WatchBody.Import;
/// <summary>
/// Запись фактов в src.workout (spec 02 m06): дедуп по fit_file_id,
/// сопоставление телефонной тренировке ± 15 минут (Ф-8.4), иначе
/// строка «только .fit». 🛡 Телефонные поля не меняются; записи только
/// в facts_json/avg_hr/hr_recovery_s/fit_file_id.
/// </summary>
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;
}
}

View File

@ -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;
/// <summary>
/// Интеграционные тесты m07 (specs 01–03) против docker watchbody-pg
/// с фейковым Garmin (FakeGarminHandler); .fit — энкодер SDK.
/// </summary>
[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<FetchException>(() => 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<GarminAuthException>(() => 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;
}

View File

@ -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;
/// <summary>
/// Интеграционные тесты m06 (specs 01–04) против docker watchbody-pg.
/// .fit-фикстуры генерируются энкодером SDK (FitParser.WriteTestFile).
/// </summary>
[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);
}
/// <summary>Каталог импорта на время теста; атлет — подпапка.</summary>
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());
/// <summary>Телефонная тренировка для сопоставления.</summary>
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<Exception>(() => 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 = """
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities><Activity Sport="Biking">
<Id>2026-10-11T08:00:00Z</Id>
<Laps><Lap StartTime="2026-10-11T08:00:00Z"><TotalTimeSeconds>1200</TotalTimeSeconds></Lap></Laps>
<Tracks><Track>
<Trackpoint><Time>2026-10-11T08:00:00Z</Time><HeartRateBpm><Value>120</Value></HeartRateBpm></Trackpoint>
<Trackpoint><Time>2026-10-11T08:05:00Z</Time><HeartRateBpm><Value>130</Value></HeartRateBpm></Trackpoint>
</Track></Tracks>
</Activity></Activities>
</TrainingCenterDatabase>
""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>прогулка</name>
<trkseg>
<trkpt lat="55.7" lon="37.6"><time>2026-10-12T09:00:00Z</time>
<extensions><gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"><gpxtpx:hr>105</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>
</trkpt>
<trkpt lat="55.8" lon="37.7"><time>2026-10-12T09:10:00Z</time>
<extensions><gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"><gpxtpx:hr>115</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
""";
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);
}
}

View File

@ -26,6 +26,28 @@ public class ServerConfig
public string MigrationsDir { get; set; } = public string MigrationsDir { get; set; } =
Path.Combine(AppContext.BaseDirectory, "migrations"); Path.Combine(AppContext.BaseDirectory, "migrations");
/// <summary>Папка импорта (--mode import, m06; подпапки — атлеты).</summary>
public string ImportDir { get; set; } =
Path.Combine(AppContext.BaseDirectory, "data", "import");
/// <summary>Папка обработанных файлов импорта.</summary>
public string ImportArchiveDir { get; set; } =
Path.Combine(AppContext.BaseDirectory, "data", "import_done");
/// <summary>Учётка Garmin Connect владельца (--mode fetch, m07).</summary>
public string GarminLogin { get; set; } = string.Empty;
public string GarminPassword { get; set; } = string.Empty;
/// <summary>Атлет, на которого пишутся факты fetch (изоляция 🛡1).</summary>
public string GarminAthleteName { get; set; } = string.Empty;
/// <summary>Папка скачанных .fit (кэш по activityId).</summary>
public string GarminDownloadDir { get; set; } =
Path.Combine(AppContext.BaseDirectory, "data", "fit");
/// <summary>Точки входа Garmin (замена в тестах/прокси).</summary>
public string GarminBaseUrl { get; set; } = "https://connect.garmin.com";
/// <summary>Пороги красных флагов (Safety.*, Р3-А; MetricsEngine).</summary> /// <summary>Пороги красных флагов (Safety.*, Р3-А; MetricsEngine).</summary>
public MetricsConfig Safety { get; set; } = new(); public MetricsConfig Safety { get; set; } = new();
@ -61,6 +83,20 @@ public class ServerConfig
result.DbConnectionString = cs.GetString() ?? string.Empty; result.DbConnectionString = cs.GetString() ?? string.Empty;
if (db.TryGetProperty("MigrationsDir", out var md) && md.ValueKind == JsonValueKind.String) if (db.TryGetProperty("MigrationsDir", out var md) && md.ValueKind == JsonValueKind.String)
result.MigrationsDir = md.GetString() ?? result.MigrationsDir; 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.Safety = MetricsConfig.Load(root);
result.Progression = ProgressionConfig.Load(root); result.Progression = ProgressionConfig.Load(root);

View File

@ -1,6 +1,8 @@
using Server.WatchBody.Web; using Server.WatchBody.Web;
using Server.WatchBody.Web.Config; using Server.WatchBody.Web.Config;
using Server.WatchBody.Web.Db; using Server.WatchBody.Web.Db;
using Server.WatchBody.Fetch;
using Server.WatchBody.Import;
using Server.WatchBody.Web.Services; using Server.WatchBody.Web.Services;
// Точка входа сервера (m02; режимы — requirements 2.3, З-36): // Точка входа сервера (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") if (mode != "serve")
{ {
Console.Error.WriteLine($"неизвестный режим '{mode}' (serve|migrate)"); Console.Error.WriteLine($"неизвестный режим '{mode}' (serve|import|fetch|migrate)");
return 1; return 1;
} }

View File

@ -2,6 +2,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Server.WatchBody.Core\Server.WatchBody.Core.csproj" /> <ProjectReference Include="..\Server.WatchBody.Core\Server.WatchBody.Core.csproj" />
<ProjectReference Include="..\Server.WatchBody.Import\Server.WatchBody.Import.csproj" />
<ProjectReference Include="..\Server.WatchBody.Fetch\Server.WatchBody.Fetch.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

@ -1,6 +1,8 @@
<Solution> <Solution>
<Project Path="Server.WatchBody.Core.Tests/Server.WatchBody.Core.Tests.csproj" /> <Project Path="Server.WatchBody.Core.Tests/Server.WatchBody.Core.Tests.csproj" />
<Project Path="Server.WatchBody.Core/Server.WatchBody.Core.csproj" /> <Project Path="Server.WatchBody.Core/Server.WatchBody.Core.csproj" />
<Project Path="Server.WatchBody.Import/Server.WatchBody.Import.csproj" />
<Project Path="Server.WatchBody.Fetch/Server.WatchBody.Fetch.csproj" />
<Project Path="Server.WatchBody.Web.Tests/Server.WatchBody.Web.Tests.csproj" /> <Project Path="Server.WatchBody.Web.Tests/Server.WatchBody.Web.Tests.csproj" />
<Project Path="Server.WatchBody.Web/Server.WatchBody.Web.csproj" /> <Project Path="Server.WatchBody.Web/Server.WatchBody.Web.csproj" />
</Solution> </Solution>