chore: snapshot production code before Apple Design UI refinement
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func main() {
|
||||
apply := flag.Bool("apply", false, "apply corrections after writing a durable backup")
|
||||
backup := flag.String("backup", "", "new backup JSON path, required with --apply")
|
||||
flag.Parse()
|
||||
if *apply && *backup == "" {
|
||||
panic("--backup is required with --apply")
|
||||
}
|
||||
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
|
||||
if err != nil {
|
||||
panic("invalid database configuration")
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err = run(ctx, db, *apply, *backup); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rows(ctx context.Context, tx *sql.Tx, query string) ([]map[string]any, error) {
|
||||
r, err := tx.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
columns, err := r.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := []map[string]any{}
|
||||
for r.Next() {
|
||||
values := make([]any, len(columns))
|
||||
targets := make([]any, len(columns))
|
||||
for i := range values {
|
||||
targets[i] = &values[i]
|
||||
}
|
||||
if err = r.Scan(targets...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := map[string]any{}
|
||||
for i, k := range columns {
|
||||
if b, ok := values[i].([]byte); ok {
|
||||
item[k] = string(b)
|
||||
} else {
|
||||
item[k] = values[i]
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, r.Err()
|
||||
}
|
||||
|
||||
type correction struct {
|
||||
id, title, status string
|
||||
remove bool
|
||||
}
|
||||
|
||||
func run(ctx context.Context, db *sql.DB, apply bool, backup string) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
archive := map[string]any{"createdAt": time.Now().UTC().Format(time.RFC3339), "reason": "decode GB32960 table 18; remove reserved-only false alarms"}
|
||||
data := map[string][]map[string]any{}
|
||||
// Consumer must be stopped for --apply. Lock event rows as well so a concurrent
|
||||
// operator's action is serialized with the correction and fully backed up.
|
||||
for _, q := range []struct{ key, sql string }{
|
||||
{"events", `SELECT * FROM vehicle_alert_event WHERE rule_id='native-gb32960-alarm' ORDER BY id FOR UPDATE`},
|
||||
{"evidence", `SELECT n.* FROM vehicle_native_alarm_evidence n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||
{"actions", `SELECT a.* FROM vehicle_alert_event_action a JOIN vehicle_alert_event e ON e.id=a.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||
{"state", `SELECT s.* FROM vehicle_native_alarm_state s JOIN vehicle_alert_event e ON e.id=s.active_event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||
{"notifications", `SELECT n.id,n.event_id FROM vehicle_alert_notification n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||
} {
|
||||
items, e := rows(ctx, tx, q.sql)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
data[q.key] = items
|
||||
archive[q.key] = items
|
||||
}
|
||||
evidence := map[string]string{}
|
||||
for _, row := range data["evidence"] {
|
||||
evidence[fmt.Sprint(row["event_id"])] = fmt.Sprint(row["fields_json"])
|
||||
}
|
||||
notified := map[string]bool{}
|
||||
for _, row := range data["notifications"] {
|
||||
notified[fmt.Sprint(row["event_id"])] = true
|
||||
}
|
||||
changes := []correction{}
|
||||
renamed, removed := 0, 0
|
||||
names := map[string]int{}
|
||||
for _, row := range data["events"] {
|
||||
id := fmt.Sprint(row["id"])
|
||||
var fields map[string]json.RawMessage
|
||||
if err = json.Unmarshal([]byte(evidence[id]), &fields); err != nil {
|
||||
return fmt.Errorf("event %s has no valid evidence; no changes applied", id)
|
||||
}
|
||||
description, valid := platform.DescribeNativeAlarm(fields)
|
||||
if !valid {
|
||||
return fmt.Errorf("event %s has unsupported evidence; no changes applied", id)
|
||||
}
|
||||
names[description.Title]++
|
||||
if !description.Active {
|
||||
if notified[id] {
|
||||
return fmt.Errorf("event %s has notifications; refusing automatic deletion", id)
|
||||
}
|
||||
changes = append(changes, correction{id: id, remove: true})
|
||||
removed++
|
||||
} else if fmt.Sprint(row["rule_name"]) != description.Title {
|
||||
changes = append(changes, correction{id: id, title: description.Title, status: fmt.Sprint(row["status"])})
|
||||
renamed++
|
||||
}
|
||||
}
|
||||
if apply && len(changes) > 0 {
|
||||
file, e := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
e = json.NewEncoder(file).Encode(archive)
|
||||
if e == nil {
|
||||
e = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.remove {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET active_event_id='' WHERE active_event_id=?`, change.id); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, table := range []string{"vehicle_alert_event_action", "vehicle_native_alarm_evidence"} {
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM "+table+" WHERE event_id=?", change.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_event WHERE id=? AND rule_id='native-gb32960-alarm'`, change.id); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET rule_name=?,version=version+1 WHERE id=? AND rule_id='native-gb32960-alarm'`, change.title, change.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'repair',?,?,'native-alarm-repair','按GB/T 32960.3-2016表18修正具体告警名称,保留原始证据')`, change.id, change.status, change.status); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"applied": apply, "scanned": len(data["events"]), "renamed": renamed, "removed": removed, "descriptions": names})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func expectSnapshot(mock sqlmock.Sqlmock, notified bool) {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`SELECT \* FROM vehicle_alert_event`).WillReturnRows(sqlmock.NewRows([]string{"id", "rule_name", "status"}).AddRow("false-event", "车辆原生告警", "unprocessed").AddRow("real-event", "车辆原生告警", "processing"))
|
||||
payload := func(flag string) string {
|
||||
data := map[string]any{"gb32960.alarm.max_alarm_level": "0", "gb32960.alarm.general_alarm_flag": flag}
|
||||
for _, key := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||
data["gb32960.alarm."+key] = []string{}
|
||||
}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b)
|
||||
}
|
||||
mock.ExpectQuery(`SELECT n.\* FROM vehicle_native_alarm_evidence`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "fields_json"}).AddRow("false-event", payload("0x00300000")).AddRow("real-event", payload("0x00380800")))
|
||||
mock.ExpectQuery(`SELECT a.\* FROM vehicle_alert_event_action`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "action"}).AddRow("false-event", "trigger"))
|
||||
mock.ExpectQuery(`SELECT s.\* FROM vehicle_native_alarm_state`).WillReturnRows(sqlmock.NewRows([]string{"vin", "active_event_id"}).AddRow("VIN1", "false-event"))
|
||||
notifications := sqlmock.NewRows([]string{"id", "event_id"})
|
||||
if notified {
|
||||
notifications.AddRow(1, "false-event")
|
||||
}
|
||||
mock.ExpectQuery(`SELECT n.id,n.event_id`).WillReturnRows(notifications)
|
||||
}
|
||||
|
||||
func TestRepairDryRunNeverWrites(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
expectSnapshot(mock, false)
|
||||
mock.ExpectRollback()
|
||||
if err := run(t.Context(), db, false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestRepairBacksUpAndCorrectsOnlyNativeEvents(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
expectSnapshot(mock, false)
|
||||
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_alert_event_action`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_native_alarm_evidence`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_alert_event WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE vehicle_alert_event SET rule_name=\?,version=version\+1 WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("绝缘报警", "real-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WithArgs("real-event", "processing", "processing").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
path := filepath.Join(t.TempDir(), "backup.json")
|
||||
if err := run(t.Context(), db, true, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var archive map[string]json.RawMessage
|
||||
if err = json.Unmarshal(data, &archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"events", "evidence", "actions", "state"} {
|
||||
if len(archive[key]) == 0 {
|
||||
t.Fatalf("missing backup %s", key)
|
||||
}
|
||||
}
|
||||
info, _ := os.Stat(path)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("backup permissions")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestRepairRefusesToOverwriteBackup(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
expectSnapshot(mock, false)
|
||||
mock.ExpectRollback()
|
||||
path := filepath.Join(t.TempDir(), "backup.json")
|
||||
if err := os.WriteFile(path, []byte("previous backup"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run(t.Context(), db, true, path); err == nil {
|
||||
t.Fatal("overwrote backup")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestRepairRefusesToOrphanNotifications(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
expectSnapshot(mock, true)
|
||||
mock.ExpectRollback()
|
||||
if err := run(t.Context(), db, true, filepath.Join(t.TempDir(), "backup.json")); err == nil {
|
||||
t.Fatal("deleted notified event")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user