- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
225 lines
12 KiB
Go
225 lines
12 KiB
Go
package hae
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// maxEntityID — предел длины идентификатора сущности.
|
|
//
|
|
// `id` приходит из тела, которым отправитель управляет целиком, а уезжает и в
|
|
// первичный ключ таблицы, и в записи лога. UUID HealthKit — 36 байт, так что
|
|
// запас велик; правило то же, что уже действует для имён непокрытых секций, и
|
|
// оно снимает класс, а не случай.
|
|
const maxEntityID = 128
|
|
|
|
// rfc3339Layout — второй формат метки, которым HAE шлёт stateOfMind
|
|
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
|
|
const rfc3339Layout = time.RFC3339
|
|
|
|
// softString — строка заголовка, которая переживает значение не того типа.
|
|
//
|
|
// Значение не того ТИПА стоит одного поля, а не сущности. Правило уже записано
|
|
// рядом для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не
|
|
// сломанная сущность»); без него `name`, приехавшее числом, уносит тренировку
|
|
// вместе с маршрутом — а доставка при этом числится разобранной, и ретеншен
|
|
// получает ответ «терять нечего» ровно там, где потеряно 95% содержимого.
|
|
//
|
|
// Через json.Unmarshaler, а не через разбор ошибки постфактум. Соблазн есть:
|
|
// encoding/json при несовпадении типа «skips that field and completes the
|
|
// unmarshaling as best it can» и возвращает *UnmarshalTypeError, то есть
|
|
// трёхстрочный errors.As выглядел бы равноценным. Он неравноценен — та же
|
|
// документация оговаривает, что дозаполнение полей ПОСЛЕ проблемного не
|
|
// гарантировано. Разбор, построенный на этом, перестал бы быть функцией тела:
|
|
// одна и та же тренировка давала бы разный заголовок в зависимости от порядка
|
|
// ключей на проводе, а он у HAE нестабилен.
|
|
//
|
|
// Различение счётчиков сохраняется само: элемент, который сам не объект, даёт
|
|
// ошибку ВЕРХНЕГО уровня и по-прежнему уходит в «не разобралось как объект», а
|
|
// не в «нет id».
|
|
// Признак `present` отличает «ключа не было» от «ключ был, но строки из него не
|
|
// вышло». Различие нужно ровно одному полю — метке начала, — и там оно
|
|
// существенно: см. фолбэк `start → date` ниже.
|
|
//
|
|
// Именно «ключ был», а не «значение не той формы»: `null` тоже даёт пустую
|
|
// строку, и без этого различения `{"date":"…","start":null}` уводил бы
|
|
// тренировку на момент времени из другого поля — молча и без счётчика.
|
|
type softString struct {
|
|
value string
|
|
// present — ключ присутствовал в объекте. UnmarshalJSON зовётся только на
|
|
// присутствующий ключ, поэтому признак взводится безусловно.
|
|
present bool
|
|
}
|
|
|
|
func (s *softString) UnmarshalJSON(raw []byte) error {
|
|
// Приёмник задаётся ЦЕЛИКОМ, а не дописывается. JSON допускает повтор
|
|
// ключа, и encoding/json зовёт UnmarshalJSON на каждое вхождение с
|
|
// семантикой «побеждает последнее» — так работает соседний Duration и весь
|
|
// разбор метрик. Накопленный признак сделал бы разбор функцией не тела, а
|
|
// истории вызовов: `{"start":123,"start":"2025-06-05 …"}` терял бы
|
|
// тренировку с маршрутом при валидной последней метке.
|
|
*s = softString{present: true}
|
|
|
|
var v string
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
// Значение не строка — поле считается непрочитанным. Ошибку глушим
|
|
// сознательно: это и есть мягкость, ради которой тип заведён.
|
|
return nil
|
|
}
|
|
s.value = v
|
|
return nil
|
|
}
|
|
|
|
// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
|
|
// хранится дословно.
|
|
type entityHead struct {
|
|
ID softString `json:"id"`
|
|
Name softString `json:"name"`
|
|
Date softString `json:"date"`
|
|
Start softString `json:"start"`
|
|
End softString `json:"end"`
|
|
|
|
Duration json.RawMessage `json:"duration"`
|
|
}
|
|
|
|
// decodeEntities разбирает элементы одной покрытой секции в сущности.
|
|
//
|
|
// Пропуск одного элемента не уносит соседей: у каждого класса пропуска свой
|
|
// счётчик, тело остаётся в архиве, и доставку вернёт пересборка, когда разбор
|
|
// научится понимать пропущенное.
|
|
func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
|
|
if len(raws) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make([]Entity, 0, len(raws))
|
|
for _, raw := range raws {
|
|
// Род элемента проверяется ДО разбора, потому что `json.Unmarshal`
|
|
// «null» в структуру ошибкой не считает (для JSON null это no-op) — и
|
|
// элемент-`null` уходил бы в счётчик «нет id», то есть сменившаяся
|
|
// форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
|
// ИДЕНТИФИКАТОРА. Два счётчика заведены ровно ради этого различия.
|
|
if !isJSONObject(raw) {
|
|
res.SkippedEntityMalformed++
|
|
continue
|
|
}
|
|
var head entityHead
|
|
if err := json.Unmarshal(raw, &head); err != nil {
|
|
res.SkippedEntityMalformed++
|
|
continue
|
|
}
|
|
// Идентификатор исключение из мягкости: без строкового `id` сущность не
|
|
// адресуема, а приведение чужого нестрокового значения к строке было бы
|
|
// выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение
|
|
// уже превратило в пустую строку — исход тот же, что у отсутствующего.
|
|
id := head.ID.value
|
|
if id == "" || len(id) > maxEntityID {
|
|
res.SkippedNoID++
|
|
continue
|
|
}
|
|
|
|
// Фолбэк `start → date` существует для сущностей, у которых ключа
|
|
// `start` НЕТ ВОВСЕ. Если ключ пришёл, но строки из него не вышло
|
|
// (число, объект, `null`), фолбэк не срабатывает: композиция двух
|
|
// правил подставила бы метку ДРУГОГО момента времени — неотличимую от
|
|
// настоящей и ничем не считаемую. Такой `start` считается неразбираемой
|
|
// меткой.
|
|
if head.Start.present && head.Start.value == "" {
|
|
res.SkippedEntityNoTime++
|
|
continue
|
|
}
|
|
start, ok := parseEntityTime(firstNonEmpty(head.Start.value, head.Date.value))
|
|
if !ok {
|
|
res.SkippedEntityNoTime++
|
|
continue
|
|
}
|
|
|
|
// Конец, которого нет или который не читается, равен началу. У точки то
|
|
// же вырождение запрещено — там оно схлопнуло бы две записи в одну
|
|
// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
|
|
// Истина при этом остаётся в Raw дословно.
|
|
end := start
|
|
if head.End.value != "" {
|
|
if e, ok := parseEntityTime(head.End.value); ok {
|
|
end = e
|
|
}
|
|
}
|
|
|
|
_, offset := start.Zone()
|
|
e := Entity{
|
|
ID: id,
|
|
Kind: kind,
|
|
Name: head.Name.value,
|
|
Start: start.UTC(),
|
|
End: end.UTC(),
|
|
OffsetSeconds: offset,
|
|
Duration: parseDuration(head.Duration),
|
|
Raw: raw,
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseEntityTime разбирает метку сущности, принимая оба измеренных формата.
|
|
//
|
|
// Оба, а не приписанный секции: формы однозначны и не пересекаются (RFC 3339
|
|
// несёт `T` и `Z`), а HAE выравнивает секции между собой по ходу своих
|
|
// обновлений — `stateOfMind` уже шлёт стабильные коды HealthKit там, где старые
|
|
// секции шлют переводы. Приписанный секции формат ломался бы молча в день
|
|
// такого выравнивания.
|
|
//
|
|
// Метка ТОЧКИ остаётся строгой (parseTime), и асимметрия намеренная: по метке
|
|
// точки выводится слой, причём по метке в исходной зоне. Терпимость там
|
|
// означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка
|
|
// складывается с минутной; у сущности слоя нет, и терять на строгости нечего.
|
|
func parseEntityTime(s string) (time.Time, bool) {
|
|
if s == "" {
|
|
return time.Time{}, false
|
|
}
|
|
if t, err := time.Parse(timeLayout, s); err == nil {
|
|
return t, true
|
|
}
|
|
if t, err := time.Parse(rfc3339Layout, s); err == nil {
|
|
return t, true
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// parseDuration переводит длительность в секунды.
|
|
//
|
|
// Отсутствие и нечисловое значение дают nil, а не ноль: ноль — законная
|
|
// длительность, и потребитель, сложивший столбец, иначе не отличил бы
|
|
// «источник не прислал» от «измерено ноль». Вычислять длительность из
|
|
// интервала нельзя: HAE шлёт 91.746 при интервале в 91 секунду.
|
|
func parseDuration(raw json.RawMessage) *float64 {
|
|
lit := bytes.TrimSpace(raw)
|
|
if len(lit) == 0 {
|
|
return nil
|
|
}
|
|
v, err := strconv.ParseFloat(string(lit), 64)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// isJSONObject говорит, является ли значение объектом JSON, по первому байту
|
|
// литерала — так же, как это делает сканер encoding/json.
|
|
func isJSONObject(raw json.RawMessage) bool {
|
|
lit := bytes.TrimSpace(raw)
|
|
return len(lit) > 0 && lit[0] == '{'
|
|
}
|
|
|
|
func firstNonEmpty(a, b string) string {
|
|
if a != "" {
|
|
return a
|
|
}
|
|
return b
|
|
}
|