pcp: единообразие времени (UTC) по группе сервисов

Фронт (pcp-tgu-ops-ui):
- тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) /
  toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc.

Бэкенд:
- UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам
  (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах.
- Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе):
  - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime);
  - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime);
  - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC);
  - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()).
- Контрактные сериализационные тесты формата на проводе (naive без зоны / Z):
  PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui).
- KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ).

Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z),
docs/standards/CHECKLIST.md (пункт про UTC на API).
This commit is contained in:
Дмитрий Соловьев
2026-06-02 21:51:38 +03:00
parent ac2220d063
commit 260583b134
84 changed files with 760 additions and 166 deletions
+92
View File
@@ -0,0 +1,92 @@
# DATETIME_UTC.md — единообразие времени (UTC) в группе сервисов `pcp`
## Назначение
Этот документ фиксирует соглашение по работе со временем во всей группе сервисов `pcp`
и состояние сервисов на момент аудита. На него ссылаются KDoc у DTO и комментарии в коде.
Цель — убрать неоднозначность парсинга/форматирования и неоднородность формата на проводе,
которая уже приводила к сдвигу времени на офсет пояса (для МСК — +3 ч).
## 1. Соглашение (canonical)
1. **Всё время — UTC.** На любом API времена представляют момент в UTC. В БД — тоже UTC.
2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис исторически
отдаёт naive `LocalDateTime` (ISO без зоны, напр. `"2026-05-29T01:00:00"`), это допустимо
**только** при железном правиле «naive = UTC»; такой эндпоинт — кандидат на миграцию к `Z`.
3. **Backend:**
- «сейчас» — только UTC: `Instant.now()` или `LocalDateTime.now(ZoneOffset.UTC)`; **не** `LocalDateTime.now()`;
- конвертация `Instant`/`OffsetDateTime``LocalDateTime` для naive-полей — только через UTC
(`LocalDateTime.ofInstant(instant, ZoneOffset.UTC)`, `withOffsetSameInstant(ZoneOffset.UTC)`),
**никогда** через `ZoneId.systemDefault()` / `atZone(systemDefault())`;
- Jackson во всех сервисах настроен с `JavaTimeModule` + `disable(WRITE_DATES_AS_TIMESTAMPS)`,
поэтому `LocalDateTime` уходит как ISO без зоны, а `OffsetDateTime`/`Instant`с `Z`/офсетом.
4. **Frontend (`pcp-tgu-ops-ui`):**
- парсинг строк бэка — только через `src/utils/utcTime.ts` (`parseUtc`); никаких «голых»
`new Date(string)` / `Date.parse(string)`;
- отображение — только UTC (`formatUtcDateTime/Date/Time` либо `toLocale*` с `timeZone: "UTC"`);
- `<input type="datetime-local">` — значение через `toUtcInputValue`, чтение через `parseUtc`,
отправка на offset-бэк — через `toUtcIso`.
- Инвариант закреплён тестом-стражем `src/utils/utcTime.guard.test.ts` (падает на запрещённых паттернах).
5. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка.
## 2. Эталоны переиспользования
- Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования.
- Backend: `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) и конвертирует вход
через `OffsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()` — корректный образец.
## 3. Состояние сервисов (срез аудита)
Формат на проводе определяется Jackson-сериализацией: `LocalDateTime` → ISO без зоны (naive, = UTC),
`OffsetDateTime`/`Instant` → ISO с `Z`. «Потребители» — кто читает время этого сервиса.
| Сервис | Преобладает на границе API | Naive? | Вердикт |
|---|---|---|---|
| pcp-request-service | `OffsetDateTime` (`Z`) | нет | **эталон**: явная зона на проводе |
| pcp-tgu-service | `LocalDateTime` (planId/plan times) | да | задокументирован «=UTC» (`PlanResponse`); баги конвертаций исправлены |
| pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries) | да | задокументировать «=UTC»; кандидат на `Z` |
| pcp-complex-mission-service | `LocalDateTime` (+ часть `Instant`) | смешано | проверить смешение; задокументировать |
| pcp-dynamic-plan-service | `LocalDateTime` (+ offset на входе клиента) | да | задокументировать «=UTC» |
| pcp-ballistics-service | `LocalDateTime` | да | задокументировать «=UTC» |
| pcp-mission-planing-service | `LocalDateTime` (вход — `OffsetDateTime`) | да | задокументировать «=UTC» |
| slots-service | `LocalDateTime` | да | задокументировать «=UTC»; проверить `rs.getTimestamp().toLocalDateTime()` |
| pcp-coverage-scheme-service | `LocalDateTime` | да | задокументировать «=UTC» |
| pcp-route-processing-service | нет времени на границе API | — | — |
| pcp-satellite-catalog-service | нет времени на границе API | — | — |
| pcp-stations-service | нет времени на границе API | — | — |
| tle-monitoring-service | нет времени на границе API | — | — |
## 4. Известные расхождения и под-задачи (миграция к `Z` — не «большим взрывом»)
Систематический класс: `LocalDateTime.now()` без UTC-зоны (~80 мест: entity-дефолты `createdAt/updatedAt`,
воркеры, use case'ы). На не-UTC JVM пишет локальное «сейчас» в UTC-поля. Низкий риск для audit-колонок,
но формально неверно. Рекомендованные под-задачи по сервисам:
- **pcp-tgu-service** — заменить оставшиеся `LocalDateTime.now()` (воркеры выдачи/решений, `PlanQueryService`
cutoff, entity-дефолты) на `LocalDateTime.now(ZoneOffset.UTC)`; тест на cutoff истории.
- **slots-service** — проверить `SlotRepositoryImpl.rs.getTimestamp("tn").toLocalDateTime()` (зависит от
зоны драйвера); зафиксировать чтение в UTC.
- **pcp-mission-planing-service / pcp-dynamic-plan-service** — `OffsetDateTime.toLocalDateTime()` теряет
офсет; убедиться, что вход всегда UTC, иначе конвертировать через `withOffsetSameInstant(UTC)`.
- **Все сервисы с naive `LocalDateTime` на границе** — добавить сериализационный тест, фиксирующий формат
на проводе (есть `Z` / нет зоны), затем по согласованию мигрировать DTO к `OffsetDateTime`/`Instant` (UTC).
Фронтовый `parseUtc` уже совместим с обоими форматами, поэтому миграция безопасна при пер-сервисном раскате.
### Контрактные тесты формата (зафиксировано)
Оба формата на проводе закреплены сериализационными тестами (mapper зеркалит конфиг Spring Boot —
JavaTimeModule + `disable(WRITE_DATES_AS_TIMESTAMPS)`):
- `PlanResponseContractTest` (pcp-tgu-service) — naive `LocalDateTime` без зоны.
- `RequestResponseDtoContractTest` (pcp-request-service) — `OffsetDateTime` с `Z` (эталон).
- `TguPlanningDtoContractTest` (pcp-ui-service) — оба формата: naive `LocalDateTime` + `Instant` с `Z`.
Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme,
mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании
пер-сервисный страж копируется по образцу `PlanResponseContractTest`.
## 5. Правило для нового кода
- Frontend: только `utils/utcTime` (страж `utcTime.guard.test.ts` не даст просочиться нарушителю).
- Backend: UTC-`now`; на новых API предпочтителен явный `Z` (`OffsetDateTime`/`Instant`).
- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).