612 lines
20 KiB
Go
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(×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<?
|
|
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])
|
|
}
|