Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/openplatform/mysql.go
2026-07-27 16:46:15 +08:00

612 lines
20 KiB
Go

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(&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) 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<?
AND prior.vin IN (` + vinPlaceholders + `)
AND prior.latest_total_mileage_km IS NOT NULL
AND 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)
}
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])
}