|
|
|
|
@@ -0,0 +1,244 @@
|
|
|
|
|
package platform
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"database/sql"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type ProductionStore struct {
|
|
|
|
|
db *sql.DB
|
|
|
|
|
database string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewProductionStore(db *sql.DB, tdengineDatabase string) *ProductionStore {
|
|
|
|
|
if db == nil {
|
|
|
|
|
panic("production db must not be nil")
|
|
|
|
|
}
|
|
|
|
|
return &ProductionStore{db: db, database: tdengineDatabase}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func OpenMySQL(ctx context.Context, dsn string) (*sql.DB, error) {
|
|
|
|
|
db, err := sql.Open("mysql", dsn)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
db.SetMaxOpenConns(20)
|
|
|
|
|
db.SetMaxIdleConns(10)
|
|
|
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
|
|
|
_ = db.Close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return db, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) DashboardSummary(ctx context.Context) (DashboardSummary, error) {
|
|
|
|
|
var summary DashboardSummary
|
|
|
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_realtime_snapshot WHERE updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)`).Scan(&summary.OnlineVehicles); err != nil {
|
|
|
|
|
return DashboardSummary{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT vin) FROM vehicle_realtime_snapshot WHERE updated_at >= CURDATE()`).Scan(&summary.ActiveToday); err != nil {
|
|
|
|
|
return DashboardSummary{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_realtime_snapshot WHERE vin = '' OR vin IS NULL`).Scan(&summary.IssueVehicles); err != nil {
|
|
|
|
|
return DashboardSummary{}, err
|
|
|
|
|
}
|
|
|
|
|
summary.FrameToday = summary.ActiveToday
|
|
|
|
|
protocols, err := s.protocolStats(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return DashboardSummary{}, err
|
|
|
|
|
}
|
|
|
|
|
summary.Protocols = protocols
|
|
|
|
|
health, err := s.OpsHealth(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return DashboardSummary{}, err
|
|
|
|
|
}
|
|
|
|
|
summary.KafkaLag = health.KafkaLag
|
|
|
|
|
summary.LinkHealth = health.LinkHealth
|
|
|
|
|
return summary, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
|
|
|
|
built := buildVehicleListSQL(query)
|
|
|
|
|
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Page[VehicleRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
items := make([]VehicleRow, 0)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var row VehicleRow
|
|
|
|
|
var online int
|
|
|
|
|
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.Protocol, &online, &row.LastSeen, &row.LocationText, &row.BindingScore); err != nil {
|
|
|
|
|
return Page[VehicleRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
row.Online = online == 1
|
|
|
|
|
items = append(items, row)
|
|
|
|
|
}
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return Page[VehicleRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
limit, offset := buildLimitOffset(query)
|
|
|
|
|
return Page[VehicleRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
|
|
|
|
limit, offset := buildLimitOffset(query)
|
|
|
|
|
where := []string{"1 = 1"}
|
|
|
|
|
args := []any{}
|
|
|
|
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
|
|
|
|
where = append(where, "l.protocol = ?")
|
|
|
|
|
args = append(args, protocol)
|
|
|
|
|
}
|
|
|
|
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
|
|
|
|
where = append(where, "l.vin = ?")
|
|
|
|
|
args = append(args, vin)
|
|
|
|
|
}
|
|
|
|
|
args = append(args, limit, offset)
|
|
|
|
|
sqlText := `SELECT l.vin, COALESCE(NULLIF(l.plate, ''), b.plate, '') AS plate, l.protocol, l.longitude, l.latitude, ` +
|
|
|
|
|
`COALESCE(l.speed_kmh, 0), COALESCE(l.soc_percent, 0), COALESCE(l.total_mileage_km, 0), ` +
|
|
|
|
|
`COALESCE(DATE_FORMAT(l.updated_at, '%Y-%m-%d %H:%i:%s'), '') ` +
|
|
|
|
|
`FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` +
|
|
|
|
|
`WHERE ` + strings.Join(where, " AND ") + ` ORDER BY l.updated_at DESC LIMIT ? OFFSET ?`
|
|
|
|
|
rows, err := s.db.QueryContext(ctx, sqlText, args...)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Page[RealtimeLocationRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
items := make([]RealtimeLocationRow, 0)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var row RealtimeLocationRow
|
|
|
|
|
if err := rows.Scan(&row.VIN, &row.Plate, &row.Protocol, &row.Longitude, &row.Latitude, &row.SpeedKmh, &row.SOCPercent, &row.TotalMileageKm, &row.LastSeen); err != nil {
|
|
|
|
|
return Page[RealtimeLocationRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
items = append(items, row)
|
|
|
|
|
}
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return Page[RealtimeLocationRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
return Page[RealtimeLocationRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
|
|
|
|
realtime, err := s.RealtimeLocations(ctx, query)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Page[HistoryLocationRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
items := make([]HistoryLocationRow, 0, len(realtime.Items))
|
|
|
|
|
for _, row := range realtime.Items {
|
|
|
|
|
items = append(items, HistoryLocationRow{
|
|
|
|
|
VIN: row.VIN, Plate: row.Plate, Protocol: row.Protocol, Longitude: row.Longitude, Latitude: row.Latitude,
|
|
|
|
|
SpeedKmh: row.SpeedKmh, TotalMileageKm: row.TotalMileageKm, DeviceTime: row.LastSeen, ServerTime: row.LastSeen,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return Page[HistoryLocationRow]{Items: items, Total: realtime.Total, Limit: realtime.Limit, Offset: realtime.Offset}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
|
|
|
|
_ = ctx
|
|
|
|
|
_ = buildRawFrameSQL(s.database, query)
|
|
|
|
|
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: query.Limit, Offset: query.Offset}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
|
|
|
|
|
built := buildDailyMileageSQL(query)
|
|
|
|
|
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Page[DailyMileageRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
items := make([]DailyMileageRow, 0)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var row DailyMileageRow
|
|
|
|
|
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.Source); err != nil {
|
|
|
|
|
return Page[DailyMileageRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
items = append(items, row)
|
|
|
|
|
}
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return Page[DailyMileageRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
limit, offset := buildLimitOffset(query)
|
|
|
|
|
return Page[DailyMileageRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
|
|
|
|
limit, offset := buildLimitOffset(query)
|
|
|
|
|
rows, err := s.db.QueryContext(ctx, `SELECT phone, COALESCE(source_endpoint, ''), COALESCE(DATE_FORMAT(latest_seen_at, '%Y-%m-%d %H:%i:%s'), '') FROM jt808_registration WHERE vin = '' OR vin = 'unknown' OR vin IS NULL ORDER BY latest_seen_at DESC LIMIT ? OFFSET ?`, limit, offset)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Page[QualityIssueRow]{}, nil
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
items := make([]QualityIssueRow, 0)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var phone, source, lastSeen string
|
|
|
|
|
if err := rows.Scan(&phone, &source, &lastSeen); err != nil {
|
|
|
|
|
return Page[QualityIssueRow]{}, err
|
|
|
|
|
}
|
|
|
|
|
items = append(items, QualityIssueRow{
|
|
|
|
|
VIN: "unknown",
|
|
|
|
|
Protocol: "JT808",
|
|
|
|
|
IssueType: "VIN_MISSING",
|
|
|
|
|
Severity: "warning",
|
|
|
|
|
LastSeen: lastSeen,
|
|
|
|
|
Detail: fmt.Sprintf("phone %s 未命中 binding 表,来源 %s", phone, source),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return Page[QualityIssueRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) OpsHealth(ctx context.Context) (OpsHealth, error) {
|
|
|
|
|
mysqlStatus := "ok"
|
|
|
|
|
mysqlDetail := "MySQL ping 正常"
|
|
|
|
|
if err := s.db.PingContext(ctx); err != nil {
|
|
|
|
|
mysqlStatus = "error"
|
|
|
|
|
mysqlDetail = err.Error()
|
|
|
|
|
}
|
|
|
|
|
var redisKeys int
|
|
|
|
|
_ = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_realtime_snapshot WHERE updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)`).Scan(&redisKeys)
|
|
|
|
|
return OpsHealth{
|
|
|
|
|
LinkHealth: []LinkHealth{
|
|
|
|
|
{Name: "MySQL realtime", Status: mysqlStatus, Detail: mysqlDetail},
|
|
|
|
|
{Name: "vehicle_realtime_snapshot", Status: "ok", Detail: "读取实时快照表"},
|
|
|
|
|
{Name: "vehicle_realtime_location", Status: "ok", Detail: "读取实时位置表"},
|
|
|
|
|
{Name: "TDengine raw_frames", Status: "warning", Detail: "中台 RAW 查询接口待接 TDengine 驱动"},
|
|
|
|
|
},
|
|
|
|
|
KafkaLag: 0,
|
|
|
|
|
RedisOnlineKeys: redisKeys,
|
|
|
|
|
TDengineWritable: false,
|
|
|
|
|
MySQLWritable: mysqlStatus == "ok",
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ProductionStore) protocolStats(ctx context.Context) ([]ProtocolStat, error) {
|
|
|
|
|
rows, err := s.db.QueryContext(ctx, `SELECT protocol, SUM(CASE WHEN updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) AS online_count, COUNT(*) AS total_count FROM vehicle_realtime_snapshot GROUP BY protocol ORDER BY protocol`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
out := make([]ProtocolStat, 0)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var row ProtocolStat
|
|
|
|
|
if err := rows.Scan(&row.Protocol, &row.Online, &row.Total); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
out = append(out, row)
|
|
|
|
|
}
|
|
|
|
|
return out, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parsedFieldsFromString(value string) map[string]any {
|
|
|
|
|
if strings.TrimSpace(value) == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
var fields map[string]any
|
|
|
|
|
if err := json.Unmarshal([]byte(value), &fields); err != nil {
|
|
|
|
|
return map[string]any{"raw": value}
|
|
|
|
|
}
|
|
|
|
|
return fields
|
|
|
|
|
}
|