package gzev import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "time" ) var ErrBusy = errors.New("another GZEV worker holds the account lock") type Store struct { Conn *sql.Conn Account string } // WithLock uses a dedicated connection: MySQL advisory locks are connection scoped. func WithLock(ctx context.Context, db *sql.DB, account string, fn func(*Store) error) error { conn, err := db.Conn(ctx) if err != nil { return err } defer conn.Close() sum := sha256.Sum256([]byte(account)) key := "gzev:" + hex.EncodeToString(sum[:24]) var ok int if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,0)", key).Scan(&ok); err != nil { return err } if ok != 1 { return ErrBusy } defer func() { releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() conn.ExecContext(releaseCtx, "SELECT RELEASE_LOCK(?)", key) }() return fn(&Store{Conn: conn, Account: account}) } func (s *Store) NeedsDay(ctx context.Context, date string, recent bool) (bool, error) { var cooling bool err := s.Conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM gzev_mileage_attempt WHERE account=? AND stat_date=? AND retry_after>UTC_TIMESTAMP(3))", s.Account, date).Scan(&cooling) if err != nil || cooling { return false, err } if recent { return true, nil } var retry bool err = s.Conn.QueryRowContext(ctx, "SELECT missing_count>0 AND last_success_at 0 { return errors.New("source vehicle list shrank; previous day retained for review") } _, err = tx.ExecContext(ctx, "INSERT IGNORE INTO gzev_mileage_snapshot(account,stat_date,snapshot_hash,fetched_at,raw_pages) VALUES(?,?,?,?,?)", s.Account, d.Date, checksum, d.FetchedAt.UTC(), string(raw)) if err != nil { return err } _, err = tx.ExecContext(ctx, "DELETE FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date) if err != nil { return err } missing := 0 for _, r := range d.Rows { status := "reported" if !r.Total.Valid || !r.Hydrogen.Valid || !r.Charge.Valid { status = "missing" missing++ } _, err = tx.ExecContext(ctx, `INSERT INTO gzev_daily_mileage(account,vin,stat_date,plate_number,vehicle_seq,production_company,operation_company,brand,model,total_km,hydrogen_km,charge_km,quality_status,raw_row,fetched_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, s.Account, r.VIN, d.Date, r.Plate, r.VehicleSeq, r.ProductionCompany, r.OperationCompany, r.Brand, r.Model, r.Total.SQLValue(), r.Hydrogen.SQLValue(), r.Charge.SQLValue(), status, string(r.Raw), d.FetchedAt.UTC()) if err != nil { return err } } _, err = tx.ExecContext(ctx, `INSERT INTO gzev_mileage_day(account,stat_date,row_count,missing_count,last_success_at,snapshot_hash) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE row_count=VALUES(row_count),missing_count=VALUES(missing_count),last_success_at=VALUES(last_success_at),snapshot_hash=VALUES(snapshot_hash)`, s.Account, d.Date, len(d.Rows), missing, d.FetchedAt.UTC(), checksum) if err != nil { return err } if _, err = tx.ExecContext(ctx, "DELETE FROM gzev_mileage_attempt WHERE account=? AND stat_date=?", s.Account, d.Date); err != nil { return err } return tx.Commit() } func (s *Store) ResolveAuth(ctx context.Context) error { _, err := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET active=FALSE,resolved_at=UTC_TIMESTAMP(3) WHERE account=? AND active=TRUE", s.Account) return err } // AuthFailure persists delivery state. Retry at most hourly and remind at most daily. // A provider timeout is ambiguous: no immediate retry to avoid duplicate notifications. func (s *Store) AuthFailure(ctx context.Context, send func(context.Context) error) error { _, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_auth_alert(account,first_failed_at) VALUES(?,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE first_failed_at=IF(active,first_failed_at,UTC_TIMESTAMP(3)),last_attempt_at=IF(active,last_attempt_at,NULL),last_sent_at=IF(active,last_sent_at,NULL),resolved_at=NULL,active=TRUE`, s.Account) if err != nil { return err } var due bool err = s.Conn.QueryRowContext(ctx, `SELECT (last_attempt_at IS NULL OR last_attempt_at<=UTC_TIMESTAMP()-INTERVAL 1 HOUR) AND (last_sent_at IS NULL OR last_sent_at<=UTC_TIMESTAMP()-INTERVAL 24 HOUR) FROM gzev_auth_alert WHERE account=?`, s.Account).Scan(&due) if err != nil || !due { return err } _, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_attempt_at=UTC_TIMESTAMP(3) WHERE account=?", s.Account) if err != nil { return err } if err = send(ctx); err != nil { _, persistErr := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_error='Notification delivery failed or not configured' WHERE account=?", s.Account) return errors.Join(fmt.Errorf("auth notification: %w", err), persistErr) } _, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_sent_at=UTC_TIMESTAMP(3),last_error='' WHERE account=?", s.Account) return err }