добавлен приём пакетов Health Auto Export
- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой архив, учёт доставки в SQLite - код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200, данные уже сохранены и доразберутся позже - сохраняется полный набор заголовков запроса с вычисткой секретов по имени и по совпадению значения с токеном
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Статусы разбора доставки.
|
||||
const (
|
||||
// ParsePending — тело сохранено, разбора ещё не было.
|
||||
ParsePending = "pending"
|
||||
)
|
||||
|
||||
// Delivery — учётная запись одного принятого пакета.
|
||||
type Delivery struct {
|
||||
ID string
|
||||
ReceivedAt time.Time
|
||||
AutomationName string
|
||||
AutomationID string
|
||||
Aggregation string
|
||||
Period string
|
||||
SessionID string
|
||||
Bytes int64
|
||||
SHA256 string
|
||||
RawPath string
|
||||
ParseStatus string
|
||||
Points int64
|
||||
// Headers — весь набор заголовков запроса как JSON-объект (имя → массив
|
||||
// значений), уже без секретов. Именованные поля выше дублируют часть из
|
||||
// них: по ним ходят запросы, а Headers хранит всё остальное на будущее.
|
||||
Headers string
|
||||
}
|
||||
|
||||
// CreateDelivery записывает факт приёма пакета.
|
||||
func (s *Store) CreateDelivery(ctx context.Context, d Delivery) error {
|
||||
const q = `
|
||||
INSERT INTO delivery (id, received_at, automation_name, automation_id,
|
||||
aggregation, period, session_id, bytes, sha256,
|
||||
raw_path, parse_status, points, headers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
headers := d.Headers
|
||||
if headers == "" {
|
||||
headers = "{}"
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx, q,
|
||||
d.ID, FormatTime(d.ReceivedAt), d.AutomationName, d.AutomationID,
|
||||
d.Aggregation, d.Period, d.SessionID, d.Bytes, d.SHA256,
|
||||
d.RawPath, d.ParseStatus, d.Points, headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert delivery: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastDelivery возвращает последнюю по времени приёма доставку.
|
||||
// Возвращает ErrNotFound, если доставок ещё не было.
|
||||
func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
|
||||
const q = `
|
||||
SELECT id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, parse_status,
|
||||
points, headers
|
||||
FROM delivery ORDER BY received_at DESC, id DESC LIMIT 1`
|
||||
|
||||
var d Delivery
|
||||
var receivedAt string
|
||||
err := s.db.QueryRowxContext(ctx, q).Scan(
|
||||
&d.ID, &receivedAt, &d.AutomationName, &d.AutomationID, &d.Aggregation,
|
||||
&d.Period, &d.SessionID, &d.Bytes, &d.SHA256, &d.RawPath,
|
||||
&d.ParseStatus, &d.Points, &d.Headers)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Delivery{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Delivery{}, fmt.Errorf("select last delivery: %w", err)
|
||||
}
|
||||
|
||||
d.ReceivedAt, err = ParseTime(receivedAt)
|
||||
if err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// CountDeliveries возвращает число принятых пакетов. Нужно для healthcheck и
|
||||
// быстрой проверки «данные вообще идут».
|
||||
func (s *Store) CountDeliveries(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM delivery`); err != nil {
|
||||
return 0, fmt.Errorf("count deliveries: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrNotFound — записи нет. Граничную ошибку драйвера (sql.ErrNoRows)
|
||||
// транслируем в доменную здесь же, у источника, чтобы выше по коду не торчал
|
||||
// database/sql.
|
||||
var ErrNotFound = errors.New("запись не найдена")
|
||||
@@ -0,0 +1,26 @@
|
||||
-- +goose Up
|
||||
-- Доставка — один принятый пакет от Health Auto Export. Тело лежит в сыром
|
||||
-- архиве по raw_path; здесь только учёт и метаданные автоматизации.
|
||||
CREATE TABLE delivery (
|
||||
id TEXT PRIMARY KEY,
|
||||
received_at TEXT NOT NULL,
|
||||
automation_name TEXT NOT NULL DEFAULT '',
|
||||
automation_id TEXT NOT NULL DEFAULT '',
|
||||
aggregation TEXT NOT NULL DEFAULT '',
|
||||
period TEXT NOT NULL DEFAULT '',
|
||||
session_id TEXT NOT NULL DEFAULT '',
|
||||
bytes INTEGER NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
raw_path TEXT NOT NULL,
|
||||
parse_status TEXT NOT NULL,
|
||||
points INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX delivery_received_at ON delivery (received_at);
|
||||
|
||||
-- Хеш тела: пока только учёт. Отсев идентичных повторов включим, когда
|
||||
-- увидим на живых данных, как часто HAE присылает байт-в-байт одно и то же.
|
||||
CREATE INDEX delivery_sha256 ON delivery (sha256);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE delivery;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
-- Полный набор заголовков запроса (JSON: имя → массив значений), кроме
|
||||
-- несущих секреты. Документация HAE называет пять заголовков, но что
|
||||
-- приложение шлёт на самом деле — неизвестно, а именно из незадокументированных
|
||||
-- полей уже вышли самые полезные находки (см. docs/local-research.md).
|
||||
ALTER TABLE delivery ADD COLUMN headers TEXT NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE delivery DROP COLUMN headers;
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package store — SQLite-витрина healthlog: доставки и (позже) разобранные
|
||||
// точки. Витрина производна от сырого архива и пересобирается из него.
|
||||
package store
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
_ "modernc.org/sqlite" // чистый Go-драйвер SQLite, без cgo
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Store — доступ к витрине.
|
||||
type Store struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// Open открывает БД по пути и накатывает миграции.
|
||||
func Open(dbPath string) (*Store, error) {
|
||||
db, err := sqlx.Connect("sqlite", dsn(dbPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", dbPath, err)
|
||||
}
|
||||
|
||||
if err := migrate(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close закрывает соединение с БД.
|
||||
func (s *Store) Close() error {
|
||||
if err := s.db.Close(); err != nil {
|
||||
return fmt.Errorf("close sqlite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dsn собирает строку подключения: WAL для параллельного чтения во время
|
||||
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
|
||||
func dsn(path string) string {
|
||||
q := url.Values{}
|
||||
q.Add("_pragma", "journal_mode(WAL)")
|
||||
q.Add("_pragma", "busy_timeout(5000)")
|
||||
q.Add("_pragma", "foreign_keys(on)")
|
||||
return "file:" + path + "?" + q.Encode()
|
||||
}
|
||||
|
||||
func migrate(db *sqlx.DB) error {
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
goose.SetLogger(goose.NopLogger())
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return fmt.Errorf("goose dialect: %w", err)
|
||||
}
|
||||
if err := goose.Up(db.DB, "migrations"); err != nil {
|
||||
return fmt.Errorf("goose up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now — единая точка генерации времени: UTC, секундная точность.
|
||||
// Секунды дают фиксированную ширину RFC 3339, а значит лексикографическая
|
||||
// сортировка TEXT совпадает с хронологией.
|
||||
func Now() time.Time { return time.Now().UTC().Truncate(time.Second) }
|
||||
|
||||
// FormatTime приводит время к каноническому виду хранения: RFC 3339, UTC.
|
||||
func FormatTime(t time.Time) string { return t.UTC().Format(time.RFC3339) }
|
||||
|
||||
// ParseTime разбирает метку времени из БД.
|
||||
func ParseTime(s string) (time.Time, error) {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse time %q: %w", s, err)
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user