Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/gzev/store.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
2026-10-26 18:05:59 +08:00

169 lines
6.7 KiB
Go

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<UTC_TIMESTAMP()-INTERVAL 7 DAY FROM gzev_mileage_day WHERE account=? AND stat_date=?", s.Account, date).Scan(&retry)
if err == sql.ErrNoRows {
return true, nil
}
return retry, err
}
func (s *Store) RecordFailure(ctx context.Context, date string) error {
_, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_mileage_attempt(account,stat_date,retry_after) VALUES(?,?,UTC_TIMESTAMP()+INTERVAL 6 HOUR) ON DUPLICATE KEY UPDATE retry_after=VALUES(retry_after)`, s.Account, date)
return err
}
func (s *Store) SaveDay(ctx context.Context, d Day) error {
if err := ValidateDate(d.Date, time.Now()); err != nil {
return err
}
if len(d.Rows) == 0 {
return errors.New("empty source day requires review; not marked complete")
}
raw, err := json.Marshal(d.Pages)
if err != nil {
return err
}
hash := sha256.Sum256(raw)
checksum := hex.EncodeToString(hash[:])
tx, err := s.Conn.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Reject source regressions rather than replacing a previously measured day with nulls.
previous, err := tx.QueryContext(ctx, "SELECT vin,total_km IS NOT NULL,hydrogen_km IS NOT NULL,charge_km IS NOT NULL FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date)
if err != nil {
return err
}
old := map[string][3]bool{}
for previous.Next() {
var vin string
var flags [3]bool
if err := previous.Scan(&vin, &flags[0], &flags[1], &flags[2]); err != nil {
previous.Close()
return err
}
old[vin] = flags
}
err = previous.Err()
previous.Close()
if err != nil {
return err
}
for _, r := range d.Rows {
flags := old[r.VIN]
if flags[0] && !r.Total.Valid || flags[1] && !r.Hydrogen.Valid || flags[2] && !r.Charge.Valid {
return errors.New("source regressed measured mileage to null; previous day retained")
}
delete(old, r.VIN)
}
if len(old) > 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
}