package openplatform import ( "context" "crypto/sha256" "database/sql" "encoding/json" "errors" "fmt" "strconv" "strings" "time" ) type MySQLRepository struct { db *sql.DB tdengine *sql.DB tdDatabase string } func (r *MySQLRepository) WithTDengine(db *sql.DB, database string) *MySQLRepository { if db == nil { panic("open platform TDengine database is required") } if database == "" { database = "lingniu_vehicle_ts" } for _, char := range database { if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '_' { panic("invalid TDengine database identifier") } } r.tdengine = db r.tdDatabase = database return r } func NewMySQLRepository(db *sql.DB) *MySQLRepository { if db == nil { panic("open platform database is required") } return &MySQLRepository{db: db} } func (r *MySQLRepository) Authenticate(ctx context.Context, hash [sha256.Size]byte, now, dayStart, dayEnd time.Time) (AppCredential, error) { var app AppCredential err := r.db.QueryRowContext(ctx, ` SELECT id,name FROM vehicle_open_app WHERE app_key_hash=? AND status='enabled' AND valid_from<=? AND (valid_to IS NULL OR valid_to>?) AND valid_from<=? AND (valid_to IS NULL OR valid_to>=?)`, hash[:], now, now, dayStart, dayEnd, ).Scan(&app.ID, &app.Name) if errors.Is(err, sql.ErrNoRows) { return AppCredential{}, ErrUnauthorized } return app, err } func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) { args := make([]any, 0, len(plates)+3) args = append(args, appID, dayStart, dayEnd) query := ` SELECT UPPER(b.plate),MIN(g.vin) FROM vehicle_open_app_vehicle g JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin WHERE g.app_id=? AND g.valid_from<=? AND (g.valid_to IS NULL OR g.valid_to>=?) AND TRIM(COALESCE(b.plate,''))<>''` if len(plates) > 0 { placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",") query += ` AND UPPER(b.plate) IN (` + placeholders + `)` for _, plate := range plates { args = append(args, plate) } } query += ` GROUP BY UPPER(b.plate) HAVING COUNT(DISTINCT g.vin)=1 ORDER BY UPPER(b.plate)` rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := make(map[string]AuthorizedVehicle, len(plates)) for rows.Next() { var vehicle AuthorizedVehicle if err := rows.Scan(&vehicle.Plate, &vehicle.VIN); err != nil { return nil, err } out[vehicle.Plate] = vehicle } return out, rows.Err() } func (r *MySQLRepository) AuthorizedVIN(ctx context.Context, appID uint64, vin string, at time.Time) (bool, error) { var count int err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM vehicle_open_app_vehicle WHERE app_id=? AND BINARY vin=BINARY ? AND valid_from<=? AND (valid_to IS NULL OR valid_to>?)`, appID, vin, at, at).Scan(&count) return count == 1, err } func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.Time, protocols []string) (*TotalMileagePoint, error) { if r.tdengine == nil || r.tdDatabase == "" { return nil, errors.New("TDengine is not configured for total mileage query") } table := r.tdDatabase + ".vehicle_locations" timeLiteral := strings.ReplaceAll(at.Format(time.RFC3339), "'", "''") for _, protocol := range protocols { query := `SELECT CAST(ts AS BIGINT),total_mileage_km,protocol FROM ` + table + ` WHERE vin='` + vin + `' AND protocol='` + protocol + `'` + ` AND ts<='` + timeLiteral + `' AND total_mileage_km IS NOT NULL AND total_mileage_km>=0` + ` ORDER BY ts DESC LIMIT 1` var timestampMS int64 var point TotalMileagePoint err := r.tdengine.QueryRowContext(ctx, query).Scan(×tampMS, &point.TotalMileageKm, &point.Protocol) if errors.Is(err, sql.ErrNoRows) { continue } if err != nil { return nil, err } point.VIN = vin point.ObservedAt = time.UnixMilli(timestampMS) return &point, nil } return nil, nil } func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date string) (map[string]DailyHydrogen, error) { if len(vins) == 0 { return map[string]DailyHydrogen{}, nil } query, args := inQuery(` SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := make(map[string]DailyHydrogen, len(vins)) for rows.Next() { var value DailyHydrogen if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil { return nil, err } out[value.VIN] = value } return out, rows.Err() } func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date string, protocols []string) (map[string]DailyMileage, error) { values, err := r.DailyMileageRange(ctx, vins, date, date, protocols) if err != nil { return nil, err } out := make(map[string]DailyMileage, len(values)) for _, value := range values { out[value.VIN] = value } return out, nil } func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) { if len(vins) == 0 { return map[string]DailyMileage{}, nil } placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",") query := ` SELECT m.vin, DATE_FORMAT(m.stat_date,'%Y-%m-%d'), m.protocol, m.daily_mileage_km, m.latest_total_mileage_km, COALESCE(DATE_FORMAT(( SELECT MAX(selected.latest_event_time) FROM vehicle_daily_mileage_source selected WHERE selected.vin=m.vin AND selected.stat_date=m.stat_date AND selected.protocol=m.protocol AND selected.is_selected=1 AND selected.latest_event_time IS NOT NULL ),'%Y-%m-%dT%H:%i:%s+08:00'),''), DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00') FROM vehicle_daily_mileage m WHERE m.stat_date BETWEEN ? AND ? AND m.vin IN (` + placeholders + `) AND m.latest_total_mileage_km IS NOT NULL AND m.latest_total_mileage_km>=0 AND m.daily_mileage_km>=0` args := make([]any, 0, len(vins)+2+len(protocols)*2) args = append(args, startDate, endDate) for _, vin := range vins { args = append(args, vin) } if len(protocols) > 0 { protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",") query += "\n AND m.protocol IN (" + protocolPlaceholders + ")\nORDER BY m.stat_date,m.vin,CASE m.protocol" for _, protocol := range protocols { args = append(args, protocol) } for index, protocol := range protocols { query += " WHEN ? THEN " + strconv.Itoa(index+1) args = append(args, protocol) } query += " ELSE 99 END,m.protocol" } else { query += ` ORDER BY m.stat_date,m.vin, CASE WHEN m.daily_mileage_km>0 THEN 0 ELSE 1 END, CASE m.protocol WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2 WHEN 'JT808' THEN 3 ELSE 99 END, m.protocol` } rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := make(map[string]DailyMileage, len(vins)) for rows.Next() { var value DailyMileage if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil { return nil, err } key := dailyMileageKey(value.VIN, value.Date) if _, exists := out[key]; !exists { out[key] = value } } return out, rows.Err() } func (r *MySQLRepository) LatestMileageBefore(ctx context.Context, vins []string, beforeDate string, protocols []string) (map[string]DailyMileage, error) { if len(vins) == 0 { return map[string]DailyMileage{}, nil } vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",") query := ` SELECT m.vin, DATE_FORMAT(m.stat_date,'%Y-%m-%d'), m.protocol, m.daily_mileage_km, m.latest_total_mileage_km, COALESCE(DATE_FORMAT(( SELECT MAX(selected.latest_event_time) FROM vehicle_daily_mileage_source selected WHERE selected.vin=m.vin AND selected.stat_date=m.stat_date AND selected.protocol=m.protocol AND selected.is_selected=1 AND selected.latest_event_time IS NOT NULL ),'%Y-%m-%dT%H:%i:%s+08:00'),''), DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00') FROM vehicle_daily_mileage m JOIN ( SELECT prior.vin,prior.protocol,MAX(prior.stat_date) AS stat_date FROM vehicle_daily_mileage prior WHERE prior.stat_date=0 AND prior.daily_mileage_km>=0` args := make([]any, 0, len(vins)+1+len(protocols)*2) args = append(args, beforeDate) for _, vin := range vins { args = append(args, vin) } if len(protocols) > 0 { protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",") query += "\n AND prior.protocol IN (" + protocolPlaceholders + ")" for _, protocol := range protocols { args = append(args, protocol) } } query += ` GROUP BY prior.vin,prior.protocol ) latest ON latest.vin=m.vin AND latest.stat_date=m.stat_date AND latest.protocol=m.protocol` if len(protocols) > 0 { query += "\nORDER BY m.vin,CASE m.protocol" for index, protocol := range protocols { query += " WHEN ? THEN " + strconv.Itoa(index+1) args = append(args, protocol) } query += " ELSE 99 END,m.protocol,m.updated_at DESC" } else { query += ` ORDER BY m.vin, CASE WHEN m.daily_mileage_km>0 THEN 0 ELSE 1 END, CASE m.protocol WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2 WHEN 'JT808' THEN 3 ELSE 99 END, m.protocol, m.updated_at DESC` } rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := make(map[string]DailyMileage, len(vins)) for rows.Next() { var value DailyMileage if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil { return nil, err } if _, exists := out[value.VIN]; !exists { out[value.VIN] = value } } return out, rows.Err() } func (r *MySQLRepository) CreateMileageSnapshot(ctx context.Context, snapshot MileageSnapshot) error { if len(snapshot.RequestHash) != sha256.Size || snapshot.ID == "" { return fmt.Errorf("%w: invalid snapshot", ErrInvalidRequest) } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_mileage_snapshot WHERE expires_at<=CURRENT_TIMESTAMP(3)`); err != nil { return err } if _, err := tx.ExecContext(ctx, ` INSERT INTO vehicle_open_mileage_snapshot (snapshot_id,app_id,request_hash,start_date,end_date,vehicle_count,expires_at) VALUES(?,?,?,?,?,?,?)`, snapshot.ID, snapshot.AppID, snapshot.RequestHash, snapshot.StartDate, snapshot.EndDate, len(snapshot.Vehicles), snapshot.ExpiresAt); err != nil { return err } const batchSize = 500 for start := 0; start < len(snapshot.Vehicles); start += batchSize { end := start + batchSize if end > len(snapshot.Vehicles) { end = len(snapshot.Vehicles) } var query strings.Builder query.WriteString(`INSERT INTO vehicle_open_mileage_snapshot_vehicle(snapshot_id,ordinal,vin,plate) VALUES `) args := make([]any, 0, (end-start)*4) for index := start; index < end; index++ { if index > start { query.WriteByte(',') } query.WriteString("(?,?,?,?)") vehicle := snapshot.Vehicles[index] args = append(args, snapshot.ID, index, vehicle.VIN, vehicle.Plate) } if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil { return err } } return tx.Commit() } func (r *MySQLRepository) LoadMileageSnapshot(ctx context.Context, snapshotID string, appID uint64, now time.Time) (MileageSnapshot, error) { snapshot := MileageSnapshot{ID: snapshotID, AppID: appID} err := r.db.QueryRowContext(ctx, ` SELECT request_hash,DATE_FORMAT(start_date,'%Y-%m-%d'),DATE_FORMAT(end_date,'%Y-%m-%d'),vehicle_count FROM vehicle_open_mileage_snapshot WHERE snapshot_id=? AND app_id=? AND expires_at>?`, snapshotID, appID, now). Scan(&snapshot.RequestHash, &snapshot.StartDate, &snapshot.EndDate, &snapshot.VehicleCount) if errors.Is(err, sql.ErrNoRows) { return MileageSnapshot{}, fmt.Errorf("%w: cursor expired or unavailable", ErrInvalidRequest) } if err != nil { return MileageSnapshot{}, err } rows, err := r.db.QueryContext(ctx, ` SELECT vin,plate FROM vehicle_open_mileage_snapshot_vehicle WHERE snapshot_id=? ORDER BY ordinal`, snapshotID) if err != nil { return MileageSnapshot{}, err } defer rows.Close() snapshot.Vehicles = make([]AuthorizedVehicle, 0, snapshot.VehicleCount) for rows.Next() { var vehicle AuthorizedVehicle if err := rows.Scan(&vehicle.VIN, &vehicle.Plate); err != nil { return MileageSnapshot{}, err } snapshot.Vehicles = append(snapshot.Vehicles, vehicle) } if err := rows.Err(); err != nil { return MileageSnapshot{}, err } if len(snapshot.Vehicles) != snapshot.VehicleCount { return MileageSnapshot{}, errors.New("mileage snapshot vehicle count mismatch") } return snapshot, nil } func (r *MySQLRepository) Audit(ctx context.Context, appID uint64, endpoint, result, traceID string, requested int, detail string) error { var nullableApp any if appID > 0 { nullableApp = appID } _, err := r.db.ExecContext(ctx, ` INSERT INTO vehicle_open_api_audit(trace_id,app_id,endpoint,result,requested_vehicle_count,detail) VALUES(?,?,?,?,?,?)`, traceID, nullableApp, endpoint, result, requested, truncate(detail, 512)) return err } func (r *MySQLRepository) CreateApp(ctx context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) { result, err := r.db.ExecContext(ctx, ` INSERT INTO vehicle_open_app(name,app_key_hash,app_key_prefix,status,valid_from,valid_to,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?)`, input.Name, hash[:], prefix, input.Status, from, nullableTime(to), actor, actor) if err != nil { return App{}, err } id, err := result.LastInsertId() if err != nil { return App{}, err } _ = r.adminAudit(ctx, uint64(id), actor, "create_app", map[string]any{"name": input.Name, "validFrom": from, "validTo": to}) return r.app(ctx, uint64(id)) } func (r *MySQLRepository) ListApps(ctx context.Context) ([]App, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id,name,app_key_prefix,status,valid_from,valid_to,created_by,created_at,updated_at FROM vehicle_open_app ORDER BY id DESC`) if err != nil { return nil, err } defer rows.Close() apps := make([]App, 0) for rows.Next() { app, err := scanApp(rows) if err != nil { return nil, err } apps = append(apps, app) } return apps, rows.Err() } func (r *MySQLRepository) UpdateApp(ctx context.Context, id uint64, input AppInput, from time.Time, to *time.Time, actor string) (App, error) { result, err := r.db.ExecContext(ctx, ` UPDATE vehicle_open_app SET name=?,status=?,valid_from=?,valid_to=?,updated_by=? WHERE id=?`, input.Name, input.Status, from, nullableTime(to), actor, id) if err != nil { return App{}, err } if affected, _ := result.RowsAffected(); affected == 0 { return App{}, ErrNotFound } _ = r.adminAudit(ctx, id, actor, "update_app", map[string]any{"name": input.Name, "status": input.Status, "validFrom": from, "validTo": to}) return r.app(ctx, id) } func (r *MySQLRepository) RotateKey(ctx context.Context, id uint64, hash [sha256.Size]byte, prefix, actor string) (App, error) { result, err := r.db.ExecContext(ctx, `UPDATE vehicle_open_app SET app_key_hash=?,app_key_prefix=?,updated_by=? WHERE id=?`, hash[:], prefix, actor, id) if err != nil { return App{}, err } if affected, _ := result.RowsAffected(); affected == 0 { return App{}, ErrNotFound } _ = r.adminAudit(ctx, id, actor, "rotate_key", map[string]any{"appKeyPrefix": prefix}) return r.app(ctx, id) } func (r *MySQLRepository) ReplaceVehicleGrants(ctx context.Context, appID uint64, grants []parsedGrant, actor string) ([]VehicleGrant, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() var exists int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=? FOR UPDATE`, appID).Scan(&exists); err != nil { return nil, err } if exists == 0 { return nil, ErrNotFound } if len(grants) > 0 { placeholders := strings.TrimRight(strings.Repeat("?,", len(grants)), ",") args := make([]any, 0, len(grants)) for _, grant := range grants { args = append(args, grant.VIN) } var vinCount int if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT vin) FROM vehicle_identity_binding WHERE vin IN (`+placeholders+`)`, args...).Scan(&vinCount); err != nil { return nil, err } if vinCount != len(grants) { return nil, fmt.Errorf("%w: one or more VINs do not exist", ErrInvalidRequest) } } if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_app_vehicle WHERE app_id=?`, appID); err != nil { return nil, err } for _, grant := range grants { if _, err := tx.ExecContext(ctx, ` INSERT INTO vehicle_open_app_vehicle(app_id,vin,valid_from,valid_to,granted_by) VALUES(?,?,?,?,?)`, appID, grant.VIN, grant.ValidFrom, nullableTime(grant.ValidTo), actor); err != nil { return nil, err } } detail, _ := json.Marshal(map[string]any{"count": len(grants)}) if _, err := tx.ExecContext(ctx, ` INSERT INTO vehicle_open_admin_audit(app_id,actor,action,detail_json) VALUES(?,?,?,?)`, appID, actor, "replace_vehicle_grants", string(detail)); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return r.ListVehicleGrants(ctx, appID) } func (r *MySQLRepository) ListVehicleGrants(ctx context.Context, appID uint64) ([]VehicleGrant, error) { rows, err := r.db.QueryContext(ctx, ` SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at FROM vehicle_open_app_vehicle g LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin WHERE g.app_id=? GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at ORDER BY g.vin`, appID) if err != nil { return nil, err } defer rows.Close() grants := make([]VehicleGrant, 0) for rows.Next() { var grant VehicleGrant var validTo sql.NullTime if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil { return nil, err } if validTo.Valid { grant.ValidTo = &validTo.Time } grants = append(grants, grant) } return grants, rows.Err() } func (r *MySQLRepository) app(ctx context.Context, id uint64) (App, error) { row := r.db.QueryRowContext(ctx, ` SELECT id,name,app_key_prefix,status,valid_from,valid_to,created_by,created_at,updated_at FROM vehicle_open_app WHERE id=?`, id) app, err := scanApp(row) if errors.Is(err, sql.ErrNoRows) { return App{}, ErrNotFound } return app, err } type scanner interface { Scan(...any) error } func scanApp(row scanner) (App, error) { var app App var validTo sql.NullTime err := row.Scan(&app.ID, &app.Name, &app.AppKeyPrefix, &app.Status, &app.ValidFrom, &validTo, &app.CreatedBy, &app.CreatedAt, &app.UpdatedAt) if validTo.Valid { app.ValidTo = &validTo.Time } return app, err } func (r *MySQLRepository) adminAudit(ctx context.Context, appID uint64, actor, action string, detail any) error { encoded, _ := json.Marshal(detail) _, err := r.db.ExecContext(ctx, `INSERT INTO vehicle_open_admin_audit(app_id,actor,action,detail_json) VALUES(?,?,?,?)`, appID, actor, action, encoded) return err } func inQuery(template, first string, values []string) (string, []any) { placeholders := strings.TrimRight(strings.Repeat("?,", len(values)), ",") args := make([]any, 0, len(values)+1) args = append(args, first) for _, value := range values { args = append(args, value) } return strings.Replace(template, "%s", placeholders, 1), args } func nullableTime(value *time.Time) any { if value == nil { return nil } return *value } func truncate(value string, size int) string { runes := []rune(value) if len(runes) <= size { return value } return string(runes[:size]) }