Этап 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:
parent
f232d592a4
commit
f41bf9a0d2
|
|
@ -40,8 +40,8 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн
|
|||
| m03-program-store | `Server.WatchBody.Core`: версионный JSON программ, `active` — ровно одна | 1 | ✅ (2026-10-18: ProgramStore, 7 методов, тесты 19 PASS) |
|
||||
| m04-metrics-engine | `Server.WatchBody.Core`: adherence, тоннаж, 1ПМ (Эпли), пульс-метрики, красные флаги | 2 | ✅ (2026-10-18: MetricsEngine + Safety, sync-пересчёт, тесты 17 PASS) |
|
||||
| m05-progression-rules | `Server.WatchBody.Core`: правила прогрессии (Ф-12.2), делоад (Ф-12.4), перерывы/возврат (Ф-14), учёт ограничений (is_active/замены), draft-версии `author=rules` | 2 | ✅ (2026-10-18: ProgressionRules + BreakEngine, фильтр ограничений, тесты Web 81/81 + Core 56/56) |
|
||||
| m06-fit-import | `Server.WatchBody.Import`: FIT SDK → `facts_json`, fallback-папка, архив | 2 | ⬜ |
|
||||
| m07-fit-fetch | `Server.WatchBody.Fetch`: клиент Garmin Connect, дедупликация `fit_file_id` | 2 | ⬜ |
|
||||
| m06-fit-import | `Server.WatchBody.Import` + `--mode import`: FitParser (FIT SDK) → workout-facts/1, сопоставление ±15 мин, дедуп fit_file_id, пакет программы, TCX/GPX, архив | 2 | ✅ (2026-10-18: Garmin.FIT.Sdk 21.218.0, FitParser/WorkoutImporter/ProgramPackageImporter/ImportService, тесты Web 93/93 + Core 56/56, psql 123/123) |
|
||||
| m07-fit-fetch | `Server.WatchBody.Fetch` + `--mode fetch`: GarminClient (SSO по контракту garminconnect), FitFetcher (дедуп 2 рубежей), разбор конвейером m06 | 2 | ✅ (2026-10-18: SSO-флоу + FakeGarminHandler, тесты Web 98/98 + Core 56/56; против живого Garmin — ручная валидация прототипа, fallback — m06) |
|
||||
| m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: атлеты, тренировки, программы, метрики, CSV | 3 | ⬜ |
|
||||
| m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ⬜ |
|
||||
| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ |
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
| `p_athlete_id` | int | да | атлет (🛡) |
|
||||
|
||||
**Возвращает** `TABLE` — колонки возврата сущности `program`:
|
||||
`id int`, `athlete_id int`, `title text`, `created_at timestamptz` +
|
||||
`id int`, `athlete_id int`, `title text`, `external_id text`
|
||||
(rev. 2 — доведение З-35, errata m06), `created_at timestamptz` +
|
||||
денормализация `active_version_id uuid`, `active_version int` (NULL —
|
||||
активной версии нет).
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@
|
|||
| `Admin.Login / Admin.PasswordHash` | string | непустые; хэш — PBKDF2 (100k итераций), префикс `pbkdf2$` |
|
||||
| `Db.ConnectionString` | string | тест подключения при старте — фатально при неудаче |
|
||||
| `Garmin.Login/Password` | string | обязательны для `--mode fetch` (иначе код возврата 1) |
|
||||
| `Garmin.AthleteName` | string | обязателен для `--mode fetch` (m07, решение гейта 1.3): имя атлета `src.athlete`, на которого пишутся факты (изоляция 🛡); не найден → код 1 |
|
||||
| `Garmin.DownloadDir` | path | умолчание `datait`; папка-кэш скачанных .fit (по activityId) |
|
||||
| `Garmin.BaseUrl` | string | умолчание `https://connect.garmin.com`; замена точек входа для тестов/прокси |
|
||||
| `Garmin.DownloadDir / Import.Dir / Import.ArchiveDir` | path | создание при старте; недоступность — фатально |
|
||||
| `Progression.BaseStepKg` | double | 0,5–10 |
|
||||
| `Progression.IsoStepKg` | double | 0,25–5 (умолчание 1,0 — коридор метрик «+0,5–1 кг», решение владельца О-3-А, З-27) |
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
|
@ -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, битый файл (усечённый байтовый
|
||||
массив).
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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 вручную).
|
||||
|
|
@ -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);
|
||||
- изоляция: файлы папки обрабатываются только на атлета папки.
|
||||
|
|
@ -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` — зелёные;
|
||||
сборка решения без ошибок.
|
||||
|
|
@ -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]`, не элемент.
|
||||
|
|
@ -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/)
|
||||
|
|
@ -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).
|
||||
|
|
@ -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` атлета-владельца учётки;
|
||||
- повторный запуск не качает дубли и не создаёт дублей в БД.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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` — зелёные;
|
||||
сборка решения без ошибок.
|
||||
|
|
@ -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
|
||||
умолчания пустые (🛡 секреты только в конфиге владельца).
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
-- Параметры: p_athlete_id; p_program_id.
|
||||
-- Возвращает: TABLE — строка program + денормализация; чужая — пустая таблица.
|
||||
|
||||
-- прежняя сигнатура (без external_id) снимается — row type меняется
|
||||
DROP FUNCTION IF EXISTS src.program_get(int, int);
|
||||
|
||||
CREATE OR REPLACE FUNCTION src.program_get(
|
||||
p_athlete_id int,
|
||||
p_program_id int
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
-- src.program_select: программы атлета + активная версия (денормализация, d-db-3).
|
||||
-- На основе: новая.
|
||||
-- На основе: новая (rev. 2 — external_id, доведение З-35, errata m06).
|
||||
-- Параметры: p_athlete_id.
|
||||
-- Возвращает: TABLE — строки program + active_version_id/active_version (NULL — активной нет).
|
||||
|
||||
-- прежняя сигнатура (без external_id) снимается — row type меняется
|
||||
DROP FUNCTION IF EXISTS src.program_select(int);
|
||||
|
||||
CREATE OR REPLACE FUNCTION src.program_select(
|
||||
p_athlete_id int
|
||||
)
|
||||
|
|
@ -10,11 +13,12 @@ RETURNS TABLE (
|
|||
id int,
|
||||
athlete_id int,
|
||||
title text,
|
||||
external_id text,
|
||||
created_at timestamptz,
|
||||
active_version_id uuid,
|
||||
active_version int
|
||||
) LANGUAGE sql AS $$
|
||||
SELECT p.id, p.athlete_id, p.title, p.created_at,
|
||||
SELECT p.id, p.athlete_id, p.title, p.external_id, p.created_at,
|
||||
v.id AS active_version_id, v.version AS active_version
|
||||
FROM src.program p
|
||||
LEFT JOIN src.program_version v
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ public class ProgramRepositoryTests
|
|||
.AddParameter("p_athlete_id", 1)
|
||||
.BuildParams();
|
||||
var table = TestInfra.Table(TestInfra.ProgramCols,
|
||||
[1, 1, "TEST-Программа", new DateTime(2026, 10, 17), DBNull.Value, DBNull.Value],
|
||||
[2, 1, "TEST-Программа-2", new DateTime(2026, 10, 17), activeId, 2]);
|
||||
[1, 1, "TEST-Программа", DBNull.Value, new DateTime(2026, 10, 17), DBNull.Value, DBNull.Value],
|
||||
[2, 1, "TEST-Программа-2", DBNull.Value, new DateTime(2026, 10, 17), activeId, 2]);
|
||||
var cs = new ConnectionStringTest(expArgs, table);
|
||||
|
||||
var rows = new ProgramRepository(cs).Select(1).ToList();
|
||||
|
|
@ -227,7 +227,7 @@ public class ProgramRepositoryTests
|
|||
.AddParameter("p_program_id", 2)
|
||||
.BuildParams();
|
||||
var table = TestInfra.Table(TestInfra.ProgramCols,
|
||||
[2, 1, "TEST-Программа-2", new DateTime(2026, 10, 17), activeId, 2]);
|
||||
[2, 1, "TEST-Программа-2", DBNull.Value, new DateTime(2026, 10, 17), activeId, 2]);
|
||||
var cs = new ConnectionStringTest(expArgs, table);
|
||||
|
||||
var p = new ProgramRepository(cs).Get(1, 2);
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ internal static class TestInfra
|
|||
|
||||
public static readonly (string, Type)[] ProgramCols =
|
||||
[("id", typeof(int)), ("athlete_id", typeof(int)), ("title", typeof(string)),
|
||||
("created_at", typeof(DateTime)), ("active_version_id", typeof(Guid)),
|
||||
("active_version", typeof(int))];
|
||||
("external_id", typeof(string)), ("created_at", typeof(DateTime)),
|
||||
("active_version_id", typeof(Guid)), ("active_version", typeof(int))];
|
||||
|
||||
public static readonly (string, Type)[] VersionCols =
|
||||
[("id", typeof(Guid)), ("program_id", typeof(int)), ("version", typeof(int)),
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ public class ProgramRepository : IProgramRepository
|
|||
Id = record.Convert<int>(record.Src["id"]),
|
||||
AthleteId = record.Convert<int>(record.Src["athlete_id"]),
|
||||
Title = record.Src["title"] as string ?? string.Empty,
|
||||
ExternalId = record.IsDbNull("external_id")
|
||||
? null : record.Src["external_id"] as string,
|
||||
CreatedAt = record.Convert<DateTime>(record.Src["created_at"]),
|
||||
ActiveVersionId = record.IsDbNull("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"]),
|
||||
AthleteId = record.Convert<int>(record.Src["athlete_id"]),
|
||||
Title = record.Src["title"] as string ?? string.Empty,
|
||||
ExternalId = record.IsDbNull("external_id")
|
||||
? null : record.Src["external_id"] as string,
|
||||
CreatedAt = record.Convert<DateTime>(record.Src["created_at"]),
|
||||
ActiveVersionId = record.IsDbNull("active_version_id")
|
||||
? null : record.Convert<Guid>(record.Src["active_version_id"]),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'}";
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,28 @@ public class ServerConfig
|
|||
public string MigrationsDir { get; set; } =
|
||||
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>
|
||||
public MetricsConfig Safety { get; set; } = new();
|
||||
|
||||
|
|
@ -61,6 +83,20 @@ public class ServerConfig
|
|||
result.DbConnectionString = cs.GetString() ?? string.Empty;
|
||||
if (db.TryGetProperty("MigrationsDir", out var md) && md.ValueKind == JsonValueKind.String)
|
||||
result.MigrationsDir = md.GetString() ?? result.MigrationsDir;
|
||||
if (db.TryGetProperty("ImportDir", out var idir) && idir.ValueKind == JsonValueKind.String)
|
||||
result.ImportDir = idir.GetString() ?? result.ImportDir;
|
||||
if (db.TryGetProperty("ImportArchiveDir", out var adir) && adir.ValueKind == JsonValueKind.String)
|
||||
result.ImportArchiveDir = adir.GetString() ?? result.ImportArchiveDir;
|
||||
if (db.TryGetProperty("Garmin", out var garmin) && garmin.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (garmin.TryGetProperty("Login", out var gl)) result.GarminLogin = gl.GetString() ?? "";
|
||||
if (garmin.TryGetProperty("Password", out var gp)) result.GarminPassword = gp.GetString() ?? "";
|
||||
if (garmin.TryGetProperty("AthleteName", out var ga)) result.GarminAthleteName = ga.GetString() ?? "";
|
||||
if (garmin.TryGetProperty("DownloadDir", out var gd) && gd.ValueKind == JsonValueKind.String)
|
||||
result.GarminDownloadDir = gd.GetString() ?? result.GarminDownloadDir;
|
||||
if (garmin.TryGetProperty("BaseUrl", out var gb) && gb.ValueKind == JsonValueKind.String)
|
||||
result.GarminBaseUrl = gb.GetString() ?? result.GarminBaseUrl;
|
||||
}
|
||||
}
|
||||
result.Safety = MetricsConfig.Load(root);
|
||||
result.Progression = ProgressionConfig.Load(root);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using Server.WatchBody.Web;
|
||||
using Server.WatchBody.Web.Config;
|
||||
using Server.WatchBody.Web.Db;
|
||||
using Server.WatchBody.Fetch;
|
||||
using Server.WatchBody.Import;
|
||||
using Server.WatchBody.Web.Services;
|
||||
|
||||
// Точка входа сервера (m02; режимы — requirements 2.3, З-36):
|
||||
|
|
@ -57,9 +59,68 @@ if (mode == "migrate")
|
|||
}
|
||||
}
|
||||
|
||||
if (mode == "import")
|
||||
{
|
||||
try
|
||||
{
|
||||
var connectionStringImport = new PostgresConnectionString(config.DbConnectionString);
|
||||
Directory.CreateDirectory(config.ImportDir);
|
||||
Directory.CreateDirectory(config.ImportArchiveDir);
|
||||
var (processed, failed) = new ImportService(connectionStringImport).Run(
|
||||
config.ImportDir, config.ImportArchiveDir);
|
||||
Console.WriteLine($"import: обработано {processed}, сбоев {failed}");
|
||||
return failed == 0 ? 0 : 2;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.Error.WriteLine($"import: авария конфигурации — {e.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == "fetch")
|
||||
{
|
||||
if (config.GarminLogin.Length == 0 || config.GarminPassword.Length == 0 ||
|
||||
config.GarminAthleteName.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"fetch: требуются Garmin.Login, Garmin.Password и Garmin.AthleteName");
|
||||
return 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(config.GarminDownloadDir);
|
||||
var client = new GarminClient(
|
||||
config.GarminLogin, config.GarminPassword, config.GarminBaseUrl);
|
||||
var (downloaded, skipped, failed) = new FitFetcher(
|
||||
new PostgresConnectionString(config.DbConnectionString),
|
||||
client, config.GarminDownloadDir, config.GarminAthleteName
|
||||
).FetchNew();
|
||||
Console.WriteLine($"fetch: скачано {downloaded}, пропущено {skipped}, сбоев {failed}");
|
||||
return failed == 0 ? 0 : 2;
|
||||
}
|
||||
catch (GarminAuthException e)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"fetch: логин Garmin не удался — {e.Message}; " +
|
||||
"fallback: папка импорта (--mode import)");
|
||||
return 2;
|
||||
}
|
||||
catch (FetchException e)
|
||||
{
|
||||
Console.Error.WriteLine($"fetch: {e.Message}");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.Error.WriteLine($"fetch: авария — {e.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode != "serve")
|
||||
{
|
||||
Console.Error.WriteLine($"неизвестный режим '{mode}' (serve|migrate)");
|
||||
Console.Error.WriteLine($"неизвестный режим '{mode}' (serve|import|fetch|migrate)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
|
||||
<PropertyGroup>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<Solution>
|
||||
<Project Path="Server.WatchBody.Core.Tests/Server.WatchBody.Core.Tests.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/Server.WatchBody.Web.csproj" />
|
||||
</Solution>
|
||||
|
|
|
|||
Loading…
Reference in New Issue