Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/openplatform/mysql.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

853 lines
31 KiB
Go

package openplatform
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"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) {
// Instant grants expire exclusively; a whole-day window may end at valid_to.
endOperator := ">="
if dayStart.Equal(dayEnd) {
endOperator = ">"
}
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` + endOperator + `?)
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(&timestampMS, &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) StationaryLocationPoints(ctx context.Context, vins []string, start, end time.Time, longitude, latitude, radiusMeters, maxSpeedKmh float64) ([]StationaryLocationPoint, error) {
if r.tdengine == nil || r.tdDatabase == "" {
return nil, errors.New("TDengine is not configured for stationary vehicle query")
}
if len(vins) == 0 {
return []StationaryLocationPoint{}, nil
}
// Use a small bounding box as TDengine's prefilter, then apply the exact
// 5-metre great-circle calculation in the service before returning a match.
latitudeDelta := radiusMeters / 111320.0
longitudeDelta := radiusMeters / math.Max(1, 111320.0*math.Cos(latitude*math.Pi/180))
quotedVINs := make([]string, 0, len(vins))
for _, vin := range vins {
quotedVINs = append(quotedVINs, "'"+strings.ReplaceAll(vin, "'", "''")+"'")
}
startLiteral := strings.ReplaceAll(start.Format(time.RFC3339), "'", "''")
endLiteral := strings.ReplaceAll(end.Format(time.RFC3339), "'", "''")
query := `SELECT CAST(ts AS BIGINT),vin,protocol,longitude,latitude,speed_kmh FROM ` + r.tdDatabase + `.vehicle_locations` +
` WHERE ts>='` + startLiteral + `' AND ts<='` + endLiteral + `'` +
` AND vin IN (` + strings.Join(quotedVINs, ",") + `)` +
` AND longitude BETWEEN ` + strconv.FormatFloat(longitude-longitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(longitude+longitudeDelta, 'f', 8, 64) +
` AND latitude BETWEEN ` + strconv.FormatFloat(latitude-latitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(latitude+latitudeDelta, 'f', 8, 64) +
` AND speed_kmh BETWEEN 0 AND ` + strconv.FormatFloat(maxSpeedKmh, 'f', 3, 64) +
` ORDER BY vin,ts ASC,protocol ASC`
rows, err := r.tdengine.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
points := make([]StationaryLocationPoint, 0)
for rows.Next() {
var point StationaryLocationPoint
var timestampMS int64
if err := rows.Scan(&timestampMS, &point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh); err != nil {
return nil, err
}
point.ObservedAt = time.UnixMilli(timestampMS).In(time.FixedZone("Asia/Shanghai", 8*60*60))
points = append(points, point)
}
return points, rows.Err()
}
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
out := make(map[string]RealtimeVehiclePoint, len(vins))
if len(vins) == 0 {
return out, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
args := make([]any, 0, len(vins)+2)
args = append(args, dayStart)
for _, vin := range vins {
args = append(args, vin)
}
args = append(args, now.Add(-10*time.Minute))
query := `
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
COALESCE(l.speed_kmh,0),l.soc_percent,l.total_mileage_km,l.updated_at,
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today,
l.event_time,l.received_at,l.event_id
FROM vehicle_realtime_location l
WHERE BINARY l.vin IN (` + placeholders + `)
ORDER BY l.vin,
CASE WHEN l.updated_at>=? THEN 0 ELSE 1 END,
CASE l.protocol WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END,
l.updated_at DESC,l.protocol ASC`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
onlineThreshold := now.Add(-time.Minute)
for rows.Next() {
var point RealtimeVehiclePoint
var soc, totalMileage sql.NullFloat64
var locationAt, receivedAt sql.NullTime
var eventID sql.NullString
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &totalMileage, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil {
return nil, err
}
point.TotalMileageKm = totalMileage.Float64 // Preserve the FULL endpoint's compatibility value.
available := totalMileage.Valid
point.TotalMileageAvailable = &available
point.LocationObservedAt, point.LocationReceivedAt, point.LocationEventID = locationAt.Time, receivedAt.Time, eventID.String
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
value := round3(soc.Float64)
point.SOCPercent = &value
}
point.Online = !point.ObservedAt.Before(onlineThreshold)
if selected, exists := out[point.VIN]; !exists {
out[point.VIN] = point
} else if point.Online && !selected.Online {
// The selected source still follows the documented protocol priority, but
// online means that any source for this VIN reported in the last minute.
selected.Online = true
out[point.VIN] = selected
}
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := r.enrichRealtimeLiveData(ctx, vins, out, now); err != nil {
return nil, err
}
return out, nil
}
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
where := []string{
"n.del_flag='0'",
"n.longitude BETWEEN -180 AND 180",
"n.latitude BETWEEN -90 AND 90",
"NOT (n.longitude=0 AND n.latitude=0)",
}
args := make([]any, 0, 4)
if request.Province != "" {
where = append(where, "(n.province=? OR province_region.NAME=?)")
args = append(args, request.Province, request.Province)
}
if request.City != "" {
where = append(where, "(n.city=? OR city_region.NAME=?)")
args = append(args, request.City, request.City)
}
rows, err := r.db.QueryContext(ctx, `
SELECT CAST(n.id AS CHAR),COALESCE(n.site_name,''),COALESCE(n.site_short_name,''),COALESCE(n.site_address,''),
n.longitude,n.latitude,
COALESCE(NULLIF(province_region.NAME,''),n.province,''),
COALESCE(NULLIF(city_region.NAME,''),n.city,''),COALESCE(s.district,''),
CASE WHEN s.inner_site_id IS NOT NULL OR (
n.cooperation_start_date IS NOT NULL
AND n.cooperation_start_date<=CURDATE()
AND (n.cooperation_end_date IS NULL OR n.cooperation_end_date>=CURDATE())
) THEN 1 ELSE 0 END,
COALESCE(n.contact_person,''),COALESCE(NULLIF(n.mobile_phone,''),n.fixed_phone,''),COALESCE(n.cost_price,0),
COALESCE(l.monthly_hydrogen_kg,0),COALESCE(l.total_hydrogen_kg,n.fill_weight,0)
FROM ln_asset_management.new_hydrogen_site n
LEFT JOIN ln_asset_management.common_district province_region
ON province_region.CODE COLLATE utf8mb4_general_ci=n.province COLLATE utf8mb4_general_ci
LEFT JOIN ln_asset_management.common_district city_region
ON city_region.CODE COLLATE utf8mb4_general_ci=n.city COLLATE utf8mb4_general_ci
LEFT JOIN ln_asset_management.tab_outside_hydrogen_site s ON s.id=(
SELECT MIN(matched.id)
FROM ln_asset_management.tab_outside_hydrogen_site matched
WHERE matched.fixed_station_name=n.site_name COLLATE utf8mb4_general_ci
OR matched.station_name=n.site_name COLLATE utf8mb4_general_ci
OR matched.fixed_station_name=n.site_short_name COLLATE utf8mb4_general_ci
OR matched.station_name=n.site_short_name COLLATE utf8mb4_general_ci
)
LEFT JOIN (
SELECT station_id,
SUM(amount_kg) AS total_hydrogen_kg,
SUM(CASE WHEN refuel_date >= DATE_FORMAT(CURDATE(),'%Y-%m-01')
AND refuel_date < DATE_ADD(DATE_FORMAT(CURDATE(),'%Y-%m-01'),INTERVAL 1 MONTH)
THEN amount_kg ELSE 0 END) AS monthly_hydrogen_kg
FROM ln_asset_management.hydrogen_fuel_ledger
WHERE del_flag='0'
GROUP BY station_id
) l ON l.station_id=s.inner_site_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY province_region.NAME,city_region.NAME,n.site_name,n.id
LIMIT 2000`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
stations := make([]HydrogenStation, 0, 512)
for rows.Next() {
var station HydrogenStation
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative, &station.ContactPerson, &station.ContactPhone, &station.UnitPrice, &station.MonthlyHydrogenKg, &station.TotalHydrogenKg); err != nil {
return nil, err
}
if isExcelSinopecStation(station.Name, station.ShortName) {
station.Cooperative = true
}
if request.CooperateOnly != nil && station.Cooperative != *request.CooperateOnly {
continue
}
stations = append(stations, station)
}
return stations, rows.Err()
}
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,quality_reason,
calculation_phase,algorithm_version,COALESCE(evidence_json,'null'),
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%f+08:00')
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, &value.QualityReason, &value.CalculationPhase, &value.AlgorithmVersion, &value.EvidenceJSON, &value.UpdatedAt); 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
}
// MileageRollbacks returns source days that were rejected because the odometer
// moved backwards. They must not silently fall back to an earlier total mileage
// and be exposed as normal data.
func (r *MySQLRepository) MileageRollbacks(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]bool, error) {
out := make(map[string]bool)
if len(vins) == 0 {
return out, nil
}
vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
query := `
SELECT DISTINCT vin,DATE_FORMAT(stat_date,'%Y-%m-%d')
FROM vehicle_daily_mileage_source
WHERE stat_date BETWEEN ? AND ?
AND vin IN (` + vinPlaceholders + `)
AND quality_reason='TOTAL_MILEAGE_ROLLBACK'`
args := make([]any, 0, len(vins)+2+len(protocols))
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 protocol IN (" + protocolPlaceholders + ")"
for _, protocol := range protocols {
args = append(args, protocol)
}
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var vin, date string
if err := rows.Scan(&vin, &date); err != nil {
return nil, err
}
out[dailyMileageKey(vin, date)] = true
}
return out, rows.Err()
}
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,
COALESCE(m.day_end_total_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'),
COALESCE(DATE_FORMAT((SELECT MIN(selected.first_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
),'%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 COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km) IS NOT NULL
AND COALESCE(m.day_end_total_mileage_km,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, &value.StatisticsStartTime); 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,
COALESCE(m.day_end_total_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<?
AND prior.vin IN (` + vinPlaceholders + `)
AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km) IS NOT NULL
AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km)>=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)
}
// Match the IN marker, not MySQL DATE_FORMAT seconds (%s).
return strings.Replace(template, "IN (%s)", "IN ("+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])
}